保存超时不等于保存失败:避免 Agent 重复提交业务操作

用户让 Agent 帮忙准备售后申请,核对演示内容后点击提交。页面等了一会儿,提示超时。用户再点一次,后台却出现了两条申请:第一次其实已经创建成功,只是回执没有及时回到浏览器。

这个问题的关键不在按钮是否置灰,而在于前端无法仅凭一次网络错误判断写操作有没有发生。超时后直接换一个请求重新创建,会把不确定性变成重复业务记录。

这一篇用 Vue 3、TypeScript 和本地 Node 演示服务完成一个恢复流程:提交前保存操作标识与内容,超时进入结果未知状态,优先核对结果;需要重试时沿用同一幂等键和同一份内容。

模型建议、订单和申请内容都是固定模拟,不调用真实模型、售后系统或退款接口。公开页面使用离线模拟传输;源码包另含真实本地 HTTP 超时示例。所有写入仅发生在演示内存,不触发付款、退款或对外消息。

机器人已把编号01的申请存入柜子,但回执卡在返回管道中;它先查询同一个编号,重试仍指向已有记录,不再新增一份。

把结果未知留在状态模型里

普通查询失败通常可以再查一次,创建业务记录却需要更谨慎。本例把提交中的状态与结果未知分开:提交超时后,页面保留原操作,显示“尚未确认结果”,不宣布申请失败,也不显示创建成功。

页面提供两个明确动作:核对结果,以及用原操作重试。正在提交、核对或等待恢复时,原请求内容保持锁定,不能偷偷把修改后的诉求挂到旧键下面。

模型在这里仅提供固定演示建议,真正提交由用户明确点击。它不能因为上一轮工具超时,就自行生成一个新操作再次写入。

HTTP 规范也要求,不能在缺少依据时自动重试非幂等方法;需要知道请求语义实际可幂等重试,或确认原请求没有被应用。RFC 9110:幂等方法与重试

在发送前记住同一件事

本例把一次已确认的提交表示成一个操作快照:

typescript
type Payload = Readonly<{
  orderId: 'DEMO-ORDER-1001';
  reason: '演示包装破损' | '演示配件缺失';
}>;
type Operation = Readonly<{ key: string; payload: Payload }>;

key 由客户端随机生成,表达“这是同一轮提交”。payload 保留这轮提交的订单号和演示原因。键不使用手机号、姓名等个人信息,也不由模型自由填写。

关键顺序是:校验内容,生成键,将完整快照写入本地存储,确认存储成功,然后才发送请求。如果持久化失败,页面阻止发送;否则一旦刷新丢失键,后续就无法可靠地恢复同一操作。

刷新时读取并校验这份快照,只恢复待核对状态,不自动重放写请求。存储损坏或字段不合法时也不能随手生成新键继续提交,应阻止操作并提示核对。

示例只在 localStorage 中保存公开演示编号与原因。它不是让真实业务把身份证件、完整聊天记录或敏感申请材料直接写入浏览器存储;真实存储方案要结合账号、数据范围与恢复期限设计。

幂等要由服务端执行

前端保存键还不能阻止重复创建。服务端需要根据键查找既有结果,并核对请求内容是否与首次一致。本例维护有上限的内存记录,处理逻辑可以概括为:

Text
键不存在 → 校验并创建一条演示申请,记录键与内容指纹
键已存在且内容相同 → 返回已有申请
键已存在但内容不同 → 409,拒绝混用操作

内容指纹按受控字段构造,用来判断是不是同一份业务请求;它不是密码学签名,也不是权限凭证。服务端返回已创建申请的编号,页面据此确认结果,而不是把一次重试误算成第二次创建。

服务的去重分支摘录如下。stamp 来自已经校验的固定订单号和演示原因,records 保存当前内存中的操作结果:

typescript
const existing = records.get(operation.key);
if (existing) {
  if (existing.fingerprint !== stamp) {
    throw new ServiceError(409, '同一操作键对应的申请内容不一致,拒绝创建。');
  }
  return { application: copy(existing.application), totalRecords: records.size };
}

Stripe 的官方文档也展示了客户端生成幂等键、同键重试以及参数一致性检查的设计。不同 API 版本对失败重试和结果保存有具体约定,不能把某个平台的全部语义直接套进自建服务。Stripe:幂等请求

本例只保证同键、同内容在当前服务内存保留期间复用记录。两个不同的键,即使内容看起来一样,也不是这个规则中的同一次操作;防止业务重复还可能需要订单级唯一约束。

让超时发生在创建之后

为了验证最容易误判的路径,本地 HTTP 服务先创建演示申请,再延迟发送响应。浏览器设置更短的等待期限,通过 AbortController 中止等待,因此会出现“服务已有记录,但页面没收到成功回执”的状态。

