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。
// 源码节选:TicketDetail 的提交参数
update.mutate({
id,
teamId: team.id,
status: nextStatus,
version: ticket.version,
reason,
});这个请求表达“我基于看到的 v3,想把它变成已解决,并附上说明”,没有提供可信的 fromStatus、actorName 或 actorRole。
只有界面禁用还不够:用户可直接访问接口,页面也可能陈旧。服务仍独立校验全部边界。
3. schema 与状态函数各验证什么#
共享 schema 的关键部分:
// 源码节选:src/shared/ticket.ts
reason: z.string().trim().max(1000, '操作说明最多1000字').default(''),
version: z.number().int().min(0),说明在接口边界先 trim,超长被拒绝;缺省时是空字符串。并没有写成所有操作 .min(1),因为回到待处理等某些路径允许不填说明。
在服务读到当前状态后,再调用:
// 源码节选: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,并记下真实前后值#
// 源码节选:保留状态主线,省略提醒相关字段
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 就相信写入时仍没变。
接着写事件:
// 源码节选: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:状态更新语句执行了,事件插入却失败#
事务开始:数据库对外仍是旧事实
→ 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:页面报网络失败,为什么不能直接再点一次当作没发生#
动手练习查看参考答案
参考答案:失败可能发生在事务提交后的响应途中。先读取详情和历史确认结果;状态修改没有独立幂等请求号,旧版本重发不能当成无条件安全重试。
链路:用户基于所见版本选择目标与说明 → 接口与服务分层校验 → 事务内按团队和版本更新 → 用真实前后值记历史 → 共同提交 → 前端读取新事实;每类失败按实际边界恢复。