LikeShop 混合支付二开:余额 + 微信 / 支付宝组合结算与账务拆分逻辑

一、原生支付链路为什么装不下“混合”

LikeShop 的原生支付是单通道模型;一次支付回调的链路是 PayController 接收回调 → PayLogic 验签和订单校验 → PayService 封装渠道 SDK → OrderLogic::paySuccess()执行状态流转、库存扣减和流水写入,核心操作在同一事务中完成;订单表上的 pay_status 是一个布尔式的状态:0 未支付,1 已支付;没有“部分支付”“组合支付”的中间态,也没有第二支付方式的容身之处。

余额支付在这套模型里是一个特殊分支:直接扣 ls_user.user_money,写一条 ls_account_log 流水,然后把订单置为已支付;微信、支付宝走的是另一条路:生成预支付单、跳转渠道、等回调;两条路是互斥的,一笔订单只能选一条。

混合支付要解决的核心矛盾就在这里:一笔订单需要同时走两条路,且两条路的完成时序不确定——余额扣减是同步的毫秒级操作,第三方支付是异步的秒到分钟级操作;如果简单地把两者串行执行,会出现“余额扣了、微信没付,余额悬空”或者“微信付了、余额没扣,订单金额对不上”的问题。

我的做法是:不修改 OrderLogic::paySuccess 的核心状态流转,在它之前插入一层支付编排(Payment Orchestrator),把一笔订单的支付拆成多个独立的支付子单,由编排层负责聚合状态,只有所有子单都成功后才调用 OrderLogic 推进订单。

二、支付编排:交易单 → 主支付单 → 支付流水

组合支付要不要拆流水?结论是必须拆;每一种支付方式有自己的金额、时间、状态和退款通道,挤在一笔支付单里,退款和状态查询时根本表达不清楚;行业通行的分层是三层:交易单(订单)→ 主支付单(一次支付的聚合)→ 支付流水(每种支付方式一条)。

落到 LikeShop 的数据模型上,订单表本身就是交易单,不需要改动;新增两张表:

