跳到正文

大型后台管理系统如何把业务操作做可靠(4):退款超时后的幂等与对账

大型后台管理系统如何把业务操作做可靠

  1. 把业务规则写进订单和退款模型
  2. 并发编辑、草稿合并与搜索乱序
  3. 让报表说清统计口径与数据进度
  4. 退款超时后的幂等与对账(当前)
  5. 可恢复的导入导出任务
  6. 批量操作的预览、执行与恢复
  7. 多租户授权、撤权与审计
  8. 旧页面、接口和审批模板如何一起演进
  9. 沿真实操作验收可靠性与性能
  10. 设计规范和组件库如何落到日常开发
  11. 脚手架、接口契约与前后端联调
  12. 构建产物、持续交付与灰度回退
  13. 低代码表单与审批流程的执行边界
  14. 功能投放、权限与灰度人群
  15. 桌面、移动端与宿主能力适配
  16. 多个前端应用如何集成与独立交付
  17. 复杂编辑器的状态、命令与协作

客服提交订单 O42 的 40 元退款,随后浏览器提示超时。服务端可能尚未接到请求,也可能已经收到支付渠道的退款成功结果。第一次提交的操作标识 R17 必须能跨过这次超时,否则客服重试时,系统无法区分原退款和一笔新退款。

操作标识在什么时候生成

第一次确认金额与理由时生成 R17,将它和提交中的草稿一起保存。再次提交同一份请求沿用 R17。用户明确要另发一笔退款时才生成新标识。服务端以租户、调用方的稳定身份、操作类型和操作标识建立唯一约束,并保存规范化后的业务参数指纹。金额和目标订单属于指纹,重试时变化的链路追踪 ID 不属于指纹。

已有记录 传入参数 处理
无 合法 创建操作并保存结果或任务地址
有,正在处理 相同 返回原任务及当前状态
有,已有终态 相同 返回原业务结果
有 不同 报操作标识冲突

先校验当前主体有权使用这个操作入口和查看对应结果。能够重放结果,并不代表已撤权的账号仍能读到它。Stripe 和 AWS 的经验说明,同一标识应代表调用方同一次意图,幂等记录要与本地副作用一起提交。Stripe 的幂等 API 设计 · AWS Builders’ Library 的安全重试设计

首次退款执行后响应丢失,重试沿用同一标识并取回原结果

用唯一约束挡住同时到达的请求

两个请求可能同时查不到 R17。单独写一次查询再插入业务记录,会有两个请求都通过的窗口。示例伪代码假设操作表、额度和待投递表共享同一个事务。唯一键冲突方按数据库的可见性规则读取已经提交的记录,若当前事务快照看不到,就结束事务后重新读取。

submitRefund(actor, order, key, input):
  checkAccess(actor, order)
  scope = (actor.tenant, actor.id, "refund", key)
  digest = fingerprint(order.id, validatedInput(input))

  transaction:
    claim = insertOperationIfAbsent(scope, digest)
    if claim.alreadyExists:
      return replayAfterMatchingFingerprint(scope, digest)

    reserveRefundCapacity(order.id, input.amount)
    refund = insertRefund(order.id, input, status = "accepted")
    linkOperationToRefund(claim.id, refund.id)
    insertOutbox("executeRefund", refund.id)
  commit

  return statusUrl(refund.id)

冲突分支读取原记录后必须比较指纹,再返回已有结果。若事务失败,操作记录、额度占用和待投递事件一起撤销。投递器在发布消息后可能还没标记完成就崩溃,所以消费者会看到重复消息。处理端根据退款 ID 取得处理资格,并按既有状态恢复。调用外部服务时保留同一渠道操作标识,数据库内去重无法覆盖外部调用成功后本地崩溃的窗口。

渠道结果未知时先查证

处理端发出渠道退款请求,若超时,把内部状态记为待确认。渠道提供商户退款号查询或幂等重试时,用原标识确认或重试。渠道结果确认成功后,按退款 ID 条件更新状态,将占用转为已退并写审计。若渠道明确确认未执行且不会稍后成功,再释放占用。无法自动确认时,进入对账或人工处理队列,界面继续显示待确认。

客户端可对可重试错误做有上限的指数退避,加入随机抖动。若操作已经受理,优先查询任务地址。服务端应约定去重记录的保留期。超过保留期仍有争议时,通过持久退款记录或渠道流水查询原交易,避免因键已清理而把旧请求当成新请求。跨渠道重试的前提是已经知道原渠道结果。

为每个未知结果准备恢复入口

把可疑的成功窗口逐项列出来。它们的共同特征是工作已经发生,但最后一步确认尚未送达。负责恢复的一方应持有能在相应系统里找到原操作的标识。

失败位置 已有证据 恢复动作
浏览器等待超时 原请求标识 R17 同参数重试,或查询 R17
业务事务已提交,投递器未发送 待投递记录 后台继续投递
已发送,未确认投递完成 同一待投递记录 允许重投,消费端识别
渠道已执行,本地未记录 原渠道退款号 查渠道,按结果补写本地状态
长时间无法查证 请求和查询轨迹 保持待确认,交给对账

客户端查不到 R17 时,也要区分「当前账号不能看」「记录已超过保留期」和「有权查询且系统没有该记录」。如果依然在幂等保留期内,可以用同一个键重提。新键代表新业务意图,界面应避免把所有失败提示都做成重新创建按钮。

让异步回调和主动查询汇入同一处

有时渠道通知先到,主动查询也随后返回同一个成功。把两条路径汇入同一份结果处理逻辑,在事务里锁定退款记录。先验证渠道流水和业务金额,再检查当前状态能否接受这项结果。成功的终态更新及额度转换只能执行一次。查询结果和回调冲突时保存证据并升级处理,别用到达时间决定谁覆盖谁。

applyProviderResult(refundId, evidence):
  transaction:
    refund = lockRefund(refundId)
    verifyProviderEvidence(refund, evidence)
    if refund.matchesFinalResult(evidence):
      return refund.result
    if refund.isFinal:
      recordReconciliationIssue(refund, evidence)
      return requiresReview()
    if evidence.confirmsSuccess:
      moveReservedAmountToRefunded(refund)
      recordRefundSucceeded(refund, evidence)
    else if evidence.confirmsNoEffect:
      releaseReservedAmount(refund)
      recordRefundFailed(refund, evidence)
    else:
      recordPendingConfirmation(refund)

渠道说「未查到」是否代表确定未执行,取决于渠道查询契约和延迟。为自动查证设最大等待、重试间隔和人工接管条件。审计保留渠道流水、验证时点、状态转移和人工决议。这样客服能解释待确认,财务也能从本地记录追到下游证据。

验收每个可能崩溃的位置

并发发两次同标识请求,数据库只应创建一笔退款。换金额再用原标识,应被拒绝。随后分别模拟受理响应丢失、消息重复、渠道已成功但本地未记结果。最后核对渠道流水、内部退款金额和订单额度,不能只以页面出现成功提示为通过。

总结

退款重试依赖贯穿客户端、业务数据库和渠道的操作身份。事务保护本地提交,渠道查询和对账确认跨系统结果。任何一段还不能确认时,都应保留可查记录和对应额度。