在 PriceQuote 版本化、Golden Case、Shadow Diff 和灰度 Gate 之上,继续解决规则从哪里来、怎样表达、如何证明安全、以什么制品上线,以及发生异常时如何精确回退。本篇用一个"空门店范围被解释为全量"的脱敏案例,完整拆解 DSL、编译器、依赖图、Impact Report 与生命周期控制。
Promotion DSLStatic AnalysisImpact AnalysisRCA / Postmortem
结论先行: 营销 DSL 的价值不是让运营写表达式,而是把规则能力收敛到一个类型化、可静态分析、可编译、可回放、可审批、可灰度的受限语言中。在线 Runtime 不直接解释草稿 DSL,只执行带版本、Hash 和签名的不可变制品;每次发布必须同时携带依赖快照、影响面报告和回退目标。
案例边界与脱敏: 本文中的租户、门店、商品、活动、券、规则键和版本号均为示例值,例如 tenant_demo、store_demo_1001 和 rule_weekday_2026_09_v3。案例保留真实技术机制,不暴露任何生产标识或业务数据。
1. 背景与问题:配置中心不是规则平台
第八篇已经让 PriceQuote 可以版本化、回放和审计,但规则本身仍可能以数据库 JSON、配置中心键值或大量 if/else 存在。只要规则创作、校验、发布和执行没有统一语义,PriceQuote 只能证明"某个版本算出了这个结果",却无法保证这个版本在进入生产前已经被充分理解。
一次策略发布演练中,运营清空了门店列表,准备改选门店分组。前端保存了 storeRefs: [],JSON Schema 认为它是合法数组;序列化层随后裁剪空数组,运行时又把缺失范围解释为 ALL。原本只面向 20 家门店的规则因此覆盖全部门店。整个链路没有语法错误,真正的问题是同一个状态在不同层拥有三套语义。

图 1:空门店范围被解释为全量范围的事故链
2. 目标与约束:让规则变更成为可证明的工程事件
| 目标 | 可验证标准 | 关键约束 |
|---|---|---|
| 表达力受控 | 覆盖活动、会员价、优惠券、买赠与预算场景 | 不支持任意代码、网络、反射和无界循环 |
| 语义唯一 | 缺失、空集合、全量、零金额都有明确含义 | 禁止依赖前端或运行时默认值猜测 |
| 静态可判定 | 发布前发现类型、单位、引用、依赖和冲突问题 | 校验必须返回源码位置和稳定诊断码 |
| 执行确定 | 同一 Fact + 规则制品得到相同 Candidate 与 Trace | 时间、随机数和外部状态必须显式注入 |
| 安全发布 | Impact Gate、审批、灰度和回退形成闭环 | ACTIVE 版本不可原地修改 |
核心不变量: same(dsl, compilerVersion, dependencySnapshot) ⇒ same(canonicalIrHash, artifactHash);same(artifact, factSnapshot) ⇒ same(candidate, executionTrace)。
3. 系统架构:创作平面、证明平面、发布平面与运行平面
规则平台必须把"编辑"和"执行"隔离。Operations Console 只创建草稿;Validator / Compiler 把草稿转成不可变制品;Simulation / Impact Analysis 证明候选版本的影响;Release Control 在审批和 Gate 通过后生成发布批次;在线 Runtime 只消费被签名的 Artifact。

图 2:营销策略 DSL 治理架构
- **创作平面:**结构化表单与 DSL 双向映射,运营不需要直接编写底层表达式。
- **证明平面:**静态分析、Golden Case、历史回放和影响面分析生成机器可读证据。
- **发布平面:**审批、生效窗口、灰度范围、Gate 和回退版本被锁定在 Release Batch。
- **运行平面:**按稳定业务键路由版本,只加载已编译制品,并输出 Trace / reasonCode。
4. DSL 核心设计:JSON/YAML 是载体,类型与语义才是语言
如果只是把原有配置换成 YAML,系统并没有获得 DSL 能力。真正的 DSL 至少要定义:有哪些事实可读、有哪些操作符、每种类型如何比较、范围如何表达、Action 输出什么、冲突和预算如何声明、错误怎样定位,以及不同 Schema 版本如何兼容。

