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

+5
View File
@@ -0,0 +1,5 @@
node_modules
data
media
tests
.env
+20
View File
@@ -0,0 +1,20 @@
# 本地开发:复制为 server/.env 后修改
# Docker 部署:这些变量写在仓库根目录的 .env 里
# production 时使用线上域名作为跨域白名单
ENV=dev
# 以下均有默认值,一般不用改
# PORT=8000
# SQLite 数据库文件
# DB_PATH=data/db.sqlite3
# 上传文件目录
# MEDIA_ROOT=../media
# 允许访问 API 的前端地址,逗号分隔
# ALLOWED_ORIGINS=http://localhost:3000
LLM_API_KEY=
LLM_MODEL=deepseek-v4-flash
# LLM_BASE_URL=https://api.deepseek.com
ARK_API_KEY=
# ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
media/
.env
+23
View File
@@ -0,0 +1,23 @@
FROM oven/bun:1.4.4-alpine
WORKDIR /app
# 国内服务器用 npmmirror 加速
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV BUN_CONFIG_REGISTRY=$NPM_REGISTRY
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY tsconfig.json ./
COPY src ./src
COPY scripts ./scripts
ENV ENV=production \
PORT=8000 \
DB_PATH=/app/data/db.sqlite3 \
MEDIA_ROOT=/app/media
EXPOSE 8000
CMD ["bun", "run", "src/index.ts"]
+74
View File
@@ -0,0 +1,74 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "server",
"dependencies": {
"hono": "^4.13.14",
"zod": "^4.6.7",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^7",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.4.4", "https://registry.npmjs.com/@types/bun/-/bun-1.4.4.tgz", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"bun-types": ["bun-types@1.4.4", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.4.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="],
"typescript": ["typescript@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici-types": ["undici-types@8.9.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.9.0.tgz", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "webpreview-server",
"type": "module",
"private": true,
"scripts": {
"dev": "bun --watch run src/index.ts",
"start": "bun run src/index.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^7"
},
"dependencies": {
"hono": "^4.13.14",
"zod": "^4.6.7"
}
}
+47
View File
@@ -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("完成")
+33
View File
@@ -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}`)
}
+134
View File
@@ -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)
+28
View File
@@ -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("完成")
+69
View File
@@ -0,0 +1,69 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { config } from "./config"
import { sessionMiddleware } from "./auth"
import { HttpError, ValidationError, type Env } from "./http"
import { isAllowedOrigin } from "./origin"
import account from "./routes/account"
import { tutorial, challenge, creation } from "./routes/tasks"
import { assets, upload } from "./routes/files"
import submission from "./routes/submission"
import prompt from "./routes/prompt"
import { mediaPath } from "./routes/files"
// strict: false —— 带不带结尾的斜杠都能匹配(前端两种写法都有)
export const app = new Hono<Env>({ strict: false })
app.use(
"/api/*",
cors({ origin: config.allowedOrigins, credentials: true }),
)
// 会话 cookie 是 SameSite=Lax,再加一道 Origin 校验防止跨站写请求
app.use("/api/*", async (c, next) => {
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method)) {
if (!isAllowedOrigin(c.req.header("origin") ?? null, c.req.header("host") ?? null)) {
return c.json({ detail: "跨站请求被拒绝" }, 403)
}
}
await next()
})
app.use("/api/*", sessionMiddleware)
const api = new Hono<Env>({ strict: false })
api.route("/account", account)
api.route("/tutorial", tutorial)
api.route("/challenge", challenge)
api.route("/creation", creation)
api.route("/submission", submission)
api.route("/upload", upload)
api.route("/assets", assets)
api.route("/prompt", prompt)
app.route("/api", api)
// 生产环境由 Caddy 直接提供 /media,这里主要给本地开发用
app.get("/media/*", async (c) => {
const relative = decodeURIComponent(c.req.path.slice("/media/".length))
let file
try {
file = Bun.file(mediaPath(relative))
} catch {
return c.notFound()
}
if (!(await file.exists())) return c.notFound()
return new Response(file)
})
app.notFound((c) => c.json({ detail: "Not Found" }, 404))
app.onError((err, c) => {
if (err instanceof HttpError) {
return c.json({ detail: err.detail }, err.status as any)
}
if (err instanceof ValidationError) {
return c.json({ detail: err.issues }, 422)
}
console.error(err)
return c.json({ detail: "服务器内部错误" }, 500)
})
+148
View File
@@ -0,0 +1,148 @@
import { pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"
import { promisify } from "node:util"
import { getCookie, setCookie, deleteCookie } from "hono/cookie"
import type { MiddlewareHandler } from "hono"
import { config } from "./config"
import { get, run, now, toDbTime } from "./db"
import { HttpError, type Ctx, type Env } from "./http"
const pbkdf2 = promisify(pbkdf2Cb)
export const Role = {
SUPER: "super",
ADMIN: "admin",
NORMAL: "normal",
} as const
export type Role = (typeof Role)[keyof typeof Role]
export interface User {
id: number
username: string
role: Role
classname: string
is_active: number
}
// ── 密码:兼容 Django 的 pbkdf2_sha256 格式,老用户无需重置密码 ──
const ITERATIONS = 600_000
export async function makePassword(raw: string): Promise<string> {
const salt = randomBytes(16).toString("base64url").slice(0, 22)
const hash = await pbkdf2(raw, salt, ITERATIONS, 32, "sha256")
return `pbkdf2_sha256$${ITERATIONS}$${salt}$${hash.toString("base64")}`
}
export async function checkPassword(raw: string, encoded: string) {
const [algorithm, iterations, salt, hash] = encoded.split("$")
if (algorithm !== "pbkdf2_sha256" || !iterations || !salt || !hash) {
return false
}
const expected = Buffer.from(hash, "base64")
const actual = await pbkdf2(
raw,
salt,
Number(iterations),
expected.length,
"sha256",
)
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
export async function setPassword(userId: number, raw: string) {
run(
`UPDATE account_user SET password = $password, raw_password = $raw WHERE id = $id`,
{ password: await makePassword(raw), raw: raw.slice(0, 20), id: userId },
)
// 改密码后让该用户的其它会话失效(与 Django 行为一致)
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: userId })
}
// ── 会话 ──
export async function authenticate(username: string, password: string) {
const row = get<User & { password: string }>(
`SELECT id, username, role, classname, is_active, password
FROM account_user WHERE username = $username`,
{ username },
)
if (!row || !row.is_active) {
// 防止通过响应时间判断用户是否存在
await makePassword(password)
return null
}
if (!(await checkPassword(password, row.password))) return null
const { password: _, ...user } = row
return user as User
}
export function login(c: Ctx, user: User) {
const key = randomBytes(24).toString("base64url")
const expire = new Date(Date.now() + config.sessionAgeSeconds * 1000)
run(`DELETE FROM auth_session WHERE expire_at < $now`, { now: now() })
run(
`INSERT INTO auth_session (key, user_id, expire_at) VALUES ($key, $user_id, $expire_at)`,
{ key, user_id: user.id, expire_at: toDbTime(expire) },
)
run(`UPDATE account_user SET last_login = $now WHERE id = $id`, {
now: now(),
id: user.id,
})
setCookie(c, config.sessionCookie, key, {
path: "/",
httpOnly: true,
sameSite: "Lax",
maxAge: config.sessionAgeSeconds,
})
}
export function logout(c: Ctx) {
const key = getCookie(c, config.sessionCookie)
if (key) run(`DELETE FROM auth_session WHERE key = $key`, { key })
deleteCookie(c, config.sessionCookie, { path: "/" })
}
export function userFromSessionKey(key: string | undefined): User | null {
if (!key) return null
return get<User>(
`SELECT u.id, u.username, u.role, u.classname, u.is_active
FROM auth_session s JOIN account_user u ON u.id = s.user_id
WHERE s.key = $key AND s.expire_at > $now AND u.is_active = 1`,
{ key, now: now() },
)
}
export function parseCookie(header: string | null, name: string) {
if (!header) return undefined
for (const part of header.split(";")) {
const [k, ...v] = part.trim().split("=")
if (k === name) return decodeURIComponent(v.join("="))
}
return undefined
}
export const sessionMiddleware: MiddlewareHandler<Env> = async (c, next) => {
c.set("user", userFromSessionKey(getCookie(c, config.sessionCookie)))
await next()
}
// ── 权限 ──
export function requireUser(c: Ctx): User {
const user = c.get("user")
if (!user) throw new HttpError(401, "请先登录")
return user
}
function requireRoles(c: Ctx, roles: Role[]): User {
const user = requireUser(c)
if (!roles.includes(user.role)) throw new HttpError(403, "没有权限")
return user
}
export const requireAdmin = (c: Ctx) =>
requireRoles(c, [Role.ADMIN, Role.SUPER])
export const requireSuper = (c: Ctx) => requireRoles(c, [Role.SUPER])
export const isStaff = (user: User) =>
user.role === Role.ADMIN || user.role === Role.SUPER
+31
View File
@@ -0,0 +1,31 @@
import { resolve } from "node:path"
const env = process.env
export const DEV = env.ENV !== "production"
export const config = {
port: Number(env.PORT ?? 8000),
dbPath: resolve(env.DB_PATH ?? "data/db.sqlite3"),
mediaRoot: resolve(env.MEDIA_ROOT ?? "../media"),
mediaUrl: "/media/",
// 允许跨域访问 / 发起写请求 / 建立 WebSocket 的前端来源
allowedOrigins: (
env.ALLOWED_ORIGINS ??
(DEV
? "http://localhost:3000"
: "https://web.xuyue.cc,http://10.13.114.114:91")
)
.split(",")
.map((s) => s.trim())
.filter(Boolean),
sessionCookie: "sessionid",
sessionAgeSeconds: 60 * 60 * 24 * 14,
// 测试时不调用真实的模型接口
llmApiKey: env.NODE_ENV === "test" ? "" : (env.LLM_API_KEY ?? ""),
llmBaseUrl: env.LLM_BASE_URL ?? "https://api.deepseek.com",
llmModel: env.LLM_MODEL ?? "deepseek-v4-flash",
arkApiKey: env.NODE_ENV === "test" ? "" : (env.ARK_API_KEY ?? ""),
arkBaseUrl: env.ARK_BASE_URL ?? "https://ark.cn-beijing.volces.com/api/v3",
}
+142
View File
@@ -0,0 +1,142 @@
import { Database } from "bun:sqlite"
import { mkdirSync } from "node:fs"
import { dirname } from "node:path"
import { config } from "./config"
// 表结构与原 Django 项目保持一致(表名、列名、时间格式、UUID 存储方式),
// 这样旧的 db.sqlite3 可以直接使用,Postgres 的数据也能原样导入。
const SCHEMA = `
CREATE TABLE IF NOT EXISTS "account_user" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "password" varchar(128) NOT NULL, "last_login" datetime NULL, "is_superuser" bool NOT NULL, "username" varchar(150) NOT NULL UNIQUE, "email" varchar(254) NOT NULL, "is_staff" bool NOT NULL, "is_active" bool NOT NULL, "date_joined" datetime NOT NULL, "role" varchar(20) NOT NULL, "raw_password" varchar(20) NULL, "classname" varchar(50) NOT NULL);
CREATE TABLE IF NOT EXISTS "task_task" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "created" datetime NOT NULL, "modified" datetime NOT NULL, "title" varchar(100) NOT NULL, "content" text NOT NULL, "task_type" varchar(20) NOT NULL, "is_public" bool NOT NULL, "display" integer NOT NULL, "example_css" text NULL, "example_html" text NULL, "example_js" text NULL);
CREATE INDEX IF NOT EXISTS "task_task_display_97da6aab" ON "task_task" ("display");
CREATE UNIQUE INDEX IF NOT EXISTS "task_task_display_task_type_f648533c_uniq" ON "task_task" ("display", "task_type");
CREATE TABLE IF NOT EXISTS "task_tutorial" ("task_ptr_id" bigint NOT NULL PRIMARY KEY REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE TABLE IF NOT EXISTS "task_challenge" ("task_ptr_id" bigint NOT NULL PRIMARY KEY REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "score" integer NOT NULL, "pass_score" real NULL, "author_id" bigint NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "task_challenge_author_id_d4492927" ON "task_challenge" ("author_id");
CREATE TABLE IF NOT EXISTS "task_creation" ("task_ptr_id" bigint NOT NULL PRIMARY KEY REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "html" text NOT NULL, "css" text NOT NULL, "js" text NOT NULL, "owner_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "task_creation_owner_id_6ae3c55d" ON "task_creation" ("owner_id");
CREATE TABLE IF NOT EXISTS "task_taskasset" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(100) NOT NULL, "file" varchar(100) NOT NULL, "task_id" bigint NOT NULL REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE UNIQUE INDEX IF NOT EXISTS "task_taskasset_task_id_name_dc8ea9a8_uniq" ON "task_taskasset" ("task_id", "name");
CREATE INDEX IF NOT EXISTS "task_taskasset_task_id_0921deb6" ON "task_taskasset" ("task_id");
CREATE TABLE IF NOT EXISTS "submission_submission" ("created" datetime NOT NULL, "modified" datetime NOT NULL, "id" char(32) NOT NULL PRIMARY KEY, "score" real NOT NULL, "html" text NULL, "css" text NULL, "js" text NULL, "task_id" bigint NOT NULL REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED, "flag" varchar(10) NULL, "raw_score" real NOT NULL, "zone" varchar(10) NULL, "view_count" integer unsigned NOT NULL CHECK ("view_count" >= 0));
CREATE INDEX IF NOT EXISTS "submission_submission_task_id_91e9556b" ON "submission_submission" ("task_id");
CREATE INDEX IF NOT EXISTS "submission_submission_user_id_71741691" ON "submission_submission" ("user_id");
CREATE INDEX IF NOT EXISTS "submission_submission_flag_d96bc4aa" ON "submission_submission" ("flag");
CREATE INDEX IF NOT EXISTS "submission_submission_zone_a1bbf0a2" ON "submission_submission" ("zone");
CREATE TABLE IF NOT EXISTS "submission_rating" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "score" integer NOT NULL, "created" datetime NOT NULL, "submission_id" char(32) NOT NULL REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE UNIQUE INDEX IF NOT EXISTS "submission_rating_user_id_submission_id_29be4c39_uniq" ON "submission_rating" ("user_id", "submission_id");
CREATE INDEX IF NOT EXISTS "submission_rating_submission_id_a20dcece" ON "submission_rating" ("submission_id");
CREATE INDEX IF NOT EXISTS "submission_rating_user_id_cfd08017" ON "submission_rating" ("user_id");
CREATE TABLE IF NOT EXISTS "submission_award" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "created" datetime NOT NULL, "modified" datetime NOT NULL, "name" varchar(100) NOT NULL UNIQUE, "description" text NOT NULL, "sort_order" integer NOT NULL, "is_active" bool NOT NULL, "item_ordering" varchar(20) NOT NULL);
CREATE INDEX IF NOT EXISTS "submission_award_sort_order_280640ce" ON "submission_award" ("sort_order");
CREATE TABLE IF NOT EXISTS "submission_submissionaward" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "created" datetime NOT NULL, "modified" datetime NOT NULL, "sort_order" integer NOT NULL, "awarded_at" datetime NOT NULL, "award_id" bigint NOT NULL REFERENCES "submission_award" ("id") DEFERRABLE INITIALLY DEFERRED, "submission_id" char(32) NOT NULL REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED, "is_stale" bool NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS "submission_submissionaward_submission_id_award_id_6fab326b_uniq" ON "submission_submissionaward" ("submission_id", "award_id");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_sort_order_7d90e1d6" ON "submission_submissionaward" ("sort_order");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_awarded_at_8093b2c7" ON "submission_submissionaward" ("awarded_at");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_award_id_b229157e" ON "submission_submissionaward" ("award_id");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_submission_id_209aabba" ON "submission_submissionaward" ("submission_id");
CREATE TABLE IF NOT EXISTS "prompt_conversation" ("created" datetime NOT NULL, "modified" datetime NOT NULL, "id" char(32) NOT NULL PRIMARY KEY, "is_active" bool NOT NULL, "task_id" bigint NOT NULL REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "prompt_conversation_task_id_90287800" ON "prompt_conversation" ("task_id");
CREATE INDEX IF NOT EXISTS "prompt_conversation_user_id_ecc418b3" ON "prompt_conversation" ("user_id");
CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "role" varchar(10) NOT NULL, "content" text NOT NULL, "code_html" text NULL, "code_css" text NULL, "code_js" text NULL, "created" datetime NOT NULL, "conversation_id" char(32) NOT NULL REFERENCES "prompt_conversation" ("id") DEFERRABLE INITIALLY DEFERRED, "prompt_level" integer NULL, "source" varchar(12) NOT NULL, "submission_id" char(32) NULL UNIQUE REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level");
CREATE TABLE IF NOT EXISTS "auth_session" ("key" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
CREATE INDEX IF NOT EXISTS "auth_session_user_id" ON "auth_session" ("user_id");
`
export function openDb(path: string): Database {
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true })
const db = new Database(path, { strict: true, create: true })
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA busy_timeout = 5000")
db.run("PRAGMA foreign_keys = ON")
db.run(SCHEMA)
return db
}
export let db = openDb(process.env.NODE_ENV === "test" ? ":memory:" : config.dbPath)
// 仅供测试使用:换一个全新的内存库
export function resetDb() {
db.close()
db = openDb(":memory:")
}
type Params = Record<string, unknown>
export function all<T = any>(sql: string, params: Params = {}): T[] {
return db.query(sql).all(params as any) as T[]
}
export function get<T = any>(sql: string, params: Params = {}): T | null {
return (db.query(sql).get(params as any) as T) ?? null
}
export function run(sql: string, params: Params = {}) {
return db.query(sql).run(params as any)
}
export function tx<T>(fn: () => T): T {
return db.transaction(fn)()
}
// ── 时间:与 Django 在 SQLite 中的存储格式一致(UTC, "YYYY-MM-DD HH:MM:SS.ffffff")──
let lastMicros = 0
// 严格递增的时间戳,避免同一毫秒内连续插入的消息排序不稳定
export function now(): string {
let micros = Date.now() * 1000
if (micros <= lastMicros) micros = lastMicros + 1
lastMicros = micros
return formatMicros(micros)
}
export function formatMicros(micros: number): string {
const d = new Date(Math.floor(micros / 1000))
const frac = String(micros % 1_000_000).padStart(6, "0")
return d.toISOString().slice(0, 19).replace("T", " ") + "." + frac
}
export function toDbTime(d: Date): string {
return formatMicros(d.getTime() * 1000)
}
// 数据库时间 → ISO 8601,格式与 Django 的 JSON 输出一致(毫秒精度,UTC 用 Z)
export function iso(value: string): string
export function iso(value: string | null): string | null
export function iso(value: string | null): string | null {
if (!value) return null
return value.replace(" ", "T").slice(0, 23) + "Z"
}
// ── UUID:SQLite 中按 Django 的方式存成 32 位无连字符的十六进制 ──
const UUID_RE =
/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i
export function uuidHex(value: string): string | null {
if (!UUID_RE.test(value)) return null
return value.replaceAll("-", "").toLowerCase()
}
export function uuidOut(hex: string): string
export function uuidOut(hex: string | null): string | null
export function uuidOut(hex: string | null): string | null {
if (!hex) return null
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
export function newUuidHex(): string {
return crypto.randomUUID().replaceAll("-", "")
}
export function likeEscape(s: string): string {
return "%" + s.replace(/[\\%_]/g, (m) => "\\" + m) + "%"
}
+102
View File
@@ -0,0 +1,102 @@
import type { Context } from "hono"
import { z } from "zod"
import type { User } from "./auth"
import { uuidHex } from "./db"
export type Env = { Variables: { user: User | null } }
export type Ctx = Context<Env>
export class HttpError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail)
}
}
export function notFound(detail = "Not Found"): never {
throw new HttpError(404, detail)
}
export function orNotFound<T>(value: T, detail?: string): NonNullable<T> {
if (value === null || value === undefined) notFound(detail)
return value as NonNullable<T>
}
function formatIssues(error: z.ZodError) {
return error.issues.map((i) => ({
loc: i.path,
msg: i.message,
type: i.code,
}))
}
export class ValidationError extends Error {
constructor(public issues: unknown) {
super("validation error")
}
}
export async function body<S extends z.ZodType>(
c: Ctx,
schema: S,
): Promise<z.output<S>> {
let raw: unknown
try {
raw = await c.req.json()
} catch {
throw new ValidationError([{ loc: ["body"], msg: "无效的 JSON" }])
}
const result = schema.safeParse(raw)
if (!result.success) throw new ValidationError(formatIssues(result.error))
return result.data
}
export function query<S extends z.ZodType>(c: Ctx, schema: S): z.output<S> {
const result = schema.safeParse(c.req.query())
if (!result.success) throw new ValidationError(formatIssues(result.error))
return result.data
}
// 路径参数里的整数
export function intParam(c: Ctx, name: string): number {
const v = c.req.param(name) ?? ""
if (!/^-?\d+$/.test(v)) {
throw new ValidationError([{ loc: ["path", name], msg: "需要整数" }])
}
return Number(v)
}
// 路径参数里的 UUID,返回数据库中的 32 位十六进制形式
export function uuidParam(c: Ctx, name: string): string {
const hex = uuidHex(c.req.param(name) ?? "")
if (!hex) throw new ValidationError([{ loc: ["path", name], msg: "需要 UUID" }])
return hex
}
// 查询参数的通用类型(与 django-ninja / pydantic 的解析方式一致)
export const qs = {
int: z.coerce.number().int(),
num: z.coerce.number(),
bool: z
.enum(["true", "false", "1", "0", "on", "off", "yes", "no"])
.transform((v) => ["true", "1", "on", "yes"].includes(v)),
// 空字符串视为未传
opt<T extends z.ZodType>(schema: T) {
return z.preprocess((v) => (v === "" ? undefined : v), schema.optional())
},
}
export const pageQuery = z.object({
page: qs.opt(qs.int.min(1)).default(1),
page_size: qs.opt(qs.int.min(1)),
})
// 与 ninja PageNumberPagination 相同:默认 10 条,最多 100 条
export function pageWindow(p: { page: number; page_size?: number }) {
const size = Math.min(p.page_size ?? 10, 100)
return { limit: size, offset: (p.page - 1) * size }
}
export const bool = (v: unknown) => v === 1 || v === true
+19
View File
@@ -0,0 +1,19 @@
import { app } from "./app"
import { config } from "./config"
import { upgrade, websocket, type WsData } from "./ws"
const server = Bun.serve<WsData>({
port: config.port,
// 上传素材 / 图片
maxRequestBodySize: 50 * 1024 * 1024,
// AI 生成走 WebSocket,普通接口不会太久
idleTimeout: 120,
fetch(req, server) {
const res = upgrade(req, server)
if (res !== null) return res
return app.fetch(req)
},
websocket,
})
console.log(`webpreview server listening on http://localhost:${server.port}`)
+12
View File
@@ -0,0 +1,12 @@
import { config } from "./config"
// 同源请求,或来自白名单里的前端地址
export function isAllowedOrigin(origin: string | null, host: string | null) {
if (!origin) return true
if (config.allowedOrigins.includes(origin)) return true
try {
return new URL(origin).host === host
} catch {
return false
}
}
+271
View File
@@ -0,0 +1,271 @@
import { Hono } from "hono"
import { randomInt } from "node:crypto"
import { z } from "zod"
import { all, get, run, tx, now, iso, likeEscape } from "../db"
import {
Role,
authenticate,
login,
logout,
makePassword,
requireSuper,
requireUser,
setPassword,
} from "../auth"
import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Env } from "../http"
const router = new Hono<Env>()
const roleEnum = z.enum([Role.SUPER, Role.ADMIN, Role.NORMAL])
// 只用 2-9 的数字,避免和字母 O/l 混淆
function generatePassword(length = 6) {
return Array.from({ length }, () => String(randomInt(2, 10))).join("")
}
async function insertUser(u: {
username: string
password: string
role?: string
classname?: string
email?: string
}) {
const res = run(
`INSERT INTO account_user
(password, last_login, is_superuser, username, email, is_staff, is_active, date_joined, role, raw_password, classname)
VALUES ($password, NULL, 0, $username, $email, 0, 1, $now, $role, $raw, $classname)`,
{
password: await makePassword(u.password),
username: u.username,
email: u.email ?? "",
now: now(),
role: u.role ?? Role.NORMAL,
raw: u.password.slice(0, 20),
classname: u.classname ?? "",
},
)
return Number(res.lastInsertRowid)
}
const userExists = (username: string) =>
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
router.post("/register", async (c) => {
const payload = await body(
c,
z.object({
username: z.string(),
email: z.email(),
password: z.string().min(6).max(20),
}),
)
if (userExists(payload.username)) throw new HttpError(400, "用户已存在")
await insertUser(payload)
return c.json({ message: "创建成功" })
})
router.post("/login", async (c) => {
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
const user = await authenticate(payload.username, payload.password)
if (!user) throw new HttpError(401, "账号密码错误")
login(c, user)
return c.json({ username: user.username, role: user.role })
})
router.post("/logout", (c) => {
requireUser(c)
logout(c)
return c.json({ message: "退出成功" })
})
router.get("/profile", (c) => {
const user = c.get("user")
if (user) return c.json({ username: user.username, role: user.role })
return c.json({ username: "", role: Role.NORMAL })
})
router.get("/list", (c) => {
requireSuper(c)
const q = query(
c,
pageQuery.extend({
username: z.string().default(""),
role: z.string().optional(),
}),
)
const where = [`username LIKE $username ESCAPE '\\'`]
const params: Record<string, unknown> = { username: likeEscape(q.username) }
if (q.role) {
where.push(`role = $role`)
params.role = q.role
}
const whereSql = where.join(" AND ")
const { limit, offset } = pageWindow(q)
const rows = all(
`SELECT id, username, raw_password, role, classname, date_joined, last_login, is_active
FROM account_user WHERE ${whereSql} ORDER BY id DESC LIMIT $limit OFFSET $offset`,
{ ...params, limit, offset },
)
const count = get<{ n: number }>(
`SELECT COUNT(*) AS n FROM account_user WHERE ${whereSql}`,
params,
)!.n
return c.json({
items: rows.map((u) => ({
id: u.id,
username: u.username,
raw_password: u.role !== Role.SUPER ? u.raw_password : "",
role: u.role,
classname: u.classname,
date_joined: iso(u.date_joined),
last_login: iso(u.last_login),
is_active: bool(u.is_active),
})),
count,
})
})
router.post("/batch", async (c) => {
requireSuper(c)
const payload = await body(
c,
z.object({ names: z.array(z.string()), classname: z.string() }),
)
const names = payload.names.map((n) => n.trim()).filter(Boolean)
const usernames = names.map((n) => "web" + payload.classname + n)
if (new Set(usernames).size !== usernames.length) {
throw new HttpError(400, "名单中有重复的名字,创建失败")
}
if (usernames.some(userExists)) {
throw new HttpError(400, "有些用户已经存在,创建失败")
}
const users = await Promise.all(
usernames.map(async (username) => {
const raw = generatePassword()
return { username, raw, password: await makePassword(raw) }
}),
)
const ts = now()
tx(() => {
for (const u of users) {
run(
`INSERT INTO account_user
(password, last_login, is_superuser, username, email, is_staff, is_active, date_joined, role, raw_password, classname)
VALUES ($password, NULL, 0, $username, '', 0, 1, $now, 'normal', $raw, $classname)`,
{ password: u.password, username: u.username, now: ts, raw: u.raw, classname: payload.classname },
)
}
})
return c.json({ message: "批量创建成功" })
})
// 新建单个用户(替代原来 Django Admin 的新增页面)
router.post("/create", async (c) => {
requireSuper(c)
const payload = await body(
c,
z.object({
username: z.string().trim().min(1, "用户名不能为空").max(150),
password: z.string().min(6, "密码至少 6 位").max(20, "密码最多 20 位"),
role: roleEnum.default(Role.NORMAL),
classname: z.string().trim().max(50).default(""),
}),
)
if (userExists(payload.username)) throw new HttpError(400, "用户已存在")
const id = await insertUser(payload)
return c.json({ id, message: "创建成功" })
})
// 修改用户(替代原来 Django Admin 的修改页面)
router.put("/:id{[0-9]+}", async (c) => {
requireSuper(c)
const id = intParam(c, "id")
const payload = await body(
c,
z.object({
username: z.string().trim().min(1, "用户名不能为空").max(150).optional(),
password: z.string().max(20, "密码最多 20 位").optional(),
role: roleEnum.optional(),
classname: z.string().trim().max(50).optional(),
}),
)
const user = orNotFound(
get<{ id: number; username: string; role: string }>(
`SELECT id, username, role FROM account_user WHERE id = $id`,
{ id },
),
"查无此人",
)
if (user.role === Role.SUPER) throw new HttpError(400, "不能在这里修改超级管理员")
if (payload.password && payload.password.length < 6) {
throw new HttpError(400, "密码至少 6 位")
}
if (payload.username && payload.username !== user.username && userExists(payload.username)) {
throw new HttpError(400, "用户名已存在")
}
run(
`UPDATE account_user SET
username = COALESCE($username, username),
role = COALESCE($role, role),
classname = COALESCE($classname, classname)
WHERE id = $id`,
{
id,
username: payload.username ?? null,
role: payload.role ?? null,
classname: payload.classname ?? null,
},
)
if (payload.password) await setPassword(id, payload.password)
return c.json({ message: "修改成功" })
})
router.get("/:id{[0-9]+}", (c) => {
requireSuper(c)
const u = orNotFound(
get<{ is_active: number }>(
`SELECT id, username, role, classname, is_active FROM account_user WHERE id = $id`,
{ id: intParam(c, "id") },
),
"查无此人",
)
return c.json({ ...u, is_active: bool(u.is_active) })
})
router.put("/active/:id", (c) => {
requireSuper(c)
const id = intParam(c, "id")
const user = orNotFound(
get<{ username: string; is_active: number }>(
`SELECT username, is_active FROM account_user WHERE id = $id`,
{ id },
),
"查无此人",
)
const active = !user.is_active
run(`UPDATE account_user SET is_active = $active WHERE id = $id`, { active, id })
if (!active) run(`DELETE FROM auth_session WHERE user_id = $id`, { id })
return c.json({ message: `${user.username} ${active ? "解封" : "封号"}成功` })
})
router.get("/classes", (c) => {
const rows = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user WHERE classname > '' ORDER BY classname`,
)
return c.json(rows.map((r) => r.classname))
})
router.get("/names", (c) => {
const { classname } = query(c, z.object({ classname: z.string() }))
const prefix = "web" + classname
const rows = all<{ username: string }>(
`SELECT username FROM account_user
WHERE classname = $classname AND substr(username, 1, $len) = $prefix
ORDER BY username`,
{ classname, prefix, len: prefix.length },
)
return c.json(rows.map((u) => ({ name: u.username.slice(prefix.length), username: u.username })))
})
export default router
+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 })
})
+197
View File
@@ -0,0 +1,197 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, iso, uuidOut } from "../db"
import { Role, isStaff, requireUser } from "../auth"
import { HttpError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
import { deleteSubmissions } from "../services/cascade"
import {
classifyConversationMessages,
classifyMessagesBatch,
inBackground,
} from "../services/classifier"
import { getPrecedingUserMessage, type MessageRow } from "../services/prompt"
const router = new Hono<Env>()
router.get("/conversations", (c) => {
const user = requireUser(c)
const q = query(c, z.object({ task_id: qs.opt(qs.int), user_id: qs.opt(qs.int) }))
const where: string[] = []
const params: Record<string, unknown> = {}
// 普通用户只能看自己的
if (user.role === Role.NORMAL) {
where.push("c.user_id = $user_id")
params.user_id = user.id
} else if (q.user_id) {
where.push("c.user_id = $user_id")
params.user_id = q.user_id
}
if (q.task_id) {
where.push("c.task_id = $task_id")
params.task_id = q.task_id
}
const rows = all(
`SELECT c.id, c.user_id, u.username, c.task_id, t.title AS task_title, c.is_active, c.created,
(SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) AS message_count
FROM prompt_conversation c
JOIN account_user u ON u.id = c.user_id
JOIN task_task t ON t.id = c.task_id
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY message_count DESC, c.created DESC`,
params,
)
return c.json(
rows.map((r) => ({
id: uuidOut(r.id),
user_id: r.user_id,
username: r.username,
task_id: r.task_id,
task_title: r.task_title,
is_active: !!r.is_active,
message_count: r.message_count,
created: iso(r.created),
})),
)
})
router.get("/conversations/:conversation_id/messages", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "conversation_id")
const conv = orNotFound(
get<{ user_id: number }>(`SELECT user_id FROM prompt_conversation WHERE id = $id`, { id }),
)
if (user.role === Role.NORMAL && conv.user_id !== user.id) return c.json([])
const messages = all<MessageRow>(
`SELECT * FROM prompt_message WHERE conversation_id = $id ORDER BY created, id`,
{ id },
)
return c.json(
messages.map((m) => ({
id: m.id,
role: m.role,
source: m.source,
content: m.content,
code_html: m.code_html,
code_css: m.code_css,
code_js: m.code_js,
prompt_level: m.prompt_level,
created: iso(m.created),
})),
)
})
// 当前用户在某任务下的历史对话轮次:用户提示词 + 紧随其后的 assistant 消息中的页面代码
router.get("/history/:task_id", (c) => {
const user = requireUser(c)
const taskId = intParam(c, "task_id")
const messages = all<MessageRow>(
`SELECT m.* FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE c.user_id = $user_id AND c.task_id = $task_id
ORDER BY m.conversation_id, m.created, m.id`,
{ user_id: user.id, task_id: taskId },
)
const byConv = Map.groupBy(messages, (m) => m.conversation_id)
const items: { key: [string, number]; item: object }[] = []
for (const msgs of byConv.values()) {
msgs.forEach((userMsg, idx) => {
if (userMsg.role !== "user") return
let asst: MessageRow | undefined
for (const reply of msgs.slice(idx + 1)) {
if (reply.role === "user") break
if (reply.role === "assistant") {
asst = reply
break
}
}
if (!asst) return
items.push({
key: [userMsg.created, userMsg.id],
item: {
user_message_id: userMsg.id,
assistant_message_id: asst.id,
submission_id: uuidOut(asst.submission_id),
source: userMsg.source,
prompt: userMsg.content,
prompt_level: userMsg.prompt_level,
code_html: asst.code_html,
code_css: asst.code_css,
code_js: asst.code_js,
created: iso(userMsg.created),
},
})
})
}
items.sort((a, b) =>
a.key[0] < b.key[0] ? -1 : a.key[0] > b.key[0] ? 1 : a.key[1] - b.key[1],
)
return c.json(items.map((i) => i.item))
})
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
router.post("/conversations/:conversation_id/classify", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const id = uuidParam(c, "conversation_id")
const { force } = query(c, z.object({ force: qs.opt(qs.bool).default(false) }))
orNotFound(get(`SELECT 1 FROM prompt_conversation WHERE id = $id`, { id }))
inBackground(classifyConversationMessages(id, force))
return c.json({ message: "开始分类" })
})
// 批量分类所有(或指定任务)对话的用户消息(管理员,后台执行)
router.post("/classify-batch", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const q = query(
c,
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
)
const where = ["m.role = 'user'"]
const params: Record<string, unknown> = {}
if (q.task_id) {
where.push("c.task_id = $task_id")
params.task_id = q.task_id
}
if (!q.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)
inBackground(classifyMessagesBatch(ids))
return c.json({ message: `开始分类 ${ids.length} 条消息`, count: ids.length })
})
// 删除一轮对话(assistant 消息 + 它前面的用户消息)以及关联的提交,只能删自己的
router.delete("/messages/:message_id/pair", (c) => {
const user = requireUser(c)
const asst = orNotFound(
get<MessageRow & { owner_id: number }>(
`SELECT m.*, c.user_id AS owner_id FROM prompt_message m
JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE m.id = $id AND m.role = 'assistant'`,
{ id: intParam(c, "message_id") },
),
)
if (asst.owner_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能删除自己的消息")
}
if (asst.submission_id && user.role !== Role.SUPER) {
const used = get(
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
{ id: asst.submission_id },
)
if (used) throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除")
}
const userMsg = getPrecedingUserMessage(asst)
tx(() => {
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
run(`DELETE FROM prompt_message WHERE id = $id`, { id: asst.id })
if (asst.submission_id) deleteSubmissions("id = $id", { id: asst.submission_id })
})
return c.json({ deleted: true, submission_deleted: !!asst.submission_id })
})
export default router
+343
View File
@@ -0,0 +1,343 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, now, iso, uuidHex, uuidOut } from "../db"
import { requireAdmin, requireUser } from "../auth"
import { HttpError, ValidationError, body, bool, intParam, orNotFound, uuidParam, type Ctx, type Env } from "../http"
import { buildPromptRounds, type MessageRow } from "../services/prompt"
const router = new Hono<Env>()
const ITEM_ORDERINGS = ["manual", "awarded_at", "score", "view_count"] as const
// 作品排序方式 → ORDER BY
const ORDER_BY: Record<string, string> = {
manual: "sa.sort_order, sa.id",
awarded_at: "sa.awarded_at DESC, sa.sort_order, sa.id",
score: "s.score DESC, sa.sort_order, sa.id",
view_count: "s.view_count DESC, sa.sort_order, sa.id",
}
const HAS_CHAIN = `EXISTS (SELECT 1 FROM prompt_message m WHERE m.submission_id = s.id) AS has_prompt_chain`
const ITEM_SELECT = `SELECT sa.id, sa.submission_id, sa.sort_order, sa.awarded_at, sa.is_stale,
u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submissionaward sa
JOIN submission_submission s ON s.id = sa.submission_id
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id`
function itemManageOut(r: any) {
return {
id: r.id,
submission_id: uuidOut(r.submission_id),
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
score: r.score,
view_count: r.view_count,
sort_order: r.sort_order,
awarded_at: iso(r.awarded_at),
has_prompt_chain: bool(r.has_prompt_chain),
is_stale: bool(r.is_stale),
}
}
const loadItem = (id: number) => get(`${ITEM_SELECT} WHERE sa.id = $id`, { id })
function awardManageOut(a: any) {
return {
id: a.id,
name: a.name,
description: a.description,
sort_order: a.sort_order,
is_active: bool(a.is_active),
item_ordering: a.item_ordering,
item_count: a.item_count,
}
}
const AWARD_SELECT = `SELECT a.*, (SELECT COUNT(*) FROM submission_submissionaward sa WHERE sa.award_id = a.id) AS item_count
FROM submission_award a`
const awardIn = z.object({
name: z.string(),
description: z.string().default(""),
sort_order: z.number().int().default(0),
is_active: z.boolean().default(true),
item_ordering: z.string().default("manual"),
})
async function parseAward(c: Ctx) {
const p = await body(c, awardIn)
if (!(ITEM_ORDERINGS as readonly string[]).includes(p.item_ordering)) {
throw new HttpError(400, "无效的作品排序方式")
}
return p
}
function assertNameFree(name: string, exceptId = 0) {
if (get(`SELECT 1 FROM submission_award WHERE name = $name AND id != $id`, { name, id: exceptId })) {
throw new HttpError(400, "奖项名称已存在")
}
}
// ── 前台作品展 ────────────────────────────────────────────────────────────────
router.get("/", (c) => {
requireUser(c)
const awards = all(`SELECT * FROM submission_award WHERE is_active = 1 ORDER BY sort_order`)
const result = []
for (const award of awards) {
const items = all(
`${ITEM_SELECT} WHERE sa.award_id = $id ORDER BY ${ORDER_BY[award.item_ordering] ?? ORDER_BY.manual}`,
{ id: award.id },
)
if (!items.length) continue
result.push({
id: award.id,
name: award.name,
description: award.description,
item_ordering: award.item_ordering,
items: items.map((r) => ({
submission_id: uuidOut(r.submission_id),
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
score: r.score,
view_count: r.view_count,
html: r.html,
css: r.css,
js: r.js,
has_prompt_chain: bool(r.has_prompt_chain),
})),
})
}
return c.json(result)
})
// ── 管理 ──────────────────────────────────────────────────────────────────────
router.get("/manage/awards", (c) => {
requireAdmin(c)
return c.json(all(`${AWARD_SELECT} ORDER BY a.sort_order, a.id`).map(awardManageOut))
})
router.post("/manage/awards", async (c) => {
requireAdmin(c)
const p = await parseAward(c)
assertNameFree(p.name)
const ts = now()
const res = run(
`INSERT INTO submission_award (created, modified, name, description, sort_order, is_active, item_ordering)
VALUES ($ts, $ts, $name, $description, $sort_order, $is_active, $item_ordering)`,
{ ts, ...p },
)
return c.json(awardManageOut(get(`${AWARD_SELECT} WHERE a.id = $id`, { id: Number(res.lastInsertRowid) })))
})
router.put("/manage/awards/:award_id", async (c) => {
requireAdmin(c)
const id = intParam(c, "award_id")
const p = await parseAward(c)
orNotFound(get(`SELECT 1 FROM submission_award WHERE id = $id`, { id }))
assertNameFree(p.name, id)
run(
`UPDATE submission_award SET name = $name, description = $description, sort_order = $sort_order,
is_active = $is_active, item_ordering = $item_ordering, modified = $ts
WHERE id = $id`,
{ id, ts: now(), ...p },
)
return c.json(awardManageOut(get(`${AWARD_SELECT} WHERE a.id = $id`, { id })))
})
router.delete("/manage/awards/:award_id", (c) => {
requireAdmin(c)
const id = intParam(c, "award_id")
orNotFound(get(`SELECT 1 FROM submission_award WHERE id = $id`, { id }))
tx(() => {
run(`DELETE FROM submission_submissionaward WHERE award_id = $id`, { id })
run(`DELETE FROM submission_award WHERE id = $id`, { id })
})
return c.json({ message: "删除成功" })
})
router.get("/manage/submissions/:submission_id", (c) => {
requireAdmin(c)
const id = uuidParam(c, "submission_id")
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, ${HAS_CHAIN}
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 s.id = $id`,
{ id },
),
)
return c.json({
submission_id: uuidOut(s.id),
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
score: s.score,
view_count: s.view_count,
has_prompt_chain: bool(s.has_prompt_chain),
})
})
router.get("/manage/awards/:award_id/items", (c) => {
requireAdmin(c)
const award = orNotFound(
get<{ id: number; item_ordering: string }>(`SELECT id, item_ordering FROM submission_award WHERE id = $id`, {
id: intParam(c, "award_id"),
}),
)
const items = all(
`${ITEM_SELECT} WHERE sa.award_id = $id ORDER BY ${ORDER_BY[award.item_ordering] ?? ORDER_BY.manual}`,
{ id: award.id },
)
return c.json(items.map(itemManageOut))
})
router.post("/manage/awards/:award_id/items", async (c) => {
requireAdmin(c)
const awardId = intParam(c, "award_id")
const p = await body(c, z.object({ submission_id: z.string(), sort_order: z.number().int().default(0) }))
const submissionId = uuidHex(p.submission_id)
if (!submissionId) throw new ValidationError([{ loc: ["body", "submission_id"], msg: "需要 UUID" }])
orNotFound(get(`SELECT 1 FROM submission_award WHERE id = $id`, { id: awardId }))
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id: submissionId }))
if (
get(`SELECT 1 FROM submission_submissionaward WHERE award_id = $a AND submission_id = $s`, {
a: awardId,
s: submissionId,
})
) {
throw new HttpError(400, "该作品已在奖项中")
}
const ts = now()
const res = run(
`INSERT INTO submission_submissionaward (created, modified, sort_order, awarded_at, award_id, submission_id, is_stale)
VALUES ($ts, $ts, $sort_order, $ts, $award_id, $submission_id, 0)`,
{ ts, sort_order: p.sort_order, award_id: awardId, submission_id: submissionId },
)
return c.json(itemManageOut(loadItem(Number(res.lastInsertRowid))))
})
router.put("/manage/items/:item_id", async (c) => {
requireAdmin(c)
const id = intParam(c, "item_id")
const { sort_order } = await body(c, z.object({ sort_order: z.number().int().default(0) }))
orNotFound(get(`SELECT 1 FROM submission_submissionaward WHERE id = $id`, { id }))
run(`UPDATE submission_submissionaward SET sort_order = $sort_order, modified = $ts WHERE id = $id`, {
sort_order,
ts: now(),
id,
})
return c.json(itemManageOut(loadItem(id)))
})
router.delete("/manage/items/:item_id", (c) => {
requireAdmin(c)
const id = intParam(c, "item_id")
orNotFound(get(`SELECT 1 FROM submission_submissionaward WHERE id = $id`, { id }))
run(`DELETE FROM submission_submissionaward WHERE id = $id`, { id })
return c.json({ message: "删除成功" })
})
// 把作品展条目切换到该学生该挑战的最新提交
router.put("/manage/items/:item_id/refresh", (c) => {
requireAdmin(c)
const id = intParam(c, "item_id")
const item = orNotFound(
get<{ user_id: number; task_id: number; submission_id: string; award_id: number }>(
`SELECT s.user_id, s.task_id, sa.submission_id, sa.award_id
FROM submission_submissionaward sa JOIN submission_submission s ON s.id = sa.submission_id
WHERE sa.id = $id`,
{ id },
),
)
const latest = get<{ id: string }>(
`SELECT id FROM submission_submission WHERE user_id = $user_id AND task_id = $task_id
ORDER BY created DESC LIMIT 1`,
{ user_id: item.user_id, task_id: item.task_id },
)
const target = latest?.id ?? item.submission_id
if (
target !== item.submission_id &&
get(`SELECT 1 FROM submission_submissionaward WHERE award_id = $a AND submission_id = $s`, {
a: item.award_id,
s: target,
})
) {
throw new HttpError(400, "最新提交已在该奖项中")
}
run(
`UPDATE submission_submissionaward SET submission_id = $sid, is_stale = 0, modified = $ts WHERE id = $id`,
{ sid: target, ts: now(), id },
)
return c.json(itemManageOut(loadItem(id)))
})
// ── 作品详情 ──────────────────────────────────────────────────────────────────
function assertAwarded(submissionId: string) {
if (
!get(
`SELECT 1 FROM submission_submissionaward sa JOIN submission_award a ON a.id = sa.award_id
WHERE sa.submission_id = $id AND a.is_active = 1`,
{ id: submissionId },
)
) {
throw new HttpError(404, "作品不存在或未授奖")
}
}
router.get("/:submission_id", (c) => {
requireUser(c)
const id = uuidParam(c, "submission_id")
assertAwarded(id)
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
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 s.id = $id`,
{ id },
),
)
const awards = all<{ name: string }>(
`SELECT a.name FROM submission_submissionaward sa JOIN submission_award a ON a.id = sa.award_id
WHERE sa.submission_id = $id AND a.is_active = 1 ORDER BY sa.sort_order, sa.id`,
{ id },
)
return c.json({
submission_id: uuidOut(s.id),
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
score: s.score,
view_count: s.view_count,
html: s.html,
css: s.css,
js: s.js,
awards: awards.map((a) => a.name),
has_prompt_chain: bool(s.has_prompt_chain),
})
})
router.get("/:submission_id/prompt-chain", (c) => {
requireUser(c)
const id = uuidParam(c, "submission_id")
assertAwarded(id)
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
if (!msg) throw new HttpError(404, "该作品没有关联提示词链")
return c.json(buildPromptRounds(msg))
})
export default router
+558
View File
@@ -0,0 +1,558 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
import { Role, isStaff, requireSuper, requireUser } from "../auth"
import {
HttpError,
ValidationError,
body,
intParam,
notFound,
orNotFound,
pageQuery,
pageWindow,
qs,
query,
type Env,
uuidParam,
} from "../http"
import { deleteSubmissions } from "../services/cascade"
import { updateSubmissionScore } from "../services/score"
import { classifyMessage, inBackground } from "../services/classifier"
import { buildGradebook, gradebookCsvRows, toCsv } from "../services/gradebook"
import {
buildPromptRounds,
createConversation,
getActiveConversation,
getPrecedingUserMessage,
insertMessage,
type MessageRow,
} from "../services/prompt"
import { pyRound } from "../services/pyround"
import showcase from "./showcase"
const router = new Hono<Env>()
router.route("/showcase", showcase)
// 当天(UTC 零点起)的评分次数,与原实现保持一致
function todayRatingCount(userId: number) {
const start = new Date()
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start.getTime() + 86_400_000)
const fmt = (d: Date) => d.toISOString().slice(0, 19).replace("T", " ") + ".000000"
return get<{ n: number }>(
`SELECT COUNT(*) AS n FROM submission_rating
WHERE user_id = $id AND created >= $start AND created <= $end`,
{ id: userId, start: fmt(start), end: fmt(end) },
)!.n
}
const SUBMISSION_SELECT = `SELECT s.id, s.user_id, u.username, s.task_id, t.display AS task_display,
t.title AS task_title, t.task_type, s.score, s.html, s.css, s.js, s.flag, s.zone,
s.view_count, s.created, s.modified,
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
const SUBMISSION_FROM = `FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id`
function submissionOut(s: any) {
return {
id: uuidOut(s.id),
userid: s.user_id,
username: s.username,
task_id: s.task_id,
task_display: s.task_display,
task_title: s.task_title,
task_type: s.task_type,
score: s.score,
my_score: s.my_score || 0,
html: s.html,
css: s.css,
js: s.js,
flag: s.flag,
zone: s.zone,
submit_count: s.submit_count || 0,
view_count: s.view_count,
created: iso(s.created),
modified: iso(s.modified),
}
}
const hasRatingsOrAwards = (submissionId: string) =>
!!get(
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
{ id: submissionId },
)
// ── 创建提交 ──────────────────────────────────────────────────────────────────
router.post("/", async (c) => {
const user = requireUser(c)
const p = await body(
c,
z.object({
task_id: z.number().int(),
html: z.string().nullable().optional(),
css: z.string().nullable().optional(),
js: z.string().nullable().optional(),
prompt: z.string().nullable().optional(),
message_id: z.number().int().nullable().optional(), // 关联的 assistant message
}),
)
const task = orNotFound(
get<{ id: number; task_type: string }>(`SELECT id, task_type FROM task_task WHERE id = $id`, {
id: p.task_id,
}),
)
if (task.task_type === "creation") throw new HttpError(400, "自由创作不需要提交")
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
const { submissionId, newUserMsgId } = tx(() => {
let manualAsstMsgId: number | null = null
let linkedMsg: MessageRow | null = null
let newUserMsgId: number | null = null
if (p.prompt) {
const convId =
getActiveConversation(user.id, task.id) ?? createConversation(user.id, task.id, false)
newUserMsgId = insertMessage({
conversation_id: convId,
role: "user",
content: p.prompt,
source: "manual",
})
manualAsstMsgId = insertMessage({
conversation_id: convId,
role: "assistant",
content: "",
code_html: code.html,
code_css: code.css,
code_js: code.js,
source: "manual",
})
} else if (p.message_id) {
linkedMsg = get<MessageRow>(
`SELECT m.* FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE m.id = $id AND m.role = 'assistant' AND c.user_id = $user_id AND c.task_id = $task_id`,
{ id: p.message_id, user_id: user.id, task_id: task.id },
)
if (!linkedMsg) {
console.warn(
`create_submission: message_id ${p.message_id} not found for user ${user.id} task ${task.id}`,
)
} else {
newUserMsgId = getPrecedingUserMessage(linkedMsg)?.id ?? null
}
}
// 同一条 AI 消息重复提交时更新原来的提交,而不是再建一条
if (linkedMsg?.submission_id) {
run(
`UPDATE submission_submission SET html = $html, css = $css, js = $js WHERE id = $id`,
{ ...code, id: linkedMsg.submission_id },
)
return { submissionId: linkedMsg.submission_id, newUserMsgId }
}
const id = newUuidHex()
const ts = now()
run(
`INSERT INTO submission_submission
(id, created, modified, score, html, css, js, task_id, user_id, flag, raw_score, zone, view_count)
VALUES ($id, $ts, $ts, 0, $html, $css, $js, $task_id, $user_id, NULL, 0, NULL, 0)`,
{ id, ts, ...code, task_id: task.id, user_id: user.id },
)
const msgToLink = manualAsstMsgId ?? linkedMsg?.id
if (msgToLink) {
run(`UPDATE prompt_message SET submission_id = $sid WHERE id = $mid`, {
sid: id,
mid: msgToLink,
})
}
// 该学生该任务之前进入作品展的提交标记为“有更新”
run(
`UPDATE submission_submissionaward SET is_stale = 1
WHERE is_stale = 0 AND submission_id != $id AND submission_id IN
(SELECT id FROM submission_submission WHERE user_id = $user_id AND task_id = $task_id)`,
{ id, user_id: user.id, task_id: task.id },
)
return { submissionId: id, newUserMsgId }
})
// 只对新增的提示词做层级分类
if (
newUserMsgId !== null &&
!get(`SELECT 1 FROM prompt_message WHERE id = $id AND prompt_level IS NOT NULL`, {
id: newUserMsgId,
})
) {
inBackground(classifyMessage(newUserMsgId))
}
return c.json({ id: uuidOut(submissionId) })
})
// ── 列表 ──────────────────────────────────────────────────────────────────────
const listQuery = pageQuery.extend({
task_id: qs.opt(qs.int),
task_type: qs.opt(z.enum(["tutorial", "challenge"])),
username: qs.opt(z.string()),
user_id: qs.opt(qs.int),
flag: qs.opt(z.enum(["red", "blue", "green", "yellow", "any"])),
zone: qs.opt(z.enum(["featured", "low", "pending"])),
score_min: qs.opt(qs.num),
score_max_exclusive: qs.opt(qs.num),
score_lt_threshold: qs.opt(qs.num),
ordering: qs.opt(z.string()),
grouped: qs.opt(qs.bool).default(true),
})
router.get("/", (c) => {
const user = requireUser(c)
const f = query(c, listQuery)
const where: string[] = []
const params: Record<string, unknown> = {}
if (f.task_id) {
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: f.task_id }))
where.push("s.task_id = $task_id")
params.task_id = f.task_id
} else if (f.task_type) {
where.push("t.task_type = $task_type")
params.task_type = f.task_type
}
if (f.username) {
where.push(`u.username LIKE $username ESCAPE '\\'`)
params.username = likeEscape(f.username)
}
if (f.user_id) {
where.push("s.user_id = $user_id")
params.user_id = f.user_id
}
if (f.flag === "any") where.push("s.flag IS NOT NULL")
else if (f.flag) {
where.push("s.flag = $flag")
params.flag = f.flag
}
if (f.zone) {
where.push("s.zone = $zone")
params.zone = f.zone
}
if (f.score_lt_threshold !== undefined) {
where.push("s.score < $score_lt")
params.score_lt = f.score_lt_threshold
} else {
if (f.score_min !== undefined) {
where.push("s.score >= $score_min")
params.score_min = f.score_min
}
if (f.score_max_exclusive !== undefined) {
where.push("s.score < $score_max")
params.score_max = f.score_max_exclusive
}
}
if (f.grouped) {
// 分组模式:每个 (user, task) 只保留最新一条
where.push(`s.id = (SELECT s3.id FROM submission_submission s3
WHERE s3.user_id = s.user_id AND s3.task_id = s.task_id ORDER BY s3.created DESC LIMIT 1)`)
}
const orderings: Record<string, string> = {
"-score": "s.score DESC",
score: "s.score ASC",
"-created": "s.created DESC",
}
const orderBy = orderings[f.ordering ?? ""] ?? "s.created DESC"
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
const { limit, offset } = pageWindow(f)
const items = all(
`${SUBMISSION_SELECT},
(SELECT COUNT(*) FROM submission_submission s2
WHERE s2.user_id = s.user_id AND s2.task_id = s.task_id) AS submit_count
${SUBMISSION_FROM} ${whereSql}
ORDER BY ${orderBy} LIMIT $limit OFFSET $offset`,
{ ...params, me: user.id, limit, offset },
)
const count = get<{ n: number }>(`SELECT COUNT(*) AS n ${SUBMISSION_FROM} ${whereSql}`, params)!.n
return c.json({ items: items.map(submissionOut), count })
})
router.get("/by-user-task", (c) => {
const me = requireUser(c)
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
const rows = all(
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
{ me: me.id, user_id: q.user_id, task_id: q.task_id },
)
return c.json(rows.map(submissionOut))
})
// 随机返回一个待打分的其他同学的提交(AI 生成期间的随手打分弹窗)
router.get("/random-for-rating", (c) => {
const user = requireUser(c)
const q = query(c, z.object({ exclude_id: qs.opt(z.string()) }))
let excludeId: string | null = null
if (q.exclude_id) {
excludeId = uuidHex(q.exclude_id)
if (!excludeId) throw new ValidationError([{ loc: ["query", "exclude_id"], msg: "需要 UUID" }])
}
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null)
const base = `SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
t.task_type, s.html, s.css, s.js
${SUBMISSION_FROM}
WHERE s.user_id != $me
AND NOT EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id AND r.user_id = $me)
${excludeId ? "AND s.id != $exclude" : ""}`
const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) }
const pick =
get(
`${base} AND (SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5
ORDER BY RANDOM() LIMIT 1`,
params,
) ?? get(`${base} ORDER BY RANDOM() LIMIT 1`, params)
if (!pick) return c.json(null)
return c.json({
submission_id: uuidOut(pick.id),
username: pick.username,
task_title: pick.task_title,
task_display: pick.task_display,
task_type: pick.task_type,
html: pick.html,
css: pick.css,
js: pick.js,
})
})
router.delete("/flags", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const res = run(`UPDATE submission_submission SET flag = NULL WHERE flag IS NOT NULL`)
return c.json({ cleared: res.changes })
})
// ── 统计 ──────────────────────────────────────────────────────────────────────
router.get("/stats/:task_id", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const taskId = intParam(c, "task_id")
const { classname } = query(c, z.object({ classname: qs.opt(z.string()) }))
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }))
const classes = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user
WHERE role = 'normal' 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 = 'normal' ${classname ? "AND classname = $classname" : ""}
ORDER BY classname, username`,
classname ? { classname } : {},
)
const studentIds = new Set(students.map((s) => s.id))
const subs = all<{ id: string; user_id: number; created: string; score: number; flag: string | null }>(
`SELECT id, user_id, created, score, flag FROM submission_submission WHERE task_id = $id`,
{ id: taskId },
)
const studentSubs = subs.filter((s) => studentIds.has(s.user_id))
const submittedIds = new Set(studentSubs.map((s) => s.user_id))
const latest = new Map<number, (typeof subs)[number]>()
const subCount = new Map<number, number>()
for (const s of studentSubs) {
const cur = latest.get(s.user_id)
if (!cur || s.created > cur.created) latest.set(s.user_id, s)
subCount.set(s.user_id, (subCount.get(s.user_id) ?? 0) + 1)
}
// 平均分:每人最新一次提交中分数 > 0 的
const scored = [...latest.values()].filter((s) => s.score > 0)
const averageScore = scored.length
? pyRound(scored.reduce((a, s) => a + s.score, 0) / scored.length, 2)
: null
// 未评分:提交过但该任务下所有提交都没人打分
const ratedIds = new Set(
all<{ user_id: number }>(
`SELECT DISTINCT s.user_id FROM submission_rating r
JOIN submission_submission s ON s.id = r.submission_id WHERE s.task_id = $id`,
{ id: taskId },
).map((r) => r.user_id),
)
const tag = (s: { username: string; classname: string }) => ({
username: s.username,
classname: s.classname,
})
const dist = { count_1: 0, count_2: 0, count_3: 0, count_4_plus: 0 }
for (const n of subCount.values()) {
if (n === 1) dist.count_1++
else if (n === 2) dist.count_2++
else if (n === 3) dist.count_3++
else dist.count_4_plus++
}
// 标记统计:该任务所有提交(不按学生过滤)
const flagStats = { red: 0, blue: 0, green: 0, yellow: 0 }
for (const s of subs) {
if (s.flag && s.flag in flagStats) flagStats[s.flag as keyof typeof flagStats]++
}
const topViewed = classname
? all(
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
WHERE s.task_id = $id AND u.role = 'normal' AND u.classname = $classname
ORDER BY s.view_count DESC LIMIT 5`,
{ id: taskId, classname },
)
: all(
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
WHERE s.task_id = $id AND u.role = 'normal'
ORDER BY s.view_count DESC LIMIT 5`,
{ id: taskId },
)
const unrated = students.filter((s) => submittedIds.has(s.id) && !ratedIds.has(s.id))
return c.json({
submitted_count: submittedIds.size,
unsubmitted_count: students.length - submittedIds.size,
average_score: averageScore,
unrated_count: unrated.length,
unsubmitted_users: students.filter((s) => !submittedIds.has(s.id)).map(tag),
unrated_users: unrated.map(tag),
submission_count_distribution: dist,
flag_stats: flagStats,
classes,
top_viewed: topViewed.map((s) => ({
username: s.username,
classname: s.classname,
view_count: s.view_count,
submission_id: uuidOut(s.id),
})),
})
})
// ── 成绩册 ────────────────────────────────────────────────────────────────────
const gradebookQuery = z.object({
classname: z.string().default(""),
task_type: qs.opt(z.enum(["tutorial", "challenge"])),
username: qs.opt(z.string()),
include_all_tasks: qs.opt(qs.bool).default(false),
})
router.get("/gradebook", (c) => {
requireSuper(c)
return c.json(buildGradebook(query(c, gradebookQuery)))
})
router.get("/gradebook/export", (c) => {
requireSuper(c)
const gb = buildGradebook(query(c, gradebookQuery))
const filename = encodeURIComponent(`gradebook-${gb.classname}.csv`)
return c.body("" + toCsv(gradebookCsvRows(gb)), 200, {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename*=UTF-8''${filename}`,
})
})
// ── 单个提交 ──────────────────────────────────────────────────────────────────
router.delete("/:id", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const sub = orNotFound(
get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }),
)
if (sub.user_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能删除自己的提交")
}
if (user.role !== Role.SUPER && hasRatingsOrAwards(id)) {
throw new HttpError(400, "该提交已被评分或获奖,无法删除")
}
tx(() => {
// 关联的助手消息会随提交一起删除,它前面那条用户消息也一起删掉
const asst = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
const userMsg = asst ? getPrecedingUserMessage(asst) : null
deleteSubmissions("id = $id", { id })
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
})
return c.json({ message: "删除成功" })
})
router.get("/:id/prompt-chain", (c) => {
requireUser(c)
const id = uuidParam(c, "id")
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
return c.json(buildPromptRounds(msg))
})
router.get("/:id", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
return c.json(submissionOut(s))
})
// 增加浏览次数(仅在全屏预览时调用)
router.post("/:id/view", (c) => {
requireUser(c)
const id = uuidParam(c, "id")
const res = run(`UPDATE submission_submission SET view_count = view_count + 1 WHERE id = $id`, { id })
if (!res.changes) notFound("提交不存在")
return c.json({ ok: true })
})
router.put("/:id/score", async (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const { score } = await body(c, z.object({ score: z.number().int() }))
if (score <= 0) throw new HttpError(400, "分数不能为零")
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
user_id: user.id,
id,
})
if (exists) return c.json({ message: "你已经给这个提交打过分了" })
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) {
throw new HttpError(400, "普通用户每天最多只能评分30次。")
}
tx(() => {
run(
`INSERT INTO submission_rating (score, created, submission_id, user_id)
VALUES ($score, $ts, $id, $user_id)`,
{ score, ts: now(), id, user_id: user.id },
)
updateSubmissionScore(id)
})
return c.json({ message: "打分成功" })
})
router.put("/:id/flag", async (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const id = uuidParam(c, "id")
const { flag } = await body(
c,
z.object({ flag: z.enum(["red", "blue", "green", "yellow"]).nullable().default(null) }),
)
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
return c.json({ flag })
})
export default router
+417
View File
@@ -0,0 +1,417 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, now, iso } from "../db"
import { Role, requireAdmin, requireSuper, requireUser, type User } from "../auth"
import { HttpError, body, bool, intParam, orNotFound, type Env } from "../http"
import { deleteTask } from "../services/cascade"
const TASK_COLUMNS = `t.id, t.created, t.modified, t.display, t.title, t.content, t.task_type,
t.is_public, t.example_html, t.example_css, t.example_js`
function taskOut(t: any) {
return {
id: t.id,
task_ptr: t.id,
created: iso(t.created),
modified: iso(t.modified),
display: t.display,
title: t.title,
content: t.content,
task_type: t.task_type,
is_public: bool(t.is_public),
example_html: t.example_html,
example_css: t.example_css,
example_js: t.example_js,
}
}
function insertTask(type: string, p: {
display: number
title: string
content: string
is_public: boolean
example_html?: string | null
example_css?: string | null
example_js?: string | null
}) {
const ts = now()
const res = run(
`INSERT INTO task_task (created, modified, display, title, content, task_type, is_public, example_html, example_css, example_js)
VALUES ($ts, $ts, $display, $title, $content, $type, $is_public, $example_html, $example_css, $example_js)`,
{
ts,
type,
display: p.display,
title: p.title,
content: p.content,
is_public: p.is_public,
example_html: p.example_html ?? null,
example_css: p.example_css ?? null,
example_js: p.example_js ?? null,
},
)
return Number(res.lastInsertRowid)
}
function updateTask(id: number, p: {
title: string
content: string
is_public: boolean
example_html?: string | null
example_css?: string | null
example_js?: string | null
}) {
run(
`UPDATE task_task SET modified = $ts, title = $title, content = $content, is_public = $is_public,
example_html = $example_html, example_css = $example_css, example_js = $example_js
WHERE id = $id`,
{
id,
ts: now(),
title: p.title,
content: p.content,
is_public: p.is_public,
example_html: p.example_html ?? null,
example_css: p.example_css ?? null,
example_js: p.example_js ?? null,
},
)
}
function togglePublic(id: number) {
const t = get<{ display: number; title: string; is_public: number }>(
`SELECT display, title, is_public FROM task_task WHERE id = $id`,
{ id },
)!
const isPublic = !t.is_public
run(`UPDATE task_task SET is_public = $p, modified = $ts WHERE id = $id`, {
p: isPublic,
ts: now(),
id,
})
return { message: `【${t.display}】${t.title} 已${isPublic ? "公开" : "隐藏"}` }
}
const exampleFields = {
example_html: z.string().nullable().optional(),
example_css: z.string().nullable().optional(),
example_js: z.string().nullable().optional(),
}
// ── 教程 ──────────────────────────────────────────────────────────────────────
export const tutorial = new Hono<Env>()
const findTutorialId = (display: number) =>
get<{ id: number }>(
`SELECT t.id FROM task_task t JOIN task_tutorial x ON x.task_ptr_id = t.id
WHERE t.display = $display`,
{ display },
)?.id
tutorial.get("/list", (c) => {
requireSuper(c)
const rows = all(
`SELECT t.display, t.title, t.is_public FROM task_task t
JOIN task_tutorial x ON x.task_ptr_id = t.id ORDER BY t.display`,
)
return c.json(rows.map((r) => ({ ...r, is_public: bool(r.is_public) })))
})
tutorial.get("/display", (c) => {
const rows = all<{ display: number }>(
`SELECT t.display FROM task_task t JOIN task_tutorial x ON x.task_ptr_id = t.id
WHERE t.is_public = 1 ORDER BY t.display`,
)
return c.json(rows.map((r) => r.display))
})
tutorial.get("/:display", (c) => {
const row = orNotFound(
get(
`SELECT ${TASK_COLUMNS} FROM task_task t JOIN task_tutorial x ON x.task_ptr_id = t.id
WHERE t.display = $display`,
{ display: intParam(c, "display") },
),
)
return c.json(taskOut(row))
})
tutorial.post("/", async (c) => {
requireSuper(c)
const p = await body(
c,
z.object({
display: z.number().int(),
title: z.string(),
content: z.string(),
is_public: z.boolean().default(false),
...exampleFields,
}),
)
const id = findTutorialId(p.display)
if (id) {
updateTask(id, p)
return c.json({ message: "更新成功" })
}
tx(() => {
const newId = insertTask("tutorial", p)
run(`INSERT INTO task_tutorial (task_ptr_id) VALUES ($id)`, { id: newId })
})
return c.json({ message: "创建成功" })
})
tutorial.put("/public/:display", (c) => {
requireSuper(c)
const id = orNotFound(findTutorialId(intParam(c, "display")))
return c.json(togglePublic(id))
})
tutorial.delete("/:display", (c) => {
requireSuper(c)
const id = findTutorialId(intParam(c, "display"))
if (id) tx(() => deleteTask(id))
return c.json({ message: "删除成功" })
})
// ── 挑战 ──────────────────────────────────────────────────────────────────────
export const challenge = new Hono<Env>()
const CHALLENGE_SELECT = `SELECT ${TASK_COLUMNS}, x.score, x.pass_score, x.author_id,
COALESCE(u.username, '') AS author_name
FROM task_task t
JOIN task_challenge x ON x.task_ptr_id = t.id
LEFT JOIN account_user u ON u.id = x.author_id`
const findChallenge = (display: number) =>
get<{ id: number; author_id: number | null }>(
`SELECT t.id, x.author_id FROM task_task t JOIN task_challenge x ON x.task_ptr_id = t.id
WHERE t.display = $display`,
{ display },
)
function challengeSlim(r: any) {
return {
display: r.display,
title: r.title,
score: r.score,
pass_score: r.pass_score,
is_public: bool(r.is_public),
author_name: r.author_name,
}
}
challenge.get("/list", (c) => {
requireAdmin(c)
return c.json(all(`${CHALLENGE_SELECT} ORDER BY t.display`).map(challengeSlim))
})
challenge.get("/display", (c) => {
const user = c.get("user")
const rows = all(`${CHALLENGE_SELECT} WHERE t.is_public = 1 ORDER BY t.display DESC`)
const submitted = new Set(
user
? all<{ task_id: number }>(
`SELECT DISTINCT task_id FROM submission_submission WHERE user_id = $id`,
{ id: user.id },
).map((r) => r.task_id)
: [],
)
return c.json(rows.map((r) => ({ ...challengeSlim(r), submitted: submitted.has(r.id) })))
})
challenge.get("/:display", (c) => {
const r = orNotFound(
get(`${CHALLENGE_SELECT} WHERE t.display = $display`, {
display: intParam(c, "display"),
}),
)
return c.json({
...taskOut(r),
score: r.score,
pass_score: r.pass_score,
author: r.author_id,
author_name: r.author_name,
})
})
challenge.post("/", async (c) => {
const user = requireAdmin(c)
const p = await body(
c,
z.object({
display: z.number().int(),
title: z.string(),
content: z.string(),
score: z.number().int().default(0),
is_public: z.boolean().default(false),
...exampleFields,
}),
)
const existing = findChallenge(p.display)
if (existing) {
tx(() => {
updateTask(existing.id, p)
run(
`UPDATE task_challenge SET score = $score, author_id = COALESCE(author_id, $author)
WHERE task_ptr_id = $id`,
{ id: existing.id, score: p.score, author: user.id },
)
})
return c.json({ message: "更新成功" })
}
tx(() => {
const id = insertTask("challenge", p)
run(
`INSERT INTO task_challenge (task_ptr_id, score, pass_score, author_id)
VALUES ($id, $score, NULL, $author)`,
{ id, score: p.score, author: user.id },
)
})
return c.json({ message: "创建成功" })
})
challenge.put("/public/:display", (c) => {
requireSuper(c)
const found = orNotFound(findChallenge(intParam(c, "display")))
return c.json(togglePublic(found.id))
})
challenge.delete("/:display", (c) => {
requireAdmin(c)
const found = findChallenge(intParam(c, "display"))
if (found) tx(() => deleteTask(found.id))
return c.json({ message: "删除成功" })
})
// ── 自由创作 ──────────────────────────────────────────────────────────────────
export const creation = new Hono<Env>()
const CREATION_SELECT = `SELECT t.id, t.display, t.title, t.content, t.created, t.modified,
x.html, x.css, x.js, x.owner_id, u.username AS owner_name
FROM task_task t
JOIN task_creation x ON x.task_ptr_id = t.id
JOIN account_user u ON u.id = x.owner_id`
function creationOut(r: any) {
return {
task_id: r.id,
display: r.display,
title: r.title,
content: r.content,
html: r.html,
css: r.css,
js: r.js,
owner_name: r.owner_name,
created: iso(r.created),
modified: iso(r.modified),
}
}
function cleanTitle(title: string) {
title = title.trim()
if (!title) throw new HttpError(400, "主题名称不能为空")
return [...title].slice(0, 100).join("")
}
function getOwned(user: User, display: number) {
const r = orNotFound(get(`${CREATION_SELECT} WHERE t.display = $display`, { display }))
if (r.owner_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能操作自己的创作")
}
return r
}
const loadCreation = (id: number) => get(`${CREATION_SELECT} WHERE t.id = $id`, { id })
const creationIn = z.object({ title: z.string(), content: z.string().default("") })
creation.get("/mine", (c) => {
const user = requireUser(c)
const rows = all(`${CREATION_SELECT} WHERE x.owner_id = $id ORDER BY t.created DESC`, {
id: user.id,
})
return c.json(
rows.map((r) => ({
display: r.display,
title: r.title,
content: r.content,
created: iso(r.created),
modified: iso(r.modified),
has_code: Boolean(r.html || r.css || r.js),
})),
)
})
creation.post("/", async (c) => {
const user = requireUser(c)
const p = await body(c, creationIn)
const title = cleanTitle(p.title)
const id = tx(() => {
// 序号在所有创作中自增
const last = get<{ display: number }>(
`SELECT t.display FROM task_task t JOIN task_creation x ON x.task_ptr_id = t.id
ORDER BY t.display DESC LIMIT 1`,
)
const id = insertTask("creation", {
display: (last?.display ?? 0) + 1,
title,
content: p.content,
is_public: false,
})
run(
`INSERT INTO task_creation (task_ptr_id, html, css, js, owner_id) VALUES ($id, '', '', '', $owner)`,
{ id, owner: user.id },
)
return id
})
return c.json(creationOut(loadCreation(id)))
})
creation.get("/:display", (c) => {
const user = requireUser(c)
return c.json(creationOut(getOwned(user, intParam(c, "display"))))
})
creation.put("/:display", async (c) => {
const user = requireUser(c)
const r = getOwned(user, intParam(c, "display"))
const p = await body(c, creationIn)
run(`UPDATE task_task SET title = $title, content = $content, modified = $ts WHERE id = $id`, {
title: cleanTitle(p.title),
content: p.content,
ts: now(),
id: r.id,
})
return c.json(creationOut(loadCreation(r.id)))
})
creation.put("/:display/code", async (c) => {
const user = requireUser(c)
const r = getOwned(user, intParam(c, "display"))
const p = await body(
c,
z.object({
html: z.string().default(""),
css: z.string().default(""),
js: z.string().default(""),
}),
)
tx(() => {
run(`UPDATE task_creation SET html = $html, css = $css, js = $js WHERE task_ptr_id = $id`, {
...p,
id: r.id,
})
run(`UPDATE task_task SET modified = $ts WHERE id = $id`, { ts: now(), id: r.id })
})
return c.json(creationOut(loadCreation(r.id)))
})
creation.delete("/:display", (c) => {
const user = requireUser(c)
const r = getOwned(user, intParam(c, "display"))
tx(() => deleteTask(r.id))
return c.json({ message: "删除成功" })
})
+28
View File
@@ -0,0 +1,28 @@
import { run } from "../db"
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
// 调用方负责包在事务里。
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
const ids = `SELECT id FROM submission_submission WHERE ${where}`
run(`DELETE FROM submission_rating WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM submission_submissionaward WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM prompt_message WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM submission_submission WHERE ${where}`, params)
}
export function deleteTask(taskId: number) {
const p = { task_id: taskId }
deleteSubmissions("task_id = $task_id", p)
run(
`DELETE FROM prompt_message WHERE conversation_id IN
(SELECT id FROM prompt_conversation WHERE task_id = $task_id)`,
p,
)
run(`DELETE FROM prompt_conversation WHERE task_id = $task_id`, p)
run(`DELETE FROM task_taskasset WHERE task_id = $task_id`, p)
run(`DELETE FROM task_tutorial WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_challenge WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_creation WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_task WHERE id = $task_id`, p)
}
+79
View File
@@ -0,0 +1,79 @@
import { all, get, run } from "../db"
import { complete, llmConfigured } from "./llm"
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
层级定义:
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
只返回一个数字(1-6),不要解释。`
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function callLlm(content: string): Promise<number | null> {
if (!llmConfigured()) return null
try {
const text = await complete(
[
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
{ role: "user", content },
],
{ maxTokens: 10, timeoutMs: 30_000 },
)
const match = /[1-6]/.exec(text)
if (!match) {
console.warn(`classify: unexpected LLM response '${text}'`)
return null
}
return Number(match[0])
} catch (e) {
console.error("classify LLM call failed:", e)
return null
}
}
export async function classifyMessage(messageId: number): Promise<number | null> {
const msg = get<{ content: string }>(
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user'`,
{ id: messageId },
)
if (!msg) return null
const level = await callLlm(msg.content)
if (level !== null) {
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
level,
id: messageId,
})
}
return level
}
export async function classifyConversationMessages(conversationId: string, force = false) {
const rows = all<{ id: number }>(
`SELECT id FROM prompt_message
WHERE conversation_id = $id AND role = 'user' ${force ? "" : "AND prompt_level IS NULL"}
ORDER BY created, id`,
{ id: conversationId },
)
for (const { id } of rows) {
await classifyMessage(id)
await sleep(300)
}
}
export async function classifyMessagesBatch(ids: number[]) {
for (const id of ids) {
await classifyMessage(id)
await sleep(500)
}
}
// 后台执行,不阻塞请求
export function inBackground(p: Promise<unknown>) {
p.catch((e) => console.error("background task failed:", e))
}
+306
View File
@@ -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"
}
+202
View File
@@ -0,0 +1,202 @@
import { config } from "../config"
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
规则:
1. 使用一个 \`\`\`html 代码块返回所有代码
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
4. 用中文回复,先简要说明你做了什么,然后给出代码
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
输出格式示例(必须严格遵守,只用一个代码块):
好的,我为你创建了一个点击按钮变色的示例。
\`\`\`html
<style>
button { padding: 8px 16px; }
</style>
<button id="btn">点击我</button>
<script>
document.getElementById('btn').onclick = function() {
this.style.background = 'red';
};
</script>
\`\`\``
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述
规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
6. 不要生成任何代码`
const DEFAULT_MODEL = "deepseek-v4-flash"
const MODEL_ALIASES: Record<string, string> = {
"deepseek-v4-flash-thinking": DEFAULT_MODEL,
}
const NON_THINKING_MODELS = new Set(["deepseek-v4-flash"])
// 走火山方舟(ARK)接口的模型
const ARK_MODELS = new Set(["doubao-seed-2-0-lite-260215"])
export interface ChatMessage {
role: "system" | "user" | "assistant"
content: string
}
interface CompletionOptions {
model?: string
stream: boolean
maxTokens?: number
signal?: AbortSignal
timeoutMs?: number
}
export const llmConfigured = () => !!config.llmApiKey
function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
const requested = opts.model || DEFAULT_MODEL
const resolved = MODEL_ALIASES[requested] ?? requested
const ark = ARK_MODELS.has(resolved)
const body: Record<string, unknown> = {
model: resolved,
messages,
stream: opts.stream,
}
if (opts.maxTokens) body.max_tokens = opts.maxTokens
if (NON_THINKING_MODELS.has(resolved) && !(requested in MODEL_ALIASES)) {
body.thinking = { type: "disabled" }
}
const apiKey = ark ? config.arkApiKey : config.llmApiKey
if (!apiKey) throw new Error("未配置模型接口密钥")
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
const signals = [AbortSignal.timeout(opts.timeoutMs ?? 120_000)]
if (opts.signal) signals.push(opts.signal)
return fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: AbortSignal.any(signals),
})
}
async function ensureOk(res: Response) {
if (res.ok) return
const text = await res.text().catch(() => "")
throw new Error(`Error code: ${res.status} - ${text.slice(0, 500)}`)
}
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
async function* streamCompletion(
messages: ChatMessage[],
model?: string,
signal?: AbortSignal,
): AsyncGenerator<string> {
const res = await buildRequest(messages, { model, stream: true, signal })
await ensureOk(res)
if (!res.body) return
const decoder = new TextDecoder()
let buffer = ""
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true })
let idx: number
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith("data:")) continue
const data = line.slice(5).trim()
if (data === "[DONE]") return
const json = JSON.parse(data)
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
const content = json.choices?.[0]?.delta?.content
if (content) yield content
}
}
}
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...history], model, signal)
}
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
return streamCompletion(
[{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history],
undefined,
signal,
)
}
export async function complete(
messages: ChatMessage[],
opts: { model?: string; maxTokens?: number; timeoutMs?: number } = {},
): Promise<string> {
const res = await buildRequest(messages, {
model: opts.model ?? config.llmModel,
stream: false,
maxTokens: opts.maxTokens,
timeoutMs: opts.timeoutMs,
})
await ensureOk(res)
const json: any = await res.json()
return json.choices?.[0]?.message?.content ?? ""
}
export interface ExtractedCode {
html: string | null
css: string | null
js: string | null
}
// 从 AI 回复中提取代码:支持单个 html 代码块(新)或分开的 html/css/js 代码块(旧)
export function extractCode(text: string): ExtractedCode {
const result: ExtractedCode = { html: null, css: null, js: null }
const pattern = /```(html|css|js|javascript|typescript|ts|jsx|tsx)\s*\n([\s\S]*?)```/gi
for (const [, rawLang, code] of text.matchAll(pattern)) {
let lang = rawLang!.toLowerCase()
if (["javascript", "typescript", "ts", "jsx", "tsx"].includes(lang)) lang = "js"
const key = lang as keyof ExtractedCode
if (key in result && result[key] === null) result[key] = code!.trim()
}
// 单个 HTML 代码块:把 <style>/<script> 的内容拆出来,并从 html 中去掉
if (result.html && result.css === null && result.js === null) {
let html = result.html
const style = /<style[^>]*>([\s\S]*?)<\/style>/i.exec(html)
if (style) {
result.css = style[1]!.trim()
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
}
const script = /<script[^>]*>([\s\S]*?)<\/script>/i.exec(html)
if (script) {
result.js = script[1]!.trim()
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
}
result.html = html.trim()
}
return result
}
export function parseGuidanceResponse(full: string): [string, boolean] {
if (full.startsWith("[READY]")) {
return [full.slice("[READY]".length).replace(/^\n+/, ""), true]
}
return [full, false]
}
+126
View File
@@ -0,0 +1,126 @@
import { all, get, run, now, newUuidHex } from "../db"
import { Role, type User } from "../auth"
export interface MessageRow {
id: number
conversation_id: string
role: string
source: string
content: string
code_html: string | null
code_css: string | null
code_js: string | null
created: string
prompt_level: number | null
submission_id: string | null
}
// 自由创作只有创作者本人(和超管)能对话,其他任务对所有登录用户开放
export function canAccessTask(user: User, taskId: number) {
const row = get<{ owner_id: number }>(
`SELECT owner_id FROM task_creation WHERE task_ptr_id = $id`,
{ id: taskId },
)
if (!row) return true
return row.owner_id === user.id || user.role === Role.SUPER
}
// 当前用户在该任务下消息最多的那个对话
export function getActiveConversation(userId: number, taskId: number) {
return get<{ id: string }>(
`SELECT c.id FROM prompt_conversation c
WHERE c.user_id = $user_id AND c.task_id = $task_id
ORDER BY (SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) DESC,
c.created DESC
LIMIT 1`,
{ user_id: userId, task_id: taskId },
)?.id
}
export function createConversation(userId: number, taskId: number, isActive = true) {
const id = newUuidHex()
const ts = now()
run(
`INSERT INTO prompt_conversation (id, created, modified, is_active, task_id, user_id)
VALUES ($id, $ts, $ts, $active, $task_id, $user_id)`,
{ id, ts, active: isActive, task_id: taskId, user_id: userId },
)
return id
}
export function getOrCreateActiveConversation(userId: number, taskId: number) {
return getActiveConversation(userId, taskId) ?? createConversation(userId, taskId)
}
export function insertMessage(m: {
conversation_id: string
role: string
content: string
source?: string
code_html?: string | null
code_css?: string | null
code_js?: string | null
submission_id?: string | null
}): number {
const res = run(
`INSERT INTO prompt_message
(conversation_id, role, source, content, code_html, code_css, code_js, created, submission_id)
VALUES ($conversation_id, $role, $source, $content, $code_html, $code_css, $code_js, $created, $submission_id)`,
{
conversation_id: m.conversation_id,
role: m.role,
source: m.source ?? "conversation",
content: m.content,
code_html: m.code_html ?? null,
code_css: m.code_css ?? null,
code_js: m.code_js ?? null,
created: now(),
submission_id: m.submission_id ?? null,
},
)
return Number(res.lastInsertRowid)
}
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
return get<MessageRow>(
`SELECT * FROM prompt_message
WHERE conversation_id = $conv AND role = 'user'
AND (created < $created OR (created = $created AND id < $id))
ORDER BY created DESC, id DESC LIMIT 1`,
{ conv: asst.conversation_id, created: asst.created, id: asst.id },
)
}
export function buildPromptRounds(source: MessageRow) {
let messages = all<MessageRow>(
`SELECT * FROM prompt_message WHERE conversation_id = $conv ORDER BY created, id`,
{ conv: source.conversation_id },
)
let idx = messages.findIndex((m) => m.id === source.id)
if (idx < 0) idx = messages.length - 1
messages = messages.slice(0, idx + 1)
const rounds = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!
if (msg.role !== "user") continue
let reply: MessageRow | undefined
for (const r of messages.slice(i + 1)) {
if (r.role === "user") break
if (r.role === "assistant") {
reply = r
break
}
}
rounds.push({
question: msg.content,
source: msg.source,
prompt_level: msg.prompt_level,
assistant_msg_id: reply?.id ?? null,
html: reply?.code_html ?? null,
css: reply?.code_css ?? null,
js: reply?.code_js ?? null,
})
}
return rounds
}
+27
View File
@@ -0,0 +1,27 @@
/**
* 与 Python 的 round(x, n) 结果一致:按浮点数的精确值舍入,正好在中间时取偶数(银行家舍入)。
* Number#toFixed 同样按精确值舍入,只是“正好在中间”时向上取,所以只需单独处理这种情况。
* 十进制 n 位小数的精确中点必然形如 奇数 / 2^(n+1),据此判断。
*/
export function pyRound(x: number, ndigits = 0): number {
const t = x * 2 ** (ndigits + 1)
if (Number.isInteger(t) && Math.abs(t) % 2 === 1) {
const scale = 10 ** ndigits
const floor = Math.floor(x * scale)
return (floor % 2 === 0 ? floor : floor + 1) / scale
}
return Number(x.toFixed(ndigits))
}
/** 与 Python 3.12+ 的 sum() 一致:对浮点数做 Neumaier 补偿求和 */
export function pySum(values: Iterable<number>): number {
let sum = 0
let c = 0
for (const x of values) {
const t = sum + x
if (Math.abs(sum) >= Math.abs(x)) c += sum - t + x
else c += x - t + sum
sum = t
}
return c && Number.isFinite(c) ? sum + c : sum
}
+51
View File
@@ -0,0 +1,51 @@
import { all, get, run } from "../db"
import { Role } from "../auth"
import { pySum } from "./pyround"
const ROLE_WEIGHT: Record<string, number> = {
[Role.SUPER]: 0.5,
[Role.ADMIN]: 0.3,
}
function zoneFor(n: number, score: number): string | null {
if (n < 5) return "pending"
if (score >= 4.0) return "featured"
if (score < 3.0) return "low"
return null
}
/**
* 重新计算提交的分数:按角色加权平均得到 raw_score,
* 再用贝叶斯平均(C=3,全站平均分做先验)平滑得到 score,并更新分区。
*/
export function updateSubmissionScore(submissionId: string) {
const ratings = all<{ score: number; role: string }>(
`SELECT r.score, u.role FROM submission_rating r
JOIN account_user u ON u.id = r.user_id
WHERE r.submission_id = $id ORDER BY r.id`,
{ id: submissionId },
)
const n = ratings.length
let rawScore = 0
let score = 0
if (n > 0) {
const weights = ratings.map((r) => ROLE_WEIGHT[r.role] ?? 0.2)
rawScore = pySum(ratings.map((r, i) => r.score * weights[i]!)) / pySum(weights)
const C = 3
const mean = get<{ avg: number | null }>(
`SELECT AVG(raw_score) AS avg FROM submission_submission
WHERE raw_score > 0 AND id != $id`,
{ id: submissionId },
)?.avg
const globalMean = mean || rawScore
score = (C * globalMean + n * rawScore) / (C + n)
}
run(
`UPDATE submission_submission
SET raw_score = $raw, score = $score, zone = $zone WHERE id = $id`,
{ raw: rawScore, score, zone: zoneFor(n, score), id: submissionId },
)
}
+225
View File
@@ -0,0 +1,225 @@
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
import { config } from "./config"
import { all, run, iso, uuidOut } from "./db"
import { parseCookie, userFromSessionKey, type User } from "./auth"
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
import {
extractCode,
parseGuidanceResponse,
streamChat,
streamGuidance,
type ChatMessage,
} from "./services/llm"
import { isAllowedOrigin } from "./origin"
type Kind = "prompt" | "guidance"
export interface WsData {
kind: Kind
user: User
taskId: number
conversationId: string
// 正在等待 AI 回复的用户消息;对话中断时删掉,避免留下没有回复的提问
pendingUserMessageId: number | null
abort: AbortController
closed: boolean
// 串行处理消息(与 Django Channels 的行为一致)
queue: Promise<void>
// 引导对话只在本次连接内保留上下文
guidanceHistory: ChatMessage[]
}
type WS = ServerWebSocket<WsData>
const PATH_RE = /^\/ws\/(prompt|guidance)\/(\d+)\/?$/
// 处理 WebSocket 升级请求;返回 undefined 表示已升级,返回 Response 表示拒绝或不是 ws 路径
export function upgrade(req: Request, server: Server<WsData>): Response | undefined | null {
const url = new URL(req.url)
const match = PATH_RE.exec(url.pathname)
if (!match) return null
if (!isAllowedOrigin(req.headers.get("origin"), req.headers.get("host"))) {
return new Response("Forbidden", { status: 403 })
}
const user = userFromSessionKey(parseCookie(req.headers.get("cookie"), config.sessionCookie))
const taskId = Number(match[2])
if (!user || !canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
const ok = server.upgrade(req, {
data: {
kind: match[1] as Kind,
user,
taskId,
conversationId: "",
pendingUserMessageId: null,
abort: new AbortController(),
closed: false,
queue: Promise.resolve(),
guidanceHistory: [],
},
})
return ok ? undefined : new Response("Upgrade failed", { status: 400 })
}
function send(ws: WS, data: unknown) {
if (!ws.data.closed) ws.send(JSON.stringify(data))
}
function dropPendingUserMessage(ws: WS) {
if (ws.data.pendingUserMessageId !== null) {
run(`DELETE FROM prompt_message WHERE id = $id`, { id: ws.data.pendingUserMessageId })
ws.data.pendingUserMessageId = null
}
}
function conversationHistory(conversationId: string) {
return all<{
id: number
role: string
content: string
code_html: string | null
code_css: string | null
code_js: string | null
submission_id: string | null
created: string
}>(
`SELECT id, role, content, code_html, code_css, code_js, submission_id, created
FROM prompt_message WHERE conversation_id = $id AND source = 'conversation'
ORDER BY created, id`,
{ id: conversationId },
)
}
async function streamToClient(ws: WS, chunks: AsyncIterable<string>) {
let full = ""
for await (const chunk of chunks) {
full += chunk
send(ws, { type: "stream", content: chunk })
}
return full
}
async function handlePrompt(ws: WS, data: any) {
if ((data.type ?? "message") === "new_conversation") return
const prompt = String(data.content ?? "").trim()
const model = String(data.model ?? "")
if (!prompt) return
ws.data.pendingUserMessageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "user",
content: prompt,
})
try {
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
role: m.role as ChatMessage["role"],
content: m.content,
}))
let full: string
try {
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal))
} catch (e) {
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
return
}
if (ws.data.closed) return
const code = extractCode(full)
const messageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "assistant",
content: full,
code_html: code.html,
code_css: code.css,
code_js: code.js,
})
ws.data.pendingUserMessageId = null
send(ws, { type: "complete", code, message_id: messageId })
} finally {
dropPendingUserMessage(ws)
}
}
async function handleGuidance(ws: WS, data: any) {
const prompt = String(data.content ?? "").trim()
if (!prompt) return
ws.data.pendingUserMessageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "user",
content: prompt,
source: "guidance",
})
ws.data.guidanceHistory.push({ role: "user", content: prompt })
try {
let full: string
try {
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
} catch (e) {
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
return
}
if (ws.data.closed) return
const [clean, isReady] = parseGuidanceResponse(full)
ws.data.guidanceHistory.push({ role: "assistant", content: clean })
const messageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "assistant",
content: clean,
source: "guidance",
})
ws.data.pendingUserMessageId = null
send(ws, { type: "complete", message_id: messageId, is_ready: isReady })
} finally {
dropPendingUserMessage(ws)
}
}
export const websocket: WebSocketHandler<WsData> = {
// AI 生成可能要一两分钟,期间没有客户端消息
idleTimeout: 960,
sendPings: true,
open(ws) {
const d = ws.data
d.conversationId = getOrCreateActiveConversation(d.user.id, d.taskId)
if (d.kind === "guidance") {
send(ws, { type: "init", messages: [] })
return
}
send(ws, {
type: "init",
conversation_id: uuidOut(d.conversationId),
messages: conversationHistory(d.conversationId).map((m) => ({
id: m.id,
role: m.role,
content: m.content,
code:
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
submitted: m.role === "assistant" ? m.submission_id !== null : null,
created: iso(m.created),
})),
})
},
message(ws, raw) {
let data: any
try {
data = JSON.parse(String(raw))
} catch {
return
}
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
ws.data.queue = ws.data.queue
.then(() => (ws.data.closed ? undefined : handler(ws, data)))
.catch((e) => console.error("websocket handler failed:", e))
},
close(ws) {
ws.data.closed = true
// 客户端断开时停止向模型请求,未完成的提问会在 finally 中删除
ws.data.abort.abort()
dropPendingUserMessage(ws)
},
}
+309
View File
@@ -0,0 +1,309 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { get, all } from "../src/db"
import { client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
beforeEach(freshDb)
describe("权限", () => {
test("未登录 401,角色不够 403", async () => {
makeUser("stu")
makeUser("adm", "admin")
expect((await (await client())("GET", "/api/submission/")).status).toBe(401)
const stu = await client("stu")
expect((await stu("GET", "/api/challenge/list")).status).toBe(403)
expect((await stu("GET", "/api/submission/gradebook/?classname=a")).status).toBe(403)
const adm = await client("adm")
expect((await adm("GET", "/api/challenge/list")).status).toBe(200)
expect((await adm("GET", "/api/submission/gradebook/?classname=a")).status).toBe(403)
})
test("封号后会话失效", async () => {
const id = makeUser("stu")
makeUser("boss", "super")
const stu = await client("stu")
expect((await stu("GET", "/api/account/profile")).json.username).toBe("stu")
await (await client("boss"))("PUT", `/api/account/active/${id}`)
expect((await stu("GET", "/api/account/profile")).json.username).toBe("")
})
test("跨站写请求被拒绝", async () => {
const { app } = await import("../src/app")
const res = await app.request("/api/account/login", {
method: "POST",
headers: { origin: "https://evil.example", "content-type": "application/json" },
body: "{}",
})
expect(res.status).toBe(403)
})
})
describe("账号管理", () => {
test("新建和修改用户", async () => {
makeUser("boss", "super")
const boss = await client("boss")
const created = await boss("POST", "/api/account/create", { username: "new1", password: "abcdef", role: "admin" })
expect(created.status).toBe(200)
expect((await boss("POST", "/api/account/create", { username: "new1", password: "abcdef" })).status).toBe(400)
expect((await boss("POST", "/api/account/create", { username: "new2", password: "abc" })).status).toBe(422)
const id = created.json.id
expect((await boss("PUT", `/api/account/${id}`, { role: "normal", classname: "10A", password: "zzzzzz" })).status).toBe(200)
const row = get(`SELECT role, classname, raw_password FROM account_user WHERE id = $id`, { id })
expect(row).toEqual({ role: "normal", classname: "10A", raw_password: "zzzzzz" })
// 新密码能登录
const { app } = await import("../src/app")
const login = await app.request("/api/account/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: "new1", password: "zzzzzz" }),
})
expect(login.status).toBe(200)
})
test("批量新建", async () => {
makeUser("boss", "super")
const boss = await client("boss")
expect((await boss("POST", "/api/account/batch", { classname: "11C", names: ["张三", "李四"] })).status).toBe(200)
expect((await boss("POST", "/api/account/batch", { classname: "11C", names: ["张三"] })).status).toBe(400)
const names = await boss("GET", "/api/account/names?classname=11C")
expect(names.json).toEqual([
{ name: "张三", username: "web11C张三" },
{ name: "李四", username: "web11C李四" },
])
const list = await boss("GET", "/api/account/list?username=web11&page=1")
expect(list.json.count).toBe(2)
expect(list.json.items[0].raw_password).toMatch(/^[2-9]{6}$/)
})
})
describe("评分", () => {
test("角色加权 + 贝叶斯平滑 + 分区", async () => {
const author = makeUser("author")
makeUser("boss", "super")
makeUser("adm", "admin")
for (let i = 0; i < 3; i++) makeUser(`s${i}`)
const task = makeTask("T")
const sub = makeSubmission(author, task)
await (await client("boss"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 5 })
await (await client("adm"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 3 })
let row = get(`SELECT raw_score, score, zone FROM submission_submission WHERE id = $id`, { id: sub })
// raw = (5*0.5 + 3*0.3) / 0.8;没有别的提交,先验就是自己
expect(row.raw_score).toBeCloseTo(4.25)
expect(row.score).toBeCloseTo(4.25)
expect(row.zone).toBe("pending")
for (let i = 0; i < 3; i++) {
await (await client(`s${i}`))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 5 })
}
row = get(`SELECT score, zone FROM submission_submission WHERE id = $id`, { id: sub })
expect(row.zone).toBe("featured")
const again = await (await client("s0"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 1 })
expect(again.json.message).toBe("你已经给这个提交打过分了")
expect((await (await client("s0"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 0 })).status).toBe(400)
})
})
describe("提交与删除", () => {
test("手写提示词提交会生成对话,删除提交连带删除提示词", async () => {
makeUser("stu")
const task = makeTask("T")
const stu = await client("stu")
const res = await stu("POST", "/api/submission/", { task_id: task, html: "<p>", prompt: "做个页面" })
expect(res.status).toBe(200)
const msgs = all(`SELECT role, source, submission_id FROM prompt_message ORDER BY id`)
expect(msgs.map((m) => [m.role, m.source])).toEqual([
["user", "manual"],
["assistant", "manual"],
])
const chain = await stu("GET", `/api/submission/${res.json.id}/prompt-chain`)
expect(chain.json[0].question).toBe("做个页面")
expect((await stu("DELETE", `/api/submission/${res.json.id}`)).status).toBe(200)
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
})
test("被评分的提交只有超管能删", async () => {
const author = makeUser("author")
makeUser("other")
makeUser("boss", "super")
const sub = makeSubmission(author, makeTask("T"))
await (await client("other"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 4 })
expect((await (await client("author"))("DELETE", `/api/submission/${uuid(sub)}`)).status).toBe(400)
expect((await (await client("boss"))("DELETE", `/api/submission/${uuid(sub)}`)).status).toBe(200)
expect(all(`SELECT * FROM submission_rating`)).toEqual([])
})
test("删除挑战级联删除提交和对话", async () => {
const stu = makeUser("stu")
makeUser("adm", "admin")
const task = makeTask("T", "challenge", 3)
makeSubmission(stu, task)
await (await client("stu"))("POST", "/api/submission/", { task_id: task, html: "", prompt: "x" })
expect((await (await client("adm"))("DELETE", "/api/challenge/3")).status).toBe(200)
for (const t of ["task_task", "task_challenge", "submission_submission", "prompt_conversation", "prompt_message"]) {
expect(all(`SELECT * FROM ${t}`)).toEqual([])
}
})
test("分组列表只保留每人每题最新一条,并统计提交次数", async () => {
const a = makeUser("alice")
const task = makeTask("T")
makeSubmission(a, task, 1, "2026-01-01 00:00:00.000000")
const latest = makeSubmission(a, task, 2, "2026-01-02 00:00:00.000000")
const list = await (await client("alice"))("GET", "/api/submission?page=1")
expect(list.json.count).toBe(1)
expect(list.json.items[0].id).toBe(uuid(latest))
expect(list.json.items[0].submit_count).toBe(2)
const all2 = await (await client("alice"))("GET", "/api/submission/?page=1&grouped=false")
expect(all2.json.count).toBe(2)
})
})
describe("成绩册", () => {
async function superClient() {
makeUser("boss", "super")
return client("boss")
}
test("必须选择班级", async () => {
const boss = await superClient()
const res = await boss("GET", "/api/submission/gradebook/")
expect(res.status).toBe(400)
expect(res.json.detail).toBe("请选择班级")
})
test("覆盖率、总分与平均分", async () => {
const boss = await superClient()
const [alice, bob] = ["alice", "bob", "carol", "dave"].map((n) => makeUser(n, "normal", "10A"))
const tutorial = makeTask("Intro", "tutorial", 1, false)
const challenge = makeTask("Challenge One", "challenge", 1)
const optional = makeTask("Optional", "challenge", 2)
makeSubmission(alice!, tutorial, 4)
makeSubmission(bob!, tutorial, 5)
makeSubmission(alice!, challenge, 3)
makeSubmission(bob!, challenge, 4)
makeSubmission(alice!, optional, 5)
const data = (await boss("GET", "/api/submission/gradebook/?classname=10A")).json
expect(data.student_count).toBe(4)
expect(data.coverage_threshold_count).toBe(2)
expect(data.included_task_count).toBe(2)
const row = data.rows.find((r: any) => r.username === "alice")
expect([row.tutorial_total, row.challenge_total, row.total_score, row.average_score]).toEqual([4, 3, 7, 3.5])
const carol = data.rows.find((r: any) => r.username === "carol")
expect([carol.total_score, carol.submitted_task_count, carol.missing_task_count]).toEqual([0, 0, 2])
const withAll = (await boss("GET", "/api/submission/gradebook/?classname=10A&include_all_tasks=true")).json
const alice2 = withAll.rows.find((r: any) => r.username === "alice")
expect(alice2.scores[String(optional)]).toMatchObject({ submitted: true, score: 5 })
expect(alice2.total_score).toBe(7)
})
test("同分取最新的提交", async () => {
const boss = await superClient()
const alice = makeUser("alice", "normal", "10A")
const task = makeTask("T")
makeSubmission(alice, task, 4.5, "2026-01-01 00:00:00.000000")
const newer = makeSubmission(alice, task, 4.5, "2026-01-02 00:00:00.000000")
makeSubmission(alice, task, 3, "2026-01-03 00:00:00.000000")
const data = (await boss("GET", "/api/submission/gradebook/?classname=10A")).json
expect(data.rows[0].scores[String(task)].submission_id).toBe(uuid(newer))
})
test("等级按向上取整的名次划分", async () => {
const boss = await superClient()
const task = makeTask("Boundary", "challenge", 7)
for (let i = 1; i <= 20; i++) {
makeSubmission(makeUser(`s${String(i).padStart(2, "0")}`, "normal", "10B"), task, 21 - i)
}
const data = (await boss("GET", "/api/submission/gradebook/?classname=10B")).json
const grade = Object.fromEntries(data.rows.map((r: any) => [r.username, r.grade]))
expect([grade.s01, grade.s06, grade.s07, grade.s14, grade.s15, grade.s18, grade.s19, grade.s20]).toEqual([
"A", "A", "B", "B", "C", "C", "D", "E",
])
const score = Object.fromEntries(data.rows.map((r: any) => [r.username, r.assessment_score]))
expect(score.s01).toBe(99)
expect(score.s20).toBe(60)
})
test("导出 CSV", async () => {
const boss = await superClient()
const alice = makeUser("alice", "normal", "10A")
const bob = makeUser("bob", "normal", "10A")
const tutorial = makeTask("Intro", "tutorial", 1)
makeSubmission(alice, tutorial, 4)
makeSubmission(bob, tutorial, 2)
makeSubmission(alice, makeTask("C", "challenge", 1), 5)
const res = await boss("GET", "/api/submission/gradebook/export/?classname=10A&task_type=tutorial")
expect(res.headers.get("content-type")).toBe("text/csv; charset=utf-8")
expect(res.headers.get("content-disposition")).toContain("attachment;")
const rows = res.text.replace(/^/, "").trim().split("\r\n").map((l) => l.split(","))
expect(rows[0]).toEqual([
"排名", "平时等级", "考核等级", "考核分数", "用户名", "班级", "教程1-Intro",
"教程合计", "挑战合计", "总分", "平均分", "已提交任务数", "未提交任务数",
])
expect([rows[1]![4], rows[1]![6], rows[1]![9]]).toEqual(["alice", "4", "4"])
})
})
describe("作品展", () => {
test("下架或停用后前台看不到", async () => {
const author = makeUser("author")
makeUser("adm", "admin")
const sub = makeSubmission(author, makeTask("T"), 4.5)
const adm = await client("adm")
const award = (await adm("POST", "/api/submission/showcase/manage/awards", { name: "最佳" })).json
expect(award.item_count).toBe(0)
const item = (await adm("POST", `/api/submission/showcase/manage/awards/${award.id}/items`, { submission_id: uuid(sub) })).json
expect((await adm("POST", `/api/submission/showcase/manage/awards/${award.id}/items`, { submission_id: uuid(sub) })).json.detail).toBe("该作品已在奖项中")
const author1 = await client("author")
expect((await author1("GET", "/api/submission/showcase/")).json).toHaveLength(1)
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).json.awards).toEqual(["最佳"])
await adm("PUT", `/api/submission/showcase/manage/awards/${award.id}`, { name: "最佳", is_active: false })
expect((await author1("GET", "/api/submission/showcase/")).json).toEqual([])
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).status).toBe(404)
await adm("DELETE", `/api/submission/showcase/manage/items/${item.id}`)
expect((await adm("GET", "/api/submission/showcase/manage/awards")).json[0].item_count).toBe(0)
})
test("新提交让旧条目变为有更新,刷新后切到最新", async () => {
makeUser("author")
makeUser("adm", "admin")
const task = makeTask("T")
const author = await client("author")
const first = (await author("POST", "/api/submission/", { task_id: task, html: "1" })).json.id
const adm = await client("adm")
const award = (await adm("POST", "/api/submission/showcase/manage/awards", { name: "A" })).json
const item = (await adm("POST", `/api/submission/showcase/manage/awards/${award.id}/items`, { submission_id: first })).json
const second = (await author("POST", "/api/submission/", { task_id: task, html: "2" })).json.id
let items = (await adm("GET", `/api/submission/showcase/manage/awards/${award.id}/items`)).json
expect(items[0].is_stale).toBe(true)
const refreshed = (await adm("PUT", `/api/submission/showcase/manage/items/${item.id}/refresh`)).json
expect(refreshed.submission_id).toBe(second)
expect(refreshed.is_stale).toBe(false)
})
})
describe("自由创作", () => {
test("只有作者本人和超管能访问", async () => {
makeUser("me")
makeUser("you")
makeUser("boss", "super")
const me = await client("me")
const c = (await me("POST", "/api/creation/", { title: " 作品 ", content: "d" })).json
expect(c.title).toBe("作品")
expect(c.display).toBe(1)
expect((await me("POST", "/api/creation/", { title: " " })).status).toBe(400)
expect((await (await client("you"))("GET", `/api/creation/${c.display}`)).status).toBe(403)
expect((await (await client("boss"))("GET", `/api/creation/${c.display}`)).status).toBe(200)
const saved = (await me("PUT", `/api/creation/${c.display}/code`, { html: "<p>" })).json
expect(saved.html).toBe("<p>")
expect((await me("GET", "/api/creation/mine")).json[0].has_code).toBe(true)
expect((await me("POST", "/api/submission/", { task_id: c.task_id })).json.detail).toBe("自由创作不需要提交")
})
})
+78
View File
@@ -0,0 +1,78 @@
import { app } from "../src/app"
import { makePassword } from "../src/auth"
import { newUuidHex, now, resetDb, run, uuidOut } from "../src/db"
export const PASSWORD = "pw123456"
// 哈希很慢,所有测试用户共用一个
const HASH = await makePassword(PASSWORD)
export function freshDb() {
resetDb()
}
export function makeUser(username: string, role = "normal", classname = "") {
const res = 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, 0, $username, '', 0, 1, $now, $role, $raw, $classname)`,
{ hash: HASH, username, now: now(), role, raw: PASSWORD, classname },
)
return Number(res.lastInsertRowid)
}
export function makeTask(title: string, taskType = "challenge", display = 1, isPublic = true) {
const ts = now()
const res = run(
`INSERT INTO task_task (created, modified, display, title, content, task_type, is_public, example_html, example_css, example_js)
VALUES ($ts, $ts, $display, $title, '', $type, $public, NULL, NULL, NULL)`,
{ ts, display, title, type: taskType, public: isPublic },
)
const id = Number(res.lastInsertRowid)
if (taskType === "challenge") {
run(`INSERT INTO task_challenge (task_ptr_id, score, pass_score, author_id) VALUES ($id, 0, NULL, NULL)`, { id })
} else if (taskType === "tutorial") {
run(`INSERT INTO task_tutorial (task_ptr_id) VALUES ($id)`, { id })
}
return id
}
export function makeSubmission(userId: number, taskId: number, score = 0, created?: string) {
const id = newUuidHex()
const ts = created ?? now()
run(
`INSERT INTO submission_submission (id, created, modified, score, html, css, js, task_id, user_id, flag, raw_score, zone, view_count)
VALUES ($id, $ts, $ts, $score, '', '', '', $task_id, $user_id, NULL, 0, NULL, 0)`,
{ id, ts, score, task_id: taskId, user_id: userId },
)
return id
}
export const uuid = (hex: string) => uuidOut(hex)
/** 登录后返回一个带 cookie 的请求函数 */
export async function client(username?: string) {
let cookie = ""
if (username) {
const res = await app.request("/api/account/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username, password: PASSWORD }),
})
if (res.status !== 200) throw new Error(`login failed: ${res.status}`)
cookie = res.headers.get("set-cookie")!.split(";")[0]!
}
return async (method: string, path: string, body?: unknown) => {
const headers: Record<string, string> = { cookie }
if (body !== undefined) headers["content-type"] = "application/json"
const res = await app.request(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await res.text()
let json: any = text
try {
json = JSON.parse(text)
} catch {}
return { status: res.status, json, text, headers: res.headers }
}
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import { pyRound, pySum } from "../src/services/pyround"
import { extractCode, parseGuidanceResponse } from "../src/services/llm"
import { checkPassword, makePassword } from "../src/auth"
import { formatMicros, iso, uuidHex, uuidOut } from "../src/db"
import { toCsv } from "../src/services/gradebook"
describe("pyRound", () => {
test("银行家舍入,与 Python round() 一致", () => {
expect(pyRound(2.5)).toBe(2)
expect(pyRound(3.5)).toBe(4)
expect(pyRound(0.125, 2)).toBe(0.12)
expect(pyRound(0.375, 2)).toBe(0.38)
// 2.675 的二进制值略小于 2.675
expect(pyRound(2.675, 2)).toBe(2.67)
expect(pyRound(1 / 3, 2)).toBe(0.33)
expect(pyRound(95.5)).toBe(96)
})
})
describe("pySum", () => {
test("补偿求和,与 Python 3.12+ 的 sum() 一致", () => {
expect(pySum([0.1, 0.2, 0.3])).toBe(0.6)
expect(pySum([1e100, 1, -1e100])).toBe(1)
expect(pySum([])).toBe(0)
})
})
describe("extractCode", () => {
test("单个 html 代码块拆出 style 和 script", () => {
const text = "说明\n```html\n<style>\na{}\n</style>\n<a>x</a>\n<script>\nf()\n</script>\n```"
expect(extractCode(text)).toEqual({ html: "<a>x</a>", css: "a{}", js: "f()" })
})
test("分开的代码块(旧格式)", () => {
const text = "```html\n<p/>\n```\n```css\np{}\n```\n```javascript\nx()\n```"
expect(extractCode(text)).toEqual({ html: "<p/>", css: "p{}", js: "x()" })
})
test("没有代码块", () => {
expect(extractCode("hello")).toEqual({ html: null, css: null, js: null })
})
})
test("parseGuidanceResponse", () => {
expect(parseGuidanceResponse("[READY]\n\n好")).toEqual(["好", true])
expect(parseGuidanceResponse("再想想")).toEqual(["再想想", false])
})
test("兼容 Django 的 pbkdf2 密码哈希", async () => {
// 由 Django 的 PBKDF2PasswordHasher 生成的 "secret" 的哈希
const django = "pbkdf2_sha256$1000$abcdefghijklmnopqrstuv$1nE4fvOmiD+ZePp39y6TYTXfkn5HEwJDyz8e6UkQE1w="
expect(await checkPassword("secret", django)).toBe(true)
expect(await checkPassword("Secret", django)).toBe(false)
expect(await checkPassword("secret", await makePassword("secret"))).toBe(true)
expect(await checkPassword("wrong", await makePassword("secret"))).toBe(false)
expect(await checkPassword("secret", "bcrypt$xxx")).toBe(false)
})
test("时间与 UUID 格式", () => {
expect(formatMicros(Date.UTC(2026, 0, 2, 3, 4, 5) * 1000 + 678901)).toBe("2026-01-02 03:04:05.678901")
expect(iso("2026-01-02 03:04:05.678901")).toBe("2026-01-02T03:04:05.678Z")
const hex = "0123456789abcdef0123456789abcdef"
expect(uuidOut(hex)).toBe("01234567-89ab-cdef-0123-456789abcdef")
expect(uuidHex("01234567-89AB-cdef-0123-456789abcdef")).toBe(hex)
expect(uuidHex("nope")).toBeNull()
})
test("CSV 转义", () => {
expect(toCsv([["a", 'b"c', "d,e"], [1, 2.5, ""]])).toBe('a,"b""c","d,e"\r\n1,2.5,\r\n')
})
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}