Files
18xchat/assets/Scripts/chat18x/payment/PaymentService.ts
T
2025-08-20 10:42:02 +08:00

168 lines
5.8 KiB
TypeScript

/**
* 支付对外的相关接口
*
*/
import { EventTarget, game, Game } from "cc";
import { PaymentApi } from "./PaymentApi";
import { PaymentOpenerFactory, type OpenStrategy } from "./PaymentOpener";
import { PendingOrderRepo } from "./PendingOrderRepo";
import PaymentPoller from "./PaymentPoller";
import type { CreateOrderReq, PaymentResult, PendingOrder, OrderStatus } from "./types";
import { ApiCode } from "../network/client/types";
export const PaymentEvents = {
Started: "pay-started", // { orderId }
UrlOpened: "pay-url-opened",
PollTick: "pay-poll-tick", // { orderId, status }
Finished: "pay-finished", // PaymentResult
};
export class PaymentService {
private static _I: PaymentService | null = null;
static get I() { return this._I ?? (this._I = new PaymentService()); }
private constructor() {}
private bus = new EventTarget();
on(event: string, cb: (...args: any[]) => void, target?: any) {
this.bus.on(event, cb, target);
}
off(event: string, cb: (...args: any[]) => void, target?: any) {
this.bus.off(event, cb, target);
}
/** 发起支付:创建订单→打开URL→回到App后开始轮询 */
async startPayment(req: CreateOrderReq, open: OpenStrategy = "system"): Promise<PaymentResult> {
// 下单
const create = await PaymentApi.I.createOrder(req);
if (create.code !== ApiCode.OK || !create.data) {
return { orderId: "", status: "FAILED", message: create.msg || "create order failed" };
}
const { orderId, payUrl, expireAt } = create.data;
this.bus.emit(PaymentEvents.Started, { orderId });
// 存为未决订单(用于异常恢复)
PendingOrderRepo.instance.add({ orderId, channel: req.channel, createdAt: Date.now(), expireAt, productId: req.productId });
// 打开支付页
await PaymentOpenerFactory.create(open).open(payUrl);
this.bus.emit(PaymentEvents.UrlOpened, { orderId });
// 等待回到前台(系统浏览器策略下)
// - 如果是 inapp/minigame,可在 WebView/回跳时立即开始轮询;
// - 这里给一个通用的“等待前台”方法(可用 Cocos 的 onShow/onHide 自行接入)
await this.waitAppResumeIfNeeded(open);
// 轮询
const poller = new PaymentPoller();
const data = await poller.poll(orderId, {
maxDurationMs: 2 * 60 * 1000,
startIntervalMs: 2000,
maxIntervalMs: 10000,
jitter: true,
}, (d) => this.bus.emit(PaymentEvents.PollTick, { orderId, status: d.status }));
// 产出结果
let res: PaymentResult;
if (!data) {
res = { orderId, status: "EXPIRED", message: "poll timeout" };
} else if (data.status === "SUCCESS") {
res = { orderId, status: "SUCCESS" };
} else {
res = { orderId, status: data.status as OrderStatus, message: data.failureReason };
}
PendingOrderRepo.instance.remove(orderId);
this.bus.emit(PaymentEvents.Finished, res);
return res;
}
/** App 回到前台后恢复未完成订单的轮询(在 onShow 时调用) */
async resumePending(): Promise<void> {
const now = Date.now();
const list = PendingOrderRepo.instance.all();
// 去重:同 orderId 只保留 createdAt 最新的一条
const uniq = new Map<string, PendingOrder>();
for (const o of list) {
const ex = uniq.get(o.orderId);
if (!ex || (o.createdAt ?? 0) > (ex.createdAt ?? 0)) {
uniq.set(o.orderId, o);
}
}
for (const o of uniq.values()) {
// 只处理未过期
if (this.isOrderExpired(o, now)) {
PendingOrderRepo.instance.remove(o.orderId);
this.bus.emit(PaymentEvents.Finished, {
orderId: o.orderId,
status: "EXPIRED" as OrderStatus,
message: "order expired",
} as PaymentResult);
continue;
}
// 轮询最新且未过期的订单
const r = await this.startPollingOnly(o.orderId);
if (r && r.status !== "PENDING" && r.status !== "CREATED") {
PendingOrderRepo.instance.remove(o.orderId);
this.bus.emit(PaymentEvents.Finished, {
orderId: o.orderId,
status: r.status as OrderStatus,
message: r.failureReason,
} as PaymentResult);
}
// r === null(轮询超时)时保留在仓库,等待下一次 resume 再查
}
}
/** 判断订单是否过期:有 expireAt 且 now >= expireAt 才认为过期 */
private isOrderExpired(o: PendingOrder, now = Date.now()): boolean {
return typeof o.expireAt === "number" && now >= o.expireAt;
}
/** 仅轮询(用于 resumePending 或手动刷新) */
private async startPollingOnly(orderId: string) {
const poller = new PaymentPoller();
return poller.poll(orderId, {
maxDurationMs: 2 * 60 * 1000,
startIntervalMs: 2000,
maxIntervalMs: 8000,
jitter: true,
});
}
/** 等待“回到前台”的占位实现:实际请在 App 生命周期里调用 resumePending() */
private waitAppResumeIfNeeded(open: OpenStrategy): Promise<void> {
if (open !== "system") {
// 内嵌 WebView / 小游戏平台通常可以在回调里直接开始轮询
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
// 安全移除监听
game.off(Game.EVENT_SHOW, onShow, this);
resolve();
};
const onShow = () => {
// 应用回到前台
finish();
};
// 监听一次“回到前台”
game.on(Game.EVENT_SHOW, onShow, this);
// 兜底:某些环境(桌面浏览器)可能不会触发隐藏/显示事件,避免一直卡住
const FALLBACK_MS = 15000; // 按需调整
setTimeout(finish, FALLBACK_MS);
});
}
}
export default PaymentService;