摘要
优惠券过期不是简单把状态改成“已过期”。在 CRMEB Pro 里,优惠券可能来自手动领取、系统发放、新人礼、会员卡激活、下单赠送、直播间领取,也可能已经被订单使用,或者在退款时需要回退。二开时如果只写一个定时任务批量更新状态,很容易误伤历史订单、赠券记录和直播间统计。
这篇文章围绕 CRMEB Pro 优惠券过期、使用、回退、订单赠券几个关键点做源码拆解,并给出适合二开的任务设计建议。
1. 用户券状态有三类
用户领取后的优惠券模型在:
app/model/activity/coupon/StoreCouponUser.php
状态映射很清楚:
protected $statusType = [
0 => '未使用',
1 => '已使用',
2 => '已过期'
];
获取方式也不止一种:
protected array $gainType = [
'send' => '系统发放',
'get' => '手动领取',
'newcomer' => '新人礼赠送',
'activate_level' => '会员卡激活赠送',
'user_first' => '用户注册赠送',
'order' => '下单赠送',
'luck_lottery' => '抽奖赠送',
'live' => '直播间领取'
];
这就是为什么“过期处理”不能只看一张表的 end_time。不同来源的券,后续关联动作不一样。
2. CRMEB Pro 已经有按用户检查过期的方法
StoreCouponUserServices 里有一个很关键的方法:
/**
* 过期优惠卷失效
*/
public function checkInvalidCoupon($uid = 0)
{
$this->dao->update([
['uid', '=', $uid],
['end_time', '<', time()],
['status', '=', '0']
], [
'status' => 2
]);
}
获取用户有效券数量时,会先检查过期:
public function getUserValidCouponCount(int $uid)
{
$this->checkInvalidCoupon($uid);
return $this->dao->getCount([
'uid' => $uid,
'status' => 0
]);
}
这个逻辑适合“用户访问时顺手刷新自己的券状态”。但如果后台要做全量过期扫描,不能直接传 uid = 0,否则只会处理 uid = 0 的数据。
3. 二开全量过期任务,建议单独封装 Dao 查询
不要在 Job 里直接写 Db::name('store_coupon_user')。按照项目分层,建议在 StoreCouponUserDao 增加方法:
/**
* 分页获取已过期但未处理的用户券ID
* @param int $limit 每批处理数量
* @return array
*/
public function getExpiredUnusedIds(int $limit = 500): array
{
return $this->getModel()
->where('status', 0)
->where('end_time', '<', time())
->limit($limit)
->column('id');
}
再加一个批量更新方法:
/**
* 批量标记用户券过期
* @param array $ids 用户券ID
* @return bool
*/
public function markExpiredByIds(array $ids): bool
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (!$ids) {
return true;
}
return $this->getModel()
->whereIn('id', $ids)
->where('status', 0)
->update(['status' => 2]) !== false;
}
Service 层负责编排:
/**
* 批量处理过期用户券
* @param int $limit 每批数量
* @return int 本批处理数量
*/
public function batchInvalidExpiredCoupon(int $limit = 500): int
{
$ids = $this->dao->getExpiredUnusedIds($limit);
if (!$ids) {
return 0;
}
$this->dao->markExpiredByIds($ids);
return count($ids);
}
Job 只调用 Service:
class CouponExpireJob extends BaseJobs
{
use QueueTrait;
public function doJob(int $limit = 500)
{
try {
$services = app()->make(StoreCouponUserServices::class);
do {
$count = $services->batchInvalidExpiredCoupon($limit);
} while ($count === $limit);
} catch (\\Throwable $e) {
response_log_write([
'message' => '优惠券过期处理失败:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
}
return true;
}
}
这样做有三个好处:
Job 不直接查库
Dao 负责查询和批量更新
Service 负责循环和业务编排
4. 订单使用后,不能被过期任务改掉
用户下单使用优惠券时,会调用用户券服务:
public function useCoupon(int $couponId, int $uid, array $cartInfo, array $promotions = [], int $liveRoomId = 0)
{
if (!$couponId || !$uid || !$cartInfo) {
return true;
}
$promotionsServices = app()->make(StorePromotionsServices::class);
[$couponInfo, $couponPrice] = $promotionsServices->useCoupon(
$couponId,
$uid,
$cartInfo,
$promotions,
$liveRoomId
);
if ($couponInfo) {
$this->dao->useCoupon($couponId);
}
return true;
}
Dao 里会把状态改为已使用:
public function useCoupon(int $id)
{
return $this->getModel()
->where('id', $id)
->update([
'status' => 1,
'use_time' => time()
]);
}
因此全量过期任务必须只处理:
status = 0
end_time < 当前时间
不要写成“所有 end_time 小于当前时间都改过期”。已使用的券是历史凭证,不能被覆盖成已过期,否则订单详情、售后、统计报表都会产生歧义。
5. 退款时优惠券可能要回退
订单退款时,CRMEB Pro 有回退优惠券逻辑:
public function integralAndCouponBack($order)
{
$res = true;
// 回退优惠卷,拆分子订单不退优惠券
if (!$order['pid'] && $order['coupon_id'] && $order['coupon_price']) {
$coumonUserServices = app()->make(StoreCouponUserServices::class);
$res = $res && $coumonUserServices->recoverCoupon((int)$order['coupon_id']);
}
[$order, $changeIntegral] = $this->regressionIntegral($order);
return $res && $order->save();
}
recoverCoupon() 会把已使用券恢复为未使用:
public function recoverCoupon(int $id)
{
$coupon = $this->dao->getOne([
'id' => $id
], 'id,status,live_room_coupon_id');
if (!$coupon || !(int)$coupon['status']) {
return true;
}
$res = $this->dao->update($id, [
'status' => 0,
'use_time' => 0
]);
if ((int)$coupon['status'] === 1 && !empty($coupon['live_room_coupon_id'])) {
$liveRoomCouponDao = app()->make(LiveRoomCouponDao::class);
$liveRoomCouponDao->decUseNum((int)$coupon['live_room_coupon_id']);
}
return $res;
}
这里有一个细节:恢复成未使用后,如果优惠券本身已经超过 end_time,下次用户进入优惠券列表或定时任务扫描时,应该再变成过期。不要在退款回退时直接判断过期并丢掉,因为回退动作的职责是恢复“这张券没有被订单消耗”。
6. 下单赠券也要考虑订单取消和退款
CRMEB Pro 支持商品关联优惠券,下单后赠送。相关 Job 在:
app/jobs/product/ProductCouponJob.php
下单后赠券:
public function doJob($orderInfo)
{
if (!$orderInfo) return true;
try {
$storeProductCouponServices = app()->make(StoreProductCouponServices::class);
$storeProductCouponServices->giveOrderProductCoupon(
(int)$orderInfo['uid'],
(int)$orderInfo['id']
);
} catch (\\Throwable $e) {
response_log_write([
'message' => '赠送订单商品关联优惠券发生错误,错误原因:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]);
}
return true;
}
退款或订单失败时,会让赠券失效:
public function failureProductCoupon($orderInfo)
{
if (!$orderInfo) return true;
try {
$storeProductCouponServices = app()->make(StoreProductCouponServices::class);
$storeProductCouponServices->failureOrderProductGiveCoupon(
$orderInfo['uid'],
$orderInfo['id']
);
} catch (\\Throwable $e) {
response_log_write([
'message' => '订单退款退还优惠券发生错误,错误原因:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]);
}
return true;
}
Service 中会按订单、用户、商品和状态筛选赠券:
$where = [
['coupon_product_id', 'in', $productIds],
['uid', '=', $uid],
['oid', 'in', $oidAll],
['status', '=', 0],
['type', '=', 'order']
];
$res = $storeCouponUserServices->update($where, ['status' => 2]);
这说明二开“赠券过期/失效”时,要区分两类状态:
自然过期:end_time 到期,未使用券 status 改为 2
业务失效:订单退款、活动失败、赠券来源失效,未使用券 status 改为 2
状态一样,但备注、日志、触发原因最好分开记录,方便售后排查。
7. 发布券本身过期和用户券过期不是一回事
发布券表 store_coupon_issue 用于控制“这张券还能不能被领取”,用户券表 store_coupon_user 用于控制“某个用户手里的券还能不能使用”。
发布券有效查询在 Dao 中:
public function validSearch(array $where = [])
{
return parent::search($where)
->where('status', 1)
->where('is_del', 0)
->where('remain_count > 0 OR is_permanent = 1')
->where(function ($query) {
$query->where(function ($query) {
$query->where('start_time', '<=', time())
->where('end_time', '>=', time());
})->whereOr(function ($query) {
$query->where('start_time', 0)
->where('end_time', 0);
});
})
->where(function ($query4) {
$query4->where(function ($query5) {
$query5->where('coupon_time', 0)
->where('end_use_time', '>=', time());
})->whereOr('coupon_time', '>', 0);
});
}
这里判断的是发布券是否还能展示、领取、发放。用户已经领取到手的券,要看 store_coupon_user.start_time/end_time/status。
二开时别把两个概念混到一个定时任务里:
发布券过期:影响后续能不能领
用户券过期:影响用户手里的券能不能用
8. 建议补一个过期处理日志
如果项目后续要做更细的售后排查,建议不要只改 status,可以补一个轻量日志表或复用现有操作日志体系,记录:
coupon_user_id
uid
cid
old_status
new_status
reason
operator_type
add_time
Service 示例:
/**
* 标记用户券过期并记录原因
* @param array $ids 用户券ID
* @param string $reason 过期原因
* @return bool
*/
public function markCouponExpired(array $ids, string $reason = '自然过期'): bool
{
return $this->transaction(function () use ($ids, $reason) {
$res = $this->dao->markExpiredByIds($ids);
// 如果项目已有日志服务,建议在这里批量写入日志。
// 注意日志写入也要走对应 Services/Dao,不要在这里直接拼 SQL。
return $res;
});
}
日志不是为了“好看”,而是为了回答售后经常问的三个问题:
这张券为什么不能用了?
是自己过期,还是退款导致失效?
什么时候被系统处理的?
9. 关键目录说明
app/model/activity/coupon/StoreCouponUser.php
用户券模型,包含状态和获取方式映射。
app/services/activity/coupon/StoreCouponUserServices.php
用户券过期检查、使用、回退、可用券筛选。
app/dao/activity/coupon/StoreCouponUserDao.php
用户券查询和状态更新,适合放批量过期查询。
app/services/order/StoreOrderRefundServices.php
退款时回退积分和优惠券。
app/jobs/product/ProductCouponJob.php
商品关联优惠券赠送、退款失效任务。
app/services/product/product/StoreProductCouponServices.php
下单赠券、订单失败后赠券失效。
10. 二开注意事项
标签建议
CRMEB Pro
优惠券过期
定时任务
订单退款
二次开发
ThinkPHP
源码解析






