fix: restrict battle pass settlement job to users

This commit is contained in:
guanchao 2026-10-09 10:38:11 +08:00
parent 20d0d35fd4
commit 66e5dfe084
3 changed files with 83 additions and 61 deletions

View File

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

View File

@ -12,11 +12,10 @@ export default async function () {
if (periodEnd > Date.now()) return { count: 0 };
const db: any = cloud.mongo.db;
let count = 0;
for (const table of ['users', 'usersAd']) {
const cursors = db.collection('passCheckJobCursors');
// Separate keys intentionally ignore legacy circular cursors: the first
// This key intentionally ignores the legacy circular cursor: the first
// run backfills from the beginning once, without deleting old job state.
const key = { _id: 'season:' + table };
const key = { _id: 'season:users' };
let cursor = await cursors.findOne(key);
if (!cursor) {
try {
@ -26,21 +25,21 @@ export default async function () {
} catch (error: any) { if (error.code !== 11000) throw error; }
cursor = await cursors.findOne(key);
}
if (cursor.completed && cursor.periodEnd >= periodEnd) continue;
if (cursor.completed && cursor.periodEnd >= periodEnd) return { count };
const now = Date.now();
if (cursor.leaseUntil > now) continue;
if (cursor.leaseUntil > now) return { count };
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;
if (!locked.modifiedCount) return { count };
const owned = { ...key, revision: cursor.revision + 1 };
try {
const rows = await db.collection(table).find(next.after != null ? { _id: { $gt: next.after } } : {})
const rows = await db.collection('users').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++; }
for (const user of rows) { await freezeUser(user, 'users'); count++; }
const completed = rows.length < BATCH_SIZE;
await cursors.updateOne(owned, {
$set: { after: rows.length ? rows[rows.length - 1]._id : next.after, completed,
@ -53,6 +52,5 @@ export default async function () {
await cursors.updateOne(owned, { $set: { leaseUntil: 0 }, $inc: { revision: 1 } });
throw error;
}
}
return { count };
}

View File

@ -4,7 +4,7 @@ import { registerHooks } from 'node:module';
const NOW = 1800000000000, END = NOW - 1000, PERIOD = 30 * 86400000;
let now = NOW, drop = false, failFreeze = '', beforeFind = null;
const scans = [], writes = [];
const reads = [], scans = [], writes = [];
const tables = { users: [], usersAd: [], order: [], idcount: [], passCheckJobCursors: [] };
const clone = v => v == null ? v : structuredClone(v);
const get = (r, k) => k.split('.').reduce((v, key) => v?.[key], r);
@ -16,7 +16,10 @@ const match = (r, q) => Object.entries(q).every(([k, v]) => {
return JSON.stringify(get(r, k)) === JSON.stringify(v);
});
const collection = name => ({
async findOne(q) { return clone(tables[name].find(r => match(r, q))); },
async findOne(q) {
reads.push({ name, q: clone(q) });
return clone(tables[name].find(r => match(r, q)));
},
find(q) {
let limit = Infinity;
const query = {
@ -71,7 +74,7 @@ Date.now = () => now;
test.after(() => { Date.now = realNow; });
function reset() {
now = NOW; drop = false; failFreeze = ''; beforeFind = null;
scans.length = 0; writes.length = 0;
reads.length = 0; scans.length = 0; writes.length = 0;
for (const key of Object.keys(tables)) tables[key] = [];
process.env.PASSCHECKTIME_ID = 'clock';
tables.idcount.push({ _id: 'clock', passcheckTime: END - PERIOD });
@ -144,16 +147,18 @@ function populate(count) {
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 () => {
test('job scans only users 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);
}
const adBefore = clone(tables.usersAd);
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').completed, true);
assert.equal(jobCursor('users').periodEnd, END);
assert.ok(u.passSettlements[END]);
assert.equal(u.coinAmount, 10);
assert.equal(jobCursor('usersAd'), undefined);
assert.deepEqual(tables.usersAd, adBefore);
assert.ok([...reads, ...scans, ...writes].every(r => r.name !== 'usersAd' && r.q._id !== 'season:usersAd'));
scans.length = 0; writes.length = 0;
for (let i = 0; i < 5; i++) { now += 60000; assert.equal((await runJob()).count, 0); }
assert.deepEqual(scans, []);
@ -182,11 +187,11 @@ test('job resumes batches without rewinding and only opens a new sweep at the ne
assert.equal(u.coinAmount, 10);
});
test('empty collections and exact full batches terminate instead of restarting', async () => {
test('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(jobCursor('usersAd'), undefined);
assert.equal((await runJob()).count, 0);
assert.equal(jobCursor('users').completed, true);
scans.length = 0;
@ -218,6 +223,22 @@ test('legacy circular cursors are preserved but never used to skip the initial b
assert.deepEqual(tables.passCheckJobCursors.find(r => r._id === 'users'), { _id: 'users', after: 'z' });
});
test('obsolete usersAd job state is never read or updated, including across periods', async () => {
const u = reset();
tables.usersAd.push({ ...clone(u), _id: 'ad' });
const oldCursor = { _id: 'season:usersAd', periodEnd: END - PERIOD, after: null,
completed: false, revision: 2, leaseUntil: 0 };
tables.passCheckJobCursors.push(clone(oldCursor));
const adBefore = clone(tables.usersAd);
await runJob();
await runJob();
now = END + PERIOD;
await runJob();
assert.deepEqual(jobCursor('usersAd'), oldCursor);
assert.deepEqual(tables.usersAd, adBefore);
assert.ok([...reads, ...scans, ...writes].every(r => r.name !== 'usersAd' && r.q._id !== 'season:usersAd'));
});
test('cross-period recovery finishes the old sweep before starting the latest period', async () => {
populate(101);
await runJob();