refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
Deploy / deploy (push) Has been cancelled

- TIME_ZONE / TIME_ZONE_OFFSET_MINUTES 移到 packages/contract/src/time.ts,
  前后端共用一份,不再各写一遍靠注释对齐。
- apps/api/src/time.ts 新增 localTime(列),替换散落 7 处的
  `at time zone ${TIME_ZONE_SQL}`;ac-trend 的 where 复用同一个 year 表达式。
- /problems/:displayId/yearly-ac 漏写了时区、按 UTC 切年,被会话时区兜底掩盖;
  改为按东八区切(只影响每年 12-31 北京 0–8 点的提交归年)。
- 删掉数据库连接的 TimeZone 和 Dockerfile 的 TZ:正确代码不依赖它们,
  它们只会在线上掩盖漏写处、让 dev 与线上答案不同。
- time.ts:calendarDayYearsAgo/pad 并入 shiftMonthsByCalendar,startOfCalendarDay
  并入 todayStart,localWeekday 改用 getUTCDay,删掉历史叙述注释。
- 前端 zonedParts 改为固定偏移 + getUTC*(与后端、日期选择器同一写法),
  去掉 Intl formatToParts;10 万次 299ms → 11ms。zonedYear 去掉按浏览器时区的兜底。
- 两份 CLAUDE.md 同步;n-date-picker 那条过时说明改成现用法。

验证:新旧「近两年起点」21359 个时刻 0 差异、localWeekday 0 差异、
前端固定偏移与 Intl 在 America/New_York 下 47821 个时刻 0 差异;
localTime 在 select/group by/where 复用可用;fix-achievement-hours 预演仍为 148 / 60。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-09-14 06:06:52 -06:00
1 parent 3921c496cb
commit cc51e305cf
15 files changed
+116 -251

No files matched your search

