diff --git a/pyproject.toml b/pyproject.toml index bf01157..6f1b63f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "aiosqlite>=0.19.0", "psycopg[binary]>=3.1.18", "websockets>=16.0", + "path>=17.1.1", ] [project.optional-dependencies] diff --git a/src/config/settings.py b/src/config/settings.py index 5bf4064..d533db9 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -514,7 +514,8 @@ def init_default_settings() -> None: ) print(f"[Settings] 初始化默认设置: {defn.db_key} = {default_value if not defn.is_secret else '***'}") except Exception as e: - print(f"[Settings] 初始化默认设置失败: {e}") + if "未初始化" not in str(e): + print(f"[Settings] 初始化默认设置失败: {e}") def _load_settings_from_db() -> Dict[str, Any]: @@ -549,7 +550,8 @@ def _load_settings_from_db() -> Dict[str, Any]: settings_dict["webui_access_password"] = env_password return settings_dict except Exception as e: - print(f"[Settings] 从数据库加载设置失败: {e},使用默认值") + if "未初始化" not in str(e): + print(f"[Settings] 从数据库加载设置失败: {e},使用默认值") return {name: defn.default_value for name, defn in SETTING_DEFINITIONS.items()} @@ -572,7 +574,8 @@ def _save_settings_to_db(**kwargs) -> None: description=defn.description ) except Exception as e: - print(f"[Settings] 保存设置到数据库失败: {e}") + if "未初始化" not in str(e): + print(f"[Settings] 保存设置到数据库失败: {e}") class Settings(BaseModel): diff --git a/src/core/__init__.py b/src/core/__init__.py index a849736..7ec7c6f 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -2,7 +2,7 @@ 核心功能模块 """ -from .oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url +from .openai.oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url from .http_client import ( OpenAIHTTPClient, HTTPClient, diff --git a/src/core/openai/__init__.py b/src/core/openai/__init__.py new file mode 100644 index 0000000..3a2da6d --- /dev/null +++ b/src/core/openai/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# @Time : 2026/3/18 19:55 \ No newline at end of file diff --git a/src/core/oauth.py b/src/core/openai/oauth.py similarity index 99% rename from src/core/oauth.py rename to src/core/openai/oauth.py index c621355..e8dc0fa 100644 --- a/src/core/oauth.py +++ b/src/core/openai/oauth.py @@ -14,7 +14,7 @@ from typing import Any, Dict, Optional from curl_cffi import requests as cffi_requests -from ..config.constants import ( +from ...config.constants import ( OAUTH_CLIENT_ID, OAUTH_AUTH_URL, OAUTH_TOKEN_URL, diff --git a/src/core/payment.py b/src/core/openai/payment.py similarity index 99% rename from src/core/payment.py rename to src/core/openai/payment.py index 24e2182..d584a58 100644 --- a/src/core/payment.py +++ b/src/core/openai/payment.py @@ -9,7 +9,7 @@ from typing import Optional from curl_cffi import requests as cffi_requests -from ..database.models import Account +from ...database.models import Account logger = logging.getLogger(__name__) diff --git a/src/core/token_refresh.py b/src/core/openai/token_refresh.py similarity index 98% rename from src/core/token_refresh.py rename to src/core/openai/token_refresh.py index 9475a71..394c56e 100644 --- a/src/core/token_refresh.py +++ b/src/core/openai/token_refresh.py @@ -12,10 +12,10 @@ from datetime import datetime, timedelta from curl_cffi import requests as cffi_requests -from ..config.settings import get_settings -from ..database.session import get_db -from ..database import crud -from ..database.models import Account +from ...config.settings import get_settings +from ...database.session import get_db +from ...database import crud +from ...database.models import Account logger = logging.getLogger(__name__) diff --git a/src/core/register.py b/src/core/register.py index a50f22d..41549a0 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -15,7 +15,7 @@ from datetime import datetime from curl_cffi import requests as cffi_requests -from .oauth import OAuthManager, OAuthStart +from .openai.oauth import OAuthManager, OAuthStart from .http_client import OpenAIHTTPClient, HTTPClientError from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType from ..database import crud diff --git a/src/core/upload/__init__.py b/src/core/upload/__init__.py new file mode 100644 index 0000000..059e515 --- /dev/null +++ b/src/core/upload/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# @Time : 2026/3/18 19:54 \ No newline at end of file diff --git a/src/core/cpa_upload.py b/src/core/upload/cpa_upload.py similarity index 98% rename from src/core/cpa_upload.py rename to src/core/upload/cpa_upload.py index 1583bd0..d61758c 100644 --- a/src/core/cpa_upload.py +++ b/src/core/upload/cpa_upload.py @@ -10,9 +10,9 @@ from datetime import datetime from curl_cffi import requests as cffi_requests from curl_cffi import CurlMime -from ..database.session import get_db -from ..database.models import Account -from ..config.settings import get_settings +from ...database.session import get_db +from ...database.models import Account +from ...config.settings import get_settings logger = logging.getLogger(__name__) diff --git a/src/core/upload/sub2api_upload.py b/src/core/upload/sub2api_upload.py new file mode 100644 index 0000000..2fae618 --- /dev/null +++ b/src/core/upload/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/core/team_manager.py b/src/core/upload/team_manager_upload.py similarity index 97% rename from src/core/team_manager.py rename to src/core/upload/team_manager_upload.py index b51b5fe..267e2a2 100644 --- a/src/core/team_manager.py +++ b/src/core/upload/team_manager_upload.py @@ -9,9 +9,9 @@ from datetime import datetime from curl_cffi import requests as cffi_requests -from ..database.session import get_db -from ..database.models import Account -from ..config.settings import get_settings +from ...database.session import get_db +from ...database.models import Account +from ...config.settings import get_settings logger = logging.getLogger(__name__) diff --git a/src/database/crud.py b/src/database/crud.py index 2e11508..4750969 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from sqlalchemy.orm import Session from sqlalchemy import and_, or_, desc, asc, func -from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService +from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService # ============================================================================ @@ -583,4 +583,132 @@ def delete_cpa_service(db: Session, service_id: int) -> bool: return False db.delete(db_service) db.commit() + return True + + +# ============================================================================ +# Sub2API 服务 CRUD +# ============================================================================ + +def create_sub2api_service( + db: Session, + name: str, + api_url: str, + api_key: str, + enabled: bool = True, + priority: int = 0 +) -> Sub2ApiService: + """创建 Sub2API 服务配置""" + svc = Sub2ApiService( + name=name, + api_url=api_url, + api_key=api_key, + enabled=enabled, + priority=priority, + ) + db.add(svc) + db.commit() + db.refresh(svc) + return svc + + +def get_sub2api_service_by_id(db: Session, service_id: int) -> Optional[Sub2ApiService]: + """按 ID 获取 Sub2API 服务""" + return db.query(Sub2ApiService).filter(Sub2ApiService.id == service_id).first() + + +def get_sub2api_services( + db: Session, + enabled: Optional[bool] = None +) -> List[Sub2ApiService]: + """获取 Sub2API 服务列表""" + query = db.query(Sub2ApiService) + if enabled is not None: + query = query.filter(Sub2ApiService.enabled == enabled) + return query.order_by(asc(Sub2ApiService.priority), asc(Sub2ApiService.id)).all() + + +def update_sub2api_service(db: Session, service_id: int, **kwargs) -> Optional[Sub2ApiService]: + """更新 Sub2API 服务配置""" + svc = get_sub2api_service_by_id(db, service_id) + if not svc: + return None + for key, value in kwargs.items(): + setattr(svc, key, value) + db.commit() + db.refresh(svc) + return svc + + +def delete_sub2api_service(db: Session, service_id: int) -> bool: + """删除 Sub2API 服务配置""" + svc = get_sub2api_service_by_id(db, service_id) + if not svc: + return False + db.delete(svc) + db.commit() + return True + + +# ============================================================================ +# Team Manager 服务 CRUD +# ============================================================================ + +def create_tm_service( + db: Session, + name: str, + api_url: str, + api_key: str, + enabled: bool = True, + priority: int = 0, +): + """创建 Team Manager 服务配置""" + from .models import TeamManagerService + svc = TeamManagerService( + name=name, + api_url=api_url, + api_key=api_key, + enabled=enabled, + priority=priority, + ) + db.add(svc) + db.commit() + db.refresh(svc) + return svc + + +def get_tm_service_by_id(db: Session, service_id: int): + """按 ID 获取 Team Manager 服务""" + from .models import TeamManagerService + return db.query(TeamManagerService).filter(TeamManagerService.id == service_id).first() + + +def get_tm_services(db: Session, enabled=None): + """获取 Team Manager 服务列表""" + from .models import TeamManagerService + q = db.query(TeamManagerService) + if enabled is not None: + q = q.filter(TeamManagerService.enabled == enabled) + return q.order_by(TeamManagerService.priority.asc(), TeamManagerService.id.asc()).all() + + +def update_tm_service(db: Session, service_id: int, **kwargs): + """更新 Team Manager 服务配置""" + svc = get_tm_service_by_id(db, service_id) + if not svc: + return None + for k, v in kwargs.items(): + setattr(svc, k, v) + db.commit() + db.refresh(svc) + return svc + + +def delete_tm_service(db: Session, service_id: int) -> bool: + """删除 Team Manager 服务配置""" + svc = get_tm_service_by_id(db, service_id) + if not svc: + return False + db.delete(svc) + db.commit() return True \ No newline at end of file diff --git a/src/database/models.py b/src/database/models.py index 7e4f52f..4f871de 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -144,6 +144,34 @@ class CpaService(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class Sub2ApiService(Base): + """Sub2API 服务配置表""" + __tablename__ = 'sub2api_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # 服务名称 + api_url = Column(String(500), nullable=False) # API URL (host) + api_key = Column(Text, nullable=False) # x-api-key + enabled = Column(Boolean, default=True) + priority = Column(Integer, default=0) # 优先级 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class TeamManagerService(Base): + """Team Manager 服务配置表""" + __tablename__ = 'tm_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # 服务名称 + api_url = Column(String(500), nullable=False) # API URL + api_key = Column(Text, nullable=False) # X-API-Key + enabled = Column(Boolean, default=True) + priority = Column(Integer, default=0) # 优先级 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class Proxy(Base): """代理列表表""" __tablename__ = 'proxies' diff --git a/src/services/__init__.py b/src/services/__init__.py index 7718a09..816d60b 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -12,13 +12,13 @@ from .base import ( ) from .tempmail import TempmailService from .outlook import OutlookService -from .custom_domain import CustomDomainEmailService +from .moe_mail import MeoMailEmailService from .temp_mail import TempMailService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) -EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, CustomDomainEmailService) +EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, MeoMailEmailService) EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) # 导出 Outlook 模块的额外内容 @@ -48,7 +48,7 @@ __all__ = [ # 服务类 'TempmailService', 'OutlookService', - 'CustomDomainEmailService', + 'MeoMailEmailService', 'TempMailService', # Outlook 模块 'ProviderType', diff --git a/src/services/custom_domain.py b/src/services/moe_mail.py similarity index 99% rename from src/services/custom_domain.py rename to src/services/moe_mail.py index 816447f..d5715a0 100644 --- a/src/services/custom_domain.py +++ b/src/services/moe_mail.py @@ -18,7 +18,7 @@ from ..config.constants import OTP_CODE_PATTERN logger = logging.getLogger(__name__) -class CustomDomainEmailService(BaseEmailService): +class MeoMailEmailService(BaseEmailService): """ 自定义域名邮箱服务 基于 REST API 接口 diff --git a/src/services/outlook_legacy.py b/src/services/outlook_legacy_mail.py similarity index 100% rename from src/services/outlook_legacy.py rename to src/services/outlook_legacy_mail.py diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index 4525212..8d6bcbd 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -298,16 +298,27 @@ class TempMailService(BaseEmailService): start_time = time.time() seen_mail_ids: set = set() + # 优先使用用户级 JWT,回退到 admin API + cached = self._email_cache.get(email, {}) + jwt = cached.get("jwt") + while time.time() - start_time < timeout: try: - # 使用 admin API 查询邮件,通过 address 参数过滤 - response = self._make_request( - "GET", - "/admin/mails", - params={"limit": 20, "offset": 0, "address": email}, - ) + if jwt: + response = self._make_request( + "GET", + "/user_api/mails", + params={"limit": 20, "offset": 0}, + headers={"x-user-token": jwt, "Content-Type": "application/json", "Accept": "application/json"}, + ) + else: + response = self._make_request( + "GET", + "/admin/mails", + params={"limit": 20, "offset": 0, "address": email}, + ) - # admin/mails 返回格式: {"results": [...], "total": N} + # /user_api/mails 和 /admin/mails 返回格式相同: {"results": [...], "total": N} mails = response.get("results", []) if not isinstance(mails, list): time.sleep(3) diff --git a/src/web/app.py b/src/web/app.py index 8bb9710..52e02d4 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -160,6 +160,13 @@ def create_app() -> FastAPI: async def startup_event(): """应用启动事件""" import asyncio + from ..database.init_db import initialize_database + + # 确保数据库已初始化(reload 模式下子进程也需要初始化) + try: + initialize_database() + except Exception as e: + logger.warning(f"数据库初始化: {e}") # 设置 TaskManager 的事件循环 loop = asyncio.get_event_loop() diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py index 3ccd6af..7748775 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -7,9 +7,11 @@ from fastapi import APIRouter from .accounts import router as accounts_router from .registration import router as registration_router from .settings import router as settings_router -from .email_services import router as email_services_router +from .email import router as email_services_router from .payment import router as payment_router -from .cpa_services import router as cpa_services_router +from .upload.cpa_services import router as cpa_services_router +from .upload.sub2api_services import router as sub2api_services_router +from .upload.tm_services import router as tm_services_router api_router = APIRouter() @@ -20,3 +22,5 @@ api_router.include_router(settings_router, prefix="/settings", tags=["settings"] api_router.include_router(email_services_router, prefix="/email-services", tags=["email-services"]) api_router.include_router(payment_router, prefix="/payment", tags=["payment"]) api_router.include_router(cpa_services_router, prefix="/cpa-services", tags=["cpa-services"]) +api_router.include_router(sub2api_services_router, prefix="/sub2api-services", tags=["sub2api-services"]) +api_router.include_router(tm_services_router, prefix="/tm-services", tags=["tm-services"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index e89be82..f0f2256 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -785,3 +785,106 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): results = batch_upload_to_cpa(ids, proxy, api_url=cpa_api_url, api_token=cpa_api_token) return results + + +class Sub2ApiUploadRequest(BaseModel): + """单账号 Sub2API 上传请求""" + service_id: Optional[int] = None + concurrency: int = 3 + priority: int = 50 + + +@router.post("/{account_id}/upload-sub2api") +async def upload_account_to_sub2api(account_id: int, request: Sub2ApiUploadRequest = None): + """上传单个账号到 Sub2API""" + from ...core.sub2api_upload import upload_to_sub2api + + service_id = request.service_id if request else None + concurrency = request.concurrency if request else 3 + priority = request.priority if request else 50 + + api_url = None + api_key = None + if service_id: + 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 服务不存在") + api_url = svc.api_url + api_key = svc.api_key + else: + with get_db() as db: + svcs = crud.get_sub2api_services(db, enabled=True) + if svcs: + api_url = svcs[0].api_url + api_key = svcs[0].api_key + + if not api_url or not api_key: + raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") + + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + if not account.access_token: + return {"success": False, "error": "账号缺少 Token,无法上传"} + + success, message = upload_to_sub2api( + [account], api_url, api_key, + concurrency=concurrency, priority=priority + ) + if success: + return {"success": True, "message": message} + else: + return {"success": False, "error": message} + + +class BatchSub2ApiUploadRequest(BaseModel): + """批量 Sub2API 上传请求""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + service_id: Optional[int] = None # 指定 Sub2API 服务 ID,不传则使用第一个启用的 + concurrency: int = 3 + priority: int = 50 + + +@router.post("/batch-upload-sub2api") +async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): + """批量上传账号到 Sub2API""" + from ...core.sub2api_upload import batch_upload_to_sub2api + + # 解析指定的 Sub2API 服务 + api_url = None + api_key = None + if request.service_id: + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, request.service_id) + if not svc: + raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在") + api_url = svc.api_url + api_key = svc.api_key + else: + with get_db() as db: + svcs = crud.get_sub2api_services(db, enabled=True) + if svcs: + api_url = svcs[0].api_url + api_key = svcs[0].api_key + + if not api_url or not api_key: + raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + results = batch_upload_to_sub2api( + ids, api_url, api_key, + concurrency=request.concurrency, + priority=request.priority, + ) + return results diff --git a/src/web/routes/email_services.py b/src/web/routes/email.py similarity index 100% rename from src/web/routes/email_services.py rename to src/web/routes/email.py diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index 94f5015..b8210f1 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -11,15 +11,16 @@ from pydantic import BaseModel from ...database.session import get_db from ...database.models import Account +from ...database import crud from ...config.settings import get_settings from .accounts import resolve_account_ids -from ...core.payment import ( +from ...core.openai.payment import ( generate_plus_link, generate_team_link, open_url_incognito, check_subscription_status, ) -from ...core.team_manager import ( +from ...core.upload.team_manager_upload import ( upload_to_team_manager, batch_upload_to_team_manager, ) @@ -61,12 +62,14 @@ class BatchCheckSubscriptionRequest(BaseModel): class UploadTMRequest(BaseModel): proxy: Optional[str] = None # 保留,TM 上传不走代理 + service_id: Optional[int] = None # 指定 TM 服务 ID,不传则使用第一个启用的 class BatchUploadTMRequest(BaseModel): ids: List[int] = [] select_all: bool = False status_filter: Optional[str] = None + service_id: Optional[int] = None # 指定 TM 服务 ID,不传则使用第一个启用的 email_service_filter: Optional[str] = None search_filter: Optional[str] = None @@ -200,14 +203,21 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): @router.post("/accounts/{account_id}/upload-tm") def upload_account_tm(account_id: int, request: UploadTMRequest = None): """上传单账号到 Team Manager""" - settings = get_settings() - if not settings.tm_enabled: - raise HTTPException(status_code=400, detail="Team Manager 上传未启用") - - api_url = settings.tm_api_url - api_key = settings.tm_api_key.get_secret_value() if settings.tm_api_key else "" + service_id = request.service_id if request and hasattr(request, 'service_id') else None with get_db() as db: + if service_id: + svc = crud.get_tm_service_by_id(db, service_id) + else: + svcs = crud.get_tm_services(db, enabled=True) + svc = svcs[0] if svcs else None + + if not svc: + raise HTTPException(status_code=400, detail="未找到可用的 Team Manager 服务,请先在设置中配置") + + api_url = svc.api_url + api_key = svc.api_key + account = db.query(Account).filter(Account.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") @@ -219,14 +229,21 @@ def upload_account_tm(account_id: int, request: UploadTMRequest = None): @router.post("/accounts/batch-upload-tm") def batch_upload_tm(request: BatchUploadTMRequest): """批量上传账号到 Team Manager""" - settings = get_settings() - if not settings.tm_enabled: - raise HTTPException(status_code=400, detail="Team Manager 上传未启用") - - api_url = settings.tm_api_url - api_key = settings.tm_api_key.get_secret_value() if settings.tm_api_key else "" + service_id = request.service_id if hasattr(request, 'service_id') else None with get_db() as db: + if service_id: + svc = crud.get_tm_service_by_id(db, service_id) + else: + svcs = crud.get_tm_services(db, enabled=True) + svc = svcs[0] if svcs else None + + if not svc: + raise HTTPException(status_code=400, detail="未找到可用的 Team Manager 服务,请先在设置中配置") + + api_url = svc.api_url + api_key = svc.api_key + ids = resolve_account_ids( db, request.ids, request.select_all, request.status_filter, request.email_service_filter, request.search_filter diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 30de00a..5ac1323 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -70,24 +70,32 @@ class RegistrationTaskCreate(BaseModel): email_service_type: str = "tempmail" proxy: Optional[str] = None email_service_config: Optional[dict] = None - email_service_id: Optional[int] = None # 使用数据库中已配置的邮箱服务 ID - auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA - cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置 + email_service_id: Optional[int] = None + auto_upload_cpa: bool = False + cpa_service_ids: List[int] = [] # 指定 CPA 服务 ID 列表,空则取第一个启用的 + auto_upload_sub2api: bool = False + sub2api_service_ids: List[int] = [] # 指定 Sub2API 服务 ID 列表 + auto_upload_tm: bool = False + tm_service_ids: List[int] = [] # 指定 TM 服务 ID 列表 class BatchRegistrationRequest(BaseModel): """批量注册请求""" - count: int = 1 # 注册数量 + count: int = 1 email_service_type: str = "tempmail" proxy: Optional[str] = None email_service_config: Optional[dict] = None - email_service_id: Optional[int] = None # 使用数据库中已配置的邮箱服务 ID - interval_min: int = 5 # 最小间隔秒数 - interval_max: int = 30 # 最大间隔秒数 - concurrency: int = 1 # 并发线程数 (1-50) - mode: str = "pipeline" # 执行模式: "parallel" 或 "pipeline" - auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA - cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置 + email_service_id: Optional[int] = None + interval_min: int = 5 + interval_max: int = 30 + concurrency: int = 1 + mode: str = "pipeline" + auto_upload_cpa: bool = False + cpa_service_ids: List[int] = [] + auto_upload_sub2api: bool = False + sub2api_service_ids: List[int] = [] + auto_upload_tm: bool = False + tm_service_ids: List[int] = [] class RegistrationTaskResponse(BaseModel): @@ -143,15 +151,19 @@ class OutlookAccountsListResponse(BaseModel): class OutlookBatchRegistrationRequest(BaseModel): """Outlook 批量注册请求""" - service_ids: List[int] # 选中的 EmailService ID - skip_registered: bool = True # 自动跳过已注册邮箱 + service_ids: List[int] + skip_registered: bool = True proxy: Optional[str] = None interval_min: int = 5 interval_max: int = 30 - concurrency: int = 1 # 并发线程数 (1-50) - mode: str = "pipeline" # 执行模式: "parallel" 或 "pipeline" - auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA - cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置 + concurrency: int = 1 + mode: str = "pipeline" + auto_upload_cpa: bool = False + cpa_service_ids: List[int] = [] + auto_upload_sub2api: bool = False + sub2api_service_ids: List[int] = [] + auto_upload_tm: bool = False + tm_service_ids: List[int] = [] class OutlookBatchRegistrationResponse(BaseModel): @@ -206,7 +218,7 @@ def _normalize_email_service_config( return normalized -def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_id: Optional[int] = None): +def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None): """ 在线程池中执行的同步注册任务 @@ -354,7 +366,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: # 保存到数据库 engine.save_to_database(result) - # 自动上传到 CPA + # 自动上传到 CPA(可多服务) if auto_upload_cpa: try: from ...core.cpa_upload import upload_to_cpa, generate_token_json @@ -362,33 +374,81 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: saved_account = db.query(AccountModel).filter_by(email=result.email).first() if saved_account and saved_account.access_token: token_data = generate_token_json(saved_account) - # 解析指定 CPA 服务,未指定则取第一个启用的服务 - _cpa_api_url = None - _cpa_api_token = None - _svc = None - if cpa_service_id: + _cpa_ids = cpa_service_ids or [] + if not _cpa_ids: + # 未指定则取所有启用的服务 + _cpa_ids = [s.id for s in crud.get_cpa_services(db, enabled=True)] + if not _cpa_ids: + log_callback("[CPA] 无可用 CPA 服务,跳过上传") + for _sid in _cpa_ids: try: - _svc = crud.get_cpa_service_by_id(db, cpa_service_id) - except Exception: - pass - if _svc is None: - svcs = crud.get_cpa_services(db, enabled=True) - _svc = svcs[0] if svcs else None - if _svc: - _cpa_api_url = _svc.api_url - _cpa_api_token = _svc.api_token - log_callback(f"[CPA] 使用服务: {_svc.name}") - cpa_success, cpa_msg = upload_to_cpa(token_data, api_url=_cpa_api_url, api_token=_cpa_api_token) - if cpa_success: - saved_account.cpa_uploaded = True - saved_account.cpa_uploaded_at = datetime.utcnow() - db.commit() - log_callback(f"[CPA] 已自动上传到 CPA: {result.email}") - else: - log_callback(f"[CPA] 上传失败: {cpa_msg}") + _svc = crud.get_cpa_service_by_id(db, _sid) + if not _svc: + continue + log_callback(f"[CPA] 上传到服务: {_svc.name}") + _ok, _msg = upload_to_cpa(token_data, api_url=_svc.api_url, api_token=_svc.api_token) + if _ok: + saved_account.cpa_uploaded = True + saved_account.cpa_uploaded_at = datetime.utcnow() + db.commit() + log_callback(f"[CPA] 上传成功: {_svc.name}") + else: + log_callback(f"[CPA] 上传失败({_svc.name}): {_msg}") + except Exception as _e: + log_callback(f"[CPA] 异常({_sid}): {_e}") except Exception as cpa_err: log_callback(f"[CPA] 上传异常: {cpa_err}") + # 自动上传到 Sub2API(可多服务) + if auto_upload_sub2api: + try: + from ...core.sub2api_upload import upload_to_sub2api + from ...database.models import Account as AccountModel + saved_account = db.query(AccountModel).filter_by(email=result.email).first() + if saved_account and saved_account.access_token: + _s2a_ids = sub2api_service_ids or [] + if not _s2a_ids: + _s2a_ids = [s.id for s in crud.get_sub2api_services(db, enabled=True)] + if not _s2a_ids: + log_callback("[Sub2API] 无可用 Sub2API 服务,跳过上传") + for _sid in _s2a_ids: + try: + _svc = crud.get_sub2api_service_by_id(db, _sid) + if not _svc: + continue + log_callback(f"[Sub2API] 上传到服务: {_svc.name}") + _ok, _msg = upload_to_sub2api([saved_account], _svc.api_url, _svc.api_key) + log_callback(f"[Sub2API] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") + except Exception as _e: + log_callback(f"[Sub2API] 异常({_sid}): {_e}") + except Exception as s2a_err: + log_callback(f"[Sub2API] 上传异常: {s2a_err}") + + # 自动上传到 Team Manager(可多服务) + if auto_upload_tm: + try: + from ...core.team_manager import upload_account_to_tm + from ...database.models import Account as AccountModel + saved_account = db.query(AccountModel).filter_by(email=result.email).first() + if saved_account and saved_account.access_token: + _tm_ids = tm_service_ids or [] + if not _tm_ids: + _tm_ids = [s.id for s in crud.get_tm_services(db, enabled=True)] + if not _tm_ids: + log_callback("[TM] 无可用 Team Manager 服务,跳过上传") + for _sid in _tm_ids: + try: + _svc = crud.get_tm_service_by_id(db, _sid) + if not _svc: + continue + log_callback(f"[TM] 上传到服务: {_svc.name}") + _ok, _msg = upload_account_to_tm(saved_account, _svc.api_url, _svc.api_key) + log_callback(f"[TM] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") + except Exception as _e: + log_callback(f"[TM] 异常({_sid}): {_e}") + except Exception as tm_err: + log_callback(f"[TM] 上传异常: {tm_err}") + # 更新任务状态 crud.update_registration_task( db, task_uuid, @@ -433,7 +493,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: pass -async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_id: Optional[int] = None): +async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None): """ 异步执行注册任务 @@ -461,7 +521,11 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: log_prefix, batch_id, auto_upload_cpa, - cpa_service_id + cpa_service_ids or [], + auto_upload_sub2api, + sub2api_service_ids or [], + auto_upload_tm, + tm_service_ids or [], ) except Exception as e: logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}") @@ -509,7 +573,11 @@ async def run_batch_parallel( email_service_id: Optional[int], concurrency: int, auto_upload_cpa: bool = False, - cpa_service_id: Optional[int] = None + cpa_service_ids: List[int] = None, + auto_upload_sub2api: bool = False, + sub2api_service_ids: List[int] = None, + auto_upload_tm: bool = False, + tm_service_ids: List[int] = None, ): """ 并行模式:所有任务同时提交,Semaphore 控制最大并发数 @@ -525,8 +593,10 @@ async def run_batch_parallel( async with semaphore: await run_registration_task( uuid, email_service_type, proxy, email_service_config, email_service_id, - log_prefix=prefix, batch_id=batch_id, auto_upload_cpa=auto_upload_cpa, - cpa_service_id=cpa_service_id + log_prefix=prefix, batch_id=batch_id, + auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [], + auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], + auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [], ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -569,7 +639,11 @@ async def run_batch_pipeline( interval_max: int, concurrency: int, auto_upload_cpa: bool = False, - cpa_service_id: Optional[int] = None + cpa_service_ids: List[int] = None, + auto_upload_sub2api: bool = False, + sub2api_service_ids: List[int] = None, + auto_upload_tm: bool = False, + tm_service_ids: List[int] = None, ): """ 流水线模式:每隔 interval 秒启动一个新任务,Semaphore 限制最大并发数 @@ -585,8 +659,10 @@ async def run_batch_pipeline( try: await run_registration_task( uuid, email_service_type, proxy, email_service_config, email_service_id, - log_prefix=pfx, batch_id=batch_id, auto_upload_cpa=auto_upload_cpa, - cpa_service_id=cpa_service_id + log_prefix=pfx, batch_id=batch_id, + auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [], + auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], + auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [], ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -653,21 +729,29 @@ async def run_batch_registration( concurrency: int = 1, mode: str = "pipeline", auto_upload_cpa: bool = False, - cpa_service_id: Optional[int] = None + cpa_service_ids: List[int] = None, + auto_upload_sub2api: bool = False, + sub2api_service_ids: List[int] = None, + auto_upload_tm: bool = False, + tm_service_ids: List[int] = None, ): """根据 mode 分发到并行或流水线执行""" if mode == "parallel": await run_batch_parallel( batch_id, task_uuids, email_service_type, proxy, email_service_config, email_service_id, concurrency, - auto_upload_cpa=auto_upload_cpa, cpa_service_id=cpa_service_id + auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids, + auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids, + auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, ) else: await run_batch_pipeline( batch_id, task_uuids, email_service_type, proxy, email_service_config, email_service_id, interval_min, interval_max, concurrency, - auto_upload_cpa=auto_upload_cpa, cpa_service_id=cpa_service_id + auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids, + auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids, + auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, ) @@ -715,7 +799,11 @@ async def start_registration( "", "", request.auto_upload_cpa, - request.cpa_service_id + request.cpa_service_ids, + request.auto_upload_sub2api, + request.sub2api_service_ids, + request.auto_upload_tm, + request.tm_service_ids, ) return task_to_response(task) @@ -788,7 +876,11 @@ async def start_batch_registration( request.concurrency, request.mode, request.auto_upload_cpa, - request.cpa_service_id + request.cpa_service_ids, + request.auto_upload_sub2api, + request.sub2api_service_ids, + request.auto_upload_tm, + request.tm_service_ids, ) return BatchRegistrationResponse( @@ -1118,7 +1210,11 @@ async def run_outlook_batch_registration( concurrency: int = 1, mode: str = "pipeline", auto_upload_cpa: bool = False, - cpa_service_id: Optional[int] = None + cpa_service_ids: List[int] = None, + auto_upload_sub2api: bool = False, + sub2api_service_ids: List[int] = None, + auto_upload_tm: bool = False, + tm_service_ids: List[int] = None, ): """ 异步执行 Outlook 批量注册任务,复用通用并发逻辑 @@ -1157,7 +1253,11 @@ async def run_outlook_batch_registration( concurrency=concurrency, mode=mode, auto_upload_cpa=auto_upload_cpa, - cpa_service_id=cpa_service_id + cpa_service_ids=cpa_service_ids, + auto_upload_sub2api=auto_upload_sub2api, + sub2api_service_ids=sub2api_service_ids, + auto_upload_tm=auto_upload_tm, + tm_service_ids=tm_service_ids, ) @@ -1257,7 +1357,11 @@ async def start_outlook_batch_registration( request.concurrency, request.mode, request.auto_upload_cpa, - request.cpa_service_id + request.cpa_service_ids, + request.auto_upload_sub2api, + request.sub2api_service_ids, + request.auto_upload_tm, + request.tm_service_ids, ) return OutlookBatchRegistrationResponse( diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 63bc107..9b7f47b 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -3,14 +3,15 @@ """ import logging -from typing import Optional, Dict, Any, List +import os +from typing import Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from ...config.settings import get_settings, update_settings from ...database import crud from ...database.session import get_db -from ...config.settings import get_settings, update_settings logger = logging.getLogger(__name__) router = APIRouter() @@ -289,6 +290,7 @@ async def backup_database(): raise HTTPException(status_code=404, detail="数据库文件不存在") # 创建备份目录 + from fastapi import Path backup_dir = Path(db_path).parent / "backups" backup_dir.mkdir(exist_ok=True) diff --git a/src/web/routes/upload/__init__.py b/src/web/routes/upload/__init__.py new file mode 100644 index 0000000..1f776fc --- /dev/null +++ b/src/web/routes/upload/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/src/web/routes/cpa_services.py b/src/web/routes/upload/cpa_services.py similarity index 97% rename from src/web/routes/cpa_services.py rename to src/web/routes/upload/cpa_services.py index cc80375..f98ec2f 100644 --- a/src/web/routes/cpa_services.py +++ b/src/web/routes/upload/cpa_services.py @@ -6,9 +6,9 @@ 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.cpa_upload import test_cpa_connection +from ....database import crud +from ....database.session import get_db +from ....core.upload.cpa_upload import test_cpa_connection router = APIRouter() diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py new file mode 100644 index 0000000..ddd7759 --- /dev/null +++ b/src/web/routes/upload/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.upload.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/upload/tm_services.py b/src/web/routes/upload/tm_services.py new file mode 100644 index 0000000..b363139 --- /dev/null +++ b/src/web/routes/upload/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.upload.team_manager_upload 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} diff --git a/static/js/accounts.js b/static/js/accounts.js index e0f6aab..3bf73e6 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -25,9 +25,8 @@ const elements = { refreshBtn: document.getElementById('refresh-btn'), batchRefreshBtn: document.getElementById('batch-refresh-btn'), batchValidateBtn: document.getElementById('batch-validate-btn'), - batchUploadCpaBtn: document.getElementById('batch-upload-cpa-btn'), + batchUploadBtn: document.getElementById('batch-upload-btn'), batchCheckSubBtn: document.getElementById('batch-check-sub-btn'), - batchUploadTmBtn: document.getElementById('batch-upload-tm-btn'), batchDeleteBtn: document.getElementById('batch-delete-btn'), exportBtn: document.getElementById('export-btn'), exportMenu: document.getElementById('export-menu'), @@ -94,14 +93,13 @@ function initEventListeners() { // 批量验证Token elements.batchValidateBtn.addEventListener('click', handleBatchValidate); - // 批量上传CPA - elements.batchUploadCpaBtn.addEventListener('click', handleBatchUploadCpa); - // 批量检测订阅 elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription); - // 批量上传TM - elements.batchUploadTmBtn.addEventListener('click', handleBatchUploadTm); + // 上传下拉菜单 + document.getElementById('batch-upload-cpa-item').addEventListener('click', (e) => { e.preventDefault(); handleBatchUploadCpa(); }); + document.getElementById('batch-upload-sub2api-item').addEventListener('click', (e) => { e.preventDefault(); handleBatchUploadSub2Api(); }); + document.getElementById('batch-upload-tm-item').addEventListener('click', (e) => { e.preventDefault(); handleBatchUploadTm(); }); // 批量删除 elements.batchDeleteBtn.addEventListener('click', handleBatchDelete); @@ -320,15 +318,12 @@ function renderAccounts(accounts) { - - @@ -465,17 +460,15 @@ function updateBatchButtons() { elements.batchDeleteBtn.disabled = count === 0; elements.batchRefreshBtn.disabled = count === 0; elements.batchValidateBtn.disabled = count === 0; - elements.batchUploadCpaBtn.disabled = count === 0; + elements.batchUploadBtn.disabled = count === 0; elements.batchCheckSubBtn.disabled = count === 0; - elements.batchUploadTmBtn.disabled = count === 0; elements.exportBtn.disabled = count === 0; elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除'; elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token'; elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token'; - elements.batchUploadCpaBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传CPA'; + elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传'; elements.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅'; - elements.batchUploadTmBtn.textContent = count > 0 ? `🚀 上传TM (${count})` : '🚀 上传TM'; } // 刷新单个账号Token @@ -810,6 +803,43 @@ function selectCpaService() { }); } +// 统一上传入口:弹出目标选择 +async function uploadAccount(id) { + const targets = [ + { label: '☁️ 上传到 CPA', value: 'cpa' }, + { label: '🔗 上传到 Sub2API', value: 'sub2api' }, + { label: '🚀 上传到 Team Manager', value: 'tm' }, + ]; + + const choice = await new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + modal.querySelector('#_upload-close').addEventListener('click', () => { modal.remove(); resolve(null); }); + modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); resolve(null); } }); + modal.querySelectorAll('button[data-val]').forEach(btn => { + btn.addEventListener('click', () => { modal.remove(); resolve(btn.dataset.val); }); + }); + }); + + if (!choice) return; + if (choice === 'cpa') return uploadToCpa(id); + if (choice === 'sub2api') return uploadToSub2Api(id); + if (choice === 'tm') return uploadToTm(id); +} + // 上传单个账号到CPA async function uploadToCpa(id) { const choice = await selectCpaService(); @@ -843,8 +873,8 @@ async function handleBatchUploadCpa() { const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到CPA吗?`); if (!confirmed) return; - elements.batchUploadCpaBtn.disabled = true; - elements.batchUploadCpaBtn.textContent = '上传中...'; + elements.batchUploadBtn.disabled = true; + elements.batchUploadBtn.textContent = '上传中...'; try { const payload = buildBatchPayload(); @@ -908,8 +938,129 @@ async function handleBatchCheckSubscription() { } } +// ============== Sub2API 上传 ============== + +// 弹出 Sub2API 服务选择框,返回 Promise<{service_id: number|null}|null> +// null 表示用户取消,{service_id: null} 表示自动选择 +function selectSub2ApiService() { + return new Promise(async (resolve) => { + const modal = document.getElementById('sub2api-service-modal'); + const listEl = document.getElementById('sub2api-service-list'); + const closeBtn = document.getElementById('close-sub2api-modal'); + const cancelBtn = document.getElementById('cancel-sub2api-modal-btn'); + const autoBtn = document.getElementById('sub2api-use-auto-btn'); + + listEl.innerHTML = '
加载中...
'; + modal.classList.add('active'); + + let services = []; + try { + services = await api.get('/sub2api-services?enabled=true'); + } catch (e) { + services = []; + } + + if (services.length === 0) { + listEl.innerHTML = '
暂无已启用的 Sub2API 服务,将自动选择第一个
'; + } else { + listEl.innerHTML = services.map(s => ` +
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+ 选择 +
+ `).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({ service_id: parseInt(item.dataset.id) }); + }); + }); + } + + function cleanup() { + modal.classList.remove('active'); + closeBtn.removeEventListener('click', onCancel); + cancelBtn.removeEventListener('click', onCancel); + autoBtn.removeEventListener('click', onAuto); + } + function onCancel() { cleanup(); resolve(null); } + function onAuto() { cleanup(); resolve({ service_id: null }); } + + closeBtn.addEventListener('click', onCancel); + cancelBtn.addEventListener('click', onCancel); + autoBtn.addEventListener('click', onAuto); + }); +} + +// 批量上传到 Sub2API +async function handleBatchUploadSub2Api() { + const count = getEffectiveCount(); + if (count === 0) return; + + const choice = await selectSub2ApiService(); + if (choice === null) return; // 用户取消 + + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`); + if (!confirmed) return; + + elements.batchUploadBtn.disabled = true; + elements.batchUploadBtn.textContent = '上传中...'; + + try { + const payload = buildBatchPayload(); + if (choice.service_id != null) payload.service_id = choice.service_id; + const result = await api.post('/accounts/batch-upload-sub2api', payload); + + let message = `成功: ${result.success_count}`; + if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; + if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`; + + toast.success(message); + loadAccounts(); + } catch (error) { + toast.error('批量上传失败: ' + error.message); + } finally { + updateBatchButtons(); + } +} + // ============== Team Manager 上传 ============== +// 上传单账号到 Sub2API +async function uploadToSub2Api(id) { + const choice = await selectSub2ApiService(); + if (choice === null) return; + try { + toast.info('正在上传到 Sub2API...'); + const payload = {}; + if (choice.service_id != null) payload.service_id = choice.service_id; + const result = await api.post(`/accounts/${id}/upload-sub2api`, payload); + if (result.success) { + toast.success('上传成功'); + loadAccounts(); + } else { + toast.error('上传失败: ' + (result.error || result.message || '未知错误')); + } + } catch (e) { + toast.error('上传失败: ' + e.message); + } +} + // 上传单账号到 Team Manager async function uploadToTm(id) { try { @@ -932,8 +1083,8 @@ async function handleBatchUploadTm() { const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Team Manager 吗?`); if (!confirmed) return; - elements.batchUploadTmBtn.disabled = true; - elements.batchUploadTmBtn.textContent = '上传中...'; + elements.batchUploadBtn.disabled = true; + elements.batchUploadBtn.textContent = '上传中...'; try { const result = await api.post('/payment/accounts/batch-upload-tm', buildBatchPayload()); diff --git a/static/js/app.js b/static/js/app.js index bdac53d..73e8878 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -85,7 +85,13 @@ const elements = { // 注册后自动操作 autoUploadCpa: document.getElementById('auto-upload-cpa'), cpaServiceSelectGroup: document.getElementById('cpa-service-select-group'), - cpaServiceSelect: document.getElementById('cpa-service-select') + cpaServiceSelect: document.getElementById('cpa-service-select'), + autoUploadSub2api: document.getElementById('auto-upload-sub2api'), + sub2apiServiceSelectGroup: document.getElementById('sub2api-service-select-group'), + sub2apiServiceSelect: document.getElementById('sub2api-service-select'), + autoUploadTm: document.getElementById('auto-upload-tm'), + tmServiceSelectGroup: document.getElementById('tm-service-select-group'), + tmServiceSelect: document.getElementById('tm-service-select'), }; // 初始化 @@ -96,48 +102,85 @@ document.addEventListener('DOMContentLoaded', () => { startAccountsPolling(); initVisibilityReconnect(); restoreActiveTask(); - checkCpaEnabled(); + initAutoUploadOptions(); }); -// 检查 CPA 是否启用,未启用则禁用复选框;同时加载 CPA 服务列表 -async function checkCpaEnabled() { - if (!elements.autoUploadCpa) return; - // 加载 CPA 服务列表,列表为空则禁用复选框 - await loadCpaServiceOptions(); - try { - const services = await api.get('/cpa-services?enabled=true'); - if (!services || services.length === 0) { - elements.autoUploadCpa.disabled = true; - elements.autoUploadCpa.title = '请先在设置中添加 CPA 服务'; - const label = elements.autoUploadCpa.closest('label'); - if (label) label.style.opacity = '0.5'; - } - } catch (e) { - elements.autoUploadCpa.disabled = true; - } - // 复选框联动显示/隐藏服务选择器 - if (elements.autoUploadCpa) { - elements.autoUploadCpa.addEventListener('change', () => { - if (elements.cpaServiceSelectGroup) { - elements.cpaServiceSelectGroup.style.display = - elements.autoUploadCpa.checked ? 'block' : 'none'; - } - }); - } +// 初始化注册后自动操作选项(CPA / Sub2API / TM) +async function initAutoUploadOptions() { + await Promise.all([ + loadServiceSelect('/cpa-services?enabled=true', elements.cpaServiceSelect, elements.autoUploadCpa, elements.cpaServiceSelectGroup), + loadServiceSelect('/sub2api-services?enabled=true', elements.sub2apiServiceSelect, elements.autoUploadSub2api, elements.sub2apiServiceSelectGroup), + loadServiceSelect('/tm-services?enabled=true', elements.tmServiceSelect, elements.autoUploadTm, elements.tmServiceSelectGroup), + ]); } -async function loadCpaServiceOptions() { - if (!elements.cpaServiceSelect) return; +// 通用:构建自定义多选下拉组件并处理联动 +async function loadServiceSelect(apiPath, container, checkbox, selectGroup) { + if (!checkbox || !container) return; + let services = []; try { - const services = await api.get('/cpa-services?enabled=true'); - const defaultOpt = ''; - const opts = services.map(s => - `