mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-06 20:02:51 +08:00
feat: auto disable failed proxies
This commit is contained in:
@@ -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)
|
||||
@@ -714,8 +721,6 @@ def delete_tm_service(db: Session, service_id: int) -> bool:
|
||||
db.delete(svc)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def update_outlook_refresh_token(db: Session, service_id: int, email: str, new_refresh_token: str):
|
||||
"""更新 EmailService.config 中指定邮箱的 refresh_token"""
|
||||
service = db.query(EmailService).filter(EmailService.id == service_id).first()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -772,11 +777,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 = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<td colspan="8">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">❌</div>
|
||||
<div class="empty-state-title">加载失败</div>
|
||||
@@ -792,7 +799,7 @@ function renderProxies(proxies) {
|
||||
if (!proxies || proxies.length === 0) {
|
||||
elements.proxiesTable.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<td colspan="8">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🌐</div>
|
||||
<div class="empty-state-title">暂无代理</div>
|
||||
@@ -836,6 +843,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');
|
||||
@@ -930,9 +948,14 @@ async function testProxyItem(id) {
|
||||
const result = await api.post(`/settings/proxies/${id}/test`);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
if (result.auto_disabled) {
|
||||
toast.warning(result.message);
|
||||
await loadProxies();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('测试失败: ' + error.message);
|
||||
}
|
||||
@@ -964,6 +987,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;
|
||||
@@ -971,8 +1010,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 {
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<h3>代理列表</h3>
|
||||
<div style="display: flex; gap: var(--spacing-sm);">
|
||||
<button class="btn btn-secondary btn-sm" id="test-all-proxies-btn">🔌 测试全部</button>
|
||||
<button class="btn btn-danger btn-sm" id="delete-disabled-proxies-btn" disabled>🧹 删除禁用项</button>
|
||||
<button class="btn btn-primary btn-sm" id="add-proxy-btn">➕ 添加代理</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +116,7 @@
|
||||
</thead>
|
||||
<tbody id="proxies-table">
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<td colspan="8">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🌐</div>
|
||||
<div class="empty-state-title">暂无代理</div>
|
||||
|
||||
136
tests/test_proxy_management_routes.py
Normal file
136
tests/test_proxy_management_routes.py
Normal file
@@ -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}
|
||||
Reference in New Issue
Block a user