使用 web redirection method 集成 payment gateway
明确结论
不要把 WebView 跳转到 success URL 视为支付成功。更可靠的处理方式是:
- 支付前,由服务端创建唯一订单,并将状态设为
PENDING。 - 主要通过支付网关发送且经过验签的 webhook 确认结果。
- App 只查询自身服务端保存的订单状态,不能根据页面跳转直接把订单改为成功。
- 所有通知和状态更新都要支持幂等处理。
- 如果支付网关没有查询 API,也不能保证 webhook 重试,就无法在技术上做到完全可靠。此时只能增加
UNKNOWN状态,并配合延迟对账和人工核查。
重定向用于改善用户体验,webhook 用于确认支付结果,两者不能互相替代。
为什么 success URL 不能证明支付成功
支付页面的跳转可能受到多种情况影响:
- 用户付款后可能立即断网或关闭 WebView。
- App 可能被系统终止,因而观察不到重定向。
- 用户可以手动访问或重放 success URL。
- WebView 检测到 URL,只能说明发生了导航,不能证明资金已经到账。
- App 上报“支付成功”的请求可能被伪造。
- 随机 token 可以防止猜测和错误关联,但不能证明支付网关已经收款。
所以,即使 App 捕获到了重定向,也只能说明“支付流程已经返回”。接下来仍要向业务服务端查询最终状态。
推荐流程
1. 服务端创建支付订单
App 发起支付时,服务端先创建内部订单:
order_id: ord_20260831_xxx
amount: 100.00
currency: CNY
status: PENDING
provider_reference: ...
return_token_hash: ...
然后,服务端调用支付 API 或生成支付 URL,再把 URL 返回给 App。
金额、币种、商品和用户信息都必须以服务端记录为准,不能信任 App 后续传回的数据。
2. App 打开支付页面
App 可以使用系统浏览器、Custom Tabs、ASWebAuthenticationSession,也可以使用支付方明确支持的 WebView。
如果支付方没有明确说明支持嵌入式 WebView,通常应优先使用系统浏览器。部分银行验证、3-D Secure、Cookie 和外部 App 跳转在 WebView 中可能无法正常工作。
3. 重定向只触发状态查询
支付流程结束后,网关将用户重定向到类似地址:
https://merchant.example.com/payment/return?order=ord_20260831_xxx&token=...
该页面可以通过 Universal Link、App Link 或自定义 scheme 返回 App。此时,App 不应直接显示“支付成功”,而应显示“正在确认支付”,并查询业务服务端:
GET /api/payments/ord_20260831_xxx/status
4. 服务端通过 webhook 确认支付
支付网关向服务端发送 webhook 后,服务端应执行以下操作:
- 验证支付方签名或消息认证信息。
- 校验商户号、订单号、金额和币种。
- 检查该通知事件是否已经处理。
- 在数据库事务中更新订单。
- 尽快返回成功响应,让支付方停止重试。
- 将耗时的发货、积分或通知操作放入异步任务。
可以采用以下状态模型:
CREATED -> PENDING -> PAID
-> FAILED
-> EXPIRED
-> UNKNOWN
状态通常只能单向推进。例如,订单一旦进入 PAID 状态,就不应因为迟到的失败通知而改回 FAILED。
服务端示例
下面是一个简化的 Node.js/Express 示例。签名算法、header 名称和 webhook 字段必须以支付方文档为准。
import express from "express";
import crypto from "node:crypto";
const app = express();
// webhook 验签通常需要原始请求体
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body;
const signature = req.get("X-Payment-Signature");
if (!verifyWebhookSignature(rawBody, signature)) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(rawBody.toString("utf8"));
} catch {
return res.sendStatus(400);
}
try {
await database.transaction(async (tx) => {
// event.id 应替换为支付方提供的唯一通知 ID
const alreadyProcessed = await tx.webhookEvents.exists(event.id);
if (alreadyProcessed) {
return;
}
const order = await tx.orders.findForUpdate(event.order_id);
if (!order) {
throw new Error("Unknown order");
}
if (
event.status !== "success" ||
event.amount !== order.amount ||
event.currency !== order.currency
) {
throw new Error("Webhook data does not match the order");
}
await tx.orders.markPaid(order.id, {
providerReference: event.payment_id,
paidAt: event.paid_at
});
await tx.webhookEvents.insert({
eventId: event.id,
orderId: order.id
});
await tx.outbox.insert({
type: "PAYMENT_CONFIRMED",
orderId: order.id
});
});
return res.sendStatus(200);
} catch (error) {
console.error("Payment webhook processing failed", error);
// 返回非 2xx,以便支持重试的支付方再次投递
return res.sendStatus(500);
}
}
);
function verifyWebhookSignature(rawBody, receivedSignature) {
if (!receivedSignature) {
return false;
}
const expectedSignature = crypto
.createHmac("sha256", process.env.PAYMENT_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const expected = Buffer.from(expectedSignature, "utf8");
const received = Buffer.from(receivedSignature, "utf8");
return (
expected.length === received.length &&
crypto.timingSafeEqual(expected, received)
);
}
其中的 X-Payment-Signature、HMAC-SHA256 和事件字段都只是示例。如果供应商使用证书、公钥签名、固定 IP、Basic Auth 或其他认证方式,必须严格按照其协议实现,不能自行假定算法。
React Native 端示例
App 捕获回跳后,只查询自身服务端,不直接确认付款:
async function waitForPaymentResult(orderId: string) {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const response = await fetch(
`https://api.example.com/payments/${encodeURIComponent(orderId)}/status`,
{
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
if (!response.ok) {
throw new Error(`Status request failed: ${response.status}`);
}
const payment = await response.json();
if (payment.status === "PAID") {
return payment;
}
if (["FAILED", "EXPIRED"].includes(payment.status)) {
throw new Error(`Payment ended with status: ${payment.status}`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
return {
status: "PENDING",
message: "支付结果仍在确认中,可稍后在订单页面查看"
};
}
轮询超时并不表示支付失败。超时后应继续保留 PENDING 或 UNKNOWN 状态,让用户稍后重新进入订单页面查看。
如果基础设施支持,也可以通过 WebSocket 或 Server-Sent Events 通知 App。这只能减少轮询,最终状态仍以服务端保存的支付结果为准。
网络中断时如何恢复
需要分别处理三类中断。
App 与服务端之间中断
影响不大。订单状态保存在服务端,App 恢复网络后重新查询即可。
用户浏览器重定向失败
这也不应影响支付确认。只要 webhook 能到达服务端,订单仍会更新为 PAID。用户再次打开订单页面时,就能看到支付结果。
支付网关向 webhook 投递失败
能否恢复取决于支付方是否支持自动重试,需要确认以下事项:
- 非
2xx响应是否会触发重试。 - 重试持续多久,采用什么退避策略。
- 每次重试是否携带相同的事件 ID。
- 通知是否可能乱序或重复。
- 商户能否在后台下载交易记录或对账文件。
如果支付方提供查询 API,服务端应定时查询长时间处于 PENDING 状态的订单。但题目中的支付方没有查询 API,因此无法主动拉取最终状态,只能依赖 webhook、对账文件或人工查询。
没有查询 API 时的现实限制
如果供应商只发送一次 webhook,并且投递失败后不再重试,商户就无法单方面实现完全可靠的支付确认。success URL 也不能弥补这一缺陷,因为客户端回跳同样可能丢失或被伪造。
此时至少应要求供应商提供以下一种能力:
- 可重试且带签名的 webhook。
- 可以按商户订单号查询交易状态的 API。
- 可下载的日结对账文件。
- 商户后台中的交易查询和导出功能。
- 由支付方签名的重定向结果。
如果重定向参数经过支付方私钥签名,服务端可以在验证签名后确认结果。普通随机 token 或商户自行生成的 token 没有这种证明能力。
对于无法自动确认的订单,应保留 UNKNOWN 状态,不要自动标记为失败,也不要让用户立即重复付款。可以向用户显示:
支付结果暂未确认,请稍后刷新订单。若已扣款,请勿重复支付。
还需要注意的事项
- 每次支付尝试都要生成唯一的
payment_attempt_id,不能只依赖业务订单号。 - 创建支付请求时使用幂等键,防止网络重试造成多次扣款。
- webhook 必须使用 HTTPS,并验证签名、时间戳和重放窗口。
- 不要只依赖来源 IP。IP 可能变化,而且 IP 校验不能代替签名。
- 金额应使用整数形式的最小货币单位,例如分,避免浮点数比较错误。
- webhook 可能早于或晚于页面重定向到达,代码必须允许两者以任意顺序发生。
- 发货、充值等业务动作,必须由服务端将订单从
PENDING成功变更为PAID后触发。 - webhook 重复投递时,不能重复发货或重复记账。
- 日志应保存支付方交易号、事件 ID、订单号和处理结果,但不能记录银行卡信息、密钥或完整的敏感请求。
- 对长时间未确认的订单设置告警和对账任务。
客户端重定向只能说明用户已经返回。只有服务端收到并验证支付方提供的可信通知后,才能确认付款成功。
备注:内容仅供参考。
