LikeShop 的原生支付是一次性结清模型。ls_order_pay_status 只有“未支付/已支付”两态,支付成功后订单直接进入待发货,库存扣减、积分发放、分销绑定全部在 OrderLogic::paySuccess()中一次性完成。这套模型假设“钱一次付完、货一次发出”,没有为“钱分几次付、订单跨时间履约”留出任何中间态。
分期支付和预售、混合支付都不同。预售是“定金锁资格、尾款定成交”,混合支付是“同一时刻多渠道凑钱”,而分期支付是首付即成交、尾款按账期后续支付。用户支付首付后,订单已经成立、库存已经锁定、商家开始备货,但尾款是一笔独立的、有到期日的债务;这笔债务需要独立的账单来承载,需要到期提醒、逾期判定、逾期处置,而原生订单表没有任何字段能表达“这笔订单还有一期尾款没付、到期日是几号”。
更关键的是逾期处置。原生订单的超时关闭只处理“未支付订单”,而分期订单的尾款逾期时,订单本身已经支付过首付、可能已经发货。逾期取消不是简单关单,而是“关单 + 回收库存(未发货时)+ 处理已付首付”,这个链路的复杂度远超原生超时关单。
二开的核心策略是:首付走原生支付通道,尾款走独立的分期账单体系,订单状态在原生基础上增加“分期中”的中间态,逾期处置通过定时任务驱动,不修改 OrderLogic::paySuccess()的核心逻辑。
2.1 分期方案配置表
分期方案是商品或订单维度的配置,定义首付比例和尾款账期:
sql
CREATE TABLE `ls_installment_plan` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`plan_name` varchar(50) NOT NULL COMMENT '方案名称',
`scope_type` tinyint NOT NULL DEFAULT 1 COMMENT '适用范围:1-全场通用,2-指定商品,3-指定分类',
`scope_ids` varchar(500) NOT NULL DEFAULT '' COMMENT '适用对象ID,逗号分隔',
`down_payment_rate` decimal(5,2) NOT NULL DEFAULT 30.00 COMMENT '首付比例(百分比)',
`period_count` tinyint NOT NULL DEFAULT 1 COMMENT '尾款期数,当前版本支持1期(首付+尾款)',
`balance_days` int UNSIGNED NOT NULL DEFAULT 7 COMMENT '尾款账期天数(首付支付后N天内付清)',
`grace_days` int UNSIGNED NOT NULL DEFAULT 3 COMMENT '逾期宽限天数',
`late_fee_rate` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '逾期违约金比例(按尾款金额百分比)',
`cancel_refund_rate` decimal(5,2) NOT NULL DEFAULT 100.00 COMMENT '逾期取消时首付退还比例(百分比)',
`min_order_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '最低订单金额门槛',
`is_enable` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_scope` (`scope_type`, `is_enable`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分期方案配置表';
down_payment_rate 控制首付比例(如 30% 表示首付三成),balance_days 控制尾款账期(首付后 7天内付清),grace_days 是逾期宽限期,cancel_refund_rate 决定逾期取消时首付退多少(如 100% 全退,或 80% 扣 20% 违约金)。
2.2 分期账单表
每笔分期订单生成一条分期账单主记录,尾款作为账单明细:
sql
CREATE TABLE `ls_installment_bill` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`bill_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',
`plan_id` int UNSIGNED NOT NULL COMMENT '分期方案ID',
`total_amount` decimal(10,2) NOT NULL COMMENT '订单总额',
`down_payment_amount` decimal(10,2) NOT NULL COMMENT '首付金额',
`balance_amount` decimal(10,2) NOT NULL COMMENT '尾款金额',
`down_payment_status` tinyint NOT NULL DEFAULT 0 COMMENT '首付状态:0-待支付,1-已支付',
`down_payment_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '首付支付时间',
`balance_due_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款到期时间',
`balance_status` tinyint NOT NULL DEFAULT 0 COMMENT '尾款状态:0-待支付,1-已支付,2-已逾期,3-已取消',
`balance_pay_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款支付时间',
`late_fee` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '已产生的逾期违约金',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '账单总状态:0-待首付,1-分期中,2-已结清,3-已逾期,4-已取消',
`remind_count` tinyint NOT NULL DEFAULT 0 COMMENT '已提醒次数',
`last_remind_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后提醒时间',
`stock_locked` tinyint NOT NULL DEFAULT 0 COMMENT '库存是否已锁 定:0-否,1-是',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_bill_sn` (`bill_sn`),
UNIQUE KEY `uk_order_id` (`order_id`),
KEY `idx_user_status` (`user_id`, `status`),
KEY `idx_balance_due` (`balance_status`, `balance_due_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分期账单表';
uk_order_id 唯一约束保证一笔订单只有一条分期账单。balance_due_time 是逾期判定的核心字段,stock_locked 记录库存锁定状态,用于逾期取消时的库存回收。
2.3 分期支付流水表
首付和尾款分别走支付通道,每次支付产生一条流水:
sql
CREATE TABLE `ls_installment_pay_flow` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`flow_sn` varchar(32) NOT NULL COMMENT '支付流水号',
`bill_id` int UNSIGNED NOT NULL COMMENT '分期账单ID',
`order_sn` varchar(32) NOT NULL COMMENT '订单号',
`pay_stage` tinyint NOT NULL DEFAULT 1 COMMENT '支付阶段:1-首付,2-尾款',
`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-已关闭',
`channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道交易号',
`pay_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '支付成功时间',
`create_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_bill_stage` (`bill_id`, `pay_stage`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分期支付流水表';
3.1 下单时选择分期
用户在下单页选择“分期支付”时,系统根据分期方案计算首付和尾款金额,创建分期账单而首付支付流水;订单本身照常创建,但 pay_status 保持 0,等待首付支付:
php
public function createInstallmentOrder($orderId, $planId)
{
$order = OrderModel::find($orderId);
if ($order['order_amount'] < $plan['min_order_amount']) {
throw new \Exception('订单金额未达到分期门槛');
}
$plan = InstallmentPlanModel::find($planId);
$downPayment = round($order['order_amount'] * $plan['down_payment_rate'] / 100, 2);
$balance = round($order['order_amount'] - $downPayment, 2);
Db::startTrans();
try {
$billId = InstallmentBillModel::create([
'bill_sn' => generateSn('IB'),
'order_id' => $orderId,
'order_sn' => $order['order_sn'],
'user_id' => $order['user_id'],
'plan_id' => $planId,
'total_amount' => $order['order_amount'],
'down_payment_amount'=> $downPayment,
'balance_amount' => $balance,
'status' => 0, // 待首付
'create_time' => time(),
])->id;
// 创建首付支付流水
InstallmentPayFlowModel::create([
'flow_sn' => generateSn('IF'),
'bill_id' => $billId,
'order_sn' => $order['order_sn'],
'pay_stage' => 1,
'pay_type' => 'wechat',
'pay_amount' => $downPayment,
'pay_status' => 0,
'create_time'=> time(),
]);
Db::commit();
return ['bill_id' => $billId, 'down_payment' => $downPayment];
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
3.2 首付支付成功:不推订单,只锁库存
首付支付回调到底时,不能调用 OrderLogic::paySuccess();原生 paySuccess 会把订单置为已支付并进入待发货,但分期订单的尾款还没付,不能发货。
首付成功后的正确编排是:更新分期账单状态 → 锁定库存 → 计算尾款到期时间 → 通知用户;订单的 pay_status 仍然保持 0,但增加一个 installment_status 字段标记“分期中”:
sql
ALTER TABLE `ls_order`
ADD COLUMN `installment_status` tinyint NOT NULL DEFAULT 0
COMMENT '分期状态:0-非分期,1-待首付,2-分期中,3-已结清,4-已逾期取消' AFTER `pay_status`;
php
public function onDownPaymentSuccess($billId)
{
Db::startTrans();
try {
$bill = InstallmentBillModel::lock(true)->find($billId);
if ($bill['down_payment_status'] == 1) {
Db::commit();
return; // 幂等
}
$plan = InstallmentPlanModel::find($bill['plan_id']);
$now = time();
$dueTime = $now + $plan['balance_days'] * 86400;
// 更新账单
InstallmentBillModel::where('id', $billId)->update([
'down_payment_status' => 1,
'down_payment_time' => $now,
'balance_due_time' => $dueTime,
'status' => 1, // 分期中
'stock_locked' => 1,
'update_time' => $now,
]);
// 锁定库存(首付成功即锁定,防止尾款期间超卖)
$this->lockOrderStock($bill['order_id']);
// 更新订单分期状态
OrderModel::where('id', $bill['order_id'])->update([
'installment_status' => 2, // 分期中
]);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
库存为什么在首付时锁定而不是尾款时锁定:首付后订单已经成立,商家开始备货,如果等尾款付完才锁库存,期间商品可能被其他订单买走,尾款付了却发不了货;首付即锁库存是分期支付的基本前提。锁定方式复用原生的库存扣减逻辑(stock 字段扣减或独立的锁定库存字段),并在 stock_locked 中标记,用于逾期取消时回退。
4.1 尾款支付入口
用户可以在“我的分期账单”中查看待付尾款,点击支付时生成尾款支付流水;尾款支付金额 = balance_amount + late_fee (如有逾期违约金):
php
public function payBalance($billId)
{
$bill = InstallmentBillModel::find($billId);
if ($bill['balance_status'] != 0 && $bill['balance_status'] != 2) {
throw new \Exception('尾款状态不可支付');
}
$payAmount = $bill['balance_amount'] + $bill['late_fee'];
// 创建尾款支付流水
$flowId = InstallmentPayFlowModel::create([
'flow_sn' => generateSn('IF'),
'bill_id' => $billId,
'order_sn' => $bill['order_sn'],
'pay_stage' => 2,
'pay_type' => 'wechat',
'pay_amount' => $payAmount,
'pay_status' => 0,
'create_time'=> time(),
])->id;
// 发起支付(out_trade_no 带流水标识)
$payParams = PayServer::createOrder([
'order_sn' => $bill['order_sn'] . '_B_' . $flowId,
'amount' => $payAmount,
'pay_type' => 'wechat',
]);
return $payParams;
}
4.2 尾款支付成功:推进订单
尾款支付回调成功后,分期账单结清,此时才调用原生的 Orderlogic::paySuccess()推进订单进入正常流程:
php
public function onBalanceSuccess($flowId)
{
Db::startTrans();
try {
$flow = InstallmentPayFlowModel::lock(true)->find($flowId);
if ($flow['pay_status'] == 2) {
Db::commit();
return;
}
InstallmentPayFlowModel::where('id', $flowId)->update([
'pay_status' => 2,
'channel_trade_no'=> $channelTradeNo,
'pay_time' => time(),
]);
$bill = InstallmentBillModel::lock(true)->find($flow['bill_id']);
$now = time();
InstallmentBillModel::where('id', $bill['id'])->update([
'balance_status' => 1,
'balance_pay_time' => $now,
'status' => 2, // 已结清
'update_time' => $now,
]);
OrderModel::where('id', $bill['order_id'])->update([
'installment_status' => 3, // 已结清
]);
// 尾款结清,推进订单进入待发货
OrderLogic::paySuccess($bill['order_id']);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
关键顺序:先更新账单和流水,再调用 paySuccess,最后提交事务;如果 paySuccess 内部抛异常,整个事务回滚,流水状态回到“待支付”,用户可重新支付。反过来先提交账单再调 paySuccess,一旦 paySuccess 失败,账单已结清但订单未推进,状态不一致。
5.1 多节点提醒
尾款到期提醒设置三个提醒节点:到期前 3天、到期前 1 天,到期当天;提醒通过站内消息 + 模版消息双通道发送,remind_count 记录已提醒次数,防止重复轰炸:
php
public function remindBalance()
{
$now = time();
// 到期前3天
$this->sendRemind($now + 3 * 86400, $now + 4 * 86400, '尾款将在3天后到期');
// 到期前1天
$this->sendRemind($now + 86400, $now + 2 * 86400, '尾款将在明天到期');
// 到期当天
$this->sendRemind($now, $now + 86400, '尾款今日到期,请尽快支付');
}
private function sendRemind($start, $end, $tip)
{
$bills = InstallmentBillModel::where('status', 1)
->where('balance_status', 0)
->where('balance_due_time', '>=', $start)
->where('balance_due_time', '<', $end)
->select();
foreach ($bills as $bill) {
MessageService::send($bill['user_id'], '分期尾款提醒',
"{$tip},应付尾款 {$bill['balance_amount']} 元。");
InstallmentBillModel::where('id', $bill['id'])->update([
'remind_count' => $bill['remind_count'] + 1,
'last_remind_time'=> time(),
]);
}
}
5.2 逾期判定与违约金
尾款超过 balance_due_time 后进入逾期状态,balance_status 置 2(已逾期)。逾期后每天按 late_fee_rate 计算违约金,但违约金总额设上限(通常不超过尾款金额的 30%),防止无限累积:
php
public function markOverdue()
{
$now = time();
$overdueBills = InstallmentBillModel::where('status', 1)
->where('balance_status', 0)
->where('balance_due_time', '<', $now)
->select();
foreach ($overdueBills as $bill) {
$plan = InstallmentPlanModel::find($bill['plan_id']);
$overdueDays = floor(($now - $bill['balance_due_time']) / 86400);
// 违约金 = 尾款 × 费率 × 逾期天数,上限为尾款30%
$lateFee = min(
round($bill['balance_amount'] * $plan['late_fee_rate'] / 100 * $overdueDays, 2),
round($bill['balance_amount'] * 0.3, 2)
);
InstallmentBillModel::where('id', $bill['id'])->update([
'balance_status' => 2,
'late_fee' => $lateFee,
'status' => 3,
'update_time' => $now,
]);
}
}
5.3 宽限期后自动取消
逾期超过 grace_days 宽限期后,自动取消订单并回收库存;这是分期支付最重的处置动作,必须严格幂等:
php
public function cancelOverdueOrders()
{
$now = time();
$bills = InstallmentBillModel::where('status', 3) // 已逾期
->where('balance_status', 2)
->where('balance_due_time', '<', $now - 86400)
->limit(50)->select();
foreach ($bills as $bill) {
Db::startTrans();
try {
// 幂等:条件更新抢占
$affected = InstallmentBillModel::where('id', $bill['id'])
->where('status', 3)
->where('balance_status', 2)
->update(['status' => 4, 'balance_status' => 3, 'update_time' => $now]);
if (!$affected) {
Db::rollback();
continue;
}
$plan = InstallmentPlanModel::find($bill['plan_id']);
// 1. 回收库存(仅当库存已锁定且订单未发货)
if ($bill['stock_locked'] == 1) {
$this->releaseOrderStock($bill['order_id']);
}
// 2. 处理首付:按方案配置退还比例
$refundAmount = round($bill['down_payment_amount'] * $plan['cancel_refund_rate'] / 100, 2);
if ($refundAmount > 0) {
RefundService::refundDownPayment(
$bill['order_id'], $refundAmount, '分期尾款逾期取消,退还首付'
);
}
// 3. 关闭订单
OrderModel::where('id', $bill['order_id'])->update([
'order_status' => 4, // 已关闭
'installment_status' => 4, // 已逾期取消
]);
// 4. 通知用户
MessageService::send($bill['user_id'], '分期订单已取消',
"尾款逾期未付,订单已取消。首付退还 {$refundAmount} 元,已原路退回。");
Db::commit();
} catch (\Exception $e) {
Db::rollback();
Log::error("分期逾期取消失败 bill_id={$bill['id']}: " . $e->getMessage());
}
}
}
库存回收的边界:如果订单在尾款逾期前已经发货(部分商家可能在首付后就发货),库存不能回收,只能走售后或追偿流程;因此库存回收前必须校验订单的发货状态,已发货的订单跳过库存回收,只关闭账单。
首付退还的边界:cancel_refund_rate 设为 100% 表示首付全退(用户友好),设为 80% 表示扣 20% 违约金;违约金和逾期违约金(late_fee)是两回事——late_fee 是尾款逾期产生的、随尾款一起支付的;cancel_refund_rate 是取消时从首付中扣除的;两者不能重复计算。
6.1 分期订单的退款拆分
分期订单退款时,按已支付阶段拆分退还:首付已付、尾款未付的,只退首付;首付和尾款都已付的,按订单退款规则全额退;退款必须按原支付流水原路退回,不能混通道:
php
public function refundInstallment($orderId, $refundAmount)
{
$bill = InstallmentBillModel::where('order_id', $orderId)->find();
$paidFlows = InstallmentPayFlowModel::where('bill_id', $bill['id'])
->where('pay_status', 2)->select();
$totalPaid = array_sum(array_column($paidFlows, 'pay_amount'));
if (bccomp($refundAmount, $totalPaid, 2) > 0) {
throw new \Exception('退款金额超过已支付金额');
}
$remaining = $refundAmount;
foreach ($paidFlows as $index => $flow) {
$split = ($index == count($paidFlows) - 1)
? $remaining
: bcmul($refundAmount, bcdiv($flow['pay_amount'], $totalPaid, 6), 2);
if ($flow['pay_type'] == 'balance') {
$this->refundToBalance($flow, $split);
} else {
$this->refundToChannel($flow, $split);
}
$remaining = bcsub($remaining, $split, 2);
}
}
6.2 账务对账的三本账
分期支付的账务对账需要三本账闭合:分期账单账(应收首付 + 应收尾款)、支付流水账(实收首付 + 实收尾款)、渠道账单(微信/支付宝实际到账);对账关系为:应收总额 = 实收总额 + 未收尾款 + 违约金,实收总额必须与渠道账单逐笔匹配。
逾期取消的订单,首付退还部分要从渠道账单中扣回,late_fee 和违约金如果实际收取了,也要在账务中单独列示,不能混入商品收入。
7.1 后台配置清单
| 配置项 | 类型 | 说明 |
|---|---|---|
| 分期支付开关 | 开关 | 是否启用分期支付 |
| 首付比例 | 百分比 | 默认 30%,范围 10%–90% |
| 尾款账期天数 | 数字 | 首付后 N 天内付清,默认 7 天 |
| 逾期宽限天数 | 数字 | 逾期后 N 天内仍可支付,默认 3 天 |
| 逾期违约金比例 | 百分比/天 | 按尾款金额每日计算,设上限 |
| 违约金上限 | 百分比 | 违约金总额不超过尾款的百分比 |
| 取消时首付退还比例 | 百分比 | 默认 100%,可设 80% 扣违约金 |
| 最低订单金额 | 金额 | 达到此金额才允许分期 |
| 提醒节点 | 多选 | 到期前 3 天 / 1 天 / 当天 |
7.2 开发注意事项
首付成功不能调用 OrderLogic::paySuccess()。分期订单的首付只是“锁定交易”,订单真正进入履约要等尾款结清。通过 instllment_status 字段做逻辑隔离,原生的 pay_status 和 order_status 在首付阶段保持不变。
库存锁定必须在首付时完成。首付后商家开始备货,库存若不在首付时锁定,尾款期间会被其他订单买走;锁定时复用原生库存扣减逻辑,stock_locked 标记用于逾期取消时的精准回退,避免重复扣减或重复回退。
逾期取消的幂等靠条件更新抢占。定时任务可能多实例并发执行,同一账单被两个实例同时处理会导致库存重复回收、首付重复退款。用 WHERE status = 3 ADN balance_status = 2 的条件更新抢占,影响行数为 0 即跳过。
违约金和首付退还比例不能重复扣。late_fee 是尾款逾期产生的、随尾款支付;cancel_refund_rate 是取消时从首付中扣的;如果两者都设了,用户实际损失是“已付 late_fee + 首付被扣部分”,后台配置时需要明确提示这两个参数的叠加关系。
兼容性。新增表独立 ls_order,installment_status 字段默认值为 0,存量订单不受影响。分期支付作为下单页的可选支付方式,不改变原生一次性支付和混合支付的链路。逾期定时任务作为定时任务的新增子任务接入,与订单超时关闭、退款扫描等任务错开执行时间。
