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

15 · 重试与幂等:回复超时后为什么不能盲目再发一次#

用户点击“发送”,页面提示网络失败。你能断定数据库没有保存吗?不能:服务端可能已经提交,只是返回结果没到浏览器。如果直接再插一条,相同消息就会出现两次。

本章把“网络请求失败”与“业务没有发生”分开,再看项目如何让同一次发送意图在重试时复用结果。

1. 一次发送可能停在哪一步#

text
浏览器发出请求 → 服务端验证 → 数据库提交回复 → 返回响应 → 浏览器收到
                                          ↑
                              这里之后断网,业务已经成功

禁用发送按钮能减少连点,不能解决响应丢失、多请求并发或客户端重发。自动重试也只是再次发请求,并不会天然避免重复写入。

幂等在这里的具体含义是:同一个作者,对同一张工单,重复提交同一个发送编号与同一份内容,得到同一条已保存回复。 它不是“相同文字永远只能发一次”,也不是系统任何接口都天然支持。

2. 请求编号应该跟随发送意图,而不是每次点击#

Conversation 在内容编辑时保存:

ts
// 源码节选:回复输入框 onChange
const text = event.target.value;
if (!text) remove(key);
else save(key, { text, requestId: crypto.randomUUID() });

每次编辑产生新的编号;停止编辑后,正文与编号一起留在草稿里。提交使用:

ts
// 源码节选:Conversation.submit
clientRequestId: draft?.requestId ?? crypto.randomUUID(),

正常编辑产生的草稿已带编号,所以失败后不再编辑内容再次提交,会复用它。刷新恢复草稿也会恢复编号,直到成功或主动清空。

这里的 fallback 为缺少编号的草稿临时生成 ID,并没有把这个新 ID 再写回草稿。因此不能把所有异常或旧格式草稿都宣称为“每次重试绝对使用同一编号”;当前标准输入路径才明确保存稳定编号。

编辑一个字、再改回来,也已经是新的编辑过程,会得到新编号。如果第一次其实成功了,再编辑后重新发送仍可能产生另一条同文回复,这是当前规则允许的行为。

3. 服务端查的是一个有范围的业务键#

reply 服务 获取团队锁并检查资源访问权限后,查找:

ts
// 源码节选:重复请求查询
const existing = await tx.ticketReply.findFirst({
  where: {
    teamId,
    ticketId: input.id,
    authorId: userId,
    clientRequestId: input.clientRequestId,
  },
  select: replySelect,
});

编号不是作者身份凭证。服务先验证会话和工单可见性,再在当前作者、工单、团队范围内查询,避免别人知道一个编号就取得无权读取的记录。

找到记录后,如果内容一致就返回原记录;不同则返回 CONFLICT,提示编号已经用过。内容经过 Zod trim() 后比较,因此只在两端多打空格不一定被视为不同正文。

4. 为什么还需要数据库唯一约束#

TicketReply 模型 定义:

prisma
@@unique([ticketId, authorId, clientRequestId])

同一张工单、同一作者、同一请求编号只能有一条记录。只写“先查有没有,再创建”,在并发下两个请求可能同时查到没有,所以还要考虑协调与数据库约束。

当前同团队回复在事务中取得相同团队行锁。第二个请求等前一个提交,再查已有回复,就能返回同一条记录;唯一约束也守住重复数据不能落库的边界。

不要因此断言“以后删掉锁也一样”。当前实现没有把任意唯一冲突都统一转换成成功回读;约束防重复,与应用能否平滑返回同一结果,是两项需要配合的行为。

5. 为什么查重在关闭状态检查之前#

服务执行顺序是:身份与资源资格 → 已有请求查询 → 新增前检查关闭状态。

假设用户刚刚发送成功,随后客服关闭工单,用户因响应丢失而用相同编号重试:如果权限仍有效、内容相同,服务可返回已经存在的回复,没有新增任何东西。

因此“关闭工单不能继续回复”准确含义是不能新增回复;它不要求拒绝一个已完成动作的结果查询。被移出团队或失去资源可见性后,则会在更前面的访问检查被拒绝,不能借幂等编号绕过权限。

现有协作测试覆盖关闭后拒绝新编号的回复,以及同编号并发只落一次;“先发成功、再关闭、再同编号重试”可从源码顺序推导,但没有在该测试文件单独验证,不能写成已有测试结论。

拓展阅读幂等与版本号为什么不能互换?

version 判断这次修改是否基于过期工单事实;clientRequestId 判断这是不是已经处理过的发送意图。同一条回复的重试不能再次推进版本,新增的另一条回复则应推进。一个解决旧状态覆盖,一个解决动作重复,各自有不同的数据合同。

6. 当前实现没有保证什么#

  • 创建工单接口没有使用这组回复去重键,不能把回复的幂等能力推广到创建。
  • 相同正文配不同编号允许产生两条回复;编号不是按正文做永久去重。
  • 当前没有离线发件箱,不会在网络恢复后自动发送草稿。
  • 默认 mutation 不自动重试;幂等只是让符合合同的再次提交更可靠。
  • 如果用户清草稿或编辑后丢失原发送编号,就不能再靠旧意图的键取得同样保证。

使用本地测试夹具可更稳定地验证并发边界,而不必依赖手工断网的时机。

7. 本章测试与练习#

在 协作集成测试 找到“同一请求重试或并发发送只落库一次”。它用 Promise.all 同时调用两次相同输入,验证返回 ID 相同、总数为 1;再用同编号改正文,断言 CONFLICT。

练习:为何“每次点发送都生成 randomUUID”仍可能重复?

动手练习查看参考答案

参考答案:每次点击的编号都不同,服务会把它们当成两次业务意图。当前常规路径在编辑时生成并保存编号,提交时复用;失败后保留草稿,才使再次发送能指向同一个动作。

这条可靠性链路是:编辑生成意图编号 → 请求携带正文与编号 → 服务验证访问资格 → 锁与唯一约束协调重复提交 → 返回旧记录或原子新增 → 成功后清草稿。

把知识变成自己的理解

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

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

搜索教程

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

阅读设置

外观
正文字号

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

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