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>
This commit is contained in:
1 parent
47d4679984
commit
26f05d1d76
297 files changed
+4659
-42956
No files matched your search
@@ -0,0 +1,306 @@
|
||||
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"
|
||||
}
|
||||
Reference in new issue
Block a user