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; } /** Freeze with CAS against ALL progress inputs; an ordinary save cannot race the snapshot. */ export async function freezeUser(user: any, table = 'users'): Promise { for (let retry = 0; retry < 8; retry++) { const snapshots = expiredSnapshots(user); if (JSON.stringify(snapshots) === JSON.stringify(user.passSettlements || {})) return user; const query = { _id: user._id, passCheck: user.passCheck ?? null, passCheckUpgrade: user.passCheckUpgrade ?? null, passSettlementRevision: user.passSettlementRevision ?? null }; const revision = (Number(user.passSettlementRevision) || 0) + 1; if ((await collection(table).updateOne(query, { $set: { passSettlements: snapshots, passSettlementRevision: revision } })).modifiedCount) { return { ...user, passSettlements: snapshots, passSettlementRevision: revision }; } user = await collection(table).findOne({ _id: user._id }); } 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); patch['passSettlements.' + end] = next; patch.passSettlementRevision = (Number(user.passSettlementRevision) || 0) + 1; const written = await collection(table).updateOne(query, { $set: patch }); if (written.modifiedCount) { 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({ ...user, ...patch }), 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 || '结算暂时不可用,请重试' }; } }