feat(排名): 排名页重做成领奖台加全班赛道,一眼看出自己第几、该追谁、谁在追自己
Deploy / deploy (push) Canceled after 0s

- 一张榜两个开关:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部,默认本班 · 这学期。
  原来最大那张全服 Top100 里,这学期在用的 25 级学生一个都进不去
- 做对一样多的不再按「提交少的在前」排,改成先做到的在前;名次旁边写比周一升降了几名
- 「你」那张卡:第几名、再做对几道上领奖台,要追的人早你几秒、追你的人晚你几秒
- 本年级 / 全服只列前面一段和你附近一段,中间折起来,点开再看
- 班级对抗按人均做对比,另有我和对手的名次走势、这周本班、每周冠军
- 老师不上榜,选班看;每行「⋯」能把怀疑抄代码的学生设成不计入排名(新增 user.rank_hidden_at,
  首页周榜同样不算他),随时能恢复
- 去掉全服 Top10、日活两张图、在线人数和班级详情弹框里的统计词,以及它们背后的旧接口

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 07:26:15 +08:00
1 parent 0cee4ef779
commit 1c6e2b9d82
31 files changed
+6633 -1392

No files matched your search

+461
View File
@@ -0,0 +1,461 @@
import {
rankHiddenRequestSchema,
rankPeriodSchema,
rankScopeSchema,
STUDENT_ROLES,
type ClassBattleItem,
type RankBoard,
type RankPeriod,
type RankRow,
type RankScope,
type SampleUser,
type WeeklyChampion,
} from "@oj2/contract"
import {
and,
countDistinct,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
like,
min,
ne,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { dayStart, termStart, weekStart } from "../time"
import { isTeacherOrAbove, rounded, sampleUser } from "./helpers"
/**
* 排名页(设计稿「排名重设计」G1–G3)。口径见契约 `ranking.ts` 的注释:
* 做对多的在前,一样多的先做到的在前;「做对」= 这道题第一次做对落在这段时间里。
*/
export const rankingRoutes = new Hono<AppEnv>()
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */
const BOARD_HEAD = 16
/** 我附近一段:前面 5 个、后面 7 个(前面的人是要追的,多给后面几个看「谁在追我」) */
const WINDOW_BEFORE = 5
const WINDOW_AFTER = 7
/** 没有「我」(老师、没上榜)时榜面给多少 */
const BOARD_HEAD_NO_ME = 30
/** 走势图往回看几周 */
const TREND_WEEKS = 4
/** 每周冠军列几周 */
const CHAMPION_WEEKS = 3
const WEEK_MS = 7 * 86_400_000
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
const rankedStudents = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNull(schema.user.rankHiddenAt),
)!
interface Entrant {
id: number
username: string
className: string | null
avatar: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
times: number[]
stamps: string[]
}
interface Standing {
entrant: Entrant
rank: number
solved: number
reachedAt: string | null
}
function audienceWhere(scope: RankScope, className: string | null) {
if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))!
if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))!
return rankedStudents
}
function avatarOf(avatar: string | null) {
return !avatar || avatar.endsWith("/default.png") ? null : avatar
}
/**
* 这群人、从 `since` 起每道题第一次做对的时刻。
*
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
* contest_id IS NULL),全服全部历史实测 24ms。
*/
async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
const firstSolved = min(schema.submission.createTime)
const [people, solves] = await Promise.all([
db
.select({
id: schema.user.id,
username: schema.user.username,
className: schema.user.className,
avatar: schema.userProfile.avatar,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where),
db
.select({ userId: schema.submission.userId, at: firstSolved })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
where,
isNull(schema.submission.contestId),
inArray(schema.submission.result, SOLVED_RESULTS),
),
)
.groupBy(schema.submission.userId, schema.submission.problemId)
.having(since ? gte(firstSolved, since) : undefined),
])
const byUser = new Map<number, string[]>()
for (const row of solves) {
if (!row.at) continue
const list = byUser.get(row.userId)
if (list) list.push(row.at)
else byUser.set(row.userId, [row.at])
}
return people.map((person) => {
const pairs = (byUser.get(person.id) ?? [])
.map((stamp) => [Date.parse(stamp), stamp] as const)
.sort((a, b) => a[0] - b[0])
return {
id: person.id,
username: person.username,
className: person.className,
avatar: avatarOf(person.avatar),
times: pairs.map(([time]) => time),
stamps: pairs.map(([, stamp]) => stamp),
}
})
}
/**
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
*/
function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) {
const list = entrants
.map((entrant) => {
let solved = entrant.times.length
if (cutoff !== null) {
solved = 0
while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++
}
return {
entrant,
solved,
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
}
})
.filter((row) => includeZero || row.solved > 0)
.sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id)
return list.map((row, index): Standing => ({
entrant: row.entrant,
rank: index + 1,
solved: row.solved,
reachedAt: row.reachedAt,
}))
}
function toRow(standing: Standing, change: number | null): RankRow {
const { entrant } = standing
return {
rank: standing.rank,
user: sampleUser(entrant, null),
avatar: entrant.avatar,
className: entrant.className,
solved: standing.solved,
reachedAt: standing.reachedAt,
change,
} satisfies RankRow
}
function periodStart(period: RankPeriod) {
if (period === "week") return weekStart()
if (period === "term") return termStart()
return null
}
/**
* 老师打开排名页默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
*/
async function defaultClassFor(teacherId: number) {
const [lesson] = await db
.select({ className: schema.classLesson.className })
.from(schema.classLesson)
.where(eq(schema.classLesson.createdBy, teacherId))
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt))
.limit(1)
if (lesson) return lesson.className
const users = countDistinct(schema.submission.userId)
const [busiest] = await db
.select({ className: schema.user.className, users })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
if (busiest?.className) return busiest.className
// 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班
const [latest] = await db
.select({ className: schema.user.className })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, "")))
.orderBy(desc(schema.submission.createTime))
.limit(1)
return latest?.className ?? null
}
/**
* 这次请求看的是哪个班 / 哪个年级。学生只能看自己的班和年级;老师用 `className` 选,
* 不给就猜(`defaultClassFor`)。返回 undefined 表示该报「没有班级」。
*/
async function resolveGroup(
user: AppEnv["Variables"]["user"],
scope: RankScope,
requested: string | undefined,
): Promise<string | null | undefined> {
if (scope === "all") return null
let className = user?.className || null
if (isTeacherOrAbove(user)) className = requested || (await defaultClassFor(user!.id))
if (!className) return undefined
return scope === "class" ? className : className.slice(0, 2)
}
rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
const user = c.get("user")
const scope = rankScopeSchema.safeParse(c.req.query("scope"))
const period = rankPeriodSchema.safeParse(c.req.query("period"))
if (!scope.success || !period.success)
return failure(c, 400, "invalid-query", "排名范围或时间段不对")
const group = await resolveGroup(user, scope.data, c.req.query("className")?.trim())
if (group === undefined) return failure(c, 400, "class-missing", "没有班级,看不了本班和本年级")
if (scope.data === "grade" && !/^\d{2}$/.test(group!))
return failure(c, 400, "invalid-grade", "年级不对")
const start = periodStart(period.data)
const changeSince = period.data === "week" ? dayStart() : weekStart()
const includeZero = scope.data === "class"
const isTeacher = isTeacherOrAbove(user)
const [entrants, mine, hiddenUsers] = await Promise.all([
loadEntrants(audienceWhere(scope.data, group), start),
user && !isTeacher
? db
.select({ rankHiddenAt: schema.user.rankHiddenAt })
.from(schema.user)
.where(eq(schema.user.id, user.id))
.then(([row]) => row)
: undefined,
isTeacher && scope.data === "class"
? db
.select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(
and(
eq(schema.user.className, group!),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNotNull(schema.user.rankHiddenAt),
),
)
.orderBy(schema.user.username)
: [],
])
const now = standings(entrants, null, includeZero)
const before = new Map(
standings(entrants, Date.parse(changeSince), includeZero)
.filter((row) => row.solved > 0)
.map((row) => [row.entrant.id, row.rank]),
)
const rowOf = (standing: Standing | undefined) =>
standing
? toRow(
standing,
before.has(standing.entrant.id) ? before.get(standing.entrant.id)! - standing.rank : null,
)
: null
// 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有对手)
const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1
const me = meIndex >= 0 ? now[meIndex] : undefined
const full = includeZero || c.req.query("full") === "1"
let picked = now
if (!full) {
const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME
picked = now.filter(
(_, index) =>
index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER),
)
}
let trend: RankBoard["trend"] = null
if (me && period.data !== "week") {
const thisWeek = Date.parse(weekStart())
const points = Array.from(
{ length: TREND_WEEKS },
(_, k) => thisWeek - (TREND_WEEKS - 1 - k) * WEEK_MS,
).filter((point) => !start || point > Date.parse(start))
const tracked = [now[meIndex - 1], me, now[meIndex + 1]].filter((row): row is Standing => !!row)
const snapshots = points.map((point) => {
const ranks = new Map(
standings(entrants, point, includeZero)
.filter((row) => row.solved > 0)
.map((row) => [row.entrant.id, row.rank]),
)
return ranks
})
trend = {
points: [...points.map((point) => new Date(point).toISOString()), new Date().toISOString()],
series: tracked.map((row) => ({
userId: row.entrant.id,
ranks: [...snapshots.map((ranks) => ranks.get(row.entrant.id) ?? null), row.rank],
})),
}
}
return success(c, {
scope: scope.data,
period: period.data,
className: group,
start,
changeSince,
total: now.length,
rows: picked.map((row) => rowOf(row)!),
complete: picked.length === now.length,
me: rowOf(me),
ahead: rowOf(meIndex > 0 ? now[meIndex - 1] : undefined),
behind: rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined),
trend,
hidden: !!mine?.rankHiddenAt,
hiddenUsers: hiddenUsers.map((row): SampleUser => sampleUser(row, null)),
} satisfies RankBoard)
})
/**
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
*/
rankingRoutes.get("/rankings/classes", async (c) => {
const where = and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
)!
const term = termStart()
const week = Date.parse(weekStart())
const entrants = await loadEntrants(where, term)
const classes = new Map<string, { members: number; term: number; week: number }>()
for (const entrant of entrants) {
const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 }
entry.members++
entry.term += entrant.times.length
entry.week += entrant.times.filter((time) => time >= week).length
classes.set(entrant.className!, entry)
}
const result = [...classes]
.filter(([, entry]) => entry.term > 0)
.map(([className, entry]) => ({
className,
members: entry.members,
perCapita: rounded(entry.term / entry.members, 1),
weekGain: rounded(entry.week / entry.members, 1),
}))
.sort((a, b) => b.perCapita - a.perCapita || b.weekGain - a.weekGain)
return success(
c,
result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem),
)
})
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
rankingRoutes.get("/rankings/champions", optionalAuth, async (c) => {
const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim())
if (!className) return failure(c, 400, "class-missing", "没有班级")
const thisWeek = Date.parse(weekStart())
const since = new Date(thisWeek - CHAMPION_WEEKS * WEEK_MS).toISOString()
const entrants = await loadEntrants(audienceWhere("class", className), since)
const champions: WeeklyChampion[] = []
for (let k = 1; k <= CHAMPION_WEEKS; k++) {
const from = thisWeek - k * WEEK_MS
const inWeek = entrants.map((entrant) => {
const keep = entrant.times
.map((time, index) => [time, entrant.stamps[index]!] as const)
.filter(([time]) => time >= from && time < from + WEEK_MS)
return {
...entrant,
times: keep.map(([time]) => time),
stamps: keep.map(([, stamp]) => stamp),
}
})
const [top] = standings(inWeek, null, false)
if (!top) continue
champions.push({
weekStart: new Date(from).toISOString(),
user: sampleUser(top.entrant, null),
avatar: top.entrant.avatar,
solved: top.solved,
} satisfies WeeklyChampion)
}
return success(c, champions)
})
/**
* 老师把一个学生设成不计入排名 / 恢复。怀疑抄代码时用(用户 2026-10-10 定的)。
* 所有排名页的榜和首页周榜都不出现他;他自己看得到「不计入排名」;比赛排名不受影响。
*/
rankingRoutes.put("/rankings/hidden/:userId", requireTeacher, async (c) => {
const userId = Number(c.req.param("userId"))
if (!Number.isInteger(userId)) return failure(c, 400, "invalid-user", "用户不对")
const body = await parseBody(c, rankHiddenRequestSchema)
if (!body.success) return body.response
const updated = await db
.update(schema.user)
.set({ rankHiddenAt: body.data.hidden ? new Date().toISOString() : null })
.where(and(eq(schema.user.id, userId), inArray(schema.user.adminType, [...STUDENT_ROLES])))
.returning({ id: schema.user.id })
if (!updated.length) return failure(c, 404, "user-not-found", "没有这个学生")
return success(c, null)
})