diff --git a/CLAUDE.md b/CLAUDE.md index 426e09e..566bf1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,36 +204,39 @@ C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者 **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** 不要写 `new Date(x).getHours()`、`setHours(0,0,0,0)`、`getFullYear()`、 `new Date(y, m, d)` 这类跟**进程时区**走的代码 —— 容器是 UTC、开发机是本机时区, -两边答案不同而且不报错。SQL 里要按日历切,显式写 -`at time zone ${TIME_ZONE_SQL}`,别依赖数据库会话时区。 +两边答案不同而且不报错。SQL 里要按日历切,用 `localTime(列)`(生成 +`列 at time zone 'Asia/Shanghai'`),别依赖数据库会话时区。 旧栈 Django 是 `TIME_ZONE = "Asia/Shanghai"` + `USE_TZ = True`:库里存 UTC、 应用层按北京时间算日历。重写时这个锚点丢了,直到 2026-09 才收回来 —— 期间 「今日提交」在北京时间 0:00–8:00 是空的,两个成就(「凌晨提交次数」0:00–5:00、 「早起提交次数」5:00–7:00)整体偏 8 小时。别再把口径散出去。 -实现上按**固定偏移**算(大陆 1991 年起没有夏令时),不查 tzdata、不用 `Intl`, -所以 dev / 编译产物 / 任何镜像基底都算得一样。Dockerfile 里的 `TZ=Asia/Shanghai` -和数据库连接上的 `TimeZone` 都只是**兜底**,不是依据。 +时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES` 在 `packages/contract/src/time.ts`, +前后端共用一份。实现按**固定偏移**算(大陆 1991 年起没有夏令时),不查 tzdata、不用 +`Intl`,所以 dev / 编译产物 / 任何镜像基底 / 任何浏览器都算得一样。 +**刻意不设** Dockerfile 的 `TZ`、也不设数据库连接的 `TimeZone`:它们不改变正确代码的 +行为,只会在线上把漏写的地方掩盖掉(`/problems/:displayId/yearly-ac` 就这样漏过一次), +而 dev 上又是另一个答案。 **分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。** - **存**:35 个时间列全是 `timestamptz`(`without time zone` 0 个、`date` 0 个), 写侧一律 `new Date().toISOString()`。库里永远是绝对时刻,换时区不用动数据。 -- **判定**:日历语义(哪一天/几点/哪一年)走 `time.ts`,SQL 显式 `at time zone`。 +- **判定**:日历语义(哪一天/几点/哪一年)走 `time.ts`,SQL 用 `localTime()`。 - **出参**:`db/index.ts` 给 OID 1184 挂了 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 处),对接外部系统时对方 - 得解析两套,而 `+08` 还取决于服务器会话时区、不该进契约。 + 得解析两套,而偏移还取决于服务器会话时区、不该进契约。 ⚠️ **微秒不能丢**,别改回 `new Date(v).toISOString()`:读出的时刻常被原样塞回查询条件 (提交列表翻页的分界行、班级 AC 排名的 `<= min(create_time)`),截成毫秒后分界行自己 被排除 —— 翻页每页丢一条、排名少 1。生产库 12.3 万条 Django 时代的提交几乎全带微秒。 ⚠️ **`::text` 的 OID 是 25、绕过那个 parser**,所以「为了拿回和列一样形状」而写的 `max(join_time)::text` 之类现在会变成异类,见到就撤掉。**只换 1184,别碰 1082(date)** —— `date(... at time zone ...)` 要的是 `2026-09-14`,套上 `toISOString()` 就错了。 -- **渲染**:前端 `parseTime()` 走 `Intl` 的 `timeZone: "Asia/Shanghai"`(见 +- **渲染**:前端 `parseTime()` / `zonedParts()` 按同一个固定偏移取东八区部件(见 `apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse `normalizeDate`)实测都能吃 ISO,改动出参格式不需要动前端。 diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index b0e0fc7..39e6c63 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -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] diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index 0f32c37..63ed1c1 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -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`extract(year from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})`.mapWith(Number) + // 年份按东八区切,和上面 `currentYear` 的夹逼同口径 + const year = sql`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)) diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 5a9e6db..6b57a04 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -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) { 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`extract(dow from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})::int`.mapWith(Number) - const period = sql`floor(extract(hour from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL}) / 6)::int`.mapWith(Number) + // 星期和小时都按东八区取,和热力图同口径 + const weekday = sql`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(Number) + const period = sql`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`date(${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})::text` + const date = sql`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 })) }) diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index 1c37559..3c2bba0 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -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 diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index da5f2f8..579a199 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -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() @@ -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`extract(year from ${schema.submission.createTime})::int` + const year = sql`extract(year from ${localTime(schema.submission.createTime)})::int` const rows = await db.select({ year, total: count(), diff --git a/apps/api/src/scripts/fix-achievement-hours.ts b/apps/api/src/scripts/fix-achievement-hours.ts index 3387b2b..f55e43b 100644 --- a/apps/api/src/scripts/fix-achievement-hours.ts +++ b/apps/api/src/scripts/fix-achievement-hours.ts @@ -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 { // 用 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, diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index a3428b6..9c05447 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -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})` +} diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 4dc9548..1b93392 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -136,15 +136,13 @@ return contract("GET /problems/:id", problemDetailSchema, value) (比如时区没设对的机房机器、或在外地的老师)从别的时区打开,同一张提交记录表就会 显示成另一个时间,和榜单、统计、成就里的日期对不上。 -锚点在后端 `../api/src/time.ts`(`Asia/Shanghai`),前端的 `DISPLAY_TIME_ZONE` -必须和它一致。实现用 `Intl` 的 IANA 时区而不是自己加 8 小时,`timeZone` 选项 -Chrome 24+ 就支持,不影响机房老 Chrome。 +时区常量在契约 `@oj2/contract` 的 `TIME_ZONE_OFFSET_MINUTES`,和后端 `time.ts` 共用。 +实现是「平移固定偏移 + 读 `getUTC*`」,不用 `Intl` 的时区选项:东八区没有夏令时, +纯算术在表格里逐格调用也不费事,老 Chrome 上结果也一致。 -**唯一还没跟上的是 `n-date-picker`**(`admin/contest/detail.vue`、 -`admin/problemset/edit.vue`):Naive 的日期选择器按浏览器本地时区渲染,没有 -`timezone` 属性。它在绝对值上往返正确(选的是什么时刻就是什么时刻),只是在非东八区 -的机器上「输入框里显示的时间」和「列表里显示的时间」会差一个时区。要修得在 -value ↔ 显示值之间做偏移换算,属于独立改动。 +**`n-date-picker` 要平移**(`admin/contest/detail.vue`、`admin/problemset/edit.vue`): +Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属性,所以绑定值走 +`toPickerValue()`,取回来走 `fromPickerValue()`。显示时间不要用这对函数。 ### Key Utilities diff --git a/apps/web/src/admin/problem/TopACTrend.vue b/apps/web/src/admin/problem/TopACTrend.vue index 06412e6..1c1e179 100644 --- a/apps/web/src/admin/problem/TopACTrend.vue +++ b/apps/web/src/admin/problem/TopACTrend.vue @@ -41,7 +41,7 @@ const minPerYearOptions = [ ] const sinceYear = ref(2023) -const untilYear = ref(zonedYear() - 1) +const untilYear = ref(currentYear - 1) const minPerYear = ref(100) const loading = ref(false) const data = ref([]) diff --git a/apps/web/src/oj/ai/components/Heatmap.vue b/apps/web/src/oj/ai/components/Heatmap.vue index 21e0c7b..8e7a8be 100644 --- a/apps/web/src/oj/ai/components/Heatmap.vue +++ b/apps/web/src/oj/ai/components/Heatmap.vue @@ -96,10 +96,8 @@ const getColor = (count: number) => { // 一格一周,横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交, // 365 格里三百多格空着,整张图看着像没用过。 // -// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,所以年月日一律按东八区取 -// (`zonedParts`),不能用 `getMonth()` / `getDate()` —— 那是浏览器本地部件, -// 从别的时区打开会整体错一格。周末直接加 6 天的毫秒数:大陆没有夏令时, -// 那正好是东八区的 6 天。 +// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,年月日按东八区取(`zonedParts`), +// 不用浏览器本地的 `getMonth()` / `getDate()`。东八区没有夏令时,周日就是加 6 天。 const cells = computed(() => aiStore.heatmapData.map((item, i) => { const start = new Date(item.timestamp) diff --git a/apps/web/src/utils/functions.ts b/apps/web/src/utils/functions.ts index f111aef..356bc57 100644 --- a/apps/web/src/utils/functions.ts +++ b/apps/web/src/utils/functions.ts @@ -1,4 +1,4 @@ -import { toAdminType } from "@oj2/contract" +import { TIME_ZONE_OFFSET_MINUTES, toAdminType } from "@oj2/contract" import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract" import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns" import { Submission, User } from "./types" @@ -115,60 +115,35 @@ export function durationFromValue( return { [unit]: count } as Duration } -/** - * 站内所有时间一律按**东八区**展示,不跟浏览器时区走。 - * - * 后端存的是 UTC 绝对时刻,显示口径的锚点在 `apps/api/src/time.ts` - * (`Asia/Shanghai`)。前端原来走 `useDateFormat`,那是按**浏览器本地时区**渲染的 - * —— 机房电脑和学生手机都在东八区,所以平时看不出来;但只要有人从别的时区打开, - * 同一张提交记录表就会显示成另一个时间,和榜单、统计、成就里的日期对不上。 - * - * 大陆 1991 年起没有夏令时,但这里仍然走 `Intl` 的 IANA 时区而不是自己加 8 小时: - * 万一时区规则变了,`Intl` 跟着 tzdata 走,写死的偏移不会。 - * `timeZone` 选项 Chrome 24+ 就支持,不影响机房老 Chrome。 - */ -export const DISPLAY_TIME_ZONE = "Asia/Shanghai" - -const zonedFormatter = new Intl.DateTimeFormat("en-CA", { - timeZone: DISPLAY_TIME_ZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", -}) +const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000 const pad2 = (value: number) => String(value).padStart(2, "0") /** - * 取一个时刻在东八区的年月日时分秒(都是数字,月/日/时/分/秒已补零成两位数)。 - * 无效日期返回 null。 + * 取一个时刻在东八区的年月日时分秒(数字)。无效日期返回 null。 * - * **不要在组件里写 `getFullYear()` / `getMonth()` / `getDate()`** —— 那些取的是 - * 浏览器本地部件,和站内的东八区口径不是一回事。要日历部件就用这个。 + * 站内所有时间一律按**东八区**展示、不跟浏览器时区走,口径是契约里的 + * `TIME_ZONE`(和后端 `apps/api/src/time.ts` 同一个常量)。平移固定偏移后读 `getUTC*`, + * 就是北京的墙上时间。**不要在组件里写 `getFullYear()` / `getMonth()` / `getDate()`** + * —— 那是浏览器本地部件。 */ export function zonedParts(value: Date | string) { - const date = normalizeDate(value) - if (Number.isNaN(date.getTime())) return null - const raw: Record = {} - for (const part of zonedFormatter.formatToParts(date)) { - if (part.type !== "literal") raw[part.type] = Number(part.value) - } + const time = normalizeDate(value).getTime() + if (Number.isNaN(time)) return null + const wall = new Date(time + OFFSET_MS) return { - year: raw.year!, - month: raw.month!, - day: raw.day!, - hour: raw.hour!, - minute: raw.minute!, - second: raw.second!, + year: wall.getUTCFullYear(), + month: wall.getUTCMonth() + 1, + day: wall.getUTCDate(), + hour: wall.getUTCHours(), + minute: wall.getUTCMinutes(), + second: wall.getUTCSeconds(), } } -/** 东八区的年份。跨年那几个小时里它和 `new Date().getFullYear()` 会差一年 */ -export function zonedYear(value: Date | string = new Date()) { - return zonedParts(value)?.year ?? new Date().getFullYear() +/** 东八区的当前年份。跨年那几个小时里它和 `new Date().getFullYear()` 会差一年 */ +export function zonedYear() { + return zonedParts(new Date())!.year } /** @@ -195,38 +170,20 @@ export function parseTime(utc: Date | string, format = "YYYY年M月D日") { } /** - * Naive 的 `n-date-picker` 没有 `timezone` 属性,它把绑定的时间戳按**浏览器本地** - * 渲染。站内的口径是东八区,所以非东八区的机器上要平移一次再交给它。 - * - * 于是这两个函数是一对逆运算: + * Naive 的 `n-date-picker` 没有 `timezone` 属性,按**浏览器本地**渲染绑定的时间戳, + * 所以要平移一次再交给它。这两个函数互为逆运算,东八区的机器上是恒等: * * toPickerValue(真实时刻) → 绑给 n-date-picker,本地渲染出来正好是北京墙上时间 - * fromPickerValue(选择器值) → 换回真实时刻,再formatISO/存库 + * fromPickerValue(选择器值) → 换回真实时刻,再 formatISO / 存库 * - * 北京机器上换算是**恒等**(480 + (-480) = 0),所以不会改变现状;只在别处才起作用。 - * - * 偏移写成常量而不是查 `Intl`:大陆 1991 年起没有夏令时,东八区恒为 UTC+8, - * 和 `../api/src/time.ts` 一个道理。`longOffset` 那套要 Chrome 95+,机房老 Chrome 用不了。 - * - * ⚠️ **只有 `n-date-picker` 需要这一对。** 要显示时间用 `parseTime`,不要把 - * 平移过的值喂给它 —— 那会显示成北京时间的「再平移」。 + * ⚠️ **只有 `n-date-picker` 需要这一对。** 显示时间用 `parseTime`,别把平移过的值喂给它。 */ -const PICKER_OFFSET_MINUTES = 8 * 60 - -/** 真实时刻(epoch 毫秒)→ n-date-picker 的绑定值 */ export function toPickerValue(instant: number) { - return ( - instant + - (PICKER_OFFSET_MINUTES + new Date(instant).getTimezoneOffset()) * 60_000 - ) + return instant + OFFSET_MS + new Date(instant).getTimezoneOffset() * 60_000 } -/** n-date-picker 的绑定值 → 真实时刻(epoch 毫秒) */ export function fromPickerValue(value: number) { - return ( - value - - (PICKER_OFFSET_MINUTES + new Date(value).getTimezoneOffset()) * 60_000 - ) + return value - OFFSET_MS - new Date(value).getTimezoneOffset() * 60_000 } function getDurationObject(start: Date | string, end: Date | string) { diff --git a/docker/Dockerfile b/docker/Dockerfile index a24b7c2..4991456 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -160,18 +160,7 @@ COPY apps/api/src/db/meta/_journal.json /usr/local/share/oj2/migrations/meta/ # 这不是审美问题:切换那天不用搬动任何文件,回滚时旧后端也立刻能找到自己的数据。 # 少一次几十 GB 的 mv,就少一个在停机窗口里出错的机会。 WORKDIR /data -# TZ 是**兜底**,不是时区口径的依据 —— 业务时区的唯一锚点在 apps/api/src/time.ts。 -# 设它是为了任何一处还在用 `getHours()` / `setHours(0,0,0,0)` 这类跟进程时区走的代码 -# 也落在北京时间上(旧 Django 栈的 `settings.TIME_ZONE` 就是这个作用)。 -# -# **必须同时设 worker 容器**:worker 跑的是同一个镜像、同样的 ENV,所以这一行两边都覆盖。 -# 本机 `bun run dev` 走不到这里,进程时区是开发机的 —— 这也是为什么 time.ts 用固定 -# 偏移而不是依赖进程 TZ:dev 和线上必须算得一样。 -# -# debian:trixie-slim 自带 tzdata(实测 /usr/share/zoneinfo/Asia/Shanghai 存在), -# 不用额外 apt 装。哪天换基底要重新确认这一条,否则 TZ 会被静默忽略、回落成 UTC。 -ENV TZ=Asia/Shanghai \ - TEST_CASE_DIRECTORY=/data/test_case \ +ENV TEST_CASE_DIRECTORY=/data/test_case \ HITOKOTO_DIRECTORY=/data/hitokoto \ UPLOAD_DIRECTORY=/data/public/upload \ AVATAR_DIRECTORY=/data/public/avatar \ diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index c226486..d2a3b84 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -14,3 +14,4 @@ export * from "./problemset" export * from "./roles" export * from "./site" export * from "./submission" +export * from "./time" diff --git a/packages/contract/src/time.ts b/packages/contract/src/time.ts new file mode 100644 index 0000000..012093c --- /dev/null +++ b/packages/contract/src/time.ts @@ -0,0 +1,9 @@ +/** + * 站内唯一的日历时区。后端按它判「哪一天 / 几点 / 哪一年」,前端按它显示时间。 + * + * 两边都按**固定偏移**算、不查 tzdata:中国大陆 1991 年起没有夏令时,东八区恒为 UTC+8, + * 这样进程 TZ、浏览器时区、镜像里有没有 tzdata 都不影响结果(`Intl` 的 `longOffset` + * 要 Chrome 95+,机房老 Chrome 用不了)。换时区时两个常量一起改。 + */ +export const TIME_ZONE = "Asia/Shanghai" +export const TIME_ZONE_OFFSET_MINUTES = 8 * 60