""" 预览 / 聊天附件上传:写入 LOCAL_FILE_TOOLS_ROOT 下,供 file_read 等工具使用。 """ from __future__ import annotations import re import uuid import logging import mimetypes from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Query from fastapi.responses import FileResponse 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, _resolve_path_under_workspace 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, ) @router.get("/preview/file") async def get_preview_file( file_path: str = Query(..., description="上传后返回的 relative_path"), current_user: User = Depends(get_current_user), ): """ 读取当前用户预览附件(用于前端历史回显图片缩略图)。 仅允许访问 uploads/preview// 下文件。 """ path, err = _resolve_path_under_workspace(file_path) if err or path is None: raise HTTPException(status_code=400, detail=f"无效文件路径: {err or file_path}") if not path.is_file(): raise HTTPException(status_code=404, detail="文件不存在") root = _local_file_workspace_root() try: rel = path.relative_to(root).as_posix() except ValueError: raise HTTPException(status_code=403, detail="不允许访问该文件") prefix = f"uploads/preview/{current_user.id}/" if not rel.startswith(prefix): raise HTTPException(status_code=403, detail="无权访问该文件") media_type, _ = mimetypes.guess_type(str(path)) return FileResponse(path=str(path), media_type=media_type or "application/octet-stream")