摘要
优惠券领取看起来只是一个按钮:用户点一下,系统给一张券。但在 CRMEB Pro 里,真正安全的领券至少要判断:券是否存在、是否还在领取时间内、是否还有库存、是否允许手动领取、用户是否已经超过领取上限、会员券是否满足会员身份,以及下单时这张券是否仍然能用。
这篇文章围绕 CRMEB Pro 的领券链路做源码解析,并给出二开时常见的增强代码示例。重点是:领券限制不能只写在前端,扣减剩余数量也不能只靠页面防抖。
1. 用户端领券入口在哪里?
用户端优惠券路由在 API 路由里:
text
GET coupons 可领取优惠券列表
POST coupon/receive 领取优惠券
POST coupon/receive/batch 批量领取优惠券
GET coupons/user/num 我的优惠券数量
GET coupons/user/:types 用户已领取优惠券
GET coupons/order/:price 下单可使用优惠券
POST coupon/receive 对应控制器:
php
class StoreCoupons
{
#[Inject]
protected StoreCouponIssueServices $services;
public function receive(Request $request)
{
[$couponId] = $request->getMore([
['couponId', 0]
], true);
$couponId = (int)$couponId;
if (!$couponId) {
return app('json')->fail('参数错误!');
}
$info = $this->services->get($couponId);
if ($info['receive_type'] != 1) {
return app('json')->fail('该优惠券不能领取');
}
$uid = (int)$request->uid();
$coupon = $this->services->issueUserCoupon($uid, $couponId, false);
if ($coupon) {
$coupon = $coupon->toArray();
return app('json')->success('领取成功', $coupon);
}
return app('json')->fail('领取失败');
}
}
这段代码有两个重点:
text
receive_type != 1 不能手动领取
issueUserCoupon($uid, $couponId, false) 负责真正发券
所以二开时不要把校验全部塞到 receive(),Controller 只处理请求和响应,核心判断应该继续收口到 Services。
2. 真正的领券校验在 issueUserCoupon
StoreCouponIssueServices::issueUserCoupon() 是领券核心:
php
public function issueUserCoupon(int $uid, int $id, bool $more = true, string $type = 'get')
{
if (!$uid || !$id) {
throw new ValidateException('参数异常');
}
$issueCouponInfo = $this->dao->getInfo((int)$id);
if (!$issueCouponInfo) {
throw new ValidateException('领取的优惠劵已领完或已过期!');
}
if ($issueCouponInfo['remain_count'] <= 0 && !$issueCouponInfo['is_permanent']) {
throw new ValidateException('抱歉优惠券已经领取完了!');
}
if ($issueCouponInfo['category'] == 2) {
$memberRightService = app()->make(MemberRightServices::class);
if (!$memberRightService->getMemberRightStatus("coupon")) {
throw new ValidateException('暂时无法领取!');
}
$userServices = app()->make(UserServices::class);
if (!$userServices->checkUserIsSvip($uid)) {
throw new ValidateException('请先购买付费会员后领取!');
}
}
$issueUserService = app()->make(StoreCouponIssueUserServices::class);
$couponUserService = app()->make(StoreCouponUserServices::class);
$receiveCount = $issueUserService->getCount([
'uid' => $uid,
'issue_coupon_id' => $id
]);
if (!$more) {
if ($receiveCount >= $issueCouponInfo['receive_limit']) {
throw new ValidateException('已领取过该优惠劵!');
}
}
return $this->transaction(function () use ($issueUserService, $uid, $id, $couponUserService, $issueCouponInfo, $type, $receiveCount) {
$issueUserService->save([
'uid' => $uid,
'issue_coupon_id' => $id,
'add_time' => time()
]);
$res = $couponUserService->addUserCoupon($uid, $issueCouponInfo, $type);
if ($issueCouponInfo['total_count'] > 0) {
$issueCouponInfo['remain_count'] -= 1;
$issueCouponInfo->save();
}
$res['receive_count'] = $receiveCount + 1;
return $res;
});
}
它已经覆盖了几类基础问题:
text
用户不存在或券 ID 为空
券已领完或过期
剩余库存不足
会员券身份校验
领取次数 receive_limit 校验
事务内写领取记录、写用户券、扣减 remain_count
3. 为什么还会出现"重复领"?
常见原因有三类。
第一类是入口绕过。比如新写了一个活动页,直接调用 StoreCouponUserServices::addUserCoupon(),绕过了 issueUserCoupon() 的库存和领取次数判断。
错误示例:
php
// 不建议:跳过了发布券状态、库存、领取次数和会员券校验
$couponUserService = app()->make(StoreCouponUserServices::class);
$couponUserService->addUserCoupon($uid, $issueCouponInfo, 'get');
推荐写法:
php
$couponIssueService = app()->make(StoreCouponIssueServices::class);
$couponIssueService->issueUserCoupon($uid, $couponId, false, 'get');
第二类是 $more 参数用错。手动领取入口传的是 false,表示要检查 receive_limit:
php
$this->services->issueUserCoupon($uid, $couponId, false);
如果二开活动页误传成 true,就可能绕过领取次数限制。
第三类是高并发下的库存扣减。现有逻辑在事务内扣减 remain_count,普通业务一般够用;如果你把优惠券放到高并发直播、秒杀、首页弹窗里,建议把扣减做成条件更新。
4. 高并发领券建议:Dao 中做条件扣减
可以在 StoreCouponIssueDao 中增加一个专门的扣减方法,继续遵守项目 Dao/Model 分层:
php
/**
* 扣减优惠券剩余数量
* @param int $id 发布券ID
* @return bool
*/
public function decRemainCount(int $id): bool
{
return $this->getModel()
->where('id', $id)
->where('is_permanent', 0)
->where('remain_count', '>', 0)
->dec('remain_count')
->update() > 0;
}
然后在 Service 中封装扣减入口:
php
/**
* 校验并扣减优惠券库存
* @param array|\ArrayAccess $coupon 发布券信息
* @return void
*/
protected function checkAndDecCouponStock($coupon): void
{
if ((int)$coupon['is_permanent'] === 1) {
return;
}
if ((int)$coupon['remain_count'] <= 0) {
throw new ValidateException('抱歉优惠券已经领取完了!');
}
if (!$this->dao->decRemainCount((int)$coupon['id'])) {
throw new ValidateException('抱歉优惠券已经领取完了!');
}
}
事务内就不要再直接 $issueCouponInfo['remain_count'] -= 1:
php
return $this->transaction(function () use ($issueUserService, $uid, $id, $couponUserService, $issueCouponInfo, $type, $receiveCount) {
$this->checkAndDecCouponStock($issueCouponInfo);
$issueUserService->save([
'uid' => $uid,
'issue_coupon_id' => $id,
'add_time' => time()
]);
$res = $couponUserService->addUserCoupon($uid, $issueCouponInfo, $type);
$res['receive_count'] = $receiveCount + 1;
return $res;
});
这样做的好处是:库存扣减由数据库条件保证,多个请求同时抢最后一张券时,不会因为旧对象值保存导致超发。
5. 场景错用:receive_type 和 type 要一起看
CRMEB Pro 里,领取方式和适用类型是两套概念:
text
receive_type 控制这张券怎么发到用户账户
type 控制这张券适用于哪些商品范围
例如:
text
receive_type = 1 手动领取
receive_type = 3 系统发放
type = 0 通用券
type = 1 品类券
type = 2 商品券
type = 3 品牌券
所以不能因为 type = 0 是通用券,就允许任何页面领取。手动领取接口已经做了限制:
php
$info = $this->services->get($couponId);
if ($info['receive_type'] != 1) {
return app('json')->fail('该优惠券不能领取');
}
二开专题页时,也应该补上场景校验:
php
protected function checkReceiveScene(array $coupon, string $scene): void
{
$receiveType = (int)($coupon['receive_type'] ?? 0);
$allowScene = [
1 => ['coupon_center', 'product_detail'],
2 => ['new_user'],
3 => ['admin_send', 'order_give'],
4 => ['custom_activity'],
];
if (!in_array($scene, $allowScene[$receiveType] ?? [], true)) {
throw new ValidateException('当前场景不能领取该优惠券');
}
}
6. 用户券入库也要看有效期
用户领取成功后,会写入 store_coupon_user,有效期由 addUserCoupon() 计算:
php
public function addUserCoupon($uid, $issueCouponInfo, string $type = 'get')
{
$data['cid'] = $issueCouponInfo['id'];
$data['uid'] = $uid;
$data['coupon_title'] = $issueCouponInfo['title'];
$data['applicable_type'] = $issueCouponInfo['type'];
$data['product_id'] = $issueCouponInfo['product_id'];
$data['category_id'] = $issueCouponInfo['category_id'];
$data['brand_id'] = $issueCouponInfo['brand_id'];
$data['coupon_price'] = $issueCouponInfo['coupon_price'];
$data['use_min_price'] = $issueCouponInfo['use_min_price'];
$data['add_time'] = time();
if ($issueCouponInfo['coupon_time']) {
$data['start_time'] = $data['add_time'];
$data['end_time'] = $data['add_time'] + $this->getCouponValidSeconds($issueCouponInfo);
} else {
$data['start_time'] = $issueCouponInfo['start_use_time'];
$data['end_time'] = $issueCouponInfo['end_use_time'];
}
$data['type'] = $type;
return $this->dao->save($data);
}
这里已经支持"按天"和"按分钟"的有效期:
php
protected function getCouponValidSeconds($coupon): int
{
$unit = (int)($coupon['coupon_time_unit'] ?? 1) === 2 ? 60 : 86400;
return max((int)($coupon['coupon_time'] ?? 0), 0) * $unit;
}
如果你新增"领取后 2 小时内有效",不要硬编码 3600 * 2 到页面里,应该扩展 coupon_time_unit 或统一在这个方法里处理。
7. 下单使用时还会再次验证
领到券不代表下单一定能用。订单创建时会把 couponId 传入价格计算和创建链路:
php
$priceData = $computedServices->computedOrder(
$uid,
$userInfo,
$cartGroup,
$addressId,
$payType,
$useIntegral,
$couponId,
$shippingType,
$isSendGift
);
真正使用优惠券时,会调用:
php
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 中状态更新是:
php
public function useCoupon(int $id)
{
return $this->getModel()
->where('id', $id)
->update([
'status' => 1,
'use_time' => time()
]);
}
二开建议:如果你要增强安全性,可以把用户 ID 和未使用状态也作为条件,避免误把别人的券或已使用券更新:
php
public function useUserCoupon(int $id, int $uid)
{
return $this->getModel()
->where('id', $id)
->where('uid', $uid)
->where('status', 0)
->update([
'status' => 1,
'use_time' => time()
]);
}
8. 关键目录说明
text
route/api.php
用户端领券、我的优惠券、下单可用券接口。
app/controller/api/v1/activity/StoreCoupons.php
用户端优惠券 Controller,负责参数接收和响应。
app/services/activity/coupon/StoreCouponIssueServices.php
发布券读取、领取校验、领取记录写入、剩余数量扣减。
app/services/activity/coupon/StoreCouponUserServices.php
用户券写入、可用券筛选、使用状态更新。
app/dao/activity/coupon/StoreCouponIssueDao.php
有效发布券查询,适合放库存条件扣减。
app/dao/activity/coupon/StoreCouponUserDao.php
用户券状态更新和用户券查询。
9. 二开注意事项
- 手动领券入口必须传
$more = false,否则容易绕过receive_limit。 - 活动页不要直接调用
addUserCoupon(),统一走issueUserCoupon()。 - 高并发发券建议用 Dao 条件扣减
remain_count。 - 领取方式
receive_type和适用范围type是两套规则,不要混淆。 - 用户券有效期以服务端写入为准,不要让前端自己计算过期时间。
- 下单使用优惠券时还要重新验证购物车、活动叠加和券状态。
标签建议
text
CRMEB Pro
优惠券
领券校验
二次开发
ThinkPHP
商城源码
高并发