Command Pipeline
Command Pipeline 是 AuraBoot 中每一次业务写操作必须流经的执行契约。它不是路由器,也不是简单的中间件链,而是一条固定、有序的阶段序列:解析元数据、强制访问控制、校验输入、执行变更、记录审计、分发副作用 —— 全部在一个事务边界内,使用同一个 principal、同一份审计、同一组事件出口完成。
这条管道也是把整个系统串起来的契约。Model 描述什么东西存在,Page 描述怎么呈现,Command 则描述什么可以变、在什么条件下可以变。系统的其他能力 —— 权限、流程、自动化、AI 工具调用 —— 最终都要落到一次命令调用上,而每一次命令都走同一条管道。
契约本身
如果没有一条统一的管道,每个模块迟早会自己长出一套对同样五件事的实现:载荷校验、权限与授权检查、事务边界、审计、事件发布。各自的实现会逐渐漂移。漂移的代价会显现成错误提示不一致、审计漏录、为后来回滚的写操作错发了事件、AI agent 调用了任何人类按钮都到不了的操作,以及一堆无法跟随平台升级的客制化补丁。
统一的管道在入口就拒绝这种漂移。这份契约对每一次业务写操作保证五条性质:
- 唯一入口。一个业务操作就是一个
cmdCode。UI 按钮、BPMN 任务、自动化规则、外部集成、AI 工具,全都派发同一个 code,走同一组阶段。 - 顺序确定。授权永远在校验之前,校验永远在变更之前,变更永远在审计之前,事件永远在事务提交之后才发。顺序不因调用方而异。
- 事务内阶段原子性。要么所有事务内阶段全部成功并与审计一起提交,要么什么都不留下。
- 基于 outbox 的副作用。外部调用和 webhook 永远不会在事务里发出,只在提交之后,从持久化记录里发出 —— 因此外部系统永远看不到一次已被回滚的写操作的"影响"。
- 声明式语义。命令的风险、幂等性、可逆性、副作用摘要都是一等元数据。它们对运维、审批策略、AI agent 都是可见的,而不是埋在 handler 代码里。
数据应用搭建工具和打包式业务套件倾向于把这些性质交给约定。AuraBoot 把它们写进管道本身,所以它们在每一个插件、每一个行业包、每一个集成里都成立。
管道阶段
管道由 20 个事务内阶段加 4 个提交后阶段组成。下图按用途分组列出。阶段名直接来自 CommandStage 中的 canonical 名称;读后端日志、trace、审计记录时,看到的就是这些名字。
Client / UI / Automation / Process / AI
|
v
+--------------------------------------------------------------+
| 解析阶段 |
| |
| 1 load 加载 CommandDefinition |
| 2 schema_validate 校验载荷形态与字段类型 |
| 3 idempotency_check 幂等键命中则直接返回缓存结果 |
+--------------------------------------------------------------+
|
v
+--------------------------------------------------------------+
| 授权阶段 |
| |
| 4 entitlement_check 插件 / feature 授权检查 |
| 5 sod_check 职责分离 (SoD) 检查 |
+--------------------------------------------------------------+
|
v
+--------------------------------------------------------------+
| 校验阶段 |
| |
| 6 state_check 状态转换守卫 |
| 7 assert 前置条件与断言 |
| 8 pre_invariant 写前业务不变量 |
| 9 cross_field_validation 跨字段依赖规则 |
+--------------------------------------------------------------+
|
v
+--------------------------------------------------------------+
| 执行阶段 |
| |
| 10 auto_set 自动填充 code / 时间戳 / 当前用户 |
| 11 field_map 映射到列并持久化 |
| 12 computed_fields 重算 SpEL 公式字段 |
| 13 change_tracking 字段级变更记录 |
| 14 handler 自定义 handler 与扩展 |
| 16 side_effect 关联记录、AGGREGATE |
| 17 roll_up 父记录汇总字段重算 |
| 18 post_action 批量子记录、后处理 |
| 19 effect 写入 outbox、写审计 |
| 20 post_invariant 写后业务不变量 |
+--------------------------------------------------------------+
|
v
=== 事务提交 ===
|
v
+--------------------------------------------------------------+
| 提交后阶段 |
| |
| 21 domain_event 进程内监听器 |
| 22 api_call 外部 API connector |
| 23 webhook 外发 webhook |
| 24 governance_snapshot 版本化模型治理快照 |
+--------------------------------------------------------------+解析阶段
解析阶段把一次请求变成一个可执行的命令实例。
1. load。 按 cmdCode 加载已发布的 CommandDefinition。未发布或未知的 code 在这里就被拒绝,后续任何工作都不会发生。
2. schema_validate。 用命令声明的 input schema 校验载荷:类型、必填、枚举、基本形态。时态规范化作为子步骤跑在这一阶段,后续阶段看到的都是已经类型化的 date / datetime 对象,而不是字符串。
3. idempotency_check。 如果请求带了幂等键并且系统里已缓存对应的结果,直接返回缓存结果,跳过后面所有阶段。客户端的乱序重试或自动化的自动重试,绝不会产生重复写入。
授权阶段
授权阶段决定当前 principal 此刻是否被允许执行这条命令。功能权限和数据权限属于更大的权限契约,详见独立文档;下面这两个阶段是命令专属的守卫。
4. entitlement_check。 校验当前租户是否对命令所属的插件和 feature 持有授权。元数据里存在但未授权给当前租户的命令在这里被挡住。
5. sod_check。 强制职责分离 (Separation of Duties)。如果当前 actor 在同一条记录、同一个模型或全局范围内已经执行过冲突的命令 —— 例如"创建采购订单"的人不能"审批采购订单" —— 这一阶段会根据策略阻断请求或记录违规。
校验阶段
校验阶段在任何状态改变之前强制业务规则。
6. state_check。 校验生命周期转换。命令带着允许的源状态列表,目标记录当前状态不在列表里就在此被拒。状态转换是一等元数据,而不是 handler 里的 if/switch。
7. assert。 执行 ASSERT 类规则:executionConfig 中声明的 preconditions、字段校验规则、以及插件贡献的 binding-rule 断言。
8. pre_invariant。 评估必须在变更之前成立的业务不变量。例如:复合唯一键约束、"有未关闭子记录的不能删除"、模型自定义的任意不变量。这一阶段失败抛出类型化校验异常,此时还没有任何写入。
9. cross_field_validation。 执行跨字段依赖规则 —— 把多个载荷字段相互比较、或与已有记录比较的规则。独立成阶段以便错误消息可以按规则名定位。
执行阶段
执行阶段在数据库事务内完成真正的变更。
10. auto_set。 注入自动生成值:业务编码、当前时间戳、当前用户、当前组织、命令声明的固定默认值。
11. field_map。 把载荷映射到数据库列,执行主操作 CREATE / UPDATE / DELETE。对于 DELETE,级联删除规则也在这里执行。同时为 Change Tracking 捕获 before 快照。
12. computed_fields。 主操作完成后重算 SpEL 公式字段。Computed field 可以依赖刚写入的行。
13. change_tracking。 把 before 快照与变更后的行进行比较,记录字段级 delta,供审计使用。
14. handler。 执行自定义命令 handler —— 实现 handler 接口的 Spring bean 以及插件 handler extension。HANDLER 类 binding rule 也在这里执行。这一阶段是插件作者放置无法通过声明式 DSL 表达的领域逻辑的地方。
16. side_effect。 执行命令上配置的 sideEffects:创建关联记录、更新关联记录、把子表行聚合到父字段。每条副作用带自己的 SpEL 条件。
17. roll_up。 如果当前模型是某个 roll-up 关系的子端,自动重算父模型的汇总字段。常见场景下无需手写 AGGREGATE 副作用。
18. post_action。 执行 postActions —— 典型用法是 CREATE_CHILDREN,批量物化一组子记录(年度计划下的月度记录、流程的默认任务列表、按模板派生的行项目)。
19. effect。 写入 outbox 事件、写入审计记录、执行 EFFECT 类 binding rule。这一阶段之后,"刚才发生了什么"的持久化证据已经存在,但外部系统还没有被告知。
20. post_invariant。 评估必须在变更之后成立的不变量,例如"库存不能为负"、"已批金额不能超过授信额度"。后置不变量违规会创建告警,但不会回滚事务 —— 它们是升级事件,不是拒绝事件。
提交后阶段
阶段 20 之后的所有事情都在事务之外执行,从已经提交的持久化记录里出发。
21. domain_event。 通过事件总线发布 CommandCompletedEvent 给进程内监听器。需要"提交之后"才执行的监听器订阅相应 phase;需要参与事务的监听器以同步方式订阅在事务边界内。
22. api_call。 调用外部 API connector。binding rule 的查询发生在事务内,实际的 HTTP 调用发生在这里,网络延迟永远不会阻塞数据库事务。
23. webhook。 向订阅者派发外发 webhook。
24. governance_snapshot。 为版本化模型捕获治理快照,审计与合规视图可以重建任意时间点的世界状态。
事务边界
事务边界是这条管道最重要的一条性质。规则简单且绝对:
阶段 1 到阶段 20 跑在同一个数据库事务里,要么全部提交,要么一个都不留。阶段 21 及之后只在事务提交成功后才会运行,而且从持久化记录 —— outbox、event store、webhook 订阅表 —— 出发,不从内存状态出发。
这就是 outbox 模式,只是被抬到了运行时层级而不是按服务实现。带来的后果:
- 外部系统永远不会收到已被回滚的写操作的事件。 如果
field_map成功但post_invariant触发了回滚,outbox 行随事务一起消失,webhook 永远不会触发。 - 审计不会有半截写入。 审计行在阶段 19 写入,与数据变更同一个事务。两者要么都可见,要么都不可见。
- 监听器自己选 phase。 同步监听器("失败就把命令一起回滚")订阅在事务内。提交后监听器("发邮件"、"更新搜索索引")订阅在提交后 phase。管道不替你做选择。
- 重试是安全的。 如果对 webhook 接收方的 HTTP 调用失败,outbox 记录还在,dispatcher 会重试。这次重试不是重新执行命令 —— 命令已经提交 —— 所以不会重跑任何校验或 state check。
这也是为什么副作用属于阶段 16-19(事务内,操作平台拥有的关联记录)而外部集成属于阶段 22-23(提交后,操作平台不拥有的系统)。把两类混在一起是范畴错误。
三种命令形态
AuraBoot 的每一条命令都属于三种形态之一。形态决定了哪些阶段会有有意义的配置;三种形态都走完整的管道。
Action
Action 改变数据。创建、更新、删除一条记录而不改变其生命周期状态,就是 Action。绝大多数"保存"按钮都是 Action。
{
"code": "crm:create_customer",
"displayName:zh-CN": "新建客户",
"modelCode": "customer",
"type": "create",
"inputFields": ["customer_name", "credit_limit"],
"autoSetFields": {
"created_by": { "strategy": "current_user" },
"customer_code": { "strategy": "auto_generate" }
},
"idempotent": true,
"reversible": false,
"cmd_risk_level": "L1",
"agent_hint": "新建一条客户记录",
"side_effect_description": "写入一行客户;无外部调用。"
}StateTransition
StateTransition 把一条记录从一个生命周期状态推进到另一个。提交草稿、审批订单、标记发票为已支付、取消发货 —— 都是 StateTransition。管道的 state_check 阶段用命令声明的源状态列表拒绝乱序调用。
{
"code": "po:submit_purchase_order",
"displayName:zh-CN": "提交采购订单",
"modelCode": "purchase_order",
"type": "state_transition",
"stateField": "po_status",
"fromStates": ["draft", "rejected"],
"toState": "pending_approval",
"preconditions": [
{
"field": "po_total_amount",
"operator": "GT",
"value": 0,
"message:zh-CN": "总金额为 0 的采购订单不能提交。"
}
],
"idempotent": false,
"reversible": true,
"cmd_risk_level": "L2",
"agent_hint": "将草稿状态的采购订单提交审批"
}FlowStep
FlowStep 是一种主要用途为推进编排流程的命令。它和其他命令一样走完整管道,但它的完成会通知流程引擎前进。FlowStep 让 BPMN 任务保持在治理契约之内 —— 用户任务不是一份任意表单数据,而是一条带权限、带审计、带风险声明的类型化命令。
{
"code": "po:approve_purchase_order",
"displayName:zh-CN": "审批采购订单",
"modelCode": "purchase_order",
"type": "state_transition",
"stateField": "po_status",
"fromStates": ["pending_approval"],
"toState": "approved",
"bpmTrigger": {
"processKey": "po_approval"
},
"idempotent": false,
"reversible": true,
"cmd_risk_level": "L2",
"agent_hint": "审批一条处于待审批状态的采购订单",
"side_effect_description": "通知申请人;推进审批流程。"
}前置条件与状态守卫
前置条件和状态守卫是写在元数据里的业务规则,不是写在 handler 代码里的。它们在管道的固定阶段被评估,并产生固定语义的错误。
precondition 的标准写法是一组结构化的 { field, operator, value } 条件,作用对象是合并后的载荷与目标记录(同名字段平铺在同一个上下文里)。每条条件携带本地化的 message:zh-CN / message:en。它在任何变更发生之前让命令失败。operator 取自 DslRegistry.PreconditionOperator 白名单:EQ / NE / GT / GE / LT / LE / IN / not_in / is_null / is_not_null / between / like / not_like / contains / starts_with / ends_with。
{
"preconditions": [
{
"field": "credit_remaining",
"operator": "GE",
"value": 0,
"message:zh-CN": "本次下单将超出客户授信额度。"
},
{
"field": "customer_status",
"operator": "IN",
"value": ["active"],
"message:zh-CN": "只有处于正常状态的客户才能下单。"
}
]
}结构化条件无法表达的跨字段算式,可改用 SpEL expression 模式。表达式运行在沙箱化的 SimpleEvaluationContext 中:同名字段以平铺变量(如 #credit_used、#amount)注入,整份载荷以 #payload 提供,不存在 #record / #context 变量;出于安全考虑,T(...) 静态方法调用、new、getClass 等都被显式拒绝。
{
"preconditions": [
{
"expression": "#credit_used + #amount <= #credit_limit",
"message:zh-CN": "本次下单将超出客户授信额度。"
}
]
}状态守卫是 stateField、fromStates、toState 这一组字段的组合。命令一旦试图作用在 fromStates 之外的状态上,就会被管道直接拒绝。对多分支转换,stateTransitionRules 按分支声明 guard 表达式:
{
"stateField": "status",
"fromStates": ["pending_approval"],
"stateTransitionRules": [
{ "guard": "#payload.amount <= 10000", "toState": "approved" },
{ "guard": "#payload.amount > 10000", "toState": "needs_director_approval" }
]
}重点在于规则是写在命令定义里而不是某个 handler 里的 switch。审插件的人只读元数据就能看出某条命令在哪些状态之间移动、在什么条件下移动。
幂等性、可逆性、风险
每条命令上的四个字段描述它在重试、撤销、暴露给自动化时如何表现。这些字段不是参考意见 —— 它们会被运行时、审批策略和 AI 工具面读到。
| 字段 | 含义 |
|---|---|
idempotent | 用同样的载荷和幂等键重试同一命令,结果一致且不会产生重复副作用。idempotency_check 阶段依赖此字段。 |
reversible | 存在后续命令能够把本命令完全撤销。供编排与 AI 回滚流使用。 |
cmd_risk_level | 取值 L0(只读)/ L1(内部写)/ L2(跨对象写)/ L3(外部系统调用)/ L4(不可逆)。 |
side_effect_description | 描述主变更之外还会发生什么的人类可读摘要。在审批 UI 和 AI agent 中被展示。 |
这些字段之所以重要,是因为命令面是共享面。同一条命令可以从 UI 按钮、自动化、BPMN 流程、AI 工具被调用。UI 可以为 L4 命令弹出确认框;自动化规则可以拒绝调用任何 cmd_risk_level >= L3 的命令;AI agent 可以被限制为只能自主执行 idempotent: true, cmd_risk_level <= L1 的命令,更高风险的必须征询人类。
如果没有这些一等元数据,每个调用方就得自己维护一份"我允许自己调的操作清单"。有了它们,命令本身声明了它的爆炸半径,任何调用方都可以用同一套策略评估。
错误语义
管道的错误是直接、命名清晰的。管道没有把未知失败转成"成功"的兜底分支,也没有静默回退。一个配置错误不会被重试吸收,而是以特定类抛出。
| 错误类 | 触发条件 | 阶段 |
|---|---|---|
CommandNotFound | cmdCode 不存在或未发布 | load |
SchemaValidationFailed | 载荷形态错、缺必填、类型错 | schema_validate |
IdempotencyConflict | 同一幂等键被用在不同载荷上 | idempotency_check |
EntitlementDenied | 当前租户对所属插件或 feature 无授权 | entitlement_check |
SodViolation | actor 在同一范围内已执行过冲突命令 | sod_check |
InvalidStateTransition | 记录当前状态不在命令的 fromStates 内 | state_check |
PreconditionFailed | precondition 表达式为 false | assert |
InvariantViolation | 前置或后置不变量为 false | pre_invariant / post_invariant |
CrossFieldValidationFailed | 跨字段规则失败 | cross_field_validation |
HandlerFailed | 自定义 handler 抛出异常 | handler |
SideEffectFailed | 必需的副作用无法执行 | side_effect |
每条错误都携带阶段名、触发的规则或字段、本地化消息。审计同时记录失败与成功 —— 失败的命令同样是一次有据可查的业务事件。管道不会"悄悄走另一条分支";命令走不下去就以特定类失败,接下来由调用方 —— UI、自动化或 agent —— 决定如何处理。
设计准则
管道对插件与应用的写法有几条明确的主张。
- 命令是唯一的写路径。 UI 按钮映射到
cmdCode,自动化派发cmdCode,BPMN 任务解析为cmdCode,AI 工具调用cmdCode。不存在绕过管道直接写库的"快捷路径"。 - 能用元数据表达的就用元数据。 前置条件、状态转换、不变量、副作用、后置动作、roll-up 全都是声明式的。只有在声明式无法表达时才写自定义 handler。
- 保持 handler 聚焦。 自定义 handler 跑在阶段 14,在授权、校验、主持久化都已完成之后。它是承接变更的位置,不是重新实现前面阶段已经做过的检查的位置。
- 副作用 vs 外部调用。 写平台拥有的记录,用
sideEffects,在事务内执行;调平台不拥有的系统,用 API connector 或 webhook,在提交后执行。 - 声明四个语义字段。 每条命令都应填写
idempotent、reversible、cmd_risk_level、side_effect_description。AI 面与审批策略都会读这些字段;省略它们的命令默认按"高风险"对待。 - 外部触发命令时带幂等键。 自动化或 webhook 接收方触发命令时传幂等键,管道会自动去重重试。
- 通过 API 测试命令。 UI 测试必要但不充分。命令的契约 —— 载荷 schema、错误类、审计条目、发出的事件 —— 是运行时契约而不是 UI 契约,要在运行时边界测它。
企业版说明。 商业发行版在同一条管道契约之上加了额外的治理层:严格授权 + Marketplace 准入、声明式 SoD 策略表达式、按风险等级在已知阶段挂起命令直到审批人响应的审批闸门、按管道阶段切分的分布式 trace span。插件与应用面对的契约不变 —— 社区版管道是契约面,企业版在其上叠加策略与可观测性。