Files
live-hub-py/scripts/cleanup_orphan_file_assets.py
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

191 lines
6.6 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())