Files
web/server/src/services/gradebook.ts
T
xuyueandClaude Opus 5.5 26f05d1d76
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build:staging, school, 8822) (push) Canceled after 0s
refactor: 后端从 Django 换成 Bun + SQLite
Django 多个 worker 内存占用 1-2 GB,换成 Bun 单进程后约 25 MB,
同时去掉 Postgres 和 Redis,只剩 Caddy + Bun 两个容器。

- server/:Hono + bun:sqlite + Bun 原生 WebSocket,接口与原 django-ninja
  保持兼容;表结构、时间/UUID 存储格式、密码哈希与 Django 一致,旧数据可直接使用
- 评分加权、贝叶斯平滑、成绩册的计算结果与原实现逐位一致(复刻 Python round/sum)
- scripts/:Postgres → SQLite 迁移、新建超管、提示词层级标注、重算分数
- 前端:Django Admin 去掉后,用户新建/修改改为页面内弹窗;
  非超管的挑战公开按钮置灰
- 权限不足返回 JSON 403(原来是重定向到不存在的登录页)
- docker-compose / Caddyfile / dev.sh / CLAUDE.md 更新,删除 AGENTS.md
- 服务器切换步骤见 docs/migrate-to-bun.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:45:09 +08:00

307 lines
9.8 KiB
TypeScript

