Rust + Axum + PostgreSQL 题库服务。当前版本的核心流程是:
- 上传单题 zip 压缩包导入题目
- 用一组有序题目引用创建试卷
- 删除走软删除,管理员可恢复记录并回收无引用 binary
- 各模块的 API 文档位于
src/api/<module>/API.md - 合并版文档:
docs/API.md(由python scripts/build_api_doc.py自动生成)
src/
├── api/
│ ├── admin/
│ │ ├── API.md
│ │ ├── handlers.rs
│ │ ├── models.rs
│ │ └── queries.rs
│ ├── mod.rs
│ ├── questions/
│ │ ├── API.md
│ │ ├── bundles.rs
│ │ ├── handlers.rs
│ │ ├── imports.rs
│ │ ├── models.rs
│ │ └── queries.rs
│ ├── papers/
│ │ ├── API.md
│ │ ├── bundles.rs
│ │ ├── handlers.rs
│ │ ├── imports.rs
│ │ ├── models.rs
│ │ └── queries.rs
│ ├── ops/
│ │ ├── API.md
│ │ ├── exports.rs
│ │ ├── handlers.rs
│ │ ├── models.rs
│ │ ├── paper_render.rs
│ │ └── quality.rs
│ ├── system/
│ │ ├── API.md
│ │ └── handlers.rs
│ └── shared/
│ ├── details.rs
│ ├── error.rs
│ ├── multipart.rs
│ ├── mod.rs
│ └── utils.rs
├── config.rs
├── db.rs
├── lib.rs
└── main.rs
objects单表保存任意上传文件的元数据与二进制内容。users用户表,含role(viewer / user / leader / bot / admin)、leader_expires_at、普通账号密码哈希,以及 bot access token 哈希等字段。questions保存题目固定 metadata,以及软删除字段deleted_at/deleted_by和created_by(创建者)。question_files保存题目的 TeX 文件和资源文件引用。question_tags保存题目标签列表。question_difficulties保存每个 difficulty tag 的score/notes,以及created_by/updated_by(追踪编辑者)。question_reviews审阅人分配表,记录哪些 user 或 leader 被分配为某题目的审阅人。papers保存试卷固定元数据,以及软删除字段deleted_at/deleted_by和created_by(创建者)。paper_questions保存试卷和题目的有序关联。
上传文件大小限制 20 MiB,使用 multipart/form-data,字段名是 file。zip 总解压体积限制 64 MiB。
zip 会按“逻辑根目录”解析:如果最外层只有一个包裹目录,会先自动剥离一层。逻辑根目录建议如下:
question.zip
├── problem.tex
├── README.txt # 可选,忽略
└── assets/
├── figure1.png
└── ...
其中:
- 逻辑根目录必须恰好有一个
.tex文件 - 可选一个
assets/目录 - 逻辑根目录下其他杂散文件会被忽略,不参与导入
- 除根目录 tex / 杂散文件外,其他被导入的非根目录文件都必须位于
assets/下 - 拒绝路径穿越(
..)和绝对路径 - 总解压体积不能超过 64 MiB
- 真正写入
objects表的只有 tex 和assets/下的资源文件 - 上传题目时必须额外提供一个非空的
description - 上传题目时必须额外提供一个
difficultyJSON 字符串,且至少包含human - 上传题目时也可以一次性提供完整 metadata:
category可选,取值none、T、Etags可选,传 JSON 字符串数组status可选,取值none、reviewed、used
description支持中文,并可直接参与GET /questions?q=...的模糊匹配description会参与 bundle 目录命名,因此不能包含/ \\ : * ? " < > |,不能是.、..,也不能以.结尾- 如果未提供可选 metadata,上传时其余字段使用默认值:
category = "none"tags = []status = "none"created_at = NOW()
POST /questions
请求示例:
curl -X POST http://127.0.0.1:8080/questions \
-F "description=热学标定题" \
-F "category=T" \
-F 'tags=["thermal","calibration"]' \
-F "status=reviewed" \
-F 'difficulty={"human":{"score":5,"notes":"baseline"}}' \
-F "file=@question.zip"PATCH /questions/{question_id}
请求体示例:
{
"category": "T",
"description": "demo question",
"tags": ["optics", "mechanics"],
"status": "reviewed",
"difficulty": {
"human": {
"score": 7,
"notes": "sample"
},
"algo1": {
"score": 6
}
}
}说明:
- 请求体支持部分更新
- 推荐在
POST /questions时就提交完整 metadata;PATCH主要用于后续修正 metadata - 服务端会先锁定目标题目的主记录;同一题目的 metadata 更新、文件替换和删除会串行执行,避免并发重建
tags/difficulty时出现竞态 description如果出现在更新请求里,必须是非空字符串,并满足上面的文件名安全限制tags传空数组会清空difficulty如果出现在更新请求里,会整体替换整组 difficulty tagdifficulty必须至少包含humandifficulty.<tag>.score要求在1..=10difficulty.<tag>.notes是可选字符串,空串会被规范化为nullcategory只能是none、T、Estatus只能是none、reviewed、used
DELETE /questions/{question_id}
说明:
- 这是软删除,不会立刻删除 binary
- 如果题目仍被未软删除试卷引用,会返回
409
POST /questions/bundles
请求体示例:
{
"question_ids": [
"8db0d12e-2968-4ede-86d5-1dc5ff0a5d10",
"e21ed70d-cd18-45cc-89ab-2785d07f4de7"
]
}返回一个 zip,根目录附带 manifest.json,并按 description_uuid前缀/ 目录分组题目文件。
POST /papers
使用 multipart/form-data 上传初始化 metadata 和附加 zip。
示例:
curl -X POST http://127.0.0.1:8080/papers \
-F 'description=demo paper' \
-F 'title=Demo Paper' \
-F 'subtitle=Initial Draft' \
-F 'authors=["Alice","Bob"]' \
-F 'reviewers=["Carol"]' \
-F 'question_ids=["8db0d12e-2968-4ede-86d5-1dc5ff0a5d10","e21ed70d-cd18-45cc-89ab-2785d07f4de7"]' \
-F 'file=@paper_appendix.zip;type=application/zip'题目顺序由 question_ids 数组顺序决定。
说明:
description为必填,必须是非空字符串description同样会参与 bundle 目录命名,因此也要满足上面的文件名安全限制title、subtitle为必填非空字符串authors、reviewers为 JSON 字符串数组POST /papers必须带一个合法 zip,服务端会原样存成附加 binaryGET /papers支持通过question_id、category、tag、q、limit、offset做组合查询q会模糊匹配试卷的description、title、subtitle、authors、reviewers
PATCH /papers/{paper_id}
更新 metadata,也支持通过 question_ids 重排题目列表。支持字段:description、title、subtitle、authors、reviewers、question_ids。
DELETE /papers/{paper_id}
说明:
- 这是软删除,不会立刻删除 appendix binary
POST /papers/bundles
请求体示例:
{
"paper_ids": [
"79bf1ce3-6b61-4e5f-82ab-29e5c7cb2942",
"8ff430a0-92aa-463b-bf0f-b244a6697bf4"
]
}返回一个 zip,根目录附带 manifest.json,并按 paperDescription_uuid前缀/questionDescription_uuid前缀/ 目录展开题目文件。每个试卷目录还会附带一个重命名后的 append.zip,内容就是创建试卷时上传的那个 zip。
GET /papersGET /papers/{paper_id}POST /papers/bundlesGET /questionsGET /questions/tagsGET /questions/difficulty-tagsGET /questions/{question_id}GET /questions/{question_id}/reviewersPOST /questions/{question_id}/reviewersDELETE /questions/{question_id}/reviewers/{reviewer_id}POST /questions/bundlesGET /admin/questionsGET /admin/questions/{question_id}POST /admin/questions/{question_id}/restoreGET /admin/papersGET /admin/papers/{paper_id}POST /admin/papers/{paper_id}/restorePOST /admin/garbage-collections/previewPOST /admin/garbage-collections/runPOST /exports/runPOST /quality-checks/run
GET /questions 额外支持:
difficulty_tagdifficulty_mindifficulty_maxcreated_after/created_beforeupdated_after/updated_before
其中 difficulty_min / difficulty_max 需要和 difficulty_tag 一起使用。日期参数支持 YYYY-MM-DD 或 RFC 3339 格式。
GET /papers 同样支持 created_after / created_before / updated_after / updated_before 日期范围筛选。
GET /questions/tags 返回:
- 所有未软删除题目的去重 tag 列表
- 响应格式为
{"tags": ["..."]} - 按字典序升序返回,适合前端做输入联想和快速选择
GET /questions/difficulty-tags 返回:
- 所有未软删除题目的去重难度标签(algorithm_tag)列表
- 响应格式为
{"difficulty_tags": ["..."]} - 按字典序升序返回,适合前端做下拉选择;建议默认选中
human
说明:
- 普通
/questions和/papers接口默认只返回未软删除记录 POST /questions/bundles和POST /papers/bundles需要viewer及以上角色POST /exports/run和POST /quality-checks/run仍然只允许editor及以上角色- 管理员查询、恢复和垃圾回收见 Admin API
POST /exports/run只导出未软删除题目POST /quality-checks/run只检查未软删除题目和试卷
系统采用 5 级角色体系,基于能力而非线性层级:
| 角色 | 说明 |
|---|---|
viewer |
只读 + bundle 下载 |
user |
可上传题目,编辑自己创建的题目,可被分配为审阅人 |
leader |
可创建/编辑/删除题目和试卷(非 used),可分配审阅人;有过期时间,过期后降级为 user |
bot |
同 leader 权限,无过期时间,用于自动化程序;不支持密码登录,由 admin 签发长期 access token |
admin |
全部权限 + ops + 用户管理 + 垃圾回收 |
Leader 角色在创建时必须指定 leader_expires_at 过期时间。JWT 中间件会在每次请求时检查过期时间,过期后自动将角色降级为 user。
export QB_DATABASE_URL='postgres://postgres:postgres@127.0.0.1:5432/qb'
export QB_BIND_ADDR='127.0.0.1:8080'
export QB_EXPORT_DIR='./exports' # 导出文件根目录(默认 ./exports)
# export QB_MAX_DB_CONNECTIONS=10 # 可选,连接池上限
# export QB_CORS_ORIGINS='http://localhost:3000,http://localhost:5173' # 可选,CORS 白名单
# export QB_JWT_SECRET='qb-dev-secret-change-me-in-production' # 生产环境必须修改
for file in migrations/*.sql; do
psql "$QB_DATABASE_URL" -v ON_ERROR_STOP=1 -f "$file"
done
cargo run仓库现在提供了完整的生产部署样例:
快速启动:
cp compose.prod.env.example .env
docker build -t qb_api:latest .
docker compose --env-file .env -f docker-compose.prod.yml up -d部署说明、升级方式、备份建议和生产注意事项见 DEPLOYMENT.md。
单元与集成测试:
cargo test端到端测试(基于 pytest,会自动启动 Docker PostgreSQL 和 API 进程):
cd scripts && python3 -m pytest tests/ -v测试覆盖:
| 模块 | 覆盖内容 |
|---|---|
test_0_auth |
登录、token 刷新、bot access token、权限矩阵、5 角色权限检查、leader 过期降级 |
test_1_questions |
题目 CRUD、标签/难度标签列表、difficulty 筛选、日期范围筛选、文件替换、bundle、审阅人管理 |
test_2_papers |
试卷 CRUD、日期范围筛选、试卷 bundle、题目排序与渲染、所有权校验 |
test_3_ops |
导出、质量检查、数据库备份恢复、权限验证 |
test_4_admin |
软删除、恢复、垃圾回收 |
如果已有运行中的 PostgreSQL 和 API 实例,可跳过自动基础设施启动:
QB_E2E_SKIP_INFRA=1 API_PORT=8080 POSTGRES_PORT=5432 python3 -m pytest tests/ -v表结构定义在 0001_init_pg.sql。5 角色权限系统和所有权追踪的迁移在 0002_role_system.sql。