From fcc845fdb9c6257e4d10eb25c096c9ebf1578a09 Mon Sep 17 00:00:00 2001 From: zhoukailian Date: Sun, 22 Mar 2026 22:32:16 +0800 Subject: [PATCH] feat: auto disable failed proxies --- src/database/crud.py | 9 +- src/web/routes/settings.py | 153 ++++++++++++++------------ static/js/settings.js | 54 ++++++++- templates/settings.html | 3 +- tests/test_proxy_management_routes.py | 136 +++++++++++++++++++++++ 5 files changed, 279 insertions(+), 76 deletions(-) create mode 100644 tests/test_proxy_management_routes.py diff --git a/src/database/crud.py b/src/database/crud.py index 67d827e..c1beb91 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -472,6 +472,13 @@ def delete_proxy(db: Session, proxy_id: int) -> bool: return True +def delete_disabled_proxies(db: Session) -> int: + """删除所有已禁用代理""" + deleted = db.query(Proxy).filter(Proxy.enabled == False).delete(synchronize_session=False) + db.commit() + return deleted + + def update_proxy_last_used(db: Session, proxy_id: int) -> bool: """更新代理最后使用时间""" db_proxy = get_proxy_by_id(db, proxy_id) @@ -713,4 +720,4 @@ def delete_tm_service(db: Session, service_id: int) -> bool: return False db.delete(svc) db.commit() - return True \ No newline at end of file + return True diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index d096fa4..d89a5f2 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -469,6 +469,60 @@ class ProxyUpdateRequest(BaseModel): priority: Optional[int] = None +def _test_proxy_connectivity(proxy_url: str) -> dict: + """测试代理连通性并返回统一结果。""" + import time + from curl_cffi import requests as cffi_requests + + test_url = "https://api.ipify.org?format=json" + start_time = time.time() + + proxies_dict = { + "http": proxy_url, + "https": proxy_url + } + + response = cffi_requests.get( + test_url, + proxies=proxies_dict, + timeout=3, + impersonate="chrome110" + ) + + elapsed_time = time.time() - start_time + if response.status_code == 200: + ip_info = response.json() + return { + "success": True, + "ip": ip_info.get("ip", ""), + "response_time": round(elapsed_time * 1000), + "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}" + } + + return { + "success": False, + "message": f"状态码: {response.status_code}" + } + + +def _auto_disable_proxy_on_failure(db, proxy, message: str) -> dict: + """代理测试失败时自动禁用,并返回统一提示。""" + auto_disabled = False + if proxy.enabled: + crud.update_proxy(db, proxy.id, enabled=False) + auto_disabled = True + + final_message = message + if auto_disabled: + final_message = f"{message},已自动禁用" + + return { + "success": False, + "auto_disabled": auto_disabled, + "message": final_message, + } + + @router.get("/proxies") async def get_proxies_list(enabled: Optional[bool] = None): """获取代理列表""" @@ -559,107 +613,59 @@ async def set_proxy_default(proxy_id: int): @router.post("/proxies/{proxy_id}/test") async def test_proxy_item(proxy_id: int): """测试单个代理""" - import time - from curl_cffi import requests as cffi_requests - with get_db() as db: proxy = crud.get_proxy_by_id(db, proxy_id) if not proxy: raise HTTPException(status_code=404, detail="代理不存在") - proxy_url = proxy.proxy_url - test_url = "https://api.ipify.org?format=json" - start_time = time.time() - try: - proxies = { - "http": proxy_url, - "https": proxy_url - } - - response = cffi_requests.get( - test_url, - proxies=proxies, - timeout=3, - impersonate="chrome110" - ) - - elapsed_time = time.time() - start_time - - if response.status_code == 200: - ip_info = response.json() - return { - "success": True, - "ip": ip_info.get("ip", ""), - "response_time": round(elapsed_time * 1000), - "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}" - } - else: - return { - "success": False, - "message": f"代理返回错误状态码: {response.status_code}" - } - + result = _test_proxy_connectivity(proxy.proxy_url) + if result["success"]: + return result + return _auto_disable_proxy_on_failure(db, proxy, f"代理返回错误状态码: {result['message'].removeprefix('状态码: ')}") except Exception as e: - return { - "success": False, - "message": f"代理连接失败: {str(e)}" - } + return _auto_disable_proxy_on_failure(db, proxy, f"代理连接失败: {str(e)}") @router.post("/proxies/test-all") async def test_all_proxies(): """测试所有启用的代理""" - import time - from curl_cffi import requests as cffi_requests - with get_db() as db: proxies = crud.get_enabled_proxies(db) results = [] + auto_disabled_count = 0 for proxy in proxies: - proxy_url = proxy.proxy_url - test_url = "https://api.ipify.org?format=json" - start_time = time.time() - try: - proxies_dict = { - "http": proxy_url, - "https": proxy_url - } - - response = cffi_requests.get( - test_url, - proxies=proxies_dict, - timeout=3, - impersonate="chrome110" - ) - - elapsed_time = time.time() - start_time - - if response.status_code == 200: - ip_info = response.json() + result = _test_proxy_connectivity(proxy.proxy_url) + if result["success"]: results.append({ "id": proxy.id, "name": proxy.name, "success": True, - "ip": ip_info.get("ip", ""), - "response_time": round(elapsed_time * 1000) + "ip": result.get("ip", ""), + "response_time": result.get("response_time"), + "auto_disabled": False, }) else: + failure_result = _auto_disable_proxy_on_failure( + db, + proxy, + f"代理返回错误状态码: {result['message'].removeprefix('状态码: ')}" + ) + auto_disabled_count += 1 if failure_result["auto_disabled"] else 0 results.append({ "id": proxy.id, "name": proxy.name, - "success": False, - "message": f"状态码: {response.status_code}" + **failure_result, }) - except Exception as e: + failure_result = _auto_disable_proxy_on_failure(db, proxy, f"代理连接失败: {str(e)}") + auto_disabled_count += 1 if failure_result["auto_disabled"] else 0 results.append({ "id": proxy.id, "name": proxy.name, - "success": False, - "message": str(e) + **failure_result, }) success_count = sum(1 for r in results if r["success"]) @@ -667,6 +673,7 @@ async def test_all_proxies(): "total": len(proxies), "success": success_count, "failed": len(proxies) - success_count, + "auto_disabled": auto_disabled_count, "results": results } @@ -691,6 +698,14 @@ async def disable_proxy(proxy_id: int): return {"success": True, "message": "代理已禁用"} +@router.delete("/proxies/disabled/batch-delete") +async def delete_disabled_proxies(): + """批量删除所有已禁用代理""" + with get_db() as db: + deleted = crud.delete_disabled_proxies(db) + return {"success": True, "deleted": deleted, "message": f"已删除 {deleted} 个禁用代理"} + + # ============== Outlook 设置 ============== class OutlookSettings(BaseModel): diff --git a/static/js/settings.js b/static/js/settings.js index 7835098..65eeffe 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -31,6 +31,7 @@ const elements = { proxiesTable: document.getElementById('proxies-table'), addProxyBtn: document.getElementById('add-proxy-btn'), testAllProxiesBtn: document.getElementById('test-all-proxies-btn'), + deleteDisabledProxiesBtn: document.getElementById('delete-disabled-proxies-btn'), addProxyModal: document.getElementById('add-proxy-modal'), proxyItemForm: document.getElementById('proxy-item-form'), closeProxyModal: document.getElementById('close-proxy-modal'), @@ -206,6 +207,10 @@ function initEventListeners() { elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies); } + if (elements.deleteDisabledProxiesBtn) { + elements.deleteDisabledProxiesBtn.addEventListener('click', handleDeleteDisabledProxies); + } + if (elements.closeProxyModal) { elements.closeProxyModal.addEventListener('click', closeProxyModal); } @@ -767,11 +772,13 @@ async function loadProxies() { try { const data = await api.get('/settings/proxies'); renderProxies(data.proxies); + updateProxyBulkActions(data.proxies || []); } catch (error) { console.error('加载代理列表失败:', error); + updateProxyBulkActions([]); elements.proxiesTable.innerHTML = ` - +
加载失败
@@ -787,7 +794,7 @@ function renderProxies(proxies) { if (!proxies || proxies.length === 0) { elements.proxiesTable.innerHTML = ` - +
🌐
暂无代理
@@ -831,6 +838,17 @@ function renderProxies(proxies) { `).join(''); } +function updateProxyBulkActions(proxies) { + if (!elements.deleteDisabledProxiesBtn) return; + + const disabledCount = (proxies || []).filter(proxy => !proxy.enabled).length; + elements.deleteDisabledProxiesBtn.disabled = disabledCount === 0; + elements.deleteDisabledProxiesBtn.dataset.count = String(disabledCount); + elements.deleteDisabledProxiesBtn.textContent = disabledCount > 0 + ? `🧹 删除禁用项 (${disabledCount})` + : '🧹 删除禁用项'; +} + function toggleSettingsMoreMenu(btn) { const menu = btn.nextElementSibling; const isActive = menu.classList.contains('active'); @@ -926,7 +944,12 @@ async function testProxyItem(id) { if (result.success) { toast.success(result.message); } else { - toast.error(result.message); + if (result.auto_disabled) { + toast.warning(result.message); + await loadProxies(); + } else { + toast.error(result.message); + } } } catch (error) { toast.error('测试失败: ' + error.message); @@ -959,6 +982,22 @@ async function deleteProxyItem(id) { } } +async function handleDeleteDisabledProxies() { + const count = Number(elements.deleteDisabledProxiesBtn?.dataset.count || 0); + if (!count) return; + + const confirmed = await confirm(`确定要删除全部 ${count} 个已禁用代理吗?此操作不可恢复。`); + if (!confirmed) return; + + try { + const result = await api.delete('/settings/proxies/disabled/batch-delete'); + toast.success(result.message); + await loadProxies(); + } catch (error) { + toast.error('批量删除失败: ' + error.message); + } +} + // 测试所有代理 async function handleTestAllProxies() { elements.testAllProxiesBtn.disabled = true; @@ -966,8 +1005,13 @@ async function handleTestAllProxies() { try { const result = await api.post('/settings/proxies/test-all'); - toast.info(`测试完成: 成功 ${result.success}, 失败 ${result.failed}`); - loadProxies(); + const summary = `测试完成: 成功 ${result.success}, 失败 ${result.failed}`; + if (result.auto_disabled > 0) { + toast.warning(`${summary},已自动禁用 ${result.auto_disabled} 个`); + } else { + toast.info(summary); + } + await loadProxies(); } catch (error) { toast.error('测试失败: ' + error.message); } finally { diff --git a/templates/settings.html b/templates/settings.html index 7b18e55..8ea1fb8 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -95,6 +95,7 @@

代理列表

+
@@ -115,7 +116,7 @@ - +
🌐
暂无代理
diff --git a/tests/test_proxy_management_routes.py b/tests/test_proxy_management_routes.py new file mode 100644 index 0000000..912b369 --- /dev/null +++ b/tests/test_proxy_management_routes.py @@ -0,0 +1,136 @@ +import asyncio +import sys +from contextlib import contextmanager +from types import ModuleType + +from src.database.models import Base, Proxy +from src.database.session import DatabaseSessionManager +from src.web.routes import settings as settings_routes + + +class FakeResponse: + def __init__(self, status_code=200, payload=None): + self.status_code = status_code + self._payload = payload or {} + + def json(self): + return self._payload + + +class FakeRequests: + def __init__(self, outcomes): + self._outcomes = iter(outcomes) + + def get(self, *args, **kwargs): + outcome = next(self._outcomes) + if isinstance(outcome, Exception): + raise outcome + return outcome + + +def make_fake_get_db(manager: DatabaseSessionManager): + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + return fake_get_db + + +def install_fake_curl_cffi(monkeypatch, outcomes): + fake_module = ModuleType("curl_cffi") + fake_module.requests = FakeRequests(outcomes) + monkeypatch.setitem(sys.modules, "curl_cffi", fake_module) + + +def create_proxy(manager: DatabaseSessionManager, name: str, enabled: bool = True) -> int: + with manager.session_scope() as session: + proxy = Proxy( + name=name, + type="http", + host="127.0.0.1", + port=8000 + len(name), + enabled=enabled, + ) + session.add(proxy) + session.flush() + proxy_id = proxy.id + return proxy_id + + +def get_proxy_state(manager: DatabaseSessionManager, proxy_id: int): + with manager.session_scope() as session: + proxy = session.get(Proxy, proxy_id) + return { + "exists": proxy is not None, + "enabled": proxy.enabled if proxy else None, + } + + +def test_test_proxy_item_disables_failed_proxy(tmp_path, monkeypatch): + db_path = tmp_path / "proxy_routes_single.db" + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + + proxy_id = create_proxy(manager, "单个失败代理") + monkeypatch.setattr(settings_routes, "get_db", make_fake_get_db(manager)) + install_fake_curl_cffi(monkeypatch, [RuntimeError("connect timeout")]) + + result = asyncio.run(settings_routes.test_proxy_item(proxy_id)) + + assert result["success"] is False + assert result["auto_disabled"] is True + assert "已自动禁用" in result["message"] + assert get_proxy_state(manager, proxy_id) == {"exists": True, "enabled": False} + + +def test_test_all_proxies_disables_failed_entries(tmp_path, monkeypatch): + db_path = tmp_path / "proxy_routes_batch.db" + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + + ok_proxy_id = create_proxy(manager, "可用代理") + failed_proxy_id = create_proxy(manager, "失败代理") + monkeypatch.setattr(settings_routes, "get_db", make_fake_get_db(manager)) + install_fake_curl_cffi(monkeypatch, [ + FakeResponse(status_code=200, payload={"ip": "1.1.1.1"}), + RuntimeError("network unreachable"), + ]) + + result = asyncio.run(settings_routes.test_all_proxies()) + + assert result["total"] == 2 + assert result["success"] == 1 + assert result["failed"] == 1 + assert result["auto_disabled"] == 1 + + failed_result = next(item for item in result["results"] if item["id"] == failed_proxy_id) + ok_result = next(item for item in result["results"] if item["id"] == ok_proxy_id) + + assert ok_result["success"] is True + assert failed_result["success"] is False + assert failed_result["auto_disabled"] is True + assert "已自动禁用" in failed_result["message"] + assert get_proxy_state(manager, ok_proxy_id) == {"exists": True, "enabled": True} + assert get_proxy_state(manager, failed_proxy_id) == {"exists": True, "enabled": False} + + +def test_delete_disabled_proxies_only_removes_disabled_entries(tmp_path, monkeypatch): + db_path = tmp_path / "proxy_routes_cleanup.db" + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + + enabled_proxy_id = create_proxy(manager, "启用代理", enabled=True) + disabled_proxy_id = create_proxy(manager, "禁用代理", enabled=False) + monkeypatch.setattr(settings_routes, "get_db", make_fake_get_db(manager)) + + result = asyncio.run(settings_routes.delete_disabled_proxies()) + + assert result["success"] is True + assert result["deleted"] == 1 + assert "已删除 1 个禁用代理" in result["message"] + assert get_proxy_state(manager, enabled_proxy_id) == {"exists": True, "enabled": True} + assert get_proxy_state(manager, disabled_proxy_id) == {"exists": False, "enabled": None}