diff --git a/core/douyu/__init__.py b/core/douyu/__init__.py index b1a85d1..ca911ab 100644 --- a/core/douyu/__init__.py +++ b/core/douyu/__init__.py @@ -1,5 +1,7 @@ """斗鱼登录模块""" +from loguru import logger + from .activity_client import DouyuActivityClient, DouyuActivityError from .email_verifier import EmailVerifier from .login import CredentialError, DouyuLogin @@ -13,7 +15,34 @@ from .recharge_api import ( FishFinRechargeError, ) +#: 登录接口策略名与实现类的映射;登录任务与账号检测共用同一套取值。 +LOGIN_API_STRATEGIES: dict[str, type[LoginAPIStrategy]] = { + "wgapi": WgapiLoginAPI, + "iframe": IframeLoginAPI, +} + +DEFAULT_LOGIN_API_STRATEGY = "wgapi" + + +def create_login_api_strategy(name: str) -> LoginAPIStrategy: + """按名称创建登录接口策略实例。 + + 只认 ``wgapi`` / ``iframe``;空值或未知值一律回退默认的 ``wgapi`` + (未知值会记一条 warning —— 静默换成另一套接口会让人查不到原因)。 + """ + key = str(name or "").strip().lower() + if key in LOGIN_API_STRATEGIES: + return LOGIN_API_STRATEGIES[key]() + if key: + logger.warning( + f"未知的登录接口策略 {name!r},回退 {DEFAULT_LOGIN_API_STRATEGY}" + ) + return LOGIN_API_STRATEGIES[DEFAULT_LOGIN_API_STRATEGY]() + + __all__ = [ + "DEFAULT_LOGIN_API_STRATEGY", + "LOGIN_API_STRATEGIES", "CredentialError", "DouyuActivityClient", "DouyuActivityError", @@ -26,4 +55,5 @@ __all__ = [ "IframeLoginAPI", "LoginAPIStrategy", "WgapiLoginAPI", + "create_login_api_strategy", ] diff --git a/tests/test_account_check_api_strategy.py b/tests/test_account_check_api_strategy.py new file mode 100644 index 0000000..8524456 --- /dev/null +++ b/tests/test_account_check_api_strategy.py @@ -0,0 +1,198 @@ +"""账号检测可选择登录接口(wgapi / iframe)。 + +背景:账号检测原来把 `WgapiLoginAPI()` **写死在**执行器里,而登录任务早就 +能让用户在下拉里选 wgapi / iframe。结果是同一个人、同一个账号, +登录任务换 iframe 能收到邮箱验证码,账号检测却永远只有 wgapi 一条路。 + +本文件守住三件事: +1. 默认仍是 wgapi —— 不改变账号检测原来的行为; +2. 选了 iframe 后,执行器**真的**把 IframeLoginAPI 交给了 DouyuLogin + (只在批次的快照里记一个字符串不算数); +3. HTTP 层把 `api_strategy` 透传到执行器,不会中途被丢掉。 +""" + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import User +from web.backend.services import account_check_service as svc + + +class _Base: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.user = User(username="chk-strategy", password_hash="h", role="super_admin") + self.session.add(self.user) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _runner(self, strategy: str | None = None): + inputs = svc.parse_account_check_lines("u1----p1----e1----ep1") + kwargs = {} + if strategy is not None: + kwargs["api_strategy"] = strategy + return svc.AccountCheckRegistry().create( + accounts=inputs, + created_by=self.user.id, + concurrency=1, + max_login_retries=0, + max_total_time=60, + **kwargs, + ) + + +class TestCoreFactory: + """core 层只认 wgapi / iframe,未知值回退默认。""" + + def test_known_names(self): + from core.douyu import create_login_api_strategy + + assert create_login_api_strategy("wgapi").name == "wgapi" + assert create_login_api_strategy("iframe").name == "iframe" + + def test_unknown_falls_back_to_wgapi(self): + from core.douyu import create_login_api_strategy + + assert create_login_api_strategy("").name == "wgapi" + assert create_login_api_strategy("IFRAME").name == "iframe" + assert create_login_api_strategy("nonsense").name == "wgapi" + + +class TestBatchCarriesStrategy(_Base): + def test_default_is_wgapi(self): + """不传就用 wgapi —— 账号检测原来的行为一行没改。""" + runner = self._runner() + assert runner.batch.api_strategy == "wgapi" + assert runner.snapshot()["api_strategy"] == "wgapi" + + def test_iframe_is_kept(self): + runner = self._runner("iframe") + assert runner.batch.api_strategy == "iframe" + assert runner.snapshot()["api_strategy"] == "iframe" + + +class TestRunnerHandsStrategyToDouyuLogin(_Base): + """关键接线:执行器必须把选中的策略**交给 DouyuLogin**。 + + 这里如果退回写死 `WgapiLoginAPI()`,`captured["strategy"].name` 会是 + "wgapi",用例变红 —— 是可以反向验证的那一类断言。 + """ + + def _patch(self, monkeypatch): + captured: dict = {} + + class FakeLogin: + def __init__(self, account, **kwargs): + captured["strategy"] = kwargs.get("api_strategy") + + def check_account(self): + from core.douyu.login import LoginResult + + return LoginResult( + success=True, message="账号已认证", code="account_verified" + ) + + monkeypatch.setattr(svc, "DouyuLogin", FakeLogin) + return captured + + def test_uses_iframe_when_selected(self, monkeypatch): + captured = self._patch(monkeypatch) + runner = self._runner("iframe") + + runner._run_one(0, runner.accounts[0]) + + assert captured["strategy"] is not None + assert captured["strategy"].name == "iframe" + assert runner.batch.items[0].status == "account_verified" + + def test_uses_wgapi_by_default(self, monkeypatch): + captured = self._patch(monkeypatch) + runner = self._runner() + + runner._run_one(0, runner.accounts[0]) + + assert captured["strategy"].name == "wgapi" + + +class TestRequestSchemaAndRouter(_Base): + def test_schema_default_is_wgapi(self): + from web.backend.schemas import AccountCheckBatchRequest + + assert AccountCheckBatchRequest(text="x").api_strategy == "wgapi" + assert ( + AccountCheckBatchRequest(text="x", api_strategy="iframe").api_strategy + == "iframe" + ) + + def test_router_passes_strategy(self, monkeypatch): + """HTTP 层丢字段是最难发现的:批次照跑,只是用错了接口。""" + from web.backend.routers import account_check as router_mod + from web.backend.schemas import AccountCheckBatchRequest + + captured: dict = {} + + class _FakeRunner: + def snapshot(self): + return {"ok": True} + + def run(self): + pass + + def fake_create(**kwargs): + captured.update(kwargs) + return _FakeRunner() + + class _NoopThread: + def __init__(self, **kwargs): + pass + + def start(self): + pass + + monkeypatch.setattr(router_mod.account_check_registry, "create", fake_create) + monkeypatch.setattr(router_mod.threading, "Thread", _NoopThread) + + result = router_mod.create_account_check_batch( + AccountCheckBatchRequest(text="u----p----e----ep", api_strategy="iframe"), + db=None, + current=self.user, + ) + + assert result == {"ok": True} + assert captured["api_strategy"] == "iframe" + + def test_router_defaults_to_wgapi(self, monkeypatch): + from web.backend.routers import account_check as router_mod + from web.backend.schemas import AccountCheckBatchRequest + + captured: dict = {} + + class _FakeRunner: + def snapshot(self): + return {"ok": True} + + def run(self): + pass + + def fake_create(**kwargs): + captured.update(kwargs) + return _FakeRunner() + + class _NoopThread: + def __init__(self, **kwargs): + pass + + def start(self): + pass + + monkeypatch.setattr(router_mod.account_check_registry, "create", fake_create) + monkeypatch.setattr(router_mod.threading, "Thread", _NoopThread) + + router_mod.create_account_check_batch( + AccountCheckBatchRequest(text="u----p----e----ep"), + db=None, + current=self.user, + ) + + assert captured["api_strategy"] == "wgapi" diff --git a/web/backend/routers/account_check.py b/web/backend/routers/account_check.py index 3d6c660..241052e 100644 --- a/web/backend/routers/account_check.py +++ b/web/backend/routers/account_check.py @@ -113,6 +113,7 @@ def create_account_check_batch( max_total_time=req.max_total_time, use_proxy=req.use_proxy, proxy_config=proxy_config, + api_strategy=req.api_strategy, ) thread = threading.Thread(target=runner.run, daemon=True) thread.start() diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 78fcb2a..aa9ef47 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -331,6 +331,9 @@ class AccountCheckBatchRequest(BaseModel): max_login_retries: int = Field(0, ge=0, le=50) max_total_time: float = Field(300, ge=0, le=3600) use_proxy: bool = False + # 登录接口策略,与登录任务同一套取值:wgapi(默认,保持原行为)或 iframe。 + # 有些人只有其中一套能收到邮箱验证码,所以要能选。 + api_strategy: str = "wgapi" class AccountCheckItemOut(BaseModel): @@ -353,6 +356,7 @@ class AccountCheckBatchOut(BaseModel): max_login_retries: int max_total_time: float use_proxy: bool + api_strategy: str = "wgapi" total: int finished_count: int running_count: int diff --git a/web/backend/services/account_check_service.py b/web/backend/services/account_check_service.py index 41548c4..2222bf3 100644 --- a/web/backend/services/account_check_service.py +++ b/web/backend/services/account_check_service.py @@ -14,7 +14,7 @@ from typing import cast from loguru import logger -from core.douyu import DouyuLogin, WgapiLoginAPI +from core.douyu import DouyuLogin, create_login_api_strategy from core.douyu.login import AccountLike from core.douyu.proxy_fetcher import ProxyFetcher @@ -109,6 +109,9 @@ class AccountCheckBatch: max_total_time: float use_proxy: bool items: list[AccountCheckItemState] + #: 检测使用的登录接口策略(wgapi / iframe)—— 与登录任务同一套取值。 + #: 默认 wgapi,保持账号检测原来的行为不变。 + api_strategy: str = "wgapi" status: str = "pending" message: str = "等待开始" created_at: datetime = field(default_factory=_now) @@ -347,7 +350,7 @@ class AccountCheckRunner: max_total_time=self.batch.max_total_time, proxy_fetcher=self._shared_proxy_fetcher, stop_event=self._stop, - api_strategy=WgapiLoginAPI(), + api_strategy=create_login_api_strategy(self.batch.api_strategy), ).check_account() except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 self._set_item( @@ -395,6 +398,7 @@ class AccountCheckRunner: "max_login_retries": self.batch.max_login_retries, "max_total_time": self.batch.max_total_time, "use_proxy": self.batch.use_proxy, + "api_strategy": self.batch.api_strategy, "total": len(self.batch.items), "finished_count": finished_count, "running_count": running_count, @@ -486,6 +490,7 @@ class AccountCheckRegistry: max_total_time: float, use_proxy: bool = False, proxy_config: ProxyConfigModel | None = None, + api_strategy: str = "wgapi", ) -> AccountCheckRunner: batch_id = uuid.uuid4().hex[:12] normalized_retries = max_login_retries if max_login_retries > 0 else 20 @@ -496,6 +501,7 @@ class AccountCheckRegistry: max_login_retries=normalized_retries, max_total_time=max(0.0, float(max_total_time or 0)), use_proxy=bool(use_proxy), + api_strategy=str(api_strategy or "wgapi"), items=[ AccountCheckItemState( line=account.line, diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index cd7ce1e..780261e 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -14,7 +14,7 @@ from loguru import logger from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session -from core.douyu import DouyuLogin, IframeLoginAPI, WgapiLoginAPI +from core.douyu import DouyuLogin, create_login_api_strategy from core.douyu.login import AccountLike from core.douyu.proxy_fetcher import ProxyFetcher @@ -26,14 +26,6 @@ from .account_lifecycle import on_ck_verdict, on_login_success from .cookie_check_service import check_douyu_cookie from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES - -def _create_api_strategy(strategy_name: str): - """根据名称创建登录接口策略实例。""" - if strategy_name == "iframe": - return IframeLoginAPI() - return WgapiLoginAPI() - - CHECK_STATUS_MESSAGES = { "account_cancelled": "账号已注销", "password_wrong": "账号密码错误", @@ -223,7 +215,7 @@ class LoginBatchRunner: self.loop = loop self.batch_id = uuid.uuid4().hex[:12] self.concurrency = max(1, min(concurrency, 10)) # 限制 1-10 - self.api_strategy = _create_api_strategy(api_strategy) + self.api_strategy = create_login_api_strategy(api_strategy) self.mode = mode if mode in ("login", "check", "relogin") else "login" self._stop = threading.Event() self._counter_lock = threading.Lock() diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 926fce2..022fb0a 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -415,6 +415,8 @@ export interface AccountCheckBatchRequest { max_login_retries?: number; max_total_time?: number; use_proxy?: boolean; + /** 登录接口:wgapi(默认,保持原行为)或 iframe;与登录任务同一套取值 */ + api_strategy?: string; } export interface AccountCheckItem { @@ -438,6 +440,7 @@ export interface AccountCheckBatch { max_login_retries: number; max_total_time: number; use_proxy: boolean; + api_strategy: string; total: number; finished_count: number; running_count: number; diff --git a/web/frontend/src/pages/AccountCheckPage.tsx b/web/frontend/src/pages/AccountCheckPage.tsx index 4521395..70616fb 100644 --- a/web/frontend/src/pages/AccountCheckPage.tsx +++ b/web/frontend/src/pages/AccountCheckPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Button, Radio, Select, Card, Col, Input, InputNumber, Popconfirm, Row, Space, Statistic, Switch, Table, Tag, Typography, + Button, Radio, Select, Card, Col, Input, InputNumber, Popconfirm, Row, Space, Statistic, Switch, Table, Tag, Tooltip, Typography, } from 'antd'; import { message } from '../utils/antdMessage'; import type { TableProps } from 'antd'; @@ -102,6 +102,9 @@ export default function AccountCheckPage() { const [maxTotalTime, setMaxTotalTime] = useState(() => readStoredNumber('account_check_max_total_time', 300)); const [maxLoginRetries, setMaxLoginRetries] = useState(() => readStoredNumber('account_check_max_login_retries', 0)); const [useProxy, setUseProxy] = useState(() => localStorage.getItem('account_check_use_proxy') === 'true'); + // 登录接口:与登录任务同款选择。默认 wgapi —— 账号检测原来就是写死 wgapi, + // 保持不变;收不到邮箱验证码时可以换成 iframe(旧版,邮箱验证走 iframe 接口)。 + const [apiStrategy, setApiStrategy] = useState(() => localStorage.getItem('account_check_api_strategy') || 'wgapi'); const navigate = useNavigate(); const [batch, setBatch] = useState(null); const [starting, setStarting] = useState(false); @@ -160,6 +163,10 @@ export default function AccountCheckPage() { localStorage.setItem('account_check_use_proxy', String(useProxy)); }, [useProxy]); + useEffect(() => { + localStorage.setItem('account_check_api_strategy', apiStrategy); + }, [apiStrategy]); + useEffect(() => { if (restoredBatchRef.current) return; restoredBatchRef.current = true; @@ -247,6 +254,7 @@ export default function AccountCheckPage() { max_total_time: maxTotalTime, max_login_retries: maxLoginRetries, use_proxy: useProxy, + api_strategy: apiStrategy, }); setBatch(data); localStorage.setItem(BATCH_STORAGE_KEY, data.batch_id); @@ -440,7 +448,22 @@ export default function AccountCheckPage() { )} - + + + 登录接口 + +