《互联网产品的客户端架构·交易、增长与平台化》6.1 端上如何避免重复支付

重复支付通常来自重复点击、重复回跳与重复轮询三个入口,而不是用户手抖。本节用支付单号去重、显式的进行中状态与超时后先查询的策略,把同一订单的多次支付尝试收敛到一张支付单上,并给出可用假时钟验证的 TypeScript 防护模块与真实测试输出。

问题:重复支付往往有三个入口

第 5 章讨论支付编排时,重点是把「拉起、轮询、回跳」串成一条可恢复的链路。链路一旦存在,端上就会多出三个可以重复触发的入口:用户重复点击支付按钮、渠道回跳页被刷新或重复到达、轮询与前台恢复同时发起查询。

这三个入口看起来不同,造成的后果却一样:同一张订单被创建出多张支付单。用户可能在两个渠道各付一次,也可能在同一渠道付两次。到了对账阶段,这些多出来的支付单会以「重复支付」的差异形式出现,退款与客服成本随之上升。端上无法阻止用户点两次,但可以保证第二次点击不产生第二张支付单。关键是先承认一个事实:支付单与订单不是一对一,而是一对多;去重不能靠订单号,必须靠支付单号与客户端支付意图键。

支付单与订单是一对多

关系是否允许约束
订单到支付单一对多换渠道、失败重试可以新建支付单
支付单到订单多对一每张支付单只归属一个订单
同一订单的在途支付单至多一张新支付单必须等旧支付单进入终态
同一订单的已支付支付单至多一张第二张入账必须拒绝并转入退款流程

这四条约束里,最容易写错的是第三条。很多客户端只挡住「同一渠道的重复点击」,用户换一个渠道就又能创建一张。服务端看到的是两张不同渠道、不同支付单号的合法请求,没有任何依据合并。端上必须把「同一订单」而不是「同一渠道」作为在途判断的单位。第四条是兜底。即使前三层都漏了,端上收到第二张支付单的成功回调时也不能把它记成第二笔已支付。正确动作是拒绝入账、保留证据,交给退款与对账流程处理。

三个入口,一条去重键

入口典型触发如果不去重正确动作
点击用户连点、按钮未禁用创建多张支付单有在途支付单时不再创建
回跳渠道页面刷新、重复到达再次拉起支付或重复入账按支付单号查询,只处理一次
轮询定时器重叠、前台恢复重复查询或新建支付单按支付单号幂等查询
恢复杀进程后重进支付页把保存的「支付中」当成仍在支付一律降级为未知,先查询

点击入口的去重键在支付单创建之前就要存在。此时还没有服务端支付单号,端上需要一个「支付意图键」,同一订单的同一次意图复用同一个键,服务端据此保证创建支付单的幂等。支付单创建成功后,后续所有入口都以 payId 为准,支付意图键退居次要位置。回跳入口有一个特殊风险:回跳参数里通常带着渠道流水号或支付结果,端上很容易直接用它更新界面。但回跳参数可以被伪造、可以被重复提交,也可能与真实结果不一致。端上只应把回跳当作「该查询了」的信号,真正的结果必须以查询接口为准。

端上必须有「已有支付进行中」这个状态

只靠按钮 loading 不够,因为页面会被销毁、恢复、重新进入。端上需要一个能持久化的支付阶段。

阶段含义允许点击支付允许新建支付单
creating正在向服务端创建支付单否否
paying支付单已创建,等待渠道结果否否
unknown超时、断网、恢复后结果不明否,只能查询否
paid渠道确认成功否否
failed渠道明确失败或查询确认不存在是是
closed被其他支付单的成功结果取代否否

unknown 是这张表的重点。它不是错误态,而是一个必须存在的一等状态:请求可能已经到达服务端,也可能没有;渠道可能已经扣款,也可能没有。端上唯一安全的动作是拿支付单号去查询。closed 则解决一对多带来的收尾问题。同一订单可能先后有几张支付单,其中一张最终成功时,其余在途支付单必须被关闭,避免它们之后又回来把界面改回「支付中」。

