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:
2026-09-14 05:55:17 -06:00
parent 6e63866cc9
commit 4c0c38445c
23 changed files with 491 additions and 106 deletions

View File

@@ -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 }

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>()

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))

View File

@@ -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<AppEnv>()
@@ -46,21 +54,14 @@ 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 才对得上,哪天给容器设了 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<number>`extract(dow from ${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL})::int`.mapWith(Number)
const period = sql<number>`floor(extract(hour from ${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL}) / 6)::int`.mapWith(Number)
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 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<string>`date(${schema.submission.createTime} at time zone ${CALENDAR_TZ_SQL})::text`
const date = sql<string>`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
}))
})

View File

@@ -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<AppEnv>()

View File

@@ -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

View File

@@ -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<AppEnv>()
@@ -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

View File

@@ -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<ContestEnv>()
@@ -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<string>`max(${schema.problemsetProgress.joinTime})::text`,
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
// 「转成 ISO 8601」的 parser所以这里拿到的和 `mode:"string"` 的列同形状。
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser反而会变成 PG 文本。
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
})
.from(schema.problemsetProgress)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))

View File

@@ -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<string>`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<string>`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))]),

View File

@@ -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<string, unknown>, 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<string, unknown>, 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

138
apps/api/src/time.ts Normal file
View File

@@ -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: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` 是兜底用的第二道保险,不是这里的依据。
*/
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)
}
/** 北京时间的钟点023 */
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}'`)

View File

