From e2e8e6829d454b9f178bad6e69f48a05ae138ff5 Mon Sep 17 00:00:00 2001 From: kplam Date: Fri, 31 Jul 2026 22:46:13 +0800 Subject: [PATCH] feat: add document file download and admin page file display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增将文档元数据存入L1向量库的功能 2. 新增文档文件下载API接口,支持路径安全校验 3. 后端文档详情接口新增文件信息返回字段 4. 管理后台页面新增原始文件信息展示与下载链接 --- app/api/v1/document.py | 29 +++++++++++++++++++++- app/core/ingestion.py | 1 + app/services/qdrant.py | 56 +++++++++++++++++++++++++++++++++++++++--- app/static/admin.html | 23 +++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/app/api/v1/document.py b/app/api/v1/document.py index 602b2d4..dba5538 100644 --- a/app/api/v1/document.py +++ b/app/api/v1/document.py @@ -8,7 +8,7 @@ from typing import Any import structlog from fastapi import APIRouter, Depends, File, Form, Query, UploadFile -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse from app.api.deps import get_current_user from app.api.response import ApiError, ok @@ -219,6 +219,33 @@ async def get_document(doc_id: str) -> dict[str, Any]: return ok(detail) +@router.get("/documents/{doc_id}/file") +async def download_document_file(doc_id: str) -> FileResponse: + """下载文档关联的原始文件(免登录) + + 从 L1 metadata 读取 raw_file_path,校验路径位于 upload_dir 之内后返回 FileResponse; + 文档不存在 / 无关联文件 / 文件缺失 / 路径越界统一返回 1004。 + """ + meta = await _get_qdrant().get_l1_metadata(doc_id) + if not meta: + raise ApiError(1004, "文档不存在或未关联文件") + raw_path = meta.get("raw_file_path", "") + if not raw_path: + raise ApiError(1004, "文档未关联文件") + path = Path(raw_path).resolve() + # 路径越界校验:只允许读取 upload_dir 下的文件 + try: + upload_dir = Path(settings.upload_dir).resolve() + path.relative_to(upload_dir) + except ValueError: + logger.warning("文件路径越界", doc_id=doc_id, raw_path=raw_path) + raise ApiError(1004, "文件不存在") from None + if not path.is_file(): + raise ApiError(1004, "文件不存在") + filename = meta.get("original_filename", path.name) + return FileResponse(path, filename=filename) + + @router.delete("/documents/{doc_id}") async def delete_document( doc_id: str, user: UserRecord = Depends(get_current_user) diff --git a/app/core/ingestion.py b/app/core/ingestion.py index ca6a762..1197256 100644 --- a/app/core/ingestion.py +++ b/app/core/ingestion.py @@ -262,6 +262,7 @@ class Ingester: tags=category.tags, dense_vector=l1_vector, sparse_vector=l1_sparse, + metadata=doc.metadata, ) # L2/L3 大纲节点(为空时跳过对应集合的 upsert) diff --git a/app/services/qdrant.py b/app/services/qdrant.py index 83884e7..a7f5f84 100644 --- a/app/services/qdrant.py +++ b/app/services/qdrant.py @@ -9,6 +9,7 @@ """ import uuid +from pathlib import Path from typing import Any import structlog @@ -103,18 +104,50 @@ class QdrantService: tags: list[str], dense_vector: list[float], sparse_vector: SparseVectorTuple | None = None, + metadata: dict[str, str] | None = None, ) -> None: - """写入 L1 文档总结,payload 含 doc_id/title/category/tags/text(=summary)""" + """写入 L1 文档总结,payload 含 doc_id/title/category/tags/text(=summary)/metadata + + metadata 默认 None 时存空 dict,保证字段始终存在;存量旧文档读取时按缺失处理。 + """ vector: dict[str, Any] = {VECTOR_DENSE: dense_vector} if sparse_vector is not None: vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse_vector[0], values=sparse_vector[1]) point = models.PointStruct( id=_point_id(f"{doc_id}:l1"), vector=vector, - payload={"doc_id": doc_id, "title": title, "category": category, "tags": tags, "text": summary}, + payload={ + "doc_id": doc_id, + "title": title, + "category": category, + "tags": tags, + "text": summary, + "metadata": metadata or {}, + }, ) await self._client.upsert(collection_name=COLLECTION_L1, points=[point]) + async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None: + """按 doc_id 查 L1 点,返回其 payload.metadata + + 文档不存在或 payload 无 metadata 字段时返回 None。 + """ + doc_filter = self.build_filter(doc_ids=[doc_id]) + records, _ = await self._client.scroll( + collection_name=COLLECTION_L1, + scroll_filter=doc_filter, + limit=1, + with_payload=True, + with_vectors=False, + ) + if not records: + return None + payload = records[0].payload or {} + meta = payload.get("metadata") + if not isinstance(meta, dict): + return None + return meta + async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None: """批量写入 L2/L3 大纲节点 @@ -252,7 +285,12 @@ class QdrantService: return items, str(next_offset) if next_offset is not None else None async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None: - """获取文档详情:L1 记录 + L2/L3 全部节点 + chunks 数量,文档不存在返回 None""" + """获取文档详情:L1 记录 + L2/L3 全部节点 + chunks 数量 + file 文件信息 + + file 字段从 L1 payload.metadata 提取(raw_file_path/original_filename/original_size_bytes): + 有 raw_file_path 时返回 {filename, size_bytes, url},否则 None。 + 文档不存在返回 None。 + """ doc_filter = self.build_filter(doc_ids=[doc_id]) l1_records, _ = await self._client.scroll( collection_name=COLLECTION_L1, @@ -271,11 +309,23 @@ class QdrantService: count_filter=doc_filter, exact=True, ) + + # 从 L1 metadata 提取原始文件信息,无 raw_file_path 时 file=None + meta = (l1_records[0].payload or {}).get("metadata") or {} + raw_path = meta.get("raw_file_path", "") + file_info: dict[str, Any] | None = None + if raw_path: + file_info = { + "filename": meta.get("original_filename", Path(raw_path).name), + "size_bytes": int(meta.get("original_size_bytes", "0") or "0"), + "url": f"/api/v1/documents/{doc_id}/file", + } return { "l1": l1_records[0].payload or {}, "l2_nodes": l2_nodes, "l3_nodes": l3_nodes, "chunks_count": chunks_count.count, + "file": file_info, } async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]: diff --git a/app/static/admin.html b/app/static/admin.html index a322244..3845048 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -398,6 +398,15 @@ function clearChildren(node) { while (node.firstChild) { node.removeChild(node.firstChild); } } +function formatFileSize(bytes) { + var n = Number(bytes); + if (!n || n <= 0) { return "0 B"; } + var units = ["B", "KB", "MB", "GB"]; + var i = 0; + while (n >= 1024 && i < units.length - 1) { n = n / 1024; i++; } + return (i === 0 ? n : n.toFixed(1)) + " " + units[i]; +} + function showError(boxId, err) { var box = document.getElementById(boxId); clearChildren(box); @@ -768,6 +777,20 @@ function renderDocDetail(data) { meta3.appendChild(el("span", data.chunks_count)); panel.appendChild(meta3); + /* 原始文件:有关联文件时展示文件名 + 大小 + 下载链接(textContent 防 XSS) */ + if (data.file) { + var fileRow = el("div", null, "kv"); + fileRow.appendChild(el("span", "原始文件", "k")); + fileRow.appendChild( + el("span", (data.file.filename || "") + "(" + formatFileSize(data.file.size_bytes) + ")") + ); + var dlLink = el("a", "下载"); + dlLink.href = location.origin + (data.file.url || ""); + dlLink.setAttribute("download", data.file.filename || ""); + fileRow.appendChild(dlLink); + panel.appendChild(fileRow); + } + panel.appendChild(el("h3", "L1 全文")); panel.appendChild(el("pre", l1.text || ""));