diff --git a/README.md b/README.md index 0c291a4..f62cf10 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # OpenAI 账号管理系统 v2 管理 OpenAI 账号的 Web UI 系统,支持多种邮箱服务、并发批量注册、代理管理和账号管理。 + > ⚠️ **免责声明**:本工具仅供学习和研究使用,使用本工具产生的一切后果由使用者自行承担。请遵守相关服务的使用条款,不要用于任何违法或不当用途。 如有侵权,请及时联系,会及时删除。 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) diff --git a/src/core/login.py b/src/core/login.py index 1520f77..5ab780d 100644 --- a/src/core/login.py +++ b/src/core/login.py @@ -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() diff --git a/src/core/openai/token_refresh.py b/src/core/openai/token_refresh.py index 394c56e..b387ddb 100644 --- a/src/core/openai/token_refresh.py +++ b/src/core/openai/token_refresh.py @@ -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 diff --git a/src/core/register.py b/src/core/register.py index 2531bb0..b63adaf 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -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: diff --git a/src/database/crud.py b/src/database/crud.py index 6eaeff2..49aaba1 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -472,6 +472,13 @@ def delete_proxy(db: Session, proxy_id: int) -> bool: return True +def delete_disabled_proxies(db: Session) -> int: + """删除所有已禁用代理""" + deleted = db.query(Proxy).filter(Proxy.enabled == False).delete(synchronize_session=False) + db.commit() + return deleted + + def update_proxy_last_used(db: Session, proxy_id: int) -> bool: """更新代理最后使用时间""" db_proxy = get_proxy_by_id(db, proxy_id) @@ -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() diff --git a/src/web/app.py b/src/web/app.py index 3395bfb..5ba7401 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -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(): diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index f244914..59350b3 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -469,6 +469,60 @@ class ProxyUpdateRequest(BaseModel): priority: Optional[int] = None +def _test_proxy_connectivity(proxy_url: str) -> dict: + """测试代理连通性并返回统一结果。""" + import time + from curl_cffi import requests as cffi_requests + + test_url = "https://api.ipify.org?format=json" + start_time = time.time() + + proxies_dict = { + "http": proxy_url, + "https": proxy_url + } + + response = cffi_requests.get( + test_url, + proxies=proxies_dict, + timeout=3, + impersonate="chrome110" + ) + + elapsed_time = time.time() - start_time + if response.status_code == 200: + ip_info = response.json() + return { + "success": True, + "ip": ip_info.get("ip", ""), + "response_time": round(elapsed_time * 1000), + "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}" + } + + return { + "success": False, + "message": f"状态码: {response.status_code}" + } + + +def _auto_disable_proxy_on_failure(db, proxy, message: str) -> dict: + """代理测试失败时自动禁用,并返回统一提示。""" + auto_disabled = False + if proxy.enabled: + crud.update_proxy(db, proxy.id, enabled=False) + auto_disabled = True + + final_message = message + if auto_disabled: + final_message = f"{message},已自动禁用" + + return { + "success": False, + "auto_disabled": auto_disabled, + "message": final_message, + } + + @router.get("/proxies") async def get_proxies_list(enabled: Optional[bool] = None): """获取代理列表""" @@ -559,107 +613,59 @@ async def set_proxy_default(proxy_id: int): @router.post("/proxies/{proxy_id}/test") async def test_proxy_item(proxy_id: int): """测试单个代理""" - import time - from curl_cffi import requests as cffi_requests - with get_db() as db: proxy = crud.get_proxy_by_id(db, proxy_id) if not proxy: raise HTTPException(status_code=404, detail="代理不存在") - proxy_url = proxy.proxy_url - test_url = "https://api.ipify.org?format=json" - start_time = time.time() - try: - proxies = { - "http": proxy_url, - "https": proxy_url - } - - response = cffi_requests.get( - test_url, - proxies=proxies, - timeout=3, - impersonate="chrome110" - ) - - elapsed_time = time.time() - start_time - - if response.status_code == 200: - ip_info = response.json() - return { - "success": True, - "ip": ip_info.get("ip", ""), - "response_time": round(elapsed_time * 1000), - "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}" - } - else: - return { - "success": False, - "message": f"代理返回错误状态码: {response.status_code}" - } - + result = _test_proxy_connectivity(proxy.proxy_url) + if result["success"]: + return result + return _auto_disable_proxy_on_failure(db, proxy, f"代理返回错误状态码: {result['message'].removeprefix('状态码: ')}") except Exception as e: - return { - "success": False, - "message": f"代理连接失败: {str(e)}" - } + return _auto_disable_proxy_on_failure(db, proxy, f"代理连接失败: {str(e)}") @router.post("/proxies/test-all") async def test_all_proxies(): """测试所有启用的代理""" - import time - from curl_cffi import requests as cffi_requests - with get_db() as db: proxies = crud.get_enabled_proxies(db) results = [] + auto_disabled_count = 0 for proxy in proxies: - proxy_url = proxy.proxy_url - test_url = "https://api.ipify.org?format=json" - start_time = time.time() - try: - proxies_dict = { - "http": proxy_url, - "https": proxy_url - } - - response = cffi_requests.get( - test_url, - proxies=proxies_dict, - timeout=3, - impersonate="chrome110" - ) - - elapsed_time = time.time() - start_time - - if response.status_code == 200: - ip_info = response.json() + result = _test_proxy_connectivity(proxy.proxy_url) + if result["success"]: results.append({ "id": proxy.id, "name": proxy.name, "success": True, - "ip": ip_info.get("ip", ""), - "response_time": round(elapsed_time * 1000) + "ip": result.get("ip", ""), + "response_time": result.get("response_time"), + "auto_disabled": False, }) else: + failure_result = _auto_disable_proxy_on_failure( + db, + proxy, + f"代理返回错误状态码: {result['message'].removeprefix('状态码: ')}" + ) + auto_disabled_count += 1 if failure_result["auto_disabled"] else 0 results.append({ "id": proxy.id, "name": proxy.name, - "success": False, - "message": f"状态码: {response.status_code}" + **failure_result, }) - except Exception as e: + failure_result = _auto_disable_proxy_on_failure(db, proxy, f"代理连接失败: {str(e)}") + auto_disabled_count += 1 if failure_result["auto_disabled"] else 0 results.append({ "id": proxy.id, "name": proxy.name, - "success": False, - "message": str(e) + **failure_result, }) success_count = sum(1 for r in results if r["success"]) @@ -667,6 +673,7 @@ async def test_all_proxies(): "total": len(proxies), "success": success_count, "failed": len(proxies) - success_count, + "auto_disabled": auto_disabled_count, "results": results } @@ -691,6 +698,14 @@ async def disable_proxy(proxy_id: int): return {"success": True, "message": "代理已禁用"} +@router.delete("/proxies/disabled/batch-delete") +async def delete_disabled_proxies(): + """批量删除所有已禁用代理""" + with get_db() as db: + deleted = crud.delete_disabled_proxies(db) + return {"success": True, "deleted": deleted, "message": f"已删除 {deleted} 个禁用代理"} + + # ============== Outlook 设置 ============== class OutlookSettings(BaseModel): diff --git a/static/js/accounts.js b/static/js/accounts.js index fe9848c..10f83d4 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -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(); } } diff --git a/static/js/settings.js b/static/js/settings.js index 39c8735..482c6d4 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -31,6 +31,7 @@ const elements = { proxiesTable: document.getElementById('proxies-table'), addProxyBtn: document.getElementById('add-proxy-btn'), testAllProxiesBtn: document.getElementById('test-all-proxies-btn'), + deleteDisabledProxiesBtn: document.getElementById('delete-disabled-proxies-btn'), addProxyModal: document.getElementById('add-proxy-modal'), proxyItemForm: document.getElementById('proxy-item-form'), closeProxyModal: document.getElementById('close-proxy-modal'), @@ -206,6 +207,10 @@ function initEventListeners() { elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies); } + if (elements.deleteDisabledProxiesBtn) { + elements.deleteDisabledProxiesBtn.addEventListener('click', handleDeleteDisabledProxies); + } + if (elements.closeProxyModal) { elements.closeProxyModal.addEventListener('click', closeProxyModal); } @@ -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 = ` - +
加载失败
@@ -792,7 +799,7 @@ function renderProxies(proxies) { if (!proxies || proxies.length === 0) { elements.proxiesTable.innerHTML = ` - +
🌐
暂无代理
@@ -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 { diff --git a/static/js/utils.js b/static/js/utils.js index b7b5dab..9862969 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -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); + } } } diff --git a/templates/settings.html b/templates/settings.html index 7b18e55..8ea1fb8 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -95,6 +95,7 @@

代理列表

+
@@ -115,7 +116,7 @@ - +
🌐
暂无代理
diff --git a/tests/test_login_engine.py b/tests/test_login_engine.py new file mode 100644 index 0000000..904c8ad --- /dev/null +++ b/tests/test_login_engine.py @@ -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