server/laf-cloud/rechargeStats.README.md

197 lines
24 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` 只保存曾付费用户的统计及尚待核验的异常记录;正常未付费用户不创建统计文档,也不查询历史订单。统计记录的 `_id` 保留 `users._id` 的原始BSON类型,不保存 `vip_level`。
已确认未付费用户首次维护时只初始化用户属性,之后身份未变则完全跳过统计集合的读取、订单查询和数据库写入。每日仍分页读取全用户的少量身份属性,以发现首次付费或账号变更,不是全用户统计重算。付费金额截止固定为北京时间昨日 `23:59:59.999`,RFM截止为紧接着的当天00:00。`order`只读;`users`仅更新两个VIP属性,现有 `pay_user` 和其他业务属性不变。
本次范围仅为每日画像维护,不改活动选价、活动实例或支付业务。不在注册/支付回调中新增画像任务:未付费用户在首次日批初始化,首次付费依赖现有支付确认路径更新 `users.pay_user`,下一次日批自动纳入。
## 版本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;核验恢复为正常未付费后,退出统计维护。
此规则依赖服务端付费标记可靠。历史漏标、外部账号迁移或绕过正常支付流程写订单,应先修复付费身份或将 `vip_profile.data_valid` 标记为false以触发核验;本任务不对稳定VIP0每日做全历史查单。上线前需单独核查已知历史迁移问题。
被跳过的VIP0保留真实初始化时间,不伪造每日更新时间。业务判断资格时还需比较当前用户身份与 `vip_profile.identity`。未付费用户无统计文档是正常状态,但缺少用户身份或VIP结果不能使用 `user?.vip_level ?? 0` 默认成未付费。
## users的VIP属性
`users.vip_level` 是业务查询等级的唯一入口。不要再读取 `userRechargeStats.vip_level`。查询用户时按需投影这两个属性即可:
| 字段 | 含义 |
| --- | --- |
| `vip_level` | 0~5;只有核实从未付费才初始化0;没有可信历史结果且本次异常则为null |
| `vip_profile.data_valid` | 最近一次发布时,计算结果与当前身份是否一致;异常时false,同时保留旧等级 |
| `vip_profile.calculated_as_of` | 付费等级实际计算截止时间;直接初始化VIP0时为初始化时刻;异常保留旧等级时保留旧时间,没有可信计算则null |
| `vip_profile.rule_version` | 当前等级实际使用的规则版本 |
| `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 |
```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`。稳定未付费用户允许保留初始化时间,曾付费用户需检查计算时效。曾付费用户读取等级与单笔因子时须再比较两个集合的批次及实际计算时间,任何无效或不一致结果都不能用于新报价。正常未付费用户直接读取用户属性,不要求统计记录;后续活动自行定义其VIP0业务规则。
## 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` 且无成功订单使用既有服务端身份记录,仅在users保存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` | 统计规则及存储版本,当前为 9 |
| `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,再读取其余用户的历史统计证据。整页均被跳过时仍推进游标,保证后面的付费用户不会漏处理。其余用户中,明确未付费且没有已知异常的用户只初始化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`,删除该字段不会影响后续维护。未付费用户使用本次确认的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属性的数量,`removedNeverPaidStats` 是本轮实际清理的未付费旧统计文档数;跳过较新结果或完全相同的记录不计入。成功返回的 `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会创建集合。旧版本用户会完成一次维护版本迁移,确认未付费身份后只更新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,可执行:
```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、旧统计等级字段删除后的迁移和维护、异常时保留用户最新等级与真实计算时间、两次写入之间失败后的重跑补齐、用户快照并发保护、计算期间身份变化失效、删除账号不重建,以及统计记录丢失后仍保留曾付费证据。
版本9验证未付费初始化不查订单、不创建统计记录、后续跳过不读取统计集合、当日注册初始化、旧零记录迁移清理、清理失败重跑、并发付费数据不误删、异常身份解决后退出统计维护,以及付费发布中断后的恢复。