CRMEB/backend/app/services/order/RefundExecutorServices.php
吴昊天 d50837b663 refactor(分销): 开源侧下线佣金提现,与私有分支口径对齐
移除佣金/提现控制器与前端页面,订单与用户链路去掉分销佣金逻辑,安装 SQL 与文档同步。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:40:30 +08:00

437 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\services\order;
use app\dao\order\OrderCenterDao;
use app\dao\product\CenterProductDao;
use app\services\activity\coupon\StoreCouponUserServices;
use app\services\pay\PayServices;
use app\services\statistic\CapitalFlowServices;
use app\services\user\UserBillServices;
use app\services\user\UserMoneyServices;
use app\services\user\UserServices;
use crmeb\basic\BaseService;
use crmeb\trade\product\ProductTypeRegistry;
use think\facade\Log;
/**
* 订单退款执行引擎(D 系列批次 D2c 新增)
*
* 统一承载退款资金回滚能力(承接自旧退款服务,旧实现已随批次 C 删除)。
* 输入为中台订单(eb_order_center)数据,按四维执行回滚:
* - 积分:收回赠送积分(幂等)+ 返还下单使用积分
* - 库存:从中台 extra_info.items 读取快照,经 ProductTypeHandler / ActivityDataProxy 回退
* - 优惠券:从中台订单 extra_info 读取 coupon_id 并恢复用户优惠券
* - 支付退款:balance 退余额 / yue 余额退款 / weixin、alipay、allinpay 走 PayServices::refund
*
* 佣金回退由分销插件自行处理,引擎不再负责。
*
* 引擎只负责资金/资源回滚与流水记录,不负责订单状态流转(状态机由调用方维护)
*/
class RefundExecutorServices extends BaseService
{
/**
* 执行订单资金回滚(引擎主入口)
* 依次执行:积分回退 → 库存回退 → 优惠券降级 → 支付退款 → 资本流水 → 开票标记
* @param array $order 中台订单数据
* @param float $refundPrice 现金退款金额(纯积分退款传 0)
* @param array $context 上下文:refund_type(original/balance)、refund_trade_no、refund_integral、refund_id
* @return array 退款结果:refund_price / refund_trade_no / pay_type
* @throws \RuntimeException 订单未支付或回滚失败
*/
public function execute(array $order, float $refundPrice, array $context = []): array
{
// 现金退款前提是订单已支付
if ($refundPrice > 0 && (int)($order['pay_status'] ?? 0) !== 1) {
throw new \RuntimeException('订单未支付,无法退款');
}
// 1. 本地资源回滚(事务内:积分/库存/优惠券均为本地库操作,支付退款为远程通道不放事务)
$this->transaction(function () use ($order, $context) {
$this->rollbackIntegral($order, (int)($context['refund_integral'] ?? 0));
$this->rollbackStock($order);
$this->rollbackCoupon($order);
});
// 2. 支付退款(事务外:余额退款为本地操作,其余走远程支付通道)
$refundTradeNo = '';
if ($refundPrice > 0 && !empty($order['pay_type'])) {
$refundTradeNo = $this->refundByPayType($order, $refundPrice, $context);
}
// 3. 资本流水 + 开票标记
$this->writeFlow($order, $refundPrice, $context);
$this->markInvoiceRefunded($order);
return [
'refund_price' => $refundPrice,
'refund_trade_no' => $refundTradeNo,
'pay_type' => $order['pay_type'] ?? '',
];
}
/**
* 积分回退:收回订单赠送积分(幂等)+ 返还下单使用积分
* 由旧退款链路 regressionIntegral 迁移,字段映射为中台订单
* @param array $order 中台订单数据
* @param int $refundIntegral 需返还积分(售后单 refund_integral,>0 时按售后单精确返还,否则按整单使用积分)
* @return void
*/
public function rollbackIntegral(array $order, int $refundIntegral = 0): void
{
$orderId = (int)($order['id'] ?? 0);
$uid = (int)($order['uid'] ?? 0);
// 已退款/已删除订单不再回滚(防重复退款路径误触发)
if (($order['status_code'] ?? '') === 'refunded' || (int)($order['is_del'] ?? 0) === 1) {
return;
}
if ($orderId <= 0 || $uid <= 0) {
return;
}
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$userInfo = $userServices->get($uid, ['integral']);
if (!$userInfo) {
return;
}
$currentIntegral = (int)($userInfo['integral'] ?? 0);
/** @var UserBillServices $userBillServices */
$userBillServices = app()->make(UserBillServices::class);
// 操作1:分族收回订单赠送积分(下单赠送族 gain/order_give_integral、购买赠送族 product_gain/pay_give_integral)
// 发放链路经 billIntegralIncome 落库类型为 order_give_integral / pay_give_integral,须按实际落库类型统计
$orderGain = (int)$userBillServices->sum([
'category' => 'integral',
'type' => 'order_give_integral',
'link_id' => $orderId,
'uid' => $uid,
], 'number') + (int)$userBillServices->sum([
'category' => 'integral',
'type' => 'gain',
'link_id' => $orderId,
'uid' => $uid,
], 'number');
$productGain = (int)$userBillServices->sum([
'category' => 'integral',
'type' => 'pay_give_integral',
'link_id' => $orderId,
'uid' => $uid,
], 'number') + (int)$userBillServices->sum([
'category' => 'integral',
'type' => 'product_gain',
'link_id' => $orderId,
'uid' => $uid,
], 'number');
if ($orderGain > 0 || $productGain > 0) {
// 幂等标记按分族落库类型分别查重
$orderRefunded = (int)$userBillServices->count([
'category' => 'integral',
'type' => 'order_gain_refund',
'link_id' => $orderId,
]);
$productRefunded = (int)$userBillServices->count([
'category' => 'integral',
'type' => 'product_gain_refund',
'link_id' => $orderId,
]);
$hasRefund = false;
// 顺序扣减:当前积分不足时扣减全部,差额不追回(总量与原合并扣减口径一致)
if (!$orderRefunded && $orderGain > 0) {
$deductAmount = min($currentIntegral, $orderGain);
if ($deductAmount > 0) {
$userBillServices->billIntegralIncome('order_gain_refund', $uid, $deductAmount, $orderId);
$currentIntegral = (int)bcsub((string)$currentIntegral, (string)$deductAmount, 0);
$hasRefund = true;
}
}
if (!$productRefunded && $productGain > 0) {
$deductAmount = min($currentIntegral, $productGain);
if ($deductAmount > 0) {
$userBillServices->billIntegralIncome('product_gain_refund', $uid, $deductAmount, $orderId);
$currentIntegral = (int)bcsub((string)$currentIntegral, (string)$deductAmount, 0);
$hasRefund = true;
}
}
if ($hasRefund) {
// 清除积分冻结
$userBillServices->update(['link_id' => $orderId], ['frozen_time' => 0]);
}
}
// 操作2:返还下单使用的积分(售后单指定 refund_integral 时按精确值,否则按整单使用积分)
$useIntegral = $refundIntegral > 0 ? $refundIntegral : (int)($order['use_integral_num'] ?? 0);
// 兜底:调用方未加载 use_integral_num 字段时按中台订单 id 补查
if ($useIntegral <= 0 && $refundIntegral <= 0 && $orderId > 0) {
/** @var OrderCenterDao $centerDao */
$centerDao = app()->make(OrderCenterDao::class);
$useIntegral = (int)$centerDao->value(['id' => $orderId], 'use_integral_num');
}
if ($useIntegral > 0) {
$userBillServices->billIntegralIncome('pay_product_integral_back', $uid, $useIntegral, $orderId);
}
}
/**
* 库存回退:从中台 extra_info.items 读取商品快照,经 ProductTypeHandler / ActivityDataProxy 回退
* 由旧退款链路 regressionStock 迁移(原实现已中台化)
* @param array $order 中台订单数据
* @return bool
*/
public function rollbackStock(array $order): bool
{
if (($order['status_code'] ?? '') === 'refunded' || (int)($order['is_del'] ?? 0) === 1) {
return true;
}
$res = true;
$orderId = (int)($order['id'] ?? 0);
// 从订单中台 extra_info.items 读取商品快照(旧 cart_info 表已废弃)
$extraInfo = $order['extra_info'] ?? null;
if (is_string($extraInfo)) {
$extraInfo = json_decode($extraInfo, true) ?: [];
}
if (!is_array($extraInfo)) {
$extraInfo = [];
}
// 兜底:调用方未加载 extra_info 时按中台订单 id 补查
if (empty($extraInfo['items']) && $orderId > 0) {
/** @var OrderCenterDao $centerDao */
$centerDao = app()->make(OrderCenterDao::class);
$centerOrder = $centerDao->get($orderId, ['extra_info']);
if ($centerOrder) {
$extraInfo = $centerOrder['extra_info'] ?? [];
if (is_string($extraInfo)) {
$extraInfo = json_decode($extraInfo, true) ?: [];
}
if (!is_array($extraInfo)) {
$extraInfo = [];
}
}
}
$items = $extraInfo['items'] ?? [];
// 活动订单的活动 ID 从中台 extra_info.activity_info 提取(中台无 combination_id 等实体列)
$activityInfo = $extraInfo['activity_info'] ?? [];
if (!is_array($activityInfo)) {
$activityInfo = [];
}
$combinationId = (int)($activityInfo['combination_id'] ?? 0);
$seckillId = (int)($activityInfo['seckill_id'] ?? 0);
$bargainId = (int)($activityInfo['bargain_id'] ?? 0);
// 通过 ProductTypeHandler 回退库存(活动商品保留事件代理)
$productType = $order['product_type'] ?? '';
$handler = null;
if ($productType) {
try {
/** @var ProductTypeRegistry $registry */
$registry = app()->make(ProductTypeRegistry::class);
$handler = $registry->get($productType);
} catch (\Throwable $e) {
$handler = null;
}
}
// 仅已支付订单回退销量:未支付取消的订单从未累加过销量,不能跟着库存一起扣
$paidOrder = (int)($order['pay_status'] ?? 0) === 1;
foreach ($items as $item) {
$cartNum = (int)($item['cart_num'] ?? 0);
if ($cartNum <= 0) {
continue;
}
$unique = (string)($item['attr_info']['unique'] ?? '');
$productId = (int)($item['product_id'] ?? 0);
if ($combinationId) {
// 通过 ActivityDataProxy 事件代理回退拼团库存(避免直接引用 modules 层)
$_results = event('ActivityDataProxy', ['mode' => 'combination', 'action' => 'incCombinationStock', 'args' => [$cartNum, $combinationId, $unique]]);
$res = $res && proxy_event_result($_results, false);
} elseif ($seckillId) {
// 通过 ActivityDataProxy 事件代理回退秒杀库存
$_results = event('ActivityDataProxy', ['mode' => 'seckill', 'action' => 'incSeckillStock', 'args' => [$cartNum, $seckillId, $unique]]);
$res = $res && proxy_event_result($_results, false);
} elseif ($bargainId) {
// 通过 ActivityDataProxy 事件代理回退砍价库存
$_results = event('ActivityDataProxy', ['mode' => 'bargain', 'action' => 'incBargainStock', 'args' => [$cartNum, $bargainId, $unique]]);
$res = $res && proxy_event_result($_results, false);
} else {
// 普通商品通过 ProductTypeHandler 回退库存
if ($handler && $productId > 0) {
try {
$res = $res && $handler->incStock($productId, $unique, $cartNum);
} catch (\Throwable $e) {
$res = false;
}
// 回退支付时累加的冗余销量(仅已支付订单),失败不影响退款主流程
if ($paidOrder) {
try {
app()->make(CenterProductDao::class)->decSales($productId, $cartNum);
} catch (\Throwable $e) {
}
}
}
}
}
return $res;
}
/**
* 优惠券回退:恢复订单创建时消费的用户优惠券
* @param array $order 中台订单数据
* @return void
*/
public function rollbackCoupon(array $order): void
{
$couponId = (int)($order['coupon_id'] ?? 0);
if (!$couponId) {
$extraInfo = $order['extra_info'] ?? [];
if (is_string($extraInfo)) {
$extraInfo = json_decode($extraInfo, true) ?: [];
} elseif (is_object($extraInfo) && method_exists($extraInfo, 'toArray')) {
$extraInfo = $extraInfo->toArray();
}
if (is_array($extraInfo)) {
$couponId = (int)($extraInfo['coupon_id'] ?? 0);
}
}
if ($couponId > 0) {
/** @var StoreCouponUserServices $couponServices */
$couponServices = app()->make(StoreCouponUserServices::class);
if (!$couponServices->recoverCoupon($couponId)) {
throw new \RuntimeException('优惠券回退失败');
}
return;
}
if ((float)($order['coupon_price'] ?? 0) > 0) {
Log::warning('[RefundExecutor] 优惠券回退缺少 coupon_id,订单号=' . ($order['order_no'] ?? ''));
}
}
/**
* 支付退款:balance 退余额 / yue 余额退款 / weixin、alipay、allinpay 走统一退款通道
* @param array $order 中台订单数据
* @param float $refundPrice 退款金额
* @param array $context 上下文:refund_type(original/balance)、refund_trade_no、refund_id
* @return string 退款单号(支付通道/余额流水单号),无则空串
*/
public function refundByPayType(array $order, float $refundPrice, array $context = []): string
{
$refundTradeNo = '';
$payType = (string)($order['pay_type'] ?? '');
$uid = (int)($order['uid'] ?? 0);
$orderId = (int)($order['id'] ?? 0);
$orderNo = (string)($order['order_no'] ?? '');
// 退余额模式:不区分原支付方式,直接退回用户余额
if (($context['refund_type'] ?? 'original') === 'balance') {
/** @var UserMoneyServices $moneyServices */
$moneyServices = app()->make(UserMoneyServices::class);
$moneyServices->moneyIncome('pay_product_refund', $uid, $refundPrice, $orderId);
return $refundTradeNo;
}
// 生成退款单号(无则基于订单号随机生成)
$refundId = $context['refund_id'] ?? ($orderNo . rand(100, 999));
$refundData = [
'refund_price' => $refundPrice,
'pay_price' => $order['pay_price'] ?? $refundPrice,
'refund_id' => $refundId,
];
if (!empty($context['refund_trade_no'])) {
$refundData['refund_trade_no'] = $context['refund_trade_no'];
}
switch ($payType) {
case PayServices::YUE_PAY:
// 余额退款
/** @var UserMoneyServices $moneyServices */
$moneyServices = app()->make(UserMoneyServices::class);
$moneyServices->moneyIncome('pay_product_refund', $uid, $refundPrice, $orderId);
$refundTradeNo = $refundId;
break;
case PayServices::WEIXIN_PAY:
case PayServices::ALIAPY_PAY:
case PayServices::ALLIN_PAY:
if ($payType === PayServices::ALIAPY_PAY) {
mt_srand();
$refundId = $refundId ?? ($orderNo . rand(100, 999));
$refundData['refund_id'] = $refundId;
}
/** @var PayServices $payServices */
$payServices = app()->make(PayServices::class);
$payServices->refund($payType, $orderNo, $refundData, $order);
$refundTradeNo = $refundId;
break;
default:
// 未知支付方式(offline/free 等):无真实资金通道,仅记录日志
Log::warning('[RefundExecutor] 未知支付方式跳过支付退款:pay_type=' . $payType . ',订单号=' . $orderNo);
}
return $refundTradeNo;
}
/**
* 写资本流水(refund 类型)
* 只有真实资金流出才记账:退回用户余额属于站内结转,余额支付订单退款同理,均不记流水
* @param array $order 中台订单数据
* @param float $refundPrice 退款金额
* @param array $context 退款上下文(refund_type: original/balance)
* @return void
*/
protected function writeFlow(array $order, float $refundPrice, array $context = []): void
{
if ($refundPrice <= 0) {
return;
}
// 退款到余额:钱没离开平台,只是从订单转为用户余额,不产生资金流出
if (($context['refund_type'] ?? 'original') === 'balance') {
return;
}
$payType = (string)($order['pay_type'] ?? '');
// 是否真实资金渠道由 PayServices 动态判定(第三方渠道来自驱动注册中心,offline 为系统常量渠道)
/** @var PayServices $payServices */
$payServices = app()->make(PayServices::class);
if (!$payServices->isCashChannel($payType)) {
return;
}
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$userInfo = $userServices->get((int)($order['uid'] ?? 0));
/** @var CapitalFlowServices $flowServices */
$flowServices = app()->make(CapitalFlowServices::class);
$order['nickname'] = $userInfo['nickname'] ?? '';
$order['phone'] = $userInfo['phone'] ?? '';
$order['refund_price'] = $refundPrice;
// 中台订单无 order_id 字段,补 order_no 作为交易订单号写入流水
$order['order_id'] = $order['order_no'] ?? '';
$flowServices->setFlow($order, 'refund');
}
/**
* 开票记录标记已退款
* @param array $order 中台订单数据
* @return void
*/
protected function markInvoiceRefunded(array $order): void
{
$orderId = (int)($order['id'] ?? 0);
if ($orderId <= 0) {
return;
}
/** @var OrderInvoiceServices $invoiceServices */
$invoiceServices = app()->make(OrderInvoiceServices::class);
$invoiceServices->update(['order_id' => $orderId], ['is_refund' => 1]);
}
}