Files
live-hub-py/tests/test_backfill_official_exchange_subject_key.py
yml2213 18bc7be4b1 feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条
「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。
旧路径只做并集与可选参数扩展,行为不变(有用例守着)。

- 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决),
  assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」
- account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、
  供给与成本预估、租约领取/释放/回收
- 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换;
  运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过)
- 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还;
  account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次
- 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道
  与租赁记录
- 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台)
- 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空),
  会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本
- 迁移 20261007_0052;权限 account:pool_use / account:pool_admin

测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。
设计稿与实施记录:docs/设计-账号自动分配与回收.md
2026-10-07 12:29:08 +08:00

138 lines
4.7 KiB
Python

"""D1 回填脚本:把官方补录事件的 `subject_key` 补上商品 ID。
脚本是一次性数据修复,但它是**生产数据**的修复 —— 写错了会污染台账,
所以守住三件事:幂等、只填空值、dry-run 不写库。
"""
from __future__ import annotations
import importlib.util
import sys
from datetime import UTC, datetime
from pathlib import Path
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent
SCRIPT_PATH = (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "backfill_official_exchange_subject_key.py"
)
NOW = datetime(2026, 10, 7, 3, 0, 0, tzinfo=UTC)
def _load_script():
spec = importlib.util.spec_from_file_location(
"backfill_official_exchange_subject_key", SCRIPT_PATH
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def script():
return _load_script()
def _run(script, monkeypatch, *argv: str) -> int:
monkeypatch.setattr(sys, "argv", [str(SCRIPT_PATH), *argv])
return script.main()
class TestBackfillOfficialExchangeSubjectKey:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.account = Account(
username="bf-acc", password="p", email="b@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.flush()
self.account_id = self.account.id
def teardown_method(self):
self.session.close()
def _add_event(self, *, commodity_id: str | None, subject_key: str = "") -> int:
event = AccountEvent(
account_id=self.account_id,
event_type="exchange",
category="business",
occurred_at=NOW,
title="兑换 x",
amount=-1280,
subject_type="goods",
subject_key=subject_key,
detail=(
{"source": "official_sync", "commodity_id": commodity_id}
if commodity_id is not None
else {"source": "official_sync"}
),
source="official_sync",
)
self.session.add(event)
self.session.commit()
return event.id
def _reload(self, event_id: int) -> AccountEvent:
"""重新读一行 —— **必须先 rollback 结束当前事务**。
脚本用的是**另一个会话**,在 MySQL REPEATABLE READ 下,本会话若沿用旧快照,
`expire_all()` 也只会拿到事务开始时的老值:脚本改没改都显示没改,
于是「dry-run 不写库」这类断言会**假绿**。
"""
self.session.rollback()
row = self.session.get(AccountEvent, event_id)
assert row is not None
return row
def test_dry_run_writes_nothing(self, script, monkeypatch):
event_id = self._add_event(commodity_id="20260120QYOOB_bag10")
assert _run(script, monkeypatch, "--dry-run") == 0
assert self._reload(event_id).subject_key == "", "dry-run 不该写库"
def test_apply_fills_subject_key(self, script, monkeypatch):
event_id = self._add_event(commodity_id="20260120QYOOB_bag10")
assert _run(script, monkeypatch, "--apply") == 0
row = self._reload(event_id)
assert row.subject_key == "20260120QYOOB_bag10"
assert row.subject_type == "commodity"
def test_is_idempotent(self, script, monkeypatch):
event_id = self._add_event(commodity_id="20260120QYOOB_bag10")
assert _run(script, monkeypatch, "--apply") == 0
# 第二遍没有可回填的行,应当直接退出且不改动
assert _run(script, monkeypatch, "--apply") == 0
assert self._reload(event_id).subject_key == "20260120QYOOB_bag10"
def test_does_not_overwrite_existing_subject_key(self, script, monkeypatch):
"""只填空值 —— 已有值(哪怕看着不对)不由脚本判断。"""
event_id = self._add_event(
commodity_id="20260120QYOOB_bag10", subject_key="手工填的值"
)
assert _run(script, monkeypatch, "--apply") == 0
assert self._reload(event_id).subject_key == "手工填的值"
def test_skips_events_without_commodity_id(self, script, monkeypatch):
"""没有 `detail.commodity_id` 的行不动,也不该报失败退出码。"""
event_id = self._add_event(commodity_id=None)
# 没有可回填的行 → 正常退出(不因为「缺 commodity_id」而误报)
assert _run(script, monkeypatch, "--apply") == 0
assert self._reload(event_id).subject_key == ""