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:
Executable
+190
@@ -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())
|
||||
Reference in New Issue
Block a user