摘要
CRMEB Pro 的优惠券不是一个简单的“金额减免”字段,它同时牵涉发布方式、领取限制、适用商品、适用品类、适用品牌、会员券、满减门槛、折扣券和下单可用券计算。很多二开翻车,不是因为代码不会写,而是把规则散落在 Controller、前端表单和下单计算里,后期一加“用户等级券”“渠道券”“指定门店券”,就很难排查。
这篇文章结合 CRMEB Pro 现有优惠券实现,拆一下优惠券规则应该怎么扩展:哪些字段在发布时校验,哪些规则在领取时校验,哪些规则必须放到下单计算链路里重新判断。
1. 先看 CRMEB Pro 优惠券的主链路
后台优惠券入口主要在营销模块:
route/admin.php ├── marketing/coupon/list ├── marketing/coupon/save_coupon ├── marketing/coupon/status/:id/:status ├── marketing/coupon/copy/:id ├── marketing/coupon/released ├── marketing/coupon/released/issue_log/:id └── marketing/coupon/user/grant后端分层大体是:
app/controller/admin/v1/marketing/coupon/StoreCouponIssue.php app/services/activity/coupon/StoreCouponIssueServices.php app/dao/activity/coupon/StoreCouponIssueDao.php app/model/activity/coupon/StoreCouponIssue.php app/services/activity/coupon/StoreCouponUserServices.php app/dao/activity/coupon/StoreCouponUserDao.php app/model/activity/coupon/StoreCouponUser.php前端后台入口在:
crmeb_pro_admin/src/router/modules/marketing.js crmeb_pro_admin/src/api/marketing.js crmeb_pro_admin/src/pages/marketing/storeCouponIssue/create.vue crmeb_pro_admin/src/pages/marketing/storeCouponIssue/index.vue优惠券二开第一条原则:不要只改前端表单。CRMEB Pro 的真正规则落点在 Services 和 Dao,前端只是录入入口。
2. 现有保存逻辑已经区分了券类型
后台保存优惠券时,Controller 会收集一组核心字段:
publicfunctionsaveCoupon(){$data=$this->request->postMore([['coupon_title',''],['coupon_price',0.00],['use_min_price',0.00],['coupon_time',0],['coupon_time_unit',1],['field_key','day'],['start_use_time',0],['end_use_time',0],['start_time',0],['end_time',0],['receive_type',0],['is_permanent',0],['total_count',0],['product_id',''],['category_id',[]],['brand_id',[]],['type',0],['sort',0],['status',0],['coupon_type',1],['rule',''],['category',1],['receive_limit',1],['id',0],]);}这里有几个二开时很关键的字段:
type 0 通用券,1 品类券,2 商品券,3 品牌券 coupon_type 1 满减券,2 折扣券 receive_type 1 手动领取,2 新人/关注类,3 系统发放,4 其他扩展场景 category 1 普通券,2 会员券 receive_limit 每个用户可领取数量 is_permanent 是否不限量 total_count 发布数量 remain_count 剩余数量保存时还会根据type清理互斥字段:
switch($data['type']){case0:$data['product_id']='';$data['category_id']=0;$data['brand_id']=0;break;case1:$data['product_id']='';$data['brand_id']=0;break;case2:$data['category_id']=0;$data['brand_id']=0;break;case3:$data['product_id']='';$data['category_id']=0;break;}这个设计很重要。做二开时,不要让“品类券同时带商品 ID”“品牌券同时带分类 ID”这种脏数据进入库,否则下单计算时会变成多个规则互相抢解释权。
3. 满减、折扣、品类、品牌,应该在哪里扩展?
优惠券真正落库在StoreCouponIssueServices::saveCoupon():
publicfunctionsaveCoupon($data){unset($data['field_key']);$data['receive_type']=$this->normalizeReceiveType((int)($data['receive_type']??0));$data['coupon_time_unit']=(int)($data['coupon_time_unit']??1)===2?2:1;$data['start_use_time']=strtotime((string)$data['start_use_time']);$data['end_use_time']=strtotime((string)$data['end_use_time']);$data['start_time']=strtotime((string)$data['start_time']);$data['end_time']=strtotime((string)$data['end_time']);$data['title']=$data['coupon_title'];$data['remain_count']=$data['total_count'];$data['add_time']=time();if($data['id']!=0){unset($data['category'],$data['coupon_type'],$data['receive_type'],$data['full_reduction'],$data['is_permanent'],$data['receive_limit'],$data['add_time']);$res=$this->dao->update($data['id'],$data);}else{$res=$this->dao->save($data);}if(!$res){thrownewAdminException('添加优惠券失败!');}returntrue;}如果要新增“用户等级券”,不要在 Controller 里直接判断数据库。建议按项目分层这样做:
// StoreCouponIssue.php Controller 中只接收入参['user_level_ids',[]],// StoreCouponIssueServices.php 中整理规则protectedfunctionnormalizeCouponRule(array$data):array{$rule=is_array($data['rule']??null)?$data['rule']:[];if(!empty($data['user_level_ids'])){$rule['user_level_ids']=array_values(array_unique(array_map('intval',$data['user_level_ids'])));}if(!empty($data['channel_type'])){$rule['channel_type']=(string)$data['channel_type'];}$data['rule']=json_encode($rule,JSON_UNESCAPED_UNICODE);unset($data['user_level_ids'],$data['channel_type']);return$data;}然后在saveCoupon()入库前调用:
publicfunctionsaveCoupon($data){unset($data['field_key']);$data=$this->normalizeCouponRule($data);$data['receive_type']=$this->normalizeReceiveType((int)($data['receive_type']??0));$data['coupon_time_unit']=(int)($data['coupon_time_unit']??1)===2?2:1;// 后续保持原有保存逻辑}重点不是这段代码本身,而是规则要集中收口。否则今天在后台保存里判断一次,明天在下单计算里又拼一遍数组,后天移动端列表再复制一份,维护成本会越来越高。
4. 下单可用券必须重新计算,不要相信前端选择
移动端用户进入下单页时,会走可用券计算。API 路由大致是:
GET coupons/order/:price对应 Controller:
publicfunctionorder(Request$request,StoreCouponIssueServices$service,$cartId,$new){[$shipping_type,$storeId]=$request->getMore([['shipping_type',1],['store_id',0],],true);returnapp('json')->successful($service->beUseableCouponList((int)$request->uid(),$cartId,!!$new,(int)$shipping_type));}Service 会重新拉购物车,再交给用户优惠券服务判断:
publicfunctionbeUseableCouponList(int$uid,$cartId,bool$new,int$shipping_type=1){$services=app()->make(StoreCartServices::class);$cartGroup=$services->getUserProductCartListV1($uid,$cartId,$new,[],$shipping_type);$coupServices=app()->make(StoreCouponUserServices::class);if(isset($cartGroup['deduction']['activity_id'])&&!$cartGroup['deduction']['activity_id']&&isset($cartGroup['deduction']['type'])&&$cartGroup['deduction']['type']!=6){return$coupServices->getUseableCouponList($uid,$cartGroup);}return[];}实际适用商品、分类、品牌的判断在StoreCouponUserServices::getUseableCouponList():
switch($coupon['applicable_type']){case0:// 通用券:统计所有允许叠加优惠券的商品金额break;case1:// 品类券:通过分类及下级分类匹配商品 cate_idbreak;case2:// 商品券:通过 product_id 匹配break;case3:// 品牌券:通过 brand_id 匹配break;}if($count&&$coupon['use_min_price']<=$price){$result[]=$coupon_data;}所以新增“用户等级券”时,下单页也必须再判断一次:
protectedfunctioncheckCouponRuleForUser(array$coupon,array$userInfo):bool{$rule=$coupon['rule']??[];if(is_string($rule)&&$rule!==''){$rule=json_decode($rule,true)?:[];}if(!empty($rule['user_level_ids'])){$levelId=(int)($userInfo['level']??0);if(!in_array($levelId,array_map('intval',$rule['user_level_ids']),true)){returnfalse;}}returntrue;}然后在循环优惠券时加入:
foreach($userCouponsas$coupon){if(!$this->checkCouponRuleForUser($coupon,$userInfo)){continue;}// 保留原有 applicable_type、use_min_price、叠加活动判断}这一步不能省。前端传了某张券,不代表这张券一定能用;用户等级、商品范围、活动叠加和满减门槛都要以后端实时购物车数据为准。
5. 折扣券和满减券不要混着算
现有字段coupon_type已经区分满减券和折扣券:
coupon_type = 1 满减券 coupon_type = 2 折扣券后台保存时对折扣范围做了基础校验:
if($data['coupon_type']==2&&($data['coupon_price']<0||$data['coupon_price']>100)){return$this->fail('优惠券折扣为0~100数字');}二开时建议把金额计算封装成独立方法,不要在多个订单入口里散写:
protectedfunctioncalcCouponDiscountPrice(array$coupon,string$couponBasePrice):string{if((int)$coupon['coupon_type']===2){$discountRate=bcdiv((string)$coupon['coupon_price'],'100',4);$discountPayPrice=bcmul($couponBasePrice,$discountRate,2);returnbcsub($couponBasePrice,$discountPayPrice,2);}returnmin((float)$coupon['coupon_price'],(float)$couponBasePrice).'';}注意:这只是示例写法,真正落地时要结合项目现有订单金额计算服务,避免绕过StoreOrderComputedServices、购物车价格组和促销叠加逻辑。
6. 关键目录说明
route/admin.php 后台优惠券发布、列表、状态、发放记录路由。 route/api.php 用户端可领取券、领券、我的优惠券、下单可用券路由。 app/controller/admin/v1/marketing/coupon/StoreCouponIssue.php 后台已发布优惠券管理,负责接收入参和返回响应。 app/services/activity/coupon/StoreCouponIssueServices.php 优惠券发布、发券、可领取券列表、下单可用券入口。 app/services/activity/coupon/StoreCouponUserServices.php 用户券、可用券筛选、用券状态、过期状态处理。 app/dao/activity/coupon/StoreCouponIssueDao.php 有效券查询、商品/分类/品牌券筛选。 app/model/activity/coupon/StoreCouponIssue.php 发布券表模型和搜索器。7. 二开注意事项
- 不要在 Controller 里直接查库或拼 SQL,规则查询放到 Dao,业务编排放到 Services。
- 新增规则字段时,发布保存、复制回显、列表筛选、下单可用券、实际用券都要一起检查。
- 优惠券适用范围必须后端重新计算,不能只相信前端选择的
coupon_id。 - 折扣券和满减券的金额计算不要混用,尤其要避免优惠金额大于可优惠商品金额。
- 修改
rule字段这类 JSON 扩展时,要考虑旧数据为空字符串、空数组、历史格式不一致的兼容。 - 如果涉及数据库字段变更,安装 SQL 和升级 SQL 都要同步维护,并给字段写清楚 COMMENT。
标签建议
CRMEB Pro CRMEB 二次开发 优惠券 ThinkPHP 商城系统 源码解析