Medusa Promotion 促销模块演进全解析:从 2.0 到 2.20 的关键能力与底层实现
【免费下载链接】medusaThe world's most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa
促销模块(@medusajs/promotion)是 Medusa 生态中负责折扣、优惠券、活动预算与买赠等营销能力的独立领域模块,在 Medusa 2.0 架构中以可插拔 Module 的形式存在。本文以该模块的 CHANGELOG 为骨架,结合 模块源码 与 核心工具枚举定义,系统梳理 2.0 至 2.20 版本间促销引擎的能力演进、关键缺陷修复与底层实现原理,帮助你理解规则求值、预算控制、并发安全、税含金额计算等核心机制,并能在实际项目中准确地配置与排障。
模块定位与整体架构
按 模块 README 的说明:PromotionModule 是 Medusa 的促销引擎,它通过一组规则(rules)约束"何时、以何种方式"使用优惠码(coupon code)对购物车进行折扣。从 package.json 可见其身份信息:包名@medusajs/promotion、当前版本2.20.1、要求 Node.js >= 20,并以@medusajs/framework(版本一致对齐)为 peer dependency。
模块的服务入口是 promotion-module.ts,它继承MedusaService,对外暴露六类实体的 DTO:Promotion、ApplicationMethod、Campaign、CampaignBudget、CampaignBudgetUsage、PromotionRule、PromotionRuleValue,并提供listActivePromotions、computeActions、registerUsage、revertUsage等核心方法(详见下文各节)。
数据模型方面(models 目录)核心实体包括:
- Promotion:促销主体,含
code(唯一)、is_automatic、is_tax_inclusive、type(standard/buyget)、status(draft/active/inactive)、limit/used(用量限制)、metadata,并与 Campaign、ApplicationMethod、PromotionRule 关联;code上建有带WHERE deleted_at IS NULL的部分唯一索引IDX_unique_promotion_code,这也是软删除环境下唯一约束的正确实现方式(对应 2.4.0 中"唯一约束应把软删除记录考虑在内"的修复)。 - ApplicationMethod:定义折扣的"应用方式"——
type(fixed/percentage)、target_type(order/items/shipping_methods)、allocation(each/across/once)、value、currency_code、max_quantity、apply_to_quantity、buy_rules_min_quantity等。 - Campaign 与 CampaignBudget:活动及其预算,预算按
type分为 spend / usage / use_by_attribute 等(枚举见 promotion/index.ts),2.11.0 起新增attribute字段与 CampaignBudgetUsage 按属性用量明细表。
促销类型、目标与分摊方式(type / target_type / allocation)
在创建促销时,type决定促销的算法类别。核心枚举定义在 packages/core/utils/src/promotion/index.ts:
PromotionType:standard与buyget(买赠)。ApplicationMethodType:fixed(固定金额)与percentage(百分比)。ApplicationMethodTargetType:order(整单)、items(商品行)、shipping_methods(运费)。ApplicationMethodAllocation:each(逐件)、across(分摊到全部)、once(仅一次)。
在 computeActions 中可以看到标准促销的分发逻辑:target_type === order时强制使用ACROSS分摊(allocationOverride),items走商品行计算,shipping_methods走运费计算;而buyget则统一进入 buy-get.ts 的getComputedActionsForBuyGet专用算法。
CHANGELOG 中有两条与"类型支持"直接相关的记录:
- 2.7.0:"percentage value is accounted for in buyget promotions"——修复了买赠促销中百分比折扣值未被正确计入的问题。结合 buy-get.ts 的
applyPromotionToTargetItems可见:FIXED时按value × 数量并以上限applicableAmount(单价×数量)封顶;PERCENTAGE时按applicableAmount × value / 100计算。 - 2.14.0(PR #14939):"support fixed amount discount type in buy-get promotions"——买赠促销正式支持固定金额折扣类型。此前买赠仅支持百分比,从 2.14.0 起
application_method.type = fixed也可用于买赠,且按上面同一段代码的分支逻辑计算。
促销规则体系与规则求值(rules / target_rules / buy_rules)
促销的"何时可用"由规则(PromotionRule)控制,规则通过PromotionRuleOperator(gte/lte/gt/lt/eq/ne/in,见 promotion/index.ts)与规则值(PromotionRuleValue)表达。规则分为三类(RuleType):作用于整单的rules、作用于目标对象的target_rules、以及买赠场景的buy_rules(见 promotion/index.ts)。
规则求值相关的版本修复集中在"运算符语义"与"空条件"上:
- 2.9.0:"in operator work as In instead of equal logic"——将
in运算符从"等值"语义修正为真正的"包含于集合"语义,这是促销规则筛选商品/地区/客户分组时的关键行为修正。 - 2.11.0:"Fix not in promotion rule empty value validation"——修复
not in规则在空值校验上的缺陷。 - 2.2.0:"don't evaluate rule condition if conditions to evaluate is empty"——当待求值条件为空时跳过规则求值,避免空集合上的误判。
- 2.4.0:"eval conditions for rules are corrected"——进一步修正规则条件的求值结果。
在 promotion-module.ts 中,areRulesValidForContext(promotionRules, applicationContext, ApplicationMethodTargetType.ORDER)负责判断整单级规则是否命中,同时会校验application_method.currency_code与购物车币种的一致性(对应2.9.0的 "check currency when computing actions for promotions" 修复)。
促销状态(status)与活动时间窗
2.3.0(PR #10950)引入促销状态字段:draft/active/inactive。对应 Promotion 模型 中的status枚举字段(默认draft,带IDX_promotion_status索引)。
状态与活动时间窗共同决定"促销是否对购物车生效",核心逻辑在listActivePromotions_(promotion-module.ts):
- 固定要求
status: ACTIVE; - 对于挂载了 Campaign 的促销,额外要求
starts_at <= now < ends_at(任一为空则不限); - 未挂载 Campaign 的促销只要状态为 active 即可。
值得注意的是,computeActions内部以单个now时间点统一驱动时间窗过滤("Ensure we share the same now date across all filters"),保证同一批次计算的一致性。
活动预算体系:spend / usage / use_by_attribute
预算控制是促销模块最核心的防超卖能力,集中在registerUsage(promotion-module.ts)与revertUsage(L543-L694)中。预算类型来自 CampaignBudgetType:
| 预算类型 | 含义 | 计数方式 |
|---|---|---|
spend | 金额预算 | 累加每次实际折扣金额computedAction.amount,超限抛NOT_ALLOWED |
usage | 次数预算 | 每个促销码只计一次(promotionCodeUsageMap去重) |
use_by_attribute | 按属性次数预算(2.11.0 起) | 按attribute(如customer_id/customer_email)维度分别计数 |
spend_by_attribute | 按属性金额预算 | 枚举中已定义,注册逻辑当前聚焦前三类 |
2.11.0:按属性限制促销使用次数
2.11.0(PR #13451)"support limiting promotion usage by attribute"——即use_by_attribute预算类型。实现上在 CampaignBudget 模型 新增attribute字段(注释示例"customer_id"、"customer_email"),并新增usages一对多关联到 CampaignBudgetUsage:该表以attribute_value+budget_id建立部分唯一索引,记录"每个属性值已使用次数"。
- 注册路径:
registerCampaignBudgetUsageByAttribute_(L203-L255)在属性值维度上校验limit并累加used; - 回退路径:
revertCampaignBudgetUsageByAttribute_(L257-L291)在used <= 1时删除该行,否则递减; - 计算路径:
computeActions中(L877-L917)通过getBudgetUsageContextFromComputeActionContext从计算上下文提取customer_id/customer_email,调用computeActionForBudgetExceeded(usage.ts)产出CAMPAIGN_BUDGET_EXCEEDED动作;若上下文中缺少预算要求的属性值,则直接抛INVALID_DATA错误提示缺失。
2.12.0:促销自身用量上限(usage limit)
2.12.0(PR #13760)"feat: promotion usage limit"——促销本身(不依赖 Campaign)也可设置使用次数上限。对应 Promotion 模型 中的limit(可空数字)与used(默认 0)。computeActions中(L920-L928)当used >= limit时产出PROMOTION_LIMIT_EXCEEDED动作;registerUsage中(L405-L419)对设置了数字limit的促销递增used并做上限校验。
同版本还包含两条配套变更:
- 2.12.0(PR #14176)"skip promotion usage limit checks on edit flows"——在订单编辑流程中跳过用量限制检查,避免编辑已有订单时因"已用满"而误拦截;
- 2.12.0(PR #13306)"Compute virtual adjustments for order previews"——为订单预览计算"虚拟"调整项,使预览金额与真实下单一致。
另外2.12.0(PR #13999)为 Promotion 模型 增加metadataJSON 字段(@since 2.12.0),使促销可携带自定义业务数据。
2.18.0:并发预算守卫(serialize concurrent money guards)
2.18.0是预算安全的关键版本:"serialize concurrent money guards to prevent over-capture, over-refund and campaign budget overspend"。其解决的是经典竞态:两个并发请求同时读到相同的used值、同时通过限额校验、同时写入,最终导致预算超支或超额退款。
在 registerUsage 中可以看到完整的并发保护实现:
- 强制事务:要求调用必须处于事务中,否则抛
UNEXPECTED_STATE("must run inside a transaction"),原因是FOR UPDATE在自动提交连接上会立即释放锁、静默失效; - 行级锁:对涉及数字
limit的promotion行与涉及的promotion_campaign_budget行执行SELECT ... FOR UPDATE,并按id稳定排序加锁以避免并发多促销注册时的死锁; - 锁超时:
SET LOCAL lock_timeout = '3s'使锁竞争快速失败而非悬挂; - 锁下重读:加锁后以
refresh: true重新读取预算用量,确保守卫判断基于已提交的最新值。
这套机制同时守护了支付领域(over-capture / over-refund)与促销领域(budget overspend)的金额一致性。
税含促销:从 2.8.5 到 2.16.0 的金额基数修复
税含(tax-inclusive)促销的金额计算经历了多轮修复,是金额正确性最集中的演进线:
- 2.8.5(PR #12412):引入税含促销能力(涉及 promotion、dashboard、core-flows、cart、types、utils、medusa 多个包);同期(PR #12644)修复"非可折扣商品"(non discountable items)的检查。
- 2.9.0(PR #12960、PR #13106):修正折扣计算逻辑与促销税含金额计算;"Moved calculation logic from total to original_total to ensure consistent base values"——将计算基数从
total调整为original_total,保证基数的一致性与可复算性。 - 2.16.0:"prevent negative taxable base when stacking tax-inclusive and non-tax-inclusive promotions"——这是税含问题的收官修复:此前 applied-promotions 累加器把每个促销的调整额存放在各自税基中,导致税含促销(含税口径)去比较前一个非税含促销记录的(不含税口径)金额时口径错位,叠加折扣可能超过行项目价值、把应税基数(taxable base)推到负数。
修复方案(见 CHANGELOG 说明):已应用金额统一以不含税口径跟踪,在被扣除前转换到各促销自身的税基口径,且对each与across两种分摊方式均生效。
买赠(Buy-Get)算法与多促销协调
买赠促销由 buy-get.ts 实现,其核心流程(函数注释自述)为迭代式应用:
preparePromotionApplicationState从剩余买量中选择满足buy_rules_min_quantity的买项、从剩余目标量中挑选目标项、并以max_quantity/apply_to_quantity约束应用数量;applyPromotionToTargetItems按单价计算折扣额(fixed 按件、percentage 按百分比),检查预算上限,更新跨促销协调映射表;updateEligibleItemQuantities扣除已消费数量,防止同一商品被重复套用;- 循环直至无法满足条件,并设有
MAX_PROMOTION_ITERATIONS = 1000的防死循环安全阀。
该文件还体现了两个关键细节:
- 稳定排序:
sortByPrice对等额商品返回 0,避免不稳定比较器导致"是否命中促销取决于行顺序"(对应注释说明的排序一致性问题); - 跨促销协调:
methodIdPromoValueMap记录每个商品行已累计的促销金额,calculateRemainingQuantities计算其他促销已占用的数量,确保多个促销叠加时不会超出商品价值。
多促销应用的排序由sortByBuyGetType(buy-get.ts)与 promotion-module.ts 中的查询排序共同决定:买赠优先、application_method.value降序,买赠之间再按buy_rules_min_quantity、apply_to_quantity降序。
2.7.0(PR #11992)修复了"多个百分比促销未全部应用"(multiple percentage promotions weren't applied)的场景,即多百分比叠加时的计算协调问题;2.6.0/2.5.1/2.5.0等版本则主要是工程清理。
自动促销与性能优化演进
2.10.0:免运费促销(free shipping)
2.10.0(PR #13263)在 dashboard、core-flows、js-sdk、link-modules、promotion 中联动支持免运费促销;同期(PR #13294)清理了旧的无用促销代码库。免运费的本质是target_type = shipping_methods的标准促销,计算路径见 promotion-module.ts 的getComputedActionsForShippingMethods。
自动促销预过滤:2.7.0 → 2.10.3 → 2.11.0
自动促销(is_automatic)不需要显式输入优惠码即可生效,但其规则求值需要把整张购物车带入计算,开销较大。性能优化分三步演进:
- 2.7.0(PR #12129)"Improve performances [1]":第一轮促销计算性能优化;
- 2.10.3(PR #13524)"promo prepare top level rules filter":将顶层规则(top-level rules)下推为数据库查询过滤条件,在 SQL 层先筛掉不可能命中的自动促销;对应 build-promotion-rule-query-filter-from-context;
- 2.10.3(PR #13540)"Prevent promotion filtering to exceed psql limits":防止预过滤生成的 SQL
IN条件数量超过 PostgreSQL 参数上限; - 2.11.0:进一步改进预过滤("Further promotions pre filtering improvements")。
在 computeActions 中可以看到该机制:非禁用自动促销时,先从应用上下文构建规则过滤条件,预查出候选自动促销 id,再与显式传入的促销码取并集($or)。
基础设施与工程演进:mikro-orm 6、迁移命令与依赖治理
CHANGELOG 后半段记录了模块底座的持续工程化:
- 2.0.0(PR #7341):随 Medusa 2.0 大版本发布,促销模块作为独立模块正式落地;2.0.x–2.1.x期间完成模块迁移重构(2.1.2 "migrate promotion module")、MikroORM CLI 包装修复(2.0.5)、移除促销后同步清除购物车调整项(2.1.1 "updating cart with removed promotion removes adjustments")等。
- 2.4.0(PR #10292):升级到mikro-orm 6,并同步修复软删除下的唯一约束问题。
- 2.5.0(PR #11216):
AbstractModuleService的create方法类型安全化。 - 2.6.1(PR #11738):移除 Medusa 包的版本区间(ranges),改为精确版本对齐;2.11.0(PR #13439)进一步把 peer deps 收敛为单一包并从 framework 统一 re-export。
- 2.12.2(PR #14262):migrate 命令新增
all-or-nothing参数,使迁移要么全部成功要么全部回滚;2.12.3(PR #14315)修复迁移生成器的 import。 - 2.13.0:minor bump;2.17.2(PR #15683):补充包 bugs 元数据。
- 模块内提供完整的 migrations 目录(从
Migration20240227120221到Migration20251107050148,共 16 个),并在 package.json 中暴露migration:initial、migration:create、migration:up、migration:down、orm:cache:clear等基于medusa-mikro-ormCLI 的迁移命令。
测试佐证与可验证性
模块配套了覆盖核心能力的集成测试(integration-tests):
- promotion.spec.ts:促销 CRUD 与规则校验;
- compute-actions.spec.ts:各类促销的计算动作输出;
- campaign.spec.ts:活动与预算行为;
- register-usage.spec.ts / revert-usage.spec.ts:预算注册与回退;
- evaluate-rule-value-condition.spec.ts:规则值条件求值。
运行方式(package.json):yarn test执行单元测试,yarn test:integration执行集成测试。
小结
从packages/modules/promotion/CHANGELOG.md的版本脉络可以看到,Medusa 促销模块在 2.x 系列中的演进主轴清晰:先完成 2.0 模块化落地(类型安全、mikro-orm 6、依赖治理),再补齐业务能力(状态机、促销用量上限、按属性预算、免运费、税含促销),最后集中攻坚正确性(并发预算守卫、税基口径统一、买赠固定金额支持)与性能(规则预过滤下推、SQL 上限规避)。理解这条演进线,不仅有助于你在当前版本中正确配置促销规则与预算,也能在排查"折扣未生效""预算被超支""金额基数异常"等问题时,快速定位到对应的实现文件与修复意图。
【免费下载链接】medusaThe world's most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考