server/laf-cloud/rechargeStats.README.md
2026-09-24 17:59:52 +08:00

189 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 付费画像维护:未付费初始化,曾付费每日重算
云函数 `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` 和其他业务属性不变。
本次范围仅为每日画像维护。活动以后在创建实例时读取画像并自行计算、锁定规格;这里不生成报价、不改变黄金矿工或幸运礼包、不执行活动冷却、探索或支付逻辑,也不在支付回调中新增实时画像任务。
## 版本8的维护范围
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` 与当前用户一致时跳过。缺失、异常、旧版本或规则变化均重新核验。异常记录每日重试,避免永久停在未知状态。
日批仍会分页读取全用户的少量身份字段及对应画像元数据,以发现变更;优化的是未付费用户的历史订单扫描、画像计算与数据库写入,并非取消所有全用户读取。没有新增独立队列或支付钩子。
被跳过的VIP0保留真实初始化时间,不每天伪造新的 `asOf/updatedAt`。后续活动读取时,须先核对当前用户身份与 `maintenance_identity/openid`;稳定未付费画像不能仅因初始化日期较早就视为失效,而身份已变化的旧VIP0不能继续用于首购判断。画像缺失/身份不一致应视为待核验,交由日批或后续显式核验流程处理,不使用 `user?.vip_level ?? 0`。
## users的VIP属性(版本8)
`users.vip_level` 是业务查询等级的唯一入口。不要再读取 `userRechargeStats.vip_level`。查询用户时按需投影这两个属性即可:
| 字段 | 含义 |
| --- | --- |
| `vip_level` | 0~5;只有核实从未付费才初始化0;没有可信历史结果且本次异常则为null |
| `vip_profile.data_valid` | 最近一次发布时,计算结果与当前身份是否一致;异常时false,同时保留旧等级 |
| `vip_profile.calculated_as_of` | 当前等级实际计算截止时间;异常保留旧等级时保留旧时间,没有可信计算则null |
| `vip_profile.rule_version` | 当前等级实际使用的规则版本 |
| `vip_profile.as_of` / `updated_at` | 对应统计记录的 `asOf` / `updatedAt`,用于比较批次和阻止较旧任务覆盖 |
| `vip_profile.identity` | 计算依据的openid、pay_user与规范化注册时间,用于识别计算之后的身份变化 |
| `vip_profile.has_ever_paid` | 已确认的曾付费证据,统计记录丢失也不能因此退回有效VIP0 |
```javascript
const user = await db.collection('users').findOne({ _id: userId }, {
projection: { vip_level: 1, vip_profile: 1, openid: 1, pay_user: 1, register_time: 1 },
});
// user.vip_level 用于读取等级,不应通过 ?? 0 把未知身份变成VIP0。
// 资格判断还需检查vip_profile.data_valid、identity和真实计算时间。
```
计算后发生的支付或身份变化仍由下一次日批维护,因此 `data_valid=true` 不是实时首购资格保证;业务须同时比较当前身份,尤其是 `pay_user`。稳定未付费用户允许保留初始化时间,曾付费用户需检查计算时效。读取等级与单笔因子的活动须再比较两个集合的批次及实际计算时间,任何无效或不一致结果都不能用于新报价。
## userRechargeStats画像字段
| 字段 | 含义 |
| --- | --- |
| `paid_status` | `never` / `paid` / `unknown`,身份冲突不当作未付费 |
| `has_ever_paid` | 成功订单证明曾付费;不因窗口过期清零 |
| `data_valid` / `invalid_reasons` | 本次现有订单输入是否能一致计算,异常时保留旧等级及整套因子 |
| `amount_basis` | 当前固定 `confirmed_order_amount`:已确认订单金额代理 |
| `net_payment_verified` | 当前为false:没有渠道实扣/退款对账,不是已核实净实付画像 |
| `quality_warnings` | 退款未同步、渠道实扣未核验、缺注册时间、使用下单时间、缺SKU等 |
| `profile_schema_version` | 当前1 |
| `profile_rule_version` | 实际因子所用规则;固定 `payment_profile_v1_20260923` |
| `attempted_profile_rule_version` | 本次尝试采用的规则;即使保留旧因子也记录 |
| `profile_calculated_as_of` | 当前保存的整套因子的真实计算截止时间;保留旧值时不伪装成今天 |
| `rfm` | M30分、付费日期数、订单数、全历史最近支付时间、R天数、M/F/R子分与综合分 |
| `spending` | M15/M30分、L15/L30、日均消费、趋势因子、日/7天/2天消费参考值 |
| `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。
### 画像规则
- VIP采用初始固定门槛30/100/300/1000元,F采用2/5/10/16个付费日,R采用7/15/30/60天边界,权重70/20/10,0.5向上取整;不改成向下取整,也不做每日分位数自动校准。曾付费且窗口过期最低VIP1。
- 付费日期按北京时间去重,不按订单笔数提高F。同日多笔仍全部参与单笔证据中位数。
- `L30=max(7,min(30,账号年龄天数))`,L15同理;缺少 `register_time` 时用30/15。`trend=clip(1+0.3*(d15/d30-1),0.75,1.25)`;d30为0时trend为1。保存日参考 `d=d30*trend`、7d和2d参考值,不在此计算活动价格。
- 单笔证据 `a=600+w*(实付代理分-600)`;P50为中位数,H为各付费日日最大值中的第二大值,不足两个付费日取P50;`q=min(1,F30/4)`,`A=600+q*(max(600,P50,H)-600)`。这些统计参考值以分计量,允许小数;原始金额累计仍为整数分。
- 已确认SKU `starter_pack`、`month_Card`、`battlepass*` 使用0.25,`reborn_Gift`、`jungle_treasure_*`、`gold_miner*` 使用0.50;其他使用1。旧 `unlimited_health_bundle_30` 仅在已核验导出截止2026-09-22 00:00之前使用0.30,之后标注 `legacy_bundle_version_unverified` 并暂按1处理,须核实实际配置后维护生效范围;不能凭售价30元给所有商品降权。
- `paymentProfile.ts` 的 `comparableActivityVersions` 默认空列表。同活动1.00证据需要确认活动类别及奖励版本可比后维护白名单,避免未知版本被直接当作同配置复购。未启用白名单时coin/lucky因子可能与general相同,这是预期行为。维护分类或权重时必须同步更改规则版本并验证。
- 所有SKU权重仅影响A,不减少累计金额或M。当前没有退款同步,因此本版不宣称已实现按退款净额修正;补齐可靠账本后再切换口径。
读取示例(仅展示画像接口,不负责活动选价):
```javascript
const profile = await db.collection('userRechargeStats').findOne({ _id: userId });
// 检查存在、当前身份是否与maintenance_identity/openid一致、data_valid及金额口径。
// 曾付费画像还需检查profile_calculated_as_of;稳定未付费画像允许保留原初始化时间。
// 等级从users.vip_level读取;profile.ticket_factors.coin.anchor_fen是金币场景单笔参考A。
// 配合等级计算时检查vip_profile与此记录属于相同批次且都有效。
// profile.spending.daily_reference_fen 可供后续活动自己的公式读取。
```
## 保存字段
| 字段 | 含义 |
| --- | --- |
| `_id` | 对应 `users._id`,保留原始类型,作为唯一关联键 |
| `openid` | 本轮读取的玩家 openid;缺失时为 null |
| `amount15d` | 从统计截止时间倒推 15 × 24 小时的充值金额,整数分 |
| `amount30d` | 从统计截止时间倒推 30 × 24 小时的充值金额,整数分 |
| `amountTotal` | 截至统计截止时间(含)、当前库中可统计的累计充值金额,整数分 |
| `amountMax` | 纳入累计统计的订单中,单笔 `goodsPrice × itemCount` 的最大值,整数分;无有效订单时为 0 |
| `currency` | 固定 `CNY` |
| `unit` | 固定 `fen`,金额单位为分 |
| `asOf` | 本轮执行日期的北京时间前一天 `23:59:59.999`,原始 Unix 毫秒时间戳 |
| `updatedAt` | 本轮开始计算的 Unix 毫秒时间戳,所有批次相同,并非每条写入完成时间 |
| `orderCount` | 纳入累计金额的去重订单数 |
| `fallbackTimeOrderCount` | 无支付确认时间,使用下单时间的订单数 |
| `missingTimeOrderCount` | 支付和下单时间均缺失,仅计入累计的订单数 |
| `invalidOrderCount` | 缺失订单号、金额或数量无效而跳过的记录数 |
| `duplicateOrderCount` | 跳过的重复订单记录数 |
| `missingOpenid` | 用户是否缺少可关联订单的 openid |
| `version` | 统计规则及存储版本,当前为 8 |
| `duplicateConflictCount` | 同一订单号出现不同金额或支付时间的记录数;触发画像保留 |
版本 1 的金额单位为元,版本 2 使用分单位但按运行时间减 3 小时作为截止点,版本 3 改为固定前一天末尾。版本 4 保留版本 3 的计算口径,改为独立集合。首次运行从订单重算,不读取或复制 `users.rechargeStats`,旧字段的单位和截止点不影响新结果。
版本 5 新增 `amountMax`,范围与累计金额一致,不受 15/30 天窗口限制,沿用相同的筛选和去重结果。支付和下单时间均缺失、但已纳入累计的订单也参与最大值计算。每轮从订单重算,订单修正后最大值可降低。发布后下一次成功处理该用户时自动补齐字段;尚未重算的旧记录可能没有此字段,不能将字段缺失当作 0。
金额字段直接位于新文档根层级,查询示例:
```javascript
// userId 必须与 users._id 类型一致;ObjectId 不要转成字符串。
const stats = await db.collection('userRechargeStats').findOne({ _id: userId });
// stats.amount15d / stats.amount30d / stats.amountTotal / stats.amountMax,单位均为分。
```
## 统计口径
- 通过 `users.openid = order.openid` 关联,仅纳入数值型 `state: 1/2` 的记录。未确认支付的 `state: 0` 和临时集合 `iosOrder` 不参与。
- 排除 `paymentAppEnv: "test"` 或 `outTradeNo` 以 `wct_` 开头的测试订单;保留没有环境字段的历史订单。
- 每个 openid 内按 `outTradeNo` 去重,同号多条按 `_id` 升序取第一条金额有效、不晚于统计截止时间的记录。若同号金额不同,应人工核查重复数据。
- 金额为 `goodsPrice × itemCount`,用整数分累加并直接保存,不除以 100。金额和数量兼容数字字符串,要求正安全整数;缺失数量不默认当作 1,异常记录计入 `invalidOrderCount`。累计金额超出安全整数范围则报错,不保存失真的数值。
- 每轮只获取一次当前时间,按北京时间计算当天 00:00,再减 1 毫秒得到 `asOf`。15/30 天窗口为 `(asOf - N × 86400000, asOf]`,不含起点、包含截止点,恰好覆盖过去 N 个完整自然日。累计金额也包含同一截止点。当天订单留到下一天统计;同一北京时间日期内,无论凌晨准时执行、延迟执行还是白天手动执行,截止点均一致,不依赖服务器本地时区。
- 例如北京时间 2026-09-15 任意时间执行:`asOf = 2026-09-14 23:59:59.999`;15 天包含 `2026-08-31 00:00:00.000` 至 `2026-09-14 23:59:59.999`;30 天包含 `2026-08-16 00:00:00.000` 至 `2026-09-14 23:59:59.999`;累计统计至同一截止点(含)。
- 已有 `goldMiner.paidAt` / `goldMiner.confirmedAt` 是标准UTC毫秒,优先使用且不减8小时。否则使用旧 `chargeTime`,其写入代码使用 `new Date(Date.now() + 8小时)`,还原时减去8小时;此处只针对现有旧字段约定,不能把标准时间统一减8小时。
- 标准确认时间与旧 `chargeTime` 均缺失或无效时回退到原始 `order.time`,不再减8小时;所有时间都不可用时只计入累计,并记录异常数量。支持原始毫秒数、Date和带时区的ISO字符串;不解析无时区日期字符串。
- 已知时间晚于 `asOf` 的订单暂不纳入任何金额。没有匹配订单的付费用户也保存零值结果;这不代表迁移前从未充值,结合 `orderCount` 核查历史完整性。
- 当前项目没有退款同步逻辑,因此这是当前已确认订单的金额统计,不扣除退款,不还原渠道优惠后的实付。订单状态异常仍可能造成偏差。
## 执行与一致性
用户按 `_id` 游标每批100条读取,只投影 `_id`、`openid`、`pay_user`、`register_time`、`vip_level`、`vip_profile`;每批读取一次对应画像元数据,筛掉稳定VIP0。整页均被跳过时仍推进游标,保证后面的付费用户不会漏处理。只有剩余用户的openid进入订单查询,订单使用MongoDB游标遍历,不受默认查询条数限制。需要维护的用户仍从历史订单重算,尚未引入日汇总增量账本;大规模部署前应测算身份扫描及付费订单扫描耗时。
正常输入整体替换统计文档;异常输入更新本次金额统计、身份、质量标记,但在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`,删除该字段不会影响后续维护。
`users`发布同样原子比较截止时间与任务启动时间,并在写入时核对身份;支付或账号变更不能让旧VIP0被标记有效。仅合并两个VIP属性,保留并发更新的金币、关卡等字段;不upsert用户,避免重新创建已经删除的账号。
两集合写入不是跨集合事务,可能短暂出现批次不一致。统计写入成功而用户写入失败时抛出错误;重跑会补齐,未完成用户属性发布的VIP0不会被缓存跳过。已有等级仍保留其真实时间;需要组合读取的业务应拒绝批次不一致的数据。发布之后发生的支付或用户变更下一轮体现。失败时抛出错误,已完成批次保留,剩余用户下次重算。删除用户后不会自动删除已有统计。出现曾付费标记回退时不会把用户重置为VIP0。稳定VIP0依赖既有服务端身份记录;绕过正常支付流程直接修改订单库时,必须同步修正用户身份或使对应画像失效,不能期望跳过的用户仍会每日重新查单。
返回值中 `scannedUsers` 是身份检查人数,`skippedNeverPaidUsers` 是跳过的稳定VIP0人数,`processedUsers` 是实际核验/重算人数,正常结束时 `scannedUsers=skippedNeverPaidUsers+processedUsers`。`updatedUsers` 是统计集合新增及实际修改的记录数,`updatedVipUsers` 是实际更新用户VIP属性的数量;跳过较新结果或完全相同的记录不计入。成功返回的 `updatedAt` 只能筛选本轮实际写入结果,不能代表全用户快照。
建议上线前在 Laf 数据库控制台建立以下非唯一索引(若已有等价索引则复用):
```javascript
// users自带的_id索引用于全用户游标分页。
db.collection('order').createIndex({ openid: 1, outTradeNo: 1, _id: 1 });
```
`userRechargeStats` 自带的唯一 `_id` 索引满足按用户写入和查询。按金额排序、按执行轮次筛选等索引应根据实际查询增加。本地代码不会自动创建上述生产索引;两个集合仍共享所在数据库实例的 CPU、内存和磁盘资源。
## 发布和启用
代码与触发器配置是独立资源,提交本地文件不会自动启动线上定时任务。`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。
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,可执行:
```shell
laf trigger list
laf trigger create "付费画像增量选人维护(每日凌晨3点)" rechargeStats "0 3 * * *"
```
5. 首次定时触发后确认日志出现 `rechargeStats completed`,抽查 `userRechargeStats` 中 `asOf` 和 `updatedAt` 已更新。大规模历史数据上线前应确认全量耗时在云函数执行时限内。
参考:[Laf 定时任务文档](https://doc.laf.run/zh/cloud-function/cron.html)、[官方 CLI 触发器命令](https://github.com/labring/laf/blob/main/cli/src/command/trigger/index.ts)、[创建触发器字段](https://github.com/labring/laf/blob/main/server/src/trigger/dto/create-trigger.dto.ts)。
## 本地测试
Node.js 24.11 或兼容 `registerHooks` / `stripTypeScriptTypes` 的版本:
```shell
node --test laf-cloud/tests/recharge-stats.test.mjs laf-cloud/tests/payment-profile.test.mjs
```
覆盖分单位、固定北京时间前一天末尾截止、15/30 天毫秒边界、准时/延迟/手动执行、跨月/跨年/闰日、当天订单次日计入、旧嵌入字段不影响新结果、8 小时存储修正、支付时间优先、时间回退、无效金额、数量、去重、测试订单排除、超过 1000 条订单、多页用户、独立集合 upsert、用户其他属性保留、重跑、跨日期及同日并发写保护、异常中断和关闭 HTTP 入口。使用内存 MongoDB 接口替身,不连接生产数据库;上线仍需实际 MongoDB 环境验证。
版本7还验证稳定VIP0跨日零查单/零写入、整页跳过后继续处理付费用户、新付费自动纳入、曾付费窗口过期后继续维护、账号关联/注册时间/规则版本变化重新核验,以及画像缺失或异常时不默认VIP0。
版本8还验证VIP只写users、旧统计等级字段删除后的迁移和维护、异常时保留用户最新等级与真实计算时间、两次写入之间失败后的重跑补齐、用户快照并发保护、计算期间身份变化失效、删除账号不重建,以及统计记录丢失后仍保留曾付费证据。