更新修复一个显示异常

This commit is contained in:
COMPUTER\EDY 2026-09-30 17:15:21 +08:00
parent 36e6adb803
commit 0d12143572
4 changed files with 1911 additions and 468 deletions

View File

@ -770,7 +770,7 @@ export class GameConfig {
vibrateOpen: true, //震动
coinnum: 0, //每局的金币数
paid_user: false, //是否是付费用户
version: "1.0.74", //版本号
version: "1.0.75", //版本号
shushu_DistinctId: "", //数数访客ID
shushu_AccountId: "", //数数账号ID
uid: "", //用户和后端唯一id

File diff suppressed because it is too large Load Diff

View File

@ -27,14 +27,14 @@
"_active": true,
"_components": [
{
"__id__": 85
"__id__": 84
},
{
"__id__": 86
"__id__": 85
}
],
"_prefab": {
"__id__": 87
"__id__": 86
},
"_opacity": 255,
"_color": {
@ -254,7 +254,7 @@
"_active": true,
"_components": [],
"_prefab": {
"__id__": 84
"__id__": 83
},
"_opacity": 255,
"_color": {
@ -734,28 +734,28 @@
"__id__": 33
},
{
"__id__": 39
"__id__": 38
},
{
"__id__": 44
"__id__": 43
},
{
"__id__": 66
"__id__": 65
},
{
"__id__": 69
"__id__": 68
},
{
"__id__": 77
"__id__": 76
},
{
"__id__": 80
"__id__": 79
}
],
"_active": true,
"_components": [],
"_prefab": {
"__id__": 83
"__id__": 82
},
"_opacity": 255,
"_color": {
@ -1198,8 +1198,8 @@
"__type__": "TypedArray",
"ctor": "Float64Array",
"array": [
49.16,
-10,
41.986,
0.667,
0,
0,
0,
@ -1292,13 +1292,10 @@
},
{
"__id__": 35
},
{
"__id__": 36
}
],
"_prefab": {
"__id__": 38
"__id__": 37
},
"_opacity": 255,
"_color": {
@ -1379,22 +1376,6 @@
"_atlas": null,
"_id": ""
},
{
"__type__": "6624fhcuF5DF67gzSViWhRV",
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 33
},
"_enabled": true,
"heath": null,
"heatht": null,
"switchNode": null,
"timeNode": null,
"switchButtons": [],
"coin": null,
"_id": ""
},
{
"__type__": "cc.Button",
"_name": "",
@ -1409,7 +1390,7 @@
"zoomScale": 1.2,
"clickEvents": [
{
"__id__": 37
"__id__": 36
}
],
"_N$interactable": true,
@ -1499,14 +1480,14 @@
"_active": true,
"_components": [
{
"__id__": 40
"__id__": 39
},
{
"__id__": 41
"__id__": 40
}
],
"_prefab": {
"__id__": 43
"__id__": 42
},
"_opacity": 255,
"_color": {
@ -1560,7 +1541,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 39
"__id__": 38
},
"_enabled": true,
"_materials": [
@ -1592,7 +1573,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 39
"__id__": 38
},
"_enabled": true,
"_normalMaterial": null,
@ -1601,7 +1582,7 @@
"zoomScale": 1.1,
"clickEvents": [
{
"__id__": 42
"__id__": 41
}
],
"_N$interactable": true,
@ -1657,7 +1638,7 @@
"hoverSprite": null,
"_N$disabledSprite": null,
"_N$target": {
"__id__": 39
"__id__": 38
},
"_id": ""
},
@ -1691,23 +1672,23 @@
},
"_children": [
{
"__id__": 45
"__id__": 44
},
{
"__id__": 59
"__id__": 58
}
],
"_active": false,
"_components": [
{
"__id__": 62
"__id__": 61
},
{
"__id__": 63
"__id__": 62
}
],
"_prefab": {
"__id__": 65
"__id__": 64
},
"_opacity": 255,
"_color": {
@ -1761,26 +1742,26 @@
"_name": "coinh",
"_objFlags": 0,
"_parent": {
"__id__": 44
"__id__": 43
},
"_children": [
{
"__id__": 46
"__id__": 45
},
{
"__id__": 49
"__id__": 48
},
{
"__id__": 52
"__id__": 51
},
{
"__id__": 55
"__id__": 54
}
],
"_active": true,
"_components": [],
"_prefab": {
"__id__": 58
"__id__": 57
},
"_opacity": 255,
"_color": {
@ -1804,15 +1785,15 @@
"__type__": "TypedArray",
"ctor": "Float64Array",
"array": [
183.311,
207.103,
5.847,
0,
0,
0,
0,
1,
1,
1,
0.8,
0.8,
1
]
},
@ -1834,17 +1815,17 @@
"_name": "button_1",
"_objFlags": 0,
"_parent": {
"__id__": 45
"__id__": 44
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 47
"__id__": 46
}
],
"_prefab": {
"__id__": 48
"__id__": 47
},
"_opacity": 255,
"_color": {
@ -1898,7 +1879,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 46
"__id__": 45
},
"_enabled": true,
"_materials": [
@ -1943,17 +1924,17 @@
"_name": "button_0",
"_objFlags": 0,
"_parent": {
"__id__": 45
"__id__": 44
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 50
"__id__": 49
}
],
"_prefab": {
"__id__": 51
"__id__": 50
},
"_opacity": 255,
"_color": {
@ -2007,7 +1988,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 49
"__id__": 48
},
"_enabled": true,
"_materials": [
@ -2052,17 +2033,17 @@
"_name": "button_0",
"_objFlags": 0,
"_parent": {
"__id__": 45
"__id__": 44
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 53
"__id__": 52
}
],
"_prefab": {
"__id__": 54
"__id__": 53
},
"_opacity": 255,
"_color": {
@ -2116,7 +2097,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 52
"__id__": 51
},
"_enabled": true,
"_materials": [
@ -2161,17 +2142,17 @@
"_name": "button_0",
"_objFlags": 0,
"_parent": {
"__id__": 45
"__id__": 44
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 56
"__id__": 55
}
],
"_prefab": {
"__id__": 57
"__id__": 56
},
"_opacity": 255,
"_color": {
@ -2225,7 +2206,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 55
"__id__": 54
},
"_enabled": true,
"_materials": [
@ -2281,17 +2262,17 @@
"_name": "tilibuman",
"_objFlags": 0,
"_parent": {
"__id__": 44
"__id__": 43
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 60
"__id__": 59
}
],
"_prefab": {
"__id__": 61
"__id__": 60
},
"_opacity": 255,
"_color": {
@ -2345,7 +2326,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 59
"__id__": 58
},
"_enabled": true,
"_materials": [
@ -2390,7 +2371,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 44
"__id__": 43
},
"_enabled": true,
"_materials": [
@ -2424,7 +2405,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 44
"__id__": 43
},
"_enabled": true,
"_normalMaterial": null,
@ -2433,7 +2414,7 @@
"zoomScale": 1.1,
"clickEvents": [
{
"__id__": 64
"__id__": 63
}
],
"_N$interactable": true,
@ -2489,7 +2470,7 @@
"hoverSprite": null,
"_N$disabledSprite": null,
"_N$target": {
"__id__": 44
"__id__": 43
},
"_id": ""
},
@ -2525,11 +2506,11 @@
"_active": false,
"_components": [
{
"__id__": 67
"__id__": 66
}
],
"_prefab": {
"__id__": 68
"__id__": 67
},
"_opacity": 255,
"_color": {
@ -2583,7 +2564,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 66
"__id__": 65
},
"_enabled": true,
"_materials": [
@ -2632,20 +2613,20 @@
},
"_children": [
{
"__id__": 70
"__id__": 69
},
{
"__id__": 73
"__id__": 72
}
],
"_active": true,
"_components": [
{
"__id__": 75
"__id__": 74
}
],
"_prefab": {
"__id__": 76
"__id__": 75
},
"_opacity": 255,
"_color": {
@ -2699,17 +2680,17 @@
"_name": "tili_x",
"_objFlags": 0,
"_parent": {
"__id__": 69
"__id__": 68
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 71
"__id__": 70
}
],
"_prefab": {
"__id__": 72
"__id__": 71
},
"_opacity": 255,
"_color": {
@ -2763,7 +2744,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 70
"__id__": 69
},
"_enabled": true,
"_materials": [
@ -2808,13 +2789,13 @@
"_name": "huifuyidian",
"_objFlags": 0,
"_parent": {
"__id__": 69
"__id__": 68
},
"_children": [],
"_active": true,
"_components": [],
"_prefab": {
"__id__": 74
"__id__": 73
},
"_opacity": 255,
"_color": {
@ -2879,7 +2860,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 69
"__id__": 68
},
"_enabled": true,
"_materials": [
@ -2930,11 +2911,11 @@
"_active": false,
"_components": [
{
"__id__": 78
"__id__": 77
}
],
"_prefab": {
"__id__": 79
"__id__": 78
},
"_opacity": 255,
"_color": {
@ -2988,7 +2969,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 77
"__id__": 76
},
"_enabled": true,
"_materials": [
@ -3039,11 +3020,11 @@
"_active": true,
"_components": [
{
"__id__": 81
"__id__": 80
}
],
"_prefab": {
"__id__": 82
"__id__": 81
},
"_opacity": 255,
"_color": {
@ -3097,7 +3078,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 80
"__id__": 79
},
"_enabled": true,
"_materials": [
@ -3202,25 +3183,25 @@
},
"switchNode": [
{
"__id__": 66
"__id__": 65
},
{
"__id__": 69
"__id__": 68
}
],
"timeNode": {
"__id__": 73
"__id__": 72
},
"switchButtons": [
{
"__id__": 39
"__id__": 38
},
{
"__id__": 44
"__id__": 43
}
],
"coin": {
"__id__": 45
"__id__": 44
},
"_id": ""
},

View File

@ -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,44 @@ 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<string, {
stats: ReturnType<typeof emptyStats>;
@ -106,9 +121,12 @@ 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 });
summaries.set(user.openid, {
stats: emptyStats(asOf), seen: new Map(), fen15d: 0, fen30d: 0, fenTotal: 0,
recent: [], lastPaidAt: null, futureOrders: 0
});
}
}
@ -118,9 +136,13 @@ export default async function () {
state: { $in: [1, 2] },
paymentAppEnv: { $ne: 'test' },
outTradeNo: { $not: /^wct_/ },
}, { projection: { openid: 1, outTradeNo: 1, goodsPrice: 1, itemCount: 1, chargeTime: 1, time: 1,
itemid: 1, 'goldMiner.configVersion': 1, 'goldMiner.paidAt': 1, 'goldMiner.confirmedAt': 1,
rewardVersion: 1, activityType: 1 } })
}, {
projection: {
openid: 1, outTradeNo: 1, goodsPrice: 1, itemCount: 1, chargeTime: 1, time: 1,
itemid: 1, 'goldMiner.configVersion': 1, 'goldMiner.paidAt': 1, 'goldMiner.confirmedAt': 1,
rewardVersion: 1, activityType: 1
}
})
.sort({ openid: 1, outTradeNo: 1, _id: 1 });
try {
for await (const order of orders) {
@ -158,9 +180,11 @@ export default async function () {
if (paidAt > asOf - 15 * DAY) summary.fen15d += amount;
if (paidAt > asOf - 30 * DAY) {
summary.fen30d += amount;
summary.recent.push({ amountFen: amount, paidAt, sku: String(order.itemid ?? ''),
summary.recent.push({
amountFen: amount, paidAt, sku: String(order.itemid ?? ''),
rewardVersion: order.rewardVersion ?? order.goldMiner?.configVersion,
activity: order.activityType ?? (order.goldMiner ? 'coin' : undefined) });
activity: order.activityType ?? (order.goldMiner ? 'coin' : undefined)
});
}
}
}
@ -174,10 +198,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,
@ -189,107 +218,189 @@ export default async function () {
if (profile.data_valid) validProfiles++; else invalidProfiles++;
// Persist calculation inputs/results here; the public VIP attribute belongs only to users.
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) } };
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: [
{ $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] },
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 },
update: [{ $replaceWith: { $cond: [
{ $and: [
{ $lte: [{ $ifNull: ['$asOf', 0] }, asOf] },
{ $lte: [{ $ifNull: ['$updatedAt', 0] }, updatedAt] },
] },
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: [...record.invalid_reasons, 'paid_identity_changed_during_calculation']
}
}, heldFields]
},
replacement,
'$$ROOT',
] } }],
upsert: true,
} };
}));
]
};
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: record._id },
update: [{
$replaceWith: {
$cond: [
{
$and: [
{ $lte: [{ $ifNull: ['$asOf', 0] }, asOf] },
{ $lte: [{ $ifNull: ['$updatedAt', 0] }, updatedAt] },
]
},
replacement,
'$$ROOT',
]
}
}],
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) } },
{ 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,
profile_calculated_as_of: 1, rfm: 1 } }).toArray();
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, 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;
const identity = { openid: saved.openid ?? null, pay_user: saved.maintenance_identity?.pay_user ?? null,
register_time: saved.maintenance_identity?.register_time ?? null };
const currentIdentity = { $and: [
{ $eq: [{ $ifNull: ['$openid', null] }, { $literal: identity.openid ?? null }] },
{ $eq: [{ $ifNull: ['$pay_user', null] }, { $literal: identity.pay_user ?? null }] },
{ $eq: [{ $ifNull: ['$register_time', null] }, { $literal: source.register_time ?? null }] },
{ $literal: timestamp(source.register_time) === identity.register_time },
] };
const valid = { $and: [
{ $literal: saved.data_valid === true && calculatedLevel !== null }, currentIdentity,
// Do not turn a concurrently established paid identity back into a never-payer.
{ $or: [
{ $literal: saved.has_ever_paid === true },
{ $and: [{ $ne: ['$vip_profile.has_ever_paid', true] },
{ $lte: [{ $ifNull: ['$vip_level', 0] }, 0] }] },
] },
] };
const identity = {
openid: saved.openid ?? null, pay_user: saved.maintenance_identity?.pay_user ?? null,
register_time: saved.maintenance_identity?.register_time ?? null
};
const currentIdentity = {
$and: [
{ $eq: [{ $ifNull: ['$openid', null] }, { $literal: identity.openid ?? null }] },
{ $eq: [{ $ifNull: ['$pay_user', null] }, { $literal: identity.pay_user ?? null }] },
{ $eq: [{ $ifNull: ['$register_time', null] }, { $literal: source.register_time ?? null }] },
{ $literal: timestamp(source.register_time) === identity.register_time },
]
};
const valid = {
$and: [
{ $literal: saved.data_valid === true && calculatedLevel !== null }, currentIdentity,
// Do not turn a concurrently established paid identity back into a never-payer.
{
$or: [
{ $literal: saved.has_ever_paid === true },
{
$and: [{ $ne: ['$vip_profile.has_ever_paid', true] },
{ $lte: [{ $ifNull: ['$vip_level', 0] }, 0] }]
},
]
},
]
};
const oldLevel = { $ifNull: ['$vip_level', { $literal: calculatedLevel }] };
const heldMetadata = (userField: string, value: any) => ({ $cond: [
{ $ne: [{ $ifNull: ['$vip_level', null] }, null] },
{ $ifNull: [`$vip_profile.${userField}`, null] },
{ $literal: calculatedLevel === null ? null : value ?? null },
] });
const heldMetadata = (userField: string, value: any) => ({
$cond: [
{ $ne: [{ $ifNull: ['$vip_level', null] }, null] },
{ $ifNull: [`$vip_profile.${userField}`, null] },
{ $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] },
{ $gt: [{ $ifNull: ['$vip_level', 0] }, 0] }, { $literal: saved.has_ever_paid === true }] },
calculated_as_of: { $cond: [valid, { $literal: saved.profile_calculated_as_of ?? null },
heldMetadata('calculated_as_of', saved.profile_calculated_as_of)] },
rule_version: { $cond: [valid, { $literal: saved.profile_rule_version ?? null },
heldMetadata('rule_version', saved.profile_rule_version)] },
has_ever_paid: {
$or: [{ $eq: ['$vip_profile.has_ever_paid', true] },
{ $gt: [{ $ifNull: ['$vip_level', 0] }, 0] }, { $literal: saved.has_ever_paid === true }]
},
calculated_as_of: {
$cond: [valid, { $literal: saved.profile_calculated_as_of ?? null },
heldMetadata('calculated_as_of', saved.profile_calculated_as_of)]
},
rule_version: {
$cond: [valid, { $literal: saved.profile_rule_version ?? null },
heldMetadata('rule_version', saved.profile_rule_version)]
},
};
return {
updateOne: {
filter: { _id: saved._id }, upsert: false,
// Merge only the two owned attributes; concurrent gameplay/account changes survive.
update: [{
$replaceWith: {
$cond: [
{
$and: [
{ $lte: [{ $ifNull: ['$vip_profile.as_of', 0] }, saved.asOf] },
{ $lte: [{ $ifNull: ['$vip_profile.updated_at', 0] }, saved.updatedAt] },
]
},
{
$mergeObjects: ['$$ROOT', {
vip_level: { $cond: [valid, { $literal: calculatedLevel }, oldLevel] },
vip_profile: metadata
}]
}, '$$ROOT',
]
}
}],
}
};
return { updateOne: {
filter: { _id: saved._id }, upsert: false,
// Merge only the two owned attributes; concurrent gameplay/account changes survive.
update: [{ $replaceWith: { $cond: [
{ $and: [
{ $lte: [{ $ifNull: ['$vip_profile.as_of', 0] }, saved.asOf] },
{ $lte: [{ $ifNull: ['$vip_profile.updated_at', 0] }, saved.updatedAt] },
] },
{ $mergeObjects: ['$$ROOT', { vip_level: { $cond: [valid, { $literal: calculatedLevel }, oldLevel] },
vip_profile: metadata }] }, '$$ROOT',
] } }],
} };
})) : { modifiedCount: 0 };
processedUsers += batch.length;
updatedUsers += result.modifiedCount + result.upsertedCount;
updatedVipUsers += vipResult.modifiedCount;
}
const result = { asOf, updatedAt, scannedUsers, skippedNeverPaidUsers,
processedUsers, updatedUsers, updatedVipUsers, validProfiles, invalidProfiles };
const result = {
asOf, updatedAt, scannedUsers, skippedNeverPaidUsers,
processedUsers, updatedUsers, updatedVipUsers, removedNeverPaidStats, validProfiles, invalidProfiles
};
console.log('rechargeStats completed', result);
return { code: 1, data: result, msg: '用户付费画像维护完成' };
}