From b08f0a647d410ce6f1ce2e814b00e81c598c7562 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 22 Jun 2026 13:59:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=90=86=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/douyu/proxy.py | 182 ++++++++--- core/douyu/whitelist.py | 46 ++- data/web.db | Bin 36864 -> 36864 bytes .../routers/__pycache__/login.cpython-312.pyc | Bin 6854 -> 6856 bytes .../routers/__pycache__/proxy.cpython-312.pyc | Bin 6784 -> 13168 bytes web/backend/routers/login.py | 2 +- web/backend/routers/proxy.py | 297 +++++++++++++----- .../__pycache__/login_service.cpython-312.pyc | Bin 6949 -> 8424 bytes web/backend/services/login_service.py | 68 +++- web/frontend/src/pages/ProxyPage.tsx | 179 ++++++++--- 10 files changed, 577 insertions(+), 197 deletions(-) diff --git a/core/douyu/proxy.py b/core/douyu/proxy.py index 7ec518e..1d31110 100644 --- a/core/douyu/proxy.py +++ b/core/douyu/proxy.py @@ -1,16 +1,17 @@ """代理管理模块""" import re +import time import requests -from typing import Optional, List +from typing import Optional from loguru import logger class ProxyManager: """代理管理器""" - def __init__(self, api_url: str = None): - self.api_url = api_url or "http://api.xiequ.cn/VAD/GetIp.aspx?act=get&uid=106015&vkey=97111DB5379E38E3BC2FF09A1B00A0C7&num=1&time=30&plat=1&re=0&type=0&so=1&ow=1&spl=1&addr=&db=1" + def __init__(self, api_url: str = ""): + self.api_url = api_url self.current_proxy: Optional[str] = None def get_proxy(self) -> Optional[str]: @@ -37,46 +38,26 @@ class ProxyManager: self.current_proxy = proxy logger.info(f"获取到代理: {proxy}") return proxy - else: - logger.warning(f"无法解析代理地址: {text}") - return None + + logger.warning(f"无法解析代理地址: {text}") + return None except Exception as e: logger.error(f"获取代理失败: {e}") return None def get_proxies_dict(self, proxy: str = None) -> dict: - """ - 获取requests使用的proxies字典 - - Args: - proxy: 代理URL,如果不提供则使用当前代理 - - Returns: - proxies字典 - """ + """获取requests使用的proxies字典""" proxy = proxy or self.current_proxy if proxy: - return { - 'http': proxy, - 'https': proxy, - } + return {'http': proxy, 'https': proxy} return {} def verify_proxy(self, proxy: str = None) -> bool: - """ - 验证代理是否可用 - - Args: - proxy: 代理URL - - Returns: - 是否可用 - """ + """验证代理是否可用""" proxy = proxy or self.current_proxy if not proxy: return False - try: response = requests.get( 'https://httpbin.org/ip', @@ -93,23 +74,138 @@ class ProxyManager: return False -# 全局代理管理器实例 -_proxy_manager: Optional[ProxyManager] = None +def get_proxy_manager(api_url: str = "") -> ProxyManager: + """获取代理管理器实例(每次传入 api_url 时创建新实例,避免全局状态污染)""" + return ProxyManager(api_url) -def get_proxy_manager(api_url: str = None) -> ProxyManager: - """获取全局代理管理器实例""" - global _proxy_manager - if _proxy_manager is None: - _proxy_manager = ProxyManager(api_url) - return _proxy_manager +def parse_proxy_response(text: str) -> tuple[Optional[str], Optional[str]]: + """ + 解析代理API响应。 + + Returns: + (proxy_url, whitelist_ip) + - proxy_url: 解析到的代理地址(http://ip:port),无法解析时为 None + - whitelist_ip: 需要添加到白名单的IP(当API返回白名单错误时),无错误时为 None + """ + text = text.strip() + + # 正常代理地址 + match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text) + if match: + ip, port = match.group(1), match.group(2) + # 排除 "请先添加白名单:1.2.3.4" 中误匹配到 ip:port 的情况 + if '白名单' not in text: + return f"http://{ip}:{port}", None + + # 白名单错误:DB1.请先添加白名单:39.144.114.76 + if '添加白名单' in text or '白名单' in text: + ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text) + if ip_match: + return None, ip_match.group(1) + + return None, None -def get_proxy() -> Optional[str]: - """获取代理URL的便捷函数""" - return get_proxy_manager().get_proxy() +def verify_proxy_url(proxy_url: str, timeout: tuple = (4, 6)) -> tuple[bool, str]: + """ + 验证代理是否可用。 + + Returns: + (是否可用, 消息) + """ + proxies = {'http': proxy_url, 'https': proxy_url} + targets = [ + 'https://qifu-api.baidubce.com/ip/local/geo/v1/district', + 'https://myip.ipip.net', + 'https://4.ipw.cn', + ] + + for url in targets: + try: + response = requests.get( + url, proxies=proxies, timeout=timeout, + headers={'User-Agent': 'Mozilla/5.0'}, + ) + response.raise_for_status() + return True, f'代理可用: {url.split("/")[2]}' + except Exception as e: + err_msg = str(e) + if 'Tunnel connection failed' in err_msg or '503' in err_msg: + detail = '代理拒绝连接(白名单可能未生效)' + elif 'timed out' in err_msg.lower(): + detail = '连接超时' + else: + detail = type(e).__name__ + logger.debug(f"代理验证 {url} 失败: {detail}") + continue + + return False, '代理验证失败(所有目标不可达)' -def get_proxies_dict() -> dict: - """获取proxies字典的便捷函数""" - return get_proxy_manager().get_proxies_dict() +def resolve_working_proxy( + api_url: str, + whitelist_uid: str = "", + whitelist_ukey: str = "", + max_attempts: int = 5, + log_func=None, +) -> tuple[Optional[str], str]: + """ + 从代理API获取可用代理,自动处理白名单同步。 + + Args: + api_url: 代理API地址 + whitelist_uid: 白名单UID(启用白名单时传入) + whitelist_ukey: 白名单UKEY + max_attempts: 最大尝试次数 + log_func: 日志回调函数 (level, message) + + Returns: + (代理URL, 消息) + """ + def log(level, msg): + if log_func: + log_func(level, msg) + else: + getattr(logger, level if level in ('info', 'warning', 'error', 'success') else 'info', logger.info)(msg) + + synced_whitelist = False + + for attempt in range(1, max_attempts + 1): + log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理') + try: + response = requests.get(api_url, timeout=10) + response.raise_for_status() + text = response.text.strip() + + proxy_url, whitelist_ip = parse_proxy_response(text) + + if proxy_url: + # 验证代理 + ok, msg = verify_proxy_url(proxy_url) + if ok: + log('success', f'代理预检成功: {proxy_url}') + return proxy_url, msg + log('warning', f'代理预检 {attempt}/{max_attempts}: {msg}') + continue + + # 代理API返回白名单错误 + if whitelist_ip and not synced_whitelist and whitelist_uid and whitelist_ukey: + log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...') + from core.douyu.whitelist import WhitelistManager + manager = WhitelistManager(whitelist_uid, whitelist_ukey) + ok, sync_msg = manager.sync_ip(whitelist_ip) + log('success' if ok else 'error', f'白名单同步: {sync_msg}') + if ok: + synced_whitelist = True + log('info', '白名单已更新,等待2秒后重试...') + time.sleep(2) + continue + return None, f'白名单同步失败: {sync_msg}' + + log('warning', f'代理预检 {attempt}/{max_attempts}: 代理API响应无法解析: {text[:80]}') + + except Exception as exc: + log('warning', f'代理预检 {attempt}/{max_attempts}: 代理API请求失败: {exc}') + + return None, f'代理预检失败,已尝试 {max_attempts} 次' diff --git a/core/douyu/whitelist.py b/core/douyu/whitelist.py index 07cdb53..3865605 100644 --- a/core/douyu/whitelist.py +++ b/core/douyu/whitelist.py @@ -2,6 +2,7 @@ import json import re +import time from typing import Optional from urllib.parse import urlencode @@ -65,12 +66,12 @@ class WhitelistManager: logger.error(f"获取白名单失败: {e}") return [] - def add_ip(self, ip: str) -> bool: + def add_ip(self, ip: str, retry: bool = True) -> tuple[bool, str]: """ 添加IP到白名单 - Args: - ip: 要添加的IP地址 + Returns: + (是否成功, API原始响应) """ try: url = self._build_url(act="add", ip=ip, meno=self._memo) @@ -80,22 +81,40 @@ class WhitelistManager: text = response.text.strip() logger.debug(f"添加白名单响应: {text}") - # 成功通常返回 "ok" 或类似信息 if "ok" in text.lower() or "success" in text.lower() or "添加成功" in text: logger.info(f"白名单添加成功: {ip} (备注: {self._memo})") - return True + return True, text - # 检查是否已存在 - if "已存在" in text or "exist" in text.lower(): + if "已存在" in text or "exist" in text.lower() or "IpRep" in text: logger.info(f"白名单已存在: {ip}") - return True + return True, text + + # 频率限制,等待后重试一次 + if retry and ("频率过快" in text or "稍后" in text): + wait = 5 + match = re.search(r'(\d+)\s*秒', text) + if match: + wait = int(match.group(1)) + logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...") + time.sleep(wait) + return self.add_ip(ip, retry=False) + + # UKEY 错误 + if "Err:Key" in text: + logger.error("白名单UKEY错误,请检查配置") + return False, "UKEY错误,请检查白名单配置" + + # 超出白名单数量限制 + if "Err:Max" in text or "超过" in text or "上限" in text: + logger.error(f"白名单数量超限: {text}") + return False, f"白名单数量超限: {text}" logger.warning(f"白名单添加结果: {text}") - return False + return False, text except Exception as e: logger.error(f"添加白名单失败: {e}") - return False + return False, str(e) def delete_ip(self, ip: str) -> bool: """ @@ -175,15 +194,18 @@ class WhitelistManager: if existing_ip: logger.info(f"白名单IP变化: {existing_ip} -> {current_ip}") self.delete_ip(existing_ip) + time.sleep(1) # IP已存在但备注不同(或备注为空),先删除后重新添加 records = self.get_whitelist_json() if any(r.get('IP') == current_ip for r in records): logger.info(f"白名单IP {current_ip} 已存在但备注不同,删除后重新添加") self.delete_ip(current_ip) + time.sleep(1) # 添加新IP - if self.add_ip(current_ip): + ok, resp = self.add_ip(current_ip) + if ok: if existing_ip: msg = f"白名单IP已更新: {existing_ip} -> {current_ip}" else: @@ -191,7 +213,7 @@ class WhitelistManager: logger.info(msg) return True, msg - return False, "白名单添加失败" + return False, f"白名单添加失败,API响应: {resp}" except Exception as e: msg = f"白名单同步失败: {e}" diff --git a/data/web.db b/data/web.db index a8a724eda0613e9e82218fb599ece969599e1d1d..f29ea739c849e5d962d960273c0d60ab07136c1d 100644 GIT binary patch delta 1344 zcmbW1?`vCC7{_nYT9+<2H&e0bgau}YPlck*+(dSK+OVd#j%9P! z`Uh_&CXLjGXH7FTb!It^BWHHjk7}S-3s*a!+MIo>P?|jz#xF;}UyFL4Uya?RFpfLz zCPQ|o7Pv|YuIvl-u|6O`8=1RsEUN*BIKocPNCiGG+boIwMOW2J6iGM zb-iteF0|u*6uQD*YiVJr-ReZH#<#k9w-&AF)enNUYX=n13$+r@3janlG5?^sl;XY(1W>q?4XC zc(+W-OgKyeC=o`;%N#LMQ~A7E@PkIJ8MRxzzSD^UKMqIV?^RpP#oE$;=ghFfI3QR+ zOrbgWt^90;a)&V>Fh)e7^kBN2Umr>oa3UGP-qAzYQ0h<&I7fm@Z}Omk^7S zJ?2P4O8ZD}{BTUlfuI=ce>yf~o}t(oN|<4!yyRh+`lb@Vba(_G+$q~n4lk${AmNG* zqnog<7}k}oW4X&#Wg&6Ym^uFZQ_m$0vvB|N_dB25*2m*Q3aO|sgp^91j~nN!jTbAy z@)GFv-|u$22$P0iTLFJdV|O2l>R#40g^|sL@$5vx>Kj&HTekG0Fs>hkd}_++t|K-Yxl=*|M=yz?c1B% zKYX=&^UEB|?cDhKA$RFO1`yL%mHw|n9>YLtQ!FM&qj+Fg4|HEUR@NHJr~ci}Ukw(1 ALI3~& delta 401 zcmZozz|^pSX@WE(|3n#QM*fWn;_{}>?uAL|?j{jF9)?wpkr71|p6P+b{smcn6=msF z`bLrY?&fYTX`W#wrUjN!WkE*im7Za#lh?@WsTmnt8JOuAnVJ|F8yXoHnduss=^7a+ z7?@fi$(U?XU=v{Z%E141vtYt&epyaNP6iHMUS1GpU|?Y7`^Lb3j(-k+G`|Mlx6OhA zPxzP&SSMfbSK%`@w=yx%voJC=G&3+{ne5=J3KBB5)H5|UHL@_VXP(^SW6TG#%2LnJ z+``P**nkP7kq0VdW^QO;IQg8vnixpHLeJ8|(!|mfWGN&60tWsCn*|l7@h8MFax=(E z>&ogfG4MK;q!yQy7N-^!Bo-GN8X1GQ4yhH1xdl0?ddc~@xrv!MAgRpU!~#7eaps4Z d-UqwM#K>|}0*e3>g8H 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 literal 6784 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 IS@~4{2l%aMegFUf diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py index c909390..096829b 100644 --- a/web/backend/routers/login.py +++ b/web/backend/routers/login.py @@ -115,7 +115,7 @@ async def ws_login_logs(websocket: WebSocket, batch_id: str): await websocket.accept() log_queue = asyncio.Queue() - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # 查找已运行的批次,或等待新批次 # 简化:直接把 log_queue 注册到全局,前端创建批次后连 ws diff --git a/web/backend/routers/proxy.py b/web/backend/routers/proxy.py index 807a3c7..d306629 100644 --- a/web/backend/routers/proxy.py +++ b/web/backend/routers/proxy.py @@ -1,17 +1,22 @@ -"""代理 & 白名单配置路由""" +"""代理 & 白名单配置路由 + WebSocket 实时日志""" -import re -import time -from fastapi import APIRouter, Depends, HTTPException +import asyncio +import threading +import uuid +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from sqlalchemy.orm import Session from ..database import get_db from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog -from ..schemas import ProxyConfigOut, ProxyConfigUpdate, MessageResponse +from ..schemas import ProxyConfigOut, ProxyConfigUpdate from ..deps import require_permission +from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"]) +# 运行中的测试: test_id -> {log_queue, loop, result} +_active_tests: dict[str, dict] = {} + def _get_or_create(db: Session) -> ProxyConfigModel: cfg = db.query(ProxyConfigModel).first() @@ -54,93 +59,233 @@ def update_proxy_config( return cfg +# ---- WebSocket 日志推送 ---- + +@router.websocket("/ws/test/{test_id}") +async def ws_test_logs(websocket: WebSocket, test_id: str): + """WebSocket 推送代理/白名单测试实时日志。""" + await websocket.accept() + + test = _active_tests.get(test_id) + if not test: + await websocket.send_json({"level": "error", "message": "测试任务不存在"}) + await websocket.close() + return + + log_queue: asyncio.Queue = test["log_queue"] + + try: + while True: + try: + msg = await asyncio.wait_for(log_queue.get(), timeout=30) + await websocket.send_json(msg) + # 收到 result 消息表示测试结束 + if msg.get("level") == "result": + await asyncio.sleep(0.1) + break + except asyncio.TimeoutError: + await websocket.send_json({"level": "heartbeat", "message": ""}) + except WebSocketDisconnect: + pass + finally: + _active_tests.pop(test_id, None) + + +# ---- 异步测试执行 ---- + +def _run_proxy_test( + cfg: ProxyConfigModel, + log_queue: asyncio.Queue, + loop: asyncio.AbstractEventLoop, +): + """在线程中执行代理测试。""" + + def push(level, message): + asyncio.run_coroutine_threadsafe( + log_queue.put({"level": level, "message": message}), + loop, + ) + + try: + if not cfg.enabled: + push("error", "代理未启用") + push("result", "") + return + + # 静态代理 + if cfg.http or cfg.https: + proxy_url = cfg.http or cfg.https + push("info", f"验证静态代理: {proxy_url}") + ok, msg = verify_proxy_url(proxy_url) + push("success" if ok else "error", msg) + push("result", "") + return + + # API代理 + if cfg.api_url: + whitelist_uid = cfg.whitelist_uid if cfg.whitelist_enabled else "" + whitelist_ukey = cfg.whitelist_ukey if cfg.whitelist_enabled else "" + + proxy_url, msg = resolve_working_proxy( + api_url=cfg.api_url, + whitelist_uid=whitelist_uid, + whitelist_ukey=whitelist_ukey, + max_attempts=3, + log_func=push, + ) + if proxy_url: + push("success", f"代理可用: {proxy_url}") + else: + push("error", msg) + push("result", "") + return + + push("error", "未配置代理地址或API") + push("result", "") + except Exception as e: + push("error", f"测试异常: {e}") + push("result", "") + + +def _run_whitelist_test( + cfg: ProxyConfigModel, + log_queue: asyncio.Queue, + loop: asyncio.AbstractEventLoop, +): + """在线程中执行白名单测试。""" + import requests as req_lib + import re + from core.douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy + + def push(level, message): + asyncio.run_coroutine_threadsafe( + log_queue.put({"level": level, "message": message}), + loop, + ) + + try: + if not cfg.whitelist_enabled: + push("error", "白名单未启用") + push("result", "") + return + if not cfg.whitelist_uid or not cfg.whitelist_ukey: + push("error", "未配置白名单UID/UKEY") + push("result", "") + return + + manager = WhitelistManager(cfg.whitelist_uid, cfg.whitelist_ukey) + + # 1. 测试API连接 + push("info", "测试白名单API连接...") + ok, msg = manager.test_connection() + push("info" if ok else "error", f"白名单API: {msg}") + if not ok: + push("result", "") + return + + # 2. 获取本机公网IP(白名单需要的是本机IP,不是代理出口IP) + push("info", "正在获取本机公网IP...") + + # 优先:从代理API响应中提取(代理API返回"请先添加白名单:xxx"时,xxx就是本机IP) + local_ip = None + if cfg.api_url: + try: + resp = req_lib.get(cfg.api_url, timeout=10) + text = resp.text.strip() + push("info", f"代理API响应: {text[:80]}") + _, whitelist_ip = parse_proxy_response(text) + if whitelist_ip: + local_ip = whitelist_ip + push("info", f"从代理API获取到本机IP: {local_ip}") + except Exception as e: + push("warning", f"代理API请求失败: {e}") + + # 备用:直接访问IP检测服务获取本机公网IP + if not local_ip: + push("info", "通过IP检测服务获取本机公网IP...") + for url in [ + 'https://qifu-api.baidubce.com/ip/local/geo/v1/district', + 'https://myip.ipip.net', + 'https://4.ipw.cn', + ]: + try: + resp = req_lib.get(url, timeout=6, headers={"User-Agent": "Mozilla/5.0"}) + match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', resp.text) + if match: + local_ip = match.group(1) + break + except Exception: + continue + + if not local_ip: + push("error", "无法获取本机公网IP") + push("result", "") + return + + push("info", f"本机公网IP: {local_ip}") + + # 3. 检查并同步白名单 + records = manager.get_whitelist_json() + in_list = any(r.get('IP') == local_ip for r in records) + push("info", f"白名单共 {len(records)} 条记录") + + if in_list: + record = next((r for r in records if r.get('IP') == local_ip), {}) + memo = record.get('MEMO', '') + if memo == manager.memo: + push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)") + else: + push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...') + sync_ok, sync_msg = manager.sync_ip(local_ip) + push("success" if sync_ok else "error", f"白名单更新: {sync_msg}") + else: + push("info", f"正在将 {local_ip} 添加到白名单...") + sync_ok, sync_msg = manager.sync_ip(local_ip) + push("success" if sync_ok else "error", f"白名单同步: {sync_msg}") + + push("result", "") + except Exception as e: + push("error", f"测试异常: {e}") + push("result", "") + + +# ---- API 端点 ---- + @router.post("/test") -def test_proxy( +async def test_proxy( db: Session = Depends(get_db), current: User = Depends(require_permission("proxy:manage")), ): - """测试代理连通性。""" - import requests as req_lib + """启动代理测试(异步执行,通过 WebSocket 推送日志)。""" cfg = _get_or_create(db) - if not cfg.enabled: - return {"success": False, "message": "代理未启用"} - proxy_url = cfg.http or cfg.https - if cfg.api_url and not proxy_url: - try: - resp = req_lib.get(cfg.api_url, timeout=10) - match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text) - if match: - proxy_url = f"http://{match.group(1)}:{match.group(2)}" - except Exception as e: - return {"success": False, "message": f"代理API请求失败: {e}"} + test_id = uuid.uuid4().hex[:12] + log_queue = asyncio.Queue() + loop = asyncio.get_running_loop() - if not proxy_url: - return {"success": False, "message": "无可用代理地址"} + _active_tests[test_id] = {"log_queue": log_queue, "loop": loop} - try: - resp = req_lib.get( - "https://qifu-api.baidubce.com/ip/local/geo/v1/district", - proxies={"http": proxy_url, "https": proxy_url}, - timeout=(4, 6), - headers={"User-Agent": "Mozilla/5.0"}, - ) - resp.raise_for_status() - return {"success": True, "message": f"代理可用,响应: {resp.text[:100]}"} - except Exception as e: - return {"success": False, "message": f"代理验证失败: {e}"} + thread = threading.Thread(target=_run_proxy_test, args=(cfg, log_queue, loop), daemon=True) + thread.start() + + return {"test_id": test_id, "success": True} @router.post("/whitelist/test") -def test_whitelist( +async def test_whitelist( db: Session = Depends(get_db), current: User = Depends(require_permission("whitelist:test")), ): - """测试白名单连接并自动同步出口IP。""" - from core.douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy - import requests as req_lib - + """启动白名单测试(异步执行,通过 WebSocket 推送日志)。""" cfg = _get_or_create(db) - if not cfg.whitelist_enabled: - return {"success": False, "message": "白名单未启用"} - if not cfg.whitelist_uid or not cfg.whitelist_ukey: - return {"success": False, "message": "未配置白名单UID/UKEY"} - manager = WhitelistManager(cfg.whitelist_uid, cfg.whitelist_ukey) + test_id = uuid.uuid4().hex[:12] + log_queue = asyncio.Queue() + loop = asyncio.get_running_loop() - # 测试API连接 - ok, msg = manager.test_connection() - if not ok: - return {"success": False, "message": msg} + _active_tests[test_id] = {"log_queue": log_queue, "loop": loop} - # 获取出口IP - proxy_url = cfg.http or cfg.https - if cfg.api_url and not proxy_url: - try: - resp = req_lib.get(cfg.api_url, timeout=10) - match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', resp.text) - if match: - proxy_url = f"http://{match.group(1)}:{match.group(2)}" - except Exception: - pass + thread = threading.Thread(target=_run_whitelist_test, args=(cfg, log_queue, loop), daemon=True) + thread.start() - exit_ip = None - if proxy_url: - exit_ip = get_exit_ip_via_proxy(proxy_url) - if not exit_ip: - try: - resp = req_lib.get("https://4.ipw.cn", timeout=6, headers={"User-Agent": "Mozilla/5.0"}) - match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', resp.text) - if match: - exit_ip = match.group(1) - except Exception: - pass - - if not exit_ip: - return {"success": False, "message": "API连接正常但无法获取出口IP"} - - # 同步白名单 - sync_ok, sync_msg = manager.sync_ip(exit_ip) - if sync_ok: - return {"success": True, "message": f"出口IP {exit_ip} 已同步: {sync_msg}"} - return {"success": False, "message": f"同步失败: {sync_msg}"} + return {"test_id": test_id, "success": True} diff --git a/web/backend/services/__pycache__/login_service.cpython-312.pyc b/web/backend/services/__pycache__/login_service.cpython-312.pyc index 65207c987ee314c420af8f64b613ceaf56298c70..4432412f13e1a0dd26ba8feb5065e003df4ed63b 100644 GIT binary patch delta 3336 zcmaJDTW}NCb$7M9vZU3+l59z~Y_C8D39!N95!(=qi@`V#1xzwEnt(+8!km_=7=R=LF-n9 zrwf-w$^+#jK@shQz#Sw6{Ur*=K@b63igI}k$u5LXoVYf2Ii* zsRLOQ)jKIX@1%s%gLJ?wm=6*GON#PZuWqOAldzSZM}czq4&6G1Ui}OGDrv7lDzYjr zCcGrz5gsQI;DHNz*k*D*>|r087a|VuAmC*{8OF5G?xaO$uh13LMe{)@Ga9qM{U@<7A4&Ya|WpbvF2rwU%M!=LTq; zl6j!3gvVKnxAz4?{iK9`0`(Cks3h_fahlpmZG}f!TXk>AbM_DkJ`m`B3jI~sbeWPx zbEUuv#VH3L=*rX-I(H~1hz1zNUu8iEt1xv{a420PtT8^Nk6)Fej@#=C%+t;xbgYZf$N7K ze|c=|)EVBRWKvKF^(2(iE9*_J2r}2wYcOb<-Id^dU{DG$^kY9f`_Ws4O^x^R<7bZ^J7)uY04IQ6l za{RZ$GbX2k_Q#^)5`N)A@3fqk^2>ymlIh)vM4!xH7*AT!HaxoVC5mG9Cp7b5U>RER z(z3d{FBD9Iu&m#=JCqQ^p?D&g3<}SdRInGnz$-6?kza1{w_N5Eww0hexxlDQrIq0Zfa@sz`iSoI%UV@c|bWGz5$L zsA#-f^IB|IDEg!BM9=QdWHc&*`ndwwU%V3CwFAVYi7;7nMt;=DU!-%MrmUxFAm?cv z^|aB!pF-c)65 z>ocbHd5i5si#y$v;hYbQHh@dhoRXwN45cKUyt=gH6h)&OxezYXS8&SJWBb*T8kpf` zlU*;Vi?y62B={d~IVIt#3>r8q3wBCuT!0Ta+ZL^$lhnA(%o3;Ci0w-WLb1v8mk3&k z36>+z$`H7MJmT4kyadiOW%T;sq_K*tDAI=;XlT=~Y)k1AFBN$bb!n>1wh?J^dv3~; zX38J_sK%n~%-p*ge<-}iRoVoS9rDV#guowj1YP^igIKVdsv|@mB0oGY7BC28jEeh0B0n?X$W4$;57_zz5)vZGJ~2$C<^=-M;>E)LZYnMu)jy_ON3%k^t?&{ zuR$sQzzH{7D;?UI9$ah)r~}Ak5WZ`5Sq;dJ(g>yjdR2XHt!B5Oh4_`LqgD&)L6wqU zp5>Q+&9BUsR1Ip=9k+GXJZC!MKji;#)o{}-u1+b(@VheW_CGflS6<32*myH_b5F+I zd5hb6r;7vm38!na zgwU5BX+PAC{oLZ5dCm+!C{K~qkg;vCxbvPRInTPRXWjQ|y3(9hPikS-S7u{CAkRN9M!G! z&+TZ1`$Sik=(cU@aufLv JLYWlf{{>cUe1`x4 delta 2284 zcma)7O>7%Q6rQoYw%7kZiDSohVkc=DNBN;?6VfzIoT?>F)mSa4Rbv%glU*9S_S%|V zlcuss4?XnIqC_KvL|Q;9ARs+dL_%D+asi=kgjjd0sz_BOP(exn!GQ}in>vX~DlxXd zc{A^OZ{EzjdH#9q;{nIFcDn_THulY6w&-1S_~DJk-EH%_oFQr8Ag9kUc_zs~Kx9MC zm^UR&n#|aOi!ad>8N_2|19|aWJW8 z!N!Mk<8^r1d<7(3#MZO&j_x5eI0zkA4Xt>|FtR!Lf;kHPO+=&WMP7#;&r}9kYV=G)-|aY$*Ihp5ORB;)cYB}c+mQvbUC+29L-bnB3=gyH=niY{ zWc57%xRVV+Er@x}dP7$+3SG~k_UsVK^TOVNTQNy{Y97kEWqKQ=9R>^V)hYmIb)Xeg z%%lSqreYu=^lcEfW40fU85;3fPq1M#ksyG?g>Z|rmzh)$zwJ3?ca+VX^Aa&!0-Ou4 zc^aS_pY!=~#Os9n@eAH)!_aoG8*@HF>KijLm!H z8pw!dsn*IkYOHs0K5qLi{bYZEc;F^edtML-$GFC4)=AWSZ1p>M4*u6O6CzP2aJ)Wh zJyo;Ko+jH2>X}p*IrCxD0=#zL0JPwyNCnZAAvye}oBFMQ<}9s1I#Dxk18AaRVlX?x3d?Y)hD7S4i;`YA9AW6k&z-$&i_0#r+S zdb&^&q*R7ejiQv6N+NO*MIxqC4KtEdR2hmQ{=DHJRPmFBGXcHI6j9;goNCv)RJxc+ zl~4|O@TC8MNo9D13J72I_cwGSx(1>zSk>ov0XgZ=fq(FK&0DGHrW$Kn6f@@qo=Zsu zZEZx86VXzw>eC#FD_tz|0_mp<`Fut~UaB$iLi+U_&!N4Pyic2Y8i~sCLMhLqw8SH= z1{yPBij+Y_9~3fCI51Cfn8HB{BNRp{&{Rhr0xFX$oX-g85S7W&g$o&8E0rpHrqL#RCk<4bSN!N&%9g`1RnK}*?yuHzg{`t=!Nt3 zfvarQAG&IJay2V@gpQaBw|8Sg!(AoU11?gQ6- pU?txNAp8IXAA^?1Ao>u*AA&P7IP(D1b^L9dgYYQ)9cZ2A{9o|s>Z$+$ diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index 516da12..469ceec 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -1,8 +1,7 @@ -"""登录服务:复用 douyu/ 核心模块,在线程池中执行登录并推送日志。""" +"""登录服务:复用 core/ 核心模块,在线程池中执行登录并推送日志。""" import asyncio import threading -import time import uuid from datetime import datetime from typing import Optional @@ -11,6 +10,7 @@ from sqlalchemy.orm import Session from core.douyu import DouyuLogin from core.models import Account, ProxyConfig as DouyuProxyConfig +from core.douyu.proxy import resolve_working_proxy from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel from ..permissions import has_permission @@ -50,6 +50,42 @@ class LoginBatchRunner: self.loop, ) + def _resolve_proxy(self) -> tuple[Optional[dict], str]: + """ + 解析代理配置,返回 (proxy_dict, message)。 + + - 静态代理:直接返回 dict + - API代理:调用 resolve_working_proxy 预检,自动同步白名单 + - 无代理:返回 (None, '') + """ + if not self.proxy_config or not self.proxy_config.enabled: + 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}' + + # API代理:预检获取可用代理 + if self.proxy_config.api_url: + whitelist_uid = '' + whitelist_ukey = '' + if self.proxy_config.whitelist_enabled: + whitelist_uid = self.proxy_config.whitelist_uid or '' + whitelist_ukey = self.proxy_config.whitelist_ukey or '' + + proxy_url, msg = resolve_working_proxy( + api_url=self.proxy_config.api_url, + whitelist_uid=whitelist_uid, + whitelist_ukey=whitelist_ukey, + log_func=self._push_log, + ) + if proxy_url: + return {'http': proxy_url, 'https': proxy_url}, msg + return None, msg + + return None, '' + def run(self): """在线程中执行批量登录。""" batch_id = self.batch_id @@ -78,17 +114,20 @@ class LoginBatchRunner: self.db.commit() - # 构建代理配置 - proxy_url = None - proxy_api_url = None - if self.proxy_config and self.proxy_config.enabled: - if self.proxy_config.http or self.proxy_config.https: - proxy_url = { - 'http': self.proxy_config.http or self.proxy_config.https, - 'https': self.proxy_config.https or self.proxy_config.http, - } - elif self.proxy_config.api_url: - proxy_api_url = self.proxy_config.api_url + # 代理预检 + proxy_dict, proxy_msg = self._resolve_proxy() + if proxy_msg: + self._push_log("info", proxy_msg) + + # 如果启用了代理但预检失败,终止任务 + if self.proxy_config and self.proxy_config.enabled and not proxy_dict: + self._push_log("error", f"代理不可用,任务终止: {proxy_msg}") + for task, _ in tasks: + task.status = "error" + task.message = f"代理不可用: {proxy_msg}" + task.finished_at = datetime.utcnow() + self.db.commit() + return for i, (task, acc) in enumerate(tasks): if self._stop.is_set(): @@ -112,8 +151,7 @@ class LoginBatchRunner: loginer = DouyuLogin( account, - proxy=proxy_url, - proxy_api_url=proxy_api_url, + proxy=proxy_dict, max_geetest_retries=self.max_geetest_retries, ) result = loginer.login() diff --git a/web/frontend/src/pages/ProxyPage.tsx b/web/frontend/src/pages/ProxyPage.tsx index c7b08e3..87f30ba 100644 --- a/web/frontend/src/pages/ProxyPage.tsx +++ b/web/frontend/src/pages/ProxyPage.tsx @@ -1,26 +1,64 @@ -import { useEffect, useState } from 'react'; -import { Form, Input, Switch, Button, Card, message, Divider, Space } from 'antd'; +import { useEffect, useState, useRef } from 'react'; +import { Form, Input, Switch, Button, Card, message, Row, Col } from 'antd'; import { proxyApi } from '../api/modules'; +const WS_BASE = `ws://${window.location.hostname}:8000`; + export default function ProxyPage() { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [testing, setTesting] = useState(false); const [testingWl, setTestingWl] = useState(false); + const [configLoaded, setConfigLoaded] = useState(false); + const [logs, setLogs] = useState<{ level: string; message: string }[]>([]); + const wsRef = useRef(null); const loadConfig = async () => { try { const data = await proxyApi.get(); - form.setFieldsValue(data); + form.setFieldsValue({ + enabled: data.enabled ?? false, + api_url: data.api_url ?? '', + http: data.http ?? '', + https: data.https ?? '', + whitelist_enabled: data.whitelist_enabled ?? false, + whitelist_uid: data.whitelist_uid ?? '', + whitelist_ukey: data.whitelist_ukey ?? '', + }); } catch (e: any) { message.error(e.message); + } finally { + setConfigLoaded(true); } }; useEffect(() => { loadConfig(); + return () => { + wsRef.current?.close(); + }; }, []); + const appendLog = (level: string, msg: string) => { + setLogs((prev) => [...prev, { level, message: msg }]); + }; + + const connectWs = (testId: string) => { + wsRef.current?.close(); + setLogs([]); + const ws = new WebSocket(`${WS_BASE}/api/proxy/ws/test/${testId}`); + wsRef.current = ws; + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.level === 'heartbeat') return; + if (msg.level === 'result') return; + appendLog(msg.level, msg.message); + }; + ws.onclose = () => { + wsRef.current = null; + }; + }; + const handleSave = async () => { setLoading(true); try { @@ -36,74 +74,115 @@ export default function ProxyPage() { const handleTestProxy = async () => { setTesting(true); + setLogs([]); try { const result = await proxyApi.test(); - if (result.success) { - message.success(result.message); - } else { - message.warning(result.message); - } + if (result.test_id) connectWs(result.test_id); } catch (e: any) { message.error(e.message); - } finally { setTesting(false); } }; const handleTestWhitelist = async () => { setTestingWl(true); + setLogs([]); try { const result = await proxyApi.testWhitelist(); - if (result.success) { - message.success(result.message); - } else { - message.warning(result.message); - } + if (result.test_id) connectWs(result.test_id); } catch (e: any) { message.error(e.message); - } finally { setTestingWl(false); } }; + // WebSocket 关闭后恢复按钮 + useEffect(() => { + if (!wsRef.current) { + setTesting(false); + setTestingWl(false); + } + }, [logs.length === 0]); + + const logColors: Record = { + error: '#ff4d4f', + success: '#52c41a', + warning: '#faad14', + info: '#333', + }; + return ( -
-

代理配置

-
- - - - - - - - - - - - - - - +
+
+

代理配置

+ +
- - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {logs.length === 0 ? ( + 点击"测试代理"或"测试白名单"查看日志 + ) : ( + logs.map((log, i) => ( +
+ {log.message} +
+ )) + )} +
); }