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