From 205e18563dfdc2f62c70625390c91973a837a9d9 Mon Sep 17 00:00:00 2001 From: houhuan Date: Tue, 5 May 2026 11:32:04 +0800 Subject: [PATCH] feat: record file operations to metadata table + add history/stats endpoints Co-Authored-By: Claude Opus 4.7 --- web/backend/routers/files.py | 204 +++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 web/backend/routers/files.py diff --git a/web/backend/routers/files.py b/web/backend/routers/files.py new file mode 100644 index 0000000..1ebe32b --- /dev/null +++ b/web/backend/routers/files.py @@ -0,0 +1,204 @@ +"""File upload, download, and listing endpoints.""" + +import logging +import os +import shutil +from pathlib import Path +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Request +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from ..auth.dependencies import get_current_user, get_current_user_flexible +from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS +from ..services.db_schema import insert_file_metadata, query_file_history, query_file_stats + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/files", tags=["files"]) + +# Resolve data directories relative to project root +_project_root = Path(__file__).resolve().parent.parent.parent.parent +_input_dir = _project_root / "data" / "input" +_output_dir = _project_root / "data" / "output" +_result_dir = _project_root / "data" / "result" + + +class FileItem(BaseModel): + name: str + size: int + modified: float + directory: str + + +class UploadResponse(BaseModel): + filename: str + size: int + path: str + + +def _ensure_dirs(): + for d in [_input_dir, _output_dir, _result_dir]: + d.mkdir(parents=True, exist_ok=True) + + +def _record_file_action(filename: str, directory: str, size: int, action: str, user: str = None): + """Record a file operation to the metadata table. Best-effort, non-blocking.""" + try: + insert_file_metadata(filename=filename, directory=directory, size=size, action=action, user=user) + except Exception: + logger.debug("Failed to record file metadata for %s/%s action=%s", directory, filename, action, exc_info=True) + + +@router.post("/upload", response_model=UploadResponse) +async def upload_file( + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), +): + _ensure_dirs() + + # Validate extension + ext = Path(file.filename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException(400, f"不支持的文件类型: {ext}") + + # Validate size + content = await file.read() + if len(content) > MAX_UPLOAD_SIZE: + raise HTTPException(400, f"文件过大,最大 {MAX_UPLOAD_SIZE // 1024 // 1024}MB") + + # Save with secure name + from werkzeug.utils import secure_filename + safe_name = secure_filename(file.filename) or file.filename + dest = _input_dir / safe_name + + # Avoid overwrite: add suffix if exists + counter = 0 + stem = Path(safe_name).stem + suffix = Path(safe_name).suffix + while dest.exists(): + counter += 1 + dest = _input_dir / f"{stem}_{counter}{suffix}" + + dest.write_bytes(content) + _record_file_action(dest.name, "input", len(content), "upload", current_user.get("username")) + + return UploadResponse( + filename=dest.name, + size=len(content), + path=str(dest.relative_to(_project_root)), + ) + + +@router.get("/list") +async def list_files( + directory: str = "input", + current_user: dict = Depends(get_current_user), +) -> List[FileItem]: + dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir} + target_dir = dir_map.get(directory) + if not target_dir or not target_dir.is_dir(): + return [] + + files = [] + for f in sorted(target_dir.iterdir()): + if f.is_file(): + stat = f.stat() + files.append(FileItem( + name=f.name, + size=stat.st_size, + modified=stat.st_mtime, + directory=directory, + )) + return files + + +@router.get("/download/{directory}/{filename}") +async def download_file( + directory: str, + filename: str, + current_user: dict = Depends(get_current_user_flexible), +): + dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir} + target_dir = dir_map.get(directory) + if not target_dir: + raise HTTPException(404, "目录不存在") + + file_path = target_dir / filename + if not file_path.is_file(): + raise HTTPException(404, "文件不存在") + + return FileResponse( + str(file_path), + filename=filename, + media_type="application/octet-stream", + ) + + +@router.delete("/{directory}/{filename}") +async def delete_file( + directory: str, + filename: str, + current_user: dict = Depends(get_current_user), +): + dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir} + target_dir = dir_map.get(directory) + if not target_dir: + raise HTTPException(404, "目录不存在") + + file_path = target_dir / filename + if not file_path.is_file(): + raise HTTPException(404, "文件不存在") + + size = file_path.stat().st_size + file_path.unlink() + _record_file_action(filename, directory, size, "delete", current_user.get("username")) + return {"message": f"已删除 {filename}"} + + +@router.post("/clear/{directory}") +async def clear_directory( + directory: str, + current_user: dict = Depends(get_current_user), +): + dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir} + target_dir = dir_map.get(directory) + if not target_dir: + raise HTTPException(404, "目录不存在") + + count = 0 + for f in target_dir.iterdir(): + if f.is_file(): + f.unlink() + count += 1 + + _record_file_action("*", directory, 0, "clear", current_user.get("username")) + return {"message": f"已清除 {count} 个文件", "count": count} + + +@router.get("/history") +async def get_file_history( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + directory: Optional[str] = None, + action: Optional[str] = None, + current_user: dict = Depends(get_current_user), +): + """Query file operation history with pagination and optional filters.""" + offset = (page - 1) * page_size + rows = query_file_history( + directory=directory, + action=action, + limit=page_size, + offset=offset, + ) + return {"page": page, "page_size": page_size, "items": rows} + + +@router.get("/stats") +async def get_file_stats( + current_user: dict = Depends(get_current_user), +): + """Return file storage statistics per directory.""" + return {"directories": query_file_stats()}