Compare commits

..

4 Commits

Author SHA1 Message Date
cc51e305cf refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
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
2026-09-14 06:06:52 -06:00
3921c496cb fix(成就): 后台补发成就后重算已解锁数并接着判「奖杯收藏家」;recount 订正存量
rescanAchievement 只插 user_achievement、加 unlock_count,不重算
achievement_unlocked_count,也不做元成就的第二轮判定(旧 rescan_achievement
原样如此)。判题结算只在「这次有新解锁」时才重算,所以被补发的人计数会一直停在
旧值。2026-09-07 一次补发之后 269 人少算,其中 10 人实际够 15 个却没拿到
「奖杯收藏家」。

- 新增 refreshUnlockedCount:一条 SQL 按 user_achievement 重算,只 jsonb_set
  这一个键、只写值变了的行。rescanAchievement 补发非白金成就后调用它,再补发元成就。
- recount 同时核对已解锁数与元成就漏发,--apply 先改计数再补发,复核同一份口径。

用 09-14 生产备份实跑:recount 订正 269 人、补发 10 条,复核通过、重跑无差异,
其余指标 0 行被动;模拟调低阈值补发 1504 条,「奖杯收藏家」随之 65 → 95,
与预先算出的跨线人数一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:38 -06:00
f6995c841b ops(成就): 加 fix-achievement-hours,订正时区丢失期间误发的「夜猫子」「早起的鸟儿」
OJ2 上线到时区修复之间,成就的小时键按 UTC 判定:UTC 的 0–5 点 / 5–7 点是北京
的上午 9–13 点 / 下午 1–3 点,上课时间的提交被记成熬夜和早起。Django 时代的存量
本来就是东八区口径(已用生产备份做判别性核对),出问题的只有这两周的增量。

脚本按东八区重算两个小时指标(只合并这两个键,不整体覆盖 metrics)→ 撤回不达标
的 → 补发达标却没发的 → 同步 unlock_count → 校正已解锁数与「奖杯收藏家」连锁。
默认只读预演,--apply 落库后自动复核,幂等。

用 db_backup_2026_09_14_18_17_37.sql 实跑:修正 148 行、撤回 60 条(47 + 13,
50 人)、补发 0、连锁 0,其余指标 0 行被动。必须先部署时区修复再跑。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:27 -06:00
4c0c38445c 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
2026-09-14 05:55:17 -06:00
28 changed files with 871 additions and 118 deletions

View File

