feat: add document file download and admin page file display
1. 新增将文档元数据存入L1向量库的功能 2. 新增文档文件下载API接口,支持路径安全校验 3. 后端文档详情接口新增文件信息返回字段 4. 管理后台页面新增原始文件信息展示与下载链接
This commit is contained in:
+28
-1
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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 || ""));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user