AgentEvalTool/backend/agenteval/web/routers/files.py
sinohqb e0b69fa2b9 v0.4-t1t2: 测试覆盖率 62%→77% + UTC 时区根本修复
## T1: P0 测试补全(+67 个测试)
- test_utils_llm.py: extract_reply_text / extract_content_from_llm_response / parse_json_from_llm_text 各边界
- test_file_repository.py: 分类 CRUD / 树形结构 / 级联删除 / 文件创建/查询/删除/物理文件清理
- test_report.py: generate_report / generate_compare_report / render_markdown / render_json
- test_llm_score.py: OpenAI 格式 / Anthropic content-block 格式 / JSON 回退解析 / 异常降级

## T2: P1 测试补全(+28 个测试)
- test_scenarios.py: 模板列表/字段完整性/规则类型有效性 + YAML/JSON 加载/校验
- test_webhook.py: 未配置不发送 / 正确 payload / secret header / 异常静默忽略
- test_reports_api.py: GET /reports/{id} / /html / /json / /markdown / /compare 集成测试

## UTC 时区根本修复
- storage/db.py: 新增 iso_utc() 函数,确保所有 datetime 序列化输出带 Z 后缀
- runs.py / files.py / report.py: 6 处 .isoformat() → iso_utc()
- 前端 toDate() 兜底仍保留(向下兼容),但后端不再输出无时区时间戳

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:19:16 +08:00

204 lines
6.8 KiB
Python

"""API routes for file management (categories + file upload/download)."""
import mimetypes
import uuid
from pathlib import Path
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.web.deps import get_db
router = APIRouter()
# ── helpers ────────────────────────────────────────────────────────────
def _get_allowed_extensions() -> set[str]:
"""Return the set of allowed lowercase extensions from settings."""
raw = get_settings().allowed_extensions
return {ext.strip().lower() for ext in raw.split(",") if ext.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,
)
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:
raise HTTPException(
status_code=413,
detail=f"文件大小超过限制 ({get_settings().max_upload_size_mb}MB)",
)
# 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,
)
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("/{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",
)
@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}