381 lines
23 KiB
TypeScript
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);
|
|
}
|
|
}
|