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() 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) })