在电商平台的预售业务中,消费者通常需要先支付定金锁定商品,待正式开售后再支付尾款完成交易。这个过程中,平台往往会发放各种优惠券来刺激消费。但一个常见的业务矛盾点在于:消费者在支付定金时领取的优惠券,可能在支付尾款时已经过期。这就引出了本文要探讨的核心问题——这类过期优惠券是否应该允许继续使用?
从技术实现角度看,这涉及到三个关键业务节点的时间轴:
重要提示:这种场景下需要特别注意优惠券的"逻辑过期"与"物理过期"区别。逻辑过期指前端展示的失效时间,物理过期则是数据库中的实际状态标记。
这是最简单的实现方式,直接按照优惠券表coupons中的expire_time字段进行判断:
sql复制SELECT * FROM coupons
WHERE user_id = 123
AND status = 'unused'
AND expire_time > NOW()
优点:
缺点:
为预售订单设计特殊类型的优惠券,在数据库中添加is_pre_sale标志位:
php复制class Coupon {
public function isValidForPreSale($order) {
if ($this->is_pre_sale && $order->is_pre_sale) {
return $this->expire_time > $order->presale_start_time;
}
return $this->expire_time > now();
}
}
业务规则:
实现要点:
is_pre_sale字段presale_start_time折中方案是允许优惠券在尾款支付阶段(如7天内)继续使用,即使已经过期:
php复制// 优惠券有效期延长逻辑
$valid = $coupon->expire_time > now() ||
($order->is_pre_sale &&
now() < $order->presale_end_time &&
now() < $coupon->expire_time + $grace_period);
参数说明:
presale_end_time:预售活动结束时间grace_period:宽限期(如7天)sql复制CREATE TABLE `coupons` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`code` varchar(32) NOT NULL,
`amount` decimal(10,2) NOT NULL COMMENT '面额',
`min_order_amount` decimal(10,2) DEFAULT NULL,
`expire_time` datetime NOT NULL,
`is_pre_sale` tinyint(1) DEFAULT '0',
`grace_period` int DEFAULT '0' COMMENT '过期后宽限期(天)',
`status` enum('unused','used','expired') DEFAULT 'unused',
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`,`status`)
) ENGINE=InnoDB;
建议采用优惠券快照方案,在订单创建时记录优惠券状态:
sql复制CREATE TABLE `order_coupons` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_id` bigint NOT NULL,
`coupon_id` bigint NOT NULL,
`coupon_amount` decimal(10,2) NOT NULL,
`original_expire_time` datetime NOT NULL,
`is_expired_used` tinyint(1) DEFAULT '0',
PRIMARY KEY (`id`)
);
php复制class CouponService {
public function validateForOrder(Coupon $coupon, Order $order) {
// 基础验证
if ($coupon->user_id != $order->user_id) {
throw new Exception('优惠券不属于当前用户');
}
if ($coupon->status != 'unused') {
throw new Exception('优惠券已使用或失效');
}
// 预售订单特殊处理
if ($order->is_pre_sale) {
$validTime = $coupon->is_pre_sale
? $order->presale_start_time
: now();
if ($coupon->expire_time < $validTime &&
now() > $coupon->expire_time + $coupon->grace_period) {
throw new Exception('优惠券已过期');
}
}
// 普通订单
elseif ($coupon->expire_time < now()) {
throw new Exception('优惠券已过期');
}
// 最低金额验证
if ($order->amount < $coupon->min_order_amount) {
throw new Exception('未达到优惠券使用门槛');
}
return true;
}
}
php复制// 在订单控制器中
public function payBalance(Request $request) {
$order = Order::find($request->order_id);
try {
DB::beginTransaction();
// 验证优惠券
if ($request->coupon_id) {
$coupon = Coupon::find($request->coupon_id);
(new CouponService)->validateForOrder($coupon, $order);
// 记录优惠券使用
OrderCoupon::create([
'order_id' => $order->id,
'coupon_id' => $coupon->id,
'coupon_amount' => $coupon->amount,
'original_expire_time' => $coupon->expire_time,
'is_expired_used' => $coupon->expire_time < now() ? 1 : 0
]);
$coupon->update(['status' => 'used']);
$order->discount_amount += $coupon->amount;
}
// 其他结算逻辑...
$order->status = 'paid';
$order->save();
DB::commit();
} catch (Exception $e) {
DB::rollBack();
return response()->json(['error' => $e->getMessage()], 400);
}
}
当多个请求同时使用同一张优惠券时,需要在数据库层面加锁:
php复制// 使用悲观锁
$coupon = Coupon::where('id', $couponId)
->lockForUpdate()
->first();
if ($coupon->status != 'unused') {
throw new Exception('优惠券已被使用');
}
当订单发生退款时,优惠券是否需要返还取决于业务规则:
php复制public function handleRefund(Order $order) {
if ($order->coupon) {
// 预售过期券不返还
if (!$order->coupon->is_expired_used) {
Coupon::where('id', $order->coupon->coupon_id)
->update(['status' => 'unused']);
}
// 其他退款逻辑...
}
}
在财务对账时需要特殊处理过期使用的优惠券:
sql复制-- 对账查询示例
SELECT
o.order_no,
oc.coupon_amount,
CASE WHEN oc.is_expired_used THEN '过期使用' ELSE '正常使用' END AS usage_type
FROM orders o
JOIN order_coupons oc ON o.id = oc.order_id
WHERE o.pay_date BETWEEN '2023-11-01' AND '2023-11-30';
对于高频访问的优惠券信息,可以使用Redis缓存:
php复制// 获取用户可用优惠券
public function getUserValidCoupons($userId) {
$cacheKey = "user_coupons:{$userId}";
return Cache::remember($cacheKey, 3600, function() use ($userId) {
return Coupon::where('user_id', $userId)
->where(function($query) {
$query->where('expire_time', '>', now())
->orWhere('grace_period', '>', 0);
})
->where('status', 'unused')
->get()
->toArray();
});
}
为coupons表添加复合索引:
sql复制ALTER TABLE coupons ADD INDEX idx_user_expire (user_id, expire_time);
对大用户采用分表策略,按user_id哈希分表
从技术实现角度,建议根据业务场景选择方案:
| 方案类型 | 适用场景 | 技术复杂度 | 用户体验 |
|---|---|---|---|
| 严格过期 | 普通商品促销 | 低 | 较差 |
| 预售专用券 | 高频预售活动 | 中 | 好 |
| 宽限期方案 | 偶尔预售活动 | 中 | 较好 |
如果平台预售活动频繁,建议采用方案二(预售专用券)。如果只是偶尔开展预售,方案三(宽限期)可能更合适。无论选择哪种方案,都需要在前端明确告知用户优惠券的使用规则,避免纠纷。