feat(比赛): 榜单一次给全,带名次变化和每题谁最先做对;期中、期末考试中学生看不到排名;老师能看全班情况

- GET /contests/:id/scoreboard 代替 /rank:整张榜、5 分钟前的名次、每题做对 / 做过人数和学生里最先做对的人(不再用 is_first_ac,老师赛中试做会占掉它)
- 期中、期末进行中:学生只拿到自己那一行,人数清零、「全班最先」也不给,考完一起公布
- GET /contests/:id/class-view(老师):真名、每人最后一次交、没进来的人 —— 比赛不绑班,来了一半以上的班才算这场的班
- 比赛列表带题数、参赛人数、我的名次和做对数,能筛「我参加过的」「期中 · 期末」
- PUT /admin/contests/:id/problems/order:比赛题按顺序重编 1、2、3,比赛结束后不让动
- 后台「审核」列表接口去掉,并进前台老师的成绩页;「看过了」的写接口留着

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 20:58:59 +08:00
1 parent 4ef2794552
commit b94e028ff4
10 files changed
+528 -740

No files matched your search

+3 -55
View File
@@ -2,7 +2,6 @@ import {
createContestRequestSchema,
updateAcmHelperRequestSchema,
updateContestRequestSchema,
type AcmHelperItem,
type AdminContest,
type AdminContestList,
} from "@oj2/contract"
@@ -268,60 +267,9 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
return success(c, await serialize(row!), 201)
})
// ---------------------------------------------------------------- ACM 赛后核查
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, id)).limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const [problems, ranks] = await Promise.all([
db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(eq(schema.problem.contestId, id)),
db
.select({
id: schema.acmContestRank.id,
username: schema.user.username,
realName: schema.userProfile.realName,
submissionInfo: schema.acmContestRank.submissionInfo,
acceptedNumber: schema.acmContestRank.acceptedNumber,
})
.from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.acmContestRank.contestId, id)),
])
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId]))
const results = []
for (const rank of ranks) {
if (rank.acceptedNumber <= 0) continue
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
if (info.is_ac !== true) continue
results.push({
id: rank.id,
username: rank.username,
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
// 接口已由 requireTeacher + ownedBy 双重把关。
realName: rank.realName,
problemId,
problemDisplayId: displayIds.get(problemId) ?? problemId,
acInfo: info,
checked: info.checked === true,
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
})
}
}
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
results.sort((left, right) => right._acTime - left._acTime)
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
})
// ---------------------------------------------------------------- 赛后核查
// 「看过了」的标记。列表原来是后台单独一页(审核),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 })
+56
View File
@@ -1,5 +1,6 @@
import {
addContestProblemRequestSchema,
reorderContestProblemsRequestSchema,
astCheckRequestSchema,
generateTestInputsRequestSchema,
isSqlProblem,
@@ -797,6 +798,61 @@ adminProblemRoutes.post(
},
)
/**
* 比赛题按给定顺序重新编号 1、2、3…(新建比赛页拖动排序用)。
* 榜单、提交、草稿都按题目 id 记,改编号不影响已经交的东西;比赛结束后就不让动了,
* 免得赛后对着「第 3 题」讲评时题号变了。
* 唯一约束是 (_id, contest_id),直接改会和还没改的那道撞,所以先统一挪到临时编号再写回来
*/
adminProblemRoutes.put(
"/contests/:contestId/problems/order",
requireProblemPermission,
async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const parsed = await parseBody(c, reorderContestProblemsRequestSchema)
if (!parsed.success) return parsed.response
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
if (contestStatus(contest) === "-1")
return failure(c, 409, "contest-ended", "比赛已经结束,不能再改题号")
const existing = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(eq(schema.problem.contestId, contestId))
const ids = parsed.data.problemIds
const all = new Set(existing.map((row) => row.id))
if (
new Set(ids).size !== ids.length ||
ids.length !== all.size ||
ids.some((id) => !all.has(id))
) {
return failure(c, 400, "problem-list-mismatch", "题目列表和比赛里的对不上,请刷新后再排")
}
await db.transaction(async (tx) => {
for (const id of ids) {
await tx
.update(schema.problem)
.set({ displayId: `__${id}` })
.where(eq(schema.problem.id, id))
}
for (const [index, id] of ids.entries()) {
await tx
.update(schema.problem)
.set({ displayId: String(index + 1) })
.where(eq(schema.problem.id, id))
}
})
return success(c, null)
},
)
// ---------------------------------------------------------------- 测试用例
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
+116 -68
View File
@@ -1,12 +1,11 @@
import {
EXAM_TAGS,
isSqlProblem,
contestPasswordRequestSchema,
STUDENT_ROLES,
type Contest,
type ContestAccess,
type ContestList,
type ContestRank,
type ContestRankItem,
type ProblemDetail,
type ProblemListItem,
} from "@oj2/contract"
@@ -23,8 +22,11 @@ import {
checkContestPassword,
contestDetailsAllowed,
contestStatus,
buildClassView,
buildScoreboard,
findAccessibleContest,
isContestAdmin,
isContestTeacher,
rankHiddenFor,
requireContestAccess,
type ContestEnv,
} from "../services/contest"
@@ -71,7 +73,59 @@ function serializeContest(
} satisfies Contest
}
contestRoutes.get("/contests", async (c) => {
/**
* 列表上要的几个数:每场几道题、几个学生交过,以及「我」在每场的名次和做对数。
* 名次和榜单同一个口径(学生、没禁用、做对数 ↓ 罚时 ↑ id ↑),期中期末进行中不给名次
*/
async function listExtras(
rows: (typeof schema.contest.$inferSelect)[],
userId: number | undefined,
) {
const ids = rows.map((row) => row.id)
if (ids.length === 0) return { problems: new Map(), people: new Map(), mine: new Map() }
const idList = sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)
const studentList = sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
sql`, `,
)
const [problemRows, peopleRows, mineRows] = await Promise.all([
db.execute<{ contest_id: number; n: number }>(sql`
select contest_id, count(*)::int as n from ${schema.problem}
where contest_id in (${idList}) and visible group by contest_id
`),
db.execute<{ contest_id: number; n: number }>(sql`
select r.contest_id, count(*)::int as n from ${schema.acmContestRank} r
join ${schema.user} u on u.id = r.user_id
where r.contest_id in (${idList}) and u.admin_type in (${studentList}) and not u.is_disabled
group by r.contest_id
`),
userId
? db.execute<{ contest_id: number; solved: number; rank: number }>(sql`
select r.contest_id, r.accepted_number as solved,
1 + (select count(*)::int from ${schema.acmContestRank} o
join ${schema.user} ou on ou.id = o.user_id
where o.contest_id = r.contest_id and ou.admin_type in (${studentList})
and not ou.is_disabled and o.user_id <> r.user_id
and (o.accepted_number > r.accepted_number
or (o.accepted_number = r.accepted_number and o.total_time < r.total_time)
or (o.accepted_number = r.accepted_number and o.total_time = r.total_time
and o.id < r.id))) as rank
from ${schema.acmContestRank} r
where r.user_id = ${userId} and r.contest_id in (${idList})
`)
: Promise.resolve([]),
])
return {
problems: new Map(problemRows.map((row) => [row.contest_id, row.n])),
people: new Map(peopleRows.map((row) => [row.contest_id, row.n])),
mine: new Map(mineRows.map((row) => [row.contest_id, row])),
}
}
contestRoutes.get("/contests", optionalAuth, 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()
@@ -80,7 +134,21 @@ contestRoutes.get("/contests", async (c) => {
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 (tag === "考试") filters.push(inArray(schema.contest.tag, [...EXAM_TAGS]))
else if (tag) filters.push(eq(schema.contest.tag, tag))
const user = c.get("user")
// 「我参加过的」:交过题的才算(榜单行是第一次交题时建的)
if (c.req.query("joined") === "1" && user) {
filters.push(
inArray(
schema.contest.id,
db
.select({ id: schema.acmContestRank.contestId })
.from(schema.acmContestRank)
.where(eq(schema.acmContestRank.userId, user.id)),
),
)
}
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")
@@ -96,14 +164,28 @@ contestRoutes.get("/contests", async (c) => {
.limit(limit)
.offset(offset),
])
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
const [byId, extras] = await Promise.all([
creators([...new Set(rows.map((row) => row.createdById))]),
listExtras(rows, user?.id),
])
return success(c, {
results: rows.map((row) =>
serializeContest(
row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
),
),
results: rows.map((row) => {
const mine = extras.mine.get(row.id)
const total = extras.people.get(row.id) ?? 0
return {
...serializeContest(
row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
),
problemCount: extras.problems.get(row.id) ?? 0,
participantCount: total,
mine: !user
? undefined
: mine
? { rank: rankHiddenFor(user, row) ? null : mine.rank, total, solved: mine.solved }
: null,
} satisfies Contest
}),
total: totalRow[0]?.value ?? 0,
} satisfies ContestList)
})
@@ -224,7 +306,8 @@ contestRoutes.get(
.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))
// 先按长度再按字面:编号是文本,直接排会变成 1、10、11…、2
.orderBy(sql`length(${schema.problem.displayId})`, 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)
@@ -323,58 +406,23 @@ contestRoutes.get(
},
)
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)
})
contestRoutes.get(
"/contests/:id/scoreboard",
optionalAuth,
requireContestAccess("ranks"),
async (c) => success(c, await buildScoreboard(c.get("contest")!, c.get("user"))),
)
contestRoutes.get(
"/contests/:id/class-view",
optionalAuth,
requireContestAccess("ranks"),
async (c) => {
const contest = c.get("contest")!
const user = c.get("user")
if (!user || !isContestTeacher(user, contest)) {
return failure(c, 403, "teacher-only", "只有老师能看全班情况")
}
return success(c, await buildClassView(contest, user))
},
)