Files
OJ2/apps/api/src/time.ts
yuetsh 4c0c38445c fix(时区): 日历口径收回东八区,读出的时刻统一成 ISO 并保留微秒
旧栈 Django 按 Asia/Shanghai 算日历,OJ2 重写时这个锚点丢了:容器和数据库会话
都是 UTC,于是「今日提交」在北京时间 0–8 点是空的,「凌晨/早起提交次数」整体偏
8 小时,热力图、AC 趋势年份、近两年活跃人数也各按进程时区切。

- 新增 apps/api/src/time.ts 作为唯一锚点(固定 +8 偏移,不依赖进程 TZ / tzdata),
  todayStart、成就小时/日期键、热力图、月份平移、年份夹逼全部改走它;
  SQL 里按日历切的一律显式 at time zone。
- db/index.ts:连接会话时区设为东八区(兜底);给 timestamptz(1184) 挂 parser,
  读出统一成 ISO 8601 UTC,撤掉为拿 PG 文本形状写的 ::text。parser 保留微秒 ——
  生产库 12.3 万条提交几乎全带微秒,截成毫秒会让翻页分界行和班级 AC 排名的
  <= min(create_time) 把自己排除(翻页每页丢一条、排名少 1)。
- 前端 parseTime/zonedParts/zonedYear 按 Asia/Shanghai 渲染,n-date-picker 做
  toPickerValue/fromPickerValue 平移,站内不再按浏览器时区取时间部件。
- Dockerfile 设 TZ=Asia/Shanghai 作为第二道兜底。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:17 -06:00

139 lines
5.9 KiB
TypeScript
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.
import { sql } from "drizzle-orm"
/**
* 全仓唯一的时间锚点:**Asia/Shanghai**。
*
* 旧栈是 Django`settings.TIME_ZONE = "Asia/Shanghai"` + `USE_TZ = True`
* 库里存 UTC应用层一律按北京时间算日历。重写成 OJ2 之后这个锚点丢了 ——
* 容器没设 TZ= UTC、数据库会话也是 UTC于是「今天」「现在几点」「哪一年」
* 全按 UTC 判,整体比学生的作息早 8 小时。
*
* 已经造成的偏差(改之前):
* - `todayStart()` 切的是 UTC 零点 → 「今日提交」在北京时间 0:008:00 是空的,
* 8:00 之后才把前一天的提交清掉;
* - 成就「凌晨提交次数」口径写的是 0:005:00、「早起提交次数」是 5:007: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` 是兜底用的第二道保险,不是这里的依据。
*/
export const TIME_ZONE = "Asia/Shanghai"
/** Asia/Shanghai 的固定偏移。换时区时这个常量必须跟着改 */
const OFFSET_MS = 8 * 60 * 60 * 1000
const DAY_MS = 86_400_000
function pad(value: number) {
return String(value).padStart(2, "0")
}
/**
* 真实时刻 → 「东八区墙上时钟」。
*
* 平移 8 小时之后,`getUTC*` 读出来的就是北京时间的年月日时分,于是日历运算
* 可以整套用 UTC 那批 API 做,完全不受进程时区影响。`fromWallClock` 是逆运算。
*/
function toWallClock(value: Date | number | string = new Date()): Date {
return new Date(new Date(value).getTime() + OFFSET_MS)
}
function fromWallClock(wall: Date): Date {
return new Date(wall.getTime() - OFFSET_MS)
}
/** 北京时间的日历日,形如 `2026-09-14` */
export function calendarDay(value: Date | number | string = new Date()): string {
return toWallClock(value).toISOString().slice(0, 10)
}
/** 北京时间的钟点023 */
export function localHour(value: Date | number | string = new Date()): number {
return toWallClock(value).getUTCHours()
}
/** 北京时间的年份 */
export function localYear(value: Date | number | string = new Date()): number {
return toWallClock(value).getUTCFullYear()
}
/**
* 日历日序号1970-01-01 为 0
*
* 「差几天」一律用它算,别拿两个 Date 相减:夏令时地区的相邻两天可能相差
* 23 或 25 小时,除 86400000 得到的不是 1`=== 1` 这种判据会静默失效。
*/
export function dayNumber(day: string): number {
const [year, month, date] = day.split("-").map(Number)
return Date.UTC(year!, month! - 1, date!) / DAY_MS
}
/** 日历日序号 → `YYYY-MM-DD` */
export function dayText(day: number): string {
return new Date(day * DAY_MS).toISOString().slice(0, 10)
}
/** 周几0 = 周日。和 `Date#getDay()` 同一套编号,但按东八区日历算 */
export function localWeekday(day: number): number {
return (((day + 4) % 7) + 7) % 7
}
/** 北京时间的某一天零点,返回真实时刻 */
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)`,切的是**进程时区**的零点。
*/
export function todayStart(now: Date | number | string = new Date()): string {
return startOfCalendarDay(calendarDay(now)).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()
wall.setUTCDate(1)
wall.setUTCMonth(wall.getUTCMonth() + months)
const lastDay = new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0)).getUTCDate()
wall.setUTCDate(Math.min(date, lastDay))
return fromWallClock(wall)
}
/**
* 时区名拼成 SQL 字面量,供 `... at time zone ${TIME_ZONE_SQL}` 用。
*
* **必须内联,不能走参数绑定**:同一个表达式在 select 和 group by 里各出现一次,
* 绑定成参数会拿到两个不同的占位符PG 就不认为它们是同一个表达式,直接报
* 「column must appear in the GROUP BY clause」。常量拼接没有注入面。
*/
export const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`)