import { EXAM_TAGS, isSqlProblem, contestPasswordRequestSchema, STUDENT_ROLES, type Contest, type ContestAccess, type ContestList, type ProblemDetail, type ProblemListItem, } from "@oj2/contract" import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, requireAuth } from "../auth/middleware" import { setContestPassword } from "../auth/session" import { db, schema } from "../db" import { astRequirements } from "../judge/ast" import { failure, parseBody, success } from "../http" import { canAccessContest, checkContestPassword, contestDetailsAllowed, contestStatus, buildClassView, buildScoreboard, findAccessibleContest, isContestTeacher, rankHiddenFor, requireContestAccess, type ContestEnv, } from "../services/contest" import { countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling" import { asRecord, publicTemplates, queryInteger, sampleUser } from "./helpers" export const contestRoutes = new Hono() /** 一次把这批比赛的创建者全查回来,按 userId 建 Map —— 比赛列表按行查会变成 N+1 */ async function creators(ids: number[]) { const map = new Map>() if (ids.length === 0) return map const rows = await db .select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName, }) .from(schema.user) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(inArray(schema.user.id, ids)) for (const row of rows) map.set(row.id, sampleUser(row, row.realName)) return map } function serializeContest( contest: typeof schema.contest.$inferSelect, createdBy: ReturnType, includeNow = false, ) { return { id: contest.id, title: contest.title, description: contest.description, tag: contest.tag, startTime: contest.startTime, endTime: contest.endTime, createTime: contest.createTime, lastUpdateTime: contest.lastUpdateTime, createdBy, status: contestStatus(contest), contestType: contest.password ? "Password Protected" : "Public", now: includeNow ? new Date().toISOString() : undefined, } satisfies Contest } /** * 列表上要的几个数:每场几道题、几个学生交过,以及「我」在每场的名次和做对数。 * 名次和榜单同一个口径(学生、没禁用、做对数 ↓ 罚时 ↑ id ↑),期中期末进行中不给名次 */ async function listExtras( rows: (typeof schema.contest.$inferSelect)[], userId: number | undefined, ) { const ids = rows.map((row) => row.id) if (ids.length === 0) return { problems: new Map(), people: new Map(), mine: new Map() } const idList = sql.join( ids.map((id) => sql`${id}`), sql`, `, ) const studentList = sql.join( STUDENT_ROLES.map((role) => sql`${role}`), sql`, `, ) const [problemRows, peopleRows, mineRows] = await Promise.all([ db.execute<{ contest_id: number; n: number }>(sql` select contest_id, count(*)::int as n from ${schema.problem} where contest_id in (${idList}) and visible group by contest_id `), db.execute<{ contest_id: number; n: number }>(sql` select r.contest_id, count(*)::int as n from ${schema.acmContestRank} r join ${schema.user} u on u.id = r.user_id where r.contest_id in (${idList}) and u.admin_type in (${studentList}) and not u.is_disabled group by r.contest_id `), userId ? db.execute<{ contest_id: number; solved: number; rank: number }>(sql` select r.contest_id, r.accepted_number as solved, 1 + (select count(*)::int from ${schema.acmContestRank} o join ${schema.user} ou on ou.id = o.user_id where o.contest_id = r.contest_id and ou.admin_type in (${studentList}) and not ou.is_disabled and o.user_id <> r.user_id and (o.accepted_number > r.accepted_number or (o.accepted_number = r.accepted_number and o.total_time < r.total_time) or (o.accepted_number = r.accepted_number and o.total_time = r.total_time and o.id < r.id))) as rank from ${schema.acmContestRank} r where r.user_id = ${userId} and r.contest_id in (${idList}) `) : Promise.resolve([]), ]) return { problems: new Map(problemRows.map((row) => [row.contest_id, row.n])), people: new Map(peopleRows.map((row) => [row.contest_id, row.n])), mine: new Map(mineRows.map((row) => [row.contest_id, row])), } } contestRoutes.get("/contests", 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 keyword = c.req.query("keyword")?.trim() const tag = c.req.query("tag")?.trim() const status = c.req.query("status") const now = new Date().toISOString() const filters = [eq(schema.contest.visible, true)] if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`)) if (tag === "考试") filters.push(inArray(schema.contest.tag, [...EXAM_TAGS])) else if (tag) filters.push(eq(schema.contest.tag, tag)) const user = c.get("user") // 「我参加过的」:交过题的才算(榜单行是第一次交题时建的) if (c.req.query("joined") === "1" && user) { filters.push( inArray( schema.contest.id, db .select({ id: schema.acmContestRank.contestId }) .from(schema.acmContestRank) .where(eq(schema.acmContestRank.userId, user.id)), ), ) } if (status === "1") filters.push(gte(schema.contest.startTime, now)) else if (status === "-1") filters.push(lte(schema.contest.endTime, now)) else if (status === "0") filters.push(and(lte(schema.contest.startTime, now), gte(schema.contest.endTime, now))!) const where = and(...filters) const [totalRow, rows] = await Promise.all([ db.select({ value: count() }).from(schema.contest).where(where), db .select() .from(schema.contest) .where(where) .orderBy(desc(schema.contest.startTime)) .limit(limit) .offset(offset), ]) const [byId, extras] = await Promise.all([ creators([...new Set(rows.map((row) => row.createdById))]), listExtras(rows, user?.id), ]) return success(c, { results: rows.map((row) => { const mine = extras.mine.get(row.id) const total = extras.people.get(row.id) ?? 0 return { ...serializeContest( row, byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null), ), problemCount: extras.problems.get(row.id) ?? 0, participantCount: total, mine: !user ? undefined : mine ? { rank: rankHiddenFor(user, row) ? null : mine.rank, total, solved: mine.solved } : null, } satisfies Contest }), total: totalRow[0]?.value ?? 0, } satisfies ContestList) }) // optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」—— // 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在 contestRoutes.get("/contests/:id", optionalAuth, async (c) => { const contest = await findAccessibleContest( c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }), ) if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在") const byId = await creators([contest.createdById]) return success( c, serializeContest( contest, byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null), true, ), ) }) const CONTEST_PASSWORD_RULE: AttemptRule = { limit: 10, windowSeconds: 10 * 60 } contestRoutes.post("/contests/:id/access", requireAuth, async (c) => { const contest = await findAccessibleContest( c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }), ) if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在") const parsed = await parseBody(c, contestPasswordRequestSchema, "请输入比赛密码") if (!parsed.success) return parsed.response // 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。 // 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。 const attemptKey = `contest-password:${contest.id}:${c.get("user")!.id}` const wait = await lockoutRemaining(attemptKey, CONTEST_PASSWORD_RULE) if (wait !== null) { return failure( c, 429, "too-many-password-attempts", `Too many wrong passwords, please wait ${wait} seconds`, ) } if (!checkContestPassword(parsed.data.password, contest.password)) { await countAttempt(attemptKey, CONTEST_PASSWORD_RULE) return failure(c, 403, "wrong-password", "比赛密码不对,或者已经过期了,请重新输入") } await setContestPassword(c, contest.id, parsed.data.password) return success(c, true) }) contestRoutes.get("/contests/:id/access", requireAuth, async (c) => { const contest = await findAccessibleContest( c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }), ) if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在") const access = await canAccessContest(c, contest, "details") return success(c, { access: access.ok } satisfies ContestAccess) }) /** * 当前用户在**比赛题**上的做题状态。判题回写记在 user_profile 的 * `acm_problems_status.contest_problems`(judge/run.ts),公开题库那份记在 `problems` * 下,两边互不干扰。 * * 原来这两条路由一律下发空状态,于是比赛题目页的「状态」列永远是「未做」,赛后也不 * 恢复 —— 而库里其实一直记着。 * * 不按「比赛结没结束」分档:这是学生自己的判题结果,赛中赛后都不泄露别人的任何信息 * (旧后端赛中不下发,纯粹是因为它整条路换了个 serializer,不是什么保密考虑)。 */ async function contestProblemStatuses(userId: number | undefined) { if (!userId) return {} const [profile] = await db .select({ status: schema.userProfile.acmProblemsStatus }) .from(schema.userProfile) .where(eq(schema.userProfile.userId, userId)) .limit(1) return asRecord(asRecord(profile?.status).contest_problems) } function myStatusOf(statuses: Record, problemId: number) { const status = asRecord(statuses[String(problemId)]).status return typeof status === "number" ? status : null } async function contestProblemTags(problemIds: number[]) { if (problemIds.length === 0) return new Map() const rows = await db .select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name, }) .from(schema.problemTags) .innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)) .where(inArray(schema.problemTags.problemId, problemIds)) const map = new Map() for (const row of rows) map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name]) return map } contestRoutes.get( "/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => { const contest = c.get("contest")! const rows = await db .select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName, }) .from(schema.problem) .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))) // 先按长度再按字面:编号是文本,直接排会变成 1、10、11…、2 .orderBy(sql`length(${schema.problem.displayId})`, asc(schema.problem.displayId)) const tags = await contestProblemTags(rows.map((row) => row.problem.id)) const allowed = contestDetailsAllowed(c.get("user"), contest) const statuses = await contestProblemStatuses(c.get("user")?.id) return success( c, rows.map( ({ problem, user, realName }) => ({ id: problem.id, _id: problem.displayId, title: problem.title, submissionNumber: allowed ? problem.submissionNumber : 0, acceptedNumber: allowed ? problem.acceptedNumber : 0, difficulty: allowed ? problem.difficulty : null, createdBy: sampleUser(user, realName), tags: tags.get(problem.id) ?? [], contestId: contest.id, allowFlowchart: problem.allowFlowchart, showFlowchart: problem.showFlowchart, hasAstRules: problem.astRules !== null, isSql: isSqlProblem(problem), myStatus: myStatusOf(statuses, problem.id), // 按人数是公开题库列表的列;比赛题表看的是榜单,不下发 solvedUsers: 0, triedUsers: 0, }) satisfies ProblemListItem, ), ) }, ) contestRoutes.get( "/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => { const contest = c.get("contest")! const [row] = await db .select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName, }) .from(schema.problem) .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where( and( eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true), sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, ), ) .limit(1) if (!row) return failure(c, 404, "problem-not-found", "题目不存在") const tags = await contestProblemTags([row.problem.id]) const allowed = contestDetailsAllowed(c.get("user"), contest) const statuses = await contestProblemStatuses(c.get("user")?.id) return success(c, { id: row.problem.id, _id: row.problem.displayId, title: row.problem.title, description: row.problem.description, inputDescription: row.problem.inputDescription, outputDescription: row.problem.outputDescription, samples: Array.isArray(row.problem.samples) ? row.problem.samples : [], hint: row.problem.hint, languages: row.problem.languages, template: publicTemplates(row.problem.template), createTime: row.problem.createTime, lastUpdateTime: row.problem.lastUpdateTime, timeLimit: row.problem.timeLimit, memoryLimit: row.problem.memoryLimit, difficulty: allowed ? row.problem.difficulty : null, source: row.problem.source, prompt: row.problem.prompt, submissionNumber: allowed ? row.problem.submissionNumber : 0, acceptedNumber: allowed ? row.problem.acceptedNumber : 0, statisticInfo: allowed ? asRecord(row.problem.statisticInfo) : {}, contestId: contest.id, tags: tags.get(row.problem.id) ?? [], createdBy: sampleUser(row.user, row.realName), myStatus: myStatusOf(statuses, row.problem.id), // 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0 myFailedCount: 0, allowFlowchart: row.problem.allowFlowchart, showFlowchart: row.problem.showFlowchart, mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, flowchartData: row.problem.allowFlowchart ? null : asRecord(row.problem.flowchartData), flowchartHint: row.problem.flowchartHint, sqlConfig: row.problem.sqlConfig, sqlDisplay: row.problem.sqlDisplay, // 代码要求:只给渲染好的文案,规则原文不下发给学生 astRequirements: astRequirements(row.problem.astRules), } satisfies ProblemDetail) }, ) contestRoutes.get( "/contests/:id/scoreboard", optionalAuth, requireContestAccess("ranks"), async (c) => success(c, await buildScoreboard(c.get("contest")!, c.get("user"))), ) contestRoutes.get( "/contests/:id/class-view", optionalAuth, requireContestAccess("ranks"), async (c) => { const contest = c.get("contest")! const user = c.get("user") if (!user || !isContestTeacher(user, contest)) { return failure(c, 403, "teacher-only", "只有老师能看全班情况") } return success(c, await buildClassView(contest, user)) }, )