从提交到服务开通:交付一个可恢复、可升级的企业 Workflow 系统

图:AI 生成的场景封面,用于表现交付与运维协作;并非真实企业现场照片,也不是实验截图。
这是「企业级 Workflow 实战」专栏的第 24 篇。我们用 AcmeFlow 星河设备客户维保服务开通案例,交付一个可复现的端到端教学验收切片:提交申请,收集审批、签署和到账事实,遇到远端结果未知时停下并对账,再让新旧规则实例按各自版本运行。随文代码由一个 SQLite 业务库和一个独立的 SQLite 模拟远端库组成;它没有把 Temporal、Camunda、n8n、LangGraph 同时装进一个生产系统,也没有连接真实 ERP、企业身份平台或付款系统。
一、客户要的是开通结果,工程师交付的不能只是流程图
想象交付会上客户问一个很普通的问题:"我上午提交了合同,下午能开通吗?"产品演示通常会沿着一条亮绿色的箭头回答:提交、审批、签约、付款、开通。真正接手运营的同事还会继续追问:销售重复点了提交怎么办;付款页面显示成功,但财务还没有确认怎么办;ERP 明明建了客户,接口却超时,重试会不会再建一次;代码上线时还有几千条旧申请在跑,新增加的合规门槛会不会突然卡住它们;服务重启后谁知道该从哪里恢复?如果这些问题的答案只保存在开发者脑中,这个系统仍然是一个 Demo。
本篇把"交付"定义为两类结果同时成立。一类是业务结果:符合条件的客户最终获得 ERP 记录和维保权益;条件不齐时不会偷偷开通。另一类是工程结果:实例有稳定标识,关键事实可查,状态归属清楚,远端结果未知时有恢复路线,规则升级不重写旧实例语义,换一位维护者也能按手册定位与处置。第二类结果不会自然从一张 BPMN 图或一段异步函数里长出来,需要设计、代码、运行证据和责任分工一起落地。
前 23 篇分别攻克了局部问题。本篇不会假称把那些独立实验机械拼接成一个同时运行的巨型部署。第 19 篇的 Temporal、第 20 篇的 Camunda、第 18 篇的 n8n,以及第 22 篇的 LangGraph 是不同引擎或子流程的对照实践,不能因为文章在同一系列中就推论它们共同编排了下文脚本。这里选择仅用标准库的两库本地模型,是为了让每个读者都能验证核心业务不变量,并据此编写自己的生产集成清单。

图 1:AcmeFlow 教学切片的责任拓扑。业务库与独立模拟远端库分别保存本地状态和远端效果;箭头是设计示意,不是线上系统监控截图。可编辑版本:SVG。
二、先确定对象、规则和"谁说了算"
我们的主对象是一份客户维保开通申请。每条申请有 tenant_id、UUID application_id、人可识别的 business_key、material_version、rule_version、状态和修订号。tenant_id=xinghe-demo 是教学租户;验收脚本里的第一份申请 UUID 为 11111111-1111-4111-8111-111111111111,第二份为 22222222-2222-4222-8222-222222222222。business_key 用合同业务键表达"同一业务申请",与请求级幂等键、事件 ID、ERP 动作键不是同一个概念。业务库用 (tenant_id,business_key) 唯一约束限制重复业务申请,所以第二次用同一键提交,即使请求里换了新 UUID 和新规则版本,也应该返回原申请,而不是偷偷创建第二份流程。
状态也必须有明确含义。WAITING_FACTS 表示当前规则所需的独立事实还不齐;READY 表示这组事实达到当前实例绑定的规则要求,可以申请执行;PROVISIONING 表示已经进入对外执行阶段,远端结果可能尚未确认;ACTIVE 表示模拟 ERP 记录与权益记录都已得到确认;MANUAL 是需要人工处理的异常状态。实验另外用 erp_state=UNKNOWN 表示一次请求的远端效果尚未被本地确认。UNKNOWN 不是 FAILED:接口回执丢失时,远端可能已经提交。把二者混为一谈会直接决定系统是否错误重试和重复创建。

