Files
OJ2/apps/api/src/routes/admin/contest.ts
T
2026-10-26 13:52:24 +08:00

310 lines
14 KiB
TypeScript

import { createContestRequestSchema, updateAcmHelperRequestSchema, updateContestRequestSchema, type AdminContest, type AdminContestList } from '@oj2/contract'
import { and, count, countDistinct, desc, eq, ilike, inArray } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireTeacher, type AppEnv } from '../../auth/middleware'
import { db, schema } from '../../db'
import { failure, parseBody, success } from '../../http'
import { contestStatus } from '../../services/contest'
import { queryInteger, sampleUser } from '../helpers'
import { ownsOrSuper } from '../../services/permissions'
export const adminContestRoutes = new Hono<AppEnv>()
async function serialize(row: { contest: typeof schema.contest.$inferSelect; user: typeof schema.user.$inferSelect; realName: string | null }) {
return {
id: row.contest.id,
title: row.contest.title,
description: row.contest.description,
tag: row.contest.tag,
startTime: row.contest.startTime,
endTime: row.contest.endTime,
createTime: row.contest.createTime,
lastUpdateTime: row.contest.lastUpdateTime,
password: row.contest.password,
visible: row.contest.visible,
createdBy: sampleUser(row.user, row.realName),
status: contestStatus(row.contest),
contestType: row.contest.password ? 'Password Protected' : 'Public',
} satisfies AdminContest
}
function selectContest(id: number) {
return db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.contest.id, id))
.limit(1)
}
/** 请求体里的时间校验,创建和编辑共用 */
function validatePayload(data: { startTime: string; endTime: string }) {
const start = Date.parse(data.startTime)
const end = Date.parse(data.endTime)
if (!Number.isFinite(start) || !Number.isFinite(end)) return '开始或结束时间不是合法的时间格式'
if (end <= start) return 'Start time must occur earlier than end time'
return null
}
adminContestRoutes.get('/contests', requireTeacher, 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 user = c.get('user')!
const filters = []
// 非超管只看得到自己建的比赛,与旧后端一致
if (user.adminType !== 'Super Admin') filters.push(eq(schema.contest.createdById, user.id))
const keyword = c.req.query('keyword')?.trim()
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.contest).where(where),
db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
// 按开始时间:原来按创建时间,「复制一份」出来的旧比赛会排到最前面
.orderBy(desc(schema.contest.startTime), desc(schema.contest.id))
.limit(limit)
.offset(offset),
])
const ids = rows.map((row) => row.contest.id)
const [problemCounts, participantCounts] = ids.length
? await Promise.all([
db
.select({ contestId: schema.problem.contestId, value: count() })
.from(schema.problem)
.where(inArray(schema.problem.contestId, ids))
.groupBy(schema.problem.contestId),
db
.select({
contestId: schema.submission.contestId,
value: countDistinct(schema.submission.userId),
})
.from(schema.submission)
.where(inArray(schema.submission.contestId, ids))
.groupBy(schema.submission.contestId),
])
: [[], []]
const problemsOf = new Map(problemCounts.map((row) => [row.contestId, row.value]))
const peopleOf = new Map(participantCounts.map((row) => [row.contestId, row.value]))
return success(c, {
results: await Promise.all(
rows.map(async (row) => ({
...(await serialize(row)),
problemCount: problemsOf.get(row.contest.id) ?? 0,
participantCount: peopleOf.get(row.contest.id) ?? 0,
})),
),
total: totalRows[0]?.value ?? 0,
} satisfies AdminContestList)
})
adminContestRoutes.get('/contests/:id', requireTeacher, async (c) => {
const [row] = await selectContest(queryInteger(c.req.param('id'), 0, { min: 1 }))
if (!row || !ownsOrSuper(c.get('user')!, row.contest)) {
return failure(c, 404, 'contest-not-found', 'Contest does not exist')
}
return success(c, await serialize(row))
})
adminContestRoutes.post('/contests', requireTeacher, async (c) => {
const parsed = await parseBody(c, createContestRequestSchema)
if (!parsed.success) return parsed.response
const error = validatePayload(parsed.data)
if (error) return failure(c, 400, 'invalid-contest', error)
const now = new Date().toISOString()
const [created] = await db
.insert(schema.contest)
.values({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
startTime: new Date(parsed.data.startTime).toISOString(),
endTime: new Date(parsed.data.endTime).toISOString(),
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
password: parsed.data.password || null,
visible: parsed.data.visible,
createdById: c.get('user')!.id,
createTime: now,
lastUpdateTime: now,
})
.returning({ id: schema.contest.id })
const [row] = await selectContest(created!.id)
return success(c, await serialize(row!), 201)
})
adminContestRoutes.put('/contests/:id', requireTeacher, async (c) => {
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
const parsed = await parseBody(c, updateContestRequestSchema)
if (!parsed.success) return parsed.response
const [existing] = await selectContest(id)
if (!existing || !ownsOrSuper(c.get('user')!, existing.contest)) {
return failure(c, 404, 'contest-not-found', 'Contest does not exist')
}
const error = validatePayload(parsed.data)
if (error) return failure(c, 400, 'invalid-contest', error)
await db
.update(schema.contest)
.set({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
startTime: new Date(parsed.data.startTime).toISOString(),
endTime: new Date(parsed.data.endTime).toISOString(),
password: parsed.data.password || null,
visible: parsed.data.visible,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.contest.id, id))
const [row] = await selectContest(id)
return success(c, await serialize(row!))
})
adminContestRoutes.post('/contests/:id/clone', requireTeacher, async (c) => {
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
const [original] = await selectContest(id)
// 这个接口是干什么的:**把以前那场比赛快速再开一场**,不用从头建一遍题。
// 所以副本要带着整套题(含 answers 和 testCaseId),时间挪到 10 分钟后、默认不可见,
// 前端点完「复制」直接跳进副本的编辑页改标题和时间(admin/contest/components/Actions.vue)。
//
// 克隆**故意不要求 ownedBy**,任何教师都能克隆任何一场比赛,包括别人的、隐藏的、
// 还没开始的 —— 于是克隆完就能从 GET /admin/problems/:id 读到别人的标准答案、
// 从 /test-cases 下载别人的测试点。这是**明确定过的政策**(2026-09-06 确认):
// 保密边界在师生之间,不在教师之间。别再把它当越权读取报上来。
//
// 注意这条政策**不能顺手推广到 make-public / from-public**:那两条守的是另一件事 ——
// 别让 B 把 A 还没考的卷子发布给**学生**,或者把 A 的草稿拖进自己比赛再放出去。
// 边界是学生,所以那两处的归属校验照旧(见 admin/problem.ts 的注释)。
//
// 已知的副作用,别当成 bug 去"修":副本和原题共用同一个测试点目录(testCaseId 原样复制),
// 今天无害(删题特意不删目录),但以后要是加"删题顺手清测试点",得先把这里改成复制目录。
if (!original) return failure(c, 404, 'contest-not-found', 'Contest does not exist')
const duration = Date.parse(original.contest.endTime) - Date.parse(original.contest.startTime)
// 新比赛从 10 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地,
// 又不至于建出一个已经结束的比赛
const start = new Date(Date.now() + 10 * 60 * 1000)
const end = new Date(start.getTime() + duration)
const now = new Date().toISOString()
const me = c.get('user')!.id
const cloned = await db.transaction(async (tx) => {
const [contest] = await tx
.insert(schema.contest)
.values({
title: original.contest.title,
description: original.contest.description,
tag: original.contest.tag,
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
password: null,
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
visible: false,
startTime: start.toISOString(),
endTime: end.toISOString(),
createdById: me,
createTime: now,
lastUpdateTime: now,
})
.returning({ id: schema.contest.id })
const problems = await tx.select().from(schema.problem).where(eq(schema.problem.contestId, id))
if (problems.length === 0) return contest!.id
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
const copies = await tx
.insert(schema.problem)
.values(
problems.map(({ id: _oldId, ...rest }) => ({
...rest,
contestId: contest!.id,
// 计数器归零:克隆的是题面,不是历史战绩
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createdById: me,
createTime: now,
lastUpdateTime: now,
})),
)
.returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id]))
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
const tags = await tx
.select({
problemId: schema.problemTags.problemId,
tagId: schema.problemTags.problemtagId,
})
.from(schema.problemTags)
.where(
inArray(
schema.problemTags.problemId,
problems.map((problem) => problem.id),
),
)
if (tags.length) {
const displayIdByOldId = new Map(problems.map((problem) => [problem.id, problem.displayId]))
const links = tags.flatMap((tag) => {
const newId = newIdByDisplayId.get(displayIdByOldId.get(tag.problemId) ?? '')
return newId === undefined ? [] : [{ problemId: newId, problemtagId: tag.tagId }]
})
if (links.length) await tx.insert(schema.problemTags).values(links)
}
return contest!.id
})
const [row] = await selectContest(cloned)
return success(c, await serialize(row!), 201)
})
// ---------------------------------------------------------------- 赛后核查
// 「看过了」的标记。列表原来是后台单独一页(审核),2026-10 并进前台比赛页老师的「成绩」,
// 数据从 GET /contests/:id/class-view 来,这里只剩写标记
adminContestRoutes.put('/contests/:id/acm-helper', requireTeacher, async (c) => {
const contestId = queryInteger(c.req.param('id'), 0, { min: 1 })
const parsed = await parseBody(c, updateAcmHelperRequestSchema)
if (!parsed.success) return parsed.response
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
if (!contest || !ownsOrSuper(c.get('user')!, contest)) {
return failure(c, 404, 'contest-not-found', 'Contest does not exist')
}
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
// 那样带上任意 rank_id 就能改别的比赛的核查标记
const [rank] = await db
.select()
.from(schema.acmContestRank)
.where(and(eq(schema.acmContestRank.id, parsed.data.rankId), eq(schema.acmContestRank.contestId, contestId)))
.limit(1)
if (!rank) return failure(c, 404, 'rank-not-found', 'Rank id does not exist')
const info = rank.submissionInfo
const entry = info[parsed.data.problemId]
if (!entry) {
return failure(c, 404, 'problem-not-in-rank', 'Problem id does not exist')
}
entry.checked = parsed.data.checked
info[parsed.data.problemId] = entry
await db.update(schema.acmContestRank).set({ submissionInfo: info }).where(eq(schema.acmContestRank.id, rank.id))
return success(c, null)
})