fix: preserve unclaimed legacy battle pass rewards during migration
This commit is contained in:
parent
bc9200bf4d
commit
3024dc8b2c
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 为准。旧客户端不可继续操作该账号通票。
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user