diff --git a/core/douyu/email_verifier.py b/core/douyu/email_verifier.py index f530249..23825bd 100644 --- a/core/douyu/email_verifier.py +++ b/core/douyu/email_verifier.py @@ -49,7 +49,9 @@ class EmailVerifier: self.password = password # 邮箱密码 self.timeout = timeout 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 会话(懒初始化) self._rc_session: Optional[requests.Session] = None @@ -87,7 +89,9 @@ class EmailVerifier: for index, password in enumerate(passwords): 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: return True credential_failed = credential_failed or password_failed @@ -112,13 +116,17 @@ class EmailVerifier: seen.add(password) 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 登录,返回 (是否成功, 是否明确为密码错误)。""" try: session = requests.Session() - session.headers.update({ - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", - }) + session.headers.update( + { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + } + ) # 1. 访问首页获取 token resp = session.get(self.roundcube_url, timeout=self.timeout) @@ -143,17 +151,20 @@ class EmailVerifier: ) # 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: logger.warning("Roundcube: 登录失败(未找到 request_token)") return False, False text_lower = resp.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 - mail_page = ( - "_task=mail" in resp.url - or re.search(r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text) + login_form_present = ( + 'name="_user"' in resp.text and 'name="_pass"' in 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: @@ -183,6 +194,8 @@ class EmailVerifier: if self._rc_login_error: raise EmailLoginError(self._rc_login_error) return [] + if self._rc_session is None: + return [] try: resp = self._rc_session.get( @@ -201,7 +214,7 @@ class EmailVerifier: # 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...); for m in re.finditer( - r'add_message_row\((\d+),\s*(\{[^}]+\})', + r"add_message_row\((\d+),\s*(\{[^}]+\})", exec_text, ): uid = int(m.group(1)) @@ -210,11 +223,13 @@ class EmailVerifier: subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str) date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str) - messages.append({ - "uid": uid, - "subject": subject_m.group(1) if subject_m else "", - "date": date_m.group(1) if date_m else "", - }) + messages.append( + { + "uid": uid, + "subject": subject_m.group(1) if subject_m else "", + "date": date_m.group(1) if date_m else "", + } + ) return messages @@ -230,11 +245,12 @@ class EmailVerifier: if self._rc_login_error: raise EmailLoginError(self._rc_login_error) return None + if self._rc_session is None: + return None try: resp = self._rc_session.get( - f"{self.roundcube_url}?_task=mail&_action=show" - f"&_mbox=INBOX&_uid={uid}", + f"{self.roundcube_url}?_task=mail&_action=show&_mbox=INBOX&_uid={uid}", timeout=self.timeout, ) return resp.text @@ -280,8 +296,14 @@ class EmailVerifier: # 星期X weekday_map = { - "星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3, - "星期五": 4, "星期六": 5, "星期日": 6, "星期天": 6, + "星期一": 0, + "星期二": 1, + "星期三": 2, + "星期四": 3, + "星期五": 4, + "星期六": 5, + "星期日": 6, + "星期天": 6, } for prefix, wd in weekday_map.items(): if date_str.startswith(prefix): @@ -295,7 +317,9 @@ class EmailVerifier: target = now else: 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): return None @@ -366,7 +390,9 @@ class EmailVerifier: raise InterruptedError("任务已停止") 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: logger.success(f"获取到验证码: {code}") return code @@ -383,7 +409,9 @@ class EmailVerifier: raise InterruptedError("任务已停止") 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 方式获取验证码 ──────────────────────────── @@ -397,7 +425,8 @@ class EmailVerifier: # 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看) douyu_msgs = [ - msg for msg in messages + msg + for msg in messages if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "") ] @@ -407,7 +436,9 @@ class EmailVerifier: msg_dt = self._parse_rc_date(msg["date"]) if msg_dt is None: # 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过 - logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过") + logger.debug( + f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过" + ) continue # Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00), # 而 after_timestamp 是秒级精度(如14:20:49)。 @@ -415,7 +446,9 @@ class EmailVerifier: # 解决:将 after_timestamp 也向下取整到分钟后再比较。 after_minute_ts = after_timestamp - (after_timestamp % 60) 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 # 读取邮件正文提取验证码 @@ -436,16 +469,16 @@ class EmailVerifier: """从文本中提取6位验证码""" # 清理HTML标签和实体 text = html.unescape(text) - text = re.sub(r'<[^>]+>', ' ', text) - text = re.sub(r'\s+', ' ', text) + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r"\s+", " ", text) # 查找6位数字验证码(优先匹配带上下文的) patterns = [ - r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})', - r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})', - r'(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)', - r'(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})', - r'\b(\d{6})\b', + r"(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})", + r"(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})", + r"(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)", + r"(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})", + r"\b(\d{6})\b", ] for pattern in patterns: @@ -459,7 +492,7 @@ class EmailVerifier: def get_email_config_for_account(email_address: str) -> dict: """根据邮箱地址返回配置(HTTP 模式下仅用于兼容)""" return { - 'server': '', - 'port': 143, - 'ssl': False, + "server": "", + "port": 143, + "ssl": False, } diff --git a/core/douyu/login.py b/core/douyu/login.py index fe3493a..9539e49 100644 --- a/core/douyu/login.py +++ b/core/douyu/login.py @@ -16,7 +16,9 @@ from .login_api_wgapi import WgapiLoginAPI from .proxy_fetcher import ProxyFetcher 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 ( get_js_address, @@ -44,6 +46,7 @@ class CredentialError(ValueError): class AccountLike(Protocol): """DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。""" + username: str password: str email: str @@ -56,7 +59,9 @@ class AccountLike(Protocol): 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.cookie = cookie self.message = message @@ -68,7 +73,9 @@ class DouyuLogin: # 斗鱼API地址 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" WEBLOGIN_API = "https://msg.douyu.com/webLogin" CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax" @@ -141,50 +148,52 @@ class DouyuLogin: """配置Session""" # 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。 self.session.trust_env = False - 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', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Referer': self.LOGIN_REFERER, - 'Origin': 'https://passport.douyu.com', - 'X-Requested-With': 'XMLHttpRequest', - }) + 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", + "Accept-Language": "zh-CN,zh;q=0.9", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Referer": self.LOGIN_REFERER, + "Origin": "https://passport.douyu.com", + "X-Requested-With": "XMLHttpRequest", + } + ) # 设置代理 self._apply_proxy() - def _apply_proxy(self, proxy: str = None) -> None: + def _apply_proxy(self, proxy: str | None = None) -> None: """应用代理到Session""" if proxy: # 使用指定的代理 self.session.proxies = { - 'http': proxy, - 'https': proxy, + "http": proxy, + "https": proxy, } self._current_proxy_url = proxy elif self.proxy: # 使用配置的静态代理 if isinstance(self.proxy, str): self.session.proxies = { - 'http': self.proxy, - 'https': self.proxy, + "http": self.proxy, + "https": self.proxy, } self._current_proxy_url = self.proxy else: self.session.proxies = { - scheme: url - for scheme, url in self.proxy.items() - if url + scheme: 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: # 从代理获取器取新代理 new_proxy = self.proxy_fetcher.fetch_new_proxy() if new_proxy: self.session.proxies = { - 'http': new_proxy, - 'https': new_proxy, + "http": new_proxy, + "https": new_proxy, } self._current_proxy_url = new_proxy else: @@ -207,7 +216,7 @@ class DouyuLogin: 统一发送请求,附带分段超时和更明确的错误信息。 代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。 """ - timeout = kwargs.pop('timeout', self.timeout) + timeout = kwargs.pop("timeout", self.timeout) safe_url = self._safe_url(url) self._ensure_not_stopped() @@ -256,19 +265,21 @@ class DouyuLogin: @staticmethod def _classify_credential_payload(payload: dict) -> tuple[str, str] | None: """识别登录接口返回的账号类终态。""" - error_code = payload.get('error') - error_msg = str(payload.get('msg') or '') + error_code = payload.get("error") + 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", "账号已注销" - password_keywords = ['密码错误', '账号或密码', '账号或者密码'] + password_keywords = ["密码错误", "账号或密码", "账号或者密码"] if error_code == 110018 or any(kw in error_msg for kw in password_keywords): return "password_wrong", "账号密码错误" 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。""" classified = self._classify_credential_payload(payload) if not classified: @@ -293,10 +304,14 @@ class DouyuLogin: # 3️⃣ 第二次登录(带极验) logger.info("步骤3: 第二次登录(带极验验证)...") 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: 跳过手机号绑定,完成登录...") return self._complete_login(value) @@ -339,12 +354,19 @@ class DouyuLogin: return LoginResult(success=False, message="任务已停止") elapsed = time.monotonic() - start_time if self.max_total_time > 0 and elapsed > self.max_total_time: - logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃") - return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)") + logger.warning( + f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃" + ) + return LoginResult( + success=False, + message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)", + ) if attempt > 1: if self.max_login_retries > 0: - logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始") + logger.info( + f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始" + ) else: logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始") # 重试前:取新代理 + 重置 session @@ -371,15 +393,23 @@ class DouyuLogin: return LoginResult(success=False, message=str(e), code=e.code) except EmailLoginError as 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: elapsed = time.monotonic() - start_time 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: - 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 if has_retry and has_time: self._sleep_interruptible(1) @@ -405,14 +435,23 @@ class DouyuLogin: return LoginResult(success=False, message="任务已停止") elapsed = time.monotonic() - start_time if self.max_total_time > 0 and elapsed > self.max_total_time: - logger.warning(f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃") - return LoginResult(success=False, message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)") + logger.warning( + f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃" + ) + return LoginResult( + success=False, + message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)", + ) if attempt > 1: if self.max_login_retries > 0: - logger.info(f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始") + logger.info( + f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始" + ) else: - logger.info(f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始") + logger.info( + f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始" + ) if not self._prepare_retry(): return LoginResult( success=False, @@ -431,15 +470,23 @@ class DouyuLogin: return LoginResult(success=True, message=e.status_message, code=e.code) except EmailLoginError as 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: elapsed = time.monotonic() - start_time 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: - 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 if has_retry and has_time: self._sleep_interruptible(1) @@ -449,40 +496,44 @@ class DouyuLogin: def _check_certification_status(self) -> LoginResult: """请求个人中心接口并判断实名状态。""" headers = { - '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', - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache', - 'Priority': 'u=1, i', - 'Referer': self.CP_REFERER, - 'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', - 'Sec-CH-UA-Mobile': '?0', - 'Sec-CH-UA-Platform': '"macOS"', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-origin', - 'X-Requested-With': 'XMLHttpRequest', - 'Origin': None, - 'Content-Type': None, + "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", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Priority": "u=1, i", + "Referer": self.CP_REFERER, + "Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "XMLHttpRequest", + "Origin": None, + "Content-Type": None, } payload = self._request_json( - 'get', + "get", self.CP_RPC_API, - '账号认证状态接口', + "账号认证状态接口", headers=headers, timeout=(5, 10), ) - info = payload.get('info') or {} - ident_status = str(info.get('ident_status', '')) - ident_type = str(info.get('ident_type', '')) + info = payload.get("info") or {} + ident_status = str(info.get("ident_status", "")) + 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("账号检测完成: 账号未认证") - 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("账号检测完成: 账号已认证") - 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 '-'}" logger.warning(message) @@ -528,17 +579,19 @@ class DouyuLogin: ) payload = self._request_json( - 'post', + "post", self.api.first_login_url, - '第一次登录接口', + "第一次登录接口", data=data, ) logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}") - if payload.get('error') != 81: - error_msg = payload.get('msg', '未知错误') - credential_error = self._credential_error_from_payload("第一次登录", payload) + if payload.get("error") != 81: + error_msg = payload.get("msg", "未知错误") + credential_error = self._credential_error_from_payload( + "第一次登录", payload + ) if credential_error: raise credential_error raise ValueError(f"第一次登录失败: {error_msg}") @@ -553,7 +606,9 @@ class DouyuLogin: 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换新代理) @@ -578,7 +633,9 @@ class DouyuLogin: finally: _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次尝试,失败直接抛异常。""" _MAX_ATTEMPTS = 3 @@ -620,14 +677,18 @@ class DouyuLogin: # 从 fullpage ajax.php 响应中提取 validate if isinstance(result, dict): - data = result.get('data', {}) - validate = data.get('validate', '') or result.get('validate', '') - success = data.get('result') == 'success' or result.get('success') == 1 - message = data.get('result') or result.get('message', '') + data = result.get("data", {}) + validate = data.get("validate", "") or result.get("validate", "") + success = ( + data.get("result") == "success" or result.get("success") == 1 + ) + message = data.get("result") or result.get("message", "") if success and validate: seccode = f"{validate}|jordan" - logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...") + logger.success( + f"极验 fullpage 验证成功! validate={validate[:20]}..." + ) return validate, seccode else: logger.warning(f"极验验证失败: {message},回到 login 换新代理") @@ -636,7 +697,9 @@ class DouyuLogin: validate = str(result) if validate: seccode = f"{validate}|jordan" - logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...") + logger.success( + f"极验 fullpage 验证成功! validate={validate[:20]}..." + ) return validate, seccode except ValueError: @@ -645,11 +708,16 @@ class DouyuLogin: except Exception as e: err_str = str(e) # 所有网络/代理异常都直接上抛,由 login 换新代理 - logger.warning(f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理") - raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e + logger.warning( + 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, - seccode: str, code_token: str) -> tuple[str, str]: + def _second_login( + 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( self.account.username, self.account.password, - gt, challenge, validate, seccode, code_token, + gt, + challenge, + validate, + seccode, + code_token, 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( - 'post', + "post", self.api.login_url, - '第二次登录接口', + "第二次登录接口", data=data, ) @@ -689,11 +763,13 @@ class DouyuLogin: if unique_key: logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定") 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: - error_msg = payload.get('msg', '未知错误') - credential_error = self._credential_error_from_payload("第二次登录", payload) + if payload.get("error") != 130014: + error_msg = payload.get("msg", "未知错误") + credential_error = self._credential_error_from_payload( + "第二次登录", payload + ) if credential_error: raise credential_error raise ValueError(f"第二次登录失败: {error_msg}") @@ -703,26 +779,26 @@ class DouyuLogin: if not remote_code: # 提取可能的风控提示 - data = payload.get('data', {}) - security_quiz = data.get('securityQuiz', '') - msg = payload.get('msg', '') - detail = security_quiz or msg or '未知原因' + data = payload.get("data", {}) + security_quiz = data.get("securityQuiz", "") + msg = payload.get("msg", "") + detail = security_quiz or msg or "未知原因" raise ValueError(f"获取remote_code失败: {detail}") 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: """跳过手机号绑定并获取后续登录回调地址。""" payload = self._request_json( - 'post', + "post", self.api.login_url, - '跳过手机号绑定接口', + "跳过手机号绑定接口", 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', '未知错误')}") login_url = self.api.extract_login_url(payload) @@ -738,13 +814,13 @@ class DouyuLogin: data = self.api.build_send_email_data(remote_code) payload = self._request_json( - 'post', + "post", self.api.send_email_url, - '发送验证邮件接口', + "发送验证邮件接口", data=data, ) - if payload.get('error') != 0: + if payload.get("error") != 0: raise ValueError(f"发送验证邮件失败: {payload.get('msg')}") logger.info("验证邮件已发送") @@ -775,13 +851,13 @@ class DouyuLogin: data = self.api.build_verify_data(remote_code, verify_code) payload = self._request_json( - 'post', + "post", self.api.verify_url, - '提交验证码接口', + "提交验证码接口", data=data, ) - if payload.get('error') != 0: + if payload.get("error") != 0: raise ValueError(f"提交验证码失败: {payload.get('msg')}") login_url = self.api.extract_login_url(payload) @@ -798,8 +874,8 @@ class DouyuLogin: @staticmethod def _normalize_login_url(login_url: str) -> str: """补全斗鱼接口返回的协议相对登录回调地址。""" - if login_url.startswith('//'): - return 'https:' + login_url + if login_url.startswith("//"): + return "https:" + login_url return login_url def _complete_login(self, login_url: str) -> str: @@ -810,16 +886,16 @@ class DouyuLogin: cookie: 完整的Cookie字符串 """ # 访问登录URL - response = self._request('get', login_url) + response = self._request("get", login_url) response.raise_for_status() # 尝试访问webLogin获取用户信息 try: - code_match = re.search(r'code=([^&]+)', login_url) + code_match = re.search(r"code=([^&]+)", login_url) if code_match: code = code_match.group(1) 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: logger.info("WebLogin成功") @@ -839,7 +915,9 @@ class DouyuLogin: raise except Exception as e: 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() @@ -848,6 +926,6 @@ class DouyuLogin: cookies = self.session.cookies.get_dict() # 格式化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 diff --git a/core/douyu/proxy_platforms/__init__.py b/core/douyu/proxy_platforms/__init__.py index e5fe918..bdd3ae8 100644 --- a/core/douyu/proxy_platforms/__init__.py +++ b/core/douyu/proxy_platforms/__init__.py @@ -6,7 +6,7 @@ 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 .xiequ import XiequAdapter @@ -22,19 +22,30 @@ _PLATFORM_REGISTRY: dict[str, Type[BaseWhitelistAdapter]] = { _PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = { "xiequ": [ {"key": "uid", "label": "UID", "placeholder": "如: 99769"}, - {"key": "ukey", "label": "UKEY", "placeholder": "如: C99371082B965B70F46DCAA87A04618B"}, + { + "key": "ukey", + "label": "UKEY", + "placeholder": "如: C99371082B965B70F46DCAA87A04618B", + }, ], "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"}, ], } # ── 平台显示名 ── _PLATFORM_LABELS: dict[str, str] = { - name: cls(dict()).platform_label - for name, cls in _PLATFORM_REGISTRY.items() + name: cast(Any, cls)({}).platform_label 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) if not cls: return None - return cls(credentials) + return cast(Any, cls)(credentials) def get_platform_names() -> list[str]: diff --git a/core/douyu/proxy_platforms/base.py b/core/douyu/proxy_platforms/base.py index 9819751..7f74e60 100644 --- a/core/douyu/proxy_platforms/base.py +++ b/core/douyu/proxy_platforms/base.py @@ -110,20 +110,26 @@ class BaseWhitelistAdapter(ABC): return True, msg # 当前IP已存在但备注不同,不处理 - if any(r.get('ip') == current_ip for r in records): - logger.info(f"白名单IP {current_ip} 已存在(备注不同),无需重复添加") + if any(r.get("ip") == current_ip for r in records): + logger.info( + f"白名单IP {current_ip} 已存在(备注不同),无需重复添加" + ) return True, f"白名单IP已存在: {current_ip}" # 超过上限,删除最老的 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: + if not old_ip: + continue logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}") self.delete_ip(old_ip) time.sleep(1) # 添加新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) if ok: msg = f"白名单IP已添加: {current_ip}" @@ -152,10 +158,12 @@ def _get_local_exit_ip() -> Optional[str]: ] for url in targets: 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() 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: return match.group(1) except Exception: @@ -174,7 +182,9 @@ def get_exit_ip_via_proxy(proxy: str) -> Optional[str]: for url in targets: try: response = requests.get( - url, proxies=proxies, timeout=6, + url, + proxies=proxies, + timeout=6, headers={"User-Agent": "Mozilla/5.0"}, ) response.raise_for_status() diff --git a/core/douyu/proxy_whitelist.py b/core/douyu/proxy_whitelist.py index f45df2d..1ff42de 100644 --- a/core/douyu/proxy_whitelist.py +++ b/core/douyu/proxy_whitelist.py @@ -17,7 +17,7 @@ class DouyuWhitelistSyncer: def __init__( self, platform: str = "xiequ", - credentials: dict = None, + credentials: Optional[dict] = None, uid: str = "", ukey: str = "", ): diff --git a/core/geetest/common/trajectory.py b/core/geetest/common/trajectory.py index 77468e0..bc71185 100644 --- a/core/geetest/common/trajectory.py +++ b/core/geetest/common/trajectory.py @@ -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 = [] @@ -41,70 +43,56 @@ def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int # 随机时间间隔(3-25ms,符合人类反应) time_delta = random.choices( [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] current_time += time_delta - trajectory.append([ - "move", - current_x, - current_y, - current_time, - "pointermove" - ]) + trajectory.append(["move", current_x, current_y, current_time, "pointermove"]) # 偶尔在同一位置停留(模拟视觉确认) if random.random() < 0.15: - trajectory.append([ - "move", - current_x, - current_y, - current_time + random.randint(5, 15), - "pointermove" - ]) + trajectory.append( + [ + "move", + current_x, + current_y, + current_time + random.randint(5, 15), + "pointermove", + ] + ) current_time += random.randint(5, 15) # 到达目标后的悬停 hover_time = random.randint(50, 150) for _ in range(random.randint(2, 5)): current_time += random.randint(8, 25) - trajectory.append([ - "move", - end_x + random.randint(-1, 1), - end_y + random.randint(-1, 1), - current_time, - "pointermove" - ]) + trajectory.append( + [ + "move", + end_x + random.randint(-1, 1), + end_y + random.randint(-1, 1), + current_time, + "pointermove", + ] + ) current_time += hover_time # 点击事件 - trajectory.append([ - "down", - end_x, - end_y, - current_time, - "pointerdown" - ]) + trajectory.append(["down", end_x, end_y, current_time, "pointerdown"]) - trajectory.append([ - "focus", - current_time + 1 - ]) + trajectory.append(["focus", current_time + 1]) click_duration = random.randint(80, 130) - trajectory.append([ - "up", - end_x, - end_y, - current_time + click_duration, - "pointerup" - ]) + trajectory.append(["up", end_x, end_y, current_time + click_duration, "pointerup"]) 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: - return [] + return {"data": [], "first_event": None, "last_event": None, "total_events": 0} # 初始化变量 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"] # 点击类事件(仅时间信息) - CLICK_EVENTS = ["down", "up", "click", "mousedown", "mouseup", - "touchstart", "touchend", "pointerdown", "pointerup"] + CLICK_EVENTS = [ + "down", + "up", + "click", + "mousedown", + "mouseup", + "touchstart", + "touchend", + "pointerdown", + "pointerup", + ] # 特殊事件(仅时间信息) 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 # 添加到结果数组 - result.append([ - event_type, - [delta_x, delta_y], - time_diff - ]) + result.append([event_type, [delta_x, delta_y], time_diff]) # 更新上一次的值 prev_x = x @@ -190,11 +183,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None time_diff = timestamp - prev_time # 添加到结果数组(坐标差为[0,0]) - result.append([ - event_type, - [0, 0], - time_diff - ]) + result.append([event_type, [0, 0], time_diff]) prev_time = timestamp @@ -209,10 +198,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None time_diff = timestamp - prev_time # 添加到结果数组(仅包含时间差) - result.append([ - event_type, - time_diff - ]) + result.append([event_type, time_diff]) prev_time = timestamp @@ -220,10 +206,11 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None "data": result, "first_event": first_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, "blur": 5, "unload": 6, - "unknown": 7 + "unknown": 7, } def h(e, t): @@ -434,11 +421,13 @@ def compress_trajectory(e:list[Any]) -> str: """Base64编码""" t = "" n = len(e) // 6 - base64_chars = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~" + base64_chars = ( + "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~" + ) for r in range(n): # 每次取6位二进制 - binary_str = e[6 * r: 6 * (r + 1)] + binary_str = e[6 * r : 6 * (r + 1)] index = int(binary_str, 2) t += base64_chars[index] @@ -446,9 +435,12 @@ def compress_trajectory(e:list[Any]) -> str: return u(c_str) + class TrajectoryEncoder: def __init__(self) -> None: - self.CHARSET = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr" + self.CHARSET = ( + "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr" + ) self.BASE = len(self.CHARSET) # 64 self.DIRECTION_CHARS = "stuvwxyz~" self.DIRECTION_PATTERNS = [ @@ -460,7 +452,7 @@ class TrajectoryEncoder: [0, -1], # x [3, 0], # y [2, -1], # z - [2, 1] # ~ + [2, 1], # ~ ] def encode_number(self, num): @@ -546,9 +538,11 @@ class TrajectoryEncoder: t_encoded.append(self.encode_number(dt)) # 拼接: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实现 @@ -568,7 +562,7 @@ class TrajectoryEncoder: # 每次读取2个字符(十六进制) while o < len(n): - r = n[o:o + 2] # 取2个字符 + r = n[o : o + 2] # 取2个字符 if len(r) < 2: break o += 2 @@ -587,7 +581,8 @@ class TrajectoryEncoder: return i -def H(t:int, e:str) -> str: + +def H(t: int, e: str) -> str: # 解析后缀 n = e[-2:] r = [] @@ -625,4 +620,4 @@ def H(t:int, e:str) -> str: g.pop(d) d -= 1 - return p \ No newline at end of file + return p diff --git a/core/huya/__init__.py b/core/huya/__init__.py index 7d50516..f131a50 100644 --- a/core/huya/__init__.py +++ b/core/huya/__init__.py @@ -1,8 +1,37 @@ """虎牙协议与业务客户端。""" +from typing import TYPE_CHECKING + from .http_client import HuyaHttpClient 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__ = [ "HuyaHttpClient", "HuyaWssClient", @@ -103,8 +132,16 @@ def __getattr__(name: str): } globals().update(values) return values[name] - if name in {"HuyaVerificationError", "HuyaVerificationSolver", "solve_huya_verification"}: - from .verification import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification + if name in { + "HuyaVerificationError", + "HuyaVerificationSolver", + "solve_huya_verification", + }: + from .verification import ( + HuyaVerificationError, + HuyaVerificationSolver, + solve_huya_verification, + ) values = { "HuyaVerificationError": HuyaVerificationError, diff --git a/core/huya/app_login.py b/core/huya/app_login.py index b638331..6cdc9dd 100644 --- a/core/huya/app_login.py +++ b/core/huya/app_login.py @@ -18,6 +18,7 @@ - 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽; - qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。 """ + from __future__ import annotations import base64 @@ -176,13 +177,13 @@ def parse_cred(resp: bytes) -> bytes | None: e = resp.find(b"_wup_header") if s < 0 or e < 0: return None - d = resp[s:e - 6] + d = resp[s : e - 6] m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d) if not m: return None ln = m.group(2)[0] st = m.start() + 4 - cred = d[st:st + ln] + cred = d[st : st + ln] if len(cred) == 114 and cred[:1] == b"\x0a": return cred return None @@ -209,7 +210,10 @@ def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict: 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") last_err: Exception | None = None for attempt in range(max_retry): @@ -287,7 +291,11 @@ def login_cred_with_flow( return cred, uid risk_url = parse_risk_url(resp) 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}") if "qr_auth" in risk_url: raise HuyaAppQrAuthRequiredError( @@ -317,12 +325,14 @@ class QrRole: self.context = f"{prefix}-{ctx_hex}-{tail}" self.page_id = random.randint(40_000_000, 41_000_000) self.req_counter = random.randint(40_000_000, 41_000_000) - self.s.headers.update({ - "User-Agent": UA_PC if pc else APP_UA_MOBILE, - "Origin": UDB_BASE, - "content-type": "application/json;charset=UTF-8", - "Accept": "*/*", - }) + self.s.headers.update( + { + "User-Agent": UA_PC if pc else APP_UA_MOBILE, + "Origin": UDB_BASE, + "content-type": "application/json;charset=UTF-8", + "Accept": "*/*", + } + ) def _headers(self, uri: str) -> dict: 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]}", } - 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 = { "uri": uri, "version": "2.6" if self.pc else "2.5", @@ -389,13 +401,16 @@ class HuyaAppPasswordLogin: self.proxies = dict(proxies) if proxies else None self.timeout = timeout or (10.0, 25.0) 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: """执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。""" result = self._login_impl() try: from .device_profile import record_login + record_login(self.username, result.success, result.message) except Exception: # 元数据记录失败不影响登录结果 pass @@ -404,7 +419,9 @@ class HuyaAppPasswordLogin: def _login_impl(self) -> HuyaLoginResult: """登录主体 (原 login)。""" 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 滑块) try: @@ -442,7 +459,9 @@ class HuyaAppPasswordLogin: # 3) 信封补丁 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: struct.pack_into(">Q", raw, env.uid_off, uid) wup = base64.b64encode(bytes(raw)).decode("ascii") @@ -457,9 +476,11 @@ class HuyaAppPasswordLogin: try: # 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享 # 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复) - sdid_obj = get_huya_sdid(allow_fallback=True, - state_dir=account_state_dir(self.username), - device_hint=self.device_info) + sdid_obj = get_huya_sdid( + allow_fallback=True, + state_dir=account_state_dir(self.username), + device_hint=self.device_info, + ) sdid = sdid_obj.sdid if sdid_obj else "" pc = QrRole(pc=True, 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: - 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 biztoken = None @@ -552,7 +575,9 @@ class HuyaAppPasswordLogin: 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( success=True, cookie=cookie_str, diff --git a/core/huya/change_password.py b/core/huya/change_password.py index 780bbb1..b495390 100644 --- a/core/huya/change_password.py +++ b/core/huya/change_password.py @@ -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" 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}" @@ -86,13 +88,15 @@ def encode_change_password_behavior(stage: str) -> str: actions: list[dict] = [] if stage in {"send", "submit"}: - actions.append({ - "id": "pmodify.btn.getsms", - "x": random.randint(470, 520), - "y": random.randint(120, 145), - "d": elapsed, - "time": now, - }) + actions.append( + { + "id": "pmodify.btn.getsms", + "x": random.randint(470, 520), + "y": random.randint(120, 145), + "d": elapsed, + "time": now, + } + ) if stage == "submit": for action_id in ("pmodify.input.sms", "pmodify.input.pw", "pmodify.input.sms"): 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}) now += random.randint(800, 2400) elapsed += random.randint(800, 2400) - actions.append({ - "id": "pmodify.btn.submit", - "x": random.randint(395, 445), - "y": random.randint(220, 245), - "d": elapsed, - "time": now, - }) + actions.append( + { + "id": "pmodify.btn.submit", + "x": random.randint(395, 445), + "y": random.randint(220, 245), + "d": elapsed, + "time": now, + } + ) value = { "furl": CHANGE_PASSWORD_FROM_URL, "curl": CHANGE_PASSWORD_PAGE_URL, "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: @@ -132,7 +140,13 @@ def _payload_data(payload: dict) -> dict: def _session_data(payload: dict, fallback: str = "") -> str: 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: @@ -174,23 +188,25 @@ class HuyaPasswordChanger: self._setup_headers() def _setup_headers(self) -> None: - self.session.headers.update({ - "Accept": "application/json, text/plain, */*", - "Accept-Language": "zh-CN,zh;q=0.9", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Content-Type": "application/json;charset=UTF-8", - "Origin": "https://udbreg.huya.com", - "Pragma": "no-cache", - "Referer": self.middle_url, - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", - "User-Agent": self.ua, - "sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"macOS"', - }) + self.session.headers.update( + { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "application/json;charset=UTF-8", + "Origin": "https://udbreg.huya.com", + "Pragma": "no-cache", + "Referer": self.middle_url, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": self.ua, + "sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + } + ) @staticmethod def _safe_url(url: str) -> str: @@ -199,7 +215,9 @@ class HuyaPasswordChanger: def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict: 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() try: return response.json() @@ -216,20 +234,26 @@ class HuyaPasswordChanger: logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}") except requests.RequestException as exc: logger.debug(f"虎牙改密 middle 初始化失败: {host}: {exc}") - self.session.headers.update({ - "Origin": "https://udbreg.huya.com", - "Referer": self.middle_url, - }) + self.session.headers.update( + { + "Origin": "https://udbreg.huya.com", + "Referer": self.middle_url, + } + ) def prepare_device(self) -> str: """获取虎牙风控 sdid。""" 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") if not token: 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", "") if not self.sdid: raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}") @@ -240,7 +264,11 @@ class HuyaPasswordChanger: from .verification import 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, sdid=self.sdid, session=self.session, @@ -402,12 +430,16 @@ class HuyaPasswordChanger: 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: 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): return_code = int(payload.get("returnCode") or 0) if return_code == 0: @@ -436,7 +468,9 @@ class HuyaPasswordChanger: logger.info(f"虎牙短信改密触发风控: {return_code} ({index + 1}/3)") self._solve_verification(payload) 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( success=False, diff --git a/core/huya/frame_decoder.py b/core/huya/frame_decoder.py index 52f8b09..c62fd6a 100644 --- a/core/huya/frame_decoder.py +++ b/core/huya/frame_decoder.py @@ -1,14 +1,23 @@ """ TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出 """ + +from typing import Any, cast from .taf_protocol import TafInputStream, TafType from .wup_protocol import normalize_wup_payload # cmd 编号 → 名称 CMD_NAMES = { - 0x03: "RPC_REQ", 0x04: "RPC_RSP", 0x0a: "AUTH", - 0x0b: "PUSH1", 0x10: "HB_SEND", 0x11: "HB_RECV", - 0x17: "CONFIRM", 0x18: "PUSH2", 0x21: "REGISTER", 0x22: "CONFIRM_RSP", + 0x03: "RPC_REQ", + 0x04: "RPC_RSP", + 0x0A: "AUTH", + 0x0B: "PUSH1", + 0x10: "HB_SEND", + 0x11: "HB_RECV", + 0x17: "CONFIRM", + 0x18: "PUSH2", + 0x21: "REGISTER", + 0x22: "CONFIRM_RSP", } # TAIL_BYTES = 2c36004c5c6600 @@ -18,10 +27,14 @@ _TAIL = bytes.fromhex("2c36004c5c6600") def _strip_tail(body: bytes) -> bytes: """裁掉 body 末尾的 TAIL_BYTES""" if body.endswith(_TAIL): - return body[:-len(_TAIL)] + return body[: -len(_TAIL)] # 有时 TAIL 前还有 0c (ZERO tag) - if len(body) > 1 and body[-len(_TAIL)-1:-len(_TAIL)] == b'\x0c' and body.endswith(_TAIL): - return body[:-len(_TAIL)-1] + if ( + len(body) > 1 + and body[-len(_TAIL) - 1 : -len(_TAIL)] == b"\x0c" + and body.endswith(_TAIL) + ): + return body[: -len(_TAIL) - 1] return body @@ -33,16 +46,18 @@ def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object return ins._read_int_value(dtype) if dtype in (TafType.FLOAT, TafType.DOUBLE): import struct as _s + if dtype == TafType.FLOAT: - 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(">f", ins.buf.read(4))[0], 4) + return round(_s.unpack(">d", ins.buf.read(8))[0], 6) if dtype == TafType.STRING1: 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: 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: cnt = ins._read_int_len() m = {} @@ -106,14 +121,14 @@ def _extract_wup(body: bytes) -> bytes: return body # 大包格式: [1B prefix][4B wup_len][wup_body][tail] # prefix 可能是 0x00,必须优先于 4B total_len 判断。 - wup_len = int.from_bytes(body[1:5], 'big') - if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03': - return body[5:5 + wup_len] - total_len = int.from_bytes(body[0:4], 'big') - if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03': + wup_len = int.from_bytes(body[1:5], "big") + if 5 + wup_len <= len(body) and body[5:7] == b"\x10\x03": + return body[5 : 5 + wup_len] + total_len = int.from_bytes(body[0:4], "big") + if 8 <= total_len <= len(body) and body[4:6] == b"\x10\x03": return body[:total_len] if 5 + wup_len <= len(body): - return body[5:5 + wup_len] + return body[5 : 5 + wup_len] return body @@ -127,7 +142,7 @@ def _decode_wup_body(body: bytes) -> dict: ins = TafInputStream(wup) # 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾) - sBuffer = b'' + sBuffer = b"" while True: try: 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}" # AUTH - if cmd == 0x0a: - text = _strip_tail(body).decode('utf-8', errors='replace') + if cmd == 0x0A: + text = _strip_tail(body).decode("utf-8", errors="replace") return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}" # REGISTER / CONFIRM / PUSH 等 @@ -302,7 +317,7 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str: fields = {} cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}") 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}" except Exception: cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}") diff --git a/tests/test_douyu_elite_lock_exchange.py b/tests/test_douyu_elite_lock_exchange.py index 5aab18d..7bf31b7 100644 --- a/tests/test_douyu_elite_lock_exchange.py +++ b/tests/test_douyu_elite_lock_exchange.py @@ -54,7 +54,7 @@ class TestEliteLockExchange: assert result["order_id"] == "5874" 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 assert create_call.args[:3] == ( "post", diff --git a/tests/test_douyu_runner_proxy.py b/tests/test_douyu_runner_proxy.py index f61a793..b1dc6f8 100644 --- a/tests/test_douyu_runner_proxy.py +++ b/tests/test_douyu_runner_proxy.py @@ -80,7 +80,9 @@ class TestDouyuProxyMode: # 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理) 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 def test_api_mode_failure_falls_back_to_direct(self): diff --git a/tests/test_huya_app_login.py b/tests/test_huya_app_login.py index 4aed4fb..403964a 100644 --- a/tests/test_huya_app_login.py +++ b/tests/test_huya_app_login.py @@ -226,6 +226,7 @@ class TestHuyaAppLogin: .filter(HuyaAccount.username == "mock_test_huya_user") .first() ) + assert acc is not None batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id]) batch_resp = app_password_login_selected_accounts( req=batch_req, db=self.session, current=self.admin diff --git a/tests/test_huya_dfp_register.py b/tests/test_huya_dfp_register.py index 5b4c674..0c60bc8 100644 --- a/tests/test_huya_dfp_register.py +++ b/tests/test_huya_dfp_register.py @@ -11,6 +11,7 @@ import pytest import base64 import json +import pytest from pathlib import Path from unittest.mock import patch @@ -69,7 +70,7 @@ class TestParseResponse: def test_parse_golden_evidence(self): """真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。""" if not CHAIN_FILE.exists(): - self.skipTest("缺失 golden 注册链模板") + pytest.skip("缺失 golden 注册链模板") data = json.loads(CHAIN_FILE.read_text(encoding="utf-8")) resp = base64.b64decode(data["dfpReport"]["resp_b64"]) t1, t2, t5 = _parse_response(resp) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 48cee58..589fe4b 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -96,11 +96,11 @@ class TestMigrationSmoke: missing_columns = model_columns - db_columns assert not missing_columns - db_indexes = { - index["name"] - for index in inspector.get_indexes(name) - if not index["name"].startswith("sqlite_autoindex") - } + db_indexes = set() + for index in inspector.get_indexes(name): + index_name = index["name"] + if index_name and not index_name.startswith("sqlite_autoindex"): + db_indexes.add(index_name) all_db_indexes |= db_indexes model_indexes = { index.name for index in table.indexes if index.name diff --git a/web/backend/deps.py b/web/backend/deps.py index 3d3d58c..1a1b79f 100644 --- a/web/backend/deps.py +++ b/web/backend/deps.py @@ -34,9 +34,10 @@ def get_current_user( payload = decode_access_token(token) if payload is None: raise credentials_exc - user_id: int = payload.get("sub") - if user_id is None: + user_id_raw = payload.get("sub") + if user_id_raw is None: raise credentials_exc + user_id = int(user_id_raw) except JWTError: raise credentials_exc @@ -48,6 +49,7 @@ def get_current_user( def require_permission(permission: str): """权限检查依赖工厂。用法: Depends(require_permission('user:create'))""" + def checker(current_user: User = Depends(get_current_user)) -> User: if not current_user.is_active: raise HTTPException(status_code=403, detail="账号已禁用") @@ -55,6 +57,7 @@ def require_permission(permission: str): if permission not in perms: raise HTTPException(status_code=403, detail=f"无权限: {permission}") return current_user + return checker diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 02d0ccd..ef2a83c 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -1,16 +1,32 @@ """账号管理路由""" 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 ..database import get_db -from ..models import User, Account, AuditLog, LoginTask, DouyuTask, DouyuWorkbenchAccount -from ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign +from ..models import ( + 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 ..permissions import user_has_permission 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=["账号管理"]) @@ -46,15 +62,19 @@ def _filter_accounts_query( search_text = (search or "").strip() if search_text: pattern = f"%{search_text}%" - query = query.filter(or_( - Account.username.ilike(pattern), - Account.tag.ilike(pattern), - Account.remark.ilike(pattern), - )) + query = query.filter( + or_( + Account.username.ilike(pattern), + Account.tag.ilike(pattern), + Account.remark.ilike(pattern), + ) + ) 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。""" if req.all_matching: rows = ( @@ -71,7 +91,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection) .order_by(None) .all() ) - return [account_id for account_id, in rows] + return [account_id for (account_id,) in rows] seen = set() ids = [] @@ -88,7 +108,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection) .order_by(None) .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] @@ -131,19 +151,25 @@ def list_accounts( if page is not None: 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)] if not can_include_sensitive: - options.extend([ - defer(Account.password), - defer(Account.email), - defer(Account.email_password), - ]) + options.extend( + [ + defer(Account.password), + defer(Account.email), + defer(Account.email_password), + ] + ) accounts = query.options(*options).all() result = [] for acc in accounts: 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 "", assigned_to=acc.assigned_to, 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 result.append(item) 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 @@ -209,8 +240,14 @@ def import_accounts( if accounts: db.add_all(accounts) - db.add(AuditLog(user_id=current.id, username=current.username, - action="account:import", target=f"导入{len(accounts)}个")) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="account:import", + target=f"导入{len(accounts)}个", + ) + ) db.commit() duplicated_note = f",重复跳过 {duplicated} 个" if duplicated else "" @@ -240,17 +277,27 @@ def assign_account( raise HTTPException(status_code=400, detail="只能分配给客服角色") # 只能分配已成功登录过的账号(有cookie) - has_success = db.query(LoginTask).filter( - LoginTask.account_id == account_id, - LoginTask.status == 'success', - LoginTask.cookie != '', - ).first() + has_success = ( + db.query(LoginTask) + .filter( + LoginTask.account_id == account_id, + LoginTask.status == "success", + LoginTask.cookie != "", + ) + .first() + ) if not has_success: raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配") acc.assigned_to = req.assigned_to - db.add(AuditLog(user_id=current.id, username=current.username, - action="account:assign", target=acc.username)) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="account:assign", + target=acc.username, + ) + ) db.commit() return {"message": "已分配", "success": True} @@ -274,31 +321,52 @@ def batch_assign_accounts( raise HTTPException(status_code=400, detail="只能分配给客服角色") # 只能分配已成功登录过的账号(有cookie) - cookie_ids_query = cookie_account_ids_query(db).subquery() - invalid_ids = db.query(Account.id).filter( - Account.id.in_(req.account_ids), - Account.id.notin_(cookie_ids_query), - ).all() + cookie_ids_query = select(LoginTask.account_id).where( + LoginTask.status == "success", + LoginTask.cookie != "", + LoginTask.cookie.isnot(None), + ) + invalid_ids = ( + db.query(Account.id) + .filter( + Account.id.in_(req.account_ids), + Account.id.notin_(cookie_ids_query), + ) + .all() + ) if invalid_ids: - names = db.query(Account.username).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 '' + names = ( + db.query(Account.username) + .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( status_code=400, detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}", ) - count = db.query(Account).filter(Account.id.in_(req.account_ids)).update( - {Account.assigned_to: req.assigned_to}, synchronize_session=False + count = ( + 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() 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") @@ -307,11 +375,17 @@ def assignments_summary( 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() 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) .filter(User.role == "support") .group_by(User.id, User.username) @@ -345,8 +419,15 @@ def set_account_tag( if not acc: raise HTTPException(status_code=404, detail="账号不存在") acc.tag = (req.tag or "").strip() - db.add(AuditLog(user_id=current.id, username=current.username, - action="account:tag", target=acc.username, detail=acc.tag)) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="account:tag", + target=acc.username, + detail=acc.tag, + ) + ) db.commit() return {"message": "标签已更新", "success": True} @@ -362,8 +443,10 @@ def batch_tag( if not req.account_ids: raise HTTPException(status_code=400, detail="请选择账号") tag = (req.tag or "").strip() - count = _visible_accounts_query(db, current).filter(Account.id.in_(req.account_ids)).update( - {Account.tag: tag}, synchronize_session=False + count = ( + _visible_accounts_query(db, current) + .filter(Account.id.in_(req.account_ids)) + .update({Account.tag: tag}, synchronize_session=False) ) db.commit() return {"message": f"已为 {count} 个账号设置标签", "success": True} @@ -388,7 +471,11 @@ def batch_tag_selection( ) db.commit() 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") @@ -422,17 +509,29 @@ def batch_delete_accounts( raise HTTPException(status_code=400, detail="无效的账号ID") # 先删除关联的登录任务 - db.query(LoginTask).filter(LoginTask.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) + db.query(LoginTask).filter(LoginTask.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( - user_id=current.id, username=current.username, - action="account:delete", target=f"批量删除{deleted}个账号" - )) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="account:delete", + target=f"批量删除{deleted}个账号", + ) + ) db.commit() return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True} @@ -448,19 +547,28 @@ def batch_delete_accounts_selection( if not ids: raise HTTPException(status_code=400, detail="请选择账号") - db.query(LoginTask).filter(LoginTask.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) + db.query(LoginTask).filter(LoginTask.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 = ( _visible_accounts_query(db, current) .filter(Account.id.in_(ids)) .delete(synchronize_session=False) ) - db.add(AuditLog( - user_id=current.id, username=current.username, - action="account:delete", - target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号", - )) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="account:delete", + target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号", + ) + ) db.commit() return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True} @@ -476,12 +584,24 @@ def delete_account( raise HTTPException(status_code=404, detail="账号不存在") # 先删除关联的登录任务,避免外键约束失败 - db.query(LoginTask).filter(LoginTask.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.query(LoginTask).filter(LoginTask.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, - action="account:delete", target=acc.username)) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="account:delete", + target=acc.username, + ) + ) db.delete(acc) db.commit() return {"message": "已删除", "success": True} diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index 97a79e3..429e636 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -27,6 +27,7 @@ def _fmt_dt(dt) -> str | None: dt = dt.replace(tzinfo=timezone.utc) return dt.isoformat() + router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"]) cookie_relogin_registry = BatchRegistry() @@ -44,7 +45,9 @@ def _parse_account_names(raw_names: str) -> list[str]: """解析前端粘贴的 Excel 账号名,每行一个并去重。""" names = [] 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() if name and name not in seen: names.append(name) @@ -60,7 +63,9 @@ def _order_cookie_tasks(query, selected_names: list[str]): value=Account.username, 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()) @@ -88,7 +93,9 @@ def _visible_cookie_operation_tasks_query(db: Session, current: User): """返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。""" query = db.query(LoginTask).filter( 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"): 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"): raise HTTPException(status_code=403, detail="无权限: cookie:view") 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: query = _visible_cookie_tasks_query(db, current).options( joinedload(LoginTask.account).joinedload(Account.assigned_user), @@ -137,15 +146,19 @@ def list_cookies( if not account_joined: query = query.join(Account, LoginTask.account_id == Account.id) account_joined = True - query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_( - Account.username.ilike(pattern), - Account.tag.ilike(pattern), - User.username.ilike(pattern), - )) + query = query.outerjoin(User, Account.assigned_to == User.id).filter( + or_( + Account.username.ilike(pattern), + Account.tag.ilike(pattern), + User.username.ilike(pattern), + ) + ) total = 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) if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) @@ -175,8 +188,10 @@ def list_cookies( "batch_id": t.batch_id, "account_id": t.account_id, "account_username": acc.username if acc else "", - "assigned_to": acc.assigned_to, - "assigned_username": acc.assigned_user.username if acc and acc.assigned_user 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, "created_at": _fmt_dt(t.finished_at), "ck_check_status": t.ck_check_status or "", "ck_check_result": t.ck_check_result, @@ -194,7 +209,12 @@ def list_cookies( item["account_password"] = "" result.append(item) 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 @@ -242,10 +262,12 @@ def list_cookie_operations( if tag_value: query = query.filter(Account.tag == tag_value) if search_text: - query = query.filter(or_( - Account.username.ilike(f"%{search_text}%"), - Account.tag.ilike(f"%{search_text}%"), - )) + query = query.filter( + or_( + 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 tasks = ( @@ -265,7 +287,9 @@ def list_cookie_operations( "created_at": _fmt_dt(task.finished_at), "relogin_status": task.status if task.status != "success" else "", "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 ], @@ -292,7 +316,7 @@ def list_cookie_operation_tags( .order_by(Account.tag.asc()) .all() ) - return [tag for tag, in rows if tag] + return [tag for (tag,) in rows if tag] @router.get("/duplicates") @@ -317,7 +341,9 @@ def find_duplicate_cookies( if not user_has_permission(current, "login:view_all"): 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] = {} for row in rows: username = (row.username or "").strip() @@ -337,26 +363,30 @@ def find_duplicate_cookies( group["account_names"].add(username) group["account_ids"].add(row.account_id) group["cookie_ids"].append(row.cookie_id) - group["records"].append({ - "id": row.cookie_id, - "account_id": row.account_id, - "batch_id": row.batch_id, - "finished_at": _fmt_dt(row.finished_at), - }) + group["records"].append( + { + "id": row.cookie_id, + "account_id": row.account_id, + "batch_id": row.batch_id, + "finished_at": _fmt_dt(row.finished_at), + } + ) duplicate_groups = [] for group in grouped.values(): if len(group["cookie_ids"]) < 2: continue - duplicate_groups.append({ - "account_key": group["account_key"], - "account_names": sorted(group["account_names"]), - "cookie_count": len(group["cookie_ids"]), - "account_count": len(group["account_ids"]), - "cookie_ids": group["cookie_ids"], - "account_ids": sorted(group["account_ids"]), - "records": group["records"], - }) + duplicate_groups.append( + { + "account_key": group["account_key"], + "account_names": sorted(group["account_names"]), + "cookie_count": len(group["cookie_ids"]), + "account_count": len(group["account_ids"]), + "cookie_ids": group["cookie_ids"], + "account_ids": sorted(group["account_ids"]), + "records": group["records"], + } + ) duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"])) 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()] if not id_list: 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) - tasks = base_query.filter(LoginTask.id.in_(id_list)).filter( - LoginTask.status.in_(("success", "relogin_failed")) - ).all() + base_query = ( + _visible_cookie_tasks_query(db, current) + if detailed + 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: 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()) except Exception as exc: task = futures[future] - results.append({ - "id": task.id, - "valid": False, - "message": f"检测异常: {exc}", - "fish_ball": None, - "nickname": None, - "level": None, - "checked_at": datetime.now(timezone.utc).isoformat(), - }) + results.append( + { + "id": task.id, + "valid": False, + "message": f"检测异常: {exc}", + "fish_ball": None, + "nickname": None, + "level": None, + "checked_at": datetime.now(timezone.utc).isoformat(), + } + ) 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", ""), } task.ck_checked_at = datetime.now(timezone.utc) - db.add(AuditLog( - user_id=current.id, - username=current.username, - action="cookie:check", - target=f"检测 {len(results)} 条已分配账号 CK", - )) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="cookie:check", + target=f"检测 {len(results)} 条已分配账号 CK", + ) + ) db.commit() if not detailed: results = [ @@ -510,7 +550,13 @@ def check_cookie_operations( 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 保留到新登录成功后才替换。""" if not tasks: 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: db.commit() - raise HTTPException(status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态") + raise HTTPException( + status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态" + ) proxy = db.query(ProxyConfigModel).first() thread_db = SessionLocal() @@ -558,13 +606,15 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *, relogin_task_ids=task_ids, ) batch_id = runner.batch_id - db.add(AuditLog( - user_id=current.id, - username=current.username, - action=action, - target=f"重登 {len(task_ids)} 条账号 CK", - detail="旧 Cookie 将在新登录成功后替换", - )) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action=action, + target=f"重登 {len(task_ids)} 条账号 CK", + detail="旧 Cookie 将在新登录成功后替换", + ) + ) db.commit() 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) batch = cookie_relogin_registry.get(batch_id) if not batch: - raise HTTPException(status_code=404, detail="重登批次不存在、已结束或服务已重启") - if batch.get("owner_id") != current.id and not user_has_permission(current, "login:view_all"): + raise HTTPException( + 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="无权限停止该重登批次") batch["runner"].stop() - db.add(AuditLog( - user_id=current.id, - username=current.username, - action="cookie:relogin_stop", - target=f"停止 CK 重登批次 {batch_id}", - )) + db.add( + AuditLog( + user_id=current.id, + username=current.username, + action="cookie:relogin_stop", + target=f"停止 CK 重登批次 {batch_id}", + ) + ) db.commit() - return {"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", "success": True} + return { + "message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", + "success": True, + } 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()) if 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() return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid") @@ -681,7 +742,9 @@ def get_cookie( """获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。""" if not user_has_permission(current, "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: raise HTTPException(status_code=404, detail="记录不存在") acc = db.query(Account).filter(Account.id == task.account_id).first() @@ -691,7 +754,9 @@ def get_cookie( "account_id": task.account_id, "account_username": acc.username if acc else "", "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), "ck_check_status": task.ck_check_status or "", "ck_check_result": task.ck_check_result, @@ -726,7 +791,11 @@ def delete_cookies_batch( t.status = "failed" t.message = "Cookie已清除" 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}") @@ -736,7 +805,9 @@ def delete_cookie( current: User = Depends(require_permission("cookie:export")), ): """删除一条 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: raise HTTPException(status_code=404, detail="记录不存在") task.cookie = "" diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 75b2857..dc2613c 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -7,10 +7,18 @@ import json import shutil import threading from datetime import datetime, timezone +from typing import cast from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import StreamingResponse from sqlalchemy import func, or_ from sqlalchemy.orm import Session, defer, joinedload @@ -27,7 +35,10 @@ from core.huya import ( ) from core.huya.cookie_utils import normalize_huya_cookie from core.huya.device_fingerprint import account_state_dir as _fp_state_dir -from core.huya.device_profile import _load_db as _load_profile_db, _save_db as _save_profile_db +from core.huya.device_profile import ( + _load_db as _load_profile_db, + _save_db as _save_profile_db, +) from core.sms_provider import parse_sms_lines from ..database import SessionLocal, get_db @@ -97,7 +108,9 @@ router = APIRouter(prefix="/api/huya", tags=["虎牙"]) def _has_huya_perm(user: User, permission: str) -> bool: """虎牙新权限兼容旧的 huya:account 大权限。""" - return user_has_permission(user, permission) or user_has_permission(user, "huya:account") + return user_has_permission(user, permission) or user_has_permission( + user, "huya:account" + ) def _require_huya_perm(user: User, permission: str) -> None: @@ -120,7 +133,9 @@ def _can_view_huya_assigned(user: User) -> bool: def _can_view_huya_cookie(user: User) -> bool: - return _has_huya_perm(user, "huya:cookie:view") or _has_huya_perm(user, "huya:cookie:export") + return _has_huya_perm(user, "huya:cookie:view") or _has_huya_perm( + user, "huya:cookie:export" + ) def _visible_huya_accounts_query(db: Session, current: User): @@ -152,19 +167,23 @@ def _filter_huya_accounts_query( search_text = (search or "").strip() if search_text: pattern = f"%{search_text}%" - query = query.filter(or_( - HuyaAccount.uid.ilike(pattern), - HuyaAccount.yyuid.ilike(pattern), - HuyaAccount.username.ilike(pattern), - HuyaAccount.nickname.ilike(pattern), - HuyaAccount.tag.ilike(pattern), - HuyaAccount.game_name.ilike(pattern), - HuyaAccount.game_phone.ilike(pattern), - )) + query = query.filter( + or_( + HuyaAccount.uid.ilike(pattern), + HuyaAccount.yyuid.ilike(pattern), + HuyaAccount.username.ilike(pattern), + HuyaAccount.nickname.ilike(pattern), + HuyaAccount.tag.ilike(pattern), + HuyaAccount.game_name.ilike(pattern), + HuyaAccount.game_phone.ilike(pattern), + ) + ) return query -def _selected_huya_account_ids(db: Session, current: User, req: AccountBulkSelection) -> list[int]: +def _selected_huya_account_ids( + db: Session, current: User, req: AccountBulkSelection +) -> list[int]: """解析虎牙批量操作目标:当前筛选全部或显式选择的 ID。""" base_query = _visible_huya_accounts_query(db, current) if req.all_matching: @@ -181,7 +200,7 @@ def _selected_huya_account_ids(db: Session, current: User, req: AccountBulkSelec .order_by(None) .all() ) - return [account_id for account_id, in rows] + return [account_id for (account_id,) in rows] seen = set() requested_ids = [] @@ -192,13 +211,12 @@ def _selected_huya_account_ids(db: Session, current: User, req: AccountBulkSelec if not requested_ids: return [] rows = ( - base_query - .filter(HuyaAccount.id.in_(requested_ids)) + base_query.filter(HuyaAccount.id.in_(requested_ids)) .with_entities(HuyaAccount.id) .order_by(None) .all() ) - allowed = {account_id for account_id, in rows} + allowed = {account_id for (account_id,) in rows} return [account_id for account_id in requested_ids if account_id in allowed] @@ -206,11 +224,15 @@ def _clear_huya_account_references(db: Session, account_ids: list[int]) -> None: """删除账号前保留注册历史,把历史流水中的账号引用置空。""" if not account_ids: return - db.query(HuyaRegisterItem).filter(HuyaRegisterItem.account_id.in_(account_ids)).update( + db.query(HuyaRegisterItem).filter( + HuyaRegisterItem.account_id.in_(account_ids) + ).update( {HuyaRegisterItem.account_id: None}, synchronize_session=False, ) - db.query(HuyaRegisterSuccessLog).filter(HuyaRegisterSuccessLog.account_id.in_(account_ids)).update( + db.query(HuyaRegisterSuccessLog).filter( + HuyaRegisterSuccessLog.account_id.in_(account_ids) + ).update( {HuyaRegisterSuccessLog.account_id: None}, synchronize_session=False, ) @@ -222,7 +244,9 @@ def _visible_huya_tasks_query(db: Session, current: User): if _can_view_huya_all(current): return query if _can_view_huya_assigned(current): - return query.join(HuyaTask.account).filter(HuyaAccount.assigned_to == current.id) + return query.join(HuyaTask.account).filter( + HuyaAccount.assigned_to == current.id + ) raise HTTPException(status_code=403, detail="无权查看虎牙任务") @@ -244,7 +268,9 @@ def _huya_task_summary(query): } -def _require_huya_task_account_access(db: Session, current: User, account_ids: list[int]) -> None: +def _require_huya_task_account_access( + db: Session, current: User, account_ids: list[int] +) -> None: """确保任务只会提交到当前用户可操作的虎牙账号。""" requested_ids = set(account_ids) query = db.query(HuyaAccount.id).filter(HuyaAccount.id.in_(requested_ids)) @@ -252,7 +278,7 @@ def _require_huya_task_account_access(db: Session, current: User, account_ids: l if not _can_view_huya_assigned(current): raise HTTPException(status_code=403, detail="无权操作虎牙账号") query = query.filter(HuyaAccount.assigned_to == current.id) - allowed_ids = {account_id for account_id, in query.all()} + allowed_ids = {account_id for (account_id,) in query.all()} if allowed_ids != requested_ids: raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号") @@ -296,13 +322,17 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou game_channel=account.game_channel or "", game_phone=account.game_phone or "", assigned_to=account.assigned_to, - assigned_username=account.assigned_user.username if account.assigned_user else None, + assigned_username=account.assigned_user.username + if account.assigned_user + else None, created_at=account.created_at, updated_at=account.updated_at, ) -def _account_out_light(account: HuyaAccount, *, has_password: bool = False) -> HuyaAccountOut: +def _account_out_light( + account: HuyaAccount, *, has_password: bool = False +) -> HuyaAccountOut: """虎牙账号列表轻量输出,不读取加密字段。""" return HuyaAccountOut( id=account.id, @@ -322,13 +352,17 @@ def _account_out_light(account: HuyaAccount, *, has_password: bool = False) -> H game_channel=account.game_channel or "", game_phone=account.game_phone or "", assigned_to=account.assigned_to, - assigned_username=account.assigned_user.username if account.assigned_user else None, + assigned_username=account.assigned_user.username + if account.assigned_user + else None, created_at=account.created_at, updated_at=account.updated_at, ) -def _sanitize_task_result(result: dict | None, *, include_images: bool = False) -> dict | None: +def _sanitize_task_result( + result: dict | None, *, include_images: bool = False +) -> dict | None: """列表接口默认剥离 base64 图片,避免轮询每次传 1MB+ 数据。""" if not isinstance(result, dict): return result @@ -355,7 +389,10 @@ def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut: task_type=task.task_type, status=task.status or "", message=task.message or "", - result=_sanitize_task_result(task.result if isinstance(task.result, dict) else None, include_images=include_images), + result=_sanitize_task_result( + task.result if isinstance(task.result, dict) else None, + include_images=include_images, + ), created_by=task.created_by, created_at=task.created_at, finished_at=task.finished_at, @@ -375,7 +412,9 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut: def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict: cookie = normalize_huya_cookie(account.cookie or "") - account_name = account.nickname or account.username or account.uid or str(account.id) + account_name = ( + account.nickname or account.username or account.uid or str(account.id) + ) return { "id": account.id, "account_id": account.id, @@ -383,7 +422,9 @@ def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict: "uid": account.uid or "", "yyuid": account.yyuid or "", "assigned_to": account.assigned_to, - "assigned_username": account.assigned_user.username if account.assigned_user else None, + "assigned_username": account.assigned_user.username + if account.assigned_user + else None, "created_at": account.updated_at.isoformat() if account.updated_at else None, "cookie": cookie if include_cookie else "", "cookie_preview": _fmt_cookie_preview(cookie) if include_cookie else "***", @@ -392,7 +433,9 @@ def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict: def _cookie_out_light(account: HuyaAccount) -> dict: """虎牙 Cookie 列表轻量输出,不读取完整加密 Cookie。""" - account_name = account.nickname or account.username or account.uid or str(account.id) + account_name = ( + account.nickname or account.username or account.uid or str(account.id) + ) return { "id": account.id, "account_id": account.id, @@ -400,7 +443,9 @@ def _cookie_out_light(account: HuyaAccount) -> dict: "uid": account.uid or "", "yyuid": account.yyuid or "", "assigned_to": account.assigned_to, - "assigned_username": account.assigned_user.username if account.assigned_user else None, + "assigned_username": account.assigned_user.username + if account.assigned_user + else None, "created_at": account.updated_at.isoformat() if account.updated_at else None, "cookie": "", "cookie_preview": "***", @@ -412,7 +457,9 @@ def _huya_password_state_map(db: Session, account_ids: list[int]) -> dict[int, b if not account_ids: return {} rows = ( - db.query(HuyaAccount.id, (HuyaAccount.account_password != "").label("has_password")) + db.query( + HuyaAccount.id, (HuyaAccount.account_password != "").label("has_password") + ) .filter(HuyaAccount.id.in_(account_ids)) .all() ) @@ -450,21 +497,39 @@ def list_accounts( total = None if page is not None: total = query.order_by(None).count() - should_include_cookie = _can_view_huya_cookie(current) if include_cookie is None else (include_cookie and _can_view_huya_cookie(current)) + should_include_cookie = ( + _can_view_huya_cookie(current) + if include_cookie is None + else (include_cookie and _can_view_huya_cookie(current)) + ) query = query.order_by(HuyaAccount.id.desc()) if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) if not should_include_cookie: - query = query.options(defer(HuyaAccount.cookie), defer(HuyaAccount.account_password)) + query = query.options( + defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) + ) accounts = query.all() if should_include_cookie: result = [_account_out(account, include_cookie=True) for account in accounts] else: - password_map = _huya_password_state_map(db, [account.id for account in accounts]) - result = [_account_out_light(account, has_password=password_map.get(account.id, False)) for account in accounts] + password_map = _huya_password_state_map( + db, [account.id for account in accounts] + ) + result = [ + _account_out_light( + account, has_password=password_map.get(account.id, False) + ) + for account in accounts + ] 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 @@ -485,11 +550,13 @@ def accounts_summary( ) password_ready_count = query.filter(HuyaAccount.account_password != "").count() point_count = query.filter(HuyaAccount.points.isnot(None)).count() - bound_count = query.filter(or_( - HuyaAccount.game_name != "", - HuyaAccount.game_channel != "", - HuyaAccount.game_phone != "", - )).count() + bound_count = query.filter( + or_( + HuyaAccount.game_name != "", + HuyaAccount.game_channel != "", + HuyaAccount.game_phone != "", + ) + ).count() return { "total": total, "assigned_count": assigned_count, @@ -554,13 +621,19 @@ def password_login_account( except HuyaLoginError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=502, detail=f"虎牙 Web 密码登录失败: {exc}") from exc + raise HTTPException( + status_code=502, detail=f"虎牙 Web 密码登录失败: {exc}" + ) from exc if not result.success or not result.cookie: - raise HTTPException(status_code=502, detail=result.message or "虎牙 Web 密码登录失败") + raise HTTPException( + status_code=502, detail=result.message or "虎牙 Web 密码登录失败" + ) try: - account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint=req.username) + account = upsert_huya_cookie( + db, result.cookie, tag=req.tag, username_hint=req.username + ) if hasattr(account, "account_password") and req.password: account.account_password = req.password account.status = "active" @@ -597,13 +670,19 @@ def app_password_login_account( except (HuyaAppLoginError, HuyaLoginError) as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc except Exception as exc: - raise HTTPException(status_code=502, detail=f"虎牙 App 密码登录失败: {exc}") from exc + raise HTTPException( + status_code=502, detail=f"虎牙 App 密码登录失败: {exc}" + ) from exc if not result.success or not result.cookie: - raise HTTPException(status_code=502, detail=result.message or "虎牙 App 密码登录失败") + raise HTTPException( + status_code=502, detail=result.message or "虎牙 App 密码登录失败" + ) try: - account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint=req.username) + account = upsert_huya_cookie( + db, result.cookie, tag=req.tag, username_hint=req.username + ) if hasattr(account, "account_password") and req.password: account.account_password = req.password account.status = "active" @@ -637,7 +716,9 @@ def send_sms_code( raise HTTPException(status_code=502, detail=f"虎牙短信发码失败: {exc}") from exc if not result.success or not result.state: - raise HTTPException(status_code=502, detail=result.message or "虎牙短信发码失败") + raise HTTPException( + status_code=502, detail=result.message or "虎牙短信发码失败" + ) return { "message": result.message or "短信已发送", @@ -669,7 +750,9 @@ def sms_login_account( raise HTTPException(status_code=502, detail=f"虎牙短信登录失败: {exc}") from exc if not result.success or not result.cookie: - raise HTTPException(status_code=502, detail=result.message or "虎牙短信登录失败") + raise HTTPException( + status_code=502, detail=result.message or "虎牙短信登录失败" + ) try: account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint="") @@ -701,7 +784,10 @@ def create_auto_register_batch( _require_huya_perm(current, "huya:import") sms_lines = parse_sms_lines(req.text) if not sms_lines: - raise HTTPException(status_code=400, detail="没有识别到有效手机号,格式为:手机号----短信查询URL 或 虎牙号----密码----手机号----短信查询URL") + raise HTTPException( + status_code=400, + detail="没有识别到有效手机号,格式为:手机号----短信查询URL 或 虎牙号----密码----手机号----短信查询URL", + ) proxy_config = db.query(ProxyConfig).first() if req.use_proxy else None runner = huya_register_registry.create( @@ -763,10 +849,12 @@ def stop_auto_register_batch( return {"message": "已发送停止信号", "success": True} -@router.post("/register/batches/{batch_id}/retry", response_model=HuyaAutoRegisterBatchOut) +@router.post( + "/register/batches/{batch_id}/retry", response_model=HuyaAutoRegisterBatchOut +) def retry_auto_register_batch( batch_id: str, - req: HuyaAutoRegisterRetryRequest = HuyaAutoRegisterRetryRequest(), + req: HuyaAutoRegisterRetryRequest = HuyaAutoRegisterRetryRequest.model_construct(), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): @@ -777,7 +865,9 @@ def retry_auto_register_batch( snapshot = huya_register_registry.get_snapshot(batch_id) if not snapshot: raise HTTPException(status_code=404, detail="批次不存在") - effective_proxy = snapshot.get("use_proxy", False) if use_proxy is None else bool(use_proxy) + effective_proxy = ( + snapshot.get("use_proxy", False) if use_proxy is None else bool(use_proxy) + ) proxy_config = db.query(ProxyConfig).first() if effective_proxy else None try: runner = huya_register_registry.retry( @@ -816,7 +906,9 @@ def export_auto_register_batch_success( return StreamingResponse( iter([content]), media_type="text/plain; charset=utf-8", - headers={"Content-Disposition": f"attachment; filename=huya-register-{batch_id}.txt"}, + headers={ + "Content-Disposition": f"attachment; filename=huya-register-{batch_id}.txt" + }, ) @@ -865,7 +957,9 @@ def password_login_selected_accounts( ): """对已导入的虎牙账号执行密码登录。""" _require_huya_perm(current, "huya:import") - selected_ids = _selected_huya_account_ids(db, current, req) + selected_ids = _selected_huya_account_ids( + db, current, cast(AccountBulkSelection, req) + ) if not selected_ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") @@ -884,25 +978,29 @@ def password_login_selected_accounts( account = account_map.get(account_id) if account is None: failed_count += 1 - results.append({ - "line": account_id, - "username": "", - "success": False, - "message": "账号不存在或无权登录", - }) + results.append( + { + "line": account_id, + "username": "", + "success": False, + "message": "账号不存在或无权登录", + } + ) continue username = (account.username or "").strip() password = (account.account_password or "").strip() if not username or not password: failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": "该账号未导入密码", - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": "该账号未导入密码", + "account": _account_out(account, include_cookie=include_cookie), + } + ) continue try: @@ -916,14 +1014,16 @@ def password_login_selected_accounts( account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": result.message or "虎牙密码登录失败", - "account": _account_out(account, include_cookie=include_cookie), - "sdid": result.sdid, - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": result.message or "虎牙密码登录失败", + "account": _account_out(account, include_cookie=include_cookie), + "sdid": result.sdid, + } + ) continue saved = save_huya_login_cookie_to_account( @@ -939,50 +1039,58 @@ def password_login_selected_accounts( saved.login_channel = "web" db.commit() success_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": True, - "message": "登录成功,Cookie 已保存", - "account": _account_out(saved, include_cookie=include_cookie), - "sdid": result.sdid, - }) + results.append( + { + "line": account.id, + "username": username, + "success": True, + "message": "登录成功,Cookie 已保存", + "account": _account_out(saved, include_cookie=include_cookie), + "sdid": result.sdid, + } + ) except HuyaCredentialError as exc: account.status = "login_failed" account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": str(exc), + "account": _account_out(account, include_cookie=include_cookie), + } + ) except (HuyaLoginError, ValueError) as exc: account.status = "login_failed" account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": str(exc), + "account": _account_out(account, include_cookie=include_cookie), + } + ) except Exception as exc: account.status = "login_failed" account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": f"虎牙密码登录失败: {exc}", - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": f"虎牙密码登录失败: {exc}", + "account": _account_out(account, include_cookie=include_cookie), + } + ) return { "message": f"Web 登录完成:成功 {success_count} 条,失败 {failed_count} 条", @@ -1001,7 +1109,9 @@ def app_password_login_selected_accounts( ): """对已导入的虎牙账号使用 App 协议批量执行密码登录(推荐)。""" _require_huya_perm(current, "huya:import") - selected_ids = _selected_huya_account_ids(db, current, req) + selected_ids = _selected_huya_account_ids( + db, current, cast(AccountBulkSelection, req) + ) if not selected_ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") @@ -1020,25 +1130,29 @@ def app_password_login_selected_accounts( account = account_map.get(account_id) if account is None: failed_count += 1 - results.append({ - "line": account_id, - "username": "", - "success": False, - "message": "账号不存在或无权登录", - }) + results.append( + { + "line": account_id, + "username": "", + "success": False, + "message": "账号不存在或无权登录", + } + ) continue username = (account.username or "").strip() password = (account.account_password or "").strip() if not username or not password: failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": "该账号未导入密码", - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": "该账号未导入密码", + "account": _account_out(account, include_cookie=include_cookie), + } + ) continue try: @@ -1052,14 +1166,16 @@ def app_password_login_selected_accounts( account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": result.message or "虎牙 App 密码登录失败", - "account": _account_out(account, include_cookie=include_cookie), - "sdid": result.sdid, - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": result.message or "虎牙 App 密码登录失败", + "account": _account_out(account, include_cookie=include_cookie), + "sdid": result.sdid, + } + ) continue saved = save_huya_login_cookie_to_account( @@ -1073,62 +1189,72 @@ def app_password_login_selected_accounts( saved.login_channel = "app" db.commit() success_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": True, - "message": "App 登录成功,Cookie 已保存", - "account": _account_out(saved, include_cookie=include_cookie), - "sdid": result.sdid, - }) + results.append( + { + "line": account.id, + "username": username, + "success": True, + "message": "App 登录成功,Cookie 已保存", + "account": _account_out(saved, include_cookie=include_cookie), + "sdid": result.sdid, + } + ) except HuyaAppQrAuthRequiredError as exc: account.status = "login_failed" account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": str(exc), + "account": _account_out(account, include_cookie=include_cookie), + } + ) except HuyaCredentialError as exc: account.status = "login_failed" account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": str(exc), + "account": _account_out(account, include_cookie=include_cookie), + } + ) except (HuyaAppLoginError, HuyaLoginError, ValueError) as exc: account.status = "login_failed" account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": str(exc), + "account": _account_out(account, include_cookie=include_cookie), + } + ) except Exception as exc: account.status = "login_failed" account.updated_at = datetime.now(timezone.utc) db.commit() failed_count += 1 - results.append({ - "line": account.id, - "username": username, - "success": False, - "message": f"虎牙 App 密码登录失败: {exc}", - "account": _account_out(account, include_cookie=include_cookie), - }) + results.append( + { + "line": account.id, + "username": username, + "success": False, + "message": f"虎牙 App 密码登录失败: {exc}", + "account": _account_out(account, include_cookie=include_cookie), + } + ) return { "message": f"App 登录完成:成功 {success_count} 条,失败 {failed_count} 条", @@ -1153,11 +1279,21 @@ def delete_accounts_batch( ids = _selected_huya_account_ids(db, current, AccountBulkSelection(account_ids=ids)) if not ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") - db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(synchronize_session=False) + db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( + synchronize_session=False + ) _clear_huya_account_references(db, ids) - deleted = db.query(HuyaAccount).filter(HuyaAccount.id.in_(ids)).delete(synchronize_session=False) + deleted = ( + db.query(HuyaAccount) + .filter(HuyaAccount.id.in_(ids)) + .delete(synchronize_session=False) + ) db.commit() - return {"message": f"已删除 {deleted} 个虎牙账号", "deleted": deleted, "success": True} + return { + "message": f"已删除 {deleted} 个虎牙账号", + "deleted": deleted, + "success": True, + } @router.post("/accounts/batch-delete") @@ -1171,12 +1307,22 @@ def delete_accounts_batch_selection( ids = _selected_huya_account_ids(db, current, req) if not ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") - db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(synchronize_session=False) + db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( + synchronize_session=False + ) _clear_huya_account_references(db, ids) - deleted = db.query(HuyaAccount).filter(HuyaAccount.id.in_(ids)).delete(synchronize_session=False) + deleted = ( + db.query(HuyaAccount) + .filter(HuyaAccount.id.in_(ids)) + .delete(synchronize_session=False) + ) db.commit() scope = "当前筛选下" if req.all_matching else "选中的" - return {"message": f"已删除{scope} {deleted} 个虎牙账号", "deleted": deleted, "success": True} + return { + "message": f"已删除{scope} {deleted} 个虎牙账号", + "deleted": deleted, + "success": True, + } @router.delete("/accounts/{account_id}") @@ -1190,7 +1336,9 @@ def delete_account( account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") - db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete(synchronize_session=False) + db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete( + synchronize_session=False + ) _clear_huya_account_references(db, [account_id]) db.delete(account) db.commit() @@ -1237,13 +1385,21 @@ def batch_assign_accounts( if target.role != "support": raise HTTPException(status_code=400, detail="只能分配给客服角色") - count = db.query(HuyaAccount).filter(HuyaAccount.id.in_(req.account_ids)).update( - {HuyaAccount.assigned_to: req.assigned_to}, - synchronize_session=False, + count = ( + db.query(HuyaAccount) + .filter(HuyaAccount.id.in_(req.account_ids)) + .update( + {HuyaAccount.assigned_to: req.assigned_to}, + synchronize_session=False, + ) ) db.commit() 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("/accounts/assignments/summary") @@ -1284,7 +1440,11 @@ def set_account_tag( ): """设置单个虎牙账号标签。""" _require_huya_tag_permission(current) - account = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id == account_id).first() + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) if not account: raise HTTPException(status_code=404, detail="账号不存在") account.tag = (req.tag or "").strip() @@ -1303,12 +1463,20 @@ def batch_tag( if not req.account_ids: raise HTTPException(status_code=400, detail="请选择账号") tag = (req.tag or "").strip() - count = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(req.account_ids)).update( - {HuyaAccount.tag: tag}, - synchronize_session=False, + count = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id.in_(req.account_ids)) + .update( + {HuyaAccount.tag: tag}, + synchronize_session=False, + ) ) db.commit() - return {"message": f"已为 {count} 个虎牙账号设置标签", "success": True, "count": count} + return { + "message": f"已为 {count} 个虎牙账号设置标签", + "success": True, + "count": count, + } @router.put("/accounts/batch-tag-selection") @@ -1323,13 +1491,21 @@ def batch_tag_selection( if not ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") tag = (req.tag_value or "").strip() - count = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).update( - {HuyaAccount.tag: tag}, - synchronize_session=False, + count = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id.in_(ids)) + .update( + {HuyaAccount.tag: tag}, + synchronize_session=False, + ) ) db.commit() 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("/accounts/tags/list") @@ -1344,7 +1520,11 @@ def list_tags( tag_query = tag_query.filter(HuyaAccount.assigned_to == current.id) else: raise HTTPException(status_code=403, detail="无权查看虎牙账号") - tags = tag_query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)).distinct().all() + tags = ( + tag_query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) + .distinct() + .all() + ) return [item[0] for item in tags if item[0]] @@ -1363,13 +1543,15 @@ def list_cookies( search_text = (search or "").strip() if search_text: pattern = f"%{search_text}%" - query = query.outerjoin(User, HuyaAccount.assigned_to == User.id).filter(or_( - HuyaAccount.uid.ilike(pattern), - HuyaAccount.yyuid.ilike(pattern), - HuyaAccount.username.ilike(pattern), - HuyaAccount.nickname.ilike(pattern), - User.username.ilike(pattern), - )) + query = query.outerjoin(User, HuyaAccount.assigned_to == User.id).filter( + or_( + HuyaAccount.uid.ilike(pattern), + HuyaAccount.yyuid.ilike(pattern), + HuyaAccount.username.ilike(pattern), + HuyaAccount.nickname.ilike(pattern), + User.username.ilike(pattern), + ) + ) total = None if page is not None: total = query.order_by(None).count() @@ -1377,7 +1559,9 @@ def list_cookies( if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) if not should_include_cookie: - query = query.options(defer(HuyaAccount.cookie), defer(HuyaAccount.account_password)) + query = query.options( + defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) + ) accounts = query.all() result = ( [_cookie_out(account, include_cookie=True) for account in accounts] @@ -1385,7 +1569,12 @@ def list_cookies( else [_cookie_out_light(account) for account in accounts] ) 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 @@ -1413,7 +1602,12 @@ def export_cookies( ): """导出虎牙 Cookie,支持 csv 和 custom 格式。""" _require_huya_perm(current, "huya:cookie:export") - accounts = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "").order_by(HuyaAccount.updated_at.desc()).all() + accounts = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.cookie != "") + .order_by(HuyaAccount.updated_at.desc()) + .all() + ) if format == "custom": lines = [] for account in accounts: @@ -1428,13 +1622,15 @@ def export_cookies( writer.writerow(["账号", "UID", "YYUID", "Cookie", "时间"]) for account in accounts: username = account.nickname or account.username or account.uid or "" - writer.writerow([ - username, - account.uid or "", - account.yyuid or "", - normalize_huya_cookie(account.cookie or ""), - account.updated_at.isoformat() if account.updated_at else "", - ]) + writer.writerow( + [ + username, + account.uid or "", + account.yyuid or "", + normalize_huya_cookie(account.cookie or ""), + account.updated_at.isoformat() if account.updated_at else "", + ] + ) content = output.getvalue() filename = "huya_cookies.csv" media = "text/csv" @@ -1452,7 +1648,11 @@ def get_cookie( current: User = Depends(get_current_user), ): """获取单条虎牙 Cookie 详情,供复制操作按需读取完整敏感字段。""" - account = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id == account_id, HuyaAccount.cookie != "").first() + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id, HuyaAccount.cookie != "") + .first() + ) if not account: raise HTTPException(status_code=404, detail="记录不存在") return _cookie_out(account, include_cookie=_can_view_huya_cookie(current)) @@ -1469,12 +1669,18 @@ def delete_cookies_batch( ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] if not ids: raise HTTPException(status_code=400, detail="无效的账号ID") - accounts = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).all() + accounts = ( + _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).all() + ) for account in accounts: account.cookie = "" account.status = "invalid" db.commit() - return {"message": f"已清除 {len(accounts)} 条虎牙 Cookie", "deleted": len(accounts), "success": True} + return { + "message": f"已清除 {len(accounts)} 条虎牙 Cookie", + "deleted": len(accounts), + "success": True, + } @router.delete("/cookies/{account_id}") @@ -1485,7 +1691,11 @@ def delete_cookie( ): """清除单个虎牙 Cookie,保留账号记录。""" _require_huya_perm(current, "huya:cookie:export") - account = _visible_huya_accounts_query(db, current).filter(HuyaAccount.id == account_id).first() + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) if not account: raise HTTPException(status_code=404, detail="记录不存在") account.cookie = "" @@ -1522,7 +1732,10 @@ def update_config( config.updated_at = datetime.now(timezone.utc) if changed_fields: record_audit( - db, current, action="recharge:huya:config", target="huya_config", + db, + current, + action="recharge:huya:config", + target="huya_config", detail={"changed_fields": changed_fields}, ) db.commit() @@ -1546,7 +1759,11 @@ def list_recharge_goods( current: User = Depends(require_permission("huya:task")), ): """查看已缓存的虎牙充值商品快照。""" - rows = db.query(HuyaRechargeGoodsSnapshot).order_by(HuyaRechargeGoodsSnapshot.id.asc()).all() + rows = ( + db.query(HuyaRechargeGoodsSnapshot) + .order_by(HuyaRechargeGoodsSnapshot.id.asc()) + .all() + ) return rows @@ -1585,9 +1802,14 @@ async def create_task_batch( if req.task_type == "create_recharge_order": record_audit( - db, current, action="recharge:huya:create", target=f"huya_batch:{batch_id}", + db, + current, + action="recharge:huya:create", + target=f"huya_batch:{batch_id}", detail={ - "batch_id": batch_id, "task_type": req.task_type, "count": count, + "batch_id": batch_id, + "task_type": req.task_type, + "count": count, "account_count": len(req.account_ids), }, ) @@ -1616,7 +1838,9 @@ async def create_task_batch( @router.get("/tasks", response_model=list[HuyaTaskOut]) def list_tasks( batch_id: str | None = None, - include_images: bool = Query(False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)"), + include_images: bool = Query( + False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)" + ), db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): @@ -1640,7 +1864,11 @@ def cleanup_orphan_tasks( statuses=("pending", "running"), message="任务已中断(无执行器接管)", ) - return {"message": f"已清理 {cleaned} 个残留任务", "cleaned": cleaned, "success": True} + return { + "message": f"已清理 {cleaned} 个残留任务", + "cleaned": cleaned, + "success": True, + } @router.get("/tasks/summary") @@ -1675,7 +1903,9 @@ def stop_batch( _require_huya_batch_owner(db, current, batch_id) is_recharge_batch = ( db.query(HuyaTask.id) - .filter(HuyaTask.batch_id == batch_id, HuyaTask.task_type == "create_recharge_order") + .filter( + HuyaTask.batch_id == batch_id, HuyaTask.task_type == "create_recharge_order" + ) .first() is not None ) @@ -1689,12 +1919,18 @@ def stop_batch( message="批次已结束", ) if cleaned: - return {"message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True} + return { + "message": f"批次已结束,已清理 {cleaned} 个残留任务", + "success": True, + } raise HTTPException(status_code=404, detail="批次已结束") batch["runner"].stop() if is_recharge_batch: record_audit( - db, current, action="recharge:huya:stop", target=f"huya_batch:{batch_id}", + db, + current, + action="recharge:huya:stop", + target=f"huya_batch:{batch_id}", detail={"batch_id": batch_id, "mode": "running"}, ) db.commit() @@ -1708,8 +1944,15 @@ def stop_batch( if cleaned: if is_recharge_batch: record_audit( - db, current, action="recharge:huya:stop", target=f"huya_batch:{batch_id}", - detail={"batch_id": batch_id, "mode": "orphan_cleanup", "cleaned": cleaned}, + db, + current, + action="recharge:huya:stop", + target=f"huya_batch:{batch_id}", + detail={ + "batch_id": batch_id, + "mode": "orphan_cleanup", + "cleaned": cleaned, + }, ) db.commit() return {"message": f"已清理 {cleaned} 个残留任务", "success": True} @@ -1779,7 +2022,9 @@ def _mask_hex(value: str | None, keep: int = 12) -> str: @router.get("/device-bindings") -def list_device_bindings(db: Session = Depends(get_db), current: User = Depends(get_current_user)): +def list_device_bindings( + db: Session = Depends(get_db), current: User = Depends(get_current_user) +): """列出所有账号 ↔ 设备环境绑定 (画像 + 指纹状态 + 账号表三方合并)。""" if not any(user_has_permission(current, p) for p in _DEVICE_BINDINGS_PERMS_VIEW): raise HTTPException(status_code=403, detail="无虎牙账号查看权限") @@ -1790,27 +2035,33 @@ def list_device_bindings(db: Session = Depends(get_db), current: User = Depends( acct = accounts.get(account) state_dir = _fp_state_dir(account) last_login = prof.get("last_login") or {} - rows.append({ - "account": account, - "vendor": prof.get("vendor", ""), - "model": prof.get("model", ""), - "screen": prof.get("screen", ""), - "fingerprint_masked": _mask_hex(prof.get("fingerprint")), - "guid32_masked": _mask_hex(prof.get("guid32")), - "hebe_count": len(prof.get("hebe") or {}), - "hdid32": prof.get("hdid", ""), # t1.t0 app 级常量, 非敏感 - "has_hydevice_state": state_dir.exists(), - "login_channel": (acct.login_channel if acct else "") or "", - "account_status": (acct.status if acct else "") or "未入库", - "bound_at": prof.get("bound_at"), - "last_login_at": last_login.get("at"), - "last_login_ok": last_login.get("ok"), - }) + rows.append( + { + "account": account, + "vendor": prof.get("vendor", ""), + "model": prof.get("model", ""), + "screen": prof.get("screen", ""), + "fingerprint_masked": _mask_hex(prof.get("fingerprint")), + "guid32_masked": _mask_hex(prof.get("guid32")), + "hebe_count": len(prof.get("hebe") or {}), + "hdid32": prof.get("hdid", ""), # t1.t0 app 级常量, 非敏感 + "has_hydevice_state": state_dir.exists(), + "login_channel": (acct.login_channel if acct else "") or "", + "account_status": (acct.status if acct else "") or "未入库", + "bound_at": prof.get("bound_at"), + "last_login_at": last_login.get("at"), + "last_login_ok": last_login.get("ok"), + } + ) return {"items": rows, "total": len(rows)} @router.delete("/device-bindings/{account}") -def delete_device_binding(account: str, db: Session = Depends(get_db), current: User = Depends(get_current_user)): +def delete_device_binding( + account: str, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): """解绑: 删除该账号的设备画像与 hydevice 指纹状态, 下次登录自动生成全新环境。""" _require_huya_perm(current, "huya:import") profiles = _load_profile_db() @@ -1823,8 +2074,13 @@ def delete_device_binding(account: str, db: Session = Depends(get_db), current: if state_dir.exists(): shutil.rmtree(state_dir, ignore_errors=True) removed_state = True - return {"ok": True, "account": account, "profile_removed": True, "fp_state_removed": removed_state, - "message": "已解绑, 下次 App 协议登录将自动生成全新设备环境并重新注册"} + return { + "ok": True, + "account": account, + "profile_removed": True, + "fp_state_removed": removed_state, + "message": "已解绑, 下次 App 协议登录将自动生成全新设备环境并重新注册", + } def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]: diff --git a/web/backend/security.py b/web/backend/security.py index 428cea7..171b55b 100644 --- a/web/backend/security.py +++ b/web/backend/security.py @@ -17,6 +17,8 @@ if not SECRET_KEY: stacklevel=2, ) SECRET_KEY = secrets.token_urlsafe(32) +assert SECRET_KEY is not None +SECRET_KEY_TYPED: str = SECRET_KEY ALGORITHM = "HS256" 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 -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() expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours) 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]: try: - return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM]) except JWTError: return None diff --git a/web/backend/services/account_check_service.py b/web/backend/services/account_check_service.py index 9a0db2f..4f13b5f 100644 --- a/web/backend/services/account_check_service.py +++ b/web/backend/services/account_check_service.py @@ -10,9 +10,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from datetime import datetime, timezone from types import SimpleNamespace -from typing import Optional +from typing import Optional, cast from core.douyu import DouyuLogin, WgapiLoginAPI +from core.douyu.login import AccountLike from core.douyu.proxy_fetcher import ProxyFetcher from ..models import ProxyConfig as ProxyConfigModel @@ -118,13 +119,15 @@ def parse_account_check_lines(text: str) -> list[AccountCheckInput]: f"或 账号|密码|邮箱|邮箱密码" ) - accounts.append(AccountCheckInput( - line=line_no, - username=parts[0], - password=parts[1], - email=parts[2], - email_password=parts[3], - )) + accounts.append( + AccountCheckInput( + line=line_no, + username=parts[0], + password=parts[1], + email=parts[2], + email_password=parts[3], + ) + ) return accounts @@ -158,9 +161,15 @@ class AccountCheckRunner: wl_platform = "xiequ" wl_credentials = None 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) - 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 = { "uid": self.proxy_config.whitelist_uid, "ukey": self.proxy_config.whitelist_ukey, @@ -209,25 +218,38 @@ class AccountCheckRunner: def _run_one(self, index: int, account: AccountCheckInput): 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 proxy_dict, proxy_error = self._resolve_static_proxy() 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 - 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: result = DouyuLogin( - SimpleNamespace( - username=account.username, - password=account.password, - email=account.email, - email_password=account.email_password, - email_imap_server="", - email_imap_port=993, - email_imap_ssl=True, + cast( + AccountLike, + SimpleNamespace( + username=account.username, + password=account.password, + email=account.email, + email_password=account.email_password, + email_imap_server="", + email_imap_port=993, + email_imap_ssl=True, + ), ), proxy=proxy_dict, max_login_retries=self.batch.max_login_retries, @@ -237,15 +259,24 @@ class AccountCheckRunner: api_strategy=WgapiLoginAPI(), ).check_account() 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 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 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, "认证状态未知") else: status = "error" @@ -260,7 +291,9 @@ class AccountCheckRunner: status: sum(1 for item in self.batch.items if item.status == status) 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 return { "batch_id": self.batch.batch_id, @@ -296,7 +329,10 @@ class AccountCheckRunner: if item.status != status: continue 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}" lines.append(line) content = "\n".join(lines) @@ -309,7 +345,9 @@ class AccountCheckRunner: 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: if self._shared_proxy_fetcher: self._shared_proxy_fetcher.warmup_whitelist() @@ -318,14 +356,21 @@ class AccountCheckRunner: futures = [] for index, account in enumerate(self.accounts): 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 futures.append(executor.submit(self._run_one, index, account)) for future in as_completed(futures): future.result() 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 if self._stop.is_set(): @@ -370,7 +415,9 @@ class AccountCheckRegistry: 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: self._runners[batch_id] = runner return runner diff --git a/web/backend/services/cookie_check_service.py b/web/backend/services/cookie_check_service.py index 28c3e59..12457c8 100644 --- a/web/backend/services/cookie_check_service.py +++ b/web/backend/services/cookie_check_service.py @@ -42,9 +42,17 @@ def check_douyu_cookie(cookie: str) -> dict: ).json() if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"): 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: - 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: fish_msg = f"请求失败: {exc}" @@ -66,11 +74,16 @@ def check_douyu_cookie(cookie: str) -> dict: ).json() if isinstance(level_data, dict) and level_data.get("error") in (0, "0"): 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 level = info.get("lv") 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: level_msg = f"请求失败: {exc}" @@ -78,10 +91,12 @@ def check_douyu_cookie(cookie: str) -> dict: if valid: message = "有效" else: - message = ";".join([ - f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}", - f"等级接口: {'ok' if level_ok else (level_msg or '失败')}", - ]) + message = ";".join( + [ + f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}", + f"等级接口: {'ok' if level_ok else (level_msg or '失败')}", + ] + ) return { **base, "valid": valid, diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index c7d0731..4d58c9f 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -21,7 +21,13 @@ from .douyu_runner_xpd import XpdMixin class DouyuBatchRunner( - DouyuBatchRunnerCore, BindMixin, ManualMixin, GoldMixin, DonateMixin, GoodsMixin, XpdMixin, + DouyuBatchRunnerCore, + BindMixin, + ManualMixin, + GoldMixin, + DonateMixin, + GoodsMixin, + XpdMixin, ): """批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。""" @@ -43,13 +49,18 @@ class DouyuBatchRunner( self._started += 1 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) cookie = login_task.cookie if login_task else "" if not 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 + assert login_task is not None cookie_check = check_douyu_cookie(cookie) login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid" @@ -62,7 +73,9 @@ class DouyuBatchRunner( if not cookie_check["valid"]: message = f"Cookie 已失效,请重新登录:{cookie_check['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 update_account_profile_from_cookie(account, cookie) @@ -109,7 +122,9 @@ class DouyuBatchRunner( return 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: if "task" in locals() and task: self._mark_task(worker_db, task, "failed", str(exc)) @@ -128,7 +143,9 @@ class DouyuBatchRunner( config = self._config_info(self.db) tasks = ( 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()) .all() ) @@ -150,7 +167,9 @@ class DouyuBatchRunner( for task in tasks: if self._stop.is_set(): 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): try: future.result() diff --git a/web/backend/services/douyu_runner_core.py b/web/backend/services/douyu_runner_core.py index f9562c1..5360c0f 100644 --- a/web/backend/services/douyu_runner_core.py +++ b/web/backend/services/douyu_runner_core.py @@ -21,7 +21,12 @@ from ..models import ( DouyuXpdGoodsSnapshot, 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: from .douyu_runner import DouyuBatchRunner @@ -78,7 +83,9 @@ class DouyuBatchRunnerCore: self._proxy_fetcher = self._create_proxy_fetcher() self._static_proxies = self._resolve_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: logger.info("[douyu] 写操作任务将按任务从代理 API 取新代理") @@ -89,7 +96,11 @@ class DouyuBatchRunnerCore: return None wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ" 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} return ProxyFetcher( api_url=cfg.api_url, @@ -267,8 +278,7 @@ class DouyuBatchRunnerCore: """同步和平小店商品快照,移除上一次热门抢购等遗留商品。""" now = datetime.now(timezone.utc) commodity_ids = { - str(raw.get("commodity_id") or raw.get("iGoodsId") or "") - for raw in goods + str(raw.get("commodity_id") or raw.get("iGoodsId") or "") for raw in goods } commodity_ids.discard("") query = db.query(DouyuXpdGoodsSnapshot) @@ -304,11 +314,15 @@ class DouyuBatchRunnerCore: def _config_info(self, db: Session) -> dict: 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: 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} def _client(self, cookie: str) -> DouyuActivityClient: @@ -338,8 +352,13 @@ class DouyuBatchRegistry: def __init__(self): self._batches: dict[str, dict] = {} - def register(self, batch_id: str, log_queue: asyncio.Queue, - loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner): + def register( + self, + batch_id: str, + log_queue: asyncio.Queue, + loop: asyncio.AbstractEventLoop, + runner: DouyuBatchRunner, + ): self._batches[batch_id] = { "log_queue": log_queue, "loop": loop, @@ -368,4 +387,3 @@ class DouyuBatchRegistry: douyu_batch_registry = DouyuBatchRegistry() - diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 9d207f9..5e8711b 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -5,6 +5,7 @@ from __future__ import annotations import uuid from datetime import datetime, timezone +from sqlalchemy import select from sqlalchemy.orm import Session 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 -SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码", +SUPPORTED_DOUYU_TASK_TYPES = { + "get_bind_qr": "获取绑定二维码", "confirm_bind": "确认绑定", "create_elite_qr": "开通精英宝典30", "prepare_esports_bind": "绑定电竞手册角色", @@ -53,26 +55,55 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码", DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"} DOUYU_HANDBOOK_TASK_TYPES = { "elite": { - "get_bind_qr", "confirm_bind", "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", + "get_bind_qr", + "confirm_bind", + "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", }, "esports": { - "prepare_esports_bind", "get_esports_bind_qr", "query_esports_game_name", "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", + "prepare_esports_bind", + "get_esports_bind_qr", + "query_esports_game_name", + "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": { - "get_xpd_bind_qr", "query_xpd_bind_info", "confirm_xpd_bind", "query_xpd_role", - "refresh_xpd_goods", "query_xpd_balance", "query_xpd_fragments", - "query_xpd_purchase_records", "exchange_xpd_goods", + "get_xpd_bind_qr", + "query_xpd_bind_info", + "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", "bind_act_alias": "20260120QYOOB", "confirm_act_alias": "20260120QYOOB", @@ -117,12 +148,18 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool: """补齐斗鱼配置默认值,返回是否发生变更。""" changed = False 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]) changed = True continue 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] if getattr(config, field, None) != normalized: setattr(config, field, normalized) @@ -178,7 +215,11 @@ def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Acc """只保留存在成功 Cookie 的斗鱼账号。""" if not account_ids: 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 ( db.query(Account) .filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids)) @@ -217,23 +258,28 @@ def create_douyu_planned_tasks( raise ValueError("该任务不属于当前工作台") 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 时前端无法选账号创建任务。 accounts = accounts[:1] batch_id = uuid.uuid4().hex[:12] payload = payload or {} for account in accounts: - db.add(DouyuTask( - batch_id=batch_id, - account_id=account.id, - task_type=task_type, - handbook_scope=handbook_scope, - status="planned", - message="任务已创建,等待执行", - result={"payload": payload} if payload else None, - created_by=created_by, - )) + db.add( + DouyuTask( + batch_id=batch_id, + account_id=account.id, + task_type=task_type, + handbook_scope=handbook_scope, + status="planned", + message="任务已创建,等待执行", + result={"payload": payload} if payload else None, + created_by=created_by, + ) + ) db.commit() return batch_id, len(accounts) @@ -272,7 +318,17 @@ def slim_douyu_goods(goods: object) -> object: return { key: value 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]: if not isinstance(item, dict): continue - result.append({ - key: value - for key, value in item.items() - if key in {"commodityId", "commodity_id", "commodityName", "name"} - }) + result.append( + { + key: value + for key, value in item.items() + if key in {"commodityId", "commodity_id", "commodityName", "name"} + } + ) return result @@ -305,7 +363,9 @@ def strip_douyu_raw_snapshots(value: object) -> object: 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。""" if not isinstance(result, dict): return result @@ -346,7 +406,12 @@ def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_d if "limited_goods" in data: 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) if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}: data.pop("pay_url", None) diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index b540f77..f7f5b57 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -8,12 +8,13 @@ import uuid from types import SimpleNamespace from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone -from typing import Optional +from typing import Optional, cast from sqlalchemy.orm import Session from loguru import logger from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI +from core.douyu.login import AccountLike from core.douyu.proxy_fetcher import ProxyFetcher from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel 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 对象。""" if proxy_config is None: return None credentials = getattr(proxy_config, "whitelist_credentials", None) - return SimpleNamespace( - enabled=bool(getattr(proxy_config, "enabled", False)), - http=getattr(proxy_config, "http", "") or "", - https=getattr(proxy_config, "https", "") or "", - api_url=getattr(proxy_config, "api_url", "") or "", - whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)), - whitelist_platform=getattr(proxy_config, "whitelist_platform", None), - 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 "", + return cast( + ProxyConfigModel, + SimpleNamespace( + enabled=bool(getattr(proxy_config, "enabled", False)), + http=getattr(proxy_config, "http", "") or "", + https=getattr(proxy_config, "https", "") or "", + api_url=getattr(proxy_config, "api_url", "") or "", + whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)), + whitelist_platform=getattr(proxy_config, "whitelist_platform", None), + 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_credentials = None if proxy_config.whitelist_enabled: - wl_platform = getattr(proxy_config, 'whitelist_platform', None) or "xiequ" - wl_credentials = getattr(proxy_config, 'whitelist_credentials', None) + wl_platform = ( + 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_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( api_url=proxy_config.api_url, @@ -170,7 +187,11 @@ class LoginBatchRunner: def _push_log(self, level: str, message: str): # 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。 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}") if self.log_queue and self.loop: asyncio.run_coroutine_threadsafe( @@ -181,15 +202,15 @@ class LoginBatchRunner: def _resolve_static_proxy(self) -> tuple[Optional[dict], str]: """解析静态代理配置。""" 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: 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 内部管理 - return None, '' + return None, "" def _execute_one(self, task_id: int, acc_info: dict, total: int): """在独立线程中执行单个账号登录,使用独立的 DB 会话。""" @@ -216,8 +237,16 @@ class LoginBatchRunner: self._completed += 1 current = self._completed - action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录" - self._push_log("info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}") + action_name = ( + "检测" + if self.mode == "check" + else "重新登录" + if self.mode == "relogin" + else "登录" + ) + self._push_log( + "info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}" + ) try: # 代理配置也可能异常,必须由当前任务的失败处理收敛状态。 @@ -226,26 +255,39 @@ class LoginBatchRunner: 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": task.status = "relogin_failed" - task.message = "重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)" + task.message = ( + "重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)" + ) else: task.status = "error" task.message = "代理不可用: 未配置代理" task.finished_at = datetime.now(timezone.utc) worker_db.commit() - self._push_log("error", f"[{current}] {acc_info['username']} 代理不可用") + self._push_log( + "error", f"[{current}] {acc_info['username']} 代理不可用" + ) return - account = SimpleNamespace( - username=acc_info["username"], - password=acc_info["password"], - email=acc_info["email"], - email_password=acc_info["email_password"], - 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"], + account = cast( + AccountLike, + SimpleNamespace( + username=acc_info["username"], + password=acc_info["password"], + email=acc_info["email"], + email_password=acc_info["email_password"], + 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( @@ -257,22 +299,33 @@ class LoginBatchRunner: stop_event=self._stop, 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: - 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.cookie = "" task.message = result.message or CHECK_STATUS_MESSAGES[status] 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: task.status = "success" task.cookie = result.cookie task.message = result.message or "登录成功" if self.mode == "relogin": 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 = { "fish_ball": check_result["fish_ball"], "nickname": check_result["nickname"], @@ -282,32 +335,54 @@ class LoginBatchRunner: task.ck_checked_at = check_result["checked_at"] if check_result["valid"]: task.message = "重新登录成功,Cookie 有效" - self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效") + self._push_log( + "success", + f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效", + ) else: 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: - self._push_log("success", f"[{current}] {acc_info['username']} {task.message}") + self._push_log( + "success", + f"[{current}] {acc_info['username']} {task.message}", + ) else: if self.mode == "relogin": # 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失 task.status = "relogin_failed" - task.message = f"重新登录失败: {result.message}(旧 Cookie 已保留)" - self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}") + task.message = ( + f"重新登录失败: {result.message}(旧 Cookie 已保留)" + ) + self._push_log( + "error", + f"[{current}] {acc_info['username']} 重新登录失败: {result.message}", + ) else: task.status = "failed" 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: if self.mode == "relogin": task.status = "relogin_failed" 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: task.status = "error" 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) worker_db.commit() @@ -319,8 +394,17 @@ class LoginBatchRunner: """在线程中执行批量登录。""" batch_id = self.batch_id concurrency = self.concurrency - action_name = "账号检测" 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}") + action_name = ( + "账号检测" + 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 不再主动同步 if self._shared_proxy_fetcher: @@ -339,18 +423,22 @@ class LoginBatchRunner: task.message = "" task.finished_at = None self.db.flush() - task_infos.append({ - "task_id": task.id, - "acc_info": { - "username": acc.username, - "password": acc.password, - "email": acc.email, - "email_password": acc.email_password, - "email_imap_server": acc.email_imap_server or "", - "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, - }, - }) + task_infos.append( + { + "task_id": task.id, + "acc_info": { + "username": acc.username, + "password": acc.password, + "email": acc.email, + "email_password": acc.email_password, + "email_imap_server": acc.email_imap_server or "", + "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: # 创建或复用任务记录(顺序执行,线程安全) @@ -358,10 +446,16 @@ class LoginBatchRunner: if self.relogin_task_ids: # 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie 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: 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: self._push_log("warning", f"跳过无账号的任务 #{task_id}") continue @@ -393,7 +487,9 @@ class LoginBatchRunner: # 一个斗鱼账号只保留一条成功 CK:再次普通登录时更新最新成功记录。 latest_success_task = ( 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()) .first() ) @@ -414,7 +510,10 @@ class LoginBatchRunner: # 复用该账号最近一条失败任务记录,避免重复产生多条失败历史。 existing_task = ( 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()) .first() ) @@ -479,9 +578,14 @@ class BatchRegistry: def __init__(self): self._batches: dict[str, dict] = {} - def register(self, batch_id: str, log_queue: Optional[asyncio.Queue], - loop: Optional[asyncio.AbstractEventLoop], runner: LoginBatchRunner, - owner_id: Optional[int] = None): + def register( + self, + batch_id: str, + log_queue: Optional[asyncio.Queue], + loop: Optional[asyncio.AbstractEventLoop], + runner: LoginBatchRunner, + owner_id: Optional[int] = None, + ): self._batches[batch_id] = { "log_queue": log_queue, "loop": loop,