图 2:状态与事实分离。READY 是门禁结论,ACTIVE 必须建立在 ERP 与权益两个模拟远端事实确认之上。可编辑版本:SVG。
审批、签署和合规确认都绑定 material_version。如果客户在待事实阶段更新材料,代码提高版本,并把 approval_version、signed_version、compliance_version 清空;旧审批、旧签名和旧合规结论不会被误用。到账在简化模型里是一个 paid 标记,不随材料改版自动清空;真实财务系统必须根据订单、合同与款项关系判断是否仍然有效。这些演示字段没有保留完整凭据或撤销记录,不能照抄为唯一事实来源。规则版本在创建申请时固定:第一版要求同版审批、同版签署和到账;第二版还要求同版合规确认。这里的版本是实例的业务规则版本,不是资料版本,也不是数据库结构迁移版本。三种版本若混用,运维人员很难解释"这个客户为什么等了这么久"。
代码把两个效果保存在独立模拟远端库:erp_records(action_key,external_id) 和 rights(action_key,entitlement_id)。两张表各以动作键为主键,创建时使用 INSERT OR IGNORE,让同一动作键在这个沙盒里只产生一条记录。键由租户、申请 UUID、资料版本和动作种类拼成,ERP 与权益有不同的后缀。这样的键是业务关联与重放控制的起点,但它不自动把本地和远端变成一个原子事务。代码用两个独立 SQLite 连接、分别提交,故障窗口真实存在。SQLite 官方的原子提交说明讨论了单库与特定多文件连接条件下的原子性;本实验没有用一个连接把两库纳入同一事务,因此绝不能把两库操作宣称为一次全局提交。
这里特别要说清楚 ACTIVE 的所有权:状态写在业务库里,远端库写的是独立效果。业务库只能在确认两类效果后宣布 ACTIVE,不能因为"已发出请求"或"AI 认为可开通"就提前结束流程。第 23 篇的执行门禁讨论谁有权把建议变成命令;本篇承接的是命令之后的效果确认和恢复。演示代码中的角色仅靠字符串比对,不构成真实身份认证,后文的交付清单会把这个缺口列为生产前必须补齐的条件。
三、用两个脚本完成一次可复核的验收
目录里的 code/capstone.py 是操作入口,支持 submit、fact、update、provision、reconcile、show。code/run_acceptance.py 是验收驱动器。它每做一步都通过 subprocess.run() 启动一个新的 Python 进程,使用同一临时目录里的 business.sqlite 与 remote.sqlite 文件。新进程并不是时间旅行,也没有模拟机器断电,但足以检验关键状态不是只存在于 Python 内存中。验收脚本用断言验证结果,任何一项不符合就抛错,最后打印六个汇总字段;临时目录退出后删除,不留下客户数据。
运行方法很短:在本篇目录执行 python -X utf8 code/run_acceptance.py。-X utf8 只是为了 Windows 控制台稳定显示文本,并非业务依赖。所有功能使用 Python 标准库。实测环境是 Python 3.12.0、SQLite 3.42.0;我们复跑这条命令得到退出码零。不同 Python 版本只要支持源码所用语法与标准库接口,理论上也可运行,但本文没有逐个版本测试。正式发布前建议把自己的 Python、SQLite、操作系统版本和退出码一起写进验收记录。
先看提交去重。验收脚本第一次用 contract-001 提交 v1 申请,第二次用同一业务键、另一 UUID 和 rule=2 提交。submit() 在业务库里按 (tenant_id,business_key) 查到旧记录,返回旧 application_id 与旧 rule_version,并把 deduplicated 置为真。这个选择非常关键:一次客户端重发不应顺手把尚未完成的 v1 流程升级为 v2,更不应生成两个同名客户订单。用第二次请求带来的规则覆盖旧记录,会让"重复请求"变成危险的隐藏迁移。实验只测顺序重复提交,没有证明高并发下每个调用者都会得到优雅响应;数据库唯一约束提供底线,冲突处理还须在服务 API 中完善。
随后运营角色写审批事实,客户角色写签署事实。此时仍在 WAITING_FACTS,因为财务到账尚未写入。财务角色补入付款事实后,facts() 重新计算当前实例的规则;v1 的条件齐备,状态变成 READY。这里的角色只是 CLI 传入的 actor 文本,代码检查某事实类型对应哪个字符串,没有企业身份提供方,也没有签署和到账回调的真实性验证。它是规则演示,不是一个安全可上线的授权方案。OWASP 的授权备忘单建议默认拒绝并逐请求校验权限;商业部署应从可信认证上下文取得主体、校验租户与资源权限,而不能允许客户端随手传 --actor finance 就写入到账。
有资格开通以后,provision() 先在业务库把状态置为 PROVISIONING,审计写下 PROVISION_START。然后它按稳定动作键在模拟远端创建 ERP 记录,再创建权益记录。正常路径里,业务库先记 erp_state=DONE,最终写 rights_state=DONE 和 status=ACTIVE。代码片段中最值得观察的不是 INSERT,而是两库之间的断点:

