144 lines
4.5 KiB
Python
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")
|