refactor(files): harden storage and split frontend

Add transactional file storage workflows, typed API contracts, recursive category handling, frontend component separation, and Files API coverage.
This commit is contained in:
sinohqb 2026-07-17 17:41:19 +08:00
parent 867d4e3ff1
commit d7514f4e65
19 changed files with 1804 additions and 818 deletions

View File

@ -0,0 +1 @@
"""Application services."""

View File

@ -0,0 +1,191 @@
"""Application service for file management workflows."""
import mimetypes
import uuid
from pathlib import Path
from fastapi import UploadFile
from sqlmodel import Session
from agenteval.storage.db import FileCategoryDB, FileRecordDB
from agenteval.storage.file_repository import FileCategoryNode, FileCategoryRepository, FileRecordRepository
from agenteval.storage.file_storage import (
FileStorage,
FileTooLargeError,
InvalidStorageNameError,
StagedDeletion,
)
class FileManagementError(Exception):
"""Base class for expected file-management errors."""
class FileValidationError(FileManagementError):
"""Raised when user input is invalid."""
class CategoryNotFoundError(FileManagementError):
"""Raised when a requested category does not exist."""
class FileRecordNotFoundError(FileManagementError):
"""Raised when a requested file record does not exist."""
class PhysicalFileNotFoundError(FileManagementError):
"""Raised when metadata exists but its physical file is missing."""
class UploadTooLargeError(FileManagementError):
"""Raised when an upload exceeds its configured size limit."""
class FileManagementService:
"""Coordinate database transactions and rollback-friendly disk operations."""
def __init__(
self,
session: Session,
storage: FileStorage,
allowed_extensions: set[str],
max_upload_size_mb: int,
):
self.session = session
self.storage = storage
self.allowed_extensions = {extension.lower() for extension in allowed_extensions}
self.max_upload_size_mb = max_upload_size_mb
self.categories = FileCategoryRepository(session)
self.files = FileRecordRepository(session)
@property
def max_upload_bytes(self) -> int:
return self.max_upload_size_mb * 1024 * 1024
def list_categories(self) -> list[FileCategoryNode]:
return self.categories.get_tree()
def create_category(self, name: str, parent_id: str | None = None) -> FileCategoryDB:
normalized_name = name.strip()
if not normalized_name:
raise FileValidationError("分类名称不能为空")
if parent_id and not self.categories.get(parent_id):
raise FileValidationError("父分类不存在")
try:
category = self.categories.create(normalized_name, parent_id)
self.session.commit()
return category
except Exception:
self.session.rollback()
raise
def update_category(self, category_id: str, name: str) -> FileCategoryDB:
normalized_name = name.strip()
if not normalized_name:
raise FileValidationError("分类名称不能为空")
try:
category = self.categories.update(category_id, normalized_name)
if not category:
raise CategoryNotFoundError
self.session.commit()
return category
except Exception:
self.session.rollback()
raise
def delete_category(self, category_id: str) -> None:
if not self.categories.get(category_id):
raise CategoryNotFoundError
category_ids = self.categories.get_subtree_ids(category_id)
records = self.files.list_by_category_ids(category_ids)
staged = self._stage_records(records)
try:
self.categories.delete(category_id)
self.session.commit()
except Exception:
self.session.rollback()
self.storage.restore_deletions(staged)
raise
self.storage.purge_deletions(staged)
def list_files(self, category_id: str | None = None) -> list[FileRecordDB]:
if category_id and not self.categories.get(category_id):
raise CategoryNotFoundError
return self.files.list_all(category_id)
async def upload(self, upload: UploadFile, category_id: str | None = None) -> FileRecordDB:
filename = upload.filename or ""
if not filename:
raise FileValidationError("文件名为空")
if category_id and not self.categories.get(category_id):
raise FileValidationError("分类不存在")
extension = Path(filename).suffix.lstrip(".").lower()
if not extension:
raise FileValidationError("文件没有扩展名")
if extension not in self.allowed_extensions:
allowed = ", ".join(sorted(self.allowed_extensions))
raise FileValidationError(f"不支持的文件类型 .{extension},允许的类型: {allowed}")
storage_name = f"{uuid.uuid4()}.{extension}"
try:
pending = await self.storage.write_upload(upload, storage_name, self.max_upload_bytes)
except FileTooLargeError as error:
raise UploadTooLargeError from error
final_path = None
try:
mime_type, _ = mimetypes.guess_type(filename)
record = self.files.create(
original_name=filename,
storage_name=storage_name,
file_size=pending.file_size,
mime_type=mime_type or "application/octet-stream",
file_ext=extension,
category_id=category_id,
)
final_path = self.storage.promote(pending)
self.session.commit()
return record
except Exception:
self.session.rollback()
self.storage.discard(pending.temp_path)
if final_path:
self.storage.discard(final_path)
raise
def get_download(self, file_id: str) -> tuple[FileRecordDB, Path]:
record = self.files.get(file_id)
if not record:
raise FileRecordNotFoundError
try:
file_path = self.storage.path_for(record.storage_name)
except InvalidStorageNameError as error:
raise PhysicalFileNotFoundError from error
if not file_path.is_file():
raise PhysicalFileNotFoundError
return record, file_path
def delete_file(self, file_id: str) -> None:
record = self.files.get(file_id)
if not record:
raise FileRecordNotFoundError
staged = self._stage_records([record])
try:
self.files.delete(file_id)
self.session.commit()
except Exception:
self.session.rollback()
self.storage.restore_deletions(staged)
raise
self.storage.purge_deletions(staged)
def _stage_records(self, records: list[FileRecordDB]) -> list[StagedDeletion]:
try:
return self.storage.stage_deletions([record.storage_name for record in records])
except InvalidStorageNameError as error:
raise PhysicalFileNotFoundError from error

View File

