Some checks failed
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies
出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:
- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
problem;列表为空时才碰巧不炸,所以一直没人报。
两个都是读出侧校验自己造出来的故障,不是它拦住的故障。
## 校验责任挪回写入侧
- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
.language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
.sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
报文是真边界,失败返回 null 而不是 500。
另删掉两处与契约等价的本地 stringArray(routes/helpers.ts、routes/submission.ts)。
## 文档
CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。
## 验证
- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
9.5 KiB
TypeScript
215 lines
9.5 KiB
TypeScript
import {
|
|
classComparisonRequestSchema,
|
|
STUDENT_ROLES,
|
|
type ClassComparison,
|
|
type ClassComparisonResponse,
|
|
type ClassRankItem,
|
|
type ClassUserRank,
|
|
} from "@oj2/contract"
|
|
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
|
|
import { Hono } from "hono"
|
|
|
|
import { requireAuth, type AppEnv } from "../auth/middleware"
|
|
import { db, schema } from "../db"
|
|
import { failure, success } from "../http"
|
|
import { JudgeStatus } from "../judge/status"
|
|
import { queryInteger, 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))
|
|
}
|
|
|
|
classroomRoutes.get("/rankings/classes", async (c) => {
|
|
const grade = c.req.query("grade")?.trim()
|
|
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
|
|
const users = await loadClassUsers(undefined, grade)
|
|
const groups = new Map<string, ClassUser[]>()
|
|
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
|
const result = [...groups].map(([className, members]) => {
|
|
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
|
|
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
|
|
return {
|
|
className,
|
|
userCount: members.length,
|
|
totalAc,
|
|
totalSubmission,
|
|
avgAc: rounded(totalAc / members.length),
|
|
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
|
|
}
|
|
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
|
|
return success(c, result.map((item, index) => ({ ...item, rank: index + 1 } satisfies ClassRankItem)))
|
|
})
|
|
|
|
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
|
const user = c.get("user")!
|
|
if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息")
|
|
const members = (await loadClassUsers([user.className])).sort(
|
|
(a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber,
|
|
)
|
|
const ranks = members.map((member, index) => ({
|
|
userId: member.userId,
|
|
username: member.username,
|
|
acceptedNumber: member.acceptedNumber,
|
|
submissionNumber: member.submissionNumber,
|
|
rank: index + 1,
|
|
}))
|
|
const myRank = ranks.find((rank) => rank.userId === user.id)?.rank ?? -1
|
|
const showAll = c.req.query("scope") === "all"
|
|
let selected = ranks
|
|
if (showAll) {
|
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
|
selected = ranks.slice(offset, offset + limit)
|
|
} else if (myRank > 0 && ranks.length > 10) {
|
|
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
|
|
selected = ranks.slice(start, start + 10)
|
|
}
|
|
return success(c, { className: user.className, myRank, total: ranks.length, ranks: selected } satisfies ClassUserRank)
|
|
})
|
|
|
|
classroomRoutes.post("/classes/comparison", async (c) => {
|
|
const parsed = classComparisonRequestSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!parsed.success) return failure(c, 400, "invalid-request", "At least one class is required")
|
|
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)
|
|
})
|