PAYATHON 2026

Web 支付表单的 technology agnostic frontend patterns

支付小周

结论

没有一套只靠 HTML 和 JavaScript 就能可靠处理重复支付、长事务、返回键和中断恢复的通用模板。

不受技术栈限制的稳妥方案,是把支付流程设计为由服务端控制的状态机,并组合使用以下模式:

  • 每次支付使用唯一的 payment_attempt_id
  • 创建或确认支付时使用 Idempotency-Key
  • 服务端保存支付状态,浏览器只负责展示
  • 提交支付后进入独立的状态页
  • 状态页通过轮询、SSE 或 WebSocket 查询结果
  • 支付是否完成,以支付网关的服务端回调为准
  • 刷新、返回或重新打开页面时,根据支付 ID 恢复状态

前端模板可以复用交互逻辑和页面结构,但不能承担支付正确性的保障。禁用按钮、拦截返回键等措施只能改善体验,无法防止重复扣款。

推荐的支付状态模型

一次支付尝试可以使用以下状态:

created
  ↓
submitting
  ↓
processing ──→ requires_action
  ↓                 ↓
succeeded       processing
  ↓
completed

任意处理中状态 ──→ failed
任意未完成状态 ──→ expired

具体名称可以调整,但状态转换必须由服务端决定。前端不能因为某个 AJAX 请求成功,就直接认定支付已经成功。

例如,支付网关返回”请求已接收”,通常只表示交易进入了处理阶段。最终结果可能稍后才会通过 webhook 到达。

Session 中应该保存什么

Session 适合保存当前用户与支付流程之间的临时关联,例如:

{
  "checkout_id": "chk_01J...",
  "payment_attempt_id": "pay_01J...",
  "return_path": "/orders/12345",
  "csrf_token": "..."
}

支付数据应该写入数据库,不能只放在 Session 中:

{
  "payment_attempt_id": "pay_01J...",
  "user_id": "user_123",
  "order_id": "order_12345",
  "amount_minor": 2599,
  "currency": "CNY",
  "status": "processing",
  "idempotency_key": "5ded...",
  "gateway_reference": "gw_...",
  "created_at": "2026-09-01T10:00:00Z",
  "updated_at": "2026-09-01T10:00:04Z"
}

金额要由服务端根据订单重新计算。不要信任浏览器提交的金额、币种、商品名称或折扣结果。

还应遵守以下原则:

  • 使用最小货币单位保存金额,例如 2599 表示 25.99 CNY
  • 不在 Session、日志或业务数据库中保存银行卡号、CVV 等敏感数据
  • 使用支付网关提供的托管字段、Hosted Checkout 或 tokenization
  • 为 payment_attempt_id 使用不可预测的随机标识
  • 查询支付状态时,仍要检查当前用户是否有权访问对应订单

使用幂等机制防止重复付款

前端可以在用户点击后禁用按钮:

payButton.disabled = true;

但这种做法无法覆盖以下情况:

  • 用户双击的速度快于页面更新
  • 请求已经到达服务端,但响应在网络中丢失
  • 用户刷新页面或重新提交表单
  • 浏览器、代理或客户端重试请求
  • 用户在多个标签页中同时支付
  • 服务端调用支付网关后发生超时

可靠的做法是为一次逻辑支付生成稳定的幂等键。同一支付意图的所有重试都必须使用同一个键,不能在每次点击时生成新键。

const idempotencyKey =
  sessionStorage.getItem("checkout-idempotency-key") ??
  crypto.randomUUID();

sessionStorage.setItem("checkout-idempotency-key", idempotencyKey);

提交时,把幂等键发送给服务端:

const response = await fetch("/api/payment-attempts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
    "X-CSRF-Token": csrfToken
  },
  credentials: "same-origin",
  body: JSON.stringify({
    order_id: "order_12345"
  })
});

服务端应建立唯一约束,并保证”检查幂等键、创建支付记录”在同一个事务中完成:

CREATE UNIQUE INDEX payment_attempt_idempotency_key_uq
ON payment_attempts (user_id, idempotency_key);

处理流程可以概括为:

收到创建支付请求
    ↓
根据 user_id + Idempotency-Key 查找记录
    ↓
记录已存在?── 是 ──→ 返回原支付尝试及当前状态
    │
    否
    ↓
重新读取订单并计算应付金额
    ↓
在数据库事务中创建 payment_attempt
    ↓
使用对应幂等键调用支付网关
    ↓
保存 gateway_reference 和最新状态

如果支付网关支持幂等请求,也应把幂等键传给网关。只在自己的数据库中去重,仍可能在”网关已受理、服务端未成功保存结果”时留下无法确定的状态。

