refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
- TIME_ZONE / TIME_ZONE_OFFSET_MINUTES 移到 packages/contract/src/time.ts,
前后端共用一份,不再各写一遍靠注释对齐。
- apps/api/src/time.ts 新增 localTime(列),替换散落 7 处的
`at time zone ${TIME_ZONE_SQL}`;ac-trend 的 where 复用同一个 year 表达式。
- /problems/:displayId/yearly-ac 漏写了时区、按 UTC 切年,被会话时区兜底掩盖;
改为按东八区切(只影响每年 12-31 北京 0–8 点的提交归年)。
- 删掉数据库连接的 TimeZone 和 Dockerfile 的 TZ:正确代码不依赖它们,
它们只会在线上掩盖漏写处、让 dev 与线上答案不同。
- time.ts:calendarDayYearsAgo/pad 并入 shiftMonthsByCalendar,startOfCalendarDay
并入 todayStart,localWeekday 改用 getUTCDay,删掉历史叙述注释。
- 前端 zonedParts 改为固定偏移 + getUTC*(与后端、日期选择器同一写法),
去掉 Intl formatToParts;10 万次 299ms → 11ms。zonedYear 去掉按浏览器时区的兜底。
- 两份 CLAUDE.md 同步;n-date-picker 那条过时说明改成现用法。
验证:新旧「近两年起点」21359 个时刻 0 差异、localWeekday 0 差异、
前端固定偏移与 Intl 在 America/New_York 下 47821 个时刻 0 差异;
localTime 在 select/group by/where 复用可用;fix-achievement-hours 预演仍为 148 / 60。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
@@ -18,7 +18,7 @@ import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { JudgeStatus } from "../../judge/status"
|
||||
import { completeChat } from "../../services/ai"
|
||||
import { localYear, TIME_ZONE_SQL } from "../../time"
|
||||
import { localTime, localYear } from "../../time"
|
||||
import { queryInteger, rounded } from "../helpers"
|
||||
import { findTagsByName, normalizeTagNames } from "./problem"
|
||||
|
||||
@@ -230,11 +230,8 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
||||
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
|
||||
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
|
||||
|
||||
// 年份一律按东八区切:`extract(year from timestamptz)` 默认走**数据库会话时区**,
|
||||
// 而 `currentYear` 走的是进程时区 —— 两个不同来源碰巧都等于 UTC 时才自洽。
|
||||
// 这里两处都用同一个常量,谁都不依赖环境。group by 会重复这个表达式,
|
||||
// 所以必须内联(见 time.ts 的 TIME_ZONE_SQL)。
|
||||
const year = sql<number>`extract(year from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})`.mapWith(Number)
|
||||
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径
|
||||
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(Number)
|
||||
const rows = await db.select({
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
@@ -246,8 +243,8 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(sql`extract(year from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})`, sinceYear),
|
||||
lte(sql`extract(year from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})`, untilYear),
|
||||
gte(year, sinceYear),
|
||||
lte(year, untilYear),
|
||||
))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
|
||||
.orderBy(asc(schema.problem.id), asc(year))
|
||||
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
calendarDay,
|
||||
dayNumber,
|
||||
dayText,
|
||||
localTime,
|
||||
localWeekday,
|
||||
shiftMonthsByCalendar,
|
||||
TIME_ZONE_SQL,
|
||||
} from "../time"
|
||||
import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
|
||||
|
||||
@@ -54,15 +54,6 @@ async function throttleAi(c: Context<AppEnv>) {
|
||||
return failure(c, 429, "too-many-requests", `Please wait ${Math.floor(throttle.wait)} seconds`)
|
||||
}
|
||||
|
||||
/*
|
||||
* 日历分桶固定按东八区,**不跟容器或数据库的 TZ 走**。
|
||||
*
|
||||
* 原先这里是三套口径混着用:SQL 的 `date(create_time)` 走数据库会话时区、JS 的
|
||||
* `toISOString()` 取 UTC 日期当 key、`getDate()` 又走容器本地时区 —— 容器恰好是
|
||||
* UTC 时才自洽。锚点和助手都收进了 `../time`:**凡是要换算「哪一天 / 几点」,
|
||||
* 一律走那边**,这里不再自己拼日期部件。
|
||||
*/
|
||||
|
||||
function grade(rank: number | null, count: number, reference = count): Grade {
|
||||
if (!rank || count <= 0) return "C"
|
||||
const percentile = (rank - 1) / count * 100
|
||||
@@ -177,10 +168,9 @@ async function listSolved(user: AuthUser, start: string, end: string, limit: num
|
||||
async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
|
||||
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
|
||||
// 星期和小时都按东八区取,和热力图同口径;时区用 sql.raw 拼进去,
|
||||
// 绑成参数的话 select 和 group by 会拿到不同占位符,PG 不认为是同一个表达式。
|
||||
const weekday = sql<number>`extract(dow from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})::int`.mapWith(Number)
|
||||
const period = sql<number>`floor(extract(hour from ${schema.submission.createTime} at time zone ${TIME_ZONE_SQL}) / 6)::int`.mapWith(Number)
|
||||
// 星期和小时都按东八区取,和热力图同口径
|
||||
const weekday = sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(Number)
|
||||
const period = sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(Number)
|
||||
const activityRows = await db.select({ weekday, period, value: count() }).from(schema.submission)
|
||||
.where(and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
@@ -376,15 +366,12 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
|
||||
if (!user) return failure(c, 404, "user-not-found", "User not found")
|
||||
const end = new Date()
|
||||
// 一格一周,共 53 格,最后一格是「本周」。周一算一周的开头(不用 GitHub 的周日)。
|
||||
//
|
||||
// 整段以**日历日序号**为单位算(`dayNumber` / `dayText`),不构造任何本地 Date:
|
||||
// 原先是「东八区的日期部件 + 容器本地时区的零点和 getDay()」拼出来的,
|
||||
// 容器 TZ 一换就整体错一格。
|
||||
// 整段以东八区的**日历日序号**为单位算(`dayNumber` / `dayText`),不构造本地 Date。
|
||||
const today = dayNumber(calendarDay(end))
|
||||
const mondayOffset = (localWeekday(today) + 6) % 7
|
||||
const firstMonday = today - mondayOffset - 52 * 7
|
||||
// SQL 两端各放宽一天:范围只用来少拉行,精确匹配靠下面按日历日 key 查表
|
||||
const date = sql<string>`date(${schema.submission.createTime} at time zone ${TIME_ZONE_SQL})::text`
|
||||
const date = sql<string>`date(${localTime(schema.submission.createTime)})::text`
|
||||
const rows = await db.select({ date, value: count() }).from(schema.submission)
|
||||
.where(and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
@@ -396,8 +383,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
|
||||
const monday = firstMonday + week * 7
|
||||
let value = 0
|
||||
for (let offset = 0; offset < 7; offset++) value += counts.get(dayText(monday + offset)) ?? 0
|
||||
// timestamp 取该周周一的 UTC 零点(= 今天线上发出去的那个值,前端只取年月日部件),
|
||||
// 换算成「北京时间的周一零点」会让 UTC 以西的浏览器看到周日,那是另一种错
|
||||
// timestamp 是该周周一的 UTC 零点,前端按东八区只取年月日部件
|
||||
return { timestamp: monday * 864e5, value } satisfies HeatmapItem
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ import { db, schema } from "../db"
|
||||
import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { calendarDayYearsAgo, startOfCalendarDay } from "../time"
|
||||
import { localTime, shiftMonthsByCalendar, todayStart } from "../time"
|
||||
import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
||||
|
||||
export const problemRoutes = new Hono<AppEnv>()
|
||||
@@ -179,9 +179,8 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
))
|
||||
if (!mine?.value) return success(c, "0")
|
||||
// 「近两年」按东八区日历算到当天零点。原先是 setFullYear/setHours,
|
||||
// 切的是进程时区的零点。
|
||||
const since = startOfCalendarDay(calendarDayYearsAgo(2)).toISOString()
|
||||
// 「近两年」按东八区日历算到当天零点
|
||||
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
|
||||
const [active, accepted] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.user).where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since))),
|
||||
db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(and(
|
||||
@@ -217,7 +216,7 @@ problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
|
||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const year = sql<number>`extract(year from ${schema.submission.createTime})::int`
|
||||
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
|
||||
const rows = await db.select({
|
||||
year,
|
||||
total: count(),
|
||||
|
||||
Reference in New Issue
Block a user