feat: add document file download API and related features

1. add `/api/v1/documents/{doc_id}/file` endpoint for downloading original document files
2. add file field to document detail API response based on metadata
3. add comprehensive tests for file download API and metadata integration
4. update API documentation in CLAUDE.md
This commit is contained in:
2026-07-31 22:46:50 +08:00
parent e2e8e6829d
commit f92eff6f65
7 changed files with 580 additions and 6 deletions
+4 -2
View File
@@ -204,11 +204,13 @@ class TestAdminClosedLoop:
assert item_b["category"] == DOC_B_CATEGORY
assert item_b["tags"] == []
# 2. 详情:l1/l2_nodes/l3_nodes/chunks_count 与写入一致
# 2. 详情:l1/l2_nodes/l3_nodes/chunks_count/file 与写入一致
resp = client.get(f"/api/v1/documents/{DOC_A}")
assert resp.status_code == 200
data = resp.json()["data"]
assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count"}
assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count", "file"}
# 文本入库无 metadata → file=None
assert data["file"] is None
l1 = data["l1"]
assert l1["doc_id"] == DOC_A
assert l1["title"] == DOC_A_TITLE
+16
View File
@@ -231,6 +231,22 @@ def test_admin_page_auth_interceptor(admin_html: str) -> None:
assert "1006" in admin_html
def test_admin_page_doc_detail_file_link(admin_html: str) -> None:
"""文档详情区:有关联文件时展示原始文件行与下载链接(textContent 防 XSS"""
# 条件判断与字段访问
assert "data.file" in admin_html
assert "file.filename" in admin_html
assert "file.size_bytes" in admin_html
# 大小格式化辅助
assert "formatFileSize" in admin_html
# 下载链接用 el("a") 创建,设 href 与 download 属性,不 innerHTML
assert 'el("a"' in admin_html
assert "download" in admin_html
# 行标签文案
assert "原始文件" in admin_html
assert "下载" in admin_html
def test_admin_page_api_guide_section(admin_html: str) -> None:
"""API 指南区块:导航按钮、section、静态清单与鉴权标注(user 角色也可见)"""
assert 'data-target="section-api-guide"' in admin_html
+169
View File
@@ -0,0 +1,169 @@
"""GET /api/v1/documents/{doc_id}/file 文件下载端点测试(TestClient + FakeQdrant,不真实联网)
覆盖分支:成功下载、文档不存在、无关联文件、文件已从磁盘删除、路径越界、免登录访问。
"""
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.api.v1 import document as document_module
from app.main import app
from app.services.qdrant import QdrantService
class FakeQdrant:
"""假 QdrantService:仅实现 get_l1_metadata,按 doc_id 返回固定 metadata 或 None"""
def __init__(self, meta: dict[str, str] | None = None) -> None:
self.meta = meta
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
return self.meta
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[TestClient]:
"""TestClientQdrant 集合初始化空操作;upload_dir 指向临时目录
不挂 admin_headers:下载端点免登录,验证无 token 可访问。
"""
async def _noop_ensure_collections(self: QdrantService) -> None:
return None
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
monkeypatch.setattr(document_module.settings, "upload_dir", str(tmp_path / "uploads"))
with TestClient(app) as test_client:
yield test_client
def _install_fake(monkeypatch: pytest.MonkeyPatch, fake: FakeQdrant) -> None:
"""将 _get_qdrant 单例替换为假服务"""
monkeypatch.setattr(document_module, "_get_qdrant", lambda: fake)
def test_download_file_success(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""文档存在且有关联文件 → 200 + 文件内容正确 + content-disposition 含文件名"""
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
target = upload_dir / "doc-1_notes.txt"
content = b"hello world file content"
target.write_bytes(content)
fake = FakeQdrant(
meta={
"raw_file_path": str(target),
"original_filename": "notes.txt",
"original_size_bytes": str(len(content)),
}
)
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
assert resp.status_code == 200
assert resp.content == content
disposition = resp.headers.get("content-disposition", "")
assert "attachment" in disposition
assert "notes.txt" in disposition
def test_download_file_doc_not_found(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""文档不存在(metadata 为 None)→ 1004"""
fake = FakeQdrant(meta=None)
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/missing/file")
body = resp.json()
assert body["code"] == 1004
assert "文件" in body["message"]
def test_download_file_no_associated_file(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""文档存在但 raw_file_path 为空 → 1004"""
fake = FakeQdrant(meta={"raw_file_path": "", "original_filename": "x.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
body = resp.json()
assert body["code"] == 1004
assert "未关联文件" in body["message"]
def test_download_file_missing_on_disk(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""文件已从磁盘删除(path.is_file() False)→ 1004"""
missing = tmp_path / "uploads" / "gone.txt"
fake = FakeQdrant(meta={"raw_file_path": str(missing), "original_filename": "gone.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
body = resp.json()
assert body["code"] == 1004
assert "文件不存在" in body["message"]
def test_download_file_path_traversal_rejected(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""raw_file_path 指向 upload_dir 之外 → 1004(路径越界校验)"""
outside = tmp_path / "secret.txt"
outside.write_bytes(b"secret")
fake = FakeQdrant(meta={"raw_file_path": str(outside), "original_filename": "secret.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
body = resp.json()
assert body["code"] == 1004
assert "文件不存在" in body["message"]
def test_download_file_no_auth_required(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""免登录:不携带 Authorization 头可正常下载(端点不挂鉴权依赖)"""
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
target = upload_dir / "doc-1_open.txt"
target.write_bytes(b"open content")
fake = FakeQdrant(meta={"raw_file_path": str(target), "original_filename": "open.txt"})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
assert resp.status_code == 200
assert resp.content == b"open content"
def test_download_file_uses_original_filename_when_missing(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""metadata 缺少 original_filename 时回退到 path.name 作为下载文件名"""
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
target = upload_dir / "fallback_name.txt"
target.write_bytes(b"fb")
fake = FakeQdrant(meta={"raw_file_path": str(target)})
_install_fake(monkeypatch, fake)
resp = client.get("/api/v1/documents/doc-1/file")
assert resp.status_code == 200
assert "fallback_name.txt" in resp.headers.get("content-disposition", "")
+161
View File
@@ -0,0 +1,161 @@
"""文件链接集成验证
在真实内存 Qdrant + FakeOllama + 真实临时 upload_dir 环境下,验证文件上传到下载的全链路闭环:
POST /documents/upload → 入库 wait_done → GET /documents/{id}file 字段)→ GET /documents/{id}/file
并覆盖纯文本入库无文件关联的对照路径(详情 file=None、下载端点 1004)。
"""
from pathlib import Path
from urllib.parse import unquote
import pytest
from fastapi.testclient import TestClient
from qdrant_client import AsyncQdrantClient
from app.api.v1 import document as document_module
from app.config import Settings
from app.core.chunker import Chunker
from app.core.classifier import Classifier
from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus
from app.core.ingestion import Ingester
from app.core.sparse import SparseEncoder
from app.core.summarizer import Summarizer
from app.main import app
from app.models.knowledge import load_taxonomy
from app.services.qdrant import QdrantService
from tests.test_e2e_integration import (
FAKE_CATEGORY,
DeterministicEmbedding,
FakeOllama,
)
async def _make_env() -> tuple[QdrantService, Ingester]:
"""构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量"""
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
await qdrant.ensure_collections()
ollama = FakeOllama()
taxonomy = load_taxonomy()
embedding = DeterministicEmbedding()
ingester = Ingester(
summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type]
classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type]
chunker=Chunker(),
embedding=embedding,
sparse=SparseEncoder(),
qdrant=qdrant,
)
return qdrant, ingester
def _patch_app(
monkeypatch: pytest.MonkeyPatch,
manager: IngestTaskManager,
qdrant: QdrantService,
upload_dir: str,
) -> None:
"""替换模块级单例:任务管理器 / Qdrant / upload_dirlifespan 建集合改空操作"""
async def _noop_ensure_collections(self: QdrantService) -> None:
return None
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
monkeypatch.setattr(document_module, "_task_manager", manager)
monkeypatch.setattr(document_module, "_qdrant", qdrant)
monkeypatch.setattr(document_module.settings, "upload_dir", upload_dir)
class TestDocumentFileIntegration:
"""文件链接集成:上传闭环 + 文本入库无文件对照"""
async def test_upload_full_loop(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
"""文件上传闭环:upload → wait_done done → 详情含 file → 下载返回原文"""
qdrant, ingester = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, str(tmp_path / "uploads"))
filename = "上传测试.md"
original_content = "上传原文测试内容"
with TestClient(app) as client:
# 1. multipart 上传 .md 文件(变更类端点需 admin 认证头)
resp_upload = client.post(
"/api/v1/documents/upload",
files={"file": (filename, original_content.encode("utf-8"), "text/markdown")},
headers=admin_headers,
)
assert resp_upload.status_code == 202
body_upload = resp_upload.json()
assert body_upload["code"] == 0
assert body_upload["data"]["status"] == "pending"
task_id = body_upload["data"]["task_id"]
assert task_id
# 2. 等待入库终态:done + 结果完整
final = await manager.wait_done(task_id)
assert final["status"] == IngestTaskStatus.DONE
result = final["result"]
document_id = result["document_id"]
assert document_id
assert result["category"] == FAKE_CATEGORY
# 3. 文档详情:file 字段非空,含原文件名 / url / size
resp_detail = client.get(f"/api/v1/documents/{document_id}")
body_detail = resp_detail.json()
assert body_detail["code"] == 0
file_info = body_detail["data"]["file"]
assert file_info is not None
assert filename in file_info["filename"]
assert file_info["url"] == f"/api/v1/documents/{document_id}/file"
assert file_info["size_bytes"] > 0
# 4. 下载:200 + 响应体含上传原文 + Content-Disposition 含原文件名(中文按 RFC 5987 百分号编码,需 unquote
resp_file = client.get(f"/api/v1/documents/{document_id}/file")
assert resp_file.status_code == 200
assert original_content in resp_file.content.decode("utf-8")
disposition = resp_file.headers.get("content-disposition", "")
assert "attachment" in disposition
assert filename in unquote(disposition)
async def test_text_ingest_no_file(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
admin_headers: dict[str, str],
) -> None:
"""纯文本入库(无 metadata):详情 file=None,下载端点返回 1004"""
qdrant, ingester = await _make_env()
manager = IngestTaskManager(ingester, None, Settings())
_patch_app(monkeypatch, manager, qdrant, str(tmp_path / "uploads"))
with TestClient(app) as client:
# 1. 纯文本入库(无 metadata
resp_post = client.post(
"/api/v1/documents",
json={"text": "纯文本入库无附件的对照内容", "title": "无文件文档"},
headers=admin_headers,
)
assert resp_post.status_code == 202
body_post = resp_post.json()
assert body_post["code"] == 0
task_id = body_post["data"]["task_id"]
# 2. 等待终态 done
final = await manager.wait_done(task_id)
assert final["status"] == IngestTaskStatus.DONE
document_id = final["result"]["document_id"]
# 3. 详情:file 为 None
resp_detail = client.get(f"/api/v1/documents/{document_id}")
body_detail = resp_detail.json()
assert body_detail["code"] == 0
assert body_detail["data"]["file"] is None
# 4. 下载端点:文档未关联文件 → 1004
resp_file = client.get(f"/api/v1/documents/{document_id}/file")
body_file = resp_file.json()
assert body_file["code"] == 1004
+80 -4
View File
@@ -3,13 +3,27 @@
from typing import Any
import pytest
from qdrant_client import AsyncQdrantClient
from app.config import settings
from app.core.chunker import Chunker
from app.core.ingestion import Ingester, IngestionError
from app.core.sparse import SparseEncoder
from app.models.document import DocumentInput, DocumentSummary, SummaryLevel
from app.models.knowledge import CategoryResult
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3, QdrantService
_DIM = settings.embedding_dimension
def _fake_vec(index: int, dim: int) -> list[float]:
"""构造确定性伪向量:第 0 维放 index 标识,其余补 0,长度对齐 dim"""
vec = [0.0] * dim
if dim > 0:
vec[0] = float(index)
if dim > 1:
vec[1] = 1.0
return vec
class FakeSummarizer:
@@ -37,14 +51,18 @@ class FakeClassifier:
class FakeEmbedding:
"""按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本"""
"""按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本
def __init__(self) -> None:
dim 默认 2FakeQdrant 不校验维度);接真实 Qdrant 时需传 settings.embedding_dimension。
"""
def __init__(self, dim: int = 2) -> None:
self.dim = dim
self.calls: list[list[str]] = []
async def embed(self, texts: list[str]) -> list[list[float]]:
self.calls.append(list(texts))
return [[float(i), 1.0] for i in range(len(texts))]
return [_fake_vec(i, self.dim) for i in range(len(texts))]
class FakeQdrant:
@@ -65,6 +83,7 @@ class FakeQdrant:
tags: list[str],
dense_vector: list[float],
sparse_vector: Any = None,
metadata: dict[str, str] | None = None,
) -> None:
if self.fail_on == "l1":
raise RuntimeError("qdrant down")
@@ -77,6 +96,7 @@ class FakeQdrant:
"tags": tags,
"dense_vector": dense_vector,
"sparse_vector": sparse_vector,
"metadata": metadata,
}
)
@@ -271,3 +291,59 @@ class TestQdrantFailure:
assert len(qdrant.l1_calls) == 1
assert {c for c, _ in qdrant.nodes_calls} == {COLLECTION_L2, COLLECTION_L3}
assert qdrant.chunks_calls == []
class TestMetadataIntegration:
"""L1 metadata 端到端:真实内存 Qdrant + 假总结/分类/向量化,验证 metadata 透传与 get_doc_detail.file"""
@pytest.fixture
async def real_service(self) -> QdrantService:
svc = QdrantService(client=AsyncQdrantClient(location=":memory:"))
await svc.ensure_collections()
return svc
def _make_real_ingester(self, service: QdrantService, summary: DocumentSummary) -> Ingester:
return Ingester(
summarizer=FakeSummarizer(summary), # type: ignore[arg-type]
classifier=FakeClassifier(_category()), # type: ignore[arg-type]
chunker=Chunker(),
embedding=FakeEmbedding(dim=_DIM), # type: ignore[arg-type]
sparse=SparseEncoder(),
qdrant=service,
)
async def test_ingest_with_metadata_populates_file_field(self, real_service: QdrantService) -> None:
"""DocumentInput 带 metadata 入库后,get_doc_detail.file 含正确信息"""
doc = DocumentInput(
text="这是一段用于测试 metadata 透传的正文内容。" * 5,
title="带文件元数据的文档",
metadata={
"raw_file_path": "/data/uploads/spec.md",
"original_filename": "spec.md",
"original_size_bytes": "5120",
},
)
ingester = self._make_real_ingester(real_service, _structured_summary())
result = await ingester.ingest(doc)
# FakeQdrant 已被真实 service 取代:l1_calls 不再可用,直接查 Qdrant
detail = await real_service.get_doc_detail(result.document_id)
assert detail is not None
assert detail["file"] == {
"filename": "spec.md",
"size_bytes": 5120,
"url": f"/api/v1/documents/{result.document_id}/file",
}
# L1 payload 也应带 metadata 字段
assert detail["l1"]["metadata"] == doc.metadata
async def test_ingest_without_metadata_file_is_none(self, real_service: QdrantService) -> None:
"""文本入库(metadata 为空 dict)→ get_doc_detail.file=None"""
doc = DocumentInput(text="纯文本入库,无文件元数据。" * 5, title="纯文本")
ingester = self._make_real_ingester(real_service, _structured_summary())
result = await ingester.ingest(doc)
detail = await real_service.get_doc_detail(result.document_id)
assert detail is not None
assert detail["file"] is None
assert detail["l1"]["metadata"] == {}
+149
View File
@@ -234,3 +234,152 @@ async def test_search_hybrid_rrf(service: QdrantService) -> None:
assert len(filtered) == 1
assert filtered[0].payload is not None
assert filtered[0].payload["doc_id"] == "doc-h2"
# ---------- L1 metadata 存储与读取 ----------
async def test_upsert_l1_metadata_roundtrip(service: QdrantService) -> None:
"""upsert_l1 写入 metadata 后,get_l1_metadata 返回写入的 dict"""
meta = {
"raw_file_path": "/data/uploads/report.pdf",
"original_filename": "年报.pdf",
"original_size_bytes": "2048",
}
await service.upsert_l1(
doc_id="doc-meta",
title="带元数据文档",
summary="总结",
category="tech",
tags=["x"],
dense_vector=_dense(0.9),
metadata=meta,
)
assert await service.get_l1_metadata("doc-meta") == meta
async def test_upsert_l1_without_metadata_stores_empty_dict(service: QdrantService) -> None:
"""upsert_l1 不传 metadata(旧签名)→ payload 存空 dictget_l1_metadata 返回 {}"""
await service.upsert_l1(
doc_id="doc-no-meta",
title="无元数据文档",
summary="总结",
category="tech",
tags=["x"],
dense_vector=_dense(0.9),
)
assert await service.get_l1_metadata("doc-no-meta") == {}
async def test_get_l1_metadata_doc_not_found(service: QdrantService) -> None:
"""文档不存在 → get_l1_metadata 返回 None"""
assert await service.get_l1_metadata("doc-missing") is None
async def test_get_l1_metadata_legacy_payload_without_field(service: QdrantService) -> None:
"""存量旧文档:payload 完全没有 metadata 字段(绕过 upsert_l1 直接写点)→ 返回 None 不报错"""
import uuid
from qdrant_client import models
point = models.PointStruct(
id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-doc:l1")),
vector={"dense": _dense(0.4)},
payload={"doc_id": "legacy-doc", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"},
)
await service.client.upsert(collection_name=COLLECTION_L1, points=[point])
assert await service.get_l1_metadata("legacy-doc") is None
# ---------- get_doc_detail file 字段 ----------
async def test_get_doc_detail_file_field_with_raw_path(service: QdrantService) -> None:
"""L1 metadata 含 raw_file_path → file 字段含 filename/size_bytes/url"""
await service.upsert_l1(
doc_id="doc-file",
title="文件文档",
summary="总结",
category="tech",
tags=["x"],
dense_vector=_dense(0.9),
metadata={
"raw_file_path": "/data/uploads/report.pdf",
"original_filename": "年报.pdf",
"original_size_bytes": "4096",
},
)
detail = await service.get_doc_detail("doc-file")
assert detail is not None
file_info = detail["file"]
assert file_info == {
"filename": "年报.pdf",
"size_bytes": 4096,
"url": "/api/v1/documents/doc-file/file",
}
async def test_get_doc_detail_file_field_filename_fallback_to_path_name(service: QdrantService) -> None:
"""metadata 缺 original_filename 时,filename 回退为 raw_file_path 的 Path.name"""
await service.upsert_l1(
doc_id="doc-file-fb",
title="文件文档",
summary="总结",
category="tech",
tags=["x"],
dense_vector=_dense(0.9),
metadata={"raw_file_path": "/data/uploads/notes.md", "original_size_bytes": "100"},
)
detail = await service.get_doc_detail("doc-file-fb")
assert detail is not None
assert detail["file"]["filename"] == "notes.md"
assert detail["file"]["size_bytes"] == 100
async def test_get_doc_detail_file_none_when_no_metadata(service: QdrantService) -> None:
"""无 metadata(文本入库,存空 dict)→ file=None"""
await service.upsert_l1(
doc_id="doc-text",
title="纯文本文档",
summary="总结",
category="tech",
tags=["x"],
dense_vector=_dense(0.9),
)
detail = await service.get_doc_detail("doc-text")
assert detail is not None
assert detail["file"] is None
async def test_get_doc_detail_file_none_when_no_raw_path(service: QdrantService) -> None:
"""metadata 无 raw_file_path → file=None"""
await service.upsert_l1(
doc_id="doc-meta-no-path",
title="文档",
summary="总结",
category="tech",
tags=["x"],
dense_vector=_dense(0.9),
metadata={"original_filename": "x.pdf", "original_size_bytes": "10"},
)
detail = await service.get_doc_detail("doc-meta-no-path")
assert detail is not None
assert detail["file"] is None
async def test_get_doc_detail_file_none_for_legacy_doc(service: QdrantService) -> None:
"""存量旧文档(payload 无 metadata 字段,绕过 upsert_l1 直接写点)→ file=None 不报错"""
import uuid
from qdrant_client import models
point = models.PointStruct(
id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-detail:l1")),
vector={"dense": _dense(0.3)},
payload={"doc_id": "legacy-detail", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"},
)
await service.client.upsert(collection_name=COLLECTION_L1, points=[point])
detail = await service.get_doc_detail("legacy-detail")
assert detail is not None
assert detail["file"] is None
assert detail["l1"]["doc_id"] == "legacy-detail"