style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 13:52:24 +08:00
1 parent 530e1a3d5d
commit 195069450f
480 files changed
+15507 -24650

No files matched your search

+91 -184
View File
@@ -8,22 +8,17 @@ import {
type ProblemSetLock,
type ProblemSetProblem,
type UserBadge,
} from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
import { Hono } from "hono"
} from '@oj2/contract'
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { JudgeStatus, NON_FAILURE_RESULTS } from "../judge/status"
import {
assigningSql,
computeProgress,
problemSetLockCutoffs,
problemSetLocksFor,
} from "../services/problemset"
import { asRecord, isTeacherOrAbove, queryInteger, sampleUser } from "./helpers"
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from '../auth/middleware'
import type { AuthUser } from '../auth/session'
import { db, schema } from '../db'
import { failure, parseBody, success } from '../http'
import { JudgeStatus, NON_FAILURE_RESULTS } from '../judge/status'
import { assigningSql, computeProgress, problemSetLockCutoffs, problemSetLocksFor } from '../services/problemset'
import { asRecord, isTeacherOrAbove, queryInteger, sampleUser } from './helpers'
export const problemsetRoutes = new Hono<AppEnv>()
@@ -68,12 +63,7 @@ async function loadVisible(id: number, user: AuthUser | null | undefined) {
const [row] = await db
.select({ set: schema.problemset, assigning: assigningSql })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
isTeacherOrAbove(user) ? undefined : eq(schema.problemset.visible, true),
),
)
.where(and(eq(schema.problemset.id, id), isTeacherOrAbove(user) ? undefined : eq(schema.problemset.visible, true)))
.limit(1)
return row ?? null
}
@@ -112,68 +102,45 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
* 固定 6 条查询,与行数无关(按行查就是 N+1)。
*/
async function serializeProblemSets(
rows: { set: ProblemSetRow; assigning: boolean }[],
userId?: number,
) {
async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) {
if (rows.length === 0) return []
const ids = rows.map((row) => row.set.id)
const [problemCounts, joinedCounts, progresses, badges, earnedRows, creators] = await Promise.all(
[
db
.select({
problemsetId: schema.problemsetProblem.problemsetId,
value: count(),
required: sql<number>`count(*) filter (where ${schema.problemsetProblem.isRequired})::int`,
})
.from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId),
db
.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
.from(schema.problemsetProgress)
.where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId),
userId
? db
.select()
.from(schema.problemsetProgress)
.where(
and(
inArray(schema.problemsetProgress.problemsetId, ids),
eq(schema.problemsetProgress.userId, userId),
),
)
: Promise.resolve([] as ProgressRow[]),
db
.select()
.from(schema.problemsetBadge)
.where(inArray(schema.problemsetBadge.problemsetId, ids))
.orderBy(asc(schema.problemsetBadge.id)),
userId
? db
.select({ id: schema.userBadge.badgeId, earnedTime: schema.userBadge.earnedTime })
.from(schema.userBadge)
.innerJoin(
schema.problemsetBadge,
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
)
.where(
and(
eq(schema.userBadge.userId, userId),
inArray(schema.problemsetBadge.problemsetId, ids),
),
)
: Promise.resolve([] as { id: number; earnedTime: string }[]),
problemSetCreators([...new Set(rows.map((row) => row.set.createdById))]),
],
)
const [problemCounts, joinedCounts, progresses, badges, earnedRows, creators] = await Promise.all([
db
.select({
problemsetId: schema.problemsetProblem.problemsetId,
value: count(),
required: sql<number>`count(*) filter (where ${schema.problemsetProblem.isRequired})::int`,
})
.from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId),
db
.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
.from(schema.problemsetProgress)
.where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId),
userId
? db
.select()
.from(schema.problemsetProgress)
.where(and(inArray(schema.problemsetProgress.problemsetId, ids), eq(schema.problemsetProgress.userId, userId)))
: Promise.resolve([] as ProgressRow[]),
db.select().from(schema.problemsetBadge).where(inArray(schema.problemsetBadge.problemsetId, ids)).orderBy(asc(schema.problemsetBadge.id)),
userId
? db
.select({ id: schema.userBadge.badgeId, earnedTime: schema.userBadge.earnedTime })
.from(schema.userBadge)
.innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
.where(and(eq(schema.userBadge.userId, userId), inArray(schema.problemsetBadge.problemsetId, ids)))
: Promise.resolve([] as { id: number; earnedTime: string }[]),
problemSetCreators([...new Set(rows.map((row) => row.set.createdById))]),
])
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item]))
const joinedBySet = new Map(joinedCounts.map((item) => [item.problemsetId, item.value]))
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
for (const badge of badges)
badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
for (const badge of badges) badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
const earned = new Map(earnedRows.map((item) => [item.id, item.earnedTime]))
return rows.map(({ set: row, assigning }) => {
const progress = progressBySet.get(row.id)
@@ -181,8 +148,7 @@ async function serializeProblemSets(
id: row.id,
title: row.title,
description: row.description,
createdBy:
creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
createdBy: creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: '' }, null),
createTime: row.createTime,
assignedUntil: row.assignedUntil,
assigning,
@@ -191,25 +157,17 @@ async function serializeProblemSets(
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
joinedCount: joinedBySet.get(row.id) ?? 0,
userProgress: progressSummary(progress),
badges: (badgesBySet.get(row.id) ?? []).map((badge) =>
badgeData(badge, userId ? (earned.get(badge.id) ?? null) : undefined),
),
badges: (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, userId ? (earned.get(badge.id) ?? null) : undefined)),
} satisfies ProblemSet
})
}
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
problemsetRoutes.get('/problem-sets', optionalAuth, async (c) => {
const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
const filters = [eq(schema.problemset.visible, true)]
const keyword = c.req.query("keyword")?.trim()
if (keyword)
filters.push(
or(
ilike(schema.problemset.title, `%${keyword}%`),
ilike(schema.problemset.description, `%${keyword}%`),
)!,
)
const keyword = c.req.query('keyword')?.trim()
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
const where = and(...filters)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where),
@@ -222,18 +180,18 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
.offset(offset),
])
return success(c, {
results: await serializeProblemSets(rows, c.get("user")?.id),
results: await serializeProblemSets(rows, c.get('user')?.id),
total: totalRows[0]?.value ?? 0,
} satisfies ProblemSetList)
})
// 字面量路由要排在 /problem-sets/:id 前面:Hono 按注册顺序匹配,不是静态优先
// 题号(problemDisplayId)和主键(problemId)都认:提交列表那边手里只有题号
problemsetRoutes.get("/problem-sets/locks", requireAuth, async (c) => {
const user = c.get("user")!
problemsetRoutes.get('/problem-sets/locks', requireAuth, async (c) => {
const user = c.get('user')!
if (isTeacherOrAbove(user)) return success(c, [] as ProblemSetLock[])
let problemId = queryInteger(c.req.query("problemId"), 0, { min: 1 })
const displayId = c.req.query("problemDisplayId")?.trim()
let problemId = queryInteger(c.req.query('problemId'), 0, { min: 1 })
const displayId = c.req.query('problemDisplayId')?.trim()
if (!problemId && displayId) {
const [problem] = await db
.select({ id: schema.problem.id })
@@ -247,17 +205,17 @@ problemsetRoutes.get("/problem-sets/locks", requireAuth, async (c) => {
return success(c, rows satisfies ProblemSetLock[])
})
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
const row = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user"))
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const [data] = await serializeProblemSets([row], c.get("user")?.id)
problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
const [data] = await serializeProblemSets([row], c.get('user')?.id)
return success(c, data)
})
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
const user = c.get("user")
const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), user)
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => {
const user = c.get('user')
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user)
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
const id = found.set.id
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
// 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
@@ -279,27 +237,14 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
db
.select({ detail: schema.problemsetProgress.progressDetail })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, id),
eq(schema.problemsetProgress.userId, user.id),
),
)
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, user.id)))
.limit(1),
db
.select({ problemId: schema.submission.problemId, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemsetId, id),
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
),
)
.where(and(eq(schema.submission.userId, user.id), eq(schema.submission.problemsetId, id), notInArray(schema.submission.result, NON_FAILURE_RESULTS)))
.groupBy(schema.submission.problemId),
isTeacherOrAbove(user)
? new Map<number, string>()
: problemSetLockCutoffs(user.id, problemIds),
isTeacherOrAbove(user) ? new Map<number, string>() : problemSetLockCutoffs(user.id, problemIds),
])
: [[], [], new Map<number, string>()]
// 「以前做对过 · 旧代码先藏着」:这道题正被闸门挡着,而且分界之前确实做对过
@@ -315,8 +260,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
inArray(schema.submission.result, SOLVED_RESULTS),
or(
...[...cutoffs].map(
([problemId, cutoff]) =>
sql`(${schema.submission.problemId} = ${problemId} and ${schema.submission.createTime} < ${cutoff})`,
([problemId, cutoff]) => sql`(${schema.submission.problemId} = ${problemId} and ${schema.submission.createTime} < ${cutoff})`,
),
),
),
@@ -338,7 +282,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
order: link.order,
isRequired: link.isRequired,
isCompleted: entry !== undefined,
solvedTime: typeof solvedTime === "string" ? solvedTime : null,
solvedTime: typeof solvedTime === 'string' ? solvedTime : null,
wrongCount: wrong.get(problemId) ?? 0,
oldCodeHidden: hidden.has(problemId),
} satisfies ProblemSetProblem
@@ -346,24 +290,19 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
)
})
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set")
problemsetRoutes.post('/problem-set-progress', requireAuth, async (c) => {
const parsed = await parseBody(c, joinProblemSetRequestSchema, 'Invalid problem set')
if (!parsed.success) return parsed.response
const user = c.get("user")!
const user = c.get('user')!
const found = await loadVisible(parsed.data.problemSetId, user)
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
const problemSet = found.set
const [existing] = await db
.select({ id: schema.problemsetProgress.id })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, problemSet.id),
eq(schema.problemsetProgress.userId, user.id),
),
)
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id)))
.limit(1)
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
if (existing) return failure(c, 409, 'already-joined', '已经加入该题单')
await db.transaction(async (tx) => {
const links = await tx
.select({
@@ -383,16 +322,16 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
return success(c, null, 201)
})
problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
const requested = c.req.param("username")
const username = requested === "me" ? c.get("user")?.username : requested
if (!username) return failure(c, 401, "login-required", "Authentication required")
problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
const requested = c.req.param('username')
const username = requested === 'me' ? c.get('user')?.username : requested
if (!username) return failure(c, 401, 'login-required', 'Authentication required')
const [target] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(and(eq(schema.user.username, username), eq(schema.user.isDisabled, false)))
.limit(1)
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
if (!target) return failure(c, 404, 'user-not-found', '用户不存在')
const rows = await db
.select({
userBadge: schema.userBadge,
@@ -429,9 +368,9 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
* 想看别的班再换(和比赛「全班情况」按参赛者推断班级是一个思路)。名单是那个班的全部学生,
* 没加入的也列出来,好点名。
*/
problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) => {
const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user"))
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => {
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
const id = found.set.id
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
const [links, joinedClasses] = await Promise.all([
@@ -454,13 +393,7 @@ problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) =
})
.from(schema.problemsetProgress)
.innerJoin(schema.user, eq(schema.user.id, schema.problemsetProgress.userId))
.where(
and(
eq(schema.problemsetProgress.problemsetId, id),
studentRole,
sql`coalesce(${schema.user.className}, '') <> ''`,
),
)
.where(and(eq(schema.problemsetProgress.problemsetId, id), studentRole, sql`coalesce(${schema.user.className}, '') <> ''`))
.groupBy(schema.user.className)
.orderBy(sql`max(${schema.problemsetProgress.joinTime}) desc`),
])
@@ -490,15 +423,9 @@ problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) =
size: sizeOf.get(row.className) ?? row.joined,
}))
.map((row, recency) => ({ row, recency, doing: row.joined >= 5 && row.joined * 3 >= row.size }))
.sort((a, b) =>
a.doing !== b.doing
? Number(b.doing) - Number(a.doing)
: a.doing
? a.recency - b.recency
: b.row.joined - a.row.joined,
)
.sort((a, b) => (a.doing !== b.doing ? Number(b.doing) - Number(a.doing) : a.doing ? a.recency - b.recency : b.row.joined - a.row.joined))
.map((item) => item.row)
const wanted = c.req.query("className")?.trim()
const wanted = c.req.query('className')?.trim()
const className = wanted || classes[0]?.className || null
const graded = gradedIds(links)
const empty: ProblemSetClassView = {
@@ -525,16 +452,8 @@ problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) =
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.leftJoin(
schema.problemsetProgress,
and(
eq(schema.problemsetProgress.userId, schema.user.id),
eq(schema.problemsetProgress.problemsetId, id),
),
)
.where(
and(eq(schema.user.className, className), studentRole, eq(schema.user.isDisabled, false)),
)
.leftJoin(schema.problemsetProgress, and(eq(schema.problemsetProgress.userId, schema.user.id), eq(schema.problemsetProgress.problemsetId, id)))
.where(and(eq(schema.user.className, className), studentRole, eq(schema.user.isDisabled, false)))
.orderBy(asc(schema.user.username))
if (roster.length === 0) return success(c, empty)
const userIds = roster.map((row) => row.userId)
@@ -547,25 +466,13 @@ problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) =
value: count(),
})
.from(schema.submission)
.where(
and(
eq(schema.submission.problemsetId, id),
inArray(schema.submission.userId, userIds),
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
),
)
.where(and(eq(schema.submission.problemsetId, id), inArray(schema.submission.userId, userIds), notInArray(schema.submission.result, NON_FAILURE_RESULTS)))
.groupBy(schema.submission.userId, schema.submission.problemId),
// 加入之前就做对过(哪个入口都算):老师据此知道谁可能是凭记忆默写的
db
.select({ userId: schema.submission.userId, problemId: schema.submission.problemId })
.from(schema.submission)
.innerJoin(
schema.problemsetProgress,
and(
eq(schema.problemsetProgress.userId, schema.submission.userId),
eq(schema.problemsetProgress.problemsetId, id),
),
)
.innerJoin(schema.problemsetProgress, and(eq(schema.problemsetProgress.userId, schema.submission.userId), eq(schema.problemsetProgress.problemsetId, id)))
.where(
and(
inArray(schema.submission.userId, userIds),
@@ -591,7 +498,7 @@ problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) =
if (graded.has(link.problemId)) completed += 1
}
return {
solvedTime: typeof time === "string" ? time : null,
solvedTime: typeof time === 'string' ? time : null,
wrongCount: wrong.get(key(row.userId, link.problemId)) ?? 0,
solvedBefore: before.has(key(row.userId, link.problemId)),
}