import Utils from "../Pay/Utils"; import ActivityScheduleService from "./ActivityScheduleService"; import ActivityBundleLoader from "./ActivityBundleLoader"; import { MiniGameSdk } from "../../Sdk/MiniGameSdk"; const FIELDS: { [id: string]: string } = { "1001": "coinAmount", "2001": "freezeAmount", "2002": "hammerAmount", "2003": "magicAmount", "2005": "userPowerTime" }; const LOCAL: { [field: string]: string } = { coinAmount: "coin", freezeAmount: "freezeAmount", hammerAmount: "hammerAmount", magicAmount: "magicAmount", userPowerTime: "userPowerTime" }; /** Main-package bridge. All mutations survive a lost response; no generic shopBuy delivery. */ export default class PyramidService { private static identity = ""; private static generation = 0; private static state: any = null; private static journal: any = {}; private static offset = 0; private static listeners: Array<() => void> = []; private static task: Promise = null; private static prefabBundle: cc.AssetManager.Bundle = null; private static paymentOnShowRegistered = false; private static prefabTask: Promise = null; private static resourceUsers = 0; private static resourceLoads = 0; private static releaseQueued = false; private static resourceBundles = new Set(); private static resourceLeases: Array<() => void> = []; private static paymentRetryBlocked = false; private static settlementTask: Promise = null; private static settlementNotices: any[] = []; private static loginVersion = 0; private static readonly paymentPollDelays = [3000, 3000, 3000, 4000, 4000, 4000, 4000, 4000]; static busy = false; static deliveryBusy = false; static paymentConfirming = false; static error = ""; static lastDraw: any = null; private static ensure(): void { const info = cc.fx.GameConfig.GM_INFO; const identity = Utils.apiBaseUrl() + ":" + String(info.uid || "") + ":" + String(info.token || ""); if (identity === this.identity) return; this.identity = identity; this.generation++; this.state = null; this.task = null; this.busy = false; this.deliveryBusy = false; this.paymentConfirming = false; this.paymentRetryBlocked = false; this.error = ""; this.lastDraw = null; this.offset = 0; this.settlementTask = null; this.settlementNotices = []; this.loginVersion++; this.journal = cc.fx.StorageMessage.getStorage(this.storageKey()) || {}; this.restoreSettlementNotices(); // Legacy unpaid orders must not start polling or prevent a fresh purchase. if (this.journal.order) { delete this.journal.order; this.save(); } } private static storageKey(): string { return "pyramid:v1:" + Utils.apiBaseUrl() + ":" + String(cc.fx.GameConfig.GM_INFO.uid || ""); } private static save(): void { cc.fx.StorageMessage.setStorage(this.storageKey(), this.journal); } static data(): any { this.ensure(); if (!this.state) return null; const pending = this.journal.confirmingOrder, drawCount = Number(this.state.drawCount) || 0; return { ...this.state, pendingOrder: pending || null, canPay: this.state.canPay && !pending, canDraw: this.state.canDraw && drawCount > 0 && !pending, canClaimFinal: this.state.canClaimFinal && !pending }; } static now(): number { return Date.now() + this.offset; } static resetLogin(): void { this.ensure(); this.loginVersion++; this.settlementTask = null; this.restoreSettlementNotices(); } private static restoreSettlementNotices(): void { const records = this.journal.settlements || {}; this.settlementNotices = Object.keys(records).filter(id => records[id].phase === "ready") .map(id => records[id].notice); } static settlements(): any[] { this.ensure(); return this.settlementNotices.slice(); } static dismissSettlement(noticeId: string): void { this.ensure(); const record = this.journal.settlements?.[noticeId]; if (!record || record.phase !== "ready") return; // Keep a small receipt so a stale read cannot re-open an acknowledged notice. this.journal.settlements[noticeId] = { phase: "dismissed" }; this.save(); this.restoreSettlementNotices(); this.emit(); } static readSettlements(): Promise { this.ensure(); if (this.settlementTask) return this.settlementTask; const generation = this.generation, loginVersion = this.loginVersion; const task = (async () => { try { const data = await this.request("pyramid/api", { action: "settlement_read" }); this.ensure(); if (generation !== this.generation || loginVersion !== this.loginVersion) return true; const records = this.journal.settlements || (this.journal.settlements = {}); for (const notice of data.notices || []) { if (!records[notice.noticeId]) records[notice.noticeId] = { phase: "pending", notice }; } // Persist before delivery: settlement_claim also marks the server notice read. this.save(); let delivered = true; for (const id of Object.keys(records)) { if (records[id].phase !== "pending") continue; try { await this.claimSettlement(records[id].notice); } catch (error) { delivered = false; cc.warn("[Pyramid] 自动补发失败,保留记录重试", error); } this.ensure(); if (generation !== this.generation || loginVersion !== this.loginVersion) return true; } this.restoreSettlementNotices(); this.emit(); return delivered && data.status !== "settling"; } catch (error) { cc.warn("[Pyramid] 补发清单查询失败", error); return false; } finally { if (generation === this.generation && loginVersion === this.loginVersion) this.settlementTask = null; } })(); this.settlementTask = task; return task; } static async claimSettlement(notice: any): Promise { this.ensure(); const generation = this.generation, loginVersion = this.loginVersion; const records = this.journal.settlements || (this.journal.settlements = {}); if (records[notice.noticeId]?.phase === "ready") return records[notice.noticeId].notice; records[notice.noticeId] = { phase: "pending", notice }; this.save(); try { const data = await this.request("pyramid/api", { action: "settlement_claim", periodId: notice.periodId, noticeId: notice.noticeId }); this.ensure(); if (generation !== this.generation || loginVersion !== this.loginVersion) throw new Error("账号已切换,请重新登录"); this.applyInventory(data.inventory, data.notice.goods); this.recordCoinTask("settlement:" + notice.noticeId, data.notice.goods); records[notice.noticeId] = data.notice.goods.length ? { phase: "ready", notice: data.notice } : { phase: "dismissed" }; this.save(); this.restoreSettlementNotices(); this.emit(); return data.notice; } catch (error) { if (generation === this.generation && loginVersion === this.loginVersion) this.error = this.friendly(error); throw error; } } static subscribe(fn: () => void): () => void { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter(x => x !== fn); }; } private static emit(): void { this.listeners.slice().forEach(fn => fn()); } static requestId(): string { return "pyr-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); } static bundle(): Promise { return ActivityBundleLoader.ensureBundle("pyramid"); } static async acquireResources(): Promise<() => void> { // Reserve before awaiting, including a new open racing with a queued release. this.resourceUsers++; try { const lease = await ActivityBundleLoader.acquireBundle("pyramid"); this.resourceBundles.add(lease.bundle); let released = false; return () => { if (released) return; released = true; this.resourceUsers--; // Keep the version pinned until every late asset callback has finished. this.resourceLeases.push(lease.release); this.queueResourceRelease(); }; } catch (error) { this.resourceUsers--; this.queueResourceRelease(); throw error; } } static loadAsset(bundle: cc.AssetManager.Bundle, path: string, type: any, done: (error: Error, asset: any) => void): void { this.resourceLoads++; let finished = false; const finish = (error: Error, asset?: any) => { if (finished) return; finished = true; try { done(error, asset); } finally { this.resourceLoads--; this.queueResourceRelease(); } }; try { bundle.load(path, type, finish); } catch (error) { if (finished) throw error; finish(error); } } private static queueResourceRelease(): void { if (this.resourceUsers || this.resourceLoads || this.releaseQueued || !this.resourceLeases.length) return; this.releaseQueued = true; cc.director.once(cc.Director.EVENT_AFTER_DRAW, () => { this.releaseQueued = false; if (this.resourceUsers || this.resourceLoads) return; this.prefabTask = null; this.prefabBundle = null; this.resourceBundles.forEach(bundle => bundle.releaseAll()); this.resourceBundles.clear(); this.resourceLeases.splice(0).forEach(release => release()); }); } static async prefab(): Promise { // Preloads also retain their version until Prefab dependencies finish loading. const release = await this.acquireResources(); try { const bundle = await this.bundle(); if (bundle !== this.prefabBundle) { this.prefabBundle = bundle; this.prefabTask = null; } if (!this.prefabTask) this.prefabTask = new Promise((resolve, reject) => { this.loadAsset(bundle, "prefab/Pyramid", cc.Prefab, (error, prefab: cc.Prefab) => { if (error) reject(error); else resolve(prefab); }); }).catch(error => { this.prefabTask = null; throw error; }); return await this.prefabTask; } finally { release(); } } private static async request(route: string, body: any = {}): Promise { this.ensure(); const generation = this.generation; const info = cc.fx.GameConfig.GM_INFO, uid = String(info.uid || ""), token = info.token; if (!uid || !token) throw Object.assign(new Error("请重新登录"), { errorCode: "AUTH_INVALID" }); const response: any = await new Promise(resolve => Utils.POST(route, { ...body, uid, token }, resolve, { json: true, timeoutMs: 8000 })); this.ensure(); if (generation !== this.generation) throw new Error("账号已切换"); if (!response || response.code !== 1) throw Object.assign(new Error("活动操作尚未完成,请重试"), response || {}); if (response.data && Number.isFinite(response.data.serverTime)) this.offset = response.data.serverTime - Date.now(); return response.data; } private static async exclusive(action: () => Promise): Promise { this.ensure(); if (this.task) return this.task; const generation = this.generation; this.busy = true; this.error = ""; this.emit(); const task = (async () => { try { await action(); return true; } catch (error) { if (generation === this.generation) this.error = this.friendly(error); return false; } finally { if (generation === this.generation) { this.busy = false; this.task = null; this.emit(); } } })(); this.task = task; return task; } private static friendly(error: any): string { if (typeof error.msg === "string" && /[\u4e00-\u9fff]/.test(error.msg)) return error.msg; const messages = { AUTH_INVALID: "请重新登录", ACTIVITY_ENDED: "本期活动已结束", PAYMENT_PENDING: "支付确认中,可稍后返回", PROFILE_UNAVAILABLE: "付费资料正在同步,请稍后再试", INSUFFICIENT_TIME: "本期剩余时间不足,下期再来", AUDIENCE_EXCLUDED: "本期活动暂未开放", DELIVERY_PENDING: "奖励正在到账,请稍候", CONFIG_INVALID: "活动配置暂不可用", LEVEL_MISMATCH: "活动进度已更新,请刷新", PAYMENT_ENV_MISMATCH: "当前活动与支付环境不匹配", INVENTORY_CHANGED: "道具数据已更新,请重试", GRANT_NOT_FOUND: "奖励记录暂不可用" }; return messages[error.errorCode] || "网络或活动状态暂未确认,请重试"; } private static async refresh(force = true): Promise { const generation = this.generation; const periodId = ActivityScheduleService.active("pyramid")?.periodId || this.state?.periodId; if (!force && this.state && this.state.periodId === periodId) return; const data = await this.request("pyramid/api", { action: "info", ...(periodId ? { periodId } : {}) }); this.ensure(); if (generation !== this.generation) throw new Error("账号已切换"); this.state = data; this.emit(); } private static async resumeOperation(): Promise { const op = this.journal.operation; if (!op) return; try { const data = await this.request("pyramid/api", { ...op.body, action: op.action }); if (op.action === "draw") this.lastDraw = data; delete this.journal.operation; this.save(); } catch (error) { // Only explicit non-committed business rejection releases an operation ID. if (["ACTIVITY_ENDED", "LEVEL_MISMATCH", "NO_DRAW_CHANCE", "FINAL_NOT_REACHED", "ALREADY_COMPLETED"].includes(error.errorCode)) { delete this.journal.operation; this.save(); } else throw error; } } static sync(retryPayment = false, force = true): Promise { return this.exclusive(async () => { const recovering = !!this.journal.operation; await this.resumeOperation(); await this.refresh(force || recovering); await this.confirmPayment(retryPayment); }); } private static async confirmPayment(retry = false): Promise { const pending = this.journal.confirmingOrder; if (!pending) return; if (this.paymentRetryBlocked && !retry) return; this.paymentRetryBlocked = false; const generation = this.generation; this.paymentConfirming = true; this.emit(); try { for (let attempt = 0; attempt <= this.paymentPollDelays.length; attempt++) { let result: any = null; try { result = pending.channel === "ios_customer" ? await this.request("wx/iosgetPayInfo", { outTradeNo: pending.orderId }) : await this.request("pyramid/api", { action: "query", orderId: pending.orderId }); } catch (error) { this.ensure(); if (generation !== this.generation) return; cc.warn("[Pyramid] 支付结果查询失败,继续重试", error); } if (result && (result.paymentStatus === "succeeded" || result.paymentStatus === "closed")) { if (result.paymentStatus === "succeeded") { try { const price = Number(result.goodsPrice ?? pending.price) * Number(result.itemCount ?? 1); const productId = result.itemid || pending.productId; if (!Number.isSafeInteger(price) || price <= 0 || !productId) throw new Error("订单上报信息缺失"); MiniGameSdk.API.yinli_Pay(price, pending.orderId, productId); } catch (error) { cc.warn("[Pyramid] 付费上报失败", error); } } delete this.journal.confirmingOrder; this.paymentRetryBlocked = false; this.save(); await this.refresh(); return; } if (attempt < this.paymentPollDelays.length) await new Promise(resolve => setTimeout(resolve, this.paymentPollDelays[attempt])); this.ensure(); if (generation !== this.generation) return; } this.paymentRetryBlocked = true; } finally { if (generation === this.generation) { this.paymentConfirming = false; this.emit(); } } } private static registerPaymentOnShow(platform: any): void { if (this.paymentOnShowRegistered || !platform || !platform.onShow) return; this.paymentOnShowRegistered = true; platform.onShow(() => { this.ensure(); if (this.journal.confirmingOrder?.channel === "ios_customer" && !this.busy) void this.sync(true); }); } private static async openIosCustomerPayment(platform: any, state: any, level: any, generation: number, orderId: string): Promise { const pending = { orderId, periodId: state.periodId, level: state.currentLevel, channel: "ios_customer", price: level.itemPrice, productId: level.itemId }; this.journal.confirmingOrder = pending; this.save(); this.emit(); const opened: any = await new Promise(resolve => Utils.GoKEFu({ outTradeNo: orderId, price: level.itemPrice, payment_name: level.itemId, payment_count: 1, activityType: "pyramid", periodId: state.periodId, level: state.currentLevel, }, resolve)); this.ensure(); if (generation !== this.generation) return; if (opened !== "success") { if (this.journal.confirmingOrder?.orderId === orderId) { delete this.journal.confirmingOrder; this.save(); this.emit(); } throw new Error("客服支付入口打开失败"); } this.registerPaymentOnShow(platform); await this.confirmPayment(true); } static async recordPriceView(): Promise { const s = this.data(); if (!s || !s.manifestVersion || this.now() >= s.endAt) return; try { await this.request("pyramid/api", { action: "price_detail_view", periodId: s.periodId, manifestVersion: s.manifestVersion }); } catch { /* Analytics must not interrupt drawing or delivery. */ } } static buy(): Promise { return this.exclusive(async () => { const state = this.data(), generation = this.generation; if (!state || !state.canPay || this.now() >= state.endAt || this.journal.operation) return; const level = state.levels[state.currentLevel - 1]; const platform: any = typeof window !== "undefined" && (window as any).wx; if (!platform) throw new Error("支付仅在微信内可用"); const system = platform.getSystemInfoSync(), ios = /ios/i.test(String(system.platform) + String(system.system)); const result: any = await new Promise(resolve => Utils.buyProp(level.itemId, 1, level.itemPrice, ios ? "ios" : "android", "金字塔", resolve, undefined, undefined, undefined, { json: true, params: { uid: cc.fx.GameConfig.GM_INFO.uid, activityType: "pyramid", periodId: state.periodId, level: state.currentLevel, requestId: this.requestId(), outTradeNo: Utils.createPaymentOrderNo() }, isCurrent: () => { this.ensure(); return generation === this.generation; } })); this.ensure(); if (generation !== this.generation) return; if (ios && Number(result.errCode) === 16 && result.outTradeNo) { await this.openIosCustomerPayment(platform, state, level, generation, result.outTradeNo); return; } // if (result.paymentFailed) { platform.showToast({ title: "取消充值", icon: "none" }); return; } if (result.err) throw Object.assign(new Error(result.err), result); // Only the SDK success callback creates durable payment confirmation work. this.journal.confirmingOrder = { orderId: result.outTradeNo, periodId: state.periodId, level: state.currentLevel, price: level.itemPrice, productId: level.itemId }; this.paymentRetryBlocked = false; this.save(); this.emit(); await this.confirmPayment(true); }); } static act(action: "draw" | "claim"): Promise { return this.exclusive(async () => { const s = this.data(); if (!s) return; if (!this.journal.operation) { if (!(action === "draw" ? s.canDraw : s.canClaimFinal) || this.now() >= s.endAt) return; this.journal.operation = { action, body: { periodId: s.periodId, level: s.currentLevel, requestId: this.requestId() } }; this.save(); } await this.resumeOperation(); await this.refresh(); }); } static async pendingDelivery(): Promise { this.ensure(); const data = await this.request("pyramid/api", { action: "delivery_list", limit: 1 }); return data.grants.length > 0; } /** Called with a home popup blocker held, preventing gameplay writes during inventory persistence. */ static deliver(): Promise { return this.exclusive(async () => { const generation = this.generation; this.deliveryBusy = true; this.emit(); try { for (; ;) { const page = await this.request("pyramid/api", { action: "delivery_list", limit: 20 }); if (!page.grants.length) break; for (const grant of page.grants) { let saved: any = null; for (let retry = 0; retry < 3; retry++) { const prepared = await this.request("pyramid/api", { action: "delivery_save", phase: "prepare", grantId: grant.grantId }); if (prepared.saved) { saved = prepared; break; } const next = { ...prepared.inventory }, calculatedAt = prepared.serverTime; grant.goods.forEach(g => { const f = FIELDS[String(g.id)]; if (!f) throw new Error("未知道具"); next[f] = f === "userPowerTime" ? Math.max(next[f], Math.floor(calculatedAt / 1000)) + g.count : next[f] + g.count; }); try { saved = await this.request("pyramid/api", { action: "delivery_save", phase: "save", grantId: grant.grantId, base: prepared.inventory, inventory: next, calculatedAt }); break; } catch (error) { if (error.errorCode !== "INVENTORY_CHANGED") throw error; } } if (!saved) throw new Error("道具数据更新,请重试"); this.applyInventory(saved.inventory, grant.goods); this.recordCoinTask("grant:" + grant.grantId, grant.goods); await this.trackCollection(grant); await this.request("pyramid/api", { action: "delivery_confirm", grantId: grant.grantId }); } } await this.refresh(); } finally { if (generation === this.generation) this.deliveryBusy = false; } }); } private static recordCoinTask(key: string, goods: any[]): void { if (this.journal.coinTasks?.[key]) return; const amount = goods.reduce((sum, good) => sum + (String(good.id) === "1001" ? good.count : 0), 0); if (!Number.isSafeInteger(amount) || amount <= 0) return; // 库存同步和确认可能重试,在现有账号/服务器日志中保留一个统计标记即可。 this.journal.coinTasks = { ...this.journal.coinTasks, [key]: true }; cc.fx.GameTool.recordActivityCoin(amount); try { this.save(); } catch (error) { cc.warn("[Pyramid] 金币任务统计标记保存失败", error); } } private static async trackCollection(grant: any): Promise { if (this.journal.collectedGrants?.[grant.grantId]) return; const generation = this.generation; try { // Recovery may deliver an older period; its tier and slot positions belong to that period. const state = this.state?.periodId === grant.periodId ? this.state : await this.request("pyramid/api", { action: "info", periodId: grant.periodId }); const tier = Number(state?.offerTier); if (!Number.isInteger(tier) || tier < 1 || tier > 4) return; let level: number, position: number; if (grant.source === "final_claim" || grant.source === "expiry_settlement") { level = 6; position = 1; } else { const row = (state.levels || []).find(row => (row.rewards || []).some(reward => reward.rewardId === grant.rewardIds?.[0])); if (!row) return; level = row.level; position = row.rewards.findIndex(reward => reward.rewardId === grant.rewardIds[0]) + 1; } if (!Number.isInteger(level) || level < 1 || level > 6 || position < 1 || position > 6) return; cc.fx.GameTool.shushu_Track("collect_pyramid", { tier, level, position }); this.journal.collectedGrants = { ...this.journal.collectedGrants, [grant.grantId]: true }; this.save(); } catch (error) { if (generation !== this.generation) throw error; cc.warn("[Pyramid] 领奖埋点上报失败", error); } } private static applyInventory(inventory: any, goods: any[]): void { const info = cc.fx.GameConfig.GM_INFO, timestamp = Date.now(); const prop = cc.fx.StorageMessage.getStorage("prop") || {}; goods.forEach(g => { const field = FIELDS[String(g.id)], local = LOCAL[field], value = inventory[field]; if (!Number.isSafeInteger(value) || value < 0) throw new Error("无效库存"); info[local] = value; if (field === "coinAmount") cc.fx.StorageMessage.setStorage("coin", { coin: value, timestamp }); else if (field === "userPowerTime") cc.fx.StorageMessage.setStorage("userPowerTime", value); else prop[local] = value; }); prop.timestamp = timestamp; cc.fx.StorageMessage.setStorage("prop", prop); } }