diff --git a/src/core/login.py b/src/core/login.py index af8473f..4d5bc11 100644 --- a/src/core/login.py +++ b/src/core/login.py @@ -345,13 +345,16 @@ class LoginEngine(RegistrationEngine): return result self._log("10. 等待验证码...") - code = self._get_verification_code() + code, otp_phase = self._await_verification_code_with_resends( + self._send_verification_code, + timeout_retry_log_template="10. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template="10. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + ) if not code: - self._log("10. 验证码超时,重新发送...") - if self._send_verification_code(): - code = self._get_verification_code() - if not code: - result.error_message = "获取验证码失败" + result.error_message = ( + otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败" + ) + result.error_code = otp_phase.error_code if otp_phase else "" return result self._log("11. 验证验证码...") @@ -380,13 +383,16 @@ class LoginEngine(RegistrationEngine): return result self._log("15. 等待验证码...") - code = self._get_verification_code() + code, otp_phase = self._await_verification_code_with_resends( + self._send_verification_code_passwordless, + timeout_retry_log_template="15. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template="15. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + ) if not code: - self._log("15. 验证码超时,重新发送...") - if self._send_verification_code_passwordless(): - code = self._get_verification_code() - if not code: - result.error_message = "获取验证码失败" + result.error_message = ( + otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败" + ) + result.error_code = otp_phase.error_code if otp_phase else "" return result self._log("16. 验证验证码...") diff --git a/src/core/codex_auth.py b/src/core/openai/codex_auth.py similarity index 97% rename from src/core/codex_auth.py rename to src/core/openai/codex_auth.py index 276ee3b..b3e9368 100644 --- a/src/core/codex_auth.py +++ b/src/core/openai/codex_auth.py @@ -7,15 +7,15 @@ import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional -from .openai.oauth import OAuthManager -from .register import PhaseContext, RegistrationEngine -from ..config.constants import ( +from core.openai.oauth import OAuthManager +from core.register import PhaseContext, RegistrationEngine +from config.constants import ( CODEX_OAUTH_ORIGINATOR, CODEX_OAUTH_REDIRECT_URI, CODEX_OAUTH_SCOPE, ) -from ..config.settings import get_settings -from ..services.base import BaseEmailService +from config.settings import get_settings +from services.base import BaseEmailService @dataclass diff --git a/src/core/register.py b/src/core/register.py index 62b2f76..cd8b27e 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -4,28 +4,20 @@ """ import base64 +import json +import logging import math import re -import json -import time -import logging import secrets -from typing import Optional, Dict, Any, Tuple, Callable +import time from dataclasses import dataclass, field from datetime import datetime +from typing import Optional, Dict, Any, Tuple, Callable from curl_cffi import requests as cffi_requests +from .http_client import OpenAIHTTPClient from .openai.oauth import OAuthManager, OAuthStart -from .http_client import OpenAIHTTPClient, HTTPClientError -from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType -from ..services.base import ( - EmailProviderBackoffState, - OTP_NO_OPENAI_SENDER_ERROR, - OTPNoOpenAISenderEmailServiceError, -) -from ..database import crud -from ..database.session import get_db from ..config.constants import ( OPENAI_API_ENDPOINTS, OPENAI_PAGE_TYPES, @@ -33,11 +25,16 @@ from ..config.constants import ( OTP_CODE_PATTERN, DEFAULT_PASSWORD_LENGTH, PASSWORD_CHARSET, - AccountStatus, - TaskStatus, ) from ..config.settings import get_settings - +from ..database import crud +from ..database.session import get_db +from ..services import BaseEmailService +from ..services.base import ( + EmailProviderBackoffState, + OTP_NO_OPENAI_SENDER_ERROR, + OTPNoOpenAISenderEmailServiceError, +) logger = logging.getLogger(__name__) @@ -639,6 +636,75 @@ class RegistrationEngine: ) return code + def _await_verification_code_with_resends( + self, + resend_callback: Callable[[], bool], + *, + timeout_retry_log_template: str = "收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template: str = "检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + timeout_retry_status_template: Optional[str] = None, + non_openai_retry_status_template: Optional[str] = None, + step_index: Optional[int] = None, + ) -> Tuple[Optional[str], Optional[PhaseResult]]: + """等待验证码,并在可重试场景下按独立预算触发重发。""" + settings = get_settings() + timeout_resend_max = settings.email_code_resend_max_retries + non_openai_sender_resend_max = settings.email_code_non_openai_sender_resend_max_retries + timeout_resend_used = 0 + non_openai_sender_resend_used = 0 + otp_phase_started_at = time.time() + otp_phase: Optional[PhaseResult] = None + + while True: + code, otp_phase = self._phase_otp_secondary( + PhaseContext(otp_sent_at=self._otp_sent_at), + started_at=otp_phase_started_at, + ) + if code: + return code, otp_phase + + if otp_phase and not otp_phase.retryable: + return None, otp_phase + + retry_error_code = otp_phase.error_code if otp_phase else "" + retry_reason = ( + "non_openai_sender" + if retry_error_code == OTP_NO_OPENAI_SENDER_ERROR + else "timeout" + ) + + if retry_reason == "non_openai_sender": + if non_openai_sender_resend_used >= non_openai_sender_resend_max: + return None, otp_phase + non_openai_sender_resend_used += 1 + resend_attempt = non_openai_sender_resend_used + self._log(non_openai_retry_log_template.format(attempt=resend_attempt)) + status_template = non_openai_retry_status_template + else: + if timeout_resend_used >= timeout_resend_max: + return None, otp_phase + timeout_resend_used += 1 + resend_attempt = timeout_resend_used + self._log(timeout_retry_log_template.format(attempt=resend_attempt)) + status_template = timeout_retry_status_template + + if status_template: + emit_kwargs = {} + if step_index is not None: + emit_kwargs["step_index"] = step_index + self._emit_status( + "otp_resend", + status_template.format(attempt=resend_attempt), + **emit_kwargs, + ) + + if not resend_callback(): + self._log("重新发送验证码失败,跳过本次重试", "warning") + + otp_phase_started_at = time.time() + + return None, otp_phase + def _phase_otp_secondary( self, context: PhaseContext, @@ -1330,9 +1396,18 @@ class RegistrationEngine: if not self._submit_login_password_step(): return None, None - code = self._get_verification_code() + code, otp_phase = self._await_verification_code_with_resends( + self._submit_login_password_step, + timeout_retry_log_template="登录流程未找到验证码,第 {attempt} 次重新触发登录验证码...", + non_openai_retry_log_template="登录流程检测到非 OpenAI 发件人干扰,第 {attempt} 次重新触发登录验证码...", + timeout_retry_status_template="重新触发登录验证码(第 {attempt} 次)", + non_openai_retry_status_template="重新触发登录验证码(非 OpenAI 发件人,第 {attempt} 次)", + ) if not code: - self._log("登录流程获取验证码失败", "warning") + self._log( + otp_phase.error_message if otp_phase and otp_phase.error_message else "登录流程获取验证码失败", + "warning", + ) return None, None valid, consent_url = self._validate_verification_code_and_get_continue_url(code) @@ -1559,57 +1634,14 @@ class RegistrationEngine: # 10. 获取验证码(支持重发重试) self._log("10. 等待验证码...") self._emit_status("otp_secondary", "等待验证码邮件", step_index=10) - otp_phase_started_at = time.time() - settings = get_settings() - timeout_resend_max = settings.email_code_resend_max_retries - non_openai_sender_resend_max = settings.email_code_non_openai_sender_resend_max_retries - timeout_resend_used = 0 - non_openai_sender_resend_used = 0 - code, otp_phase = None, None - while True: - code, otp_phase = self._phase_otp_secondary( - PhaseContext(otp_sent_at=self._otp_sent_at), - started_at=otp_phase_started_at, - ) - if code: - break - - retry_error_code = otp_phase.error_code if otp_phase else "" - retry_reason = ( - "non_openai_sender" - if retry_error_code == OTP_NO_OPENAI_SENDER_ERROR - else "timeout" - ) - - if retry_reason == "non_openai_sender": - if non_openai_sender_resend_used >= non_openai_sender_resend_max: - break - non_openai_sender_resend_used += 1 - resend_attempt = non_openai_sender_resend_used - self._log( - f"10. 检测到非 OpenAI 发件人干扰,第 {resend_attempt} 次重新发送验证码..." - ) - self._emit_status( - "otp_resend", - f"重新发送验证码(非 OpenAI 发件人,第 {resend_attempt} 次)", - step_index=10, - ) - else: - if timeout_resend_used >= timeout_resend_max: - break - timeout_resend_used += 1 - resend_attempt = timeout_resend_used - self._log(f"10. 收件箱未找到验证码,第 {resend_attempt} 次重新发送验证码...") - self._emit_status( - "otp_resend", - f"重新发送验证码(第 {resend_attempt} 次)", - step_index=10, - ) - - if not self._send_verification_code(): - self._log("重新发送验证码失败,跳过本次重试", "warning") - - otp_phase_started_at = time.time() + code, otp_phase = self._await_verification_code_with_resends( + self._send_verification_code, + timeout_retry_log_template="10. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...", + non_openai_retry_log_template="10. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...", + timeout_retry_status_template="重新发送验证码(第 {attempt} 次)", + non_openai_retry_status_template="重新发送验证码(非 OpenAI 发件人,第 {attempt} 次)", + step_index=10, + ) if not code: result.error_message = ( otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败" diff --git a/src/database/crud.py b/src/database/crud.py index 3a19d46..84f3fa6 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -532,6 +532,42 @@ def delete_proxy(db: Session, proxy_id: int) -> bool: db.commit() return True + +def delete_proxies_by_ids(db: Session, proxy_ids: Iterable[int]) -> Dict[str, Any]: + """按 ID 批量删除代理配置。""" + normalized_ids = [] + seen_ids: Set[int] = set() + for proxy_id in proxy_ids: + current_id = int(proxy_id) + if current_id <= 0 or current_id in seen_ids: + continue + seen_ids.add(current_id) + normalized_ids.append(current_id) + + if not normalized_ids: + return { + "requested_count": 0, + "deleted_count": 0, + "not_found_ids": [], + } + + existing_ids = { + proxy_id + for (proxy_id,) in db.query(Proxy.id).filter(Proxy.id.in_(normalized_ids)).all() + } + not_found_ids = [proxy_id for proxy_id in normalized_ids if proxy_id not in existing_ids] + + deleted_count = 0 + if existing_ids: + deleted_count = db.query(Proxy).filter(Proxy.id.in_(existing_ids)).delete(synchronize_session=False) + db.commit() + + return { + "requested_count": len(normalized_ids), + "deleted_count": deleted_count, + "not_found_ids": not_found_ids, + } + def delete_disabled_proxies(db: Session) -> int: """删除所有已禁用代理""" deleted = db.query(Proxy).filter(Proxy.enabled == False).delete(synchronize_session=False) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index a0a20f4..9dc6040 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -759,7 +759,7 @@ async def codex_auth_login(request: CodexAuthLoginRequest): log_queue.put(("log", msg)) def run_login(): - from ...core.codex_auth import CodexAuthEngine + from core.openai.codex_auth import CodexAuthEngine try: engine = CodexAuthEngine( email=email, @@ -876,7 +876,7 @@ async def codex_auth_login_batch(request: CodexAuthBatchRequest): log_queue = queue.Queue() def run_batch(): - from ...core.codex_auth import CodexAuthEngine + from core.openai.codex_auth import CodexAuthEngine results = [] for i, acc_data in enumerate(accounts_data): diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 3186812..574089f 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -470,6 +470,11 @@ class ProxyCreateRequest(BaseModel): priority: int = 0 +class ProxyBatchDeleteRequest(BaseModel): + """批量删除代理请求""" + ids: List[int] + + class ProxyUpdateRequest(BaseModel): """更新代理请求""" name: Optional[str] = None @@ -563,6 +568,35 @@ async def create_proxy_item(request: ProxyCreateRequest): return {"success": True, "proxy": proxy.to_dict()} +@router.post("/proxies/batch-delete") +async def batch_delete_proxies(request: ProxyBatchDeleteRequest): + """批量删除代理。""" + if not request.ids: + raise HTTPException(status_code=400, detail="请至少选择一个代理") + + with get_db() as db: + result = crud.delete_proxies_by_ids(db, request.ids) + return { + "success": True, + "requested_count": result["requested_count"], + "deleted_count": result["deleted_count"], + "not_found_ids": result["not_found_ids"], + "message": f"已删除 {result['deleted_count']} 个代理", + } + + +@router.post("/proxies/delete-disabled") +async def delete_disabled_proxy_items(): + """删除所有已禁用代理。""" + with get_db() as db: + deleted_count = crud.delete_disabled_proxies(db) + return { + "success": True, + "deleted_count": deleted_count, + "message": f"已删除 {deleted_count} 个禁用代理", + } + + @router.get("/proxies/{proxy_id}") async def get_proxy_item(proxy_id: int): """获取单个代理""" diff --git a/static/js/settings.js b/static/js/settings.js index 0902f24..20eac53 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -29,7 +29,9 @@ const elements = { selectAllServices: document.getElementById('select-all-services'), // 代理列表 proxiesTable: document.getElementById('proxies-table'), + selectAllProxies: document.getElementById('select-all-proxies'), addProxyBtn: document.getElementById('add-proxy-btn'), + batchDeleteProxiesBtn: document.getElementById('batch-delete-proxies-btn'), testAllProxiesBtn: document.getElementById('test-all-proxies-btn'), deleteDisabledProxiesBtn: document.getElementById('delete-disabled-proxies-btn'), batchImportProxyBtn: document.getElementById('batch-import-proxy-btn'), @@ -89,6 +91,8 @@ const elements = { // 选中的服务 ID let selectedServiceIds = new Set(); +let selectedProxyIds = new Set(); +let disabledProxyCount = 0; // 初始化 document.addEventListener('DOMContentLoaded', () => { @@ -252,6 +256,20 @@ function initEventListeners() { elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies); } + if (elements.selectAllProxies) { + elements.selectAllProxies.addEventListener('change', (e) => { + toggleSelectAllProxies(e.target.checked); + }); + } + + if (elements.batchDeleteProxiesBtn) { + elements.batchDeleteProxiesBtn.addEventListener('click', handleBatchDeleteProxies); + } + + if (elements.deleteDisabledProxiesBtn) { + elements.deleteDisabledProxiesBtn.addEventListener('click', handleDeleteDisabledProxies); + } + if (elements.closeProxyModal) { elements.closeProxyModal.addEventListener('click', closeProxyModal); } @@ -845,12 +863,16 @@ function escapeHtml(text) { async function loadProxies() { try { const data = await api.get('/settings/proxies'); + syncSelectedProxyIds(data.proxies || []); renderProxies(data.proxies); } catch (error) { console.error('加载代理列表失败:', error); + selectedProxyIds = new Set(); + disabledProxyCount = 0; + updateProxyBatchActions(); elements.proxiesTable.innerHTML = `
| + + | ID | 名称 | 类型 | @@ -116,7 +120,7 @@|||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + |
暂无代理
diff --git a/tests/test_codex_auth_flow.py b/tests/test_codex_auth_flow.py
index 599ee9c..5fae698 100644
--- a/tests/test_codex_auth_flow.py
+++ b/tests/test_codex_auth_flow.py
@@ -1,7 +1,7 @@
from types import SimpleNamespace
-import src.core.codex_auth as codex_auth_module
-from src.core.codex_auth import CodexAuthEngine
+import core.openai.codex_auth as codex_auth_module
+from core.openai.codex_auth import CodexAuthEngine
from src.core.register import PhaseResult, RegistrationEngine
from src.services import EmailServiceType
diff --git a/tests/test_login_engine_otp_retry.py b/tests/test_login_engine_otp_retry.py
new file mode 100644
index 0000000..4de0f38
--- /dev/null
+++ b/tests/test_login_engine_otp_retry.py
@@ -0,0 +1,153 @@
+import src.core.register as register_module
+from src.core.login import LoginEngine
+from src.core.register import PhaseResult
+from src.services import EmailServiceType
+
+
+class DummySettings:
+ openai_client_id = "client-id"
+ openai_auth_url = "https://auth.example.test"
+ openai_token_url = "https://token.example.test"
+ openai_redirect_uri = "https://callback.example.test"
+ openai_scope = "openid profile email"
+ email_code_timeout = 120
+ email_code_poll_interval = 3
+ email_code_resend_max_retries = 2
+ email_code_non_openai_sender_resend_max_retries = 1
+
+
+class FakeEmailService:
+ service_type = EmailServiceType.TEMPMAIL
+
+ def get_verification_code(self, **kwargs):
+ raise AssertionError("unexpected direct email_service.get_verification_code call")
+
+
+def _success_phase() -> PhaseResult:
+ return PhaseResult(
+ phase=register_module.PHASE_OTP_SECONDARY,
+ success=True,
+ )
+
+
+def _failed_phase(error_code: str, error_message: str) -> PhaseResult:
+ return PhaseResult(
+ phase=register_module.PHASE_OTP_SECONDARY,
+ success=False,
+ error_code=error_code,
+ error_message=error_message,
+ retryable=True,
+ next_action="resend_otp",
+ )
+
+
+def _build_login_engine(monkeypatch) -> LoginEngine:
+ monkeypatch.setattr(register_module, "get_settings", lambda: DummySettings())
+ engine = LoginEngine(email_service=FakeEmailService())
+ engine.session = type("FakeSession", (), {"cookies": type("FakeCookies", (), {"get": staticmethod(lambda _name: None)})()})()
+ return engine
+
+
+def _stub_login_success_path(monkeypatch, engine: LoginEngine):
+ monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
+
+ def fake_create_email():
+ engine.email = "tester@example.com"
+ engine.email_info = {"service_id": "svc-1"}
+ return True
+
+ def fake_start_oauth():
+ engine.oauth_start = type("OAuthStart", (), {"auth_url": "https://auth.example.test/authorize"})()
+ return True
+
+ monkeypatch.setattr(engine, "_create_email", fake_create_email)
+ monkeypatch.setattr(engine, "_init_session", lambda: True)
+ monkeypatch.setattr(engine, "_start_oauth", fake_start_oauth)
+ monkeypatch.setattr(engine, "_get_device_id", lambda: "did-1")
+ monkeypatch.setattr(engine, "_check_sentinel", lambda _did: None)
+ monkeypatch.setattr(
+ engine,
+ "_submit_signup_form",
+ lambda _did, _sen_token: type("SignupResult", (), {"success": True, "error_message": ""})(),
+ )
+ monkeypatch.setattr(engine, "_register_password", lambda: (True, "pass-123"))
+ monkeypatch.setattr(engine, "_validate_verification_code", lambda _code: True)
+ monkeypatch.setattr(engine, "_create_user_account", lambda: True)
+ monkeypatch.setattr(engine, "_follow_login_redirects", lambda _url: True)
+ monkeypatch.setattr(engine, "_submit_login_form", lambda _did, _sen_token: True)
+ monkeypatch.setattr(engine, "_get_workspace_id", lambda: "ws-1")
+ monkeypatch.setattr(engine, "_select_workspace", lambda _workspace_id: "https://auth.example.test/continue")
+ monkeypatch.setattr(engine, "_follow_redirects", lambda _url: "https://callback.example.test?code=abc&state=xyz")
+ monkeypatch.setattr(
+ engine,
+ "_handle_oauth_callback",
+ lambda _url: {
+ "account_id": "acct-1",
+ "access_token": "access-token",
+ "refresh_token": "refresh-token",
+ "id_token": "id-token",
+ },
+ )
+ monkeypatch.setattr(engine, "close", lambda: None)
+
+
+def test_login_engine_run_uses_retryable_waiter_for_both_otp_steps(monkeypatch):
+ engine = _build_login_engine(monkeypatch)
+ _stub_login_success_path(monkeypatch, engine)
+
+ send_calls = []
+ wait_callbacks = []
+
+ def send_signup_code():
+ send_calls.append("signup")
+ return True
+
+ def send_passwordless_code():
+ send_calls.append("passwordless")
+ return True
+
+ monkeypatch.setattr(engine, "_send_verification_code", send_signup_code)
+ monkeypatch.setattr(engine, "_send_verification_code_passwordless", send_passwordless_code)
+
+ wait_results = iter([
+ ("111111", _success_phase()),
+ ("222222", _success_phase()),
+ ])
+
+ def fake_wait_for_code(resend_callback, **_kwargs):
+ wait_callbacks.append(resend_callback.__name__)
+ return next(wait_results)
+
+ monkeypatch.setattr(engine, "_await_verification_code_with_resends", fake_wait_for_code)
+
+ result = engine.run()
+
+ assert result.success is True
+ assert send_calls == ["signup", "passwordless"]
+ assert wait_callbacks == ["send_signup_code", "send_passwordless_code"]
+ assert result.workspace_id == "ws-1"
+ assert result.account_id == "acct-1"
+
+
+def test_login_engine_run_preserves_non_openai_sender_error(monkeypatch):
+ engine = _build_login_engine(monkeypatch)
+ _stub_login_success_path(monkeypatch, engine)
+
+ monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
+ monkeypatch.setattr(
+ engine,
+ "_await_verification_code_with_resends",
+ lambda _resend_callback, **_kwargs: (
+ None,
+ _failed_phase("OTP_NO_OPENAI_SENDER", "当前邮件批次未发现 OpenAI 发件人"),
+ ),
+ )
+ monkeypatch.setattr(engine, "close", lambda: None)
+
+ result = engine.run()
+
+ assert result.success is False
+ assert result.error_code == "OTP_NO_OPENAI_SENDER"
+ assert result.error_message == "当前邮件批次未发现 OpenAI 发件人"
+
+
diff --git a/tests/test_proxy_batch_delete.py b/tests/test_proxy_batch_delete.py
new file mode 100644
index 0000000..5edf6f4
--- /dev/null
+++ b/tests/test_proxy_batch_delete.py
@@ -0,0 +1,152 @@
+import asyncio
+from contextlib import contextmanager
+
+import pytest
+from fastapi import HTTPException
+
+from src.database import crud
+from src.database.session import DatabaseSessionManager
+from src.web.routes import settings as settings_routes
+
+
+def _build_fake_get_db(manager):
+ @contextmanager
+ def fake_get_db():
+ with manager.session_scope() as session:
+ yield session
+
+ return fake_get_db
+
+
+def _create_proxy_ids(manager, total: int) -> list[int]:
+ created_ids = []
+ with manager.session_scope() as session:
+ for index in range(total):
+ proxy = crud.create_proxy(
+ session,
+ name=f"proxy-{index}",
+ type="http",
+ host=f"127.0.0.{index + 1}",
+ port=8000 + index,
+ )
+ created_ids.append(proxy.id)
+ return created_ids
+
+
+def test_batch_delete_proxies_removes_selected_ids(tmp_path, monkeypatch):
+ manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-batch-delete.db")
+ manager.create_tables()
+ manager.migrate_tables()
+ monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
+
+ proxy_ids = _create_proxy_ids(manager, 3)
+
+ result = asyncio.run(
+ settings_routes.batch_delete_proxies(
+ settings_routes.ProxyBatchDeleteRequest(ids=[proxy_ids[0], proxy_ids[2]])
+ )
+ )
+
+ assert result["success"] is True
+ assert result["requested_count"] == 2
+ assert result["deleted_count"] == 2
+ assert result["not_found_ids"] == []
+
+ with manager.session_scope() as session:
+ remaining_ids = [proxy.id for proxy in crud.get_proxies(session)]
+
+ assert remaining_ids == [proxy_ids[1]]
+
+
+def test_batch_delete_proxies_reports_missing_ids(tmp_path, monkeypatch):
+ manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-batch-delete-missing.db")
+ manager.create_tables()
+ manager.migrate_tables()
+ monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
+
+ proxy_ids = _create_proxy_ids(manager, 1)
+
+ result = asyncio.run(
+ settings_routes.batch_delete_proxies(
+ settings_routes.ProxyBatchDeleteRequest(ids=[proxy_ids[0], 99999, proxy_ids[0]])
+ )
+ )
+
+ assert result["requested_count"] == 2
+ assert result["deleted_count"] == 1
+ assert result["not_found_ids"] == [99999]
+
+
+def test_batch_delete_proxies_requires_non_empty_selection():
+ with pytest.raises(HTTPException) as exc_info:
+ asyncio.run(
+ settings_routes.batch_delete_proxies(
+ settings_routes.ProxyBatchDeleteRequest(ids=[])
+ )
+ )
+
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.detail == "请至少选择一个代理"
+
+
+def test_delete_disabled_proxy_items_only_removes_disabled_records(tmp_path, monkeypatch):
+ manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-delete-disabled.db")
+ manager.create_tables()
+ manager.migrate_tables()
+ monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
+
+ with manager.session_scope() as session:
+ enabled_proxy = crud.create_proxy(
+ session,
+ name="enabled-proxy",
+ type="http",
+ host="127.0.0.10",
+ port=8010,
+ enabled=True,
+ )
+ crud.create_proxy(
+ session,
+ name="disabled-proxy-1",
+ type="http",
+ host="127.0.0.11",
+ port=8011,
+ enabled=False,
+ )
+ crud.create_proxy(
+ session,
+ name="disabled-proxy-2",
+ type="http",
+ host="127.0.0.12",
+ port=8012,
+ enabled=False,
+ )
+ enabled_proxy_id = enabled_proxy.id
+
+ result = asyncio.run(settings_routes.delete_disabled_proxy_items())
+
+ assert result["success"] is True
+ assert result["deleted_count"] == 2
+
+ with manager.session_scope() as session:
+ remaining = [(proxy.id, proxy.enabled) for proxy in crud.get_proxies(session)]
+
+ assert remaining == [(enabled_proxy_id, True)]
+
+
+def test_delete_disabled_proxy_items_is_noop_when_none_exist(tmp_path, monkeypatch):
+ manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-delete-disabled-empty.db")
+ manager.create_tables()
+ manager.migrate_tables()
+ monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
+
+ _create_proxy_ids(manager, 2)
+
+ result = asyncio.run(settings_routes.delete_disabled_proxy_items())
+
+ assert result["success"] is True
+ assert result["deleted_count"] == 0
+ assert result["message"] == "已删除 0 个禁用代理"
+
+
+
+
diff --git a/tests/test_registration_otp_phase.py b/tests/test_registration_otp_phase.py
index a5a3678..49faf45 100644
--- a/tests/test_registration_otp_phase.py
+++ b/tests/test_registration_otp_phase.py
@@ -244,7 +244,7 @@ def test_run_keeps_timeout_budget_after_non_openai_sender_resend(monkeypatch):
assert len(send_calls) == 4
-def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monkeypatch):
+def test_advance_login_authorization_sets_otp_anchor_before_retryable_wait(monkeypatch):
email_service = FakeEmailService(code=None)
engine = _build_engine(monkeypatch, email_service)
engine.oauth_start = object()
@@ -264,11 +264,12 @@ def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monk
monkeypatch.setattr(engine, "_submit_login_password_step", fake_submit_login_password_step)
- def fake_get_verification_code():
+ def fake_wait_for_verification_code(resend_callback, **_kwargs):
seen_anchors.append(engine._otp_sent_at)
- return None
+ assert resend_callback is engine._submit_login_password_step
+ return None, _build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender")
- monkeypatch.setattr(engine, "_get_verification_code", fake_get_verification_code)
+ monkeypatch.setattr(engine, "_await_verification_code_with_resends", fake_wait_for_verification_code)
workspace_id, callback_url = engine._advance_login_authorization()
diff --git a/tests/test_settings_email_code.py b/tests/test_settings_email_code.py
new file mode 100644
index 0000000..ec8bab2
--- /dev/null
+++ b/tests/test_settings_email_code.py
@@ -0,0 +1,112 @@
+import asyncio
+from types import SimpleNamespace
+
+from fastapi import HTTPException
+
+from src.web.routes import settings as settings_routes
+
+
+class DummySecret:
+ def __init__(self, value: str = ""):
+ self._value = value
+
+ def get_secret_value(self) -> str:
+ return self._value
+
+
+def _build_settings(**overrides):
+ data = {
+ "proxy_enabled": False,
+ "proxy_type": "http",
+ "proxy_host": "127.0.0.1",
+ "proxy_port": 7890,
+ "proxy_username": "",
+ "proxy_password": DummySecret(""),
+ "proxy_dynamic_enabled": False,
+ "proxy_dynamic_api_url": "",
+ "proxy_dynamic_api_key_header": "Authorization",
+ "proxy_dynamic_result_field": "data.proxy",
+ "proxy_dynamic_api_key": DummySecret(""),
+ "registration_max_retries": 3,
+ "registration_timeout": 120,
+ "registration_default_password_length": 12,
+ "registration_sleep_min": 5,
+ "registration_sleep_max": 30,
+ "webui_host": "127.0.0.1",
+ "webui_port": 15555,
+ "debug": False,
+ "webui_access_password": DummySecret(""),
+ "tempmail_base_url": "https://mail.example.test",
+ "tempmail_timeout": 30,
+ "tempmail_max_retries": 3,
+ "email_code_timeout": 120,
+ "email_code_poll_interval": 3,
+ "email_code_resend_max_retries": 2,
+ "email_code_non_openai_sender_resend_max_retries": 1,
+ }
+ data.update(overrides)
+ return SimpleNamespace(**data)
+
+
+def test_get_all_settings_includes_non_openai_sender_resend_budget(monkeypatch):
+ monkeypatch.setattr(
+ settings_routes,
+ "get_settings",
+ lambda: _build_settings(email_code_non_openai_sender_resend_max_retries=4),
+ )
+
+ result = asyncio.run(settings_routes.get_all_settings())
+
+ assert result["email_code"]["timeout"] == 120
+ assert result["email_code"]["resend_max_retries"] == 2
+ assert result["email_code"]["non_openai_sender_resend_max_retries"] == 4
+
+
+def test_update_email_code_settings_persists_non_openai_sender_budget(monkeypatch):
+ captured = {}
+
+ monkeypatch.setattr(
+ settings_routes,
+ "update_settings",
+ lambda **kwargs: captured.update(kwargs),
+ )
+
+ response = asyncio.run(
+ settings_routes.update_email_code_settings(
+ settings_routes.EmailCodeSettings(
+ timeout=180,
+ poll_interval=5,
+ resend_max_retries=3,
+ non_openai_sender_resend_max_retries=2,
+ )
+ )
+ )
+
+ assert response["success"] is True
+ assert captured == {
+ "email_code_timeout": 180,
+ "email_code_poll_interval": 5,
+ "email_code_resend_max_retries": 3,
+ "email_code_non_openai_sender_resend_max_retries": 2,
+ }
+
+
+def test_update_email_code_settings_rejects_invalid_non_openai_sender_budget():
+ try:
+ asyncio.run(
+ settings_routes.update_email_code_settings(
+ settings_routes.EmailCodeSettings(
+ timeout=120,
+ poll_interval=3,
+ resend_max_retries=2,
+ non_openai_sender_resend_max_retries=11,
+ )
+ )
+ )
+ except HTTPException as exc:
+ assert exc.status_code == 400
+ assert exc.detail == "非 OpenAI 发件人重发次数必须在 0-10 之间"
+ return
+
+ raise AssertionError("expected HTTPException for invalid non_openai_sender_resend_max_retries")
+
diff --git a/tests/test_settings_proxy_batch_delete.cjs b/tests/test_settings_proxy_batch_delete.cjs
new file mode 100644
index 0000000..90e6ccc
--- /dev/null
+++ b/tests/test_settings_proxy_batch_delete.cjs
@@ -0,0 +1,239 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const SETTINGS_JS_PATH = path.join(__dirname, '..', 'static', 'js', 'settings.js');
+
+function createClassList() {
+ const values = new Set();
+ return {
+ add(...items) {
+ items.forEach((item) => values.add(item));
+ },
+ remove(...items) {
+ items.forEach((item) => values.delete(item));
+ },
+ contains(item) {
+ return values.has(item);
+ },
+ };
+}
+
+function createElementStub(overrides = {}) {
+ return {
+ value: '',
+ checked: false,
+ disabled: false,
+ indeterminate: false,
+ innerHTML: '',
+ textContent: '',
+ style: {},
+ dataset: {},
+ classList: createClassList(),
+ addEventListener() {},
+ removeEventListener() {},
+ querySelectorAll() {
+ return [];
+ },
+ querySelector() {
+ return null;
+ },
+ reset() {},
+ ...overrides,
+ };
+}
+
+function createSandbox() {
+ const elements = new Map();
+ const proxyCheckboxes = [];
+ const toastCalls = [];
+ const apiCalls = [];
+
+ function getElement(id) {
+ if (!elements.has(id)) {
+ elements.set(id, createElementStub({ id }));
+ }
+ return elements.get(id);
+ }
+
+ const sandbox = {
+ console,
+ setTimeout,
+ clearTimeout,
+ __proxyCheckboxes: proxyCheckboxes,
+ __toastCalls: toastCalls,
+ __apiCalls: apiCalls,
+ document: {
+ getElementById(id) {
+ return getElement(id);
+ },
+ querySelectorAll(selector) {
+ if (selector === '.proxy-checkbox') {
+ return proxyCheckboxes;
+ }
+ if (selector === '.proxy-checkbox:checked') {
+ return proxyCheckboxes.filter((checkbox) => checkbox.checked);
+ }
+ return [];
+ },
+ addEventListener() {},
+ },
+ window: null,
+ api: {
+ get: async () => ({ proxies: [] }),
+ post: async (url, payload) => {
+ apiCalls.push({ url, payload });
+ if (url === '/settings/proxies/delete-disabled') {
+ return { success: true, deleted_count: 2, message: '已删除 2 个禁用代理' };
+ }
+ return { success: true, message: `已删除 ${payload.ids.length} 个代理`, not_found_ids: [] };
+ },
+ patch: async () => ({ success: true }),
+ delete: async () => ({ success: true }),
+ },
+ toast: {
+ success(message) {
+ toastCalls.push({ type: 'success', message });
+ },
+ error(message) {
+ toastCalls.push({ type: 'error', message });
+ },
+ info(message) {
+ toastCalls.push({ type: 'info', message });
+ },
+ },
+ confirm: async () => true,
+ format: {
+ date(value) {
+ return value || '-';
+ },
+ number(value) {
+ return String(value ?? 0);
+ },
+ },
+ };
+
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ vm.createContext(sandbox);
+ vm.runInContext(fs.readFileSync(SETTINGS_JS_PATH, 'utf8'), sandbox, { filename: 'settings.js' });
+ return sandbox;
+}
+
+test('updateSelectedProxies updates batch delete button state', () => {
+ const sandbox = createSandbox();
+ sandbox.__proxyCheckboxes.push(
+ createElementStub({ checked: true, dataset: { id: '1' } }),
+ createElementStub({ checked: false, dataset: { id: '2' } }),
+ createElementStub({ checked: true, dataset: { id: '3' } }),
+ );
+
+ vm.runInContext('updateSelectedProxies()', sandbox);
+
+ const batchDeleteBtn = sandbox.document.getElementById('batch-delete-proxies-btn');
+ const selectAllProxies = sandbox.document.getElementById('select-all-proxies');
+
+ assert.equal(batchDeleteBtn.disabled, false);
+ assert.equal(batchDeleteBtn.textContent, '🗑️ 批量删除 (2)');
+ assert.equal(selectAllProxies.checked, false);
+ assert.equal(selectAllProxies.indeterminate, true);
+});
+
+test('handleBatchDeleteProxies posts selected proxy ids and reloads list', async () => {
+ const sandbox = createSandbox();
+ sandbox.__proxyCheckboxes.push(
+ createElementStub({ checked: true, dataset: { id: '11' } }),
+ createElementStub({ checked: true, dataset: { id: '12' } }),
+ );
+
+ vm.runInContext('updateSelectedProxies()', sandbox);
+ vm.runInContext('loadProxies = async () => { globalThis.__reloaded = true; }', sandbox);
+
+ await vm.runInContext('handleBatchDeleteProxies()', sandbox);
+
+ assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), [
+ {
+ url: '/settings/proxies/batch-delete',
+ payload: { ids: [11, 12] },
+ },
+ ]);
+ assert.equal(sandbox.__reloaded, true);
+ assert.equal(sandbox.__toastCalls.at(-1).type, 'success');
+});
+
+test('handleBatchDeleteProxies rejects empty selection without api call', async () => {
+ const sandbox = createSandbox();
+
+ await vm.runInContext('handleBatchDeleteProxies()', sandbox);
+
+ assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
+ assert.equal(sandbox.__toastCalls.at(-1).type, 'error');
+ assert.equal(sandbox.__toastCalls.at(-1).message, '请先选择要删除的代理');
+});
+
+test('handleBatchDeleteProxies stops when user cancels confirmation', async () => {
+ const sandbox = createSandbox();
+ sandbox.confirm = async () => false;
+ sandbox.__proxyCheckboxes.push(
+ createElementStub({ checked: true, dataset: { id: '21' } }),
+ );
+
+ vm.runInContext('updateSelectedProxies()', sandbox);
+ await vm.runInContext('handleBatchDeleteProxies()', sandbox);
+
+ assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
+ assert.equal(sandbox.__toastCalls.length, 0);
+});
+
+test('updateProxyBatchActions enables delete-disabled button based on disabled count', () => {
+ const sandbox = createSandbox();
+
+ vm.runInContext('disabledProxyCount = 3; updateProxyBatchActions()', sandbox);
+
+ const deleteDisabledBtn = sandbox.document.getElementById('delete-disabled-proxies-btn');
+ assert.equal(deleteDisabledBtn.disabled, false);
+ assert.equal(deleteDisabledBtn.textContent, '🧹 删除禁用项 (3)');
+});
+
+test('handleDeleteDisabledProxies posts dedicated route and reloads list', async () => {
+ const sandbox = createSandbox();
+ vm.runInContext('disabledProxyCount = 2', sandbox);
+ vm.runInContext('loadProxies = async () => { globalThis.__reloadedDisabled = true; }', sandbox);
+
+ await vm.runInContext('handleDeleteDisabledProxies()', sandbox);
+
+ assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), [
+ {
+ url: '/settings/proxies/delete-disabled',
+ },
+ ]);
+ assert.equal(sandbox.__reloadedDisabled, true);
+ assert.equal(sandbox.__toastCalls.at(-1).message, '已删除 2 个禁用代理');
+});
+
+test('handleDeleteDisabledProxies rejects when there are no disabled proxies', async () => {
+ const sandbox = createSandbox();
+
+ await vm.runInContext('handleDeleteDisabledProxies()', sandbox);
+
+ assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
+ assert.equal(sandbox.__toastCalls.at(-1).message, '当前没有可删除的禁用代理');
+});
+
+test('handleDeleteDisabledProxies stops when user cancels confirmation', async () => {
+ const sandbox = createSandbox();
+ sandbox.confirm = async () => false;
+ vm.runInContext('disabledProxyCount = 2', sandbox);
+
+ await vm.runInContext('handleDeleteDisabledProxies()', sandbox);
+
+ assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
+ assert.equal(sandbox.__toastCalls.length, 0);
+});
+
+
+
+
+
| |||||||||||||||