feat(douyu): 代理写读分离 — 仅兑换/锁单/支付/开通/充值/送礼走代理

此前代理配置页开启后所有斗鱼任务(含查积分/刷列表等只读任务)统一走代理:
- API 模式每任务 fetch 新代理, 查询任务白白消耗代理额度
- 查询任务失败面被代理抖动放大, 但查询本不怕 IP 频控

新策略 (DOUYU_PROXY_TASK_TYPES 白名单):
- 写操作/消耗类: lock/pay/exchange_goods, exchange_esports/xpd_goods,
  create_elite/esports/gold_qr, donate_* 走代理 (静态模式或 API 每任务新代理)
- 读操作类: query_*/refresh_*/get_*_bind_qr 等始终直连, 不消耗额度
- 判型用批次 task_type (同批次同类型, 与任务一致); 总开关仍为代理配置页

测试: 新增读任务直连/白名单覆盖断言, 三模式行为断言保留, 101 单测通过
This commit is contained in:
yml2213
2026-08-30 11:28:10 +08:00
parent b9eb944d2f
commit 3b3a4bebcb
2 changed files with 74 additions and 21 deletions
+37 -10
View File
@@ -1,4 +1,4 @@
"""斗鱼任务代理接入三模式行为测试(静态 / API / 关闭)。"""
"""斗鱼任务代理接入三模式行为测试(静态 / API / 关闭)+ 写读分离"""
import os
import unittest
@@ -9,7 +9,7 @@ os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODl
from core.douyu.activity_client import DouyuActivityClient
from web.backend.services.douyu_runner import DouyuBatchRunner
from web.backend.services.douyu_runner_core import DouyuBatchRunnerCore
from web.backend.services.douyu_runner_core import DouyuBatchRunnerCore, DOUYU_PROXY_TASK_TYPES
from web.backend.models import ProxyConfig
@@ -32,17 +32,17 @@ def _cfg(**overrides) -> ProxyConfig:
return cfg
def _runner(db_cfg: ProxyConfig):
def _runner(db_cfg: ProxyConfig, task_type: str = "query_points"):
db = Mock()
db.query.return_value.first.return_value = db_cfg
runner = DouyuBatchRunner.__new__(DouyuBatchRunner)
DouyuBatchRunnerCore.__init__(runner, db, "batch", "query_points")
DouyuBatchRunnerCore.__init__(runner, db, "batch", task_type)
return runner
class DouyuProxyModeTests(unittest.TestCase):
def test_disabled_mode_goes_direct(self):
runner = _runner(_cfg(enabled=False))
runner = _runner(_cfg(enabled=False), task_type="exchange_goods")
self.assertIsNone(runner._proxies_for_task())
self.assertIsNone(runner._proxy_fetcher)
client = runner._client("acf_uid=100")
@@ -50,8 +50,8 @@ class DouyuProxyModeTests(unittest.TestCase):
# 未开启代理时 execute 客户端不带 proxies 参数
self.assertIsInstance(client, DouyuActivityClient)
def test_static_mode_uses_configured_proxies(self):
runner = _runner(_cfg(enabled=True, http="http://10.0.0.1:8080"))
def test_static_mode_uses_configured_proxies_for_write_tasks(self):
runner = _runner(_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="exchange_goods")
proxies = runner._proxies_for_task()
self.assertEqual(proxies, {"http": "http://10.0.0.1:8080", "https": "http://10.0.0.1:8080"})
client = runner._client("acf_uid=100")
@@ -59,9 +59,9 @@ class DouyuProxyModeTests(unittest.TestCase):
# 每次任务都取同一静态代理
self.assertEqual(runner._proxies_for_task(), proxies)
def test_api_mode_fetches_new_proxy_per_task(self):
def test_api_mode_fetches_new_proxy_per_write_task(self):
cfg = _cfg(enabled=True, api_url="https://proxy-api.example/fetch")
runner = _runner(cfg)
runner = _runner(cfg, task_type="exchange_goods")
self.assertIsNotNone(runner._proxy_fetcher)
fake = Mock()
fake.fetch_new_proxy.return_value = "http://10.0.0.2:3128"
@@ -78,7 +78,7 @@ class DouyuProxyModeTests(unittest.TestCase):
self.assertEqual(fake.fetch_new_proxy.call_count, 3)
def test_api_mode_failure_falls_back_to_direct(self):
runner = _runner(_cfg(enabled=True, api_url="https://proxy-api.example/fetch"))
runner = _runner(_cfg(enabled=True, api_url="https://proxy-api.example/fetch"), task_type="exchange_goods")
runner._proxy_fetcher = Mock()
runner._proxy_fetcher.fetch_new_proxy.return_value = None
runner._push_log = Mock()
@@ -88,6 +88,33 @@ class DouyuProxyModeTests(unittest.TestCase):
self.assertEqual(client.session.proxies, {})
runner._push_log.assert_called()
def test_read_tasks_stay_direct_even_with_proxy_enabled(self):
"""写读分离:查询/刷新/绑定等读任务即使代理开启也直连,不消耗代理额度。"""
runner = _runner(_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="query_points")
self.assertIsNotNone(runner._static_proxies)
self.assertIsNone(runner._proxies_for_task())
client = runner._client("acf_uid=100")
self.assertEqual(client.session.proxies, {})
# API 模式同理:读任务不触发 fetch
runner = _runner(_cfg(enabled=True, api_url="https://proxy-api.example/fetch"), task_type="query_exchange_records")
runner._proxy_fetcher = Mock()
runner._proxy_fetcher.fetch_new_proxy.side_effect = AssertionError("读任务不应取代理")
self.assertIsNone(runner._proxies_for_task())
def test_proxy_task_types_covers_all_write_tasks(self):
"""白名单集合应覆盖全部会消耗/下单的写操作任务,且不含查询类。"""
self.assertIn("exchange_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("lock_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("pay_locked_order", DOUYU_PROXY_TASK_TYPES)
self.assertIn("exchange_esports_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("exchange_xpd_goods", DOUYU_PROXY_TASK_TYPES)
self.assertIn("create_gold_qr", DOUYU_PROXY_TASK_TYPES)
self.assertIn("donate_elite_gift", DOUYU_PROXY_TASK_TYPES)
self.assertNotIn("query_points", DOUYU_PROXY_TASK_TYPES)
self.assertNotIn("refresh_goods", DOUYU_PROXY_TASK_TYPES)
self.assertNotIn("get_bind_qr", DOUYU_PROXY_TASK_TYPES)
def test_activity_client_accepts_proxies_at_session_level(self):
client = DouyuActivityClient(
"acf_uid=100",