协作台账:新表 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)。
166 lines
5.4 KiB
Python
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()
|