fix: stop battle pass scans after each period completes

This commit is contained in:
guanchao 2026-10-09 10:31:29 +08:00
parent d8aecb5d9a
commit 20d0d35fd4
5 changed files with 249 additions and 20 deletions

View File

@ -1,4 +1,4 @@
战令结束结算接入说明(2026-10-08) 战令结束结算接入说明(2026-10-09)
时间契约 时间契约
沿用现有 passCheck/read 返回的 time:毫秒级周期起点,结束点为 time + 30 天。 沿用现有 passCheck/read 返回的 time:毫秒级周期起点,结束点为 time + 30 天。
@ -10,10 +10,31 @@
passCheckTime、wx/checkIos。passCheckUpgrade 补齐了 POST YAML。 passCheckTime、wx/checkIos。passCheckUpgrade 补齐了 POST YAML。
2. 按 functions/pass-check.trigger.json 创建/更新每分钟触发器,目标 passCheckJobs。 2. 按 functions/pass-check.trigger.json 创建/更新每分钟触发器,目标 passCheckJobs。
passCheckJobs 不开放 HTTP 方法;需要现有 PASSCHECKTIME_ID 环境配置。 passCheckJobs 不开放 HTTP 方法;需要现有 PASSCHECKTIME_ID 环境配置。
每分钟仅用于检查周期和继续未完成的批次;本期扫描完成后不再读取用户表。
3. 再发布配套客户端。旧客户端的过期进度写入将被拒绝,不能继续按旧方式领上期奖励。 3. 再发布配套客户端。旧客户端的过期进度写入将被拒绝,不能继续按旧方式领上期奖励。
4. 不清空用户历史。快照存入 users/usersAd 的 passSettlements, 4. 不清空用户历史。快照存入 users/usersAd 的 passSettlements,
passSettlementRevision 用于 CAS。后台每分钟每个用户集合扫描100条,游标保存在 passSettlementRevision 用于 CAS。后台每期每个用户集合只完成一轮分页扫描,
passCheckJobCursors。请按用户规模评估扫描延迟;玩家请求时同样惰性补建快照。 每次触发各处理最多100条;进度保存在 passCheckJobCursors。
请按用户规模评估扫描延迟;玩家请求时同样惰性补建快照,不必等待后台扫到自己。
按期扫描与旧任务升级
- 当前全局周期起点同时是上一期结束点,以该毫秒时间戳标识扫描期。
即使在线接口先推进了全局周期,后台仍会对比自己的 periodEnd,启动新一期扫描。
- 新进度键为 season:users、season:usersAd;保存 periodEnd、after、completed、
completedAt、revision、leaseUntil。仅使用已有集合的 _id 索引,无需手动初始化。
保留旧 users/usersAd 游标,但不再读取它们;升级后的首次运行从头补扫一次历史存档。
此后只有全局周期推进才开启新一轮;配置的起点还在未来时不扫描。
- 每批成功后推进游标,不足100条时标记完成;恰好整批时下一次空页确认完成。
两个集合分别完成。全部完成后每次触发仅读取周期配置和两个进度文档,不扫描用户,
不反复更新游标;云函数每分钟的触发次数本身未减少。
- 每批取得5分钟租约,并用 revision 比较更新防止重叠执行或旧执行覆盖新进度。
批次失败保留起始游标、释放租约,下次重试;进程直接退出时租约到期后恢复。
失败重试或租约超时接管可能重复读取该批,但已有快照不重复生成,也不自动发奖。
- 跨期仍未扫完时先续完旧扫描,再开启最新一期。停机错过多期时无需为每个中间期
重扫全服:freezeUser 一次补齐该玩家存档中所有可验证的到期记录。
扫描完成后新增或导入的历史玩家记录,由其在线请求补建,或在下一期扫描时补齐。
- 已部署旧版时,本次只需重新发布 passCheckJobs 并更新触发器描述;cron 仍为
* * * * *。不需要改客户端、奖励领取接口,不删除用户快照或旧游标。
协议 协议
POST passCheckSettlement,使用现有 uid/token/gameName 鉴权。 POST passCheckSettlement,使用现有 uid/token/gameName 鉴权。
@ -41,7 +62,9 @@ claimFlags 仅更新实际领取的 free/passCheck 索引为0,不修改冻结
需人工退款处理,不自动退款。 需人工退款处理,不自动退款。
验证 验证
Node 24:node --test laf-cloud/tests/pass-check-settlement.test.mjs(6项通过)。 Node 24:node --test laf-cloud/tests/pass-check-settlement.test.mjs(15项通过)。
覆盖完成后零用户扫描/零写入、分页、新周期、空集合/整批边界、失败重试、旧游标升级、
跨期续跑、租约超时接管与旧执行防回退、在线补建、未来周期配置;保留原发奖幂等测试。
配套客户端 tools/test-battle-pass-settlement.cjs 覆盖奖励表、UI关闭=领取、 配套客户端 tools/test-battle-pass-settlement.cjs 覆盖奖励表、UI关闭=领取、
失败重试、确认弹窗、优先队列、多期顺序和支付幂等。 失败重试、确认弹窗、优先队列、多期顺序和支付幂等。
现有 rookie-gift.test.mjs 在导入阶段失败:goldMiner/config 缺少 paymentProductId 现有 rookie-gift.test.mjs 在导入阶段失败:goldMiner/config 缺少 paymentProductId

