`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 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""斗鱼鱼翅充值渠道分流测试。"""
|
|
|
|
import asyncio
|
|
import os
|
|
from unittest.mock import ANY, AsyncMock, Mock, patch
|
|
|
|
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
|
|
from tests.support.db import get_test_engine, make_test_session
|
|
from web.backend.models import Account, AuditLog, DouyuTask, User
|
|
from web.backend.routers.douyu import get_recharge_channel, supplier_recharge_callback
|
|
from web.backend.services.audit_service import record_audit
|
|
from web.backend.services.douyu_runner import DouyuBatchRunner
|
|
from web.backend.services.douyu_service import ensure_douyu_config
|
|
|
|
|
|
class TestDouyuGoldRechargeChannel:
|
|
def setup_method(self):
|
|
self.engine = get_test_engine()
|
|
self.session = make_test_session(self.engine)
|
|
user = User(username="operator", password_hash="hash", role="super_admin")
|
|
self.session.add(user)
|
|
self.session.commit()
|
|
self.account = Account(
|
|
username="douyu-user",
|
|
password="password",
|
|
email="mail@example.com",
|
|
email_password="mail-password",
|
|
uid="10001",
|
|
nickname="罗炅729",
|
|
)
|
|
self.session.add(self.account)
|
|
self.session.commit()
|
|
self.task = DouyuTask(
|
|
batch_id="batch",
|
|
account_id=self.account.id,
|
|
task_type="create_gold_qr",
|
|
handbook_scope="elite",
|
|
status="running",
|
|
created_by=user.id,
|
|
)
|
|
self.session.add(self.task)
|
|
self.session.commit()
|
|
self.runner = DouyuBatchRunner(self.session, "batch", "create_gold_qr")
|
|
os.environ["FISH_FIN_RECHARGE_BASE_URL"] = "https://supplier.example"
|
|
os.environ["FISH_FIN_RECHARGE_APP_ID"] = "test-app"
|
|
os.environ["FISH_FIN_RECHARGE_APP_SECRET"] = "test-secret"
|
|
|
|
def teardown_method(self):
|
|
self.session.close()
|
|
|
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
|
def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(
|
|
self, client_class
|
|
):
|
|
supplier = Mock()
|
|
supplier.create_order.return_value = {
|
|
"code": 200,
|
|
"result": {"order_status": 2, "order_id": "supplier-001"},
|
|
"sign": "not-stored",
|
|
}
|
|
client_class.return_value = supplier
|
|
config = {
|
|
"gold_recharge_channel": "supplier_api",
|
|
"gold_api_product_id": "gold-product",
|
|
"gold_api_account_template_name": "斗鱼账号",
|
|
}
|
|
|
|
self.task.result = {"payload": {"amount": 10}}
|
|
self.session.commit()
|
|
self.runner._execute_create_gold_qr(
|
|
self.session,
|
|
self.task,
|
|
self.account,
|
|
"acf_uid=10001",
|
|
config,
|
|
)
|
|
|
|
expected_order_id = self.runner._supplier_out_order_id(self.task)
|
|
supplier.create_order.assert_called_once_with(
|
|
buy_num=10,
|
|
pay_amount=ANY,
|
|
out_order_id=expected_order_id,
|
|
product_id="gold-product",
|
|
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "罗炅729"}],
|
|
order_type=0,
|
|
notify_url=ANY,
|
|
)
|
|
assert str(supplier.create_order.call_args.kwargs["pay_amount"]) == "10"
|
|
self.session.refresh(self.task)
|
|
assert self.task.status == "success"
|
|
task_result = self.task.result
|
|
assert isinstance(task_result, dict)
|
|
assert task_result["recharge_channel"] == "supplier_api"
|
|
assert task_result["supplier_order_status"] == 2
|
|
assert task_result["buy_num"] == 10
|
|
assert task_result["out_order_id"] == expected_order_id
|
|
assert task_result["recharge_account"] == "罗炅729"
|
|
assert task_result["douyu_uid"] == "10001"
|
|
assert task_result["order_id"] == "supplier-001"
|
|
assert task_result["pay_amount"] == "10"
|
|
assert "pay_url" not in task_result
|
|
assert "sign" not in task_result["supplier_order"]
|
|
|
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
|
def test_supplier_channel_rejects_account_without_nickname(self, client_class):
|
|
self.account.nickname = ""
|
|
self.session.commit()
|
|
config = {
|
|
"gold_recharge_channel": "supplier_api",
|
|
"gold_api_product_id": "gold-product",
|
|
"gold_api_account_template_name": "斗鱼昵称",
|
|
}
|
|
|
|
self.runner._execute_create_gold_qr(
|
|
self.session,
|
|
self.task,
|
|
self.account,
|
|
"acf_uid=10001",
|
|
config,
|
|
)
|
|
|
|
client_class.assert_not_called()
|
|
self.session.refresh(self.task)
|
|
assert self.task.status == "failed"
|
|
assert "斗鱼昵称" in self.task.message
|
|
|
|
def test_supplier_out_order_id_reuses_persisted_value(self):
|
|
self.task.supplier_out_order_id = "DYGFHISTORICAL-001"
|
|
assert self.runner._supplier_out_order_id(self.task) == "DYGFHISTORICAL-001"
|
|
|
|
def test_recharge_channel_can_be_read_without_full_config(self):
|
|
config = ensure_douyu_config(self.session)
|
|
config.gold_recharge_channel = "supplier_api"
|
|
self.session.commit()
|
|
|
|
channel = get_recharge_channel(
|
|
db=self.session,
|
|
current=self.session.get(User, self.task.created_by),
|
|
)
|
|
|
|
assert channel == {"gold_recharge_channel": "supplier_api"}
|
|
|
|
def test_recharge_audit_detail_includes_safe_account_identity(self):
|
|
record_audit(
|
|
self.session,
|
|
self.session.get(User, self.task.created_by),
|
|
action="recharge:douyu:create",
|
|
detail={
|
|
"recharge_accounts": [
|
|
{
|
|
"username": self.account.username,
|
|
"douyu_uid": self.account.uid,
|
|
"douyu_nickname": self.account.nickname,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
self.session.commit()
|
|
|
|
audit = self.session.query(AuditLog).one()
|
|
assert "douyu_uid" in audit.detail
|
|
assert "10001" in audit.detail
|
|
assert "password" not in audit.detail
|
|
|
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
|
def test_supplier_channel_marks_non_200_create_response_as_failed(
|
|
self, client_class
|
|
):
|
|
supplier = Mock()
|
|
supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"}
|
|
client_class.return_value = supplier
|
|
config = {
|
|
"gold_recharge_channel": "supplier_api",
|
|
"gold_api_product_id": "gold-product",
|
|
"gold_api_account_template_name": "斗鱼UID",
|
|
}
|
|
|
|
self.runner._execute_create_gold_qr(
|
|
self.session,
|
|
self.task,
|
|
self.account,
|
|
"acf_uid=10001",
|
|
config,
|
|
)
|
|
|
|
self.session.refresh(self.task)
|
|
assert self.task.status == "failed"
|
|
assert self.task.message == "商品已下架"
|
|
|
|
@patch("web.backend.services.douyu_runner.check_douyu_cookie")
|
|
@patch("web.backend.services.douyu_runner.SessionLocal")
|
|
def test_invalid_cookie_skips_activity_and_marks_relogin_required(
|
|
self, session_local, check_cookie
|
|
):
|
|
# CK 挂在账号上(2026-10-04 CK 解耦)
|
|
self.account.cookie = "acf_uid=10001"
|
|
self.task.status = "planned"
|
|
self.session.commit()
|
|
task_id = self.task.id
|
|
account_id = self.account.id
|
|
session_local.return_value = self.session
|
|
check_cookie.return_value = {
|
|
"valid": False,
|
|
"message": "鱼丸接口: 未登录;等级接口: 未登录",
|
|
"fish_ball": None,
|
|
"nickname": None,
|
|
"level": None,
|
|
"checked_at": None,
|
|
}
|
|
self.runner._execute_create_gold_qr = Mock()
|
|
|
|
self.runner._execute_one(task_id, {}, 1)
|
|
|
|
self.runner._execute_create_gold_qr.assert_not_called()
|
|
verify_session = make_test_session(self.engine)
|
|
try:
|
|
task = verify_session.get(DouyuTask, task_id)
|
|
checked_account = verify_session.get(Account, account_id)
|
|
assert task is not None
|
|
assert checked_account is not None
|
|
assert task.status == "failed"
|
|
assert "Cookie 已失效,请重新登录" in task.message
|
|
# 检测结论写在**账号**上,CK 本体不动
|
|
assert checked_account.ck_check_status == "invalid"
|
|
check_result = checked_account.ck_check_result
|
|
assert isinstance(check_result, dict)
|
|
assert check_result["message"] == "鱼丸接口: 未登录;等级接口: 未登录"
|
|
assert checked_account.cookie == "acf_uid=10001"
|
|
finally:
|
|
verify_session.close()
|
|
|
|
def test_supplier_callback_verifies_signature_and_updates_terminal_task(self):
|
|
self.task.supplier_out_order_id = f"DYGFBATCHT{self.task.id}"
|
|
self.task.result = {
|
|
"recharge_channel": "supplier_api",
|
|
"out_order_id": self.task.supplier_out_order_id,
|
|
}
|
|
self.session.commit()
|
|
client = FishFinRechargeClient(
|
|
FishFinRechargeConfig(
|
|
base_url="https://supplier.example",
|
|
app_id="test-app",
|
|
app_secret="test-secret",
|
|
)
|
|
)
|
|
payload = {
|
|
"app_id": "test-app",
|
|
"timestamp": 1786629000,
|
|
"out_order_id": self.task.supplier_out_order_id,
|
|
"order_id": "supplier-001",
|
|
"order_type": 0,
|
|
"order_status": 2,
|
|
"create_time": "2026/8/13 21:30",
|
|
}
|
|
payload["sign"] = client.sign(payload, "POST")
|
|
request = Mock()
|
|
request.json = AsyncMock(return_value=payload)
|
|
|
|
response = asyncio.run(supplier_recharge_callback(request, self.session))
|
|
|
|
assert response["code"] == 200
|
|
assert response["message"] == "success"
|
|
assert client.verify_response_sign(response, "POST")
|
|
self.session.refresh(self.task)
|
|
assert self.task.status == "success"
|
|
assert self.task.message == "供应商直充成功(异步通知)"
|
|
self.session.refresh(self.account)
|
|
# 回调到账的事实现在进**账号时间线**(S5 直发事件),不再写 bind_status。
|
|
from web.backend.models import AccountEvent
|
|
|
|
events = (
|
|
self.session.query(AccountEvent)
|
|
.filter(
|
|
AccountEvent.account_id == self.account.id,
|
|
AccountEvent.event_type == "recharge",
|
|
)
|
|
.all()
|
|
)
|
|
assert len(events) == 1, "回调成功必须补一条充值事件"
|
|
assert events[0].detail["channel"] == "supplier_api"
|
|
task_result = self.task.result
|
|
assert isinstance(task_result, dict)
|
|
assert task_result["order_id"] == "supplier-001"
|
|
assert task_result["supplier_order_status"] == 2
|
|
audit = (
|
|
self.session.query(AuditLog)
|
|
.filter(AuditLog.action == "recharge:douyu:callback")
|
|
.one()
|
|
)
|
|
assert audit.username == "supplier"
|
|
assert audit.success
|
|
assert "sign" not in audit.detail
|
|
# 同一终态回调可重复投递,不会改写已结束任务。
|
|
asyncio.run(supplier_recharge_callback(request, self.session))
|
|
self.session.refresh(self.task)
|
|
assert self.task.status == "success"
|