diff --git a/laf-cloud/docs/pass-check-settlement.txt b/laf-cloud/docs/pass-check-settlement.txt new file mode 100644 index 0000000..9f3ffa0 --- /dev/null +++ b/laf-cloud/docs/pass-check-settlement.txt @@ -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视觉预览不替代上述真实支付及云端验收。 diff --git a/laf-cloud/functions/pass-check.trigger.json b/laf-cloud/functions/pass-check.trigger.json new file mode 100644 index 0000000..c54d00a --- /dev/null +++ b/laf-cloud/functions/pass-check.trigger.json @@ -0,0 +1,5 @@ +{ + "desc": "战令到期快照与离线玩家补扫,每分钟,不自动入账", + "target": "passCheckJobs", + "cron": "* * * * *" +} diff --git a/laf-cloud/functions/passCheck.ts b/laf-cloud/functions/passCheck.ts index 38ee5dc..b4e3d40 100644 --- a/laf-cloud/functions/passCheck.ts +++ b/laf-cloud/functions/passCheck.ts @@ -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 { diff --git a/laf-cloud/functions/passCheckJobs.ts b/laf-cloud/functions/passCheckJobs.ts new file mode 100644 index 0000000..ea36ea5 --- /dev/null +++ b/laf-cloud/functions/passCheckJobs.ts @@ -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 }; +} diff --git a/laf-cloud/functions/passCheckJobs.yaml b/laf-cloud/functions/passCheckJobs.yaml new file mode 100644 index 0000000..d12d317 --- /dev/null +++ b/laf-cloud/functions/passCheckJobs.yaml @@ -0,0 +1,4 @@ +name: passCheckJobs +desc: 战令到期批量冻结与离线玩家补扫 +methods: [] +tags: [] diff --git a/laf-cloud/functions/passCheckSettlement.ts b/laf-cloud/functions/passCheckSettlement.ts new file mode 100644 index 0000000..0f9e3e9 --- /dev/null +++ b/laf-cloud/functions/passCheckSettlement.ts @@ -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 { + 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 { + 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 { + 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 || '结算暂时不可用,请重试' }; } +} diff --git a/laf-cloud/functions/passCheckSettlement.yaml b/laf-cloud/functions/passCheckSettlement.yaml new file mode 100644 index 0000000..6787a58 --- /dev/null +++ b/laf-cloud/functions/passCheckSettlement.yaml @@ -0,0 +1,5 @@ +name: passCheckSettlement +desc: 战令结束快照、客户端计算奖励的原子入账与一次性购买状态 +methods: + - POST +tags: [] diff --git a/laf-cloud/functions/passCheckTime.ts b/laf-cloud/functions/passCheckTime.ts index aaefccf..25d0102 100644 --- a/laf-cloud/functions/passCheckTime.ts +++ b/laf-cloud/functions/passCheckTime.ts @@ -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: "成功" } } diff --git a/laf-cloud/functions/passCheckUpgrade.ts b/laf-cloud/functions/passCheckUpgrade.ts index 83b614b..c5c36ca 100644 --- a/laf-cloud/functions/passCheckUpgrade.ts +++ b/laf-cloud/functions/passCheckUpgrade.ts @@ -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 }); } diff --git a/laf-cloud/functions/passCheckUpgrade.yaml b/laf-cloud/functions/passCheckUpgrade.yaml new file mode 100644 index 0000000..60b8850 --- /dev/null +++ b/laf-cloud/functions/passCheckUpgrade.yaml @@ -0,0 +1,4 @@ +name: passCheckUpgrade +methods: + - POST +tags: [] diff --git a/laf-cloud/functions/wx/checkIos.ts b/laf-cloud/functions/wx/checkIos.ts index ae96efc..dd53d37 100644 --- a/laf-cloud/functions/wx/checkIos.ts +++ b/laf-cloud/functions/wx/checkIos.ts @@ -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, diff --git a/laf-cloud/tests/pass-check-settlement.test.mjs b/laf-cloud/tests/pass-check-settlement.test.mjs new file mode 100644 index 0000000..b23e5f9 --- /dev/null +++ b/laf-cloud/tests/pass-check-settlement.test.mjs @@ -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); +});