From 3024dc8b2c8b7960468a0cc58674701040cefe23 Mon Sep 17 00:00:00 2001 From: huanghaipeng Date: Mon, 7 Sep 2026 19:52:38 +0800 Subject: [PATCH] fix: preserve unclaimed legacy battle pass rewards during migration --- laf-cloud/functions/passCheckService.ts | 23 +++++++-- laf-cloud/passCheckV2.API.md | 4 +- laf-cloud/tests/pass-check-v2.test.mjs | 62 ++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/laf-cloud/functions/passCheckService.ts b/laf-cloud/functions/passCheckService.ts index 67625ec..e85808f 100644 --- a/laf-cloud/functions/passCheckService.ts +++ b/laf-cloud/functions/passCheckService.ts @@ -144,17 +144,22 @@ function migrate(user: any, a: number, c: any, now: number) { if (old && old !== 'null') { requirePass(typeof old === 'object', 'LEGACY_STATE_INVALID'); for (const key of ['1', '2']) { - const raw = old[key]; if (!raw || Number(raw.time) === 0) continue; + const raw = old[key]; if (!raw) continue; + if (Number(raw.time) === 0) { + requirePass(!Number(raw.progress) && !Number(raw.progressLevel) + && ['free', 'passCheck'].every(field => !raw[field] || (Array.isArray(raw[field]) && raw[field].length === 0)), 'LEGACY_STATE_INVALID'); + continue; + } const end = Number(raw.time); requirePass(Number.isSafeInteger(end), 'LEGACY_STATE_INVALID'); - if (end + PERIOD <= now) continue; const start = a + Math.round((end - PERIOD - a) / PERIOD) * PERIOD; - requirePass(Math.abs(start + PERIOD - end) < 86400000 && start <= now, 'LEGACY_PERIOD_INVALID'); - const p = newSeason(start, c); + // Existing entitlements use the old reward table, even if V2 config changes at rollout. + const p = newSeason(start, { ...c, levels: BASE_LEVELS }); const level = Number(raw.progressLevel), progress = Number(raw.progress); requirePass(Number.isSafeInteger(level) && level >= 0 && Number.isSafeInteger(progress) && progress >= 0, 'LEGACY_STATE_INVALID'); + requirePass([true, false, 0, 1].includes(raw.activate), 'LEGACY_STATE_INVALID'); p.tier = raw.activate === true || raw.activate === 1 ? 2 : 1; p.giftGranted = p.tier >= 2 && raw.giftGranted === true; - p.points = level > 30 ? metrics(p).total : c.levels.slice(0, Math.max(0, level - 1)).reduce((n: number, r: any) => n + r.token, 0) + progress; + p.points = level > 30 ? metrics(p).total : p.config.levels.slice(0, Math.max(0, level - 1)).reduce((n: number, r: any) => n + r.token, 0) + progress; p.points = Math.min(p.points, metrics(p).total); for (const [source, dest] of [['free', 'freeClaimed'], ['passCheck', 'paidClaimed']] as const) { requirePass(Array.isArray(raw[source]) && raw[source].length <= 30, 'LEGACY_STATE_INVALID'); @@ -164,6 +169,14 @@ function migrate(user: any, a: number, c: any, now: number) { if (v === 0) p[dest].push(index + 1); }); } + // Never silently discard outstanding legacy rewards when first entering V2. + // Leave the entire user document untouched until this exceptional archive is resolved. + if (end + PERIOD <= now) { + requirePass(!hasRewards(p), 'LEGACY_REWARDS_PENDING'); + continue; + } + // Rounding a drifting legacy period can shorten its remaining claim window. + requirePass(start + PERIOD === end && start <= now, 'LEGACY_PERIOD_INVALID'); requirePass(!s.seasons.some((x: any) => x.id === p.id), 'LEGACY_PERIOD_INVALID'); s.seasons.push(p); } } diff --git a/laf-cloud/passCheckV2.API.md b/laf-cloud/passCheckV2.API.md index 9209365..5e443ed 100644 --- a/laf-cloud/passCheckV2.API.md +++ b/laf-cloud/passCheckV2.API.md @@ -1,6 +1,6 @@ # 三档通票 V2 后端接入 -本次只交付独立 server 项目的后端;不包含客户端修改、支付平台商品创建或线上部署。默认关闭迁移入口。等级奖励沿用现有30级的免费/付费两列,没有第三列。 +本次只交付独立 server 项目的后端;客户端战令接入修改已撤回,不包含支付平台商品创建或线上部署。默认关闭迁移入口。等级奖励沿用现有30级的免费/付费两列,没有第三列。 ## 权益与时间 @@ -123,6 +123,8 @@ Android 使用现有 `wx/orderPaySig`,iOS 使用 `wx/iosorderPaySig`,请求 旧 passCheck 字符串/对象的 `[1]`、`[2]` 是上期、本期。迁移保留 `legacySnapshot`,将 activate 映射为1/2档,保存已领取记录及明确的 giftGranted:true 礼包记录;不推算旧版已丢弃的满级溢出。旧档案没有礼包记录时不推断已发,后续付费升级按未发处理;若历史渠道曾额外发过礼包,迁移前需核实并补齐该记录。异常 JSON、异常期次拒绝迁移,避免覆盖原档案。 +迁移中的旧期使用 `BASE_LEVELS` 原等级奖励表,避免新版自定义奖励或门槛改变历史权益。免费、付费领取记录分别保留;本期与上期一起迁移,上期可领完后再展示本期。若超过30天补领期限仍有已解锁未领奖励,返回 `LEGACY_REWARDS_PENDING` 并拒绝整个迁移,保留原 `passCheck`、资源和未迁移状态;需要核实、补发这些历史奖励后再迁移,不能清空记录绕过检查。时间锚点不一致返回 `LEGACY_PERIOD_INVALID`,不通过四舍五入缩短补领时间。非法激活状态、无期次却有进度或奖励的记录也拒绝迁移。 + 旧 battlepass 仍有未支付/未发货订单时返回 LEGACY_ORDER_PENDING,先完成旧流程或确认关闭后再迁移。五彩账号尚待选择数据时返回 MIGRATION_CHOICE_PENDING。已迁移用户不能通过五彩选择接口覆盖通票;需人工处理这类跨档案替换。 已迁移用户的旧 passCheck/passCheckLv 读写均返回 CLIENT_UPGRADE_REQUIRED,保存还有数据库条件保护。登录返回 `passCheckVersion:2` 和原始 JSON 字符串 passCheckV2,旧 passCheck 返回 null;通票展示以新版 read 为准。旧客户端不可继续操作该账号通票。 diff --git a/laf-cloud/tests/pass-check-v2.test.mjs b/laf-cloud/tests/pass-check-v2.test.mjs index 6f867a7..37ea564 100644 --- a/laf-cloud/tests/pass-check-v2.test.mjs +++ b/laf-cloud/tests/pass-check-v2.test.mjs @@ -1,12 +1,13 @@ import './pass-check-loader.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { isDeepStrictEqual } from 'node:util'; import { createHmac } from 'node:crypto'; const { handlePassRequest, preparePassOrder, acceptPassPayment, reconcilePassOrder, verifyPassCallback, PERIOD, config } = await import('../functions/passCheckService.ts'); const A = Date.UTC(2026, 8, 1), realNow = Date.now; let now, tables, beforeUpdate, failFinalize, failAdd; const copy = x => x == null ? x : structuredClone(x); -function matches(row, query) { return Object.entries(query).every(([k,v]) => v === null ? row[k] == null : row[k] === v); } +function matches(row, query) { return Object.entries(query).every(([k,v]) => v === null ? row[k] == null : isDeepStrictEqual(row[k], v)); } const db = { collection(name) { return { async add(value) { if (failAdd) { failAdd=false; throw Error('add interrupted'); } if (tables[name].some(r=>r._id===value._id)) throw Error('duplicate id'); tables[name].push(copy(value)); return {id:value._id}; }, where(query) { return { @@ -261,3 +262,62 @@ test('migration preserves explicit legacy gift receipt on premium upgrade',async tables.users[0].passCheck=JSON.stringify({2:{time:String(A+PERIOD),progressLevel:1,progress:0,activate:true,free:[1],passCheck:[1],giftGranted:true}}); await request('read');await buy('upgrade-test','upgrade');assert.equal(tables.users[0].coinAmount,0);assert.equal(state().seasons[0].giftGranted,true); }); + +const oldPeriod = (end, overrides={}) => ({time:String(end),progressLevel:1,progress:1,activate:true,free:[1],passCheck:[1],...overrides}); +test('migration keeps both old periods and all unpaid rewards claimable exactly once',async()=>{ + const legacy=JSON.stringify({1:oldPeriod(A,{free:[0],passCheck:[1]}),2:oldPeriod(A+PERIOD)}); + tables.users[0].passCheck=legacy; + const r=await request('read');assert.equal(r.data.displaySeasonId,String(A-PERIOD));assert.equal(r.data.current.tier,2); + assert.deepEqual(r.data.previous.freeClaimed,[1]);assert.deepEqual(r.data.previous.paidClaimed,[]); + const claim={seasonId:r.data.previous.id,level:1,track:'paid'}; + await Promise.all([request('claim_level',claim),request('claim_level',claim)]); + assert.equal(tables.users[0].coinAmount,1500); + const next=await request('read');assert.equal(next.data.displaySeasonId,String(A)); + // Consuming the initial token also unlocks level 1 only; no unclaimed old rewards remain. + assert.equal(next.data.previous,null); + assert.equal(state().legacySnapshot,legacy);assert.equal(tables.users[0].passCheck,legacy); + await request('claim_level',{seasonId:r.data.current.id,level:1,track:'paid'}); + assert.equal(tables.users[0].coinAmount,3000); + await request('claim_level',{seasonId:r.data.current.id,level:1,track:'free'}); + assert.equal(tables.users[0].userPowerTime,Math.floor(now/1000)+900); +}); +test('expired legacy unpaid rewards block migration without changing the user or resources',async()=>{ + for(const track of ['free','passCheck']) { + reset();tables.users[0].passCheck=JSON.stringify({1:oldPeriod(A-PERIOD,{free:[0],passCheck:[0],[track]:[1]}),2:oldPeriod(A+PERIOD)}); + const before=copy(tables.users[0]);await rejects(()=>request('read'),'LEGACY_REWARDS_PENDING');assert.deepEqual(tables.users[0],before); + } +}); +test('expired fully claimed or unpurchased paid rewards do not block migration',async()=>{ + for(const activate of [true,false]) { + reset();tables.users[0].passCheck={1:oldPeriod(A-PERIOD,{activate,free:[0],passCheck:activate?[0]:[1]}),2:oldPeriod(A+PERIOD)}; + const r=await request('read');assert.equal(r.data.previous,null);assert.equal(state().seasons.length,1); + } +}); +test('legacy reward amounts and progress use the original table despite V2 overrides',async()=>{ + const levels=copy(config().levels);levels[0]={token:100,free:[{field:'coinAmount',amount:1}],paid:[{field:'coinAmount',amount:1}]}; + process.env.PASSCHECK_V2_CONFIG=JSON.stringify({...fixture,levels}); + tables.users[0].passCheck={2:oldPeriod(A+PERIOD,{progressLevel:2,progress:1,free:[0,1],passCheck:[1,1]})}; + const r=await request('read');assert.equal(r.data.current.points,2);assert.equal(r.data.current.level,2); + await request('claim_level',{seasonId:r.data.current.id,level:1,track:'paid'});assert.equal(tables.users[0].coinAmount,1500); +}); +test('drifting periods and ambiguous entitlement flags cannot silently lose old rewards',async()=>{ + for(const overrides of [{time:String(A+PERIOD+3600000)},{activate:'true'},{time:'0'}]) { + reset();tables.users[0].passCheck={2:oldPeriod(A+PERIOD,overrides)};const before=copy(tables.users[0]); + await assert.rejects(()=>request('read'),e=>['LEGACY_PERIOD_INVALID','LEGACY_STATE_INVALID'].includes(e.reason));assert.deepEqual(tables.users[0],before); + } +}); +test('legacy concurrent claim during migration is re-read instead of granting it twice',async()=>{ + tables.users[0].passCheck=JSON.stringify({2:oldPeriod(A+PERIOD)}); + // Initialize only the shared anchor so the hook targets the user CAS. + tables.idcount[0].passcheckV2Anchor=A; + beforeUpdate=async(name)=>{assert.equal(name,'users');tables.users[0].passCheck=JSON.stringify({2:oldPeriod(A+PERIOD,{passCheck:[0]})});tables.users[0].coinAmount=1500;}; + const r=await request('read');assert.deepEqual(r.data.current.paidClaimed,[1]); + await request('claim_level',{seasonId:r.data.current.id,level:1,track:'paid'});assert.equal(tables.users[0].coinAmount,1500); +}); + +test('unmigrated users keep the legacy interface while rollout is disabled',async()=>{ + const legacy=JSON.stringify({2:oldPeriod(A+PERIOD)});tables.users[0].passCheck=legacy; + process.env.PASSCHECK_V2_ENABLED='false';await rejects(()=>request('read'),'FEATURE_DISABLED'); + const r=await legacyPass({body:{uid:'u',token:'t',action:'read'}}); + assert.equal(r.code,1);assert.equal(r.data.passCheck,legacy);assert.equal(tables.users[0].passCheckV2,undefined); +});