@ -1,22 +1,25 @@
"""Repository layer for file management (categories + records)."""
"""Database repositories for file categories and uploaded file records."""
import os
from dataclasses import dataclass, field
from typing import Optional
from sqlmodel import Session, select
from sqlmodel import Session, col, select
from agenteval.storage.db import (
FILES_DIR,
FileCategoryDB,
FileRecordDB,
get_session,
new_uuid,
utc_now,
)
from agenteval.storage.db import FileCategoryDB, FileRecordDB, get_session, new_uuid, utc_now
@dataclass
class FileCategoryNode:
"""Framework-independent category tree node."""
id: str
name: str
parent_id: Optional[str]
children: list["FileCategoryNode"] = field(default_factory=list)
class FileCategoryRepository:
"""Repository for file categories (tree structure)."""
"""Persistence operations for the file category tree."""
def __init__(self, session: Optional[Session] = None):
self.session = session or get_session()
@ -28,87 +31,78 @@ class FileCategoryRepository:
def get(self, category_id: str) -> Optional[FileCategoryDB]:
return self.session.get(FileCategoryDB, category_id)
def get_tree(self) -> list[dict]:
"""Return categories as a nested tree structure for frontend Tree component."""
all_cats = self.list_all()
cat_map: dict[str, dict] = {}
roots: list[dict] = []
def get_tree(self) -> list[FileCategoryNode]:
"""Build the complete category tree with one database query."""
categories = self.list_all()
node_map = {
category.id: FileCategoryNode(
id=category.id,
name=category.name,
parent_id=category.parent_id,
)
for category in categories
}
roots: list[FileCategoryNode] = []
for cat in all_cats:
node = {
"key": cat.id,
"title": cat.name,
"parent_id": cat.parent_id,
"children": [],
}
cat_map[cat.id] = node
for cat in all_cats:
node = cat_map[cat.id]
if cat.parent_id and cat.parent_id in cat_map:
cat_map[cat.parent_id]["children"].append(node)
for category in categories:
node = node_map[category.id]
parent = node_map.get(category.parent_id) if category.parent_id else None
if parent:
parent.children.append(node)
else:
roots.append(node)
return roots
def get_subtree_ids(self, category_id: str) -> list[str]:
"""Return the category and all descendant IDs with one database query."""
categories = self.list_all()
children_by_parent: dict[str, list[str]] = {}
for category in categories:
if category.parent_id:
children_by_parent.setdefault(category.parent_id, []).append(category.id)
result: list[str] = []
pending = [category_id]
while pending:
current = pending.pop()
result.append(current)
pending.extend(children_by_parent.get(current, []))
return result
def create(self, name: str, parent_id: Optional[str] = None) -> FileCategoryDB:
db = FileCategoryDB(
category = FileCategoryDB(
id=new_uuid(),
name=name,
parent_id=parent_id,
created_at=utc_now(),
updated_at=utc_now(),
)
self.session.add(db)
self.session.commit()
self.session.refresh(db)
return db
self.session.add(category)
self.session.flush()
return category
def update(self, category_id: str, name: str) -> Optional[FileCategoryDB]:
existing = self.session.get(FileCategoryDB, category_id)
if not existing:
category = self.session.get(FileCategoryDB, category_id)
if not category:
return None
existing.name = name
existing.updated_at = utc_now()
self.session.add(existing)
self.session.commit()
self.session.refresh(existing)
return existing
category.name = name
category.updated_at = utc_now()
self.session.add(category)
self.session.flush()
return category
def delete(self, category_id: str) -> bool:
"""Delete a category and cascade-delete children + files.
Physical files are cleaned up via FileRecordRepository.delete().
We must explicitly delete files first to trigger physical cleanup,
because the DB cascade only removes rows.
"""
existing = self.session.get(FileCategoryDB, category_id)
if not existing:
category = self.session.get(FileCategoryDB, category_id)
if not category:
return False
# Collect all file IDs to clean up physical files
file_ids = self._collect_file_ids(existing)
# Delete physical files
file_repo = FileRecordRepository(self.session)
for fid in file_ids:
file_repo._remove_physical_file(fid)
self.session.delete(existing)
self.session.commit()
self.session.delete(category)
self.session.flush()
return True
def _collect_file_ids(self, category: FileCategoryDB) -> list[str]:
"""Recursively collect all file IDs under a category and its children."""
file_ids = [f.id for f in category.files]
for child in category.children:
file_ids.extend(self._collect_file_ids(child))
return file_ids
class FileRecordRepository:
"""Repository for uploaded file records."""
"""Persistence operations for uploaded file metadata."""
def __init__(self, session: Optional[Session] = None):
self.session = session or get_session()
@ -116,29 +110,16 @@ class FileRecordRepository:
def list_all(self, category_id: Optional[str] = None) -> list[FileRecordDB]:
statement = select(FileRecordDB)
if category_id:
# Also include files in subcategories
cat_repo = FileCategoryRepository(self.session)
cat_ids = self._get_descendant_ids(category_id, cat_repo)
cat_ids.append(category_id)
from sqlmodel import col
statement = statement.where(col(FileRecordDB.category_id).in_(cat_ids))
else:
# Only filter when category_id is explicitly provided;
# None means "all files" (no filter).
pass
category_ids = FileCategoryRepository(self.session).get_subtree_ids(category_id)
statement = statement.where(col(FileRecordDB.category_id).in_(category_ids))
statement = statement.order_by(FileRecordDB.created_at.desc())
return list(self.session.exec(statement).all())
def _get_descendant_ids(self, parent_id: str, cat_repo: FileCategoryRepository) -> list[str]:
"""Recursively collect IDs of all descendant categories."""
ids: list[str] = []
all_cats = cat_repo.list_all()
children = [c for c in all_cats if c.parent_id == parent_id]
for child in children:
ids.append(child.id)
ids.extend(self._get_descendant_ids(child.id, cat_repo))
return ids
def list_by_category_ids(self, category_ids: list[str]) -> list[FileRecordDB]:
if not category_ids:
return []
statement = select(FileRecordDB).where(col(FileRecordDB.category_id).in_(category_ids))
return list(self.session.exec(statement).all())
def get(self, file_id: str) -> Optional[FileRecordDB]:
return self.session.get(FileRecordDB, file_id)
@ -152,7 +133,7 @@ class FileRecordRepository:
file_ext: str,
category_id: Optional[str] = None,
) -> FileRecordDB:
db = FileRecordDB(
record = FileRecordDB(
id=new_uuid(),
original_name=original_name,
storage_name=storage_name,
@ -162,25 +143,14 @@ class FileRecordRepository:
file_ext=file_ext,
created_at=utc_now(),
)
self.session.add(db)
self.session.commit()
self.session.refresh(db)
return db
self.session.add(record)
self.session.flush()
return record
def delete(self, file_id: str) -> bool:
record = self.session.get(FileRecordDB, file_id)
if not record:
return False
self._remove_physical_file(file_id)
self.session.delete(record)
self.session.commit()
self.session.flush()
return True
def _remove_physical_file(self, file_id: str) -> None:
"""Remove the physical file from disk if it exists."""
record = self.session.get(FileRecordDB, file_id)
if not record:
return
file_path = FILES_DIR / record.storage_name
if file_path.exists():
os.remove(file_path)

View File

@ -0,0 +1,94 @@
"""Physical storage operations for uploaded files."""
import os
import uuid
from dataclasses import dataclass
from pathlib import Path
from fastapi import UploadFile
UPLOAD_CHUNK_SIZE = 1024 * 1024
class FileTooLargeError(Exception):
"""Raised when a streamed upload exceeds its configured limit."""
class InvalidStorageNameError(Exception):
"""Raised when a storage name could escape the configured root."""
@dataclass(frozen=True)
class PendingUpload:
temp_path: Path
storage_name: str
file_size: int
@dataclass(frozen=True)
class StagedDeletion:
original_path: Path
staged_path: Path
class FileStorage:
"""Store files below one root and provide rollback-friendly operations."""
def __init__(self, root: Path):
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
def path_for(self, storage_name: str) -> Path:
if not storage_name or Path(storage_name).name != storage_name:
raise InvalidStorageNameError(storage_name)
return self.root / storage_name
async def write_upload(self, upload: UploadFile, storage_name: str, max_bytes: int) -> PendingUpload:
self.path_for(storage_name)
temp_path = self.root / f".upload-{uuid.uuid4()}.tmp"
file_size = 0
try:
with temp_path.open("xb") as output:
while chunk := await upload.read(UPLOAD_CHUNK_SIZE):
file_size += len(chunk)
if file_size > max_bytes:
raise FileTooLargeError
output.write(chunk)
except Exception:
temp_path.unlink(missing_ok=True)
raise
finally:
await upload.close()
return PendingUpload(temp_path=temp_path, storage_name=storage_name, file_size=file_size)
def promote(self, pending: PendingUpload) -> Path:
final_path = self.path_for(pending.storage_name)
os.replace(pending.temp_path, final_path)
return final_path
def discard(self, path: Path) -> None:
path.unlink(missing_ok=True)
def stage_deletions(self, storage_names: list[str]) -> list[StagedDeletion]:
staged: list[StagedDeletion] = []
try:
for storage_name in storage_names:
original_path = self.path_for(storage_name)
if not original_path.exists():
continue
staged_path = self.root / f".delete-{uuid.uuid4()}.tmp"
os.replace(original_path, staged_path)
staged.append(StagedDeletion(original_path=original_path, staged_path=staged_path))
except Exception:
self.restore_deletions(staged)
raise
return staged
def restore_deletions(self, staged: list[StagedDeletion]) -> None:
for item in reversed(staged):
if item.staged_path.exists():
os.replace(item.staged_path, item.original_path)
def purge_deletions(self, staged: list[StagedDeletion]) -> None:
for item in staged:
item.staged_path.unlink(missing_ok=True)

View File

@ -0,0 +1,69 @@
"""HTTP schemas for the file management API."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_serializer
from agenteval.storage.db import FileCategoryDB, FileRecordDB, iso_utc
class CategoryCreate(BaseModel):
name: str
parent_id: str | None = None
class CategoryUpdate(BaseModel):
name: str
class FileCategoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
parent_id: str | None
children: list["FileCategoryResponse"] = Field(default_factory=list)
@classmethod
def from_record(cls, category: FileCategoryDB) -> "FileCategoryResponse":
return cls(
id=category.id or "",
name=category.name,
parent_id=category.parent_id,
children=[],
)
class FileRecordResponse(BaseModel):
id: str
original_name: str
file_size: int
mime_type: str
file_ext: str
category_id: str | None
created_at: datetime | None
@field_serializer("created_at")
def serialize_created_at(self, value: datetime | None) -> str | None:
return iso_utc(value)
@classmethod
def from_record(cls, record: FileRecordDB) -> "FileRecordResponse":
return cls(
id=record.id or "",
original_name=record.original_name,
file_size=record.file_size,
mime_type=record.mime_type,
file_ext=record.file_ext,
category_id=record.category_id,
created_at=record.created_at,
)
class FileUploadConfigResponse(BaseModel):
allowed_extensions: list[str]
max_upload_size_mb: int
class OkResponse(BaseModel):
ok: bool = True

View File

@ -1,203 +1,148 @@
"""API routes for file management (categories + file upload/download)."""
"""API routes for file management."""
import mimetypes
import uuid
from pathlib import Path
from typing import NoReturn
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlmodel import Session
from agenteval.config import get_settings
from agenteval.storage.db import FILES_DIR, iso_utc
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
from agenteval.services.files import (
CategoryNotFoundError,
FileManagementError,
FileManagementService,
FileRecordNotFoundError,
FileValidationError,
PhysicalFileNotFoundError,
UploadTooLargeError,
)
from agenteval.storage.db import FILES_DIR
from agenteval.storage.file_storage import FileStorage
from agenteval.web.deps import get_db
from agenteval.web.file_schemas import (
CategoryCreate,
CategoryUpdate,
FileCategoryResponse,
FileRecordResponse,
FileUploadConfigResponse,
OkResponse,
)
router = APIRouter()
# ── helpers ────────────────────────────────────────────────────────────
def _get_allowed_extensions() -> set[str]:
"""Return the set of allowed lowercase extensions from settings."""
def _allowed_extensions() -> set[str]:
raw = get_settings().allowed_extensions
return {ext.strip().lower() for ext in raw.split(",") if ext.strip()}
return {extension.strip().lower() for extension in raw.split(",") if extension.strip()}
def _get_max_bytes() -> int:
return get_settings().max_upload_size_mb * 1024 * 1024
def _validate_extension(filename: str) -> str:
"""Validate the file extension and return the lowercase extension."""
ext = Path(filename).suffix.lstrip(".").lower()
if not ext:
raise HTTPException(status_code=400, detail="文件没有扩展名")
allowed = _get_allowed_extensions()
if ext not in allowed:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型 .{ext},允许的类型: {', '.join(sorted(allowed))}",
)
return ext
# ── API models ─────────────────────────────────────────────────────────
class CategoryCreate(BaseModel):
name: str
parent_id: str | None = None
class CategoryUpdate(BaseModel):
name: str
# ── Category endpoints ─────────────────────────────────────────────────
@router.get("/categories")
def list_categories(session: Session = Depends(get_db)) -> list[dict]:
"""List categories as a nested tree."""
return FileCategoryRepository(session).get_tree()
@router.post("/categories")
def create_category(body: CategoryCreate, session: Session = Depends(get_db)) -> dict:
if not body.name.strip():
raise HTTPException(status_code=400, detail="分类名称不能为空")
cat = FileCategoryRepository(session).create(
name=body.name.strip(),
parent_id=body.parent_id,
def get_file_service(session: Session = Depends(get_db)) -> FileManagementService:
settings = get_settings()
return FileManagementService(
session=session,
storage=FileStorage(FILES_DIR),
allowed_extensions=_allowed_extensions(),
max_upload_size_mb=settings.max_upload_size_mb,
)
return {
"key": cat.id,
"title": cat.name,
"parent_id": cat.parent_id,
"children": [],
}
@router.put("/categories/{category_id}")
def update_category(category_id: str, body: CategoryUpdate, session: Session = Depends(get_db)) -> dict:
if not body.name.strip():
raise HTTPException(status_code=400, detail="分类名称不能为空")
cat = FileCategoryRepository(session).update(category_id, body.name.strip())
if not cat:
raise HTTPException(status_code=404, detail="分类不存在")
return {"ok": True, "name": cat.name}
@router.delete("/categories/{category_id}")
def delete_category(category_id: str, session: Session = Depends(get_db)) -> dict:
if not FileCategoryRepository(session).delete(category_id):
raise HTTPException(status_code=404, detail="分类不存在")
return {"ok": True}
# ── File endpoints ─────────────────────────────────────────────────────
@router.get("")
def list_files(category_id: str | None = None, session: Session = Depends(get_db)) -> list[dict]:
"""List file records, optionally filtered by category."""
records = FileRecordRepository(session).list_all(category_id=category_id)
return [
{
"id": r.id,
"original_name": r.original_name,
"file_size": r.file_size,
"mime_type": r.mime_type,
"file_ext": r.file_ext,
"category_id": r.category_id,
"created_at": iso_utc(r.created_at),
}
for r in records
]
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
category_id: str | None = Form(default=None),
session: Session = Depends(get_db),
) -> dict:
"""Upload a single file."""
if not file.filename:
raise HTTPException(status_code=400, detail="文件名为空")
ext = _validate_extension(file.filename)
# Read content and validate size
content = await file.read()
max_bytes = _get_max_bytes()
if len(content) > max_bytes:
def _raise_http_error(error: FileManagementError) -> NoReturn:
if isinstance(error, UploadTooLargeError):
raise HTTPException(
status_code=413,
detail=f"文件大小超过限制 ({get_settings().max_upload_size_mb}MB)",
)
) from error
if isinstance(error, FileValidationError):
raise HTTPException(status_code=400, detail=str(error)) from error
if isinstance(error, CategoryNotFoundError):
raise HTTPException(status_code=404, detail="分类不存在") from error
if isinstance(error, FileRecordNotFoundError):
raise HTTPException(status_code=404, detail="文件不存在") from error
if isinstance(error, PhysicalFileNotFoundError):
raise HTTPException(status_code=404, detail="物理文件不存在") from error
raise HTTPException(status_code=500, detail="文件操作失败") from error
# Generate storage name
storage_name = f"{uuid.uuid4()}.{ext}"
# Guess MIME type
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
# Validate category exists if provided
if category_id:
cat = FileCategoryRepository(session).get(category_id)
if not cat:
raise HTTPException(status_code=400, detail="分类不存在")
# Write physical file
file_path = FILES_DIR / storage_name
file_path.write_bytes(content)
# Create DB record
record = FileRecordRepository(session).create(
original_name=file.filename,
storage_name=storage_name,
file_size=len(content),
mime_type=mime_type,
file_ext=ext,
category_id=category_id if category_id else None,
@router.get("/config", response_model=FileUploadConfigResponse)
def get_upload_config() -> FileUploadConfigResponse:
return FileUploadConfigResponse(
allowed_extensions=sorted(_allowed_extensions()),
max_upload_size_mb=get_settings().max_upload_size_mb,
)
return {
"id": record.id,
"original_name": record.original_name,
"file_size": record.file_size,
"mime_type": record.mime_type,
"file_ext": record.file_ext,
"category_id": record.category_id,
"created_at": iso_utc(record.created_at),
}
@router.get("/categories", response_model=list[FileCategoryResponse])
def list_categories(service: FileManagementService = Depends(get_file_service)):
return service.list_categories()
@router.post("/categories", response_model=FileCategoryResponse)
def create_category(body: CategoryCreate, service: FileManagementService = Depends(get_file_service)):
try:
return FileCategoryResponse.from_record(service.create_category(body.name, body.parent_id))
except FileManagementError as error:
_raise_http_error(error)
@router.put("/categories/{category_id}", response_model=FileCategoryResponse)
def update_category(
category_id: str,
body: CategoryUpdate,
service: FileManagementService = Depends(get_file_service),
):
try:
return FileCategoryResponse.from_record(service.update_category(category_id, body.name))
except FileManagementError as error:
_raise_http_error(error)
@router.delete("/categories/{category_id}", response_model=OkResponse)
def delete_category(category_id: str, service: FileManagementService = Depends(get_file_service)):
try:
service.delete_category(category_id)
return OkResponse()
except FileManagementError as error:
_raise_http_error(error)
@router.get("", response_model=list[FileRecordResponse])
def list_files(category_id: str | None = None, service: FileManagementService = Depends(get_file_service)):
try:
return [FileRecordResponse.from_record(record) for record in service.list_files(category_id)]
except FileManagementError as error:
_raise_http_error(error)
@router.post("/upload", response_model=FileRecordResponse)
async def upload_file(
file: UploadFile = File(...),
category_id: str | None = Form(default=None),
service: FileManagementService = Depends(get_file_service),
):
try:
record = await service.upload(file, category_id)
return FileRecordResponse.from_record(record)
except FileManagementError as error:
_raise_http_error(error)
@router.get("/{file_id}/download")
def download_file(file_id: str, session: Session = Depends(get_db)):
"""Download a file by its record ID."""
record = FileRecordRepository(session).get(file_id)
if not record:
raise HTTPException(status_code=404, detail="文件不存在")
file_path = FILES_DIR / record.storage_name
if not file_path.exists():
raise HTTPException(status_code=404, detail="物理文件不存在")
return FileResponse(
path=str(file_path),
filename=record.original_name,
media_type=record.mime_type or "application/octet-stream",
)
def download_file(file_id: str, service: FileManagementService = Depends(get_file_service)):
try:
record, file_path = service.get_download(file_id)
return FileResponse(
path=str(file_path),
filename=record.original_name,
media_type=record.mime_type or "application/octet-stream",
)
except FileManagementError as error:
_raise_http_error(error)
@router.delete("/{file_id}")
def delete_file(file_id: str, session: Session = Depends(get_db)) -> dict:
if not FileRecordRepository(session).delete(file_id):
raise HTTPException(status_code=404, detail="文件不存在")
return {"ok": True}
@router.delete("/{file_id}", response_model=OkResponse)
def delete_file(file_id: str, service: FileManagementService = Depends(get_file_service)):
try:
service.delete_file(file_id)
return OkResponse()
except FileManagementError as error:
_raise_http_error(error)

View File

@ -153,8 +153,8 @@ export const statsApi = {
// ── File Management ──────────────────────────────────────────────
export interface FileCategory {
key: string
title: string
id: string
name: string
parent_id: string | null
children: FileCategory[]
}
@ -169,8 +169,14 @@ export interface FileRecord {
created_at: string | null
}
export interface FileUploadConfig {
allowed_extensions: string[]
max_upload_size_mb: number
}
export const filesApi = {
// Categories
getConfig: () => api.get<FileUploadConfig>('/files/config'),
listCategories: () => api.get<FileCategory[]>('/files/categories'),
createCategory: (data: { name: string; parent_id?: string | null }) =>
api.post<FileCategory>('/files/categories', data),
@ -191,6 +197,6 @@ export const filesApi = {
timeout: 120000,
})
},
downloadUrl: (id: string) => `/api/files/${id}/download`,
download: (id: string) => api.get<Blob>(`/files/${id}/download`, { responseType: 'blob' }),
delete: (id: string) => api.delete(`/files/${id}`),
}

View File

@ -0,0 +1,127 @@
import {
DeleteOutlined,
EditOutlined,
FileOutlined,
FolderAddOutlined,
FolderOutlined,
PlusOutlined,
} from '@ant-design/icons'
import { Button, Popconfirm, Tooltip, Tree } from 'antd'
import type { DataNode } from 'antd/es/tree'
import type { FileCategory } from '../../api'
import { colors, fontSizes } from '../../tokens'
const ALL_FILES_KEY = '__all__'
interface FileCategoryTreeProps {
categories: FileCategory[]
selectedCategoryId: string | null
onSelect: (categoryId: string | null) => void
onCreate: (parentId?: string) => void
onRename: (category: FileCategory) => void
onDelete: (categoryId: string) => Promise<void>
}
export default function FileCategoryTree({
categories,
selectedCategoryId,
onSelect,
onCreate,
onRename,
onDelete,
}: FileCategoryTreeProps) {
const mapCategory = (category: FileCategory): DataNode => ({
key: category.id,
icon: <FolderOutlined style={{ color: '#d48806' }} />,
title: (
<div className="cat-tree-node">
<span className="cat-tree-label">{category.name}</span>
<span className="cat-actions">
<Tooltip title="新建子分类">
<Button
size="small"
type="text"
aria-label={`${category.name}下新建分类`}
icon={<PlusOutlined />}
onClick={(event) => {
event.stopPropagation()
onCreate(category.id)
}}
/>
</Tooltip>
<Tooltip title="重命名">
<Button
size="small"
type="text"
aria-label={`重命名${category.name}`}
icon={<EditOutlined />}
onClick={(event) => {
event.stopPropagation()
onRename(category)
}}
/>
</Tooltip>
<Popconfirm
title="删除此分类及其所有子分类和文件?"
onConfirm={() => onDelete(category.id)}
>
<Tooltip title="删除">
<Button
size="small"
type="text"
danger
aria-label={`删除${category.name}`}
icon={<DeleteOutlined />}
onClick={(event) => event.stopPropagation()}
/>
</Tooltip>
</Popconfirm>
</span>
</div>
),
children: category.children.map(mapCategory),
})
const treeData: DataNode[] = [{
key: ALL_FILES_KEY,
icon: <FileOutlined />,
title: (
<span style={{ fontWeight: 600, fontSize: fontSizes.emphasis }}>
<FolderOutlined style={{ marginRight: 6 }} />
</span>
),
children: categories.map(mapCategory),
}]
return (
<aside className="files-category-panel">
<div className="files-panel-header">
<span style={{ fontSize: fontSizes.emphasis, fontWeight: 600, color: colors.text }}>
</span>
<Tooltip title="新建根分类">
<Button
size="small"
type="text"
aria-label="新建根分类"
icon={<FolderAddOutlined />}
onClick={() => onCreate()}
/>
</Tooltip>
</div>
<div className="files-category-scroll">
<Tree
showIcon
defaultExpandAll
treeData={treeData}
selectedKeys={[selectedCategoryId ?? ALL_FILES_KEY]}
onSelect={(keys) => {
const key = keys[0]?.toString()
onSelect(!key || key === ALL_FILES_KEY ? null : key)
}}
/>
</div>
</aside>
)
}

View File

@ -0,0 +1,182 @@
import {
DeleteOutlined,
DownloadOutlined,
FileOutlined,
FolderOutlined,
InboxOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons'
import { Button, Popconfirm, Space, Table, Tag, Tooltip } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMemo } from 'react'
import type { FileRecord } from '../../api'
import { colors, fontSizes } from '../../tokens'
import { formatDateTime } from '../../utils/date'
import { extensionColor, formatFileSize } from '../../utils/fileFormat'
interface FileTableProps {
files: FileRecord[]
loading: boolean
categoryName: string
onRefresh: () => void
onOpenUpload: () => void
onDownload: (record: FileRecord) => Promise<void>
onDelete: (id: string) => Promise<void>
}
export default function FileTable({
files,
loading,
categoryName,
onRefresh,
onOpenUpload,
onDownload,
onDelete,
}: FileTableProps) {
const totalSize = useMemo(() => files.reduce((sum, file) => sum + file.file_size, 0), [files])
const extensionCounts = useMemo(() => files.reduce<Record<string, number>>((counts, file) => {
counts[file.file_ext] = (counts[file.file_ext] || 0) + 1
return counts
}, {}), [files])
const columns: ColumnsType<FileRecord> = [
{
title: '文件名',
dataIndex: 'original_name',
key: 'name',
ellipsis: true,
render: (name: string, record) => (
<Space size={8} style={{ maxWidth: '100%' }}>
<FileOutlined style={{ color: extensionColor(record.file_ext), fontSize: 16 }} />
<span className="files-name-cell">{name}</span>
</Space>
),
},
{
title: '大小',
dataIndex: 'file_size',
key: 'size',
width: 100,
responsive: ['sm'],
render: (size: number) => (
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
{formatFileSize(size)}
</span>
),
},
{
title: '类型',
dataIndex: 'file_ext',
key: 'type',
width: 90,
render: (extension: string) => (
<Tag color={extensionColor(extension)} style={{ margin: 0, fontWeight: 600, fontSize: 11 }}>
{extension.toUpperCase()}
</Tag>
),
},
{
title: '上传时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
responsive: ['md'],
render: (time: string | null) => (
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
{time ? formatDateTime(time) : '-'}
</span>
),
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
render: (_, record) => (
<Space size={0}>
<Tooltip title="下载">
<Button
size="small"
type="text"
aria-label={`下载${record.original_name}`}
icon={<DownloadOutlined />}
onClick={() => void onDownload(record)}
/>
</Tooltip>
<Popconfirm title="确认删除此文件?" onConfirm={() => onDelete(record.id)}>
<Tooltip title="删除">
<Button
size="small"
type="text"
danger
aria-label={`删除${record.original_name}`}
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
]
const topExtensions = Object.entries(extensionCounts)
.sort((left, right) => right[1] - left[1])
.slice(0, 5)
return (
<section className="files-list-panel">
<div className="files-list-header">
<div className="files-current-category">
<FolderOutlined style={{ color: colors.primary, fontSize: 18 }} />
<span>{categoryName}</span>
</div>
<span className="files-stat">{files.length} </span>
<span className="files-stat">{formatFileSize(totalSize)}</span>
<Space size={4} wrap className="files-extension-stats">
{topExtensions.map(([extension, count]) => (
<Tag key={extension} color={extensionColor(extension)} style={{ margin: 0, fontSize: 11 }}>
{extension.toUpperCase()} {count}
</Tag>
))}
{Object.keys(extensionCounts).length > 5 && (
<span style={{ fontSize: 11, color: colors.textMuted }}>
+{Object.keys(extensionCounts).length - 5}
</span>
)}
</Space>
<div className="files-list-actions">
<Tooltip title="刷新文件列表">
<Button icon={<ReloadOutlined />} aria-label="刷新文件列表" onClick={onRefresh} />
</Tooltip>
<Button type="primary" icon={<UploadOutlined />} onClick={onOpenUpload}>
</Button>
</div>
</div>
<div className="files-table-scroll">
<Table<FileRecord>
dataSource={files}
columns={columns}
rowKey="id"
loading={loading}
scroll={{ x: 620 }}
pagination={files.length > 10 ? {
pageSize: 15,
showSizeChanger: true,
pageSizeOptions: ['15', '30', '50'],
showTotal: (total) => `${total} 个文件`,
} : false}
locale={{
emptyText: (
<div className="files-empty">
<InboxOutlined />
<span></span>
</div>
),
}}
/>
</div>
</section>
)
}

View File

@ -0,0 +1,121 @@
import { InboxOutlined } from '@ant-design/icons'
import { message, Modal, Select, Upload } from 'antd'
import type { UploadFile, UploadProps } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import type { FileCategory, FileUploadConfig } from '../../api'
import { flattenCategoryOptions } from '../../utils/fileTree'
const { Dragger } = Upload
interface UploadResult {
succeeded: number
failed: number
}
interface FileUploadModalProps {
open: boolean
categories: FileCategory[]
config: FileUploadConfig
initialCategoryId: string | null
onClose: () => void
onUpload: (files: File[], categoryId: string | null) => Promise<UploadResult>
}
export default function FileUploadModal({
open,
categories,
config,
initialCategoryId,
onClose,
onUpload,
}: FileUploadModalProps) {
const [targetCategoryId, setTargetCategoryId] = useState<string | undefined>()
const [fileList, setFileList] = useState<UploadFile[]>([])
const [uploading, setUploading] = useState(false)
useEffect(() => {
if (open) {
setTargetCategoryId(initialCategoryId ?? undefined)
setFileList([])
}
}, [initialCategoryId, open])
const options = useMemo(() => flattenCategoryOptions(categories), [categories])
const accept = config.allowed_extensions.map((extension) => `.${extension}`).join(',')
const uploadProps: UploadProps = {
multiple: true,
accept: accept || undefined,
fileList,
customRequest: () => undefined,
beforeUpload: (file) => {
const maxBytes = config.max_upload_size_mb * 1024 * 1024
if (file.size > maxBytes) {
message.error(`${file.name} 超过 ${config.max_upload_size_mb}MB 限制`)
return Upload.LIST_IGNORE
}
return true
},
onChange: ({ fileList: nextFileList }) => setFileList(nextFileList),
onRemove: (file) => {
setFileList((current) => current.filter((item) => item.uid !== file.uid))
return true
},
}
const handleUpload = async () => {
const files = fileList.flatMap((item) => item.originFileObj ? [item.originFileObj] : [])
if (files.length === 0) return
setUploading(true)
try {
const result = await onUpload(files, targetCategoryId ?? null)
if (result.succeeded > 0 && result.failed === 0) {
message.success(`成功上传 ${result.succeeded} 个文件`)
} else if (result.succeeded > 0) {
message.warning(`成功 ${result.succeeded} 个,失败 ${result.failed}`)
} else {
message.error('上传失败,请检查文件格式和大小')
}
onClose()
} finally {
setUploading(false)
}
}
const formats = config.allowed_extensions.length > 0
? config.allowed_extensions.map((extension) => `.${extension}`).join(' ')
: '正在读取支持格式'
return (
<Modal
title="上传文件"
open={open}
onCancel={onClose}
onOk={() => void handleUpload()}
okText="开始上传"
confirmLoading={uploading}
okButtonProps={{ disabled: fileList.length === 0 }}
width={520}
destroyOnHidden
>
<div className="files-upload-category">
<span></span>
<Select
allowClear
placeholder="选择分类(可选)"
value={targetCategoryId}
onChange={setTargetCategoryId}
options={options}
/>
</div>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
{formats} {config.max_upload_size_mb}MB
</p>
</Dragger>
</Modal>
)
}

View File

@ -0,0 +1,136 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
filesApi,
type FileCategory,
type FileRecord,
type FileUploadConfig,
} from '../api'
import { categoryContains, findCategory } from '../utils/fileTree'
const DEFAULT_CONFIG: FileUploadConfig = {
allowed_extensions: [],
max_upload_size_mb: 50,
}
export function useFiles() {
const [categories, setCategories] = useState<FileCategory[]>([])
const [files, setFiles] = useState<FileRecord[]>([])
const [config, setConfig] = useState<FileUploadConfig>(DEFAULT_CONFIG)
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const fileRequestId = useRef(0)
const loadCategories = useCallback(async () => {
const response = await filesApi.listCategories()
setCategories(response.data)
return response.data
}, [])
const loadFiles = useCallback(async (categoryId: string | null = null) => {
const requestId = ++fileRequestId.current
setLoading(true)
try {
const response = await filesApi.list(categoryId)
if (requestId === fileRequestId.current) setFiles(response.data)
} finally {
if (requestId === fileRequestId.current) setLoading(false)
}
}, [])
const loadConfig = useCallback(async () => {
const response = await filesApi.getConfig()
setConfig(response.data)
}, [])
useEffect(() => {
void Promise.allSettled([loadCategories(), loadFiles(), loadConfig()])
}, [loadCategories, loadConfig, loadFiles])
const selectCategory = useCallback((categoryId: string | null) => {
setSelectedCategoryId(categoryId)
void loadFiles(categoryId)
}, [loadFiles])
const refresh = useCallback(async () => {
await Promise.all([loadCategories(), loadFiles(selectedCategoryId), loadConfig()])
}, [loadCategories, loadConfig, loadFiles, selectedCategoryId])
const createCategory = useCallback(async (name: string, parentId?: string) => {
await filesApi.createCategory({ name, parent_id: parentId })
await loadCategories()
}, [loadCategories])
const renameCategory = useCallback(async (id: string, name: string) => {
await filesApi.updateCategory(id, { name })
await loadCategories()
}, [loadCategories])
const deleteCategory = useCallback(async (id: string) => {
const deletedRoot = findCategory(categories, id)
const resetsSelection = Boolean(
selectedCategoryId && deletedRoot && categoryContains(deletedRoot, selectedCategoryId),
)
const nextCategoryId = resetsSelection ? null : selectedCategoryId
await filesApi.deleteCategory(id)
setSelectedCategoryId(nextCategoryId)
await Promise.all([loadCategories(), loadFiles(nextCategoryId)])
}, [categories, loadCategories, loadFiles, selectedCategoryId])
const uploadFiles = useCallback(async (items: File[], categoryId: string | null) => {
let succeeded = 0
let failed = 0
for (const file of items) {
try {
await filesApi.upload(file, categoryId)
succeeded += 1
} catch {
failed += 1
}
}
if (succeeded > 0) {
setSelectedCategoryId(categoryId)
await loadFiles(categoryId)
}
return { succeeded, failed }
}, [loadFiles])
const deleteFile = useCallback(async (id: string) => {
await filesApi.delete(id)
await loadFiles(selectedCategoryId)
}, [loadFiles, selectedCategoryId])
const downloadFile = useCallback(async (record: FileRecord) => {
const response = await filesApi.download(record.id)
const url = URL.createObjectURL(response.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = record.original_name
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}, [])
const selectedCategoryName = useMemo(() => {
if (!selectedCategoryId) return '全部文件'
return findCategory(categories, selectedCategoryId)?.name ?? '全部文件'
}, [categories, selectedCategoryId])
return {
categories,
files,
config,
selectedCategoryId,
selectedCategoryName,
loading,
selectCategory,
refresh,
createCategory,
renameCategory,
deleteCategory,
uploadFiles,
deleteFile,
downloadFile,
}
}

View File

@ -102,7 +102,194 @@ body {
40% { opacity: 1; transform: translateY(-2px); }
}
/* Category tree node hover — reveal action buttons */
.cat-tree-node:hover .cat-actions {
opacity: 1 !important;
/* File management */
.files-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.files-category-panel,
.files-list-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #fff;
}
.files-category-panel {
border-right: 1px solid #f0f0f0;
}
.files-panel-header,
.files-list-header {
min-height: 48px;
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
box-sizing: border-box;
}
.files-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.files-category-scroll,
.files-table-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.files-category-scroll {
padding: 6px 4px;
}
.files-category-scroll .ant-tree,
.files-category-scroll .ant-tree-list,
.files-category-scroll .ant-tree-list-holder-inner {
min-width: 100%;
}
.files-category-scroll .ant-tree-node-content-wrapper {
min-width: 0;
flex: 1;
}
.cat-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
min-width: 0;
}
.cat-tree-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-actions {
display: inline-flex;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.cat-actions .ant-btn {
width: 24px;
height: 24px;
padding: 0;
}
.cat-tree-node:hover .cat-actions,
.cat-tree-node:focus-within .cat-actions {
opacity: 1;
}
.files-list-header {
display: flex;
align-items: center;
gap: 16px;
}
.files-current-category {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
.files-current-category span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.files-stat {
flex-shrink: 0;
color: #8c8c8c;
font-size: 13px;
}
.files-extension-stats {
flex: 1;
min-width: 0;
}
.files-list-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
margin-left: auto;
}
.files-name-cell {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.files-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px;
color: #8c8c8c;
}
.files-empty .anticon {
font-size: 40px;
color: #bfbfbf;
}
.files-upload-category {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
@media (max-width: 760px) {
.files-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(150px, 30%) minmax(0, 1fr);
}
.files-category-panel {
border-right: 0;
border-bottom: 1px solid #f0f0f0;
}
.files-list-header {
flex-wrap: wrap;
gap: 8px 12px;
}
.files-extension-stats {
display: none;
}
.files-list-actions {
margin-left: auto;
}
}

View File

@ -1,544 +1,159 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Input,
message,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Tooltip,
Tree,
Upload,
} from 'antd'
import type { DataNode } from 'antd/es/tree'
import {
PlusOutlined,
UploadOutlined,
DownloadOutlined,
DeleteOutlined,
EditOutlined,
FolderOutlined,
FolderAddOutlined,
FileOutlined,
InboxOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import type { UploadFile, UploadProps } from 'antd'
import { filesApi, type FileCategory, type FileRecord } from '../api'
import { colors, fontSizes } from '../tokens'
import { formatDateTime } from '../utils/date'
import { Input, message, Modal } from 'antd'
import { useState } from 'react'
import type { FileCategory } from '../api'
import FileCategoryTree from '../components/files/FileCategoryTree'
import FileTable from '../components/files/FileTable'
import FileUploadModal from '../components/files/FileUploadModal'
import PageWrapper from '../components/PageWrapper'
import { useFiles } from '../hooks/useFiles'
const { Dragger } = Upload
/** Format bytes to human-readable string. */
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
interface CategoryDialogState {
open: boolean
mode: 'create' | 'rename'
name: string
parentId?: string
categoryId?: string
}
/** Map extension to a vibrant color. */
const extColorMap: Record<string, string> = {
txt: '#8c8c8c', md: '#1677ff', json: '#52c41a', yaml: '#13c2c2', yml: '#13c2c2',
csv: '#fa8c16', xml: '#722ed1', xlsx: '#fa541c', xls: '#fa541c',
png: '#eb2f96', jpg: '#eb2f96', jpeg: '#eb2f96', gif: '#eb2f96', svg: '#eb2f96',
zip: '#f5222d', py: '#2f54eb', js: '#fadb14', ts: '#1677ff',
}
function extTagColor(ext: string): string {
return extColorMap[ext] || '#8c8c8c'
}
/** 获取选中的分类名称 */
function findCategoryName(cats: FileCategory[], id: string): string {
for (const c of cats) {
if (c.key === id) return c.title
for (const ch of c.children) {
if (ch.key === id) return ch.title
}
}
return ''
const CLOSED_CATEGORY_DIALOG: CategoryDialogState = {
open: false,
mode: 'create',
name: '',
}
export default function FilesPage() {
const [categories, setCategories] = useState<FileCategory[]>([])
const [files, setFiles] = useState<FileRecord[]>([])
const [loading, setLoading] = useState(false)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedCatName, setSelectedCatName] = useState('全部文件')
// Upload dialog
const {
categories,
files,
config,
selectedCategoryId,
selectedCategoryName,
loading,
selectCategory,
refresh,
createCategory,
renameCategory,
deleteCategory,
uploadFiles,
deleteFile,
downloadFile,
} = useFiles()
const [uploadOpen, setUploadOpen] = useState(false)
const [uploadCategory, setUploadCategory] = useState<string | undefined>(undefined)
const [uploadFileList, setUploadFileList] = useState<UploadFile[]>([])
const [uploading, setUploading] = useState(false)
const [categoryDialog, setCategoryDialog] = useState<CategoryDialogState>(CLOSED_CATEGORY_DIALOG)
const [categorySaving, setCategorySaving] = useState(false)
// Category dialog (create / rename)
const [catDialogOpen, setCatDialogOpen] = useState(false)
const [catDialogMode, setCatDialogMode] = useState<'create' | 'rename'>('create')
const [catDialogName, setCatDialogName] = useState('')
const [catDialogParentId, setCatDialogParentId] = useState<string | undefined>(undefined)
const [catDialogEditId, setCatDialogEditId] = useState<string | null>(null)
const openCreateCategory = (parentId?: string) => {
setCategoryDialog({ open: true, mode: 'create', name: '', parentId })
}
// ── Data loading ─────────────────────────────────────────────────
const openRenameCategory = (category: FileCategory) => {
setCategoryDialog({
open: true,
mode: 'rename',
name: category.name,
categoryId: category.id,
})
}
const loadCategories = useCallback(async () => {
const saveCategory = async () => {
const name = categoryDialog.name.trim()
if (!name) return
setCategorySaving(true)
try {
const res = await filesApi.listCategories()
setCategories(res.data)
} catch { /* error handled by interceptor */ }
}, [])
const loadFiles = useCallback(async (categoryId?: string | null) => {
setLoading(true)
try {
const res = await filesApi.list(categoryId)
setFiles(res.data)
} catch { /* error handled by interceptor */ }
finally { setLoading(false) }
}, [])
useEffect(() => {
loadCategories()
loadFiles()
}, [loadCategories, loadFiles])
const handleCategorySelect = (keys: React.Key[]) => {
const key = keys.length > 0 ? (keys[0] as string) : null
setSelectedCategory(key)
const name = key ? findCategoryName(categories, key) : '全部文件'
setSelectedCatName(name || '全部文件')
loadFiles(key)
}
// ── Total stats ─────────────────────────────────────────────────
const totalSize = files.reduce((sum, f) => sum + f.file_size, 0)
const extCounts = files.reduce<Record<string, number>>((acc, f) => {
acc[f.file_ext] = (acc[f.file_ext] || 0) + 1
return acc
}, {})
// ── Tree helpers ─────────────────────────────────────────────────
const buildTree = (cats: FileCategory[]): DataNode[] => {
const allNode: DataNode = {
key: '__all__',
title: (
<span style={{ fontWeight: 600, fontSize: fontSizes.emphasis }}>
<FolderOutlined style={{ marginRight: 6 }} />
</span>
),
icon: <FileOutlined />,
children: cats.length > 0 ? cats.map(mapCategory) : [],
selectable: true,
}
return [allNode]
}
const mapCategory = (cat: FileCategory): DataNode => {
const titleNode = (
<div className="cat-tree-node" style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', paddingRight: 4,
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cat.title}
</span>
<span className="cat-actions" style={{
display: 'inline-flex', gap: 0, flexShrink: 0, opacity: 0.55,
transition: 'opacity 0.15s',
}}>
<Button size="small" type="text" icon={<PlusOutlined style={{ fontSize: 11 }} />}
onClick={(e) => { e.stopPropagation(); openCreateCat(cat.key) }}
style={{ width: 24, height: 24 }} />
<Button size="small" type="text" icon={<EditOutlined style={{ fontSize: 11 }} />}
onClick={(e) => { e.stopPropagation(); openRenameCat(cat.key, cat.title) }}
style={{ width: 24, height: 24 }} />
<Popconfirm title="删除此分类及其所有子分类和文件?" onConfirm={() => handleDeleteCategory(cat.key)}>
<Button size="small" type="text" danger icon={<DeleteOutlined style={{ fontSize: 11 }} />}
onClick={(e) => { e.stopPropagation() }}
style={{ width: 24, height: 24 }} />
</Popconfirm>
</span>
</div>
)
return {
key: cat.key,
title: titleNode,
icon: <FolderOutlined style={{ color: '#faad14' }} />,
children: cat.children.length > 0 ? cat.children.map(mapCategory) : undefined,
}
}
// ── Category actions ─────────────────────────────────────────────
const openCreateCat = (parentId?: string) => {
setCatDialogMode('create')
setCatDialogName('')
setCatDialogParentId(parentId)
setCatDialogEditId(null)
setCatDialogOpen(true)
}
const openRenameCat = (id: string, currentName: string) => {
setCatDialogMode('rename')
setCatDialogName(currentName)
setCatDialogParentId(undefined)
setCatDialogEditId(id)
setCatDialogOpen(true)
}
const handleCatDialogOk = async () => {
if (!catDialogName.trim()) return
try {
if (catDialogMode === 'create') {
await filesApi.createCategory({ name: catDialogName.trim(), parent_id: catDialogParentId })
if (categoryDialog.mode === 'create') {
await createCategory(name, categoryDialog.parentId)
message.success('分类已创建')
} else if (catDialogEditId) {
await filesApi.updateCategory(catDialogEditId, { name: catDialogName.trim() })
} else if (categoryDialog.categoryId) {
await renameCategory(categoryDialog.categoryId, name)
message.success('分类已更新')
}
setCatDialogOpen(false)
loadCategories()
} catch { /* handled */ }
setCategoryDialog(CLOSED_CATEGORY_DIALOG)
} catch {
// The shared API interceptor displays the server error.
} finally {
setCategorySaving(false)
}
}
const handleDeleteCategory = async (id: string) => {
try {
await filesApi.deleteCategory(id)
await deleteCategory(id)
message.success('分类已删除')
if (selectedCategory === id) {
setSelectedCategory(null)
setSelectedCatName('全部文件')
loadFiles(null)
}
loadCategories()
} catch { /* handled */ }
}
// ── File actions ─────────────────────────────────────────────────
const handleUpload = async () => {
if (uploadFileList.length === 0) return
setUploading(true)
let success = 0
let fail = 0
for (const item of uploadFileList) {
try {
await filesApi.upload(item.originFileObj as File, uploadCategory || null)
success++
} catch { fail++ }
} catch {
// The shared API interceptor displays the server error.
}
setUploading(false)
setUploadOpen(false)
setUploadFileList([])
if (success > 0) {
const msg = fail > 0 ? `成功 ${success} 个,失败 ${fail}` : `成功上传 ${success} 个文件`
message[fail > 0 ? 'warning' : 'success'](msg)
// 刷新上传目标分类的文件列表
loadFiles(uploadCategory || null)
if (uploadCategory && uploadCategory !== selectedCategory) {
setSelectedCategory(uploadCategory)
setSelectedCatName(findCategoryName(categories, uploadCategory) || '全部文件')
}
} else {
message.error('上传失败,请检查文件格式和大小')
}
}
const handleDownload = (record: FileRecord) => {
window.open(filesApi.downloadUrl(record.id), '_blank')
}
const handleDeleteFile = async (id: string) => {
await filesApi.delete(id)
message.success('文件已删除')
loadFiles(selectedCategory)
try {
await deleteFile(id)
message.success('文件已删除')
} catch {
// The shared API interceptor displays the server error.
}
}
const openUpload = () => {
setUploadCategory(selectedCategory || undefined)
setUploadFileList([])
setUploadOpen(true)
const handleDownload = async (record: Parameters<typeof downloadFile>[0]) => {
try {
await downloadFile(record)
} catch {
// The shared API interceptor displays the server error.
}
}
const uploadProps: UploadProps = {
multiple: true,
fileList: uploadFileList,
customRequest: () => {
// 不自动上传,由「开始上传」按钮统一处理
},
onChange: (info) => {
setUploadFileList(info.fileList)
},
onRemove: (file) => {
setUploadFileList((prev) => prev.filter((f) => f.uid !== file.uid))
return true
},
}
// ── Table columns ────────────────────────────────────────────────
const columns = [
{
title: '文件名', dataIndex: 'original_name', key: 'name',
render: (name: string, record: FileRecord) => (
<Space size={8}>
<FileOutlined style={{ color: extTagColor(record.file_ext), fontSize: 16 }} />
<span style={{ fontWeight: 500 }}>{name}</span>
</Space>
),
},
{
title: '大小', dataIndex: 'file_size', key: 'size', width: 100,
render: (size: number) => (
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
{formatSize(size)}
</span>
),
},
{
title: '类型', dataIndex: 'file_ext', key: 'type', width: 90,
render: (ext: string) => (
<Tag color={extTagColor(ext)} style={{ margin: 0, fontWeight: 600, fontSize: 11 }}>
{ext.toUpperCase()}
</Tag>
),
},
{
title: '上传时间', dataIndex: 'created_at', key: 'created_at', width: 170,
render: (t: string) => (
<span style={{ fontSize: fontSizes.body, color: colors.textSecondary }}>
{formatDateTime(t)}
</span>
),
},
{
title: '操作', key: 'action', width: 100,
render: (_: any, record: FileRecord) => (
<Space size={0}>
<Tooltip title="下载">
<Button size="small" type="text" icon={<DownloadOutlined />}
onClick={() => handleDownload(record)} />
</Tooltip>
<Popconfirm title="确认删除此文件?" onConfirm={() => handleDeleteFile(record.id)}>
<Tooltip title="删除">
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
),
},
]
// ── Render ───────────────────────────────────────────────────────
const treeData = buildTree(categories)
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 页头 */}
<div style={{
padding: '10px 16px 8px', flexShrink: 0,
display: 'flex', alignItems: 'center', gap: 12,
}}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}></h2>
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
<span style={{ fontSize: 13, color: colors.textSecondary }}></span>
<PageWrapper title="原始文件" description="上传和管理评测相关的原始文件" inline fullHeight>
<div className="files-layout">
<FileCategoryTree
categories={categories}
selectedCategoryId={selectedCategoryId}
onSelect={selectCategory}
onCreate={openCreateCategory}
onRename={openRenameCategory}
onDelete={handleDeleteCategory}
/>
<FileTable
files={files}
loading={loading}
categoryName={selectedCategoryName}
onRefresh={() => void refresh().catch(() => undefined)}
onOpenUpload={() => setUploadOpen(true)}
onDownload={handleDownload}
onDelete={handleDeleteFile}
/>
</div>
{/* 主体 grid — 贴边铺满 */}
<div style={{ flex: 1, minHeight: 0 }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(240px, 22vw) 1fr',
gridTemplateRows: '1fr',
gap: 0,
height: '100%',
minHeight: 480,
}}>
{/* LEFT: 分类面板 */}
<div style={{
display: 'flex',
flexDirection: 'column',
borderRight: `1px solid ${colors.border}`,
background: '#fff',
minHeight: 0,
overflow: 'hidden',
}}>
{/* 分类头部 */}
<div style={{
padding: '10px 12px',
borderBottom: `1px solid ${colors.border}`,
background: colors.bgSubtle,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: fontSizes.emphasis, fontWeight: 600, color: colors.text }}>
</span>
<Tooltip title="新建根分类">
<Button size="small" type="text" icon={<FolderAddOutlined />}
onClick={() => openCreateCat(undefined)} />
</Tooltip>
</div>
{/* 分类树 — 可滚动 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 4px' }}>
<Tree
showIcon
defaultExpandAll
treeData={treeData}
selectedKeys={selectedCategory ? [selectedCategory] : ['__all__']}
onSelect={handleCategorySelect}
style={{ background: 'transparent' }}
/>
</div>
</div>
{/* RIGHT: 文件列表 */}
<div style={{
display: 'flex',
flexDirection: 'column',
background: '#fff',
minHeight: 0,
overflow: 'hidden',
}}>
{/* 文件头部 — 统计 + 操作 */}
<div style={{
padding: '10px 16px',
borderBottom: `1px solid ${colors.border}`,
background: colors.bgSubtle,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 20,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FolderOutlined style={{ color: colors.primary, fontSize: 18 }} />
<span style={{ fontSize: fontSizes.emphasis, fontWeight: 600, color: colors.text }}>
{selectedCatName}
</span>
</div>
<span style={{ color: colors.textMuted, fontSize: fontSizes.body }}>
{files.length}
</span>
<span style={{ color: colors.textMuted, fontSize: fontSizes.body }}>
{formatSize(totalSize)}
</span>
{Object.keys(extCounts).length > 0 && (
<Space size={4} wrap style={{ flex: 1 }}>
{Object.entries(extCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([ext, count]) => (
<Tag key={ext} color={extTagColor(ext)} style={{ margin: 0, fontSize: 11 }}>
{ext.toUpperCase()} {count}
</Tag>
))}
{Object.keys(extCounts).length > 5 && (
<span style={{ fontSize: 11, color: colors.textMuted }}>
+{Object.keys(extCounts).length - 5}
</span>
)}
</Space>
)}
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', gap: 8 }}>
<Tooltip title="刷新文件列表">
<Button size="middle" icon={<ReloadOutlined />}
onClick={() => loadFiles(selectedCategory)} />
</Tooltip>
<Button type="primary" icon={<UploadOutlined />} onClick={openUpload} size="middle">
</Button>
</div>
</div>
{/* 文件表格 — 可滚动 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<Table
dataSource={files}
columns={columns}
rowKey="id"
loading={loading}
showHeader={true}
pagination={files.length > 10 ? {
pageSize: 15,
showSizeChanger: true,
pageSizeOptions: ['15', '30', '50'],
showTotal: (t) => `${t} 个文件`,
} : false}
locale={{ emptyText: (
<div style={{ padding: 40 }}>
<InboxOutlined style={{ fontSize: 48, color: '#d9d9d9', marginBottom: 12 }} />
<div style={{ color: colors.textMuted }}>
</div>
</div>
) }}
style={{ marginTop: 0 }}
/>
</div>
</div>
</div>
</div>
{/* Upload Dialog */}
<Modal
title="上传文件"
<FileUploadModal
open={uploadOpen}
onCancel={() => { setUploadOpen(false); setUploadFileList([]) }}
onOk={handleUpload}
okText="开始上传"
confirmLoading={uploading}
okButtonProps={{ disabled: uploadFileList.length === 0 }}
width={520}
>
<div style={{ marginBottom: 16 }}>
<span style={{ marginRight: 8 }}></span>
<Select
allowClear
placeholder="选择分类(可选)"
style={{ width: 300 }}
value={uploadCategory}
onChange={(v) => setUploadCategory(v)}
options={categories.flatMap((c) => [
{ value: c.key, label: c.title },
...c.children.map((ch) => ({ value: ch.key, label: `${ch.title}` })),
])}
/>
</div>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
.txt .md .json .yaml .csv .xml .xlsx .png .jpg .gif .svg .zip .py .js .ts 50MB
</p>
</Dragger>
</Modal>
categories={categories}
config={config}
initialCategoryId={selectedCategoryId}
onClose={() => setUploadOpen(false)}
onUpload={uploadFiles}
/>
{/* Category Dialog */}
<Modal
title={catDialogMode === 'create' ? '新建分类' : '重命名分类'}
open={catDialogOpen}
onOk={handleCatDialogOk}
onCancel={() => setCatDialogOpen(false)}
okText={catDialogMode === 'create' ? '创建' : '保存'}
okButtonProps={{ disabled: !catDialogName.trim() }}
title={categoryDialog.mode === 'create' ? '新建分类' : '重命名分类'}
open={categoryDialog.open}
onOk={() => void saveCategory()}
onCancel={() => setCategoryDialog(CLOSED_CATEGORY_DIALOG)}
okText={categoryDialog.mode === 'create' ? '创建' : '保存'}
confirmLoading={categorySaving}
okButtonProps={{ disabled: !categoryDialog.name.trim() }}
width={360}
destroyOnHidden
>
<Input
autoFocus
placeholder="分类名称"
value={catDialogName}
onChange={(e) => setCatDialogName(e.target.value)}
onPressEnter={handleCatDialogOk}
value={categoryDialog.name}
onChange={(event) => setCategoryDialog((current) => ({
...current,
name: event.target.value,
}))}
onPressEnter={() => void saveCategory()}
/>
</Modal>
</div>
</PageWrapper>
)
}

View File

@ -0,0 +1,31 @@
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
const extensionColors: Record<string, string> = {
txt: '#666666',
md: '#1677ff',
json: '#389e0d',
yaml: '#08979c',
yml: '#08979c',
csv: '#d46b08',
xml: '#531dab',
xlsx: '#237804',
xls: '#237804',
png: '#c41d7f',
jpg: '#c41d7f',
jpeg: '#c41d7f',
gif: '#c41d7f',
svg: '#c41d7f',
zip: '#cf1322',
py: '#1d39c4',
js: '#ad8b00',
ts: '#0958d9',
}
export function extensionColor(extension: string): string {
return extensionColors[extension.toLowerCase()] || '#595959'
}

View File

@ -0,0 +1,32 @@
import type { FileCategory } from '../api'
export interface CategoryOption {
value: string
label: string
}
export function findCategory(categories: FileCategory[], id: string): FileCategory | undefined {
for (const category of categories) {
if (category.id === id) return category
const nested = findCategory(category.children, id)
if (nested) return nested
}
return undefined
}
export function categoryContains(category: FileCategory, id: string): boolean {
return category.id === id || category.children.some((child) => categoryContains(child, id))
}
export function flattenCategoryOptions(
categories: FileCategory[],
depth = 0,
): CategoryOption[] {
return categories.flatMap((category) => [
{
value: category.id,
label: `${' '.repeat(depth)}${depth > 0 ? '└ ' : ''}${category.name}`,
},
...flattenCategoryOptions(category.children, depth + 1),
])
}

View File

@ -85,6 +85,7 @@ run rsync -az --delete \
--exclude='__pycache__' \
--exclude='.pytest_cache' \
--exclude='.ruff_cache' \
--exclude='AGENTS.md' \
--exclude='data' \
--exclude='.env' \
--exclude='config/config.json' \

View File

@ -0,0 +1,198 @@
"""Integration tests for the file management API."""
from pathlib import Path
import pytest
from agenteval.config import get_settings
from agenteval.web.app import app
from agenteval.web.deps import get_db
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
@pytest.fixture()
def files_client(tmp_path: Path, monkeypatch):
from agenteval.storage.db import ( # noqa: F401
EvalResultDB,
EvalRunDB,
EvalTargetDB,
FileCategoryDB,
FileRecordDB,
ScenarioDB,
TurnDB,
)
from agenteval.web.routers import files as files_module
engine = create_engine(
f"sqlite:///{tmp_path / 'files_api.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
files_dir = tmp_path / "files"
files_dir.mkdir()
monkeypatch.setattr(files_module, "FILES_DIR", files_dir)
settings = get_settings()
monkeypatch.setattr(settings, "allowed_extensions", "txt,json")
monkeypatch.setattr(settings, "max_upload_size_mb", 1)
def override_get_db():
yield session
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as client:
yield client, session, files_dir
app.dependency_overrides.clear()
session.close()
engine.dispose()
def _create_category(client: TestClient, name: str, parent_id: str | None = None) -> dict:
response = client.post(
"/api/files/categories",
json={"name": name, "parent_id": parent_id},
)
assert response.status_code == 200
return response.json()
def test_upload_config_uses_server_settings(files_client):
client, _, _ = files_client
response = client.get("/api/files/config")
assert response.status_code == 200
assert response.json() == {
"allowed_extensions": ["json", "txt"],
"max_upload_size_mb": 1,
}
def test_category_crud_returns_domain_fields_and_deep_tree(files_client):
client, _, _ = files_client
root = _create_category(client, "根分类")
child = _create_category(client, "二级", root["id"])
leaf = _create_category(client, "三级", child["id"])
tree = client.get("/api/files/categories").json()
assert tree[0]["id"] == root["id"]
assert tree[0]["name"] == "根分类"
assert tree[0]["children"][0]["id"] == child["id"]
assert tree[0]["children"][0]["children"][0]["id"] == leaf["id"]
updated = client.put(
f"/api/files/categories/{leaf['id']}",
json={"name": "三级分类"},
)
assert updated.status_code == 200
assert updated.json()["name"] == "三级分类"
def test_create_category_rejects_missing_parent(files_client):
client, _, _ = files_client
response = client.post(
"/api/files/categories",
json={"name": "孤立分类", "parent_id": "missing"},
)
assert response.status_code == 400
assert response.json()["detail"] == "父分类不存在"
def test_list_files_includes_all_descendant_categories(files_client):
client, _, _ = files_client
root = _create_category(client, "根分类")
child = _create_category(client, "二级", root["id"])
leaf = _create_category(client, "三级", child["id"])
upload = client.post(
"/api/files/upload",
data={"category_id": leaf["id"]},
files={"file": ("deep.txt", b"deep content", "text/plain")},
)
assert upload.status_code == 200
response = client.get("/api/files", params={"category_id": root["id"]})
assert response.status_code == 200
assert [item["original_name"] for item in response.json()] == ["deep.txt"]
def test_upload_download_and_delete_file(files_client):
client, _, files_dir = files_client
uploaded = client.post(
"/api/files/upload",
files={"file": ("notes.txt", b"hello", "text/plain")},
)
assert uploaded.status_code == 200
record = uploaded.json()
assert record["original_name"] == "notes.txt"
assert record["file_size"] == 5
assert len(list(files_dir.iterdir())) == 1
downloaded = client.get(f"/api/files/{record['id']}/download")
assert downloaded.status_code == 200
assert downloaded.content == b"hello"
deleted = client.delete(f"/api/files/{record['id']}")
assert deleted.status_code == 200
assert list(files_dir.iterdir()) == []
assert client.get("/api/files").json() == []
def test_upload_rejects_invalid_category_and_extension(files_client):
client, _, files_dir = files_client
invalid_category = client.post(
"/api/files/upload",
data={"category_id": "missing"},
files={"file": ("notes.txt", b"hello", "text/plain")},
)
invalid_extension = client.post(
"/api/files/upload",
files={"file": ("notes.exe", b"hello", "application/octet-stream")},
)
assert invalid_category.status_code == 400
assert invalid_category.json()["detail"] == "分类不存在"
assert invalid_extension.status_code == 400
assert list(files_dir.iterdir()) == []
def test_upload_rejects_oversized_file_without_disk_artifact(files_client):
client, _, files_dir = files_client
response = client.post(
"/api/files/upload",
files={"file": ("large.txt", b"x" * (1024 * 1024 + 1), "text/plain")},
)
assert response.status_code == 413
assert list(files_dir.iterdir()) == []
assert client.get("/api/files").json() == []
def test_delete_category_removes_descendant_files(files_client):
client, _, files_dir = files_client
root = _create_category(client, "根分类")
child = _create_category(client, "子分类", root["id"])
uploaded = client.post(
"/api/files/upload",
data={"category_id": child["id"]},
files={"file": ("child.txt", b"content", "text/plain")},
)
assert uploaded.status_code == 200
assert len(list(files_dir.iterdir())) == 1
response = client.delete(f"/api/files/categories/{root['id']}")
assert response.status_code == 200
assert client.get("/api/files/categories").json() == []
assert client.get("/api/files").json() == []
assert list(files_dir.iterdir()) == []

View File

@ -1,19 +1,23 @@
"""Unit tests for FileCategoryRepository and FileRecordRepository."""
import uuid
from pathlib import Path
import pytest
from sqlmodel import Session, SQLModel, create_engine
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
from sqlmodel import Session, SQLModel, create_engine
@pytest.fixture()
def file_db_session(tmp_path: Path):
"""Isolated SQLite session with file management tables."""
from agenteval.storage.db import ( # noqa: F401
EvalResultDB, EvalRunDB, EvalTargetDB, FileCategoryDB, FileRecordDB, ScenarioDB, TurnDB,
EvalResultDB,
EvalRunDB,
EvalTargetDB,
FileCategoryDB,
FileRecordDB,
ScenarioDB,
TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'file_test.db'}",
@ -28,19 +32,6 @@ def file_db_session(tmp_path: Path):
engine.dispose()
@pytest.fixture()
def fake_files_dir(tmp_path: Path, monkeypatch):
"""Redirect FILES_DIR to a temp directory so tests don't touch data/files."""
import agenteval.storage.db as db_mod
import agenteval.storage.file_repository as repo_mod
fake_dir = tmp_path / "files"
fake_dir.mkdir()
monkeypatch.setattr(db_mod, "FILES_DIR", fake_dir)
monkeypatch.setattr(repo_mod, "FILES_DIR", fake_dir)
return fake_dir
# ── FileCategoryRepository ────────────────────────────────────────────────
def test_create_root_category(file_db_session):
@ -73,8 +64,8 @@ def test_get_tree_structure(file_db_session):
repo.create("child2", parent_id=root.id)
tree = repo.get_tree()
assert len(tree) == 1
assert tree[0]["key"] == root.id
assert len(tree[0]["children"]) == 2
assert tree[0].id == root.id
assert len(tree[0].children) == 2
def test_get_tree_flat(file_db_session):
@ -83,7 +74,7 @@ def test_get_tree_flat(file_db_session):
repo.create("B")
tree = repo.get_tree()
assert len(tree) == 2
assert all(len(node["children"]) == 0 for node in tree)
assert all(len(node.children) == 0 for node in tree)
def test_update_category_name(file_db_session):
@ -116,7 +107,7 @@ def test_delete_nonexistent_returns_false(file_db_session):
assert repo.delete("ghost-id") is False
def test_delete_cascades_to_children(file_db_session, fake_files_dir):
def test_delete_cascades_to_children(file_db_session):
cat_repo = FileCategoryRepository(file_db_session)
file_repo = FileRecordRepository(file_db_session)
@ -203,16 +194,13 @@ def test_delete_file_record(file_db_session):
assert repo.get(rec.id) is None
def test_delete_removes_physical_file(file_db_session, fake_files_dir):
repo = FileRecordRepository(file_db_session)
storage_name = f"{uuid.uuid4()}.txt"
physical = fake_files_dir / storage_name
physical.write_text("content")
assert physical.exists()
def test_get_subtree_ids(file_db_session):
repo = FileCategoryRepository(file_db_session)
root = repo.create("root")
child = repo.create("child", parent_id=root.id)
leaf = repo.create("leaf", parent_id=child.id)
rec = repo.create("orig.txt", storage_name, 7, "text/plain", "txt")
repo.delete(rec.id)
assert not physical.exists()
assert set(repo.get_subtree_ids(root.id)) == {root.id, child.id, leaf.id}
def test_delete_nonexistent_file_returns_false(file_db_session):

View File

@ -0,0 +1,92 @@
"""Tests for file workflow transaction compensation and path safety."""
from io import BytesIO
from pathlib import Path
import pytest
from agenteval.services.files import FileManagementService
from agenteval.storage.file_repository import FileRecordRepository
from agenteval.storage.file_storage import FileStorage, InvalidStorageNameError
from sqlmodel import Session, SQLModel, create_engine
from starlette.datastructures import Headers, UploadFile
@pytest.fixture()
def service_context(tmp_path: Path):
from agenteval.storage.db import ( # noqa: F401
EvalResultDB,
EvalRunDB,
EvalTargetDB,
FileCategoryDB,
FileRecordDB,
ScenarioDB,
TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'file_service.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
storage = FileStorage(tmp_path / "files")
service = FileManagementService(session, storage, {"txt"}, 1)
yield service, session, storage
session.close()
engine.dispose()
def _upload(content: bytes = b"content") -> UploadFile:
return UploadFile(
filename="test.txt",
file=BytesIO(content),
headers=Headers({"content-type": "text/plain"}),
)
async def test_upload_commit_failure_removes_physical_file(service_context, monkeypatch):
service, session, storage = service_context
def fail_commit():
raise RuntimeError("commit failed")
monkeypatch.setattr(session, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
await service.upload(_upload())
assert list(storage.root.iterdir()) == []
assert FileRecordRepository(session).list_all() == []
def test_delete_commit_failure_restores_physical_file(service_context, monkeypatch):
service, session, storage = service_context
record = FileRecordRepository(session).create(
original_name="test.txt",
storage_name="stored.txt",
file_size=7,
mime_type="text/plain",
file_ext="txt",
)
session.commit()
physical_file = storage.path_for("stored.txt")
physical_file.write_bytes(b"content")
def fail_commit():
raise RuntimeError("commit failed")
monkeypatch.setattr(session, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
service.delete_file(record.id)
assert physical_file.read_bytes() == b"content"
assert FileRecordRepository(session).get(record.id) is not None
@pytest.mark.parametrize("storage_name", ["../escape.txt", "folder/file.txt", ""])
def test_storage_rejects_names_outside_root(service_context, storage_name):
_, _, storage = service_context
with pytest.raises(InvalidStorageNameError):
storage.path_for(storage_name)