fix: anchor recharge statistics to previous Beijing day end

This commit is contained in:
guanchao 2026-09-15 18:01:22 +08:00
parent d137b4e030
commit 58a97dc355
4 changed files with 80 additions and 47 deletions

View File

@ -2,12 +2,12 @@ import cloud from '@lafjs/cloud';
const DAY = 24 * 60 * 60 * 1000; const DAY = 24 * 60 * 60 * 1000;
const LEGACY_TIME_OFFSET = 8 * 60 * 60 * 1000; const LEGACY_TIME_OFFSET = 8 * 60 * 60 * 1000;
const REPORT_DELAY = 3 * 60 * 60 * 1000; const REPORT_TIMEZONE_OFFSET = 8 * 60 * 60 * 1000; // Asia/Shanghai
const BATCH_SIZE = 100; const BATCH_SIZE = 100;
function emptyStats(asOf: number) { function emptyStats(asOf: number) {
return { return {
version: 2, version: 3,
currency: 'CNY', currency: 'CNY',
unit: 'fen', unit: 'fen',
asOf, asOf,
@ -44,8 +44,8 @@ function timestamp(value: any): number | null {
export default async function () { export default async function () {
const mongo = cloud.mongo.db; const mongo = cloud.mongo.db;
const users = mongo.collection('users'); const users = mongo.collection('users');
// The 03:00 run reports through 00:00; this is separate from the stored +8h correction. // End of yesterday in Beijing time, independent of execution time and server timezone.
const asOf = Date.now() - REPORT_DELAY; const asOf = Math.floor((Date.now() + REPORT_TIMEZONE_OFFSET) / DAY) * DAY - REPORT_TIMEZONE_OFFSET - 1;
let afterId: any; let afterId: any;
let processedUsers = 0; let processedUsers = 0;
let updatedUsers = 0; let updatedUsers = 0;
@ -93,7 +93,7 @@ export default async function () {
// order.time is the original, unshifted Unix timestamp. // order.time is the original, unshifted Unix timestamp.
const chargeTime = timestamp(order.chargeTime); const chargeTime = timestamp(order.chargeTime);
const paidAt = chargeTime === null ? timestamp(order.time) : chargeTime - LEGACY_TIME_OFFSET; const paidAt = chargeTime === null ? timestamp(order.time) : chargeTime - LEGACY_TIME_OFFSET;
if (paidAt !== null && paidAt >= asOf) continue; if (paidAt !== null && paidAt > asOf) continue;
if (summary.seen.has(order.outTradeNo)) { if (summary.seen.has(order.outTradeNo)) {
summary.stats.duplicateOrderCount++; summary.stats.duplicateOrderCount++;
continue; continue;
@ -105,8 +105,8 @@ export default async function () {
if (paidAt === null) summary.stats.missingTimeOrderCount++; if (paidAt === null) summary.stats.missingTimeOrderCount++;
else { else {
if (chargeTime === null) summary.stats.fallbackTimeOrderCount++; if (chargeTime === null) summary.stats.fallbackTimeOrderCount++;
if (paidAt >= asOf - 15 * DAY) summary.fen15d += amount; if (paidAt > asOf - 15 * DAY) summary.fen15d += amount;
if (paidAt >= asOf - 30 * DAY) summary.fen30d += amount; if (paidAt > asOf - 30 * DAY) summary.fen30d += amount;
} }
} }
} finally { } finally {
@ -122,14 +122,14 @@ export default async function () {
const result = await users.bulkWrite(batch.map(user => { const result = await users.bulkWrite(batch.map(user => {
const rechargeStats = summaries.get(user.openid)?.stats ?? { ...emptyStats(asOf), missingOpenid: true }; const rechargeStats = summaries.get(user.openid)?.stats ?? { ...emptyStats(asOf), missingOpenid: true };
return { updateOne: { return { updateOne: {
// Recheck eligibility and identity, and prevent an older run overwriting a newer snapshot. // Recheck eligibility and identity; an earlier reporting day cannot replace a later one.
filter: { filter: {
_id: user._id, _id: user._id,
openid: user.openid ?? null, openid: user.openid ?? null,
pay_user: true, pay_user: true,
$or: [ $or: [
// Version 1 used yuan and a later cutoff: replace it even when its asOf is newer. // Earlier versions could have a later cutoff: allow their first full recalculation.
{ 'rechargeStats.version': 1 }, { 'rechargeStats.version': { $in: [1, 2] } },
{ 'rechargeStats.asOf': { $exists: false } }, { 'rechargeStats.asOf': { $exists: false } },
{ 'rechargeStats.asOf': { $lte: asOf } }, { 'rechargeStats.asOf': { $lte: asOf } },
], ],

View File

@ -1,5 +1,5 @@
name: rechargeStats name: rechargeStats
desc: "付费用户充值统计(分);每日03:00执行,截止时间回退3小时" desc: "付费用户充值统计(分);每日03:00执行,固定截至北京时间昨日23:59:59.999"
methods: [] methods: []
tags: tags:
- statistics - statistics

View File

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

View File

@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs';
const DAY = 86400000; const DAY = 86400000;
const OFFSET = 8 * 3600000; const OFFSET = 8 * 3600000;
const NOW = Date.parse('2026-09-15T03:00:00+08:00'); const NOW = Date.parse('2026-09-15T03:00:00+08:00');
const CUTOFF = NOW - 3 * 3600000; const CUTOFF = Date.parse('2026-09-14T23:59:59.999+08:00');
const state = { users: [], order: [], writes: 0, closed: 0, beforeWrite: null, failRead: false }; const state = { users: [], order: [], writes: 0, closed: 0, beforeWrite: null, failRead: false };
const realNow = Date.now; const realNow = Date.now;
Date.now = () => NOW; Date.now = () => NOW;
@ -98,11 +98,11 @@ function order(id, age, extra = {}) {
}; };
} }
test('03:00 run uses midnight cutoff for all totals, including the start and excluding the end', async () => { test('windows exclude the previous end-of-day boundary and include yesterday final millisecond', async () => {
reset(); reset();
state.order = [ state.order = [
order('before-midnight', 1), order('15d', 15 * DAY), order('15d-old', 15 * DAY + 1), order('yesterday-end', 0), order('15d-start', 15 * DAY - 1), order('15d-excluded', 15 * DAY),
order('30d', 30 * DAY), order('30d-old', 30 * DAY + 1), order('midnight', 0), order('30d-start', 30 * DAY - 1), order('30d-excluded', 30 * DAY), order('today-start', -1),
order('early-morning', -2 * 3600000), order('future', -4 * 3600000), order('early-morning', -2 * 3600000), order('future', -4 * 3600000),
]; ];
await run(); await run();
@ -111,8 +111,8 @@ test('03:00 run uses midnight cutoff for all totals, including the start and exc
assert.equal(stats.amount30d, 400); assert.equal(stats.amount30d, 400);
assert.equal(stats.amountTotal, 500); assert.equal(stats.amountTotal, 500);
assert.equal(stats.orderCount, 5); assert.equal(stats.orderCount, 5);
assert.equal(stats.asOf, Date.parse('2026-09-15T00:00:00+08:00')); assert.equal(stats.asOf, CUTOFF);
assert.equal(stats.version, 2); assert.equal(stats.version, 3);
assert.equal(stats.unit, 'fen'); assert.equal(stats.unit, 'fen');
}); });
@ -134,9 +134,9 @@ test('counts quantities, numeric strings, paid states and deduplicates; excludes
test('falls back to unshifted creation time, tracks unknown times, and rejects malformed amounts', async () => { test('falls back to unshifted creation time, tracks unknown times, and rejects malformed amounts', async () => {
reset(); reset();
state.order = [ state.order = [
order('fallback', 0, { chargeTime: 0, time: CUTOFF - 15 * DAY }), order('fallback', 0, { chargeTime: 0, time: CUTOFF - 15 * DAY + 1 }),
order('unknown', 0, { chargeTime: 0, time: null }), order('unknown', 0, { chargeTime: 0, time: null }),
order('iso', 0, { chargeTime: new Date(CUTOFF - 30 * DAY + OFFSET).toISOString() }), order('iso', 0, { chargeTime: new Date(CUTOFF - 30 * DAY + 1 + OFFSET).toISOString() }),
order('bad-price', 0, { goodsPrice: 'bad' }), order('bad-count', 0, { itemCount: null }), order('bad-price', 0, { goodsPrice: 'bad' }), order('bad-count', 0, { itemCount: null }),
order('negative', 0, { goodsPrice: -1 }), order('missing-id', 0, { outTradeNo: '' }), order('negative', 0, { goodsPrice: -1 }), order('missing-id', 0, { outTradeNo: '' }),
order('overflow', 0, { goodsPrice: Number.MAX_SAFE_INTEGER, itemCount: 2 }), order('overflow', 0, { goodsPrice: Number.MAX_SAFE_INTEGER, itemCount: 2 }),
@ -177,19 +177,19 @@ test('reruns overwrite instead of accumulating; older runs cannot overwrite newe
await run(); await run();
await run(); await run();
assert.equal(state.users[0].rechargeStats.amountTotal, 100); assert.equal(state.users[0].rechargeStats.amountTotal, 100);
state.users[0].rechargeStats = { version: 2, asOf: CUTOFF + 1, amountTotal: 200 }; state.users[0].rechargeStats = { version: 3, asOf: CUTOFF + DAY, amountTotal: 200 };
await run(); await run();
assert.equal(state.users[0].rechargeStats.amountTotal, 200); assert.equal(state.users[0].rechargeStats.amountTotal, 200);
}); });
test('streams more than 1000 orders and expired windows clear on the next run', async () => { test('streams more than 1000 orders and expired windows clear on the next run', async () => {
reset(); reset();
state.order = Array.from({ length: 1005 }, (_, i) => order(`paid-${i}`, 30 * DAY, { goodsPrice: 1 })); state.order = Array.from({ length: 1005 }, (_, i) => order(`paid-${i}`, 30 * DAY - 1, { goodsPrice: 1 }));
await run(); await run();
assert.equal(state.users[0].rechargeStats.amountTotal, 1005); assert.equal(state.users[0].rechargeStats.amountTotal, 1005);
assert.equal(state.users[0].rechargeStats.amount30d, 1005); assert.equal(state.users[0].rechargeStats.amount30d, 1005);
try { try {
Date.now = () => NOW + 1; Date.now = () => NOW + DAY;
await run(); await run();
assert.equal(state.users[0].rechargeStats.amount30d, 0); assert.equal(state.users[0].rechargeStats.amount30d, 0);
assert.equal(state.users[0].rechargeStats.amountTotal, 1005); assert.equal(state.users[0].rechargeStats.amountTotal, 1005);
@ -225,23 +225,25 @@ test('timer function does not expose a public HTTP endpoint', () => {
assert.equal(trigger.cron, '0 3 * * *'); assert.equal(trigger.cron, '0 3 * * *');
}); });
test('first version 2 run replaces version 1 yuan snapshot despite its later cutoff', async () => { test('recalculates version 1 and 2 snapshots despite their later cutoffs', async () => {
reset(); for (const version of [1, 2]) {
state.users[0].rechargeStats = { version: 1, asOf: NOW - 1, amount15d: 1, amount30d: 1, amountTotal: 1 }; reset();
state.order = [order('paid', DAY, { goodsPrice: 101, itemCount: 3 })]; state.users[0].rechargeStats = { version, asOf: NOW - 1, amount15d: 1, amount30d: 1, amountTotal: 1 };
await run(); state.order = [order('paid', DAY, { goodsPrice: 101, itemCount: 3 })];
const stats = state.users[0].rechargeStats; await run();
assert.equal(stats.version, 2); const stats = state.users[0].rechargeStats;
assert.equal(stats.unit, 'fen'); assert.equal(stats.version, 3);
assert.equal(stats.asOf, CUTOFF); assert.equal(stats.unit, 'fen');
assert.equal(stats.amount15d, 303); assert.equal(stats.asOf, CUTOFF);
assert.equal(stats.amount30d, 303); assert.equal(stats.amount15d, 303);
assert.equal(stats.amountTotal, 303); assert.equal(stats.amount30d, 303);
assert.equal(stats.amountTotal, 303);
}
}); });
test('payments between midnight and 03:00 are included on the following daily run', async () => { test('payments between midnight and 03:00 are included on the following daily run', async () => {
reset(); reset();
state.order = [order('midnight', 0), order('02:00', -2 * 3600000)]; state.order = [order('midnight', -1), order('02:00', -2 * 3600000 - 1)];
await run(); await run();
assert.equal(state.users[0].rechargeStats.amountTotal, 0); assert.equal(state.users[0].rechargeStats.amountTotal, 0);
try { try {
@ -252,3 +254,34 @@ test('payments between midnight and 03:00 are included on the following daily ru
assert.equal(state.users[0].rechargeStats.amountTotal, 200); assert.equal(state.users[0].rechargeStats.amountTotal, 200);
} finally { Date.now = () => NOW; } } finally { Date.now = () => NOW; }
}); });
test('midnight, scheduled, delayed and manual runs on the same Beijing date have the same cutoff', async () => {
reset();
state.order = [order('yesterday-end', 0), order('today-start', -1)];
try {
for (const time of ['00:00:00.000', '01:00:00.000', '03:00:00.000', '03:27:16.321', '12:00:00.000', '23:59:59.999']) {
Date.now = () => Date.parse(`2026-09-15T${time}+08:00`);
await run();
const stats = state.users[0].rechargeStats;
assert.equal(stats.asOf, CUTOFF, time);
assert.equal(stats.amount15d, 100, time);
assert.equal(stats.amount30d, 100, time);
assert.equal(stats.amountTotal, 100, time);
}
} finally { Date.now = () => NOW; }
});
test('Beijing midnight advances the cutoff across month, year and leap-day boundaries', async () => {
try {
for (const [runAt, cutoff] of [
['2026-10-01T00:00:00+08:00', '2026-09-30T23:59:59.999+08:00'],
['2027-01-01T03:00:00+08:00', '2026-12-31T23:59:59.999+08:00'],
['2028-03-01T12:00:00+08:00', '2028-02-29T23:59:59.999+08:00'],
]) {
reset();
Date.now = () => Date.parse(runAt);
await run();
assert.equal(state.users[0].rechargeStats.asOf, Date.parse(cutoff));
}
} finally { Date.now = () => NOW; }
});