05 · 状态流转与并发:为什么“改一个字段”并不简单#
修改状态同时包含三个判断:你能否修改这张工单、这个状态能否变成目标状态、你看到的数据是否已经过期。只检查请求里有合法枚举值,远远不够。
1. 状态机是业务允许走的路#
allowedTransitions 明确列出:
| 当前状态 | 可以变为 | 业务含义 |
|---|---|---|
OPEN 待处理 |
IN_PROGRESS、CLOSED |
接手,或直接结束 |
IN_PROGRESS 处理中 |
OPEN、RESOLVED、CLOSED |
放回待处理、解决、关闭 |
RESOLVED 已解决 |
OPEN、CLOSED |
重新打开,或最终结束 |
CLOSED 已关闭 |
无 | 本项目中的终态 |
这也意味着 OPEN → RESOLVED 不合法,需要先进入处理中。“已解决”还可以重新打开,“已关闭”不可以。这是本项目的产品约定,不是所有工单系统都必须遵守的通用标准。
前端用规则限制下拉选项,服务端再独立检查。隐藏选项便于操作,不能阻止绕过页面的调用。数据库枚举也只知道 RESOLVED 是合法值,并不知道这条路径是否合法。
当前第四阶段增加 statusChangeIssue 和重要变更说明:解决、关闭、从已解决重新打开时必须填写原因。这个要求不是最初第一阶段的内容,但运行当前页面时必须遵守。输入 schema 给 reason 默认空字符串,服务层再结合起止状态判断是否必填:因为“需要说明吗”依赖数据库当前状态,单独检查请求形状不够。
2. 请求携带“我基于哪个版本编辑”#
状态请求包含 id、teamId、status、version、reason。其中 version 来自上一次详情响应,不是前端随意写一个递增值。
设工单版本为 0,两个页面同时打开:
| 时刻 | 页面 A | 页面 B | 数据库 |
|---|---|---|---|
| 初始 | 读到 OPEN / v0 |
读到 OPEN / v0 |
OPEN / v0 |
| A 提交 | 请求 IN_PROGRESS,携带 v0 |
仍显示旧数据 | 更新为 IN_PROGRESS / v1 |
| B 提交 | 已成功 | 请求 CLOSED,携带 v0 |
拒绝,返回 CONFLICT |
| B 刷新 | — | 读取新版本后重新判断 | 保持 IN_PROGRESS / v1 |
这叫乐观并发控制:允许大家读,但写入时检查编辑依据是否仍有效。它和“乐观更新 UI”是不同概念:一个控制写入冲突,一个控制界面何时显示预期结果。
3. 为什么先读再判断还不够#
如果流程只有“先查 version 等于 0,再按 ID 更新”,两个请求都可能在写入前读到 0。数据库执行写入的瞬间,仍要把版本放在条件里。
当前核心代码是:
// 源码节选:只保留状态与版本主线,省略提醒字段等
const updated = await tx.ticket.updateManyAndReturn({
where: { id: input.id, teamId, version: input.version },
data: {
status: input.status,
version: { increment: 1 },
},
select: ticketSelect,
});概念上相当于:
-- 概念示意:实际 SQL 与参数由 Prisma 生成
UPDATE "Ticket"
SET "status" = :next_status, "version" = "version" + 1
WHERE "id" = :id AND "teamId" = :team_id AND "version" = :old_version
RETURNING *;如果返回数组为空,说明条件已经不成立,服务抛出 CONFLICT。初次读取检查给出清楚的业务错误;最终的数据库条件更新守住检查与写入之间的竞争窗口。
虽然方法叫 updateManyAndReturn,这里有唯一工单 ID,最多更新一条。使用它的目的,是方便根据“没有匹配到记录”明确识别冲突,而不是批量修改工单。
4. 当前事务与团队锁保护什么#
当前状态修改还会执行团队行的 SELECT ... FOR UPDATE、成员检查、角色检查、工单查询、状态和版本验证、条件写入、操作记录和提醒更新。
团队锁与其他使用同一约定的团队操作协调,避免成员权限变更、分配和关闭等交错破坏规则。它会让同一团队相关写操作相互等待,锁在事务结束时释放。读请求与没有使用该锁的写路径并不会因此全部自动排队。
可以把锁理解为“相关服务端流程先拿到同一把钥匙再操作”,但它不是前端按钮的 disabled,也不是整个数据库的总开关。团队粒度锁便于当前实现保持一致,团队写入量很大时可能成为瓶颈;以后可评估更细粒度的锁与约束。
版本条件仍有价值:用户页面可能早在事务开始前就过期,即使服务端写流程已经串行,也不应该默默接受旧页面的决策。
事务则保证本次修改及相关记录一起成功或回滚。例如操作记录写入失败,状态也不能单独提交。锁负责协调竞争,版本号识别旧编辑依据,事务保证一组写入的原子性。 三者不能互相替代。
5. 权限与错误顺序也是接口行为#
当前服务按身份和团队限制操作,客户不能修改状态。常见结果包括:
| 错误 | 原因 | 页面行为 |
|---|---|---|
UNAUTHORIZED |
无有效会话 | 重新登录 |
FORBIDDEN |
非成员或角色不能修改 | 提示无权限 |
NOT_FOUND |
在允许查询范围内找不到工单 | 显示未找到 |
BAD_REQUEST |
非法路径、重复状态、缺必要说明 | 展示业务提示,修正输入 |
CONFLICT |
请求版本落后 | 读取最新详情后重新决定 |
当版本已经落后时,即使目标状态看上去合理,也应先刷新。服务端无法确定用户是否仍愿意在新事实下执行同一动作。
6. 冲突后不要悄悄替用户重试#
在 TicketDetail 的 onError 中,检测到 CONFLICT 后清空目标状态并重新请求详情。用户看到新状态后再选择,这样不会把原先的操作意图强行套到新版本上。
成功后则让操作记录、列表、详情查询失效并刷新。即使状态没有改变页面标题,列表筛选、状态标签和版本号也都依赖最新结果。
重新读取详情与重复提交修改不同:读通常可以安全重试;修改能否重试要看业务是否幂等、请求是否可能已经成功。当前 mutations.retry 为 false,不会自动覆盖冲突。
7. 练一次“旧页面修改”#
- 以团队管理员打开新工单,记录版本 v0。
- 在第二个窗口打开相同详情,保持同一团队。
- 第一个窗口把它改为处理中。
- 第二个窗口基于旧详情选择关闭,填写说明再提交。
- 如果它仍持有旧版本,应看到冲突提示,随后展示最新详情。
Query 可能在窗口聚焦时刷新,所以只开两个窗口不保证能稳定复现。若 B 已刷新,就重新建立旧状态;需要稳定验证时,使用下一章的真实数据库集成测试,它能显式复用旧版本发起第二次修改。
现在可以完整描述:前端携带所见版本 → 接口校验与权限 → 服务检查合法状态路径 → 数据库按 ID、团队、版本原子更新 → 相关记录同事务提交 → 返回新版本 → 缓存刷新;冲突则让用户重新确认意图。