CRMEB Pro 超时关单机制:订单没支付,库存、优惠券和状态为什么要一起回收?

CRMEB Pro 超时关单机制:订单没支付,库存、优惠券和状态为什么要一起回收?

## 摘要

订单二开里最容易被低估的,不是“如何把单建出来”,而是“单没付成之后怎么收回来”。很多系统一旦只处理了订单取消,但没同步回收库存、优惠券、积分、状态记录和缓存,就会留下大量脏数据:库存看着少了,券也被占了,订单状态却还停在待支付,客服和用户两边都解释不清。

CRMEB Pro 的订单收尾不是一个按钮,而是一整套链路:创建订单后会注册未支付提醒和超时取消队列,用户手动取消时会走积分和优惠券回退、库存回退和订单删除,退款时还要根据支付方式、订单类型和明细快照把售后、库存、佣金和资金再处理一遍。真正稳的做法,不是“取消订单”,而是“取消订单时把相关资源一并回收”。

本文基于 CRMEB Pro 当前项目真实实现,拆开未支付关单、手动取消、自动取消、退款回收和状态记录,看看一笔订单在收尾阶段到底经历了什么。

本文涉及的真实目录:

```text
app/controller/api/v1/order/StoreOrder.php
app/services/order/StoreOrderServices.php
app/services/order/StoreOrderRefundServices.php
app/services/order/StoreOrderStatusServices.php
app/services/order/StoreOrderCartInfoServices.php
app/jobs/order/UnpaidOrderJob.php
app/jobs/order/UnpaidOrderCancelJob.php
app/listener/order/Create.php
app/listener/order/Pay.php
app/listener/order/Refund.php
app/jobs/order/OrderStatusJob.php
```

## 一、订单一创建,就已经埋好了“自动关单”的钩子

CRMEB Pro 在订单创建事件里,不只是写入订单,还会顺手把后续处理都安排好。入口在:

```text
app/listener/order/Create.php
```

创建成功后,会做几件事:

```php
OrderCreateAfterJob::dispatchDo('updateUser', [$orderInfo, $group, $userInfo]);
OrderCreateAfterJob::dispatchDo('delCart', [$group]);
OrderCreateAfterJob::dispatchDo('delOrderCache', [$uid, $orderInfo['unique']], 120);
OrderStatusJob::dispatch([$orderId, 'create', ['change_message' => '订单生成', ...]]);
```

最关键的是最后这段延迟任务:

```php
// 未支付10分钟后发送短信
UnpaidOrderJob::dispatchSece(600, 'sendNotice', [$orderId]);

// 未支付根据系统设置事件取消订单
$secs = $storeOrderServices->getOrderCancelTime($type);
UnpaidOrderJob::dispatchSece((int)($secs * 3600), 'cancelOrder', [$orderId]);
```

这意味着订单创建时,系统已经默认把“提醒付款”和“超时关单”排进日程了。二开时如果你新增一种订单类型,或者给某些活动单独配置关单时长,就要从这条入口往下接,不然只会出现“订单生成了,但没人管它什么时候结束”。

## 二、未支付订单的处理,不是简单删单

未支付关单的入口在:

```text
app/jobs/order/UnpaidOrderJob.php
```

它做两件事:

```php
sendNotice($id)
cancelOrder($orderId)
```

`sendNotice()` 只是提醒用户付款,真正收尾的是 `cancelOrder()`:

```php
if ($orderInfo->paid) {
return true;
}
if ($orderInfo->is_del) {
return true;
}
if ($orderInfo->pay_type == 'offline') {
return true;
}
$services->cancelOrder((int)$orderId, 0, '订单未支付已超过系统预设时间');
```

这里能看出一个很实在的边界:

```text
线下支付订单不走同一套自动取消逻辑。
```

因为线下单往往需要人工确认,不能拿普通在线支付单的超时策略直接套。二开时如果你给某些渠道单独加支付方式,也要同步确认它是否应该进入自动关单队列。

## 三、用户手动取消订单,真正要回退的是一串资源

取消订单的核心方法在:

```php
app/services/order/StoreOrderServices.php
```

方法签名是:

```php
public function cancelOrder(int $id, int $uid = 0, string $mark = '用户取消订单')
```

它不是直接把订单状态改掉,而是先在事务里做三个动作:

```php
// 回退积分和优惠卷
$res = $refundServices->integralAndCouponBack($order);

// 回退库存和销量
$res = $res && $refundServices->regressionStock($order);

// 修改订单状态
$res = $res && $this->dao->update($order['id'], ['is_del' => 1, 'mark' => $mark]);
```

这三步是绑定在一起的,原因很直接:

```text
1. 不回退优惠券,用户券池会少一张。
2. 不回退库存,商品会被白白占住。
3. 不写订单状态,前台、后台、统计都看不懂这单现在是什么状态。
```

所以取消订单不是“删一条记录”,而是“把下单时占用的资源恢复掉”。这就是为什么这类接口必须走事务。

## 四、库存回退不是随便加回去,而是按订单商品快照回收

