协作台账:新表 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)。
191 lines
6.6 KiB
Python
Executable File
191 lines
6.6 KiB
Python
Executable File
#!/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())
|