超时后先查询,再决定

超时发生在两个位置:创建支付单的请求超时,以及支付单已创建但渠道结果迟迟不回来。两者的处理原则相同,细节不同。创建超时时还没有支付单号,端上只能拿支付意图键或订单号去问服务端「这张订单有没有创建出支付单」。查询命中就把支付单号补回本地,查询确认不存在才允许重新创建。支付超时时已经有支付单号,直接用 payId 查询即可。

查询结果只有三种:命中已支付就进入成功态并关闭其他在途支付单;确认不存在才把当前支付单标记失败、允许换渠道;仍然不确定就保持未知态并按退避策略继续查询,不能因为查了一次没结果就放行新支付。

这里有一条经常被忽略的规则:只有「确认不存在」才能创建新支付单,「查询失败」不等于「支付失败」。 把查询超时当成不存在,等于把未知重新变成重复支付。

崩溃恢复:不要相信保存的「进行中」

支付页被系统杀掉时,本地可能保存着 creating 或 paying。恢复时最危险的做法是原样信任:界面显示「支付中」,但原来那个请求的回包永远不会有人接收。正确做法是把所有没有拿到终态的请求降级为 unknown:保存的 creating 恢复为未知并标记创建中断;保存的 paying 恢复为未知但保留支付单号;paid 原样保留并继续阻止新支付;failed、closed 保持原样,允许重新发起。

恢复后第一次进入支付页,应该先查询而不是先创建。查询命中就恢复成功态;确认不存在再让用户重新选择渠道。这样即使进程在任意时刻被杀,端上也不会因为「看起来还在支付」而再创建一张支付单。

防护模块的实现

下面的模块属于 L2:时间由 now() 注入,模块内部不注册定时器,网络请求由调用方发起。它把支付意图、支付单号、回跳去重、轮询幂等与崩溃恢复放在同一个状态容器里。

// file: src/reconcile/double-pay.ts
// tier: L2
// verify: node --test test/reconcile/double-pay.test.ts

export type PayPhase = "creating" | "paying" | "unknown" | "paid" | "failed" | "closed";

export interface PayAttempt {
  key: string; payId: string | null; orderId: string; channel: string;
  amountCents: number; phase: PayPhase; createdAt: number; updatedAt: number;
  channelTxnId: string | null; reason: string | null; polls: number;
}

export interface PaySnapshot { orderId: string; attempts: PayAttempt[]; }

export interface EntryResult {
  ok: boolean; action: "create" | "query" | "none";
  key: string | null; payId: string | null; reason: string;
}

export type QueryView =
  | { status: "found"; channelTxnId: string; amountCents: number }
  | { status: "not_found" }
  | { status: "unknown" };

const ACTIVE: PayPhase[] = ["creating", "paying", "unknown"];

function assertCents(cents: number): void {
  if (!Number.isSafeInteger(cents) || cents <= 0) throw new Error("支付金额必须是正整数分");
}

// 支付单是订单的一对多关系:可以换渠道重试,但同一时刻只允许一个在途支付单。
export class DoublePayGuard {
  orderId: string;
  attempts: PayAttempt[];
  now: () => number;
  returned: Set<string>;

  constructor(orderId: string, now: () => number) {
    const id = orderId.trim();
    if (id === "") throw new Error("订单号不能为空");
    this.orderId = id;
    this.attempts = [];
    this.now = now;
    this.returned = new Set();
  }

  snapshot(): PaySnapshot {
    return { orderId: this.orderId, attempts: this.attempts.map((item) => ({ ...item })) };
  }

  hasPaid(): boolean {
    return this.attempts.some((item) => item.phase === "paid");
  }

  activeAttempt(): PayAttempt | null {
    return this.attempts.find((item) => ACTIVE.includes(item.phase)) ?? null;
  }

  findByKey(key: string): PayAttempt {
    const attempt = this.attempts.find((item) => item.key === key);
    if (!attempt) throw new Error(`支付意图不存在:${key}`);
    return attempt;
  }

