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