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>
需要特别注意:状态查询失败不等于付款失败。网络断开、服务端超时或支付网关暂时不可用,都意味着”结果未知”。此时,界面应该引导用户查询原交易,而不是直接提供会创建新交易的”再次付款”按钮。
轮询接口只读取本地支付记录。支付网关的最终状态通常通过以下过程同步:
- 网关向服务端发送 webhook。
- 服务端验证 webhook 的签名、时间戳和事件标识。
- 服务端以幂等方式更新支付记录。
- 浏览器在下一次查询时取得新状态。
如果业务需要更及时的交互,可以使用 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`
);
}
这项检查主要用于改善体验。最终的拦截仍要在服务端完成,例如限制同一订单不能同时存在两个可以扣款的活动支付尝试。
恢复被中止的交易
用户关闭页面不代表支付已经取消。只要请求已经提交给支付网关,交易就可能仍在继续。
恢复流程可以这样设计:
- 用户重新进入订单页。
- 服务端查找该订单最近的活动支付尝试。
- 如果状态是
processing,跳转到状态页继续查询。 - 如果状态是
requires_action,恢复验证或跳转流程。 - 如果状态是
succeeded,进入订单确认页。 - 只有明确进入
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 模板只负责实现它的展示层。即使更换前端框架或后端技术栈,支付流程的这些约束也不会改变。
备注:内容仅供参考。
