Compare commits
4 Commits
6e63866cc9
...
cc51e305cf
| Author | SHA1 | Date | |
|---|---|---|---|
| cc51e305cf | |||
| 3921c496cb | |||
| f6995c841b | |||
| 4c0c38445c |
95
CLAUDE.md
95
CLAUDE.md
@@ -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:00–8:00 是空的,两个成就(「凌晨提交次数」0:00–5:00、
|
||||
「早起提交次数」5:00–7: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 的「凌晨 0–5 点」正好是北京的上午 9–13 点,而学生恰恰在上课时间提交。
|
||||
- **结果是 60 条误发**:47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2
|
||||
时期的新账号(`backfilled = false`)。
|
||||
- **0 条漏发**,而且是结构性的:OJ2 窗口那 1508 条提交里,真正落在北京 0–5 点和
|
||||
5–7 点的**都是 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 建的表
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))]),
|
||||
|
||||
337
apps/api/src/scripts/fix-achievement-hours.ts
Normal file
337
apps/api/src/scripts/fix-achievement-hours.ts
Normal 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 的「凌晨 0–5 点」
|
||||
* 其实是北京时间的上午 9–13 点**,学生在上课时间提交被记成熬夜;
|
||||
* 「5–7 点」对应北京下午 1–3 点,被记成早起。
|
||||
*
|
||||
* 旧 Django 栈用的是 `timezone.localtime()`(`TIME_ZONE = "Asia/Shanghai"`),
|
||||
* 所以 2022–2026.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 条提交里,真正落在北京 0–5 点和 5–7 点的**都是 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
|
||||
}
|
||||
@@ -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_count(0010 之后
|
||||
* 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} 处差异:`)
|
||||
|
||||
@@ -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
95
apps/api/src/time.ts
Normal 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)
|
||||
}
|
||||
|
||||
/** 北京时间的钟点,0–23 */
|
||||
export function localHour(value: Date | number | string = new Date()): number {
|
||||
return toWallClock(value).getUTCHours()
|
||||
}
|
||||
|
||||
/** 北京时间的年份 */
|
||||
export function localYear(value: Date | number | string = new Date()): number {
|
||||
return toWallClock(value).getUTCFullYear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历日序号(1970-01-01 为 0)。
|
||||
*
|
||||
* 「差几天」一律用它算,别拿两个 Date 相减:夏令时地区的相邻两天可能相差
|
||||
* 23 或 25 小时,除 86400000 得到的不是 1,`=== 1` 这种判据会静默失效。
|
||||
*/
|
||||
export function dayNumber(day: string): number {
|
||||
const [year, month, date] = day.split("-").map(Number)
|
||||
return Date.UTC(year!, month! - 1, date!) / DAY_MS
|
||||
}
|
||||
|
||||
/** 日历日序号 → `YYYY-MM-DD` */
|
||||
export function dayText(day: number): string {
|
||||
return new Date(day * DAY_MS).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** 日历日序号是周几,0 = 周日(和 `Date#getDay()` 同一套编号) */
|
||||
export function localWeekday(day: number): number {
|
||||
return 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})`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 "已保存"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -131,7 +131,8 @@ export const classPkAnalysisRequestSchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 热力图的一格 = **一周**(不是一天)。timestamp 是那一周周一的本地零点,
|
||||
* 热力图的一格 = **一周**(不是一天)。timestamp 是那一周(按东八区日历)周一的 UTC 零点,
|
||||
* 前端按东八区取年月日(`zonedParts`),不要用浏览器本地部件。
|
||||
* value 是整周的提交次数。按天切的话一年 365 格里三百多格是空的,
|
||||
* 中职学生一年也就在二三十天有提交,整张图看着像没用过。
|
||||
*/
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./problemset"
|
||||
export * from "./roles"
|
||||
export * from "./site"
|
||||
export * from "./submission"
|
||||
export * from "./time"
|
||||
|
||||
9
packages/contract/src/time.ts
Normal file
9
packages/contract/src/time.ts
Normal 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
|
||||
Reference in New Issue
Block a user