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,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 })
|
||||
})
|
||||
Reference in new issue
Block a user