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

78 lines
6.9 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` 的玩家,将结果覆盖到 `users.rechargeStats`。每日凌晨 03:00 执行,统计截止时间固定回退 3 小时;不修改订单和玩家付费标记。
## 保存字段
| 字段 | 含义 |
| --- | --- |
| `amount15d` | 从统计截止时间倒推 15 × 24 小时的充值金额,整数分 |
| `amount30d` | 从统计截止时间倒推 30 × 24 小时的充值金额,整数分 |
| `amountTotal` | 统计截止时间之前、当前库中可统计的累计充值金额,整数分 |
| `currency` | 固定 `CNY` |
| `unit` | 固定 `fen`,金额单位为分 |
| `asOf` | 本轮运行时间减 3 小时,原始 Unix 毫秒时间戳 |
| `orderCount` | 纳入累计金额的去重订单数 |
| `fallbackTimeOrderCount` | 无支付确认时间,使用下单时间的订单数 |
| `missingTimeOrderCount` | 支付和下单时间均缺失,仅计入累计的订单数 |
| `invalidOrderCount` | 缺失订单号、金额或数量无效而跳过的记录数 |
| `duplicateOrderCount` | 跳过的重复订单记录数 |
| `missingOpenid` | 用户是否缺少可关联订单的 openid |
| `version` | 统计规则版本,当前为 2 |
版本 1 的金额单位为元。版本 2 首次运行会重新计算并整体覆盖旧统计,即使旧版 `asOf` 晚于新版截止时间,也允许完成升级。读取方应以 `version: 2 / unit: "fen"` 识别分单位;运行未覆盖到的用户仍可能保留旧版数据,不能仅按字段名判断单位。
## 统计口径
- 通过 `users.openid = order.openid` 关联,仅纳入数值型 `state: 1/2` 的记录。未确认支付的 `state: 0` 和临时集合 `iosOrder` 不参与。
- 排除 `paymentAppEnv: "test"` 或 `outTradeNo` 以 `wct_` 开头的测试订单;保留没有环境字段的历史订单。
- 每个 openid 内按 `outTradeNo` 去重,同号多条按 `_id` 升序取第一条金额有效、未达到统计截止时间的记录。若同号金额不同,应人工核查重复数据。
- 金额为 `goodsPrice × itemCount`,用整数分累加并直接保存,不除以 100。金额和数量兼容数字字符串,要求正安全整数;缺失数量不默认当作 1,异常记录计入 `invalidOrderCount`。累计金额超出安全整数范围则报错,不保存失真的数值。
- 每轮只获取一次当前时间,`asOf = Date.now() - 3小时`,窗口为 `[asOf - N × 86400000, asOf)`,含起点、不含截止点。累计金额也使用同一截止点。每天 03:00 准时运行时,截止点即当天 00:00,凌晨 00:00~03:00 的订单留到下一天统计。手动运行或触发延迟时也固定回退 3 小时,不自动截断到午夜。
- 例如北京时间 2026-09-15 03:00 执行:15 天窗口为 `[2026-08-31 00:00, 2026-09-15 00:00)`;30 天窗口为 `[2026-08-16 00:00, 2026-09-15 00:00)`;累计统计到 2026-09-15 00:00 之前。
- 优先使用 `chargeTime`。当前支付写入代码使用 `new Date(Date.now() + 8小时)`,因此还原时减去 8 小时;此处针对现有存储约定,若以后修正支付时间存储方式,必须同步调整统计规则。
- `chargeTime` 缺失或无效时回退到原始 `order.time`,不再减 8 小时;两个时间都不可用时只计入累计,并记录异常数量。支持原始毫秒数、Date 和带时区的 ISO 字符串;不解析无时区日期字符串。
- 已知时间达到或晚于 `asOf` 的订单暂不纳入任何金额。没有匹配订单的付费用户也保存零值结果;这不代表迁移前从未充值,结合 `orderCount` 核查历史完整性。
- 当前项目没有退款同步逻辑,因此这是当前已确认订单的金额统计,不扣除退款,不还原渠道优惠后的实付。订单状态异常仍可能造成偏差。
## 执行与一致性
用户按 `_id` 游标每批 100 条读取,订单使用 MongoDB 游标遍历,避免默认查询上限截断。每批只更新 `rechargeStats`,写入时重新检查 `pay_user` 和 `openid`,且较早轮次不会覆盖较新 `asOf` 的快照。重复执行不会重复累加;没有新增充值时,旧订单也会按新截止时间退出滚动窗口。
整轮不是数据库事务:运行期间新支付或用户变更可能下一轮才体现。失败时抛出错误,已完成批次保留,剩余用户下次重算;每个用户的 `asOf` 可判断新旧结果,不能把混合轮次当作同一时刻的全库快照。
建议上线前在 Laf 数据库控制台建立以下非唯一索引(若已有等价索引则复用):
```javascript
db.collection('users').createIndex({ pay_user: 1, _id: 1 });
db.collection('order').createIndex({ openid: 1, outTradeNo: 1, _id: 1 });
```
## 发布和启用
代码与触发器配置是独立资源,提交本地文件不会自动启动线上定时任务。`recharge-stats.trigger.json` 使用 Laf 创建触发器 API 的 `desc / target / cron` 字段。
1. 在目标 Laf 应用发布 `functions/rechargeStats.ts` 和同名 YAML,保持 `methods: []`,不开放公共 HTTP 入口。
2. 在云函数控制台手动执行一次 `rechargeStats`(无参数),检查返回的 `processedUsers`、`updatedUsers` 和部分玩家的结果,确认运行耗时。
3. 在触发器面板绑定函数 `rechargeStats`,使用 `recharge-stats.trigger.json` 的配置:每日 03:00 执行。表达式 `0 3 * * *` 按触发器时区解释;目标是北京时间 03:00,应确认调度时区为 `Asia/Shanghai`。如果部署使用 UTC 调度,则使用 `0 19 * * *`(UTC 19:00 为次日北京时间 03:00)。3 小时统计回退和订单存储的 8 小时修正是两件事,不改变 Unix 时间戳所属时区。
4. 检查已有触发器,避免重复创建。若使用已登录且已绑定正确应用的 Laf CLI,可执行:
```shell
laf trigger list
laf trigger create "付费用户充值统计(每日凌晨3点)" rechargeStats "0 3 * * *"
```
5. 首次定时触发后确认日志出现 `rechargeStats completed`,抽查 `users.rechargeStats.asOf` 已更新。大规模历史数据上线前应确认全量耗时在云函数执行时限内。
参考:[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
```
覆盖分单位、3 小时截止回退、午夜半开区间边界、凌晨订单次日计入、旧版元统计升级、8 小时存储修正、支付时间优先、时间回退、无效金额、数量、去重、测试订单排除、超过 1000 条订单、多页用户、重跑、并发写保护、异常中断和关闭 HTTP 入口。使用内存 MongoDB 接口替身,不连接生产数据库。