refactor: 后端从 Django 换成 Bun + SQLite
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build:staging, school, 8822) (push) Canceled after 0s

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:
xuyueandClaude Opus 5.5 committed 2026-10-26 17:45:09 +08:00
1 parent 47d4679984
commit 26f05d1d76
297 files changed
+4659 -42956

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import { Hono } from "hono"
import { mkdir, rm } from "node:fs/promises"
import { existsSync } from "node:fs"
import { dirname, extname, join, resolve, sep } from "node:path"
import { config } from "../config"
import { all, get, run } from "../db"
import { requireAdmin, requireSuper, type User } from "../auth"
import { HttpError, ValidationError, intParam, orNotFound, type Ctx, type Env } from "../http"
// 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符
export function validFilename(name: string) {
const s = name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "")
if (!s || s === "." || s === "..") throw new HttpError(400, `无效的文件名:${name}`)
return s
}
// 解析 media 下的相对路径,防止越出 media 目录
export function mediaPath(relative: string) {
const root = config.mediaRoot
const full = resolve(root, relative)
if (full !== root && !full.startsWith(root + sep)) throw new HttpError(400, "无效的路径")
return full
}
async function saveFile(relative: string, file: File) {
let target = relative
// 同名文件已存在时加随机后缀(Django 存储的行为)
while (existsSync(mediaPath(target))) {
const ext = extname(relative)
const suffix = Math.random().toString(36).slice(2, 9)
target = relative.slice(0, relative.length - ext.length) + "_" + suffix + ext
}
const full = mediaPath(target)
await mkdir(dirname(full), { recursive: true })
await Bun.write(full, file)
return target
}
async function formFile(c: Ctx, field: string) {
const form = await c.req.formData().catch(() => null)
const value = form?.get(field)
if (!(value instanceof File)) {
throw new ValidationError([{ loc: ["body", field], msg: "缺少文件" }])
}
return { form: form!, file: value }
}
// ── 任务素材 ──────────────────────────────────────────────────────────────────
export const assets = new Hono<Env>()
type Kind = "challenge" | "tutorial"
function findTask(kind: Kind, display: number) {
return orNotFound(
get<{ id: number; task_type: string; display: number }>(
`SELECT t.id, t.task_type, t.display FROM task_task t
JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`,
{ display },
),
)
}
const assetOut = (a: { name: string; file: string }) => ({
name: a.name,
url: config.mediaUrl + a.file,
})
for (const kind of ["challenge", "tutorial"] as const) {
// 挑战素材管理员可改,教程素材仅超管可改
const requireEditor: (c: Ctx) => User = kind === "challenge" ? requireAdmin : requireSuper
assets.get(`/${kind}/:display`, (c) => {
const task = findTask(kind, intParam(c, "display"))
const rows = all<{ name: string; file: string }>(
`SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`,
{ id: task.id },
)
return c.json(rows.map(assetOut))
})
assets.post(`/${kind}/:display`, async (c) => {
requireEditor(c)
const task = findTask(kind, intParam(c, "display"))
const { form, file } = await formFile(c, "file")
const name = form.get("name")
if (typeof name !== "string" || !name) {
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
}
const existing = get<{ id: number; file: string }>(
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
{ task_id: task.id, name },
)
if (existing?.file) await rm(mediaPath(existing.file), { force: true })
const stored = await saveFile(
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`,
file,
)
if (existing) {
run(`UPDATE task_taskasset SET file = $file WHERE id = $id`, { file: stored, id: existing.id })
} else {
run(`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)`, {
name,
file: stored,
task_id: task.id,
})
}
return c.json(assetOut({ name, file: stored }))
})
assets.delete(`/${kind}/:display/:name`, async (c) => {
requireEditor(c)
const task = findTask(kind, intParam(c, "display"))
const asset = orNotFound(
get<{ id: number; file: string }>(
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
{ task_id: task.id, name: c.req.param("name") },
),
)
if (asset.file) await rm(mediaPath(asset.file), { force: true })
run(`DELETE FROM task_taskasset WHERE id = $id`, { id: asset.id })
return c.json({ message: "删除成功" })
})
}
// ── 编辑器里插入图片 ──────────────────────────────────────────────────────────
export const upload = new Hono<Env>()
upload.post("/", async (c) => {
requireSuper(c)
const { file } = await formFile(c, "image")
const filename = crypto.randomUUID() + extname(file.name)
await mkdir(config.mediaRoot, { recursive: true })
await Bun.write(join(config.mediaRoot, filename), file)
return c.json({ url: config.mediaUrl + filename })
})