From 6219a1048b64142d300873deb3fbe51eb73db9f8 Mon Sep 17 00:00:00 2001 From: chen wei bo <1025839511@qq.com> Date: Tue, 19 Aug 2025 18:32:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat18x/network/services/AuthService.ts | 2 +- .../network/services/PlayerDataService.ts | 4 +- assets/Scripts/chat18x/payment.meta | 9 ++ assets/Scripts/chat18x/payment/PaymentApi.ts | 48 +++++++ .../chat18x/payment/PaymentApi.ts.meta | 9 ++ .../Scripts/chat18x/payment/PaymentOpener.ts | 54 ++++++++ .../chat18x/payment/PaymentOpener.ts.meta | 9 ++ .../Scripts/chat18x/payment/PaymentPoller.ts | 57 +++++++++ .../chat18x/payment/PaymentPoller.ts.meta | 9 ++ .../Scripts/chat18x/payment/PaymentService.ts | 117 ++++++++++++++++++ .../chat18x/payment/PaymentService.ts.meta | 9 ++ .../chat18x/payment/PendingOrderRepo.ts | 57 +++++++++ .../chat18x/payment/PendingOrderRepo.ts.meta | 9 ++ assets/Scripts/chat18x/payment/types.ts | 51 ++++++++ assets/Scripts/chat18x/payment/types.ts.meta | 9 ++ 15 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 assets/Scripts/chat18x/payment.meta create mode 100644 assets/Scripts/chat18x/payment/PaymentApi.ts create mode 100644 assets/Scripts/chat18x/payment/PaymentApi.ts.meta create mode 100644 assets/Scripts/chat18x/payment/PaymentOpener.ts create mode 100644 assets/Scripts/chat18x/payment/PaymentOpener.ts.meta create mode 100644 assets/Scripts/chat18x/payment/PaymentPoller.ts create mode 100644 assets/Scripts/chat18x/payment/PaymentPoller.ts.meta create mode 100644 assets/Scripts/chat18x/payment/PaymentService.ts create mode 100644 assets/Scripts/chat18x/payment/PaymentService.ts.meta create mode 100644 assets/Scripts/chat18x/payment/PendingOrderRepo.ts create mode 100644 assets/Scripts/chat18x/payment/PendingOrderRepo.ts.meta create mode 100644 assets/Scripts/chat18x/payment/types.ts create mode 100644 assets/Scripts/chat18x/payment/types.ts.meta diff --git a/assets/Scripts/chat18x/network/services/AuthService.ts b/assets/Scripts/chat18x/network/services/AuthService.ts index be8fcdd8..bfd677da 100644 --- a/assets/Scripts/chat18x/network/services/AuthService.ts +++ b/assets/Scripts/chat18x/network/services/AuthService.ts @@ -18,7 +18,7 @@ export class AuthService { method: "POST", codec: "json", needsAuth: false - }; + }; return this.api.call(epData, req); } } diff --git a/assets/Scripts/chat18x/network/services/PlayerDataService.ts b/assets/Scripts/chat18x/network/services/PlayerDataService.ts index 1a901b3f..dae6c6b7 100644 --- a/assets/Scripts/chat18x/network/services/PlayerDataService.ts +++ b/assets/Scripts/chat18x/network/services/PlayerDataService.ts @@ -18,7 +18,7 @@ export class PlayerDataService { method: "POST", codec: "json", needsAuth: true - }; + }; return this.api.call(epData, req); } @@ -29,7 +29,7 @@ export class PlayerDataService { method: "POST", codec: "json", needsAuth: true - }; + }; return this.api.call(epData, req); } } diff --git a/assets/Scripts/chat18x/payment.meta b/assets/Scripts/chat18x/payment.meta new file mode 100644 index 00000000..3f475b42 --- /dev/null +++ b/assets/Scripts/chat18x/payment.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "3da5f7b4-77ad-4f88-959b-624499248ae0", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/Scripts/chat18x/payment/PaymentApi.ts b/assets/Scripts/chat18x/payment/PaymentApi.ts new file mode 100644 index 00000000..5a3b9fdb --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentApi.ts @@ -0,0 +1,48 @@ +/** + * 和服务器交互的API接口 + * + */ +import { ApiClient } from "../network/client/ApiClient"; +import type { Endpoint } from "../network/client/endpoints"; +import type { ApiResponse } from "../network/client/types"; +import { OrderStatus, CreateOrderReq, CreateOrderData, QueryOrderData } from "./types"; +import proto from 'db://assets/Scripts/proto/proto.pb.js'; + +export class PaymentApi { + private static _I: PaymentApi | null = null; + static get I() { return this._I ?? (this._I = new PaymentApi()); } + private constructor(private api = ApiClient.I) {} + + // 下单 + createOrder(req: CreateOrderReq): Promise> { + let epData: Endpoint = { + path: "pay/create", + method: "POST", + codec: "json", + needsAuth: true, + }; + return this.api.call(epData, req); + } + + // 查询订单 + queryOrder(orderId: string): Promise> { + let epData: Endpoint<{ orderId: string }, { orderId: string; status: OrderStatus; paidAt?: number; failureReason?: string; }> = { + path: "pay/query", + method: "POST", + codec: "json", + needsAuth: true, + }; + return this.api.call(epData, { orderId }); + } + + // 取消订单 + cancelOrder(orderId: string): Promise> { + let epData: Endpoint<{ orderId: string }, { ok: boolean; }> = { + path: "pay/cancel", + method: "POST", + codec: "json", + needsAuth: true, + }; + return this.api.call(epData, { orderId }); + } +} diff --git a/assets/Scripts/chat18x/payment/PaymentApi.ts.meta b/assets/Scripts/chat18x/payment/PaymentApi.ts.meta new file mode 100644 index 00000000..99f6c15d --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentApi.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "c4e3bad2-2588-427a-ba4f-9d839205c37d", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/Scripts/chat18x/payment/PaymentOpener.ts b/assets/Scripts/chat18x/payment/PaymentOpener.ts new file mode 100644 index 00000000..efa8adf7 --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentOpener.ts @@ -0,0 +1,54 @@ +/** + * 打开支付URL(系统浏览器 / 内嵌 WebView / 小游戏 API),可插拔 + * + */ + +import { sys } from "cc"; + +export type OpenStrategy = "system" | "inapp" | "minigame"; + +export interface IPaymentOpener { + open(url: string): Promise; + canReturnSignal(): boolean; // 是否能“自动感知回到App”(inapp/minigame一般可以) +} + +/** 系统浏览器:使用 sys.openURL。无法自动知道用户何时完成,靠“回到前台”事件触发轮询 */ +class SystemBrowserOpener implements IPaymentOpener { + async open(url: string) { sys.openURL(url); } + canReturnSignal() { return false; } +} + +// 如需内嵌 WebView,可封装一个组件并在 open 里显示;此处给空壳 +class InAppWebViewOpener implements IPaymentOpener { + async open(url: string) { + // TODO: 实现你的 WebView 弹窗并加载 url;可拦截 redirect_uri 关闭弹窗 + sys.openURL(url); // 临时:用系统浏览器兜底 + } + canReturnSignal() { return true; } // 拦截回跳时可立即开始轮询 +} + +// 小游戏:使用平台API打开/内嵌webview,回跳后触发事件 +class MiniGameOpener implements IPaymentOpener { + async open(url: string) { + const g: any = globalThis as any; + if (g.tt?.openAwemeUserProfile) { + // 示例:按实际平台API替换 + g.tt.openSchema({ schema: url }); + } else if (g.wx?.openUrl) { + g.wx.openUrl({ url }); + } else { + sys.openURL(url); + } + } + canReturnSignal() { return true; } +} + +export class PaymentOpenerFactory { + static create(strategy: OpenStrategy): IPaymentOpener { + switch (strategy) { + case "inapp": return new InAppWebViewOpener(); + case "minigame": return new MiniGameOpener(); + default: return new SystemBrowserOpener(); + } + } +} diff --git a/assets/Scripts/chat18x/payment/PaymentOpener.ts.meta b/assets/Scripts/chat18x/payment/PaymentOpener.ts.meta new file mode 100644 index 00000000..bc6e0c2c --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentOpener.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "d1bfe39e-0c3f-4c88-ac5d-510ee01c9121", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/Scripts/chat18x/payment/PaymentPoller.ts b/assets/Scripts/chat18x/payment/PaymentPoller.ts new file mode 100644 index 00000000..1da29a15 --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentPoller.ts @@ -0,0 +1,57 @@ +/** + * 轮询器(指数退避+超时+可取消) + * + */ + +import { PaymentApi } from "./PaymentApi"; +import type { QueryOrderData } from "./types"; +import { ApiCode } from "../network/client/types"; + +export interface PollOptions { + maxDurationMs: number; // 轮询总时长(例如 2分钟) + startIntervalMs: number; // 初始间隔(例如 2s) + maxIntervalMs: number; // 最大间隔(例如 10s) + jitter?: boolean; // 抖动 +} + +export class PaymentPoller { + private cancelled = false; + + // 取消轮询 + cancel() { this.cancelled = true; } + + // 轮询订单 + async poll(orderId: string, opt: PollOptions, onTick?: (d: QueryOrderData) => void): Promise { + const t0 = Date.now(); + let interval = opt.startIntervalMs; + while (!this.cancelled) { + // 查询 + const r = await PaymentApi.I.queryOrder(orderId); + if (r.code === ApiCode.OK && r.data) { + // 回调 + onTick?.(r.data); + if (["SUCCESS", "FAILED", "CANCELED", "EXPIRED"].indexOf(r.data.status) !== -1) { + return r.data; + } + } + // 退出条件 + if (Date.now() - t0 > opt.maxDurationMs) return null; + // 等待 + await this.sleep(this.jitter(interval, opt)); + // 每次轮询后,interval 会按照 1.5 倍增长,直到达到最大间隔 maxIntervalMs,避免在短时间内重复请求 + interval = Math.min(interval * 1.5, opt.maxIntervalMs); + } + return null; + } + + // 用来暂停一定的时间(ms 毫秒),并让轮询进入等待状态 + private sleep(ms: number) { return new Promise(res => setTimeout(res, ms)); } + + // 用于实现抖动(随机化轮询间隔) + private jitter(base: number, opt: PollOptions) { + if (!opt.jitter) return base; + const delta = Math.min(500, Math.max(100, base * 0.1)); + return base + (Math.random() * 2 - 1) * delta; + } +} +export default PaymentPoller; diff --git a/assets/Scripts/chat18x/payment/PaymentPoller.ts.meta b/assets/Scripts/chat18x/payment/PaymentPoller.ts.meta new file mode 100644 index 00000000..c902d7dd --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentPoller.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "a5127d19-3cee-4a5f-9ff9-d602dbb48f09", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/Scripts/chat18x/payment/PaymentService.ts b/assets/Scripts/chat18x/payment/PaymentService.ts new file mode 100644 index 00000000..00f4898d --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentService.ts @@ -0,0 +1,117 @@ +/** + * 支付对外的相关接口 + * + */ +import { EventTarget } 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 { + // 下单 + 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 { + const all = PendingOrderRepo.instance.all(); + for (const o of all) { + // 可以做去重/只处理未过期的 + 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); + } + } + } + + /** 仅轮询(用于 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 async waitAppResumeIfNeeded(open: OpenStrategy) { + // 如果是系统浏览器,我们通常等“App 回到前台”再开始轮询; + // 这里简单 sleep 1s,实际项目请监听 Cocos 的 onShow/onHide 或平台回调。 + if (open === "system") { + await new Promise(res => setTimeout(res, 1000)); + } + } +} +export default PaymentService; diff --git a/assets/Scripts/chat18x/payment/PaymentService.ts.meta b/assets/Scripts/chat18x/payment/PaymentService.ts.meta new file mode 100644 index 00000000..5644cd5e --- /dev/null +++ b/assets/Scripts/chat18x/payment/PaymentService.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "4474d3e2-964e-49b9-9793-7edab2f9d007", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/Scripts/chat18x/payment/PendingOrderRepo.ts b/assets/Scripts/chat18x/payment/PendingOrderRepo.ts new file mode 100644 index 00000000..bb946447 --- /dev/null +++ b/assets/Scripts/chat18x/payment/PendingOrderRepo.ts @@ -0,0 +1,57 @@ +/** + * 本地持久化未决订单,断线/重启后可恢复 + * + */ + +import { sys } from "cc"; +import type { PendingOrder } from "./types"; + +export class PendingOrderRepo { + private static _instance: PendingOrderRepo | null = null; // 私有静态实例 + private static KEY = "pay:pending_orders"; // 本地存储的 key + private cache: PendingOrder[] | null = null; // 本地缓存 + + // 私有构造函数,禁止外部直接实例化 + private constructor() {} + + // 获取唯一实例 + public static get instance(): PendingOrderRepo { + if (!PendingOrderRepo._instance) { + PendingOrderRepo._instance = new PendingOrderRepo(); + } + return PendingOrderRepo._instance; + } + + // 加载数据 + private load(): PendingOrder[] { + if (this.cache) return this.cache; // 如果有缓存,直接返回 + const raw = sys.localStorage.getItem(PendingOrderRepo.KEY); // 从 localStorage 获取数据 + if (!raw) return (this.cache = []); // 如果没有数据,返回空数组 + try { return (this.cache = JSON.parse(raw) || []); } catch { return (this.cache = []); } // 解析失败时返回空数组 + } + + // 保存数据 + private save(list: PendingOrder[]) { + this.cache = list; // 更新缓存 + sys.localStorage.setItem(PendingOrderRepo.KEY, JSON.stringify(list)); // 存储到 localStorage + } + + // 添加未决订单 + add(o: PendingOrder) { + const list = this.load(); + list.unshift(o); // 将新订单添加到数组前面 + this.save(list.slice(0, 10)); // 保存最多 10 条订单 + } + + // 删除指定订单 + remove(orderId: string) { + const list = this.load().filter(x => x.orderId !== orderId); // 过滤掉指定订单 + this.save(list); // 更新数据 + } + + // 获取所有未决订单 + all(): PendingOrder[] { + return this.load(); // 返回加载的数据 + } +} + diff --git a/assets/Scripts/chat18x/payment/PendingOrderRepo.ts.meta b/assets/Scripts/chat18x/payment/PendingOrderRepo.ts.meta new file mode 100644 index 00000000..8f4deb0f --- /dev/null +++ b/assets/Scripts/chat18x/payment/PendingOrderRepo.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "5165ac21-6ad4-444b-8575-47a851305a31", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/Scripts/chat18x/payment/types.ts b/assets/Scripts/chat18x/payment/types.ts new file mode 100644 index 00000000..3d190b8f --- /dev/null +++ b/assets/Scripts/chat18x/payment/types.ts @@ -0,0 +1,51 @@ +/** + * 支付相关的类型定义 + * + */ + +export type PaymentChannel = "alipay" | "wxpay" | "stripe" | "paypal" | "url"; + +export type OrderStatus = + | "CREATED" // 已创建,待支付 + | "PENDING" // 服务器处理中 + | "SUCCESS" + | "FAILED" + | "CANCELED" + | "EXPIRED"; + +// 下单的请求数据 +export interface CreateOrderReq { + productId: string; + amount: number; // 分/元按后端定义 + currency: string; // "CNY" / "USD" ... + channel: PaymentChannel; // “alipay/wxpay/stripe/url”等 + extra?: Record; // 透传(区服、活动、角色信息等) +} + +export interface CreateOrderData { + orderId: string; + payUrl: string; // 这次集成的核心 + expireAt?: number; // ms +} + +// 查询订单的响应数据 +export interface QueryOrderData { + orderId: string; + status: OrderStatus; + paidAt?: number; // ms + failureReason?: string; +} + +export interface PaymentResult { + orderId: string; + status: OrderStatus; // SUCCESS / FAILED / CANCELED / EXPIRED + message?: string; // 失败原因/提示 +} + +export interface PendingOrder { + orderId: string; + channel: PaymentChannel; + createdAt: number; + expireAt?: number; + productId?: string; +} diff --git a/assets/Scripts/chat18x/payment/types.ts.meta b/assets/Scripts/chat18x/payment/types.ts.meta new file mode 100644 index 00000000..5c53f7e7 --- /dev/null +++ b/assets/Scripts/chat18x/payment/types.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "5d3e814f-7ee9-463f-a568-c5f721f73498", + "files": [], + "subMetas": {}, + "userData": {} +}