协作台账:新表 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)。
181 lines
5.4 KiB
Python
181 lines
5.4 KiB
Python
"""协作台账孤儿图清理脚本:只删「过期 + 没人引用」的图,且删对象成功才删台账行。
|
||
|
||
脚本本体在 ``scripts/cleanup_orphan_file_assets.py``(一次性运维脚本,不入包),
|
||
所以这里用 importlib 按路径加载。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
from types import ModuleType
|
||
|
||
import pytest
|
||
|
||
from web.backend.models import FileAsset, HuyaExternalOrder
|
||
from web.backend.services import file_storage
|
||
|
||
_SCRIPT_PATH = (
|
||
Path(__file__).resolve().parents[1] / "scripts" / "cleanup_orphan_file_assets.py"
|
||
)
|
||
|
||
|
||
def load_script() -> ModuleType:
|
||
spec = importlib.util.spec_from_file_location(
|
||
"cleanup_orphan_file_assets", _SCRIPT_PATH
|
||
)
|
||
assert spec and spec.loader
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def cleanup_script() -> ModuleType:
|
||
return load_script()
|
||
|
||
|
||
class FakeStorage:
|
||
"""记录被删的 key;``fail_keys`` 里的 key 删除时抛错。"""
|
||
|
||
def __init__(self, fail_keys: set[str] | None = None):
|
||
self.deleted: list[str] = []
|
||
self.fail_keys = fail_keys or set()
|
||
|
||
def delete_object(self, key: str) -> None:
|
||
if key in self.fail_keys:
|
||
raise RuntimeError(f"删不掉 {key}")
|
||
self.deleted.append(key)
|
||
|
||
|
||
def make_asset(
|
||
db,
|
||
key: str,
|
||
*,
|
||
scene: str = "collab",
|
||
days_old: int = 40,
|
||
size: int = 1024,
|
||
status: str = "active",
|
||
thumb: str = "",
|
||
medium: str = "",
|
||
) -> FileAsset:
|
||
asset = FileAsset(
|
||
object_key=key,
|
||
scene=scene,
|
||
size_bytes=size,
|
||
original_filename=key.rsplit("/", 1)[-1],
|
||
thumbnail_object_key=thumb,
|
||
medium_object_key=medium,
|
||
status=status,
|
||
created_at=datetime.now(UTC) - timedelta(days=days_old),
|
||
)
|
||
db.add(asset)
|
||
db.commit()
|
||
return asset
|
||
|
||
|
||
def make_order(db, order_no: str, keys: list[str]) -> HuyaExternalOrder:
|
||
order = HuyaExternalOrder(
|
||
order_no=order_no,
|
||
shop="佳佳",
|
||
bind_images=list(keys),
|
||
home_images=[],
|
||
status="pending",
|
||
ck_status="unverified",
|
||
version=0,
|
||
)
|
||
db.add(order)
|
||
db.commit()
|
||
return order
|
||
|
||
|
||
def test_find_orphans_skips_referenced_and_fresh_assets(db, cleanup_script):
|
||
referenced = make_asset(
|
||
db, "collab/2026/09/01/ref.png", thumb="collab/2026/09/01/ref.thumb.jpg"
|
||
)
|
||
make_order(db, "A1", [referenced.object_key])
|
||
make_asset(db, "collab/2026/09/02/fresh.png", days_old=3)
|
||
orphan = make_asset(db, "collab/2026/09/03/orphan.png")
|
||
|
||
orphans = cleanup_script.find_orphans(db, days=30)
|
||
|
||
assert [asset.object_key for asset in orphans] == [orphan.object_key]
|
||
|
||
|
||
def test_find_orphans_ignores_other_scenes_and_inactive_rows(db, cleanup_script):
|
||
make_asset(db, "misc/2026/09/01/other.png", scene="misc")
|
||
make_asset(db, "collab/2026/09/01/gone.png", status="deleted")
|
||
|
||
assert cleanup_script.find_orphans(db, days=30) == []
|
||
|
||
|
||
def test_dry_run_writes_nothing(db, cleanup_script):
|
||
orphan = make_asset(db, "collab/2026/09/01/orphan.png")
|
||
storage = FakeStorage()
|
||
|
||
result = cleanup_script.run(db, apply=False, days=30)
|
||
|
||
assert result["candidates"] == 1
|
||
assert result["deleted"] == 0
|
||
assert result["freed_bytes"] == 1024
|
||
assert storage.deleted == []
|
||
assert db.query(FileAsset).filter(FileAsset.id == orphan.id).count() == 1
|
||
|
||
|
||
def test_apply_deletes_original_and_variants_then_the_row(
|
||
db, cleanup_script, monkeypatch
|
||
):
|
||
make_asset(
|
||
db,
|
||
"collab/2026/09/01/orphan.png",
|
||
thumb="collab/2026/09/01/orphan.thumb.webp",
|
||
medium="collab/2026/09/01/orphan.medium.jpg",
|
||
)
|
||
storage = FakeStorage()
|
||
monkeypatch.setattr(
|
||
file_storage, "get_object_storage", lambda settings=None: storage
|
||
)
|
||
|
||
result = cleanup_script.run(db, apply=True, days=30)
|
||
|
||
assert result["deleted"] == 1
|
||
assert result["failed"] == 0
|
||
assert storage.deleted == [
|
||
"collab/2026/09/01/orphan.png",
|
||
"collab/2026/09/01/orphan.thumb.webp",
|
||
"collab/2026/09/01/orphan.medium.jpg",
|
||
]
|
||
assert db.query(FileAsset).count() == 0
|
||
|
||
|
||
def test_failed_object_delete_keeps_the_ledger_row(db, cleanup_script, monkeypatch):
|
||
"""对象没删掉就绝不能删台账行 —— 否则再也没人知道这个对象存在。"""
|
||
orphan = make_asset(db, "collab/2026/09/01/orphan.png")
|
||
storage = FakeStorage(fail_keys={orphan.object_key})
|
||
monkeypatch.setattr(
|
||
file_storage, "get_object_storage", lambda settings=None: storage
|
||
)
|
||
|
||
result = cleanup_script.run(db, apply=True, days=30)
|
||
|
||
assert result["deleted"] == 0
|
||
assert result["failed"] == 1
|
||
assert db.query(FileAsset).filter(FileAsset.id == orphan.id).count() == 1
|
||
|
||
|
||
def test_referenced_asset_survives_a_full_apply(db, cleanup_script, monkeypatch):
|
||
referenced = make_asset(db, "collab/2026/09/01/ref.png")
|
||
make_order(db, "A1", [referenced.object_key])
|
||
make_asset(db, "collab/2026/09/01/orphan.png")
|
||
storage = FakeStorage()
|
||
monkeypatch.setattr(
|
||
file_storage, "get_object_storage", lambda settings=None: storage
|
||
)
|
||
|
||
cleanup_script.run(db, apply=True, days=30)
|
||
|
||
remaining = [asset.object_key for asset in db.query(FileAsset).all()]
|
||
assert remaining == [referenced.object_key]
|
||
assert storage.deleted == ["collab/2026/09/01/orphan.png"]
|