nocobase / lifecycle-approval

Lifecycle 承载审批业务

用 @nocobase/lifecycle 实现 28 个传统审批场景:能不能做、哪里不够、审批该怎么抽象,以及接下来改什么。

2026-10-03 至 2026-10-04 代码基于 feat/record-lifecycles(PR #519),验证代码见 PR #542 9 份文档 示例:PR #542
28个审批场景,P0–P3 分级
20 ✅ · 8 ⚠️ · 0 ❌场景达成度,所有 P0 达成
328个场景用例(PR #542),其中 50 个 limitation:
772 行原生写法的四环节合同审批,业务相关只有几十行
11 + 3个库层面缺口,前 5 个反复出现
20 项改进点:库 13、审批层 5、验证 2
一句话结论:lifecycle 能做审批的执行内核,但它不是审批模块。传统审批应抽象成公共的审批层:外层环节是 lifecycle 状态,环节内用"任务 + 决策策略",同时配统一的待办和流程版本管理。

文档

按建议的阅读顺序排列。"阅读"打开渲染后的页面,文档间的链接也能直接跳转;"原文"是 Markdown 源文件。

文档里还引用了 lifecycle.md(库设计笔记)和 ../2026-09-06-v3-approval/approval-scenarios-and-design-constraints.md。这两份没有放进来,点这些链接会显示"找不到"。原文在 worklog/ 下。

主要结论

摘自 summary.md · 结论摘要。

1可行:能承载传统审批

28 个场景全部能表达、能跑通,所有 P0 要求都达成,期间没有改动库本身。同事务提交、乐观并发、请求去重、副作用重试与恢复都按预期工作。

2库只是执行内核

环节、责任人、决定、汇总规则、内容绑定、待办都是应用代码。另有 5 个库缺口在多个场景里反复出现,需要优先补齐。

3应当抽象成公共模块

两种不抽象的写法都验证过,各有代价:

  • 手写状态机:772 行里业务相关只有几十行;
  • JSON 计划 + 运行时解释:状态退化为粗粒度,计时、按钮可用性、待办查询都变差。

4两层结构,内层不是状态机

外层环节由声明生成真实状态。环节内多人用"任务集合 + 决策策略",每条任务有一个框架统一提供的小状态机。加签、转签、领取都落在任务层。

5必须有统一待办

需要任务表 + API,否则只能扫描业务表,委托和重派也无处落地。是否扩展到单人流转、人与 AI 协同,先由产品定义,技术上只保证将来可扩展。

6流程版本

  • 在途审批按提交时的版本走完;
  • 结构性改动必须升版本,由指纹检查强制;
  • 旧版本保留到没有在途实例为止;
  • 在途迁移是显式、逐条、留痕的管理操作。

28 个场景与达成度

按主题分组,点击跳到 scenario-cases.md 中的场景定义。标注的优先级取自 lifecycle-evaluation.md。

✅ 达成⚠️ 达成,但依赖应用代码绕行或只覆盖部分变体

库层面的缺口

按影响面排序,高亮的前 5 项在多个场景里反复出现。详见 lifecycle-evaluation.md,其中标注了源码位置。

#缺口影响场景为什么重要
1转换内不能在同一事务中触发或创建其他记录9 10 19 25 28父子汇总只能最终一致。这是审批抽象的前置条件:一次表态要在同一个事务里完成写任务、计票、推进环节和推进业务记录
2自转换必然刷新 statusChangedAt 并重跑 onEnter10 16 18 23 25 26转签、投票、加人都会重置超时、重发通知。需要"内部转换"
3触发器读不到记录字段,也不能加过滤条件17 18 27 28无法按订单截止时间或任务 dueAt 计时,也无法按版本过滤
4route 只能同步,且和 set 各算一次9 10 19 …同一份计算做两遍,汇总需要两次转换
5没有字段保护,版本号只随转换变化1 2 21 23 27审批中的内容可以被直接写入绕过
6onFailure 只拿到 { error: message }18 27失败类型只能靠消息前缀编码
7幂等键按运行生成;retryRun() 可能重复执行副作用1 17 23 27外部调用必须自带业务键
8requestId 只按记录匹配,不比对转换1不同操作误用同一个键时会被当成重放
9create() 不经守卫和校验1 22 25谁能创建只能在路由里控制
10available() 以空输入调用守卫,守卫先于校验执行20 23逐项操作拿不到准确的可用性
11内存存储回滚恢复整库快照;进程内派发忽略 runAfter测试并发测试可能出现假象,必须先修
新没有在进入/离开状态时、在事务内写字段的钩子原生实验每个业务都要手写约 100 行,已经出过领取人字段残留的 bug
新状态图按 from × to 展开,看不到真实路径原生实验合同审批画出 126 条边,实际只可能走 12 种
新定义没有版本,同一张表的多个定义缺少区分字段3 19版本策略需要按记录上的版本分派定义

推荐的审批抽象

外层状态机 + 任务状态机 + 策略函数。详见 summary.md · 如何抽象。

外层:环节defineApproval

表达推进到哪个审批环节。由声明生成真实的 lifecycle 状态,一个环节对应一个状态。保留了按环节超时、准确的按钮可用性、可读的历史。一轮对应一个审批实例,提交时快照计划,只生成可达的边,并做内容绑定。

内层:环节内的人任务集合 + 决策策略

表达多个审批人如何共同得出环节结论。策略是作用在任务上的纯函数。 singleall({ onReject })any / firstthreshold({ min, vetoers })claimablesequential

任务每人一次处理

所有审批共用的小状态机:待处理 / 被阻塞 / 已领取 / 挂起 / 已完成 / 已作废 / 已过期。转签、重派、加签、代理都落在这一层,外层状态不会成倍增加。

统一待办

审批层自带任务表(每人每次处理一行)和 API:

  • 查询四类:待处理、已处理、我发起的、抄送,按"责任人 + 状态"建索引;
  • 操作:领取、释放、转签、回应、批量重派。

流程版本

  • 每个版本单独注册,共用实例表,用版本字段区分;
  • 结构指纹写在锁文件里,指纹变了却没升版本,CI 就失败;
  • 还有在途实例引用的版本被删掉时,应用拒绝启动;
  • 历史不依赖旧代码。

不一刀切

  • 单人为主的简单状态流转:直接用 lifecycle;
  • 多人、多环节的传统审批:用审批层;
  • 人与 AI 协同:等产品定义。

改进路线

20 项改进按依赖关系排成 4 个阶段。详见 improvement-plan.md · 改进点汇总。

1 · 独立的小修复

库 · 可以并行
  • 内存 store 回滚只撤销本事务;派发尊重 runAfter
  • 请求号重放时比对转换名
  • create() 走守卫和校验
  • effect 结构化失败;限制 retryRun
  • can() 带输入,先校验再走守卫

2 · 审批层的前置条件

库
  • fire / create 加入调用方事务(最关键)
  • 内部转换:不刷新计时、不重跑 onEnter
  • 进入/离开状态时写字段的钩子
  • 按记录字段计时 + 触发器过滤
  • 一次算出 { to, values } 的异步钩子
  • 按状态冻结字段
  • 状态图只画可达的边;同一张表支持多个定义

3 · 审批层原型

审批层
  • 任务表 + 任务小状态机
  • 策略库
  • defineApproval:计划快照、一轮一个实例、内容绑定、退回与加签
  • 待办 API
  • 版本指纹、锁文件、启动检查、显式迁移

4 · 验证

验证
  • 用审批层重写 772 行合同审批,以及 S1、S5–S8、S11–S16、S24,对比代码量
  • 在 PostgreSQL / MySQL 上验证唯一索引和"先锁父行"
阶段 3 之前要先定:外层环节做成"生成的真实状态"还是"只存数据的计划"?summary.md 倾向前者,因为能保留按环节计时和准确的按钮可用性。improvement-plan.md 把它列为未拍板,因为后者做加签和升版更简单,建议在原型里用同一组场景对比两种方案。
Runtime 更新(补充中提出):工作流和 forge 中的流程预期能在 runtime 更新,但 summary.md 的版本方案(锁文件指纹、CI 检查、拒绝启动)建立在"流程即代码、变更即部署"的前提上。Forge 已经是一个具体需求:它的流程定义存在数据库里,按项目分配、运行时可编辑,PR #519 的评估把"数据定义 / 按记录解析定义"列为内核的第一条必须项。需要先确认哪些场景要求 runtime 更新、是否和代码定义的流程共用一套机制,再决定版本方案是否要调整。见 abstraction-notes.md。
产品问题:统一待办是否覆盖审批以外的人工任务,人与 AI 协同用什么交互形态。在产品定义之前,任务内核的接口只保证不依赖审批特有的概念。