Files
OJ2/apps/api/src/routes/contest.ts
T
xuyueandClaude Opus 5.5 b94e028ff4 feat(比赛): 榜单一次给全,带名次变化和每题谁最先做对;期中、期末考试中学生看不到排名;老师能看全班情况
- GET /contests/:id/scoreboard 代替 /rank:整张榜、5 分钟前的名次、每题做对 / 做过人数和学生里最先做对的人(不再用 is_first_ac,老师赛中试做会占掉它)
- 期中、期末进行中:学生只拿到自己那一行,人数清零、「全班最先」也不给,考完一起公布
- GET /contests/:id/class-view(老师):真名、每人最后一次交、没进来的人 —— 比赛不绑班,来了一半以上的班才算这场的班
- 比赛列表带题数、参赛人数、我的名次和做对数,能筛「我参加过的」「期中 · 期末」
- PUT /admin/contests/:id/problems/order:比赛题按顺序重编 1、2、3,比赛结束后不让动
- 后台「审核」列表接口去掉,并进前台老师的成绩页;「看过了」的写接口留着

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:58:59 +08:00

429 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ContestEnv>()
/** 一次把这批比赛的创建者全查回来,按 userId 建 Map —— 比赛列表按行查会变成 N+1 */
async function creators(ids: number[]) {
const map = new Map<number, ReturnType<typeof sampleUser>>()
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<typeof sampleUser>,
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<string, unknown>, 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<number, string[]>()
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<number, string[]>()
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))
},
)