图 3:PromotionRule 的六层 DSL 结构
apiVersion: promotion/v2
kind: CouponRule
metadata:
ruleKey: rule_weekday_demo
owner: marketing-platform
scope:
mode: INCLUDE
storeRefs: [store_demo_1001, store_demo_1002]
when:
all:
- gte: [{fact: order.payableAmount}, {money: "80.00", currency: CNY}]
- between: [{fact: clock.localTime}, "10:00", "14:00"]
then:
discount: {type: AMOUNT_OFF, amount: "10.00", currency: CNY}
policy:
priority: 120
stackGroup: lunch_coupon
budgetRef: budget_demo_monthly
explain:
reasonCode: WEEKDAY_LUNCH_COUPON
范围语义必须显式: scope.mode = ALL 才表示全量;INCLUDE 和 EXCLUDE 均要求非空引用集合。缺失 mode、空列表和未知门店必须 fail closed。
5. 编译流程:AST、静态语义与 Canonical IR
在线解释 DSL 会把解析、默认值、类型转换和错误处理带到核心定价链路,既慢又难以证明。更稳妥的方式是发布前编译:Parser 保留源码位置,Schema Check 处理结构,Type Checker 处理 Money / Time / Quantity,Semantic Analyzer 解析依赖和约束,最后生成稳定 Canonical IR 与签名制品。

图 4:DSL 到不可变执行制品的编译流水线
| 阶段 | 输入与输出 | 典型诊断 |
|---|---|---|
| Parse | YAML/JSON → Source AST | E001 非法语法、E002 重复字段 |
| Schema | AST → Typed Node Skeleton | S101 未知字段、S102 不支持的 apiVersion |
| Type / Unit | 类型推断与单位归一 | T201 Money 与 Decimal 混算、T202 时区缺失 |
| Semantic | 范围、引用、依赖图、可达性 | M301 空 INCLUDE、M302 循环依赖、M303 无效预算 |
| Canonical IR | 显式默认值、稳定排序、内容 Hash | C401 非确定字段、C402 不稳定集合 |
| Artifact | 索引 + Predicate + Action + 签名 | A501 制品过大、A502 成本超限 |
CompileResult compile(SourceDsl source) {
Ast ast = parser.parseWithLocation(source);
TypedAst typed = schemaAndTypeChecker.check(ast);
DependencyGraph graph = semanticAnalyzer.resolve(typed);
requireAcyclic(graph);
CanonicalIr ir = canonicalizer.lower(typed, graph.snapshot());
require(costEstimator.estimate(ir).withinLimit());
return artifactSigner.sign(artifactBuilder.build(ir));
}
6. 数据模型与 ERD:定义、版本、制品和发布批次不能混在一张表
rule_definition 表示稳定业务身份,rule_version 表示一次不可变内容,rule_artifact 表示某编译器生成的可执行制品。Simulation、Approval 和 Release Batch 都引用 ruleVersionId,但不回写 DSL 内容;Execution Trace 关联 Quote 与实际执行的规则版本。