图 3:新旧规则并行的正常路径。v1 在审批、签署、到账后可继续;v2 增加合规门槛。图是教学示意,结果需以脚本断言为准。可编辑版本:SVG。
所谓正常路径,也要避免把"按顺序调用函数"误写成"业务自然串行"。验收脚本在 v1 事实收集阶段刻意拆成多个进程,原因是企业事实通常由不同岗位、不同系统、不同时间产生。审批可能先到,签署可能后到,付款可能隔天才确认;任何一个事实抵达时都应重新计算当前实例是否具备进入下一步的条件。这个示例对事实操作作了简单角色分配和状态检查,却尚未处理事件乱序、重复通知、撤销和更正。接入真实财务或签署平台时,必须先定义事件唯一标识、签名或令牌验证、到达时间与业务生效时间的区别,并为已发布错误事实建立修正流程。
python
with db:
db.execute("UPDATE applications SET status='PROVISIONING',revision=revision+1 WHERE tenant_id=? AND application_id=?", (tenant, app))
log(db, tenant, app, "worker", "PROVISION_START", "durable intent")
create_erp(remote, erp_key)
if simulate == "erp_response_lost":
with db:
db.execute("UPDATE applications SET erp_state='UNKNOWN' WHERE tenant_id=? AND application_id=?", (tenant, app))
log(db, tenant, app, "worker", "ERP_UNKNOWN", "remote committed; response hidden")
return inspect(db, tenant, app)
这个教学注入点把"远端已经提交但回执不可见"的结果写成 UNKNOWN,并提前返回。脚本没有真实网络,也没有真的丢包;它用控制参数隐藏回执,专门模拟这一故障语义。第 07 篇重试与失败分类给出了为什么不能把未知结果当可直接重试的原理。本篇的处理是先读取远端记录再对账,不凭本地 UNKNOWN 去创造一个新业务动作。
四、正常结果、未知结果和新旧规则的同场演练
验收脚本选择 v1 实例走一次异常:先在远端库写入 ERP-9001,随后模拟响应丢失。本地可见的是 status=PROVISIONING、erp_state=UNKNOWN。脚本接着另起一个进程执行 show,断言它得到与异常结束时完全相同的状态。这样就验证了"重启后仍能看见待恢复事实",而不是只在一个进程的局部变量里保持悬而未决。请注意,这个断言没有证明进程在任意指令点被杀死时都能恢复;也没有证明数据落盘策略在突然断电时满足生产要求。它覆盖的是一个有意设置并已提交的断点。

