refactor(douyu): runner 按功能域拆分 Mixin + 精英手册兑换对齐真机流程 (HAR 审核通过)
拆分: douyu_runner.py (-3186行) → core/bind/manual/gold/donate/goods/xpd 七个 Mixin + 入口聚合类, douyu_batch_registry 供 routers 重导出 精英手册兑换对齐 HAR 抓包 (activity_client +379 行): - csrf 复用 Cookie 值不再每次 generateCsrf (HAR 实测全程零 csrf 请求), 仅服务端报 csrf 错误时 force_refresh 重试一次 - pay Referer 补 roomId 对齐浏览器 - 新增手册链路接口: elite_user_info/storedetail/pre_exchange check+confirm/ subscribe/batch_limit/赠品兑换/自动转换 - resolve_exchange_plan: 浏览器兑换按钮状态机路由 (normal/subscribe/ pre_exchange/wait/blocked) - 兑换执行: 人类节奏抖动 → 锁单(火爆重试1次) → 支付分级退避 (频控 30/60/90s 4次上限, 普错 2/5/10/20s 5次上限, 300s 锁单期 15s 余量, 可中断睡眠) 测试: 新增 17 项 (csrf 复用语义/状态机路由/Referer), 全量 94 通过; 未夹带代理改动
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, call
|
||||
|
||||
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
||||
|
||||
|
||||
class CsrfReuseTests(unittest.TestCase):
|
||||
def test_reuses_cookie_token_without_any_http_call(self):
|
||||
client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=existing-token")
|
||||
client._request_json = Mock()
|
||||
|
||||
self.assertEqual(client.csrf_token(), "existing-token")
|
||||
client._request_json.assert_not_called()
|
||||
|
||||
def test_generates_csrf_when_cookie_missing(self):
|
||||
client = DouyuActivityClient("acf_uid=100")
|
||||
|
||||
def fake_generate(*_args, **_kwargs):
|
||||
# 模拟 generateCsrf 响应把新 token 写回 Cookie
|
||||
client._set_cookie_value("cvl_csrf_token", "new-token")
|
||||
return {"error": 0}
|
||||
|
||||
client._request_json = Mock(side_effect=fake_generate)
|
||||
|
||||
self.assertEqual(client.csrf_token(), "new-token")
|
||||
client._request_json.assert_called_once()
|
||||
|
||||
def test_force_refresh_rotates_token(self):
|
||||
client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=old-token")
|
||||
client._request_json = Mock(return_value={"error": 0})
|
||||
client._set_cookie_value("cvl_csrf_token", "rotated-token")
|
||||
|
||||
self.assertEqual(client.csrf_token(force_refresh=True), "rotated-token")
|
||||
|
||||
def test_refreshes_csrf_once_on_token_error(self):
|
||||
client = DouyuActivityClient("acf_uid=100")
|
||||
client.csrf_token = Mock(side_effect=["bad-token", "good-token"])
|
||||
client._request_json = Mock(side_effect=[
|
||||
{"error": 1, "msg": "csrfToken 校验失败"},
|
||||
{"error": 0, "data": {"orderId": "9", "expireSeconds": "300"}},
|
||||
])
|
||||
|
||||
result = client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
||||
|
||||
self.assertEqual(result["order_id"], "9")
|
||||
self.assertEqual(client.csrf_token.call_args_list, [call(), call(force_refresh=True)])
|
||||
|
||||
def test_no_refresh_on_regular_error(self):
|
||||
client = DouyuActivityClient("acf_uid=100")
|
||||
client.csrf_token = Mock(return_value="t")
|
||||
client._request_json = Mock(return_value={"error": 1, "msg": "需要开通精英手册,才可以兑换本道具哦"})
|
||||
|
||||
with self.assertRaisesRegex(DouyuActivityError, "需要开通精英手册"):
|
||||
client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
||||
client.csrf_token.assert_called_once_with()
|
||||
|
||||
def test_pay_referer_includes_room_id_like_browser(self):
|
||||
client = DouyuActivityClient("acf_uid=100")
|
||||
client.csrf_token = Mock(return_value="t")
|
||||
client._request_json = Mock(return_value={"error": 0, "data": {"exchangeId": "11098933"}})
|
||||
|
||||
client.pay_exchange_order(manual_id="m", order_id="5874", rid="9263298")
|
||||
|
||||
headers = client._request_json.call_args.kwargs["headers"]
|
||||
self.assertEqual(
|
||||
headers["Referer"],
|
||||
"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298",
|
||||
)
|
||||
|
||||
|
||||
class ExchangePlanTests(unittest.TestCase):
|
||||
"""对齐浏览器兑换按钮状态机($e / Ve / Ge)的路由判定。"""
|
||||
|
||||
@staticmethod
|
||||
def detail(**overrides):
|
||||
base = {
|
||||
"status": 0,
|
||||
"storeStatus": 1,
|
||||
"openStatus": 1,
|
||||
"roomLimited": 0,
|
||||
"isLimitRoom": 0,
|
||||
"eliteLimited": 0,
|
||||
"subscribeType": 0,
|
||||
"exchangeInfo": {"userStatus": 0},
|
||||
"preExchange": {"isExists": 0, "expiringTime": "0"},
|
||||
"batchExchange": 0,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_normal_item_uses_classic_flow(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail())
|
||||
self.assertEqual(plan["action"], "classic")
|
||||
self.assertEqual(plan["max_num"], 1)
|
||||
|
||||
def test_pre_exchange_order_still_classic(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(preExchange={"isExists": 1}))
|
||||
self.assertEqual(plan["action"], "classic")
|
||||
|
||||
def test_reached_exchange_limit_blocked(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(status=1))
|
||||
self.assertEqual(plan["action"], "blocked")
|
||||
self.assertIn("上限", plan["text"])
|
||||
|
||||
def test_no_stock_blocked(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(storeStatus=0))
|
||||
self.assertEqual(plan["action"], "blocked")
|
||||
self.assertIn("无库存", plan["text"])
|
||||
|
||||
def test_room_limited_and_not_in_room_blocked(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||
self.detail(roomLimited=1, isLimitRoom=0)
|
||||
)
|
||||
self.assertEqual(plan["action"], "blocked")
|
||||
self.assertIn("限定房间", plan["text"])
|
||||
|
||||
def test_not_open_subscribe_item_subscribes(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||
self.detail(openStatus=0, subscribeType=1)
|
||||
)
|
||||
self.assertEqual(plan["action"], "subscribe")
|
||||
|
||||
def test_not_open_pre_exchange_item_pre_exchanges(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||
self.detail(openStatus=0, subscribeType=2)
|
||||
)
|
||||
self.assertEqual(plan["action"], "pre_exchange")
|
||||
|
||||
def test_not_open_plain_item_blocked(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(openStatus=0))
|
||||
self.assertEqual(plan["action"], "blocked")
|
||||
self.assertIn("未开启", plan["text"])
|
||||
|
||||
def test_elite_limited_requires_manual(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||
self.detail(eliteLimited=1), manual_type=0
|
||||
)
|
||||
self.assertEqual(plan["action"], "blocked")
|
||||
self.assertIn("精英手册", plan["text"])
|
||||
# 已开通手册(manualType=1)不受精英专享限制
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||
self.detail(eliteLimited=1), manual_type=1
|
||||
)
|
||||
self.assertEqual(plan["action"], "classic")
|
||||
|
||||
def test_pre_exchanged_waits_for_open(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||
self.detail(subscribeType=2, exchangeInfo={"userStatus": 1})
|
||||
)
|
||||
self.assertEqual(plan["action"], "wait")
|
||||
|
||||
def test_batch_exchange_clamps_num(self):
|
||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||
self.detail(batchExchange=1), batch_num_limit="5"
|
||||
)
|
||||
self.assertEqual(plan["max_num"], 5)
|
||||
self.assertTrue(plan["batch_exchange"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user