提交后立即进入状态页

不要把支付处理过程绑定在原始表单页面上。更稳妥的流程是:

GET /checkout
    ↓
POST /api/payment-attempts
    ↓
返回 payment_attempt_id
    ↓
跳转 /payments/{id}/status
    ↓
状态页查询服务端
    ↓
成功、失败、需要用户操作或继续等待

前端创建支付尝试后,使用 location.replace() 进入状态页:

const result = await response.json();

location.replace(
  `/payments/${encodeURIComponent(result.payment_attempt_id)}/status`
);

location.replace() 可以减少用户返回已提交表单的机会,但它只是一项导航优化,不能代替服务端幂等机制。

传统的服务端表单也可以使用 Post/Redirect/Get:

POST /checkout/submit
303 See Other
Location: /payments/pay_01J.../status

这样一来,刷新状态页只会重新执行 GET,不会再次提交原来的 POST。

处理长时间交易

状态页加载后,可以立即查询支付状态,并按照退避策略继续轮询:

<section aria-live="polite">
  <h2 id="payment-title">正在确认付款</h2>
  <p id="payment-message">请稍候,不要重复付款。</p>
  <button id="retry-button" hidden>重新查询</button>
</section>

<script type="module">
  const paymentAttemptId = document.body.dataset.paymentAttemptId;
  const title = document.querySelector("#payment-title");
  const message = document.querySelector("#payment-message");
  const retryButton = document.querySelector("#retry-button");

  let stopped = false;
  let delay = 1000;

  async function checkStatus() {
    try {
      const response = await fetch(
        `/api/payment-attempts/${encodeURIComponent(paymentAttemptId)}`,
        {
          credentials: "same-origin",
          cache: "no-store"
        }
      );

      if (!response.ok) {
        throw new Error(`Status request failed: ${response.status}`);
      }

      const payment = await response.json();

      switch (payment.status) {
        case "succeeded":
          stopped = true;
          location.replace(
            `/orders/${encodeURIComponent(payment.order_id)}/confirmation`
          );
          return;

        case "failed":
          stopped = true;
          title.textContent = "付款未完成";
          message.textContent =
            payment.display_message ?? "付款失败,请检查后重试。";
          retryButton.hidden = false;
          return;

        case "requires_action":
          stopped = true;
          location.assign(payment.action_url);
          return;

        case "created":
        case "submitting":
        case "processing":
          title.textContent = "正在确认付款";
          message.textContent =
            "交易仍在处理中。离开此页面不会取消已经提交的交易。";
          break;

        default:
          throw new Error(`Unknown payment status: ${payment.status}`);
      }

      delay = Math.min(Math.round(delay * 1.5), 10000);
    } catch (error) {
      title.textContent = "暂时无法获取付款状态";
      message.textContent =
        "这不代表付款失败。请稍后重新查询,不要再次付款。";
      retryButton.hidden = false;
      delay = Math.min(Math.round(delay * 2), 15000);
    }

    if (!stopped) {
      window.setTimeout(checkStatus, delay);
    }
  }

  retryButton.addEventListener("click", () => {
    retryButton.hidden = true;
    stopped = false;
    delay = 1000;
    checkStatus();
  });

  checkStatus();
</script>

需要特别注意:状态查询失败不等于付款失败。网络断开、服务端超时或支付网关暂时不可用,都意味着”结果未知”。此时,界面应该引导用户查询原交易,而不是直接提供会创建新交易的”再次付款”按钮。

轮询接口只读取本地支付记录。支付网关的最终状态通常通过以下过程同步:

  1. 网关向服务端发送 webhook。
  2. 服务端验证 webhook 的签名、时间戳和事件标识。
  3. 服务端以幂等方式更新支付记录。
  4. 浏览器在下一次查询时取得新状态。

如果业务需要更及时的交互,可以使用 SSE 或 WebSocket。它们只改变状态的推送方式,判断支付结果时仍应以服务端状态为准。

处理返回键和刷新

不要尝试彻底禁用浏览器的返回键。脚本无法完全控制历史记录,这样做还会影响正常导航和无障碍体验。

每个页面都应该能够安全地重新打开:

  • 表单页加载时,检查订单是否已有未完成或已成功的支付
  • 状态页刷新后,继续查询同一个 payment_attempt_id
  • 确认页可以重复访问,但不能重复扣款或履约
  • 已付款订单再次进入结账页时,直接跳转到确认页
  • 处理中订单再次进入结账页时,跳转到原状态页

前端可以在表单页进行一次预检查:

const response = await fetch(
  `/api/orders/${encodeURIComponent(orderId)}/payment-state`,
  {
    credentials: "same-origin",
    cache: "no-store"
  }
);

