- 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:
1 parent
3921c496cb
commit
cc51e305cf
15 files changed
+116
-251
No files matched your search
+29
-72
@@ -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})`
|
||||
}
|
||||
Reference in new issue
Block a user