feat: add document file download and admin page file display

1. 新增将文档元数据存入L1向量库的功能
2. 新增文档文件下载API接口,支持路径安全校验
3. 后端文档详情接口新增文件信息返回字段
4. 管理后台页面新增原始文件信息展示与下载链接
This commit is contained in:
2026-07-31 22:46:13 +08:00
parent 92b062c048
commit e2e8e6829d
4 changed files with 105 additions and 4 deletions
+28 -1
View File
@@ -8,7 +8,7 @@ from typing import Any
import structlog import structlog
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile 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.deps import get_current_user
from app.api.response import ApiError, ok from app.api.response import ApiError, ok
@@ -219,6 +219,33 @@ async def get_document(doc_id: str) -> dict[str, Any]:
return ok(detail) 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}") @router.delete("/documents/{doc_id}")
async def delete_document( async def delete_document(
doc_id: str, user: UserRecord = Depends(get_current_user) doc_id: str, user: UserRecord = Depends(get_current_user)
+1
View File
@@ -262,6 +262,7 @@ class Ingester:
tags=category.tags, tags=category.tags,
dense_vector=l1_vector, dense_vector=l1_vector,
sparse_vector=l1_sparse, sparse_vector=l1_sparse,
metadata=doc.metadata,
) )
# L2/L3 大纲节点(为空时跳过对应集合的 upsert) # L2/L3 大纲节点(为空时跳过对应集合的 upsert)
+53 -3
View File
@@ -9,6 +9,7 @@
""" """
import uuid import uuid
from pathlib import Path
from typing import Any from typing import Any
import structlog import structlog
@@ -103,18 +104,50 @@ class QdrantService:
tags: list[str], tags: list[str],
dense_vector: list[float], dense_vector: list[float],
sparse_vector: SparseVectorTuple | None = None, sparse_vector: SparseVectorTuple | None = None,
metadata: dict[str, str] | None = 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} vector: dict[str, Any] = {VECTOR_DENSE: dense_vector}
if sparse_vector is not None: if sparse_vector is not None:
vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse_vector[0], values=sparse_vector[1]) vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse_vector[0], values=sparse_vector[1])
point = models.PointStruct( point = models.PointStruct(
id=_point_id(f"{doc_id}:l1"), id=_point_id(f"{doc_id}:l1"),
vector=vector, 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]) 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: async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None:
"""批量写入 L2/L3 大纲节点 """批量写入 L2/L3 大纲节点
@@ -252,7 +285,12 @@ class QdrantService:
return items, str(next_offset) if next_offset is not None else None 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: 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]) doc_filter = self.build_filter(doc_ids=[doc_id])
l1_records, _ = await self._client.scroll( l1_records, _ = await self._client.scroll(
collection_name=COLLECTION_L1, collection_name=COLLECTION_L1,
@@ -271,11 +309,23 @@ class QdrantService:
count_filter=doc_filter, count_filter=doc_filter,
exact=True, 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 { return {
"l1": l1_records[0].payload or {}, "l1": l1_records[0].payload or {},
"l2_nodes": l2_nodes, "l2_nodes": l2_nodes,
"l3_nodes": l3_nodes, "l3_nodes": l3_nodes,
"chunks_count": chunks_count.count, "chunks_count": chunks_count.count,
"file": file_info,
} }
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]: async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
+23
View File
@@ -398,6 +398,15 @@ function clearChildren(node) {
while (node.firstChild) { node.removeChild(node.firstChild); } 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) { function showError(boxId, err) {
var box = document.getElementById(boxId); var box = document.getElementById(boxId);
clearChildren(box); clearChildren(box);
@@ -768,6 +777,20 @@ function renderDocDetail(data) {
meta3.appendChild(el("span", data.chunks_count)); meta3.appendChild(el("span", data.chunks_count));
panel.appendChild(meta3); 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("h3", "L1 全文"));
panel.appendChild(el("pre", l1.text || "")); panel.appendChild(el("pre", l1.text || ""));