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

20 · 状态说明与事务:一次解决工单的成功和失败#

延续上一章:这张“客户无法登录”工单已经分配给小陈,收到客户补充,进入处理中,当前为 v3。小陈修复问题,准备改成已解决,并说明“已重置登录配置,请重新登录验证”。

我们沿这一次点击走完页面、接口、服务和数据库,再分别分析缺说明、旧版本、写记录失败和响应丢失。读完应该能说明:为什么有的失败根本没发请求,有的失败没有写库,有的网络失败却可能已经提交。

1. 先明确允许走的路,而不是只判断枚举#

allowedTransitions 定义业务路径:

当前状态 可以变成 重要说明
OPEN IN_PROGRESS、CLOSED 不能直接解决,关闭需说明
IN_PROGRESS OPEN、RESOLVED、CLOSED 解决或关闭需说明
RESOLVED OPEN、CLOSED 重新打开或关闭需说明
CLOSED 无 本项目终态

目标 RESOLVED 是合法枚举,并不代表 OPEN → RESOLVED 是合法路径。状态集合是字段可能取什么值,状态机表达当前业务允许怎么走。

同状态被拒绝,这与“同负责人分配返回成功”不同:两个动作有不同合同,不要把所有重复操作统一处理。

2. 页面怎样收集这次决定#

打开 workspace.tsx 的 TicketDetail,按以下顺序找:nextStatus / reason → “更新状态”卡片 → update.mutate → mutation 回调。

页面以服务器返回的 ticket.status 生成目标选项;客户不显示这张操作卡片。说明 placeholder 与按钮禁用依赖 statusNeedsReason,点击时再执行 statusChangeIssue。

tsx
// 源码节选:TicketDetail 的提交参数
update.mutate({
  id,
  teamId: team.id,
  status: nextStatus,
  version: ticket.version,
  reason,
});

这个请求表达“我基于看到的 v3,想把它变成已解决,并附上说明”,没有提供可信的 fromStatus、actorName 或 actorRole。

只有界面禁用还不够:用户可直接访问接口,页面也可能陈旧。服务仍独立校验全部边界。

3. schema 与状态函数各验证什么#

共享 schema 的关键部分:

ts
// 源码节选:src/shared/ticket.ts
reason: z.string().trim().max(1000, '操作说明最多1000字').default(''),
version: z.number().int().min(0),

说明在接口边界先 trim,超长被拒绝;缺省时是空字符串。并没有写成所有操作 .min(1),因为回到待处理等某些路径允许不填说明。

在服务读到当前状态后,再调用:

ts
// 源码节选:src/shared/ticket.ts
if (from === to) return '工单已经处于该状态,无需重复修改';
if (!allowedTransitions[from].includes(to))
  return '不允许此状态变更,请查看可用操作';
if (statusNeedsReason(from, to) && !reason.trim())
  return '解决、关闭或重新打开工单时,请填写操作说明';
return null;

顺序也有含义:给 CLOSED 传 OPEN 和一段很长的理由,也不会因此允许重新打开。说明用于解释合法决定,不是豁免业务规则的通行证。

4. 从 router 追到服务中的检查顺序#

router.ts 的 updateStatus 使用 protectedProcedure,合并状态 schema 与 teamScopeSchema,再由 ctx.db、ctx.userId、input.teamId 创建服务。

services/ticket.ts 中按这个顺序读:

位置 检查什么 失败时写库了吗
团队锁后 requireMembership 当前调用人仍属于团队 没有业务写入
member.role 客户不能改状态 没有
findFirst({ id, teamId }) 目标工单属于本团队 没有
ticket.version 对比 input.version 页面依据仍有效 没有
statusChangeIssue 路径与说明符合规则 没有
条件更新 写入瞬间仍匹配团队与版本 无匹配时没有
事件与当前提醒逻辑 关联写入一同完成 中途失败则回滚事务

检查角色在查目标工单前面,所以客户改状态可能先得到 FORBIDDEN,不要武断要求所有越权请求一律是 NOT_FOUND。

5. 数据库怎样把 v3 变成 v4,并记下真实前后值#

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

increment 在数据库更新内完成。不能先在页面计算 v4 再无条件保存,也不能只在前面查过 v3 就相信写入时仍没变。

接着写事件:

ts
// 源码节选:src/server/services/ticket.ts
await recordTicketEvent(tx, {
  ticketId: input.id,
  teamId,
  version: updated[0].version,
  kind: 'STATUS_CHANGED',
  actorUserId: userId,
  actorRole: member.role,
  fromStatus: ticket.status,
  toStatus: input.status,
  reason: input.reason || undefined,
});

此处有两个不同时间点的对象:ticket 是修改前,updated[0] 是修改后。事件版本取新值,fromStatus 取旧值;把两者混用,就可能产生“已解决 → 已解决”这样的假历史。

成功后,本例 Ticket 为 RESOLVED / v4,增加一条 IN_PROGRESS → RESOLVED 的 v4 事件。若没有其他修改,历史总数从 4 变为 5。

6. 四种故障要分别推演#

故障 A:纯空白说明#