View File

@ -1,5 +1,5 @@
{ {
"desc": "战令到期快照与离线玩家补扫,每分钟,不自动入账", "desc": "每分钟检查战令周期,每期补扫一次,完成停止,不自动入账",
"target": "passCheckJobs", "target": "passCheckJobs",
"cron": "* * * * *" "cron": "* * * * *"
} }

View File

@ -1,18 +1,58 @@
import cloud from '@lafjs/cloud'; import cloud from '@lafjs/cloud';
import { currentPassStart, freezeUser } from '@/passCheckSettlement'; import { currentPassStart, freezeUser } from '@/passCheckSettlement';
/** Bounded durable scans also backfill players who were offline when this job was deployed. */ const BATCH_SIZE = 100;
const LEASE_MS = 5 * 60000;
/** One resumable sweep per period; completed periods never restart their user scan. */
export default async function () { export default async function () {
await currentPassStart(); // The current period's start is the previous period's end, even if an online
// request has already advanced the shared clock before this job runs.
const periodEnd = await currentPassStart();
if (periodEnd > Date.now()) return { count: 0 };
const db: any = cloud.mongo.db; const db: any = cloud.mongo.db;
let count = 0; let count = 0;
for (const table of ['users', 'usersAd']) { for (const table of ['users', 'usersAd']) {
const cursors = db.collection('passCheckJobCursors'); const cursors = db.collection('passCheckJobCursors');
const cursor = await cursors.findOne({ _id: table }); // Separate keys intentionally ignore legacy circular cursors: the first
const rows = await db.collection(table).find(cursor?.after ? { _id: { $gt: cursor.after } } : {}).sort({ _id: 1 }).limit(100).toArray(); // run backfills from the beginning once, without deleting old job state.
// A failed row leaves the cursor unchanged, so the next run retries the batch. const key = { _id: 'season:' + table };
for (const user of rows) { await freezeUser(user, table); count++; } let cursor = await cursors.findOne(key);
await cursors.updateOne({ _id: table }, { $set: { after: rows.length === 100 ? rows[rows.length - 1]._id : null } }, { upsert: true }); if (!cursor) {
try {
await cursors.updateOne(key, { $setOnInsert: {
periodEnd, after: null, completed: false, revision: 0, leaseUntil: 0
} }, { upsert: true });
} catch (error: any) { if (error.code !== 11000) throw error; }
cursor = await cursors.findOne(key);
}
if (cursor.completed && cursor.periodEnd >= periodEnd) continue;
const now = Date.now();
if (cursor.leaseUntil > now) continue;
const next = cursor.completed
? { periodEnd, after: null, completed: false, completedAt: null }
: { periodEnd: cursor.periodEnd, after: cursor.after, completed: false };
const locked = await cursors.updateOne({ ...key, revision: cursor.revision, leaseUntil: { $lte: now } }, {
$set: { ...next, leaseUntil: now + LEASE_MS }, $inc: { revision: 1 }
});
if (!locked.modifiedCount) continue;
const owned = { ...key, revision: cursor.revision + 1 };
try {
const rows = await db.collection(table).find(next.after != null ? { _id: { $gt: next.after } } : {})
.sort({ _id: 1 }).limit(BATCH_SIZE).toArray();
for (const user of rows) { await freezeUser(user, table); count++; }
const completed = rows.length < BATCH_SIZE;
await cursors.updateOne(owned, {
$set: { after: rows.length ? rows[rows.length - 1]._id : next.after, completed,
completedAt: completed ? Date.now() : null, leaseUntil: 0 },
$inc: { revision: 1 }
});
} catch (error) {
// Keep this batch's starting cursor. Freezing is idempotent on retry;
// revision fencing prevents an expired worker overwriting its successor.
await cursors.updateOne(owned, { $set: { leaseUntil: 0 }, $inc: { revision: 1 } });
throw error;
}
} }
return { count }; return { count };
} }

