Files
aiagent/backend/app/api/uploads.py
2026-04-13 22:52:36 +08:00

144 lines
4.5 KiB
Python

"""
预览 / 聊天附件上传:写入 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/<user_id>/`,返回相对路径。
大小上限与 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/<current_user.id>/ 下文件。
"""
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")