Files
live-hub-py/tests/test_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

181 lines
5.4 KiB
Python
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.
"""协作台账孤儿图清理脚本:只删「过期 + 没人引用」的图,且删对象成功才删台账行。
脚本本体在 ``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"]