feat(班级PK): 班级 PK 只比两个班都布置过的同一批题,按做对的人占几成分输赢;登录了就能看,AI 分析由服务端现算

- 新接口 GET /rankings/pk:一个班过半的人交过算布置过,至少两个班布置过的题才比;
  显示的整数百分比一样算平;口径和排名页一样(这学期、比赛不算、不计入排名的人不算)
- 不给班就用自己的班(老师用默认的班),只给一个班就配同年级一起布置过的题最多的班,
  一样多挑班级对抗排得更前的
- 去掉 POST /classes/comparison(原来不登录也能查,综合分、四分位数、优秀率都按所选几个班算)
- /ai/class-pk-analysis 只收班号和时间段,数字和页面上的是同一份

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 09:06:21 +08:00
1 parent 3c6e4a1e7c
commit d83804b814
7 files changed
+390 -240

No files matched your search

+1 -191
View File
@@ -1,6 +1,5 @@
import {
isSqlProblem,
classComparisonRequestSchema,
STUDENT_ROLES,
classLessonRequestSchema,
type ClassActivity,
@@ -8,26 +7,11 @@ import {
type ClassBoardStudent,
type ClassLesson,
type ClassActivityProblem,
type ClassComparison,
type ClassComparisonResponse,
type LastVisit,
type LessonLanguage,
FLOWCHART_PASS_GRADES,
} from "@oj2/contract"
import {
and,
asc,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
like,
lt,
lte,
sql,
} from "drizzle-orm"
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
@@ -37,75 +21,9 @@ import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { accepted } from "../services/learning-stats"
import { calendarDay, dayStart, localTime } from "../time"
import { rounded } from "./helpers"
export const classroomRoutes = new Hono<AppEnv>()
interface ClassUser {
userId: number
username: string
className: string
acceptedNumber: number
submissionNumber: number
}
/**
* 入班学生的 AC/提交数。`gradePrefix` 是年级(班号形如 `241` = 24 级 1 班),
* 走 SQL 的 like 而不是拉全表再在内存里 startsWith —— 班级榜每换一次年级就要跑一遍,
* 没必要每次都把全校一千多号人搬进进程。年级在调用处已校验为纯数字,不含 like 通配符。
*/
async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
const filters = [
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
sql`${schema.user.className} is not null`,
]
if (classNames) filters.push(inArray(schema.user.className, classNames))
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
const rows = await db
.select({
userId: schema.user.id,
username: schema.user.username,
className: schema.user.className,
acceptedNumber: schema.userProfile.acceptedNumber,
submissionNumber: schema.userProfile.submissionNumber,
})
.from(schema.user)
.innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(...filters))
return rows.filter((row): row is ClassUser => row.className !== null)
}
function mean(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
}
function quantile(values: number[], p: number) {
if (values.length <= 1) return values[0] ?? 0
const sorted = [...values].sort((a, b) => a - b)
const position = (sorted.length + 1) * p - 1
if (position <= 0) return sorted[0]!
if (position >= sorted.length - 1) return sorted.at(-1)!
const lower = Math.floor(position)
const fraction = position - lower
return sorted[lower]! + (sorted[lower + 1]! - sorted[lower]!) * fraction
}
function sampleStdDev(values: number[]) {
if (values.length <= 1) return 0
const average = mean(values)
return Math.sqrt(
values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1),
)
}
/**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
@@ -892,111 +810,3 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
})
return success(c, null)
})
classroomRoutes.post("/classes/comparison", async (c) => {
const parsed = await parseBody(c, classComparisonRequestSchema, "At least one class is required")
if (!parsed.success) return parsed.response
const users = await loadClassUsers(parsed.data.classNames)
const allAc = users.map((user) => user.acceptedNumber)
const globalQ1 = quantile(allAc, 0.25)
const globalQ3 = quantile(allAc, 0.75)
const byClass = new Map<string, ClassUser[]>()
for (const user of users)
byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
let recentByUser = new Map<number, Set<number>>()
let recentSubmissionCount = new Map<string, number>()
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
if (hasTimeRange) {
const rows = await db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
inArray(
schema.submission.userId,
users.map((user) => user.userId),
),
gte(schema.submission.createTime, parsed.data.startTime!),
lte(schema.submission.createTime, parsed.data.endTime!),
),
)
const userClass = new Map(users.map((user) => [user.userId, user.className]))
for (const row of rows) {
const className = userClass.get(row.userId)
if (!className) continue
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1)
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) {
const set = recentByUser.get(row.userId) ?? new Set<number>()
set.add(row.problemId)
recentByUser.set(row.userId, set)
}
}
}
const comparisons = [...byClass].map(([className, members]) => {
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a)
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a)
const userCount = members.length
const topCount = Math.max(1, Math.ceil(userCount * 0.1))
const bottomCount = topCount
const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
const totalAc = ac.reduce((sum, value) => sum + value, 0)
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
const base: ClassComparison = {
className,
userCount,
totalAc,
totalSubmission,
avgAc: rounded(mean(ac)),
medianAc: rounded(median(ac)),
q1Ac: rounded(quantile(ac, 0.25)),
q3Ac: rounded(quantile(ac, 0.75)),
iqr: rounded(quantile(ac, 0.75) - quantile(ac, 0.25)),
stdDev: rounded(sampleStdDev(ac)),
top10Avg: rounded(mean(ac.slice(0, topCount))),
middle80Avg: rounded(mean(middle)),
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
excellentRate: rounded((ac.filter((value) => value >= globalQ3).length / userCount) * 100),
passRate: rounded((ac.filter((value) => value >= globalQ1).length / userCount) * 100),
activeRate: rounded((submissions.filter((value) => value > 0).length / userCount) * 100),
acRate: totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
compositeScore: 0,
}
if (hasTimeRange) {
const recent = members
.map((member) => recentByUser.get(member.userId)?.size ?? 0)
.sort((a, b) => b - a)
base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0)
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
base.recentAvgAc = rounded(mean(recent))
base.recentMedianAc = rounded(median(recent))
base.recentTop10Avg = rounded(
mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))),
)
base.recentActiveCount = recent.filter((value) => value > 0).length
}
return base
})
const maxMedian = Math.max(1, ...comparisons.map((item) => item.medianAc))
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
for (const item of comparisons) {
item.compositeScore = rounded(
0.4 * ((item.medianAc / maxMedian) * 100) +
0.15 * ((item.middle80Avg / maxMiddle) * 100) +
0.2 * item.activeRate +
0.15 * item.passRate +
0.1 * item.excellentRate,
1,
)
}
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
return success(c, {
comparisons,
hasTimeRange,
} satisfies ClassComparisonResponse)
})