图 5:规则生命周期数据模型与表关联关系
| 表 | 主键 / 业务键 | 职责与关系 |
|---|---|---|
| rule_definition | rule_id;rule_key 唯一 | 稳定规则身份;与 rule_version 为 1:N |
| rule_version | rule_version_id;rule_id + version_no | 不可变 DSL、Schema 版本、内容 Hash 与生效窗口 |
| rule_artifact | artifact_id;rule_version_id + compiler_version | Canonical IR、索引、签名与 artifactHash |
| rule_dependency | dependency_id | 引用预算、券模板、商品组、门店组及依赖 Hash |
| simulation_run | run_id;dataset_version | 回放输入、Impact Summary、Diff 与结论 |
| approval_record / release_batch | approval_id / batch_id | 审批证据、版本集合、灰度计划和回退目标 |
| execution_trace | trace_id;quote_id + rule_version_id | 命中、淘汰、耗时、reasonCode 与实际制品 |
6.1 ID 映射与写入顺序
ruleKey是稳定业务键,ruleVersionId是不可变版本主键,artifactId是编译产物主键。- 保存 Draft 后先生成 ruleVersion;静态校验和编译通过后写 Artifact / Dependency Snapshot。
- Simulation Run 完成并冻结 Impact Report 后才能创建 Approval;审批通过后创建 Release Batch。
- Release Batch、版本集合、灰度键和 rollbackBatchId 应在同一事务中锁定,并通过 Outbox 发布。
7. 静态校验:从字段合法走向业务可执行
JSON Schema 只能证明结构像一条规则,不能证明它在业务上可执行。语义校验必须把多字段关系、不变量、引用对象和依赖图一起检查,并给出稳定诊断码、JSON Pointer、源码行列和修复建议。
| 类别 | 校验示例 | 失败策略 |
|---|---|---|
| 范围 | INCLUDE / EXCLUDE 必须非空;ALL 不允许携带 refs | 阻断编译 |
| 类型与单位 | Money 比较必须币种一致;Duration 不能与 Time 直接比较 | 阻断编译 |
| 引用完整性 | 门店组、券模板、预算和商品组必须存在且可发布 | 阻断发布并返回 dependencyRef |
| 依赖图 | 禁止循环依赖、跨租户引用和未固定版本引用 | 阻断编译 |
| 可达性 | 永真、永假、互相矛盾或被更高优先级完全遮蔽 | 警告或阻断,按风险分级 |
| 复杂度 | AST 深度、集合大小、候选数和预计执行成本 | 超过配额则拒绝制品 |
8. 影响面分析:发布前必须回答'会影响谁、多少钱、为什么'
Impact Analysis 不能只给出一个 Diff Rate。报告必须同时覆盖人群、规则重叠、金额与预算、PriceQuote 差异、运行成本和上下游兼容性;每个百分比都应带分母、采样窗口、数据集版本和可复现 caseId。

图 7:规则影响面分析的六个维度
| Gate | 示例阈值 | 阻断原因 |
|---|---|---|
| Scope Gate | 新增覆盖门店 ≤ 审批声明;命中订单增幅 < 5% | 防止范围意外放大 |
| Money Gate | P0 = 0;P1 < 0.01%;单笔优惠不超过上限 | 防止价格与资金风险 |
| Budget Gate | 日预算 P95 消耗预测不超过 85% | 保留峰值缓冲 |
| Conflict Gate | 新增高优先级遮蔽规则必须人工确认 | 避免旧规则静默失效 |
| Performance Gate | P95 增幅 < 10%;候选数与 AST 成本不超配额 | 保护在线链路 |
| Compatibility Gate | Fact / Quote Schema 与 reasonCode 均向前兼容 | 保护下游订单、退款与客服 |
9. 发布流程:把校验、模拟、审批和灰度串成不可跳过的状态机
发布不是把 status 改成 ACTIVE。候选版本先编译,再模拟,再审批,最后由 Release Control 锁定 ruleVersionSet、artifactHash、dependencyHash、灰度计划和 rollbackBatchId。任一证据过期,例如门店组或预算版本变化,都必须重新模拟。

图 6:规则发布 Sequence Diagram

图 9:规则版本生命周期状态机
**版本不可变:**ACTIVE 规则禁止原地修改。紧急停用只改变路由状态,不删除版本或制品;修复必须创建新 ruleVersionId,并重新经过静态校验、影响分析和审批。
10. 运行时执行:先索引缩小候选,再执行受限 Predicate
在线 Runtime 不解析 YAML,也不访问配置中心。Version Router 先锁定版本集合;索引根据租户、门店、渠道和时段预筛;类型化 Predicate 只读 Fact Snapshot;Action 仅产生 Candidate,由统一 Conflict / Allocation 阶段决定最终 PriceQuote。

