refactor: 后端从 Django 换成 Bun + SQLite
Django 多个 worker 内存占用 1-2 GB,换成 Bun 单进程后约 25 MB, 同时去掉 Postgres 和 Redis,只剩 Caddy + Bun 两个容器。 - server/:Hono + bun:sqlite + Bun 原生 WebSocket,接口与原 django-ninja 保持兼容;表结构、时间/UUID 存储格式、密码哈希与 Django 一致,旧数据可直接使用 - 评分加权、贝叶斯平滑、成绩册的计算结果与原实现逐位一致(复刻 Python round/sum) - scripts/:Postgres → SQLite 迁移、新建超管、提示词层级标注、重算分数 - 前端:Django Admin 去掉后,用户新建/修改改为页面内弹窗; 非超管的挑战公开按钮置灰 - 权限不足返回 JSON 403(原来是重定向到不存在的登录页) - docker-compose / Caddyfile / dev.sh / CLAUDE.md 更新,删除 AGENTS.md - 服务器切换步骤见 docs/migrate-to-bun.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47d4679984
commit
26f05d1d76
297 files changed
+4659
-42956
No files matched your search
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
data
|
||||
media
|
||||
tests
|
||||
.env
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
media/
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 用大模型给学生提示词标注布鲁姆认知层级(L1-L6)。
|
||||
*
|
||||
* bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
||||
*
|
||||
* 默认只处理还没有层级的用户消息;--force 重新标注全部。
|
||||
*/
|
||||
import { parseArgs } from "node:util"
|
||||
import { all } from "../src/db"
|
||||
import { classifyMessage } from "../src/services/classifier"
|
||||
import { llmConfigured } from "../src/services/llm"
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"task-id": { type: "string" },
|
||||
force: { type: "boolean", default: false },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
},
|
||||
})
|
||||
|
||||
const where = ["m.role = 'user'"]
|
||||
const params: Record<string, unknown> = {}
|
||||
if (values["task-id"]) {
|
||||
where.push("c.task_id = $task_id")
|
||||
params.task_id = Number(values["task-id"])
|
||||
}
|
||||
if (!values.force) where.push("m.prompt_level IS NULL")
|
||||
|
||||
const ids = all<{ id: number }>(
|
||||
`SELECT m.id FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
|
||||
WHERE ${where.join(" AND ")} ORDER BY m.created, m.id`,
|
||||
params,
|
||||
).map((r) => r.id)
|
||||
|
||||
console.log(`找到 ${ids.length} 条消息需要标注`)
|
||||
if (values["dry-run"]) process.exit(0)
|
||||
if (!llmConfigured()) {
|
||||
console.error("没有配置 LLM_API_KEY")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const level = await classifyMessage(id)
|
||||
console.log(`[${i + 1}/${ids.length}] msg#${id} → ${level ? `L${level}` : "(跳过)"}`)
|
||||
await Bun.sleep(300)
|
||||
}
|
||||
console.log("完成")
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 新建超级管理员,或把已有用户设为超级管理员并重设密码。
|
||||
*
|
||||
* bun scripts/create-superuser.ts <用户名> <密码>
|
||||
*/
|
||||
import { get, run, now } from "../src/db"
|
||||
import { makePassword } from "../src/auth"
|
||||
|
||||
const [username, password] = process.argv.slice(2)
|
||||
if (!username || !password) {
|
||||
console.error("用法:bun scripts/create-superuser.ts <用户名> <密码>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hash = await makePassword(password)
|
||||
const user = get<{ id: number }>(`SELECT id FROM account_user WHERE username = $username`, { username })
|
||||
if (user) {
|
||||
run(
|
||||
`UPDATE account_user SET password = $hash, raw_password = $raw, role = 'super',
|
||||
is_superuser = 1, is_staff = 1, is_active = 1 WHERE id = $id`,
|
||||
{ hash, raw: password.slice(0, 20), id: user.id },
|
||||
)
|
||||
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: user.id })
|
||||
console.log(`已将 ${username} 设为超级管理员并重设密码`)
|
||||
} else {
|
||||
run(
|
||||
`INSERT INTO account_user
|
||||
(password, last_login, is_superuser, username, email, is_staff, is_active, date_joined, role, raw_password, classname)
|
||||
VALUES ($hash, NULL, 1, $username, '', 1, 1, $now, 'super', $raw, '')`,
|
||||
{ hash, username, now: now(), raw: password.slice(0, 20) },
|
||||
)
|
||||
console.log(`已创建超级管理员 ${username}`)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 把旧 Django 项目的 Postgres 数据导入 SQLite(一次性迁移)。
|
||||
*
|
||||
* POSTGRES_HOST=... POSTGRES_DB=... POSTGRES_USER=... POSTGRES_PASSWORD=... \
|
||||
* bun scripts/migrate-from-postgres.ts [--force]
|
||||
*
|
||||
* 目标库为 DB_PATH(默认 data/db.sqlite3)。目标库已有数据时需要 --force(会先清空)。
|
||||
* 会话不迁移,所有人需要重新登录一次。
|
||||
*/
|
||||
import { SQL } from "bun"
|
||||
import { config } from "../src/config"
|
||||
import { db } from "../src/db"
|
||||
|
||||
// 按外键依赖顺序
|
||||
const TABLES = [
|
||||
"account_user",
|
||||
"task_task",
|
||||
"task_tutorial",
|
||||
"task_challenge",
|
||||
"task_creation",
|
||||
"task_taskasset",
|
||||
"submission_submission",
|
||||
"submission_rating",
|
||||
"submission_award",
|
||||
"submission_submissionaward",
|
||||
"prompt_conversation",
|
||||
"prompt_message",
|
||||
]
|
||||
|
||||
const force = process.argv.includes("--force")
|
||||
const env = process.env
|
||||
for (const key of ["POSTGRES_HOST", "POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"]) {
|
||||
if (!env[key]) {
|
||||
console.error(`缺少环境变量 ${key}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const pg = new SQL({
|
||||
hostname: env.POSTGRES_HOST,
|
||||
port: Number(env.POSTGRES_PORT ?? 5432),
|
||||
database: env.POSTGRES_DB,
|
||||
username: env.POSTGRES_USER,
|
||||
password: env.POSTGRES_PASSWORD,
|
||||
})
|
||||
|
||||
const existing = db.query(`SELECT COUNT(*) AS n FROM account_user`).get() as { n: number }
|
||||
if (existing.n > 0 && !force) {
|
||||
console.error(`目标库 ${config.dbPath} 已有数据,加 --force 会清空后重新导入`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`
|
||||
|
||||
// 按 Postgres 的列类型转换成 Django 在 SQLite 中的存储格式
|
||||
function selectExpr(column: string, pgType: string) {
|
||||
const c = quote(column)
|
||||
switch (pgType) {
|
||||
case "timestamp with time zone":
|
||||
return `to_char(${c} AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
|
||||
case "timestamp without time zone":
|
||||
return `to_char(${c}, 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
|
||||
case "uuid":
|
||||
return `replace(${c}::text, '-', '') AS ${c}`
|
||||
case "boolean":
|
||||
return `${c}::int AS ${c}`
|
||||
case "bigint":
|
||||
return `${c}::text AS ${c}`
|
||||
default:
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
const rowsByTable: Record<string, Record<string, unknown>[]> = {}
|
||||
|
||||
for (const table of TABLES) {
|
||||
const sqliteColumns = (db.query(`PRAGMA table_info(${quote(table)})`).all() as { name: string }[]).map(
|
||||
(c) => c.name,
|
||||
)
|
||||
const pgColumns = await pg`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ${table}`
|
||||
if (!pgColumns.length) throw new Error(`Postgres 中没有表 ${table}`)
|
||||
const pgTypes = new Map<string, string>(pgColumns.map((c: any) => [c.column_name, c.data_type]))
|
||||
const missing = sqliteColumns.filter((c) => !pgTypes.has(c))
|
||||
if (missing.length) throw new Error(`Postgres 表 ${table} 缺少列:${missing.join(", ")}`)
|
||||
|
||||
const select = sqliteColumns.map((c) => selectExpr(c, pgTypes.get(c)!)).join(", ")
|
||||
const rows: Record<string, unknown>[] = await pg.unsafe(`SELECT ${select} FROM ${quote(table)}`)
|
||||
for (const row of rows) {
|
||||
for (const c of sqliteColumns) {
|
||||
if (pgTypes.get(c) === "bigint" && row[c] !== null) row[c] = Number(row[c])
|
||||
}
|
||||
}
|
||||
rowsByTable[table] = rows
|
||||
console.log(`读取 ${table}: ${rows.length} 行`)
|
||||
}
|
||||
await pg.close()
|
||||
|
||||
db.transaction(() => {
|
||||
db.run("PRAGMA defer_foreign_keys = ON")
|
||||
for (const table of [...TABLES].reverse()) db.run(`DELETE FROM ${quote(table)}`)
|
||||
db.run(`DELETE FROM auth_session`)
|
||||
for (const table of TABLES) {
|
||||
const rows = rowsByTable[table]!
|
||||
if (!rows.length) {
|
||||
counts[table] = 0
|
||||
continue
|
||||
}
|
||||
const columns = Object.keys(rows[0]!)
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO ${quote(table)} (${columns.map(quote).join(", ")})
|
||||
VALUES (${columns.map((c) => "$" + c).join(", ")})`,
|
||||
)
|
||||
for (const row of rows) insert.run(row as any)
|
||||
counts[table] = rows.length
|
||||
}
|
||||
})()
|
||||
|
||||
// 校验
|
||||
let ok = true
|
||||
for (const table of TABLES) {
|
||||
const n = (db.query(`SELECT COUNT(*) AS n FROM ${quote(table)}`).get() as { n: number }).n
|
||||
if (n !== counts[table]) ok = false
|
||||
console.log(`${n === counts[table] ? "✓" : "✗"} ${table}: ${n}`)
|
||||
}
|
||||
const fk = db.query(`PRAGMA foreign_key_check`).all()
|
||||
if (fk.length) {
|
||||
ok = false
|
||||
console.error("外键检查失败:", fk.slice(0, 10))
|
||||
}
|
||||
console.log(ok ? `\n导入完成:${config.dbPath}` : "\n导入有问题,请检查上面的输出")
|
||||
process.exit(ok ? 0 : 1)
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 重新计算所有被评过分的提交的 score / raw_score / zone(修改评分规则后使用)。
|
||||
*
|
||||
* bun scripts/recalculate-scores.ts [--dry-run]
|
||||
*/
|
||||
import { all, get } from "../src/db"
|
||||
import { updateSubmissionScore } from "../src/services/score"
|
||||
|
||||
const rows = all<{ id: string; username: string; title: string; score: number }>(
|
||||
`SELECT s.id, u.username, t.title, s.score FROM submission_submission s
|
||||
JOIN account_user u ON u.id = s.user_id JOIN task_task t ON t.id = s.task_id
|
||||
WHERE EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id)
|
||||
ORDER BY s.created DESC`,
|
||||
)
|
||||
console.log(`找到 ${rows.length} 个被评过分的提交`)
|
||||
if (process.argv.includes("--dry-run")) process.exit(0)
|
||||
|
||||
for (const [i, s] of rows.entries()) {
|
||||
updateSubmissionScore(s.id)
|
||||
const after = get<{ score: number; zone: string | null }>(
|
||||
`SELECT score, zone FROM submission_submission WHERE id = $id`,
|
||||
{ id: s.id },
|
||||
)!
|
||||
console.log(
|
||||
`[${i + 1}/${rows.length}] ${s.username}/${s.title}: ${s.score.toFixed(3)} → ${after.score.toFixed(3)} zone=${after.zone}`,
|
||||
)
|
||||
}
|
||||
console.log("完成")
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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) + "%"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}`)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: "删除成功" })
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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("自由创作不需要提交")
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user