feat(account-check): 登录接口可选 wgapi / iframe
账号检测原来把 WgapiLoginAPI 写死在执行器里,收不到邮箱验证码时只能干等; 登录任务早就可选 iframe(邮箱验证走 iframe 接口)。默认仍 wgapi,旧行为不变。
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AccountCheckBatch | null>(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() {
|
||||
</Space>
|
||||
)}
|
||||
<Row gutter={[12, 12]} align="bottom">
|
||||
<Col xs={8} md={4}>
|
||||
<Col xs={12} md={5}>
|
||||
<Tooltip title="登录使用的接口版本。wgapi 为新版(默认,与原来一致);iframe 为旧版备选,邮箱验证码走 iframe 接口。收不到验证码时可以换一个试。">
|
||||
<Text type="secondary">登录接口</Text>
|
||||
</Tooltip>
|
||||
<Select
|
||||
value={apiStrategy}
|
||||
onChange={setApiStrategy}
|
||||
disabled={isRunning}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'wgapi', label: 'wgapi(默认)' },
|
||||
{ value: 'iframe', label: 'iframe(旧版备选)' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={8} md={3}>
|
||||
<Text type="secondary">并发</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
@@ -480,7 +503,7 @@ export default function AccountCheckPage() {
|
||||
<Button disabled>次</Button>
|
||||
</Space.Compact>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Col xs={12} md={3}>
|
||||
<Text type="secondary">代理</Text>
|
||||
<div style={{ height: 32, display: 'flex', alignItems: 'center' }}>
|
||||
<Switch
|
||||
@@ -492,7 +515,7 @@ export default function AccountCheckPage() {
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Col xs={24} md={5}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -528,7 +551,12 @@ export default function AccountCheckPage() {
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="检测结果"
|
||||
title={(
|
||||
<Space size={8}>
|
||||
检测结果
|
||||
{batch && <Tag color="blue">接口: {batch.api_strategy || 'wgapi'}</Tag>}
|
||||
</Space>
|
||||
)}
|
||||
extra={(
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} loading={refreshing} disabled={!batchId} onClick={refreshBatch}>
|
||||
|
||||
Reference in New Issue
Block a user