feat: archive expired battle passes and support idempotent settlement

This commit is contained in:
guanchao 2026-10-08 17:16:36 +08:00
parent f17259283b
commit d8aecb5d9a
12 changed files with 421 additions and 25 deletions

View 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视觉预览不替代上述真实支付及云端验收。

View File

@ -0,0 +1,5 @@
{
"desc": "战令到期快照与离线玩家补扫,每分钟,不自动入账",
"target": "passCheckJobs",
"cron": "* * * * *"
}

View File

@ -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 {

View 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 };
}

View File

@ -0,0 +1,4 @@
name: passCheckJobs
desc: 战令到期批量冻结与离线玩家补扫
methods: []
tags: []

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

View File

@ -0,0 +1,5 @@
name: passCheckSettlement
desc: 战令结束快照、客户端计算奖励的原子入账与一次性购买状态
methods:
- POST
tags: []

View File

@ -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: "成功" }
}

View File

@ -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 });
}

View File

@ -0,0 +1,4 @@
name: passCheckUpgrade
methods:
- POST
tags: []

View File

@ -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,

View 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);
});