Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
14 KiB
TypeScript
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)
|
|
})
|