refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
Some checks failed
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:
2026-09-14 06:06:52 -06:00
parent 3921c496cb
commit cc51e305cf
15 changed files with 116 additions and 251 deletions

View File

@@ -1,55 +1,29 @@
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { TIME_ZONE } from "../time"
import * as schema from "./schema"
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
/**
* 会话时区固定成东八区(启动包里的 `TimeZone` 参数)
*
* 只影响 SQL 里那些**把 timestamptz 换算成日历**的函数 —— `extract(year from …)`、
* `date(…)`、`to_char(…, 'YYYY-MM-DD')`。比较、排序、存取值都不受它影响
* timestamptz 存的是绝对时刻)。不设的话 PostgreSQL 默认 UTC于是同一个
* 「哪一年」在 SQL 里和 JS 里会差 8 小时。
*
* 注意这只是**默认值**,别把正确性押在它身上:动了日历语义的 SQL 仍然应该显式写
* `at time zone`(见 `../time` 的 TIME_ZONE_SQL否则换库、走 pgbouncer、
* 或者谁改了这里的配置,都会静默漂回去。
*/
const client = postgres(url, { connection: { TimeZone: TIME_ZONE } })
// 不设会话时区:日历语义的 SQL 一律显式 `at time zone``../time` 的 localTime
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉dev 上又是另一个答案
const client = postgres(url)
export const db = drizzle(client, { schema })
/**
* 让**读出来的时刻**和**写进去的时刻**是同一种字符串ISO 8601 UTC
* 读出来的时刻统一成 ISO 8601 UTC和写侧的 `new Date().toISOString()` 同形状
*
* 写侧一直是 `new Date().toISOString()``2026-09-14T12:00:00.000Z`),但读侧原本
* 拿回来的是 PostgreSQL 的文本格式`2026-09-14 20:00:00+08`空格分隔 + 会话时区偏移)。
* 于是同一个字段在接口上有两种形状:从库里读的是一种、后端现拼的是另一种,
* 对接外部系统时对方得解析两套。
* drizzle 的 `construct()``drizzle-orm/postgres-js/driver.js`)把 1184(timestamptz) 等
* OID 的 parser 换成了恒等函数,不处理的话读出来是 PG 文本`2026-09-14 20:00:00+08`
* 接口上同一个字段有两种形状。所以**必须在 `drizzle(client)` 之后**覆盖回来。
*
* 根因在 drizzle`drizzle-orm/postgres-js/driver.js` 的 `construct()` 把 1184(timestamptz)
* 等 8 个 OID 的 parser 换成了恒等函数postgres.js 本来会做的 `new Date()` 解析被跳过,
* 原样吐 PG 文本。所以**必须在 `drizzle(client)` 之后**再把它换回来(顺序不能反)。
*
* **只换 1184不要碰 1082(date)。** `date(create_time at time zone …)` 这种日历日
* 表达式要的就是 `2026-09-14`,把 1082 也套上 `toISOString()` 会把它变成带时分的时刻
* 1114(timestamp without time zone) 同理不碰 —— 全库 35 个时间列都是 timestamptz。
*
* ⚠️ **`::text` 转出来的字符串 OID 是 25不走这里**。所以原来为了「拿回和列一样形状的
* 字符串」而写的 `max(join_time)::text` 这类 cast 现在会反过来变成异类,必须一起撤掉。
*
* 数据库里存的始终是 UTC 绝对时刻,这一层只改**序列化形状**,不改任何值。
*
* ⚠️ **必须保留微秒,别「简化」回 `new Date(value).toISOString()`。** `Date` 只到毫秒,
* 而生产库 12.3 万条提交几乎全带微秒Django 写入的)。读出来的时刻经常被原样当查询条件
* 塞回去 —— 提交列表翻页的 `(create_time, id) <= (分界行, id)`、班级 AC 排名的
* `create_time <= min(create_time)` —— 截成毫秒后分界行自己比「分界值」大,被条件排除:
* 翻页每页丢第一条,排名少算 1。所以偏移换算交给 `Date`(先去掉小数,避免任何进位),
* 小数位原文拼回去,至少补足 3 位:`2026-09-14T12:00:00.123456Z` / `…00.000Z`。
* Bun、Node、老 Chrome 的 `Date` 与 date-fns `parseISO` 都能解析 6 位小数。
* - **只换 1184。** 1082(date) 要的就是 `2026-09-14`;全库时间列都是 timestamptz
* - **`::text` 的 OID 是 25绕过这里**:别再为了拿字符串形状给时间列加 `::text`。
* - **保留微秒。** `Date` 只到毫秒,而 Django 时代的提交几乎全带微秒;读出的时刻常被
* 原样塞回查询条件(提交列表翻页的分界行、班级 AC 排名的 `<= min(create_time)`
* 截掉会让分界行把自己排除。所以偏移换算交给 `Date`(先去掉小数,免得进位),
* 小数位原文拼回去、至少补足 3 位。Bun、老 Chrome 和 date-fns 都能解析 6 位小数
*/
client.options.parsers[1184] = (value: string) => {
const fraction = /\.\d+/.exec(value)?.[0]

View File

@@ -18,7 +18,7 @@ import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { JudgeStatus } from "../../judge/status"
import { completeChat } from "../../services/ai"
import { localYear, TIME_ZONE_SQL } from "../../time"
import { localTime, localYear } from "../../time"
import { queryInteger, rounded } from "../helpers"
import { findTagsByName, normalizeTagNames } from "./problem"
@@ -230,11 +230,8 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
// 年份一律按东八区切`extract(year from timestamptz)` 默认走**数据库会话时区**
// 而 `currentYear` 走的是进程时区 —— 两个不同来源碰巧都等于 UTC 时才自洽。
// 这里两处都用同一个常量谁都不依赖环境。group by 会重复这个表达式,
// 所以必须内联(见 time.ts 的 TIME_ZONE_SQL
const year = sql<number>`extract(year from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})`.mapWith(Number)
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(Number)
const rows = await db.select({
problemId: schema.problem.id,
displayId: schema.problem.displayId,
@@ -246,8 +243,8 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(and(
isNull(schema.submission.contestId),
gte(sql`extract(year from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})`, sinceYear),
lte(sql`extract(year from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})`, untilYear),
gte(year, sinceYear),
lte(year, untilYear),
))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
.orderBy(asc(schema.problem.id), asc(year))

View File

@@ -28,9 +28,9 @@ import {
calendarDay,
dayNumber,
dayText,
localTime,
localWeekday,
shiftMonthsByCalendar,
TIME_ZONE_SQL,
} from "../time"
import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
@@ -54,15 +54,6 @@ async function throttleAi(c: Context<AppEnv>) {
return failure(c, 429, "too-many-requests", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
/*
* 日历分桶固定按东八区,**不跟容器或数据库的 TZ 走**。
*
* 原先这里是三套口径混着用SQL 的 `date(create_time)` 走数据库会话时区、JS 的
* `toISOString()` 取 UTC 日期当 key、`getDate()` 又走容器本地时区 —— 容器恰好是
* UTC 时才自洽。锚点和助手都收进了 `../time`**凡是要换算「哪一天 / 几点」,
* 一律走那边**,这里不再自己拼日期部件。
*/
function grade(rank: number | null, count: number, reference = count): Grade {
if (!rank || count <= 0) return "C"
const percentile = (rank - 1) / count * 100
@@ -177,10 +168,9 @@ async function listSolved(user: AuthUser, start: string, end: string, limit: num
async function buildDetail(user: AuthUser, start: string, end: string) {
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
// 星期和小时都按东八区取,和热力图同口径;时区用 sql.raw 拼进去,
// 绑成参数的话 select 和 group by 会拿到不同占位符PG 不认为是同一个表达式。
const weekday = sql<number>`extract(dow from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})::int`.mapWith(Number)
const period = sql<number>`floor(extract(hour from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL}) / 6)::int`.mapWith(Number)
// 星期和小时都按东八区取,和热力图同口径
const weekday = sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(Number)
const period = sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(Number)
const activityRows = await db.select({ weekday, period, value: count() }).from(schema.submission)
.where(and(
eq(schema.submission.userId, user.id),
@@ -376,15 +366,12 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
if (!user) return failure(c, 404, "user-not-found", "User not found")
const end = new Date()
// 一格一周,共 53 格,最后一格是「本周」。周一算一周的开头(不用 GitHub 的周日)。
//
// 整段以**日历日序号**为单位算(`dayNumber` / `dayText`),不构造任何本地 Date
// 原先是「东八区的日期部件 + 容器本地时区的零点和 getDay()」拼出来的,
// 容器 TZ 一换就整体错一格。
// 整段以东八区的**日历日序号**为单位算(`dayNumber` / `dayText`),不构造本地 Date。
const today = dayNumber(calendarDay(end))
const mondayOffset = (localWeekday(today) + 6) % 7
const firstMonday = today - mondayOffset - 52 * 7
// SQL 两端各放宽一天:范围只用来少拉行,精确匹配靠下面按日历日 key 查表
const date = sql<string>`date(${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})::text`
const date = sql<string>`date(${localTime(schema.submission.createTime)})::text`
const rows = await db.select({ date, value: count() }).from(schema.submission)
.where(and(
eq(schema.submission.userId, user.id),
@@ -396,8 +383,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
const monday = firstMonday + week * 7
let value = 0
for (let offset = 0; offset < 7; offset++) value += counts.get(dayText(monday + offset)) ?? 0
// timestamp 该周周一的 UTC 零点= 今天线上发出去的那个值,前端只取年月日部件
// 换算成「北京时间的周一零点」会让 UTC 以西的浏览器看到周日,那是另一种错
// timestamp 该周周一的 UTC 零点,前端按东八区只取年月日部件
return { timestamp: monday * 864e5, value } satisfies HeatmapItem
}))
})

View File

@@ -102,9 +102,6 @@ export function publicTemplates(value: unknown) {
return templates
}
// todayStart() 搬去了 `../time` —— 它原来用 setHours(0,0,0,0) 切进程时区的零点,
// 而全仓的日历口径是东八区。别在这里再放一份。
export function rounded(value: number, digits = 2) {
const factor = 10 ** digits
return Math.round(value * factor) / factor

View File

@@ -21,7 +21,7 @@ import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { calendarDayYearsAgo, startOfCalendarDay } from "../time"
import { localTime, shiftMonthsByCalendar, todayStart } from "../time"
import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
export const problemRoutes = new Hono<AppEnv>()
@@ -179,9 +179,8 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
))
if (!mine?.value) return success(c, "0")
// 「近两年」按东八区日历算到当天零点。原先是 setFullYear/setHours
// 切的是进程时区的零点。
const since = startOfCalendarDay(calendarDayYearsAgo(2)).toISOString()
// 「近两年」按东八区日历算到当天零点
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
const [active, accepted] = await Promise.all([
db.select({ value: count() }).from(schema.user).where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since))),
db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(and(
@@ -217,7 +216,7 @@ problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
const year = sql<number>`extract(year from ${schema.submission.createTime})::int`
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
const rows = await db.select({
year,
total: count(),

View File

@@ -1,7 +1,7 @@
import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { TIME_ZONE } from "../time"
import { localTime, TIME_ZONE } from "../time"
/**
* 一次性数据对账:把「夜猫子」「早起的鸟儿」的历史发放与真实提交时间对齐。
@@ -88,7 +88,7 @@ interface Plan {
async function audit(): Promise<Plan> {
// 用 SQL 一次算完,口径和 apps/api/src/time.ts 完全一致(东八区墙上时钟的钟点)。
// 只统计非比赛提交 —— 和 updateAchievementsForSubmission 的 contestId !== null 提前返回对齐。
const hour = sql`extract(hour from ${schema.submission.createTime} at time zone ${TIME_ZONE})`
const hour = sql`extract(hour from ${localTime(schema.submission.createTime)})`
const recomputed = await db
.select({
userId: schema.submission.userId,

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: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` 是兜底用的第二道保险,不是这里的依据。
* **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,都必须走这里**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})`
}