图 4:响应丢失故障的教学时间线。远端提交成功、本地 UNKNOWN、新进程读取状态、按原键查询并补权益;图中 ERP-9001 是模拟远端记录。可编辑版本:SVG。
恢复由 reconcile() 完成。它先要求演示操作者字符串是 operator 且填写非空原因,再检查实例确实在 PROVISIONING 或 MANUAL。然后用原 ERP 动作键查询远端库;查不到则保留 MANUAL,不贸然宣布成功。查到了才用稳定权益动作键创建或复用权益记录,并把本地 ERP 与权益标记为完成,最后进入 ACTIVE。这就是"对账"与"重试一个请求"的区别:先回答先前动作是否生效,再决定剩余动作该做什么。若真正 ERP 的查询接口不支持按幂等键查询,交付方必须与业务方制定人工核对外部单号、客户号和时间窗的规则,不能把当前模拟库能力投射到真实 ERP。
验收报告中的 reconciled_v1=ACTIVE 只对这个模拟远端成立。reconcile() 当前会在远端有 ERP 记录时调用 create_rights(),代码内的唯一动作键让重复调用在本地 fixture 中无副作用;真实权益平台可能不支持同样语义,需要适配器、调用预算和人工处理条件。恢复也不能无限执行:若远端既没有确认成功,也不能可靠确认失败,应停在人工处理中,保留调查证据与操作理由。更高级的补偿或撤销策略取决于合同与业务承诺,不能靠单个脚本替客户决定。
再看规则升级。v1 申请创建时记录 rule_version=1。脚本随后发布另一份 contract-002 新申请,并指定 rule_version=2。新实例收到审批、签署、到账后仍是 WAITING_FACTS,直到合规角色对当前材料版写入合规确认才成为 READY,最终模拟 ERP 与权益成功后进入 ACTIVE。这不是改变旧实例的规则,而是让不同创建时间的实例遵循不同版本的解释。第 12 篇运行中流程的升级讨论更一般的兼容策略;本实验验证的只是业务规则快照,尚未演练真正工作流引擎的 Worker 代码版本、事件历史重放或数据库迁移。

图 5:实例级规则版本。v1 不要求合规确认,v2 在相同审批、签署、到账外增加一项条件;这是教学规则演练,不代表实际生产已经发布 v2。可编辑版本:SVG。
通过这些实例,读者应能区分四个看似相似的"版本"问题:客户修改合同是资料版本变化;业务增加合规要求是规则版本变化;服务发布新程序是代码版本变化;数据库增加字段是结构版本变化。实验用 material_version 和 rule_version 两个字段显式建模,还用 compliance_version 等事实版本关联当前材料。若要进入商业生产,每一种变化都需要相应迁移、回滚、兼容窗口和审计解释。简单地让所有老实例读取"当前最新规则",往往会让待处理客户在没有重新告知、重新审批的情况下改变交付条件。
更新后的验收脚本还创建第三份 v2 申请,先对材料第一版做合规确认,再把材料改为第二版。material_update() 清掉旧审批、签署与合规版本;即使随后补上新版审批、新版签署和到账,实例仍停在 WAITING_FACTS,直到合规人员针对第二版重新确认才到 READY。这里证明的是代码在这条受测路径上拒绝复用旧合规事实,而不是证明所有真实材料变更都需要重做付款。到底哪些事实要随材料改版失效,必须由合同、财务和合规负责人与工程团队共同定义。
五、实际输出是什么,它证明了什么
随文保存的 code/run-output.txt 是更新后的验收驱动器实跑结果。驱动器打印一行 JSON,字段按排序输出:
json
{"duplicate_request": true, "erp_rows": 4, "old_compliance_invalid_after_material_update": "WAITING_FACTS", "payment_gate": "WAITING_FACTS", "reconciled_v1": "ACTIVE", "rights_failure_exit": "MANUAL", "rights_repaired": "ACTIVE", "rights_rows": 4, "unauthorized_fact_rejected": true, "unknown_after_restart": "UNKNOWN", "v2_with_compliance": "ACTIVE", "v2_without_compliance": "WAITING_FACTS"}
首批六个核心字段说明同一业务键的顺序重复提交返回原实例;只审批和签署、缺财务事实时不开通;模拟远端已创建而回执丢失后,新进程仍看到未知状态,随后通过远端查询与权益补齐恢复到活动状态;新规则实例在缺合规事实时等待,补齐后进入活动状态。新增字段把范围扩大:材料改版后旧合规事实失效;权益故障进入 MANUAL,经受控对账恢复 ACTIVE;错误角色字符串被拒绝写入付款事实;最终模拟远端 ERP 与权益表各有四条记录。所有输出均在脚本断言后打印,退出码零是验收通过的辅助证据。unauthorized_fact_rejected=true 只证明 CLI 中不匹配的字符串被拒绝,不是企业身份认证测试;两张表各四行只对应本次四份申请,不能推论真实远端的全局唯一性。

