feat(阶段4): 比赛管理 + 克隆 + ACM 赛后核查

GET/POST          admin/contests
  GET/PUT           admin/contests/:id
  POST              admin/contests/:id/clone
  GET/PUT           admin/contests/:id/acm-helper

克隆是深拷贝:新比赛从 10 分钟后开始、时长与原比赛相同、一律不可见(时间是拍脑袋定的,
直接开放会让学生看到一场没准备好的赛),比赛题目连同标签一起复制,
提交数/通过数/statistic_info 归零 —— 克隆的是题面不是历史战绩。
实测:源题 99/55 两个标签,克隆出来 0/0 两个标签俱在。

两处比旧后端更严:

- **ACM 核查的 rank 必须属于本场比赛**。旧后端只按 rank_id 取,不校验归属,
  带上任意 rank_id 就能改别的比赛的核查标记。
- 比赛详情/编辑越权时报「不存在」而不是「无权限」,不泄露「有这么个东西但你看不到」。

其余对齐:非超管只看得到自己建的比赛;空串密码归一成 null(否则 contestType 会把
「密码是空字符串」当成密码保护赛);CIDR 按 ip_network(strict=False) 的口径校验,
允许主机位非零。

核查页的 realName 是**有意下发**的:这个页面就是老师对着名单确认谁抄了,
接口已由 requireTeacher + 归属校验双重把关。

实测:学生 403;结束早于开始 400、非法 CIDR 400 且文案带具体网段;创建空串密码
→ Public、改密码后 → Password Protected 且后台能读到密码原文;克隆时长一致且不可见;
不可见比赛的核查页 404;rank 不属于本场 404。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:02:28 -06:00
parent bc54fbff98
commit 553c084579
4 changed files with 400 additions and 16 deletions

View File

@@ -178,9 +178,9 @@ export function deleteUsers(userIDs: number[]) {
}
export function getContestList(offset = 0, limit = 10, keyword: string) {
return http.get("admin/contest", {
params: { paging: true, offset, limit, keyword },
})
return legacyResponse(
api2.get("admin/contests", { params: { offset, limit, keyword } }),
)
}
// 上传图片
@@ -242,22 +242,41 @@ export function createContestProblem(problem: BlankProblem) {
return http.post("admin/contest/problem", problem)
}
/** 组件里的比赛对象是 snake_case出站转成新后端要的 camelCase */
function toContestBody(contest: Contest | BlankContest) {
return {
title: contest.title,
description: contest.description,
tag: contest.tag,
startTime: contest.start_time,
endTime: contest.end_time,
password: contest.password || null,
visible: contest.visible,
allowedIpRanges: contest.allowed_ip_ranges ?? [],
}
}
export function createContest(contest: BlankContest) {
return http.post("admin/contest", contest)
return legacyResponse(api2.post("admin/contests", toContestBody(contest)))
}
export function editContest(contest: Contest | BlankContest) {
return http.put("admin/contest", contest)
return legacyResponse(
api2.put(
`admin/contests/${(contest as Contest).id}`,
toContestBody(contest),
),
)
}
export function cloneContest(contest_id: number) {
return http.post("admin/contest/clone", { contest_id })
return legacyResponse(api2.post(`admin/contests/${contest_id}/clone`))
}
export function getContest(id: string) {
return http.get<Contest & { password: string }>("admin/contest", {
params: { id },
})
return legacyResponse<Contest & { password: string }>(
api2.get(`admin/contests/${id}`),
)
}
export function addProblemForContest(
@@ -435,9 +454,7 @@ export function makeProblemPublic(id: number, display_id: string) {
// 比赛辅助检查
export function getACMHelperList(contest_id: number) {
return http.get("admin/contest/acm_helper", {
params: { contest_id },
})
return legacyResponse(api2.get(`admin/contests/${contest_id}/acm-helper`))
}
export function updateACMHelperChecked(
@@ -446,10 +463,9 @@ export function updateACMHelperChecked(
problem_id: string,
checked: boolean,
) {
return http.put("admin/contest/acm_helper", {
contest_id,
rank_id,
problem_id,
return api2.put(`admin/contests/${contest_id}/acm-helper`, {
rankId: rank_id,
problemId: problem_id,
checked,
})
}