Files
aiagent/backend/app/api/uploads.py

144 lines
4.5 KiB
Python
Raw Normal View History

"""
预览 / 聊天附件上传写入 LOCAL_FILE_TOOLS_ROOT file_read 等工具使用
"""
from __future__ import annotations
import re
import uuid
import logging
2026-04-13 22:52:36 +08:00
import mimetypes
from pathlib import Path
2026-04-13 22:52:36 +08:00
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
2026-04-13 22:52:36 +08:00
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,
)
2026-04-13 22:52:36 +08:00
@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")