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
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-09-14 05:55:17 -06:00
1 parent 6e63866cc9
commit 4c0c38445c
23 files changed
+491 -106

No files matched your search

+2 -1
View File
@@ -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<AppEnv>()
+9 -4
View File
@@ -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<number>`extract(year from ${schema.submission.createTime})`.mapWith(Number)
// 年份一律按东八区切:`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)
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))