+29 -72
View File
@@ -1,45 +1,22 @@
import { sql } from "drizzle-orm"
import { TIME_ZONE, TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
import { sql, type SQLWrapper } from "drizzle-orm"
/**
* 全仓唯一的时间锚点:**Asia/Shanghai**。
* 后端的日历换算全在这里,锚点是契约里的 `TIME_ZONE`(东八区,固定偏移)。
*
* 旧栈是 Django,`settings.TIME_ZONE = "Asia/Shanghai"` + `USE_TZ = True`:
* 库里存 UTC,应用层一律按北京时间算日历。重写成 OJ2 之后这个锚点丢了 ——
* 容器没设 TZ(= UTC)、数据库会话也是 UTC,于是「今天」「现在几点」「哪一年」
* 全按 UTC 判,整体比学生的作息早 8 小时。
*
* 已经造成的偏差(改之前):
* - `todayStart()` 切的是 UTC 零点 → 「今日提交」在北京时间 0:00–8:00 是空的,
* 8:00 之后才把前一天的提交清掉;
* - 成就「凌晨提交次数」口径写的是 0:00–5:00、「早起提交次数」是 5:00–7:00,
* 实际按 UTC 小时判定,整体偏 8 小时;
* - 「活跃天数」「单日最多 AC」「最长连续 AC 天数」按 UTC 日切分。
*
* **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,都必须走这里。**
* 不要再写 `new Date(x).getHours()` / `setHours(0,0,0,0)` / `getFullYear()` /
* `new Date(y, m, d)` 这类跟**进程时区**走的代码:在容器(UTC)和开发机
* (本机时区,可能是任何值)上给出不同答案,而且不报错、没人会发现。
*
* 实现上按**固定偏移**算,不查 tzdata、不依赖 `Intl` 的时区库:中国大陆
* 1991 年起不再有夏令时,Asia/Shanghai 恒为 UTC+8。这样无论进程 TZ 是什么、
* 镜像里有没有 tzdata,结果都一样,dev 和线上也一致。
* Dockerfile 里的 `TZ=Asia/Shanghai` 是兜底用的第二道保险,不是这里的依据。
* **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,都必须走这里**;SQL 里按日历切
* 就用 `localTime()`。不要写 `new Date(x).getHours()` / `setHours(0,0,0,0)` /
* `getFullYear()` / `new Date(y, m, d)` 这类跟**进程时区**走的代码,也不要依赖数据库
* 会话时区:容器(UTC)和开发机给出不同答案,而且不报错。
*/
export const TIME_ZONE = "Asia/Shanghai"
export { TIME_ZONE }
/** Asia/Shanghai 的固定偏移。换时区时这个常量必须跟着改 */
const OFFSET_MS = 8 * 60 * 60 * 1000
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const DAY_MS = 86_400_000
function pad(value: number) {
return String(value).padStart(2, "0")
}
/**
* 真实时刻 → 「东八区墙上时钟」。
*
* 平移 8 小时之后,`getUTC*` 读出来的就是北京时间的年月日时分,于是日历运算
* 可以整套用 UTC 那批 API 做,完全不受进程时区影响。`fromWallClock` 是逆运算。
* 真实时刻 → 「东八区墙上时钟」。平移之后 `getUTC*` 读出来的就是北京时间的年月日时分,
* 日历运算可以整套用 UTC 那批 API 做。`fromWallClock` 是逆运算。
*/
function toWallClock(value: Date | number | string = new Date()): Date {
return new Date(new Date(value).getTime() + OFFSET_MS)
@@ -80,44 +57,17 @@ export function dayText(day: number): string {
return new Date(day * DAY_MS).toISOString().slice(0, 10)
}
/** 周几,0 = 周日。和 `Date#getDay()` 同一套编号,但按东八区日历算 */
/** 日历日序号是周几,0 = 周日(和 `Date#getDay()` 同一套编号) */
export function localWeekday(day: number): number {
return (((day + 4) % 7) + 7) % 7
return new Date(day * DAY_MS).getUTCDay()
}
/** 北京时间的某一天零点,返回真实时刻 */
export function startOfCalendarDay(day: string): Date {
return new Date(dayNumber(day) * DAY_MS - OFFSET_MS)
}
/**
* 「东八区今天」的零点,返回 ISO 字符串。
*
* 提交列表的 `?today=1`、流程图列表的 `?today=1`、后台首页的「今日提交数」都用它。
* 原来是 `setHours(0,0,0,0)`,切的是**进程时区**的零点。
*/
/** 「东八区今天」的零点,返回 ISO 字符串。提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它 */
export function todayStart(now: Date | number | string = new Date()): string {
return startOfCalendarDay(calendarDay(now)).toISOString()
return new Date(dayNumber(calendarDay(now)) * DAY_MS - OFFSET_MS).toISOString()
}
/**
* 北京时间的「N 年前的今天」。日号超出目标月长度时截到月末
* (2 月 29 日往前两年不能静默滚到 3 月 1 日)。
*/
export function calendarDayYearsAgo(years: number, now: Date | number | string = new Date()): string {
const [year, month, date] = calendarDay(now).split("-").map(Number)
const lastDay = new Date(Date.UTC(year! - years, month!, 0)).getUTCDate()
return `${year! - years}-${pad(month!)}-${pad(Math.min(date!, lastDay))}`
}
/**
* 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留。
*
* 就是原来 `ai.ts` 里那个 `shiftMonths` 的逐句改写:`getDate` → `getUTCDate`、
* `setMonth` → `setUTCMonth`、`new Date(y, m, d)` → `new Date(Date.UTC(...))`,
* 外面套一层墙上时钟平移。结果和「进程 TZ 恰好是 Asia/Shanghai」时逐位相同,
* 但不再依赖进程 TZ。
*/
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant)
const date = wall.getUTCDate()
@@ -129,10 +79,17 @@ export function shiftMonthsByCalendar(instant: Date, months: number): Date {
}
/**
* 时区名拼成 SQL 字面量,供 `... at time zone ${TIME_ZONE_SQL}` 用。
*
* **必须内联,不能走参数绑定**:同一个表达式在 select 和 group by 里各出现一次,
* 绑定成参数会拿到两个不同的占位符,PG 就不认为它们是同一个表达式,直接报
* 「column must appear in the GROUP BY clause」。常量拼接,没有注入面。
* 时区名直接拼成 SQL 字面量,**不走参数绑定**:同一个表达式在 select 和 group by 里
* 各出现一次,绑定成参数会拿到两个不同的占位符,PG 就不认为它们是同一个表达式,报
* 「must appear in the GROUP BY clause」。常量拼接,没有注入面。
*/
export const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`)
const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`)
/**
* `timestamptz` 列 → 北京墙上时间(`timestamp`),供 `extract(hour from …)` /
* `date(…)` 这类日历函数用。每次调用渲染出的 SQL 文本相同,select 和 group by
* 各调一次也能匹配上。
*/
export function localTime(column: SQLWrapper) {
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
}