- 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>
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
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))
|
||
},
|
||
)
|