diff --git a/src/core/sub2api_upload.py b/src/core/sub2api_upload.py new file mode 100644 index 0000000..2874f04 --- /dev/null +++ b/src/core/sub2api_upload.py @@ -0,0 +1,202 @@ +""" +Sub2API 账号上传功能 +将账号以 sub2api-data 格式批量导入到 Sub2API 平台 +""" + +import json +import logging +from datetime import datetime, timezone +from typing import List, Tuple, Optional + +from curl_cffi import requests as cffi_requests + +from ..database.session import get_db +from ..database.models import Account + +logger = logging.getLogger(__name__) + + +def upload_to_sub2api( + accounts: List[Account], + api_url: str, + api_key: str, + concurrency: int = 3, + priority: int = 50, +) -> Tuple[bool, str]: + """ + 上传账号列表到 Sub2API 平台(不走代理) + + Args: + accounts: 账号模型实例列表 + api_url: Sub2API 地址,如 http://host + api_key: Admin API Key(x-api-key header) + concurrency: 账号并发数,默认 3 + priority: 账号优先级,默认 50 + + Returns: + (成功标志, 消息) + """ + if not accounts: + return False, "无可上传的账号" + + if not api_url: + return False, "Sub2API URL 未配置" + + if not api_key: + return False, "Sub2API API Key 未配置" + + exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + account_items = [] + for acc in accounts: + if not acc.access_token: + continue + account_items.append({ + "name": acc.email, + "platform": "openai", + "type": "oauth", + "credentials": { + "access_token": acc.access_token, + }, + "concurrency": concurrency, + "priority": priority, + }) + + if not account_items: + return False, "所有账号均缺少 access_token,无法上传" + + payload = { + "data": { + "type": "sub2api-data", + "version": 1, + "exported_at": exported_at, + "proxies": [], + "accounts": account_items, + }, + "skip_default_group_bind": True, + } + + url = api_url.rstrip("/") + "/api/v1/admin/accounts/data" + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "Idempotency-Key": f"import-{exported_at}", + } + + try: + response = cffi_requests.post( + url, + json=payload, + headers=headers, + proxies=None, + timeout=30, + impersonate="chrome110", + ) + + if response.status_code in (200, 201): + return True, f"成功上传 {len(account_items)} 个账号" + + error_msg = f"上传失败: HTTP {response.status_code}" + try: + detail = response.json() + if isinstance(detail, dict): + error_msg = detail.get("message", error_msg) + except Exception: + error_msg = f"{error_msg} - {response.text[:200]}" + return False, error_msg + + except Exception as e: + logger.error(f"Sub2API 上传异常: {e}") + return False, f"上传异常: {str(e)}" + + +def batch_upload_to_sub2api( + account_ids: List[int], + api_url: str, + api_key: str, + concurrency: int = 3, + priority: int = 50, +) -> dict: + """ + 批量上传指定 ID 的账号到 Sub2API 平台 + + Returns: + 包含成功/失败/跳过统计和详情的字典 + """ + results = { + "success_count": 0, + "failed_count": 0, + "skipped_count": 0, + "details": [] + } + + with get_db() as db: + accounts = [] + for account_id in account_ids: + acc = db.query(Account).filter(Account.id == account_id).first() + if not acc: + results["failed_count"] += 1 + results["details"].append({"id": account_id, "email": None, "success": False, "error": "账号不存在"}) + continue + if not acc.access_token: + results["skipped_count"] += 1 + results["details"].append({"id": account_id, "email": acc.email, "success": False, "error": "缺少 access_token"}) + continue + accounts.append(acc) + + if not accounts: + return results + + success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority) + + if success: + for acc in accounts: + results["success_count"] += 1 + results["details"].append({"id": acc.id, "email": acc.email, "success": True, "message": message}) + else: + for acc in accounts: + results["failed_count"] += 1 + results["details"].append({"id": acc.id, "email": acc.email, "success": False, "error": message}) + + return results + + +def test_sub2api_connection(api_url: str, api_key: str) -> Tuple[bool, str]: + """ + 测试 Sub2API 连接(GET /api/v1/admin/accounts/data 探活) + + Returns: + (成功标志, 消息) + """ + if not api_url: + return False, "API URL 不能为空" + if not api_key: + return False, "API Key 不能为空" + + url = api_url.rstrip("/") + "/api/v1/admin/accounts/data" + headers = {"x-api-key": api_key} + + try: + response = cffi_requests.get( + url, + headers=headers, + proxies=None, + timeout=10, + impersonate="chrome110", + ) + + if response.status_code in (200, 201, 204, 405): + return True, "Sub2API 连接测试成功" + if response.status_code == 401: + return False, "连接成功,但 API Key 无效" + if response.status_code == 403: + return False, "连接成功,但权限不足" + + return False, f"服务器返回异常状态码: {response.status_code}" + + except cffi_requests.exceptions.ConnectionError as e: + return False, f"无法连接到服务器: {str(e)}" + except cffi_requests.exceptions.Timeout: + return False, "连接超时,请检查网络配置" + except Exception as e: + return False, f"连接测试失败: {str(e)}" diff --git a/src/web/routes/sub2api_services.py b/src/web/routes/sub2api_services.py new file mode 100644 index 0000000..d682665 --- /dev/null +++ b/src/web/routes/sub2api_services.py @@ -0,0 +1,207 @@ +""" +Sub2API 服务管理 API 路由 +""" + +from typing import List, Optional +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ...database import crud +from ...database.session import get_db +from ...core.sub2api_upload import test_sub2api_connection, batch_upload_to_sub2api + +router = APIRouter() + + +# ============== Pydantic Models ============== + +class Sub2ApiServiceCreate(BaseModel): + name: str + api_url: str + api_key: str + enabled: bool = True + priority: int = 0 + + +class Sub2ApiServiceUpdate(BaseModel): + name: Optional[str] = None + api_url: Optional[str] = None + api_key: Optional[str] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +class Sub2ApiServiceResponse(BaseModel): + id: int + name: str + api_url: str + has_key: bool + enabled: bool + priority: int + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class Sub2ApiTestRequest(BaseModel): + api_url: Optional[str] = None + api_key: Optional[str] = None + + +class Sub2ApiUploadRequest(BaseModel): + account_ids: List[int] + service_id: Optional[int] = None + concurrency: int = 3 + priority: int = 50 + + +def _to_response(svc) -> Sub2ApiServiceResponse: + return Sub2ApiServiceResponse( + id=svc.id, + name=svc.name, + api_url=svc.api_url, + has_key=bool(svc.api_key), + enabled=svc.enabled, + priority=svc.priority, + created_at=svc.created_at.isoformat() if svc.created_at else None, + updated_at=svc.updated_at.isoformat() if svc.updated_at else None, + ) + + +# ============== API Endpoints ============== + +@router.get("", response_model=List[Sub2ApiServiceResponse]) +async def list_sub2api_services(enabled: Optional[bool] = None): + """获取 Sub2API 服务列表""" + with get_db() as db: + services = crud.get_sub2api_services(db, enabled=enabled) + return [_to_response(s) for s in services] + + +@router.post("", response_model=Sub2ApiServiceResponse) +async def create_sub2api_service(request: Sub2ApiServiceCreate): + """新增 Sub2API 服务""" + with get_db() as db: + svc = crud.create_sub2api_service( + db, + name=request.name, + api_url=request.api_url, + api_key=request.api_key, + enabled=request.enabled, + priority=request.priority, + ) + return _to_response(svc) + + +@router.get("/{service_id}", response_model=Sub2ApiServiceResponse) +async def get_sub2api_service(service_id: int): + """获取单个 Sub2API 服务详情""" + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Sub2API 服务不存在") + return _to_response(svc) + + +@router.get("/{service_id}/full") +async def get_sub2api_service_full(service_id: int): + """获取 Sub2API 服务完整配置(含 API Key)""" + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Sub2API 服务不存在") + return { + "id": svc.id, + "name": svc.name, + "api_url": svc.api_url, + "api_key": svc.api_key, + "enabled": svc.enabled, + "priority": svc.priority, + } + + +@router.patch("/{service_id}", response_model=Sub2ApiServiceResponse) +async def update_sub2api_service(service_id: int, request: Sub2ApiServiceUpdate): + """更新 Sub2API 服务配置""" + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Sub2API 服务不存在") + + update_data = {} + if request.name is not None: + update_data["name"] = request.name + if request.api_url is not None: + update_data["api_url"] = request.api_url + # api_key 留空则保持原值 + if request.api_key: + update_data["api_key"] = request.api_key + if request.enabled is not None: + update_data["enabled"] = request.enabled + if request.priority is not None: + update_data["priority"] = request.priority + + svc = crud.update_sub2api_service(db, service_id, **update_data) + return _to_response(svc) + + +@router.delete("/{service_id}") +async def delete_sub2api_service(service_id: int): + """删除 Sub2API 服务""" + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Sub2API 服务不存在") + crud.delete_sub2api_service(db, service_id) + return {"success": True, "message": f"Sub2API 服务 {svc.name} 已删除"} + + +@router.post("/{service_id}/test") +async def test_sub2api_service(service_id: int): + """测试 Sub2API 服务连接""" + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Sub2API 服务不存在") + success, message = test_sub2api_connection(svc.api_url, svc.api_key) + return {"success": success, "message": message} + + +@router.post("/test-connection") +async def test_sub2api_connection_direct(request: Sub2ApiTestRequest): + """直接测试 Sub2API 连接(用于添加前验证)""" + if not request.api_url or not request.api_key: + raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") + success, message = test_sub2api_connection(request.api_url, request.api_key) + return {"success": success, "message": message} + + +@router.post("/upload") +async def upload_accounts_to_sub2api(request: Sub2ApiUploadRequest): + """批量上传账号到 Sub2API 平台""" + if not request.account_ids: + raise HTTPException(status_code=400, detail="账号 ID 列表不能为空") + + with get_db() as db: + if request.service_id: + svc = crud.get_sub2api_service_by_id(db, request.service_id) + else: + svcs = crud.get_sub2api_services(db, enabled=True) + svc = svcs[0] if svcs else None + + if not svc: + raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务") + + api_url = svc.api_url + api_key = svc.api_key + + results = batch_upload_to_sub2api( + request.account_ids, + api_url, + api_key, + concurrency=request.concurrency, + priority=request.priority, + ) + return results diff --git a/src/web/routes/tm_services.py b/src/web/routes/tm_services.py new file mode 100644 index 0000000..b3c64b7 --- /dev/null +++ b/src/web/routes/tm_services.py @@ -0,0 +1,153 @@ +""" +Team Manager 服务管理 API 路由 +""" + +from typing import List, Optional +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ...database import crud +from ...database.session import get_db +from ...core.team_manager import test_team_manager_connection + +router = APIRouter() + + +# ============== Pydantic Models ============== + +class TmServiceCreate(BaseModel): + name: str + api_url: str + api_key: str + enabled: bool = True + priority: int = 0 + + +class TmServiceUpdate(BaseModel): + name: Optional[str] = None + api_url: Optional[str] = None + api_key: Optional[str] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +class TmServiceResponse(BaseModel): + id: int + name: str + api_url: str + has_key: bool + enabled: bool + priority: int + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class TmTestRequest(BaseModel): + api_url: Optional[str] = None + api_key: Optional[str] = None + + +def _to_response(svc) -> TmServiceResponse: + return TmServiceResponse( + id=svc.id, + name=svc.name, + api_url=svc.api_url, + has_key=bool(svc.api_key), + enabled=svc.enabled, + priority=svc.priority, + created_at=svc.created_at.isoformat() if svc.created_at else None, + updated_at=svc.updated_at.isoformat() if svc.updated_at else None, + ) + + +# ============== API Endpoints ============== + +@router.get("", response_model=List[TmServiceResponse]) +async def list_tm_services(enabled: Optional[bool] = None): + """获取 Team Manager 服务列表""" + with get_db() as db: + services = crud.get_tm_services(db, enabled=enabled) + return [_to_response(s) for s in services] + + +@router.post("", response_model=TmServiceResponse) +async def create_tm_service(request: TmServiceCreate): + """新增 Team Manager 服务""" + with get_db() as db: + svc = crud.create_tm_service( + db, + name=request.name, + api_url=request.api_url, + api_key=request.api_key, + enabled=request.enabled, + priority=request.priority, + ) + return _to_response(svc) + + +@router.get("/{service_id}", response_model=TmServiceResponse) +async def get_tm_service(service_id: int): + """获取单个 Team Manager 服务详情""" + with get_db() as db: + svc = crud.get_tm_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Team Manager 服务不存在") + return _to_response(svc) + + +@router.patch("/{service_id}", response_model=TmServiceResponse) +async def update_tm_service(service_id: int, request: TmServiceUpdate): + """更新 Team Manager 服务配置""" + with get_db() as db: + svc = crud.get_tm_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Team Manager 服务不存在") + + update_data = {} + if request.name is not None: + update_data["name"] = request.name + if request.api_url is not None: + update_data["api_url"] = request.api_url + if request.api_key: + update_data["api_key"] = request.api_key + if request.enabled is not None: + update_data["enabled"] = request.enabled + if request.priority is not None: + update_data["priority"] = request.priority + + svc = crud.update_tm_service(db, service_id, **update_data) + return _to_response(svc) + + +@router.delete("/{service_id}") +async def delete_tm_service(service_id: int): + """删除 Team Manager 服务""" + with get_db() as db: + svc = crud.get_tm_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Team Manager 服务不存在") + crud.delete_tm_service(db, service_id) + return {"success": True, "message": f"Team Manager 服务 {svc.name} 已删除"} + + +@router.post("/{service_id}/test") +async def test_tm_service(service_id: int): + """测试 Team Manager 服务连接""" + with get_db() as db: + svc = crud.get_tm_service_by_id(db, service_id) + if not svc: + raise HTTPException(status_code=404, detail="Team Manager 服务不存在") + success, message = test_team_manager_connection(svc.api_url, svc.api_key) + return {"success": success, "message": message} + + +@router.post("/test-connection") +async def test_tm_connection_direct(request: TmTestRequest): + """直接测试 Team Manager 连接(用于添加前验证)""" + if not request.api_url or not request.api_key: + raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") + success, message = test_team_manager_connection(request.api_url, request.api_key) + return {"success": success, "message": message}