From dfebd9a780e7519a93409af9934e290297b4961a Mon Sep 17 00:00:00 2001 From: Jack <2091813161@qq.com> Date: Tue, 24 Mar 2026 09:05:27 +0800 Subject: [PATCH] Add remote Sub2API proxy selection for account uploads. Use the target Sub2API service's remote proxy list during account uploads so selected proxies are converted into proxy_key bindings the import API recognizes. --- src/core/upload/sub2api_upload.py | 174 +++++++++++++++++++++- src/web/routes/accounts.py | 6 +- src/web/routes/upload/sub2api_services.py | 69 ++++++++- static/js/accounts.js | 86 +++++++++++ templates/accounts.html | 20 +++ 5 files changed, 346 insertions(+), 9 deletions(-) diff --git a/src/core/upload/sub2api_upload.py b/src/core/upload/sub2api_upload.py index 8df7960..35e12f7 100644 --- a/src/core/upload/sub2api_upload.py +++ b/src/core/upload/sub2api_upload.py @@ -3,25 +3,165 @@ Sub2API 账号上传功能 将账号以 sub2api-data 格式批量导入到 Sub2API 平台 """ -import json import logging from datetime import datetime, timezone -from typing import List, Tuple, Optional +from typing import Any, List, Optional, Tuple from curl_cffi import requests as cffi_requests -from ...database.session import get_db from ...database.models import Account +from ...database.session import get_db logger = logging.getLogger(__name__) +def _extract_sub2api_data(response) -> Any: + try: + payload = response.json() + except Exception: + payload = None + + if response.status_code >= 400: + if isinstance(payload, dict) and payload.get("message"): + raise ValueError(str(payload["message"])) + response_text = (response.text or "").strip() + raise ValueError( + f"HTTP {response.status_code}" + (f" - {response_text[:200]}" if response_text else "") + ) + + if isinstance(payload, dict): + if payload.get("code") not in (None, 0): + raise ValueError(str(payload.get("message") or "Sub2API 返回错误")) + return payload.get("data", payload) + + if payload is None: + raise ValueError("Sub2API 返回了无法解析的响应") + + return payload + + +def _normalize_remote_sub2api_proxy(proxy: dict) -> dict: + protocol = str(proxy.get("protocol") or "").strip() + host = str(proxy.get("host") or "").strip() + try: + port = int(proxy.get("port") or 0) + except (TypeError, ValueError): + port = 0 + + normalized = { + "id": proxy.get("id"), + "name": str(proxy.get("name") or "").strip(), + "protocol": protocol, + "host": host, + "port": port, + "username": str(proxy.get("username") or "").strip(), + "password": str(proxy.get("password") or "").strip(), + "status": str(proxy.get("status") or "inactive").strip() or "inactive", + } + if not normalized["name"]: + if normalized["id"] is not None: + normalized["name"] = f"Proxy {normalized['id']}" + else: + normalized["name"] = f"{protocol}://{host}:{port}" + return normalized + + +def _build_sub2api_proxy_key(proxy: dict) -> str: + normalized = _normalize_remote_sub2api_proxy(proxy) + return ( + f"{normalized['protocol']}|{normalized['host']}|{normalized['port']}|" + f"{normalized['username']}|{normalized['password']}" + ) + + +def _build_sub2api_proxy_payload(proxy: dict) -> dict: + normalized = _normalize_remote_sub2api_proxy(proxy) + if not normalized["protocol"] or not normalized["host"] or normalized["port"] <= 0: + raise ValueError(f"远端 Sub2API 代理 {normalized['id']} 配置不完整,无法生成上传数据") + + return { + "proxy_key": _build_sub2api_proxy_key(normalized), + "name": normalized["name"], + "protocol": normalized["protocol"], + "host": normalized["host"], + "port": normalized["port"], + "username": normalized["username"], + "password": normalized["password"], + "status": normalized["status"], + } + + +def fetch_remote_sub2api_proxies(api_url: str, api_key: str) -> List[dict]: + if not api_url: + raise ValueError("Sub2API URL 未配置") + if not api_key: + raise ValueError("Sub2API API Key 未配置") + + url = api_url.rstrip("/") + "/api/v1/admin/proxies/all" + + try: + response = cffi_requests.get( + url, + headers={"x-api-key": api_key}, + proxies=None, + timeout=15, + impersonate="chrome110", + ) + data = _extract_sub2api_data(response) + if not isinstance(data, list): + raise ValueError("远端 Sub2API 代理列表格式异常") + return [_normalize_remote_sub2api_proxy(item) for item in data if isinstance(item, dict)] + except ValueError: + raise + except cffi_requests.exceptions.ConnectionError as e: + raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e + except cffi_requests.exceptions.Timeout as e: + raise ValueError("拉取远端 Sub2API 代理列表超时") from e + except Exception as e: + logger.error(f"拉取远端 Sub2API 代理列表异常: {e}") + raise ValueError(f"拉取远端 Sub2API 代理列表失败: {e}") from e + + +def fetch_remote_sub2api_proxy(api_url: str, api_key: str, proxy_id: int) -> dict: + if proxy_id is None: + raise ValueError("远端 Sub2API 代理 ID 不能为空") + if not api_url: + raise ValueError("Sub2API URL 未配置") + if not api_key: + raise ValueError("Sub2API API Key 未配置") + + url = api_url.rstrip("/") + f"/api/v1/admin/proxies/{proxy_id}" + + try: + response = cffi_requests.get( + url, + headers={"x-api-key": api_key}, + proxies=None, + timeout=15, + impersonate="chrome110", + ) + data = _extract_sub2api_data(response) + if not isinstance(data, dict): + raise ValueError("远端 Sub2API 代理详情格式异常") + return _normalize_remote_sub2api_proxy(data) + except ValueError: + raise + except cffi_requests.exceptions.ConnectionError as e: + raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e + except cffi_requests.exceptions.Timeout as e: + raise ValueError("拉取远端 Sub2API 代理详情超时") from e + except Exception as e: + logger.error(f"拉取远端 Sub2API 代理详情异常: {e}") + raise ValueError(f"拉取远端 Sub2API 代理详情失败: {e}") from e + + def upload_to_sub2api( accounts: List[Account], api_url: str, api_key: str, concurrency: int = 3, priority: int = 50, + proxy_id: Optional[int] = None, ) -> Tuple[bool, str]: """ 上传账号列表到 Sub2API 平台(不走代理) @@ -32,6 +172,7 @@ def upload_to_sub2api( api_key: Admin API Key(x-api-key header) concurrency: 账号并发数,默认 3 priority: 账号优先级,默认 50 + proxy_id: 远端 Sub2API 代理 ID,用于生成 Sub2API 识别的 proxy_key(可选) Returns: (成功标志, 消息) @@ -47,12 +188,20 @@ def upload_to_sub2api( exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + proxy_payload = None + if proxy_id is not None: + try: + remote_proxy = fetch_remote_sub2api_proxy(api_url, api_key, proxy_id) + proxy_payload = _build_sub2api_proxy_payload(remote_proxy) + except ValueError as e: + return False, str(e) + account_items = [] for acc in accounts: if not acc.access_token: continue expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0 - account_items.append({ + account_item = { "name": acc.email, "platform": "openai", "type": "oauth", @@ -82,7 +231,10 @@ def upload_to_sub2api( "priority": priority, "rate_multiplier": 1, "auto_pause_on_expired": True, - }) + } + if proxy_payload is not None: + account_item["proxy_key"] = proxy_payload["proxy_key"] + account_items.append(account_item) if not account_items: return False, "所有账号均缺少 access_token,无法上传" @@ -92,7 +244,7 @@ def upload_to_sub2api( "type": "sub2api-data", "version": 1, "exported_at": exported_at, - "proxies": [], + "proxies": [proxy_payload] if proxy_payload is not None else [], "accounts": account_items, }, "skip_default_group_bind": True, @@ -138,6 +290,7 @@ def batch_upload_to_sub2api( api_key: str, concurrency: int = 3, priority: int = 50, + proxy_id: Optional[int] = None, ) -> dict: """ 批量上传指定 ID 的账号到 Sub2API 平台 @@ -169,7 +322,14 @@ def batch_upload_to_sub2api( if not accounts: return results - success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority) + success, message = upload_to_sub2api( + accounts, + api_url, + api_key, + concurrency, + priority, + proxy_id=proxy_id, + ) if success: for acc in accounts: diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index a6a597f..d2e1df5 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -796,6 +796,7 @@ class Sub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None concurrency: int = 3 priority: int = 50 + proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID class BatchSub2ApiUploadRequest(BaseModel): @@ -808,6 +809,7 @@ class BatchSub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None # 指定 Sub2API 服务 ID,不传则使用第一个启用的 concurrency: int = 3 priority: int = 50 + proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID @router.post("/batch-upload-sub2api") @@ -844,6 +846,7 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, + proxy_id=request.proxy_id, ) return results @@ -855,6 +858,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp service_id = request.service_id if request else None concurrency = request.concurrency if request else 3 priority = request.priority if request else 50 + proxy_id = request.proxy_id if request else None api_url = None api_key = None @@ -884,7 +888,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp success, message = upload_to_sub2api( [account], api_url, api_key, - concurrency=concurrency, priority=priority + concurrency=concurrency, priority=priority, proxy_id=proxy_id ) if success: return {"success": True, "message": message} diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index ddd7759..b15fa94 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -8,7 +8,11 @@ from pydantic import BaseModel from ....database import crud from ....database.session import get_db -from ....core.upload.sub2api_upload import test_sub2api_connection, batch_upload_to_sub2api +from ....core.upload.sub2api_upload import ( + batch_upload_to_sub2api, + fetch_remote_sub2api_proxies, + test_sub2api_connection, +) router = APIRouter() @@ -55,6 +59,22 @@ class Sub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None concurrency: int = 3 priority: int = 50 + proxy_id: Optional[int] = None + + +class Sub2ApiRemoteProxyResponse(BaseModel): + id: int + name: str + protocol: str + host: str + port: int + username: str = "" + status: str + + +class Sub2ApiRemoteProxyListResponse(BaseModel): + service: Sub2ApiServiceResponse + proxies: List[Sub2ApiRemoteProxyResponse] def _to_response(svc) -> Sub2ApiServiceResponse: @@ -70,6 +90,35 @@ def _to_response(svc) -> Sub2ApiServiceResponse: ) +def _resolve_sub2api_service(service_id: Optional[int] = None): + with get_db() as db: + if service_id: + svc = crud.get_sub2api_service_by_id(db, service_id) + else: + services = crud.get_sub2api_services(db, enabled=True) + svc = services[0] if services else None + + if not svc: + raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务") + return svc + + +def _to_remote_proxy_response(proxy: dict) -> Sub2ApiRemoteProxyResponse: + proxy_id = proxy.get("id") + if proxy_id is None: + raise ValueError("远端 Sub2API 代理缺少 id") + + return Sub2ApiRemoteProxyResponse( + id=int(proxy_id), + name=str(proxy.get("name") or "").strip() or f"Proxy {proxy_id}", + protocol=str(proxy.get("protocol") or "").strip(), + host=str(proxy.get("host") or "").strip(), + port=int(proxy.get("port") or 0), + username=str(proxy.get("username") or "").strip(), + status=str(proxy.get("status") or "inactive").strip() or "inactive", + ) + + # ============== API Endpoints ============== @router.get("", response_model=List[Sub2ApiServiceResponse]) @@ -95,6 +144,23 @@ async def create_sub2api_service(request: Sub2ApiServiceCreate): return _to_response(svc) +@router.get("/remote-proxies", response_model=Sub2ApiRemoteProxyListResponse) +async def list_remote_sub2api_proxies(service_id: Optional[int] = None): + """拉取目标 Sub2API 服务中的远端代理列表""" + svc = _resolve_sub2api_service(service_id) + + try: + proxies = fetch_remote_sub2api_proxies(svc.api_url, svc.api_key) + proxy_items = [_to_remote_proxy_response(proxy) for proxy in proxies] + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + return Sub2ApiRemoteProxyListResponse( + service=_to_response(svc), + proxies=proxy_items, + ) + + @router.get("/{service_id}", response_model=Sub2ApiServiceResponse) async def get_sub2api_service(service_id: int): """获取单个 Sub2API 服务详情""" @@ -203,5 +269,6 @@ async def upload_accounts_to_sub2api(request: Sub2ApiUploadRequest): api_key, concurrency=request.concurrency, priority=request.priority, + proxy_id=request.proxy_id, ) return results diff --git a/static/js/accounts.js b/static/js/accounts.js index fe9848c..303d1ce 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -1022,6 +1022,83 @@ function selectSub2ApiService() { }); } +function selectSub2ApiProxy(serviceChoice) { + return new Promise(async (resolve) => { + const modal = document.getElementById('sub2api-proxy-modal'); + const listEl = document.getElementById('sub2api-proxy-list'); + const closeBtn = document.getElementById('close-sub2api-proxy-modal'); + const cancelBtn = document.getElementById('cancel-sub2api-proxy-modal-btn'); + const noProxyBtn = document.getElementById('sub2api-no-proxy-btn'); + + listEl.innerHTML = '
加载中...
'; + modal.classList.add('active'); + + let proxies = []; + try { + const query = serviceChoice && serviceChoice.service_id != null + ? `?service_id=${encodeURIComponent(serviceChoice.service_id)}` + : ''; + const result = await api.get(`/sub2api-services/remote-proxies${query}`); + proxies = result.proxies || []; + } catch (e) { + cleanup(); + toast.error('加载远端 Sub2API 代理失败: ' + e.message); + resolve(null); + return; + } + + if (proxies.length === 0) { + listEl.innerHTML = '
该 Sub2API 服务下暂无可选远端代理,可选择不使用代理继续上传
'; + } else { + listEl.innerHTML = proxies.map(proxy => ` +
+
+
+ #${proxy.id} ${escapeHtml(proxy.name)} + ${escapeHtml(String(proxy.protocol || '').toUpperCase())} + ${escapeHtml(proxy.status || 'inactive')} +
+
${escapeHtml(proxy.host)}:${proxy.port}
+
+ 选择 +
+ `).join(''); + + listEl.querySelectorAll('.sub2api-service-item').forEach(item => { + item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)'); + item.addEventListener('mouseleave', () => item.style.background = ''); + item.addEventListener('click', () => { + cleanup(); + resolve({ proxy_id: parseInt(item.dataset.id) }); + }); + }); + } + + function cleanup() { + modal.classList.remove('active'); + closeBtn.removeEventListener('click', onCancel); + cancelBtn.removeEventListener('click', onCancel); + noProxyBtn.removeEventListener('click', onNoProxy); + } + function onCancel() { cleanup(); resolve(null); } + function onNoProxy() { cleanup(); resolve({ proxy_id: null }); } + + closeBtn.addEventListener('click', onCancel); + cancelBtn.addEventListener('click', onCancel); + noProxyBtn.addEventListener('click', onNoProxy); + }); +} + // 批量上传到 Sub2API async function handleBatchUploadSub2Api() { const count = getEffectiveCount(); @@ -1030,6 +1107,9 @@ async function handleBatchUploadSub2Api() { const choice = await selectSub2ApiService(); if (choice === null) return; // 用户取消 + const proxyInput = await selectSub2ApiProxy(choice); + if (proxyInput === null) return; + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`); if (!confirmed) return; @@ -1039,6 +1119,7 @@ async function handleBatchUploadSub2Api() { try { const payload = buildBatchPayload(); if (choice.service_id != null) payload.service_id = choice.service_id; + if (proxyInput.proxy_id != null) payload.proxy_id = proxyInput.proxy_id; const result = await api.post('/accounts/batch-upload-sub2api', payload); let message = `成功: ${result.success_count}`; @@ -1060,10 +1141,15 @@ async function handleBatchUploadSub2Api() { async function uploadToSub2Api(id) { const choice = await selectSub2ApiService(); if (choice === null) return; + + const proxyInput = await selectSub2ApiProxy(choice); + if (proxyInput === null) return; + try { toast.info('正在上传到 Sub2API...'); const payload = {}; if (choice.service_id != null) payload.service_id = choice.service_id; + if (proxyInput.proxy_id != null) payload.proxy_id = proxyInput.proxy_id; const result = await api.post(`/accounts/${id}/upload-sub2api`, payload); if (result.success) { toast.success('上传成功'); diff --git a/templates/accounts.html b/templates/accounts.html index 32b6731..9e7eb5d 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -277,6 +277,26 @@ + + +