MatchMaster/server/laf-cloud/rechargeStats.README.md

95 lines
9.7 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` 每次全量重算读取时 `users.pay_user === true` 的玩家,将结果写入独立集合 `userRechargeStats`。每位玩家只保留一条最新结果,使用与 `users._id` 相同的值和 BSON 类型作为 `_id`,重复执行时覆盖,不追加每日历史。每日凌晨 03:00 执行,统计截止时间固定为北京时间前一天 `23:59:59.999`;`users` 和 `order` 均只读。
## 保存字段
| 字段 | 含义 |
| --- | --- |
| `_id` | 对应 `users._id`,保留原始类型,作为唯一关联键 |
| `openid` | 本轮读取的玩家 openid;缺失时为 null |
| `amount15d` | 从统计截止时间倒推 15 × 24 小时的充值金额,整数分 |
| `amount30d` | 从统计截止时间倒推 30 × 24 小时的充值金额,整数分 |
| `amountTotal` | 截至统计截止时间(含)、当前库中可统计的累计充值金额,整数分 |
| `currency` | 固定 `CNY` |
| `unit` | 固定 `fen`,金额单位为分 |
| `asOf` | 本轮执行日期的北京时间前一天 `23:59:59.999`,原始 Unix 毫秒时间戳 |
| `updatedAt` | 本轮开始计算的 Unix 毫秒时间戳,所有批次相同,并非每条写入完成时间 |
| `orderCount` | 纳入累计金额的去重订单数 |
| `fallbackTimeOrderCount` | 无支付确认时间,使用下单时间的订单数 |
| `missingTimeOrderCount` | 支付和下单时间均缺失,仅计入累计的订单数 |
| `invalidOrderCount` | 缺失订单号、金额或数量无效而跳过的记录数 |
| `duplicateOrderCount` | 跳过的重复订单记录数 |
| `missingOpenid` | 用户是否缺少可关联订单的 openid |
| `version` | 统计规则及存储版本,当前为 4 |
版本 1 的金额单位为元,版本 2 使用分单位但按运行时间减 3 小时作为截止点,版本 3 改为固定前一天末尾。版本 4 保留版本 3 的计算口径,改为独立集合。首次运行从订单重算,不读取或复制 `users.rechargeStats`,旧字段的单位和截止点不影响新结果。
金额字段直接位于新文档根层级,查询示例:
```javascript
// userId 必须与 users._id 类型一致;ObjectId 不要转成字符串。
const stats = await db.collection('userRechargeStats').findOne({ _id: userId });
// stats.amount15d / stats.amount30d / stats.amountTotal,单位均为分。
```
## 统计口径
- 通过 `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`;累计统计至同一截止点(含)。
- 优先使用 `chargeTime`。当前支付写入代码使用 `new Date(Date.now() + 8小时)`,因此还原时减去 8 小时;此处针对现有存储约定,若以后修正支付时间存储方式,必须同步调整统计规则。
- `chargeTime` 缺失或无效时回退到原始 `order.time`,不再减 8 小时;两个时间都不可用时只计入累计,并记录异常数量。支持原始毫秒数、Date 和带时区的 ISO 字符串;不解析无时区日期字符串。
- 已知时间晚于 `asOf` 的订单暂不纳入任何金额。没有匹配订单的付费用户也保存零值结果;这不代表迁移前从未充值,结合 `orderCount` 核查历史完整性。
- 当前项目没有退款同步逻辑,因此这是当前已确认订单的金额统计,不扣除退款,不还原渠道优惠后的实付。订单状态异常仍可能造成偏差。
## 执行与一致性
用户按 `_id` 游标每批 100 条读取,只投影 `_id`、`openid`,订单使用 MongoDB 游标遍历,避免默认查询上限截断。每批向 `userRechargeStats` 批量 upsert:不存在则新增,存在则整体替换统计文档。文档用于统计任务专有数据,不应混入其他业务字段。不会逐个用户查询或写回 `users`,但每轮仍需读取付费名单及其历史订单,独立集合不消除这些计算开销。
写入通过 MongoDB 更新管道原子比较 `asOf` 和 `updatedAt`:较早统计日期或较早启动的任务不能覆盖较新的结果,包括同一天并发执行的情况。同一天重跑会重新计算,不重复累加;跨到下一个北京时间日期时,旧订单会按新截止时间退出滚动窗口。需 MongoDB 4.2 或以上支持更新管道,参考 [MongoDB 官方文档](https://www.mongodb.com/docs/manual/tutorial/update-documents-with-aggregation-pipeline/)。
付费标记和 openid 以每批读取时为准,写入独立集合时不再重新检查 `users`。整轮不是数据库事务:运行期间新支付或用户变更可能下一轮才体现。失败时抛出错误,已完成批次保留,剩余用户下次重算。用户被删除或付费标记改为 false 后,不会自动删除其已有统计,新任务会跳过该用户,保留旧的 `asOf` / `updatedAt`。
返回值中 `processedUsers` 是本轮已处理人数,`updatedUsers` 包括新增及实际修改的记录,跳过较新结果或完全相同的记录不计入。成功返回的 `updatedAt` 可用于筛选本轮写入的结果;任务失败时不能将已完成批次视为完整快照。`asOf` 相同也可能来自不同轮次,需结合 `updatedAt` 判断。
建议上线前在 Laf 数据库控制台建立以下非唯一索引(若已有等价索引则复用):
```javascript
db.collection('users').createIndex({ pay_user: 1, _id: 1 });
db.collection('order').createIndex({ openid: 1, outTradeNo: 1, _id: 1 });
```
`userRechargeStats` 自带的唯一 `_id` 索引满足按用户写入和查询。按金额排序、按执行轮次筛选等索引应根据实际查询增加。本地代码不会自动创建上述生产索引;两个集合仍共享所在数据库实例的 CPU、内存和磁盘资源。
## 发布和启用
代码与触发器配置是独立资源,提交本地文件不会自动启动线上定时任务。`recharge-stats.trigger.json` 使用 Laf 创建触发器 API 的 `desc / target / cron` 字段。
1. 停止旧版任务并等待正在运行的旧任务结束,在目标 Laf 应用发布 `functions/rechargeStats.ts` 和同名 YAML,保持 `methods: []`,不开放公共 HTTP 入口。
2. 在云函数控制台手动执行一次 `rechargeStats`(无参数),首次 upsert 会创建 `userRechargeStats` 集合。检查返回的 `processedUsers`、`updatedUsers`,抽查新集合中的金额、`asOf`、`updatedAt`、`version: 4` 和用户关联,确认运行耗时。验证后将后台或其他读取方切换到新集合;旧 `users.rechargeStats` 不再更新,但本任务不自动删除该字段,清理可在确认所有读取方完成切换后另行执行。
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
```
覆盖分单位、固定北京时间前一天末尾截止、15/30 天毫秒边界、准时/延迟/手动执行、跨月/跨年/闰日、当天订单次日计入、旧嵌入字段不影响新结果、8 小时存储修正、支付时间优先、时间回退、无效金额、数量、去重、测试订单排除、超过 1000 条订单、多页用户、独立集合 upsert、users 只读、重跑、跨日期及同日并发写保护、异常中断和关闭 HTTP 入口。使用内存 MongoDB 接口替身,不连接生产数据库;上线仍需实际 MongoDB 环境验证。