图 8:编译制品的在线执行与证据输出路径
- 禁止网络、文件、反射、动态类加载、任意脚本和无界循环。
- 只允许白名单函数,例如金额比较、集合包含、时间窗口和确定性字符串匹配。
- 限制 AST 深度、集合长度、候选数、执行步数、内存与单规则耗时。
- 超限、未知 Fact、缺失依赖和签名失败全部 fail closed,并记录稳定 reasonCode。
- Trace 至少包含 ruleVersionId、artifactHash、输入 factHash、结果、淘汰原因和耗时。
11. 依赖图与一致性:规则引用必须固定到可验证快照
DSL 常会引用门店组、商品组、券模板、预算和会员分群。如果运行时只保存一个逻辑名称,引用目标被修改后,同一规则版本会产生不同结果。编译时必须解析依赖并保存 dependencyHash;发布前再次核验,运行时按快照或不可变版本读取。
RuleArtifactIdentity {
ruleVersionId: "rv_demo_0009",
dslSchemaVersion: "promotion/v2",
compilerVersion: "dsl-compiler-3.4.1",
canonicalIrHash: "sha256:...",
dependencies: [
{type: "STORE_GROUP", ref: "sg_demo_01", version: "7", hash: "sha256:..."},
{type: "BUDGET", ref: "budget_demo_monthly", version: "12", hash: "sha256:..."}
],
artifactHash: "sha256:...",
signatureKeyVersion: "sig-key-05"
}
12. Troubleshooting:空门店范围意外覆盖全量的完整排查
Symptoms
规则 rule_weekday_demo 灰度后,非目标门店也出现优惠 Candidate;Shadow Diff 的命中订单量比 Impact Report 高 34 倍,但单笔折扣金额和金额不变量均正常,因此常规金额告警没有触发。
Investigation
- 按 ruleVersionId 查询 Execution Trace,确认异常门店使用了同一 artifactHash,排除版本路由漂移。
- 对比 Draft、Canonical IR 和 Artifact,发现 Draft 中存在
storeRefs: [],IR 中 scope 字段完全缺失。 - 检查序列化日志,确认空数组被通用
NON_EMPTY策略裁剪。 - 检查 Runtime 默认逻辑,发现缺失 scope 为兼容旧配置被解释为 ALL。
- 重放 Impact Dataset,发现模拟器把空数组解释为 NONE,与 Runtime 语义不一致。
Root Cause
直接根因是空数组被裁剪后触发 Runtime 的 ALL 默认值;系统性根因是规则范围没有显式代数模型,Draft、Simulator、Compiler 和 Runtime 各自推断缺失值语义。发布流程又只校验金额差异,没有把作用域扩张作为独立 Gate。