const state = await response.json();

if (state.status === "succeeded") {
  location.replace(`/orders/${encodeURIComponent(orderId)}/confirmation`);
} else if (state.active_payment_attempt_id) {
  location.replace(
    `/payments/${encodeURIComponent(
      state.active_payment_attempt_id
    )}/status`
  );
}

这项检查主要用于改善体验。最终的拦截仍要在服务端完成,例如限制同一订单不能同时存在两个可以扣款的活动支付尝试。

恢复被中止的交易

用户关闭页面不代表支付已经取消。只要请求已经提交给支付网关,交易就可能仍在继续。

恢复流程可以这样设计:

  1. 用户重新进入订单页。
  2. 服务端查找该订单最近的活动支付尝试。
  3. 如果状态是 processing,跳转到状态页继续查询。
  4. 如果状态是 requires_action,恢复验证或跳转流程。
  5. 如果状态是 succeeded,进入订单确认页。
  6. 只有明确进入 failed、expired 或可以安全重试的状态后,才允许创建新的支付尝试。

sessionStorage 可以帮助恢复当前浏览器标签页中的状态,但不能作为唯一依据:

sessionStorage.setItem(
  "active-payment-attempt",
  result.payment_attempt_id
);

用户可能清除浏览器数据、换设备,或通过邮件链接重新进入。因此,服务端还要能够根据当前用户和订单找到未完成的交易。

如果网关状态长期不明,需要由后台对账任务主动查询网关,不能一直依赖用户停留在状态页:

processing 超过预期时间
    ↓
后台任务按 gateway_reference 查询网关
    ↓
更新为 succeeded、failed 或继续保持 unknown
    ↓
必要时转入人工对账

区分两种”重试”

支付界面经常把含义不同的操作都写成”重试”,这会增加重复付款的风险。

界面应明确区分:

  • 重新查询:查询原支付尝试的状态,不产生新的扣款
  • 重新付款:创建新的支付尝试,可能产生新的扣款

当原交易仍处于 processing 或 unknown 状态时,界面只能提供”重新查询”。只有服务端确认原交易已经失败、过期或没有提交到网关后,才能开放”重新付款”。

即使允许重新付款,服务端也要检查订单是否已经支付:

开始新的支付尝试
    ↓
锁定或原子更新订单
    ↓
订单已经支付?── 是 ──→ 返回原支付结果
    │
    否
    ↓
是否存在 processing/unknown 交易?
    ├── 是:拒绝新付款,返回原状态页
    └── 否:创建新的支付尝试

模拟服务端时需要覆盖的情况

可下载的前端示例适合验证页面交互,但如果模拟服务端总是在固定时间后返回成功,就无法覆盖真正危险的情况。

一个有用的模拟器至少应支持:

立即成功
延迟成功
明确失败
需要额外验证
请求超时但交易最终成功
重复提交同一个 Idempotency-Key
多个标签页同时提交
webhook 重复发送
webhook 乱序到达
状态查询暂时返回 5xx
交易长时间保持 unknown

尤其需要测试以下情况:

浏览器发送付款请求
→ 服务端调用网关
→ 网关已经受理
→ 服务端响应丢失
→ 浏览器重试

使用同一个幂等键重试后,如果仍然只产生一笔交易,说明幂等设计覆盖了这个故障窗口。

其他注意事项

  • 不要通过 beforeunload 阻止用户离开。它并不可靠,也无法撤销已经提交的支付。
  • 不要只在前端变量、Cookie 或 localStorage 中保存支付成功状态。
  • 不要根据跳转回调页面的 URL 参数,直接把订单标记为已付款。
  • webhook 可能重复、延迟或乱序,处理程序必须保证幂等。
  • 处理回调事件前要验证签名,并防止重放攻击。
  • 支付成功后的订单履约也要保证幂等,避免重复发货、重复充值或重复发送权益。
  • 在日志中记录 payment_attempt_id、订单 ID 和网关引用,不要记录完整的支付凭据。
  • 支付状态页要明确告诉用户:关闭页面不会取消交易,状态未知时不要再次付款。
  • 为”处理中”和”结果未知”设置业务超时及人工处理通道,但不能擅自将其视为失败。
  • 如果支付网关提供 Hosted Checkout,应优先使用官方流程,以减少敏感数据的处理范围和合规负担。

因此,应该复用的不是一套固定页面,而是由服务端状态机、端到端幂等、Post/Redirect/Get、独立状态页、webhook 确认和可恢复导航组成的完整协议。HTML 和 JavaScript 模板只负责实现它的展示层。即使更换前端框架或后端技术栈,支付流程的这些约束也不会改变。

备注:内容仅供参考。