feat(proxy): add batch delete functionality for proxies and enhance verification code handling

This commit is contained in:
cnlimiter
2026-03-28 04:13:15 +08:00
parent 51922ef2a6
commit 8b8ef7c6c0
14 changed files with 996 additions and 97 deletions

View File

@@ -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. 验证验证码...")

View File

@@ -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

View File

@@ -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 "获取验证码失败"

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):
"""获取单个代理"""

View File

@@ -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 = `
<tr>
<td colspan="7">
<td colspan="9">
<div class="empty-state">
<div class="empty-state-icon">❌</div>
<div class="empty-state-title">加载失败</div>
@@ -864,9 +886,12 @@ async function loadProxies() {
// 渲染代理列表
function renderProxies(proxies) {
if (!proxies || proxies.length === 0) {
selectedProxyIds = new Set();
disabledProxyCount = 0;
updateProxyBatchActions();
elements.proxiesTable.innerHTML = `
<tr>
<td colspan="7">
<td colspan="9">
<div class="empty-state">
<div class="empty-state-icon">🌐</div>
<div class="empty-state-title">暂无代理</div>
@@ -878,8 +903,20 @@ function renderProxies(proxies) {
return;
}
disabledProxyCount = proxies.filter((proxy) => proxy && proxy.enabled === false).length;
elements.proxiesTable.innerHTML = proxies.map(proxy => `
<tr data-proxy-id="${proxy.id}">
<td style="text-align:center;">
<input
type="checkbox"
class="proxy-checkbox"
data-id="${proxy.id}"
${selectedProxyIds.has(proxy.id) ? 'checked' : ''}
onchange="updateSelectedProxies()"
aria-label="选择代理 ${proxy.id}"
>
</td>
<td>${proxy.id}</td>
<td>${escapeHtml(proxy.name)}</td>
<td><span class="badge">${proxy.type.toUpperCase()}</span></td>
@@ -911,6 +948,56 @@ function renderProxies(proxies) {
</td>
</tr>
`).join('');
updateProxyBatchActions();
}
function syncSelectedProxyIds(proxies) {
const validIds = new Set((proxies || []).map(proxy => proxy.id));
selectedProxyIds = new Set([...selectedProxyIds].filter(id => validIds.has(id)));
}
function toggleSelectAllProxies(checked) {
document.querySelectorAll('.proxy-checkbox').forEach((checkbox) => {
checkbox.checked = checked;
});
updateSelectedProxies();
}
function updateSelectedProxies() {
selectedProxyIds = new Set(
[...document.querySelectorAll('.proxy-checkbox:checked')]
.map((checkbox) => parseInt(checkbox.dataset.id, 10))
.filter((id) => Number.isInteger(id) && id > 0)
);
updateProxyBatchActions();
}
function updateProxyBatchActions() {
const proxyCheckboxes = [...document.querySelectorAll('.proxy-checkbox')];
const checkedCount = selectedProxyIds.size;
if (elements.selectAllProxies) {
const totalCount = proxyCheckboxes.length;
const allChecked = totalCount > 0 && checkedCount === totalCount;
elements.selectAllProxies.checked = allChecked;
elements.selectAllProxies.indeterminate = checkedCount > 0 && checkedCount < totalCount;
elements.selectAllProxies.disabled = totalCount === 0;
}
if (elements.batchDeleteProxiesBtn) {
elements.batchDeleteProxiesBtn.disabled = checkedCount === 0;
elements.batchDeleteProxiesBtn.textContent = checkedCount > 0
? `🗑️ 批量删除 (${checkedCount})`
: '🗑️ 批量删除';
}
if (elements.deleteDisabledProxiesBtn) {
elements.deleteDisabledProxiesBtn.disabled = disabledProxyCount === 0;
elements.deleteDisabledProxiesBtn.textContent = disabledProxyCount > 0
? `🧹 删除禁用项 (${disabledProxyCount})`
: '🧹 删除禁用项';
}
}
function toggleSettingsMoreMenu(btn) {
@@ -1071,6 +1158,49 @@ async function deleteProxyItem(id) {
}
}
async function handleBatchDeleteProxies() {
const ids = [...selectedProxyIds];
if (ids.length === 0) {
toast.error('请先选择要删除的代理');
return;
}
const confirmed = await confirm(`确定要批量删除选中的 ${ids.length} 个代理吗?`);
if (!confirmed) return;
try {
const result = await api.post('/settings/proxies/batch-delete', { ids });
const missingCount = Array.isArray(result.not_found_ids) ? result.not_found_ids.length : 0;
const summary = missingCount > 0
? `${result.message},其中 ${missingCount} 个代理不存在或已被删除`
: result.message;
toast.success(summary || `已删除 ${ids.length} 个代理`);
selectedProxyIds = new Set();
await loadProxies();
} catch (error) {
toast.error('批量删除失败: ' + error.message);
}
}
async function handleDeleteDisabledProxies() {
if (disabledProxyCount === 0) {
toast.error('当前没有可删除的禁用代理');
return;
}
const confirmed = await confirm(`确定要删除全部 ${disabledProxyCount} 个禁用代理吗?`);
if (!confirmed) return;
try {
const result = await api.post('/settings/proxies/delete-disabled');
selectedProxyIds = new Set();
toast.success(result.message || `已删除 ${result.deleted_count || 0} 个禁用代理`);
await loadProxies();
} catch (error) {
toast.error('删除禁用代理失败: ' + error.message);
}
}
// 测试所有代理
async function handleTestAllProxies() {
elements.testAllProxiesBtn.disabled = true;

View File

@@ -94,6 +94,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="batch-delete-proxies-btn" disabled>🗑️ 批量删除</button>
<button class="btn btn-danger btn-sm" id="delete-disabled-proxies-btn" disabled>🧹 删除禁用项</button>
<button class="btn btn-secondary btn-sm" id="batch-import-proxy-btn">📋 批量导入</button>
<button class="btn btn-primary btn-sm" id="add-proxy-btn"> 添加代理</button>
@@ -104,6 +105,9 @@
<table class="data-table">
<thead>
<tr>
<th style="width: 42px; text-align: center;">
<input type="checkbox" id="select-all-proxies" title="全选代理">
</th>
<th style="width: 50px;">ID</th>
<th>名称</th>
<th>类型</th>
@@ -116,7 +120,7 @@
</thead>
<tbody id="proxies-table">
<tr>
<td colspan="8">
<td colspan="9">
<div class="empty-state">
<div class="empty-state-icon">🌐</div>
<div class="empty-state-title">暂无代理</div>

View File

@@ -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

View File

@@ -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 发件人"

View File

@@ -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 个禁用代理"

View File

@@ -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()

View File

@@ -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")

View File

@@ -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);
});