CLAUDE.md 从 490 行降到 228 行:只留日常要当场记住的约束,展开拆成五份专题 文档 —— docs/deploy.md(部署与备份恢复)、database.md(迁移执行器、基线、 drizzle-kit 的坑)、timezone.md(时区口径与那次成就订正)、contract.md (出参不 parse 的四次故障)、ast-rules.md(AST 规则与 C++ 的调用形态)。 删掉的是阶段 0–5 那批一次性产物:4 份实施计划、10 份评审/核验/修复报告、 endpoint-inventory.md(110 端点是 2026-08 的快照,现在 363 条路由)、 docs/spikes/ 的 spike 与提取脚本(结论早已落进代码)。phase5 切换手册删之前 先把仍然有效的部分提炼进 docs/deploy.md:拓扑、deploy.sh、部署后验证清单、 NPM 那两个不能关的开关、pg_dumpall 恢复的两个坑、镜像体积;演练报告与回滚 两节随旧栈下线一并作废。 两份设计文档保留,补上状态行说明它们是「当初为什么这么定」而不是现状。 apps/web/CLAUDE.md 顺手订正过期内容:PUBLIC_OJ_URL / PUBLIC_WS_URL 两个变量 早已不存在(baseURL 写死 /api,dev 走 vite proxy、线上由 Caddy 同源伺服), store 与 composable 清单补齐到与目录一致。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.8 KiB
时区:口径、出参格式、存量成就的那次订正
日常要守的规矩在 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 人差异),再比受影响的指标。别靠推理。