From 4c0c38445ccf64c2395f3ddc450c519f3ec9d302 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 14 Sep 2026 05:55:17 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E6=97=B6=E5=8C=BA):=20=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E6=94=B6=E5=9B=9E=E4=B8=9C=E5=85=AB=E5=8C=BA?= =?UTF-8?q?=EF=BC=8C=E8=AF=BB=E5=87=BA=E7=9A=84=E6=97=B6=E5=88=BB=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=88=90=20ISO=20=E5=B9=B6=E4=BF=9D=E7=95=99=E5=BE=AE?= =?UTF-8?q?=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧栈 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 Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd --- CLAUDE.md | 38 +++++ apps/api/src/db/index.ts | 51 ++++++- apps/api/src/routes/admin/conf.ts | 3 +- apps/api/src/routes/admin/tag.ts | 13 +- apps/api/src/routes/ai.ts | 76 +++++----- apps/api/src/routes/flowchart.ts | 2 +- apps/api/src/routes/helpers.ts | 7 +- apps/api/src/routes/problem.ts | 9 +- apps/api/src/routes/submission.ts | 10 +- apps/api/src/scripts/backfill-problemsets.ts | 6 +- apps/api/src/services/achievements.ts | 17 +-- apps/api/src/time.ts | 138 ++++++++++++++++++ apps/web/CLAUDE.md | 21 +++ apps/web/src/admin/contest/detail.vue | 16 +- apps/web/src/admin/problem/TopACTrend.vue | 8 +- apps/web/src/admin/problemset/edit.vue | 9 +- .../components/AchievementCard.vue | 5 +- apps/web/src/oj/ai/components/Heatmap.vue | 29 ++-- apps/web/src/shared/components/Beian.vue | 5 +- .../components/FlowchartEditor/Toolbar.vue | 4 +- apps/web/src/utils/functions.ts | 114 ++++++++++++++- docker/Dockerfile | 13 +- packages/contract/src/ai.ts | 3 +- 23 files changed, 491 insertions(+), 106 deletions(-) create mode 100644 apps/api/src/time.ts diff --git a/CLAUDE.md b/CLAUDE.md index 96789ab..95a789a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,6 +199,44 @@ C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者 机房电脑 Chrome < 94。`mermaid-legacy` 等 fallback 依赖和 vite 的构建 target 不能动,`vite.config.ts` 里有注释说明。 +### 时间只有一个锚点:`apps/api/src/time.ts` + +**凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** +不要写 `new Date(x).getHours()`、`setHours(0,0,0,0)`、`getFullYear()`、 +`new Date(y, m, d)` 这类跟**进程时区**走的代码 —— 容器是 UTC、开发机是本机时区, +两边答案不同而且不报错。SQL 里要按日历切,显式写 +`at time zone ${TIME_ZONE_SQL}`,别依赖数据库会话时区。 + +旧栈 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` 都只是**兜底**,不是依据。 + +**分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。** + +- **存**:35 个时间列全是 `timestamptz`(`without time zone` 0 个、`date` 0 个), + 写侧一律 `new Date().toISOString()`。库里永远是绝对时刻,换时区不用动数据。 +- **判定**:日历语义(哪一天/几点/哪一年)走 `time.ts`,SQL 显式 `at time zone`。 +- **出参**:`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"`(见 + `apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse + `normalizeDate`)实测都能吃 ISO,改动出参格式不需要动前端。 + ## 数据库 Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表 diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 90e40da..b0e0fc7 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -1,11 +1,60 @@ 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" -const client = postgres(url) +/** + * 会话时区固定成东八区(启动包里的 `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 } }) export const db = drizzle(client, { schema }) + +/** + * 让**读出来的时刻**和**写进去的时刻**是同一种字符串:ISO 8601 UTC。 + * + * 写侧一直是 `new Date().toISOString()`(`2026-09-14T12:00:00.000Z`),但读侧原本 + * 拿回来的是 PostgreSQL 的文本格式(`2026-09-14 20:00:00+08`,空格分隔 + 会话时区偏移)。 + * 于是同一个字段在接口上有两种形状:从库里读的是一种、后端现拼的是另一种, + * 对接外部系统时对方得解析两套。 + * + * 根因在 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 位小数。 + */ +client.options.parsers[1184] = (value: string) => { + const fraction = /\.\d+/.exec(value)?.[0] + if (!fraction) return new Date(value).toISOString() + return `${new Date(value.replace(fraction, "")).toISOString().slice(0, 19)}${fraction.padEnd(4, "0")}Z` +} + export { schema } diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 6d3a6a5..85dd122 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -20,7 +20,8 @@ import { db, schema } from "../../db" import { publishConfigUpdate } from "../../events" import { failure, success } from "../../http" import { getWebsiteOptions } from "../../services/options" -import { queryInteger, todayStart } from "../helpers" +import { todayStart } from "../../time" +import { queryInteger } from "../helpers" export const adminConfRoutes = new Hono() diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index 6052f3a..0f32c37 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -18,6 +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 { queryInteger, rounded } from "../helpers" import { findTagsByName, normalizeTagNames } from "./problem" @@ -220,7 +221,7 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => { }) adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => { - const currentYear = new Date().getFullYear() + const currentYear = localYear() // 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错 let sinceYear = queryInteger(c.req.query("sinceYear"), 2023) if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023 @@ -229,7 +230,11 @@ 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 - const year = sql`extract(year from ${schema.submission.createTime})`.mapWith(Number) + // 年份一律按东八区切:`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) const rows = await db.select({ problemId: schema.problem.id, displayId: schema.problem.displayId, @@ -241,8 +246,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})`, sinceYear), - lte(sql`extract(year from ${schema.submission.createTime})`, untilYear), + 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), )) .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 fe13850..5a9e6db 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -24,6 +24,14 @@ import { JudgeStatus, judgeStatusName, type JudgeStatusValue } from "../judge/st import { failure, success } from "../http" import { completeChat, streamChat } from "../services/ai" import { consumeToken } from "../services/throttling" +import { + calendarDay, + dayNumber, + dayText, + localWeekday, + shiftMonthsByCalendar, + TIME_ZONE_SQL, +} from "../time" import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers" export const aiRoutes = new Hono() @@ -46,21 +54,14 @@ 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 才对得上,哪天给容器设了 TZ 热力图就整体错一格。 +/* + * 日历分桶固定按东八区,**不跟容器或数据库的 TZ 走**。 + * + * 原先这里是三套口径混着用:SQL 的 `date(create_time)` 走数据库会话时区、JS 的 + * `toISOString()` 取 UTC 日期当 key、`getDate()` 又走容器本地时区 —— 容器恰好是 + * UTC 时才自洽。锚点和助手都收进了 `../time`:**凡是要换算「哪一天 / 几点」, + * 一律走那边**,这里不再自己拼日期部件。 */ -const CALENDAR_TZ = "Asia/Shanghai" -/** - * 时区直接拼进 SQL,不走参数绑定:同一个表达式在 select 和 group by 里各出现一次, - * 绑定成参数会拿到两个不同的占位符,PG 就不认为它们是同一个表达式,直接报 - * 「must appear in the GROUP BY clause」。常量拼接,没有注入面。 - */ -const CALENDAR_TZ_SQL = sql.raw(`'${CALENDAR_TZ}'`) -const calendarDay = new Intl.DateTimeFormat("en-CA", { - timeZone: CALENDAR_TZ, year: "numeric", month: "2-digit", day: "2-digit", -}) function grade(rank: number | null, count: number, reference = count): Grade { if (!rank || count <= 0) return "C" @@ -178,8 +179,8 @@ async function buildDetail(user: AuthUser, start: string, end: string) { // 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。 // 星期和小时都按东八区取,和热力图同口径;时区用 sql.raw 拼进去, // 绑成参数的话 select 和 group by 会拿到不同占位符,PG 不认为是同一个表达式。 - const weekday = sql`extract(dow from ${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL})::int`.mapWith(Number) - const period = sql`floor(extract(hour from ${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL}) / 6)::int`.mapWith(Number) + 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 activityRows = await db.select({ weekday, period, value: count() }).from(schema.submission) .where(and( eq(schema.submission.userId, user.id), @@ -278,19 +279,10 @@ aiRoutes.get("/ai/solved", requireAuth, async (c) => { return success(c, await listSolved(user, start, end, limit, offset)) }) -function shiftMonths(date: Date, months: number) { - const result = new Date(date) - const day = result.getDate() - result.setDate(1) - result.setMonth(result.getMonth() + months) - result.setDate(Math.min(day, new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate())) - return result -} - async function buildDuration(user: AuthUser, endText: string, duration: string) { const config = duration === "months:2" ? { count: 8, unit: "weeks", rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5), advance: (date: Date) => new Date(date.getTime() + 7 * 864e5) } - : duration === "months:6" ? { count: 6, unit: "months", rewind: (date: Date) => shiftMonths(date, -7), advance: (date: Date) => shiftMonths(date, 1) } - : duration === "years:1" ? { count: 12, unit: "months", rewind: (date: Date) => shiftMonths(date, -13), advance: (date: Date) => shiftMonths(date, 1) } + : duration === "months:6" ? { count: 6, unit: "months", rewind: (date: Date) => shiftMonthsByCalendar(date, -7), advance: (date: Date) => shiftMonthsByCalendar(date, 1) } + : duration === "years:1" ? { count: 12, unit: "months", rewind: (date: Date) => shiftMonthsByCalendar(date, -13), advance: (date: Date) => shiftMonthsByCalendar(date, 1) } : { count: 4, unit: "weeks", rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5), advance: (date: Date) => new Date(date.getTime() + 7 * 864e5) } // 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。 // 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。 @@ -384,31 +376,29 @@ 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 的周日)。 - // 日期部件全部取自东八区,再用它们构造本地零点的 Date 做日历运算 —— - // 前端 new Date(timestamp) 后取的也是本地部件,这样两边看到的是同一个日历日。 - const [nowYear, nowMonth, nowDay] = calendarDay.format(end).split("-").map(Number) - const today = new Date(nowYear!, nowMonth! - 1, nowDay!) - const mondayOffset = (today.getDay() + 6) % 7 - const firstMonday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - mondayOffset - 52 * 7) + // + // 整段以**日历日序号**为单位算(`dayNumber` / `dayText`),不构造任何本地 Date: + // 原先是「东八区的日期部件 + 容器本地时区的零点和 getDay()」拼出来的, + // 容器 TZ 一换就整体错一格。 + 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 ${CALENDAR_TZ_SQL})::text` + const date = sql`date(${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})::text` const rows = await db.select({ date, value: count() }).from(schema.submission) .where(and( eq(schema.submission.userId, user.id), - gte(schema.submission.createTime, new Date(firstMonday.getTime() - 864e5).toISOString()), + gte(schema.submission.createTime, new Date((firstMonday - 1) * 864e5).toISOString()), lte(schema.submission.createTime, new Date(end.getTime() + 864e5).toISOString()), )).groupBy(date).orderBy(date) const counts = new Map(rows.map((row) => [row.date, row.value])) - const dateKey = (value: Date) => - `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}` return success(c, Array.from({ length: 53 }, (_, week) => { - const monday = new Date(firstMonday.getFullYear(), firstMonday.getMonth(), firstMonday.getDate() + week * 7) + const monday = firstMonday + week * 7 let value = 0 - for (let offset = 0; offset < 7; offset++) { - const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset) - value += counts.get(dateKey(day)) ?? 0 - } - return { timestamp: monday.getTime(), value } satisfies HeatmapItem + for (let offset = 0; offset < 7; offset++) value += counts.get(dayText(monday + offset)) ?? 0 + // timestamp 取该周周一的 UTC 零点(= 今天线上发出去的那个值,前端只取年月日部件), + // 换算成「北京时间的周一零点」会让 UTC 以西的浏览器看到周日,那是另一种错 + return { timestamp: monday * 864e5, value } satisfies HeatmapItem })) }) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 1565c87..1827a49 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -21,13 +21,13 @@ import { flowchartQueue } from "../queue" import { getBooleanOption } from "../services/options" import { consumeToken } from "../services/throttling" import { buildWordFrequencies } from "../services/word-frequency" +import { todayStart } from "../time" import { isAdminRole, objectValue, queryInteger, rounded, stripClassPrefix, - todayStart, } from "./helpers" export const flowchartRoutes = new Hono() diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index f46f286..1c37559 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -102,11 +102,8 @@ export function publicTemplates(value: unknown) { return templates } -export function todayStart() { - const now = new Date() - now.setHours(0, 0, 0, 0) - return now.toISOString() -} +// todayStart() 搬去了 `../time` —— 它原来用 setHours(0,0,0,0) 切进程时区的零点, +// 而全仓的日历口径是东八区。别在这里再放一份。 export function rounded(value: number, digits = 2) { const factor = 10 ** digits diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index 9a99dab..da5f2f8 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -21,6 +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 { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers" export const problemRoutes = new Hono() @@ -178,11 +179,13 @@ 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") - const since = new Date(); since.setFullYear(since.getFullYear() - 2); since.setHours(0, 0, 0, 0) + // 「近两年」按东八区日历算到当天零点。原先是 setFullYear/setHours, + // 切的是进程时区的零点。 + const since = startOfCalendarDay(calendarDayYearsAgo(2)).toISOString() 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.toISOString()))), + 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( - eq(schema.submission.problemId, id), inArray(schema.submission.result, [0, 10]), gte(schema.submission.createTime, since.toISOString()), + eq(schema.submission.problemId, id), inArray(schema.submission.result, [0, 10]), gte(schema.submission.createTime, since), )), ]) const total = active[0]?.value ?? 0 diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 770553c..0dcb5e9 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -36,7 +36,8 @@ import { import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" import { consumeToken } from "../services/throttling" -import { asFilterValue, isAdminRole, queryInteger, rounded, stripClassPrefix, todayStart } from "./helpers" +import { todayStart } from "../time" +import { asFilterValue, isAdminRole, queryInteger, rounded, stripClassPrefix } from "./helpers" export const submissionRoutes = new Hono() @@ -749,9 +750,10 @@ async function problemSetJoinTimes(userId: number, problemIds: number[]) { const rows = await db .select({ problemId: schema.problemsetProblem.problemId, - // ::text 是为了拿回和 mode:"string" 列同样形状的字符串——聚合表达式不走列的类型映射, - // 不加这个 cast 驱动会把 timestamptz 解析成 Date,下游的 Date.parse 就接不住了 - joinTime: sql`max(${schema.problemsetProgress.joinTime})::text`, + // 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了 + // 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。 + // 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。 + joinTime: sql`max(${schema.problemsetProgress.joinTime})`, }) .from(schema.problemsetProgress) .innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId)) diff --git a/apps/api/src/scripts/backfill-problemsets.ts b/apps/api/src/scripts/backfill-problemsets.ts index 74ceec8..6ec25d2 100644 --- a/apps/api/src/scripts/backfill-problemsets.ts +++ b/apps/api/src/scripts/backfill-problemsets.ts @@ -69,7 +69,11 @@ async function recoverable(links: ProblemLink[], progresses: (typeof schema.prob const rows = await db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, - solvedAt: sql`min(${schema.submission.createTime})::text`, + // 和 recoverable 上面那段同口径。不加 `::text`:OID 1184 由 db/index.ts 统一转成 + // ISO 8601 UTC,比 PG 文本更稳定(PG 文本的形状跟着会话时区走)。 + // 注:这个值写进 progress_detail.submit_time,而那个字段没有任何读取方, + // 存量里还混着 Django 的 `...+00:00`,所以形状变化只影响 backfill 自己的差异比对。 + solvedAt: sql`min(${schema.submission.createTime})`, }).from(schema.submission).where(and( inArray(schema.submission.userId, [...new Set(gaps.map((g) => g.userId))]), inArray(schema.submission.problemId, [...new Set(gaps.map((g) => g.problemId))]), diff --git a/apps/api/src/services/achievements.ts b/apps/api/src/services/achievements.ts index 3bcd4f8..7b547b5 100644 --- a/apps/api/src/services/achievements.ts +++ b/apps/api/src/services/achievements.ts @@ -2,6 +2,7 @@ import { and, count, countDistinct, eq, inArray, isNotNull, isNull, ne, notInArr import { db, schema } from "../db" import { publishAchievementNotification } from "../events" +import { calendarDay, dayNumber, localHour } from "../time" import { findMetric } from "./achievement-metrics" import { isAccepted, JudgeStatus } from "../judge/status" import { objectValue } from "../routes/helpers" @@ -11,14 +12,6 @@ function numberMetric(metrics: Record, key: string) { return typeof value === "number" ? value : 0 } -function localDate(value: string) { - const date = new Date(value) - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, "0") - const day = String(date.getDate()).padStart(2, "0") - return `${year}-${month}-${day}` -} - async function unlockAchievements(userId: number, metrics: Record, onlyMeta = false) { const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement) .where(eq(schema.userAchievement.userId, userId)) @@ -66,8 +59,8 @@ export async function updateAchievementsForSubmission(submissionId: string) { const accepted = isAccepted(row.submission.result) const firstAc = accepted && !priorAccepted const firstTry = accepted && priorRows.length === 0 - const date = localDate(row.submission.createTime) - const hour = new Date(row.submission.createTime).getHours() + const date = calendarDay(row.submission.createTime) + const hour = localHour(row.submission.createTime) const metrics = await db.transaction(async (tx) => { await tx.insert(schema.userStat).values({ @@ -97,7 +90,9 @@ export async function updateAchievementsForSubmission(submissionId: string) { if (accepted) { const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null if (last !== date) { - const current = last && (Date.parse(`${date}T00:00:00`) - Date.parse(`${last}T00:00:00`)) / 86_400_000 === 1 + // 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时, + // 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。 + const current = last && dayNumber(date) - dayNumber(last) === 1 ? numberMetric(value, "_current_ac_streak") + 1 : 1 value._last_ac_date = date diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts new file mode 100644 index 0000000..a3428b6 --- /dev/null +++ b/apps/api/src/time.ts @@ -0,0 +1,138 @@ +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: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` 是兜底用的第二道保险,不是这里的依据。 + */ +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) +} + +/** 北京时间的钟点,0–23 */ +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}'`) diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index ea63f72..4dc9548 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -127,11 +127,32 @@ return contract("GET /problems/:id", problemDetailSchema, value) 一样会渲染错。收紧任何字段之前,拿根目录那份生产备份把全量数据跑一遍, 尤其要看**空值**而不只是键集合。 +### 时间一律按东八区展示,不跟浏览器走 + +**显示时间走 `utils/functions.ts` 的 `parseTime()`;要日历部件走 `zonedParts()` / +`zonedYear()`。** 不要在组件里写 `new Date(x).getFullYear()` / `getMonth()` / +`getDate()` / `toLocaleDateString()` / `toLocaleTimeString()` —— 那些取的是 +**浏览器本地**时区。机房电脑、学生手机平时都在东八区所以看不出来,但只要有人 +(比如时区没设对的机房机器、或在外地的老师)从别的时区打开,同一张提交记录表就会 +显示成另一个时间,和榜单、统计、成就里的日期对不上。 + +锚点在后端 `../api/src/time.ts`(`Asia/Shanghai`),前端的 `DISPLAY_TIME_ZONE` +必须和它一致。实现用 `Intl` 的 IANA 时区而不是自己加 8 小时,`timeZone` 选项 +Chrome 24+ 就支持,不影响机房老 Chrome。 + +**唯一还没跟上的是 `n-date-picker`**(`admin/contest/detail.vue`、 +`admin/problemset/edit.vue`):Naive 的日期选择器按浏览器本地时区渲染,没有 +`timezone` 属性。它在绝对值上往返正确(选的是什么时刻就是什么时刻),只是在非东八区 +的机器上「输入框里显示的时间」和「列表里显示的时间」会差一个时区。要修得在 +value ↔ 显示值之间做偏移换算,属于独立改动。 + ### Key Utilities - `utils/constants.ts` — Judge status codes, language IDs, difficulty levels, contest types - `utils/types.ts` — 契约类型的派生与前端专有收窄(不是手写的一份平行类型) - `utils/contract.ts` — 运行时契约闸门,见上 +- `utils/functions.ts` — `parseTime` / `zonedParts` / `zonedYear`(东八区时间口径,见上)、 + `duration`、压缩与剪贴板等杂项 - `utils/judge.ts` — Judge-related utilities - `utils/renders.ts` — Table column render helpers for Naive UI DataTable diff --git a/apps/web/src/admin/contest/detail.vue b/apps/web/src/admin/contest/detail.vue index 68a39b6..ec9ac4d 100644 --- a/apps/web/src/admin/contest/detail.vue +++ b/apps/web/src/admin/contest/detail.vue @@ -1,7 +1,7 @@ diff --git a/apps/web/src/oj/ai/components/Heatmap.vue b/apps/web/src/oj/ai/components/Heatmap.vue index e75eae3..21e0c7b 100644 --- a/apps/web/src/oj/ai/components/Heatmap.vue +++ b/apps/web/src/oj/ai/components/Heatmap.vue @@ -55,7 +55,7 @@