图 6:首批六项核心验收观察点的整理图。当前验收脚本还追加材料改版、权益故障、角色字符串拒绝和远端行数断言;完整原始结果以文本为准。图是教学信息图,不是自动化测试平台截图。可编辑版本:SVG。
如果交付报告只写"测试通过",下一位维护者无法判断已经覆盖什么。我们另外给出验收报告,列出环境、命令、每项断言、证据路径及未覆盖范围;给出演示运维手册,写明如何运行、查看状态、识别未知结果、何时停止自动动作和如何记录人工对账理由。文档故意不写"生产已上线",因为脚本当前缺认证、凭据管理、真实外部接口和并发压力验证。交付可信度来自结论与证据匹配,而不是结论听起来更大。
六、故障矩阵:哪些路径会停、哪些路径能恢复
在设计故障矩阵时,先按"明确失败"和"结果未知"分开。重复提交是请求语义问题:业务键相同要返回旧申请,不能因为第二次请求带了新 UUID 就复制一个客户。缺付款是事实门禁问题:状态停在 WAITING_FACTS,操作人应去核对财务数据,而不是重跑 provision()。ERP 响应丢失是外部效果未知:业务状态保持 PROVISIONING,erp_state=UNKNOWN,需要用原动作键查询远端,再决定是否补权益。权益创建报错是部分成功:ERP 记录已经存在,状态进入 MANUAL,不能把整单当成从未发生。查不到远端 ERP 记录时,reconcile() 也停在 MANUAL,等待进一步调查,而不是凭空写成 ACTIVE。
这些故障在本篇的证据等级并不相同。顺序重复提交、缺付款、模拟 ERP 回执丢失与重启后对账、新规则缺合规事实、旧版合规失效、权益失败后的人工出口与修复、错误角色字符串被拒绝,都是 run_acceptance.py 实际断言过的。capstone.py 中还有远端查不到时转人工的分支,但标准验收脚本没有覆盖它;本文把它列为下一轮故障演练。并发重复提交、数据库崩溃、远端权限失败、两个系统之一不可用、错误回滚与审计库篡改,也都不在已验收范围内。没有这种分级,故障矩阵就会变成愿望清单。
| 场景 | 可观测状态 | 建议处置 | 本篇证据 |
|---|---|---|---|
| 同业务键重复提交 | 返回旧申请与旧规则版本 | 不创建第二条业务实例 | 脚本断言通过 |
| 审批、签署已到,付款未到 | WAITING_FACTS |
核对财务事实,禁止开通 | 脚本断言通过 |
| ERP 远端已建,回执丢失 | PROVISIONING、UNKNOWN |
按原动作键查询,确认后补权益 | 重启与对账断言通过 |
| 权益创建失败 | MANUAL |
核对 ERP,受控补权益 | 脚本断言进入人工并恢复 |
| 查询不到 ERP 记录 | MANUAL |
保留未知证据并人工调查 | 代码有分支,标准验收未覆盖 |
| v2 缺合规确认 | WAITING_FACTS |
补齐合规事实,再计算门禁 | 脚本断言通过 |
| 资料改版,旧合规确认仍在 | WAITING_FACTS |
对新资料重新审批、签署、合规确认 | 脚本断言旧版失效并补齐 |
| 错误角色字符串写付款 | CLI 非零退出 | 拒绝写入,记录真实授权缺口 | 脚本断言通过,但未接企业认证 |
| 并发、断电、真实接口超时 | 由生产设计确定 | 在预生产环境注入故障 | 本篇未验证 |
故障矩阵里的"建议处置"不是无条件执行的命令。例如 UNKNOWN 时,查询远端必须使用与原请求相同的业务动作键,并核对租户、申请、资料版本和远端单号。若查询结果模糊,操作者应保留 PROVISIONING 或转人工,不应把"没查到"直接解释成"从未创建"。权益失败时,恢复的目标是完成尚未确认的权益动作,而不是再次创建 ERP 客户。多个系统对"客户已开通"的定义也可能不同:ERP 记录存在但权益未激活时,对外页面应展示处理中或异常,而不是已开通。把这些语义写清,值班人员才能在压力下做出同一类决定。
第 10 篇跨系统补偿讨论一半完成后何时补偿、何时继续;第 16 篇监控与受控修复讨论如何让异常可见。放进本篇案例,运营人员至少应能按租户与申请 UUID 找到实例、读出资料版本、规则版本、状态、ERP 与权益结果,再沿审计记录知道谁提交、谁写事实、谁发起对账及原因。图形界面可以更友好,但这些信息不能只存在于图形界面的瞬时状态。OWASP 的日志备忘单强调应用层安全事件和日志保护;本实验的 audit 表没有时间戳、防篡改或访问控制,商业交付必须补齐。
故障矩阵还应规定动作所有者。销售负责核对原始申请是否重复;运营负责审批与异常工单;财务负责到账真实性;合规负责 v2 新条件;平台工程师负责实例状态与消息链路;ERP 业务负责人确认远端客户记录;权益负责人确认服务权益。把所有异常都指派给"开发排查"不是交接。尤其是需要人工确认的未知结果,执行人不能自己修改本地数据库把状态改为成功;应使用受控操作入口,记录外部证据、审批、操作者和原因,并保留原始状态历史。
七、规则升级与部署交接不是同一个按钮
本地脚本里 rule_version 只是提交时写入的一列。生产环境上线一个新规则还要安排顺序:先发布兼容旧版和新版数据的数据库迁移;再发布能够识别两个规则版本的服务;再开启新申请路由到 v2;观察两个版本的等待时长、异常量和人工任务积压;最后在旧实例自然结束或明确迁移之后,才考虑删除 v1 代码。直接替换旧代码并把默认值改成 v2,虽然新请求可能运行正常,却可能让未完成的旧流程在恢复时读到不认识的状态或新的必填字段。
在这个切片里,submit() 限定规则只能是 1 或 2,这是显式拒绝未知版本的保护。第二次重复业务键请求即使提出 rule=2,也返回原实例的 rule_version=1。如果客户真的要把老合同迁到新政策,那应是另一项带有审批和审计的迁移操作,而不是把重复提交当迁移。资料改版则走 material_update(),清空与原材料绑定的审批和签署,要求重新收集。标准验收脚本没有测试该命令,交接前还应给它增加回归用例,并验证改版后旧版动作键不会误作用于新版申请。
部署清单要比"跑起来"具体。至少列出环境和机密配置来源、数据库备份与恢复演练、迁移与回滚脚本、认证和授权路径、Webhook 验签、消息重复与死信处置、外部 ERP 与权益接口的幂等及查询能力、审计保留周期、告警阈值与值班联系、人工处理权限、版本路由、容量上限、灰度及回退标准。本文演示运维手册提供这些项目的可填写检查框;它不会伪造生产环境的地址、密钥或责任人。不同企业对可用性、数据驻留和审批留痕的要求不同,具体阈值应由实际业务与安全团队定稿。
同样需要交接的是读者能自己跑的代码与证据。源码、验收驱动、原始输出、六张信息图、可编辑 SVG、故障矩阵和验收报告都放在这一篇目录下。接手人无需记住作者曾在哪次对话里说过"ERP 超时先别重试";她可以根据 UNKNOWN 说明和动作键定位远端事实,再依手册进入人工对账。真正的生产手册还需要把这些步骤映射到企业实际控制台和审批系统,并在预生产演练通过后才签署交接。
八、项目导航:24 篇不是 24 个互不相干的玩具
系列的起点是第 03 篇 FastAPI 与 PostgreSQL 审批基座:它定义申请、流程实例、任务和历史。第 05 篇并发审批解决同一任务相反决定的竞争;第 06 篇幂等与 Outbox处理本地事务与事件投递边界;第 07 篇失败分类给未知结果与明确错误不同的恢复策略。第 09 篇人任务和第 14 篇Webhook 与业务事件,让审批、签署、到账这些事实有可靠的入口。第 12 篇流程升级说明版本不是改个布尔值。
到集成层,第 13 篇ERP 与 CRM 适配器把外部系统作为不可靠边界;第 16 篇观测与修复关心实例出了错以后谁看得见、谁能修。第 17 篇引擎选型提供分阶段选择依据;第 19、20 篇分别用 Temporal 和 Camunda 做独立的对照实现。最后,第 21 篇Workflow 与 AI Agent 分工、第 22 篇LangGraph 材料分析子流程以及第 23 篇执行门禁界定了 AI 可以帮助判断,却不拥有业务终态的边界。
这份导航表达的是问题和契约的继承,不是运行组件清单。第 03 篇是 PostgreSQL 加 FastAPI;本篇是两份 SQLite 文件;Temporal、Camunda、n8n、LangGraph 实验各有自己的运行条件。若要打造一个商业部署,必须选定主编排方式与数据所有权,逐个把实验中的事实入口、状态迁移、授权、Outbox、外部适配器和 AI 建议接到同一受测架构上。不能因为文章目录完整,就宣称系统集成已经完成。
九、Workflow Thinking:交付的单位是"可恢复的承诺"
我会把一个企业 Workflow 的最小交付单位定义为"对某个业务承诺的可恢复实现"。客户承诺得到服务,就必须知道承诺在什么时候成立,哪些人可以改变它,哪些外部事实还在等待,发生中断时凭什么继续。如果只把正向路径写成一串函数,系统在第一处超时就会露出原形;如果只留下很详细的业务流程图,运维仍然不知道第二次点击是重复、补偿还是新申请。状态、业务键、动作键、事实来源、规则版本与审计共同构成了这个承诺的证据。
这套实验也刻意保留了一个不完美的地方:为了让任何读者能在本机复现,我们用字符串模拟角色,用两个 SQLite 库模拟本地与远端,用 --simulate 制造回执丢失。这样做比把一套没有企业账号和 ERP 凭据的"完整生产架构"写在纸上更诚实。示例证明的是若干重要机制可运行,未证明它满足高可用、强认证、数据保护和真实远端幂等。商业交付需要在这些缺口上继续投入,并把每一次扩大结论的依据重新验收。
真正值得带走的方法是:先给出可判定的不变量,再为正常和异常路径保存可复核证据,最后把恢复操作交给明确的人与受控接口。客户在业务上看到"已开通"时,维护者应该能够追溯是哪份申请、哪版规则、哪版材料、哪些独立事实、哪个远端单号与哪次操作使它成立。没有这些信息,"自动化"只是在顺利时省掉几次点击;有了这些信息,系统才开始成为团队可以长期负责的企业 Workflow。