一、前言
这一篇聚焦定时任务,重点讲订单超时关闭 和自动确认收货这两个最核心的定时任务场景。
先说一个真实场景。有朋友部署完LikeShop后发现:用户下单后不付款,订单一直挂在"待付款"状态不自动关闭;用户收到货后不点确认收货,订单永远停留在"待收货"。排查了半天,最后发现是定时任务没有配置。
官方文档对此有明确说明:待付款订单在开启定时任务的情况下会自动取消,未发货的订单取消后如果订单状态未发生改变,需要检查"支付回调"以及"定时任务"的配置。定时任务不跑,所有依赖时间的自动化逻辑都不会执行。
这篇文章就从定时任务的命令调度机制出发,把订单超时关闭和自动确认收货这两个场景的实现逻辑拆开讲清楚。
二、定时任务的整体设计
2.1 一条命令统一调度
LikeShop单商户高级版的定时任务入口非常简洁------项目根目录下的 think 文件,通过命令行调用 crontab 命令。在服务器上配置为每分钟执行一次:
bash
*/1 * * * * php /www/wwwroot/likeshop/server/think crontab
其中 /www/wwwroot/likeshop/server/think 是项目服务端所在的绝对路径。
在宝塔面板中,配置方式有两种,2022年5月以后发布的版本建议使用第一种方式------访问URL方式 ,任务类型选择"访问URL",URL填写 https://你的域名/crontab,执行周期设置为"N分钟 1分钟"。
这个URL对应的路由定义在 server/route/route.php 中:
php
// 定时任务
Route::rule('crontab', function () {
think\Console::call('crontab');
});
这行代码的意思是:当访问 /crontab 时,通过 think\Console::call() 调用命令行中的 crontab 命令,实现和命令行方式完全相同的效果。
2.2 后台可视化配置
LikeShop单商户高级版的一个特色是定时任务可以在管理后台配置和开关 。设置路径是:系统设置 → 系统维护 → 定时任务 。每个任务可以单独设置开启或停止,状态必须为开启时才生效。
系统还支持记录定时任务运行日志 ,方便排查任务执行情况。管理后台提供了完整的定时任务CRUD接口,包括添加任务(/crontab.crontab/add)和操作任务(/crontab.crontab/operate,支持start开启和stop停止)。
2.3 与交易设置的联动
定时任务的执行依赖于交易设置中的时间参数。在后台的交易设置 页面,可以设置平台订单自动取消、完成时间,订单售后退款的时长。这三个参数直接决定了定时任务的行为:
| 配置项 | 作用 | 默认值 |
|---|---|---|
| 订单自动取消时长 | 下单后多久未支付自动关闭 | 可配置 |
| 订单自动完成时长 | 发货后多久未确认收货自动完成 | 5天 |
| 订单售后退款时长 | 订单完成后多久内允许售后 | 7天 |
订单自动取消时长和订单自动完成时长配合开启计划任务后系统自动执行。如果C端没有取消订单按钮,需要检查交易设置中的时间是否已超过允许取消订单时长。
三、订单超时关闭的实现
3.1 业务逻辑
用户下单后,订单状态为"待付款"(order_status = 0)。如果在配置的超时时间内没有完成支付,系统需要自动关闭这个订单并释放占用的库存。
订单状态流转规则为:待付款订单取消后则为已关闭。在状态机中,对应的迁移路径是:
CREATED(0-待付款) → CANCELLED(4-已关闭)
以状态机的视角来看,CREATED 状态可以迁移到 CANCELLED,这是状态机中明确定义的合法路径。定时任务在关闭订单时,调用的应该是 OrderLogic 中定义的状态迁移方法,而不是直接 update 数据库。
3.2 核心代码实现
Crontab 命令每分钟执行一次,每次执行时扫描 ls_order 表,找出满足以下条件的订单:order_status = 0(待付款)且 create_time 距离当前时间已超过配置的支付时限。
php
// server/app/common/command/Crontab.php 中的超时订单处理逻辑
public function handleTimeoutOrder()
{
// 读取交易设置中的自动取消时长
$timeoutMinutes = ConfigService::get('order_timeout', 30);
$timeoutTime = time() - $timeoutMinutes * 60;
// 查询超时未支付的订单,每次处理100条
$timeoutOrders = OrderModel::where('order_status', 0)
->where('create_time', '<', $timeoutTime)
->where('delete_time', 0)
->limit(100)
->select();
foreach ($timeoutOrders as $order) {
try {
// 使用状态机迁移(带合法性校验)
OrderStatusLogic::transit($order['id'], 4, '订单超时未支付,自动关闭');
// 释放库存
$goodsList = OrderGoodsModel::where('order_id', $order['id'])->select();
foreach ($goodsList as $goods) {
GoodsLogic::restoreStock(
$goods['goods_id'],
$goods['item_id'],
$goods['goods_num']
);
}
// 返还使用的优惠券
CouponLogic::returnCoupon($order['id']);
// 返还使用的积分
IntegralLogic::returnIntegral($order['id']);
} catch (Exception $e) {
Log::error("订单超时关闭失败:{$order['id']},{$e->getMessage()}");
}
}
}
3.3 状态迁移的合法性校验
状态迁移必须在 OrderStatusLogic 中进行合法性校验,防止非法状态变更:
php
// server/app/common/logic/OrderStatusLogic.php
class OrderStatusLogic
{
// 状态迁移的合法路径定义
const TRANSITIONS = [
0 => [1, 4], // 待付款 → 待发货(支付成功)、已关闭(取消/超时)
1 => [2, 4], // 待发货 → 待收货(发货)、已关闭(退款)
2 => [3], // 待收货 → 已完成(确认收货)
3 => [], // 已完成 → 终态
4 => [], // 已关闭 → 终态
];
public static function canTransit(int $fromStatus, int $toStatus): bool
{
if (!isset(self::TRANSITIONS[$fromStatus])) {
return false;
}
return in_array($toStatus, self::TRANSITIONS[$fromStatus]);
}
public static function transit(int $orderId, int $toStatus, string $remark = ''): bool
{
$order = OrderModel::find($orderId);
if (!$order) {
throw new Exception('订单不存在');
}
if (!self::canTransit($order['order_status'], $toStatus)) {
throw new Exception(
"非法状态迁移:{$order['order_status']} → {$toStatus}"
);
}
// 乐观锁更新,防止并发冲突
$affected = OrderModel::where('id', $orderId)
->where('order_status', $order['order_status'])
->update([
'order_status' => $toStatus,
'update_time' => time(),
]);
if (!$affected) {
throw new Exception('订单状态更新失败,请重试');
}
// 记录订单日志
OrderLogModel::create([
'order_id' => $orderId,
'type' => 0,
'content' => '订单状态变更为:已关闭,' . $remark,
'create_time' => time(),
]);
return true;
}
}
关键设计点 :状态迁移方法带乐观锁 (where('order_status', $order['order_status'])),防止并发场景下多个请求同时更新订单状态。如果UPDATE影响行数为0,说明状态已被其他请求修改,当前请求应当重试。
3.4 与支付回调的竞争处理
订单超时关闭和支付回调之间存在竞争条件。假设订单在超时关闭的瞬间,用户刚好完成了支付------如果两个操作同时执行,可能出现"订单已关闭但支付成功"的状态冲突。
LikeShop的应对策略是在状态迁移时做乐观锁校验------更新订单状态时带上当前状态的判断条件,只有状态仍然符合预期时才执行更新。这样即使出现并发,也只有一个操作能成功。
四、自动确认收货的实现
4.1 业务逻辑
商家发货后,订单状态变为"待收货"(order_status = 2)。用户收到货后可以手动点击"确认收货",订单状态变为"已完成"。如果用户迟迟不确认,系统需要在配置的自动完成时间后自动将订单标记为已完成。
官方说明:商家发货后,买家需要在5天内确认收货,未确认收货系统将自动完成收货,担保交易结束,资金自动解除冻结分账至商家指定账户。
在状态机中,对应的迁移路径是:
DELIVERING(2-待收货) → FINISHED(3-已完成)
4.2 核心代码实现
php
// server/app/common/command/Crontab.php 中的自动确认收货逻辑
public function handleAutoConfirmReceive()
{
// 读取交易设置中的自动完成时长(默认5天)
$autoFinishDays = ConfigService::get('order_auto_finish_days', 5);
$finishDeadline = time() - $autoFinishDays * 86400;
// 查询已发货且超过自动确认时间的订单
$orders = OrderModel::where('order_status', 2) // 待收货
->where('delivery_time', '<', $finishDeadline)
->where('delete_time', 0)
->limit(100)
->select();
foreach ($orders as $order) {
try {
// 状态迁移:待收货 → 已完成
OrderStatusLogic::transit(
$order['id'], 3, '发货后超时未确认收货,系统自动完成'
);
// 触发订单完成后的联动逻辑
OrderCompleteLogic::afterComplete($order['id']);
} catch (Exception $e) {
Log::error("自动确认收货失败:{$order['id']},{$e->getMessage()}");
}
}
}
4.3 订单完成后的联动处理
订单自动完成后,需要触发一系列联动操作:
php
// server/app/common/logic/OrderCompleteLogic.php
class OrderCompleteLogic
{
/**
* 订单完成后的联动处理
*/
public static function afterComplete(int $orderId): void
{
$order = OrderModel::find($orderId);
Db::startTrans();
try {
// 1. 更新订单完成时间
OrderModel::where('id', $orderId)->update([
'finish_time' => time(),
'update_time' => time(),
]);
// 2. 赠送消费积分和成长值
IntegralLogic::giveOrderIntegral($orderId);
UserLevelLogic::giveGrowthValue($orderId);
// 3. 分销佣金进入待结算状态(不立即发放)
DistributionLogic::markCommissionPending($orderId);
// 4. 记录订单日志
OrderLogModel::create([
'order_id' => $orderId,
'type' => 0,
'content' => '系统自动确认收货,订单已完成',
'create_time' => time(),
]);
Db::commit();
} catch (Exception $e) {
Db::rollback();
throw $e;
}
}
}
4.4 佣金结算与自动收货的联动
订单自动完成后,分销佣金进入待结算状态,但不会立即发放 。佣金结算需要同时满足三个条件:商品已确认收货 + 已过商品售后期 + 已过结算时间,并且定时任务正常运行。
这里有一个二开时极其容易踩坑的设计:商城的佣金"结算时机"与"订单售后退款时长"是独立设置的。官方文档举了一个例子:后台分销的"结算时机"设置为5分钟,交易设置的"订单售后退款时长"设置为7天。小C购买分销商品,收到货并点击确认收货的5分钟后,当定时任务执行完的时候,该佣金就已经发放出去了,然后在这7天里小C依旧可以对此商品发起售后申请。
建议在配置层面增加校验:结算时间不能短于售后期,或者在结算前强制检查订单是否仍在售后期内。
五、多任务调度的内部机制
5.1 子任务的注册与执行
Crontab 命令的核心逻辑是:每分钟被触发一次,然后检查哪些子任务到了执行时间。每个子任务在数据库中有自己的cron表达式和执行状态。Crontab命令每次执行时,会遍历所有已开启的任务,判断当前时间是否匹配任务的cron表达式。如果匹配,就执行对应的任务逻辑,并更新最后执行时间。
这种设计的好处是:你不需要为每个子任务单独配置一条服务器Crontab 。所有任务共用一条 php think crontab 命令,由PHP层面的调度器统一管理。新增任务时只需要在数据库中添加记录,不需要登录服务器改配置。
LikeShop单商户高级版的定时任务主要处理以下几类业务:
- 订单退款扫描:定时检查退款状态的订单,推进退款流程
- 关闭超时未支付订单:扫描超过支付时限仍未付款的订单,自动关闭
- 自动确认收货:扫描发货后超过自动完成时间的订单,自动完成
- 佣金自动结算:检查满足结算条件的订单,执行佣金发放
5.2 批量处理与性能控制
定时任务每次执行时会限制处理数量 (如limit(100)),避免一次全量扫描拖慢数据库。如果超时订单数量很大,每分钟处理一批,多轮执行后逐步清理完毕。
六、二开实战:新增自定义定时任务
假设业务需要在每天凌晨3点清理过期的优惠券。
第一步 :在 Crontab.php 中新增任务逻辑,或者在管理后台通过定时任务配置界面添加新任务。
第二步:在Logic层新增处理逻辑:
php
// server/app/common/logic/CouponLogic.php
public static function clearExpired()
{
$now = time();
$expiredList = CouponListModel::where('status', 0)
->where('expire_time', '<', $now)
->limit(500)
->select();
foreach ($expiredList as $item) {
CouponListModel::where('id', $item['id'])
->update(['status' => 2, 'update_time' => $now]);
}
}
第三步 :在管理后台的定时任务配置中,设置cron表达式为 0 3 * * *(每天凌晨3点),并开启任务。
需要注意的是,批量操作要控制每次处理的数据量。如果过期优惠券数量很大,一次全量扫描会拖慢数据库。
七、二开避坑清单
坑一:Crontab路径用了相对路径。 Crontab配置中的 think 文件路径必须是绝对路径,很多人用相对路径导致任务无法执行。
坑二:定时任务没有在后台开启。 定时任务的状态必须为开启时才生效。配置了服务器Crontab但后台任务状态是"停止",任务同样不会执行。
坑三:订单自动取消时长设置为0。 交易设置中的"订单自动取消时长"如果不填写的话则不自动关闭。需要自动关闭就必须配置一个有效的时间值。
坑四:订单超时关闭和支付回调的竞争条件。 如果订单在超时关闭的瞬间用户刚好完成了支付,两个操作同时执行会导致状态冲突。解决方案是使用乐观锁------更新状态时带上当前状态条件。
坑五:结算时间短于售后期。 官方明确提醒,结算时机和售后时长独立设置,设置不合理会导致"佣金已发放但用户仍可发起售后"。建议在配置层面增加校验。
坑六:自动确认收货后佣金立即发放。 订单自动完成后,佣金进入待结算状态,需要等售后期和结算时间都过去后才会通过定时任务发放。不要在订单完成的逻辑中直接发放佣金。
八、总结
LikeShop单商户高级版定时任务的核心可以概括为三条线:
命令调度 :一条 php think crontab 命令统一调度,每分钟执行一次,由PHP层面的调度器遍历所有已开启的子任务。后台可视化配置和开关,支持运行日志记录。
订单超时关闭 :扫描 order_status = 0 且超过支付时限的订单,通过 OrderStatusLogic::transit() 执行状态迁移,同步释放库存、返还优惠券和积分。使用乐观锁防止与支付回调的并发冲突。
自动确认收货 :扫描 order_status = 2 且超过自动完成时间的订单,执行状态迁移到"已完成",触发积分赠送、成长值发放和佣金待结算等联动逻辑。
二开时守住三条底线:定时任务必须在后台开启、Crontab路径必须用绝对路径、状态变更必须走OrderStatusLogic。把这三点处理好,定时任务就能稳定支撑订单的自动化处理。