@@ -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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue"
import { parseTime } from "utils/functions"
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
import type { BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api"
@@ -27,13 +27,15 @@ watch([waitMins, durationMins], () => {
contest.endTime = formatISO(times[1])
})
// 编辑的时候
// 编辑的时候。这两个 ref 绑给 n-date-picker值要平移过见 utils/functions.ts
// 的 toPickerValue—— 选择器按浏览器本地渲染,不换算的话非东八区的老师看到的是
// 自己时区的钟点,存进去就成了另一个时刻。
const startTime = ref(0)
const endTime = ref(0)
watch([startTime, endTime], (values) => {
contest.startTime = formatISO(values[0])
contest.endTime = formatISO(values[1])
contest.startTime = formatISO(fromPickerValue(values[0]))
contest.endTime = formatISO(fromPickerValue(values[1]))
})
const route = useRoute()
@@ -79,9 +81,9 @@ async function getContestDetail() {
contest.password = data.password
contest.visible = data.visible
// 显示
startTime.value = Date.parse(data.startTime)
endTime.value = Date.parse(data.endTime)
// 显示:交给选择器之前先平移成「北京墙上时间」
startTime.value = toPickerValue(Date.parse(data.startTime))
endTime.value = toPickerValue(Date.parse(data.endTime))
}
async function submit() {

View File

@@ -12,6 +12,7 @@ import {
Tooltip,
} from "chart.js"
import { getTopACTrend } from "admin/api"
import { zonedYear } from "utils/functions"
ChartJS.register(
CategoryScale,
@@ -25,7 +26,10 @@ ChartJS.register(
type ProblemTrend = AcTrend
const currentYear = new Date().getFullYear()
// 年份按东八区取,和后端 ac-trend 的夹逼口径(`localYear()`)对齐。
// 用 `new Date().getFullYear()` 的话,跨年那几个小时里浏览器年份可能比后端认定的
// 年份晚一年,默认的 untilYear 会被后端夹掉、图表悄悄变成另一个区间。
const currentYear = zonedYear()
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
label: String(2022 + i),
value: 2022 + i,
@@ -37,7 +41,7 @@ const minPerYearOptions = [
]
const sinceYear = ref(2023)
const untilYear = ref(new Date().getFullYear() - 1)
const untilYear = ref(zonedYear() - 1)
const minPerYear = ref(100)
const loading = ref(false)
const data = ref<ProblemTrend[]>([])

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
const route = useRoute()
@@ -18,11 +19,15 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
endTime: null,
})
// n-date-picker 按浏览器本地渲染,所以要平移一次再绑定(见 utils/functions.ts
// 的 toPickerValue。`formData.endTime` 里始终存**真实时刻**,只有喂给选择器那一步换。
const endTimeTimestamp = computed({
get: () =>
formData.value.endTime ? new Date(formData.value.endTime).getTime() : null,
formData.value.endTime
? toPickerValue(formData.value.endTime.getTime())
: null,
set: (val: number | null) => {
formData.value.endTime = val ? new Date(val) : null
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
},
})

View File

@@ -2,6 +2,7 @@
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useRarityColor } from "shared/composables/rarity"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import { parseTime } from "utils/functions"
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
@@ -48,7 +49,9 @@ const unlockDate = computed(() => {
const { unlockTime, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlockTime) return "已获得"
return `${new Date(unlockTime).toLocaleDateString()} 获得`
// 走 parseTime 而不是 toLocaleDateString():后者按浏览器时区渲染,
// 站内所有日期都是东八区口径
return `${parseTime(unlockTime)} 获得`
})
</script>

View File

@@ -55,7 +55,7 @@
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
import { parseTime, zonedParts } from "utils/functions"
import { useChartTheme } from "shared/composables/chartTheme"
const aiStore = useAIStore()
@@ -94,18 +94,21 @@ const getColor = (count: number) => {
}
// 一格一周横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交,
// 365 格里三百多格空着,整张图看着像没用过
// 365 格里三百多格空着,整张图看着像没用过
//
// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,所以年月日一律按东八区取
// `zonedParts`),不能用 `getMonth()` / `getDate()` —— 那是浏览器本地部件,
// 从别的时区打开会整体错一格。周末直接加 6 天的毫秒数:大陆没有夏令时,
// 那正好是东八区的 6 天。
const cells = computed(() =>
aiStore.heatmapData.map((item, i) => {
const start = new Date(item.timestamp)
const endOfWeek = new Date(
start.getFullYear(),
start.getMonth(),
start.getDate() + 6,
)
const parts = zonedParts(start)
return {
start,
end: endOfWeek,
end: new Date(start.getTime() + 6 * 86_400_000),
month: parts?.month ?? 1,
day: parts?.day ?? 1,
count: item.value,
color: getColor(item.value),
x: i * CELL_TOTAL,
@@ -117,11 +120,11 @@ const monthLabels = computed(() => {
const labels: { text: string; x: number }[] = []
let lastMonth = -1
cells.value.forEach((cell, i) => {
const month = cell.start.getMonth()
const month = cell.month
if (month !== lastMonth) {
// 第一格所在的月往往只露出小半个月,标签会和下一个月挤在一起,跳过
if (i > 0 || cell.start.getDate() <= 7) {
labels.push({ text: `${month + 1}`, x: cell.x })
if (i > 0 || cell.day <= 7) {
labels.push({ text: `${month}`, x: cell.x })
}
lastMonth = month
}
@@ -129,9 +132,7 @@ const monthLabels = computed(() => {
return labels
})
const svgWidth = computed(
() => cells.value.length * CELL_TOTAL + RIGHT_PADDING,
)
const svgWidth = computed(() => cells.value.length * CELL_TOTAL + RIGHT_PADDING)
const svgHeight = computed(() => MONTH_HEIGHT + CELL_HEIGHT)
interface Cell {

View File

@@ -22,6 +22,7 @@
</template>
<script lang="ts" setup>
import { useBreakpoints } from "shared/composables/breakpoints"
import { zonedYear } from "utils/functions"
const route = useRoute()
const { isMobile } = useBreakpoints()
@@ -29,7 +30,9 @@ const hiddenICP = computed(() =>
["problem", "contest problem"].includes(route.name as string),
)
const currentYear = new Date().getFullYear()
// 版权年份也走东八区:站内不留任何一处按浏览器时区取时间部件的代码,
// 免得下一个人照着抄
const currentYear = zonedYear()
const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利`
function goICP() {

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from "vue"
import { parseTime } from "utils/functions"
import { getNodeTypeConfig } from "./useNodeStyles"
import { currentDragNodeType } from "./useDnD"
@@ -60,7 +61,8 @@ const saveStatusTitle = computed(() => {
} else if (props.hasUnsavedChanges) {
return "有未保存的更改"
} else if (props.lastSaved) {
return `已保存 - ${new Date(props.lastSaved).toLocaleTimeString()}`
// 和站内其它时间同一口径(东八区),不用 toLocaleTimeString() 跟着浏览器走
return `已保存 - ${parseTime(props.lastSaved, "HH:mm:ss")}`
} else {
return "已保存"
}

View File

@@ -12,6 +12,7 @@ import {
type Zippable,
} from "fflate"
import copyTextFallback from "copy-text-to-clipboard"
import { normalizeDate } from "@vueuse/core"
import { customAlphabet } from "nanoid"
function calculateACRate(acCount: number, totalCount: number): string {
@@ -114,9 +115,118 @@ 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 pad2 = (value: number) => String(value).padStart(2, "0")
/**
* 取一个时刻在东八区的年月日时分秒(都是数字,月/日/时/分/秒已补零成两位数)。
* 无效日期返回 null。
*
* **不要在组件里写 `getFullYear()` / `getMonth()` / `getDate()`** —— 那些取的是
* 浏览器本地部件,和站内的东八区口径不是一回事。要日历部件就用这个。
*/
export function zonedParts(value: Date | string) {
const date = normalizeDate(value)
if (Number.isNaN(date.getTime())) return null
const raw: Record<string, number> = {}
for (const part of zonedFormatter.formatToParts(date)) {
if (part.type !== "literal") raw[part.type] = Number(part.value)
}
return {
year: raw.year!,
month: raw.month!,
day: raw.day!,
hour: raw.hour!,
minute: raw.minute!,
second: raw.second!,
}
}
/** 东八区的年份。跨年那几个小时里它和 `new Date().getFullYear()` 会差一年 */
export function zonedYear(value: Date | string = new Date()) {
return zonedParts(value)?.year ?? new Date().getFullYear()
}
/**
* 按东八区格式化。格式串只认下面这几个 token站内实际用到的就这些
* 其余字符原样输出,所以 `YYYY年M月D日` 这种中英混排也能用。
*
* 长度不同的 token 靠正则的**顺序**区分:`YYYY` 必须排在 `M`/`D` 前面,
* 否则 `MM` 会被拆成两个 `M`。
*/
export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
const time = useDateFormat(utc, format, { locales: "zh-CN" })
return time.value
const parts = zonedParts(utc)
if (!parts) return ""
const table: Record<string, string> = {
YYYY: String(parts.year),
MM: pad2(parts.month),
DD: pad2(parts.day),
HH: pad2(parts.hour),
mm: pad2(parts.minute),
ss: pad2(parts.second),
M: String(parts.month),
D: String(parts.day),
}
return format.replace(/YYYY|MM|DD|HH|mm|ss|M|D/g, (token) => table[token]!)
}
/**
* Naive 的 `n-date-picker` 没有 `timezone` 属性,它把绑定的时间戳按**浏览器本地**
* 渲染。站内的口径是东八区,所以非东八区的机器上要平移一次再交给它。
*
* 于是这两个函数是一对逆运算:
*
* toPickerValue(真实时刻) → 绑给 n-date-picker本地渲染出来正好是北京墙上时间
* fromPickerValue(选择器值) → 换回真实时刻再formatISO/存库
*
* 北京机器上换算是**恒等**480 + (-480) = 0所以不会改变现状只在别处才起作用。
*
* 偏移写成常量而不是查 `Intl`:大陆 1991 年起没有夏令时,东八区恒为 UTC+8
* 和 `../api/src/time.ts` 一个道理。`longOffset` 那套要 Chrome 95+,机房老 Chrome 用不了。
*
* ⚠️ **只有 `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
)
}
/** n-date-picker 的绑定值 → 真实时刻epoch 毫秒) */
export function fromPickerValue(value: number) {
return (
value -
(PICKER_OFFSET_MINUTES + new Date(value).getTimezoneOffset()) * 60_000
)
}
function getDurationObject(start: Date | string, end: Date | string) {