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,47 @@
|
||||
/**
|
||||
* 用大模型给学生提示词标注布鲁姆认知层级(L1-L6)。
|
||||
*
|
||||
* bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
||||
*
|
||||
* 默认只处理还没有层级的用户消息;--force 重新标注全部。
|
||||
*/
|
||||
import { parseArgs } from "node:util"
|
||||
import { all } from "../src/db"
|
||||
import { classifyMessage } from "../src/services/classifier"
|
||||
import { llmConfigured } from "../src/services/llm"
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"task-id": { type: "string" },
|
||||
force: { type: "boolean", default: false },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
},
|
||||
})
|
||||
|
||||
const where = ["m.role = 'user'"]
|
||||
const params: Record<string, unknown> = {}
|
||||
if (values["task-id"]) {
|
||||
where.push("c.task_id = $task_id")
|
||||
params.task_id = Number(values["task-id"])
|
||||
}
|
||||
if (!values.force) where.push("m.prompt_level IS NULL")
|
||||
|
||||
const ids = all<{ id: number }>(
|
||||
`SELECT m.id FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
|
||||
WHERE ${where.join(" AND ")} ORDER BY m.created, m.id`,
|
||||
params,
|
||||
).map((r) => r.id)
|
||||
|
||||
console.log(`找到 ${ids.length} 条消息需要标注`)
|
||||
if (values["dry-run"]) process.exit(0)
|
||||
if (!llmConfigured()) {
|
||||
console.error("没有配置 LLM_API_KEY")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const level = await classifyMessage(id)
|
||||
console.log(`[${i + 1}/${ids.length}] msg#${id} → ${level ? `L${level}` : "(跳过)"}`)
|
||||
await Bun.sleep(300)
|
||||
}
|
||||
console.log("完成")
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 新建超级管理员,或把已有用户设为超级管理员并重设密码。
|
||||
*
|
||||
* bun scripts/create-superuser.ts <用户名> <密码>
|
||||
*/
|
||||
import { get, run, now } from "../src/db"
|
||||
import { makePassword } from "../src/auth"
|
||||
|
||||
const [username, password] = process.argv.slice(2)
|
||||
if (!username || !password) {
|
||||
console.error("用法:bun scripts/create-superuser.ts <用户名> <密码>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hash = await makePassword(password)
|
||||
const user = get<{ id: number }>(`SELECT id FROM account_user WHERE username = $username`, { username })
|
||||
if (user) {
|
||||
run(
|
||||
`UPDATE account_user SET password = $hash, raw_password = $raw, role = 'super',
|
||||
is_superuser = 1, is_staff = 1, is_active = 1 WHERE id = $id`,
|
||||
{ hash, raw: password.slice(0, 20), id: user.id },
|
||||
)
|
||||
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: user.id })
|
||||
console.log(`已将 ${username} 设为超级管理员并重设密码`)
|
||||
} else {
|
||||
run(
|
||||
`INSERT INTO account_user
|
||||
(password, last_login, is_superuser, username, email, is_staff, is_active, date_joined, role, raw_password, classname)
|
||||
VALUES ($hash, NULL, 1, $username, '', 1, 1, $now, 'super', $raw, '')`,
|
||||
{ hash, username, now: now(), raw: password.slice(0, 20) },
|
||||
)
|
||||
console.log(`已创建超级管理员 ${username}`)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 把旧 Django 项目的 Postgres 数据导入 SQLite(一次性迁移)。
|
||||
*
|
||||
* POSTGRES_HOST=... POSTGRES_DB=... POSTGRES_USER=... POSTGRES_PASSWORD=... \
|
||||
* bun scripts/migrate-from-postgres.ts [--force]
|
||||
*
|
||||
* 目标库为 DB_PATH(默认 data/db.sqlite3)。目标库已有数据时需要 --force(会先清空)。
|
||||
* 会话不迁移,所有人需要重新登录一次。
|
||||
*/
|
||||
import { SQL } from "bun"
|
||||
import { config } from "../src/config"
|
||||
import { db } from "../src/db"
|
||||
|
||||
// 按外键依赖顺序
|
||||
const TABLES = [
|
||||
"account_user",
|
||||
"task_task",
|
||||
"task_tutorial",
|
||||
"task_challenge",
|
||||
"task_creation",
|
||||
"task_taskasset",
|
||||
"submission_submission",
|
||||
"submission_rating",
|
||||
"submission_award",
|
||||
"submission_submissionaward",
|
||||
"prompt_conversation",
|
||||
"prompt_message",
|
||||
]
|
||||
|
||||
const force = process.argv.includes("--force")
|
||||
const env = process.env
|
||||
for (const key of ["POSTGRES_HOST", "POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"]) {
|
||||
if (!env[key]) {
|
||||
console.error(`缺少环境变量 ${key}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const pg = new SQL({
|
||||
hostname: env.POSTGRES_HOST,
|
||||
port: Number(env.POSTGRES_PORT ?? 5432),
|
||||
database: env.POSTGRES_DB,
|
||||
username: env.POSTGRES_USER,
|
||||
password: env.POSTGRES_PASSWORD,
|
||||
})
|
||||
|
||||
const existing = db.query(`SELECT COUNT(*) AS n FROM account_user`).get() as { n: number }
|
||||
if (existing.n > 0 && !force) {
|
||||
console.error(`目标库 ${config.dbPath} 已有数据,加 --force 会清空后重新导入`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`
|
||||
|
||||
// 按 Postgres 的列类型转换成 Django 在 SQLite 中的存储格式
|
||||
function selectExpr(column: string, pgType: string) {
|
||||
const c = quote(column)
|
||||
switch (pgType) {
|
||||
case "timestamp with time zone":
|
||||
return `to_char(${c} AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
|
||||
case "timestamp without time zone":
|
||||
return `to_char(${c}, 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
|
||||
case "uuid":
|
||||
return `replace(${c}::text, '-', '') AS ${c}`
|
||||
case "boolean":
|
||||
return `${c}::int AS ${c}`
|
||||
case "bigint":
|
||||
return `${c}::text AS ${c}`
|
||||
default:
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
const rowsByTable: Record<string, Record<string, unknown>[]> = {}
|
||||
|
||||
for (const table of TABLES) {
|
||||
const sqliteColumns = (db.query(`PRAGMA table_info(${quote(table)})`).all() as { name: string }[]).map(
|
||||
(c) => c.name,
|
||||
)
|
||||
const pgColumns = await pg`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ${table}`
|
||||
if (!pgColumns.length) throw new Error(`Postgres 中没有表 ${table}`)
|
||||
const pgTypes = new Map<string, string>(pgColumns.map((c: any) => [c.column_name, c.data_type]))
|
||||
const missing = sqliteColumns.filter((c) => !pgTypes.has(c))
|
||||
if (missing.length) throw new Error(`Postgres 表 ${table} 缺少列:${missing.join(", ")}`)
|
||||
|
||||
const select = sqliteColumns.map((c) => selectExpr(c, pgTypes.get(c)!)).join(", ")
|
||||
const rows: Record<string, unknown>[] = await pg.unsafe(`SELECT ${select} FROM ${quote(table)}`)
|
||||
for (const row of rows) {
|
||||
for (const c of sqliteColumns) {
|
||||
if (pgTypes.get(c) === "bigint" && row[c] !== null) row[c] = Number(row[c])
|
||||
}
|
||||
}
|
||||
rowsByTable[table] = rows
|
||||
console.log(`读取 ${table}: ${rows.length} 行`)
|
||||
}
|
||||
await pg.close()
|
||||
|
||||
db.transaction(() => {
|
||||
db.run("PRAGMA defer_foreign_keys = ON")
|
||||
for (const table of [...TABLES].reverse()) db.run(`DELETE FROM ${quote(table)}`)
|
||||
db.run(`DELETE FROM auth_session`)
|
||||
for (const table of TABLES) {
|
||||
const rows = rowsByTable[table]!
|
||||
if (!rows.length) {
|
||||
counts[table] = 0
|
||||
continue
|
||||
}
|
||||
const columns = Object.keys(rows[0]!)
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO ${quote(table)} (${columns.map(quote).join(", ")})
|
||||
VALUES (${columns.map((c) => "$" + c).join(", ")})`,
|
||||
)
|
||||
for (const row of rows) insert.run(row as any)
|
||||
counts[table] = rows.length
|
||||
}
|
||||
})()
|
||||
|
||||
// 校验
|
||||
let ok = true
|
||||
for (const table of TABLES) {
|
||||
const n = (db.query(`SELECT COUNT(*) AS n FROM ${quote(table)}`).get() as { n: number }).n
|
||||
if (n !== counts[table]) ok = false
|
||||
console.log(`${n === counts[table] ? "✓" : "✗"} ${table}: ${n}`)
|
||||
}
|
||||
const fk = db.query(`PRAGMA foreign_key_check`).all()
|
||||
if (fk.length) {
|
||||
ok = false
|
||||
console.error("外键检查失败:", fk.slice(0, 10))
|
||||
}
|
||||
console.log(ok ? `\n导入完成:${config.dbPath}` : "\n导入有问题,请检查上面的输出")
|
||||
process.exit(ok ? 0 : 1)
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 重新计算所有被评过分的提交的 score / raw_score / zone(修改评分规则后使用)。
|
||||
*
|
||||
* bun scripts/recalculate-scores.ts [--dry-run]
|
||||
*/
|
||||
import { all, get } from "../src/db"
|
||||
import { updateSubmissionScore } from "../src/services/score"
|
||||
|
||||
const rows = all<{ id: string; username: string; title: string; score: number }>(
|
||||
`SELECT s.id, u.username, t.title, s.score FROM submission_submission s
|
||||
JOIN account_user u ON u.id = s.user_id JOIN task_task t ON t.id = s.task_id
|
||||
WHERE EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id)
|
||||
ORDER BY s.created DESC`,
|
||||
)
|
||||
console.log(`找到 ${rows.length} 个被评过分的提交`)
|
||||
if (process.argv.includes("--dry-run")) process.exit(0)
|
||||
|
||||
for (const [i, s] of rows.entries()) {
|
||||
updateSubmissionScore(s.id)
|
||||
const after = get<{ score: number; zone: string | null }>(
|
||||
`SELECT score, zone FROM submission_submission WHERE id = $id`,
|
||||
{ id: s.id },
|
||||
)!
|
||||
console.log(
|
||||
`[${i + 1}/${rows.length}] ${s.username}/${s.title}: ${s.score.toFixed(3)} → ${after.score.toFixed(3)} zone=${after.zone}`,
|
||||
)
|
||||
}
|
||||
console.log("完成")
|
||||
Reference in new issue
Block a user