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,72 @@
# Checklist
## 多格式文件文本提取
- [x] `app/core/file_parser.py` 存在并实现 `parse_file(filename, content)``supported_extensions()`
- [x] 支持 .txt/.mdUTF-8 解码 errors=replace
- [x] 支持 .html/.htmhtml.parser 剥离 script/style 后提取可见文本)
- [x] 支持 .pdfpypdf 逐页 extract_text 拼接)
- [x] 支持 .docxpython-docx 段落文本拼接)
- [x] 未识别扩展名抛 `ValueError("不支持的文件类型: {ext}")`
- [x] 解析异常包装为 `ValueError("文件解析失败: {detail}")` 并保留原异常链
- [x] `tests/test_file_parser.py` 13 例覆盖各格式 + 异常路径
## 文件上传入库端点
- [x] `POST /api/v1/documents/upload` 端点存在,接收 multipart/form-datafile + title/source/metadata
- [x] 扩展名校验基于 `settings.upload_allowed_extensions`,失败抛 code=1001 含扩展名
- [x] 大小校验基于 `settings.upload_max_size_mb`,失败抛 code=1001 含「大小上限」
- [x] 调用 `parse_file` 提取文本,失败抛 code=1001
- [x] 提取文本为空抛 code=1001 含「无法从文件提取文本」
- [x] 落盘到 `settings.upload_dir`,失败仅 warning 不阻塞入库
- [x] 落盘元数据 raw_file_path/original_filename/original_size_bytes 写入 DocumentInput.metadata
- [x] 用户传入 metadata JSON 解析合并(落盘元数据优先级更高,用 setdefault
- [x] 默认 title 取文件名 stem,默认 source 取 `file:{原文件名}`
- [x] 复用现有 IngestTaskManager.submit 提交异步入库流水线
- [x] 返回 202 + `{code:0, data:{task_id, status:"pending", saved_path}}`
- [x] `tests/test_document_upload_api.py` 10 例覆盖合法上传/不支持扩展名/超大/空文本/损坏 PDF/落盘失败降级/metadata JSON/默认 title/source
## 配置项
- [x] `app/config.py` Settings 新增 `upload_dir: str = "./uploads"`
- [x] `app/config.py` Settings 新增 `upload_max_size_mb: int = 20`
- [x] `app/config.py` Settings 新增 `upload_allowed_extensions: str = ".txt,.md,.html,.htm,.pdf,.docx"`
## 依赖声明
- [x] `pyproject.toml` dependencies 含 `python-multipart>=0.0.20`
- [x] `pyproject.toml` dependencies 含 `pypdf>=5.1.0`
- [x] `pyproject.toml` dependencies 含 `python-docx>=1.1.2`
## 应用启动初始化(Task 4.1
- [x] `app/main.py` lifespan 在 `ensure_default_admin` 之后调用 `Path(settings.upload_dir).mkdir(parents=True, exist_ok=True)`
- [x] mkdir 失败仅 warning 不阻塞启动
## 环境变量模板(Task 4.2
- [x] `.env.example` 末尾含 `--- 文件上传 ---` 段落
- [x]`UPLOAD_DIR=./uploads`
- [x]`UPLOAD_MAX_SIZE_MB=20`
- [x]`UPLOAD_ALLOWED_EXTENSIONS=.txt,.md,.html,.htm,.pdf,.docx`
## 管理后台 UITask 5
- [x] `admin.html` `#section-ingest` 区块含「文件上传」子表单
- [x] file input 含 `accept=".txt,.md,.html,.htm,.pdf,.docx"`
- [x] 可选 title 输入框
- [x] 可选 source 输入框
- [x] 提交按钮 id=`btn-upload-submit`
- [x] submit 监听构造 FormData 调用 `/api/v1/documents/upload`
- [x] 复用 `renderIngestHeader` + `startIngestPolling` 展示任务进度
- [x] 提交期间禁用按钮并显示「上传中…」
- [x] 完成(成功/失败)后恢复按钮文案为「上传入库」
## 文档同步(Task 6
- [x] `CLAUDE.md` API 清单含 `POST /api/v1/documents/upload`
- [x] `CLAUDE.md` 「文档入库与三级总结」节简述文件上传通道
## 全量回归(Task 7
- [x] `uv run pytest` 全绿(289 passed,含新增 23 测试 + 既有 266 测试套件)
@@ -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)。
@@ -0,0 +1,37 @@
# Tasks
## 已完成(前期工作)
- [x] Task 1: 新增 `app/core/file_parser.py` 多格式文本提取
- [x] SubTask 1.1: 实现 _parse_text/_parse_html/_parse_pdf/_parse_docx 解析器与 _PARSERS 注册表
- [x] SubTask 1.2: 实现 `parse_file(filename, content)` 入口与异常包装(不支持/解析失败)
- [x] SubTask 1.3: 实现 `supported_extensions()` 辅助函数
- [x] Task 2: 单元测试 `tests/test_file_parser.py`13 例覆盖各格式 + 异常路径)
- [x] Task 3: 新增 `POST /api/v1/documents/upload` 端点 + 测试
- [x] SubTask 3.1: 在 `app/api/v1/document.py` 增加 upload 端点(扩展名/大小/解析/落盘/metadata/默认 title-source/提交 IngestTaskManager
- [x] SubTask 3.2: 增加 `_allowed_extensions()` 辅助函数
- [x] SubTask 3.3: 在 `app/config.py` 增加 `upload_dir` / `upload_max_size_mb` / `upload_allowed_extensions`
- [x] SubTask 3.4: `tests/test_document_upload_api.py` 10 例测试(合法上传/不支持扩展名/超大/空文本/损坏 PDF/落盘失败降级/metadata JSON/默认 title/source
- [x] SubTask 3.5: `pyproject.toml` dependencies 增加 `python-multipart>=0.0.20` / `pypdf>=5.1.0` / `python-docx>=1.1.2`
## 剩余任务
- [x] Task 4: 应用启动初始化 + 环境变量模板
- [x] SubTask 4.1: 在 `app/main.py` lifespan 的 `ensure_default_admin` 之后增加 `Path(settings.upload_dir).mkdir(parents=True, exist_ok=True)`,异常仅 warning 不阻塞启动
- [x] SubTask 4.2: 在 `.env.example` 末尾增加 `--- 文件上传 ---` 段落,包含 `UPLOAD_DIR` / `UPLOAD_MAX_SIZE_MB` / `UPLOAD_ALLOWED_EXTENSIONS` 三项
- [x] Task 5: 管理后台 `admin.html` 文件上传 UI
- [x] SubTask 5.1: 在 `#section-ingest` 现有 `#ingest-form` 之后新增「文件上传」子表单,含 file inputaccept=".txt,.md,.html,.htm,.pdf,.docx"+ 可选 title + 可选 source + 提交按钮 `#btn-upload-submit`
- [x] SubTask 5.2: 新增 `#upload-form` submit 监听:构造 FormDatafile/title/source)→ 调用 `/api/v1/documents/upload` → 复用 `renderIngestHeader` + `startIngestPolling` 展示任务进度
- [x] SubTask 5.3: 提交期间禁用 `#btn-upload-submit` 显示「上传中…」,请求完成(成功/失败)后恢复按钮文案
- [x] Task 6: 文档同步 `CLAUDE.md`
- [x] SubTask 6.1: API 清单表格在 `POST /api/v1/documents` 行之后增加 `POST /api/v1/documents/upload` 行(说明:multipart 文件上传入库,202 异步)
- [x] SubTask 6.2: 「文档入库与三级总结」节增加简述:文件上传通道(multipart 入口→parse_file 提取→复用现有 IngestTaskManager→同一任务查询端点)
- [x] Task 7: 全量 pytest 验证回归
- [x] SubTask 7.1: 执行 `uv run pytest` 全绿(289 passed,含新增 23 测试 + 既有 266 测试套件)
# Task Dependencies
- Task 4 独立于 Task 5/6,可并行
- Task 5admin.html UI)依赖 Task 3 已完成(端点已存在)✓
- Task 6 独立
- Task 7 依赖 Task 4/5/6 全部完成