Skip to content

支付流程 ​

本页给出从下单到确认到账的完整链路,以及可直接复制到游戏脚本中的编排代码。各接口的参数与返回值定义见 API 总览。

角色 ​

角色职责
Game游戏客户端脚本,调用 sud.* 接口,只透传签名产物
SUD OPOpenPaaS 运行环境,唤起平台支付
Game SVR游戏服务端,生成业务订单与三个签名产物、接收发货回调、处理扣币与退款
SUD OP SVROpenPaaS 服务端,确认真实资金状态并下发货或关单指令

完整链路 ​

Phase 1 到 Phase 3 与到账确认方式无关,两种方式只在 Phase 4 不同。

关键约束 ​

  1. 三个签名产物必须由 Game SVR 生成,客户端不参与 signData 拼装与签名计算,也不接触 pay_sign_key 与 session_key。
  2. success 回调不代表已发货,只代表客户端支付流程结束。严禁据此直接发放资产;发货以 Game SVR 收到的服务端回调为准。
  3. 业务订单号全局唯一,发货必须幂等。服务端回调可能因网络重试而重复到达,同一笔订单只允许成功发货一次,重复请求也要返回成功。
  4. 同一笔订单不要并发发起支付。上一笔尚未确认到账时应阻断新的下单请求,否则会同时存在多笔待确认订单,无法判断哪一笔到账。
  5. 到账与读到余额是两件事。服务端确认发货后余额可能有短暂延迟,余额查询失败应重试,不要据此判定到账失败。
  6. 到账确认逻辑必须可终止。页面失活、场景切换或用户退出时要能停止轮询或忽略迟到的推送,避免写入已销毁的界面。
  7. 无论轮询还是推送,客户端都要按业务订单号幂等处理。重复的查询结果或重复的推送都只允许触发一次到账处理。

到账确认方式 ​

Phase 4 有两种实现,二者可以互换,也可以并存(推送为主、轮询兜底)。

维度方式 A:客户端轮询方式 B:服务端推送
实时性受间隔档位限制,最坏情况约 59 秒接近实时
请求量每笔订单最多 11 次查询复用已有长连接,无额外查询请求
前置依赖只需一个订单状态查询接口需要长连接通道与断线重连
可靠性客户端主动发起,天然有超时终态依赖连接可用,必须保留兜底补查
实现成本低较高
适用场景无长连接、支付频率低已有长连接、对到账实时性敏感

OpenPaaS SDK 不提供订单状态的推送回调:sud 对象上没有支付或订单相关的事件接口,sud.requestVirtualPayment 的 success 也只覆盖客户端支付流程。因此方式 B 的推送必须由游戏自有的长连接通道承载,例如 WebSocket。

本文的编排代码以方式 A 为示例,方式 B 在其基础上替换到账触发源,并复用同一套查询逻辑作为兜底。

接口封装 ​

两个接口都是回调式,下面先封装为 Promise 再编排。createOrder 与 queryOrder 是游戏自己的服务端接口,需按各自后端实现替换,不是 OpenPaaS 提供的接口;订单状态字段的取值以各自后端约定为准。

javascript
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));
}

购买入口在两种方式下相同,只有最后一步不同:

javascript
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 毫秒

轮询编排代码 ​

轮询需要一个可失效的轮询标识。每次启动轮询时递增该标识,并在每一步校验它是否仍然是最新值;页面失活时递增一次即可终止所有在途轮询。

javascript
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:服务端推送 ​

推送约束 ​

  1. 推送会重复到达。断线重连、服务端重试都可能让同一笔订单的终态被推送多次,客户端必须按业务订单号幂等处理,同一笔只触发一次到账。
  2. 推送不可靠,必须保留兜底。连接未建立、切后台、进程被回收时推送会丢失。重连后应对仍未确认的订单补查一次,即复用方式 A 的 confirmOrder()。
  3. 不要在推送里下发敏感信息明文。推送只承载订单终态与业务订单号,敏感字段仍走 Game SVR 的鉴权接口。
  4. 推送到达不等于余额已刷新,同样要走 refreshBalanceWithRetry()。
  5. 迟到的推送要能被忽略。页面失活后到达的推送不应再写界面。

推送编排代码 ​

到账处理与终态判断在两种方式下是同一套,抽出后由推送和轮询共用:

javascript
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,避免轮询与推送对同一笔订单各处理一次。

每一步都可能失败,应逐步处理并中止后续步骤;不要在最后统一捕获后当作支付成功。