feat: 新增多格式文件上传入库与认证体系

- 新增 JWT 认证模块,支持登录/注册/用户管理
- 新增文件上传接口,支持 .txt/.md/.html/.pdf/.docx 等格式解析入库
- 新增检索结果 AI 总结功能
- 新增文本去重缓存机制
- 新增全局认证夹具简化测试
- 新增配置项与环境变量支持
- 完善文档与测试覆盖
This commit is contained in:
2026-07-30 10:30:15 +08:00
parent 51dc8dc4f6
commit dce9e31bde
31 changed files with 3021 additions and 45 deletions
@@ -0,0 +1,90 @@
# 多格式文件上传入库 Spec
## Why
QMDSearch 现有入库接口 `POST /api/v1/documents` 仅接受 JSON 文本字段,用户必须先将文件内容贴成 text 才能入库;对 .md/.html/.pdf/.docx 等常见知识载体缺少直接通道。引入文件上传端点可减少入库摩擦、保留原始文件元数据、让管理后台一站式完成「上传→解析→入库→轮询任务」全流程。
## What Changes
- 新增 `app/core/file_parser.py`:按扩展名分发解析器(.txt/.md/.html/.htm/.pdf/.docx),统一异常包装。
- 新增 `POST /api/v1/documents/upload` 端点:multipart/form-data 接收文件 + 可选 title/source/metadata,校验扩展名/大小→提取文本→落盘→复用现有 IngestTaskManager 提交异步入库流水线,返回 202 + task_id + saved_path。
- 配置项:`upload_dir` / `upload_max_size_mb` / `upload_allowed_extensions` 注入到 `Settings`
- 应用启动 lifespan 中 `mkdir -p upload_dir`(失败仅 warning 不阻塞启动)。
- 依赖:`pyproject.toml` 增加 `python-multipart` / `pypdf` / `python-docx`
- 环境变量模板 `.env.example` 增加 `--- 文件上传 ---` 段落。
- 管理后台 `admin.html` 在入库区块新增「文件上传」子表单:file input + 可选 title/source + 提交按钮,构造 FormData 调用 upload 端点,复用 `ingestPollTimer` 轮询 UI;提交期间禁用按钮。
- `CLAUDE.md` API 清单加入 `POST /api/v1/documents/upload`,并在「文档入库与三级总结」节简述文件上传通道。
- 单元测试:`tests/test_file_parser.py`13 例覆盖各格式与异常路径)、`tests/test_document_upload_api.py`(10 例覆盖合法上传/不支持扩展名/超大/空文本/损坏 PDF/落盘失败降级)。
## Impact
- Affected specs: 无(首次新增能力,不修改既有 spec)
- Affected code:
- 新增:`app/core/file_parser.py``tests/test_file_parser.py``tests/test_document_upload_api.py`
- 修改:`app/api/v1/document.py``app/config.py``app/main.py``app/static/admin.html``pyproject.toml``.env.example``CLAUDE.md`
## ADDED Requirements
### Requirement: 多格式文件文本提取
系统 SHALL 提供 `parse_file(filename, content)` 函数,按扩展名分发到对应解析器:.txt/.md 走 UTF-8 解码(errors=replace 兜底);.html/.htm 走标准库 html.parser 剥离 script/style 后提取可见文本;.pdf 走 pypdf 逐页 extract_text 拼接;.docx 走 python-docx 段落文本拼接(不含表格/页眉页脚)。未识别扩展名 SHALL 抛 `ValueError("不支持的文件类型: {ext}")`;解析异常 SHALL 包装为 `ValueError("文件解析失败: {detail}")` 并保留原异常链。
#### Scenario: 支持的扩展名提取成功
- **WHEN** 调用 `parse_file("notes.md", b"# Hello\n\nWorld")`
- **THEN** 返回字符串 `"# Hello\n\nWorld"`
#### Scenario: 不支持的扩展名
- **WHEN** 调用 `parse_file("data.xlsx", b"binary")`
- **THEN** 抛 `ValueError`message 含 "不支持的文件类型: .xlsx"
#### Scenario: 损坏 PDF
- **WHEN** 调用 `parse_file("bad.pdf", b"not a real pdf")`
- **THEN** 抛 `ValueError`message 含 "文件解析失败"
### Requirement: 文件上传入库端点
系统 SHALL 提供 `POST /api/v1/documents/upload` 端点,接收 multipart/form-datafile + 可选 title/source/metadata JSON 字符串),返回 `202` + `{code:0, data:{task_id, status:"pending", saved_path}}`。端点流程 SHALL 依次:扩展名校验(基于 `settings.upload_allowed_extensions`)→ 大小校验(`settings.upload_max_size_mb`)→ `parse_file` 提取文本→ 落盘到 `settings.upload_dir`(失败仅 warning 不阻塞)→ 合并用户 metadata(落盘元数据 raw_file_path/original_filename/original_size_bytes 优先级更高)→ 默认 title 取文件名 stem、默认 source 取 `file:{原文件名}` → 提交 IngestTaskManager → 返回 202。
#### Scenario: 合法 .md 上传
- **WHEN** POST /api/v1/documents/upload 携带 file=notes.md(内容 "# Hello\n\nWorld")、title="我的笔记"、source="manual"
- **THEN** 返回 202data.task_id 非空、data.status=="pending"、data.saved_path 指向已存在文件;任务管理器收到 DocumentInputtext 与文件解码一致、metadata.original_filename=="notes.md"
#### Scenario: 不支持扩展名
- **WHEN** POST 上传 data.xlsx
- **THEN** 返回 code=1001、message 含 ".xlsx",任务管理器未收到任何提交
#### Scenario: 超大文件
- **WHEN** upload_max_size_mb=1 且上传 2MB txt
- **THEN** 返回 code=1001、message 含 "大小上限"
#### Scenario: 解析后为空文本
- **WHEN** 上传 blank.txt 内容仅空白
- **THEN** 返回 code=1001、message 含 "无法从文件提取文本"
#### Scenario: 落盘失败降级
- **WHEN** upload_dir 指向已存在的文件(mkdir 失败)
- **THEN** 返回 202saved_path==""DocumentInput.metadata 不含 raw_file_path/original_filename
### Requirement: 应用启动初始化 upload_dir
应用 lifespan SHALL 在 `ensure_default_admin` 之后尝试 `Path(settings.upload_dir).mkdir(parents=True, exist_ok=True)`,失败仅 warning 不阻塞启动。
### Requirement: 管理后台文件上传 UI
`admin.html` 入库区块 SHALL 在现有「文本入库」表单旁新增「文件上传」子表单,包含:file input(accept 当前支持的扩展名)+ 可选标题输入框 + 可选来源输入框 + 提交按钮。提交时构造 FormData 调用 `/api/v1/documents/upload`,复用现有 `ingestPollTimer` 与 ingest-result 渲染逻辑展示任务进度与最终结果。提交期间 SHALL 禁用提交按钮并显示「上传中…」文案。
#### Scenario: 用户上传文件
- **WHEN** 用户选择 notes.md 文件、填标题、点提交
- **THEN** 按钮禁用显示「上传中…」,FormData 含 file/title,请求返回 202 后开始轮询 task 状态直到 done/failed/timeout
### Requirement: 配置项与环境变量
`Settings` SHALL 新增三项配置:`upload_dir: str = "./uploads"``upload_max_size_mb: int = 20``upload_allowed_extensions: str = ".txt,.md,.html,.htm,.pdf,.docx"``.env.example` SHALL 增加 `--- 文件上传 ---` 段落包含对应环境变量 `UPLOAD_DIR` / `UPLOAD_MAX_SIZE_MB` / `UPLOAD_ALLOWED_EXTENSIONS`
### Requirement: 依赖声明
`pyproject.toml` dependencies SHALL 增加:`python-multipart>=0.0.20`FastAPI 文件上传依赖)、`pypdf>=5.1.0`PDF 解析)、`python-docx>=1.1.2`DOCX 解析)。
### Requirement: 文档同步
`CLAUDE.md` API 清单 SHALL 加入 `POST /api/v1/documents/upload` 行;「文档入库与三级总结」节 SHALL 简述文件上传通道(multipart 入口→parse_file 提取→复用现有 IngestTaskManager)。