19 · 操作记录:沿着一张工单还原谁做了什么#
客户小林问:“刚才还没有负责人,谁接手了?我补充的信息保存了吗?”当前工单只能告诉我们现在的状态、负责人和更新时间,无法完整回答这些问题。第四阶段要把每次有效业务变化和一条可信历史一起保存。
本章从创建开始追踪到界面。读完应能独立指出:记录在哪个服务中产生,身份和前值来自哪里,为什么绑定版本,历史读取如何继续保持团队与客户隔离。
1. 先把故事写成可检查的事实#
沿用第 12 章的甲团队身份:管理员 A(小周)、客服 C(小陈)、客户 D(小林)与另一客户 E,另保留乙团队账号用于隔离验证。由 D 新建一张“客户无法登录”的普通优先级工单。本章实验暂停你为此实验启动的 Worker,并确认没有其他 Worker 处理这套学习数据库,避免超时提醒另行推进版本;也暂不让其他人修改这张工单。
按以下顺序操作,先预测数据库,再检查页面:
| 步骤 | 操作 | Ticket 的预期变化 | 新增 TicketEvent |
|---|---|---|---|
| 0 | D 创建工单 | OPEN,未分配,v0 | CREATED,操作者 D,version=0 |
| 1 | A 分配给 C 并保存 | 负责人 C,仍是 OPEN,v1 | ASSIGNED,操作者 A,version=1 |
| 2 | D 回复“重置密码后仍失败” | 状态与负责人不变,v2 | REPLIED,操作者 D,version=2 |
| 3 | C 将状态改为处理中 | IN_PROGRESS,v3 | STATUS_CHANGED,操作者 C,version=3 |
分配不会自动把状态改成处理中;回复也会推进版本。 版本表示这张工单发生了有效变化,并非只统计状态变化次数。
正常完成这四步后应有 4 条历史,界面倒序显示 v3、v2、v1、v0。本章中的精确数量建立在“新建工单、没有额外修改”的前提上,不能照搬到任意旧工单。
2. 先读模型:为什么不只存一句展示文案#
打开 schema.prisma,找到 TicketEvent。按下面三个问题读字段:
| 问题 | 字段 | 为什么这样保存 |
|---|---|---|
| 属于哪里、处在哪次修改 | ticketId、teamId、version、kind | 定位资源、团队和变化顺序 |
| 谁在当时操作 | actorUserId、actorName、actorRole | 关联身份,保留姓名与角色快照 |
| 到底改变了什么 | fromStatus / toStatus、负责人快照、reason、replyId | 保留结构化变化、说明或回复引用 |
如果只保存“某客服处理了工单”,将来无法可靠区分解决、关闭、重新打开;如果只关联当前用户资料,用户更名或角色变化又会改变历史语境。当前服务读取事件时直接返回快照,不拿今天的角色重写过去。
模型还声明:
// 源码节选:prisma/schema.prisma
@@unique([ticketId, version])
@@index([teamId, ticketId, version])第一条限制同工单同版本不能有两条事件;第二条支持按团队、工单、版本查历史。唯一约束只能挡重复,不能强迫每次 Ticket 更新都必须有事件。 “有变化就有记录”还依赖业务写路径与事务。
复合外键 [ticketId, teamId] 保证记录引用的工单和团队匹配;它不负责判断请求者是谁,读取仍要授权。
3. 第一条记录怎样从创建表单走到数据库#
按这个顺序打开源码,先不要一口气读完整文件:
| 阅读点 | 搜索符号 | 这一层要确认的事实 |
|---|---|---|
| 创建组件 | CreateTicket、create.mutate |
请求提供标题、描述、优先级和团队 |
| 业务 router | ticket 下的 create |
运行输入校验,使用 ctx.userId |
| 工单服务 | async create |
创建与事件使用同一个 tx |
| 记录函数 | recordTicketEvent |
查询真实操作者姓名,写快照 |
创建服务中的关键连接点是:
// 源码节选:src/server/services/ticket.ts
await recordTicketEvent(tx, {
ticketId: ticket.id,
teamId,
version: ticket.version,
kind: 'CREATED',
actorUserId: userId,
actorRole: member.role,
toStatus: ticket.status,
});逐项追踪来源:ticket 是刚插入的工单;userId 是 HTTP 会话上下文提供的调用人;member 是事务内查询到的真实成员关系;初始状态和版本来自创建结果。浏览器没有负责声明“我是管理员”或“这是 v0”。
当前创建事务还会保存第五阶段的提醒任务,但此处不访问 Redis。先聚焦本阶段的工单与事件共同提交,提醒投递留在后面学习。
4. recordTicketEvent 为什么必须沿用同一个 tx#
记录函数执行:
// 源码节选:src/server/services/ticket-events.ts
const actor = await tx.user.findUniqueOrThrow({
where: { id: input.actorUserId },
select: { name: true },
});
await tx.ticketEvent.create({ data: { ...input, actorName: actor.name } });tx 可类比一次编辑过程里的共同提交上下文:前面的 Ticket 写入、这里的姓名读取和事件写入都属于同一个数据库事务。类比的限制是,它由数据库协调提交与回滚,不是 React Context,也不会管理浏览器 state。
假如事件写入失败,异常从记录函数传回外层事务,之前的创建也回滚。如果改成另一份 db 单独提交,或在数据库仍可提交时吞掉应用异常,业务修改和历史就可能分家。但不能反推“catch 一定让写入提交”:PostgreSQL 语句错误会让事务进入失败状态,捕获异常本身不能恢复提交资格。第 21 章区分这两类错误,并用真实数据库拒绝写事件验证回滚。
参数类型写成 Prisma.TransactionClient 是给调用者的合同提示,不能把 TypeScript 类型当成“数据库自动审计”。直接改表不会自动调用这个函数;所有写入口都要遵守约定。
5. 再追踪两类容易制造假历史的动作#
在 协作服务 找 assign 和 reply:
- 分配先验证版本,若当前负责人已等于目标,就成功返回,不递增版本也不写事件。所以用旧版本重复分配仍可能先冲突,并非所有同负责人请求都无条件成功。
- 回复先验证资源资格,再按作者、工单和 clientRequestId 查重。同编号同内容返回原记录,不新增回复、版本或 REPLIED 事件。
正常 UI 回复成功后会清草稿;重新输入同样文字会产生新编号,属于新的发送意图。不能通过“再打一次同文消息”来验证旧请求重试。 这项实验应看第 21 章的稳定请求夹具。
成员被移除或降为客户时,成员服务 还会解除分配、递增相应工单版本并批量写 ASSIGNEE_RELEASED,这些与成员变更同事务。它没有逐张调用记录 helper,因此审查时不能只搜索 helper 的调用次数;还要查直接的 ticketEvent.createMany。
6. 历史从数据库返回页面时,边界仍然存在#
router.ts 的 ticket.events 合并工单 ID、团队与分页 schema,再交给 createTicketEventService.list。客户端可以选择要看的工单,不能因此获得访问权。
服务在 RepeatableRead 事务中先 requireTicketAccess,再使用同一份 { teamId, ticketId } 查询列表和 count:
// 源码节选:src/server/services/ticket-events.ts
const where = { teamId, ticketId: input.id };
const items = await tx.ticketEvent.findMany({
where,
orderBy: { version: 'desc' },
skip: (input.page - 1) * input.pageSize,
take: input.pageSize,
select: {
id: true,
version: true,
kind: true,
actorName: true,
actorRole: true,
fromStatus: true,
toStatus: true,
fromAssigneeName: true,
toAssigneeName: true,
reason: true,
createdAt: true,
},
});
const total = await tx.ticketEvent.count({ where });同一快照让本次列表与总数一致,不会固定用户之后所有翻页的结果。客户只能读本人工单的历史;另一团队成员不能借历史接口读详情里被挡住的信息。
TicketEvents 固定每页 10 条,正常活动页面每 15 秒重新查询,并提供手动刷新。kind 映射成中文,from/to 变成箭头,reason 用文本渲染;时间转成本地展示,版本保留业务顺序。
修改成功时还会主动失效事件查询,不必只等轮询。页面有 loading、error、empty 三类显示;网络失败不能伪装成“没有历史”。
7. 两种故障怎样逐层判断#
故障一:数据库里已有 v3,页面历史仍停在 v2。
先用 Prisma Studio 按工单 UUID 查 TicketEvent。若 v3 已存在,再看 Network 中 ticket.events 是否重新请求、输入 teamId / id 是否正确、缓存是否失效。业务已提交而界面缓存旧,不能靠再次执行状态修改“补一笔历史”。
故障二:新注册账号看不到旧工单的完整过程。
第四份迁移 对既有工单插入 HISTORY_STARTED,用当时的 version、状态和负责人保存起点快照,明确此前未追溯。第二阶段的无成员归档团队也仍保持权限隔离。
历史缺失不能靠猜测补成“某人曾经解决”。快照表示从这里开始可靠记账,不代表恢复了过去所有动作。
拓展阅读记录表是否等于事件溯源或防篡改审计?
当前业务读取仍以 Ticket 为准,没有实现由事件重放全部状态,也没有替数据库管理员加上不可篡改保证。事件表提供应用层可追踪历史;完整事件溯源和合规审计还有独立的数据、权限与保留要求。
8. 动手检查四条记录,再回答三个问题#
按第 1 节完成四步,在 Prisma Studio 只读检查:Ticket.version=3,状态 IN_PROGRESS,负责人是 C;TicketEvent 按 ticketId 过滤后应有 4 条,版本为 0~3。
再回到页面按最新在前比对:操作者、kind、前后状态和版本应能对上。用客户 D 读自己的历史应成功,换同团队客户 E 应不可见。若出现额外系统事件,先检查 Worker 与其他修改,不直接断言记录重复。
练习 A:没有改状态,为什么从 v1 到了 v2#
动手练习查看参考答案
参考答案:第 2 步新增回复也是有效工单修改,服务同时更新 updatedAt、递增版本并记录 REPLIED。版本不能只理解为“状态修改计数”。
练习 B:唯一约束存在,漏调用记录函数会怎样#
动手练习查看参考答案
参考答案:唯一约束只拒绝同工单重复版本的事件,不会为漏写自动补记录,也不会自动拒绝一个没有事件的 Ticket 更新。这要靠共同事务、完整写路径和回滚测试来保证。
练习 C:客户能读记录,说明可以写内部备注吗#
动手练习查看参考答案
参考答案:当前 reason 会在有权读该工单的客户面前显示,没有“仅工作人员可见”的备注字段或访问规则。要做内部备注,需要另行设计可见性,不能只换一个表单标签。
本章链路可复述为:前端发起业务动作 → tRPC 校验身份与输入 → 服务确定真实操作者和前值 → 工单与事件同事务写入 → 历史接口再次授权读取 → Query 刷新并按版本展示。