退款和取消都可能涉及库存回退,但回退的依据不是商品当前信息,而是订单商品快照。订单商品快照在:

```text
app/services/order/StoreOrderCartInfoServices.php
```

取消售后或者拒绝退款时,也会把 `refund_num` 和订单商品快照重新整理:

```php
$cartInfos = $storeOrderCartInfoServices->getColumn([['oid', '=', $oid], ['cart_id', 'in', $cart_ids]], 'cart_id,refund_num', 'cart_id');
```

然后按快照里的数量回写:

```php
if ($cart['cart_num'] >= $cart_refund_num) {
$refund_num = 0;
} else {
$refund_num = bcsub((string)$cart_refund_num, (string)$cart['cart_num'], 0);
}
$storeOrderCartInfoServices->update(['oid' => $oid, 'cart_id' => $cart['id']], ['refund_num' => $refund_num]);
```

这说明一个核心原则:

```text
库存回退和售后处理,必须基于订单快照,而不是当前商品主数据。
```

因为商品主数据可能已经变了,活动可能已经结束,SKU 甚至可能被删掉。真正能证明那一单买了什么、退了什么的,只有订单快照。

## 五、自动取消和手动取消,走的是同一条回收思路

系统除了单个订单的延迟任务,还提供批量扫描未支付订单的入口:

```php
public function runOrderUnpaidCancel(int $page = 0, int $limit = 0)
```

它会根据系统配置判断不同订单类型的超时时间:

```php
$secsArr = $this->getOrderCancelTime();
if (($order['add_time'] + bcmul($secs, '3600', 0)) < time()) {
$this->cancelOrder((int)$order['id'], 0, '订单未支付已超过系统预设时间');
}
```

配置来源在:

```text
order_cancel_time
order_activity_time
order_bargain_time
order_seckill_time
order_pink_time
rebate_points_orders_time
```

所以你如果想给某种订单单独缩短关单时间,不需要重写整个取消逻辑,只要把对应配置和类型映射弄清楚就行。真正的收尾动作仍然复用 `cancelOrder()`。

## 六、退款回收比取消更复杂,因为它还要管钱、券、库存和佣金

退款相关逻辑在:

```text
app/services/order/StoreOrderRefundServices.php
```

它比取消订单更重,因为它可能涉及:

```text
退款金额
余额退款
原路退款
积分回退
优惠券回退
拼团状态回退
佣金回退
库存回退
```

比如同意退款时,核心动作是:

```php
// 回退积分和优惠卷
if (!$this->integralAndCouponBack($order)) {
throw new ValidateException('回退积分和优惠卷失败');
}

// 退拼团
if ($order['pid'] == 0 && $order['type'] == 3) {
$pinkServices->setRefundPink($order);
}

// 退佣金
if (!$userBrokerageServices->orderRefundBrokerageBack($order)) {
throw new ValidateException('回退佣金失败');
}

// 回退库存
if ($order['status'] == 0) {
$this->regressionStock($order, 1, $refundOrder['order_id']);
}
```

这说明退款不是一个单独支付动作,而是订单生命周期的逆操作。只要你在下单时占用了资源,退款时就要尽可能把资源按原路径归还。

## 七、订单状态记录也是一条独立链路,别省

订单状态变更不是只改主表字段,系统还会写状态流水。状态记录服务在:

```text
app/services/order/StoreOrderStatusServices.php
```

它会把状态、操作人、变更类型、时间都存下来:

```php
$statusData = [
'oid' => $orderId,
'change_time' => time(),
'change_type' => $changeType,
'change_message' => $data['change_message'] ?? '',
'change_manager_type' => $changeManagerType,
'change_manager_id' => $changeManagerId
];
```

这个状态流水特别重要,因为很多问题不是“订单没变”,而是“到底谁在什么时候把它变成这个状态的”说不清。二开时如果你加了新的自动关单策略、外部同步策略或者人工审核策略,也要同步打状态记录。

## 八、二开时最容易漏的三个点

如果你要改订单收尾链路,优先检查这三处:

```text
1. 有没有把未支付超时任务挂上去。
2. 取消订单时有没有回退积分、优惠券和库存。
3. 退款时有没有同步处理快照、佣金和状态流水。
```

再往深一点看,最好再补一层:

```text
4. 自动取消和手动取消是否共用同一套回收逻辑。
5. 订单类型不同,超时时间是否不同。
6. 退款后历史订单详情是否还能按快照正确展示。
```

## 注意事项

1. 订单取消、退款、库存、优惠券、积分都属于高风险业务,改动前先说明影响范围。
2. 不要只改主表状态,忘了回退关联资源。
3. 自动关单和手动取消最好复用同一套回收逻辑,避免出现两套规则。
4. 退款时要优先按订单快照处理,不要直接读商品当前数据。
5. 状态流水不要省,它是后面排查问题最有用的证据链。

## 标签建议

#CRMEBPro #订单取消 #超时关单 #退款回收 #库存回退 #优惠券回退 #积分回退 #源码解析 #订单状态 #二开实战