mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-11 18:10:53 +08:00
Merge remote-tracking branch 'origin/fix1' into fix1
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# OpenAI 账号管理系统 v2
|
||||
|
||||
管理 OpenAI 账号的 Web UI 系统,支持多种邮箱服务、并发批量注册、代理管理和账号管理。
|
||||
|
||||
> ⚠️ **免责声明**:本工具仅供学习和研究使用,使用本工具产生的一切后果由使用者自行承担。请遵守相关服务的使用条款,不要用于任何违法或不当用途。 如有侵权,请及时联系,会及时删除。
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import urllib.parse
|
||||
import base64
|
||||
import json as json_module
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
@@ -100,7 +101,6 @@ class LoginEngine(RegistrationEngine):
|
||||
def _send_verification_code_passwordless(self) -> bool:
|
||||
"""发送验证码"""
|
||||
try:
|
||||
import time
|
||||
# 记录发送时间戳
|
||||
self._otp_sent_at = time.time()
|
||||
response = self.session.post(
|
||||
@@ -118,46 +118,54 @@ class LoginEngine(RegistrationEngine):
|
||||
self._log(f"发送验证码失败: {e}", "error")
|
||||
return False
|
||||
|
||||
def _decode_workspace_id(self, auth_cookie: str) -> str:
|
||||
"""从授权 Cookie 中解析 Workspace ID"""
|
||||
segments = auth_cookie.split(".")
|
||||
if len(segments) < 1:
|
||||
raise ValueError("授权 Cookie 格式错误")
|
||||
|
||||
payload = segments[0]
|
||||
pad = "=" * ((4 - (len(payload) % 4)) % 4)
|
||||
decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
|
||||
auth_json = json_module.loads(decoded.decode("utf-8"))
|
||||
|
||||
workspaces = auth_json.get("workspaces") or []
|
||||
if not workspaces:
|
||||
raise ValueError("授权 Cookie 里没有 workspace 信息")
|
||||
|
||||
workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
|
||||
if not workspace_id:
|
||||
raise ValueError("无法解析 workspace_id")
|
||||
|
||||
return workspace_id
|
||||
|
||||
def _get_workspace_id(self) -> Optional[str]:
|
||||
"""获取 Workspace ID"""
|
||||
try:
|
||||
auth_cookie = self.session.cookies.get("oai-client-auth-session")
|
||||
if not auth_cookie:
|
||||
self._log("未能获取到授权 Cookie", "error")
|
||||
return None
|
||||
backoff_seconds = (1, 2, 4)
|
||||
max_attempts = len(backoff_seconds) + 1
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
segments = auth_cookie.split(".")
|
||||
if len(segments) < 1:
|
||||
self._log("授权 Cookie 格式错误", "error")
|
||||
return None
|
||||
|
||||
# 解码第一个 segment
|
||||
payload = segments[0]
|
||||
pad = "=" * ((4 - (len(payload) % 4)) % 4)
|
||||
decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
|
||||
auth_json = json_module.loads(decoded.decode("utf-8"))
|
||||
|
||||
workspaces = auth_json.get("workspaces") or []
|
||||
if not workspaces:
|
||||
self._log("授权 Cookie 里没有 workspace 信息", "error")
|
||||
return None
|
||||
|
||||
workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
|
||||
if not workspace_id:
|
||||
self._log("无法解析 workspace_id", "error")
|
||||
return None
|
||||
|
||||
self._log(f"Workspace ID: {workspace_id}")
|
||||
return workspace_id
|
||||
auth_cookie = self.session.cookies.get("oai-client-auth-session")
|
||||
if auth_cookie:
|
||||
workspace_id = self._decode_workspace_id(auth_cookie)
|
||||
self._log(f"Workspace ID: {workspace_id}")
|
||||
return workspace_id
|
||||
|
||||
raise ValueError("未能获取到授权 Cookie")
|
||||
except Exception as e:
|
||||
self._log(f"解析授权 Cookie 失败: {e}", "error")
|
||||
return None
|
||||
level = "warning" if attempt < max_attempts else "error"
|
||||
self._log(
|
||||
f"获取 Workspace ID 失败: {e} (第 {attempt}/{max_attempts} 次)",
|
||||
level,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"获取 Workspace ID 失败: {e}", "error")
|
||||
return None
|
||||
if attempt < max_attempts:
|
||||
wait_seconds = backoff_seconds[attempt - 1]
|
||||
self._log(f"等待 {wait_seconds} 秒后重试 Workspace ID", "warning")
|
||||
time.sleep(wait_seconds)
|
||||
|
||||
return None
|
||||
|
||||
def _select_workspace(self, workspace_id: str) -> Optional[str]:
|
||||
"""选择 Workspace"""
|
||||
@@ -464,3 +472,5 @@ class LoginEngine(RegistrationEngine):
|
||||
self._log(f"注册过程中发生未预期错误: {e}", "error")
|
||||
result.error_message = str(e)
|
||||
return result
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
@@ -57,6 +57,35 @@ class TokenRefreshManager:
|
||||
session = cffi_requests.Session(impersonate="chrome120", proxy=self.proxy_url)
|
||||
return session
|
||||
|
||||
def _parse_oauth_error(self, response: cffi_requests.Response) -> str:
|
||||
"""解析 OAuth 错误信息"""
|
||||
body_text = (response.text or "").strip()
|
||||
error_message = ""
|
||||
|
||||
try:
|
||||
body = response.json()
|
||||
error_obj = body.get("error") if isinstance(body, dict) else None
|
||||
if isinstance(error_obj, dict):
|
||||
error_message = str(error_obj.get("message") or "").strip()
|
||||
elif isinstance(body, dict):
|
||||
error_message = str(body.get("error_description") or body.get("message") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
error_lower = error_message.lower()
|
||||
if "refresh token has already been used" in error_lower:
|
||||
return "OAuth refresh_token 已失效(一次性令牌已被使用),请重新登录该账号后再上传 CPA"
|
||||
if response.status_code == 401:
|
||||
if error_message:
|
||||
return f"OAuth token 刷新失败: {error_message}"
|
||||
else:
|
||||
return "OAuth token 刷新失败: refresh_token 无效或已过期,请重新登录账号"
|
||||
if error_message:
|
||||
return f"OAuth token 刷新失败: {error_message}"
|
||||
if body_text:
|
||||
return f"OAuth token 刷新失败: HTTP {response.status_code}, 响应: {body_text[:200]}"
|
||||
return f"OAuth token 刷新失败: HTTP {response.status_code}"
|
||||
|
||||
def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult:
|
||||
"""
|
||||
使用 Session Token 刷新
|
||||
@@ -167,7 +196,7 @@ class TokenRefreshManager:
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
result.error_message = f"OAuth token 刷新失败: HTTP {response.status_code}"
|
||||
result.error_message = self._parse_oauth_error(response)
|
||||
logger.warning(f"{result.error_message}, 响应: {response.text[:200]}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -211,6 +211,28 @@ class RegistrationEngine:
|
||||
self._log(f"初始化会话失败: {e}", 'error')
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""关闭注册流程占用的资源"""
|
||||
if self.session:
|
||||
try:
|
||||
self.session.close()
|
||||
except Exception as e:
|
||||
self._log(f"关闭注册会话失败: {e}", "warning")
|
||||
finally:
|
||||
self.session = None
|
||||
|
||||
try:
|
||||
self.http_client.close()
|
||||
except Exception as e:
|
||||
self._log(f"关闭 HTTP 客户端失败: {e}", "warning")
|
||||
|
||||
close_email_service = getattr(self.email_service, "close", None)
|
||||
if callable(close_email_service):
|
||||
try:
|
||||
close_email_service()
|
||||
except Exception as e:
|
||||
self._log(f"关闭邮箱服务失败: {e}", "warning")
|
||||
|
||||
def _get_device_id(self) -> Optional[str]:
|
||||
"""获取 Device ID"""
|
||||
if not self.oauth_start:
|
||||
|
||||
@@ -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,7 @@ 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()
|
||||
|
||||
@@ -108,8 +108,9 @@ def create_app() -> FastAPI:
|
||||
async def login_page(request: Request, next: Optional[str] = "/"):
|
||||
"""登录页面"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{"request": request, "error": "", "next": next or "/"}
|
||||
{"error": "", "next": next or "/"}
|
||||
)
|
||||
|
||||
@app.post("/login")
|
||||
@@ -118,8 +119,9 @@ def create_app() -> FastAPI:
|
||||
expected = get_settings().webui_access_password.get_secret_value()
|
||||
if not secrets.compare_digest(password, expected):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{"request": request, "error": "密码错误", "next": next or "/"},
|
||||
{"error": "密码错误", "next": next or "/"},
|
||||
status_code=401
|
||||
)
|
||||
|
||||
@@ -139,33 +141,33 @@ def create_app() -> FastAPI:
|
||||
"""首页 - 注册页面"""
|
||||
if not _is_authenticated(request):
|
||||
return _redirect_to_login(request)
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
@app.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
"""账号管理页面"""
|
||||
if not _is_authenticated(request):
|
||||
return _redirect_to_login(request)
|
||||
return templates.TemplateResponse("accounts.html", {"request": request})
|
||||
return templates.TemplateResponse(request, "accounts.html")
|
||||
|
||||
@app.get("/email-services", response_class=HTMLResponse)
|
||||
async def email_services_page(request: Request):
|
||||
"""邮箱服务管理页面"""
|
||||
if not _is_authenticated(request):
|
||||
return _redirect_to_login(request)
|
||||
return templates.TemplateResponse("email_services.html", {"request": request})
|
||||
return templates.TemplateResponse(request, "email_services.html")
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""设置页面"""
|
||||
if not _is_authenticated(request):
|
||||
return _redirect_to_login(request)
|
||||
return templates.TemplateResponse("settings.html", {"request": request})
|
||||
return templates.TemplateResponse(request, "settings.html")
|
||||
|
||||
@app.get("/payment", response_class=HTMLResponse)
|
||||
async def payment_page(request: Request):
|
||||
"""支付页面"""
|
||||
return templates.TemplateResponse("payment.html", {"request": request})
|
||||
return templates.TemplateResponse(request, "payment.html")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -11,6 +11,8 @@ let selectedAccounts = new Set();
|
||||
let isLoading = false;
|
||||
let selectAllPages = false; // 是否选中了全部页
|
||||
let currentFilters = { status: '', email_service: '', search: '' }; // 当前筛选条件
|
||||
const refreshingAccountIds = new Set();
|
||||
let isBatchValidating = false;
|
||||
|
||||
// DOM 元素
|
||||
const elements = {
|
||||
@@ -488,6 +490,12 @@ function updateBatchButtons() {
|
||||
|
||||
// 刷新单个账号Token
|
||||
async function refreshToken(id) {
|
||||
if (refreshingAccountIds.has(id)) {
|
||||
toast.info('该账号正在刷新,请稍候...');
|
||||
return;
|
||||
}
|
||||
refreshingAccountIds.add(id);
|
||||
|
||||
try {
|
||||
toast.info('正在刷新Token...');
|
||||
const result = await api.post(`/accounts/${id}/refresh`);
|
||||
@@ -500,6 +508,8 @@ async function refreshToken(id) {
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('刷新失败: ' + error.message);
|
||||
} finally {
|
||||
refreshingAccountIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,17 +538,24 @@ async function handleBatchRefresh() {
|
||||
// 批量验证Token
|
||||
async function handleBatchValidate() {
|
||||
if (getEffectiveCount() === 0) return;
|
||||
if (isBatchValidating) {
|
||||
toast.info('批量验证进行中,请稍候...');
|
||||
return;
|
||||
}
|
||||
|
||||
isBatchValidating = true;
|
||||
|
||||
elements.batchValidateBtn.disabled = true;
|
||||
elements.batchValidateBtn.textContent = '验证中...';
|
||||
|
||||
try {
|
||||
const result = await api.post('/accounts/batch-validate', buildBatchPayload());
|
||||
const result = await api.post('/accounts/batch-validate', buildBatchPayload(), { timeoutMs: 120000 });
|
||||
toast.info(`有效: ${result.valid_count},无效: ${result.invalid_count}`);
|
||||
loadAccounts();
|
||||
} catch (error) {
|
||||
toast.error('批量验证失败: ' + error.message);
|
||||
} finally {
|
||||
isBatchValidating = false;
|
||||
updateBatchButtons();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -931,7 +949,12 @@ async function testProxyItem(id) {
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
if (result.auto_disabled) {
|
||||
toast.warning(result.message);
|
||||
await loadProxies();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('测试失败: ' + error.message);
|
||||
@@ -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 {
|
||||
|
||||
@@ -187,12 +187,21 @@ class ApiClient {
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
const timeoutMs = Number(finalOptions.timeoutMs || 0);
|
||||
delete finalOptions.timeoutMs;
|
||||
|
||||
if (finalOptions.body && typeof finalOptions.body === 'object') {
|
||||
finalOptions.body = JSON.stringify(finalOptions.body);
|
||||
}
|
||||
|
||||
let timeoutId = null;
|
||||
try {
|
||||
if (timeoutMs > 0) {
|
||||
const controller = new AbortController();
|
||||
finalOptions.signal = controller.signal;
|
||||
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
}
|
||||
|
||||
const response = await fetch(url, finalOptions);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
@@ -205,11 +214,19 @@ class ApiClient {
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
const timeoutError = new Error('请求超时,请稍后重试');
|
||||
throw timeoutError;
|
||||
}
|
||||
// 网络错误处理
|
||||
if (!error.response) {
|
||||
toast.error('网络连接失败,请检查网络');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
tests/test_login_engine.py
Normal file
57
tests/test_login_engine.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.core.login import LoginEngine
|
||||
|
||||
|
||||
def _build_auth_cookie(workspace_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"workspaces": [{"id": workspace_id}]}).encode("utf-8")
|
||||
).decode("ascii").rstrip("=")
|
||||
return f"{payload}.signature"
|
||||
|
||||
|
||||
def test_get_workspace_id_retries_with_exponential_backoff(monkeypatch):
|
||||
engine = LoginEngine.__new__(LoginEngine)
|
||||
engine.logs = []
|
||||
engine._log = lambda message, level="info": engine.logs.append((level, message))
|
||||
|
||||
auth_cookie = _build_auth_cookie("ws-123")
|
||||
cookies = SimpleNamespace()
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_get(name):
|
||||
assert name == "oai-client-auth-session"
|
||||
calls["count"] += 1
|
||||
if calls["count"] < 4:
|
||||
return None
|
||||
return auth_cookie
|
||||
|
||||
cookies.get = fake_get
|
||||
engine.session = SimpleNamespace(cookies=cookies)
|
||||
|
||||
sleeps = []
|
||||
monkeypatch.setattr("src.core.login.time.sleep", lambda seconds: sleeps.append(seconds))
|
||||
|
||||
workspace_id = engine._get_workspace_id()
|
||||
|
||||
assert workspace_id == "ws-123"
|
||||
assert calls["count"] == 4
|
||||
assert sleeps == [1, 2, 4]
|
||||
|
||||
|
||||
def test_run_always_closes_resources_on_early_return():
|
||||
engine = LoginEngine.__new__(LoginEngine)
|
||||
engine.logs = []
|
||||
engine._log = lambda message, level="info": None
|
||||
engine.close_called = False
|
||||
engine.close = lambda: setattr(engine, "close_called", True)
|
||||
|
||||
engine._check_ip_location = lambda: (False, "blocked")
|
||||
|
||||
result = engine.run()
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_message == "IP 地理位置不支持: blocked"
|
||||
assert engine.close_called is True
|
||||
Reference in New Issue
Block a user