Files
OJ2/apps/api/src/services/ranking.ts
T
xuyueandClaude Opus 5.5 fb11e86151 perf(排名): 一个班同时打开排名、班级对抗、按人数排的题目列表时只查一次库
这几样谁来看都一样,原来每次请求现算:全服全部时间的排名本机 16 核就要 250ms,
服务器两核还要判题。services/cache.ts 进程内缓存 10 秒,同时到达的相同请求等同一个结果:
- 排名榜面的原始数据(「我」排第几、前后一名照旧每次现算):250ms → 6ms
- 班级对抗 / 详情 / PK 共用的这学期数据:40ms → 1ms
- 题目列表按做对、做过人数排序的全表人数:45ms → 20ms
40 个人同时打开全服排名(缓存是冷的)一共 342ms。代价是做对之后最多晚 10 秒上榜。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:47:24 +08:00

607 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
STUDENT_ROLES,
type ClassBattleItem,
type ClassDetail,
type ClassPk,
type ClassPkCell,
type ClassPkClass,
type ClassPkPeriod,
type RankScope,
} from "@oj2/contract"
import {
and,
count,
countDistinct,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
like,
min,
ne,
sql,
type SQL,
} from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus } from "../judge/status"
import { sampleUser, rounded } from "../routes/helpers"
import { termStart, weekStart } from "../time"
import { cached } from "./cache"
/**
* 排名页和班级详情、班级 AI 分析共用的口径(routes/ranking.ts、routes/ai.ts):
* 「做对」= 这道题第一次做对落在这段时间里,比赛里的提交不算;入榜人群是正常状态的学生,
* 老师设成不计入排名的不算。
*/
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
export const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
export const WEEK_MS = 7 * 86_400_000
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
export const rankedStudents = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNull(schema.user.rankHiddenAt),
)!
export interface Entrant {
id: number
username: string
className: string | null
avatar: string | null
mood: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
times: number[]
stamps: string[]
}
export interface Standing {
entrant: Entrant
rank: number
solved: number
reachedAt: string | null
}
export 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
}
export 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。
*/
export 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,
mood: schema.userProfile.mood,
})
.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),
mood: person.mood?.trim() || null,
times: pairs.map(([time]) => time),
stamps: pairs.map(([, stamp]) => stamp),
}
})
}
/**
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
*/
export 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,
}))
}
/** 「要多关心的同学」:这学期做对不到这么多道 */
const CARE_BELOW = 3
/** 一档同分至少这么多人,才说「N 个人停在 x 道」 */
const PLATEAU_MIN = 5
/** 班级详情的每周人均列最近几周 */
const DETAIL_WEEKS = 6
/**
* 排名、班级对抗这些「谁来看都一样」的数据缓存多久(services/cache.ts)。10 秒:
* 一个班同时打开时只查一次;学生做对一道题马上去看排名,最多晚 10 秒看到。
*/
const SHARED_TTL = 10_000
/** 排名榜面那群人的做对时刻:同一个范围、同一个时间段谁来看都一样,「我」的部分由调用方现算 */
export function rankEntrants(scope: RankScope, group: string | null, since: string | null) {
return cached(`rank-entrants:${scope}:${group}:${since}`, SHARED_TTL, () =>
loadEntrants(audienceWhere(scope, group), since),
)
}
/** 有班级的学生,这学期的做对时刻 —— 班级对抗、班级详情、班级 PK 都从这一份算 */
async function classedEntrants() {
const term = termStart()
const where = and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
)!
return {
term,
entrants: await cached(`classed-entrants:${term}`, SHARED_TTL, () => loadEntrants(where, term)),
}
}
function battleFrom(entrants: Entrant[]) {
const week = Date.parse(weekStart())
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)
}
return [...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)
.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem)
}
/**
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
*/
export async function classBattle() {
const { entrants } = await classedEntrants()
return { items: battleFrom(entrants) }
}
function average(values: number[]) {
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
}
function median(values: number[]) {
if (!values.length) return 0
const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2
}
/**
* 班级详情,抽屉里的全部数字(也是班级 AI 分析的输入)。`withCare` 只对老师开:
* 「要多关心的同学」点了名,学生不该看到别人被点名。
*/
export async function classDetail(className: string, withCare: boolean): Promise<ClassDetail> {
const { term, entrants } = await classedEntrants()
const battle = battleFrom(entrants)
const mine = entrants.filter((entrant) => entrant.className === className)
const solved = mine.map((entrant) => entrant.times.length)
const desc = [...solved].sort((a, b) => b - a)
const tenth = Math.max(1, Math.ceil(mine.length / 10))
// 同年级在用的班:班级对抗里列着的(这学期做对过题)、班号前两位相同
const grade = className.slice(0, 2)
const gradeClasses = new Set(
battle.filter((item) => item.className.slice(0, 2) === grade).map((item) => item.className),
)
const gradeEntrants = entrants.filter((entrant) => gradeClasses.has(entrant.className!))
const gradeAvg = gradeEntrants.length
? rounded(average(gradeEntrants.map((entrant) => entrant.times.length)), 1)
: null
const groups = new Map<number, number>()
for (const value of solved) if (value > 0) groups.set(value, (groups.get(value) ?? 0) + 1)
const [plateau] = [...groups]
.filter(([, n]) => n >= PLATEAU_MIN)
.sort((a, b) => b[1] - a[1] || b[0] - a[0])
// 每周:周一零点起算,从这学期第一周到这周,只留最近几周
const firstWeek = Date.parse(weekStart(term))
const thisWeek = Date.parse(weekStart())
const weeks: ClassDetail["weeks"] = []
for (let from = firstWeek; from <= thisWeek; from += WEEK_MS) {
const inWeek = (entrant: Entrant) =>
entrant.times.filter((time) => time >= from && time < from + WEEK_MS).length
weeks.push({
weekStart: new Date(from).toISOString(),
perCapita: mine.length ? rounded(average(mine.map(inWeek)), 1) : 0,
gradeAvg: gradeEntrants.length ? rounded(average(gradeEntrants.map(inWeek)), 1) : null,
})
}
let care: ClassDetail["care"] = null
if (withCare) {
const low = mine
.filter((entrant) => entrant.times.length < CARE_BELOW)
.sort((a, b) => a.times.length - b.times.length || a.id - b.id)
const submitted = low.length
? await db
.select({ userId: schema.submission.userId, n: count() })
.from(schema.submission)
.where(
and(
inArray(
schema.submission.userId,
low.map((entrant) => entrant.id),
),
isNull(schema.submission.contestId),
gte(schema.submission.createTime, term),
),
)
.groupBy(schema.submission.userId)
: []
const counts = new Map(submitted.map((row) => [row.userId, row.n]))
care = low.map((entrant) => ({
user: sampleUser(entrant, null),
avatar: entrant.avatar,
solved: entrant.times.length,
submissions: counts.get(entrant.id) ?? 0,
}))
}
const item = battle.find((entry) => entry.className === className)
return {
className,
start: term,
members: mine.length,
rank: item?.rank ?? null,
battleSize: battle.length,
perCapita: mine.length ? rounded(average(solved), 1) : 0,
gradeAvg,
median: median(solved),
solvedMembers: solved.filter((value) => value > 0).length,
top10Avg: rounded(average(desc.slice(0, tenth)), 1),
bottom10Avg: rounded(average(desc.slice(-tenth)), 1),
plateau: plateau ? { solved: plateau[0], count: plateau[1] } : null,
distribution: solved,
weeks: weeks.slice(-DETAIL_WEEKS),
care,
} satisfies ClassDetail
}
/** 一次最多比几个班(对照格再宽就放不下了) */
export const PK_MAX_CLASSES = 8
interface PkPair {
className: string
problemId: number
solved: boolean
firstSolved: boolean
submissions: number
}
/**
* 这几个班从 `since` 起每个人每道题:交了几次、做对没有、第一次交就对没有。比赛里的提交不算,
* 入榜人群和排名页一样(不计入排名的人不算)。「第一次」是这段时间里的第一次。
*/
async function pkPairs(classNames: string[], since: string): Promise<PkPair[]> {
const rows = await db
.select({
className: schema.user.className,
problemId: schema.submission.problemId,
submissions: count(),
solved: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
first: sql<number>`(array_agg(${schema.submission.result} order by ${schema.submission.createTime}, ${schema.submission.id}))[1]`,
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
rankedStudents,
inArray(schema.user.className, classNames),
isNull(schema.submission.contestId),
gte(schema.submission.createTime, since),
),
)
.groupBy(schema.user.className, schema.submission.userId, schema.submission.problemId)
return rows.map((row) => ({
className: row.className!,
problemId: row.problemId,
solved: row.solved,
firstSolved: (SOLVED_RESULTS as number[]).includes(row.first),
submissions: row.submissions,
}))
}
/** 每个班布置过哪些题:过半的人交过就算 */
function assignedSets(pairs: PkPair[], members: Map<string, number>) {
const tried = new Map<string, Map<number, number>>()
for (const pair of pairs) {
const byProblem = tried.get(pair.className) ?? new Map<number, number>()
byProblem.set(pair.problemId, (byProblem.get(pair.problemId) ?? 0) + 1)
tried.set(pair.className, byProblem)
}
const sets = new Map<string, Set<number>>()
for (const [className, byProblem] of tried) {
const n = members.get(className) ?? 0
sets.set(
className,
new Set([...byProblem].filter(([, people]) => n > 0 && people * 2 >= n).map(([id]) => id)),
)
}
return sets
}
function memberCounts(entrants: Entrant[]) {
const members = new Map<string, number>()
for (const entrant of entrants)
members.set(entrant.className!, (members.get(entrant.className!) ?? 0) + 1)
return members
}
/**
* 只给了一个班(或者没给、用看的人自己的班)时,配一个对手:同年级在用的班里,和它一起
* 布置过的题最多的那个;一样多挑班级对抗里排得更前的(用户 2026-10-10 定的)。
* 同年级没有别的在用的班就不配。
*/
export async function pkPartner(className: string) {
const { term, entrants } = await classedEntrants()
const battle = battleFrom(entrants)
const grade = className.slice(0, 2)
const candidates = battle.filter(
(item) => item.className !== className && item.className.slice(0, 2) === grade,
)
if (!candidates.length) return null
const names = [className, ...candidates.map((item) => item.className)]
const sets = assignedSets(
await pkPairs(names, term),
memberCounts(entrants.filter((entrant) => names.includes(entrant.className!))),
)
const mine = sets.get(className) ?? new Set<number>()
const common = (name: string) => [...(sets.get(name) ?? [])].filter((id) => mine.has(id)).length
return candidates.sort((a, b) => common(b.className) - common(a.className) || a.rank - b.rank)[0]!
.className
}
/**
* 班级 PK 的全部数字(页面和 AI 分析共用)。人均、分布按 `period` 算;每周人均(赛跑)
* 一直按这学期,横轴才有东西。
*/
export async function classPk(
classNames: string[],
period: ClassPkPeriod,
mine: string | null,
): Promise<ClassPk> {
const { term, entrants: all } = await classedEntrants()
const since = period === "week" ? weekStart() : term
const sinceMs = Date.parse(since)
const battle = battleFrom(all)
const entrants = all.filter((entrant) => classNames.includes(entrant.className!))
const members = memberCounts(entrants)
const pairs = await pkPairs(classNames, since)
const assigned = assignedSets(pairs, members)
const firstWeek = Date.parse(weekStart(term))
const thisWeek = Date.parse(weekStart())
const weeks: number[] = []
for (let from = firstWeek; from <= thisWeek; from += WEEK_MS) weeks.push(from)
// 每道题、每个班:交过 / 做对 / 一次就对 / 提交次数
const cells = new Map<
number,
Map<string, Omit<ClassPkCell, "percent" | "firstPercent" | "best">>
>()
for (const pair of pairs) {
if (!assigned.get(pair.className)?.has(pair.problemId)) continue
const byClass = cells.get(pair.problemId) ?? new Map()
const cell = byClass.get(pair.className) ?? { tried: 0, solved: 0, firstTry: 0, submissions: 0 }
cell.tried++
if (pair.solved) cell.solved++
if (pair.firstSolved) cell.firstTry++
cell.submissions += pair.submissions
byClass.set(pair.className, cell)
cells.set(pair.problemId, byClass)
}
const titles = cells.size
? await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(inArray(schema.problem.id, [...cells.keys()]))
: []
const titleOf = new Map(titles.map((row) => [row.id, row]))
const lead = new Map<string, number>()
const problems: ClassPk["problems"] = []
const solo: ClassPk["solo"] = []
for (const [problemId, byClass] of [...cells].sort((a, b) => a[0] - b[0])) {
const meta = titleOf.get(problemId)
if (!meta) continue
if (byClass.size === 1) {
const [className] = byClass.keys()
solo.push({ problemId, displayId: meta.displayId, title: meta.title, className: className! })
continue
}
const row = classNames.map((className) => {
const cell = byClass.get(className)
if (!cell) return null
return {
...cell,
percent: Math.round((cell.solved / members.get(className)!) * 100),
firstPercent: Math.round((cell.firstTry / cell.tried) * 100),
best: false,
}
})
const top = Math.max(...row.map((cell) => cell?.percent ?? -1))
row.forEach((cell, index) => {
if (!cell || cell.percent !== top) return
cell.best = true
lead.set(classNames[index]!, (lead.get(classNames[index]!) ?? 0) + 1)
})
problems.push({ problemId, displayId: meta.displayId, title: meta.title, cells: row })
}
const classes = classNames.map((className) => {
const people = entrants.filter((entrant) => entrant.className === className)
const solved = people.map((entrant) => entrant.times.filter((time) => time >= sinceMs).length)
const own = pairs.filter((pair) => pair.className === className)
return {
className,
members: people.length,
perCapita: people.length ? rounded(average(solved), 1) : 0,
median: median(solved),
solvedMembers: solved.filter((value) => value > 0).length,
firstPercent: own.length
? Math.round((own.filter((pair) => pair.firstSolved).length / own.length) * 100)
: null,
lead: lead.get(className) ?? 0,
battleRank: battle.find((item) => item.className === className)?.rank ?? null,
distribution: solved,
weekly: weeks.map((from) =>
people.length
? rounded(
average(
people.map(
(entrant) =>
entrant.times.filter((time) => time >= from && time < from + WEEK_MS).length,
),
),
1,
)
: 0,
),
} satisfies ClassPkClass
})
return {
period,
start: since,
weeks: weeks.map((from) => new Date(from).toISOString()),
classes,
mine,
problems,
solo,
} satisfies ClassPk
}
/**
* 老师自己最近布置过作业的班。老师账号没有班级,这是唯一能说明「这是他的班」的数据;
* 排名页点「本班」只认这个,猜不出就交给前端(用上次选的班,没有就停在全部)。
*/
export async function lessonClassFor(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)
return lesson?.className ?? null
}
/**
* 后台「自学情况」和班级 PK 默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
*/
export async function defaultClassFor(teacherId: number) {
const lesson = await lessonClassFor(teacherId)
if (lesson) return lesson
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
}