Some checks failed
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies
出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:
- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
problem;列表为空时才碰巧不炸,所以一直没人报。
两个都是读出侧校验自己造出来的故障,不是它拦住的故障。
## 校验责任挪回写入侧
- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
.language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
.sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
报文是真边界,失败返回 null 而不是 500。
顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。
## 文档
CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。
## 验证
- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
(error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
problemDisplayId 收到 undefined,改动后同样两个响应均通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
344 lines
16 KiB
TypeScript
344 lines
16 KiB
TypeScript
import { randomBytes } from "node:crypto"
|
||
import { extname, resolve } from "node:path"
|
||
|
||
import {
|
||
rankProfileSchema,
|
||
registerRequestSchema,
|
||
STUDENT_ROLES,
|
||
updateProfileRequestSchema,
|
||
type ActivityRankItem,
|
||
type Metrics,
|
||
type MyRank,
|
||
type ProblemRank,
|
||
type RankProfile,
|
||
type UserRank,
|
||
} from "@oj2/contract"
|
||
import {
|
||
and,
|
||
asc,
|
||
count,
|
||
countDistinct,
|
||
desc,
|
||
eq,
|
||
gt,
|
||
gte,
|
||
inArray,
|
||
isNull,
|
||
lt,
|
||
lte,
|
||
min,
|
||
ne,
|
||
or,
|
||
sql,
|
||
} from "drizzle-orm"
|
||
import { Hono } from "hono"
|
||
|
||
import { hashPassword } from "../auth/password"
|
||
import { onlineUserIds } from "../auth/presence"
|
||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||
import { config } from "../config"
|
||
import { db, schema } from "../db"
|
||
import { failure, success } from "../http"
|
||
import { JudgeStatus } from "../judge/status"
|
||
import { getBooleanOption } from "../services/options"
|
||
import { getUserProfileById } from "../services/profile"
|
||
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers"
|
||
|
||
export const accountRoutes = new Hono<AppEnv>()
|
||
|
||
accountRoutes.post("/users", async (c) => {
|
||
const parsed = registerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||
if (!(await getBooleanOption("allow_register", true))) {
|
||
return failure(c, 403, "registration-disabled", "Register function has been disabled by admin")
|
||
}
|
||
|
||
const username = parsed.data.username.toLowerCase()
|
||
const email = parsed.data.email.toLowerCase()
|
||
const [duplicate] = await db
|
||
.select({ username: schema.user.username, email: schema.user.email })
|
||
.from(schema.user)
|
||
.where(or(sql`lower(${schema.user.username}) = ${username}`, sql`lower(${schema.user.email}) = ${email}`))
|
||
.limit(1)
|
||
if (duplicate?.username.toLowerCase() === username) {
|
||
return failure(c, 409, "username-exists", "Username already exists")
|
||
}
|
||
if (duplicate?.email?.toLowerCase() === email) {
|
||
return failure(c, 409, "email-exists", "Email already exists")
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
const password = await hashPassword(parsed.data.password)
|
||
await db.transaction(async (tx) => {
|
||
const [created] = await tx.insert(schema.user).values({
|
||
username,
|
||
email,
|
||
password,
|
||
rawPassword: parsed.data.password.slice(0, 20),
|
||
lastLogin: null,
|
||
createTime: now,
|
||
adminType: "Regular User",
|
||
isDisabled: false,
|
||
problemPermission: "None",
|
||
className: null,
|
||
}).returning({ id: schema.user.id })
|
||
if (!created) throw new Error("User insert did not return an id")
|
||
await tx.insert(schema.userProfile).values({
|
||
userId: created.id,
|
||
acmProblemsStatus: {},
|
||
avatar: `${config.avatarUriPrefix}/default.png`,
|
||
mood: null,
|
||
acceptedNumber: 0,
|
||
submissionNumber: 0,
|
||
realName: null,
|
||
})
|
||
})
|
||
return success(c, { ok: true }, 201)
|
||
})
|
||
|
||
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
|
||
// 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行:
|
||
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
|
||
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
|
||
if (!c.get("user")) return success(c, null)
|
||
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
|
||
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`, eq(schema.user.isDisabled, false))).limit(1)
|
||
if (!target) return failure(c, 404, "user-not-found", "User does not exist")
|
||
const profile = await getUserProfileById(target.id, c.get("user")?.id === target.id)
|
||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||
return success(c, profile)
|
||
})
|
||
|
||
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
||
const parsed = updateProfileRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid profile payload")
|
||
const values = Object.fromEntries(
|
||
Object.entries(parsed.data).map(([key, value]) => [key, value === "" ? null : value]),
|
||
)
|
||
await db.update(schema.userProfile).set(values).where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||
const profile = await getUserProfileById(c.get("user")!.id, true)
|
||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||
return success(c, profile)
|
||
})
|
||
|
||
accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
||
const body: Record<string, string | File> = await c.req.parseBody().catch(() => ({}))
|
||
const image = body.image
|
||
if (!(image instanceof File)) return failure(c, 400, "invalid-file", "Invalid file content")
|
||
if (image.size > 2 * 1024 * 1024) return failure(c, 400, "file-too-large", "Picture is too large")
|
||
const extension = extname(image.name).toLowerCase()
|
||
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
|
||
return failure(c, 400, "unsupported-file", "Unsupported file format")
|
||
}
|
||
const filename = `${randomBytes(10).toString("hex")}${extension}`
|
||
const directory = resolve(config.avatarDirectory)
|
||
await Bun.$`mkdir -p ${directory}`.quiet()
|
||
await Bun.write(resolve(directory, filename), image)
|
||
const avatar = `${config.avatarUriPrefix}/${filename}`
|
||
await db.update(schema.userProfile).set({ avatar }).where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||
return success(c, { avatar })
|
||
})
|
||
|
||
accountRoutes.get("/users/:id/metrics", async (c) => {
|
||
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
const [row] = await db.select({ total: count(), first: min(schema.submission.createTime), latest: sql<string>`max(${schema.submission.createTime})` })
|
||
.from(schema.submission)
|
||
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
|
||
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
|
||
return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest } satisfies Metrics)
|
||
})
|
||
|
||
/**
|
||
* 全服榜单的大小。**写死在服务端,不接受调用方传** —— 上限是这个端点的属性,
|
||
* 不是调用方的选择。
|
||
*
|
||
* 之前它是个 `top` 查询参数,三个调用方各传各的(100 / 10 / 0),
|
||
* 而 top 又会覆盖 limit 与 offset,total 却按全量人数算 —— 于是分页器算出几十页、
|
||
* 页页内容相同(36e4ac2)。「全服 Top10」不需要另一个上限,取 limit=10&offset=0 即可;
|
||
* 后台那个「不限量」的用法搬去了 /admin/rankings/users。
|
||
*/
|
||
const LEADERBOARD_SIZE = 100
|
||
|
||
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
|
||
const leaderboardWhere = and(
|
||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||
eq(schema.user.isDisabled, false),
|
||
)
|
||
|
||
/**
|
||
* 榜单排序:AC 多的在前 → 同 AC 时提交少的在前 → 再同就按 id。
|
||
*
|
||
* 第三档不是凑数:前两个键完全相同的学生在真实数据里成片存在(都是 0/0),
|
||
* 没有稳定的兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复或漏掉的人。
|
||
*/
|
||
const leaderboardOrder = [
|
||
desc(schema.userProfile.acceptedNumber),
|
||
asc(schema.userProfile.submissionNumber),
|
||
asc(schema.user.id),
|
||
]
|
||
|
||
accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE })
|
||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||
|
||
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
|
||
// 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去,
|
||
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
|
||
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
|
||
|
||
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline)
|
||
const [totalRow, rows, me, online] = await Promise.all([
|
||
db.select({ value: count() }).from(schema.userProfile)
|
||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(leaderboardWhere).then(([row]) => row),
|
||
pageLimit === 0 ? [] : db
|
||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
||
.limit(pageLimit).offset(offset),
|
||
myLeaderboardRank(c.get("user")?.id),
|
||
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
|
||
])
|
||
|
||
return success(c, {
|
||
results: rows.map((row) => serializeRankRow(row, online)),
|
||
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
|
||
me,
|
||
} satisfies UserRank)
|
||
})
|
||
|
||
function serializeRankRow({ profile, user }: {
|
||
profile: typeof schema.userProfile.$inferSelect
|
||
user: typeof schema.user.$inferSelect
|
||
}, online: Set<number> | null = null) {
|
||
return {
|
||
id: profile.id,
|
||
user: sampleUser(user, profile.realName),
|
||
acceptedNumber: profile.acceptedNumber,
|
||
submissionNumber: profile.submissionNumber,
|
||
mood: profile.mood,
|
||
isOnline: online ? online.has(user.id) : null,
|
||
} satisfies RankProfile
|
||
}
|
||
|
||
/**
|
||
* 「我」的全服名次,登录且身份入榜时才有。
|
||
*
|
||
* 名次 = 排在我前面的人数 + 1,三个排序键**逐级**比较,与列表的 orderBy 逐字对应 ——
|
||
* 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」这种对不上的情况。
|
||
* 三个键全等才算并列,此时名次相同。
|
||
*/
|
||
async function myLeaderboardRank(userId: number | undefined) {
|
||
if (!userId) return null
|
||
const [mine] = await db
|
||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(and(leaderboardWhere, eq(schema.user.id, userId))).limit(1)
|
||
if (!mine) return null
|
||
|
||
const { acceptedNumber, submissionNumber } = mine.profile
|
||
const [ahead] = await db.select({ value: count() }).from(schema.userProfile)
|
||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(and(leaderboardWhere, or(
|
||
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
||
and(
|
||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||
lt(schema.userProfile.submissionNumber, submissionNumber),
|
||
),
|
||
and(
|
||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||
eq(schema.userProfile.submissionNumber, submissionNumber),
|
||
lt(schema.user.id, userId),
|
||
),
|
||
)))
|
||
|
||
return {
|
||
...serializeRankRow(mine),
|
||
rank: (ahead?.value ?? 0) + 1,
|
||
} satisfies MyRank
|
||
}
|
||
|
||
accountRoutes.get("/rankings/activity", async (c) => {
|
||
const start = c.req.query("start")
|
||
if (!start || Number.isNaN(Date.parse(start))) return failure(c, 400, "invalid-start", "start time is required")
|
||
/**
|
||
* 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话,
|
||
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
|
||
* 两个名字同时挂在榜上。同 `/submissions/statistics` 那批。
|
||
*
|
||
* innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。
|
||
*/
|
||
const rows = await db.select({ username: schema.user.username, value: countDistinct(schema.submission.problemId) })
|
||
.from(schema.submission)
|
||
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
|
||
.where(and(
|
||
isNull(schema.submission.contestId),
|
||
gte(schema.submission.createTime, start),
|
||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||
eq(schema.user.isDisabled, false),
|
||
ne(schema.user.adminType, "Super Admin"),
|
||
))
|
||
.groupBy(schema.submission.userId, schema.user.username)
|
||
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
|
||
return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem)))
|
||
})
|
||
|
||
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||
const user = c.get("user")!
|
||
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 accepted = and(eq(schema.submission.problemId, problem.id), inArray(schema.submission.result, [0, 10]))
|
||
const [all] = await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(accepted)
|
||
const className = user.className ?? ""
|
||
const classWhere = className
|
||
? and(accepted, inArray(schema.submission.userId, db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false)))))
|
||
: accepted
|
||
const [classCount] = className
|
||
? await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(classWhere)
|
||
: [{ value: 0 }]
|
||
const [first] = await db.select({ value: min(schema.submission.createTime) }).from(schema.submission)
|
||
.where(and(classWhere, eq(schema.submission.userId, user.id)))
|
||
let rank = -1
|
||
if (first?.value) {
|
||
const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value)))
|
||
rank = rankRow?.value ?? -1
|
||
}
|
||
return success(c, { className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 } satisfies ProblemRank)
|
||
})
|
||
|
||
/**
|
||
* 把 `user_profile.acm_problems_status` 里缓存的题目编号刷成当前值 ——
|
||
* 教师改了题目的 `_id`(后台「修改题目编号」)之后,学生个人主页上的那份缓存会变旧。
|
||
*
|
||
* **目前没有任何前端在调用它**,两代前端都只定义了函数、没有调用点。保留是因为
|
||
* 它是唯一能修这份缓存的入口;要接 UI 的话,从这里开始。
|
||
*
|
||
* 旧后端 `ProfileProblemDisplayIDRefreshAPI` 这段是坏的:它用
|
||
* `dict(zip(ids, display_ids))` 把「dict 键顺序」和「查询返回顺序」硬凑成对,
|
||
* 题目一旦被隐藏或删除,display_ids 就比 ids 短 —— 轻则把编号张冠李戴写进库,
|
||
* 重则 `id_map[k]` KeyError。这里改成按 id 建 Map、查不到就不动。
|
||
*/
|
||
accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) => {
|
||
const user = c.get("user")!
|
||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }).from(schema.userProfile)
|
||
.where(eq(schema.userProfile.userId, user.id)).limit(1)
|
||
const status = objectValue(profile?.value)
|
||
const problems = objectValue(status.problems)
|
||
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
||
if (ids.length > 0) {
|
||
const rows = await db.select({ id: schema.problem.id, displayId: schema.problem.displayId }).from(schema.problem)
|
||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true)))
|
||
const displayIds = new Map(rows.map((row) => [String(row.id), row.displayId]))
|
||
for (const [id, value] of Object.entries(problems)) {
|
||
const item = objectValue(value)
|
||
const displayId = displayIds.get(id)
|
||
if (displayId) item._id = displayId
|
||
problems[id] = item
|
||
}
|
||
status.problems = problems
|
||
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id))
|
||
}
|
||
return success(c, null)
|
||
})
|