171 lines
11 KiB
TypeScript
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 || '结算暂时不可用,请重试' }; }
|
|
}
|