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))
},
)
+254 -1
View File
@@ -1,6 +1,15 @@
import { createHash } from "node:crypto"
import { eq } from "drizzle-orm"
import {
isExamTag,
STUDENT_ROLES,
TEACHER_ROLES,
type ContestClassView,
type ContestScoreboard,
type ContestScoreRow,
type ContestSubmissionInfo,
} from "@oj2/contract"
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"
import type { Context, MiddlewareHandler } from "hono"
import type { AppEnv } from "../auth/middleware"
@@ -128,3 +137,247 @@ export function requireContestAccess(
await next()
}
}
/** 老师(含出题人、超管):看得到真名、全班情况,期中期末进行中也看得到排名 */
export function isContestTeacher(user: AuthUser | null | undefined, contest: ContestRow) {
return Boolean(user && (isContestAdmin(user, contest) || TEACHER_ROLES.includes(user.adminType)))
}
/** 期中、期末进行中,学生看不到排名和每题做对人数(见契约 isExamTag) */
export function rankHiddenFor(user: AuthUser | null | undefined, contest: ContestRow) {
return (
isExamTag(contest.tag) && contestStatus(contest) === "0" && !isContestTeacher(user, contest)
)
}
const PENALTY_SECONDS = 20 * 60
/** 「5 分钟里上升 3 名」的 5 分钟 */
const RANK_MOVE_WINDOW_SECONDS = 5 * 60
type RankRow = {
rankId: number
userId: number
username: string
realName: string | null
className: string | null
solved: number
totalTime: number
info: Record<string, ContestSubmissionInfo>
}
/**
* 只算 `cutoff` 秒之前做对的题,得到那一刻的名次(用户 id → 名次)。
* 做对前的错误次数都发生在做对之前,所以「那时做对的题 + 它们的罚时」就是那一刻的榜;
* 那时还没做对的题当时错了几次不影响名次(ACM 只罚做对的题)
*/
function ranksAt(rows: RankRow[], cutoff: number) {
const scored = rows.map((row) => {
let solved = 0
let time = 0
for (const info of Object.values(row.info)) {
if (info.is_ac && info.ac_time <= cutoff) {
solved += 1
time += info.ac_time + info.error_number * PENALTY_SECONDS
}
}
return { row, solved, time }
})
scored.sort((a, b) => b.solved - a.solved || a.time - b.time || a.row.rankId - b.row.rankId)
return new Map(scored.map((item, index) => [item.row.userId, index + 1]))
}
/**
* 整张榜。名次按 做对数 ↓、罚时 ↑ 排,同分的按 rank 行 id 定个稳定顺序(不并列名次,和原来一样)。
* 只算学生、不算禁用的号;老师赛中试做的提交不上榜,也不占「最先做对」。
*/
export async function buildScoreboard(
contest: ContestRow,
viewer: AuthUser | null | undefined,
): Promise<ContestScoreboard> {
const teacher = isContestTeacher(viewer, contest)
const hidden = rankHiddenFor(viewer, contest)
const [problems, rankRows] = await Promise.all([
db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true)))
.orderBy(sql`length(${schema.problem.displayId})`, asc(schema.problem.displayId)),
db
.select({
rankId: schema.acmContestRank.id,
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
solved: schema.acmContestRank.acceptedNumber,
totalTime: schema.acmContestRank.totalTime,
info: schema.acmContestRank.submissionInfo,
})
.from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.acmContestRank.contestId, contest.id),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
),
)
.orderBy(
desc(schema.acmContestRank.acceptedNumber),
asc(schema.acmContestRank.totalTime),
asc(schema.acmContestRank.id),
),
])
// 每题学生里最早做对的那一秒
const firstAcTime = new Map<string, number>()
for (const row of rankRows) {
for (const [problemId, info] of Object.entries(row.info)) {
if (!info.is_ac) continue
const best = firstAcTime.get(problemId)
if (best === undefined || info.ac_time < best) firstAcTime.set(problemId, info.ac_time)
}
}
const elapsed = Math.floor((Date.now() - Date.parse(contest.startTime)) / 1000)
const prev =
contestStatus(contest) === "0" ? ranksAt(rankRows, elapsed - RANK_MOVE_WINDOW_SECONDS) : null
const rows: ContestScoreRow[] = rankRows.map((row, index) => ({
rankId: row.rankId,
userId: row.userId,
username: row.username,
realName: teacher ? row.realName : null,
className: row.className,
rank: index + 1,
prevRank: prev?.get(row.userId) ?? null,
solved: row.solved,
totalTime: row.totalTime,
cells: Object.fromEntries(
Object.entries(row.info).map(([problemId, info]) => [
problemId,
{
isAc: info.is_ac,
acTime: info.ac_time,
errors: info.error_number,
firstAc: info.is_ac && info.ac_time === firstAcTime.get(problemId),
checked: info.checked === true,
},
]),
),
}))
const problemRows = problems.map((problem) => {
const key = String(problem.id)
let solvedUsers = 0
let triedUsers = 0
let firstSolver: { username: string; acTime: number } | null = null
for (const row of rows) {
const cell = row.cells[key]
if (!cell) continue
triedUsers += 1
if (!cell.isAc) continue
solvedUsers += 1
if (cell.firstAc && !firstSolver)
firstSolver = { username: row.username, acTime: cell.acTime }
}
return hidden
? {
id: problem.id,
_id: problem.displayId,
title: problem.title,
solvedUsers: 0,
triedUsers: 0,
firstSolver: null,
}
: {
id: problem.id,
_id: problem.displayId,
title: problem.title,
solvedUsers,
triedUsers,
firstSolver,
}
})
if (hidden) {
const mine = rows.filter((row) => row.userId === viewer?.id)
return {
hidden,
problems: problemRows,
// 「全班最先做对」也是别人的信息,考完再说
rows: mine.map((row) => ({
...row,
rank: null,
prevRank: null,
cells: Object.fromEntries(
Object.entries(row.cells).map(([key, cell]) => [key, { ...cell, firstAc: false }]),
),
})),
}
}
return { hidden, problems: problemRows, rows }
}
/**
* 老师的「全班情况 / 成绩」:榜单 + 每人最后一次交 + 没进来的人。
* 比赛不绑班(2026-10 用户定的「都能进」),名单按参赛的人推:一个班来了一半以上,
* 就把这个班算进来,班里没交过题的人列成「没进来」。技能周那种七个班各来几个人的,一个班也不算
*/
export async function buildClassView(
contest: ContestRow,
viewer: AuthUser,
): Promise<ContestClassView> {
const board = await buildScoreboard(contest, viewer)
const last = await db.execute<{ user_id: number; create_time: string; problem_id: number }>(sql`
select distinct on (user_id) user_id, create_time, problem_id
from submission
where contest_id = ${contest.id}
order by user_id, create_time desc
`)
const lastSubmit: ContestClassView["lastSubmit"] = {}
for (const row of last) {
lastSubmit[String(row.user_id)] = { time: row.create_time, problemId: row.problem_id }
}
const came = new Map<string, number>()
for (const row of board.rows) {
if (row.className) came.set(row.className, (came.get(row.className) ?? 0) + 1)
}
const candidates = [...came.keys()]
const roster =
candidates.length === 0
? []
: await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
inArray(schema.user.className, candidates),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
),
)
.orderBy(asc(schema.user.username))
const size = new Map<string, number>()
for (const user of roster) size.set(user.className!, (size.get(user.className!) ?? 0) + 1)
const classes = candidates
.filter((name) => (came.get(name) ?? 0) * 2 >= (size.get(name) ?? 0))
.sort()
const joined = new Set(board.rows.map((row) => row.userId))
const absent = roster.filter(
(user) => classes.includes(user.className!) && !joined.has(user.userId),
)
return { ...board, lastSubmit, classes, absent }
}