@@ -199,6 +199,101 @@ 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 里要按日历切,用 `localTime(列)`(生成
`列 at time zone 'Asia/Shanghai'`),别依赖数据库会话时区。
旧栈 Django 是 `TIME_ZONE = "Asia/Shanghai"` + `USE_TZ = True`:库里存 UTC、
应用层按北京时间算日历。重写时这个锚点丢了,直到 2026-09 才收回来 —— 期间
「今日提交」在北京时间 0:008:00 是空的两个成就「凌晨提交次数」0:005:00、
「早起提交次数」5:007:00整体偏 8 小时。别再把口径散出去。
时区常量 `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 用 `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 处),对接外部系统时对方
得解析两套,而偏移还取决于服务器会话时区、不该进契约。
⚠️ **微秒不能丢**,别改回 `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()` / `zonedParts()` 按同一个固定偏移取东八区部件(见
`apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse
`normalizeDate`)实测都能吃 ISO改动出参格式不需要动前端。
### 存量成就的口径是东八区,别再退回 UTC
2026-09-14 用生产备份 `db_backup_2026_09_08_19_22_11.sql` 实测过,结论和直觉相反,
记在这里省得下次重新推。下面「那两周留下的实际后果」和脚本的跑数,又用
`db_backup_2026_09_14_18_17_37.sql`(最新提交到北京时间 9-14 17:06逐项复核过一致。
**历史指标本来就是北京时间。** 旧栈 `OnlineJudge/achievement/metrics.py` 全程用
`timezone.localtime(...)`,而 `settings.TIME_ZONE = "Asia/Shanghai"`,所以
2022-04 到 OJ2 上线之间那 10 万多条提交累积出的 `user_stat.metrics` 是**东八区口径**。
判别性核对(只在新旧口径算出不同值的用户里看存量更像哪边):
| 指标 | 两口径不同 | 存量==UTC | 存量==东八区 |
|---|---|---|---|
| `midnight_submissions` | 1259 | 132 | 1123 |
| `early_bird_submissions` | 909 | 1 | 905 |
| `active_days` | 90 | 0 | 90 |
| `max_ac_in_one_day` | 17 | 0 | 17 |
| `max_ac_streak_days` | 40 | 0 | 40 |
**所以修 OJ2 的时区不是「换口径」,是「把 OJ2 弄丢的口径补回来」。** 别以为改动会让
存量数据失配 —— 失配的是 OJ2 上线后那两周,修完反而对齐了。
**那两周留下的实际后果**(截至 2026-09-14 备份1508 条 OJ2 期提交、约 1500 个已结算用户):
- **日期键没被污染**`_active_dates` / `_ac_per_day` 一个都没偏 —— 上课时间的提交
在 UTC 下日期和北京是同一天。所以 5 个日期口径的成就(活跃天数、单日最多 AC、
连续天数)一条都没错。
- **只有小时键被污染**145 人 `midnight_submissions` 虚高、45 人 `early_bird_submissions`
虚高。因为 UTC 的「凌晨 05 点」正好是北京的上午 913 点,而学生恰恰在上课时间提交。
- **结果是 60 条误发**47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2
时期的新账号(`backfilled = false`)。
- **0 条漏发**而且是结构性的OJ2 窗口那 1508 条提交里,真正落在北京 05 点和
57 点的**都是 0 条** —— 真熬夜、真早起的人都在 Django 时代活跃过了,他们的成就
是当时按东八区正确发的。这个 bug 只会多给,不会少给。
**改数据时最大的坑:不能只删 `user_achievement`。**
`unlockAchievements()` 的判定是**纯阈值比较**`metrics[metric] >= threshold`),不是
「这次有没有跨过阈值」。只删行、不修 `user_stat.metrics` 的话,学生**下一次提交就把
同一个成就原样再发一次**。必须「按东八区重算小时指标」和「对账发放」一起做。
现成的工具是一次性对账脚本 `apps/api/src/scripts/fix-achievement-hours.ts`
`bun run --filter '@oj2/api' fix:achievement-hours`,默认 dry-run`--apply` 才写):
重算两个小时指标 → 撤回不达标的 → 补发达标却没发的 → 同步 `achievement.unlock_count`
→ 校正 `achievement_unlocked_count` 与「奖杯收藏家」连锁。实测幂等2026-09-14 那批
跑出来是「修正 148 行 · 撤回 60 条 · 补发 0 条 · 连锁 0 条」。
⚠️ **顺序:先部署时区修复,再跑这个脚本。** 反过来的话,旧代码还在按 UTC 累加,
跑完马上又被写脏、成就又发回来。
**下次再动日历口径,照这套方法核实**:从生产备份里捞出 `submission` / `user_stat` /
`user_achievement` / `achievement` 四张表回放一遍,先用与时间无关的指标
`submission_count` / `accepted_count`)校准重放器(实测逐人 0 差异 / 4 人差异),
再比受影响的指标。别靠推理 —— 这次推理就得出过相反的结论。
## 数据库
Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表

View File

@@ -12,6 +12,7 @@
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
"seed:dev": "bun src/scripts/seed-dev.ts",
"backfill:problemsets": "bun src/main.ts backfill-problemsets",
"fix:achievement-hours": "bun src/main.ts fix-achievement-hours",
"typecheck": "tsc --noEmit",
"check:routes": "bun src/scripts/check-route-shadowing.ts",
"check:ast": "bun src/scripts/check-ast-targets.ts",

View File

@@ -5,7 +5,30 @@ import * as schema from "./schema"
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
// 不设会话时区:日历语义的 SQL 一律显式 `at time zone``../time` 的 localTime
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉dev 上又是另一个答案。
const client = postgres(url)
export const db = drizzle(client, { schema })
/**
* 读出来的时刻统一成 ISO 8601 UTC和写侧的 `new Date().toISOString()` 同形状。
*
* drizzle 的 `construct()``drizzle-orm/postgres-js/driver.js`)把 1184(timestamptz) 等
* OID 的 parser 换成了恒等函数,不处理的话读出来是 PG 文本(`2026-09-14 20:00:00+08`
* 接口上同一个字段就有两种形状。所以**必须在 `drizzle(client)` 之后**覆盖回来。
*
* - **只换 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]
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

@@ -13,7 +13,8 @@
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列重算回与 submission 一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
* oj2-api fix-achievement-hours # 订正「夜猫子」「早起的鸟儿」的历史误发,默认只读预演
*
* 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -49,6 +50,11 @@ switch (command) {
const { recount } = await import("./scripts/recount")
process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") }))
}
// 同上。OJ2 上线时区丢了两周,两个小时口径的成就按 UTC 误发了一批,拿它订正。
case "fix-achievement-hours": {
const { fixAchievementHours } = await import("./scripts/fix-achievement-hours")
process.exit(await fixAchievementHours({ apply: process.argv.slice(3).includes("--apply") }))
}
case "sql-child": {
const { runSqlChild } = await import("./judge/sql/child")
await runSqlChild()
@@ -69,6 +75,6 @@ switch (command) {
}
}
default:
console.error(`未知子命令:${command}\n可用serve | worker | migrate | backfill-problemsets | recount | healthcheck | sql-child`)
console.error(`未知子命令:${command}\n可用serve | worker | migrate | backfill-problemsets | recount | fix-achievement-hours | healthcheck | sql-child`)
process.exit(2)
}

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 { localTime, localYear } 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,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
const year = sql<number>`extract(year from ${schema.submission.createTime})`.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,
@@ -241,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})`, sinceYear),
lte(sql`extract(year from ${schema.submission.createTime})`, 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))

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,
localTime,
localWeekday,
shiftMonthsByCalendar,
} from "../time"
import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, rounded } from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
@@ -46,22 +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 才对得上,哪天给容器设了 TZ 热力图就整体错一格。
*/
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"
const percentile = (rank - 1) / count * 100
@@ -176,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 ${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 ${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),
@@ -278,19 +269,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 +366,25 @@ 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
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(${localTime(schema.submission.createTime)})::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 零点,前端按东八区只取年月日部件
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,12 +102,6 @@ export function publicTemplates(value: unknown) {
return templates
}
export function todayStart() {
const now = new Date()
now.setHours(0, 0, 0, 0)
return now.toISOString()
}
export function rounded(value: number, digits = 2) {
const factor = 10 ** digits
return Math.round(value * factor) / factor

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 { localTime, shiftMonthsByCalendar, todayStart } from "../time"
import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
export const problemRoutes = new Hono<AppEnv>()
@@ -178,11 +179,12 @@ 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)
// 「近两年」按东八区日历算到当天零点
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.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
@@ -214,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(),

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

@@ -0,0 +1,337 @@
import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { localTime, TIME_ZONE } from "../time"
/**
* 一次性数据对账:把「夜猫子」「早起的鸟儿」的历史发放与真实提交时间对齐。
*
* ## 背景
*
* 2026-08-26 OJ2 上线到 2026-09-14 修好时区之间,`services/achievements.ts` 用
* `new Date(createTime).getHours()` 判定,而容器是 UTC —— 于是 **UTC 的「凌晨 05 点」
* 其实是北京时间的上午 913 点**,学生在上课时间提交被记成熬夜;
* 「57 点」对应北京下午 13 点,被记成早起。
*
* 旧 Django 栈用的是 `timezone.localtime()``TIME_ZONE = "Asia/Shanghai"`
* 所以 20222026.08 的历史数据**本来就是东八区口径、本来就是对的**。
* 出问题的只有 OJ2 那两周的增量。
*
* ## 为什么不能只删 `user_achievement`
*
* `unlockAchievements()` 的判定是**纯粹的阈值比较**`metrics[metric] >= threshold`
* 不是「这次有没有跨过阈值」。所以只要 `user_stat.metrics.midnight_submissions`
* 还留着虚高的 13把成就行删掉之后学生**下一次提交就会原样再发一次**。
*
* 必须一起做:按东八区重算小时指标写回 user_stat再对账发放。
*
* ## 两个方向
*
* - **撤回**:重算后不达标却持有 → 删行 + `achievement.unlock_count` 下调。
* - **补发**:重算后达标却没持有 → 插行(`backfilled = true`,前端显示「已获得」
* 而不编一个解锁时间)+ 计数上调。
*
* 2026-09-14 的实测结论:**只有撤回60 条),补发 0 条** —— 原因是 OJ2 窗口内
* 1508 条提交里,真正落在北京 05 点和 57 点的**都是 0 条**(真熬夜的人都在
* Django 时代活跃过了)。补发分支留着是为了对称和以后重跑,不是为了现在有用。
*
* ## 跑法(对齐 migrate / recount跟着二进制走生产镜像里没有 bun 也没有源码)
*
* # 本机开发
* bun apps/api/src/main.ts fix-achievement-hours
* bun apps/api/src/main.ts fix-achievement-hours --apply
*
* # 服务器 / 机房docker/ 目录下,或照 deploy.sh 用 -f 指定)
* docker compose -f docker/compose.debian.yml --env-file docker/.env \
* run --rm oj-api oj2-api fix-achievement-hours
* # 确认无误后再加 --apply
*
* ⚠️ **必须先部署时区修复,再跑这个脚本。** 反过来的话旧代码还在按 UTC 累加,
* 跑完马上又被写脏、成就又发回来。
*
* ⚠️ **--apply 要挑没人做题的时候跑。** 差异在事务外算、事务内写;算完到写完之间
* 要是学生交了一发并判完,那一笔的小时计数会被覆盖。落库后的复核会报「仍有 N 处差异」
* 并以 1 退出,不会静默 —— 见到了重跑一次即可。
*
* 写回时**只动两个小时键**jsonb `||` 合并),不整体覆盖 `metrics`:整体写回的是事务外
* 读的快照,并发时会连带把 `submission_count` / `_last_ac_date` 等一起写旧,而复核只比
* 小时指标,那种覆盖是查不出来的。
*
* 幂等:干净状态下再跑,报 0 处差异并以 0 退出。
*/
/** 受时区影响的小时口径指标 —— 只有这两个 */
const HOUR_METRICS = ["midnight_submissions", "early_bird_submissions"] as const
interface Target {
id: number
name: string
metric: string
threshold: number
operator: string
}
interface Plan {
metricFixes: Array<{ id: number; userId: number; midnight: number; earlyBird: number }>
revoke: Array<{ userId: number; achievementId: number; name: string; held: number; actual: number }>
grant: Array<{ userId: number; achievementId: number; name: string; actual: number }>
cascade: Array<{ userId: number; achievementId: number; name: string; before: number; after: number }>
targets: Target[]
meta: Target | undefined
touched: number[]
}
/**
* 只读对账:算出「该改的指标 / 该撤的 / 该补的 / 连锁该撤的」。
* 落库之后再调一次就是复核。
*/
async function audit(): Promise<Plan> {
// 用 SQL 一次算完,口径和 apps/api/src/time.ts 完全一致(东八区墙上时钟的钟点)。
// 只统计非比赛提交 —— 和 updateAchievementsForSubmission 的 contestId !== null 提前返回对齐。
const hour = sql`extract(hour from ${localTime(schema.submission.createTime)})`
const recomputed = await db
.select({
userId: schema.submission.userId,
midnight: sql<number>`count(*) filter (where ${hour} < 5)`.mapWith(Number),
earlyBird: sql<number>`count(*) filter (where ${hour} >= 5 and ${hour} < 7)`.mapWith(Number),
})
.from(schema.submission)
.where(isNull(schema.submission.contestId))
.groupBy(schema.submission.userId)
const truth = new Map(recomputed.map((row) => [row.userId, row]))
const actualOf = (userId: number, metric: string) => {
const fresh = truth.get(userId)
return (metric === "midnight_submissions" ? fresh?.midnight : fresh?.earlyBird) ?? 0
}
// 哪些 user_stat 的小时指标要改
const stats = await db.select().from(schema.userStat)
const statByUser = new Map(stats.map((row) => [row.userId, row]))
const metricFixes: Plan["metricFixes"] = []
for (const row of stats) {
const fresh = truth.get(row.userId)
const corrected = { midnight_submissions: fresh?.midnight ?? 0, early_bird_submissions: fresh?.earlyBird ?? 0 }
const changed = HOUR_METRICS.filter((key) => (row.metrics as Record<string, unknown>)[key] !== corrected[key])
if (changed.length) {
metricFixes.push({ id: row.id, userId: row.userId, midnight: corrected.midnight_submissions, earlyBird: corrected.early_bird_submissions })
}
}
const targets = await db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
metric: schema.achievement.metric,
threshold: schema.achievement.threshold,
operator: schema.achievement.operator,
})
.from(schema.achievement)
.where(and(eq(schema.achievement.visible, true), inArray(schema.achievement.metric, [...HOUR_METRICS])))
const targetById = new Map(targets.map((item) => [item.id, item]))
const met = (target: Target, userId: number) => {
const value = actualOf(userId, target.metric)
return target.operator === "gte" ? value >= target.threshold : value <= target.threshold
}
const grants = await db
.select({ achievementId: schema.userAchievement.achievementId, userId: schema.userAchievement.userId })
.from(schema.userAchievement)
.where(inArray(schema.userAchievement.achievementId, targets.map((item) => item.id)))
const heldByUser = new Map<number, Set<number>>()
for (const grant of grants) {
heldByUser.set(grant.userId, (heldByUser.get(grant.userId) ?? new Set()).add(grant.achievementId))
}
const revoke: Plan["revoke"] = []
for (const grant of grants) {
const target = targetById.get(grant.achievementId)
if (!target || met(target, grant.userId)) continue
revoke.push({
userId: grant.userId,
achievementId: grant.achievementId,
name: target.name,
held: Number((statByUser.get(grant.userId)?.metrics as Record<string, unknown> | undefined)?.[target.metric] ?? 0),
actual: actualOf(grant.userId, target.metric),
})
}
// 补发只针对「被成就系统结算过」的用户(有 user_stat 行)—— 从没结算过的是另一件事
// (备份里还有 31 个有提交却没有 user_stat 行的用户),不在这个脚本的职责内。
const grant: Plan["grant"] = []
for (const target of targets) {
for (const userId of truth.keys()) {
if (!statByUser.has(userId)) continue
if (heldByUser.get(userId)?.has(target.id)) continue
if (!met(target, userId)) continue
grant.push({ userId, achievementId: target.id, name: target.name, actual: actualOf(userId, target.metric) })
}
}
// 连锁:`achievement_unlocked_count` = 「已解锁的非白金成就数」,是 奖杯收藏家 的判据。
// 撤回会让它降、补发会让它升;降破了阈值的 奖杯收藏家 要一起撤。
const touched = [...new Set([...revoke.map((item) => item.userId), ...grant.map((item) => item.userId)])]
const meta = (
await db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
metric: schema.achievement.metric,
threshold: schema.achievement.threshold,
operator: schema.achievement.operator,
})
.from(schema.achievement)
.where(and(eq(schema.achievement.visible, true), eq(schema.achievement.metric, "achievement_unlocked_count")))
)[0]
const cascade: Plan["cascade"] = []
if (meta && touched.length) {
const unlocked = await db
.select({
userId: schema.userAchievement.userId,
achievementId: schema.userAchievement.achievementId,
rarity: schema.achievement.rarity,
})
.from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
const held = new Map<number, Array<{ achievementId: number; rarity: string }>>()
for (const row of unlocked) {
const list = held.get(row.userId) ?? []
list.push({ achievementId: row.achievementId, rarity: row.rarity })
held.set(row.userId, list)
}
for (const userId of touched) {
const mine = held.get(userId) ?? []
const gone = new Set(revoke.filter((item) => item.userId === userId).map((item) => item.achievementId))
const coming = new Set(grant.filter((item) => item.userId === userId).map((item) => item.achievementId))
const before = mine.filter((item) => item.rarity !== "platinum").length
const after = mine.filter((item) => item.rarity !== "platinum" && !gone.has(item.achievementId)).length + coming.size
if (before === after) continue
const holdsMeta = mine.some((item) => item.achievementId === meta.id)
const ok = meta.operator === "gte" ? after >= meta.threshold : after <= meta.threshold
if (holdsMeta && !ok) cascade.push({ userId, achievementId: meta.id, name: meta.name, before, after })
}
}
return { metricFixes, revoke, grant, cascade, targets, meta, touched }
}
function report(plan: Plan) {
console.log(`① 需要修正小时指标的 user_stat 行:${plan.metricFixes.length}`)
console.log(`② 需要【撤回】的发放:${plan.revoke.length} 条,涉及 ${new Set(plan.revoke.map((r) => r.userId)).size}`)
for (const target of plan.targets) {
console.log(` - ${target.name}(阈值 ${target.operator} ${target.threshold}${plan.revoke.filter((r) => r.achievementId === target.id).length}`)
}
console.log(`③ 需要【补发】的发放:${plan.grant.length} 条,涉及 ${new Set(plan.grant.map((r) => r.userId)).size}`)
for (const target of plan.targets) {
console.log(` - ${target.name}${plan.grant.filter((r) => r.achievementId === target.id).length}`)
}
console.log(`④ 连带需要撤销的「${plan.meta?.name ?? "(未配置)"}」:${plan.cascade.length}`)
if (plan.revoke.length <= 40) {
for (const item of plan.revoke) console.log(` [撤] user ${item.userId} ${item.name} 存量 ${item.held} → 实际 ${item.actual}`)
}
if (plan.grant.length <= 40) {
for (const item of plan.grant) console.log(` [补] user ${item.userId} ${item.name} 实际 ${item.actual}`)
}
for (const item of plan.cascade) console.log(` [撤] user ${item.userId} ${item.name} 已解锁数 ${item.before}${item.after}`)
}
export async function fixAchievementHours({ apply }: { apply: boolean }): Promise<number> {
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
const database = (() => {
try {
const parsed = new URL(url)
return `${parsed.host}${parsed.pathname}`
} catch {
return "(无法解析)"
}
})()
console.log(`目标库:${database}`)
console.log(`模式:${apply ? "【真写】" : "只读预演(不改任何数据)"}`)
console.log(`时区锚点:${TIME_ZONE}\n`)
const plan = await audit()
report(plan)
if (!plan.metricFixes.length && !plan.revoke.length && !plan.grant.length && !plan.cascade.length) {
console.log("\n复核通过发放与真实提交时间一致无需修改。")
return 0
}
if (!apply) {
console.log("\n只读预演结束。确认无误后加 --apply。")
return 0
}
const { metricFixes, revoke, grant, cascade, targets, meta, touched } = plan
await db.transaction(async (tx) => {
for (const fix of metricFixes) {
// 只合并两个小时键,其余指标以库里当下的值为准(见文件头「只动两个小时键」)
await tx
.update(schema.userStat)
.set({
metrics: sql`${schema.userStat.metrics} || jsonb_build_object('midnight_submissions', ${fix.midnight}::int, 'early_bird_submissions', ${fix.earlyBird}::int)`,
})
.where(eq(schema.userStat.id, fix.id))
}
for (const target of targets) {
const ids = revoke.filter((item) => item.achievementId === target.id).map((item) => item.userId)
if (ids.length) {
await tx
.delete(schema.userAchievement)
.where(and(eq(schema.userAchievement.achievementId, target.id), inArray(schema.userAchievement.userId, ids)))
await tx
.update(schema.achievement)
.set({ unlockCount: sql`greatest(${schema.achievement.unlockCount} - ${ids.length}, 0)` })
.where(eq(schema.achievement.id, target.id))
}
// backfilled = true这是事后对账补的不编一个假的解锁时间前端因此显示「已获得」
const rows = grant
.filter((item) => item.achievementId === target.id)
.map((item) => ({ userId: item.userId, achievementId: target.id, unlockTime: new Date().toISOString(), backfilled: true, notified: false }))
if (rows.length) {
await tx.insert(schema.userAchievement).values(rows)
await tx
.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + ${rows.length}` })
.where(eq(schema.achievement.id, target.id))
}
}
for (const item of cascade) {
await tx
.delete(schema.userAchievement)
.where(and(eq(schema.userAchievement.achievementId, item.achievementId), eq(schema.userAchievement.userId, item.userId)))
await tx
.update(schema.achievement)
.set({ unlockCount: sql`greatest(${schema.achievement.unlockCount} - 1, 0)` })
.where(eq(schema.achievement.id, item.achievementId))
}
// 最后把 achievement_unlocked_count 校正成「对账之后实际持有的非白金数」
if (meta && touched.length) {
const fresh = await tx
.select({ userId: schema.userAchievement.userId, value: sql<number>`count(*)`.mapWith(Number) })
.from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(inArray(schema.userAchievement.userId, touched), ne(schema.achievement.rarity, "platinum")))
.groupBy(schema.userAchievement.userId)
const byUser = new Map(fresh.map((row) => [row.userId, row.value]))
for (const userId of touched) {
await tx
.update(schema.userStat)
.set({
metrics: sql`jsonb_set(${schema.userStat.metrics}, '{achievement_unlocked_count}', ${JSON.stringify(byUser.get(userId) ?? 0)}::jsonb)`,
})
.where(eq(schema.userStat.userId, userId))
}
}
})
console.log(`\n已订正指标 ${metricFixes.length} 行、撤回 ${revoke.length} 条、补发 ${grant.length} 条、连锁撤销 ${cascade.length} 条,复核中……`)
// 复核跑的是同一份 audit。还剩差异说明口径或并发写入了必须让调用方看见非零退出码。
const after = await audit()
const left = after.metricFixes.length + after.revoke.length + after.grant.length + after.cascade.length
if (left === 0) {
console.log("复核通过:发放与真实提交时间一致。")
return 0
}
console.error(`复核未通过,仍有 ${left} 处差异(多半是算完到写完之间有新提交判完,重跑一次即可):`)
report(after)
return 1
}

View File

@@ -3,6 +3,7 @@ import { eq, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus, isAccepted } from "../judge/status"
import { objectValue } from "../routes/helpers"
import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../services/achievements"
/**
* 把反范式的计数列重算回与 submission 表一致。
@@ -16,6 +17,11 @@ import { objectValue } from "../routes/helpers"
* problem.submission_number / accepted_number / statistic_info
* user_profile.submission_number / accepted_number / acm_problems_status
*
* 外加 `user_stat.metrics.achievement_unlocked_count`(已解锁的非白金成就数,是
* user_achievement 的副本)以及它连带的「奖杯收藏家」:计数改对之后,达标却没发的
* 走 `rescanAchievement` 补发backfilled、推通知。已知漂移来源是后台补发成就 ——
* 2026-09-07 一次补发后 269 人少算、10 人漏发,`rescanAchievement` 已修,这里订存量。
*
* **不管**的acm_contest_rank比赛榜有自己的一套罚时累计重算要连带 submission_info
* 里每题的尝试次数口径复杂单独一件事、achievement.unlock_count0010 之后
* user_achievement 随成就级联,漂不了)、题单进度与奖章(走 backfill-problemsets
@@ -151,6 +157,51 @@ type Plan = {
diffs: Diff[]
problemFixes: { id: number; value: ProblemExpected }[]
profileFixes: { id: number; value: ProfileExpected & { merged: Record<string, unknown> } }[]
/** achievement_unlocked_count 不对的用户 */
unlockedCountFixes: number[]
/** 按正确计数已达标、却没持有元成就的 (用户, 元成就) */
metaGrants: { userId: number; achievementId: number }[]
}
/**
* 已解锁数与元成就的差异。口径和 `refreshUnlockedCount` / 判题结算一致;
* 元成就只看有 user_stat 的用户 —— `rescanAchievement` 也只扫这些人。
*/
async function unlockedCountPlan(plan: Plan) {
const [rows, metas] = await Promise.all([
db.execute<{ user_id: number; counter: unknown; actual: number }>(sql`
select s.user_id, s.metrics -> 'achievement_unlocked_count' as counter, coalesce(c.value, 0) as actual
from user_stat s
left join (
select ua.user_id, count(*)::int as value
from user_achievement ua
join achievement a on a.id = ua.achievement_id
where a.rarity <> 'platinum'
group by ua.user_id
) c on c.user_id = s.user_id
`),
metaAchievements(),
])
const holders = metas.length
? await db.select({ userId: schema.userAchievement.userId, achievementId: schema.userAchievement.achievementId })
.from(schema.userAchievement)
.where(sql`${schema.userAchievement.achievementId} in ${metas.map((meta) => meta.id)}`)
: []
const held = new Set(holders.map((row) => `${row.userId}:${row.achievementId}`))
for (const row of rows) {
const label = `用户 ${row.user_id}`
if (row.counter !== row.actual) {
plan.diffs.push({ label, field: "achievement_unlocked_count", before: row.counter ?? null, after: row.actual })
plan.unlockedCountFixes.push(row.user_id)
}
for (const meta of metas) {
const met = meta.operator === "gte" ? row.actual >= meta.threshold : row.actual <= meta.threshold
if (!met || held.has(`${row.user_id}:${meta.id}`)) continue
plan.diffs.push({ label, field: `成就「${meta.name}`, before: "未发", after: "补发" })
plan.metaGrants.push({ userId: row.user_id, achievementId: meta.id })
}
}
}
/** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */
@@ -174,7 +225,7 @@ async function computePlan(): Promise<Plan> {
expectedProfiles(),
])
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [] }
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [], unlockedCountFixes: [], metaGrants: [] }
for (const problem of problems) {
const want = expectedProblem.get(problem.id) ?? {
@@ -230,11 +281,12 @@ async function computePlan(): Promise<Plan> {
plan.profileFixes.push({ id: profile.id, value: { ...want, merged } })
}
}
await unlockedCountPlan(plan)
return plan
}
function report(plan: Plan) {
console.log(`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} 人):`)
console.log(`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} / 已解锁数 ${plan.unlockedCountFixes.length} 人 / 元成就补发 ${plan.metaGrants.length}`)
for (const diff of plan.diffs.slice(0, 40)) {
console.log(` ${diff.label} ${diff.field}: ${JSON.stringify(diff.before)}${JSON.stringify(diff.after)}`)
}
@@ -245,7 +297,7 @@ function report(plan: Plan) {
export async function recount(options: { apply: boolean }) {
const plan = await computePlan()
if (plan.diffs.length === 0) {
console.log("计数列与 submission 一致,没有要订正的。")
console.log("计数列与 submission / user_achievement 一致,没有要订正的。")
return 0
}
report(plan)
@@ -271,13 +323,19 @@ export async function recount(options: { apply: boolean }) {
}).where(eq(schema.userProfile.id, fix.id))
}
})
console.log(`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人,复核中……`)
// 先改计数、再补发rescanAchievement 读的是 metrics 里的计数。
// 补发幂等(唯一键 + 冲突忽略),重跑不会重复发
const recounted = await refreshUnlockedCount(plan.unlockedCountFixes)
if (plan.metaGrants.length) {
for (const meta of await metaAchievements()) await rescanAchievement(meta.id)
}
console.log(`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人、已解锁数 ${recounted.length} 人,补发元成就 ${plan.metaGrants.length} 条,复核中……`)
// 复核跑的是同一份 computePlan。这里还剩差异说明口径本身有问题不是数据脏
// 必须让部署脚本看见非零退出码,而不是打一行字了事。
const after = await computePlan()
if (after.diffs.length === 0) {
console.log("复核通过:计数列与 submission 一致")
console.log("复核通过:计数列与 submission / user_achievement 一致")
return 0
}
console.error(`复核未通过,仍有 ${after.diffs.length} 处差异:`)

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
@@ -232,10 +227,57 @@ export async function rescanAchievement(achievementId: number) {
kind: "achievement",
}])
}
// 补发的非白金成就同样计入「已解锁数」,要和判题结算一样接着做第二轮(元成就)判定。
// 旧 `rescan_achievement` 就漏了这步OJ2 原样搬过来2026-09-07 一次补发之后
// 269 人的计数停在旧值,其中 10 人实际够了「奖杯收藏家」却一直没发 ——
// 判题结算只在「这次有新解锁」时才重算,被补发的人不再解锁新成就就永远不会自愈。
if (achievement.rarity !== "platinum" && achievement.metric !== "achievement_unlocked_count") {
await refreshUnlockedCount(unlockedUserIds)
for (const meta of await metaAchievements()) await rescanAchievement(meta.id)
}
}
return { scanned: stats.length, unlocked: unlockedUserIds.length }
}
/** 以「已解锁数」为指标的元成就(奖杯收藏家)。只取上架的,和 rescan 的口径一致 */
export function metaAchievements() {
return db.select({ id: schema.achievement.id, name: schema.achievement.name, threshold: schema.achievement.threshold, operator: schema.achievement.operator })
.from(schema.achievement)
.where(and(eq(schema.achievement.visible, true), eq(schema.achievement.metric, "achievement_unlocked_count")))
}
/**
* 按 `user_achievement` 重算 `achievement_unlocked_count`,返回实际改动了的用户 id。
*
* 口径和判题结算一致:已解锁的**非白金**成就数。只 `jsonb_set` 这一个键、只写值变了的行,
* 不整体覆盖 `metrics` —— 整体写回会和并发判题写的其它指标互相踩。
* 不传 `userIds` 就是全体有 `user_stat` 的用户(`recount` 存量订正用)。
*/
export async function refreshUnlockedCount(userIds?: number[]) {
if (userIds && userIds.length === 0) return []
const scope = userIds ? sql`and s.user_id in ${userIds}` : sql``
const rows = await db.execute<{ user_id: number }>(sql`
update ${schema.userStat} as target
set metrics = jsonb_set(target.metrics, '{achievement_unlocked_count}', to_jsonb(fresh.value))
from (
select s.id, coalesce(c.value, 0) as value
from ${schema.userStat} s
left join (
select ua.user_id, count(*)::int as value
from ${schema.userAchievement} ua
join ${schema.achievement} a on a.id = ua.achievement_id
where a.rarity <> 'platinum'
group by ua.user_id
) c on c.user_id = s.user_id
where true ${scope}
) fresh
where target.id = fresh.id
and (target.metrics -> 'achievement_unlocked_count') is distinct from to_jsonb(fresh.value)
returning target.user_id
`)
return rows.map((row) => row.user_id)
}
/** 同上3 个参数一行 */
const STAT_UPSERT_CHUNK = 1000

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

@@ -0,0 +1,95 @@
import { TIME_ZONE, TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
import { sql, type SQLWrapper } from "drizzle-orm"
/**
* 后端的日历换算全在这里,锚点是契约里的 `TIME_ZONE`(东八区,固定偏移)。
*
* **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,都必须走这里**SQL 里按日历切
* 就用 `localTime()`。不要写 `new Date(x).getHours()` / `setHours(0,0,0,0)` /
* `getFullYear()` / `new Date(y, m, d)` 这类跟**进程时区**走的代码,也不要依赖数据库
* 会话时区容器UTC和开发机给出不同答案而且不报错。
*/
export { TIME_ZONE }
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const DAY_MS = 86_400_000
/**
* 真实时刻 → 「东八区墙上时钟」。平移之后 `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 new Date(day * DAY_MS).getUTCDay()
}
/** 「东八区今天」的零点,返回 ISO 字符串。提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它 */
export function todayStart(now: Date | number | string = new Date()): string {
return new Date(dayNumber(calendarDay(now)) * DAY_MS - OFFSET_MS).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
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 字面量,**不走参数绑定**:同一个表达式在 select 和 group by 里
* 各出现一次绑定成参数会拿到两个不同的占位符PG 就不认为它们是同一个表达式,报
* 「must appear in the GROUP BY clause」。常量拼接没有注入面。
*/
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})`
}

View File

@@ -127,11 +127,30 @@ return contract("GET /problems/:id", problemDetailSchema, value)
一样会渲染错。收紧任何字段之前,拿根目录那份生产备份把全量数据跑一遍,
尤其要看**空值**而不只是键集合。
### 时间一律按东八区展示,不跟浏览器走
**显示时间走 `utils/functions.ts``parseTime()`;要日历部件走 `zonedParts()` /
`zonedYear()`。** 不要在组件里写 `new Date(x).getFullYear()` / `getMonth()` /
`getDate()` / `toLocaleDateString()` / `toLocaleTimeString()` —— 那些取的是
**浏览器本地**时区。机房电脑、学生手机平时都在东八区所以看不出来,但只要有人
(比如时区没设对的机房机器、或在外地的老师)从别的时区打开,同一张提交记录表就会
显示成另一个时间,和榜单、统计、成就里的日期对不上。
时区常量在契约 `@oj2/contract``TIME_ZONE_OFFSET_MINUTES`,和后端 `time.ts` 共用。
实现是「平移固定偏移 + 读 `getUTC*`」,不用 `Intl` 的时区选项:东八区没有夏令时,
纯算术在表格里逐格调用也不费事,老 Chrome 上结果也一致。
**`n-date-picker` 要平移**`admin/contest/detail.vue``admin/problemset/edit.vue`
Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属性,所以绑定值走
`toPickerValue()`,取回来走 `fromPickerValue()`。显示时间不要用这对函数。
### 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(currentYear - 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,19 @@ const getColor = (count: number) => {
}
// 一格一周横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交,
// 365 格里三百多格空着,整张图看着像没用过
// 365 格里三百多格空着,整张图看着像没用过
//
// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,年月日按东八区取(`zonedParts`
// 不用浏览器本地的 `getMonth()` / `getDate()`。东八区没有夏令时,周日就是加 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 +118,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 +130,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

@@ -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"
@@ -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,75 @@ export function durationFromValue(
return { [unit]: count } as Duration
}
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const pad2 = (value: number) => String(value).padStart(2, "0")
/**
* 取一个时刻在东八区的年月日时分秒(数字)。无效日期返回 null。
*
* 站内所有时间一律按**东八区**展示、不跟浏览器时区走,口径是契约里的
* `TIME_ZONE`(和后端 `apps/api/src/time.ts` 同一个常量)。平移固定偏移后读 `getUTC*`
* 就是北京的墙上时间。**不要在组件里写 `getFullYear()` / `getMonth()` / `getDate()`**
* —— 那是浏览器本地部件。
*/
export function zonedParts(value: Date | string) {
const time = normalizeDate(value).getTime()
if (Number.isNaN(time)) return null
const wall = new Date(time + OFFSET_MS)
return {
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() {
return zonedParts(new Date())!.year
}
/**
* 按东八区格式化。格式串只认下面这几个 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 / 存库
*
* ⚠️ **只有 `n-date-picker` 需要这一对。** 显示时间用 `parseTime`,别把平移过的值喂给它。
*/
export function toPickerValue(instant: number) {
return instant + OFFSET_MS + new Date(instant).getTimezoneOffset() * 60_000
}
export function fromPickerValue(value: number) {
return value - OFFSET_MS - new Date(value).getTimezoneOffset() * 60_000
}
function getDurationObject(start: Date | string, end: Date | string) {

View File

@@ -131,7 +131,8 @@ export const classPkAnalysisRequestSchema = z.object({
})
/**
* 热力图的一格 = **一周**不是一天。timestamp 是那一周周一的本地零点,
* 热力图的一格 = **一周**不是一天。timestamp 是那一周(按东八区日历)周一的 UTC 零点,
* 前端按东八区取年月日(`zonedParts`),不要用浏览器本地部件。
* value 是整周的提交次数。按天切的话一年 365 格里三百多格是空的,
* 中职学生一年也就在二三十天有提交,整张图看着像没用过。
*/

View File

@@ -14,3 +14,4 @@ export * from "./problemset"
export * from "./roles"
export * from "./site"
export * from "./submission"
export * from "./time"

View File

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