图 10:空门店范围变成全量范围的 5 Whys
Resolution
- 引入
scope.mode = ALL | INCLUDE | EXCLUDE,INCLUDE / EXCLUDE 的 refs 必须非空。 - Canonical IR 展开所有语义默认值,禁止序列化层裁剪有业务含义的空值。
- Simulator 与 Runtime 共享同一编译制品,不再分别解释 Draft DSL。
- 新增 Scope Gate:覆盖门店、命中订单和新增人群超过审批声明即阻断。
- 把事故样本加入 Golden Case,并对历史规则执行静态迁移扫描。
Verification
修复后,对 3,216 个存量规则版本执行迁移检查,识别出 17 个范围语义不明确的版本并阻断自动迁移;对 28,640 个历史订单回放,Simulator 与 Runtime 的 artifactHash 和命中集合一致;连续 48 小时 Shadow 中 Scope P0/P1 为 0。
Lessons Learned
缺失、空集合和全量不是三个可互换的表示,而是三个业务状态。任何依赖默认值的规则语言都会在多实现、多版本或序列化边界上产生歧义;语义必须在 DSL 类型系统里显式封闭。
13. 为什么原方案会失败,最终方案为什么有效
| 原方案 | 失败机制 | 最终方案为何有效 |
|---|---|---|
| JSON Schema 校验 | 只能检查结构,无法表达多字段语义和业务不变量 | 类型检查 + Semantic Analyzer 同时验证范围、引用、依赖与成本 |
| 在线解释配置 | 不同服务复制默认值与解析逻辑,结果容易漂移 | 发布前生成唯一 Canonical IR,Simulator 与 Runtime 共用制品 |
| 逻辑名称引用依赖 | 门店组、预算或模板修改后历史版本不可复现 | Dependency Snapshot + Hash 固定引用语义 |
| 只看金额 Diff | 作用域放大但单笔金额正常时无法发现 | Scope、Budget、Conflict、Performance 多维 Gate |
| ACTIVE 原地修改 | 无法审计、回放和安全回退 | 版本只追加,路由切换表达发布与回退 |
14. 验证方案:编译器、规则库、回放与生产证据四层测试
- **Compiler Unit:**Parser、类型、单位、默认值、Canonicalization 和诊断位置测试。
- **Rule Property:**随机生成范围、金额、时间和集合,验证确定性、单调性与复杂度上限。
- **Golden / Migration:**覆盖历史事故、Schema 升级、依赖版本和旧规则兼容。
- **Shadow / Canary:**比较命中集合、Candidate、PriceQuote、预算消耗、耗时和 reasonCode。
Invariant 1: INCLUDE / EXCLUDE => refs is non-empty
Invariant 2: missing scope.mode is always invalid
Invariant 3: canonicalize(parse(source)) is deterministic
Invariant 4: artifactHash binds compilerVersion and dependencyHash
Invariant 5: Simulator and Runtime execute the same artifact
Invariant 6: ACTIVE version content is immutable
Invariant 7: every execution result has ruleVersionId and reasonCode
15. 方案取舍
| 决策 | 收益 | 代价与控制 |
|---|---|---|
| 受限 DSL 而非通用脚本 | 可静态分析、可估算成本、攻击面小 | 表达力受限;通过白名单算子和版本化扩展 |
| 发布前编译 | 在线低延迟,Simulator / Runtime 语义一致 | 编译链路更复杂;保存诊断、IR 与制品证据 |
| 依赖快照 | 历史可复现,发布影响明确 | 存储与治理成本增加;使用 Hash、引用计数和冷热分层 |
| 多维 Impact Gate | 发现作用域、预算和兼容性风险 | 回放成本增加;分层采样与增量数据集 |
| 版本只追加 | 审计、灰度与回退简单可靠 | 版本数量增长;生命周期与保留期治理 |
16. Lessons Learned
- **第一,**配置格式不等于 DSL;语言必须拥有类型、语义、诊断、版本和执行边界。
- **第二,**缺失、空集合、零值和全量必须显式建模,不能交给序列化或默认值决定。
- **第三,**Simulator 与 Runtime 必须执行同一个 Artifact,否则预览正确也无法证明生产正确。
- **第四,**规则发布风险不只有金额,覆盖人群、规则重叠、预算、性能和兼容性同样需要 Gate。
- **第五,**依赖也是版本的一部分;规则内容不变但引用对象变化,结果仍可能变化。
- **第六,**版本不可变让审计和回退变简单,紧急操作也应切换路由而不是覆盖历史。
17. 后续优化
- 建设可视化 AST / Dependency Graph,让运营和研发看到规则如何被编译。
- 为诊断码建立知识库,提供可执行修复建议和 IDE 式即时反馈。
- 引入规则等价性与可达性分析,识别永真、永假、遮蔽和冗余版本。
- 建立存量规则迁移器:旧 Schema → 新 AST → Canonical IR,并保留迁移 Diff。
- 把高风险 Impact 样本自动转成脱敏 Golden Case,经审批后进入长期回归集。
- 对 Artifact 加入 SBOM 式元数据:编译器、函数库、Schema、依赖和签名密钥版本。
18. 总结
规则平台的成熟度,不取决于它能配置多少种优惠,而取决于每条规则能否被明确表达、静态理解、确定编译、量化影响、安全发布和完整回放。Promotion DSL 把业务意图变成受控语言,Compiler 把语言变成不可变制品,Impact Report 把变化变成证据,Lifecycle 与 Gate 则把证据变成发布约束。
**最终结论:**运营配置必须先成为可证明的 Rule Artifact,才能成为线上执行逻辑。没有类型系统、依赖快照、影响面分析和不可变版本的 DSL,只是另一种更难排查的配置文件。
19. 下一篇
第十篇建议继续进入权益履约与资金一致性:《营销权益执行与预算控制:预占、核销、释放、退款回补与分布式一致性》。重点讨论 Benefit Ledger、Budget Reservation、幂等键、状态机、Outbox / Inbox、补偿事务、超卖防护和财务对账。