"""文件上传与回读(在线协作里的图片)。 - ``POST /api/files/upload`` multipart 上传,需要登录 + ``file:upload`` 权限; - ``GET /api/files/object`` 由后端代理对象存储回读,需要登录 + ``file:view`` 权限。 两个接口都在 ``def``(同步)里实现:FastAPI 会把它们丢到线程池,图片解码与对象 存储 IO 不会占住事件循环。 图片只用本服务的代理地址回读(``/api/files/object?key=...``),**不下发 OSS 直链**。 前端 ```` 是同源请求,浏览器会自动带上登录 cookie,权限照样生效。 """ from __future__ import annotations import contextlib from collections.abc import Iterator from typing import Any from fastapi import ( APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, ) from fastapi.responses import Response, StreamingResponse from sqlalchemy.orm import Session from ..database import get_db from ..deps import require_permission from ..models import User from ..schemas import FileAssetOut from ..services.audit_service import record_audit from ..services.file_service import ( FileUploadError, get_stored_object, upload_file_asset, ) from ..services.file_storage import StorageUnavailableError, max_upload_size_bytes router = APIRouter(prefix="/api/files", tags=["files"]) _READ_CHUNK_SIZE = 64 * 1024 # 私有文件缓存在浏览器侧只留 5 分钟:协作场景里图片可能被替换或下线。 _PRIVATE_CACHE_CONTROL = "private, max-age=300" @router.post("/upload", response_model=FileAssetOut) def upload_file( file: UploadFile = File(...), scene: str = Form("misc"), db: Session = Depends(get_db), current: User = Depends(require_permission("file:upload")), ) -> FileAssetOut: """上传一张图片(也接受 PDF)。 先按上限多读 1 字节再判断,超限的请求不会把整个文件灌进内存。 """ limit = max_upload_size_bytes() data = file.file.read(limit + 1) if len(data) > limit: raise HTTPException( status_code=413, detail=f"文件不能超过 {limit // (1024 * 1024)}MB" ) filename = (file.filename or "").strip() try: result = upload_file_asset( db, data=data, filename=filename, content_type=file.content_type or "", scene=scene, uploader_type="user", uploader_id=str(current.id), ) except FileUploadError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc except StorageUnavailableError as exc: raise HTTPException(status_code=503, detail="文件存储暂时不可用") from exc record_audit( db, current, action="file:upload", target=result.object_key, detail={ "scene": scene, "filename": filename, "content_type": result.content_type, "size": result.size, }, ) db.commit() return FileAssetOut( object_key=result.object_key, url=result.url, thumbnail_url=result.thumbnail_url, medium_url=result.medium_url, filename=result.filename, content_type=result.content_type, size=result.size, ) @router.get("/object") def read_file_object( request: Request, key: str = Query(..., min_length=1, max_length=512, description="对象 key"), variant: str = Query("", description="thumb / medium;留空取原图"), _current: User = Depends(require_permission("file:view")), ) -> Response: """回读对象(变体缺失时自动回退原图),支持 ETag 304。""" try: stored = get_stored_object(key, variant) except FileUploadError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc except StorageUnavailableError as exc: raise HTTPException(status_code=503, detail="文件存储暂时不可用") from exc if stored.etag and request.headers.get("if-none-match") == stored.etag: _close_quietly(stored.reader) return Response(status_code=304, headers={"ETag": stored.etag}) headers: dict[str, str] = { "Cache-Control": _PRIVATE_CACHE_CONTROL, "X-Content-Type-Options": "nosniff", # 图片/PDF 本身已压缩,GZip 中间件再压一遍只是白烧 CPU, # 这里显式声明未编码,让中间件跳过(starlette 见到 Content-Encoding 就不压)。 "Content-Encoding": "identity", } if stored.etag: headers["ETag"] = stored.etag if stored.size > 0: headers["Content-Length"] = str(stored.size) return StreamingResponse( _iter_reader(stored.reader), media_type=stored.content_type or "application/octet-stream", headers=headers, ) def _iter_reader(reader: Any) -> Iterator[bytes]: """把存储侧的可读对象按固定块大小吐给客户端,结束时确保关闭。""" try: while True: chunk = reader.read(_READ_CHUNK_SIZE) if not chunk: break yield chunk finally: _close_quietly(reader) def _close_quietly(reader: Any) -> None: close = getattr(reader, "close", None) if callable(close): # 关闭失败不影响已经发出的响应(本地 BytesIO / S3 StreamingBody 都可能抛)。 with contextlib.suppress(Exception): close()