113 lines
3.3 KiB
Python
113 lines
3.3 KiB
Python
|
|
"""
|
|||
|
|
预览 / 聊天附件上传:写入 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/<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,
|
|||
|
|
)
|