sql
CREATE TABLE `ls_payment_main` (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `main_sn` varchar(32) NOT NULL COMMENT '主支付单号',
  `order_id` int UNSIGNED NOT NULL COMMENT '订单ID',
  `order_sn` varchar(32) NOT NULL COMMENT '订单号',
  `user_id` int UNSIGNED NOT NULL COMMENT '用户ID',
  `total_amount` decimal(10,2) NOT NULL COMMENT '应付总额',
  `paid_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '已成功支付金额',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-待支付,1-支付中,2-全部成功,3-部分成功,4-失败,5-已关闭',
  `expire_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '支付超时时间',
  `create_time` int UNSIGNED NOT NULL DEFAULT 0,
  `update_time` int UNSIGNED NOT NULL DEFAULT 0,
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_main_sn` (`main_sn`),
 UNIQUE KEY `uk_order_id` (`order_id`),
 KEY `idx_status_expire` (`status`, `expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主支付单';

CREATE TABLE `ls_payment_flow` (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `main_id` int UNSIGNED NOT NULL COMMENT '主支付单ID',
  `flow_sn` varchar(32) NOT NULL COMMENT '支付流水号',
  `order_sn` varchar(32) NOT NULL COMMENT '订单号',
  `pay_type` varchar(20) NOT NULL COMMENT '支付方式:balance/wechat/alipay',
  `pay_amount` decimal(10,2) NOT NULL COMMENT '本流水金额',
  `pay_status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-待支付,1-支付中,2-成功,3-失败,4-已关闭,5-已退款',
  `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道交易号',
  `pay_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '支付成功时间',
  `close_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '关闭时间',
  `create_time` int UNSIGNED NOT NULL DEFAULT 0,
  `update_time` int UNSIGNED NOT NULL DEFAULT 0,
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_flow_sn` (`flow_sn`),
 UNIQUE KEY `uk_channel_trade` (`pay_type`, `channel_trade_no`),
 KEY `idx_main_status` (`main_id`, `pay_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水';

uk_order_id 唯一约束保证一笔订单只有活跃的主支付单,这是防止重复发起混合支付的第一道闸门;uk_channel_trade 唯一约束(pay_type + channel_trade_no)用于支付回调的幂等:同一个渠道交易号只能绑定一条流水,回调重复到达时插入会失败,而不是重复更新金额。

三、余额部分的扣减:冻结而非直接扣

余额在混合支付中不能“先扣了再说”;如果余额已经扣减、第三方支付超时失败,用户会陷入“钱扣了、单没成”的焦虑,客服需要手工回退余额,而这个回退动作没有原支付流水可关联,对账时只能靠人工标注。

正确的做法是两步扣减:发起混合支付时先冻结余额,第三方支付成功回调后再确认扣减;如果第三方支付超时或失败,释放冻结,余额回到可用状态。

php
public function freezeBalance($userId, $amount, $orderSn)
{
Db::startTrans();
try {
    $user = UserModel::lock(true)->find($userId);
    $available = $user['user_money'] - $user['frozen_money'];
    if ($available < $amount) {
        throw new \Exception('可用余额不足');
    }

    UserModel::where('id', $userId)->inc('frozen_money', $amount)->update();

    AccountLogModel::create([
        'log_sn'      => generateSn('FL'),
        'user_id'     => $userId,
        'source_type' => 冻结来源类型,
        'source_id'   => 0,
        'change_amount'=> $amount,
        'left_amount' => $user['user_money'],
        'change_type' => 2, // 减少方向,但语义为冻结
        'remark'      => "混合支付冻结余额,订单 {$orderSn}",
        'create_time' => time(),
    ]);

    Db::commit();
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
  }
}

确认扣减时,frozen_money 和 user_money 同时减少,可用余额不变;释放冻结时只减 frozen_money;这样 user_money 始终是账面总额,frozen_money 是其中被锁定的部分,可用 = user_money - frozen_money;ls_account_log 的 source_type + source_id 组合记录冻结和扣减的来源单据,排查“余额为什么少了”时可以直接定位到订单。

四、支付编排的执行顺序与第三方支付

混合支付的编排排序是先冻结余额,再发起第三方支付;原因很实际:余额冻结是本地事务,失败可以立即反馈给用户;第三方支付需要跳转或拉起收银台,是异步的;如果顺序反过来,用户已经跳转到微信支付页,回来才发现余额不足,体验更差。

php
public function createMixedPayment($orderId, $useBalance)
{
$order = OrderModel::find($orderId);
if ($order['pay_status'] == 1) {
    throw new \Exception('订单已支付');
}

$totalAmount = $order['order_amount'];
$balanceAmount = min($useBalance, $totalAmount);
$onlineAmount = round($totalAmount - $balanceAmount, 2);

if ($balanceAmount <= 0) {
    throw new \Exception('混合支付余额部分必须大于0');
}

// 1. 创建主支付单和两条流水
Db::startTrans();
try {
    $mainId = PaymentMainModel::create([
        'main_sn'     => generateSn('MP'),
        'order_id'    => $orderId,
        'order_sn'    => $order['order_sn'],
        'user_id'     => $order['user_id'],
        'total_amount'=> $totalAmount,
        'status'      => 1, // 支付中
        'expire_time' => time() + 1800,
        'create_time' => time(),
    ])->id;

    $balanceFlowId = PaymentFlowModel::create([
        'main_id'    => $mainId,
        'flow_sn'    => generateSn('PF'),
        'order_sn'   => $order['order_sn'],
        'pay_type'   => 'balance',
        'pay_amount' => $balanceAmount,
        'pay_status' => 0,
        'create_time'=> time(),
    ])->id;

    $onlineFlowId = PaymentFlowModel::create([
        'main_id'    => $mainId,
        'flow_sn'    => generateSn('PF'),
        'order_sn'   => $order['order_sn'],
        'pay_type'   => 'wechat',
        'pay_amount' => $onlineAmount,
        'pay_status' => 0,
        'create_time'=> time(),
    ])->id;

    Db::commit();
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
}

// 2. 冻结余额
$this->freezeBalance($order['user_id'], $balanceAmount, $order['order_sn']);

// 3. 余额流水直接标记为成功(冻结即视为余额侧已就绪)
PaymentFlowModel::where('id', $balanceFlowId)->update([
    'pay_status' => 2,
    'pay_time'   => time(),
]);

// 4. 发起第三方支付,金额为 onlineAmount
$payParams = PayServer::createOrder([
    'order_sn' => $order['order_sn'] . '_' . $onlineFlowId,
    'amount'   => $onlineAmount,
    'pay_type' => 'wechat',
]);

return ['pay_params' => $payParams, 'main_sn' => $mainSn];
}

第三方支付的商户订单号必须使用“订单号 + 流水 ID”的拼接,而不是原始订单号;这样微信回调时可以通过 out_trade_no 直接定位到具体的支付流水,避免同一笔订单的多次支付尝试在回调层面混淆。

五、回调聚合:支付域自己收口,再推订单

第三方支付回调到达时,不能让 PayLogic 直接调用 OrderLogic::paySccuess;如果余额部分还未确认、或者主支付单还有其他流水未成功,直接推进订单状态会造成“部分支付却标记为已支付”的严重问题。

回调处理分两步;第一步,更新支付流水的状态,这是支付域自己的事:

php
public function handleOnlineCallback($outTradeNo, $channelTradeNo, $amount)
{
// outTradeNo = order_sn + '_' + flowId
$flowId = $this->parseFlowId($outTradeNo);
$flow = PaymentFlowModel::lock(true)->find($flowId);
if (!$flow) return 'FAIL';

// 幂等:已成功的流水直接返回
if ($flow['pay_status'] == 2) return 'SUCCESS';

// 金额校验:回调金额必须与流水金额一致
if (bccomp($amount, $flow['pay_amount'], 2) !== 0) {
    Log::error("支付金额不一致 flow_id={$flowId}");
    return 'FAIL';
}

PaymentFlowModel::where('id', $flowId)->update([
    'pay_status'      => 2,
    'channel_trade_no'=> $channelTradeNo,
    'pay_time'        => time(),
]);

 return 'SUCCESS';
}

第二步,检查主支付单下的所有流水是否都已成功,只有全部成功才推进订单:

php
public function tryCompleteMain($mainId)
{
Db::startTrans();
try {
    $main = PaymentMainModel::lock(true)->find($mainId);

    $flows = PaymentFlowModel::where('main_id', $mainId)
        ->where('pay_status', '<>', 2)->count();

    if ($flows > 0) {
        PaymentMainModel::where('id', $mainId)->update(['status' => 3]); // 部分成功
        Db::commit();
        return false;
    }

    // 所有流水成功:确认扣减余额(冻结转实际扣减)
    $this->confirmBalanceDeduction($main);

    PaymentMainModel::where('id', $mainId)->update([
        'status'      => 2,
        'paid_amount' => $main['total_amount'],
        'update_time' => time(),
    ]);

    // 最后才推进订单
    OrderLogic::paySuccess($main['order_id']);

    Db::commit();
    return true;
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
   }
}

顺序不能反:先确认余额扣减、再更新主支付单、最后推进订单;如果余额确认扣减失败,整个事务回滚,第三方支付的流水保持“已成功单主支付单未完成”的状态,由补偿任务处理;反过来,如果先推进订单再扣余额,一旦扣余额失败,订单已经变成已支付,退款逻辑会被触发,链路更复杂。

六、超时与失败:释放冻结、关闭流水

主支付单有 expire_time,超时后由定时任务扫描并关闭未完成的混合支付:

php
public function closeExpiredMain()
{
$expired = PaymentMainModel::where('status', 1)
    ->where('expire_time', '<=', time())
    ->limit(50)->select();

foreach ($expired as $main) {
    Db::startTrans();
    try {
        $flows = PaymentFlowModel::where('main_id', $main['id'])->select();

        foreach ($flows as $flow) {
            if ($flow['pay_type'] == 'balance' && $flow['pay_status'] == 2) {
                // 释放冻结,而非退还已扣余额
                $this->releaseFrozenBalance($main['user_id'], $flow['pay_amount']);
                PaymentFlowModel::where('id', $flow['id'])
                    ->update(['pay_status' => 4, 'close_time' => time()]);
            } elseif ($flow['pay_status'] == 0 || $flow['pay_status'] == 1) {
                PaymentFlowModel::where('id', $flow['id'])
                    ->update(['pay_status' => 4, 'close_time' => time()]);
            }
        }

        PaymentMainModel::where('id', $main['id'])->update(['status' => 5]);
        Db::commit();
    } catch (\Exception $e) {
        Db::rollback();
        Log::error("关闭混合支付失败 main_id={$main['id']}: " . $e->getMessage());
    }
  }
} 

边界情况:如果主支付单关闭的同时,第三方支付的成功回调刚好到达,会形成“已关闭主单 + 成功流水”的矛盾;处理方式是回调侧检测到 main.status = 5 时,不推进订单,而是将这笔成功流水标记为“待退款”,推入退款补偿队列,按原路退回;不能因为主单关闭就忽略已成功的渠道扣款,那是真实的资金流入。

七、财务拆分:退款必须按原支付构成原路退

混合支付最容易出问题的不是下单,是退款;Tigshop 那篇实战文章里有一句话点得很准:“组合支付下单能拆,售后退款若只走微信通道,余额那截就漏了”;用户退 100,微信只退了 80.系统显示绿灯,但余额的 20 没有回来。

退款拆分的铁律是:按原支付流水的构成比例,分别原路退回;一笔订单余额付了 20、微信付了 80,退款 50 时,余额退 10、微信退 40,而不是全从微信退。

php
public function splitRefund($orderId, $refundAmount)
{
$flows = PaymentFlowModel::where('order_sn', $orderSn)
    ->where('pay_status', 2)->select();

$totalPaid = array_sum(array_column($flows, 'pay_amount'));
if (bccomp($refundAmount, $totalPaid, 2) > 0) {
    throw new \Exception('退款金额超过原支付金额');
}

$remaining = $refundAmount;
$results = [];

foreach ($flows as $index => $flow) {
    if ($index == count($flows) - 1) {
        $splitAmount = $remaining; // 最后一条取剩余,保证总额精确
    } else {
        $ratio = bcdiv($flow['pay_amount'], $totalPaid, 6);
        $splitAmount = bcmul($refundAmount, $ratio, 2);
        $remaining = bcsub($remaining, $splitAmount, 2);
    }

    if (bccomp($splitAmount, '0.00', 2) <= 0) continue;

    if ($flow['pay_type'] == 'balance') {
        $this->refundToBalance($flow, $splitAmount);
    } else {
        $this->refundToChannel($flow, $splitAmount);
    }
    $results[] = ['pay_type' => $flow['pay_type'], 'amount' => $splitAmount];
}

  return $results;
}

部分退款的分配必须在售后单上落明细,不能每次退款时临时按比例估算。退款拆分结果写入售后单的退款明细,记录每一笔退到哪个通道、金额多少、渠道退款单号是什么。重复退款的防护靠“原流水已退金额 + 本次退款金额 ≤ 原支付金额”的条件更新来保证。

余额退款和渠道退款的完成时序不同。余额退回是同步的,写一条 ls_account_log 就完成;微信退款是异步的,需要等渠道退款回调或主动查询。对账时不能把“售后单状态为成功”等同于“所有通道都已退完”——余额可能退了,微信还在退款中,这是两个独立的状态。

八、对账:三本账必须闭合

混合支付的对账不是“把各渠道流水加起来”,而是三本账相互核对:

支付流水账:主支付单 + 各支付流水的金额、状态、渠道交易号,核对“各通道原支付额”。

售后单账:每一笔退款拆到各通道的金额和渠道退款单号,核对“拆给各通道的退款额”。

渠道账单:微信/支付宝商户平台的实际到账和退款记录,核对“实际资金流动”。

三者的闭合关系是:支付流水总额 = 售后退款总额 + 未退款余额,且每条渠道流水的金额必须与渠道账单中的对应交易匹配。定时任务每日拉取渠道账单,按 channel_trade_no 匹配 ls_payment_flow,金额不一致或渠道有记录而系统无流水的,写入对账差异表,人工介入。

余额侧的对账依赖 ls_account_log 的 source_type + source_id 追溯每一条余额冻结、扣减、退还的来源单据。如果一条余额流水的来源单据在主支付单中找不到对应记录,说明存在绕过支付编排的余额变动,这是必须排查的异常。

九、开发注意事项

不碰 OrderLogic::paySuccess 的内部逻辑。混合支付的编排全部在 OrderLogic::paySuccess 之前完成,只有主支付单的所有流水都成功后才调用它。paySuccess 内部的状态机校验、库存扣减、积分发放等逻辑保持原生不变。

回调幂等靠唯一约束兜底。uk_channel_trade 唯一约束比“先查再插”的代码判断更可靠。微信回调是“至少一次”投递,重复回调的 channel_trade_no 相同,插入失败即视为已处理,返回 SUCCESS 即可。

余额冻结的可用额计算不能漏掉冻结部分。可用余额 = user_money - frozen_money。如果只判断 user_money,同一笔余额可以被多个混合支付同时冻结,导致超扣。

第三方支付的 out_trade_no 必须带流水标识。用原始订单号会导致同一订单的多次支付尝试在渠道侧冲突。回调解析时从 out_trade_no 中提取流水 ID,才能精确更新对应的支付流水。

兼容性。新增表独立于 ls_order 和 ls_user。纯余额支付和纯第三方支付继续走原生链路,不经过支付编排层。只有用户主动选择“混合支付”时才创建主支付单和流水。frozen_money 字段默认值为 0,存量用户不受影响。

0
0
0
0
评论
未登录
暂无评论