Files
OJ2/docs/timezone.md
yuetsh a8408c0bb5 docs: 文档整理,CLAUDE.md 瘦身一半,删掉重写期已完成的 22 份阶段产物
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>
2026-09-16 08:03:26 -06:00

91 lines
5.8 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.
# 时区:口径、出参格式、存量成就的那次订正
日常要守的规矩在 `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别碰 1082date** —— `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 的「凌晨 05 点」正好是北京的上午 913 点,而学生恰恰在上课时间提交。
- **结果是 60 条误发**47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2 时期的
新账号(`backfilled = false`)。
- **0 条漏发**,而且是结构性的:那 1508 条提交里真正落在北京 05 点和 57 点的**都是 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 人差异),再比受影响的指标。别靠推理。