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 = Promise.resolve(); private refreshing: Promise = null; private displayReady: Promise = 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(work: () => Promise): Promise { const result = this.tail.then(work); this.tail = result.catch(() => undefined); return result; } private async post(path: string, body: any, authenticated = true): Promise { 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 === 'claimable' || t.claimStatus === 'issuing')); } stopPolling() { this.polling++; } /** First validated snapshot; recovery continues on the existing serialized queue. */ readForDisplay(): Promise { this.refresh(); return this.displayReady; } refresh(): Promise { 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 { 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 { 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((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 { 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(); } }