优化登录重试与配置清理
This commit is contained in:
@@ -21,5 +21,8 @@ COOKIE_SECURE=false
|
|||||||
# CORS 允许的源(逗号分隔,不设则默认开发环境)
|
# CORS 允许的源(逗号分隔,不设则默认开发环境)
|
||||||
# CORS_ORIGINS=https://example.com,https://www.example.com
|
# CORS_ORIGINS=https://example.com,https://www.example.com
|
||||||
|
|
||||||
|
# Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务)
|
||||||
|
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
|
||||||
|
|
||||||
# Uvicorn reload(开发模式设为 true,生产环境保持 false)
|
# Uvicorn reload(开发模式设为 true,生产环境保持 false)
|
||||||
UVICORN_RELOAD=false
|
UVICORN_RELOAD=false
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ cd web/frontend && npm install && npm run dev
|
|||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 邮件验证码服务
|
||||||
|
|
||||||
|
验证码读取默认使用内置的 Roundcube 服务地址;如部署自己的邮件读取服务,可在 `.env` 中设置 `MAIL_ROUNDCUBE_URL` 覆盖。
|
||||||
|
|
||||||
## 角色权限
|
## 角色权限
|
||||||
|
|
||||||
| 角色 | 权限 |
|
| 角色 | 权限 |
|
||||||
|
|||||||
+10
-137
@@ -1,6 +1,7 @@
|
|||||||
"""邮箱验证模块 - 优先通过 Roundcube Webmail API 获取验证码,read.php 作为备用"""
|
"""邮箱验证模块 - 通过 Roundcube Webmail API 获取验证码。"""
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -11,15 +12,12 @@ from loguru import logger
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
# 旧接口(时间解析不可靠,作为备用)
|
# Roundcube Webmail 地址。可通过环境变量覆盖。
|
||||||
READ_PHP_URL = "http://111.229.206.54:8000/read.php"
|
ROUNDCUBE_URL = os.getenv("MAIL_ROUNDCUBE_URL", "http://111.229.206.54:8000/")
|
||||||
|
|
||||||
# Roundcube Webmail 地址
|
|
||||||
ROUNDCUBE_URL = "http://111.229.206.54:8000/"
|
|
||||||
|
|
||||||
|
|
||||||
class EmailVerifier:
|
class EmailVerifier:
|
||||||
"""邮箱验证器(Roundcube API 优先,read.php 备用)"""
|
"""邮箱验证器(Roundcube API)。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -32,7 +30,7 @@ class EmailVerifier:
|
|||||||
lookback_minutes: int = 10,
|
lookback_minutes: int = 10,
|
||||||
max_messages: int = 20,
|
max_messages: int = 20,
|
||||||
use_ssl: bool = False,
|
use_ssl: bool = False,
|
||||||
mail_api_url: str = READ_PHP_URL,
|
roundcube_url: str = "",
|
||||||
):
|
):
|
||||||
# 兼容旧参数名
|
# 兼容旧参数名
|
||||||
self.imap_server = imap_server
|
self.imap_server = imap_server
|
||||||
@@ -40,8 +38,7 @@ class EmailVerifier:
|
|||||||
self.username = username # 邮箱地址
|
self.username = username # 邮箱地址
|
||||||
self.password = password # 邮箱密码
|
self.password = password # 邮箱密码
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.read_php_url = mail_api_url or READ_PHP_URL
|
self.roundcube_url = roundcube_url or ROUNDCUBE_URL
|
||||||
self.roundcube_url = ROUNDCUBE_URL
|
|
||||||
|
|
||||||
# Roundcube 会话(懒初始化)
|
# Roundcube 会话(懒初始化)
|
||||||
self._rc_session: Optional[requests.Session] = None
|
self._rc_session: Optional[requests.Session] = None
|
||||||
@@ -265,7 +262,7 @@ class EmailVerifier:
|
|||||||
stop_event: Optional[threading.Event] = None,
|
stop_event: Optional[threading.Event] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
轮询获取斗鱼验证码。优先使用 Roundcube API,失败则回退到 read.php。
|
轮询获取斗鱼验证码。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
max_wait: 最大等待时间(秒)
|
max_wait: 最大等待时间(秒)
|
||||||
@@ -281,33 +278,19 @@ class EmailVerifier:
|
|||||||
|
|
||||||
deadline = time.monotonic() + max_wait
|
deadline = time.monotonic() + max_wait
|
||||||
last_error = ""
|
last_error = ""
|
||||||
tried_roundcube = False
|
|
||||||
|
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if stop_event and stop_event.is_set():
|
if stop_event and stop_event.is_set():
|
||||||
raise InterruptedError("任务已停止")
|
raise InterruptedError("任务已停止")
|
||||||
|
|
||||||
# 优先尝试 Roundcube
|
|
||||||
if not tried_roundcube or self._rc_logged_in:
|
|
||||||
try:
|
|
||||||
code = self._fetch_code_via_roundcube(after_timestamp, allow_old_seconds)
|
|
||||||
if code:
|
|
||||||
logger.success(f"获取到验证码: {code}")
|
|
||||||
return code
|
|
||||||
except Exception as e:
|
|
||||||
last_error = str(e)
|
|
||||||
logger.warning(f"Roundcube 读邮件异常: {e}")
|
|
||||||
tried_roundcube = True
|
|
||||||
|
|
||||||
# 回退到 read.php
|
|
||||||
try:
|
try:
|
||||||
code = self._fetch_code_via_readphp(after_timestamp, allow_old_seconds)
|
code = self._fetch_code_via_roundcube(after_timestamp, allow_old_seconds)
|
||||||
if code:
|
if code:
|
||||||
logger.success(f"获取到验证码: {code}")
|
logger.success(f"获取到验证码: {code}")
|
||||||
return code
|
return code
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = str(e)
|
last_error = str(e)
|
||||||
logger.warning(f"read.php 读邮件异常: {e}")
|
logger.warning(f"Roundcube 读邮件异常: {e}")
|
||||||
|
|
||||||
logger.debug("未找到验证码,等待中...")
|
logger.debug("未找到验证码,等待中...")
|
||||||
sleep_deadline = time.monotonic() + interval
|
sleep_deadline = time.monotonic() + interval
|
||||||
@@ -354,118 +337,8 @@ class EmailVerifier:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── read.php 方式获取验证码(备用) ─────────────────────
|
|
||||||
|
|
||||||
def _fetch_code_via_readphp(
|
|
||||||
self,
|
|
||||||
after_timestamp: Optional[float] = None,
|
|
||||||
allow_old_seconds: int = 15,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""通过 read.php HTTP 接口读取最新邮件并提取验证码"""
|
|
||||||
try:
|
|
||||||
response = requests.get(
|
|
||||||
self.read_php_url,
|
|
||||||
params={
|
|
||||||
"yhm": self.username,
|
|
||||||
"mm": self.password,
|
|
||||||
},
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.text
|
|
||||||
|
|
||||||
# 检查是否是斗鱼验证码邮件
|
|
||||||
if not self._is_douyu_email(content):
|
|
||||||
logger.debug("read.php: 最新邮件不是斗鱼验证码邮件")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 检查邮件时间(read.php 时间不可靠,放宽判断)
|
|
||||||
if after_timestamp and not self._is_email_recent(content, after_timestamp, allow_old_seconds):
|
|
||||||
logger.debug("read.php: 邮件时间早于验证码发送时间,跳过")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 提取验证码
|
|
||||||
code = self._extract_verification_code(content)
|
|
||||||
if code:
|
|
||||||
logger.info(f"read.php: 从邮件中提取到验证码: {code}")
|
|
||||||
return code
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.warning(f"read.php: 请求失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ── 通用工具方法 ───────────────────────────────────────
|
# ── 通用工具方法 ───────────────────────────────────────
|
||||||
|
|
||||||
def _is_douyu_email(self, content: str) -> bool:
|
|
||||||
"""判断邮件内容是否是斗鱼验证码邮件"""
|
|
||||||
douyu_keywords = ['斗鱼', 'douyu', 'douyutv']
|
|
||||||
verify_keywords = ['验证码', '安全验证', '登录验证', '动态码', '校验码']
|
|
||||||
combined = content.lower()
|
|
||||||
|
|
||||||
return (
|
|
||||||
any(keyword in combined for keyword in douyu_keywords)
|
|
||||||
or any(keyword in content for keyword in verify_keywords)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_email_recent(
|
|
||||||
self,
|
|
||||||
content: str,
|
|
||||||
after_timestamp: float,
|
|
||||||
allow_old_seconds: int = 15,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
检查 read.php 返回的邮件时间是否晚于验证码发送时间。
|
|
||||||
注意:read.php 的时间解析不可靠,此处仅作参考判断。
|
|
||||||
"""
|
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
# 先清理 HTML 标签,再提取时间
|
|
||||||
clean = re.sub(r'<[^>]+>', ' ', content)
|
|
||||||
clean = re.sub(r'\s+', ' ', clean)
|
|
||||||
|
|
||||||
# 匹配 RFC2822 格式时间
|
|
||||||
time_match = re.search(
|
|
||||||
r'发信时间[::]?\s*'
|
|
||||||
r'((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*\d{1,2}\s+'
|
|
||||||
r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+'
|
|
||||||
r'\d{4}\s+\d{2}:\d{2}:\d{2}\s*[+-]\d{4})',
|
|
||||||
clean,
|
|
||||||
)
|
|
||||||
if not time_match:
|
|
||||||
# 尝试匹配不带星期的时间格式
|
|
||||||
time_match2 = re.search(
|
|
||||||
r'发信时间[::]?\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})',
|
|
||||||
clean,
|
|
||||||
)
|
|
||||||
if time_match2:
|
|
||||||
time_str = time_match2.group(1).strip()
|
|
||||||
try:
|
|
||||||
from datetime import datetime as dt
|
|
||||||
msg_time = dt.strptime(time_str, '%Y-%m-%d %H:%M:%S')
|
|
||||||
if msg_time.timestamp() < after_timestamp - allow_old_seconds:
|
|
||||||
logger.debug(f"邮件时间 {time_str} 早于发送时间,跳过旧邮件")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# read.php 时间不可靠时,不再保守拒绝,改为放行
|
|
||||||
# 让验证码提取逻辑自行判断
|
|
||||||
logger.debug("read.php: 未找到可解析的发信时间,跳过时间检查直接提取验证码")
|
|
||||||
return True
|
|
||||||
|
|
||||||
time_str = time_match.group(1).strip()
|
|
||||||
try:
|
|
||||||
msg_time = parsedate_to_datetime(time_str)
|
|
||||||
if msg_time and msg_time.timestamp() < after_timestamp - allow_old_seconds:
|
|
||||||
logger.debug(f"邮件时间 {time_str} 早于发送时间,跳过旧邮件")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.debug(f"无法解析邮件时间: {time_str},跳过时间检查")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _extract_verification_code(self, text: str) -> Optional[str]:
|
def _extract_verification_code(self, text: str) -> Optional[str]:
|
||||||
"""从文本中提取6位验证码"""
|
"""从文本中提取6位验证码"""
|
||||||
# 清理HTML标签和实体
|
# 清理HTML标签和实体
|
||||||
|
|||||||
+47
-31
@@ -14,7 +14,6 @@ from .crypto import encrypt_password, encrypt_nickname_or_phone
|
|||||||
from .email_verifier import EmailVerifier
|
from .email_verifier import EmailVerifier
|
||||||
from .proxy import ProxyManager, get_proxy_manager
|
from .proxy import ProxyManager, get_proxy_manager
|
||||||
|
|
||||||
from core.geetest import run_solver
|
|
||||||
from core.geetest.v3_slide.solver import (
|
from core.geetest.v3_slide.solver import (
|
||||||
_generate_seed, get_w1, get_w2,
|
_generate_seed, get_w1, get_w2,
|
||||||
)
|
)
|
||||||
@@ -53,7 +52,6 @@ class DouyuLogin:
|
|||||||
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
|
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
|
||||||
SEND_EMAIL_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
|
SEND_EMAIL_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
|
||||||
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
|
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
|
||||||
LOGIN_CALLBACK_API = "https://www.douyu.com/api/passport/login"
|
|
||||||
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
||||||
LOGIN_REFERER = (
|
LOGIN_REFERER = (
|
||||||
"https://passport.douyu.com/index/login?"
|
"https://passport.douyu.com/index/login?"
|
||||||
@@ -72,10 +70,9 @@ class DouyuLogin:
|
|||||||
proxy: Optional[str | Mapping[str, str]] = None,
|
proxy: Optional[str | Mapping[str, str]] = None,
|
||||||
proxy_api_url: Optional[str] = None,
|
proxy_api_url: Optional[str] = None,
|
||||||
timeout: tuple[float, float] = REQUEST_TIMEOUT,
|
timeout: tuple[float, float] = REQUEST_TIMEOUT,
|
||||||
max_geetest_retries: int = 5,
|
|
||||||
max_proxy_retries: int = 0,
|
max_proxy_retries: int = 0,
|
||||||
max_login_retries: int = 3,
|
max_login_retries: int = 0,
|
||||||
max_total_time: float = 300,
|
max_total_time: float = 0,
|
||||||
whitelist_uid: str = "",
|
whitelist_uid: str = "",
|
||||||
whitelist_ukey: str = "",
|
whitelist_ukey: str = "",
|
||||||
proxy_manager: Optional[ProxyManager] = None,
|
proxy_manager: Optional[ProxyManager] = None,
|
||||||
@@ -84,10 +81,9 @@ class DouyuLogin:
|
|||||||
self.account = account
|
self.account = account
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.max_geetest_retries = max_geetest_retries
|
|
||||||
self.max_proxy_retries = max_proxy_retries # 0=无限切换直到成功
|
self.max_proxy_retries = max_proxy_retries # 0=无限切换直到成功
|
||||||
self.max_login_retries = max_login_retries # 登录整体重试次数(换代理从头重跑)
|
self.max_login_retries = max_login_retries # 0=无限整体重试直到成功
|
||||||
self.max_total_time = max_total_time # 单账号登录总时长上限(秒),超时则放弃
|
self.max_total_time = max_total_time # 0=不限制单账号登录总时长
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|
||||||
@@ -308,34 +304,39 @@ class DouyuLogin:
|
|||||||
"""
|
"""
|
||||||
完整登录流程(带整体重试)。
|
完整登录流程(带整体重试)。
|
||||||
|
|
||||||
任何步骤失败时,换新代理从头重跑,最多重试 max_login_retries 次。
|
任何步骤失败时,换新代理从头重跑。
|
||||||
整体超时 max_total_time 秒后放弃。
|
max_login_retries=0 表示无限重试,max_total_time=0 表示不限制总时长。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LoginResult: 登录结果,包含cookie
|
LoginResult: 登录结果,包含cookie
|
||||||
"""
|
"""
|
||||||
logger.info(f"开始登录账号: {self.account.username}")
|
logger.info(f"开始登录账号: {self.account.username}")
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
deadline = start_time + self.max_total_time
|
deadline = start_time + self.max_total_time if self.max_total_time > 0 else 0
|
||||||
|
|
||||||
for attempt in range(1, self.max_login_retries + 1):
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
if self._is_stopped():
|
if self._is_stopped():
|
||||||
logger.warning("登录任务已停止")
|
logger.warning("登录任务已停止")
|
||||||
return LoginResult(success=False, message="任务已停止")
|
return LoginResult(success=False, message="任务已停止")
|
||||||
elapsed = time.monotonic() - start_time
|
elapsed = time.monotonic() - start_time
|
||||||
if elapsed > self.max_total_time:
|
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||||||
logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
||||||
return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)")
|
return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)")
|
||||||
|
|
||||||
if attempt > 1:
|
if attempt > 1:
|
||||||
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换代理重新开始")
|
if self.max_login_retries > 0:
|
||||||
|
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换代理重新开始")
|
||||||
|
else:
|
||||||
|
logger.info(f"登录整体重试 {attempt} (无限重试),换代理重新开始")
|
||||||
# 重试前:换新代理 + 重置 session(清 cookies)
|
# 重试前:换新代理 + 重置 session(清 cookies)
|
||||||
self._prepare_retry()
|
self._prepare_retry()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1️⃣ 第一次登录(获取极验参数)
|
# 1️⃣ 第一次登录(获取极验参数)
|
||||||
logger.info("步骤1: 第一次登录,获取极验参数...")
|
logger.info("步骤1: 第一次登录,获取极验参数...")
|
||||||
gt, challenge, code_token, initial_cookies = self._first_login()
|
gt, challenge, code_token, _ = self._first_login()
|
||||||
|
|
||||||
# 2️⃣ 极验 fullpage 验证
|
# 2️⃣ 极验 fullpage 验证
|
||||||
logger.info("步骤2: 极验 fullpage 验证...")
|
logger.info("步骤2: 极验 fullpage 验证...")
|
||||||
@@ -376,8 +377,14 @@ class DouyuLogin:
|
|||||||
return LoginResult(success=False, message=str(e))
|
return LoginResult(success=False, message=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed = time.monotonic() - start_time
|
elapsed = time.monotonic() - start_time
|
||||||
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}")
|
if self.max_login_retries > 0:
|
||||||
if attempt < self.max_login_retries and elapsed < self.max_total_time:
|
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}")
|
||||||
|
else:
|
||||||
|
logger.error(f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}")
|
||||||
|
|
||||||
|
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||||
|
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||||||
|
if has_retry and has_time:
|
||||||
# 还有重试机会且未超时:标记当前代理坏,下一轮自动换新代理
|
# 还有重试机会且未超时:标记当前代理坏,下一轮自动换新代理
|
||||||
if self.proxy_manager and self._current_proxy_url:
|
if self.proxy_manager and self._current_proxy_url:
|
||||||
self.proxy_manager.mark_bad(self._current_proxy_url)
|
self.proxy_manager.mark_bad(self._current_proxy_url)
|
||||||
@@ -386,8 +393,6 @@ class DouyuLogin:
|
|||||||
# 所有重试耗尽或超时
|
# 所有重试耗尽或超时
|
||||||
return LoginResult(success=False, message=str(e))
|
return LoginResult(success=False, message=str(e))
|
||||||
|
|
||||||
return LoginResult(success=False, message=f"登录失败,已重试 {self.max_login_retries} 次")
|
|
||||||
|
|
||||||
def _prepare_retry(self) -> None:
|
def _prepare_retry(self) -> None:
|
||||||
"""重试前准备:换新代理、重置 session cookies。"""
|
"""重试前准备:换新代理、重置 session cookies。"""
|
||||||
# 重置 session(清掉旧 cookies,避免残留状态干扰)
|
# 重置 session(清掉旧 cookies,避免残留状态干扰)
|
||||||
@@ -471,24 +476,35 @@ class DouyuLogin:
|
|||||||
"""
|
"""
|
||||||
logger.info("开始极验 fullpage 验证...")
|
logger.info("开始极验 fullpage 验证...")
|
||||||
|
|
||||||
# max_proxy_retries=0 表示无限重试直到成功
|
# max_proxy_retries=0 表示不限代理切换次数。
|
||||||
max_attempts = self.max_proxy_retries if self.max_proxy_retries > 0 else 999999
|
max_proxy_switches = self.max_proxy_retries if self.max_proxy_retries > 0 else 999999
|
||||||
|
proxy_switches = 0
|
||||||
|
|
||||||
# 连续临时失败计数(同一代理下),超过阈值才换代理
|
# 连续临时失败计数(同一代理下),超过阈值才换代理
|
||||||
_soft_fail_streak = 0
|
_soft_fail_streak = 0
|
||||||
_SOFT_FAIL_THRESHOLD = 2 # 同一代理连续临时失败2次才换
|
_SOFT_FAIL_THRESHOLD = 2 # 同一代理连续临时失败2次才换
|
||||||
|
|
||||||
for attempt in range(max_attempts):
|
def refresh_proxy(mark_bad: bool) -> None:
|
||||||
|
"""按代理切换上限刷新代理。"""
|
||||||
|
nonlocal proxy_switches
|
||||||
|
if not self.proxy_manager:
|
||||||
|
return
|
||||||
|
if proxy_switches >= max_proxy_switches:
|
||||||
|
raise ValueError(f"极验验证代理切换次数已达上限 {self.max_proxy_retries}")
|
||||||
|
new_proxy = self._refresh_proxy(mark_bad=mark_bad)
|
||||||
|
if new_proxy:
|
||||||
|
proxy_switches += 1
|
||||||
|
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
self._ensure_not_stopped()
|
self._ensure_not_stopped()
|
||||||
# 超时兜底:极验验证不应超过登录整体时间上限
|
# 超时兜底:极验验证不应超过登录整体时间上限
|
||||||
if deadline and time.monotonic() > deadline:
|
if deadline and time.monotonic() > deadline:
|
||||||
raise ValueError(f"极验验证超时(登录整体时间耗尽)")
|
raise ValueError(f"极验验证超时(登录整体时间耗尽)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.max_proxy_retries > 0:
|
logger.info(f"极验验证尝试 {attempt} (无限重试)")
|
||||||
logger.info(f"极验验证尝试 {attempt + 1}/{max_attempts}")
|
|
||||||
else:
|
|
||||||
logger.info(f"极验验证尝试 {attempt + 1} (无限重试)")
|
|
||||||
|
|
||||||
# 按斗鱼登录页 HAR:fullpage 智能检测流程,不进入图片滑块。
|
# 按斗鱼登录页 HAR:fullpage 智能检测流程,不进入图片滑块。
|
||||||
str_16 = _generate_seed()
|
str_16 = _generate_seed()
|
||||||
@@ -525,7 +541,7 @@ class DouyuLogin:
|
|||||||
_soft_fail_streak += 1
|
_soft_fail_streak += 1
|
||||||
if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD:
|
if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD:
|
||||||
logger.warning(f"极验验证失败: {message},同一代理连续 {_soft_fail_streak} 次,换代理")
|
logger.warning(f"极验验证失败: {message},同一代理连续 {_soft_fail_streak} 次,换代理")
|
||||||
self._refresh_proxy(mark_bad=False)
|
refresh_proxy(mark_bad=False)
|
||||||
_soft_fail_streak = 0
|
_soft_fail_streak = 0
|
||||||
else:
|
else:
|
||||||
logger.warning(f"极验验证失败: {message},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
|
logger.warning(f"极验验证失败: {message},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
|
||||||
@@ -540,27 +556,27 @@ class DouyuLogin:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_str = str(e)
|
err_str = str(e)
|
||||||
|
if "代理切换次数已达上限" in err_str:
|
||||||
|
raise
|
||||||
is_proxy_dead = self._is_proxy_connection_error(err_str)
|
is_proxy_dead = self._is_proxy_connection_error(err_str)
|
||||||
|
|
||||||
if is_proxy_dead:
|
if is_proxy_dead:
|
||||||
# 代理确实不可用:立即换,标记坏
|
# 代理确实不可用:立即换,标记坏
|
||||||
logger.warning(f"极验验证代理连接失败: {self._truncate_error(err_str)},换代理")
|
logger.warning(f"极验验证代理连接失败: {self._truncate_error(err_str)},换代理")
|
||||||
self._refresh_proxy(mark_bad=True)
|
refresh_proxy(mark_bad=True)
|
||||||
_soft_fail_streak = 0
|
_soft_fail_streak = 0
|
||||||
else:
|
else:
|
||||||
# 临时异常(KeyError、网络不给力等):先原代理重试
|
# 临时异常(KeyError、网络不给力等):先原代理重试
|
||||||
_soft_fail_streak += 1
|
_soft_fail_streak += 1
|
||||||
if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD:
|
if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD:
|
||||||
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},连续 {_soft_fail_streak} 次,换代理")
|
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},连续 {_soft_fail_streak} 次,换代理")
|
||||||
self._refresh_proxy(mark_bad=False)
|
refresh_proxy(mark_bad=False)
|
||||||
_soft_fail_streak = 0
|
_soft_fail_streak = 0
|
||||||
else:
|
else:
|
||||||
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
|
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
|
||||||
self._sleep_interruptible(2)
|
self._sleep_interruptible(2)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.max_proxy_retries > 0:
|
|
||||||
raise ValueError(f"极验验证失败,已重试 {max_attempts} 次")
|
|
||||||
raise ValueError("极验验证失败(无限重试模式仍未能通过)")
|
raise ValueError("极验验证失败(无限重试模式仍未能通过)")
|
||||||
|
|
||||||
def _second_login(self, gt: str, challenge: str, validate: str,
|
def _second_login(self, gt: str, challenge: str, validate: str,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ services:
|
|||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# JWT 密钥(生产环境务必修改,可用 python -c "import secrets; print(secrets.token_urlsafe(32))" 生成)
|
# JWT 密钥(生产环境务必修改,可用 python -c "import secrets; print(secrets.token_urlsafe(32))" 生成)
|
||||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-}
|
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-}
|
||||||
|
# 敏感字段落盘加密密钥(生产环境务必长期保持不变)
|
||||||
|
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
|
||||||
# 默认管理员账号密码(仅首次启动建库时生效)
|
# 默认管理员账号密码(仅首次启动建库时生效)
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
@@ -20,6 +22,8 @@ services:
|
|||||||
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
||||||
# CORS 允许的源(逗号分隔)
|
# CORS 允许的源(逗号分隔)
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
|
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
|
||||||
|
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
|
||||||
# Uvicorn reload(生产环境保持 false)
|
# Uvicorn reload(生产环境保持 false)
|
||||||
- UVICORN_RELOAD=${UVICORN_RELOAD:-false}
|
- UVICORN_RELOAD=${UVICORN_RELOAD:-false}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -24,21 +24,25 @@ def _fmt_dt(dt) -> str | None:
|
|||||||
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_cookie_tasks_query(db: Session, current: User):
|
||||||
|
"""返回当前用户可见的成功 Cookie 任务查询。"""
|
||||||
|
query = db.query(LoginTask).filter(LoginTask.status == "success")
|
||||||
|
|
||||||
|
# 无全量登录任务权限时,只能操作分配给自己的账号 Cookie。
|
||||||
|
if not user_has_permission(current, "login:view_all"):
|
||||||
|
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
||||||
|
Account.assigned_to == current.id
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_cookies(
|
def list_cookies(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""查看登录成功的 Cookie 列表。"""
|
"""查看登录成功的 Cookie 列表。"""
|
||||||
query = db.query(LoginTask).filter(LoginTask.status == "success")
|
tasks = _visible_cookie_tasks_query(db, current).order_by(LoginTask.finished_at.desc()).all()
|
||||||
|
|
||||||
# 客服只能看自己账号的
|
|
||||||
if not user_has_permission(current, "login:view_all"):
|
|
||||||
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
|
||||||
Account.assigned_to == current.id
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks = query.order_by(LoginTask.finished_at.desc()).all()
|
|
||||||
|
|
||||||
# 批量查账号,避免 N+1 查询
|
# 批量查账号,避免 N+1 查询
|
||||||
account_ids = [t.account_id for t in tasks]
|
account_ids = [t.account_id for t in tasks]
|
||||||
@@ -81,9 +85,7 @@ def export_cookies(
|
|||||||
csv: 账号, Cookie, 时间
|
csv: 账号, Cookie, 时间
|
||||||
custom: 账号----密码----ck
|
custom: 账号----密码----ck
|
||||||
"""
|
"""
|
||||||
tasks = db.query(LoginTask).filter(
|
tasks = _visible_cookie_tasks_query(db, current).order_by(LoginTask.finished_at.desc()).all()
|
||||||
LoginTask.status == "success"
|
|
||||||
).order_by(LoginTask.finished_at.desc()).all()
|
|
||||||
|
|
||||||
# 批量查账号,避免 N+1
|
# 批量查账号,避免 N+1
|
||||||
account_ids = [t.account_id for t in tasks]
|
account_ids = [t.account_id for t in tasks]
|
||||||
@@ -136,7 +138,7 @@ def delete_cookies_batch(
|
|||||||
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="无效的ID")
|
raise HTTPException(status_code=400, detail="无效的ID")
|
||||||
tasks = db.query(LoginTask).filter(LoginTask.id.in_(ids)).all()
|
tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(ids)).all()
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
t.cookie = ""
|
t.cookie = ""
|
||||||
t.status = "failed"
|
t.status = "failed"
|
||||||
@@ -152,7 +154,7 @@ def delete_cookie(
|
|||||||
current: User = Depends(require_permission("cookie:export")),
|
current: User = Depends(require_permission("cookie:export")),
|
||||||
):
|
):
|
||||||
"""删除一条 Cookie 记录。"""
|
"""删除一条 Cookie 记录。"""
|
||||||
task = db.query(LoginTask).filter(LoginTask.id == task_id).first()
|
task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
task.cookie = ""
|
task.cookie = ""
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ async def create_batch(
|
|||||||
account_ids=valid_ids,
|
account_ids=valid_ids,
|
||||||
created_by=current.id,
|
created_by=current.id,
|
||||||
creator_permissions=get_user_permissions(current),
|
creator_permissions=get_user_permissions(current),
|
||||||
max_geetest_retries=req.max_geetest_retries,
|
|
||||||
max_proxy_retries=req.max_proxy_retries,
|
max_proxy_retries=req.max_proxy_retries,
|
||||||
max_login_retries=req.max_login_retries,
|
max_login_retries=req.max_login_retries,
|
||||||
max_total_time=req.max_total_time,
|
max_total_time=req.max_total_time,
|
||||||
|
|||||||
@@ -124,10 +124,9 @@ class AccountOut(BaseModel):
|
|||||||
# ---- 登录任务 ----
|
# ---- 登录任务 ----
|
||||||
class LoginBatchRequest(BaseModel):
|
class LoginBatchRequest(BaseModel):
|
||||||
account_ids: list[int]
|
account_ids: list[int]
|
||||||
max_geetest_retries: int = 5
|
|
||||||
max_proxy_retries: int = 0 # 代理切换次数,0=无限切换直到成功
|
max_proxy_retries: int = 0 # 代理切换次数,0=无限切换直到成功
|
||||||
max_login_retries: int = 3 # 登录整体重试次数(换代理从头重跑)
|
max_login_retries: int = 0 # 登录整体重试次数,0=无限重试直到成功
|
||||||
max_total_time: float = 300 # 单账号登录总时长上限(秒),超时则放弃
|
max_total_time: float = 0 # 单账号登录总时长上限,0=不限制
|
||||||
concurrency: int = 3 # 并发数,1-10
|
concurrency: int = 3 # 并发数,1-10
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,9 @@ class LoginBatchRunner:
|
|||||||
account_ids: list[int],
|
account_ids: list[int],
|
||||||
created_by: int,
|
created_by: int,
|
||||||
creator_permissions: list[str],
|
creator_permissions: list[str],
|
||||||
max_geetest_retries: int = 5,
|
|
||||||
max_proxy_retries: int = 0,
|
max_proxy_retries: int = 0,
|
||||||
max_login_retries: int = 3,
|
max_login_retries: int = 0,
|
||||||
max_total_time: float = 300,
|
max_total_time: float = 0,
|
||||||
proxy_config: Optional[ProxyConfigModel] = None,
|
proxy_config: Optional[ProxyConfigModel] = None,
|
||||||
log_queue: Optional[asyncio.Queue] = None,
|
log_queue: Optional[asyncio.Queue] = None,
|
||||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
@@ -41,7 +40,6 @@ class LoginBatchRunner:
|
|||||||
self.account_ids = account_ids
|
self.account_ids = account_ids
|
||||||
self.created_by = created_by
|
self.created_by = created_by
|
||||||
self.creator_permissions = creator_permissions
|
self.creator_permissions = creator_permissions
|
||||||
self.max_geetest_retries = max_geetest_retries
|
|
||||||
self.max_proxy_retries = max_proxy_retries
|
self.max_proxy_retries = max_proxy_retries
|
||||||
self.max_login_retries = max_login_retries
|
self.max_login_retries = max_login_retries
|
||||||
self.max_total_time = max_total_time
|
self.max_total_time = max_total_time
|
||||||
@@ -200,7 +198,6 @@ class LoginBatchRunner:
|
|||||||
loginer = DouyuLogin(
|
loginer = DouyuLogin(
|
||||||
account,
|
account,
|
||||||
proxy=proxy_dict,
|
proxy=proxy_dict,
|
||||||
max_geetest_retries=self.max_geetest_retries,
|
|
||||||
max_proxy_retries=self.max_proxy_retries,
|
max_proxy_retries=self.max_proxy_retries,
|
||||||
max_login_retries=self.max_login_retries,
|
max_login_retries=self.max_login_retries,
|
||||||
max_total_time=self.max_total_time,
|
max_total_time=self.max_total_time,
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import ProxyPage from './pages/ProxyPage';
|
|||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import CookiePage from './pages/CookiePage';
|
import CookiePage from './pages/CookiePage';
|
||||||
import { getUser } from './store/auth';
|
import { getUser } from './store/auth';
|
||||||
import { ThemeProvider, useTheme } from './store/theme';
|
import { ThemeProvider } from './store/theme';
|
||||||
|
import { useTheme } from './store/useTheme';
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
// 用 state 驱动重渲染,登录/登出时调 refreshAuth()
|
// 用 state 驱动重渲染,登录/登出时调 refreshAuth()
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageRe
|
|||||||
|
|
||||||
interface CreateBatchParams {
|
interface CreateBatchParams {
|
||||||
account_ids: number[];
|
account_ids: number[];
|
||||||
max_geetest_retries?: number;
|
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
max_proxy_retries?: number;
|
|
||||||
max_login_retries?: number;
|
max_login_retries?: number;
|
||||||
max_total_time?: number;
|
max_total_time?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,33 +121,6 @@ export interface ProxyTestResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Log ====================
|
|
||||||
|
|
||||||
export interface HttpLogEntry {
|
|
||||||
timestamp: string;
|
|
||||||
ts: number;
|
|
||||||
category: string;
|
|
||||||
tag: string;
|
|
||||||
method: string;
|
|
||||||
url: string;
|
|
||||||
proxy: string | null;
|
|
||||||
request: { headers: Record<string, string>; body: string };
|
|
||||||
response: { status_code: number | null; headers: Record<string, string>; body: string };
|
|
||||||
duration_ms: number | null;
|
|
||||||
error: string | null;
|
|
||||||
level: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HttpLogListResult {
|
|
||||||
items: HttpLogEntry[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HttpLogClearResult {
|
|
||||||
success: boolean;
|
|
||||||
cleared: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Permissions ====================
|
// ==================== Permissions ====================
|
||||||
|
|
||||||
export interface PermissionsListResult {
|
export interface PermissionsListResult {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||||
import { authApi } from '../api/modules';
|
import { authApi } from '../api/modules';
|
||||||
import { useTheme, type ThemeMode } from '../store/theme';
|
import { type ThemeMode } from '../store/theme';
|
||||||
|
import { useTheme } from '../store/useTheme';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ function copyToClipboard(text: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const ok = document.execCommand('copy');
|
const ok = document.execCommand('copy');
|
||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
ok ? resolve() : reject(new Error('execCommand copy failed'));
|
if (ok) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('execCommand copy failed'));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
reject(e);
|
reject(e);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { authApi } from '../api/modules';
|
import { authApi } from '../api/modules';
|
||||||
import { setAuth, type AuthUser } from '../store/auth';
|
import { setAuth, type AuthUser } from '../store/auth';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
import { useTheme } from '../store/theme';
|
import { useTheme } from '../store/useTheme';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Table, Button, Select, message, Tag, Space, InputNumber, Tooltip, Popconfirm, theme,
|
Table, Button, Select, message, Tag, Space, InputNumber, Tooltip, Popconfirm, theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import { accountApi, loginApi, type AccountItem, type LoginTaskItem } from '../api/modules';
|
import { accountApi, loginApi, type AccountItem, type LoginTaskItem } from '../api/modules';
|
||||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
@@ -42,14 +42,9 @@ export default function LoginTasksPage() {
|
|||||||
const v = localStorage.getItem('login_concurrency');
|
const v = localStorage.getItem('login_concurrency');
|
||||||
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
|
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
|
||||||
});
|
});
|
||||||
const [maxProxyRetries, setMaxProxyRetries] = useState(() => {
|
|
||||||
const v = localStorage.getItem('login_max_proxy_retries');
|
|
||||||
return v ? Math.max(0, Number(v) || 0) : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 值变化时自动持久化
|
// 值变化时自动持久化
|
||||||
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
|
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
|
||||||
useEffect(() => { localStorage.setItem('login_max_proxy_retries', String(maxProxyRetries)); }, [maxProxyRetries]);
|
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
@@ -145,11 +140,7 @@ export default function LoginTasksPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await loginApi.createBatch({
|
const result = await loginApi.createBatch({
|
||||||
account_ids: accountIds,
|
account_ids: accountIds,
|
||||||
max_geetest_retries: 5,
|
|
||||||
concurrency,
|
concurrency,
|
||||||
max_proxy_retries: maxProxyRetries,
|
|
||||||
max_login_retries: 3,
|
|
||||||
max_total_time: 300,
|
|
||||||
});
|
});
|
||||||
setBatchId(result.batch_id);
|
setBatchId(result.batch_id);
|
||||||
message.success(`已创建登录任务,共 ${result.count} 个账号`);
|
message.success(`已创建登录任务,共 ${result.count} 个账号`);
|
||||||
@@ -347,19 +338,6 @@ export default function LoginTasksPage() {
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="代理切换次数,0=无限切换直到成功">
|
|
||||||
<Space size={4}>
|
|
||||||
<SwapOutlined style={{ color: token.colorTextSecondary }} />
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={999}
|
|
||||||
value={maxProxyRetries}
|
|
||||||
onChange={(v) => setMaxProxyRetries(v ?? 10)}
|
|
||||||
style={{ width: 55 }}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</Tooltip>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
DEFAULT_THEME_MODE,
|
||||||
|
THEME_STORAGE_KEY,
|
||||||
|
ThemeContext,
|
||||||
|
type ThemeMode,
|
||||||
|
} from './themeContext';
|
||||||
|
|
||||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
export type { ThemeMode } from './themeContext';
|
||||||
|
|
||||||
const STORAGE_KEY = 'theme_mode';
|
|
||||||
const DEFAULT_MODE: ThemeMode = 'system';
|
|
||||||
|
|
||||||
interface ThemeContextValue {
|
|
||||||
mode: ThemeMode;
|
|
||||||
isDark: boolean;
|
|
||||||
setMode: (mode: ThemeMode) => void;
|
|
||||||
toggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue>({
|
|
||||||
mode: DEFAULT_MODE,
|
|
||||||
isDark: false,
|
|
||||||
setMode: () => {},
|
|
||||||
toggle: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [mode, setModeState] = useState<ThemeMode>(() => {
|
const [mode, setModeState] = useState<ThemeMode>(() => {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
return (saved as ThemeMode) || DEFAULT_MODE;
|
return (saved as ThemeMode) || DEFAULT_THEME_MODE;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 监听系统主题变化
|
||||||
@@ -41,7 +30,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const setMode = useCallback((newMode: ThemeMode) => {
|
const setMode = useCallback((newMode: ThemeMode) => {
|
||||||
setModeState(newMode);
|
setModeState(newMode);
|
||||||
localStorage.setItem(STORAGE_KEY, newMode);
|
localStorage.setItem(THEME_STORAGE_KEY, newMode);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
@@ -59,7 +48,3 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
return useContext(ThemeContext);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_MODE: ThemeMode = 'system';
|
||||||
|
export const THEME_STORAGE_KEY = 'theme_mode';
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
mode: ThemeMode;
|
||||||
|
isDark: boolean;
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
mode: DEFAULT_THEME_MODE,
|
||||||
|
isDark: false,
|
||||||
|
setMode: () => {},
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext } from './themeContext';
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user