在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 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
138 lines
4.7 KiB
Python
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 == ""
|