import { all } from "../db"
import { uuidOut } from "../db"
import { HttpError } from "../http"
import { pyRound, pySum } from "./pyround"
export type GradebookTaskType = "tutorial" | "challenge"
type Grade = "A" | "B" | "C" | "D" | "E"
export interface GradebookFilters {
classname: string
task_type?: GradebookTaskType
username?: string
include_all_tasks?: boolean
}
interface GradebookTask {
id: number
display: number
title: string
task_type: string
submitted_count: number
coverage: number
included: boolean
}
interface GradebookRow {
user_id: number
username: string
classname: string
rank: number
grade: Grade
assessment_grade: Grade
assessment_score: number
top3_score: number
scores: Record<number, { score: number; submitted: boolean; submission_id: string | null }>
tutorial_total: number
challenge_total: number
total_score: number
average_score: number | null
submitted_task_count: number
missing_task_count: number
}
export interface Gradebook {
classname: string
classes: string[]
task_count: number
included_task_count: number
student_count: number
coverage_threshold_count: number
tasks: GradebookTask[]
rows: GradebookRow[]
}
const score = (value: number | null | undefined) => pyRound(value || 0, 2)
// Python 字符串比较(按码点),不能用 localeCompare
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function gradeForRank(rank: number, n: number): Grade {
if (rank <= Math.ceil(0.3 * n)) return "A"
if (rank <= Math.ceil(0.7 * n)) return "B"
if (rank <= Math.ceil(0.9 * n)) return "C"
if (rank <= Math.ceil(0.95 * n)) return "D"
return "E"
}
// 考核等级:A10% B20% C40% D20% E10%,按最高三次提交均分排名
function assessmentGradeForRank(rank: number, n: number): Grade {
if (rank <= Math.ceil(0.1 * n)) return "A"
if (rank <= Math.ceil(0.3 * n)) return "B"
if (rank <= Math.ceil(0.7 * n)) return "C"
if (rank <= Math.ceil(0.9 * n)) return "D"
return "E"
}
// 考核分数:各等级对应的分数段(高分→低分),段内按名次线性铺开;最高分封顶 99
const ASSESSMENT_SCORE_BANDS: Record<Grade, [number, number]> = {
A: [92, 99],
B: [84, 91],
C: [76, 83],
D: [68, 75],
E: [60, 67],
}
function assessmentScore(indexInGrade: number, gradeSize: number, grade: Grade) {
const [low, high] = ASSESSMENT_SCORE_BANDS[grade]
if (gradeSize <= 1) return high
// 段内第 1 名拿段顶,最后 1 名拿段底
return pyRound(high - (indexInGrade / (gradeSize - 1)) * (high - low))
}
export function buildGradebook(filters: GradebookFilters): Gradebook {
const classname = filters.classname?.trim() ?? ""
if (!classname) throw new HttpError(400, "请选择班级")
const classes = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
).map((r) => r.classname)
const students = all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user
WHERE role IN ('normal', 'admin') AND classname = $classname
ORDER BY username, id`,
{ classname },
)
const studentCount = students.length
const coverageThreshold = studentCount ? Math.ceil(studentCount * 0.5) : 0
const typeFilter = filters.task_type ? `AND t.task_type = $task_type` : ""
const typeParams = filters.task_type ? { task_type: filters.task_type } : {}
const taskRows = all<{ id: number; display: number; title: string; task_type: string; submitted_count: number }>(
`SELECT t.id, t.display, t.title, t.task_type, COUNT(DISTINCT s.user_id) AS submitted_count
FROM submission_submission s
JOIN task_task t ON t.id = s.task_id
JOIN account_user u ON u.id = s.user_id
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname ${typeFilter}
GROUP BY t.id`,
{ classname, ...typeParams },
)
taskRows.sort(
(a, b) =>
(a.task_type === "tutorial" ? 0 : 1) - (b.task_type === "tutorial" ? 0 : 1) ||
a.display - b.display ||
a.id - b.id,
)
const allTasks: GradebookTask[] = taskRows.map((t) => ({
id: t.id,
display: t.display,
title: t.title,
task_type: t.task_type,
submitted_count: t.submitted_count,
coverage: studentCount ? score(t.submitted_count / studentCount) : 0,
included: studentCount > 0 && t.submitted_count >= coverageThreshold,
}))
const tasks = allTasks.filter((t) => filters.include_all_tasks || t.included)
const visibleTaskIds = new Set(tasks.map((t) => t.id))
const includedTaskCount = tasks.filter((t) => t.included).length
// 每个 (学生, 任务) 取最高分的提交;同分取最新
const best = new Map<string, { id: string; score: number }>()
if (studentCount && visibleTaskIds.size) {
const subs = all<{ id: string; user_id: number; task_id: number; score: number }>(
`SELECT s.id, s.user_id, s.task_id, s.score
FROM submission_submission s JOIN account_user u ON u.id = s.user_id
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname
ORDER BY s.user_id, s.task_id, s.score DESC, s.created DESC, s.id DESC`,
{ classname },
)
for (const s of subs) {
if (!visibleTaskIds.has(s.task_id)) continue
const key = `${s.user_id}:${s.task_id}`
if (!best.has(key)) best.set(key, s)
}
}
let rows: GradebookRow[] = students.map((student) => {
const scores: GradebookRow["scores"] = {}
let tutorialTotal = 0
let challengeTotal = 0
const submittedScores: number[] = []
let submittedTaskCount = 0
let missingTaskCount = 0
for (const task of tasks) {
const sub = best.get(`${student.id}:${task.id}`)
const cellScore = sub ? score(sub.score) : 0
scores[task.id] = {
score: cellScore,
submitted: !!sub,
submission_id: sub ? uuidOut(sub.id) : null,
}
if (!task.included) continue
if (sub) {
submittedTaskCount++
submittedScores.push(cellScore)
} else {
missingTaskCount++
}
if (task.task_type === "tutorial") tutorialTotal += cellScore
else challengeTotal += cellScore
}
tutorialTotal = score(tutorialTotal)
challengeTotal = score(challengeTotal)
const top3 = submittedScores.sort((a, b) => b - a).slice(0, 3)
const top3Score = top3.length ? score(pySum(top3) / top3.length) : 0
const totalScore = score(tutorialTotal + challengeTotal)
return {
user_id: student.id,
username: student.username,
classname: student.classname,
rank: 0,
grade: "E",
assessment_grade: "E",
assessment_score: 60,
top3_score: top3Score,
scores,
tutorial_total: tutorialTotal,
challenge_total: challengeTotal,
total_score: totalScore,
average_score: includedTaskCount ? score(totalScore / includedTaskCount) : null,
submitted_task_count: submittedTaskCount,
missing_task_count: missingTaskCount,
}
})
rows.sort((a, b) => b.total_score - a.total_score || cmp(a.username, b.username))
rows.forEach((row, i) => {
row.rank = i + 1
row.grade = gradeForRank(i + 1, studentCount)
})
// 考核等级:按最高三次提交均分单独排名后定级(不改变上面的总分排名)
const assessmentOrder = [...rows].sort(
(a, b) => b.top3_score - a.top3_score || cmp(a.username, b.username),
)
assessmentOrder.forEach((row, i) => {
row.assessment_grade = assessmentGradeForRank(i + 1, studentCount)
})
// 考核分数:在各等级段内按名次线性铺开到对应分数段
const groups = new Map<Grade, GradebookRow[]>()
for (const row of assessmentOrder) {
const g = groups.get(row.assessment_grade) ?? []
g.push(row)
groups.set(row.assessment_grade, g)
}
for (const [grade, group] of groups) {
group.forEach((row, i) => {
row.assessment_score = assessmentScore(i, group.length, grade)
})
}
const username = filters.username?.trim().toLowerCase() ?? ""
if (username) rows = rows.filter((r) => r.username.toLowerCase().includes(username))
return {
classname,
classes,
task_count: tasks.length,
included_task_count: includedTaskCount,
student_count: studentCount,
coverage_threshold_count: coverageThreshold,
tasks,
rows,
}
}
function csvNumber(value: number) {
if (Number.isInteger(value)) return String(value)
return pyRound(value, 2).toFixed(2).replace(/0+$/, "").replace(/\.$/, "")
}
const taskTypeLabel = (t: string) => (t === "tutorial" ? "教程" : "挑战")
export function gradebookCsvRows(gb: Gradebook): (string | number)[][] {
const rows: (string | number)[][] = [
[
"排名",
"平时等级",
"考核等级",
"考核分数",
"用户名",
"班级",
...gb.tasks.map((t) => `${taskTypeLabel(t.task_type)}${t.display}-${t.title}`),
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
],
]
for (const row of gb.rows) {
rows.push([
row.rank,
row.grade,
row.assessment_grade,
row.assessment_score,
row.username,
row.classname,
...gb.tasks.map((t) => csvNumber(row.scores[t.id]!.score)),
csvNumber(row.tutorial_total),
csvNumber(row.challenge_total),
csvNumber(row.total_score),
row.average_score === null ? "" : csvNumber(row.average_score),
row.submitted_task_count,
row.missing_task_count,
])
}
return rows
}
// 与 Python csv 模块默认行为一致:含逗号/引号/换行时加引号,行尾 \r\n
export function toCsv(rows: (string | number)[][]) {
const cell = (v: string | number) => {
const s = String(v)
return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
}
return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n"
}