Files
OJ2/apps/api/src/routes/contest.ts
yuetsh 4b6242ba70 fix(比赛): 比赛进行中,学生打开比赛题必 500
contest.ts 两处把「藏起来的难度」表示成空串,而 problemDifficultySchema
是严格的三值枚举,parse 直接抛 ZodError。contestDetailsAllowed 在
「比赛进行中 + 看的人不是管理员」时就是假 —— 也就是正常学生在正常比赛里,
比赛题列表和详情页一开就挂。dev 库 contest 表一直是空的,这条路径没被跑到过。

改成下发 null,契约里给这两个字段单独起了 maskedProblemDifficultySchema。
语义上对齐旧 Django:ProblemSafeSerializer 把 difficulty 放在 exclude 里,
字段整个不下发,而不是给一个假值。

端上顺着 null 补了四处:ProblemInfo 的「难度」项整条不渲染(和旧栈表现一致),
题目列表、题单列表同理,transforms 的 ProblemFiltered.difficulty 也放开为可空。
这几处是 vue-tsc 逐个揪出来的,正是严格枚举该起的作用。

实测(学生 / 超管 × 比赛题 / 普通题 四种组合):学生看比赛题详情与列表
从 500 变 200 且 difficulty 为 null,超管仍拿到真值 Low,普通题不受影响;
页面上「难度」整项消失,统计面板其余内容正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:01:56 -06:00

234 lines
11 KiB
TypeScript

import {
contestAccessSchema,
contestListSchema,
contestPasswordRequestSchema,
contestRankItemSchema,
contestRankSchema,
contestSchema,
problemDetailSchema,
problemListItemSchema,
} 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, success } from "../http"
import {
canAccessContest,
checkContestPassword,
contestDetailsAllowed,
contestStatus,
findVisibleContest,
isContestAdmin,
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } 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 contestSchema.parse({
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,
})
}
contestRoutes.get("/contests", 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(eq(schema.contest.tag, tag))
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 = await creators([...new Set(rows.map((row) => row.createdById))])
return success(c, contestListSchema.parse({
results: rows.map((row) => serializeContest(
row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
)),
total: totalRow[0]?.value ?? 0,
}))
})
contestRoutes.get("/contests/:id", async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const byId = await creators([contest.createdById])
return success(c, serializeContest(
contest,
byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null),
true,
))
})
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
if (!checkContestPassword(parsed.data.password, contest.password)) {
return failure(c, 403, "wrong-password", "Wrong password or password expired")
}
await setContestPassword(c, contest.id, parsed.data.password)
return success(c, true)
})
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "details")
return success(c, contestAccessSchema.parse({ access: access.ok }))
})
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))).orderBy(asc(schema.problem.displayId))
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
const allowed = contestDetailsAllowed(c.get("user"), contest)
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({
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,
myStatus: null,
})))
})
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", "Problem does not exist")
const tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest)
return success(c, problemDetailSchema.parse({
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: stringArray(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 ? objectValue(row.problem.statisticInfo) : {},
shareSubmission: row.problem.shareSubmission,
contestId: contest.id,
tags: tags.get(row.problem.id) ?? [],
createdBy: sampleUser(row.user, row.realName),
myStatus: null,
myFailedCount: 0,
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
// 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules),
}))
})
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, ["Regular User", "Student Admin"]), eq(schema.user.isDisabled, false))
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)).where(where),
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime)).limit(limit).offset(offset),
])
const admin = isContestAdmin(c.get("user"), contest)
return success(c, contestRankSchema.parse({
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({
id: rank.id,
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
user: sampleUser(user, realName, { includeRealName: admin }),
submissionNumber: rank.submissionNumber,
acceptedNumber: rank.acceptedNumber,
totalTime: rank.totalTime,
submissionInfo: objectValue(rank.submissionInfo),
contestId: rank.contestId,
})),
total: totalRows[0]?.value ?? 0,
}))
})