多商户平台的优惠券,真正的设计对象是「适用范围」而不是券模板
一、先说一个被低估的现场
商城上线三个月,运营在群里问:「为什么我发的券,用户说领了用不了?」
点进去看:商品详情页明明挂着这张券的入口,用户点了「去使用」,下单页却提示「当前订单不满足使用条件」。运营回到平台后台看券配置------适用范围选的是「一级分类 · 家居生活」,看着也没问题。开发去数据库捞了一下才发现,那件商品挂的是二级分类「厨房用具」,而「家居生活」下面还有一层没被包含进判定。
这不是谁粗心,这是多商户优惠券最典型的故障形态:券模板本身没有任何问题,面值、门槛、有效期全对,出问题的是「这张券到底覆盖哪些商品」这件事没有唯一答案。
单商户系统的券只有「全店」和「指定商品」两种,判定器基本写死在商品表上,不容易错。多商户是另一个量级:券有平台发的、有店铺发的,作用域要横跨多个店铺,还要同时处理类目树和店铺属性。券的难点从来不在「发多少张」,而在「适用范围」这件事怎么被表达、由谁判定、在哪几处调用。
二、先分清角色:平台券和店铺券,不是两套东西
CRMEB 多商户把券按发放主体分成两类,官方文档的定义很直接:
- 平台券 (平台后台 > 营销 > 优惠券 > 优惠券列表):类型包括通用券、品类券、跨店券;
- 店铺券 (商户后台 > 营销 > 优惠券 > 优惠券列表):类型包括店铺券、商品券。
很多人的第一反应是「这不就是两套券系统吗,各写各的」。但真按两套系统做,很快就会撞上同一个问题:用户在商品详情页看到的券列表里,平台券和店铺券是混在一起排的;下单页算「可用优惠券」时,它们也是混在一起算的。两套判定逻辑,迟早会在同一张订单的同一个商品上给出两个答案。
我的判断是:它们应该是同一套模型的两次取值。 一张券在数据上可以由几个正交字段描述:
yaml
Coupon = {
issuer_scope : 平台 | 店铺 // 谁来发、谁承担成本
product_scope : 适用范围表达式 // 覆盖哪些商品(真正复杂的地方)
threshold : 使用门槛
value : 面值
validity : 有效期 / 领取后有效期
stack_group : 叠加分组 // 决定能和哪些券叠加
refund_rule : 返还规则
}
关键是 issuer_scope 和 product_scope 必须拆成两个独立字段:前者决定钱从哪出 ,后者决定能不能用。把它们揉进一个叫「券类型」的枚举里,是后面所有麻烦的起点------因为一旦揉进去,每加一种作用域就要改一次枚举,而每一次改枚举,都会波及所有按枚举分支的代码。
三、三类平台券,本质是三种「适用范围表达式」
官方文档对三类平台券的定义,其实可以直接逐条翻译成条件表达式:
| 券类型 | 官方定义 | 翻译成条件 |
|---|---|---|
| 通用券 | 适用于平台所有店铺、所有商品、所有用户 | 恒真(全集) |
| 品类券 | 适用于某一品类;选择商品分类,可多选也可单选;可选择一级分类,也可选择到二级或三级分类 | 类目树上的节点集合(含子树展开) |
| 跨店券 | 适用于某部分店铺;可按商户分类、店铺类型、商户类别筛选;也可搜索指定店铺 | 店铺集合 |
看这张表会发现:这三者的差异只体现在条件表达式上,不体现在券的其他任何字段上。所以正确的做法是一张券模板 + 一个适用范围条件对象,而不是三套互不相干的实现。
这里有两个容易踩的细节。
其一,类目匹配到底是「含子树」还是「精确相等」。 第一节那个客诉就是这个问题:选了「家居生活」这个一级分类,一件挂在它子分类「厨房用具」下的商品,按树形语义应该被覆盖。如果判定器写成了 ID 精确相等,或者用分类名称做字符串前缀匹配,就会出现「详情页说能用、下单页说不能用」的分裂。选一级分类时,判定必须展开子树------这应该是默认行为,而不是一个需要开发记得住的隐含前提。
其二,「跨店」是个很容易被打歪的概念。 官方定义里跨店券是「适用于某部分店铺」,筛选维度是商户分类、店铺类型、商户类别,也就是平台侧招商时打的标签,而不是店铺的销售数据或评分。这个设计是对的:平台想给「餐饮类」所有店铺打包发一张券,靠的是入驻时打好的标签,不需要在创建活动时手工枚举店铺。如果二开时图省事改成「手选 N 个店铺」,十几个店铺还行,商户上到几百个就没法维护了------而且每次新商户入驻都要回头改活动。
四、适用范围必须「同源判定」,否则一定会分裂
这是全篇我最想强调的一点。
券的「能不能用」,在用户动线上至少有三个调用点:
- 领券中心------哪些券要展示、展示在哪个分类下、券下面挂哪些商品;
- 商品详情页------这件商品身上应该挂哪几张券;
- 下单页------这笔订单实际能抵扣多少钱。
官方文档里描述了移动端券的展示位置,仔细看会发现这三处的口径并不天然一致:
- 商城首页 > 更多优惠券 > 领券中心 > 店铺优惠券:仅展示店铺券,不展示商品券;
- 店铺首页 > 领券:仅展示店铺券,不展示商品券;
- 商品详情 > 优惠券:店铺券和商品券均展示。
也就是说,同一张商品券,在商品详情页看得见,在领券中心看不见。这是产品设计上的有意区分------商品券脱离了具体商品没有意义,不该去占领券中心的位置。但如果在实现上把它做成三条各自拼 SQL 的查询,那么每加一种券类型就要改三处,改漏一处就是线上事故。
可执行的做法是收敛成一个判定入口:
php
// 唯一判定入口:给定「用户 + 商品集合 + 店铺集合」,返回可用券集合
$usable = \app\services\activity\coupon\CouponScopeResolver::resolve(
$user, $productIds, $storeIds
);
领券中心、商品详情、下单页都调它,只是传入的商品集合不同------详情页传单个商品,下单页传购物车,领券中心传空集合表示「只看券本身、不看商品」。这样「能不能用」永远只有一个答案来源,加新券类型时只需要改判定器内部,三个入口自动对齐。
顺带说一条文档里容易被忽略的规则:领券中心以带商品的样式展示为主,所以当优惠券下面没有添加商品时,该券不会展示在领券中心;商户及时在后台添加商品、商品审核并上架后,相关优惠券会自动展示出来。
这条规则的潜台词是:券的可见性依赖商品状态。判定器必须能感知「商品是否上架、是否过审」,不能只看券表本身。如果你的判定入口只查券表,这条规则就实现不了;而要让它能感知,判定器就必须把商品表、类目表一起纳入进来------这也反过来印证了「统一入口」的必要性。
五、叠加是硬边界,必须落在服务端
多商户券最容易被二开改坏的地方是叠加规则。官方口径写得很明确:
平台优惠券与商户优惠券可叠加使用,但平台优惠券只能使用 1 张,不分优惠券类型;(目前商户优惠券的店铺券和商品券可叠加使用),即一个商品最多可同时使用 3 张优惠券,1 张店铺券、1 张商品券、1 张平台优惠券。
拆开看是三条约束:
- 平台券只用 1 张 ,且不区分通用券 / 品类券 / 跨店券------用户手里有三张平台券,一单也只能用一张;
- 店铺券、商品券各 1 张,可以叠加;
- 上限写的是**「一个商品」3 张**,而不是「整单无限叠加」。
前两条很好落,第三条是真正的坑。注意原文给的计量单位是**「一个商品」**,不是「一笔订单」。那么:一笔订单里有 5 个商品,用户能不能给每个商品各配一套券?如果实现时按「整单最多 3 张」去拦,该省的没省;如果按「每个商品最多 3 张」去放,又可能出现抵扣金额和账单对不上。
我不会在这里替你拍板业务规则------这属于产品决策。但有一条底线值得所有做多商户的人记住:文档里这个「一个商品」的粒度措辞,必须在二开前和自己的业务先对齐,并且让判定和校验的最小单位和它对上(通常就是订单商品行 order item)。 粒度没对齐,账单一定对不平。
无论最终业务怎么定,还有一条更基础的底线:叠加上限必须是服务端在创建订单时校验的。 前端把不可选的券置灰只是体验优化,不能当作约束------券的抵扣金额直接决定订单实付和后续分账,服务端不校验就是敞口。
六、券的返还,绑定的是「退款粒度」
券使用之后还能不能「回来」,多商户的规则很干脆,而且平台券和店铺券是同一口径:
使用优惠券的订单全部退款 时,所使用优惠券返还 ;使用优惠券的订单部分退款 时,所使用优惠券不返还。
这条规则在社区里贡献了大量重复提问------有商户在 CRMEB 技术社区问过「分开退款为什么不退券」,官方给出的答复也是「分开退款目前不退回优惠券,只有下单后整单一次退款才会回退」。
从工程角度看,实现要点在于:券的状态机必须挂在订单上,并且要能识别「本次退款是否覆盖了整单」。 一个常见的错误做法是「退款成功就返还券」,结果部分退款也把券退了,用户拿着一张已经用掉部分权益的券再去下单,平台的营销成本直接翻倍。
如果产品上确实想支持「部分退款也按比例返券」,那就不是改个开关的事,而是要引入券的部分核销 / 按比例折算模型,同时账单侧的「优惠券补贴」也要跟着按比例冲正。这是个成本明确的产品决策,不是缺陷修复------建议在排期时单独列一项,别塞进「顺手修个 bug」里。
七、别把发券当成零成本动作
还有一条官方口径,做平台的人最好在设计阶段就记住:
平台优惠券的成本由平台承担;商户对账单详情里,收入项有平台优惠券补贴;平台对账单详情里,支出项有平台优惠券支出。
意思是:平台发出去一张券,不是发一个「虚拟资产」,而是签下一份真实的、会进入双方账单的负债。用户在 A 店用掉一张 50 元平台券,平台就要补给 A 店 50 元;而平台券还能和店铺券叠加,一笔订单上平台要承担的补贴可能不止一笔。
据此有两条很实际的设计建议:
- 发券要有预算闸门。 券的配置里有「是否限时限量」这一项,创建和领取环节至少要有一道总量上限,避免一次误配置就把不限量的券投到领券中心。
- 券的适用范围,同时也是一个资金口径。 跨店券筛的是商户分类 / 店铺类型 / 商户类别,一旦某个标签下的店铺被批量纳入,补贴规模的放大是指数级的。改标签之前,先按最坏情况算一遍成本。
(补充一句:券补贴进入账单之后怎么读、怎么对平,是另一个独立话题。不同版本里「收入 / 支出」的符号方向在不同视图下可能读起来不一致------社区里就有商户提过「列表页显示 −50、详情弹窗显示 +50」的疑问。这类问题建议选型时直接拿演示站自己跑一遍,不要只看文档结论。)
八、四条自检:判断这套券系统能不能上生产
如果你正在选型,或者已经在 CRMEB 多商户上做二次开发,可以用下面四条快速判断券模块的成熟度:
- 有没有统一的适用范围判定入口? 如果「能不能用」分散在详情页、领券中心、下单页三处各写一遍 SQL,长期必然分裂。问开发一句话就能试出来:加一种新券类型,要改几个文件?
- 叠加上限是服务端校验还是前端置灰? 前者是约束,后者只是建议。
- 类目匹配是「含子树」还是「精确相等」? 拿一个「一级分类 + 子分类商品」的组合跑一遍,答案立刻出来。
- 退款返还的口径,文档里有没有明写、代码里有没有按粒度判断? 如果只写了「退款退券」而没区分整单 / 部分,说明这块边界还没被想清楚。
这四条都不涉及功能多寡,全是边界问题。多商户系统选型时,功能清单谁都能罗列一长串,真正拉开差距的,恰恰是这些边界有没有被显式定义出来。
九、一个版本口径的提醒
写这类文章容易被问「你说的是哪个版本」,这里说清:
截至本文,CRMEB 多商户系统(PHP)最新的正式版是 v4.1(2026-08-03 发布) ;v4.2 目前仍处于官方更新预告阶段,尚未正式发布------帮助文档已按 v4.2 的结构做了调整,但不宜据此认为 v4.2 已经可用。
另外,本文讨论的优惠券能力(平台券 / 店铺券 / 叠加与返还规则)是自多商户 v2.0 起就有的既有能力,在现行版本中同样适用,不属于新版预告内容。
券这个东西,看起来是运营的配置项,真写起来是表达式 + 边界两件事:适用范围是一个待求值的条件,叠加上限和返还口径是两条不能松的约束。
如果你在二开时被「券覆盖不到商品」或者「退款返券」坑过,欢迎在评论区说说你的场景------尤其是分批退款、拆单发货这类组合情况,那才是这套判定器真正的压力测试。