import cloud from '@lafjs/cloud'; import Utils from '@/Utils'; export const PERIOD = 30 * 86400000; export const parse = (raw: any): any => { try { return typeof raw === 'string' ? JSON.parse(raw) || {} : raw || {}; } catch (_) { return {}; } }; const collection = (name: string): any => (cloud.mongo.db as any).collection(name); const copy = (value: any) => JSON.parse(JSON.stringify(value)); const fail = (msg: string, code = 409): never => { throw { code, msg }; }; const resources = ['coinAmount', 'freezeAmount', 'hammerAmount', 'magicAmount', 'userPowerTime']; export const balances = (user: any) => Object.fromEntries(resources.map(k => [k, Number(user[k]) || 0])); /** Keep the existing millisecond start + 30 days contract, independent of host timezone. */ export async function currentPassStart(now = Date.now()): Promise { const table = cloud.database().collection('idcount'); const { data } = await table.where({ _id: process.env.PASSCHECKTIME_ID }).getOne(); const start = Number(data?.passcheckTime); if (!Number.isFinite(start) || start <= 0) fail('战令周期未配置', 503); if (now < start + PERIOD) return start; const next = start + Math.floor((now - start) / PERIOD) * PERIOD; await table.where({ _id: process.env.PASSCHECKTIME_ID, passcheckTime: data.passcheckTime }).update({ passcheckTime: next }); return next; } /** The frozen object contains progress/ownership/claim flags only, never reward configuration. */ export function expiredSnapshots(user: any, now = Date.now()): any { const base = parse(user.passCheck), extra = parse(user.passCheckUpgrade); const result = copy(user.passSettlements || {}); const stages: any = { ...extra }; for (const k of [1, 2]) { const old = base[k]; if (!old || !Number(old.time)) continue; const key = String(old.time), saved = extra[key]; const stage = saved ? { ...saved, activate: !!saved.activate || !!old.activate } : copy(old); if (saved) { const costs = [1, ...Array(8).fill(2), ...Array(21).fill(4)]; const level = Math.max(1, Math.min(30, Number(old.progressLevel) || 1)); const oldExperience = costs.slice(0, level - 1).reduce((a, b) => a + b, 0) + Math.min(costs[level - 1], Number(old.progress) || 0); stage.experience = (Number(saved.experience) || 0) + Math.max(0, oldExperience - (Number(saved.legacyExperience) || 0)); for (const lane of ['free', 'passCheck']) { stage[lane] = [...(saved[lane] || [])]; for (let i = 0; i < Math.min(30, (old[lane] || []).length); i++) if (old[lane][i] === 0) stage[lane][i] = 0; } } stages[key] = stage; } for (const key of Object.keys(stages)) { const stage = stages[key], end = Number(stage?.time || key); if (!end || end > now || result[String(end)]) continue; result[String(end)] = { end, stage: copy({ ...stage, time: String(end) }), frozenAt: now, claimed: false, emptySeen: false, offer: 'unused' }; } return result; } const SETTLEMENTS = 'passCheckSettlements'; const archiveId = (user: any, table: string, end: number) => table + ':' + encodeURIComponent(String(user._id)) + ':' + end; let archiveIndex: Promise; async function ensureArchiveIndex() { if (!archiveIndex) archiveIndex = collection(SETTLEMENTS).createIndex({ accountTable: 1, uid: 1, end: 1 }, { unique: true, name: 'pass_settlement_owner_end' }).catch(error => { archiveIndex = undefined; throw error; }); await archiveIndex; } /** A single durable outbox slot bridges the two collections without transactions. * A stale flusher may finish late, but can neither regress an archive nor clear a newer slot. */ async function flushPending(user: any, table: string): Promise { const pending = user.passSettlementPending; if (!pending) return user; const key = { _id: archiveId(user, table, pending.row.end) }; try { await collection(SETTLEMENTS).updateOne({ ...key, revision: { $lt: pending.revision } }, { $set: { uid: user._id, accountTable: table, end: pending.row.end, row: pending.row, revision: pending.revision } }, { upsert: true }); } catch (error: any) { if (error.code !== 11000) throw error; const saved = await collection(SETTLEMENTS).findOne(key); if (!saved || !Number.isSafeInteger(saved.revision) || saved.revision < pending.revision) throw error; } await collection(table).updateOne({ _id: user._id, 'passSettlementPending.revision': pending.revision }, { $unset: { passSettlementPending: '' } }); return collection(table).findOne({ _id: user._id }); } /** Resource changes and their receipt are atomic in users. No subsequent settlement * can commit until that receipt has been durably copied to the independent archive. */ export async function commitUserSettlement(user: any, row: any, patch: any, expected: any, table = 'users', unset: any = {}): Promise { const revision = (Number(user.passSettlementRevision) || 0) + 1; const written = await collection(table).updateOne({ ...expected, _id: user._id, passSettlementRevision: user.passSettlementRevision ?? null, passSettlementPending: null }, { $set: { ...patch, passSettlementRevision: revision, passSettlementPending: { revision, row } }, ...(Object.keys(unset).length ? { $unset: unset } : {}) }); if (!written.modifiedCount) return null; return flushPending({ ...user, ...patch, passSettlementRevision: revision, passSettlementPending: { revision, row } }, table); } /** Load from the archive; passSettlements on the return value is an in-memory view only. * Legacy map entries move one at a time into the durable slot before being removed. */ export async function freezeUser(user: any, table = 'users'): Promise { await ensureArchiveIndex(); const uid = user._id; for (let retry = 0; retry < 8;) { user = await collection(table).findOne({ _id: uid }); if (!user) fail('用户不存在', 404); user = await flushPending(user, table); // Another writer may have installed its slot while the previous one was flushed. if (user.passSettlementPending) { retry++; continue; } const records = await collection(SETTLEMENTS).find({ uid, accountTable: table }).toArray(); const saved = Object.fromEntries(records.map(record => [String(record.end), record.row])); const legacy = user.passSettlements || {}; const snapshots = expiredSnapshots({ ...user, passSettlements: { ...legacy, ...saved } }); const legacyEnd = Object.keys(legacy)[0]; if (legacyEnd && saved[legacyEnd] && JSON.stringify(legacy[legacyEnd]) !== JSON.stringify(saved[legacyEnd])) { fail('历史结算记录冲突,请核对迁移数据'); } const end = legacyEnd || Object.keys(snapshots).find(key => !saved[key]); if (!end) return { ...user, passSettlements: saved }; const expected: any = { passCheck: user.passCheck ?? null, passCheckUpgrade: user.passCheckUpgrade ?? null }; const unset: any = {}; if (legacyEnd) { expected.passSettlements = legacy; unset[Object.keys(legacy).length === 1 ? 'passSettlements' : 'passSettlements.' + legacyEnd] = ''; } if (await commitUserSettlement(user, snapshots[end], {}, expected, table, unset)) retry = 0; else retry++; } fail('战令结算正在更新,请重试'); } export function postSeasonOrderFields(user: any, body: any): any { const row = user.passSettlements?.[String(body.passEnd)]; if (!row || body.itemid !== 'battlepass' || row.stage.activate || row.stage.tier || row.purchase || row.offer !== 'shown' || !body.passOfferToken || row.offerToken !== body.passOfferToken || row.offerSession !== user.token || Number(row.end) <= Date.now() - PERIOD) fail('上期购买机会已结束', 410); return { passVersion: 2, passEnd: String(row.end), passPostSeason: true }; } function normalizeRewards(input: any): any[] { if (!Array.isArray(input) || input.length > 5) fail('奖励数据无效', 400); const allowed = ['coin', 'freeze', 'hammer', 'magic', 'infinite_health']; const seen = new Set(); return input.map(r => { if (!allowed.includes(r.type) || seen.has(r.type) || !Number.isSafeInteger(r.count) || r.count <= 0) fail('奖励数据无效', 400); seen.add(r.type); return { type: r.type, count: r.count }; }); } /** Reward quantities are computed by the client. The server commits deltas + receipt atomically. */ export async function mutateSettlement(user: any, body: any, table = 'users'): Promise { for (let retry = 0; retry < 8; retry++) { user = await freezeUser(user, table); const end = String(body.end || body.passEnd), row = user.passSettlements?.[end]; if (!row) fail('找不到上期战令记录', 404); const next = copy(row), patch: any = {}, query: any = { _id: user._id, passSettlementRevision: user.passSettlementRevision ?? null }; let rewards = [], duplicate = false, order: any; if (body.action === 'empty_seen') { next.emptySeen = true; } else if (body.action === 'offer_shown') { const latest = Math.max(...Object.keys(user.passSettlements).map(Number)); if (row.stage.activate || row.stage.tier || row.purchase || Number(end) !== latest || Number(end) <= Date.now() - PERIOD) fail('不符合上期购买条件', 410); if (typeof body.offerToken !== 'string' || !/^[a-zA-Z0-9_-]{16,100}$/.test(body.offerToken)) fail('购买会话无效', 400); if (row.offer !== 'unused' && (row.offerToken !== body.offerToken || row.offerSession !== user.token)) fail('该期购买机会已使用', 410); next.offer = 'shown'; next.offerToken = body.offerToken; next.offerSession = user.token; } else if (body.action === 'decline') { next.offer = 'declined'; delete next.offerToken; } else if (body.action === 'claim' || body.action === 'purchase_claim') { const purchase = body.action === 'purchase_claim'; if (purchase) { order = (await cloud.database().collection('order').where({ outTradeNo: body.outTradeNo }).getOne()).data; if (!order || order.openid !== user.openid || order.itemid !== 'battlepass' || !order.passPostSeason || String(order.passEnd) !== end || ![1, 2].includes(order.state)) fail('订单尚未支付或不属于本期', 400); if (row.purchase && row.purchase !== order.outTradeNo) { await cloud.database().collection('order').where({ outTradeNo: order.outTradeNo }).update({ passReview: 'duplicate_post_season_payment' }); fail('重复支付已记录,请联系客服退款', 410); } } duplicate = purchase ? !!row.purchase : !!row.claimed; if (duplicate) rewards = purchase ? row.purchaseRewards : row.rewards; else { rewards = normalizeRewards(body.rewards); if (body.claimFlags) for (const lane of purchase ? ['passCheck'] : ['free', 'passCheck']) { const flags = body.claimFlags[lane]; if (!Array.isArray(flags) || flags.length > 10000 || flags.some(v => v !== 0 && v !== 1 && v !== null)) fail('领取记录无效', 400); next.stage[lane] = [...(next.stage[lane] || [])]; flags.forEach((v, i) => { if (v === 0) next.stage[lane][i] = 0; }); } const mapping = { coin: 'coinAmount', freeze: 'freezeAmount', hammer: 'hammerAmount', magic: 'magicAmount' }; for (const r of rewards) { const field = r.type === 'infinite_health' ? 'userPowerTime' : mapping[r.type]; query[field] = user[field] ?? null; patch[field] = r.type === 'infinite_health' ? Math.max(Math.floor(Date.now() / 1000), Number(user[field]) || 0) + r.count : (Number(user[field]) || 0) + r.count; if (!Number.isSafeInteger(patch[field])) fail('奖励数量超出范围', 400); } patch.timestamp = Date.now(); if (purchase) { next.purchase = order.outTradeNo; next.purchaseRewards = rewards; next.offer = 'purchased'; } else { next.claimed = true; next.rewards = rewards; next.emptySeen = true; } } } else fail('无效结算操作', 400); const written = await commitUserSettlement(user, next, patch, query, table); if (written) { if (order) await cloud.database().collection('order').where({ outTradeNo: order.outTradeNo }).update({ state: 2, passGranted: true, getTime: new Date() }); return { row: publicRow(next), resources: balances(written), rewards, alreadyGranted: duplicate }; } user = await collection(table).findOne({ _id: user._id }); } fail('资源已更新,请重试'); } function publicRow(row: any) { const { offerToken, offerSession, ...view } = row; return view; } export default async function (ctx: FunctionContext) { try { const body = ctx.body, table = body.gameName === 'iaa' ? 'usersAd' : 'users'; let user = (await cloud.database().collection(table).where({ _id: body.uid }).getOne()).data; if (!user || !user.token || !body.token || !Utils.checkToken(body.token, user.token)) return { code: 0, msg: 'token校验失败' }; user = await freezeUser(user, table); if (body.action === 'read') { return { code: 1, data: { rows: Object.values(user.passSettlements || {}).map(publicRow).sort((a: any, b: any) => a.end - b.end), resources: balances(user), serverTime: Date.now() } }; } return { code: 1, data: await mutateSettlement(user, body, table) }; } catch (error: any) { return { code: error.code || 500, msg: error.msg || '结算暂时不可用,请重试' }; } }