餐饮 SaaS 优惠券系统架构演进(六)从优惠券模块到营销平台:Coupon / Promotion / Pricing / Benefit 的领域拆分与迁移路线

发布时间:2026/10/11 1:47:45
餐饮 SaaS 优惠券系统架构演进(六)从优惠券模块到营销平台:Coupon / Promotion / Pricing / Benefit 的领域拆分与迁移路线
基于当前项目源码反推领域边界不是先建四个微服务而是先把四类业务不变量拆清再用 Strangler Pattern 渐进迁移确保每一步都可验证、可回滚。DDD / Bounded ContextPricing EngineTransactional OutboxStrangler Migration结论先行当前代码其实已经出现了目标架构的“雏形”商品域负责活动用户域内部已经存在统一优惠编排器、会员价引擎、优惠券计算与冲突策略订单域通过远程接口使用和归还优惠券。下一步最合理的动作不是一次性拆成四个服务而是把这些已有边界显式化Pricing 先模块化并独立验证Coupon 保持强一致聚合Promotion 留在活动规则域Benefit 把分散的会员/订阅/积分权益收敛成统一 entitlement 模型。阅读边界本文中的“当前实现”来自项目源码结构“目标模型、表结构、拆分顺序和迁移阈值”属于架构演进建议。对外发布内容已按示例化方式脱敏服务名、表名和业务 ID 均使用通用名称。图示约定所有架构图均优先采用分层、分域、分阶段布局避免超长横向链路和过度缩小字体正文图只保留对理解领域边界、事务边界和迁移路线真正有帮助的内容。1. 为什么第五篇 RCA 之后必须讨论“营销平台”而不是继续修补优惠券第五篇把超发、孤儿券、MQ 丢事件和补偿失败归纳成了一个共同问题关键业务不变量没有被统一控制面持续守护。继续沿着这个结论往前走会发现单纯在 Coupon 内部补锁、加重试、加巡检已经无法解决所有结构性问题。因为订单最终价格并不是由优惠券单独决定而是商品活动、会员价、优惠券、买赠、加料、限购和冲突策略共同计算出来的。源码中已经出现非常明显的领域分化信号商品域维护多种活动类型与活动优先级用户域内部存在统一优惠计算编排器并拆出了会员价、活动刷新、买赠分配、优惠券分摊、Coupon-Activity 冲突策略等独立 Engine与此同时会员卡和订阅卡又各自维护“权益”概念。换句话说系统已经不是一个“优惠券模块”而是在自然长成一个营销与定价平台。系列上下文从上一篇 RCA 的“稳定性治理”继续往前走第六篇把视角从故障控制面推进到领域边界。下面保留附件中的五位一体稳定性总览作为这一篇的架构出发点。图 1领域边界与系统边界——先按职责拆清 Promotion / Coupon / Pricing / Order再通过稳定业务 ID 与事件协作避免把所有规则继续堆回一个“优惠券模块”。2. Current State源码里已经有哪些“可拆边界”2.1 Pricing 不是从零开始当前统一优惠编排器已经在做领域拆分当前统一优惠计算服务的职责不是简单“减一个券金额”而是编排多类价格事实先读取商品事实识别会员身份刷新活动价格计算会员价应用活动限购生成行级价格基准再处理买赠最后计算优惠券候选并执行 Coupon 与 Activity 的冲突策略。这个调用顺序已经非常接近一个独立 Pricing Context 的应用服务。// 现状的简化调用顺序示意化 productFacts productProvider.load(request.items) memberIdentity memberPriceEngine.resolveIdentity(user) promotionFacts activityEngine.refresh(productFacts, memberIdentity) memberPriceEngine.apply(productFacts, memberIdentity) linePricing cartLinePricing.prepare(productFacts, promotionFacts) buyGiftAllocator.apply(linePricing) couponCandidate conflictPolicy.calculateCouponCandidate(linePricing) quote buildFinalPrice(linePricing, couponCandidate)这说明第一步根本不需要“大重写”。相反应该保护现有计算逻辑先把它包进稳定的 Pricing API并通过 Shadow Quote 对比保证新旧结果一致。2.2 Promotion 已经形成独立规则体系但它仍与商品域部署在一起现有活动模型至少覆盖折扣、特价、立减、第二件折扣、买一赠一、满 N 赠一、会员日等多种类型并且拥有活动优先级、会员价策略、优惠券与活动是否同享、不可同享时的 Coupon First / Activity First / Best Price 策略。这些已经明显超出“商品基本资料”的职责范围。因此从领域角度看Promotion 已经应该被视为独立上下文但从部署角度不一定要立刻从商品服务中物理拆出去。先通过接口隔离做到商品域只提供 Product FactsPromotion 负责活动规则和活动候选等团队边界、发布频率或性能压力达到阈值后再独立部署。2.3 Benefit 目前是“概念分散”最容易被忽略会员等级折扣、会员卡展示权益、订阅卡权益、储值卡赠券、积分商城兑换这些能力今天可能分散在不同模块但从营销平台视角看它们共同表达的是某个用户因为某个来源而拥有一项可消费或可展示的权益。这就是 Benefit / Entitlement Context 应该解决的问题。需要特别区分Benefit 不等于 Coupon。Coupon 是一种可核销凭证Benefit 是更上层的权益定义与归属模型。一个 Benefit 可以表现为会员折扣、免配送费、积分倍率也可以触发发放一张 Coupon但它不应该直接拥有 Coupon 的库存与 Claim。图 2优惠券系统核心架构——客户端经统一网关进入业务服务层MySQL、Redis、MQ、ELK、Prometheus 组成基础设施与可观测控制面。3. 目标领域设计四个上下文分别守护什么不变量上下文核心聚合必须守护的不变量不应该负责CouponTemplate / Inventory / Claim / Reservation库存不超发Claim 状态合法Reserve/Confirm/Release 幂等规则快照稳定商品活动计算、会员等级、订单总价PromotionPromotion / Rule / Scope / Schedule活动在时间、门店、商品、渠道范围内生效优先级与限购一致用户券包、用户长期权益、订单支付PricingPriceQuote / LineAdjustment同一输入 同一策略版本得到确定结果所有优惠来源可解释、可追溯修改 Coupon Claim、修改订单状态BenefitBenefitDefinition / Entitlement / GrantLog权益归属明确授予/消费幂等来源可审计Coupon 库存、Promotion 活动库存、订单结算关键设计原则不要把“所有营销相关东西”塞进一个 marketing-service。领域拆分的目的恰恰是让 Coupon、Promotion、Pricing、Benefit 之间只交换稳定事实而不是重新制造一个更大的营销单体。图 3事务边界与能力归属——Coupon / Promotion / Pricing / Benefit 分别拥有自己的数据与业务不变量跨服务事务通过本地事务、可靠事件、幂等消费与对账补偿收敛。4. 数据模型哪些表应该留在同一事务域服务拆分时最容易犯的错误是先按页面或 controller 拆服务再去被动处理跨库事务。更安全的方法恰好相反先从事务不变量出发决定数据归属。4.1 CouponInventory 与 Claim 必须留在同一强一致边界Coupon 的核心不是模板而是“库存 用户凭证”。模板可以缓存规则可以版本化但发放时的 Inventory 扣减、Claim 创建、规则快照冻结和 Outbox 事件应该处在同一个本地事务中。否则一旦先扣库存后写 Claim 失败或者先写 Claim 后库存更新失败就会把第五篇中讨论的超发和孤儿权益问题重新带回来。4.2 Pricing尽量无副作用只保存报价与解释性结果Pricing 最适合成为“读很多、写很少”的服务。它可以读取 Promotion、Benefit、Coupon 的候选事实但不应该在一次报价请求中直接修改券状态或权益余额。为了订单重放、售后解释和审计可以持久化price_quote与price_adjustment记录每一行价格由哪些来源贡献了多少优惠。4.3 Benefit定义和 Entitlement 分开BenefitDefinition 描述“是什么权益”BenefitEntitlement 描述“谁拥有这项权益”GrantLog 描述“为什么拥有”。这样会员等级、订阅卡、积分兑换、运营补偿都可以成为权益来源而不必各自发明一套领取记录。图 4目标数据模型与领域数据归属——表跟随事务不变量归属到各上下文跨域只保存稳定业务关联 ID 与历史事实不建立跨库强耦合。5. Pricing 执行流订单应该拿“报价”而不是拼接多个远程计算结果如果订单服务依次调用商品活动、会员、优惠券再自己拼总价就会把营销规则复制进订单域。正确方式是让订单只提交 PricingRequest并得到一个不可变 PriceQuote。Pricing 负责解释“为什么是这个价格”订单负责决定“是否接受这个报价并创建订单”。POST /pricing/quotes { tenantId: tenant_demo, storeId: 10001, userId: 90001, orderType: DINE_IN, items: [{lineId:L1,productId:2001,specId:3001,qty:2}], couponClaimId: 50001 } { quoteId: PQ-20261008-000001, policyVersion: pricing-v6, totalAmount: 68.00, payAmount: 51.20, adjustments: [ {sourceType:PROMOTION,sourceRefId:7001,amount:8.00}, {sourceType:BENEFIT,sourceRefId:8001,amount:2.80}, {sourceType:COUPON,sourceRefId:50001,amount:6.00} ] }这个模型还解决了一个长期问题售后、对账、打印小票和客服不再需要重新执行一次“今天的营销规则”而是可以直接读取下单时冻结的 Quote 与 Adjustment。图 5Pricing 核心执行流——先收集 Product / Promotion / Benefit / Coupon 稳定事实再统一进行冲突决策、叠加顺序、金额分摊与解释最终生成不可变 PriceQuote。6. 事务边界哪些调用可以同步哪些必须用事件业务动作推荐方式原因查询可用活动/会员权益/券规则同步 Query API Cache读路径允许短暂延迟重点是稳定契约与版本生成 PriceQuotePricing 本地事务或纯计算不修改外域状态最容易做 Shadow 与回滚领券 / 发券Coupon 本地事务 OutboxInventory Claim 必须原子一致Benefit 授予Benefit 本地事务 OutboxEntitlement 与 GrantLog 必须可审计订单使用优惠券Reserve → Order → Confirm / Release Saga订单与 Coupon 不在同一数据库不能假装存在全局事务活动生命周期推进Event Reconcile快路径保证及时性慢路径保证最终正确6.1 为什么“Pricing 不能顺手 useCoupon”报价可能被刷新、放弃、并发请求或超时重试。如果 Pricing 每算一次就修改 Claim 状态会把“价格计算”变成高副作用命令幂等和补偿复杂度会陡增。更合适的方式是Quote 只引用 Claim真正创建订单时由订单编排 Coupon Reserve支付或订单确认后 Confirm取消则 Release。图 6优惠券核销一致性流程——预扣减、订单本地落库、可靠事件、异步库存确认与结果回调组成完整闭环重要状态在本地事务中落盘跨域协同交给可靠事件与补偿。7. 什么时候该真的拆成微服务而不是只分 packageDDD 的 Bounded Context 不等于一个 Kubernetes Deployment。逻辑边界应该尽早建立物理拆分则应该满足明确的收益条件。判断维度继续模块化即可考虑物理拆服务发布节奏同一团队、同一发布窗口规则变更频繁独立发布价值高事务耦合大量本地事务必须跨模块跨域主要是 Query / Event事务边界已经稳定扩缩容资源模型相似Pricing CPU 密集、Coupon 写热点、Promotion 读多写少资源模型差异明显故障隔离故障仍可由同一服务熔断处理一个域故障会拖垮其他业务需要独立限流和容量团队所有权同一小团队维护多个团队并行演进需要清晰 API 契约数据治理同库查询仍是主要依赖已经禁止跨域表查询数据所有权明确建议先给四个上下文建立独立 package、Facade、DTO 和数据库访问边界。只有当“跨域不再偷查表”这件事做到以后物理拆服务才不会把代码耦合升级成网络耦合。8. 迁移路线为什么推荐先抽 Pricing再动 Coupon8.1 P0冻结契约和观测面给现有优惠计算接口补齐 requestId / quoteTraceId / policyVersion。记录各优惠来源的原始输入、候选结果和最终选择建立可解释日志。补齐 Coupon/Order/Benefit 关键业务不变量 SQL 和指标。8.2 P1先做“单体内 DDD”源码已经把统一优惠计算拆成多个 Engine这正是最好的切入点。先禁止 Engine 直接访问不属于自己的 Mapper把数据访问收口到 Provider / Repository再把 Coupon、Promotion、Benefit 的输入统一成只读 Fact DTO。这样即使暂时仍然部署在原服务中边界也已经建立。8.3 P2Pricing 最适合第一个独立部署Pricing 相比 Coupon 有三个天然优势一是计算结果可对比二是副作用最少三是失败时可以快速回退旧链路。因此可以先部署新 Pricing在真实请求上执行 Shadow Quote但仍使用旧结果下单当差异稳定后再灰度切流。8.4 P3 以后高一致性域最后迁移Coupon 携带大量历史 Claim、库存和生命周期状态迁移成本最高。必须在 Outbox、幂等、对账、ID 映射、双读校验都成熟以后再切写入口。Benefit 收敛和 Promotion 独立则可以根据团队组织和业务增长速度决定节奏。图 7Strangler 渐进迁移路线——P0 契约冻结 → P1 单体内模块化 → P2 Pricing 旁路验证 → P3 Coupon 再抽离 → P4 Benefit 收敛 → P5 Promotion 独立。9. 数据迁移如何做到不停机、可回滚、可证明9.1 ID 策略迁移初期不要轻易换业务 ID如果旧 Coupon Claim ID 已经被订单、日志、客服工具和消息引用迁移时最好保留原 ID如果新系统必须重建主键也应建立稳定的legacy_id → new_id映射表并且让外部契约在迁移期继续接受 legacyId。不要把 ID 映射问题留给每个下游自己解决。9.2 双写不是终点Outbox 才是增量事实来源直接在业务代码里同时写旧库和新库会制造新的分布式事务。迁移期更稳妥的方式是旧系统完成本地业务写入时同事务写 Outbox同步消费者幂等地把事件投影到新域。这样失败可以重放也能知道“哪些记录还没有迁过去”。9.3 对账必须基于业务不变量不只是 count(*)Coupon可用库存 Reserved Granted 是否守恒Claim 状态分布是否一致。Promotion同一时间点、门店、商品、渠道下的候选活动集合是否一致。Pricing同一输入的 Quote 金额、LineAdjustment、冲突结果是否一致。Benefit同一 userId 的 entitlement 数量、状态、余额/次数是否一致。图 8数据迁移链路——历史 Backfill 与增量 Outbox 分开处理新旧模型通过 Dual Read Compare 和业务不变量对账建立切流证据。10. Cutover真正的“上线条件”是什么下面这些阈值只能作为示例不能直接当作现网 SLO但它们展示了正确思路迁移必须由数据驱动而不是“看起来没报错”。Gate建议验证项示例失败动作Shadow Quote金额差异 0.01 的请求比例 0.01%冲突结果差异可解释只保留旧结果修复新 PricingCanaryP95/P99 不劣化业务不变量为 0事件积压稳定立即回退灰度租户/门店Write SwitchOutbox lag 在 SLA 内双读差异为 0DLQ 无未处理事件恢复旧写入口并重放事件Decommission至少跨过一个完整结算/营销周期无旧依赖调用保留只读快照与回滚数据窗口图 9稳定性观测指标与验收基线——Quote 差异率、领域不变量、事件健康和依赖质量共同构成发布 Gate任何一项不满足都不能继续扩大流量。11. Troubleshooting领域拆分最容易踩的六个坑症状InvestigationRoot CauseResolution / Verification新 Pricing 和旧结果经常差 1 分钱比较分摊顺序、舍入位数、按行/按单计算方式金额算法没有统一 Money Policy统一 scale/rounding建立 golden cases逐行 diff拆服务后接口数量暴涨、延迟升高查看一次 Quote 的远程调用次数把类级调用原样搬成网络调用Provider 批量化一次请求加载 Fact 快照禁止 N1 RPCCoupon 切库后偶发重复发券检查幂等键与事件 version双写/重放没有统一业务幂等键source user template bizKey 唯一约束重复事件必须 no-opBenefit 与 Coupon 互相引用形成循环梳理谁拥有 entitlement、谁拥有 claim把“权益”和“券凭证”混成同一个模型Benefit 只发 grant command/eventCoupon 返回 claimIdPromotion 独立后商品活动展示变慢看商品列表是否逐商品查活动活动查询缺少批量快照接口按 store productIds 批量获取 Promotion Facts短 TTL 缓存旧库迟迟无法下线统计仍在访问旧表的 SQL / Feign缺少 decommission gate 和 owner建立 dependency inventory旧表写权限先收回再移除读权限5 Whys 示例“为什么拆成微服务后反而更慢” → 因为 RPC 变多 → 因为照搬了原本的类调用粒度 → 因为拆的是技术层而不是业务上下文 → 因为没有先定义 Fact DTO 与批量 Query Contract → 根因没有先完成逻辑解耦就直接做物理拆分。12. Lessons Learned第一从优惠券模块走向营销平台真正的分水岭不是“功能变多”而是出现了多套不同业务不变量券库存、活动生效、价格决策、权益归属。第二Pricing 是最适合先抽离的上下文因为它可 Shadow、可对比、低副作用高一致性的 Coupon 应该最后迁移。第三Bounded Context 不等于一个服务。先禁止跨域偷查表再谈 Kubernetes Deployment。第四跨服务一致性不是靠全局事务而是靠本地事务 Outbox 幂等消费者 Saga Reconcile。第五迁移成功的证据不是“接口没报错”而是 Quote 差异、业务不变量、事件积压、双读对账和回滚演练都有量化结果。最终结论营销平台化不是“把 Coupon 服务拆成四份”而是让 Coupon、Promotion、Pricing、Benefit 分别拥有清晰的数据所有权、事务边界和稳定契约。只有逻辑边界先成立物理拆分才会降低复杂度而不是把原来的代码耦合升级成分布式耦合。13. 下一篇第七篇建议继续深入 Pricing《营销定价引擎设计活动、会员价、优惠券、买赠如何在同一 PriceQuote 中可解释地组合》。重点会把当前统一优惠计算编排器拆成可复用的 Fact → Candidate → Conflict → Allocation → Quote 五阶段模型并补充金额分摊、舍入、幂等与回放测试体系。