正常页面中按钮被禁用或前端规则拒绝;若绕过 UI 发请求,Zod 将空白清成空串,业务状态检查返回 BAD_REQUEST。数据库仍为 IN_PROGRESS / v3,事件仍 4 条。

这也是为什么“按钮没反应”要先看 Network:没有请求是前端交互边界,收到业务错误才进入服务端分析。

故障 B:另一客服先改了负责人#

页面 A 读 v3;页面 B 保存分配后数据库已 v4。A 携带 v3 请求解决,服务先发现版本过期,返回 CONFLICT,不用 A 的说明覆盖新事实。

团队锁协调当前写流程,版本号仍判断用户之前读到的编辑依据。即使执行阶段完全串行,也不代表所有旧页面的意图都能自动接受。

故障 C:状态更新语句执行了,事件插入却失败#

text
事务开始:数据库对外仍是旧事实
  → tx 中更新为 RESOLVED / v4(尚未提交)
  → 写 v4 事件时数据库拒绝
  → 异常传出事务
事务回滚:对外仍是 IN_PROGRESS / v3,事件仍 4 条

“某条 SQL 执行成功”不等于“整个事务提交成功”。第 21 章用触发器在事件写入点失败,查回状态、版本和历史数量证明这条路径。

故障 D:事务提交了,浏览器没有收到响应#

数据库可能已经是 RESOLVED / v4,并有对应事件。此时不要把超时理解成业务一定没发生,也不要自行补写一条事件。

当前状态接口没有回复那样的 clientRequestId。原样用旧版本再次提交会冲突;应先重新读取详情与历史,让用户确认结果。事务管数据库内部提交,不能保证响应穿过网络。

7. 事务、锁、版本与缓存分别在哪结束#

机制 本例解决的问题 不覆盖什么
团队行锁 与成员、分配等同团队写流程协调 不替代角色检查或阻止旧页面存在
version 条件 拒绝过期修改依据 不把多次写入自动绑成事务
PostgreSQL 事务 工单、记录及当前关联写入共同提交 不回滚已发到浏览器的内容或 Redis 操作
Query 失效 让界面重新读取可信结果 不改变数据库,也不是权限控制

当前第五阶段会在解决 / 关闭时失效旧提醒,重新打开时计算新 dueAt、推进 reminderRevision 并保存任务,仍在数据库事务中;这里没有请求 Redis。学习本阶段可先确认这些动作也会影响共同提交,具体投递机制留到第 23 章。

拓展阅读为什么事务里不要顺手发一封邮件?

数据库回滚撤不回已经发出的邮件,而且外部网络等待会延长事务与锁占用。跨系统副作用需要另行设计可靠待办与幂等,不能把外部调用放进回调就以为它也加入了数据库事务。

8. 成功后页面怎样确认结果,冲突后怎样恢复#

onSuccess 清空目标状态与说明,并等待事件、列表、详情相关查询失效,再显示成功。关闭后页面没有后续状态选项;已解决仍可重新打开。

onError 先显示消息;遇到 CONFLICT 时清空 nextStatus 并 refetch 详情。当前冲突分支没有清空 reason,也没有主动失效事件查询,事件还可能等自身轮询或手动刷新。不要把期望的理想 UX 写成源码已经完成的动作。

如果详情最新、历史暂旧,先查各自请求和缓存;不能因为两个卡片刷新时刻不同,就断定事务只提交了一半。

9. 动手验证与练习#

延续 v3 工单先尝试无说明解决,确认无有效变化;再填说明解决,检查 v4 和新事件。然后从 RESOLVED 重新打开:不填说明被拒绝,填“客户反馈仍失败”后变 OPEN / v5。最后关闭并说明,变 CLOSED / v6,界面无后续状态操作。

观察各次请求、详情与事件。不要先做额外分配或回复再机械套用版本数字;若版本不同,按真实结果重新推算下一步。

练习 A:为什么 reason 有默认值,仍可能必填#

动手练习查看参考答案

参考答案:schema 只补齐字段并限制形状,是否必填依赖服务端读取的起止状态。默认空串能让选填路径成立,不能豁免解决、关闭、重新打开的业务要求。

练习 B:事件版本用 input.version 会怎样#

动手练习查看参考答案

参考答案:input.version 是修改前的编辑依据,当前应记录更新后的版本。若新工单 v0 已有创建事件,再用 0 写状态事件,会触发同工单版本唯一约束,并使事务回滚;漏掉约束则会留下错误顺序。

练习 C:页面报网络失败,为什么不能直接再点一次当作没发生#

动手练习查看参考答案

参考答案:失败可能发生在事务提交后的响应途中。先读取详情和历史确认结果;状态修改没有独立幂等请求号,旧版本重发不能当成无条件安全重试。

链路:用户基于所见版本选择目标与说明 → 接口与服务分层校验 → 事务内按团队和版本更新 → 用真实前后值记历史 → 共同提交 → 前端读取新事实;每类失败按实际边界恢复。

把知识变成自己的理解

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

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

搜索教程

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

阅读设置

外观
正文字号

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

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