Files
live-hub-py/web/backend/routers/files.py
T
yml2213 df8c63b829 feat(huya): 外部 CK 协作台账(S1–S6)+ 图片上传能力
协作台账:新表 huya_external_orders 替代 Excel 多人协同表(设计见
docs/设计-虎牙外部CK协作台账.md),一条 CK 一行,所有人共享、改动留痕。

- 前端 pages/HuyaCollabPage.tsx + 菜单「虎牙 → 外部单协作」/huya/collab:
  内联编辑(带乐观锁 version)、图片缩略图、详情抽屉(字段表单 + 两个图片槽位 +
  操作时间线)、粘贴导入弹窗(带 TSV 预览)、批量/单条验证 CK、页头统计条、筛选
- 两套状态机:业务 pending→bound→recharged→scripted→done(旁路 void)、
  凭据 unverified/valid/invalid/disabled;唯一写入口在 services/huya_collab_service.py,
  字段级审计 + 整数 version 乐观锁(不用 updated_at:DATETIME 只到秒)
- CK 只读探针 check_user_bind_game_account(真机判据:响应非 None 且 status == 200),
  成功即覆盖写绑定快照 bind_game_account/bind_game_role/bind_game_channel(迁移 0056),
  失败只改凭据状态、不动业务状态;人工只能停用/恢复(POST /{id}/ck/status)
- 贴/换/重建 CK、TSV 批量导入(SAVEPOINT 单行隔离,部分失败不整体失败)、
  权限 huya:collab:view/edit/manage(删除只给运营)
- 孤儿图清理脚本 scripts/cleanup_orphan_file_assets.py(默认 --dry-run)

图片上传(协作台账截图用):OSS/本地双后端 + 缩略图变体 + /api/files 代理回读,
权限 file:upload / file:view。

验证:pytest 880 passed / 6 skipped;ruff format 全绿、ruff check 仅剩既有告警;
前端 tsc -b / eslint / vite build 通过;接口端到端冒烟 29 项全绿(TestClient,探针用假客户端);
探针真机冒烟 3 例(有效 CK=True、别人 uid=False、裸 cookie=False)。
2026-10-16 19:38:59 +08:00

166 lines
5.4 KiB
Python

"""文件上传与回读(在线协作里的图片)。
- ``POST /api/files/upload`` multipart 上传,需要登录 + ``file:upload`` 权限;
- ``GET /api/files/object`` 由后端代理对象存储回读,需要登录 + ``file:view`` 权限。
两个接口都在 ``def``(同步)里实现:FastAPI 会把它们丢到线程池,图片解码与对象
存储 IO 不会占住事件循环。
图片只用本服务的代理地址回读(``/api/files/object?key=...``),**不下发 OSS 直链**。
前端 ``<img src>`` 是同源请求,浏览器会自动带上登录 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()