Files
OJ2/apps/api/src/routes/contest.ts
yuetsh ed56a209ea
Some checks failed
Deploy / deploy (push) Has been cancelled
chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:34 -06:00

426 lines
14 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 {
contestPasswordRequestSchema,
STUDENT_ROLES,
type Contest,
type ContestAccess,
type ContestList,
type ContestRank,
type ContestRankItem,
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, success } from "../http"
import {
canAccessContest,
checkContestPassword,
contestDetailsAllowed,
contestStatus,
findAccessibleContest,
isContestAdmin,
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import {
objectValue,
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
}
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, {
results: rows.map((row) =>
serializeContest(
row,
byId.get(row.createdById) ??
sampleUser({ id: row.createdById, username: "" }, null),
),
),
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", "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 findAccessibleContest(
c.get("user"),
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 findAccessibleContest(
c.get("user"),
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, { 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 objectValue(objectValue(profile?.status).contest_problems)
}
function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
const status = objectValue(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),
),
)
.orderBy(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,
myStatus: myStatusOf(statuses, problem.id),
}) 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", "Problem does not exist")
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 ? objectValue(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
: objectValue(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/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, [...STUDENT_ROLES]),
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)
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
.orderBy(
desc(schema.acmContestRank.acceptedNumber),
asc(schema.acmContestRank.totalTime),
asc(schema.acmContestRank.id),
)
.limit(limit)
.offset(offset),
])
const admin = isContestAdmin(c.get("user"), contest)
return success(c, {
results: rows.map(
({ rank, user, realName }) =>
({
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: rank.submissionInfo,
contestId: rank.contestId,
}) satisfies ContestRankItem,
),
total: totalRows[0]?.value ?? 0,
} satisfies ContestRank)
},
)