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, type WeeklyRank, type WeeklyRankItem, } from "@oj2/contract" import { and, asc, count, countDistinct, desc, eq, gt, gte, inArray, isNull, lt, lte, max, min, ne, notExists, or, sql, } from "drizzle-orm" import { alias } from "drizzle-orm/pg-core" 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 { localTime, weekStart } from "../time" import { isTeacherOrAbove, objectValue, queryInteger, sampleUser, } from "./helpers" export const accountRoutes = new Hono() 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 = 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({ first: min(schema.submission.createTime), latest: max(schema.submission.createTime), activeDays: countDistinct( sql`date(${localTime(schema.submission.createTime)})`, ), }) .from(schema.submission) .where(eq(schema.submission.userId, userId)) if (!row?.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交") return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest, activeDays: row.activeDays, } 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 | 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, ), ) }) /** * 周榜的榜面大小。**存量榜(`/rankings/users`)解决的是「谁最强」,周榜解决的是 * 「这一周谁在往前走」** —— 后者每周一清零,所以榜面短一点更像「这周的头名」, * 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。 */ const WEEKLY_BOARD_SIZE = 10 /** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与 /rankings/activity 同口径 */ const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] /** * 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。 * * 和 `/rankings/users` 的区别不只是加了时间窗:那张榜排的是 `user_profile` 的存量 * AC 总数,名次几乎不动,中位学生看一眼就知道追不上,等于负反馈。这张榜的分母是 * 「这一周」,谁都可能进前十。 * * 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单 * 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。 * 相关子查询的四个条件正好是 `submission_public_metrics_idx` * (user_id, problem_id, result, create_time,WHERE contest_id IS NULL)的全部列, * 而且外层已经把行数收在「本周的 AC」这一小撮上,不会退化成按人全表回查。 */ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => { const user = c.get("user") const scope = c.req.query("scope") === "class" ? "class" : "global" const className = scope === "class" ? (user?.className ?? null) : null if (scope === "class" && !className) return failure(c, 400, "class-missing", "用户没有班级信息") const start = weekStart() // 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员 const audience = and( inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false), className ? eq(schema.user.className, className) : undefined, ) const thisWeek = and( isNull(schema.submission.contestId), gte(schema.submission.createTime, start), audience, ) const earlier = alias(schema.submission, "earlier") const [solvedRows, submittedRows] = await Promise.all([ db .select({ userId: schema.submission.userId, username: schema.user.username, value: countDistinct(schema.submission.problemId), }) .from(schema.submission) .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .where( and( thisWeek, inArray(schema.submission.result, ACCEPTED_RESULTS), notExists( db .select({ one: sql`1` }) .from(earlier) .where( and( eq(earlier.userId, schema.submission.userId), eq(earlier.problemId, schema.submission.problemId), isNull(earlier.contestId), inArray(earlier.result, ACCEPTED_RESULTS), lt(earlier.createTime, start), ), ), ), ), ) .groupBy(schema.submission.userId, schema.user.username), db .select({ userId: schema.submission.userId, value: count() }) .from(schema.submission) .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .where(thisWeek) .groupBy(schema.submission.userId), ]) const submissions = new Map( submittedRows.map((row) => [row.userId, row.value]), ) /** * 排序键与全服榜同构:解决多的在前 → 同解决数时提交少的在前 → 再同按 id。 * 第三档同样不是凑数,周榜上「都是 1 题」的学生成片存在,没有稳定兜底键时 * postgres 每次返回的顺序可以不同,刷新一下名次就变了。 */ const ranked = solvedRows .sort( (a, b) => b.value - a.value || (submissions.get(a.userId) ?? 0) - (submissions.get(b.userId) ?? 0) || a.userId - b.userId, ) .map( (row, index) => ({ user: sampleUser({ id: row.userId, username: row.username }, null), solvedCount: row.value, submissionCount: submissions.get(row.userId) ?? 0, rank: index + 1, }) satisfies WeeklyRankItem, ) return success(c, { start, scope, className, total: ranked.length, results: ranked.slice(0, WEEKLY_BOARD_SIZE), me: ranked.find((row) => row.user.id === user?.id) ?? null, } satisfies WeeklyRank) }) 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) }, )