中止的是浏览器这次等待,不会回滚服务已经创建的申请。页面此时保留原键与内容,进入未知状态,让用户选择查询结果。

状态查询只按原键查记录,不创建申请。查到后展示已有编号并确认成功;查询也超时或失败时继续保持未知。暂未查到时同样不能武断地说“从未执行”,更不能自动换键重建。

用户明确选择同键重试时,前端重新发送原快照。若首次已经创建,服务返回已有记录;若本例服务尚无该键,才按当前规则创建。传输演示开关只控制回执延迟,不属于业务内容,不应让同一内容的恢复变成指纹冲突。

刷新恢复与过期响应一起处理

单纯禁用按钮只能减少当前页面的重复点击,不能覆盖刷新、网络断开或工具重试。本例持久化操作快照,恢复后保留同一个键;在成功核对前,不开放一个新的创建入口。

每次提交、核对和重试还分配操作序号。只有仍属于当前操作的返回才能更新状态,旧查询的失败不能覆盖后来已经确认的成功;组件卸载后停止向页面发布状态。

本例没有跨标签页协调,也没有跨设备恢复。即使两个标签恰好拿着同一个键,服务端去重仍是最后一道约束;但浏览器各自生成不同键时,不能指望本地按钮状态自动合并业务意图。

服务内存也不是持久数据库。重启会丢失记录,未知键查不到可能只是保留范围已经变化;本例无法证明重启之前的业务从未执行。真实系统必须明确幂等记录保留期限、状态查询一致性和过期后的处理方式。

测试创建次数,而不只比较文案

验证重点是最终产生多少条记录,以及恢复过程中有没有换键。页面出现“已成功”并不足以证明没有重复写入。

场景 应满足的约束
正常提交 返回申请编号,只创建一条记录
创建后回执超时 页面显示未知,原键与内容保留
超时后核对 查回同一个申请编号,不新增记录
同键重试或并发重复请求 返回既有记录,创建数仍为一
同键换了另一个有效演示原因 409,不能创建或改写原记录
非演示订单或非法字段 400,不创建记录
刷新未知操作 恢复相同键,不自动发送写请求
状态查询失败或未查到 保持未知,不自动换键
本地存储写入失败 阻止提交,工具创建次数为零

核心测试使用可控存储、Promise 和服务夹具,检查操作快照、状态转换与创建计数。HTTP 集成测试则真实启动 Node,验证浏览器式超时之后,状态接口仍能找到刚才创建的记录。

这些计数只针对演示内存服务,不是生产吞吐、真实模型成功率或支付结果。真实系统还需要验证多进程竞争、事务提交与幂等记录写入是否处在可靠边界内。

运行离线与本地 HTTP 示例

打开结果未知恢复演示 · 下载 Vue / TypeScript 与 Node 源码

公开页面只模拟传输。它可以展示提交、未知、核对和同键恢复的交互;没有后台服务,也没有真实网络超时。Vue 已打包,无需模型密钥或 CDN。

要检查实际 HTTP 路径,解压后在目录运行:

Bash
npm run serve

打开 http://127.0.0.1:8091/,启用延迟回执,明确提交一次。页面超时后先核对结果,观察申请编号和创建计数;也可以刷新页面,确认仍保留原键,然后再恢复。不要用普通静态服务器占用这个端口。

修改源码时执行:

Bash
npm ci
npm run typecheck
npm test
npm run build
npm run test:http

本次类型检查、21 组核心测试和 17 个真实本地 HTTP 检查通过,证据随源码保存。浏览器分别验证离线和 HTTP 的成功、超时、核对、同键重试、刷新恢复与存储失败,并检查电脑和手机布局,截图与报告留在当日工作区。离线模拟、真实本地传输与真实模型调用是三个不同的验证范围,本例没有执行后者。

接到真实业务时补上持久化边界

幂等键应与账号、业务操作和请求内容绑定。服务端需要可靠的唯一约束、事务和结果记录,避免两个进程同时看到“键不存在”后各创建一次;不能直接把本例单进程内存逻辑当成数据库并发保证。

状态查询需要权限检查,键本身不能成为访问别人申请的通行证。对于支付、退款或发送消息,还要结合下游系统的去重与结果核对协议,而不是只在最外层保存一个键。

这个最小案例解决的是页面面对不确定结果时的行为:没收到回执,就保留同一操作并继续核对。能够恢复同一件事,才有条件在重试时避免把它再做一遍。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2022-2026 alan_mf
  • 全站访客— 累计浏览— 统计加载中…

请我喝杯咖啡吧~

支付宝收款码 支付宝
微信收款码 微信