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:
1 parent
4ef2794552
commit
b94e028ff4
10 files changed
+528
-740
No files matched your search
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user