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

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。验证通过后进入团队锁内事务,先检查资源权限与已有请求,再决定是否允许新增,最后依次写:

  1. 新回复。
  2. 工单 updatedAt,同时将 version 加 1。
  3. 当前版本的回复操作记录。

任一步失败都回滚,不会只留下一条回复却没有相应工单变更。操作记录是第四阶段增加的逻辑,当前阅读时知道它与回复同事务即可。

回复会推进工单版本,尽管状态没有变化。因此另一个旧详情页稍后修改负责人或状态,可能收到 CONFLICT;这是“数据发生了有效变化”的结果,不是状态字段坏了。

4. 为什么回复接口不要求传工单 version#

分配与状态修改是基于已看到的工单事实作决策,所以请求携带版本。回复主要是新增一条独立记录,两个不同的人可以先后补充内容,不必要求后发者先重新获取版本。

当前服务用团队锁与关闭、分配等写流程协调,在事务内确认资格和工单状态。它仍有业务约束,只是并发合同与覆盖某个已有字段不同。

去重使用 clientRequestId,不是借工单 version 判断两条回复是否相同。下一章专门讲这条链路。

5. 历史列表为什么分页且最新在前#

服务端 replies 在 RepeatableRead 事务中验证工单访问资格,再按相同 { teamId, ticketId } 查询列表与总数。

ts
// 源码节选: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 与工单归属重新判断,历史标签不会授予权限。

这条链路是:草稿输入 → 前后端校验 → 服务验证资源访问资格与关闭状态 → 事务写回复、更新工单和记录 → 返回序列化结果 → 清草稿并刷新查询。

把知识变成自己的理解

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

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

搜索教程

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

阅读设置

外观
正文字号

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

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