Files
MatchMaster/assets/Script/module/Config/PyramidService.ts
T
2026-10-16 15:02:14 +08:00

381 lines
23 KiB
TypeScript

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<any> = null;
private static prefabBundle: cc.AssetManager.Bundle = null;
private static paymentOnShowRegistered = false;
private static prefabTask: Promise<cc.Prefab> = null;
private static paymentRetryBlocked = false;
private static settlementTask: Promise<boolean> = 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()) || {};
// 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.settlementNotices = [];
}
static settlements(): any[] { this.ensure(); return this.settlementNotices.slice(); }
static dismissSettlement(noticeId: string): void {
this.ensure(); this.settlementNotices = this.settlementNotices.filter(notice => notice.noticeId !== noticeId); this.emit();
}
static readSettlements(): Promise<boolean> {
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" });
if (generation !== this.generation || loginVersion !== this.loginVersion) return true;
const notices = data.notices || [];
// Reading only lists unclaimed notices; inventory changes after an explicit claim.
for (const notice of notices) {
if (!this.settlementNotices.some(existing => existing.noticeId === notice.noticeId)) this.settlementNotices.push(notice);
}
this.emit();
return 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<any> {
this.ensure(); const generation = this.generation, loginVersion = this.loginVersion;
try {
const data = await this.request("pyramid/api", { action: "settlement_claim", periodId: notice.periodId, noticeId: notice.noticeId });
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);
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<cc.AssetManager.Bundle> {
return ActivityBundleLoader.ensureBundle("pyramid");
}
static async acquireResources(): Promise<() => void> {
const lease = await ActivityBundleLoader.acquireBundle("pyramid"); return lease.release;
}
static async prefab(): Promise<cc.Prefab> {
// Preloads also retain their version until Prefab dependencies finish loading.
const lease = await ActivityBundleLoader.acquireBundle("pyramid"), bundle = lease.bundle;
try {
if (bundle !== this.prefabBundle) { this.prefabBundle = bundle; this.prefabTask = null; }
if (!this.prefabTask) this.prefabTask = new Promise<cc.Prefab>((resolve, reject) => {
bundle.load("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 { lease.release(); }
}
private static async request(route: string, body: any = {}): Promise<any> {
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<any>): Promise<boolean> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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);
}
}