# 付费用户充值统计定时任务 云函数 `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 环境验证。