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)。
This commit is contained in:
yml2213
2026-10-16 19:38:59 +08:00
parent 99e8643d83
commit df8c63b829
32 changed files with 7449 additions and 16 deletions
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python
"""清理协作台账的孤儿图(`scene=collab` 且已经没有任何一行在引用)。
## 为什么需要它
换图时旧 key 只是从行里移除,**不立即删对象**(可能有人正在看、误删不可恢复,设计文档 §6.4)。
攒久了对象存储里全是没人引用的图,所以补一个**人工跑**的清理脚本:
- 只扫 `file_assets.scene = 'collab'`(协作台账专用的 scene,不碰别的业务);
- 只删**不被任何 `huya_external_orders` 行引用**的对象(bind_images / home_images 两个槽位);
- 只删 `created_at` 早于 N 天(默认 30)的(给"刚上传还没提交"的图留足时间);
- **默认 `--dry-run`**,先出清单,确认后再 `--apply`。
## 用法
```bash
# 1) 先看会删什么(不碰库、不碰对象存储)
python scripts/cleanup_orphan_file_assets.py --dry-run
# 2) 真删(务必先备份库;对象删了不可恢复)
python scripts/cleanup_orphan_file_assets.py --apply
python scripts/cleanup_orphan_file_assets.py --apply --days 60
```
## 安全约束(别删掉正在用的图)
1. **删对象成功才删台账行**:对象存储报错时保留 `file_assets` 行,下次还能再清一遍;
反过来先删行再删对象,一旦对象没删掉就真的没人知道它存在了。
2. 变体(thumb / medium)按上传时记录在台账里的 key 删,不靠猜扩展名。
3. 引用判断读的是**当前库里**的行;脚本运行期间有人在编辑 → 可能误判,所以默认留 30 天,
并要求先 `--dry-run`。
"""
from __future__ import annotations
import argparse
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy.orm import Session
from web.backend.database import SessionLocal
from web.backend.models import FileAsset, HuyaExternalOrder
# 协作台账的 scene(web/backend/services/file_service.normalize_scene 会把 scene 转小写)。
COLLAB_SCENE = "collab"
DEFAULT_RETENTION_DAYS = 30
def referenced_keys(db: Session) -> set[str]:
"""当前被协作台账行引用的 object_key(两个图片槽位)。"""
keys: set[str] = set()
rows = db.query(HuyaExternalOrder.bind_images, HuyaExternalOrder.home_images).all()
for bind_images, home_images in rows:
for value in (bind_images, home_images):
if isinstance(value, list):
keys.update(str(item) for item in value if item)
return keys
def find_orphans(
db: Session,
*,
days: int = DEFAULT_RETENTION_DAYS,
scene: str = COLLAB_SCENE,
now: datetime | None = None,
) -> list[FileAsset]:
"""找出「超过保留期 + 没人引用」的图(按最老的排前面)。"""
cutoff = (now or datetime.now(UTC)) - timedelta(days=max(0, int(days)))
referenced = referenced_keys(db)
candidates = (
db.query(FileAsset)
.filter(
FileAsset.scene == scene,
FileAsset.status == "active",
FileAsset.created_at < cutoff,
)
.order_by(FileAsset.created_at.asc(), FileAsset.id.asc())
.all()
)
return [asset for asset in candidates if asset.object_key not in referenced]
def _keys_of(asset: FileAsset) -> list[str]:
"""原图 + 两个变体的 key(变体为空说明当时生成失败)。"""
return [
key
for key in (
asset.object_key,
asset.thumbnail_object_key,
asset.medium_object_key,
)
if key
]
def _format_size(size: int) -> str:
value = float(size or 0)
for unit in ("B", "KB", "MB", "GB"):
if value < 1024 or unit == "GB":
return f"{value:.1f}{unit}"
value /= 1024
return f"{value:.1f}GB"
def run(db: Session, *, apply: bool, days: int = DEFAULT_RETENTION_DAYS) -> dict:
"""列出/清理孤儿图,返回统计(`--dry-run` 什么都不写)。"""
orphans = find_orphans(db, days=days)
storage = None
if apply and orphans:
from web.backend.services.file_storage import get_object_storage
storage = get_object_storage()
deleted = 0
failed = 0
freed = 0
for asset in orphans:
created = (
asset.created_at.strftime("%Y-%m-%d %H:%M") if asset.created_at else "-"
)
prefix = "[DRY-RUN] " if not apply else ""
print(
f"{prefix}id={asset.id} {asset.object_key} "
f"{_format_size(asset.size_bytes)} {created} {asset.original_filename or '-'}"
)
if not apply:
freed += int(asset.size_bytes or 0)
continue
try:
for key in _keys_of(asset):
storage.delete_object(key)
except Exception as exc: # noqa: BLE001 删对象失败就留台账行,下次再清
failed += 1
print(f" !! 删除对象失败,保留台账行:{type(exc).__name__}: {exc}")
continue
db.delete(asset)
deleted += 1
freed += int(asset.size_bytes or 0)
if apply:
db.commit()
return {
"scene": COLLAB_SCENE,
"days": days,
"candidates": len(orphans),
"deleted": deleted,
"failed": failed,
"freed_bytes": freed,
"applied": apply,
}
def main() -> int:
parser = argparse.ArgumentParser(
description="清理协作台账的孤儿图(scene=collab 且没人引用)"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--dry-run", action="store_true", help="只列清单,不写库")
group.add_argument("--apply", action="store_true", help="真删(先备份库!)")
parser.add_argument(
"--days",
type=int,
default=DEFAULT_RETENTION_DAYS,
help=f"保留天数,超过才删(默认 {DEFAULT_RETENTION_DAYS})",
)
args = parser.parse_args()
db = SessionLocal()
try:
result = run(db, apply=args.apply, days=args.days)
prefix = "[DRY-RUN] " if not args.apply else ""
print(
f"{prefix}scene={result['scene']} 保留 {result['days']} 天:"
f"候选 {result['candidates']} 张 / 已删 {result['deleted']} 张"
f" / 失败 {result['failed']} 张 / 释放 {_format_size(result['freed_bytes'])}"
)
if not args.apply and result["candidates"]:
print("[DRY-RUN] 确认无误后再跑 --apply(对象删了不可恢复)")
return 1 if result["failed"] else 0
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())