  findByPayId(payId: string): PayAttempt | null {
    return this.attempts.find((item) => item.payId === payId) ?? null;
  }

  beginClick(channel: string, amountCents: number): EntryResult {
    if (this.hasPaid()) return { ok: false, action: "none", key: null, payId: null, reason: "already_paid" };
    const active = this.activeAttempt();
    if (active) {
      return {
        ok: false,
        action: active.phase === "unknown" ? "query" : "none",
        key: active.key,
        payId: active.payId,
        reason: active.phase === "unknown" ? "need_query" : "attempt_active",
      };
    }
    const name = channel.trim();
    if (name === "") throw new Error("支付渠道不能为空");
    assertCents(amountCents);
    const at = this.now();
    const key = `pay-${this.orderId}-${this.attempts.length + 1}`;
    this.attempts.push({
      key,
      payId: null,
      orderId: this.orderId,
      channel: name,
      amountCents,
      phase: "creating",
      createdAt: at,
      updatedAt: at,
      channelTxnId: null,
      reason: null,
      polls: 0,
    });
    return { ok: true, action: "create", key, payId: null, reason: "started" };
  }

  markCreated(key: string, payId: string): PayAttempt {
    const attempt = this.findByKey(key);
    if (attempt.phase !== "creating") throw new Error("只有创建中的支付意图可以回填支付单号");
    const id = payId.trim();
    if (id === "") throw new Error("支付单号不能为空");
    if (this.attempts.some((item) => item.payId === id)) throw new Error("支付单号重复");
    attempt.payId = id;
    attempt.phase = "paying";
    attempt.updatedAt = this.now();
    return { ...attempt };
  }

  // 创建或支付超时都只能落到未知,不允许直接判失败。
  timeout(key: string): PayAttempt {
    const attempt = this.findByKey(key);
    if (attempt.phase === "creating") {
      attempt.phase = "unknown";
      attempt.reason = "create_timeout";
    } else if (attempt.phase === "paying") {
      attempt.phase = "unknown";
      attempt.reason = "pay_timeout";
    }
    attempt.updatedAt = this.now();
    return { ...attempt };
  }

  onReturn(payId: string): EntryResult {
    const attempt = this.findByPayId(payId);
    if (!attempt) return { ok: false, action: "query", key: null, payId, reason: "unknown_pay_id" };
    if (attempt.phase === "paid" || this.returned.has(payId)) {
      return { ok: false, action: "none", key: attempt.key, payId, reason: "duplicate_return" };
    }
    this.returned.add(payId);
    attempt.updatedAt = this.now();
    return { ok: true, action: "query", key: attempt.key, payId, reason: "returned" };
  }

  // 轮询按支付单号幂等:重复轮询只增加计数,不创建新的支付单。
  onPoll(payId: string): EntryResult {
    const attempt = this.findByPayId(payId);
    if (!attempt) return { ok: false, action: "query", key: null, payId, reason: "unknown_pay_id" };
    attempt.polls += 1;
    attempt.updatedAt = this.now();
    if (attempt.phase === "paid") {
      return { ok: false, action: "none", key: attempt.key, payId, reason: "already_paid" };
    }
    return { ok: true, action: "query", key: attempt.key, payId, reason: "polling" };
  }

  applyQuery(payId: string, view: QueryView): PayAttempt {
    const attempt = this.findByPayId(payId);
    if (!attempt) throw new Error(`支付单不存在:${payId}`);
    if (view.status === "found") return this.markPaid(payId, view.channelTxnId, view.amountCents).attempt;
    if (view.status === "not_found") {
      if (attempt.phase !== "paid") {
        attempt.phase = "failed";
        attempt.reason = "channel_not_found";
        attempt.updatedAt = this.now();
      }
      return { ...attempt };
    }
    if (attempt.phase === "paying") attempt.phase = "unknown";
    attempt.reason = "query_pending";
    attempt.updatedAt = this.now();
    return { ...attempt };
  }

