`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。
## 收敛成四值 + 三列一义
`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:
| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |
- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
(动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。
## 回填不猜(设计文档 §5.5)
只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。
## ⚠️ 顺带救回一个差点被我自己丢掉的状态
「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。
## 前端不再从任务日志反推账号状态
工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。
现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。
## 踩到并修掉的东西
- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
干脆改成源码级解析比对(不造数据)。
## 验证
- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)
未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
108 lines
3.8 KiB
Python
108 lines
3.8 KiB
Python
"""斗鱼写路径契约:锁单/支付编排走 mock 客户端,不打真实兑换。"""
|
|
|
|
from unittest.mock import Mock
|
|
|
|
from tests.support.db import get_test_engine, make_test_session
|
|
from web.backend.models import Account, DouyuGoodsSnapshot, DouyuTask, User
|
|
from web.backend.services.douyu_runner import DouyuBatchRunner
|
|
|
|
|
|
class TestDouyuWritePathContracts:
|
|
def setup_method(self):
|
|
self.engine = get_test_engine()
|
|
self.session = make_test_session(self.engine)
|
|
self.user = User(username="operator", password_hash="hash", role="super_admin")
|
|
self.account = Account(
|
|
username="dyuser",
|
|
password="pw",
|
|
email="a@example.com",
|
|
email_password="em",
|
|
)
|
|
self.session.add_all([self.user, self.account])
|
|
self.session.commit()
|
|
self.session.add(
|
|
DouyuGoodsSnapshot(
|
|
commodity_id="bag10",
|
|
name="套装-兔兔白日梦",
|
|
raw={"name": "套装-兔兔白日梦"},
|
|
)
|
|
)
|
|
self.session.commit()
|
|
|
|
def teardown_method(self):
|
|
self.session.close()
|
|
|
|
def _runner(self, task_type: str) -> DouyuBatchRunner:
|
|
return DouyuBatchRunner(db=self.session, batch_id="batch1", task_type=task_type)
|
|
|
|
def _task(self, task_type: str, payload: dict) -> DouyuTask:
|
|
task = DouyuTask(
|
|
batch_id="batch1",
|
|
account_id=self.account.id,
|
|
task_type=task_type,
|
|
handbook_scope="elite",
|
|
status="running",
|
|
result={"payload": payload},
|
|
created_by=self.user.id,
|
|
)
|
|
self.session.add(task)
|
|
self.session.commit()
|
|
return task
|
|
|
|
def test_lock_goods_records_order_without_paying(self):
|
|
task = self._task("lock_goods", {"commodity_id": "bag10", "num": 1})
|
|
runner = self._runner("lock_goods")
|
|
client = Mock()
|
|
client.create_exchange_order.return_value = {
|
|
"order_id": "5874",
|
|
"expire_seconds": "300",
|
|
}
|
|
runner._client = Mock(return_value=client)
|
|
|
|
runner._execute_lock_goods(
|
|
self.session,
|
|
task,
|
|
self.account,
|
|
"acf_uid=100",
|
|
{"manual_id": "G4KA4Qnz4LDp7", "rid": "9263298"},
|
|
)
|
|
|
|
self.session.refresh(task)
|
|
self.session.refresh(self.account)
|
|
assert task.status == "success"
|
|
assert task.result["order_id"] == "5874"
|
|
# 锁单是**流程痕迹**,不是账号状态 —— S6 起不再写 bind_status
|
|
# (动作本身在 douyu_tasks 与 account_events 里都能查)。
|
|
assert self.account.bind_status != "goods_locked"
|
|
client.create_exchange_order.assert_called_once()
|
|
client.pay_exchange_order.assert_not_called()
|
|
|
|
def test_pay_locked_order_uses_existing_order_id(self):
|
|
task = self._task(
|
|
"pay_locked_order",
|
|
{"order_id": "5874", "commodity_id": "bag10"},
|
|
)
|
|
runner = self._runner("pay_locked_order")
|
|
client = Mock()
|
|
client.pay_exchange_order.return_value = {
|
|
"exchange_id": "11098933",
|
|
"commodity_image": "https://example.test/item.png",
|
|
"exchange_num": 1,
|
|
}
|
|
runner._client = Mock(return_value=client)
|
|
runner._refresh_account_points = Mock(return_value={"points": 1280})
|
|
|
|
runner._execute_pay_locked_order(
|
|
self.session,
|
|
task,
|
|
self.account,
|
|
"acf_uid=100",
|
|
{"manual_id": "G4KA4Qnz4LDp7", "rid": "9263298"},
|
|
)
|
|
|
|
self.session.refresh(task)
|
|
assert task.status == "success"
|
|
assert task.result["exchange_id"] == "11098933"
|
|
client.pay_exchange_order.assert_called_once()
|
|
client.create_exchange_order.assert_not_called()
|