""" 预览 / 聊天附件上传:写入 LOCAL_FILE_TOOLS_ROOT 下,供 file_read 等工具使用。 """ from __future__ import annotations import re import uuid import logging from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from pydantic import BaseModel from app.api.auth import get_current_user from app.core.config import settings from app.models.user import User from app.services.builtin_tools import _local_file_workspace_root logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/v1/uploads", tags=["uploads"], ) class PreviewUploadResponse(BaseModel): """相对工作区根的路径,与 file_read 约定一致(可用相对路径)。""" relative_path: str filename: str size: int content_type: str | None = None def _safe_filename(name: str) -> str: base = Path(name).name.strip() if not base: return "attachment.bin" base = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", base, flags=re.UNICODE) return base[:180] if len(base) > 180 else base @router.post( "/preview", response_model=PreviewUploadResponse, status_code=status.HTTP_201_CREATED, ) async def upload_preview_file( file: UploadFile = File(...), current_user: User = Depends(get_current_user), ): """ 上传单个文件到工作区 `uploads/preview//`,返回相对路径。 大小上限与 LOCAL_FILE_WRITE_MAX_BYTES 一致。 """ max_bytes = max(1024, int(getattr(settings, "LOCAL_FILE_WRITE_MAX_BYTES", 2_097_152) or 2_097_152)) root = _local_file_workspace_root() uid = str(current_user.id) dest_dir = root / "uploads" / "preview" / uid try: dest_dir.mkdir(parents=True, exist_ok=True) except OSError as e: logger.warning("创建上传目录失败: %s", e) raise HTTPException(status_code=500, detail="无法创建上传目录") from e raw_name = file.filename or "attachment" safe = _safe_filename(raw_name) short = uuid.uuid4().hex[:12] dest = dest_dir / f"{short}_{safe}" total = 0 chunk_size = 1024 * 256 try: with dest.open("wb") as out: while True: buf = await file.read(chunk_size) if not buf: break total += len(buf) if total > max_bytes: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=f"文件超过允许大小({max_bytes} 字节)", ) out.write(buf) except HTTPException: try: dest.unlink(missing_ok=True) except OSError: pass raise except OSError as e: try: dest.unlink(missing_ok=True) except OSError: pass logger.warning("写入上传文件失败: %s", e) raise HTTPException(status_code=500, detail="保存文件失败") from e try: rel = dest.relative_to(root).as_posix() except ValueError: rel = str(dest).replace("\\", "/") logger.info("预览上传 user=%s path=%s size=%s", uid, rel, total) return PreviewUploadResponse( relative_path=rel, filename=raw_name, size=total, content_type=file.content_type, )