14 · 工单回复:把一次沟通保存成可信记录#
小陈接单后询问“是否能提供错误提示”,客户补充情况。页面看起来像一组消息,服务端要保存的却不只是文字:还要知道属于哪张工单、哪个团队、谁在当时以什么角色发出。
1. 回复是一张独立的关系表#
查看 TicketReply 模型:
| 字段 | 作用 | 来源 |
|---|---|---|
ticketId、teamId |
回复所属工单与团队 | 请求目标,先通过资源检查 |
authorId |
关联作者账号 | 服务端会话身份 |
authorName、authorRole |
发送时的姓名与角色快照 | 服务端查询用户和成员关系 |
content |
回复正文 | 经校验的输入 |
clientRequestId |
标识同一次发送意图 | 客户端生成,服务端去重 |
id、createdAt |
记录身份与时间 | Prisma / 数据库默认机制 |
一张工单可以有很多回复,每条回复必须关联工单。[ticketId, teamId] 复合外键还能阻止回复记录挂到另一个团队的工单上。
authorId 可空以容纳作者关联删除;姓名和角色快照仍能让历史沟通可读。快照表示发送当时的事实,不能用历史 authorRole 决定此人今天的权限。
2. 谁能发回复,与谁能改状态不同#
reply 复用 requireTicketAccess:管理员和客服可访问本团队工单,客户只能访问自己创建的工单。通过后先查重,确实需要新增时才检查关闭状态;已保存请求的回读顺序见下一章。
客户不能改状态,但能在自己的未关闭工单中补充回复。当前“已解决”仍允许回复,“已关闭”禁止新增回复;不要把所有非待处理状态都当成终态。
客户端只提交内容、请求编号、工单和团队,不提交可信作者姓名或角色。服务端根据会话与数据库写入作者,防止用户给自己的消息伪造一个管理员标记。
3. 从输入框到数据库的写入链路#
Conversation.submit 用 replyContentSchema.safeParse 校验草稿,成功后发送清理后的正文。共享 schema 先 trim(),再限制 1~10000 字符,所以纯空白不能发送。
服务端 ticket.reply 再次执行 Zod。验证通过后进入团队锁内事务,先检查资源权限与已有请求,再决定是否允许新增,最后依次写:
- 新回复。
- 工单
updatedAt,同时将version加 1。 - 当前版本的回复操作记录。
任一步失败都回滚,不会只留下一条回复却没有相应工单变更。操作记录是第四阶段增加的逻辑,当前阅读时知道它与回复同事务即可。
回复会推进工单版本,尽管状态没有变化。因此另一个旧详情页稍后修改负责人或状态,可能收到 CONFLICT;这是“数据发生了有效变化”的结果,不是状态字段坏了。
4. 为什么回复接口不要求传工单 version#
分配与状态修改是基于已看到的工单事实作决策,所以请求携带版本。回复主要是新增一条独立记录,两个不同的人可以先后补充内容,不必要求后发者先重新获取版本。
当前服务用团队锁与关闭、分配等写流程协调,在事务内确认资格和工单状态。它仍有业务约束,只是并发合同与覆盖某个已有字段不同。
去重使用 clientRequestId,不是借工单 version 判断两条回复是否相同。下一章专门讲这条链路。
5. 历史列表为什么分页且最新在前#
服务端 replies 在 RepeatableRead 事务中验证工单访问资格,再按相同 { teamId, ticketId } 查询列表与总数。
// 源码节选:src/server/services/collaboration.ts
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (input.page - 1) * input.pageSize,
take: input.pageSize,界面固定每页 20 条,接口 schema 上限仍为 50。id 用于同一时间下的稳定排序,不意味着 UUID 能提供严格的业务发送顺序。
偏移分页容易使用,但翻页期间有人新增回复,后续页可能移动;事务只保证同一次响应中列表和总数的快照一致,不能冻结用户几分钟的浏览过程。
当前回复页码是 Conversation 的局部 state,不在 URL 里。复制详情链接可以定位工单,但不会自动恢复第几页回复;后面 nuqs 管理的是工单工作台的筛选与分页。
6. 轮询、主动刷新与发送成功#
Conversation 给回复查询设置 refetchInterval: 15000,在正常活动页面中定期读取。这里没有 WebSocket,也没有“每次有人发消息就即时推送”的承诺;浏览器后台节流等情况也会影响体验。
发送成功后,当前 UI:
- 清除该工单的本地草稿。
- 将回复页码回到 1,以便看到最新回复。
- 失效该工单回复、详情、列表和操作记录查询。
- 给出成功提示。
失败时草稿保留,提示错误,并让详情失效以便发现已关闭等状态变化。当前默认 mutation 不自动重试,是否再次发送由用户决定;稳定请求编号让同内容再次发送有机会安全复用结果。
不要只在页面 setReplies([...]),却忘了数据库与其他分页缓存。这一阶段由服务端确认后失效查询,减少多个结果集手动同步的复杂度。
拓展阅读纯文本是怎样展示出来的?
当前 JSX 用 {reply.content} 输出正文,React 将其作为文本转义;whitespace-pre-wrap 保留换行,没有使用 dangerouslySetInnerHTML。输入 <script> 会按文本显示,不会被当 HTML 执行。以后增加富文本时,需要重新设计渲染、清理与允许内容的边界,不能直接把现在的字符串插进 HTML。
7. 验证与练习#
客户在自己的工单发一条带换行的回复,客服补充另一条。确认作者标签来自各自身份,刷新后仍存在,关闭后历史可读但不能新增。使用另一客户或其他团队的请求,应由服务端拒绝读取或发送。
协作集成测试 断言内容被 trim、authorId / authorRole 正确、两条回复分两页返回,以及未登录、客户归属和跨团队边界。它没有在真实浏览器验证纯文本渲染,也没单独验证作者后来变更角色仍保留旧快照;这些分别是 JSX / 字段读法与可补的实验,不能只凭“作者身份已测”扩大结论。
练习:客服后来降为客户,过去的消息为什么还显示“客服”?
动手练习查看参考答案
参考答案:authorRole 是发送时的快照,帮助理解历史沟通。今天能否访问或回复由当前 Membership 与工单归属重新判断,历史标签不会授予权限。
这条链路是:草稿输入 → 前后端校验 → 服务验证资源访问资格与关闭状态 → 事务写回复、更新工单和记录 → 返回序列化结果 → 清草稿并刷新查询。