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

+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 }
}