454 lines
29 KiB
TypeScript
454 lines
29 KiB
TypeScript
import Utils from '../module/Pay/Utils';
|
||
declare const wx: any;
|
||
|
||
/** Resident protocol client; contains no activity bundle assets. */
|
||
export default class GoldMinerService {
|
||
private static clients: { [key: string]: GoldMinerService } = {};
|
||
static enabled() { return !CC_PREVIEW && typeof wx !== 'undefined'; }
|
||
static testEnabled(): boolean {
|
||
if (!this.enabled()) return false;
|
||
const version = cc.fx.GameTool.getWechatGameVersion();
|
||
const normalize = value => String(value || '').replace(/\/+$/, '');
|
||
return ['开发版', '体验版'].indexOf(version) >= 0 && !!Utils.testHttpip
|
||
&& normalize(Utils.serverUrl()) === normalize(Utils.testHttpip)
|
||
&& normalize(Utils.serverUrl()) !== normalize(Utils.httpip);
|
||
}
|
||
static current(): GoldMinerService {
|
||
if (!this.enabled()) return null;
|
||
const user = cc.fx.GameConfig.GM_INFO;
|
||
if (!user.uid || !user.token) return null;
|
||
const key = Utils.serverUrl() + ':' + user.uid;
|
||
return this.clients[key] || (this.clients[key] = new GoldMinerService(String(user.uid), key));
|
||
}
|
||
info: any = null;
|
||
schedule: any = null;
|
||
private settlementCursor: string = null;
|
||
message = '';
|
||
refreshNeedsRetry = false;
|
||
get serverNow() { return Date.now() + this.offset; }
|
||
private offset = 0;
|
||
private tail: Promise<any> = Promise.resolve();
|
||
private refreshing: Promise<any> = null;
|
||
private displayReady: Promise<any> = null;
|
||
private ledger: any;
|
||
private polling = 0;
|
||
private constructor(private uid: string, private scope: string) {
|
||
const raw = cc.sys.localStorage.getItem('goldMiner:v1:' + scope);
|
||
this.ledger = raw ? JSON.parse(raw) : { orders: {}, grants: {}, events: {}, notices: {}, sequence: 0 };
|
||
cc.game.on(cc.game.EVENT_HIDE, () => this.stopPolling());
|
||
}
|
||
private active() { return String(cc.fx.GameConfig.GM_INFO.uid) === this.uid && Utils.serverUrl() + ':' + this.uid === this.scope; }
|
||
private persist() { cc.sys.localStorage.setItem('goldMiner:v1:' + this.scope, JSON.stringify(this.ledger)); }
|
||
private id(prefix: string) { return prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 12); }
|
||
private serial<T>(work: () => Promise<T>): Promise<T> {
|
||
const result = this.tail.then(work);
|
||
this.tail = result.catch(() => undefined);
|
||
return result;
|
||
}
|
||
private async post(path: string, body: any, authenticated = true): Promise<any> {
|
||
if (!this.active()) throw new Error('账号已切换,请重新打开活动');
|
||
const startedAt = Date.now(), url = Utils.serverUrl() + path;
|
||
let response: any;
|
||
const request = authenticated ? { ...body, uid: this.uid } : body;
|
||
this.iosPaymentLog('request', { url, request });
|
||
try { response = await new Promise(resolve => Utils.POSTJSON(path, request, resolve, authenticated)); }
|
||
catch (error) {
|
||
this.iosPaymentLog('request exception', { url, code: error.code, errCode: error.errCode, message: error.message });
|
||
throw error;
|
||
}
|
||
this.iosPaymentLog('response', { url, action: body && body.action, durationMs: Date.now() - startedAt,
|
||
code: response && response.code, errorCode: response && response.errorCode, response });
|
||
if (!this.active()) throw new Error('账号已切换,请重新打开活动');
|
||
if (!response || response.code !== 1) {
|
||
const error: any = new Error(response && (response.msg || response.message || response.errorCode) || '网络请求失败,请重试');
|
||
error.code = response && response.errorCode;
|
||
// The deployed query endpoint uses this response for unconfirmed payment.
|
||
if (!error.code && (path === 'wx/getPayInfo' || path === 'wx/iosgetPayInfo')
|
||
&& response && response.code === 0 && response.data && response.data.pay_state === 1
|
||
&& response.msg === '充值未成功') error.code = 'PAYMENT_PENDING';
|
||
if (error.code === 'PAYMENT_PENDING') error.paymentPending = true;
|
||
throw error;
|
||
}
|
||
return response.data;
|
||
}
|
||
testAvailable() { return GoldMinerService.testEnabled() && this.active(); }
|
||
private iosPaymentLog(event: string, detail: any) {
|
||
try {
|
||
if (typeof wx === 'undefined' || wx.getSystemInfoSync().platform !== 'ios') return;
|
||
const entry = '[GoldMiner iOS] ' + event + ' ' + JSON.stringify(this.testSafe({ time: new Date().toISOString(), ...detail }), null, 2);
|
||
console.log(entry);
|
||
} catch (_) { /* Diagnostics must never interrupt payment. */ }
|
||
}
|
||
private testSafe(value: any): any {
|
||
if (Array.isArray(value)) return value.map(item => this.testSafe(item));
|
||
if (value && typeof value === 'object') {
|
||
const result = {};
|
||
Object.keys(value).forEach(key => result[key] = /token|signature|paySig|signData|sessionFrom|session_key/i.test(key) ? '[已隐藏]' : this.testSafe(value[key]));
|
||
return result;
|
||
}
|
||
return value;
|
||
}
|
||
private activity(action: string, body: any = {}) { return this.post('goldMiner/index', { ...body, action }); }
|
||
private async readSchedule() {
|
||
const data = await this.post('activityConfig/list', {}, false);
|
||
if (!data || data.schemaVersion !== 1 || !Array.isArray(data.activities) || !Number.isFinite(data.serverNow)) throw new Error('活动日程数据异常');
|
||
this.schedule = data;
|
||
this.offset = data.serverNow - Date.now();
|
||
return data;
|
||
}
|
||
private async readInfo() {
|
||
const info = await this.activity('info');
|
||
if (!info || !Array.isArray(info.tasks) || !Number.isFinite(info.serverTime)
|
||
|| ['locked', 'unavailable', 'purchasable', 'purchase_disabled', 'unlocked'].indexOf(info.status) < 0) throw new Error('活动数据异常,请稍后重试');
|
||
this.info = info; this.offset = info.serverTime - Date.now();
|
||
return info;
|
||
}
|
||
/** Adapt the server snapshot to the existing prefab's view model. */
|
||
view(granted = 0): any {
|
||
const info = this.info;
|
||
if (!info) return null;
|
||
const stages = info.tasks.map(task => ({ taskId: task.taskId, wins: task.targetWins,
|
||
coins: task.itemsSnapshot.filter(item => item.type === 'coin').reduce((sum, item) => sum + item.count, 0),
|
||
claimStatus: task.claimStatus }));
|
||
const claimed = info.tasks.map(task => task.claimStatus === 'claimed');
|
||
const next = claimed.indexOf(false);
|
||
return { serverNow: Date.now() + this.offset, granted, status: info.status, state: {
|
||
config: { periodId: info.periodId, startsAt: info.startsAt, endsAt: info.endsAt,
|
||
priceCents: info.priceFen, stages },
|
||
totalWins: info.progressWins || 0, paidAt: info.status === 'unlocked' ? 1 : 0,
|
||
claimed, stage: next < 0 ? stages.length : next, settled: false,
|
||
} };
|
||
}
|
||
get settlementNotice() { return Object.keys(this.ledger.notices).reduce((sum, id) => sum + this.ledger.notices[id], 0); }
|
||
dismissNotice() { this.ledger.notices = {}; this.persist(); }
|
||
get hasBadge() {
|
||
return this.settlementNotice > 0 || !!(this.info && this.info.tasks.some(t =>
|
||
t.claimStatus !== 'claimed' && (t.claimStatus === 'claimable' || t.claimStatus === 'issuing'
|
||
|| t.claimStatus === 'pending_unlock' || this.info.progressWins >= t.targetWins)));
|
||
}
|
||
stopPolling() { this.polling++; }
|
||
/** First validated snapshot; recovery continues on the existing serialized queue. */
|
||
readForDisplay(): Promise<any> {
|
||
this.refresh();
|
||
return this.displayReady;
|
||
}
|
||
refresh(): Promise<any> {
|
||
if (this.refreshing) return this.refreshing;
|
||
let show: (data: any) => void, fail: (error: any) => void;
|
||
this.displayReady = new Promise((resolve, reject) => { show = resolve; fail = reject; });
|
||
// Homepage refreshes need not consume the early snapshot.
|
||
this.displayReady.catch(() => undefined);
|
||
this.refreshing = this.serial(async () => {
|
||
this.message = '';
|
||
this.refreshNeedsRetry = false;
|
||
await this.readInfo();
|
||
show(this.view());
|
||
try { await this.readSchedule(); } catch (error) { this.message = this.friendly(error); }
|
||
for (const period of Object.keys(this.ledger.orders)) {
|
||
const order = this.ledger.orders[period];
|
||
if (order.outTradeNo && !order.done && order.paymentState !== 'cancelled') {
|
||
try { await this.queryOrder(order); } catch (error) { this.refreshNeedsRetry = true; this.message = this.friendly(error); }
|
||
}
|
||
}
|
||
for (const id of Object.keys(this.ledger.grants)) {
|
||
const grant = this.ledger.grants[id];
|
||
if (grant.phase !== 'confirmed' && !grant.settlementId && this.info.periodId === grant.periodId
|
||
&& Date.now() + this.offset < this.info.endsAt) {
|
||
try { await this.deliver(grant.periodId, grant.taskId); } catch (error) { this.message = this.friendly(error); }
|
||
}
|
||
}
|
||
// Recover issuing grants even if the page was closed immediately after claim.
|
||
for (const task of this.info.tasks) {
|
||
if (task.claimStatus === 'issuing') {
|
||
try { await this.deliver(this.info.periodId, task.taskId); } catch (error) { this.message = this.friendly(error); }
|
||
}
|
||
}
|
||
try { await this.settle(); } catch (error) { this.refreshNeedsRetry = true; this.message = this.friendly(error); }
|
||
await this.readInfo();
|
||
return this.view();
|
||
});
|
||
this.refreshing.then(() => this.refreshing = null, error => { fail(error); this.refreshing = null; });
|
||
return this.refreshing;
|
||
}
|
||
/** Retry recovery without reloading the activity schedule or replaying ordinary claims. */
|
||
retryRecovery(): Promise<any> {
|
||
return this.serial(async () => {
|
||
this.refreshNeedsRetry = false;
|
||
this.message = '';
|
||
let changed = false;
|
||
for (const period of Object.keys(this.ledger.orders)) {
|
||
const order = this.ledger.orders[period];
|
||
if (order.outTradeNo && !order.done && order.paymentState !== 'cancelled') {
|
||
try { await this.queryOrder(order); changed = true; }
|
||
catch (error) { this.refreshNeedsRetry = true; this.message = this.friendly(error); }
|
||
}
|
||
}
|
||
const notice = this.settlementNotice;
|
||
try { await this.settle(); }
|
||
catch (error) { this.refreshNeedsRetry = true; this.message = this.friendly(error); }
|
||
if (changed || notice !== this.settlementNotice) await this.readInfo();
|
||
return this.view();
|
||
});
|
||
}
|
||
async request(action: string, body: any = {}, onSaved?: (amount: number) => void): Promise<any> {
|
||
let purchaseNotified = false;
|
||
try {
|
||
if (action === 'read') return { code: 1, data: await this.refresh(), msg: this.message };
|
||
return await this.serial(async () => {
|
||
let granted = 0;
|
||
if (action === 'purchase') {
|
||
await this.purchase(body.periodId);
|
||
const order = this.ledger.orders[body.periodId];
|
||
const confirmed = !!(order && order.done) || !!(this.info && this.info.periodId === body.periodId && this.info.status === 'unlocked');
|
||
wx.showToast({ title: confirmed ? '购买成功' : '支付结果确认中', icon: confirmed ? 'success' : 'none', duration: 2000 });
|
||
purchaseNotified = true;
|
||
}
|
||
else if (action === 'claim') {
|
||
const task = this.info && this.info.tasks[body.stage];
|
||
if (!task || body.periodId !== this.info.periodId) throw new Error('活动已更新,请刷新');
|
||
granted = await this.deliver(body.periodId, task.taskId, undefined, undefined, onSaved);
|
||
} else throw new Error('不支持的活动操作');
|
||
await this.readInfo();
|
||
return { code: 1, data: this.view(granted), msg: this.message };
|
||
});
|
||
} catch (error) {
|
||
this.message = this.friendly(error);
|
||
this.iosPaymentLog('operation failed', { action, code: error.code, errCode: error.errCode, message: error.message });
|
||
if (action === 'purchase' && error.paymentPending) this.refreshNeedsRetry = true;
|
||
if (action === 'purchase' && !purchaseNotified && this.active()) {
|
||
wx.showToast({ title: error.code === 'PAYMENT_CANCELLED' ? '已取消支付' : error.paymentPending ? '支付结果确认中' : '购买失败', icon: 'none', duration: 2000 });
|
||
}
|
||
// Invalidate stale claim buttons after a rejected operation.
|
||
try { await this.readInfo(); } catch (_) { /* Preserve the error for the player. */ }
|
||
return { code: 0, data: this.view(), msg: this.message };
|
||
}
|
||
}
|
||
private friendly(error: any) {
|
||
const messages = { PAYMENT_PENDING: '支付结果确认中,请稍后刷新',
|
||
NOT_PAID: '请先解锁活动', PERIOD_ENDED: '本期活动已结束', PERIOD_SETTLEMENT_REQUIRED: '本期已结束,奖励将通过补发到账', PURCHASE_DISABLED: '本期暂不可购买',
|
||
PAYMENT_CHANNEL_CONFLICT: '请使用本期订单原有的支付渠道', UNAUTHORIZED: '登录已失效,请重新进入游戏',
|
||
TARGET_NOT_REACHED: '通关目标尚未达成', ORDER_CLOSED: '订单已关闭,请联系客服处理',
|
||
MANUAL_REVIEW: '订单需要核对,请联系客服,勿重复付款',
|
||
PAYMENT_CANCELLED: '已取消支付,再次点击解锁将重新下单' };
|
||
return messages[error.code] || error.message || '活动请求失败,请稍后重试';
|
||
}
|
||
private async purchase(periodId: string, fallbackToCustomerService = false) {
|
||
await this.readInfo();
|
||
if (this.info.periodId !== periodId || this.info.status !== 'purchasable') throw new Error('当前不可购买,请刷新活动');
|
||
if (!Number.isSafeInteger(this.info.priceFen) || this.info.priceFen <= 0) throw new Error('活动价格异常,请刷新活动');
|
||
// Each explicit purchase starts a fresh order request, regardless of the previous attempt.
|
||
const ios = wx.getSystemInfoSync().platform === 'ios';
|
||
const order = { createRequestId: this.id('buy'),
|
||
channel: ios && (fallbackToCustomerService || !cc.fx.GameConfig.GM_INFO.iosCanPay) ? 'legacy_ios' : 'native', ios } as any;
|
||
this.iosPaymentLog('purchase route', { periodId, ...order, iosCanPay: cc.fx.GameConfig.GM_INFO.iosCanPay, fallbackToCustomerService });
|
||
// Keep the native order recoverable until the server accepts the channel switch.
|
||
if (!fallbackToCustomerService) { this.ledger.orders[periodId] = order; this.persist(); }
|
||
const body = { itemid: this.info.productId, createRequestId: order.createRequestId,
|
||
itemCount: 1, itemPrice: this.info.priceFen };
|
||
let payment;
|
||
try {
|
||
payment = order.channel === 'legacy_ios'
|
||
? {}
|
||
: await this.post(order.ios ? 'wx/iosorderPaySig' : 'wx/orderPaySig', body);
|
||
} catch (error) {
|
||
if (error.code === 'ALREADY_UNLOCKED') { await this.readInfo(); return; }
|
||
if (fallbackToCustomerService && error.code === 'PAYMENT_CHANNEL_CONFLICT') {
|
||
throw new Error('当前 iOS 直付不可用,但服务端不允许将本期订单切换为客服支付,请联系客服处理');
|
||
}
|
||
throw error;
|
||
}
|
||
if (!payment || (order.channel !== 'legacy_ios' && !payment.outTradeNo)) throw new Error('订单数据异常');
|
||
this.ledger.orders[periodId] = order;
|
||
order.outTradeNo = payment.outTradeNo; this.persist();
|
||
const required = order.channel === 'legacy_ios' ? [] : ['signData', 'signature', 'paySig'];
|
||
if (required.some(key => typeof payment[key] !== 'string' || !payment[key].trim())) {
|
||
order.paymentState = 'failed'; this.persist();
|
||
throw new Error('下单响应缺少有效支付参数:' + required.filter(key => typeof payment[key] !== 'string' || !payment[key].trim()).join(', '));
|
||
}
|
||
const ticket = ++this.polling;
|
||
order.paymentState = 'pending'; this.persist();
|
||
try { await new Promise<void>((resolve, reject) => {
|
||
let done = false;
|
||
const api = order.channel === 'legacy_ios' ? 'openCustomerServiceConversation' : 'requestMidasPaymentGameItem';
|
||
this.iosPaymentLog('wx call', { api, outTradeNo: order.outTradeNo });
|
||
const finish = (ok: boolean, detail: any = {}) => {
|
||
if (done) return; done = true; clearTimeout(timer);
|
||
this.iosPaymentLog('wx callback', { api, outTradeNo: order.outTradeNo, success: ok, errCode: detail.errCode, response: detail });
|
||
if (ok) resolve();
|
||
else {
|
||
const cancelled = /cancel/i.test(detail.errMsg || '');
|
||
order.paymentState = cancelled ? 'cancelled' : 'failed'; this.persist();
|
||
const error: any = new Error('微信支付未拉起或未完成:' + (detail.errMsg || detail.message || '未知错误')
|
||
+ (detail.errCode != null ? '(' + detail.errCode + ')' : ''));
|
||
error.errCode = detail.errCode;
|
||
if (Number(detail.errCode) === 2860 || /PRODUCT ID NOT EXIST OR NOT PUBLISH/i.test(detail.errMsg || '')) {
|
||
error.message = '微信支付商品不存在或未发布(2860)。请核对微信支付后台的商品 ID、发布状态及支付环境。';
|
||
if (this.testAvailable()) {
|
||
error.message += '\n活动商品 ID:' + String(body.itemid);
|
||
try {
|
||
const signed = JSON.parse(payment.signData);
|
||
error.message += '\n签名商品 ID:' + String(signed.productId)
|
||
+ '\n支付环境 env:' + String(signed.env)
|
||
+ '\n支付应用 offerId:' + String(signed.offerId)
|
||
+ '\n分区 zoneId:' + String(signed.zoneId);
|
||
} catch (_) { /* Keep the original payment payload untouched. */ }
|
||
}
|
||
}
|
||
if (cancelled) error.code = 'PAYMENT_CANCELLED';
|
||
reject(error);
|
||
}
|
||
};
|
||
const timer = setTimeout(() => {
|
||
if (done) return; done = true;
|
||
this.iosPaymentLog('wx timeout', { api, outTradeNo: order.outTradeNo, timeoutMs: 120000 });
|
||
const error: any = new Error('支付结果确认中,请稍后刷新'); error.paymentPending = true; reject(error);
|
||
}, 120000);
|
||
const fail = (error: any) => finish(false, error || {});
|
||
try { if (order.channel === 'legacy_ios') {
|
||
Utils.GoKEFu({ price: this.info.priceFen, payment_name: this.info.productId, payment_count: 1 },
|
||
(result, detail) => result === 'success' ? finish(true, { result }) : fail(detail || { errMsg: '客服会话打开失败' }),
|
||
outTradeNo => {
|
||
order.outTradeNo = outTradeNo; this.persist();
|
||
this.iosPaymentLog('customer order created', { outTradeNo, productId: this.info.productId, priceFen: this.info.priceFen });
|
||
});
|
||
} else {
|
||
wx.requestMidasPaymentGameItem({ signData: payment.signData, signature: payment.signature, paySig: payment.paySig,
|
||
success: res => finish(true, res), fail });
|
||
} } catch (error) { fail(error); }
|
||
}); } catch (error) {
|
||
if (ios && order.channel === 'native' && Number(error.errCode) === 16
|
||
&& error.code !== 'PAYMENT_CANCELLED') {
|
||
this.iosPaymentLog('fallback to legacy_ios', { errCode: error.errCode, message: error.message, outTradeNo: order.outTradeNo });
|
||
return this.purchase(periodId, true);
|
||
}
|
||
throw error;
|
||
}
|
||
for (let attempt = 0; attempt < 4; attempt++) {
|
||
if (ticket !== this.polling) break;
|
||
try { await this.queryOrder(order); return; }
|
||
catch (error) {
|
||
if (error.code !== 'PAYMENT_PENDING' && error.code !== 'NETWORK_UNKNOWN') { error.paymentPending = true; throw error; }
|
||
}
|
||
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1500));
|
||
}
|
||
this.refreshNeedsRetry = true;
|
||
this.message = '支付结果确认中,回到游戏后会继续查询';
|
||
}
|
||
private async queryOrder(order: any) {
|
||
const data = await this.post(order.ios ? 'wx/iosgetPayInfo' : 'wx/getPayInfo', { outTradeNo: order.outTradeNo });
|
||
if (!data || data.pay_state !== 2 || data.rewardDelivery !== 'goldMiner.claim' || !data.goldMiner) {
|
||
const error: any = new Error('支付权益尚未确认');
|
||
error.paymentPending = true;
|
||
// Only the documented PAYMENT_PENDING response is eligible for rapid retries.
|
||
throw error;
|
||
}
|
||
order.done = true; order.route = data.goldMiner.fulfillmentRoute; order.targetPeriodId = data.goldMiner.targetPeriodId;
|
||
this.persist();
|
||
this.message = order.route === 'carry_next' ? '购买资格将在 ' + order.targetPeriodId.replace('goldMiner:', '') + ' 开始的一期生效' : '活动权益已确认';
|
||
}
|
||
private async deliver(periodId: string, taskId: string, settlementId?: string, reward?: any, onSaved?: (amount: number) => void): Promise<number> {
|
||
const key = periodId + ':' + taskId;
|
||
let record = this.ledger.grants[key];
|
||
const known = !!record;
|
||
if (!record) {
|
||
record = this.ledger.grants[key] = { periodId, taskId, requestId: this.id('claim'), phase: 'requested' };
|
||
this.persist();
|
||
}
|
||
const data = reward ? { ...reward, claimStatus: 'issuing', requiresClientDelivery: true }
|
||
: await this.activity('claim', { periodId, taskId, requestId: record.requestId });
|
||
if (data.claimStatus === 'claimed' || data.requiresClientDelivery === false) {
|
||
record.phase = 'confirmed'; this.persist(); return 0;
|
||
}
|
||
if (!data.grantId || !Array.isArray(data.items) || data.claimStatus !== 'issuing' || data.requiresClientDelivery !== true) throw new Error('奖励授权数据异常');
|
||
// A previously issuing grant without a local journal may have been paid on another installation.
|
||
if (!known && this.info && periodId === this.info.periodId
|
||
&& this.info.tasks.some(t => t.taskId === taskId && t.claimStatus === 'issuing')) record.phase = 'uncertain';
|
||
if (record.grantId && record.grantId !== data.grantId) throw new Error('奖励凭证不一致,请联系客服');
|
||
record.grantId = data.grantId; record.items = data.items;
|
||
if (settlementId && record.phase !== 'confirmed') record.settlementId = settlementId;
|
||
this.persist();
|
||
if (record.phase === 'saving' || record.phase === 'uncertain') throw new Error('奖励到账结果待核对,请联系客服,勿重复领取');
|
||
const amount = data.items.reduce((sum, item) => {
|
||
if (item.type !== 'coin' || !Number.isSafeInteger(item.count) || item.count <= 0) throw new Error('奖励类型暂不支持,请更新游戏或联系客服');
|
||
return sum + item.count;
|
||
}, 0);
|
||
let granted = 0;
|
||
if (record.phase !== 'saved' && record.phase !== 'confirmed') {
|
||
await Utils.withCoinSave(async () => {
|
||
if (!this.active()) throw new Error('账号已切换');
|
||
const balance = Math.floor(cc.fx.GameConfig.GM_INFO.coin) + amount;
|
||
if (!Number.isSafeInteger(balance) || balance < amount) throw new Error('金币余额异常');
|
||
record.phase = 'saving'; record.balance = balance;
|
||
if (settlementId) record.settlementId = settlementId;
|
||
this.persist();
|
||
cc.fx.GameConfig.GM_INFO.coin = balance;
|
||
cc.fx.StorageMessage.setStorage('coin', { coin: balance, timestamp: Date.now() });
|
||
const saved = await this.post('userCoin', { action: 'save', coinAmount: balance });
|
||
if (!saved || saved.coinAmount !== balance) throw new Error('金币保存结果待核对,请联系客服');
|
||
record.phase = 'saved'; record.credited = true; record.amount = amount; this.persist(); granted = amount;
|
||
// Presentation must not delay or prevent delivery confirmation.
|
||
if (onSaved) { try { onSaved(amount); } catch (error) { cc.warn('奖励动画启动失败', error); } }
|
||
});
|
||
}
|
||
if (settlementId) {
|
||
const confirmation = await this.activity('confirm_settlement_delivery', { settlementId, grantIds: [record.grantId] });
|
||
if (confirmation.settlementId !== settlementId || !Array.isArray(confirmation.confirmedGrantIds)
|
||
|| confirmation.confirmedGrantIds.indexOf(record.grantId) < 0) throw new Error('补发确认结果异常');
|
||
} else await this.activity('confirm_delivery', { periodId, taskId, grantId: record.grantId });
|
||
record.phase = 'confirmed'; this.persist();
|
||
return granted;
|
||
}
|
||
private async settle() {
|
||
let cursor: string = this.settlementCursor;
|
||
const seen: string[] = [];
|
||
do {
|
||
const data = await this.activity('settlements', { limit: 100, ...(cursor ? { afterId: cursor } : {}) });
|
||
if (data.status === 'settling') { this.refreshNeedsRetry = true; this.settlementCursor = cursor; this.message = '活动奖励结算中,请稍后刷新'; return; }
|
||
for (const settlement of data.items) {
|
||
if (!settlement.settlementId || !Array.isArray(settlement.rewards)) throw new Error('补发数据异常');
|
||
for (const reward of settlement.rewards) {
|
||
await this.deliver(settlement.periodId, reward.taskId, settlement.settlementId, reward);
|
||
const record = this.ledger.grants[settlement.periodId + ':' + reward.taskId];
|
||
if (record.credited && record.settlementId === settlement.settlementId && !record.notified) {
|
||
this.ledger.notices[settlement.settlementId] = (this.ledger.notices[settlement.settlementId] || 0) + record.amount;
|
||
record.notified = true; this.persist();
|
||
}
|
||
}
|
||
}
|
||
cursor = data.nextCursor;
|
||
if (cursor && seen.indexOf(cursor) >= 0) throw new Error('补发分页异常');
|
||
if (cursor) seen.push(cursor);
|
||
} while (cursor);
|
||
this.settlementCursor = null;
|
||
}
|
||
/** Attach once to the existing victory save. Never replay the non-idempotent legacy save on login. */
|
||
victory(level: number, endless: boolean): any {
|
||
if (!this.active()) return null;
|
||
const now = Date.now() + this.offset;
|
||
// Use server-issued IDs verbatim, including temporary periods on any weekday.
|
||
const scheduled = this.schedule && this.schedule.activities.find(a => a.activityId === 'goldMiner'
|
||
&& a.startsAt <= now && now < a.endsAt);
|
||
const periodId = scheduled ? scheduled.periodId : this.info && this.info.startsAt <= now
|
||
&& now < this.info.endsAt ? this.info.periodId : null;
|
||
if (!periodId) return null;
|
||
const event: any = { periodId, eventId: this.id('win'), outcome: 'win', mode: endless ? 'endless' : 'main' };
|
||
if (endless) {
|
||
// Millisecond time plus random suffix reduces cross-device collisions; persisted monotonic floor.
|
||
this.ledger.sequence = Math.max(this.ledger.sequence + 1, Date.now() * 1000 + Math.floor(Math.random() * 1000));
|
||
event.endlessSequence = this.ledger.sequence;
|
||
} else event.clearedMainLevel = level;
|
||
this.ledger.events[event.eventId] = { event, level, state: 'sent', sentAt: Date.now() }; this.persist();
|
||
return event;
|
||
}
|
||
victoryResult(event: any, response: any) {
|
||
if (!event || !this.active()) return;
|
||
const result = response && response.data && response.data.goldMiner;
|
||
const record = this.ledger.events[event.eventId];
|
||
if (result && result.code === 1) {
|
||
delete this.ledger.events[event.eventId];
|
||
if (this.info && this.info.periodId === event.periodId) this.info.progressWins = result.progressWins;
|
||
} else if (record) record.state = result && result.errorCode || 'unknown';
|
||
this.persist();
|
||
}
|
||
}
|