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 = '