支付流程
本页给出从下单到确认到账的完整链路,以及可直接复制到游戏脚本中的编排代码。各接口的参数与返回值定义见 API 总览。
角色
| 角色 | 职责 |
|---|---|
| Game | 游戏客户端脚本,调用 sud.* 接口,只透传签名产物 |
| SUD OP | OpenPaaS 运行环境,唤起平台支付 |
| Game SVR | 游戏服务端,生成业务订单与三个签名产物、接收发货回调、处理扣币与退款 |
| SUD OP SVR | OpenPaaS 服务端,确认真实资金状态并下发货或关单指令 |
完整链路
Phase 1 到 Phase 3 与到账确认方式无关,两种方式只在 Phase 4 不同。
关键约束
- 三个签名产物必须由 Game SVR 生成,客户端不参与
signData拼装与签名计算,也不接触pay_sign_key与session_key。 success回调不代表已发货,只代表客户端支付流程结束。严禁据此直接发放资产;发货以 Game SVR 收到的服务端回调为准。- 业务订单号全局唯一,发货必须幂等。服务端回调可能因网络重试而重复到达,同一笔订单只允许成功发货一次,重复请求也要返回成功。
- 同一笔订单不要并发发起支付。上一笔尚未确认到账时应阻断新的下单请求,否则会同时存在多笔待确认订单,无法判断哪一笔到账。
- 到账与读到余额是两件事。服务端确认发货后余额可能有短暂延迟,余额查询失败应重试,不要据此判定到账失败。
- 到账确认逻辑必须可终止。页面失活、场景切换或用户退出时要能停止轮询或忽略迟到的推送,避免写入已销毁的界面。
- 无论轮询还是推送,客户端都要按业务订单号幂等处理。重复的查询结果或重复的推送都只允许触发一次到账处理。
到账确认方式
Phase 4 有两种实现,二者可以互换,也可以并存(推送为主、轮询兜底)。
| 维度 | 方式 A:客户端轮询 | 方式 B:服务端推送 |
|---|---|---|
| 实时性 | 受间隔档位限制,最坏情况约 59 秒 | 接近实时 |
| 请求量 | 每笔订单最多 11 次查询 | 复用已有长连接,无额外查询请求 |
| 前置依赖 | 只需一个订单状态查询接口 | 需要长连接通道与断线重连 |
| 可靠性 | 客户端主动发起,天然有超时终态 | 依赖连接可用,必须保留兜底补查 |
| 实现成本 | 低 | 较高 |
| 适用场景 | 无长连接、支付频率低 | 已有长连接、对到账实时性敏感 |
OpenPaaS SDK 不提供订单状态的推送回调:sud 对象上没有支付或订单相关的事件接口,sud.requestVirtualPayment 的 success 也只覆盖客户端支付流程。因此方式 B 的推送必须由游戏自有的长连接通道承载,例如 WebSocket。
本文的编排代码以方式 A 为示例,方式 B 在其基础上替换到账触发源,并复用同一套查询逻辑作为兜底。
接口封装
两个接口都是回调式,下面先封装为 Promise 再编排。createOrder 与 queryOrder 是游戏自己的服务端接口,需按各自后端实现替换,不是 OpenPaaS 提供的接口;订单状态字段的取值以各自后端约定为准。
const RETRY_DELAYS_MS = [1000, 2000, 3000, 5000, 8000, 8000, 8000, 8000, 8000, 8000];
function requestVirtualPayment(order) {
return new Promise((resolve, reject) => {
sud.requestVirtualPayment({
signData: order.signData,
paySig: order.paySig,
signature: order.signature,
success: (res) => resolve(res),
fail: (err) => reject(err),
complete: () => {},
});
});
}
function getBalance(env) {
return new Promise((resolve, reject) => {
sud.getBalance({
env,
success: (res) => resolve(res),
fail: (err) => reject(err),
complete: () => {},
});
});
}
// 到账后刷新余额:与订单确认分开处理,失败只影响展示,不影响已到账的事实
async function refreshBalanceWithRetry(env, maxAttempts) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await getBalance(env);
} catch (err) {
console.error('查询余额失败', err.errCode, err.errMsg);
if (attempt < maxAttempts - 1) await wait(1000);
}
}
return null;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}购买入口在两种方式下相同,只有最后一步不同:
let pendingOrderId = '';
async function purchase(productId, quantity, attach) {
if (pendingOrderId) {
console.log('上一笔订单正在确认到账,请稍候');
return;
}
const order = await createOrder(productId, quantity, attach);
if (!order) {
console.error('创建订单失败');
return;
}
try {
const payment = await requestVirtualPayment(order);
console.log('客户端支付流程结束', payment.sudTradeNo);
} catch (err) {
console.error('支付失败或已取消', err.errCode, err.errMsg);
return;
}
// success 不代表已发货,必须继续确认订单状态
// order.orderId 指 Game SVR 下单接口返回的业务订单号,字段名以各自后端约定为准
pendingOrderId = order.orderId;
// 方式 A:立即开始轮询
confirmOrder(order.orderId);
// 方式 B:不启动轮询,等待长连接推送;仅在重连后兜底补查
}方式 A:客户端轮询
轮询参数
下表是可直接采用的一组取值。间隔前密后疏,兼顾到账感知速度与请求量。
| 项 | 取值 |
|---|---|
| 间隔档位(毫秒) | 1000, 2000, 3000, 5000, 8000, 8000, 8000, 8000, 8000, 8000 |
| 查询次数 | 11 次(首轮立即查询,其后按档位等待) |
| 累计等待 | 约 59 秒 |
| 终态 | 已发货 / 已关闭 / 超时 |
| 到账后余额刷新 | 最多重试 3 次,间隔 1000 毫秒 |
轮询编排代码
轮询需要一个可失效的轮询标识。每次启动轮询时递增该标识,并在每一步校验它是否仍然是最新值;页面失活时递增一次即可终止所有在途轮询。
let pollId = 0;
// 页面失活或场景切换时调用,终止在途轮询
function deactivatePaymentPolling() {
pollId += 1;
}
// 页面重新可见时调用,恢复尚未确认的订单
function resumePendingOrder() {
if (pendingOrderId) confirmOrder(pendingOrderId);
}
async function confirmOrder(orderId) {
const currentPollId = ++pollId;
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
if (!isPollActive(currentPollId)) return;
const order = await queryOrder(orderId);
if (!isPollActive(currentPollId)) return;
if (isTerminalStatus(order)) {
pendingOrderId = '';
await handleOrderSettled(order.status);
return;
}
if (attempt === RETRY_DELAYS_MS.length) break;
await wait(RETRY_DELAYS_MS[attempt]);
}
pendingOrderId = '';
console.log('到账确认超时,请稍后手动查询订单状态');
}
function isPollActive(currentPollId) {
return currentPollId === pollId && isPageVisible();
}isPageVisible() 与 queryOrder() 需按各自的引擎和后端实现。
方式 B:服务端推送
推送约束
- 推送会重复到达。断线重连、服务端重试都可能让同一笔订单的终态被推送多次,客户端必须按业务订单号幂等处理,同一笔只触发一次到账。
- 推送不可靠,必须保留兜底。连接未建立、切后台、进程被回收时推送会丢失。重连后应对仍未确认的订单补查一次,即复用方式 A 的
confirmOrder()。 - 不要在推送里下发敏感信息明文。推送只承载订单终态与业务订单号,敏感字段仍走 Game SVR 的鉴权接口。
- 推送到达不等于余额已刷新,同样要走
refreshBalanceWithRetry()。 - 迟到的推送要能被忽略。页面失活后到达的推送不应再写界面。
推送编排代码
到账处理与终态判断在两种方式下是同一套,抽出后由推送和轮询共用:
function isTerminalStatus(order) {
return !!order && (order.status === 'DELIVERED' || order.status === 'CLOSED');
}
async function handleOrderSettled(status) {
if (status === 'DELIVERED') {
const balance = await refreshBalanceWithRetry(0, 3);
if (balance) {
console.log('到账成功,总余额', balance.balance, '赠送币', balance.presentBalance);
} else {
console.log('订单已到账,但余额刷新失败,请稍后手动查询');
}
return;
}
console.log('订单已关闭,未完成到账');
}
// 已处理过终态的业务订单号,用于推送幂等
const settledOrderIds = new Set();
function onOrderStatusPush(message) {
// 页面失活后到达的推送直接忽略
if (!isPageVisible()) return;
const orderId = message.orderId;
const status = message.status;
// 推送可能重复到达,按业务订单号幂等处理
if (settledOrderIds.has(orderId)) return;
if (status !== 'DELIVERED' && status !== 'CLOSED') return;
settledOrderIds.add(orderId);
if (pendingOrderId === orderId) pendingOrderId = '';
handleOrderSettled(status);
}
// 兜底:长连接恢复后,对仍未确认的订单补查一次
function onConnectionRestored() {
if (pendingOrderId) confirmOrder(pendingOrderId);
}onOrderStatusPush 需挂到游戏自有的长连接消息分发上,message 的字段名以各自后端约定为准。若同时启用两种方式,confirmOrder() 里也应写入 settledOrderIds,避免轮询与推送对同一笔订单各处理一次。
每一步都可能失败,应逐步处理并中止后续步骤;不要在最后统一捕获后当作支付成功。
