feat: archive expired battle passes and support idempotent settlement
This commit is contained in:
parent
f17259283b
commit
d8aecb5d9a
55
laf-cloud/docs/pass-check-settlement.txt
Normal file
55
laf-cloud/docs/pass-check-settlement.txt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
战令结束结算接入说明(2026-10-08)
|
||||
|
||||
时间契约
|
||||
沿用现有 passCheck/read 返回的 time:毫秒级周期起点,结束点为 time + 30 天。
|
||||
不把该值改成秒,也不改变已存档 stage.time(毫秒结束点)。周期保留运营配置的
|
||||
北京时间起点,不再按云函数宿主本地时区 setHours,也不再由不同接口分别推进30/60天。
|
||||
|
||||
发布清单(本次只本地实现和测试,尚未部署)
|
||||
1. 先发布 passCheckSettlement、passCheckJobs、passCheckUpgrade、passCheck、
|
||||
passCheckTime、wx/checkIos。passCheckUpgrade 补齐了 POST YAML。
|
||||
2. 按 functions/pass-check.trigger.json 创建/更新每分钟触发器,目标 passCheckJobs。
|
||||
passCheckJobs 不开放 HTTP 方法;需要现有 PASSCHECKTIME_ID 环境配置。
|
||||
3. 再发布配套客户端。旧客户端的过期进度写入将被拒绝,不能继续按旧方式领上期奖励。
|
||||
4. 不清空用户历史。快照存入 users/usersAd 的 passSettlements,
|
||||
passSettlementRevision 用于 CAS。后台每分钟每个用户集合扫描100条,游标保存在
|
||||
passCheckJobCursors。请按用户规模评估扫描延迟;玩家请求时同样惰性补建快照。
|
||||
|
||||
协议
|
||||
POST passCheckSettlement,使用现有 uid/token/gameName 鉴权。
|
||||
read:返回 rows(按结束时间升序)、resources、serverTime。
|
||||
empty_seen + end:图2实际展示后标记该期已提示。
|
||||
offer_shown + end + offerToken:图1实际展示后消耗唯一展示机会;仅原令牌/登录会话
|
||||
可重试确认。令牌不在 read 中返回;进程关闭后不可恢复该购买窗口。
|
||||
decline + end:永久放弃该期机会,不影响已支付订单的补发。
|
||||
claim + end + rewards + claimFlags:领取客户端按最新配置计算的已解锁未领奖励。
|
||||
purchase_claim 另加 outTradeNo:上期18元订单补发已解锁付费奖励。
|
||||
rewards 为合并后的 coin/freeze/hammer/magic/infinite_health,体力数量单位为秒。
|
||||
claimFlags 仅更新实际领取的 free/passCheck 索引为0,不修改冻结经验或等级。
|
||||
|
||||
服务端不保存奖励配置或版本、不计算应发奖励数量。按已确认的客户端可信方案,
|
||||
只检查类型/正整数、归属、支付状态和收据。此方案防重复领取,但不能防篡改客户端
|
||||
伪造奖励数量;这不是服务端权威发奖方案。
|
||||
|
||||
一致性
|
||||
到期只冻结进度、购买档位、领取标志,不给离线玩家入账。
|
||||
普通进度更新用数据库 $$NOW 截止条件;冻结同时 CAS 所有进度输入。
|
||||
领取资源增量与收据在同一用户文档原子写入,订单状态更新可幂等补齐。
|
||||
已支付的期内订单延迟恢复时,仅接受冻结进度及订单对应权益,不接受客户端替换
|
||||
过期进度。领取掩码保留,30元的20点经验/3000金币仍沿用已有权益逻辑。
|
||||
同一期第二笔补购支付不二次发奖,订单标记 passReview=duplicate_post_season_payment,
|
||||
需人工退款处理,不自动退款。
|
||||
|
||||
验证
|
||||
Node 24:node --test laf-cloud/tests/pass-check-settlement.test.mjs(6项通过)。
|
||||
配套客户端 tools/test-battle-pass-settlement.cjs 覆盖奖励表、UI关闭=领取、
|
||||
失败重试、确认弹窗、优先队列、多期顺序和支付幂等。
|
||||
现有 rookie-gift.test.mjs 在导入阶段失败:goldMiner/config 缺少 paymentProductId
|
||||
导出,与本次战令改动无关,未修改该模块。
|
||||
|
||||
上线前真机验收
|
||||
Android、原生iOS支付、客服iOS支付分别验证成功/取消/延迟回调/杀进程恢复。
|
||||
跨截止时刻验证已解锁未领范围、点击X领取、奖励动画后补购、永久放弃。
|
||||
断网/重复点击/跨设备重试验证资源只入账一次。停留在图1时可重试失败支付,
|
||||
重新登录不恢复该期图1;已支付订单仍可从原有登录订单补发入口恢复。
|
||||
本地模拟数据库和Cocos视觉预览不替代上述真实支付及云端验收。
|
||||
5
laf-cloud/functions/pass-check.trigger.json
Normal file
5
laf-cloud/functions/pass-check.trigger.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"desc": "战令到期快照与离线玩家补扫,每分钟,不自动入账",
|
||||
"target": "passCheckJobs",
|
||||
"cron": "* * * * *"
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import cloud from '@lafjs/cloud'
|
|||
const db = cloud.database();
|
||||
import Utils from "@/Utils";
|
||||
import { legacyPassView } from '@/passCheckUpgrade';
|
||||
import { currentPassStart, freezeUser, parse } from '@/passCheckSettlement';
|
||||
export default async function (ctx: FunctionContext) {
|
||||
const action = ctx.body.action;
|
||||
const uid = ctx.body.uid;
|
||||
|
|
@ -24,24 +25,25 @@ export default async function (ctx: FunctionContext) {
|
|||
return { code: 0, data: null, msg: "token校验失败" };
|
||||
}
|
||||
}
|
||||
if (!res.data) return { code: 0, msg: '用户不存在' };
|
||||
res.data = await freezeUser(res.data);
|
||||
switch (action) {
|
||||
case 'save':
|
||||
let passCheck = ctx.body.passCheck;
|
||||
if (!passCheck) {
|
||||
return { code: 0, data: null, msg: "未获取到coinAmount" };
|
||||
}
|
||||
let ret = await db.collection(dbname).where({ _id: uid }).update({ passCheck: passCheck });
|
||||
const incoming = parse(passCheck);
|
||||
const ends = [incoming[1], incoming[2]].filter(s => Number(s?.time) > 0).map(s => Number(s.time));
|
||||
if (ends.some(end => end <= Date.now())) return { code: 410, msg: '上期进度已冻结' };
|
||||
const where: any = { _id: res.data._id, passCheck: res.data.passCheck ?? null };
|
||||
if (ends.length) where.$expr = { $lt: ['$$NOW', new Date(Math.min(...ends))] };
|
||||
const ret = await (cloud.mongo.db as any).collection(dbname).updateOne(where, { $set: { passCheck } });
|
||||
if (!(ret.matchedCount ?? ret.modifiedCount)) return { code: 409, msg: '战令已更新或到期,请重试' };
|
||||
return { code: 1, data: { passCheck: passCheck }, msg: "用户数据更新成功" }
|
||||
case 'read':
|
||||
let passCheck1 = legacyPassView(res.data?.passCheck || null, res.data?.passCheckUpgrade);
|
||||
let time = await db.collection("idcount").where({ _id: process.env.PASSCHECKTIME_ID }).getOne();
|
||||
let t = time.data.passcheckTime;
|
||||
if (Date.now() > t + 30 * 24 * 3600000) {
|
||||
const endOfDay: any = new Date();
|
||||
endOfDay.setHours(16, 0, 0, 0); // 设置为明天的0点,等同于今天的24点
|
||||
t = endOfDay.getTime();
|
||||
await db.collection("idcount").where({ _id: process.env.PASSCHECKTIME_ID }).update({ passcheckTime: t });
|
||||
}
|
||||
let t = await currentPassStart();
|
||||
return { code: 1, data: { passCheck: passCheck1, time: t }, msg: "成功" }
|
||||
default:
|
||||
return {
|
||||
|
|
|
|||
18
laf-cloud/functions/passCheckJobs.ts
Normal file
18
laf-cloud/functions/passCheckJobs.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import cloud from '@lafjs/cloud';
|
||||
import { currentPassStart, freezeUser } from '@/passCheckSettlement';
|
||||
|
||||
/** Bounded durable scans also backfill players who were offline when this job was deployed. */
|
||||
export default async function () {
|
||||
await currentPassStart();
|
||||
const db: any = cloud.mongo.db;
|
||||
let count = 0;
|
||||
for (const table of ['users', 'usersAd']) {
|
||||
const cursors = db.collection('passCheckJobCursors');
|
||||
const cursor = await cursors.findOne({ _id: table });
|
||||
const rows = await db.collection(table).find(cursor?.after ? { _id: { $gt: cursor.after } } : {}).sort({ _id: 1 }).limit(100).toArray();
|
||||
// A failed row leaves the cursor unchanged, so the next run retries the batch.
|
||||
for (const user of rows) { await freezeUser(user, table); count++; }
|
||||
await cursors.updateOne({ _id: table }, { $set: { after: rows.length === 100 ? rows[rows.length - 1]._id : null } }, { upsert: true });
|
||||
}
|
||||
return { count };
|
||||
}
|
||||
4
laf-cloud/functions/passCheckJobs.yaml
Normal file
4
laf-cloud/functions/passCheckJobs.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: passCheckJobs
|
||||
desc: 战令到期批量冻结与离线玩家补扫
|
||||
methods: []
|
||||
tags: []
|
||||
170
laf-cloud/functions/passCheckSettlement.ts
Normal file
170
laf-cloud/functions/passCheckSettlement.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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 || '结算暂时不可用,请重试' }; }
|
||||
}
|
||||
5
laf-cloud/functions/passCheckSettlement.yaml
Normal file
5
laf-cloud/functions/passCheckSettlement.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
name: passCheckSettlement
|
||||
desc: 战令结束快照、客户端计算奖励的原子入账与一次性购买状态
|
||||
methods:
|
||||
- POST
|
||||
tags: []
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
import cloud from '@lafjs/cloud'
|
||||
const db = cloud.database();
|
||||
import { currentPassStart } from '@/passCheckSettlement';
|
||||
export default async function (ctx: FunctionContext) {
|
||||
let time = await db.collection("idcount").where({ _id: "69fac1528463a95668c5e5db" }).getOne();
|
||||
let t = time.data.passcheckTime;
|
||||
if (Date.now() > t + 60 * 24 * 3600000) {
|
||||
const endOfDay: any = new Date();
|
||||
endOfDay.setHours(0, 0, 0, 0); // 设置为明天的0点,等同于今天的24点
|
||||
t = endOfDay.getTime();
|
||||
await db.collection("idcount").where({ _id: "69fac1528463a95668c5e5db" }).update({ passcheckTime: t });
|
||||
}
|
||||
const t = await currentPassStart();
|
||||
return { code: 1, data: { time: t }, msg: "成功" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import cloud from '@lafjs/cloud';
|
||||
import Utils from '@/Utils';
|
||||
import { freezeUser, postSeasonOrderFields } from '@/passCheckSettlement';
|
||||
const db = cloud.database();
|
||||
const PERIOD = 30 * 24 * 3600000;
|
||||
export const PASS_PRICES = { battlepass: 1800, battlepass_30: 3000, battlepass_12: 1200 };
|
||||
|
|
@ -15,6 +16,7 @@ export function passOrderFields(user: any, body: any): any {
|
|||
if (body.itemid === 'battlepass') return {};
|
||||
throw new Error('请更新客户端后购买高级战令');
|
||||
}
|
||||
if (body.passOfferToken) return postSeasonOrderFields(user, body);
|
||||
const stage = parsePass(user.passCheck)[2];
|
||||
if (!stage || String(stage.time) !== String(body.passEnd) || Number(stage.time) <= Date.now()) throw new Error('本期已结束,请重新打开战令');
|
||||
const extra = parsePass(user.passCheckUpgrade)[String(stage.time)] || {};
|
||||
|
|
@ -39,8 +41,9 @@ export function legacyPassView(raw: any, extraRaw: any): any {
|
|||
export default async function (ctx: FunctionContext) {
|
||||
const body = ctx.body;
|
||||
const users = db.collection(body.gameName === 'iaa' ? 'usersAd' : 'users');
|
||||
const { data: user } = await users.where({ _id: body.uid }).getOne();
|
||||
let { data: user } = await users.where({ _id: body.uid }).getOne();
|
||||
if (!user || (user.token && !Utils.checkToken(body.token, user.token))) return { code: 0, msg: 'token校验失败' };
|
||||
user = await freezeUser(user, body.gameName === 'iaa' ? 'usersAd' : 'users');
|
||||
const extra = parsePass(user.passCheckUpgrade), base = parsePass(user.passCheck);
|
||||
const revision = Number(user.passUpgradeRevision) || 0;
|
||||
const reply = (data = {}) => ({ code: 1, data: { extra, legacy: user.passCheck, revision, coinAmount: user.coinAmount || 0, ...data } });
|
||||
|
|
@ -57,6 +60,7 @@ export default async function (ctx: FunctionContext) {
|
|||
order = (await db.collection('order').where({ outTradeNo: body.outTradeNo }).getOne()).data;
|
||||
if (!order || order.openid !== user.openid || !Object.prototype.hasOwnProperty.call(PASS_PRICES, order.itemid)
|
||||
|| ![1, 2].includes(order.state)) return { code: 0, msg: '战令订单尚未支付或不属于当前玩家' };
|
||||
if (order.passPostSeason) return reply({ postSeason: true, passEnd: String(order.passEnd), snapshot: user.passSettlements?.[String(order.passEnd)], outTradeNo: order.outTradeNo });
|
||||
if (order.passGranted) return reply({ alreadyGranted: true });
|
||||
end = String(order.passEnd || [base[1], base[2]].find(s => Number(order.time) >= Number(s?.time) - PERIOD && Number(order.time) < Number(s?.time))?.time || '');
|
||||
const stage = [base[1], base[2]].find(s => String(s?.time) === end);
|
||||
|
|
@ -75,10 +79,28 @@ export default async function (ctx: FunctionContext) {
|
|||
if (body.action !== 'save') return { code: 0, msg: '无效战令操作' };
|
||||
if (Number(body.revision) !== revision) return { code: 409, msg: '战令已更新,请重试' };
|
||||
const supplied = parsePass(body.passCheck), legacy = parsePass(body.legacy);
|
||||
const next: any = {};
|
||||
const next: any = { ...extra };
|
||||
let cutoff = Infinity;
|
||||
for (const key of [1, 2]) {
|
||||
const stage = supplied[key];
|
||||
let stage = supplied[key];
|
||||
if (!stage || !Number(stage.time)) continue;
|
||||
if (Number(stage.time) <= Date.now()) {
|
||||
if (!order || String(stage.time) !== end) {
|
||||
if (order) continue;
|
||||
return { code: 410, msg: '上期进度已冻结,请通过结算领取' };
|
||||
}
|
||||
// A paid in-season order may arrive late. Only its original entitlement is recoverable;
|
||||
// never accept a replacement expired progress/claim payload from the client.
|
||||
stage = { ...user.passSettlements[end].stage };
|
||||
if (bonus) {
|
||||
// Legacy records encode XP as level + progress; do not drop that
|
||||
// verified progress when adding the existing paid 20-XP entitlement.
|
||||
const costs = [1, ...Array(8).fill(2), ...Array(21).fill(4)];
|
||||
const level = Math.max(1, Math.min(30, Number(stage.progressLevel) || 1));
|
||||
const legacyXP = costs.slice(0, level - 1).reduce((a, b) => a + b, 0) + Math.min(costs[level - 1], Number(stage.progress) || 0);
|
||||
stage.experience = (Number.isFinite(stage.experience) ? stage.experience : legacyXP) + 20;
|
||||
}
|
||||
} else cutoff = Math.min(cutoff, Number(stage.time));
|
||||
const time = String(stage.time), saved = extra[time] || {};
|
||||
const old = [base[1], base[2]].find(s => String(s?.time) === time);
|
||||
next[time] = { ...stage, experience: Math.max(saved.experience || 0, stage.experience || 0),
|
||||
|
|
@ -104,6 +126,12 @@ export default async function (ctx: FunctionContext) {
|
|||
for (const key of [1, 2]) if (String(legacy[key]?.time) === end) legacy[key].activate = true;
|
||||
}
|
||||
const update: any = { passCheck: JSON.stringify(legacy), passCheckUpgrade: JSON.stringify(next), passUpgradeRevision: revision + 1 };
|
||||
if (order && Number(end) <= Date.now() && user.passSettlements?.[end]) {
|
||||
const frozen = user.passSettlements[end];
|
||||
const recovered = { ...next[end] };
|
||||
update.passSettlements = { ...user.passSettlements, [end]: { ...frozen, stage: recovered, claimed: false, emptySeen: false, offer: 'purchased' } };
|
||||
update.passSettlementRevision = (Number(user.passSettlementRevision) || 0) + 1;
|
||||
}
|
||||
if (bonus) {
|
||||
// 战令赠金由客户端计算,此处仅保存上传余额与同一期的发奖记录。
|
||||
const coin = Number(body.coinAmount);
|
||||
|
|
@ -112,7 +140,9 @@ export default async function (ctx: FunctionContext) {
|
|||
}
|
||||
const where: any = { _id: user._id, passCheck: user.passCheck == null ? null : user.passCheck,
|
||||
passUpgradeRevision: user.passUpgradeRevision == null ? null : user.passUpgradeRevision };
|
||||
if ((await users.where(where).update(update)).updated === 0) return { code: 409, msg: '战令已更新,请重试' };
|
||||
if (update.passSettlements) where.passSettlementRevision = user.passSettlementRevision ?? null;
|
||||
if (Number.isFinite(cutoff)) where.$expr = { $lt: ['$$NOW', new Date(cutoff)] };
|
||||
if ((await (cloud.mongo.db as any).collection(body.gameName === 'iaa' ? 'usersAd' : 'users').updateOne(where, { $set: update })).modifiedCount === 0) return { code: 409, msg: '战令已更新或到期,请重试' };
|
||||
if (order) await db.collection('order').where({ outTradeNo: order.outTradeNo }).update({ state: 2, passGranted: true, getTime: new Date() });
|
||||
return reply({ extra: next, legacy: update.passCheck, revision: revision + 1, bonus, coinAmount: bonus ? update.coinAmount : user.coinAmount });
|
||||
return reply({ extra: next, legacy: update.passCheck, revision: revision + 1, bonus, targetTier, coinAmount: bonus ? update.coinAmount : user.coinAmount });
|
||||
}
|
||||
|
|
|
|||
4
laf-cloud/functions/passCheckUpgrade.yaml
Normal file
4
laf-cloud/functions/passCheckUpgrade.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: passCheckUpgrade
|
||||
methods:
|
||||
- POST
|
||||
tags: []
|
||||
|
|
@ -48,7 +48,7 @@ export default async function (ctx: FunctionContext) {
|
|||
let res = await db.collection("iosOrder").where({ outTradeNo: outTradeNo }).getOne();
|
||||
if (res.data) {
|
||||
await db.collection("order").add({
|
||||
...(res.data.passVersion === 2 ? { passVersion: 2, passEnd: res.data.passEnd } : {}),
|
||||
...(res.data.passVersion === 2 ? { passVersion: 2, passEnd: res.data.passEnd, ...(res.data.passPostSeason ? { passPostSeason: true } : {}) } : {}),
|
||||
openid: res.data.openid,
|
||||
outTradeNo: res.data.outTradeNo,
|
||||
itemid: res.data.itemid,
|
||||
|
|
|
|||
111
laf-cloud/tests/pass-check-settlement.test.mjs
Normal file
111
laf-cloud/tests/pass-check-settlement.test.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
const NOW = 1800000000000, END = NOW - 1000, PERIOD = 30 * 86400000;
|
||||
let now = NOW, drop = false;
|
||||
const tables = { users: [], usersAd: [], order: [], idcount: [] };
|
||||
const clone = v => v == null ? v : structuredClone(v);
|
||||
const get = (r, k) => k.split('.').reduce((v, key) => v?.[key], r);
|
||||
const match = (r, q) => Object.entries(q).every(([k, v]) => k === '$expr'
|
||||
? now < new Date(v.$lt[1]).getTime() : v === null ? get(r, k) == null : JSON.stringify(get(r, k)) === JSON.stringify(v));
|
||||
const collection = name => ({
|
||||
async findOne(q) { return clone(tables[name].find(r => match(r, q))); },
|
||||
async updateOne(q, update) {
|
||||
const row = tables[name].find(r => match(r, q));
|
||||
if (!row) return { matchedCount: 0, modifiedCount: 0 };
|
||||
for (const [k, v] of Object.entries(update.$set || {})) {
|
||||
const keys = k.split('.'); let target = row;
|
||||
for (const part of keys.slice(0, -1)) target = target[part] ||= {};
|
||||
target[keys.at(-1)] = clone(v);
|
||||
}
|
||||
if (drop && update.$set.timestamp) { drop = false; throw Error('lost response'); }
|
||||
return { matchedCount: 1, modifiedCount: 1 };
|
||||
}
|
||||
});
|
||||
globalThis.__passTestCloud = {
|
||||
mongo: { db: { collection } },
|
||||
database: () => ({ collection: name => ({ where: q => ({
|
||||
getOne: async () => ({ data: await collection(name).findOne(q) }),
|
||||
get: async () => ({ data: clone(tables[name].filter(r => match(r, q))) }),
|
||||
update: async values => ({ updated: (await collection(name).updateOne(q, { $set: values })).modifiedCount })
|
||||
}) }) })
|
||||
};
|
||||
registerHooks({ resolve(specifier, context, next) {
|
||||
if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__passTestCloud', shortCircuit: true };
|
||||
if (specifier === '@/Utils') return { url: 'data:text/javascript,export default {checkToken:(a,b)=>a===b}', shortCircuit: true };
|
||||
if (specifier.startsWith('@/')) return { url: new URL('../functions/' + specifier.slice(2) + '.ts', import.meta.url).href, shortCircuit: true };
|
||||
return next(specifier, context);
|
||||
} });
|
||||
const { default: settlement, currentPassStart } = await import('../functions/passCheckSettlement.ts');
|
||||
const { default: upgrade, passOrderFields } = await import('../functions/passCheckUpgrade.ts');
|
||||
const realNow = Date.now;
|
||||
Date.now = () => now;
|
||||
test.after(() => { Date.now = realNow; });
|
||||
function reset() {
|
||||
now = NOW; drop = false;
|
||||
for (const key of Object.keys(tables)) tables[key] = [];
|
||||
process.env.PASSCHECKTIME_ID = 'clock';
|
||||
tables.idcount.push({ _id: 'clock', passcheckTime: END - PERIOD });
|
||||
const stage = { time: String(END), experience: 4, free: [0, 1, 1], passCheck: [1, 1, 1], activate: false };
|
||||
tables.users.push({ _id: 'u', openid: 'o', token: 't', coinAmount: 10, passCheck: JSON.stringify({ 2: stage }) });
|
||||
return tables.users[0];
|
||||
}
|
||||
const body = data => ({ body: { uid: 'u', token: 't', ...data } });
|
||||
const call = data => settlement(body(data));
|
||||
test('offline freeze is immutable, never credits resources, and uses millisecond periods', async () => {
|
||||
const u = reset(); await call({ action: 'read' });
|
||||
assert.equal(u.coinAmount, 10);
|
||||
u.passCheck = JSON.stringify({ 2: { time: END, experience: 999 } });
|
||||
assert.equal((await call({ action: 'read' })).data.rows[0].stage.experience, 4);
|
||||
assert.equal(await currentPassStart(), END);
|
||||
now = END + PERIOD; assert.equal(await currentPassStart(), now);
|
||||
});
|
||||
test('concurrent requests and a lost response cannot award twice', async () => {
|
||||
const u = reset(), request = { action: 'claim', end: END, rewards: [{ type: 'coin', count: 15 }] };
|
||||
drop = true;
|
||||
const replies = await Promise.all([call(request), call(request)]);
|
||||
assert.equal(u.coinAmount, 25);
|
||||
assert.ok(replies.some(r => r.code === 1));
|
||||
assert.equal((await call(request)).data.alreadyGranted, true);
|
||||
assert.equal(u.coinAmount, 25);
|
||||
});
|
||||
test('offer is scoped to the rendered view and permanent abandonment revokes ordering', async () => {
|
||||
const u = reset(), offerToken = 'original_render_token';
|
||||
assert.equal((await call({ action: 'offer_shown', end: END, offerToken })).code, 1);
|
||||
assert.equal((await call({ action: 'offer_shown', end: END, offerToken: 'another_render_token' })).code, 410);
|
||||
const order = { itemid: 'battlepass', passVersion: 2, passEnd: END, passOfferToken: offerToken };
|
||||
assert.equal(passOrderFields(u, order).passPostSeason, true);
|
||||
await call({ action: 'decline', end: END });
|
||||
assert.throws(() => passOrderFields(u, order));
|
||||
});
|
||||
test('paid duplicate gets a manual-review marker, not a second grant', async () => {
|
||||
const u = reset(); await call({ action: 'read' });
|
||||
const order = { openid: 'o', itemid: 'battlepass', passEnd: String(END), passPostSeason: true, state: 1 };
|
||||
tables.order.push({ ...order, outTradeNo: 'first' }, { ...order, outTradeNo: 'second' });
|
||||
const request = { action: 'purchase_claim', end: END, rewards: [{ type: 'coin', count: 300 }] };
|
||||
assert.equal((await call({ ...request, outTradeNo: 'first' })).code, 1);
|
||||
assert.equal((await call({ ...request, outTradeNo: 'second' })).code, 410);
|
||||
assert.equal(u.coinAmount, 310);
|
||||
assert.equal(tables.order[1].passReview, 'duplicate_post_season_payment');
|
||||
});
|
||||
test('late paid entitlement preserves prior claim masks and original verified XP', async () => {
|
||||
const u = reset();
|
||||
await call({ action: 'claim', end: END, rewards: [], claimFlags: { free: [0, 0, 0], passCheck: [1, 1, 1] } });
|
||||
tables.order.push({ openid: 'o', itemid: 'battlepass_30', passVersion: 2, passEnd: String(END), state: 1, outTradeNo: 'late' });
|
||||
const reply = await upgrade(body({ action: 'save', outTradeNo: 'late', revision: 0, coinAmount: 3010,
|
||||
passCheck: JSON.stringify({ 2: { time: String(END), experience: 9999 } }), legacy: u.passCheck }));
|
||||
assert.equal(reply.code, 1);
|
||||
assert.equal(u.passSettlements[END].stage.experience, 24);
|
||||
assert.deepEqual(u.passSettlements[END].stage.free, [0, 0, 0]);
|
||||
assert.equal(u.passSettlements[END].stage.tier, 30);
|
||||
assert.equal(u.passSettlements[END].claimed, false);
|
||||
assert.equal((await upgrade(body({ action: 'order', outTradeNo: 'late' }))).data.alreadyGranted, true);
|
||||
});
|
||||
test('unauthenticated requests and expired ordinary writes cannot alter the snapshot', async () => {
|
||||
const u = reset();
|
||||
assert.equal((await call({ action: 'claim', end: END, token: 'wrong', rewards: [] })).code, 0);
|
||||
const result = await upgrade(body({ action: 'save', revision: 0, passCheck: u.passCheck, legacy: u.passCheck }));
|
||||
assert.equal(result.code, 410);
|
||||
assert.equal(u.passSettlements[END].stage.experience, 4);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user