TTeamDesk 学习手册
本章目录 展开导航 ↓

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。数据库执行写入的瞬间,仍要把版本放在条件里。

当前核心代码是:

ts
// 源码节选:只保留状态与版本主线,省略提醒字段等
const updated = await tx.ticket.updateManyAndReturn({
  where: { id: input.id, teamId, version: input.version },
  data: {
    status: input.status,
    version: { increment: 1 },
  },
  select: ticketSelect,
});

概念上相当于:

sql
-- 概念示意:实际 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. 练一次“旧页面修改”#

  1. 以团队管理员打开新工单,记录版本 v0。
  2. 在第二个窗口打开相同详情,保持同一团队。
  3. 第一个窗口把它改为处理中。
  4. 第二个窗口基于旧详情选择关闭,填写说明再提交。
  5. 如果它仍持有旧版本,应看到冲突提示,随后展示最新详情。

Query 可能在窗口聚焦时刷新,所以只开两个窗口不保证能稳定复现。若 B 已刷新,就重新建立旧状态;需要稳定验证时,使用下一章的真实数据库集成测试,它能显式复用旧版本发起第二次修改。

现在可以完整描述:前端携带所见版本 → 接口校验与权限 → 服务检查合法状态路径 → 数据库按 ID、团队、版本原子更新 → 相关记录同事务提交 → 返回新版本 → 缓存刷新;冲突则让用户重新确认意图。

把知识变成自己的理解

完成本章练习,再继续下一站。

TEAMDESK HANDBOOK · 从一张工单,走进全栈开发

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。