From 9f5892a987667bd1b9302e47d4a89038cd7f040e Mon Sep 17 00:00:00 2001 From: icesixgod Date: Thu, 24 Jul 2025 19:41:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加完整的代理检测系统,包括: 后端功能: - 新增代理检测服务模块 (app/service/proxy/) * ProxyCheckService: 核心检测逻辑,支持HTTP/HTTPS/SOCKS5代理 * 内置缓存机制,避免重复检测 (10秒缓存期) * 支持并发检测,可配置最大并发数 - 新增4个API端点 (app/router/config_routes.py): * POST /api/config/proxy/check - 单个代理检测 * POST /api/config/proxy/check-all - 批量代理检测 * GET /api/config/proxy/cache-stats - 缓存统计 * POST /api/config/proxy/clear-cache - 清空缓存 前端功能: - 代理列表中每项添加状态图标和检测按钮 - 支持单个代理实时检测 - 新增检测所有代理功能按钮 - 代理检测结果模态框,显示: * 检测进度条和状态 * 可用/不可用代理统计 * 详细的检测结果列表(响应时间、错误信息) * 重试失败代理功能 技术特性: - 10秒超时机制,避免长时间等待 - 异步并发检测,提升批量检测效率 - 缓存机制减少重复请求 - 完整的错误处理和用户友好提示 - 响应式UI设计,支持实时状态更新 --- app/router/config_routes.py | 91 +++++++ app/service/proxy/__init__.py | 7 + app/service/proxy/proxy_check_service.py | 219 ++++++++++++++++ app/static/js/config_editor.js | 311 +++++++++++++++++++++++ app/templates/config_editor.html | 85 +++++++ 5 files changed, 713 insertions(+) create mode 100644 app/service/proxy/__init__.py create mode 100644 app/service/proxy/proxy_check_service.py diff --git a/app/router/config_routes.py b/app/router/config_routes.py index ee4af76..5af7be2 100644 --- a/app/router/config_routes.py +++ b/app/router/config_routes.py @@ -11,6 +11,7 @@ from app.core.security import verify_auth_token from app.log.logger import Logger, get_config_routes_logger from app.service.config.config_service import ConfigService +from app.service.proxy.proxy_check_service import get_proxy_check_service, ProxyCheckResult from app.utils.helpers import redact_key_for_logging router = APIRouter(prefix="/api/config", tags=["config"]) @@ -132,3 +133,93 @@ async def get_ui_models(request: Request): status_code=500, detail=f"An unexpected error occurred while fetching UI models: {str(e)}", ) + + +class ProxyCheckRequest(BaseModel): + """Proxy check request""" + proxy: str = Field(..., description="Proxy address to check") + use_cache: bool = Field(True, description="Whether to use cached results") + + +class ProxyBatchCheckRequest(BaseModel): + """Batch proxy check request""" + proxies: List[str] = Field(..., description="List of proxy addresses to check") + use_cache: bool = Field(True, description="Whether to use cached results") + max_concurrent: int = Field(5, description="Maximum concurrent check count", ge=1, le=10) + + +@router.post("/proxy/check", response_model=ProxyCheckResult) +async def check_single_proxy(proxy_request: ProxyCheckRequest, request: Request): + """Check if a single proxy is available""" + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to proxy check") + return RedirectResponse(url="/", status_code=302) + + try: + logger.info(f"Checking single proxy: {proxy_request.proxy}") + proxy_service = get_proxy_check_service() + result = await proxy_service.check_single_proxy( + proxy_request.proxy, + proxy_request.use_cache + ) + return result + except Exception as e: + logger.error(f"Proxy check failed: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Proxy check failed: {str(e)}") + + +@router.post("/proxy/check-all", response_model=List[ProxyCheckResult]) +async def check_all_proxies(batch_request: ProxyBatchCheckRequest, request: Request): + """Check multiple proxies availability""" + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to batch proxy check") + return RedirectResponse(url="/", status_code=302) + + try: + logger.info(f"Batch checking {len(batch_request.proxies)} proxies") + proxy_service = get_proxy_check_service() + results = await proxy_service.check_multiple_proxies( + batch_request.proxies, + batch_request.use_cache, + batch_request.max_concurrent + ) + return results + except Exception as e: + logger.error(f"Batch proxy check failed: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Batch proxy check failed: {str(e)}") + + +@router.get("/proxy/cache-stats") +async def get_proxy_cache_stats(request: Request): + """Get proxy check cache statistics""" + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to proxy cache stats") + return RedirectResponse(url="/", status_code=302) + + try: + proxy_service = get_proxy_check_service() + stats = proxy_service.get_cache_stats() + return stats + except Exception as e: + logger.error(f"Get proxy cache stats failed: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Get cache stats failed: {str(e)}") + + +@router.post("/proxy/clear-cache") +async def clear_proxy_cache(request: Request): + """Clear proxy check cache""" + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to clear proxy cache") + return RedirectResponse(url="/", status_code=302) + + try: + proxy_service = get_proxy_check_service() + proxy_service.clear_cache() + return {"success": True, "message": "Proxy check cache cleared"} + except Exception as e: + logger.error(f"Clear proxy cache failed: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Clear cache failed: {str(e)}") diff --git a/app/service/proxy/__init__.py b/app/service/proxy/__init__.py new file mode 100644 index 0000000..079019c --- /dev/null +++ b/app/service/proxy/__init__.py @@ -0,0 +1,7 @@ +""" +Proxy service module +""" + +from .proxy_check_service import ProxyCheckService + +__all__ = ["ProxyCheckService"] \ No newline at end of file diff --git a/app/service/proxy/proxy_check_service.py b/app/service/proxy/proxy_check_service.py new file mode 100644 index 0000000..34fe1c3 --- /dev/null +++ b/app/service/proxy/proxy_check_service.py @@ -0,0 +1,219 @@ +""" +Proxy detection service module +""" +import asyncio +import time +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import httpx +from pydantic import BaseModel + +from app.log.logger import get_config_routes_logger + +logger = get_config_routes_logger() + + +class ProxyCheckResult(BaseModel): + """Proxy check result model""" + proxy: str + is_available: bool + response_time: Optional[float] = None + error_message: Optional[str] = None + checked_at: float + + +class ProxyCheckService: + """Proxy detection service class""" + + # Target URL for checking + CHECK_URL = "https://www.google.com" + # Timeout in seconds + TIMEOUT_SECONDS = 10 + # Cache duration in seconds + CACHE_DURATION = 10 # 10s + + def __init__(self): + self._cache: Dict[str, ProxyCheckResult] = {} + + def _is_valid_proxy_format(self, proxy: str) -> bool: + """Validate proxy format""" + try: + parsed = urlparse(proxy) + return parsed.scheme in ['http', 'https', 'socks5'] and parsed.hostname + except Exception: + return False + + def _get_cached_result(self, proxy: str) -> Optional[ProxyCheckResult]: + """Get cached check result""" + if proxy in self._cache: + result = self._cache[proxy] + # Check if cache is expired + if time.time() - result.checked_at < self.CACHE_DURATION: + logger.debug(f"Using cached proxy check result: {proxy}") + return result + else: + # Remove expired cache + del self._cache[proxy] + return None + + def _cache_result(self, result: ProxyCheckResult) -> None: + """Cache check result""" + self._cache[result.proxy] = result + + async def check_single_proxy(self, proxy: str, use_cache: bool = True) -> ProxyCheckResult: + """ + Check if a single proxy is available + + Args: + proxy: Proxy address in format like http://host:port or socks5://host:port + use_cache: Whether to use cached results + + Returns: + ProxyCheckResult: Check result + """ + # Check cache first + if use_cache: + cached = self._get_cached_result(proxy) + if cached: + return cached + + # Validate proxy format + if not self._is_valid_proxy_format(proxy): + result = ProxyCheckResult( + proxy=proxy, + is_available=False, + error_message="Invalid proxy format", + checked_at=time.time() + ) + self._cache_result(result) + return result + + # Perform check + start_time = time.time() + try: + logger.info(f"Starting proxy check: {proxy}") + + timeout = httpx.Timeout(self.TIMEOUT_SECONDS, read=self.TIMEOUT_SECONDS) + async with httpx.AsyncClient(timeout=timeout, proxy=proxy) as client: + response = await client.head(self.CHECK_URL) + + response_time = time.time() - start_time + + # Check response status + is_available = response.status_code in [200, 204, 301, 302, 307, 308] + + result = ProxyCheckResult( + proxy=proxy, + is_available=is_available, + response_time=round(response_time, 3), + error_message=None if is_available else f"HTTP {response.status_code}", + checked_at=time.time() + ) + + logger.info(f"Proxy check completed: {proxy}, available: {is_available}, response_time: {response_time:.3f}s") + + except asyncio.TimeoutError: + result = ProxyCheckResult( + proxy=proxy, + is_available=False, + error_message="Connection timeout", + checked_at=time.time() + ) + logger.warning(f"Proxy check timeout: {proxy}") + + except Exception as e: + result = ProxyCheckResult( + proxy=proxy, + is_available=False, + error_message=str(e), + checked_at=time.time() + ) + logger.error(f"Proxy check failed: {proxy}, error: {str(e)}") + + # Cache result + self._cache_result(result) + return result + + async def check_multiple_proxies( + self, + proxies: List[str], + use_cache: bool = True, + max_concurrent: int = 5 + ) -> List[ProxyCheckResult]: + """ + Check multiple proxies concurrently + + Args: + proxies: List of proxy addresses + use_cache: Whether to use cached results + max_concurrent: Maximum concurrent check count + + Returns: + List[ProxyCheckResult]: List of check results + """ + if not proxies: + return [] + + logger.info(f"Starting batch proxy check for {len(proxies)} proxies") + + # Use semaphore to limit concurrency + semaphore = asyncio.Semaphore(max_concurrent) + + async def check_with_semaphore(proxy: str) -> ProxyCheckResult: + async with semaphore: + return await self.check_single_proxy(proxy, use_cache) + + # Execute checks concurrently + tasks = [check_with_semaphore(proxy) for proxy in proxies] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Handle exception results + final_results = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error(f"Proxy check task exception: {proxies[i]}, error: {str(result)}") + final_results.append(ProxyCheckResult( + proxy=proxies[i], + is_available=False, + error_message=f"Check task exception: {str(result)}", + checked_at=time.time() + )) + else: + final_results.append(result) + + available_count = sum(1 for r in final_results if r.is_available) + logger.info(f"Batch proxy check completed: {available_count}/{len(proxies)} proxies available") + + return final_results + + def get_cache_stats(self) -> Dict[str, int]: + """Get cache statistics""" + current_time = time.time() + valid_cache_count = sum( + 1 for result in self._cache.values() + if current_time - result.checked_at < self.CACHE_DURATION + ) + + return { + "total_cached": len(self._cache), + "valid_cached": valid_cache_count, + "expired_cached": len(self._cache) - valid_cache_count + } + + def clear_cache(self) -> None: + """Clear all cache""" + self._cache.clear() + logger.info("Proxy check cache cleared") + + +# Global instance +_proxy_check_service: Optional[ProxyCheckService] = None + + +def get_proxy_check_service() -> ProxyCheckService: + """Get proxy check service instance""" + global _proxy_check_service + if _proxy_check_service is None: + _proxy_check_service = ProxyCheckService() + return _proxy_check_service \ No newline at end of file diff --git a/app/static/js/config_editor.js b/app/static/js/config_editor.js index 8cbeb60..938406f 100644 --- a/app/static/js/config_editor.js +++ b/app/static/js/config_editor.js @@ -184,6 +184,13 @@ document.addEventListener("DOMContentLoaded", function () { const closeProxyModalBtn = document.getElementById("closeProxyModalBtn"); const cancelAddProxyBtn = document.getElementById("cancelAddProxyBtn"); const confirmAddProxyBtn = document.getElementById("confirmAddProxyBtn"); + + // Proxy Check Elements and Events + const checkAllProxiesBtn = document.getElementById("checkAllProxiesBtn"); + const proxyCheckModal = document.getElementById("proxyCheckModal"); + const closeProxyCheckModalBtn = document.getElementById("closeProxyCheckModalBtn"); + const closeProxyCheckBtn = document.getElementById("closeProxyCheckBtn"); + const retryFailedProxiesBtn = document.getElementById("retryFailedProxiesBtn"); if (addProxyBtn) { addProxyBtn.addEventListener("click", () => { @@ -191,6 +198,25 @@ document.addEventListener("DOMContentLoaded", function () { if (proxyBulkInput) proxyBulkInput.value = ""; }); } + + if (checkAllProxiesBtn) { + checkAllProxiesBtn.addEventListener("click", checkAllProxies); + } + + if (closeProxyCheckModalBtn) { + closeProxyCheckModalBtn.addEventListener("click", () => closeModal(proxyCheckModal)); + } + + if (closeProxyCheckBtn) { + closeProxyCheckBtn.addEventListener("click", () => closeModal(proxyCheckModal)); + } + + if (retryFailedProxiesBtn) { + retryFailedProxiesBtn.addEventListener("click", () => { + // 重试失败的代理检测 + checkAllProxies(); + }); + } if (closeProxyModalBtn) closeProxyModalBtn.addEventListener("click", () => closeModal(proxyModal)); if (cancelAddProxyBtn) @@ -1455,6 +1481,45 @@ function createRemoveButton() { return removeBtn; } +/** + * Creates a proxy status icon for displaying proxy check status. + * @returns {HTMLSpanElement} The status icon element. + */ +function createProxyStatusIcon() { + const statusIcon = document.createElement("span"); + statusIcon.className = "proxy-status-icon px-2 py-2 text-gray-400"; + statusIcon.innerHTML = ''; + statusIcon.setAttribute("data-status", "unknown"); + return statusIcon; +} + +/** + * Creates a proxy check button for individual proxy checking. + * @returns {HTMLButtonElement} The check button element. + */ +function createProxyCheckButton() { + const checkBtn = document.createElement("button"); + checkBtn.type = "button"; + checkBtn.className = + "proxy-check-btn px-2 py-2 text-blue-500 hover:text-blue-700 focus:outline-none transition-colors duration-150 rounded-r-md"; + checkBtn.innerHTML = ''; + checkBtn.title = "检测此代理"; + + // 添加点击事件监听器 + checkBtn.addEventListener("click", function(e) { + e.preventDefault(); + e.stopPropagation(); + const inputElement = this.closest('.flex').querySelector('.array-input'); + if (inputElement && inputElement.value.trim()) { + checkSingleProxy(inputElement.value.trim(), this); + } else { + showNotification("请先输入代理地址", "warning"); + } + }); + + return checkBtn; +} + /** * Adds a new item to an array configuration section (e.g., API_KEYS, ALLOWED_TOKENS). * This function is typically called by a "+" button. @@ -1486,6 +1551,7 @@ function addArrayItemWithValue(key, value) { const isThinkingModel = key === "THINKING_MODELS"; const isAllowedToken = key === "ALLOWED_TOKENS"; const isVertexApiKey = key === "VERTEX_API_KEYS"; // 新增判断 + const isProxy = key === "PROXIES"; // 新增代理判断 const isSensitive = key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断 const modelId = isThinkingModel ? generateUUID() : null; @@ -1513,6 +1579,13 @@ function addArrayItemWithValue(key, value) { if (isAllowedToken) { const generateBtn = createGenerateTokenButton(); inputWrapper.appendChild(generateBtn); + } else if (isProxy) { + // 为代理添加状态显示和检测按钮 + const proxyStatusIcon = createProxyStatusIcon(); + inputWrapper.appendChild(proxyStatusIcon); + + const proxyCheckBtn = createProxyCheckButton(); + inputWrapper.appendChild(proxyCheckBtn); } else { // Ensure right-side rounding if no button is present input.classList.add("rounded-r-md"); @@ -2299,3 +2372,241 @@ function handleModelSelection(selectedModelId) { } // -- End Model Helper Functions -- + +// -- Proxy Check Functions -- + +/** + * 检测单个代理是否可用 + * @param {string} proxy - 代理地址 + * @param {HTMLElement} buttonElement - 触发检测的按钮元素 + */ +async function checkSingleProxy(proxy, buttonElement) { + const statusIcon = buttonElement.parentElement.querySelector('.proxy-status-icon'); + const originalButtonContent = buttonElement.innerHTML; + + try { + // 更新UI状态为检测中 + buttonElement.innerHTML = ''; + buttonElement.disabled = true; + if (statusIcon) { + statusIcon.className = "proxy-status-icon px-2 py-2 text-blue-500"; + statusIcon.innerHTML = ''; + statusIcon.setAttribute("data-status", "checking"); + } + + const response = await fetch('/api/config/proxy/check', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + proxy: proxy, + use_cache: true + }) + }); + + if (!response.ok) { + throw new Error(`检测请求失败: ${response.status}`); + } + + const result = await response.json(); + updateProxyStatus(statusIcon, result); + + // 显示检测结果通知 + if (result.is_available) { + showNotification(`代理可用 (${result.response_time}s)`, "success"); + } else { + showNotification(`代理不可用: ${result.error_message}`, "error"); + } + + } catch (error) { + console.error('代理检测失败:', error); + if (statusIcon) { + statusIcon.className = "proxy-status-icon px-2 py-2 text-red-500"; + statusIcon.innerHTML = ''; + statusIcon.setAttribute("data-status", "error"); + } + showNotification(`检测失败: ${error.message}`, "error"); + } finally { + // 恢复按钮状态 + buttonElement.innerHTML = originalButtonContent; + buttonElement.disabled = false; + } +} + +/** + * 更新代理状态图标 + * @param {HTMLElement} statusIcon - 状态图标元素 + * @param {Object} result - 检测结果 + */ +function updateProxyStatus(statusIcon, result) { + if (!statusIcon) return; + + if (result.is_available) { + statusIcon.className = "proxy-status-icon px-2 py-2 text-green-500"; + statusIcon.innerHTML = ``; + statusIcon.setAttribute("data-status", "available"); + } else { + statusIcon.className = "proxy-status-icon px-2 py-2 text-red-500"; + statusIcon.innerHTML = ``; + statusIcon.setAttribute("data-status", "unavailable"); + } +} + +/** + * 检测所有代理 + */ +async function checkAllProxies() { + const proxyContainer = document.getElementById("PROXIES_container"); + if (!proxyContainer) return; + + const proxyInputs = proxyContainer.querySelectorAll('.array-input'); + const proxies = Array.from(proxyInputs) + .map(input => input.value.trim()) + .filter(proxy => proxy.length > 0); + + if (proxies.length === 0) { + showNotification("没有代理需要检测", "warning"); + return; + } + + // 打开检测结果模态框 + const proxyCheckModal = document.getElementById("proxyCheckModal"); + if (proxyCheckModal) { + openModal(proxyCheckModal); + + // 显示进度 + const progressContainer = document.getElementById("proxyCheckProgress"); + const summaryContainer = document.getElementById("proxyCheckSummary"); + const resultsContainer = document.getElementById("proxyCheckResults"); + + if (progressContainer) progressContainer.classList.remove("hidden"); + if (summaryContainer) summaryContainer.classList.add("hidden"); + if (resultsContainer) resultsContainer.innerHTML = ""; + + // 更新总数 + const totalCountElement = document.getElementById("totalCount"); + if (totalCountElement) totalCountElement.textContent = proxies.length; + + try { + const response = await fetch('/api/config/proxy/check-all', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + proxies: proxies, + use_cache: true, + max_concurrent: 5 + }) + }); + + if (!response.ok) { + throw new Error(`批量检测请求失败: ${response.status}`); + } + + const results = await response.json(); + displayProxyCheckResults(results); + updateProxyStatusInList(results); + + } catch (error) { + console.error('批量代理检测失败:', error); + showNotification(`批量检测失败: ${error.message}`, "error"); + if (resultsContainer) { + resultsContainer.innerHTML = `
检测失败: ${error.message}
`; + } + } finally { + // 隐藏进度 + if (progressContainer) progressContainer.classList.add("hidden"); + } + } +} + +/** + * 显示代理检测结果 + * @param {Array} results - 检测结果数组 + */ +function displayProxyCheckResults(results) { + const summaryContainer = document.getElementById("proxyCheckSummary"); + const resultsContainer = document.getElementById("proxyCheckResults"); + const availableCountElement = document.getElementById("availableCount"); + const unavailableCountElement = document.getElementById("unavailableCount"); + const retryButton = document.getElementById("retryFailedProxiesBtn"); + + if (!resultsContainer) return; + + // 统计结果 + const availableCount = results.filter(r => r.is_available).length; + const unavailableCount = results.length - availableCount; + + // 更新概览 + if (availableCountElement) availableCountElement.textContent = availableCount; + if (unavailableCountElement) unavailableCountElement.textContent = unavailableCount; + if (summaryContainer) summaryContainer.classList.remove("hidden"); + + // 显示重试按钮(如果有失败的代理) + if (retryButton) { + if (unavailableCount > 0) { + retryButton.classList.remove("hidden"); + } else { + retryButton.classList.add("hidden"); + } + } + + // 清空并填充结果 + resultsContainer.innerHTML = ""; + + results.forEach(result => { + const resultItem = document.createElement("div"); + resultItem.className = `flex items-center justify-between p-3 border rounded-lg ${ + result.is_available ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50' + }`; + + const statusIcon = result.is_available ? + '' : + ''; + + const responseTimeText = result.response_time ? + ` (${result.response_time}s)` : ''; + + const errorText = result.error_message ? + `${result.error_message}` : ''; + + resultItem.innerHTML = ` +
+ ${statusIcon} + ${result.proxy} + ${responseTimeText} +
+
+ + ${result.is_available ? '可用' : '不可用'} + + ${errorText} +
+ `; + + resultsContainer.appendChild(resultItem); + }); +} + +/** + * 根据检测结果更新代理列表中的状态图标 + * @param {Array} results - 检测结果数组 + */ +function updateProxyStatusInList(results) { + const proxyContainer = document.getElementById("PROXIES_container"); + if (!proxyContainer) return; + + results.forEach(result => { + const proxyInputs = proxyContainer.querySelectorAll('.array-input'); + proxyInputs.forEach(input => { + if (input.value.trim() === result.proxy) { + const statusIcon = input.parentElement.querySelector('.proxy-status-icon'); + updateProxyStatus(statusIcon, result); + } + }); + }); +} + +// -- End Proxy Check Functions -- diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index c2d2210..113a216 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -1232,6 +1232,13 @@

> 删除代理 + + + + + + + + + + +
+ +
+ +
+ + +
+ + + +