type: 收敛测试 schemas 与协议层类型

This commit is contained in:
yml2213
2026-08-30 20:35:08 +08:00
parent 92dc461e52
commit c891ac982e
26 changed files with 1846 additions and 882 deletions
+70 -37
View File
@@ -49,7 +49,9 @@ class EmailVerifier:
self.password = password # 邮箱密码 self.password = password # 邮箱密码
self.timeout = timeout self.timeout = timeout
self.roundcube_url = roundcube_url or ROUNDCUBE_URL self.roundcube_url = roundcube_url or ROUNDCUBE_URL
self.backup_passwords = backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS self.backup_passwords = (
backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS
)
# Roundcube 会话(懒初始化) # Roundcube 会话(懒初始化)
self._rc_session: Optional[requests.Session] = None self._rc_session: Optional[requests.Session] = None
@@ -87,7 +89,9 @@ class EmailVerifier:
for index, password in enumerate(passwords): for index, password in enumerate(passwords):
is_backup = index > 0 is_backup = index > 0
ok, password_failed = self._try_roundcube_login(password, is_backup=is_backup) ok, password_failed = self._try_roundcube_login(
password, is_backup=is_backup
)
if ok: if ok:
return True return True
credential_failed = credential_failed or password_failed credential_failed = credential_failed or password_failed
@@ -112,13 +116,17 @@ class EmailVerifier:
seen.add(password) seen.add(password)
return result return result
def _try_roundcube_login(self, password: str, is_backup: bool = False) -> tuple[bool, bool]: def _try_roundcube_login(
self, password: str, is_backup: bool = False
) -> tuple[bool, bool]:
"""尝试一次 Roundcube 登录,返回 (是否成功, 是否明确为密码错误)。""" """尝试一次 Roundcube 登录,返回 (是否成功, 是否明确为密码错误)。"""
try: try:
session = requests.Session() session = requests.Session()
session.headers.update({ session.headers.update(
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", {
}) "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
)
# 1. 访问首页获取 token # 1. 访问首页获取 token
resp = session.get(self.roundcube_url, timeout=self.timeout) resp = session.get(self.roundcube_url, timeout=self.timeout)
@@ -143,17 +151,20 @@ class EmailVerifier:
) )
# 3. 检查登录是否成功:登录失败页也可能有 request_token,不能只靠 token 判定。 # 3. 检查登录是否成功:登录失败页也可能有 request_token,不能只靠 token 判定。
new_token_match = re.search(r'request_token["\s:]+["\']([^"\']+)', resp.text) new_token_match = re.search(
r'request_token["\s:]+["\']([^"\']+)', resp.text
)
if not new_token_match: if not new_token_match:
logger.warning("Roundcube: 登录失败(未找到 request_token") logger.warning("Roundcube: 登录失败(未找到 request_token")
return False, False return False, False
text_lower = resp.text.lower() text_lower = resp.text.lower()
login_failed = "_err=loginfailed" in resp.url or "loginfailed" in text_lower login_failed = "_err=loginfailed" in resp.url or "loginfailed" in text_lower
login_form_present = 'name="_user"' in resp.text and 'name="_pass"' in resp.text login_form_present = (
mail_page = ( 'name="_user"' in resp.text and 'name="_pass"' in resp.text
"_task=mail" in resp.url )
or re.search(r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text) mail_page = "_task=mail" in resp.url or re.search(
r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text
) )
if login_failed or login_form_present or not mail_page: if login_failed or login_form_present or not mail_page:
@@ -183,6 +194,8 @@ class EmailVerifier:
if self._rc_login_error: if self._rc_login_error:
raise EmailLoginError(self._rc_login_error) raise EmailLoginError(self._rc_login_error)
return [] return []
if self._rc_session is None:
return []
try: try:
resp = self._rc_session.get( resp = self._rc_session.get(
@@ -201,7 +214,7 @@ class EmailVerifier:
# 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...); # 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...);
for m in re.finditer( for m in re.finditer(
r'add_message_row\((\d+),\s*(\{[^}]+\})', r"add_message_row\((\d+),\s*(\{[^}]+\})",
exec_text, exec_text,
): ):
uid = int(m.group(1)) uid = int(m.group(1))
@@ -210,11 +223,13 @@ class EmailVerifier:
subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str) subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str)
date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str) date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str)
messages.append({ messages.append(
"uid": uid, {
"subject": subject_m.group(1) if subject_m else "", "uid": uid,
"date": date_m.group(1) if date_m else "", "subject": subject_m.group(1) if subject_m else "",
}) "date": date_m.group(1) if date_m else "",
}
)
return messages return messages
@@ -230,11 +245,12 @@ class EmailVerifier:
if self._rc_login_error: if self._rc_login_error:
raise EmailLoginError(self._rc_login_error) raise EmailLoginError(self._rc_login_error)
return None return None
if self._rc_session is None:
return None
try: try:
resp = self._rc_session.get( resp = self._rc_session.get(
f"{self.roundcube_url}?_task=mail&_action=show" f"{self.roundcube_url}?_task=mail&_action=show&_mbox=INBOX&_uid={uid}",
f"&_mbox=INBOX&_uid={uid}",
timeout=self.timeout, timeout=self.timeout,
) )
return resp.text return resp.text
@@ -280,8 +296,14 @@ class EmailVerifier:
# 星期X # 星期X
weekday_map = { weekday_map = {
"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3, "星期一": 0,
"星期": 4, "星期六": 5, "星期日": 6, "星期天": 6, "星期": 1,
"星期三": 2,
"星期四": 3,
"星期五": 4,
"星期六": 5,
"星期日": 6,
"星期天": 6,
} }
for prefix, wd in weekday_map.items(): for prefix, wd in weekday_map.items():
if date_str.startswith(prefix): if date_str.startswith(prefix):
@@ -295,7 +317,9 @@ class EmailVerifier:
target = now target = now
else: else:
target = now - timedelta(days=days_ago) target = now - timedelta(days=days_ago)
return target.replace(hour=int(h), minute=int(m), second=0, microsecond=0) return target.replace(
hour=int(h), minute=int(m), second=0, microsecond=0
)
except (ValueError, AttributeError): except (ValueError, AttributeError):
return None return None
@@ -366,7 +390,9 @@ class EmailVerifier:
raise InterruptedError("任务已停止") raise InterruptedError("任务已停止")
try: try:
code = self._fetch_code_via_roundcube(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
@@ -383,7 +409,9 @@ class EmailVerifier:
raise InterruptedError("任务已停止") raise InterruptedError("任务已停止")
time.sleep(min(0.2, sleep_deadline - time.monotonic())) time.sleep(min(0.2, sleep_deadline - time.monotonic()))
raise TimeoutError(f"等待验证码超时{'' + last_error + '' if last_error else ''}") raise TimeoutError(
f"等待验证码超时{'' + last_error + '' if last_error else ''}"
)
# ── Roundcube 方式获取验证码 ──────────────────────────── # ── Roundcube 方式获取验证码 ────────────────────────────
@@ -397,7 +425,8 @@ class EmailVerifier:
# 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看) # 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看)
douyu_msgs = [ douyu_msgs = [
msg for msg in messages msg
for msg in messages
if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "") if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "")
] ]
@@ -407,7 +436,9 @@ class EmailVerifier:
msg_dt = self._parse_rc_date(msg["date"]) msg_dt = self._parse_rc_date(msg["date"])
if msg_dt is None: if msg_dt is None:
# 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过 # 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过") logger.debug(
f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过"
)
continue continue
# Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00), # Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00),
# 而 after_timestamp 是秒级精度(如14:20:49)。 # 而 after_timestamp 是秒级精度(如14:20:49)。
@@ -415,7 +446,9 @@ class EmailVerifier:
# 解决:将 after_timestamp 也向下取整到分钟后再比较。 # 解决:将 after_timestamp 也向下取整到分钟后再比较。
after_minute_ts = after_timestamp - (after_timestamp % 60) after_minute_ts = after_timestamp - (after_timestamp % 60)
if msg_dt.timestamp() < after_minute_ts - allow_old_seconds: if msg_dt.timestamp() < after_minute_ts - allow_old_seconds:
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过") logger.debug(
f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过"
)
continue continue
# 读取邮件正文提取验证码 # 读取邮件正文提取验证码
@@ -436,16 +469,16 @@ class EmailVerifier:
"""从文本中提取6位验证码""" """从文本中提取6位验证码"""
# 清理HTML标签和实体 # 清理HTML标签和实体
text = html.unescape(text) text = html.unescape(text)
text = re.sub(r'<[^>]+>', ' ', text) text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r'\s+', ' ', text) text = re.sub(r"\s+", " ", text)
# 查找6位数字验证码(优先匹配带上下文的) # 查找6位数字验证码(优先匹配带上下文的)
patterns = [ patterns = [
r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|)?\s*(\d{6})', r"(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|)?\s*(\d{6})",
r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})', r"(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})",
r'(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)', r"(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)",
r'(?:verification\s+code|security\s+code|code)\s*(?:is|:|)?\s*(\d{6})', r"(?:verification\s+code|security\s+code|code)\s*(?:is|:|)?\s*(\d{6})",
r'\b(\d{6})\b', r"\b(\d{6})\b",
] ]
for pattern in patterns: for pattern in patterns:
@@ -459,7 +492,7 @@ class EmailVerifier:
def get_email_config_for_account(email_address: str) -> dict: def get_email_config_for_account(email_address: str) -> dict:
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)""" """根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
return { return {
'server': '', "server": "",
'port': 143, "port": 143,
'ssl': False, "ssl": False,
} }
+194 -116
View File
@@ -16,7 +16,9 @@ from .login_api_wgapi import WgapiLoginAPI
from .proxy_fetcher import ProxyFetcher from .proxy_fetcher import ProxyFetcher
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,
) )
from core.geetest.common.network import ( from core.geetest.common.network import (
get_js_address, get_js_address,
@@ -44,6 +46,7 @@ class CredentialError(ValueError):
class AccountLike(Protocol): class AccountLike(Protocol):
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。""" """DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
username: str username: str
password: str password: str
email: str email: str
@@ -56,7 +59,9 @@ class AccountLike(Protocol):
class LoginResult: class LoginResult:
"""登录结果""" """登录结果"""
def __init__(self, success: bool, cookie: str = "", message: str = "", code: str = ""): def __init__(
self, success: bool, cookie: str = "", message: str = "", code: str = ""
):
self.success = success self.success = success
self.cookie = cookie self.cookie = cookie
self.message = message self.message = message
@@ -68,7 +73,9 @@ class DouyuLogin:
# 斗鱼API地址 # 斗鱼API地址
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"
WEBLOGIN_API = "https://msg.douyu.com/webLogin" WEBLOGIN_API = "https://msg.douyu.com/webLogin"
CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax" CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax"
@@ -141,50 +148,52 @@ class DouyuLogin:
"""配置Session""" """配置Session"""
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。 # 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
self.session.trust_env = False self.session.trust_env = False
self.session.headers.update({ self.session.headers.update(
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', {
'Accept': 'application/json, text/javascript, */*; q=0.01', "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
'Accept-Language': 'zh-CN,zh;q=0.9', "Accept": "application/json, text/javascript, */*; q=0.01",
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', "Accept-Language": "zh-CN,zh;q=0.9",
'Referer': self.LOGIN_REFERER, "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
'Origin': 'https://passport.douyu.com', "Referer": self.LOGIN_REFERER,
'X-Requested-With': 'XMLHttpRequest', "Origin": "https://passport.douyu.com",
}) "X-Requested-With": "XMLHttpRequest",
}
)
# 设置代理 # 设置代理
self._apply_proxy() self._apply_proxy()
def _apply_proxy(self, proxy: str = None) -> None: def _apply_proxy(self, proxy: str | None = None) -> None:
"""应用代理到Session""" """应用代理到Session"""
if proxy: if proxy:
# 使用指定的代理 # 使用指定的代理
self.session.proxies = { self.session.proxies = {
'http': proxy, "http": proxy,
'https': proxy, "https": proxy,
} }
self._current_proxy_url = proxy self._current_proxy_url = proxy
elif self.proxy: elif self.proxy:
# 使用配置的静态代理 # 使用配置的静态代理
if isinstance(self.proxy, str): if isinstance(self.proxy, str):
self.session.proxies = { self.session.proxies = {
'http': self.proxy, "http": self.proxy,
'https': self.proxy, "https": self.proxy,
} }
self._current_proxy_url = self.proxy self._current_proxy_url = self.proxy
else: else:
self.session.proxies = { self.session.proxies = {
scheme: url scheme: url for scheme, url in self.proxy.items() if url
for scheme, url in self.proxy.items()
if url
} }
self._current_proxy_url = self.proxy.get('http') or self.proxy.get('https') self._current_proxy_url = self.proxy.get("http") or self.proxy.get(
"https"
)
elif self.proxy_fetcher: elif self.proxy_fetcher:
# 从代理获取器取新代理 # 从代理获取器取新代理
new_proxy = self.proxy_fetcher.fetch_new_proxy() new_proxy = self.proxy_fetcher.fetch_new_proxy()
if new_proxy: if new_proxy:
self.session.proxies = { self.session.proxies = {
'http': new_proxy, "http": new_proxy,
'https': new_proxy, "https": new_proxy,
} }
self._current_proxy_url = new_proxy self._current_proxy_url = new_proxy
else: else:
@@ -207,7 +216,7 @@ class DouyuLogin:
统一发送请求,附带分段超时和更明确的错误信息。 统一发送请求,附带分段超时和更明确的错误信息。
代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。 代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。
""" """
timeout = kwargs.pop('timeout', self.timeout) timeout = kwargs.pop("timeout", self.timeout)
safe_url = self._safe_url(url) safe_url = self._safe_url(url)
self._ensure_not_stopped() self._ensure_not_stopped()
@@ -256,19 +265,21 @@ class DouyuLogin:
@staticmethod @staticmethod
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None: def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
"""识别登录接口返回的账号类终态。""" """识别登录接口返回的账号类终态。"""
error_code = payload.get('error') error_code = payload.get("error")
error_msg = str(payload.get('msg') or '') error_msg = str(payload.get("msg") or "")
if error_code == 110022 or '账号不存在' in error_msg: if error_code == 110022 or "账号不存在" in error_msg:
return "account_cancelled", "账号已注销" return "account_cancelled", "账号已注销"
password_keywords = ['密码错误', '账号或密码', '账号或者密码'] password_keywords = ["密码错误", "账号或密码", "账号或者密码"]
if error_code == 110018 or any(kw in error_msg for kw in password_keywords): if error_code == 110018 or any(kw in error_msg for kw in password_keywords):
return "password_wrong", "账号密码错误" return "password_wrong", "账号密码错误"
return None return None
def _credential_error_from_payload(self, stage: str, payload: dict) -> CredentialError | None: def _credential_error_from_payload(
self, stage: str, payload: dict
) -> CredentialError | None:
"""把账号类错误转换为不重试的 CredentialError。""" """把账号类错误转换为不重试的 CredentialError。"""
classified = self._classify_credential_payload(payload) classified = self._classify_credential_payload(payload)
if not classified: if not classified:
@@ -293,10 +304,14 @@ class DouyuLogin:
# 3️⃣ 第二次登录(带极验) # 3️⃣ 第二次登录(带极验)
logger.info("步骤3: 第二次登录(带极验验证)...") logger.info("步骤3: 第二次登录(带极验验证)...")
next_step, value = self._second_login( next_step, value = self._second_login(
gt, challenge, validate, seccode, code_token, gt,
challenge,
validate,
seccode,
code_token,
) )
if next_step == 'mobile_bind_skip': if next_step == "mobile_bind_skip":
logger.info("步骤4: 跳过手机号绑定,完成登录...") logger.info("步骤4: 跳过手机号绑定,完成登录...")
return self._complete_login(value) return self._complete_login(value)
@@ -339,12 +354,19 @@ class DouyuLogin:
return LoginResult(success=False, message="任务已停止") return LoginResult(success=False, message="任务已停止")
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
if self.max_total_time > 0 and 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(
return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s") 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:
if self.max_login_retries > 0: if self.max_login_retries > 0:
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始") logger.info(
f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
)
else: else:
logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始") logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始")
# 重试前:取新代理 + 重置 session # 重试前:取新代理 + 重置 session
@@ -371,15 +393,23 @@ class DouyuLogin:
return LoginResult(success=False, message=str(e), code=e.code) return LoginResult(success=False, message=str(e), code=e.code)
except EmailLoginError as e: except EmailLoginError as e:
logger.error(f"登录失败(邮箱登录失败,不再重试): {e}") logger.error(f"登录失败(邮箱登录失败,不再重试): {e}")
return LoginResult(success=False, message=str(e), code="email_login_failed") return LoginResult(
success=False, message=str(e), code="email_login_failed"
)
except Exception as e: except Exception as e:
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
if self.max_login_retries > 0: if self.max_login_retries > 0:
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}") logger.error(
f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}"
)
else: else:
logger.error(f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}") logger.error(
f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}"
)
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries 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 has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
if has_retry and has_time: if has_retry and has_time:
self._sleep_interruptible(1) self._sleep_interruptible(1)
@@ -405,14 +435,23 @@ class DouyuLogin:
return LoginResult(success=False, message="任务已停止") return LoginResult(success=False, message="任务已停止")
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
if self.max_total_time > 0 and 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(
return LoginResult(success=False, message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s") 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:
if self.max_login_retries > 0: if self.max_login_retries > 0:
logger.info(f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始") logger.info(
f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
)
else: else:
logger.info(f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始") logger.info(
f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始"
)
if not self._prepare_retry(): if not self._prepare_retry():
return LoginResult( return LoginResult(
success=False, success=False,
@@ -431,15 +470,23 @@ class DouyuLogin:
return LoginResult(success=True, message=e.status_message, code=e.code) return LoginResult(success=True, message=e.status_message, code=e.code)
except EmailLoginError as e: except EmailLoginError as e:
logger.error(f"账号检测失败(邮箱登录失败,不再重试): {e}") logger.error(f"账号检测失败(邮箱登录失败,不再重试): {e}")
return LoginResult(success=False, message=str(e), code="email_login_failed") return LoginResult(
success=False, message=str(e), code="email_login_failed"
)
except Exception as e: except Exception as e:
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
if self.max_login_retries > 0: if self.max_login_retries > 0:
logger.error(f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}") logger.error(
f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}"
)
else: else:
logger.error(f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}") logger.error(
f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}"
)
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries 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 has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
if has_retry and has_time: if has_retry and has_time:
self._sleep_interruptible(1) self._sleep_interruptible(1)
@@ -449,40 +496,44 @@ class DouyuLogin:
def _check_certification_status(self) -> LoginResult: def _check_certification_status(self) -> LoginResult:
"""请求个人中心接口并判断实名状态。""" """请求个人中心接口并判断实名状态。"""
headers = { headers = {
'Accept': 'application/json, text/javascript, */*; q=0.01', "Accept": "application/json, text/javascript, */*; q=0.01",
'Accept-Language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6', "Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Pragma': 'no-cache', "Pragma": "no-cache",
'Priority': 'u=1, i', "Priority": "u=1, i",
'Referer': self.CP_REFERER, "Referer": self.CP_REFERER,
'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', "Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
'Sec-CH-UA-Mobile': '?0', "Sec-CH-UA-Mobile": "?0",
'Sec-CH-UA-Platform': '"macOS"', "Sec-CH-UA-Platform": '"macOS"',
'Sec-Fetch-Dest': 'empty', "Sec-Fetch-Dest": "empty",
'Sec-Fetch-Mode': 'cors', "Sec-Fetch-Mode": "cors",
'Sec-Fetch-Site': 'same-origin', "Sec-Fetch-Site": "same-origin",
'X-Requested-With': 'XMLHttpRequest', "X-Requested-With": "XMLHttpRequest",
'Origin': None, "Origin": None,
'Content-Type': None, "Content-Type": None,
} }
payload = self._request_json( payload = self._request_json(
'get', "get",
self.CP_RPC_API, self.CP_RPC_API,
'账号认证状态接口', "账号认证状态接口",
headers=headers, headers=headers,
timeout=(5, 10), timeout=(5, 10),
) )
info = payload.get('info') or {} info = payload.get("info") or {}
ident_status = str(info.get('ident_status', '')) ident_status = str(info.get("ident_status", ""))
ident_type = str(info.get('ident_type', '')) ident_type = str(info.get("ident_type", ""))
if ident_status == '0' and ident_type == '0': if ident_status == "0" and ident_type == "0":
logger.success("账号检测完成: 账号未认证") logger.success("账号检测完成: 账号未认证")
return LoginResult(success=True, message="账号未认证", code="account_unverified") return LoginResult(
success=True, message="账号未认证", code="account_unverified"
)
if ident_status == '2' and ident_type == '2': if ident_status == "2" and ident_type == "2":
logger.success("账号检测完成: 账号已认证") logger.success("账号检测完成: 账号已认证")
return LoginResult(success=True, message="账号已认证", code="account_verified") return LoginResult(
success=True, message="账号已认证", code="account_verified"
)
message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}" message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}"
logger.warning(message) logger.warning(message)
@@ -528,17 +579,19 @@ class DouyuLogin:
) )
payload = self._request_json( payload = self._request_json(
'post', "post",
self.api.first_login_url, self.api.first_login_url,
'第一次登录接口', "第一次登录接口",
data=data, data=data,
) )
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}") logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
if payload.get('error') != 81: if payload.get("error") != 81:
error_msg = payload.get('msg', '未知错误') error_msg = payload.get("msg", "未知错误")
credential_error = self._credential_error_from_payload("第一次登录", payload) credential_error = self._credential_error_from_payload(
"第一次登录", payload
)
if credential_error: if credential_error:
raise credential_error raise credential_error
raise ValueError(f"第一次登录失败: {error_msg}") raise ValueError(f"第一次登录失败: {error_msg}")
@@ -553,7 +606,9 @@ class DouyuLogin:
return gt, challenge, code_token, self.session.cookies.get_dict() return gt, challenge, code_token, self.session.cookies.get_dict()
def _solve_geetest(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]: def _solve_geetest(
self, gt: str, challenge: str, deadline: float = 0
) -> Tuple[str, str]:
""" """
解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理) 解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理)
@@ -578,7 +633,9 @@ class DouyuLogin:
finally: finally:
_geetest_semaphore.release() _geetest_semaphore.release()
def _solve_geetest_inner(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]: def _solve_geetest_inner(
self, gt: str, challenge: str, deadline: float = 0
) -> Tuple[str, str]:
"""极验验证内部实现:最多3次尝试,失败直接抛异常。""" """极验验证内部实现:最多3次尝试,失败直接抛异常。"""
_MAX_ATTEMPTS = 3 _MAX_ATTEMPTS = 3
@@ -620,14 +677,18 @@ class DouyuLogin:
# 从 fullpage ajax.php 响应中提取 validate # 从 fullpage ajax.php 响应中提取 validate
if isinstance(result, dict): if isinstance(result, dict):
data = result.get('data', {}) data = result.get("data", {})
validate = data.get('validate', '') or result.get('validate', '') validate = data.get("validate", "") or result.get("validate", "")
success = data.get('result') == 'success' or result.get('success') == 1 success = (
message = data.get('result') or result.get('message', '') data.get("result") == "success" or result.get("success") == 1
)
message = data.get("result") or result.get("message", "")
if success and validate: if success and validate:
seccode = f"{validate}|jordan" seccode = f"{validate}|jordan"
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...") logger.success(
f"极验 fullpage 验证成功! validate={validate[:20]}..."
)
return validate, seccode return validate, seccode
else: else:
logger.warning(f"极验验证失败: {message},回到 login 换新代理") logger.warning(f"极验验证失败: {message},回到 login 换新代理")
@@ -636,7 +697,9 @@ class DouyuLogin:
validate = str(result) validate = str(result)
if validate: if validate:
seccode = f"{validate}|jordan" seccode = f"{validate}|jordan"
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...") logger.success(
f"极验 fullpage 验证成功! validate={validate[:20]}..."
)
return validate, seccode return validate, seccode
except ValueError: except ValueError:
@@ -645,11 +708,16 @@ class DouyuLogin:
except Exception as e: except Exception as e:
err_str = str(e) err_str = str(e)
# 所有网络/代理异常都直接上抛,由 login 换新代理 # 所有网络/代理异常都直接上抛,由 login 换新代理
logger.warning(f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理") logger.warning(
raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理"
)
raise ValueError(
f"极验验证异常: {self._truncate_error(err_str)}"
) from e
def _second_login(self, gt: str, challenge: str, validate: str, def _second_login(
seccode: str, code_token: str) -> tuple[str, str]: self, gt: str, challenge: str, validate: str, seccode: str, code_token: str
) -> tuple[str, str]:
""" """
第二次登录(带极验验证) 第二次登录(带极验验证)
@@ -668,16 +736,22 @@ class DouyuLogin:
data = self.api.build_second_login_data( data = self.api.build_second_login_data(
self.account.username, self.account.username,
self.account.password, self.account.password,
gt, challenge, validate, seccode, code_token, gt,
challenge,
validate,
seccode,
code_token,
self.LOGIN_REFERER, self.LOGIN_REFERER,
) )
logger.debug(f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}...") logger.debug(
f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}..."
)
payload = self._request_json( payload = self._request_json(
'post', "post",
self.api.login_url, self.api.login_url,
'第二次登录接口', "第二次登录接口",
data=data, data=data,
) )
@@ -689,11 +763,13 @@ class DouyuLogin:
if unique_key: if unique_key:
logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定") logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定")
login_url = self._skip_mobile_bind(unique_key) login_url = self._skip_mobile_bind(unique_key)
return 'mobile_bind_skip', login_url return "mobile_bind_skip", login_url
if payload.get('error') != 130014: if payload.get("error") != 130014:
error_msg = payload.get('msg', '未知错误') error_msg = payload.get("msg", "未知错误")
credential_error = self._credential_error_from_payload("第二次登录", payload) credential_error = self._credential_error_from_payload(
"第二次登录", payload
)
if credential_error: if credential_error:
raise credential_error raise credential_error
raise ValueError(f"第二次登录失败: {error_msg}") raise ValueError(f"第二次登录失败: {error_msg}")
@@ -703,26 +779,26 @@ class DouyuLogin:
if not remote_code: if not remote_code:
# 提取可能的风控提示 # 提取可能的风控提示
data = payload.get('data', {}) data = payload.get("data", {})
security_quiz = data.get('securityQuiz', '') security_quiz = data.get("securityQuiz", "")
msg = payload.get('msg', '') msg = payload.get("msg", "")
detail = security_quiz or msg or '未知原因' detail = security_quiz or msg or "未知原因"
raise ValueError(f"获取remote_code失败: {detail}") raise ValueError(f"获取remote_code失败: {detail}")
logger.info(f"获取remote_code成功: {remote_code[:20]}...") logger.info(f"获取remote_code成功: {remote_code[:20]}...")
return 'remote_email', remote_code return "remote_email", remote_code
def _skip_mobile_bind(self, unique_key: str) -> str: def _skip_mobile_bind(self, unique_key: str) -> str:
"""跳过手机号绑定并获取后续登录回调地址。""" """跳过手机号绑定并获取后续登录回调地址。"""
payload = self._request_json( payload = self._request_json(
'post', "post",
self.api.login_url, self.api.login_url,
'跳过手机号绑定接口', "跳过手机号绑定接口",
data=self.api.build_skip_mobile_bind_data(unique_key), data=self.api.build_skip_mobile_bind_data(unique_key),
) )
if payload.get('error') != 0: if payload.get("error") != 0:
raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}") raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}")
login_url = self.api.extract_login_url(payload) login_url = self.api.extract_login_url(payload)
@@ -738,13 +814,13 @@ class DouyuLogin:
data = self.api.build_send_email_data(remote_code) data = self.api.build_send_email_data(remote_code)
payload = self._request_json( payload = self._request_json(
'post', "post",
self.api.send_email_url, self.api.send_email_url,
'发送验证邮件接口', "发送验证邮件接口",
data=data, data=data,
) )
if payload.get('error') != 0: if payload.get("error") != 0:
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}") raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
logger.info("验证邮件已发送") logger.info("验证邮件已发送")
@@ -775,13 +851,13 @@ class DouyuLogin:
data = self.api.build_verify_data(remote_code, verify_code) data = self.api.build_verify_data(remote_code, verify_code)
payload = self._request_json( payload = self._request_json(
'post', "post",
self.api.verify_url, self.api.verify_url,
'提交验证码接口', "提交验证码接口",
data=data, data=data,
) )
if payload.get('error') != 0: if payload.get("error") != 0:
raise ValueError(f"提交验证码失败: {payload.get('msg')}") raise ValueError(f"提交验证码失败: {payload.get('msg')}")
login_url = self.api.extract_login_url(payload) login_url = self.api.extract_login_url(payload)
@@ -798,8 +874,8 @@ class DouyuLogin:
@staticmethod @staticmethod
def _normalize_login_url(login_url: str) -> str: def _normalize_login_url(login_url: str) -> str:
"""补全斗鱼接口返回的协议相对登录回调地址。""" """补全斗鱼接口返回的协议相对登录回调地址。"""
if login_url.startswith('//'): if login_url.startswith("//"):
return 'https:' + login_url return "https:" + login_url
return login_url return login_url
def _complete_login(self, login_url: str) -> str: def _complete_login(self, login_url: str) -> str:
@@ -810,16 +886,16 @@ class DouyuLogin:
cookie: 完整的Cookie字符串 cookie: 完整的Cookie字符串
""" """
# 访问登录URL # 访问登录URL
response = self._request('get', login_url) response = self._request("get", login_url)
response.raise_for_status() response.raise_for_status()
# 尝试访问webLogin获取用户信息 # 尝试访问webLogin获取用户信息
try: try:
code_match = re.search(r'code=([^&]+)', login_url) code_match = re.search(r"code=([^&]+)", login_url)
if code_match: if code_match:
code = code_match.group(1) code = code_match.group(1)
weblogin_url = f"{self.WEBLOGIN_API}?code={code}" weblogin_url = f"{self.WEBLOGIN_API}?code={code}"
response2 = self._request('get', weblogin_url) response2 = self._request("get", weblogin_url)
if response2.status_code == 200: if response2.status_code == 200:
logger.info("WebLogin成功") logger.info("WebLogin成功")
@@ -839,7 +915,9 @@ class DouyuLogin:
raise raise
except Exception as e: except Exception as e:
self._cookie_enrich_error = self._truncate_error(str(e), 160) self._cookie_enrich_error = self._truncate_error(str(e), 160)
logger.warning(f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}") logger.warning(
f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}"
)
return self._format_cookie_string() return self._format_cookie_string()
@@ -848,6 +926,6 @@ class DouyuLogin:
cookies = self.session.cookies.get_dict() cookies = self.session.cookies.get_dict()
# 格式化Cookie字符串 # 格式化Cookie字符串
cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()]) cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()])
return cookie_str return cookie_str
+18 -7
View File
@@ -6,7 +6,7 @@
3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段 3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段
""" """
from typing import Optional, Type from typing import Any, Optional, Type, cast
from .base import BaseWhitelistAdapter, get_exit_ip_via_proxy from .base import BaseWhitelistAdapter, get_exit_ip_via_proxy
from .xiequ import XiequAdapter from .xiequ import XiequAdapter
@@ -22,19 +22,30 @@ _PLATFORM_REGISTRY: dict[str, Type[BaseWhitelistAdapter]] = {
_PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = { _PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = {
"xiequ": [ "xiequ": [
{"key": "uid", "label": "UID", "placeholder": "如: 99769"}, {"key": "uid", "label": "UID", "placeholder": "如: 99769"},
{"key": "ukey", "label": "UKEY", "placeholder": "如: C99371082B965B70F46DCAA87A04618B"}, {
"key": "ukey",
"label": "UKEY",
"placeholder": "如: C99371082B965B70F46DCAA87A04618B",
},
], ],
"xkdaili": [ "xkdaili": [
{"key": "apikey", "label": "API Key", "placeholder": "如: XK86872CC0C85A415461"}, {
{"key": "wl_sign", "label": "白名单签名(Sign)", "placeholder": "白名单接口专用,如: 8953b3082173aa4188e7403e81249027"}, "key": "apikey",
"label": "API Key",
"placeholder": "如: XK86872CC0C85A415461",
},
{
"key": "wl_sign",
"label": "白名单签名(Sign)",
"placeholder": "白名单接口专用,如: 8953b3082173aa4188e7403e81249027",
},
{"key": "flag", "label": "Flag", "placeholder": "套餐标识,如: 8"}, {"key": "flag", "label": "Flag", "placeholder": "套餐标识,如: 8"},
], ],
} }
# ── 平台显示名 ── # ── 平台显示名 ──
_PLATFORM_LABELS: dict[str, str] = { _PLATFORM_LABELS: dict[str, str] = {
name: cls(dict()).platform_label name: cast(Any, cls)({}).platform_label for name, cls in _PLATFORM_REGISTRY.items()
for name, cls in _PLATFORM_REGISTRY.items()
} }
@@ -43,7 +54,7 @@ def create_adapter(platform: str, credentials: dict) -> Optional[BaseWhitelistAd
cls = _PLATFORM_REGISTRY.get(platform) cls = _PLATFORM_REGISTRY.get(platform)
if not cls: if not cls:
return None return None
return cls(credentials) return cast(Any, cls)(credentials)
def get_platform_names() -> list[str]: def get_platform_names() -> list[str]:
+17 -7
View File
@@ -110,20 +110,26 @@ class BaseWhitelistAdapter(ABC):
return True, msg return True, msg
# 当前IP已存在但备注不同,不处理 # 当前IP已存在但备注不同,不处理
if any(r.get('ip') == current_ip for r in records): if any(r.get("ip") == current_ip for r in records):
logger.info(f"白名单IP {current_ip} 已存在(备注不同),无需重复添加") logger.info(
f"白名单IP {current_ip} 已存在(备注不同),无需重复添加"
)
return True, f"白名单IP已存在: {current_ip}" return True, f"白名单IP已存在: {current_ip}"
# 超过上限,删除最老的 # 超过上限,删除最老的
if len(memo_ips) >= keep_recent: if len(memo_ips) >= keep_recent:
to_delete = memo_ips[:len(memo_ips) - keep_recent + 1] to_delete = memo_ips[: len(memo_ips) - keep_recent + 1]
for old_ip in to_delete: for old_ip in to_delete:
if not old_ip:
continue
logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}") logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}")
self.delete_ip(old_ip) self.delete_ip(old_ip)
time.sleep(1) time.sleep(1)
# 添加新IP # 添加新IP
logger.info(f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)") logger.info(
f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)"
)
ok, resp = self.add_ip(current_ip) ok, resp = self.add_ip(current_ip)
if ok: if ok:
msg = f"白名单IP已添加: {current_ip}" msg = f"白名单IP已添加: {current_ip}"
@@ -152,10 +158,12 @@ def _get_local_exit_ip() -> Optional[str]:
] ]
for url in targets: for url in targets:
try: try:
response = requests.get(url, timeout=8, headers={"User-Agent": "Mozilla/5.0"}) response = requests.get(
url, timeout=8, headers={"User-Agent": "Mozilla/5.0"}
)
response.raise_for_status() response.raise_for_status()
text = response.text.strip() text = response.text.strip()
match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', text) match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
if match: if match:
return match.group(1) return match.group(1)
except Exception: except Exception:
@@ -174,7 +182,9 @@ def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
for url in targets: for url in targets:
try: try:
response = requests.get( response = requests.get(
url, proxies=proxies, timeout=6, url,
proxies=proxies,
timeout=6,
headers={"User-Agent": "Mozilla/5.0"}, headers={"User-Agent": "Mozilla/5.0"},
) )
response.raise_for_status() response.raise_for_status()
+1 -1
View File
@@ -17,7 +17,7 @@ class DouyuWhitelistSyncer:
def __init__( def __init__(
self, self,
platform: str = "xiequ", platform: str = "xiequ",
credentials: dict = None, credentials: Optional[dict] = None,
uid: str = "", uid: str = "",
ukey: str = "", ukey: str = "",
): ):
+66 -71
View File
@@ -4,7 +4,9 @@ from typing import Any, Optional
# 生成类人的鼠标轨迹 # 生成类人的鼠标轨迹
def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int, start_time:int) -> list[Any]: def generate_realistic_trajectory(
start_x: int, start_y: int, end_x: int, end_y: int, start_time: int
) -> list[Any]:
"""生成类人的鼠标轨迹""" """生成类人的鼠标轨迹"""
trajectory = [] trajectory = []
@@ -41,70 +43,56 @@ def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int
# 随机时间间隔(3-25ms,符合人类反应) # 随机时间间隔(3-25ms,符合人类反应)
time_delta = random.choices( time_delta = random.choices(
[3, 4, 5, 6, 7, 8, 10, 12, 15, 20, 24], [3, 4, 5, 6, 7, 8, 10, 12, 15, 20, 24],
weights=[5, 8, 10, 12, 10, 8, 5, 3, 2, 1, 1] weights=[5, 8, 10, 12, 10, 8, 5, 3, 2, 1, 1],
)[0] )[0]
current_time += time_delta current_time += time_delta
trajectory.append([ trajectory.append(["move", current_x, current_y, current_time, "pointermove"])
"move",
current_x,
current_y,
current_time,
"pointermove"
])
# 偶尔在同一位置停留(模拟视觉确认) # 偶尔在同一位置停留(模拟视觉确认)
if random.random() < 0.15: if random.random() < 0.15:
trajectory.append([ trajectory.append(
"move", [
current_x, "move",
current_y, current_x,
current_time + random.randint(5, 15), current_y,
"pointermove" current_time + random.randint(5, 15),
]) "pointermove",
]
)
current_time += random.randint(5, 15) current_time += random.randint(5, 15)
# 到达目标后的悬停 # 到达目标后的悬停
hover_time = random.randint(50, 150) hover_time = random.randint(50, 150)
for _ in range(random.randint(2, 5)): for _ in range(random.randint(2, 5)):
current_time += random.randint(8, 25) current_time += random.randint(8, 25)
trajectory.append([ trajectory.append(
"move", [
end_x + random.randint(-1, 1), "move",
end_y + random.randint(-1, 1), end_x + random.randint(-1, 1),
current_time, end_y + random.randint(-1, 1),
"pointermove" current_time,
]) "pointermove",
]
)
current_time += hover_time current_time += hover_time
# 点击事件 # 点击事件
trajectory.append([ trajectory.append(["down", end_x, end_y, current_time, "pointerdown"])
"down",
end_x,
end_y,
current_time,
"pointerdown"
])
trajectory.append([ trajectory.append(["focus", current_time + 1])
"focus",
current_time + 1
])
click_duration = random.randint(80, 130) click_duration = random.randint(80, 130)
trajectory.append([ trajectory.append(["up", end_x, end_y, current_time + click_duration, "pointerup"])
"up",
end_x,
end_y,
current_time + click_duration,
"pointerup"
])
return trajectory return trajectory
# 处理原始轨迹数组 # 处理原始轨迹数组
def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None) -> dict[str,Any]: def process_mouse_trajectory(
events: list[Any], max_records: Optional[int] = None
) -> dict[str, Any]:
""" """
处理鼠标/触摸轨迹数据,将绝对坐标转换为相对坐标和时间差 处理鼠标/触摸轨迹数据,将绝对坐标转换为相对坐标和时间差
@@ -116,7 +104,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
处理后的事件列表 处理后的事件列表
""" """
if not events or len(events) == 0: if not events or len(events) == 0:
return [] return {"data": [], "first_event": None, "last_event": None, "total_events": 0}
# 初始化变量 # 初始化变量
prev_x = 0 # 上一个X坐标 prev_x = 0 # 上一个X坐标
@@ -130,8 +118,17 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
MOVE_EVENTS = ["move", "mousemove", "touchmove", "pointermove"] MOVE_EVENTS = ["move", "mousemove", "touchmove", "pointermove"]
# 点击类事件(仅时间信息) # 点击类事件(仅时间信息)
CLICK_EVENTS = ["down", "up", "click", "mousedown", "mouseup", CLICK_EVENTS = [
"touchstart", "touchend", "pointerdown", "pointerup"] "down",
"up",
"click",
"mousedown",
"mouseup",
"touchstart",
"touchend",
"pointerdown",
"pointerup",
]
# 特殊事件(仅时间信息) # 特殊事件(仅时间信息)
TIME_ONLY_EVENTS = ["focus", "blur", "keydown", "keyup"] TIME_ONLY_EVENTS = ["focus", "blur", "keydown", "keyup"]
@@ -168,11 +165,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
time_diff = timestamp - prev_time time_diff = timestamp - prev_time
# 添加到结果数组 # 添加到结果数组
result.append([ result.append([event_type, [delta_x, delta_y], time_diff])
event_type,
[delta_x, delta_y],
time_diff
])
# 更新上一次的值 # 更新上一次的值
prev_x = x prev_x = x
@@ -190,11 +183,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
time_diff = timestamp - prev_time time_diff = timestamp - prev_time
# 添加到结果数组(坐标差为[0,0]) # 添加到结果数组(坐标差为[0,0])
result.append([ result.append([event_type, [0, 0], time_diff])
event_type,
[0, 0],
time_diff
])
prev_time = timestamp prev_time = timestamp
@@ -209,10 +198,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
time_diff = timestamp - prev_time time_diff = timestamp - prev_time
# 添加到结果数组(仅包含时间差) # 添加到结果数组(仅包含时间差)
result.append([ result.append([event_type, time_diff])
event_type,
time_diff
])
prev_time = timestamp prev_time = timestamp
@@ -220,10 +206,11 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
"data": result, "data": result,
"first_event": first_event, "first_event": first_event,
"last_event": last_event, "last_event": last_event,
"total_events": len(result) "total_events": len(result),
} }
def compress_trajectory(e:list[Any]) -> str:
def compress_trajectory(e: list[Any]) -> str:
""" """
压缩轨迹数据的完整实现 压缩轨迹数据的完整实现
@@ -242,7 +229,7 @@ def compress_trajectory(e:list[Any]) -> str:
"focus": 4, "focus": 4,
"blur": 5, "blur": 5,
"unload": 6, "unload": 6,
"unknown": 7 "unknown": 7,
} }
def h(e, t): def h(e, t):
@@ -434,11 +421,13 @@ def compress_trajectory(e:list[Any]) -> str:
"""Base64编码""" """Base64编码"""
t = "" t = ""
n = len(e) // 6 n = len(e) // 6
base64_chars = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~" base64_chars = (
"()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
)
for r in range(n): for r in range(n):
# 每次取6位二进制 # 每次取6位二进制
binary_str = e[6 * r: 6 * (r + 1)] binary_str = e[6 * r : 6 * (r + 1)]
index = int(binary_str, 2) index = int(binary_str, 2)
t += base64_chars[index] t += base64_chars[index]
@@ -446,9 +435,12 @@ def compress_trajectory(e:list[Any]) -> str:
return u(c_str) return u(c_str)
class TrajectoryEncoder: class TrajectoryEncoder:
def __init__(self) -> None: def __init__(self) -> None:
self.CHARSET = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr" self.CHARSET = (
"()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
)
self.BASE = len(self.CHARSET) # 64 self.BASE = len(self.CHARSET) # 64
self.DIRECTION_CHARS = "stuvwxyz~" self.DIRECTION_CHARS = "stuvwxyz~"
self.DIRECTION_PATTERNS = [ self.DIRECTION_PATTERNS = [
@@ -460,7 +452,7 @@ class TrajectoryEncoder:
[0, -1], # x [0, -1], # x
[3, 0], # y [3, 0], # y
[2, -1], # z [2, -1], # z
[2, 1] # ~ [2, 1], # ~
] ]
def encode_number(self, num): def encode_number(self, num):
@@ -546,9 +538,11 @@ class TrajectoryEncoder:
t_encoded.append(self.encode_number(dt)) t_encoded.append(self.encode_number(dt))
# 拼接:x坐标 !! y坐标 !! 时间戳 # 拼接:x坐标 !! y坐标 !! 时间戳
return "".join(x_encoded) + "!!" + "".join(y_encoded) + "!!" + "".join(t_encoded) return (
"".join(x_encoded) + "!!" + "".join(y_encoded) + "!!" + "".join(t_encoded)
)
def encrypt_string(self,e, t, n): def encrypt_string(self, e, t, n):
""" """
JS加密函数的Python实现 JS加密函数的Python实现
@@ -568,7 +562,7 @@ class TrajectoryEncoder:
# 每次读取2个字符(十六进制) # 每次读取2个字符(十六进制)
while o < len(n): while o < len(n):
r = n[o:o + 2] # 取2个字符 r = n[o : o + 2] # 取2个字符
if len(r) < 2: if len(r) < 2:
break break
o += 2 o += 2
@@ -587,7 +581,8 @@ class TrajectoryEncoder:
return i return i
def H(t:int, e:str) -> str:
def H(t: int, e: str) -> str:
# 解析后缀 # 解析后缀
n = e[-2:] n = e[-2:]
r = [] r = []
@@ -625,4 +620,4 @@ def H(t:int, e:str) -> str:
g.pop(d) g.pop(d)
d -= 1 d -= 1
return p return p
+39 -2
View File
@@ -1,8 +1,37 @@
"""虎牙协议与业务客户端。""" """虎牙协议与业务客户端。"""
from typing import TYPE_CHECKING
from .http_client import HuyaHttpClient from .http_client import HuyaHttpClient
from .wss_client import HuyaWssClient from .wss_client import HuyaWssClient
if TYPE_CHECKING:
from .activity_structs import GetUserScoreReq, GetUserScoreResp
from .app_login import (
HuyaAppLoginError,
HuyaAppPasswordLogin,
HuyaAppQrAuthRequiredError,
login_huya_app_password,
)
from .login import (
HuyaCredentialError,
HuyaLoginError,
HuyaLoginResult,
HuyaPasswordLogin,
login_huya_password,
)
from .sms_login import (
HuyaSmsCodeResult,
HuyaSmsLogin,
login_huya_sms,
send_huya_sms_code,
)
from .verification import (
HuyaVerificationError,
HuyaVerificationSolver,
solve_huya_verification,
)
__all__ = [ __all__ = [
"HuyaHttpClient", "HuyaHttpClient",
"HuyaWssClient", "HuyaWssClient",
@@ -103,8 +132,16 @@ def __getattr__(name: str):
} }
globals().update(values) globals().update(values)
return values[name] return values[name]
if name in {"HuyaVerificationError", "HuyaVerificationSolver", "solve_huya_verification"}: if name in {
from .verification import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification "HuyaVerificationError",
"HuyaVerificationSolver",
"solve_huya_verification",
}:
from .verification import (
HuyaVerificationError,
HuyaVerificationSolver,
solve_huya_verification,
)
values = { values = {
"HuyaVerificationError": HuyaVerificationError, "HuyaVerificationError": HuyaVerificationError,
+44 -19
View File
@@ -18,6 +18,7 @@
- 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽; - 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽;
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。 - qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
@@ -176,13 +177,13 @@ def parse_cred(resp: bytes) -> bytes | None:
e = resp.find(b"_wup_header") e = resp.find(b"_wup_header")
if s < 0 or e < 0: if s < 0 or e < 0:
return None return None
d = resp[s:e - 6] d = resp[s : e - 6]
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d) m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d)
if not m: if not m:
return None return None
ln = m.group(2)[0] ln = m.group(2)[0]
st = m.start() + 4 st = m.start() + 4
cred = d[st:st + ln] cred = d[st : st + ln]
if len(cred) == 114 and cred[:1] == b"\x0a": if len(cred) == 114 and cred[:1] == b"\x0a":
return cred return cred
return None return None
@@ -209,7 +210,10 @@ def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict:
HuyaVerificationSolver, HuyaVerificationSolver,
) )
q = {k: v[0] for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()} q = {
k: v[0]
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
}
app_id = str(q.get("appId") or "5002") app_id = str(q.get("appId") or "5002")
last_err: Exception | None = None last_err: Exception | None = None
for attempt in range(max_retry): for attempt in range(max_retry):
@@ -287,7 +291,11 @@ def login_cred_with_flow(
return cred, uid return cred, uid
risk_url = parse_risk_url(resp) risk_url = parse_risk_url(resp)
if risk_url: if risk_url:
kind = "pt_auth(滑块)" if "pt_auth" in risk_url else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知") kind = (
"pt_auth(滑块)"
if "pt_auth" in risk_url
else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
)
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}") logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
if "qr_auth" in risk_url: if "qr_auth" in risk_url:
raise HuyaAppQrAuthRequiredError( raise HuyaAppQrAuthRequiredError(
@@ -317,12 +325,14 @@ class QrRole:
self.context = f"{prefix}-{ctx_hex}-{tail}" self.context = f"{prefix}-{ctx_hex}-{tail}"
self.page_id = random.randint(40_000_000, 41_000_000) self.page_id = random.randint(40_000_000, 41_000_000)
self.req_counter = random.randint(40_000_000, 41_000_000) self.req_counter = random.randint(40_000_000, 41_000_000)
self.s.headers.update({ self.s.headers.update(
"User-Agent": UA_PC if pc else APP_UA_MOBILE, {
"Origin": UDB_BASE, "User-Agent": UA_PC if pc else APP_UA_MOBILE,
"content-type": "application/json;charset=UTF-8", "Origin": UDB_BASE,
"Accept": "*/*", "content-type": "application/json;charset=UTF-8",
}) "Accept": "*/*",
}
)
def _headers(self, uri: str) -> dict: def _headers(self, uri: str) -> dict:
mid = "2.6" if self.pc else "2.5" mid = "2.6" if self.pc else "2.5"
@@ -334,7 +344,9 @@ class QrRole:
"Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}", "Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}",
} }
def call(self, path: str, uri: str, data: dict, cookies: dict | None = None) -> dict: def call(
self, path: str, uri: str, data: dict, cookies: dict | None = None
) -> dict:
envelope = { envelope = {
"uri": uri, "uri": uri,
"version": "2.6" if self.pc else "2.5", "version": "2.6" if self.pc else "2.5",
@@ -389,13 +401,16 @@ class HuyaAppPasswordLogin:
self.proxies = dict(proxies) if proxies else None self.proxies = dict(proxies) if proxies else None
self.timeout = timeout or (10.0, 25.0) self.timeout = timeout or (10.0, 25.0)
self.force_new_device = force_new_device self.force_new_device = force_new_device
self.device_info = device_info or get_profile(self.username, force_new=force_new_device) self.device_info = device_info or get_profile(
self.username, force_new=force_new_device
)
def login(self) -> HuyaLoginResult: def login(self) -> HuyaLoginResult:
"""执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。""" """执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。"""
result = self._login_impl() result = self._login_impl()
try: try:
from .device_profile import record_login from .device_profile import record_login
record_login(self.username, result.success, result.message) record_login(self.username, result.success, result.message)
except Exception: # 元数据记录失败不影响登录结果 except Exception: # 元数据记录失败不影响登录结果
pass pass
@@ -404,7 +419,9 @@ class HuyaAppPasswordLogin:
def _login_impl(self) -> HuyaLoginResult: def _login_impl(self) -> HuyaLoginResult:
"""登录主体 (原 login)。""" """登录主体 (原 login)。"""
acct = self.username acct = self.username
logger.info(f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})...") logger.info(
f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})..."
)
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块) # 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
try: try:
@@ -442,7 +459,9 @@ class HuyaAppPasswordLogin:
# 3) 信封补丁 # 3) 信封补丁
raw = bytearray(env.raw) raw = bytearray(env.raw)
raw[env.cert_off:env.cert_off + env.cert_len] = cert.encode("ascii") if env.cert_off is None or env.uid_off is None:
raise ValueError("信封缺少证书或 uid 偏移")
raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii")
if env.uid != uid: if env.uid != uid:
struct.pack_into(">Q", raw, env.uid_off, uid) struct.pack_into(">Q", raw, env.uid_off, uid)
wup = base64.b64encode(bytes(raw)).decode("ascii") wup = base64.b64encode(bytes(raw)).decode("ascii")
@@ -457,9 +476,11 @@ class HuyaAppPasswordLogin:
try: try:
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享 # 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复) # 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
sdid_obj = get_huya_sdid(allow_fallback=True, sdid_obj = get_huya_sdid(
state_dir=account_state_dir(self.username), allow_fallback=True,
device_hint=self.device_info) state_dir=account_state_dir(self.username),
device_hint=self.device_info,
)
sdid = sdid_obj.sdid if sdid_obj else "" sdid = sdid_obj.sdid if sdid_obj else ""
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies) pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies) ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies)
@@ -502,7 +523,9 @@ class HuyaAppPasswordLogin:
}, },
) )
if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0: if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0:
logger.warning(f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}") logger.warning(
f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}"
)
# 4.3 轮询 tryQrLogin # 4.3 轮询 tryQrLogin
biztoken = None biztoken = None
@@ -552,7 +575,9 @@ class HuyaAppPasswordLogin:
code="COOKIE_INCOMPLETE", code="COOKIE_INCOMPLETE",
) )
logger.info(f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)") logger.info(
f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)"
)
return HuyaLoginResult( return HuyaLoginResult(
success=True, success=True,
cookie=cookie_str, cookie=cookie_str,
+79 -45
View File
@@ -75,7 +75,9 @@ def generate_huya_password(prefix: str = "hy", random_length: int = 8) -> str:
"""生成适合虎牙账号使用的随机密码。""" """生成适合虎牙账号使用的随机密码。"""
clean_prefix = "".join(ch for ch in str(prefix or "hy") if ch.isalnum())[:8] or "hy" clean_prefix = "".join(ch for ch in str(prefix or "hy") if ch.isalnum())[:8] or "hy"
alphabet = string.ascii_lowercase + string.digits alphabet = string.ascii_lowercase + string.digits
suffix = "".join(random.SystemRandom().choice(alphabet) for _ in range(max(6, random_length))) suffix = "".join(
random.SystemRandom().choice(alphabet) for _ in range(max(6, random_length))
)
return f"{clean_prefix}{suffix}" return f"{clean_prefix}{suffix}"
@@ -86,13 +88,15 @@ def encode_change_password_behavior(stage: str) -> str:
actions: list[dict] = [] actions: list[dict] = []
if stage in {"send", "submit"}: if stage in {"send", "submit"}:
actions.append({ actions.append(
"id": "pmodify.btn.getsms", {
"x": random.randint(470, 520), "id": "pmodify.btn.getsms",
"y": random.randint(120, 145), "x": random.randint(470, 520),
"d": elapsed, "y": random.randint(120, 145),
"time": now, "d": elapsed,
}) "time": now,
}
)
if stage == "submit": if stage == "submit":
for action_id in ("pmodify.input.sms", "pmodify.input.pw", "pmodify.input.sms"): for action_id in ("pmodify.input.sms", "pmodify.input.pw", "pmodify.input.sms"):
now += random.randint(600, 3200) now += random.randint(600, 3200)
@@ -100,20 +104,24 @@ def encode_change_password_behavior(stage: str) -> str:
actions.append({"id": action_id, "d": elapsed, "time": now}) actions.append({"id": action_id, "d": elapsed, "time": now})
now += random.randint(800, 2400) now += random.randint(800, 2400)
elapsed += random.randint(800, 2400) elapsed += random.randint(800, 2400)
actions.append({ actions.append(
"id": "pmodify.btn.submit", {
"x": random.randint(395, 445), "id": "pmodify.btn.submit",
"y": random.randint(220, 245), "x": random.randint(395, 445),
"d": elapsed, "y": random.randint(220, 245),
"time": now, "d": elapsed,
}) "time": now,
}
)
value = { value = {
"furl": CHANGE_PASSWORD_FROM_URL, "furl": CHANGE_PASSWORD_FROM_URL,
"curl": CHANGE_PASSWORD_PAGE_URL, "curl": CHANGE_PASSWORD_PAGE_URL,
"user_action": actions, "user_action": actions,
} }
return quote(json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'") return quote(
json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'"
)
def _sleep_or_stop(stop_event: threading.Event | None, seconds: float) -> bool: def _sleep_or_stop(stop_event: threading.Event | None, seconds: float) -> bool:
@@ -132,7 +140,13 @@ def _payload_data(payload: dict) -> dict:
def _session_data(payload: dict, fallback: str = "") -> str: def _session_data(payload: dict, fallback: str = "") -> str:
data = _payload_data(payload) data = _payload_data(payload)
return str(data.get("sessionData") or data.get("sessiondata") or payload.get("sessionData") or fallback or "") return str(
data.get("sessionData")
or data.get("sessiondata")
or payload.get("sessionData")
or fallback
or ""
)
class HuyaPasswordChanger: class HuyaPasswordChanger:
@@ -174,23 +188,25 @@ class HuyaPasswordChanger:
self._setup_headers() self._setup_headers()
def _setup_headers(self) -> None: def _setup_headers(self) -> None:
self.session.headers.update({ self.session.headers.update(
"Accept": "application/json, text/plain, */*", {
"Accept-Language": "zh-CN,zh;q=0.9", "Accept": "application/json, text/plain, */*",
"Cache-Control": "no-cache", "Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "keep-alive", "Cache-Control": "no-cache",
"Content-Type": "application/json;charset=UTF-8", "Connection": "keep-alive",
"Origin": "https://udbreg.huya.com", "Content-Type": "application/json;charset=UTF-8",
"Pragma": "no-cache", "Origin": "https://udbreg.huya.com",
"Referer": self.middle_url, "Pragma": "no-cache",
"Sec-Fetch-Dest": "empty", "Referer": self.middle_url,
"Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty",
"Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors",
"User-Agent": self.ua, "Sec-Fetch-Site": "same-origin",
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"', "User-Agent": self.ua,
"sec-ch-ua-mobile": "?0", "sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-platform": '"macOS"', "sec-ch-ua-mobile": "?0",
}) "sec-ch-ua-platform": '"macOS"',
}
)
@staticmethod @staticmethod
def _safe_url(url: str) -> str: def _safe_url(url: str) -> str:
@@ -199,7 +215,9 @@ class HuyaPasswordChanger:
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict: def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
response = self.session.request(method, url, timeout=self.timeout, **kwargs) response = self.session.request(method, url, timeout=self.timeout, **kwargs)
logger.debug(f"{method.upper()} {self._safe_url(url)} -> {response.status_code}") logger.debug(
f"{method.upper()} {self._safe_url(url)} -> {response.status_code}"
)
response.raise_for_status() response.raise_for_status()
try: try:
return response.json() return response.json()
@@ -216,20 +234,26 @@ class HuyaPasswordChanger:
logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}") logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}")
except requests.RequestException as exc: except requests.RequestException as exc:
logger.debug(f"虎牙改密 middle 初始化失败: {host}: {exc}") logger.debug(f"虎牙改密 middle 初始化失败: {host}: {exc}")
self.session.headers.update({ self.session.headers.update(
"Origin": "https://udbreg.huya.com", {
"Referer": self.middle_url, "Origin": "https://udbreg.huya.com",
}) "Referer": self.middle_url,
}
)
def prepare_device(self) -> str: def prepare_device(self) -> str:
"""获取虎牙风控 sdid。""" """获取虎牙风控 sdid。"""
token_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"} token_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"}
token_res = self._request_json("post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload) token_res = self._request_json(
"post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload
)
token = token_res.get("data", {}).get("token") token = token_res.get("data", {}).get("token")
if not token: if not token:
raise HuyaLoginError(f"获取虎牙 df token 失败: {token_res}") raise HuyaLoginError(f"获取虎牙 df token 失败: {token_res}")
collect_res = self._request_json("post", DF_COLLECT_URL, "获取虎牙 sdid", json={"token": token}) collect_res = self._request_json(
"post", DF_COLLECT_URL, "获取虎牙 sdid", json={"token": token}
)
self.sdid = collect_res.get("data", {}).get("sdid", "") self.sdid = collect_res.get("data", {}).get("sdid", "")
if not self.sdid: if not self.sdid:
raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}") raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}")
@@ -240,7 +264,11 @@ class HuyaPasswordChanger:
from .verification import HuyaVerificationSolver from .verification import HuyaVerificationSolver
solver = HuyaVerificationSolver( solver = HuyaVerificationSolver(
cookie=self.session.cookies.get_dict(), cookie={
key: value
for key, value in self.session.cookies.get_dict().items()
if value is not None
},
ua=self.ua, ua=self.ua,
sdid=self.sdid, sdid=self.sdid,
session=self.session, session=self.session,
@@ -402,12 +430,16 @@ class HuyaPasswordChanger:
request_id=self.request_id, request_id=self.request_id,
) )
def submit_code(self, password: str, sms_code: str, session_data: str) -> HuyaChangePasswordResult: def submit_code(
self, password: str, sms_code: str, session_data: str
) -> HuyaChangePasswordResult:
"""提交改密短信验证码。""" """提交改密短信验证码。"""
if not session_data: if not session_data:
raise HuyaLoginError("缺少改密 sessionData,请先发送改密短信") raise HuyaLoginError("缺少改密 sessionData,请先发送改密短信")
payload = self._submit_once(password=password, sms_code=sms_code, session_data=session_data) payload = self._submit_once(
password=password, sms_code=sms_code, session_data=session_data
)
for index in range(3): for index in range(3):
return_code = int(payload.get("returnCode") or 0) return_code = int(payload.get("returnCode") or 0)
if return_code == 0: if return_code == 0:
@@ -436,7 +468,9 @@ class HuyaPasswordChanger:
logger.info(f"虎牙短信改密触发风控: {return_code} ({index + 1}/3)") logger.info(f"虎牙短信改密触发风控: {return_code} ({index + 1}/3)")
self._solve_verification(payload) self._solve_verification(payload)
time.sleep(0.5) time.sleep(0.5)
payload = self._submit_once(password=password, sms_code=sms_code, session_data=session_data) payload = self._submit_once(
password=password, sms_code=sms_code, session_data=session_data
)
return HuyaChangePasswordResult( return HuyaChangePasswordResult(
success=False, success=False,
+36 -21
View File
@@ -1,14 +1,23 @@
""" """
TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出 TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出
""" """
from typing import Any, cast
from .taf_protocol import TafInputStream, TafType from .taf_protocol import TafInputStream, TafType
from .wup_protocol import normalize_wup_payload from .wup_protocol import normalize_wup_payload
# cmd 编号 → 名称 # cmd 编号 → 名称
CMD_NAMES = { CMD_NAMES = {
0x03: "RPC_REQ", 0x04: "RPC_RSP", 0x0a: "AUTH", 0x03: "RPC_REQ",
0x0b: "PUSH1", 0x10: "HB_SEND", 0x11: "HB_RECV", 0x04: "RPC_RSP",
0x17: "CONFIRM", 0x18: "PUSH2", 0x21: "REGISTER", 0x22: "CONFIRM_RSP", 0x0A: "AUTH",
0x0B: "PUSH1",
0x10: "HB_SEND",
0x11: "HB_RECV",
0x17: "CONFIRM",
0x18: "PUSH2",
0x21: "REGISTER",
0x22: "CONFIRM_RSP",
} }
# TAIL_BYTES = 2c36004c5c6600 # TAIL_BYTES = 2c36004c5c6600
@@ -18,10 +27,14 @@ _TAIL = bytes.fromhex("2c36004c5c6600")
def _strip_tail(body: bytes) -> bytes: def _strip_tail(body: bytes) -> bytes:
"""裁掉 body 末尾的 TAIL_BYTES""" """裁掉 body 末尾的 TAIL_BYTES"""
if body.endswith(_TAIL): if body.endswith(_TAIL):
return body[:-len(_TAIL)] return body[: -len(_TAIL)]
# 有时 TAIL 前还有 0c (ZERO tag) # 有时 TAIL 前还有 0c (ZERO tag)
if len(body) > 1 and body[-len(_TAIL)-1:-len(_TAIL)] == b'\x0c' and body.endswith(_TAIL): if (
return body[:-len(_TAIL)-1] len(body) > 1
and body[-len(_TAIL) - 1 : -len(_TAIL)] == b"\x0c"
and body.endswith(_TAIL)
):
return body[: -len(_TAIL) - 1]
return body return body
@@ -33,16 +46,18 @@ def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object
return ins._read_int_value(dtype) return ins._read_int_value(dtype)
if dtype in (TafType.FLOAT, TafType.DOUBLE): if dtype in (TafType.FLOAT, TafType.DOUBLE):
import struct as _s import struct as _s
if dtype == TafType.FLOAT: if dtype == TafType.FLOAT:
return round(_s.unpack('>f', ins.buf.read(4))[0], 4) return round(_s.unpack(">f", ins.buf.read(4))[0], 4)
return round(_s.unpack('>d', ins.buf.read(8))[0], 6) return round(_s.unpack(">d", ins.buf.read(8))[0], 6)
if dtype == TafType.STRING1: if dtype == TafType.STRING1:
ln = ins.buf.read(1)[0] ln = ins.buf.read(1)[0]
return ins.buf.read(ln).decode('utf-8', errors='replace') return ins.buf.read(ln).decode("utf-8", errors="replace")
if dtype == TafType.STRING4: if dtype == TafType.STRING4:
import struct as _s import struct as _s
ln = _s.unpack('>I', ins.buf.read(4))[0]
return ins.buf.read(ln).decode('utf-8', errors='replace') ln = _s.unpack(">I", ins.buf.read(4))[0]
return ins.buf.read(ln).decode("utf-8", errors="replace")
if dtype == TafType.MAP: if dtype == TafType.MAP:
cnt = ins._read_int_len() cnt = ins._read_int_len()
m = {} m = {}
@@ -106,14 +121,14 @@ def _extract_wup(body: bytes) -> bytes:
return body return body
# 大包格式: [1B prefix][4B wup_len][wup_body][tail] # 大包格式: [1B prefix][4B wup_len][wup_body][tail]
# prefix 可能是 0x00,必须优先于 4B total_len 判断。 # prefix 可能是 0x00,必须优先于 4B total_len 判断。
wup_len = int.from_bytes(body[1:5], 'big') wup_len = int.from_bytes(body[1:5], "big")
if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03': if 5 + wup_len <= len(body) and body[5:7] == b"\x10\x03":
return body[5:5 + wup_len] return body[5 : 5 + wup_len]
total_len = int.from_bytes(body[0:4], 'big') total_len = int.from_bytes(body[0:4], "big")
if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03': if 8 <= total_len <= len(body) and body[4:6] == b"\x10\x03":
return body[:total_len] return body[:total_len]
if 5 + wup_len <= len(body): if 5 + wup_len <= len(body):
return body[5:5 + wup_len] return body[5 : 5 + wup_len]
return body return body
@@ -127,7 +142,7 @@ def _decode_wup_body(body: bytes) -> dict:
ins = TafInputStream(wup) ins = TafInputStream(wup)
# 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾) # 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾)
sBuffer = b'' sBuffer = b""
while True: while True:
try: try:
tag, dtype = ins.peek_head() tag, dtype = ins.peek_head()
@@ -267,8 +282,8 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
return f"{prefix} {label}" return f"{prefix} {label}"
# AUTH # AUTH
if cmd == 0x0a: if cmd == 0x0A:
text = _strip_tail(body).decode('utf-8', errors='replace') text = _strip_tail(body).decode("utf-8", errors="replace")
return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}" return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}"
# REGISTER / CONFIRM / PUSH 等 # REGISTER / CONFIRM / PUSH 等
@@ -302,7 +317,7 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
fields = {} fields = {}
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}") cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
if fields: if fields:
return f"{prefix} {cmd_name} {_fmt_fields(_truncate(fields))}" return f"{prefix} {cmd_name} {_fmt_fields(cast(dict[str, Any], _truncate(fields)))}"
return f"{prefix} {cmd_name}" return f"{prefix} {cmd_name}"
except Exception: except Exception:
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}") cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
+1 -1
View File
@@ -54,7 +54,7 @@ class TestEliteLockExchange:
assert result["order_id"] == "5874" assert result["order_id"] == "5874"
assert result["exchange_id"] == "11098933" assert result["exchange_id"] == "11098933"
assert self.client.csrf_token.call_count == 2 assert self.client.__dict__["csrf_token"].call_count == 2
create_call, pay_call = self.client._request_json.call_args_list create_call, pay_call = self.client._request_json.call_args_list
assert create_call.args[:3] == ( assert create_call.args[:3] == (
"post", "post",
+3 -1
View File
@@ -80,7 +80,9 @@ class TestDouyuProxyMode:
# 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理) # 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理)
fake.fetch_new_proxy.return_value = "http://10.0.0.3:3128" fake.fetch_new_proxy.return_value = "http://10.0.0.3:3128"
assert runner._proxies_for_task()["http"] == "http://10.0.0.3:3128" refreshed = runner._proxies_for_task()
assert refreshed is not None
assert refreshed["http"] == "http://10.0.0.3:3128"
assert fake.fetch_new_proxy.call_count == 3 assert fake.fetch_new_proxy.call_count == 3
def test_api_mode_failure_falls_back_to_direct(self): def test_api_mode_failure_falls_back_to_direct(self):
+1
View File
@@ -226,6 +226,7 @@ class TestHuyaAppLogin:
.filter(HuyaAccount.username == "mock_test_huya_user") .filter(HuyaAccount.username == "mock_test_huya_user")
.first() .first()
) )
assert acc is not None
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id]) batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
batch_resp = app_password_login_selected_accounts( batch_resp = app_password_login_selected_accounts(
req=batch_req, db=self.session, current=self.admin req=batch_req, db=self.session, current=self.admin
+2 -1
View File
@@ -11,6 +11,7 @@
import pytest import pytest
import base64 import base64
import json import json
import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -69,7 +70,7 @@ class TestParseResponse:
def test_parse_golden_evidence(self): def test_parse_golden_evidence(self):
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。""" """真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
if not CHAIN_FILE.exists(): if not CHAIN_FILE.exists():
self.skipTest("缺失 golden 注册链模板") pytest.skip("缺失 golden 注册链模板")
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8")) data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
resp = base64.b64decode(data["dfpReport"]["resp_b64"]) resp = base64.b64decode(data["dfpReport"]["resp_b64"])
t1, t2, t5 = _parse_response(resp) t1, t2, t5 = _parse_response(resp)
+5 -5
View File
@@ -96,11 +96,11 @@ class TestMigrationSmoke:
missing_columns = model_columns - db_columns missing_columns = model_columns - db_columns
assert not missing_columns assert not missing_columns
db_indexes = { db_indexes = set()
index["name"] for index in inspector.get_indexes(name):
for index in inspector.get_indexes(name) index_name = index["name"]
if not index["name"].startswith("sqlite_autoindex") if index_name and not index_name.startswith("sqlite_autoindex"):
} db_indexes.add(index_name)
all_db_indexes |= db_indexes all_db_indexes |= db_indexes
model_indexes = { model_indexes = {
index.name for index in table.indexes if index.name index.name for index in table.indexes if index.name
+5 -2
View File
@@ -34,9 +34,10 @@ def get_current_user(
payload = decode_access_token(token) payload = decode_access_token(token)
if payload is None: if payload is None:
raise credentials_exc raise credentials_exc
user_id: int = payload.get("sub") user_id_raw = payload.get("sub")
if user_id is None: if user_id_raw is None:
raise credentials_exc raise credentials_exc
user_id = int(user_id_raw)
except JWTError: except JWTError:
raise credentials_exc raise credentials_exc
@@ -48,6 +49,7 @@ def get_current_user(
def require_permission(permission: str): def require_permission(permission: str):
"""权限检查依赖工厂。用法: Depends(require_permission('user:create'))""" """权限检查依赖工厂。用法: Depends(require_permission('user:create'))"""
def checker(current_user: User = Depends(get_current_user)) -> User: def checker(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_active: if not current_user.is_active:
raise HTTPException(status_code=403, detail="账号已禁用") raise HTTPException(status_code=403, detail="账号已禁用")
@@ -55,6 +57,7 @@ def require_permission(permission: str):
if permission not in perms: if permission not in perms:
raise HTTPException(status_code=403, detail=f"无权限: {permission}") raise HTTPException(status_code=403, detail=f"无权限: {permission}")
return current_user return current_user
return checker return checker
+193 -73
View File
@@ -1,16 +1,32 @@
"""账号管理路由""" """账号管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, defer, joinedload from sqlalchemy.orm import Session, defer, joinedload
from ..database import get_db from ..database import get_db
from ..models import User, Account, AuditLog, LoginTask, DouyuTask, DouyuWorkbenchAccount from ..models import (
from ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign User,
Account,
AuditLog,
LoginTask,
DouyuTask,
DouyuWorkbenchAccount,
)
from ..schemas import (
AccountBulkSelection,
AccountBulkTag,
AccountImport,
AccountAssign,
AccountTag,
AccountOut,
BatchAssign,
)
from ..deps import get_current_user, require_permission from ..deps import get_current_user, require_permission
from ..permissions import user_has_permission from ..permissions import user_has_permission
from ..services.account_service import ( from ..services.account_service import (
cookie_account_ids_query, parse_and_build_accounts, cookie_account_ids_query,
parse_and_build_accounts,
) )
router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
@@ -46,15 +62,19 @@ def _filter_accounts_query(
search_text = (search or "").strip() search_text = (search or "").strip()
if search_text: if search_text:
pattern = f"%{search_text}%" pattern = f"%{search_text}%"
query = query.filter(or_( query = query.filter(
Account.username.ilike(pattern), or_(
Account.tag.ilike(pattern), Account.username.ilike(pattern),
Account.remark.ilike(pattern), Account.tag.ilike(pattern),
)) Account.remark.ilike(pattern),
)
)
return query return query
def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection) -> list[int]: def _selected_account_ids(
db: Session, current: User, req: AccountBulkSelection
) -> list[int]:
"""解析批量操作目标:当前筛选全部或显式选择的 ID。""" """解析批量操作目标:当前筛选全部或显式选择的 ID。"""
if req.all_matching: if req.all_matching:
rows = ( rows = (
@@ -71,7 +91,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
.order_by(None) .order_by(None)
.all() .all()
) )
return [account_id for account_id, in rows] return [account_id for (account_id,) in rows]
seen = set() seen = set()
ids = [] ids = []
@@ -88,7 +108,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
.order_by(None) .order_by(None)
.all() .all()
) )
allowed = {account_id for account_id, in rows} allowed = {account_id for (account_id,) in rows}
return [account_id for account_id in ids if account_id in allowed] return [account_id for account_id in ids if account_id in allowed]
@@ -131,19 +151,25 @@ def list_accounts(
if page is not None: if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size) query = query.offset((page - 1) * page_size).limit(page_size)
can_include_sensitive = include_sensitive and user_has_permission(current, "account:view_full") can_include_sensitive = include_sensitive and user_has_permission(
current, "account:view_full"
)
options = [joinedload(Account.assigned_user)] options = [joinedload(Account.assigned_user)]
if not can_include_sensitive: if not can_include_sensitive:
options.extend([ options.extend(
defer(Account.password), [
defer(Account.email), defer(Account.password),
defer(Account.email_password), defer(Account.email),
]) defer(Account.email_password),
]
)
accounts = query.options(*options).all() accounts = query.options(*options).all()
result = [] result = []
for acc in accounts: for acc in accounts:
item = AccountOut( item = AccountOut(
id=acc.id, username=acc.username, remark=acc.remark or "", id=acc.id,
username=acc.username,
remark=acc.remark or "",
tag=acc.tag or "", tag=acc.tag or "",
assigned_to=acc.assigned_to, assigned_to=acc.assigned_to,
assigned_username=acc.assigned_user.username if acc.assigned_user else None, assigned_username=acc.assigned_user.username if acc.assigned_user else None,
@@ -156,7 +182,12 @@ def list_accounts(
item.email_password = acc.email_password item.email_password = acc.email_password
result.append(item) result.append(item)
if page is not None: if page is not None:
return {"items": result, "total": total or 0, "page": page, "page_size": page_size} return {
"items": result,
"total": total or 0,
"page": page,
"page_size": page_size,
}
return result return result
@@ -209,8 +240,14 @@ def import_accounts(
if accounts: if accounts:
db.add_all(accounts) db.add_all(accounts)
db.add(AuditLog(user_id=current.id, username=current.username, db.add(
action="account:import", target=f"导入{len(accounts)}")) AuditLog(
user_id=current.id,
username=current.username,
action="account:import",
target=f"导入{len(accounts)}",
)
)
db.commit() db.commit()
duplicated_note = f",重复跳过 {duplicated}" if duplicated else "" duplicated_note = f",重复跳过 {duplicated}" if duplicated else ""
@@ -240,17 +277,27 @@ def assign_account(
raise HTTPException(status_code=400, detail="只能分配给客服角色") raise HTTPException(status_code=400, detail="只能分配给客服角色")
# 只能分配已成功登录过的账号(有cookie) # 只能分配已成功登录过的账号(有cookie)
has_success = db.query(LoginTask).filter( has_success = (
LoginTask.account_id == account_id, db.query(LoginTask)
LoginTask.status == 'success', .filter(
LoginTask.cookie != '', LoginTask.account_id == account_id,
).first() LoginTask.status == "success",
LoginTask.cookie != "",
)
.first()
)
if not has_success: if not has_success:
raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配") raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配")
acc.assigned_to = req.assigned_to acc.assigned_to = req.assigned_to
db.add(AuditLog(user_id=current.id, username=current.username, db.add(
action="account:assign", target=acc.username)) AuditLog(
user_id=current.id,
username=current.username,
action="account:assign",
target=acc.username,
)
)
db.commit() db.commit()
return {"message": "已分配", "success": True} return {"message": "已分配", "success": True}
@@ -274,31 +321,52 @@ def batch_assign_accounts(
raise HTTPException(status_code=400, detail="只能分配给客服角色") raise HTTPException(status_code=400, detail="只能分配给客服角色")
# 只能分配已成功登录过的账号(有cookie) # 只能分配已成功登录过的账号(有cookie)
cookie_ids_query = cookie_account_ids_query(db).subquery() cookie_ids_query = select(LoginTask.account_id).where(
invalid_ids = db.query(Account.id).filter( LoginTask.status == "success",
Account.id.in_(req.account_ids), LoginTask.cookie != "",
Account.id.notin_(cookie_ids_query), LoginTask.cookie.isnot(None),
).all() )
invalid_ids = (
db.query(Account.id)
.filter(
Account.id.in_(req.account_ids),
Account.id.notin_(cookie_ids_query),
)
.all()
)
if invalid_ids: if invalid_ids:
names = db.query(Account.username).filter(Account.id.in_([i[0] for i in invalid_ids])).all() names = (
name_list = ', '.join([n[0] for n in names[:5]]) db.query(Account.username)
suffix = '...' if len(invalid_ids) > 5 else '' .filter(Account.id.in_([i[0] for i in invalid_ids]))
.all()
)
name_list = ", ".join([n[0] for n in names[:5]])
suffix = "..." if len(invalid_ids) > 5 else ""
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}", detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
) )
count = db.query(Account).filter(Account.id.in_(req.account_ids)).update( count = (
{Account.assigned_to: req.assigned_to}, synchronize_session=False db.query(Account)
.filter(Account.id.in_(req.account_ids))
.update({Account.assigned_to: req.assigned_to}, synchronize_session=False)
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:assign",
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号",
)
) )
db.add(AuditLog(
user_id=current.id, username=current.username,
action="account:assign",
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号"
))
db.commit() db.commit()
action = "分配" if req.assigned_to else "取消分配" action = "分配" if req.assigned_to else "取消分配"
return {"message": f"已批量{action} {count} 个账号", "success": True, "count": count} return {
"message": f"已批量{action} {count} 个账号",
"success": True,
"count": count,
}
@router.get("/assignments/summary") @router.get("/assignments/summary")
@@ -307,11 +375,17 @@ def assignments_summary(
current: User = Depends(require_permission("account:assign")), current: User = Depends(require_permission("account:assign")),
): ):
"""分配概览:每个客服分配了多少账号(仅统计已成功登录的账号)。""" """分配概览:每个客服分配了多少账号(仅统计已成功登录的账号)。"""
cookie_subq = cookie_account_ids_query(db).subquery() cookie_subq = select(LoginTask.account_id).where(
LoginTask.status == "success",
LoginTask.cookie != "",
LoginTask.cookie.isnot(None),
)
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery() cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
results = ( results = (
db.query(User.id, User.username, func.count(cookie_accounts.c.id).label("count")) db.query(
User.id, User.username, func.count(cookie_accounts.c.id).label("count")
)
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id) .outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
.filter(User.role == "support") .filter(User.role == "support")
.group_by(User.id, User.username) .group_by(User.id, User.username)
@@ -345,8 +419,15 @@ def set_account_tag(
if not acc: if not acc:
raise HTTPException(status_code=404, detail="账号不存在") raise HTTPException(status_code=404, detail="账号不存在")
acc.tag = (req.tag or "").strip() acc.tag = (req.tag or "").strip()
db.add(AuditLog(user_id=current.id, username=current.username, db.add(
action="account:tag", target=acc.username, detail=acc.tag)) AuditLog(
user_id=current.id,
username=current.username,
action="account:tag",
target=acc.username,
detail=acc.tag,
)
)
db.commit() db.commit()
return {"message": "标签已更新", "success": True} return {"message": "标签已更新", "success": True}
@@ -362,8 +443,10 @@ def batch_tag(
if not req.account_ids: if not req.account_ids:
raise HTTPException(status_code=400, detail="请选择账号") raise HTTPException(status_code=400, detail="请选择账号")
tag = (req.tag or "").strip() tag = (req.tag or "").strip()
count = _visible_accounts_query(db, current).filter(Account.id.in_(req.account_ids)).update( count = (
{Account.tag: tag}, synchronize_session=False _visible_accounts_query(db, current)
.filter(Account.id.in_(req.account_ids))
.update({Account.tag: tag}, synchronize_session=False)
) )
db.commit() db.commit()
return {"message": f"已为 {count} 个账号设置标签", "success": True} return {"message": f"已为 {count} 个账号设置标签", "success": True}
@@ -388,7 +471,11 @@ def batch_tag_selection(
) )
db.commit() db.commit()
scope = "当前筛选下" if req.all_matching else "选中的" scope = "当前筛选下" if req.all_matching else "选中的"
return {"message": f"已为{scope} {count} 个账号设置标签", "success": True, "count": count} return {
"message": f"已为{scope} {count} 个账号设置标签",
"success": True,
"count": count,
}
@router.get("/tags/list") @router.get("/tags/list")
@@ -422,17 +509,29 @@ def batch_delete_accounts(
raise HTTPException(status_code=400, detail="无效的账号ID") raise HTTPException(status_code=400, detail="无效的账号ID")
# 先删除关联的登录任务 # 先删除关联的登录任务
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False) db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False) synchronize_session=False
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False) )
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(
synchronize_session=False
)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.account_id.in_(ids)
).delete(synchronize_session=False)
# 删除账号 # 删除账号
deleted = db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False) deleted = (
db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
)
db.add(AuditLog( db.add(
user_id=current.id, username=current.username, AuditLog(
action="account:delete", target=f"批量删除{deleted}个账号" user_id=current.id,
)) username=current.username,
action="account:delete",
target=f"批量删除{deleted}个账号",
)
)
db.commit() db.commit()
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True} return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
@@ -448,19 +547,28 @@ def batch_delete_accounts_selection(
if not ids: if not ids:
raise HTTPException(status_code=400, detail="请选择账号") raise HTTPException(status_code=400, detail="请选择账号")
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False) db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False) synchronize_session=False
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False) )
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(
synchronize_session=False
)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.account_id.in_(ids)
).delete(synchronize_session=False)
deleted = ( deleted = (
_visible_accounts_query(db, current) _visible_accounts_query(db, current)
.filter(Account.id.in_(ids)) .filter(Account.id.in_(ids))
.delete(synchronize_session=False) .delete(synchronize_session=False)
) )
db.add(AuditLog( db.add(
user_id=current.id, username=current.username, AuditLog(
action="account:delete", user_id=current.id,
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号", username=current.username,
)) action="account:delete",
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
)
)
db.commit() db.commit()
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True} return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
@@ -476,12 +584,24 @@ def delete_account(
raise HTTPException(status_code=404, detail="账号不存在") raise HTTPException(status_code=404, detail="账号不存在")
# 先删除关联的登录任务,避免外键约束失败 # 先删除关联的登录任务,避免外键约束失败
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(synchronize_session=False) db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(synchronize_session=False) synchronize_session=False
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id == account_id).delete(synchronize_session=False) )
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(
synchronize_session=False
)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.account_id == account_id
).delete(synchronize_session=False)
db.add(AuditLog(user_id=current.id, username=current.username, db.add(
action="account:delete", target=acc.username)) AuditLog(
user_id=current.id,
username=current.username,
action="account:delete",
target=acc.username,
)
)
db.delete(acc) db.delete(acc)
db.commit() db.commit()
return {"message": "已删除", "success": True} return {"message": "已删除", "success": True}
+148 -77
View File
@@ -27,6 +27,7 @@ def _fmt_dt(dt) -> str | None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat() return dt.isoformat()
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"]) router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
cookie_relogin_registry = BatchRegistry() cookie_relogin_registry = BatchRegistry()
@@ -44,7 +45,9 @@ def _parse_account_names(raw_names: str) -> list[str]:
"""解析前端粘贴的 Excel 账号名,每行一个并去重。""" """解析前端粘贴的 Excel 账号名,每行一个并去重。"""
names = [] names = []
seen = set() seen = set()
for value in (raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"): for value in (
(raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
):
name = value.strip() name = value.strip()
if name and name not in seen: if name and name not in seen:
names.append(name) names.append(name)
@@ -60,7 +63,9 @@ def _order_cookie_tasks(query, selected_names: list[str]):
value=Account.username, value=Account.username,
else_=len(selected_names), else_=len(selected_names),
) )
return query.order_by(input_order, LoginTask.finished_at.desc(), LoginTask.id.desc()) return query.order_by(
input_order, LoginTask.finished_at.desc(), LoginTask.id.desc()
)
return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc()) return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
@@ -88,7 +93,9 @@ def _visible_cookie_operation_tasks_query(db: Session, current: User):
"""返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。""" """返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。"""
query = db.query(LoginTask).filter( query = db.query(LoginTask).filter(
LoginTask.cookie != "", LoginTask.cookie != "",
LoginTask.status.in_(("success", "relogin_pending", "relogin_running", "relogin_failed")), LoginTask.status.in_(
("success", "relogin_pending", "relogin_running", "relogin_failed")
),
) )
if not user_has_permission(current, "login:view_all"): if not user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id).filter( query = query.join(Account, LoginTask.account_id == Account.id).filter(
@@ -112,7 +119,9 @@ def list_cookies(
if not user_has_permission(current, "cookie:view"): if not user_has_permission(current, "cookie:view"):
raise HTTPException(status_code=403, detail="无权限: cookie:view") raise HTTPException(status_code=403, detail="无权限: cookie:view")
if not include_cookie: if not include_cookie:
query = _visible_cookie_tasks_query(db, current).options(defer(LoginTask.cookie)) query = _visible_cookie_tasks_query(db, current).options(
defer(LoginTask.cookie)
)
else: else:
query = _visible_cookie_tasks_query(db, current).options( query = _visible_cookie_tasks_query(db, current).options(
joinedload(LoginTask.account).joinedload(Account.assigned_user), joinedload(LoginTask.account).joinedload(Account.assigned_user),
@@ -137,15 +146,19 @@ def list_cookies(
if not account_joined: if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id) query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True account_joined = True
query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_( query = query.outerjoin(User, Account.assigned_to == User.id).filter(
Account.username.ilike(pattern), or_(
Account.tag.ilike(pattern), Account.username.ilike(pattern),
User.username.ilike(pattern), Account.tag.ilike(pattern),
)) User.username.ilike(pattern),
)
)
total = None total = None
if page is not None: if page is not None:
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0 total = (
query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
)
query = _order_cookie_tasks(query, selected_names) query = _order_cookie_tasks(query, selected_names)
if page is not None: if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size) query = query.offset((page - 1) * page_size).limit(page_size)
@@ -175,8 +188,10 @@ def list_cookies(
"batch_id": t.batch_id, "batch_id": t.batch_id,
"account_id": t.account_id, "account_id": t.account_id,
"account_username": acc.username if acc else "", "account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to, "assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None, "assigned_username": acc.assigned_user.username
if acc and acc.assigned_user
else None,
"created_at": _fmt_dt(t.finished_at), "created_at": _fmt_dt(t.finished_at),
"ck_check_status": t.ck_check_status or "", "ck_check_status": t.ck_check_status or "",
"ck_check_result": t.ck_check_result, "ck_check_result": t.ck_check_result,
@@ -194,7 +209,12 @@ def list_cookies(
item["account_password"] = "" item["account_password"] = ""
result.append(item) result.append(item)
if page is not None: if page is not None:
return {"items": result, "total": total or 0, "page": page, "page_size": page_size} return {
"items": result,
"total": total or 0,
"page": page,
"page_size": page_size,
}
return result return result
@@ -242,10 +262,12 @@ def list_cookie_operations(
if tag_value: if tag_value:
query = query.filter(Account.tag == tag_value) query = query.filter(Account.tag == tag_value)
if search_text: if search_text:
query = query.filter(or_( query = query.filter(
Account.username.ilike(f"%{search_text}%"), or_(
Account.tag.ilike(f"%{search_text}%"), Account.username.ilike(f"%{search_text}%"),
)) Account.tag.ilike(f"%{search_text}%"),
)
)
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0 total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
tasks = ( tasks = (
@@ -265,7 +287,9 @@ def list_cookie_operations(
"created_at": _fmt_dt(task.finished_at), "created_at": _fmt_dt(task.finished_at),
"relogin_status": task.status if task.status != "success" else "", "relogin_status": task.status if task.status != "success" else "",
"relogin_message": task.message or "", "relogin_message": task.message or "",
"relogin_batch_id": task.batch_id if task.status in {"relogin_pending", "relogin_running"} else "", "relogin_batch_id": task.batch_id
if task.status in {"relogin_pending", "relogin_running"}
else "",
} }
for task in tasks for task in tasks
], ],
@@ -292,7 +316,7 @@ def list_cookie_operation_tags(
.order_by(Account.tag.asc()) .order_by(Account.tag.asc())
.all() .all()
) )
return [tag for tag, in rows if tag] return [tag for (tag,) in rows if tag]
@router.get("/duplicates") @router.get("/duplicates")
@@ -317,7 +341,9 @@ def find_duplicate_cookies(
if not user_has_permission(current, "login:view_all"): if not user_has_permission(current, "login:view_all"):
query = query.filter(Account.assigned_to == current.id) query = query.filter(Account.assigned_to == current.id)
rows = query.order_by(Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()).all() rows = query.order_by(
Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()
).all()
grouped: dict[str, dict] = {} grouped: dict[str, dict] = {}
for row in rows: for row in rows:
username = (row.username or "").strip() username = (row.username or "").strip()
@@ -337,26 +363,30 @@ def find_duplicate_cookies(
group["account_names"].add(username) group["account_names"].add(username)
group["account_ids"].add(row.account_id) group["account_ids"].add(row.account_id)
group["cookie_ids"].append(row.cookie_id) group["cookie_ids"].append(row.cookie_id)
group["records"].append({ group["records"].append(
"id": row.cookie_id, {
"account_id": row.account_id, "id": row.cookie_id,
"batch_id": row.batch_id, "account_id": row.account_id,
"finished_at": _fmt_dt(row.finished_at), "batch_id": row.batch_id,
}) "finished_at": _fmt_dt(row.finished_at),
}
)
duplicate_groups = [] duplicate_groups = []
for group in grouped.values(): for group in grouped.values():
if len(group["cookie_ids"]) < 2: if len(group["cookie_ids"]) < 2:
continue continue
duplicate_groups.append({ duplicate_groups.append(
"account_key": group["account_key"], {
"account_names": sorted(group["account_names"]), "account_key": group["account_key"],
"cookie_count": len(group["cookie_ids"]), "account_names": sorted(group["account_names"]),
"account_count": len(group["account_ids"]), "cookie_count": len(group["cookie_ids"]),
"cookie_ids": group["cookie_ids"], "account_count": len(group["account_ids"]),
"account_ids": sorted(group["account_ids"]), "cookie_ids": group["cookie_ids"],
"records": group["records"], "account_ids": sorted(group["account_ids"]),
}) "records": group["records"],
}
)
duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"])) duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"]))
return { return {
@@ -432,10 +462,16 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()] id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()]
if not id_list: if not id_list:
raise HTTPException(status_code=400, detail="无效的ID") raise HTTPException(status_code=400, detail="无效的ID")
base_query = _visible_cookie_tasks_query(db, current) if detailed else _visible_cookie_operation_tasks_query(db, current) base_query = (
tasks = base_query.filter(LoginTask.id.in_(id_list)).filter( _visible_cookie_tasks_query(db, current)
LoginTask.status.in_(("success", "relogin_failed")) if detailed
).all() else _visible_cookie_operation_tasks_query(db, current)
)
tasks = (
base_query.filter(LoginTask.id.in_(id_list))
.filter(LoginTask.status.in_(("success", "relogin_failed")))
.all()
)
if not tasks: if not tasks:
raise HTTPException(status_code=404, detail="记录不存在") raise HTTPException(status_code=404, detail="记录不存在")
@@ -447,15 +483,17 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
results.append(future.result()) results.append(future.result())
except Exception as exc: except Exception as exc:
task = futures[future] task = futures[future]
results.append({ results.append(
"id": task.id, {
"valid": False, "id": task.id,
"message": f"检测异常: {exc}", "valid": False,
"fish_ball": None, "message": f"检测异常: {exc}",
"nickname": None, "fish_ball": None,
"level": None, "nickname": None,
"checked_at": datetime.now(timezone.utc).isoformat(), "level": None,
}) "checked_at": datetime.now(timezone.utc).isoformat(),
}
)
results.sort(key=lambda item: item["id"]) results.sort(key=lambda item: item["id"])
# 持久化检测结果,刷新/翻页不丢失 # 持久化检测结果,刷新/翻页不丢失
@@ -472,12 +510,14 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
"message": item.get("message", ""), "message": item.get("message", ""),
} }
task.ck_checked_at = datetime.now(timezone.utc) task.ck_checked_at = datetime.now(timezone.utc)
db.add(AuditLog( db.add(
user_id=current.id, AuditLog(
username=current.username, user_id=current.id,
action="cookie:check", username=current.username,
target=f"检测 {len(results)} 条已分配账号 CK", action="cookie:check",
)) target=f"检测 {len(results)} 条已分配账号 CK",
)
)
db.commit() db.commit()
if not detailed: if not detailed:
results = [ results = [
@@ -510,7 +550,13 @@ def check_cookie_operations(
return _check_cookies(ids, db, current, detailed=False) return _check_cookies(ids, db, current, detailed=False)
def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *, action: str = "cookie:relogin"): def _start_relogin_tasks(
tasks: list[LoginTask],
db: Session,
current: User,
*,
action: str = "cookie:relogin",
):
"""启动重登批次:旧 Cookie 保留到新登录成功后才替换。""" """启动重登批次:旧 Cookie 保留到新登录成功后才替换。"""
if not tasks: if not tasks:
raise HTTPException(status_code=404, detail="记录不存在") raise HTTPException(status_code=404, detail="记录不存在")
@@ -538,7 +584,9 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *,
if not task_ids: if not task_ids:
db.commit() db.commit()
raise HTTPException(status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态") raise HTTPException(
status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态"
)
proxy = db.query(ProxyConfigModel).first() proxy = db.query(ProxyConfigModel).first()
thread_db = SessionLocal() thread_db = SessionLocal()
@@ -558,13 +606,15 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *,
relogin_task_ids=task_ids, relogin_task_ids=task_ids,
) )
batch_id = runner.batch_id batch_id = runner.batch_id
db.add(AuditLog( db.add(
user_id=current.id, AuditLog(
username=current.username, user_id=current.id,
action=action, username=current.username,
target=f"重登 {len(task_ids)} 条账号 CK", action=action,
detail="旧 Cookie 将在新登录成功后替换", target=f"重登 {len(task_ids)} 条账号 CK",
)) detail="旧 Cookie 将在新登录成功后替换",
)
)
db.commit() db.commit()
cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id) cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id)
@@ -599,19 +649,28 @@ def stop_cookie_relogin(
_require_cookie_operation_perm(current) _require_cookie_operation_perm(current)
batch = cookie_relogin_registry.get(batch_id) batch = cookie_relogin_registry.get(batch_id)
if not batch: if not batch:
raise HTTPException(status_code=404, detail="重登批次不存在、已结束或服务已重启") raise HTTPException(
if batch.get("owner_id") != current.id and not user_has_permission(current, "login:view_all"): status_code=404, detail="重登批次不存在、已结束或服务已重启"
)
if batch.get("owner_id") != current.id and not user_has_permission(
current, "login:view_all"
):
raise HTTPException(status_code=403, detail="无权限停止该重登批次") raise HTTPException(status_code=403, detail="无权限停止该重登批次")
batch["runner"].stop() batch["runner"].stop()
db.add(AuditLog( db.add(
user_id=current.id, AuditLog(
username=current.username, user_id=current.id,
action="cookie:relogin_stop", username=current.username,
target=f"停止 CK 重登批次 {batch_id}", action="cookie:relogin_stop",
)) target=f"停止 CK 重登批次 {batch_id}",
)
)
db.commit() db.commit()
return {"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", "success": True} return {
"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止",
"success": True,
}
def _start_relogin(req: CookieReloginRequest, db: Session, current: User): def _start_relogin(req: CookieReloginRequest, db: Session, current: User):
@@ -667,7 +726,9 @@ def relogin_invalid_cookie_operations(
query = query.filter(Account.tag == tag.strip()) query = query.filter(Account.tag == tag.strip())
if search.strip(): if search.strip():
pattern = f"%{search.strip()}%" pattern = f"%{search.strip()}%"
query = query.filter(or_(Account.username.ilike(pattern), Account.tag.ilike(pattern))) query = query.filter(
or_(Account.username.ilike(pattern), Account.tag.ilike(pattern))
)
tasks = query.order_by(LoginTask.id.asc()).all() tasks = query.order_by(LoginTask.id.asc()).all()
return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid") return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid")
@@ -681,7 +742,9 @@ def get_cookie(
"""获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。""" """获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。"""
if not user_has_permission(current, "cookie:view"): if not user_has_permission(current, "cookie:view"):
raise HTTPException(status_code=403, detail="无权限: cookie:view") raise HTTPException(status_code=403, detail="无权限: cookie:view")
task = _visible_cookie_tasks_query(db, current).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="记录不存在")
acc = db.query(Account).filter(Account.id == task.account_id).first() acc = db.query(Account).filter(Account.id == task.account_id).first()
@@ -691,7 +754,9 @@ def get_cookie(
"account_id": task.account_id, "account_id": task.account_id,
"account_username": acc.username if acc else "", "account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to if acc else None, "assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None, "assigned_username": acc.assigned_user.username
if acc and acc.assigned_user
else None,
"created_at": _fmt_dt(task.finished_at), "created_at": _fmt_dt(task.finished_at),
"ck_check_status": task.ck_check_status or "", "ck_check_status": task.ck_check_status or "",
"ck_check_result": task.ck_check_result, "ck_check_result": task.ck_check_result,
@@ -726,7 +791,11 @@ def delete_cookies_batch(
t.status = "failed" t.status = "failed"
t.message = "Cookie已清除" t.message = "Cookie已清除"
db.commit() db.commit()
return {"message": f"已删除 {len(tasks)}", "deleted": len(tasks), "success": True} return {
"message": f"已删除 {len(tasks)}",
"deleted": len(tasks),
"success": True,
}
@router.delete("/{task_id}") @router.delete("/{task_id}")
@@ -736,7 +805,9 @@ def delete_cookie(
current: User = Depends(require_permission("cookie:export")), current: User = Depends(require_permission("cookie:export")),
): ):
"""删除一条 Cookie 记录。""" """删除一条 Cookie 记录。"""
task = _visible_cookie_tasks_query(db, current).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 = ""
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -17,6 +17,8 @@ if not SECRET_KEY:
stacklevel=2, stacklevel=2,
) )
SECRET_KEY = secrets.token_urlsafe(32) SECRET_KEY = secrets.token_urlsafe(32)
assert SECRET_KEY is not None
SECRET_KEY_TYPED: str = SECRET_KEY
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24")) ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24"))
@@ -35,15 +37,17 @@ def verify_password(plain: str, hashed: str) -> bool:
return False return False
def create_access_token(data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS) -> str: def create_access_token(
data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS
) -> str:
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours) expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]: def decode_access_token(token: str) -> Optional[dict]:
try: try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM])
except JWTError: except JWTError:
return None return None
+77 -30
View File
@@ -10,9 +10,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional from typing import Optional, cast
from core.douyu import DouyuLogin, WgapiLoginAPI from core.douyu import DouyuLogin, WgapiLoginAPI
from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import ProxyConfig as ProxyConfigModel from ..models import ProxyConfig as ProxyConfigModel
@@ -118,13 +119,15 @@ def parse_account_check_lines(text: str) -> list[AccountCheckInput]:
f"或 账号|密码|邮箱|邮箱密码" f"或 账号|密码|邮箱|邮箱密码"
) )
accounts.append(AccountCheckInput( accounts.append(
line=line_no, AccountCheckInput(
username=parts[0], line=line_no,
password=parts[1], username=parts[0],
email=parts[2], password=parts[1],
email_password=parts[3], email=parts[2],
)) email_password=parts[3],
)
)
return accounts return accounts
@@ -158,9 +161,15 @@ class AccountCheckRunner:
wl_platform = "xiequ" wl_platform = "xiequ"
wl_credentials = None wl_credentials = None
if self.proxy_config.whitelist_enabled: if self.proxy_config.whitelist_enabled:
wl_platform = getattr(self.proxy_config, "whitelist_platform", None) or "xiequ" wl_platform = (
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None) wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
if not wl_credentials and self.proxy_config.whitelist_uid and self.proxy_config.whitelist_ukey: if (
not wl_credentials
and self.proxy_config.whitelist_uid
and self.proxy_config.whitelist_ukey
):
wl_credentials = { wl_credentials = {
"uid": self.proxy_config.whitelist_uid, "uid": self.proxy_config.whitelist_uid,
"ukey": self.proxy_config.whitelist_ukey, "ukey": self.proxy_config.whitelist_ukey,
@@ -209,25 +218,38 @@ class AccountCheckRunner:
def _run_one(self, index: int, account: AccountCheckInput): def _run_one(self, index: int, account: AccountCheckInput):
if self._stop.is_set(): if self._stop.is_set():
self._set_item(index, status="stopped", message="已停止", finished_at=_now()) self._set_item(
index, status="stopped", message="已停止", finished_at=_now()
)
return return
proxy_dict, proxy_error = self._resolve_static_proxy() proxy_dict, proxy_error = self._resolve_static_proxy()
if proxy_error: if proxy_error:
self._set_item(index, status="error", message=proxy_error, finished_at=_now()) self._set_item(
index, status="error", message=proxy_error, finished_at=_now()
)
return return
self._set_item(index, status="running", message="检测中", started_at=_now(), finished_at=None) self._set_item(
index,
status="running",
message="检测中",
started_at=_now(),
finished_at=None,
)
try: try:
result = DouyuLogin( result = DouyuLogin(
SimpleNamespace( cast(
username=account.username, AccountLike,
password=account.password, SimpleNamespace(
email=account.email, username=account.username,
email_password=account.email_password, password=account.password,
email_imap_server="", email=account.email,
email_imap_port=993, email_password=account.email_password,
email_imap_ssl=True, email_imap_server="",
email_imap_port=993,
email_imap_ssl=True,
),
), ),
proxy=proxy_dict, proxy=proxy_dict,
max_login_retries=self.batch.max_login_retries, max_login_retries=self.batch.max_login_retries,
@@ -237,15 +259,24 @@ class AccountCheckRunner:
api_strategy=WgapiLoginAPI(), api_strategy=WgapiLoginAPI(),
).check_account() ).check_account()
except Exception as exc: except Exception as exc:
self._set_item(index, status="error", message=f"检测异常: {exc}", finished_at=_now()) self._set_item(
index, status="error", message=f"检测异常: {exc}", finished_at=_now()
)
return return
if self._stop.is_set() and not result.success: if self._stop.is_set() and not result.success:
self._set_item(index, status="stopped", message=result.message or "已停止", finished_at=_now()) self._set_item(
index,
status="stopped",
message=result.message or "已停止",
finished_at=_now(),
)
return return
if result.success: if result.success:
status = result.code if result.code in STATUS_LABELS else "account_auth_unknown" status = (
result.code if result.code in STATUS_LABELS else "account_auth_unknown"
)
message = result.message or STATUS_LABELS.get(status, "认证状态未知") message = result.message or STATUS_LABELS.get(status, "认证状态未知")
else: else:
status = "error" status = "error"
@@ -260,7 +291,9 @@ class AccountCheckRunner:
status: sum(1 for item in self.batch.items if item.status == status) status: sum(1 for item in self.batch.items if item.status == status)
for status in STATUS_LABELS for status in STATUS_LABELS
} }
running_count = sum(1 for item in self.batch.items if item.status in {"pending", "running"}) running_count = sum(
1 for item in self.batch.items if item.status in {"pending", "running"}
)
finished_count = len(self.batch.items) - running_count finished_count = len(self.batch.items) - running_count
return { return {
"batch_id": self.batch.batch_id, "batch_id": self.batch.batch_id,
@@ -296,7 +329,10 @@ class AccountCheckRunner:
if item.status != status: if item.status != status:
continue continue
line = item.export_text line = item.export_text
if status in {"error", "stopped", "account_auth_unknown"} and item.message: if (
status in {"error", "stopped", "account_auth_unknown"}
and item.message
):
line = f"{line}----{item.message}" line = f"{line}----{item.message}"
lines.append(line) lines.append(line)
content = "\n".join(lines) content = "\n".join(lines)
@@ -309,7 +345,9 @@ class AccountCheckRunner:
def run(self): def run(self):
"""线程入口。""" """线程入口。"""
self._set_batch(status="running", message="批次运行中", started_at=_now(), finished_at=None) self._set_batch(
status="running", message="批次运行中", started_at=_now(), finished_at=None
)
try: try:
if self._shared_proxy_fetcher: if self._shared_proxy_fetcher:
self._shared_proxy_fetcher.warmup_whitelist() self._shared_proxy_fetcher.warmup_whitelist()
@@ -318,14 +356,21 @@ class AccountCheckRunner:
futures = [] futures = []
for index, account in enumerate(self.accounts): for index, account in enumerate(self.accounts):
if self._stop.is_set(): if self._stop.is_set():
self._set_item(index, status="stopped", message="已停止", finished_at=_now()) self._set_item(
index,
status="stopped",
message="已停止",
finished_at=_now(),
)
continue continue
futures.append(executor.submit(self._run_one, index, account)) futures.append(executor.submit(self._run_one, index, account))
for future in as_completed(futures): for future in as_completed(futures):
future.result() future.result()
except Exception as exc: except Exception as exc:
self._set_batch(status="error", message=f"批次执行异常: {exc}", finished_at=_now()) self._set_batch(
status="error", message=f"批次执行异常: {exc}", finished_at=_now()
)
return return
if self._stop.is_set(): if self._stop.is_set():
@@ -370,7 +415,9 @@ class AccountCheckRegistry:
for account in accounts for account in accounts
], ],
) )
runner = AccountCheckRunner(batch=batch, accounts=accounts, proxy_config=proxy_config) runner = AccountCheckRunner(
batch=batch, accounts=accounts, proxy_config=proxy_config
)
with self._lock: with self._lock:
self._runners[batch_id] = runner self._runners[batch_id] = runner
return runner return runner
+23 -8
View File
@@ -42,9 +42,17 @@ def check_douyu_cookie(cookie: str) -> dict:
).json() ).json()
if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"): if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"):
fish_ok = True fish_ok = True
fish_ball = (fish_data.get("data") or {}).get("count") if isinstance(fish_data.get("data"), dict) else None fish_ball = (
(fish_data.get("data") or {}).get("count")
if isinstance(fish_data.get("data"), dict)
else None
)
else: else:
fish_msg = str(fish_data.get("msg") or fish_data.get("error") or "响应异常") if isinstance(fish_data, dict) else "响应异常" fish_msg = (
str(fish_data.get("msg") or fish_data.get("error") or "响应异常")
if isinstance(fish_data, dict)
else "响应异常"
)
except Exception as exc: except Exception as exc:
fish_msg = f"请求失败: {exc}" fish_msg = f"请求失败: {exc}"
@@ -66,11 +74,16 @@ def check_douyu_cookie(cookie: str) -> dict:
).json() ).json()
if isinstance(level_data, dict) and level_data.get("error") in (0, "0"): if isinstance(level_data, dict) and level_data.get("error") in (0, "0"):
level_ok = True level_ok = True
info = level_data.get("data") if isinstance(level_data.get("data"), dict) else {} info_raw = level_data.get("data")
info = info_raw if isinstance(info_raw, dict) else {}
nickname = str(info.get("nn") or "") or None nickname = str(info.get("nn") or "") or None
level = info.get("lv") level = info.get("lv")
else: else:
level_msg = str(level_data.get("msg") or level_data.get("error") or "响应异常") if isinstance(level_data, dict) else "响应异常" level_msg = (
str(level_data.get("msg") or level_data.get("error") or "响应异常")
if isinstance(level_data, dict)
else "响应异常"
)
except Exception as exc: except Exception as exc:
level_msg = f"请求失败: {exc}" level_msg = f"请求失败: {exc}"
@@ -78,10 +91,12 @@ def check_douyu_cookie(cookie: str) -> dict:
if valid: if valid:
message = "有效" message = "有效"
else: else:
message = "".join([ message = "".join(
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}", [
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}", f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
]) f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
]
)
return { return {
**base, **base,
"valid": valid, "valid": valid,
+26 -7
View File
@@ -21,7 +21,13 @@ from .douyu_runner_xpd import XpdMixin
class DouyuBatchRunner( class DouyuBatchRunner(
DouyuBatchRunnerCore, BindMixin, ManualMixin, GoldMixin, DonateMixin, GoodsMixin, XpdMixin, DouyuBatchRunnerCore,
BindMixin,
ManualMixin,
GoldMixin,
DonateMixin,
GoodsMixin,
XpdMixin,
): ):
"""批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。""" """批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。"""
@@ -43,13 +49,18 @@ class DouyuBatchRunner(
self._started += 1 self._started += 1
current = self._started current = self._started
self._push_log("info", f"[{current}/{total}] 开始: {self._account_name(account)}") self._push_log(
"info", f"[{current}/{total}] 开始: {self._account_name(account)}"
)
login_task = latest_success_login_task(worker_db, account.id) login_task = latest_success_login_task(worker_db, account.id)
cookie = login_task.cookie if login_task else "" cookie = login_task.cookie if login_task else ""
if not cookie: if not cookie:
self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie") self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie")
self._push_log("warning", f"[{current}] {self._account_name(account)} 无 Cookie") self._push_log(
"warning", f"[{current}] {self._account_name(account)} 无 Cookie"
)
return return
assert login_task is not None
cookie_check = check_douyu_cookie(cookie) cookie_check = check_douyu_cookie(cookie)
login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid" login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
@@ -62,7 +73,9 @@ class DouyuBatchRunner(
if not cookie_check["valid"]: if not cookie_check["valid"]:
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}" message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
self._mark_task(worker_db, task, "failed", message) self._mark_task(worker_db, task, "failed", message)
self._push_log("warning", f"[{current}] {self._account_name(account)} {message}") self._push_log(
"warning", f"[{current}] {self._account_name(account)} {message}"
)
return return
update_account_profile_from_cookie(account, cookie) update_account_profile_from_cookie(account, cookie)
@@ -109,7 +122,9 @@ class DouyuBatchRunner(
return return
handler(worker_db, task, account, cookie, config) handler(worker_db, task, account, cookie, config)
self._push_log("success", f"[{current}] {self._account_name(account)} {task.message}") self._push_log(
"success", f"[{current}] {self._account_name(account)} {task.message}"
)
except DouyuActivityError as exc: except DouyuActivityError as exc:
if "task" in locals() and task: if "task" in locals() and task:
self._mark_task(worker_db, task, "failed", str(exc)) self._mark_task(worker_db, task, "failed", str(exc))
@@ -128,7 +143,9 @@ class DouyuBatchRunner(
config = self._config_info(self.db) config = self._config_info(self.db)
tasks = ( tasks = (
self.db.query(DouyuTask) self.db.query(DouyuTask)
.filter(DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned") .filter(
DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned"
)
.order_by(DouyuTask.id.asc()) .order_by(DouyuTask.id.asc())
.all() .all()
) )
@@ -150,7 +167,9 @@ class DouyuBatchRunner(
for task in tasks: for task in tasks:
if self._stop.is_set(): if self._stop.is_set():
break break
futures.append(executor.submit(self._execute_one, task.id, config, total)) futures.append(
executor.submit(self._execute_one, task.id, config, total)
)
for future in as_completed(futures): for future in as_completed(futures):
try: try:
future.result() future.result()
+28 -10
View File
@@ -21,7 +21,12 @@ from ..models import (
DouyuXpdGoodsSnapshot, DouyuXpdGoodsSnapshot,
ProxyConfig as ProxyConfigModel, ProxyConfig as ProxyConfigModel,
) )
from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload from .douyu_service import (
DOUYU_CONFIG_FIELDS,
douyu_config_value,
ensure_douyu_config,
douyu_task_payload,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .douyu_runner import DouyuBatchRunner from .douyu_runner import DouyuBatchRunner
@@ -78,7 +83,9 @@ class DouyuBatchRunnerCore:
self._proxy_fetcher = self._create_proxy_fetcher() self._proxy_fetcher = self._create_proxy_fetcher()
self._static_proxies = self._resolve_static_proxies() self._static_proxies = self._resolve_static_proxies()
if self._static_proxies: if self._static_proxies:
logger.info(f"[douyu] 写操作任务将走静态代理: {self._static_proxies.get('https', '')}") logger.info(
f"[douyu] 写操作任务将走静态代理: {self._static_proxies.get('https', '')}"
)
elif self._proxy_fetcher: elif self._proxy_fetcher:
logger.info("[douyu] 写操作任务将按任务从代理 API 取新代理") logger.info("[douyu] 写操作任务将按任务从代理 API 取新代理")
@@ -89,7 +96,11 @@ class DouyuBatchRunnerCore:
return None return None
wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ" wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
wl_credentials = getattr(cfg, "whitelist_credentials", None) wl_credentials = getattr(cfg, "whitelist_credentials", None)
if not wl_credentials and getattr(cfg, "whitelist_uid", "") and getattr(cfg, "whitelist_ukey", ""): if (
not wl_credentials
and getattr(cfg, "whitelist_uid", "")
and getattr(cfg, "whitelist_ukey", "")
):
wl_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey} wl_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
return ProxyFetcher( return ProxyFetcher(
api_url=cfg.api_url, api_url=cfg.api_url,
@@ -267,8 +278,7 @@ class DouyuBatchRunnerCore:
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。""" """同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
commodity_ids = { commodity_ids = {
str(raw.get("commodity_id") or raw.get("iGoodsId") or "") str(raw.get("commodity_id") or raw.get("iGoodsId") or "") for raw in goods
for raw in goods
} }
commodity_ids.discard("") commodity_ids.discard("")
query = db.query(DouyuXpdGoodsSnapshot) query = db.query(DouyuXpdGoodsSnapshot)
@@ -304,11 +314,15 @@ class DouyuBatchRunnerCore:
def _config_info(self, db: Session) -> dict: def _config_info(self, db: Session) -> dict:
config = ensure_douyu_config(db) config = ensure_douyu_config(db)
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS} return {
field: douyu_config_value(field, getattr(config, field, None))
for field in DOUYU_CONFIG_FIELDS
}
def _task_payload(self, task: DouyuTask) -> dict: def _task_payload(self, task: DouyuTask) -> dict:
result = task.result if isinstance(task.result, dict) else {} result = task.result if isinstance(task.result, dict) else {}
payload = result.get("payload") if isinstance(result.get("payload"), dict) else {} payload_raw = result.get("payload")
payload = payload_raw if isinstance(payload_raw, dict) else {}
return {**payload, **self.payload} return {**payload, **self.payload}
def _client(self, cookie: str) -> DouyuActivityClient: def _client(self, cookie: str) -> DouyuActivityClient:
@@ -338,8 +352,13 @@ class DouyuBatchRegistry:
def __init__(self): def __init__(self):
self._batches: dict[str, dict] = {} self._batches: dict[str, dict] = {}
def register(self, batch_id: str, log_queue: asyncio.Queue, def register(
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner): self,
batch_id: str,
log_queue: asyncio.Queue,
loop: asyncio.AbstractEventLoop,
runner: DouyuBatchRunner,
):
self._batches[batch_id] = { self._batches[batch_id] = {
"log_queue": log_queue, "log_queue": log_queue,
"loop": loop, "loop": loop,
@@ -368,4 +387,3 @@ class DouyuBatchRegistry:
douyu_batch_registry = DouyuBatchRegistry() douyu_batch_registry = DouyuBatchRegistry()
+99 -34
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.douyu.activity_client import DouyuActivityClient from core.douyu.activity_client import DouyuActivityClient
@@ -13,7 +14,8 @@ from core.douyu.cookie_utils import cookie_value
from ..models import Account, DouyuConfig, DouyuTask, LoginTask from ..models import Account, DouyuConfig, DouyuTask, LoginTask
SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码", SUPPORTED_DOUYU_TASK_TYPES = {
"get_bind_qr": "获取绑定二维码",
"confirm_bind": "确认绑定", "confirm_bind": "确认绑定",
"create_elite_qr": "开通精英宝典30", "create_elite_qr": "开通精英宝典30",
"prepare_esports_bind": "绑定电竞手册角色", "prepare_esports_bind": "绑定电竞手册角色",
@@ -53,26 +55,55 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"} DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
DOUYU_HANDBOOK_TASK_TYPES = { DOUYU_HANDBOOK_TASK_TYPES = {
"elite": { "elite": {
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift", "get_bind_qr",
"query_points", "lock_goods", "pay_locked_order", "exchange_goods", "query_game_name", "query_change_bind_time", "confirm_bind",
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records", "create_elite_qr",
"create_gold_qr",
"donate_elite_gift",
"query_points",
"lock_goods",
"pay_locked_order",
"exchange_goods",
"query_game_name",
"query_change_bind_time",
"query_limited_goods",
"query_gold_balance",
"refresh_goods",
"query_exchange_records",
"prefetch_csrf_token", "prefetch_csrf_token",
}, },
"esports": { "esports": {
"prepare_esports_bind", "get_esports_bind_qr", "query_esports_game_name", "confirm_esports_bind", "prepare_esports_bind",
"create_esports_qr", "query_esports_points", "query_gold_balance", "query_change_bind_time", "get_esports_bind_qr",
"query_limited_goods", "refresh_esports_goods", "exchange_esports_goods", "create_gold_qr", "query_esports_game_name",
"donate_esports_chicken_gift", "donate_esports_firework_gift", "confirm_esports_bind",
"create_esports_qr",
"query_esports_points",
"query_gold_balance",
"query_change_bind_time",
"query_limited_goods",
"refresh_esports_goods",
"exchange_esports_goods",
"create_gold_qr",
"donate_esports_chicken_gift",
"donate_esports_firework_gift",
}, },
"peace": { "peace": {
"get_xpd_bind_qr", "query_xpd_bind_info", "confirm_xpd_bind", "query_xpd_role", "get_xpd_bind_qr",
"refresh_xpd_goods", "query_xpd_balance", "query_xpd_fragments", "query_xpd_bind_info",
"query_xpd_purchase_records", "exchange_xpd_goods", "confirm_xpd_bind",
"query_xpd_role",
"refresh_xpd_goods",
"query_xpd_balance",
"query_xpd_fragments",
"query_xpd_purchase_records",
"exchange_xpd_goods",
}, },
} }
DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7", DOUYU_CONFIG_DEFAULTS = {
"manual_id": "G4KA4Qnz4LDp7",
"rid": "9263298", "rid": "9263298",
"bind_act_alias": "20260120QYOOB", "bind_act_alias": "20260120QYOOB",
"confirm_act_alias": "20260120QYOOB", "confirm_act_alias": "20260120QYOOB",
@@ -117,12 +148,18 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool:
"""补齐斗鱼配置默认值,返回是否发生变更。""" """补齐斗鱼配置默认值,返回是否发生变更。"""
changed = False changed = False
for field in DOUYU_CONFIG_FIELDS: for field in DOUYU_CONFIG_FIELDS:
if field == "bind_act_alias" and str(getattr(config, field, "") or "").strip() == "20250213NQCYX": if (
field == "bind_act_alias"
and str(getattr(config, field, "") or "").strip() == "20250213NQCYX"
):
setattr(config, field, DOUYU_CONFIG_DEFAULTS[field]) setattr(config, field, DOUYU_CONFIG_DEFAULTS[field])
changed = True changed = True
continue continue
normalized = douyu_config_value(field, getattr(config, field, None)) normalized = douyu_config_value(field, getattr(config, field, None))
if field == "gold_recharge_channel" and normalized not in {"wechat_qr", "supplier_api"}: if field == "gold_recharge_channel" and normalized not in {
"wechat_qr",
"supplier_api",
}:
normalized = DOUYU_CONFIG_DEFAULTS[field] normalized = DOUYU_CONFIG_DEFAULTS[field]
if getattr(config, field, None) != normalized: if getattr(config, field, None) != normalized:
setattr(config, field, normalized) setattr(config, field, normalized)
@@ -178,7 +215,11 @@ def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Acc
"""只保留存在成功 Cookie 的斗鱼账号。""" """只保留存在成功 Cookie 的斗鱼账号。"""
if not account_ids: if not account_ids:
return [] return []
cookie_ids = cookie_account_ids_query(db).subquery() cookie_ids = select(LoginTask.account_id).where(
LoginTask.status == "success",
LoginTask.cookie != "",
LoginTask.cookie.isnot(None),
)
return ( return (
db.query(Account) db.query(Account)
.filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids)) .filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids))
@@ -217,23 +258,28 @@ def create_douyu_planned_tasks(
raise ValueError("该任务不属于当前工作台") raise ValueError("该任务不属于当前工作台")
accounts = visible_douyu_task_accounts(db, account_ids) accounts = visible_douyu_task_accounts(db, account_ids)
if task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"} and accounts: if (
task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"}
and accounts
):
# 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。 # 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。
accounts = accounts[:1] accounts = accounts[:1]
batch_id = uuid.uuid4().hex[:12] batch_id = uuid.uuid4().hex[:12]
payload = payload or {} payload = payload or {}
for account in accounts: for account in accounts:
db.add(DouyuTask( db.add(
batch_id=batch_id, DouyuTask(
account_id=account.id, batch_id=batch_id,
task_type=task_type, account_id=account.id,
handbook_scope=handbook_scope, task_type=task_type,
status="planned", handbook_scope=handbook_scope,
message="任务已创建,等待执行", status="planned",
result={"payload": payload} if payload else None, message="任务已创建,等待执行",
created_by=created_by, result={"payload": payload} if payload else None,
)) created_by=created_by,
)
)
db.commit() db.commit()
return batch_id, len(accounts) return batch_id, len(accounts)
@@ -272,7 +318,17 @@ def slim_douyu_goods(goods: object) -> object:
return { return {
key: value key: value
for key, value in goods.items() for key, value in goods.items()
if key in {"commodityId", "commodity_id", "commodityName", "name", "webPic", "pic", "score", "status"} if key
in {
"commodityId",
"commodity_id",
"commodityName",
"name",
"webPic",
"pic",
"score",
"status",
}
} }
@@ -284,11 +340,13 @@ def slim_douyu_limited_goods(goods: object) -> list[dict[str, object]]:
for item in goods[:5]: for item in goods[:5]:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
result.append({ result.append(
key: value {
for key, value in item.items() key: value
if key in {"commodityId", "commodity_id", "commodityName", "name"} for key, value in item.items()
}) if key in {"commodityId", "commodity_id", "commodityName", "name"}
}
)
return result return result
@@ -305,7 +363,9 @@ def strip_douyu_raw_snapshots(value: object) -> object:
return value return value
def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_detail: bool = False) -> dict | None: def sanitize_douyu_task_result(
result: dict | None, task_type: str, *, include_detail: bool = False
) -> dict | None:
"""列表/实时推送接口剥离原始快照与大数组;详情接口保留完整 result。""" """列表/实时推送接口剥离原始快照与大数组;详情接口保留完整 result。"""
if not isinstance(result, dict): if not isinstance(result, dict):
return result return result
@@ -346,7 +406,12 @@ def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_d
if "limited_goods" in data: if "limited_goods" in data:
data["limited_goods"] = slim_douyu_limited_goods(data.get("limited_goods")) data["limited_goods"] = slim_douyu_limited_goods(data.get("limited_goods"))
if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr", "get_xpd_bind_qr"}: if task_type not in {
"get_bind_qr",
"prepare_esports_bind",
"get_esports_bind_qr",
"get_xpd_bind_qr",
}:
data.pop("url", None) data.pop("url", None)
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}: if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
data.pop("pay_url", None) data.pop("pay_url", None)
+170 -66
View File
@@ -8,12 +8,13 @@ import uuid
from types import SimpleNamespace from types import SimpleNamespace
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional, cast
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from loguru import logger from loguru import logger
from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI
from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
from .cookie_check_service import check_douyu_cookie from .cookie_check_service import check_douyu_cookie
@@ -60,21 +61,28 @@ def get_relogin_limits() -> tuple[int, int]:
) )
def _snapshot_proxy_config(proxy_config: Optional[ProxyConfigModel]) -> Optional[SimpleNamespace]: def _snapshot_proxy_config(
proxy_config: Optional[ProxyConfigModel],
) -> Optional[ProxyConfigModel]:
"""复制代理配置,避免后台线程访问已关闭会话中的 ORM 对象。""" """复制代理配置,避免后台线程访问已关闭会话中的 ORM 对象。"""
if proxy_config is None: if proxy_config is None:
return None return None
credentials = getattr(proxy_config, "whitelist_credentials", None) credentials = getattr(proxy_config, "whitelist_credentials", None)
return SimpleNamespace( return cast(
enabled=bool(getattr(proxy_config, "enabled", False)), ProxyConfigModel,
http=getattr(proxy_config, "http", "") or "", SimpleNamespace(
https=getattr(proxy_config, "https", "") or "", enabled=bool(getattr(proxy_config, "enabled", False)),
api_url=getattr(proxy_config, "api_url", "") or "", http=getattr(proxy_config, "http", "") or "",
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)), https=getattr(proxy_config, "https", "") or "",
whitelist_platform=getattr(proxy_config, "whitelist_platform", None), api_url=getattr(proxy_config, "api_url", "") or "",
whitelist_credentials=dict(credentials) if isinstance(credentials, dict) else credentials, whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "", whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "", whitelist_credentials=dict(credentials)
if isinstance(credentials, dict)
else credentials,
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
),
) )
@@ -124,12 +132,21 @@ class LoginBatchRunner:
wl_platform = "xiequ" wl_platform = "xiequ"
wl_credentials = None wl_credentials = None
if proxy_config.whitelist_enabled: if proxy_config.whitelist_enabled:
wl_platform = getattr(proxy_config, 'whitelist_platform', None) or "xiequ" wl_platform = (
wl_credentials = getattr(proxy_config, 'whitelist_credentials', None) getattr(proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(proxy_config, "whitelist_credentials", None)
# 向后兼容 # 向后兼容
if not wl_credentials and proxy_config.whitelist_uid and proxy_config.whitelist_ukey: if (
not wl_credentials
and proxy_config.whitelist_uid
and proxy_config.whitelist_ukey
):
wl_platform = "xiequ" wl_platform = "xiequ"
wl_credentials = {"uid": proxy_config.whitelist_uid, "ukey": proxy_config.whitelist_ukey} wl_credentials = {
"uid": proxy_config.whitelist_uid,
"ukey": proxy_config.whitelist_ukey,
}
self._shared_proxy_fetcher = ProxyFetcher( self._shared_proxy_fetcher = ProxyFetcher(
api_url=proxy_config.api_url, api_url=proxy_config.api_url,
@@ -170,7 +187,11 @@ class LoginBatchRunner:
def _push_log(self, level: str, message: str): def _push_log(self, level: str, message: str):
# 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。 # 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。
if message: if message:
log_level = level if level in {"debug", "info", "warning", "error", "success"} else "debug" log_level = (
level
if level in {"debug", "info", "warning", "error", "success"}
else "debug"
)
getattr(logger, log_level)(f"[登录批次 {self.batch_id}] {message}") getattr(logger, log_level)(f"[登录批次 {self.batch_id}] {message}")
if self.log_queue and self.loop: if self.log_queue and self.loop:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
@@ -181,15 +202,15 @@ class LoginBatchRunner:
def _resolve_static_proxy(self) -> tuple[Optional[dict], str]: def _resolve_static_proxy(self) -> tuple[Optional[dict], str]:
"""解析静态代理配置。""" """解析静态代理配置。"""
if not self.proxy_config or not self.proxy_config.enabled: if not self.proxy_config or not self.proxy_config.enabled:
return None, '' return None, ""
# 静态代理 # 静态代理
if self.proxy_config.http or self.proxy_config.https: if self.proxy_config.http or self.proxy_config.https:
proxy_url = self.proxy_config.http or self.proxy_config.https proxy_url = self.proxy_config.http or self.proxy_config.https
return {'http': proxy_url, 'https': proxy_url}, f'使用静态代理: {proxy_url}' return {"http": proxy_url, "https": proxy_url}, f"使用静态代理: {proxy_url}"
# API代理:由 DouyuLogin 通过 proxy_fetcher 内部管理 # API代理:由 DouyuLogin 通过 proxy_fetcher 内部管理
return None, '' return None, ""
def _execute_one(self, task_id: int, acc_info: dict, total: int): def _execute_one(self, task_id: int, acc_info: dict, total: int):
"""在独立线程中执行单个账号登录,使用独立的 DB 会话。""" """在独立线程中执行单个账号登录,使用独立的 DB 会话。"""
@@ -216,8 +237,16 @@ class LoginBatchRunner:
self._completed += 1 self._completed += 1
current = self._completed current = self._completed
action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录" action_name = (
self._push_log("info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}") "检测"
if self.mode == "check"
else "重新登录"
if self.mode == "relogin"
else "登录"
)
self._push_log(
"info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}"
)
try: try:
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。 # 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
@@ -226,26 +255,39 @@ class LoginBatchRunner:
self._push_log("info", f"[{current}] {proxy_msg}") self._push_log("info", f"[{current}] {proxy_msg}")
# 静态代理启用但配置为空 → 不可用 # 静态代理启用但配置为空 → 不可用
if self.proxy_config and self.proxy_config.enabled and not (self.proxy_config.http or self.proxy_config.https) and not self._shared_proxy_fetcher and not proxy_dict: if (
self.proxy_config
and self.proxy_config.enabled
and not (self.proxy_config.http or self.proxy_config.https)
and not self._shared_proxy_fetcher
and not proxy_dict
):
if self.mode == "relogin": if self.mode == "relogin":
task.status = "relogin_failed" task.status = "relogin_failed"
task.message = "重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)" task.message = (
"重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
)
else: else:
task.status = "error" task.status = "error"
task.message = "代理不可用: 未配置代理" task.message = "代理不可用: 未配置代理"
task.finished_at = datetime.now(timezone.utc) task.finished_at = datetime.now(timezone.utc)
worker_db.commit() worker_db.commit()
self._push_log("error", f"[{current}] {acc_info['username']} 代理不可用") self._push_log(
"error", f"[{current}] {acc_info['username']} 代理不可用"
)
return return
account = SimpleNamespace( account = cast(
username=acc_info["username"], AccountLike,
password=acc_info["password"], SimpleNamespace(
email=acc_info["email"], username=acc_info["username"],
email_password=acc_info["email_password"], password=acc_info["password"],
email_imap_server=acc_info["email_imap_server"] or "", email=acc_info["email"],
email_imap_port=acc_info["email_imap_port"] or 993, email_password=acc_info["email_password"],
email_imap_ssl=acc_info["email_imap_ssl"], email_imap_server=acc_info["email_imap_server"] or "",
email_imap_port=acc_info["email_imap_port"] or 993,
email_imap_ssl=acc_info["email_imap_ssl"],
),
) )
loginer = DouyuLogin( loginer = DouyuLogin(
@@ -257,22 +299,33 @@ class LoginBatchRunner:
stop_event=self._stop, stop_event=self._stop,
api_strategy=self.api_strategy, api_strategy=self.api_strategy,
) )
result = loginer.check_account() if self.mode == "check" else loginer.login() result = (
loginer.check_account() if self.mode == "check" else loginer.login()
)
if self.mode == "check" and result.success: if self.mode == "check" and result.success:
status = result.code if result.code in CHECK_STATUS_MESSAGES else "account_auth_unknown" status = (
result.code
if result.code in CHECK_STATUS_MESSAGES
else "account_auth_unknown"
)
task.status = status task.status = status
task.cookie = "" task.cookie = ""
task.message = result.message or CHECK_STATUS_MESSAGES[status] task.message = result.message or CHECK_STATUS_MESSAGES[status]
level = CHECK_STATUS_LOG_LEVELS.get(status, "info") level = CHECK_STATUS_LOG_LEVELS.get(status, "info")
self._push_log(level, f"[{current}] {acc_info['username']} 检测结果: {task.message}") self._push_log(
level,
f"[{current}] {acc_info['username']} 检测结果: {task.message}",
)
elif result.success: elif result.success:
task.status = "success" task.status = "success"
task.cookie = result.cookie task.cookie = result.cookie
task.message = result.message or "登录成功" task.message = result.message or "登录成功"
if self.mode == "relogin": if self.mode == "relogin":
check_result = check_douyu_cookie(result.cookie) check_result = check_douyu_cookie(result.cookie)
task.ck_check_status = "valid" if check_result["valid"] else "invalid" task.ck_check_status = (
"valid" if check_result["valid"] else "invalid"
)
task.ck_check_result = { task.ck_check_result = {
"fish_ball": check_result["fish_ball"], "fish_ball": check_result["fish_ball"],
"nickname": check_result["nickname"], "nickname": check_result["nickname"],
@@ -282,32 +335,54 @@ class LoginBatchRunner:
task.ck_checked_at = check_result["checked_at"] task.ck_checked_at = check_result["checked_at"]
if check_result["valid"]: if check_result["valid"]:
task.message = "重新登录成功,Cookie 有效" task.message = "重新登录成功,Cookie 有效"
self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效") self._push_log(
"success",
f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效",
)
else: else:
task.message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}" task.message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}"
self._push_log("warning", f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}") self._push_log(
"warning",
f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}",
)
else: else:
self._push_log("success", f"[{current}] {acc_info['username']} {task.message}") self._push_log(
"success",
f"[{current}] {acc_info['username']} {task.message}",
)
else: else:
if self.mode == "relogin": if self.mode == "relogin":
# 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失 # 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
task.status = "relogin_failed" task.status = "relogin_failed"
task.message = f"重新登录失败: {result.message}(旧 Cookie 已保留)" task.message = (
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}") f"重新登录失败: {result.message}(旧 Cookie 已保留)"
)
self._push_log(
"error",
f"[{current}] {acc_info['username']} 重新登录失败: {result.message}",
)
else: else:
task.status = "failed" task.status = "failed"
task.message = result.message task.message = result.message
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}失败: {result.message}") self._push_log(
"error",
f"[{current}] {acc_info['username']} {action_name}失败: {result.message}",
)
except Exception as e: except Exception as e:
if self.mode == "relogin": if self.mode == "relogin":
task.status = "relogin_failed" task.status = "relogin_failed"
task.message = f"重新登录异常: {e}(旧 Cookie 已保留)" task.message = f"重新登录异常: {e}(旧 Cookie 已保留)"
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录异常: {e}") self._push_log(
"error", f"[{current}] {acc_info['username']} 重新登录异常: {e}"
)
else: else:
task.status = "error" task.status = "error"
task.message = str(e) task.message = str(e)
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}异常: {e}") self._push_log(
"error",
f"[{current}] {acc_info['username']} {action_name}异常: {e}",
)
task.finished_at = datetime.now(timezone.utc) task.finished_at = datetime.now(timezone.utc)
worker_db.commit() worker_db.commit()
@@ -319,8 +394,17 @@ class LoginBatchRunner:
"""在线程中执行批量登录。""" """在线程中执行批量登录。"""
batch_id = self.batch_id batch_id = self.batch_id
concurrency = self.concurrency concurrency = self.concurrency
action_name = "账号检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录" action_name = (
self._push_log("info", f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}") "账号检测"
if self.mode == "check"
else "重新登录"
if self.mode == "relogin"
else "登录"
)
self._push_log(
"info",
f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}",
)
# 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步 # 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步
if self._shared_proxy_fetcher: if self._shared_proxy_fetcher:
@@ -339,18 +423,22 @@ class LoginBatchRunner:
task.message = "" task.message = ""
task.finished_at = None task.finished_at = None
self.db.flush() self.db.flush()
task_infos.append({ task_infos.append(
"task_id": task.id, {
"acc_info": { "task_id": task.id,
"username": acc.username, "acc_info": {
"password": acc.password, "username": acc.username,
"email": acc.email, "password": acc.password,
"email_password": acc.email_password, "email": acc.email,
"email_imap_server": acc.email_imap_server or "", "email_password": acc.email_password,
"email_imap_port": acc.email_imap_port or 993, "email_imap_server": acc.email_imap_server or "",
"email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True, "email_imap_port": acc.email_imap_port or 993,
}, "email_imap_ssl": acc.email_imap_ssl
}) if acc.email_imap_ssl is not None
else True,
},
}
)
try: try:
# 创建或复用任务记录(顺序执行,线程安全) # 创建或复用任务记录(顺序执行,线程安全)
@@ -358,10 +446,16 @@ class LoginBatchRunner:
if self.relogin_task_ids: if self.relogin_task_ids:
# 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie # 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie
for task_id in self.relogin_task_ids: for task_id in self.relogin_task_ids:
task = self.db.query(LoginTask).filter(LoginTask.id == task_id).first() task = (
self.db.query(LoginTask).filter(LoginTask.id == task_id).first()
)
if not task: if not task:
continue continue
acc = self.db.query(AccountModel).filter(AccountModel.id == task.account_id).first() acc = (
self.db.query(AccountModel)
.filter(AccountModel.id == task.account_id)
.first()
)
if not acc: if not acc:
self._push_log("warning", f"跳过无账号的任务 #{task_id}") self._push_log("warning", f"跳过无账号的任务 #{task_id}")
continue continue
@@ -393,7 +487,9 @@ class LoginBatchRunner:
# 一个斗鱼账号只保留一条成功 CK:再次普通登录时更新最新成功记录。 # 一个斗鱼账号只保留一条成功 CK:再次普通登录时更新最新成功记录。
latest_success_task = ( latest_success_task = (
self.db.query(LoginTask) self.db.query(LoginTask)
.filter(LoginTask.account_id == aid, LoginTask.status == "success") .filter(
LoginTask.account_id == aid, LoginTask.status == "success"
)
.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc()) .order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
.first() .first()
) )
@@ -414,7 +510,10 @@ class LoginBatchRunner:
# 复用该账号最近一条失败任务记录,避免重复产生多条失败历史。 # 复用该账号最近一条失败任务记录,避免重复产生多条失败历史。
existing_task = ( existing_task = (
self.db.query(LoginTask) self.db.query(LoginTask)
.filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"])) .filter(
LoginTask.account_id == aid,
LoginTask.status.in_(["failed", "error"]),
)
.order_by(LoginTask.id.desc()) .order_by(LoginTask.id.desc())
.first() .first()
) )
@@ -479,9 +578,14 @@ class BatchRegistry:
def __init__(self): def __init__(self):
self._batches: dict[str, dict] = {} self._batches: dict[str, dict] = {}
def register(self, batch_id: str, log_queue: Optional[asyncio.Queue], def register(
loop: Optional[asyncio.AbstractEventLoop], runner: LoginBatchRunner, self,
owner_id: Optional[int] = None): batch_id: str,
log_queue: Optional[asyncio.Queue],
loop: Optional[asyncio.AbstractEventLoop],
runner: LoginBatchRunner,
owner_id: Optional[int] = None,
):
self._batches[batch_id] = { self._batches[batch_id] = {
"log_queue": log_queue, "log_queue": log_queue,
"loop": loop, "loop": loop,