View File

@ -1,4 +1,4 @@
name: passCheckJobs name: passCheckJobs
desc: 战令到期批量冻结与离线玩家补扫 desc: 战令每期一次分批冻结,完成后停止扫描
methods: [] methods: []
tags: [] tags: []

View File

@ -3,22 +3,48 @@ import assert from 'node:assert/strict';
import { registerHooks } from 'node:module'; import { registerHooks } from 'node:module';
const NOW = 1800000000000, END = NOW - 1000, PERIOD = 30 * 86400000; const NOW = 1800000000000, END = NOW - 1000, PERIOD = 30 * 86400000;
let now = NOW, drop = false; let now = NOW, drop = false, failFreeze = '', beforeFind = null;
const tables = { users: [], usersAd: [], order: [], idcount: [] }; const scans = [], writes = [];
const tables = { users: [], usersAd: [], order: [], idcount: [], passCheckJobCursors: [] };
const clone = v => v == null ? v : structuredClone(v); const clone = v => v == null ? v : structuredClone(v);
const get = (r, k) => k.split('.').reduce((v, key) => v?.[key], r); const get = (r, k) => k.split('.').reduce((v, key) => v?.[key], r);
const match = (r, q) => Object.entries(q).every(([k, v]) => k === '$expr' const match = (r, q) => Object.entries(q).every(([k, v]) => {
? now < new Date(v.$lt[1]).getTime() : v === null ? get(r, k) == null : JSON.stringify(get(r, k)) === JSON.stringify(v)); if (k === '$expr') return now < new Date(v.$lt[1]).getTime();
if (v === null) return get(r, k) == null;
if (v && typeof v === 'object' && '$gt' in v) return get(r, k) > v.$gt;
if (v && typeof v === 'object' && '$lte' in v) return get(r, k) <= v.$lte;
return JSON.stringify(get(r, k)) === JSON.stringify(v);
});
const collection = name => ({ const collection = name => ({
async findOne(q) { return clone(tables[name].find(r => match(r, q))); }, async findOne(q) { return clone(tables[name].find(r => match(r, q))); },
async updateOne(q, update) { find(q) {
const row = tables[name].find(r => match(r, q)); let limit = Infinity;
const query = {
sort() { return query; },
limit(value) { limit = value; return query; },
async toArray() {
scans.push({ name, q: clone(q) });
if (beforeFind) await beforeFind(name);
return clone(tables[name].filter(r => match(r, q)).sort((a, b) => a._id < b._id ? -1 : a._id > b._id ? 1 : 0).slice(0, limit));
}
};
return query;
},
async updateOne(q, update, options = {}) {
writes.push({ name, q: clone(q), update: clone(update) });
if (q._id === failFreeze && update.$set?.passSettlements) throw Error('freeze failed');
let row = tables[name].find(r => match(r, q));
if (!row && options.upsert) {
row = { ...clone(q), ...clone(update.$setOnInsert || {}) };
tables[name].push(row);
}
if (!row) return { matchedCount: 0, modifiedCount: 0 }; if (!row) return { matchedCount: 0, modifiedCount: 0 };
for (const [k, v] of Object.entries(update.$set || {})) { for (const [k, v] of Object.entries(update.$set || {})) {
const keys = k.split('.'); let target = row; const keys = k.split('.'); let target = row;
for (const part of keys.slice(0, -1)) target = target[part] ||= {}; for (const part of keys.slice(0, -1)) target = target[part] ||= {};
target[keys.at(-1)] = clone(v); target[keys.at(-1)] = clone(v);
} }
for (const [k, v] of Object.entries(update.$inc || {})) row[k] = (row[k] || 0) + v;
if (drop && update.$set.timestamp) { drop = false; throw Error('lost response'); } if (drop && update.$set.timestamp) { drop = false; throw Error('lost response'); }
return { matchedCount: 1, modifiedCount: 1 }; return { matchedCount: 1, modifiedCount: 1 };
} }
@ -39,11 +65,13 @@ registerHooks({ resolve(specifier, context, next) {
} }); } });
const { default: settlement, currentPassStart } = await import('../functions/passCheckSettlement.ts'); const { default: settlement, currentPassStart } = await import('../functions/passCheckSettlement.ts');
const { default: upgrade, passOrderFields } = await import('../functions/passCheckUpgrade.ts'); const { default: upgrade, passOrderFields } = await import('../functions/passCheckUpgrade.ts');
const { default: runJob } = await import('../functions/passCheckJobs.ts');
const realNow = Date.now; const realNow = Date.now;
Date.now = () => now; Date.now = () => now;
test.after(() => { Date.now = realNow; }); test.after(() => { Date.now = realNow; });
function reset() { function reset() {
now = NOW; drop = false; now = NOW; drop = false; failFreeze = ''; beforeFind = null;
scans.length = 0; writes.length = 0;
for (const key of Object.keys(tables)) tables[key] = []; for (const key of Object.keys(tables)) tables[key] = [];
process.env.PASSCHECKTIME_ID = 'clock'; process.env.PASSCHECKTIME_ID = 'clock';
tables.idcount.push({ _id: 'clock', passcheckTime: END - PERIOD }); tables.idcount.push({ _id: 'clock', passcheckTime: END - PERIOD });
@ -109,3 +137,141 @@ test('unauthenticated requests and expired ordinary writes cannot alter the snap
assert.equal(result.code, 410); assert.equal(result.code, 410);
assert.equal(u.passSettlements[END].stage.experience, 4); assert.equal(u.passSettlements[END].stage.experience, 4);
}); });
const jobCursor = table => tables.passCheckJobCursors.find(r => r._id === 'season:' + table);
function populate(count) {
const original = reset();
tables.users = Array.from({ length: count }, (_, i) => ({ ...clone(original), _id: 'u' + String(i).padStart(4, '0') }));
}
test('job completes each collection once and idle ticks never scan users or write cursors', async () => {
const u = reset();
tables.usersAd.push({ ...clone(u), _id: 'ad' });
assert.equal((await runJob()).count, 2);
for (const table of ['users', 'usersAd']) {
assert.equal(jobCursor(table).completed, true);
assert.equal(jobCursor(table).periodEnd, END);
assert.ok(tables[table][0].passSettlements[END]);
assert.equal(tables[table][0].coinAmount, 10);
}
scans.length = 0; writes.length = 0;
for (let i = 0; i < 5; i++) { now += 60000; assert.equal((await runJob()).count, 0); }
assert.deepEqual(scans, []);
assert.deepEqual(writes, []);
});
test('job resumes batches without rewinding and only opens a new sweep at the next boundary', async () => {
populate(201);
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').after, 'u0099');
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').after, 'u0199');
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').completed, true);
now = END + PERIOD - 1;
assert.equal((await runJob()).count, 0);
now++;
// Online reads can advance the global clock first; this must not hide a new season.
await currentPassStart();
const u = tables.users[0];
u.passCheck = JSON.stringify({ 2: { time: String(now), experience: 8, free: [1] } });
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').periodEnd, now);
assert.equal(jobCursor('users').after, 'u0099');
assert.equal(u.passSettlements[now].stage.experience, 8);
assert.equal(u.coinAmount, 10);
});
test('empty collections and exact full batches terminate instead of restarting', async () => {
populate(100);
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').completed, false);
assert.equal(jobCursor('usersAd').completed, true);
assert.equal((await runJob()).count, 0);
assert.equal(jobCursor('users').completed, true);
scans.length = 0;
await runJob();
assert.deepEqual(scans, []);
});
test('failed batch preserves its cursor, releases the lease and retries idempotently', async () => {
populate(102);
await runJob();
failFreeze = 'u0101';
await assert.rejects(runJob(), /freeze failed/);
assert.equal(jobCursor('users').after, 'u0099');
assert.equal(jobCursor('users').completed, false);
assert.equal(jobCursor('users').leaseUntil, 0);
assert.ok(tables.users[100].passSettlements[END]);
const revision = tables.users[100].passSettlementRevision;
failFreeze = '';
assert.equal((await runJob()).count, 2);
assert.equal(jobCursor('users').completed, true);
assert.equal(tables.users[100].passSettlementRevision, revision);
});
test('legacy circular cursors are preserved but never used to skip the initial backfill', async () => {
reset();
tables.passCheckJobCursors.push({ _id: 'users', after: 'z' });
await runJob();
assert.ok(tables.users[0].passSettlements[END]);
assert.deepEqual(tables.passCheckJobCursors.find(r => r._id === 'users'), { _id: 'users', after: 'z' });
});
test('cross-period recovery finishes the old sweep before starting the latest period', async () => {
populate(101);
await runJob();
now = END + 3 * PERIOD;
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').periodEnd, END);
assert.equal(jobCursor('users').completed, true);
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').periodEnd, now);
});
test('overlapping workers skip leased batches and cannot overwrite newer progress after expiry', async () => {
populate(101);
let resume, started;
const paused = new Promise(resolve => { started = resolve; });
beforeFind = async name => {
if (name !== 'users') return;
beforeFind = null;
started();
await new Promise(resolve => { resume = resolve; });
};
const stale = runJob();
await paused;
assert.equal((await runJob()).count, 0);
now += 5 * 60000 + 1;
assert.equal((await runJob()).count, 100);
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').completed, true);
const finished = clone(jobCursor('users'));
resume();
await stale;
assert.deepEqual(jobCursor('users'), finished);
scans.length = 0;
await runJob();
assert.deepEqual(scans, []);
});
test('online reads still freeze late-arriving records after the period scan is complete', async () => {
reset();
tables.users = [];
await runJob();
tables.users.push({ _id: 'u', token: 't', coinAmount: 10,
passCheck: JSON.stringify({ 2: { time: String(END), experience: 4 } }) });
scans.length = 0;
await runJob();
assert.deepEqual(scans, []);
assert.equal((await call({ action: 'read' })).data.rows[0].end, END);
assert.equal(tables.users[0].coinAmount, 10);
});
test('a future configured start never begins a settlement scan', async () => {
reset();
tables.idcount[0].passcheckTime = NOW + PERIOD;
assert.equal((await runJob()).count, 0);
assert.deepEqual(scans, []);
assert.deepEqual(tables.passCheckJobCursors, []);
});