从屎山到防线:14 行状态迁移表,给任务系统装上防瞎跳闸门

7 阅读6分钟

先说结论:我给自己的任务系统加了一道闸门,从此 pending 不能一步跳到 done。

听起来像废话。但在这之前,它真的能跳过去——而且我打了 22 条接口测试,全绿。

一、事故现场:一条本来不该存在的路径

系统很简单,一张 tasks 表,状态五个值:pending / running / done / failed / dropped。

校验长这样:

js

const STATUS = new Set(['pending','running','done','failed','dropped']);
if (!STATUS.has(status)) throw new ApiError(400, '状态不合法');

看着挺严谨。它校验的是这个词合不合法,从来不问从这儿能不能走到那儿。

于是 PATCH /tasks/12 {"status":"done"} 一个直接干到终点——没经过 running,没有开始记录,没有耗时,事件表上只有「创建」和「完成」两条,中间那段凭空消失。审计的时候你根本说不清这活儿是谁干的、干了多久。

更麻烦的是这漏洞不出声。它不会报错,不会弄坏数据,只会在某天你翻事件表时发现:一半的任务都是凭空完成的。

纸糊的防线就是这个特征:只要没人手贱,它永远不会被戳破。

二、别加检查,改结构

第一反应是继续加 if:if (from === 'pending' && to === 'done') throw。

这么写能挡住这一次,下次加个「审核驳回」,路径从 5 条变成 9 条,if 会变成一坨谁也不敢碰的东西。校验和路径写在一起,每加一个状态就要重新读一遍全部逻辑——这是屎山的标准配方。

真正该改的是结构:把「允许怎么走」抽成一张表,代码只负责查表。

我正好在读 langgraph 的源码,就顺手偷了它的三个词。

三、从 langgraph 偷三个词(只读骨架,不抄代码)

用 git clone --filter=blob:none --no-checkout --depth 1 只拉了文件树,七百多个路径,一个源文件都没落盘;然后按需 git show 读了五个文件(graph/state.py、graph/_branch.py、channels/last_value.py、channels/binop.py、pregel/_algo.py)。

它教我的三件事:

  1. 状态只经通道改。每个字段是一个 channel,写入要过 reducer。这样「谁能改」「改了之后是什么」是集中定义的,不是散落在各处的赋值。
  2. 下一步由「图」定。节点之间连静态边,分叉处挂一个路由函数,返回的是下一个节点的名字或者 END。想加一条路,是在图上加一条边,不是在一个巨型 if 里再塞一个分支。
  3. 每步写进什么都有记录。执行器把「写哪些通道」变成显式的写入集合,而不是靠副作用。

我没搬它的执行器(Pregel 那一套对我们这个单进程服务太重),但把它的一句话拿走了:

状态是数据,流转是图,代码只是执行器。

四、落地:真正的改动只有一张表

先写了个 188 行的通用件 state-machine.mjs(通道 + 节点 + 边 + branch + maxSteps 死循环保护 + toMermaid),跑通五个用例:一次通过、驳回返工、maxSteps 拦死循环、非法流转被拒、未声明通道报错。

但落到 cc2 上,我没有把这个通用件搬进去。理由很直接:cc2 只有一张表、五个状态、一条主流程,上一整套图执行器等于给自行车装涡轮。

真正进代码的,是这张表:

js

// src/domain/taskFlow.js
export const TRANSITIONS = {
  pending: ['running', 'dropped'],
  running: ['done', 'failed', 'dropped'],
  failed:  ['running', 'dropped'],
  done:    [],
  dropped: [],
};

export function assertTransition(from, to) {
  const ok = TRANSITIONS[from]?.includes(to);
  if (!ok) throw new IllegalTransitionError(from, to, TRANSITIONS[from] ?? []);
}

加上服务层十几行接线:

js

// src/services/tasks.js
assertTransition(cur.status, status);   // 先过闸门
// 再执行 UPDATE … SET status=?, version = version + 1

就这样。53 行的表和守卫,替代了原来那圈「词合法就放行」。

那个 188 行的通用件留着当工具,将来真需要「多步审批流、并行分支、断点续跑」时再用——现在上它属于过度设计,我认。

五、证据:那个红色的 400

测试分两轮。第一轮跑接口测试:24 条全过,原来的「pending→done 合法」那条断言被我改成「必须 400」,另外新增了合法链路与 version 自增的断言。

第二轮跑真机——临时起一个实例、独立数据库,专门验一条本来能过的非法路径:

代码块

PATCH /api/v1/tasks/1  {"status":"done"}
→ HTTP 400
  {"code":"invalid_transition",
   "message":"非法流转 pending -> done;pending 只允许 → running / dropped"}

同一条数据,走合法路径:

代码块

PATCH … {"status":"running"}  → 200,version=1
PATCH … {"status":"done"}     → 200,version=2
PATCH … {"status":"running"}  → 400「终态,不可再流转」

事件表也跟着变厚了:

代码块

#2 task.status  {"from":"pending","to":"running","version":1}
#3 task.status  {"from":"running","to":"done","version":2}

以前事件只记 from → to,现在多一个 version。这个字段是我顺手加的,理由在下一节。

改完我还做了一件事:重启线上服务,再验一遍。因为「代码改了」和「跑着的进程改了」是两件事——我在自己的日志里踩过这个坑,spawn 时 stdio: 'ignore',日志里永远看不到新代码的输出,只能靠行为差异判断。这次我直接在活进程上打了一枪 400,才算数。

六、加需求变便宜了

最直观的对照:现在要加一个「审核驳回」(done 可以被打回 running),改动是——

  • 以前:服务端 services/tasks.js 的 if、db/index.js 的 CHECK 约束、事件写法、前端 useTasks.js 的列定义、TaskBoard.vue 的列、TaskDetail.vue 的按钮,六个文件。
  • 现在:服务端一张表加一条边(done: ['running']),CHECK 约束改一行。

六个文件变一处。这就是「把校验从流程里抽出来」的全部意义——不是少写几行,是下次改的时候你还敢改。

七、我留的两个 TODO,和不做的理由

一、version 只增不比对。  现在它是「记录版本」,不是「锁」。真出现多人/多进程并发时,得改成 UPDATE … WHERE id = ? AND version = ?,影响行数为 0 就返 409。我在代码里钉了注释写清这件事——不是忘了,是单机单用户当下不值得。

二、签字流不做。  有过一个想法:每次流转带上 actor 和 reason,谁改的、为什么改,都必须写。听着很对,但会把接口变重、前端每个按钮都要弹框,而我现在只有自己一个用户。见好就收,是这次克制下来的部分。

八、边界:AI 写得快,防呆得人定

这套东西是我和 AI 一起做的,但分工很清楚。

AI 能在二十分钟里给你写出一个带通道、带分支、带死循环保护的状态机——它甚至能告诉你 langgraph 是怎么做的。但它不会主动说:「你这个系统本来就不该有 pending → done 这条路。」

它不知道你的业务里「完成」意味着什么:意味着有人真的开始过、花过时间、有产出可查。这条判断只能由人来下,因为它是关于「什么算数」的定义,不是关于代码的知识。

所以我的结论是:

AI 让写代码变快了,但防呆的边界必须由人的架构思维来划。它可以帮你把闸门造得很漂亮,但「哪儿该有闸门」这件事,它没有立场。

那天写完这条闸门,我在日记里写:以前这道口子开着,靠的是「用户不会乱点」;现在门卫站在那儿,谁在哪儿拐弯它都要看一眼。

这不是什么高深架构。就是承认一件事——能走通的路,早晚会有人走。