feat: 添加历史文件浏览下载
This commit is contained in:
+129
-2
@@ -2,6 +2,7 @@ import zipfile
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -25,11 +26,11 @@ def _safe_filename_part(value: str) -> str:
|
||||
return safe.strip("_") or "history"
|
||||
|
||||
|
||||
def _get_safe_work_dir(record_id: str) -> tuple[Path, str]:
|
||||
def _get_safe_work_dir(record_id: str, *, require_finished: bool = True) -> tuple[Path, str]:
|
||||
record = state.history_manager.get(record_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
if record.status in {"pending", "processing"}:
|
||||
if require_finished and record.status in {"pending", "processing"}:
|
||||
raise HTTPException(status_code=409, detail="任务尚未完成,暂不能下载历史数据")
|
||||
|
||||
work_dir = Path(record.work_dir).resolve()
|
||||
@@ -45,6 +46,33 @@ def _get_safe_work_dir(record_id: str) -> tuple[Path, str]:
|
||||
return work_dir, record.id
|
||||
|
||||
|
||||
def _get_safe_history_path(record_id: str, relative_path: str | None, *, require_finished: bool = False) -> tuple[Path, Path, str]:
|
||||
work_dir, safe_record_id = _get_safe_work_dir(record_id, require_finished=require_finished)
|
||||
clean_path = _normalize_relative_path(relative_path)
|
||||
target = work_dir.joinpath(*clean_path.split("/")).resolve() if clean_path else work_dir
|
||||
|
||||
try:
|
||||
target.relative_to(work_dir)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="历史文件路径不合法") from exc
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="历史文件不存在")
|
||||
|
||||
return work_dir, target, safe_record_id
|
||||
|
||||
|
||||
def _normalize_relative_path(value: str | None) -> str:
|
||||
normalized = (value or "").replace("\\", "/").strip("/")
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
parts = [part for part in normalized.split("/") if part and part != "."]
|
||||
if any(part == ".." for part in parts):
|
||||
raise HTTPException(status_code=400, detail="历史文件路径不合法")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _zip_directory(source_dir: Path, archive_path: Path) -> None:
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
for item in source_dir.rglob("*"):
|
||||
@@ -55,6 +83,42 @@ def _zip_directory(source_dir: Path, archive_path: Path) -> None:
|
||||
archive.write(item, arcname)
|
||||
|
||||
|
||||
def _zip_history_item(source_path: Path, archive_path: Path) -> None:
|
||||
root_name = source_path.name or "history"
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
if source_path.is_file():
|
||||
archive.write(source_path, root_name)
|
||||
return
|
||||
|
||||
has_content = False
|
||||
for item in source_path.rglob("*"):
|
||||
has_content = True
|
||||
arcname = Path(root_name, item.relative_to(source_path)).as_posix()
|
||||
if item.is_dir():
|
||||
archive.writestr(f"{arcname}/", "")
|
||||
elif item.is_file():
|
||||
archive.write(item, arcname)
|
||||
|
||||
if not has_content:
|
||||
archive.writestr(f"{root_name}/", "")
|
||||
|
||||
|
||||
def _history_entry(item: Path, work_dir: Path) -> dict[str, Any]:
|
||||
stat = item.stat()
|
||||
is_dir = item.is_dir()
|
||||
size = get_dir_size(item) if is_dir else stat.st_size
|
||||
modified = datetime.fromtimestamp(stat.st_mtime)
|
||||
return {
|
||||
"name": item.name,
|
||||
"path": item.relative_to(work_dir).as_posix(),
|
||||
"type": "dir" if is_dir else "file",
|
||||
"size": size,
|
||||
"size_formatted": format_size(size),
|
||||
"modified": modified.isoformat(),
|
||||
"modified_formatted": modified.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/history")
|
||||
async def get_history(limit: int = Body(50, embed=True)):
|
||||
return {"records": state.history_manager.list(limit)}
|
||||
@@ -98,6 +162,33 @@ async def get_history_size(record_id: str = Body(..., embed=True)):
|
||||
return {"success": True, "size": size, "size_formatted": format_size(size)}
|
||||
|
||||
|
||||
@router.post("/api/history/files")
|
||||
async def list_history_files(
|
||||
record_id: str = Body(..., embed=True),
|
||||
path: str | None = Body("", embed=True),
|
||||
):
|
||||
work_dir, current_path, _safe_record_id = _get_safe_history_path(record_id, path)
|
||||
if not current_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="请选择目录")
|
||||
|
||||
current_relative = current_path.relative_to(work_dir).as_posix()
|
||||
current_relative = "" if current_relative == "." else current_relative
|
||||
parent_relative = None
|
||||
if current_path != work_dir:
|
||||
parent_relative = current_path.parent.relative_to(work_dir).as_posix()
|
||||
parent_relative = "" if parent_relative == "." else parent_relative
|
||||
|
||||
entries = [_history_entry(item, work_dir) for item in current_path.iterdir()]
|
||||
entries.sort(key=lambda item: (item["type"] != "dir", item["name"].lower()))
|
||||
return {
|
||||
"success": True,
|
||||
"record_id": record_id,
|
||||
"path": current_relative,
|
||||
"parent_path": parent_relative,
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/history/download")
|
||||
async def download_history(record_id: str = Body(..., embed=True)):
|
||||
work_dir, safe_record_id = _get_safe_work_dir(record_id)
|
||||
@@ -120,3 +211,39 @@ async def download_history(record_id: str = Body(..., embed=True)):
|
||||
media_type="application/zip",
|
||||
background=BackgroundTask(_remove_file, archive_path),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/history/file/download")
|
||||
async def download_history_file(
|
||||
record_id: str = Body(..., embed=True),
|
||||
path: str = Body(..., embed=True),
|
||||
):
|
||||
_work_dir, target_path, safe_record_id = _get_safe_history_path(record_id, path, require_finished=True)
|
||||
if target_path.is_file():
|
||||
return FileResponse(
|
||||
path=str(target_path),
|
||||
filename=target_path.name,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
if not target_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="历史文件类型不支持下载")
|
||||
|
||||
export_dir = CACHE_DIR / ".downloads"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{_safe_filename_part(safe_record_id)}_{_safe_filename_part(target_path.name)}_{timestamp}.zip"
|
||||
archive_path = export_dir / filename
|
||||
|
||||
try:
|
||||
_zip_history_item(target_path, archive_path)
|
||||
except Exception:
|
||||
_remove_file(archive_path)
|
||||
raise
|
||||
|
||||
return FileResponse(
|
||||
path=str(archive_path),
|
||||
filename=filename,
|
||||
media_type="application/zip",
|
||||
background=BackgroundTask(_remove_file, archive_path),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user