  // 渠道确认入账。金额必须与支付单一致,重复回调按流水号幂等。
  markPaid(payId: string, channelTxnId: string, amountCents: number): { attempt: PayAttempt; duplicate: boolean } {
    const attempt = this.findByPayId(payId);
    if (!attempt) throw new Error(`支付单不存在:${payId}`);
    const txn = channelTxnId.trim();
    if (txn === "") throw new Error("渠道流水号不能为空");
    assertCents(amountCents);
    if (amountCents !== attempt.amountCents) {
      throw new Error(`入账金额与支付单不一致:${amountCents} != ${attempt.amountCents}`);
    }
    if (attempt.phase === "paid") {
      if (attempt.channelTxnId === txn) return { attempt: { ...attempt }, duplicate: true };
      throw new Error("同一支付单出现不同的渠道流水号");
    }
    if (this.attempts.some((item) => item.phase === "paid" && item.key !== attempt.key)) {
      throw new Error("订单已存在已支付支付单,拒绝重复入账");
    }
    const at = this.now();
    attempt.phase = "paid";
    attempt.channelTxnId = txn;
    attempt.reason = null;
    attempt.updatedAt = at;
    for (const other of this.attempts) {
      if (other.key !== attempt.key && ACTIVE.includes(other.phase)) {
        other.phase = "closed";
        other.reason = "superseded_by_paid";
        other.updatedAt = at;
      }
    }
    return { attempt: { ...attempt }, duplicate: false };
  }
}

export function openDoublePay(orderId: string, now: () => number): DoublePayGuard {
  return new DoublePayGuard(orderId, now);
}

// 进程被杀后恢复:没有拿到终态的请求一律降级为未知并等待查询。
export function resumeDoublePay(orderId: string, saved: PaySnapshot, now: () => number): DoublePayGuard {
  const id = orderId.trim();
  if (saved.orderId !== id) throw new Error("订单号不一致,不能恢复支付会话");
  const guard = new DoublePayGuard(id, now);
  guard.attempts = saved.attempts.map((attempt) => {
    const restored = { ...attempt };
    if (restored.orderId !== id) throw new Error("支付意图归属其他订单");
    if (restored.phase === "creating") {
      restored.phase = "unknown";
      restored.reason = "resumed_creating";
    } else if (restored.phase === "paying") {
      restored.phase = "unknown";
      restored.reason = "resumed_in_flight";
    }
    restored.updatedAt = now();
    return restored;
  });
  return guard;
}

四个方法承担了主要约束。

beginClick() 是唯一的创建入口。它先看订单是否已支付,再看有没有在途支付单,最后才生成支付意图键。因此重复点击、换渠道重试、已支付后再次进入,都会在创建之前被挡住。

onReturn() 与 onPoll() 都按支付单号查找已有支付单,找不到时只返回「需要查询」,绝不创建新单。回跳用 returned 集合保证同一支付单只处理一次;轮询只增加计数,重复调用不会改变支付单数量。

markPaid() 校验金额与渠道流水号,重复回调按流水号幂等。它还会关闭同订单其他在途支付单,避免迟到的旧支付单把界面改回中间态。若订单已经有一张已支付支付单,第二张入账会被直接拒绝。

resumeDoublePay() 不信任保存的进行中状态,把所有 creating 与 paying 降级为 unknown。恢复后的支付页只能先查询。

测试与真实输出

21 条用例覆盖:首次创建与重复点击(4 条)、超时与查询分支(5 条)、回跳与轮询去重(4 条)、入账校验与多支付单收尾(5 条)、崩溃恢复与未知支付单(3 条)。全部使用假时钟与内存状态,不访问网络。

本机运行结果如下:

