# 时区:口径、出参格式、存量成就的那次订正 日常要守的规矩在 `CLAUDE.md`「时间只有一个锚点」一节。这里是它背后的实测与那次数据订正, **下次再动日历口径之前先读这份**,别重新推理 —— 上次推理得出过相反的结论。 ## 出参为什么统一成 ISO UTC,而且不能丢微秒 `db/index.ts` 给 OID 1184(`timestamptz`)挂了 parser,所有读出来的时刻统一成 ISO 8601 UTC(`2026-09-14T12:00:00.000Z`,库里带微秒的保留成 `…00.123456Z`)。 原来 drizzle 把 1184 的 parser 换成了恒等函数,读出来是 PG 文本 (`2026-09-14 20:00:00+08`),于是同一个字段在接口上有两种形状(实测同一批端点: PG 文本 77 处 + ISO 14 处),对接外部系统时对方得解析两套,而偏移还取决于服务器会话时区、 不该进契约。 ⚠️ **微秒不能丢**,别改回 `new Date(v).toISOString()`:读出的时刻常被原样塞回查询条件 (提交列表翻页的分界行、班级 AC 排名的 `<= min(create_time)`),截成毫秒后分界行自己被 排除 —— 翻页每页丢一条、排名少 1。生产库 12.3 万条 Django 时代的提交几乎全带微秒。 ⚠️ **`::text` 的 OID 是 25、绕过那个 parser**,所以「为了拿回和列一样形状」而写的 `max(join_time)::text` 之类现在会变成异类,见到就撤掉。 **只换 1184,别碰 1082(date)** —— `date(... at time zone ...)` 要的是 `2026-09-14`, 套上 `toISOString()` 就错了。 ## 为什么刻意不设 `TZ` Dockerfile 不设 `TZ`、数据库连接也不设 `TimeZone`。它们不改变正确代码的行为, 只会在线上把漏写的地方掩盖掉(`/problems/:displayId/yearly-ac` 就这样漏过一次), 而 dev 上又是另一个答案。 时区常量按**固定偏移**算(大陆 1991 年起没有夏令时),不查 tzdata、不用 `Intl`, 所以 dev / 编译产物 / 任何镜像基底 / 任何浏览器都算得一样。 ## 存量成就的口径是东八区,别再退回 UTC (2026-09-14 用生产备份 `db_backup_2026_09_08_19_22_11.sql` 实测过,结论和直觉相反。 下面「那两周留下的实际后果」和脚本跑数,又用 `db_backup_2026_09_14_18_17_37.sql` 逐项复核过,一致。) **历史指标本来就是北京时间。** 旧栈 `OnlineJudge/achievement/metrics.py` 全程用 `timezone.localtime(...)`,而 `settings.TIME_ZONE = "Asia/Shanghai"`,所以 2022-04 到 OJ2 上线之间那 10 万多条提交累积出的 `user_stat.metrics` 是**东八区口径**。 判别性核对(只在新旧口径算出不同值的用户里看存量更像哪边): | 指标 | 两口径不同 | 存量==UTC | 存量==东八区 | |---|---|---|---| | `midnight_submissions` | 1259 | 132 | 1123 | | `early_bird_submissions` | 909 | 1 | 905 | | `active_days` | 90 | 0 | 90 | | `max_ac_in_one_day` | 17 | 0 | 17 | | `max_ac_streak_days` | 40 | 0 | 40 | **所以修 OJ2 的时区不是「换口径」,是「把 OJ2 弄丢的口径补回来」。** 失配的是 OJ2 上线后 那两周,修完反而对齐了。 **那两周留下的实际后果**(截至 2026-09-14 备份,1508 条 OJ2 期提交、约 1500 个已结算用户): - **日期键没被污染**:`_active_dates` / `_ac_per_day` 一个都没偏 —— 上课时间的提交在 UTC 下 日期和北京是同一天。所以 5 个日期口径的成就(活跃天数、单日最多 AC、连续天数)一条都没错。 - **只有小时键被污染**:145 人 `midnight_submissions` 虚高、45 人 `early_bird_submissions` 虚高。因为 UTC 的「凌晨 0–5 点」正好是北京的上午 9–13 点,而学生恰恰在上课时间提交。 - **结果是 60 条误发**:47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2 时期的 新账号(`backfilled = false`)。 - **0 条漏发**,而且是结构性的:那 1508 条提交里真正落在北京 0–5 点和 5–7 点的**都是 0 条** —— 真熬夜、真早起的人都在 Django 时代活跃过了,他们的成就是当时按东八区正确发的。 这个 bug 只会多给,不会少给。 ### 改这类数据时最大的坑:不能只删 `user_achievement` `unlockAchievements()` 的判定是**纯阈值比较**(`metrics[metric] >= threshold`),不是 「这次有没有跨过阈值」。只删行、不修 `user_stat.metrics` 的话,学生**下一次提交就把同一个 成就原样再发一次**。必须「按东八区重算小时指标」和「对账发放」一起做。 那次用一次性脚本 `fix-achievement-hours` 做的对账:重算两个小时指标 → 撤回不达标的 → 补发达标却没发的 → 同步 `achievement.unlock_count` → 校正 `achievement_unlocked_count` 与「奖杯收藏家」连锁。2026-09-14 跑出来是「修正 148 行 · 撤回 60 条 · 补发 0 条 · 连锁 0 条」, 再跑一遍零改动。**账已经平了,脚本也已删除**(2026-09-16,在 git 历史里: `apps/api/src/scripts/fix-achievement-hours.ts`)—— 根因(时区)修在代码里了, 同样的误发不会再产生。 真要再做一次同类订正,照它的次序来,两条别记反:**先部署口径修复,再跑对账**。 反过来的话旧代码还在按错口径累加,跑完马上又被写脏。剩下那个通用对账工具是 `oj2-api recount`(`bun run --filter '@oj2/api' recount`,默认只读预演), 它管的是反范式计数列和成就已解锁数。 ### 下次再动日历口径,照这套方法核实 从生产备份里捞出 `submission` / `user_stat` / `user_achievement` / `achievement` 四张表 回放一遍,先用与时间无关的指标(`submission_count` / `accepted_count`)校准重放器 (实测逐人 0 差异 / 4 人差异),再比受影响的指标。别靠推理。