diff --git a/laf-cloud/functions/rechargeStats.ts b/laf-cloud/functions/rechargeStats.ts index 9869857..ca885ae 100644 --- a/laf-cloud/functions/rechargeStats.ts +++ b/laf-cloud/functions/rechargeStats.ts @@ -1,5 +1,5 @@ import cloud from '@lafjs/cloud'; -import { buildPaymentProfile, paymentVipLevel, PAYMENT_PROFILE_RULE } from '@/paymentProfile'; +import { buildPaymentProfile, paymentVipLevel } from '@/paymentProfile'; import type { ProfileOrder } from '@/paymentProfile'; const DAY = 24 * 60 * 60 * 1000; @@ -10,7 +10,7 @@ const userKey = (id: any) => `${typeof id}:${String(id)}`; function emptyStats(asOf: number) { return { - version: 8, + version: 9, currency: 'CNY', unit: 'fen', asOf, @@ -61,6 +61,7 @@ export default async function () { let invalidProfiles = 0; let scannedUsers = 0; let skippedNeverPaidUsers = 0; + let removedNeverPaidStats = 0; while (true) { const candidates: any[] = await users.find({ @@ -70,30 +71,40 @@ export default async function () { // Advance on the identity page, including pages consisting entirely of stable VIP0 users. afterId = candidates[candidates.length - 1]._id; scannedUsers += candidates.length; - const previous: any[] = await rechargeStatsCollection.find({ _id: { $in: candidates.map(user => user._id) } }, - { projection: { _id: 1, openid: 1, has_ever_paid: 1, paid_status: 1, - data_valid: 1, version: 1, asOf: 1, updatedAt: 1, profile_rule_version: 1, maintenance_identity: 1 } }).toArray(); - const previousById = new Map(previous.map(row => [userKey(row._id), row])); + // VIP0 is a user attribute, not a cached zero-valued statistics document. const batch = candidates.filter(user => { - const saved = previousById.get(userKey(user._id)); - // Only a verified, initialized never-payer can be cached. A missing profile, - // ambiguous identity or changed account must go through the order check below. - const stableNeverPaid = user.pay_user === false && saved?.version === 8 - && saved.data_valid === true && saved.paid_status === 'never' && saved.has_ever_paid === false - && user.vip_level === 0 && user.vip_profile?.data_valid === true - && user.vip_profile.as_of === saved.asOf && user.vip_profile.updated_at === saved.updatedAt - && user.vip_profile.rule_version === PAYMENT_PROFILE_RULE.version - && saved.profile_rule_version === PAYMENT_PROFILE_RULE.version - && saved.openid === user.openid && saved.maintenance_identity?.pay_user === false - && saved.maintenance_identity.register_time === timestamp(user.register_time); + const vip = user.vip_profile; + const stableNeverPaid = user.pay_user === false && user.vip_level === 0 + && vip?.maintenance_version === 9 && vip.data_valid === true && vip.has_ever_paid === false + && vip.identity?.openid === user.openid && vip.identity?.pay_user === false + && vip.identity?.register_time === timestamp(user.register_time); if (stableNeverPaid) skippedNeverPaidUsers++; return !stableNeverPaid; }); if (!batch.length) continue; + // Read legacy evidence only for users actually needing maintenance/migration. + const previous: any[] = await rechargeStatsCollection.find({ _id: { $in: batch.map(user => user._id) } }, + { projection: { _id: 1, openid: 1, has_ever_paid: 1, paid_status: 1, + data_valid: 1, amountTotal: 1, orderCount: 1, maintenance_identity: 1 } }).toArray(); + const previousById = new Map(previous.map(row => [userKey(row._id), row])); const previouslyPaid = new Set([ - ...previous.filter(row => row.has_ever_paid === true), + ...previous.filter(row => row.has_ever_paid === true || row.paid_status === 'paid' || row.amountTotal > 0 || row.orderCount > 0), ...batch.filter(user => user.vip_level > 0 || user.vip_profile?.has_ever_paid === true), ].map(row => userKey(row._id))); + const directNeverPaid = new Set(batch.filter(user => { + const saved = previousById.get(userKey(user._id)); + const identity = user.vip_profile?.identity; + const identityMatches = !identity || (identity.openid === user.openid + && identity.pay_user === user.pay_user && identity.register_time === timestamp(user.register_time)); + // Trust the explicit server-owned false flag, never a missing flag/profile. + // Existing conflicts and account changes still require an order check. + return user.pay_user === false && !previouslyPaid.has(userKey(user._id)) + && typeof user.openid === 'string' && !!user.openid.trim() + && user.vip_profile?.data_valid !== false && identityMatches + && (!saved || (saved.data_valid === true && saved.paid_status === 'never' + && saved.openid === user.openid && saved.maintenance_identity?.pay_user === false + && saved.maintenance_identity?.register_time === timestamp(user.register_time))); + }).map(user => userKey(user._id))); const summaries = new Map; @@ -106,6 +117,7 @@ export default async function () { futureOrders: number; }>(); for (const user of batch) { + if (directNeverPaid.has(userKey(user._id))) continue; if (typeof user.openid === 'string' && user.openid.trim() && !summaries.has(user.openid)) { summaries.set(user.openid, { stats: emptyStats(asOf), seen: new Map(), fen15d: 0, fen30d: 0, fenTotal: 0, recent: [], lastPaidAt: null, futureOrders: 0 }); @@ -174,10 +186,15 @@ export default async function () { summary.stats.amount30d = summary.fen30d; summary.stats.amountTotal = summary.fenTotal; } - const result = await rechargeStatsCollection.bulkWrite(batch.map(user => { - const rechargeStats = summaries.get(user.openid)?.stats ?? { ...emptyStats(asOf), missingOpenid: true }; + const records = batch.map(user => { + const rechargeStats = summaries.get(user.openid)?.stats + ?? { ...emptyStats(asOf), missingOpenid: !directNeverPaid.has(userKey(user._id)) }; const summary = summaries.get(user.openid); - const profile = buildPaymentProfile({ + const profile = directNeverPaid.has(userKey(user._id)) ? { + vip_level: 0, data_valid: true, paid_status: 'never', has_ever_paid: false, + invalid_reasons: [], profile_rule_version: null, profile_calculated_as_of: updatedAt, + rfm: null, + } : buildPaymentProfile({ cutoff: asOf + 1, registerTime: timestamp(user.register_time), payUser: user.pay_user, missingOpenid: rechargeStats.missingOpenid, previousPaid: previouslyPaid.has(userKey(user._id)), confirmedOrders: rechargeStats.orderCount, futureOrders: summary?.futureOrders ?? 0, @@ -191,25 +208,30 @@ export default async function () { const { vip_level: _vipLevel, ...features } = profile; const record = { _id: user._id, openid: user.openid ?? null, ...rechargeStats, ...features, updatedAt, maintenance_identity: { pay_user: user.pay_user ?? null, register_time: timestamp(user.register_time) } }; + return record; + }); + const freeRecords = records.filter(record => record.data_valid && record.paid_status === 'never'); + const statsRecords = records.filter(record => !record.data_valid || record.paid_status !== 'never'); + const result = statsRecords.length ? await rechargeStatsCollection.bulkWrite(statsRecords.map(record => { // Preserve the complete last calculated feature tuple atomically on bad input. // Keep current error/identity metadata and its attempted rule version visible. const heldFields = Object.fromEntries([ 'profile_rule_version', 'profile_calculated_as_of', 'rfm', 'spending', 'ticket_factors', ].map(key => [key, { $ifNull: [`$${key}`, null] }])); - let replacement: any = profile.data_valid ? { $literal: record } + let replacement: any = record.data_valid ? { $literal: record } : { $mergeObjects: [{ $literal: record }, heldFields] }; // A concurrent earlier run may establish paid identity after our batch read. // Do not erase it or publish VIP0 over it, even on structurally valid free-user input. - if (profile.has_ever_paid !== true) replacement = { $cond: [ + if (record.has_ever_paid !== true) replacement = { $cond: [ { $eq: ['$has_ever_paid', true] }, { $mergeObjects: [{ $literal: { ...record, has_ever_paid: true, paid_status: 'unknown', - data_valid: false, invalid_reasons: [...profile.invalid_reasons, 'paid_identity_changed_during_calculation'] } }, heldFields] }, + data_valid: false, invalid_reasons: [...record.invalid_reasons, 'paid_identity_changed_during_calculation'] } }, heldFields] }, replacement, ] }; return { updateOne: { // Match only the unique user ID, so a newer snapshot cannot cause an upsert ID conflict. // Apply the freshness check atomically inside the update, including same-day reruns. - filter: { _id: user._id }, + filter: { _id: record._id }, update: [{ $replaceWith: { $cond: [ { $and: [ { $lte: [{ $ifNull: ['$asOf', 0] }, asOf] }, @@ -220,20 +242,40 @@ export default async function () { ] } }], upsert: true, } }; - })); + })) : { modifiedCount: 0, upsertedCount: 0 }; // Read back the committed tuple: a concurrent task may have won the freshness check, // or invalid input may have retained a newer good tuple. Never publish the attempted result. - const committed: any[] = await rechargeStatsCollection.find({ _id: { $in: batch.map(user => user._id) } }, + const persisted: any[] = await rechargeStatsCollection.find({ _id: { $in: batch.map(user => user._id) } }, { projection: { _id: 1, openid: 1, asOf: 1, updatedAt: 1, data_valid: 1, paid_status: 1, - has_ever_paid: 1, maintenance_identity: 1, profile_rule_version: 1, + has_ever_paid: 1, amountTotal: 1, orderCount: 1, maintenance_identity: 1, profile_rule_version: 1, profile_calculated_as_of: 1, rfm: 1 } }).toArray(); + const persistedById = new Map(persisted.map(row => [userKey(row._id), row])); + const freeById = new Map(freeRecords.map(row => [userKey(row._id), row])); + const committed: any[] = batch.map(user => { + const saved = persistedById.get(userKey(user._id)); + const free = freeById.get(userKey(user._id)); + // A paid/newer snapshot committed during calculation wins over initialization. + if (!free || saved?.has_ever_paid === true || saved?.paid_status === 'paid' + || saved?.amountTotal > 0 || saved?.orderCount > 0 + || saved?.updatedAt > updatedAt || saved?.asOf > asOf) return saved; + return free; + }).filter(Boolean); + // Remove only zero-valued, explicitly verified legacy free-user records. Do this + // before marking migration complete on users so a failed cleanup can be retried. + const cleanupIds = committed.filter(row => row.data_valid && row.paid_status === 'never').map(row => row._id); + if (cleanupIds.length) { + const cleanup = await rechargeStatsCollection.deleteMany({ _id: { $in: cleanupIds }, + has_ever_paid: { $ne: true }, paid_status: { $ne: 'paid' }, amountTotal: 0, orderCount: 0, + asOf: { $lte: asOf }, updatedAt: { $lte: updatedAt } }); + removedNeverPaidStats += cleanup.deletedCount; + } const batchById = new Map(batch.map(user => [userKey(user._id), user])); const vipResult = committed.length ? await users.bulkWrite(committed.map(saved => { const source = batchById.get(userKey(saved._id))!; // The retained RFM tuple also permits first-time migration/retry without the legacy // userRechargeStats.vip_level field, even if this attempt has invalid order inputs. const score = saved.rfm?.score; - const calculatedLevel = Number.isFinite(score) && saved.profile_calculated_as_of != null + const calculatedLevel = saved.data_valid && saved.paid_status === 'never' ? 0 : Number.isFinite(score) && saved.profile_calculated_as_of != null && saved.rfm.last_effective_paid_at !== undefined ? paymentVipLevel(saved.rfm.last_effective_paid_at !== null, score) : null; @@ -261,6 +303,7 @@ export default async function () { { $literal: calculatedLevel === null ? null : value ?? null }, ] }); const metadata = { + maintenance_version: { $literal: 9 }, as_of: { $literal: saved.asOf }, updated_at: { $literal: saved.updatedAt }, data_valid: valid, identity: { $literal: identity }, has_ever_paid: { $or: [{ $eq: ['$vip_profile.has_ever_paid', true] }, @@ -289,7 +332,7 @@ export default async function () { } const result = { asOf, updatedAt, scannedUsers, skippedNeverPaidUsers, - processedUsers, updatedUsers, updatedVipUsers, validProfiles, invalidProfiles }; + processedUsers, updatedUsers, updatedVipUsers, removedNeverPaidStats, validProfiles, invalidProfiles }; console.log('rechargeStats completed', result); return { code: 1, data: result, msg: '用户付费画像维护完成' }; } diff --git a/laf-cloud/functions/rechargeStats.yaml b/laf-cloud/functions/rechargeStats.yaml index 98e0fa3..2f84b6d 100644 --- a/laf-cloud/functions/rechargeStats.yaml +++ b/laf-cloud/functions/rechargeStats.yaml @@ -1,5 +1,5 @@ name: rechargeStats -desc: "每日03:00维护付费及身份变化用户画像;未付费初始化VIP0后跳过重复计算" +desc: "每日03:00维护曾付费及异常用户统计;未付费仅初始化users中的VIP0" methods: [] tags: - statistics diff --git a/laf-cloud/rechargeStats.README.md b/laf-cloud/rechargeStats.README.md index 2a73a1d..08338fa 100644 --- a/laf-cloud/rechargeStats.README.md +++ b/laf-cloud/rechargeStats.README.md @@ -1,21 +1,24 @@ # 付费画像维护:未付费初始化,曾付费每日重算 -云函数 `rechargeStats` 每天北京时间03:00轻量检查用户身份,主要重算曾付费用户,并处理需要初始化、身份变化或异常待核验的用户。等级写入 `users.vip_level`,有效状态与计算元数据写入 `users.vip_profile`;计算依据及因子写入独立集合 `userRechargeStats`,不再保存 `vip_level`;每位玩家保留一条最新记录,`_id`保留 `users._id` 的原始BSON类型。已核实未付费的用户初始化VIP0后,身份未变则跳过订单查询与画像写入,不追加每日历史。实际重算的金额截止固定为北京时间昨日 `23:59:59.999`,画像截止为紧接着的当天00:00。`order`只读;`users`仅更新本任务的两个VIP属性,现有 `pay_user` 和其他业务属性不变。 +云函数 `rechargeStats` 每天北京时间03:00轻量检查用户身份,持续重算曾付费用户,并处理身份异常或变化的用户。等级保存在 `users.vip_level`,有效状态与计算元数据保存在 `users.vip_profile`。`userRechargeStats` 只保存曾付费用户的统计及尚待核验的异常记录;正常未付费用户不创建统计文档,也不查询历史订单。统计记录的 `_id` 保留 `users._id` 的原始BSON类型,不保存 `vip_level`。 -本次范围仅为每日画像维护。活动以后在创建实例时读取画像并自行计算、锁定规格;这里不生成报价、不改变黄金矿工或幸运礼包、不执行活动冷却、探索或支付逻辑,也不在支付回调中新增实时画像任务。 +已确认未付费用户首次维护时只初始化用户属性,之后身份未变则完全跳过统计集合的读取、订单查询和数据库写入。每日仍分页读取全用户的少量身份属性,以发现首次付费或账号变更,不是全用户统计重算。付费金额截止固定为北京时间昨日 `23:59:59.999`,RFM截止为紧接着的当天00:00。`order`只读;`users`仅更新两个VIP属性,现有 `pay_user` 和其他业务属性不变。 -## 版本8的维护范围 +本次范围仅为每日画像维护,不改活动选价、活动实例或支付业务。不在注册/支付回调中新增画像任务:未付费用户在首次日批初始化,首次付费依赖现有支付确认路径更新 `users.pay_user`,下一次日批自动纳入。 -1. **未付费初始化**:画像缺失时读取服务端用户身份并查询成功订单,只有 `pay_user=false` 且没有冲突/异常的记录才保存VIP0。不能因为查不到画像直接赋0。 -2. **曾付费每日重算**:`pay_user=true` 或已有曾付费记录的用户持续处理,长时间不付费也要更新窗口和R;不会因近30天金额变0而退出维护。 -3. **新付费与身份变化重新纳入**:现有支付确认路径更新 `users.pay_user`,下一次日批会自动发现变化;openid或注册时间变化同样触发核验。无需给每条支付路径新增画像写入。当天发生在统计截止之后的首笔支付,先记录身份/截止不一致并标记无效,下一日纳入计算,不把旧VIP0继续当有效首购依据。 -4. **稳定VIP0跳过重算**:仅当统计版本8、两集合计算批次一致、规则版本一致、`data_valid=true`、`paid_status=never`、`has_ever_paid=false`、`users.vip_level=0` 且 `users.vip_profile.data_valid=true`,且openid及 `maintenance_identity` 与当前用户一致时跳过。缺失、异常、旧版本或规则变化均重新核验。异常记录每日重试,避免永久停在未知状态。 +## 版本9的维护范围 -日批仍会分页读取全用户的少量身份字段及对应画像元数据,以发现变更;优化的是未付费用户的历史订单扫描、画像计算与数据库写入,并非取消所有全用户读取。没有新增独立队列或支付钩子。 +1. **未付费初始化**:明确的服务端 `pay_user=false`、openid可用、没有历史付费证据或已知异常、账号身份未变化时,直接在 `users` 初始化VIP0,不创建统计文档、不查订单。当日刚注册的用户也可初始化,VIP0不依赖昨日截止的RFM计算。 +2. **曾付费每日重算**:`pay_user=true`、用户已记录曾付费或历史统计存在付费证据的用户持续维护。即使近30天没有付费,也不退出日批,不回退VIP0。 +3. **首次付费/身份变化**:当前付费标记、openid或注册时间与身份快照不同即重新纳入。当天发生在统计截止之后的首笔支付先标记画像无效,下一日纳入金额计算,不能继续把旧VIP0作为首购依据。 +4. **稳定VIP0跳过**:只依据 `users.vip_level=0`、`vip_profile.maintenance_version=9`、有效且未曾付费、当前身份与快照一致。无需存在 `userRechargeStats`;RFM规则版本变化也不导致未付费用户重算。 +5. **异常核验**:身份标记缺失、付费证据冲突、历史画像无效或账号身份变化时查询订单,并保留必要的异常统计与失效状态。不能因没有统计记录而认定VIP0;核验恢复为正常未付费后,退出统计维护。 -被跳过的VIP0保留真实初始化时间,不每天伪造新的 `asOf/updatedAt`。后续活动读取时,须先核对当前用户身份与 `maintenance_identity/openid`;稳定未付费画像不能仅因初始化日期较早就视为失效,而身份已变化的旧VIP0不能继续用于首购判断。画像缺失/身份不一致应视为待核验,交由日批或后续显式核验流程处理,不使用 `user?.vip_level ?? 0`。 +此规则依赖服务端付费标记可靠。历史漏标、外部账号迁移或绕过正常支付流程写订单,应先修复付费身份或将 `vip_profile.data_valid` 标记为false以触发核验;本任务不对稳定VIP0每日做全历史查单。上线前需单独核查已知历史迁移问题。 -## users的VIP属性(版本8) +被跳过的VIP0保留真实初始化时间,不伪造每日更新时间。业务判断资格时还需比较当前用户身份与 `vip_profile.identity`。未付费用户无统计文档是正常状态,但缺少用户身份或VIP结果不能使用 `user?.vip_level ?? 0` 默认成未付费。 + +## users的VIP属性 `users.vip_level` 是业务查询等级的唯一入口。不要再读取 `userRechargeStats.vip_level`。查询用户时按需投影这两个属性即可: @@ -23,9 +26,10 @@ | --- | --- | | `vip_level` | 0~5;只有核实从未付费才初始化0;没有可信历史结果且本次异常则为null | | `vip_profile.data_valid` | 最近一次发布时,计算结果与当前身份是否一致;异常时false,同时保留旧等级 | -| `vip_profile.calculated_as_of` | 当前等级实际计算截止时间;异常保留旧等级时保留旧时间,没有可信计算则null | +| `vip_profile.calculated_as_of` | 付费等级实际计算截止时间;直接初始化VIP0时为初始化时刻;异常保留旧等级时保留旧时间,没有可信计算则null | | `vip_profile.rule_version` | 当前等级实际使用的规则版本 | -| `vip_profile.as_of` / `updated_at` | 对应统计记录的 `asOf` / `updatedAt`,用于比较批次和阻止较旧任务覆盖 | +| `vip_profile.as_of` / `updated_at` | 维护批次标识,用于阻止较旧任务覆盖;付费用户对应统计记录的 `asOf` / `updatedAt`,未付费用户无需对应文档 | +| `vip_profile.maintenance_version` | 维护流程版本,当前9;与RFM规则版本不同 | | `vip_profile.identity` | 计算依据的openid、pay_user与规范化注册时间,用于识别计算之后的身份变化 | | `vip_profile.has_ever_paid` | 已确认的曾付费证据,统计记录丢失也不能因此退回有效VIP0 | @@ -37,7 +41,7 @@ const user = await db.collection('users').findOne({ _id: userId }, { // 资格判断还需检查vip_profile.data_valid、identity和真实计算时间。 ``` -计算后发生的支付或身份变化仍由下一次日批维护,因此 `data_valid=true` 不是实时首购资格保证;业务须同时比较当前身份,尤其是 `pay_user`。稳定未付费用户允许保留初始化时间,曾付费用户需检查计算时效。读取等级与单笔因子的活动须再比较两个集合的批次及实际计算时间,任何无效或不一致结果都不能用于新报价。 +计算后发生的支付或身份变化仍由下一次日批维护,因此 `data_valid=true` 不是实时首购资格保证;业务须同时比较当前身份,尤其是 `pay_user`。稳定未付费用户允许保留初始化时间,曾付费用户需检查计算时效。曾付费用户读取等级与单笔因子时须再比较两个集合的批次及实际计算时间,任何无效或不一致结果都不能用于新报价。正常未付费用户直接读取用户属性,不要求统计记录;后续活动自行定义其VIP0业务规则。 ## userRechargeStats画像字段 @@ -58,7 +62,7 @@ const user = await db.collection('users').findOne({ _id: userId }, { | `ticket_factors` | `general`、`coin`、`lucky` 三种上下文的单笔证据:置信度、中位数、重复价位及A | | `maintenance_identity` | 计算时的 `pay_user` 与规范化注册时间;结合openid判断身份是否变化 | -`data_valid=true` 仅表示当前确认订单口径可计算,**不代表已核实净实付或生产定价效果**。后续活动必须同时检查口径、质量标记与计算时点;不得将缺记录、null或异常保留值直接当成VIP0。缺订单且只有 `pay_user=true` 时标记unknown;曾确认付费后订单消失时保留曾付费身份并标记本次无效;存在订单但 `pay_user=false` 时标记身份冲突,不自动改写用户付费标记。正常的 `pay_user=false` 且无成功订单使用既有服务端身份记录,保存VIP0。 +`data_valid=true` 仅表示当前确认订单口径可计算,**不代表已核实净实付或生产定价效果**。后续活动必须同时检查口径、质量标记与计算时点;不得将缺记录、null或异常保留值直接当成VIP0。缺订单且只有 `pay_user=true` 时标记unknown;曾确认付费后订单消失时保留曾付费身份并标记本次无效;存在订单但 `pay_user=false` 时标记身份冲突,不自动改写用户付费标记。正常的 `pay_user=false` 且无成功订单使用既有服务端身份记录,仅在users保存VIP0。 ### 画像规则 @@ -101,7 +105,7 @@ const profile = await db.collection('userRechargeStats').findOne({ _id: userId } | `invalidOrderCount` | 缺失订单号、金额或数量无效而跳过的记录数 | | `duplicateOrderCount` | 跳过的重复订单记录数 | | `missingOpenid` | 用户是否缺少可关联订单的 openid | -| `version` | 统计规则及存储版本,当前为 8 | +| `version` | 统计规则及存储版本,当前为 9 | | `duplicateConflictCount` | 同一订单号出现不同金额或支付时间的记录数;触发画像保留 | 版本 1 的金额单位为元,版本 2 使用分单位但按运行时间减 3 小时作为截止点,版本 3 改为固定前一天末尾。版本 4 保留版本 3 的计算口径,改为独立集合。首次运行从订单重算,不读取或复制 `users.rechargeStats`,旧字段的单位和截止点不影响新结果。 @@ -131,19 +135,21 @@ const stats = await db.collection('userRechargeStats').findOne({ _id: userId }); ## 执行与一致性 -用户按 `_id` 游标每批100条读取,只投影 `_id`、`openid`、`pay_user`、`register_time`、`vip_level`、`vip_profile`;每批读取一次对应画像元数据,筛掉稳定VIP0。整页均被跳过时仍推进游标,保证后面的付费用户不会漏处理。只有剩余用户的openid进入订单查询,订单使用MongoDB游标遍历,不受默认查询条数限制。需要维护的用户仍从历史订单重算,尚未引入日汇总增量账本;大规模部署前应测算身份扫描及付费订单扫描耗时。 +用户按 `_id` 游标每批100条读取,只投影 `_id`、`openid`、`pay_user`、`register_time`、`vip_level`、`vip_profile`;先仅依据用户属性筛掉稳定VIP0,再读取其余用户的历史统计证据。整页均被跳过时仍推进游标,保证后面的付费用户不会漏处理。其余用户中,明确未付费且没有已知异常的用户只初始化VIP0;只有曾付费或需要核验用户的openid进入订单查询,订单使用MongoDB游标遍历,不受默认查询条数限制。需要维护的用户仍从历史订单重算,尚未引入日汇总增量账本;大规模部署前应测算身份扫描及付费订单扫描耗时。 -正常输入整体替换统计文档;异常输入更新本次金额统计、身份、质量标记,但在MongoDB更新管道内保留数据库中最新的 `rfm / spending / ticket_factors / profile_rule_version / profile_calculated_as_of` 整组字段。不能混用异常时的新统计金额与旧画像因子。该集合属于任务专有数据,不应混入活动实例、报价或支付状态。 +曾付费用户正常输入整体替换统计文档;异常输入更新本次金额统计、身份、质量标记,但在MongoDB更新管道内保留数据库中最新的 `rfm / spending / ticket_factors / profile_rule_version / profile_calculated_as_of` 整组字段。不能混用异常时的新统计金额与旧画像因子。该集合属于任务专有数据,不应混入活动实例、报价或支付状态。 写入通过 MongoDB 更新管道原子比较 `asOf` 和 `updatedAt`:较早统计日期或较早启动的任务不能覆盖较新的结果,包括同一天并发执行的情况。同一天重跑会重新计算,不重复累加;跨到下一个北京时间日期时,旧订单会按新截止时间退出滚动窗口。需 MongoDB 4.2 或以上支持更新管道,参考 [MongoDB 官方文档](https://www.mongodb.com/docs/manual/tutorial/update-documents-with-aggregation-pipeline/)。 -每批先写统计集合,再读取实际落库的RFM结果,按同一等级函数发布到 `users`;不直接发布可能在并发竞争中失效的内存计算值。正常输入更新等级和元数据;异常时原子保留 `users` 中最新等级及真实计算时间,并标记无效。首次迁移时若订单异常,可从统计集合保留的可信RFM结果恢复历史等级,但仍标记本次无效;无可信RFM则写null。整个流程不读取旧 `userRechargeStats.vip_level`,删除该字段不会影响后续维护。 +每批只向统计集合写入曾付费/异常记录,再读取实际落库的RFM结果,按同一等级函数发布到 `users`;不直接发布可能在并发竞争中失效的内存计算值。正常输入更新等级和元数据;异常时原子保留 `users` 中最新等级及真实计算时间,并标记无效。首次迁移时若订单异常,可从统计集合保留的可信RFM结果恢复历史等级,但仍标记本次无效;无可信RFM则写null。整个流程不读取旧 `userRechargeStats.vip_level`,删除该字段不会影响后续维护。未付费用户使用本次确认的VIP0直接更新用户属性,无需中转统计记录;写入前发现并发落库的曾付费或较新统计时,优先使用该记录。 `users`发布同样原子比较截止时间与任务启动时间,并在写入时核对身份;支付或账号变更不能让旧VIP0被标记有效。仅合并两个VIP属性,保留并发更新的金币、关卡等字段;不upsert用户,避免重新创建已经删除的账号。 +升级时会自动清理本轮已确认未付费的旧零统计文档:仅删除无曾付费标记、`amountTotal=0`、`orderCount=0` 且截止/更新时间不晚于本轮的文档。身份未解决的异常、付费证据及较新文档不清理;残缺或孤立旧记录需要单独核查,不能直接批量按 `vip_level=0` 删除。清理在用户维护版本标记写入前执行;清理失败会抛错,重跑不会因提前跳过而遗漏。统计清理不删除用户,也不改订单。 + 两集合写入不是跨集合事务,可能短暂出现批次不一致。统计写入成功而用户写入失败时抛出错误;重跑会补齐,未完成用户属性发布的VIP0不会被缓存跳过。已有等级仍保留其真实时间;需要组合读取的业务应拒绝批次不一致的数据。发布之后发生的支付或用户变更下一轮体现。失败时抛出错误,已完成批次保留,剩余用户下次重算。删除用户后不会自动删除已有统计。出现曾付费标记回退时不会把用户重置为VIP0。稳定VIP0依赖既有服务端身份记录;绕过正常支付流程直接修改订单库时,必须同步修正用户身份或使对应画像失效,不能期望跳过的用户仍会每日重新查单。 -返回值中 `scannedUsers` 是身份检查人数,`skippedNeverPaidUsers` 是跳过的稳定VIP0人数,`processedUsers` 是实际核验/重算人数,正常结束时 `scannedUsers=skippedNeverPaidUsers+processedUsers`。`updatedUsers` 是统计集合新增及实际修改的记录数,`updatedVipUsers` 是实际更新用户VIP属性的数量;跳过较新结果或完全相同的记录不计入。成功返回的 `updatedAt` 只能筛选本轮实际写入结果,不能代表全用户快照。 +返回值中 `scannedUsers` 是身份检查人数,`skippedNeverPaidUsers` 是跳过的稳定VIP0人数,`processedUsers` 是初始化、核验或重算人数,正常结束时 `scannedUsers=skippedNeverPaidUsers+processedUsers`。`updatedUsers` 是统计集合新增及实际修改的记录数,`updatedVipUsers` 是实际更新用户VIP属性的数量,`removedNeverPaidStats` 是本轮实际清理的未付费旧统计文档数;跳过较新结果或完全相同的记录不计入。成功返回的 `updatedAt` 只能筛选本轮实际写入结果,不能代表全用户快照。 建议上线前在 Laf 数据库控制台建立以下非唯一索引(若已有等价索引则复用): @@ -159,8 +165,8 @@ db.collection('order').createIndex({ openid: 1, outTradeNo: 1, _id: 1 }); 代码与触发器配置是独立资源,提交本地文件不会自动启动线上定时任务。`recharge-stats.trigger.json` 使用 Laf 创建触发器 API 的 `desc / target / cron` 字段。 1. 停止旧版任务并等待正在运行的旧任务结束。先发布 `functions/paymentProfile.ts` 及同名YAML,再发布 `functions/rechargeStats.ts` 及同名YAML,保持 `methods: []`。旧任务必须停完,否则其整体替换操作可能删掉新增画像字段。 -2. 手动执行一次 `rechargeStats`(无参数),首次upsert会创建集合。旧版本画像首次升级时会重新核验一次,不能在用户VIP属性尚未落库时跳过。检查 `scannedUsers / skippedNeverPaidUsers / processedUsers / updatedUsers / updatedVipUsers / validProfiles / invalidProfiles`,抽查统计版本8、`users.vip_level/vip_profile`及相同批次标识;再运行一次应能观察到稳定VIP0被跳过。返回的valid/invalid是本次计算人数,可能因写入时发现较新记录而未落库;不是全服原子快照。只保存最新状态,不新增每日全量历史或周快照任务。 - 版本8重写统计文档时不再保留旧 `vip_level`。确认用户属性已完成发布后,可全量清理统计集合尚未处理记录中的旧字段;本代码不依赖该字段,也不会重新生成它。必须同时更新业务读取入口;缺用户属性的记录应待核验,不能默认0。 +2. 手动执行一次 `rechargeStats`(无参数),首次upsert会创建集合。旧版本用户会完成一次维护版本迁移,确认未付费身份后只更新users并清理旧零统计,不再为其重建统计记录。检查 `scannedUsers / skippedNeverPaidUsers / processedUsers / updatedUsers / updatedVipUsers / removedNeverPaidStats / validProfiles / invalidProfiles`,抽查付费统计版本9、`users.vip_level/vip_profile`及相同批次标识,确认正常未付费用户只保留users属性;再运行一次应能观察到稳定VIP0被跳过。返回的valid/invalid是本次计算人数,可能因写入时发现较新记录而未落库;不是全服原子快照。只保存最新状态,不新增每日全量历史或周快照任务。 + 重写统计文档时不再保留旧 `vip_level`。确认用户属性已完成发布后,可全量清理统计集合尚未处理记录中的旧字段;本代码不依赖该字段,也不会重新生成它。必须同时更新业务读取入口;缺用户属性的记录应待核验,不能默认0。 3. 在触发器面板绑定函数 `rechargeStats`,使用 `recharge-stats.trigger.json` 的配置:每日 03:00 执行。表达式 `0 3 * * *` 按触发器时区解释;目标是北京时间 03:00,应确认调度时区为 `Asia/Shanghai`。如果部署使用 UTC 调度,则使用 `0 19 * * *`(UTC 19:00 为次日北京时间 03:00)。统计日期固定按北京时间计算,与订单存储的 8 小时修正分别处理。 4. 检查已有触发器,避免重复创建。若使用已登录且已绑定正确应用的 Laf CLI,可执行: @@ -183,6 +189,8 @@ node --test laf-cloud/tests/recharge-stats.test.mjs laf-cloud/tests/payment-prof 覆盖分单位、固定北京时间前一天末尾截止、15/30 天毫秒边界、准时/延迟/手动执行、跨月/跨年/闰日、当天订单次日计入、旧嵌入字段不影响新结果、8 小时存储修正、支付时间优先、时间回退、无效金额、数量、去重、测试订单排除、超过 1000 条订单、多页用户、独立集合 upsert、用户其他属性保留、重跑、跨日期及同日并发写保护、异常中断和关闭 HTTP 入口。使用内存 MongoDB 接口替身,不连接生产数据库;上线仍需实际 MongoDB 环境验证。 -版本7还验证稳定VIP0跨日零查单/零写入、整页跳过后继续处理付费用户、新付费自动纳入、曾付费窗口过期后继续维护、账号关联/注册时间/规则版本变化重新核验,以及画像缺失或异常时不默认VIP0。 +版本7还验证稳定VIP0跨日零查单/零写入、整页跳过后继续处理付费用户、新付费自动纳入、曾付费窗口过期后继续维护、账号关联/注册时间变化重新核验,以及画像缺失或异常时不默认VIP0。 版本8还验证VIP只写users、旧统计等级字段删除后的迁移和维护、异常时保留用户最新等级与真实计算时间、两次写入之间失败后的重跑补齐、用户快照并发保护、计算期间身份变化失效、删除账号不重建,以及统计记录丢失后仍保留曾付费证据。 + +版本9验证未付费初始化不查订单、不创建统计记录、后续跳过不读取统计集合、当日注册初始化、旧零记录迁移清理、清理失败重跑、并发付费数据不误删、异常身份解决后退出统计维护,以及付费发布中断后的恢复。 diff --git a/laf-cloud/tests/recharge-stats.test.mjs b/laf-cloud/tests/recharge-stats.test.mjs index 8f1cb42..238471c 100644 --- a/laf-cloud/tests/recharge-stats.test.mjs +++ b/laf-cloud/tests/recharge-stats.test.mjs @@ -7,7 +7,7 @@ const DAY = 86400000; const OFFSET = 8 * 3600000; const NOW = Date.parse('2026-09-15T03:00:00+08:00'); const CUTOFF = Date.parse('2026-09-14T23:59:59.999+08:00'); -const state = { users: [], order: [], userRechargeStats: [], reads: [], orderQueries: [], writes: 0, closed: 0, beforeWrite: null, beforeUserWrite: null, failUserWrite: false, vipWrites: 0, failRead: false }; +const state = { users: [], order: [], userRechargeStats: [], reads: [], orderQueries: [], writes: 0, closed: 0, beforeStatsRead: null, beforeCleanup: null, beforeWrite: null, beforeUserWrite: null, failUserWrite: false, vipWrites: 0, failRead: false }; const realNow = Date.now; Date.now = () => NOW; test.after(() => { Date.now = realNow; }); @@ -59,6 +59,7 @@ globalThis.__rechargeStatsCloud = { mongo: { db: { return { find(query, options) { state.reads.push(name); + if (name === 'userRechargeStats') state.beforeStatsRead?.(); if (name === 'order') state.orderQueries.push(structuredClone(query)); if (name === 'users') assert.deepEqual(options.projection, { _id: 1, openid: 1, pay_user: 1, register_time: 1, vip_level: 1, vip_profile: 1 }); let rows = state[name].filter(row => matches(row, query)); @@ -84,6 +85,13 @@ globalThis.__rechargeStatsCloud = { mongo: { db: { async close() { state.closed++; }, }; }, + async deleteMany(query) { + assert.equal(name, 'userRechargeStats'); + state.beforeCleanup?.(); + const before = state[name].length; + state[name] = state[name].filter(row => !matches(row, query)); + return { deletedCount: before - state[name].length }; + }, async bulkWrite(operations) { assert.notEqual(name, 'order', 'orders remain read-only'); if (name === 'userRechargeStats') { state.writes++; state.beforeWrite?.(); } @@ -130,7 +138,7 @@ const { default: run } = await import('../functions/rechargeStats.ts'); function reset() { Object.assign(state, { users: [{ _id: 'u1', openid: 'o1', pay_user: true }], order: [], userRechargeStats: [], reads: [], orderQueries: [], - writes: 0, closed: 0, beforeWrite: null, beforeUserWrite: null, failUserWrite: false, vipWrites: 0, failRead: false, + writes: 0, closed: 0, beforeStatsRead: null, beforeCleanup: null, beforeWrite: null, beforeUserWrite: null, failUserWrite: false, vipWrites: 0, failRead: false, }); } function userFor(id = 'u1') { return state.users.find(row => row._id === id); } @@ -157,7 +165,7 @@ test('windows exclude the previous end-of-day boundary and include yesterday fin assert.equal(stats.amountTotal, 500); assert.equal(stats.orderCount, 5); assert.equal(stats.asOf, CUTOFF); - assert.equal(stats.version, 8); + assert.equal(stats.version, 9); assert.equal(stats.unit, 'fen'); assert.equal(stats.updatedAt, NOW); assert.equal(stats._id, 'u1'); @@ -226,7 +234,7 @@ test('maximum includes undated lifetime orders and is recomputed when orders cha await run(); assert.equal(statsFor().amountMax, 1200); assert.equal(statsFor().amount30d, 0); - assert.equal(statsFor().version, 8); + assert.equal(statsFor().version, 9); state.order[0].goodsPrice = 200; await run(); assert.equal(statsFor().amountMax, 200); @@ -242,7 +250,7 @@ test('processes all users across pages, retains legacy totals and distinguishes rechargeStats: { asOf: NOW - DAY, amountTotal: 999 }, })); state.users.push({ _id: 'no-openid', pay_user: true }); - state.users.push({ _id: 'free', openid: 'free', pay_user: false }); + state.users.push({ _id: 'free', openid: 'free', pay_user: false, vip_profile: { data_valid: false } }); state.users.push({ _id: 'string', openid: 'string', pay_user: 'true' }); state.order = [order('last', 1, { openid: 'o250' }), order('free', 1, { openid: 'free' })]; state.userRechargeStats = [{ _id: 'u0000', asOf: CUTOFF - DAY, amountTotal: 999 }]; @@ -263,7 +271,7 @@ test('processes all users across pages, retains legacy totals and distinguishes assert.equal(userFor('string').vip_level, null); assert.equal(state.userRechargeStats.length, 254); assert.equal(state.reads.filter(name => name === 'users').length, 4, 'one read per page, not per user'); - assert.deepEqual(state.users.map(withoutVip), originalUsers); + assert.deepEqual(state.users.map(withoutVip), originalUsers.map(withoutVip)); }); test('reruns overwrite instead of accumulating; older runs cannot overwrite newer snapshots', async () => { @@ -356,7 +364,7 @@ test('ignores legacy embedded amounts and preserves unrelated user fields', asyn state.order = [order('paid', DAY, { goodsPrice: 101, itemCount: 3 })]; await run(); const stats = statsFor(); - assert.equal(stats.version, 8); + assert.equal(stats.version, 9); assert.equal(stats.unit, 'fen'); assert.equal(stats.asOf, CUTOFF); assert.equal(stats.amount15d, 303); @@ -416,7 +424,8 @@ test('writes VIP0 for a confirmed never-payer and null for a paid flag with miss state.users.push({ _id: 'free', openid: 'free', pay_user: false, register_time: CUTOFF - 10 * DAY }); await run(); assert.equal(userFor('free').vip_level, 0); - assert.equal(statsFor('free').data_valid, true); + assert.equal(statsFor('free'), undefined); + assert.equal(userFor('free').vip_profile.data_valid, true); assert.equal(statsFor().paid_status, 'unknown'); assert.equal(userFor().vip_level, null); assert.equal(statsFor().data_valid, false); @@ -472,15 +481,15 @@ test('invalid calculation preserves a good tuple committed during the calculatio test('a paid identity saved during the batch read cannot be overwritten with VIP0', async () => { reset(); state.users[0].pay_user = false; - state.beforeWrite = () => { state.userRechargeStats = [{ + let reads = 0; + state.beforeStatsRead = () => { if (++reads !== 2) return; state.userRechargeStats = [{ _id: 'u1', asOf: CUTOFF - DAY, updatedAt: NOW - 1, has_ever_paid: true, vip_level: 2, rfm: { score: 2, last_effective_paid_at: CUTOFF - DAY }, profile_calculated_as_of: CUTOFF - DAY + 1, }]; }; await run(); assert.equal(userFor().vip_level, 2); assert.equal(statsFor().has_ever_paid, true); - assert.equal(statsFor().data_valid, false); - assert.equal(statsFor().paid_status, 'unknown'); + assert.equal(userFor().vip_profile.data_valid, false); }); test('conflicting duplicates hold the old profile; corrected records restore valid calculation', async () => { @@ -511,9 +520,10 @@ test('verified never-payers initialize once and skip order queries and writes on const first = await run(); assert.equal(first.data.processedUsers, 1); assert.equal(userFor().vip_level, 0); - assert.equal(statsFor().data_valid, true); - assert.equal(state.orderQueries.length, 1, 'initialization verifies the order source'); - const initialized = structuredClone(statsFor()); + assert.equal(statsFor(), undefined); + assert.equal(userFor().vip_profile.data_valid, true); + assert.equal(state.orderQueries.length, 0, 'explicit false identity requires no payment statistics'); + const initialized = structuredClone(userFor()); state.reads = []; state.orderQueries = []; state.writes = 0; try { Date.now = () => NOW + DAY; @@ -524,7 +534,9 @@ test('verified never-payers initialize once and skip order queries and writes on assert.equal(second.data.updatedUsers, 0); assert.equal(state.orderQueries.length, 0); assert.equal(state.writes, 0); - assert.deepEqual(statsFor(), initialized, 'old initialization time remains truthful'); + assert.deepEqual(userFor(), initialized, 'old initialization time remains truthful'); + assert.equal(state.reads.includes('userRechargeStats'), false); + assert.equal(statsFor(), undefined); } finally { Date.now = () => NOW; } }); @@ -584,37 +596,30 @@ test('a changed account binding is reverified and does not inherit cached VIP0', assert.equal(userFor().vip_level, 1); }); -test('registration changes, rule changes and old schemas trigger re-initialization', async () => { - for (const change of ['registration', 'rule', 'schema']) { - reset(); state.users[0].pay_user = false; - await run(); - if (change === 'registration') state.users[0].register_time = CUTOFF - 30 * DAY; - if (change === 'rule') statsFor().profile_rule_version = 'old-rule'; - if (change === 'schema') statsFor().version = 6; - state.orderQueries = []; - const result = await run(); - assert.equal(result.data.processedUsers, 1, change); - assert.equal(state.orderQueries.length, 1, change); - assert.equal(userFor().vip_level, 0); - assert.equal(statsFor().version, 8); - } +test('registration changes are checked once, while RFM rule changes do not recalculate free users', async () => { + reset(); userFor().pay_user = false; + await run(); + userFor().register_time = CUTOFF - 30 * DAY; + const changed = await run(); + assert.equal(changed.data.processedUsers, 1); + assert.equal(state.orderQueries.length, 1); + assert.equal(userFor().vip_level, 0); + assert.equal(statsFor(), undefined); + userFor().vip_profile.rule_version = 'old-rule'; + assert.equal((await run()).data.skippedNeverPaidUsers, 1); }); -test('deleted or unknown profiles require identity and order verification, not a default VIP0', async () => { - reset(); state.users[0].pay_user = false; +test('unknown user identity is verified even without a stats record, and is never defaulted to VIP0', async () => { + reset(); delete userFor().pay_user; await run(); - state.userRechargeStats = []; - state.order = [order('unmatched-success', DAY)]; - await run(); - assert.equal(userFor().vip_level, 0); + assert.equal(userFor().vip_level, null); assert.equal(userFor().vip_profile.data_valid, false); assert.equal(statsFor().paid_status, 'unknown'); - assert.equal(statsFor().data_valid, false); - // Still unknown on subsequent runs: never cache invalid profiles as free users. + state.userRechargeStats = []; state.orderQueries = []; - const result = await run(); - assert.equal(result.data.processedUsers, 1); + assert.equal((await run()).data.processedUsers, 1); assert.equal(state.orderQueries.length, 1); + assert.equal(userFor().vip_level, null); }); test('a new payment after the daily cutoff invalidates cached VIP0 until next daily calculation', async () => { @@ -671,7 +676,7 @@ test('partial publication failure propagates and rerun repairs users before VIP0 reset(); userFor().pay_user = false; state.failUserWrite = true; await assert.rejects(run(), /users write failed/); - assert.equal(statsFor().version, 8); + assert.equal(statsFor(), undefined); assert.equal(userFor().vip_level, undefined); state.failUserWrite = false; const result = await run(); @@ -752,3 +757,97 @@ test('publication uses the committed winner rather than the attempted calculatio assert.equal(userFor().vip_profile.updated_at, NOW + 60000); assert.equal(userFor().vip_profile.data_valid, true); }); + + +function legacyFreeStats() { + return { _id: 'u1', openid: 'o1', version: 8, asOf: CUTOFF - DAY, updatedAt: NOW - DAY, + amountTotal: 0, orderCount: 0, has_ever_paid: false, paid_status: 'never', data_valid: true, + maintenance_identity: { pay_user: false, register_time: null } }; +} + +test('version 8 free statistics are removed during migration and never recreated', async () => { + reset(); userFor().pay_user = false; + state.userRechargeStats = [legacyFreeStats()]; + const result = await run(); + assert.equal(result.data.removedNeverPaidStats, 1); + assert.equal(result.data.updatedUsers, 0); + assert.equal(statsFor(), undefined); + assert.equal(userFor().vip_level, 0); + assert.equal(userFor().vip_profile.maintenance_version, 9); + assert.equal(state.orderQueries.length, 0); + state.reads = []; + assert.equal((await run()).data.skippedNeverPaidUsers, 1); + assert.ok(!state.reads.includes('userRechargeStats')); +}); + +test('failed legacy cleanup retries before the user is marked as migrated', async () => { + reset(); userFor().pay_user = false; + state.userRechargeStats = [legacyFreeStats()]; + state.beforeCleanup = () => { throw new Error('cleanup failed'); }; + await assert.rejects(run(), /cleanup failed/); + assert.equal(userFor().vip_profile, undefined); + assert.equal(state.userRechargeStats.length, 1); + state.beforeCleanup = null; + assert.equal((await run()).data.removedNeverPaidStats, 1); + assert.equal(userFor().vip_profile.maintenance_version, 9); +}); + +test('cleanup cannot delete concurrently established paid statistics or reset a paid user', async () => { + reset(); userFor().pay_user = false; + state.userRechargeStats = [legacyFreeStats()]; + state.beforeCleanup = () => { + Object.assign(statsFor(), { has_ever_paid: true, paid_status: 'paid', amountTotal: 600, orderCount: 1 }); + Object.assign(userFor(), { pay_user: true, vip_level: 1 }); + }; + const result = await run(); + assert.equal(result.data.removedNeverPaidStats, 0); + assert.equal(statsFor().amountTotal, 600); + assert.equal(userFor().vip_level, 1); + assert.equal(userFor().vip_profile.data_valid, false); +}); + +test('new never-payers registered after midnight initialize VIP0 without scanning orders', async () => { + reset(); userFor().pay_user = false; userFor().register_time = NOW - 1000; + await run(); + assert.equal(userFor().vip_level, 0); + assert.equal(userFor().vip_profile.data_valid, true); + assert.equal(userFor().vip_profile.calculated_as_of, NOW); + assert.equal(state.orderQueries.length, 0); + assert.equal(state.writes, 0); + assert.equal(statsFor(), undefined); +}); + +test('paid publication failure remains recoverable when free users no longer store stats', async () => { + reset(); state.order = [order('paid', DAY, { goodsPrice: 6800 })]; + state.failUserWrite = true; + await assert.rejects(run(), /users write failed/); + assert.equal(statsFor().amountTotal, 6800); + assert.equal(userFor().vip_level, undefined); + state.failUserWrite = false; + await run(); + assert.equal(userFor().vip_level, 2); + assert.equal(userFor().vip_profile.data_valid, true); +}); + +test('unknown zero statistics are removed only after the identity conflict is resolved', async () => { + reset(); delete userFor().pay_user; + await run(); + assert.equal(statsFor().data_valid, false); + assert.equal(userFor().vip_level, null); + userFor().pay_user = false; + const result = await run(); + assert.equal(result.data.removedNeverPaidStats, 1); + assert.equal(userFor().vip_level, 0); + assert.equal(userFor().vip_profile.data_valid, true); + assert.equal(statsFor(), undefined); +}); + +test('legacy positive order evidence prevents trusting a rolled-back false pay flag', async () => { + reset(); userFor().pay_user = false; + state.userRechargeStats = [{ ...legacyFreeStats(), amountTotal: 600, orderCount: 1 }]; + await run(); + assert.equal(userFor().vip_level, null); + assert.equal(userFor().vip_profile.data_valid, false); + assert.equal(statsFor().has_ever_paid, true); + assert.equal(state.orderQueries.length, 1); +});