第 10 / 21 篇

第9章 审批必须绑定执行身份与最终参数

聊天里的“同意”不能直接作为批准凭据

用户对“退款十二元”说了同意,Agent 后来把金额改成一千二百元,工具仍然拿同一段对话证明“已经获批”。问题不在用户没有看仔细,而在审批系统没有定义究竟批准了哪一个对象和哪一组参数。审批如果只关联任务 ID 或工具名,就很容易被参数漂移、重试和上下文混淆绕过。

审批应绑定业务动作的规范化表示、责任主体、执行程序、租户、授权版本和有效期。执行时重新计算规范化摘要并逐项比较。若参数改变、主体改变、actor 改变、用户被撤权或审批过期,都需要重新批准。审批人看到的内容必须与摘要覆盖的最终内容一致,不能展示一个简短自然语言总结,却执行另一份隐藏 JSON。

本书 Operation 包含 tenant、kind、id、action、amount、note 和 approval。摘要计算先清空 approval 字段,再对固定 Go 结构进行 JSON 编码和 SHA-256。金额使用整数分,避免浮点数等价问题。说明文本也进入摘要,因此批准后改变说明会失效。这种规范化适合本实验固定结构,不是通用跨语言 JSON canonicalization 标准;多语言系统应采用明确、版本化的规范化契约。

审批人也有身份

实验审批端点要求额外的开发审批密钥,并验证业务执行凭据,模拟“受控审批渠道提交一份代表当前主体执行的批准”。它没有真正的审批人登录、岗位职责分离和人机界面,也没有多级审批。公开开发密钥不能证明一个真实人点击过按钮。生产系统应让审批人通过成熟身份认证,并授权其批准特定租户、动作与金额,必要时禁止自批。

这是一个重要边界:本实验确实验证审批绑定与消费,但没有证明真人意图。把这两者分开,才能防止团队拿一个自动生成的 approval ID 当作“已经有人审核”。高风险产品还应显示订单关键属性、货币、金额、影响范围和不可逆性;用户看到的摘要应固定下来,并与后端审批记录关联。

执行时必须再次判断

审批不是业务状态快照的永久豁免。订单在批准后可能已退款,工单可能已关闭,用户可能已离职。执行服务先检查当前身份状态,再校验对象状态与审批绑定,并在同一事务中消费审批、修改对象和写入审计。不能让“审批通过”绕过最新业务条件。

失败案例是批准记录只保存 approved=true。Agent 把同一个 approval ID 用于另一笔订单,服务只查看该布尔值就放行。另一个案例是审批只绑定参数,不绑定主体,导致同租户其他客服可以借用。TestApprovalBindingsAndReplay 分别改变金额、说明、主体和 actor,证明这些变化都会拒绝;合法参数随后仍能成功,说明失败不是审批被意外提前消费。

实验与设计练习

运行 go test -race -run 'TestApprovalBindingsAndReplay|TestApprovalExpiryRevocationAndConcurrency' -v ./lab。观察审批过期、撤权后换新凭据仍无法复活旧审批,以及并发执行只成功一次。测试直接操作内部时钟字段只为稳定制造过期前提,HTTP 执行仍走完整验签与业务校验。

练习:为你的“批量关闭一百张工单”设计审批摘要。必须说明对象集合如何排序、重复 ID 如何处理、单个对象状态改变怎么办、允许部分成功还是全有全无,以及审批界面如何避免只显示数量却隐藏对象变化。若批量计划被重新生成,原审批是否还能使用,应由摘要规则明确决定。

审批记录需要版本和展示证据

摘要算法一旦改变,旧审批应如何解释必须明确。可以在审批记录中保存 schema_version、规范化版本、展示模板版本和最终参数摘要。执行器只接受支持的版本,迁移时保留旧规范化逻辑或让旧审批失效,不能用新代码重新解释旧的批准内容。

展示证据不一定意味着保存整张截图。通常可以保存用户看到的结构化关键字段、模板版本、语言、货币与对象标签,并让这些信息与摘要关联。关键是事故后能还原“批准了什么”,同时避免过度保存客户隐私。若展示中把一千二百分错误格式化成一千二百元,密码学摘要正确也无法证明用户理解了真实金额。

审批撤回也应成为独立动作。撤回与执行竞争时,必须定义哪个提交先取得权威结果;不能让界面显示撤回成功,后台却在不明确时序下继续执行。数据库中的条件状态迁移可以表达 approved、consumed、revoked、expired 等互斥终态,本书用 Used 和 Expires 展示其中最小部分。