server/laf-cloud/functions/passCheckSettlement.ts

171 lines
11 KiB
TypeScript

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<number> {
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<any> {
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<any> {
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 || '结算暂时不可用,请重试' }; }
}