feat: record file operations to metadata table + add history/stats endpoints
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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()}
|
||||||
Reference in New Issue
Block a user