$ node --test --test-concurrency=1 test/reconcile/double-pay.test.ts
✔ 新订单没有任何支付意图 (0.583833ms)
✔ 首次点击创建支付意图 (0.4745ms)
✔ 创建中重复点击被挡且不产生第二张支付单 (0.08675ms)
✔ 创建超时转为未知并禁止再次发起 (0.108125ms)
✔ 创建成功后进入支付中并保留支付单号 (0.115375ms)
✔ 支付中重复点击同样被挡 (0.075958ms)
✔ 支付超时转为未知,轮询只触发查询 (0.107416ms)
✔ 查询确认支付单不存在后可以换渠道新建 (0.101708ms)
✔ 查询命中后按支付单号入账 (0.131708ms)
✔ 重复回跳只处理一次 (0.129542ms)
✔ 重复轮询不新建支付单 (0.074458ms)
✔ 已支付后拒绝再次发起支付 (0.058958ms)
✔ 迟到的成功回调关闭其他在途支付单 (0.080375ms)
✔ 入账金额与支付单不一致时拒绝 (0.320833ms)
✔ 渠道流水号为空时拒绝入账 (0.10575ms)
✔ 同一流水重复入账是幂等的 (0.077208ms)
✔ 已支付订单不接受第二张支付单入账 (0.086834ms)
✔ 崩溃恢复时创建中的意图降级为未知 (0.116083ms)
✔ 崩溃恢复时支付中的意图保留支付单号并等待查询 (0.06175ms)
✔ 崩溃恢复保留已支付结果 (0.053ms)
✔ 未知支付单号的回跳只要求查询,不创建新单 (0.036959ms)
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 158.359333

这些用例只验证端上状态容器,不验证服务端是否真的按 (userId, 支付意图键) 建了唯一索引。端上防护减少重复请求,服务端唯一约束才是最后一道闸门,两者缺一不可。

一次重复回跳造成的双支付复盘

某交易客户端的支付回跳页在 onload 里直接调用「创建支付单并拉起渠道」。正常路径下,用户从渠道返回时页面会带上支付结果,这段逻辑不会执行;但用户刷新回跳页时,参数仍在,创建逻辑又跑了一遍,生成第二张支付单。

更麻烦的是回跳页没有禁用按钮。用户看到「正在确认支付结果」迟迟不结束,手动点了页面上的「重新支付」,第三张支付单也被创建出来。最终渠道侧有两笔成功扣款,订单却只有一张。

复盘发现三个缺口:回跳入口没有按支付单号去重;回跳页把「创建支付单」与「查询支付结果」混在同一个动作里;崩溃恢复时把保存的 paying 当成仍在支付,没有转成未知。修复后,回跳只触发按 payId 查询,创建入口只在没有在途支付单时可用,恢复时一律先查询。回归覆盖了刷新回跳页、重复点击、杀进程重进三个场景,同类双支付不再从这条路径产生。

这个复盘的结论可以压缩成一句:回跳是信号,不是结果;创建支付单必须与查询支付结果分开。

小结

重复支付的端上防线不是「按钮点一次就禁用」,而是把同一订单的支付尝试收敛到受控的一对多关系里:同一时刻至多一张在途支付单,已支付至多一张,其余尝试在成功时关闭。

三个入口共用一条去重键:创建前用支付意图键,创建后用支付单号。超时只能进入未知态,查询确认不存在才允许新建;崩溃恢复不信任保存的进行中状态,一律先查询。

下一节换一个视角:端上能看到的订单、支付单与渠道流水,如何对齐成一份可执行的对账口径。

阅读导航:上一节:5.3 支付回跳与回调竞争 · 下一节:6.2 支付对账的数据口径 。

常见错误与边界

  • 每次点击都创建一张支付单,只靠按钮 loading 防重。
  • 只按渠道去重,用户换渠道后又能创建第二张支付单。
  • 回跳页直接根据 URL 参数把订单改成已支付,不做查询。
  • 回跳页重复执行创建逻辑,刷新一次就多一张支付单。
  • 轮询没有按支付单号幂等,定时器重叠时重复发起查询甚至重复创建。
  • 创建或支付超时直接判失败,引导用户换渠道再付一次。
  • 崩溃恢复时信任保存的 paying,界面永远停在支付中。
  • 第二张支付单成功时直接入账,而不是拒绝并转入退款流程。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查