Merge branch 'master' into fix/temp-mail-registration-flow

This commit is contained in:
演变
2026-03-18 23:48:28 +08:00
committed by GitHub
36 changed files with 1977 additions and 288 deletions

View File

@@ -15,6 +15,7 @@ dependencies = [
"aiosqlite>=0.19.0", "aiosqlite>=0.19.0",
"psycopg[binary]>=3.1.18", "psycopg[binary]>=3.1.18",
"websockets>=16.0", "websockets>=16.0",
"path>=17.1.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -514,7 +514,8 @@ def init_default_settings() -> None:
) )
print(f"[Settings] 初始化默认设置: {defn.db_key} = {default_value if not defn.is_secret else '***'}") print(f"[Settings] 初始化默认设置: {defn.db_key} = {default_value if not defn.is_secret else '***'}")
except Exception as e: 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]: 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 settings_dict["webui_access_password"] = env_password
return settings_dict return settings_dict
except Exception as e: 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()} 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 description=defn.description
) )
except Exception as e: except Exception as e:
print(f"[Settings] 保存设置到数据库失败: {e}") if "未初始化" not in str(e):
print(f"[Settings] 保存设置到数据库失败: {e}")
class Settings(BaseModel): class Settings(BaseModel):

View File

@@ -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 ( from .http_client import (
OpenAIHTTPClient, OpenAIHTTPClient,
HTTPClient, HTTPClient,

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# @Time : 2026/3/18 19:55

View File

@@ -14,7 +14,7 @@ from typing import Any, Dict, Optional
from curl_cffi import requests as cffi_requests from curl_cffi import requests as cffi_requests
from ..config.constants import ( from ...config.constants import (
OAUTH_CLIENT_ID, OAUTH_CLIENT_ID,
OAUTH_AUTH_URL, OAUTH_AUTH_URL,
OAUTH_TOKEN_URL, OAUTH_TOKEN_URL,

View File

@@ -9,7 +9,7 @@ from typing import Optional
from curl_cffi import requests as cffi_requests from curl_cffi import requests as cffi_requests
from ..database.models import Account from ...database.models import Account
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -12,10 +12,10 @@ from datetime import datetime, timedelta
from curl_cffi import requests as cffi_requests from curl_cffi import requests as cffi_requests
from ..config.settings import get_settings from ...config.settings import get_settings
from ..database.session import get_db from ...database.session import get_db
from ..database import crud from ...database import crud
from ..database.models import Account from ...database.models import Account
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -15,7 +15,7 @@ from datetime import datetime
from curl_cffi import requests as cffi_requests 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 .http_client import OpenAIHTTPClient, HTTPClientError
from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType
from ..database import crud from ..database import crud

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# @Time : 2026/3/18 19:54

View File

@@ -10,9 +10,9 @@ from datetime import datetime
from curl_cffi import requests as cffi_requests from curl_cffi import requests as cffi_requests
from curl_cffi import CurlMime from curl_cffi import CurlMime
from ..database.session import get_db from ...database.session import get_db
from ..database.models import Account from ...database.models import Account
from ..config.settings import get_settings from ...config.settings import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -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 Keyx-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)}"

View File

@@ -9,9 +9,9 @@ from datetime import datetime
from curl_cffi import requests as cffi_requests from curl_cffi import requests as cffi_requests
from ..database.session import get_db from ...database.session import get_db
from ..database.models import Account from ...database.models import Account
from ..config.settings import get_settings from ...config.settings import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, desc, asc, func 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 return False
db.delete(db_service) db.delete(db_service)
db.commit() 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 return True

View File

@@ -144,6 +144,34 @@ class CpaService(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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): class Proxy(Base):
"""代理列表表""" """代理列表表"""
__tablename__ = 'proxies' __tablename__ = 'proxies'

View File

@@ -12,13 +12,13 @@ from .base import (
) )
from .tempmail import TempmailService from .tempmail import TempmailService
from .outlook import OutlookService from .outlook import OutlookService
from .custom_domain import CustomDomainEmailService from .moe_mail import MeoMailEmailService
from .temp_mail import TempMailService from .temp_mail import TempMailService
# 注册服务 # 注册服务
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, CustomDomainEmailService) EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, MeoMailEmailService)
EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
# 导出 Outlook 模块的额外内容 # 导出 Outlook 模块的额外内容
@@ -48,7 +48,7 @@ __all__ = [
# 服务类 # 服务类
'TempmailService', 'TempmailService',
'OutlookService', 'OutlookService',
'CustomDomainEmailService', 'MeoMailEmailService',
'TempMailService', 'TempMailService',
# Outlook 模块 # Outlook 模块
'ProviderType', 'ProviderType',

View File

@@ -18,7 +18,7 @@ from ..config.constants import OTP_CODE_PATTERN
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CustomDomainEmailService(BaseEmailService): class MeoMailEmailService(BaseEmailService):
""" """
自定义域名邮箱服务 自定义域名邮箱服务
基于 REST API 接口 基于 REST API 接口

View File

@@ -298,16 +298,27 @@ class TempMailService(BaseEmailService):
start_time = time.time() start_time = time.time()
seen_mail_ids: set = set() seen_mail_ids: set = set()
# 优先使用用户级 JWT回退到 admin API
cached = self._email_cache.get(email, {})
jwt = cached.get("jwt")
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
try: try:
# 使用 admin API 查询邮件,通过 address 参数过滤 if jwt:
response = self._make_request( response = self._make_request(
"GET", "GET",
"/admin/mails", "/user_api/mails",
params={"limit": 20, "offset": 0, "address": email}, 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", []) mails = response.get("results", [])
if not isinstance(mails, list): if not isinstance(mails, list):
time.sleep(3) time.sleep(3)

View File

@@ -160,6 +160,13 @@ def create_app() -> FastAPI:
async def startup_event(): async def startup_event():
"""应用启动事件""" """应用启动事件"""
import asyncio import asyncio
from ..database.init_db import initialize_database
# 确保数据库已初始化reload 模式下子进程也需要初始化)
try:
initialize_database()
except Exception as e:
logger.warning(f"数据库初始化: {e}")
# 设置 TaskManager 的事件循环 # 设置 TaskManager 的事件循环
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

View File

@@ -7,9 +7,11 @@ from fastapi import APIRouter
from .accounts import router as accounts_router from .accounts import router as accounts_router
from .registration import router as registration_router from .registration import router as registration_router
from .settings import router as settings_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 .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() 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(email_services_router, prefix="/email-services", tags=["email-services"])
api_router.include_router(payment_router, prefix="/payment", tags=["payment"]) 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(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"])

View File

@@ -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) results = batch_upload_to_cpa(ids, proxy, api_url=cpa_api_url, api_token=cpa_api_token)
return results 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

View File

@@ -11,15 +11,16 @@ from pydantic import BaseModel
from ...database.session import get_db from ...database.session import get_db
from ...database.models import Account from ...database.models import Account
from ...database import crud
from ...config.settings import get_settings from ...config.settings import get_settings
from .accounts import resolve_account_ids from .accounts import resolve_account_ids
from ...core.payment import ( from ...core.openai.payment import (
generate_plus_link, generate_plus_link,
generate_team_link, generate_team_link,
open_url_incognito, open_url_incognito,
check_subscription_status, check_subscription_status,
) )
from ...core.team_manager import ( from ...core.upload.team_manager_upload import (
upload_to_team_manager, upload_to_team_manager,
batch_upload_to_team_manager, batch_upload_to_team_manager,
) )
@@ -61,12 +62,14 @@ class BatchCheckSubscriptionRequest(BaseModel):
class UploadTMRequest(BaseModel): class UploadTMRequest(BaseModel):
proxy: Optional[str] = None # 保留TM 上传不走代理 proxy: Optional[str] = None # 保留TM 上传不走代理
service_id: Optional[int] = None # 指定 TM 服务 ID不传则使用第一个启用的
class BatchUploadTMRequest(BaseModel): class BatchUploadTMRequest(BaseModel):
ids: List[int] = [] ids: List[int] = []
select_all: bool = False select_all: bool = False
status_filter: Optional[str] = None status_filter: Optional[str] = None
service_id: Optional[int] = None # 指定 TM 服务 ID不传则使用第一个启用的
email_service_filter: Optional[str] = None email_service_filter: Optional[str] = None
search_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") @router.post("/accounts/{account_id}/upload-tm")
def upload_account_tm(account_id: int, request: UploadTMRequest = None): def upload_account_tm(account_id: int, request: UploadTMRequest = None):
"""上传单账号到 Team Manager""" """上传单账号到 Team Manager"""
settings = get_settings() service_id = request.service_id if request and hasattr(request, 'service_id') else None
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 ""
with get_db() as db: 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() account = db.query(Account).filter(Account.id == account_id).first()
if not account: if not account:
raise HTTPException(status_code=404, detail="账号不存在") 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") @router.post("/accounts/batch-upload-tm")
def batch_upload_tm(request: BatchUploadTMRequest): def batch_upload_tm(request: BatchUploadTMRequest):
"""批量上传账号到 Team Manager""" """批量上传账号到 Team Manager"""
settings = get_settings() service_id = request.service_id if hasattr(request, 'service_id') else None
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 ""
with get_db() as db: 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( ids = resolve_account_ids(
db, request.ids, request.select_all, db, request.ids, request.select_all,
request.status_filter, request.email_service_filter, request.search_filter request.status_filter, request.email_service_filter, request.search_filter

View File

@@ -70,24 +70,32 @@ class RegistrationTaskCreate(BaseModel):
email_service_type: str = "tempmail" email_service_type: str = "tempmail"
proxy: Optional[str] = None proxy: Optional[str] = None
email_service_config: Optional[dict] = None email_service_config: Optional[dict] = None
email_service_id: Optional[int] = None # 使用数据库中已配置的邮箱服务 ID email_service_id: Optional[int] = None
auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA auto_upload_cpa: bool = False
cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置 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): class BatchRegistrationRequest(BaseModel):
"""批量注册请求""" """批量注册请求"""
count: int = 1 # 注册数量 count: int = 1
email_service_type: str = "tempmail" email_service_type: str = "tempmail"
proxy: Optional[str] = None proxy: Optional[str] = None
email_service_config: Optional[dict] = None email_service_config: Optional[dict] = None
email_service_id: Optional[int] = None # 使用数据库中已配置的邮箱服务 ID email_service_id: Optional[int] = None
interval_min: int = 5 # 最小间隔秒数 interval_min: int = 5
interval_max: int = 30 # 最大间隔秒数 interval_max: int = 30
concurrency: int = 1 # 并发线程数 (1-50) concurrency: int = 1
mode: str = "pipeline" # 执行模式: "parallel" 或 "pipeline" mode: str = "pipeline"
auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA auto_upload_cpa: bool = False
cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID不传则使用全局配置 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): class RegistrationTaskResponse(BaseModel):
@@ -143,15 +151,19 @@ class OutlookAccountsListResponse(BaseModel):
class OutlookBatchRegistrationRequest(BaseModel): class OutlookBatchRegistrationRequest(BaseModel):
"""Outlook 批量注册请求""" """Outlook 批量注册请求"""
service_ids: List[int] # 选中的 EmailService ID service_ids: List[int]
skip_registered: bool = True # 自动跳过已注册邮箱 skip_registered: bool = True
proxy: Optional[str] = None proxy: Optional[str] = None
interval_min: int = 5 interval_min: int = 5
interval_max: int = 30 interval_max: int = 30
concurrency: int = 1 # 并发线程数 (1-50) concurrency: int = 1
mode: str = "pipeline" # 执行模式: "parallel" 或 "pipeline" mode: str = "pipeline"
auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA auto_upload_cpa: bool = False
cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID不传则使用全局配置 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): class OutlookBatchRegistrationResponse(BaseModel):
@@ -206,7 +218,7 @@ def _normalize_email_service_config(
return normalized 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) engine.save_to_database(result)
# 自动上传到 CPA # 自动上传到 CPA(可多服务)
if auto_upload_cpa: if auto_upload_cpa:
try: try:
from ...core.cpa_upload import upload_to_cpa, generate_token_json 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() saved_account = db.query(AccountModel).filter_by(email=result.email).first()
if saved_account and saved_account.access_token: if saved_account and saved_account.access_token:
token_data = generate_token_json(saved_account) token_data = generate_token_json(saved_account)
# 解析指定 CPA 服务,未指定则取第一个启用的服务 _cpa_ids = cpa_service_ids or []
_cpa_api_url = None if not _cpa_ids:
_cpa_api_token = None # 未指定则取所有启用的服务
_svc = None _cpa_ids = [s.id for s in crud.get_cpa_services(db, enabled=True)]
if cpa_service_id: if not _cpa_ids:
log_callback("[CPA] 无可用 CPA 服务,跳过上传")
for _sid in _cpa_ids:
try: try:
_svc = crud.get_cpa_service_by_id(db, cpa_service_id) _svc = crud.get_cpa_service_by_id(db, _sid)
except Exception: if not _svc:
pass continue
if _svc is None: log_callback(f"[CPA] 上传到服务: {_svc.name}")
svcs = crud.get_cpa_services(db, enabled=True) _ok, _msg = upload_to_cpa(token_data, api_url=_svc.api_url, api_token=_svc.api_token)
_svc = svcs[0] if svcs else None if _ok:
if _svc: saved_account.cpa_uploaded = True
_cpa_api_url = _svc.api_url saved_account.cpa_uploaded_at = datetime.utcnow()
_cpa_api_token = _svc.api_token db.commit()
log_callback(f"[CPA] 使用服务: {_svc.name}") 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) else:
if cpa_success: log_callback(f"[CPA] 上传失败({_svc.name}): {_msg}")
saved_account.cpa_uploaded = True except Exception as _e:
saved_account.cpa_uploaded_at = datetime.utcnow() log_callback(f"[CPA] 异常({_sid}): {_e}")
db.commit()
log_callback(f"[CPA] 已自动上传到 CPA: {result.email}")
else:
log_callback(f"[CPA] 上传失败: {cpa_msg}")
except Exception as cpa_err: except Exception as cpa_err:
log_callback(f"[CPA] 上传异常: {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( crud.update_registration_task(
db, task_uuid, db, task_uuid,
@@ -433,7 +493,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
pass 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, log_prefix,
batch_id, batch_id,
auto_upload_cpa, 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: except Exception as e:
logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}") logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}")
@@ -509,7 +573,11 @@ async def run_batch_parallel(
email_service_id: Optional[int], email_service_id: Optional[int],
concurrency: int, concurrency: int,
auto_upload_cpa: bool = False, 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 控制最大并发数 并行模式所有任务同时提交Semaphore 控制最大并发数
@@ -525,8 +593,10 @@ async def run_batch_parallel(
async with semaphore: async with semaphore:
await run_registration_task( await run_registration_task(
uuid, email_service_type, proxy, email_service_config, email_service_id, uuid, email_service_type, proxy, email_service_config, email_service_id,
log_prefix=prefix, batch_id=batch_id, auto_upload_cpa=auto_upload_cpa, log_prefix=prefix, batch_id=batch_id,
cpa_service_id=cpa_service_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: with get_db() as db:
t = crud.get_registration_task(db, uuid) t = crud.get_registration_task(db, uuid)
@@ -569,7 +639,11 @@ async def run_batch_pipeline(
interval_max: int, interval_max: int,
concurrency: int, concurrency: int,
auto_upload_cpa: bool = False, 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 限制最大并发数 流水线模式:每隔 interval 秒启动一个新任务Semaphore 限制最大并发数
@@ -585,8 +659,10 @@ async def run_batch_pipeline(
try: try:
await run_registration_task( await run_registration_task(
uuid, email_service_type, proxy, email_service_config, email_service_id, uuid, email_service_type, proxy, email_service_config, email_service_id,
log_prefix=pfx, batch_id=batch_id, auto_upload_cpa=auto_upload_cpa, log_prefix=pfx, batch_id=batch_id,
cpa_service_id=cpa_service_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: with get_db() as db:
t = crud.get_registration_task(db, uuid) t = crud.get_registration_task(db, uuid)
@@ -653,21 +729,29 @@ async def run_batch_registration(
concurrency: int = 1, concurrency: int = 1,
mode: str = "pipeline", mode: str = "pipeline",
auto_upload_cpa: bool = False, 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 分发到并行或流水线执行""" """根据 mode 分发到并行或流水线执行"""
if mode == "parallel": if mode == "parallel":
await run_batch_parallel( await run_batch_parallel(
batch_id, task_uuids, email_service_type, proxy, batch_id, task_uuids, email_service_type, proxy,
email_service_config, email_service_id, concurrency, 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: else:
await run_batch_pipeline( await run_batch_pipeline(
batch_id, task_uuids, email_service_type, proxy, batch_id, task_uuids, email_service_type, proxy,
email_service_config, email_service_id, email_service_config, email_service_id,
interval_min, interval_max, concurrency, 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.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) return task_to_response(task)
@@ -788,7 +876,11 @@ async def start_batch_registration(
request.concurrency, request.concurrency,
request.mode, request.mode,
request.auto_upload_cpa, 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( return BatchRegistrationResponse(
@@ -1118,7 +1210,11 @@ async def run_outlook_batch_registration(
concurrency: int = 1, concurrency: int = 1,
mode: str = "pipeline", mode: str = "pipeline",
auto_upload_cpa: bool = False, 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 批量注册任务,复用通用并发逻辑 异步执行 Outlook 批量注册任务,复用通用并发逻辑
@@ -1157,7 +1253,11 @@ async def run_outlook_batch_registration(
concurrency=concurrency, concurrency=concurrency,
mode=mode, mode=mode,
auto_upload_cpa=auto_upload_cpa, 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.concurrency,
request.mode, request.mode,
request.auto_upload_cpa, 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( return OutlookBatchRegistrationResponse(

View File

@@ -3,14 +3,15 @@
""" """
import logging import logging
from typing import Optional, Dict, Any, List import os
from typing import Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from ...config.settings import get_settings, update_settings
from ...database import crud from ...database import crud
from ...database.session import get_db from ...database.session import get_db
from ...config.settings import get_settings, update_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -289,6 +290,7 @@ async def backup_database():
raise HTTPException(status_code=404, detail="数据库文件不存在") raise HTTPException(status_code=404, detail="数据库文件不存在")
# 创建备份目录 # 创建备份目录
from fastapi import Path
backup_dir = Path(db_path).parent / "backups" backup_dir = Path(db_path).parent / "backups"
backup_dir.mkdir(exist_ok=True) backup_dir.mkdir(exist_ok=True)

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

View File

@@ -6,9 +6,9 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from ...database import crud from ....database import crud
from ...database.session import get_db from ....database.session import get_db
from ...core.cpa_upload import test_cpa_connection from ....core.upload.cpa_upload import test_cpa_connection
router = APIRouter() router = APIRouter()

View File

@@ -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

View File

@@ -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}

View File

@@ -25,9 +25,8 @@ const elements = {
refreshBtn: document.getElementById('refresh-btn'), refreshBtn: document.getElementById('refresh-btn'),
batchRefreshBtn: document.getElementById('batch-refresh-btn'), batchRefreshBtn: document.getElementById('batch-refresh-btn'),
batchValidateBtn: document.getElementById('batch-validate-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'), batchCheckSubBtn: document.getElementById('batch-check-sub-btn'),
batchUploadTmBtn: document.getElementById('batch-upload-tm-btn'),
batchDeleteBtn: document.getElementById('batch-delete-btn'), batchDeleteBtn: document.getElementById('batch-delete-btn'),
exportBtn: document.getElementById('export-btn'), exportBtn: document.getElementById('export-btn'),
exportMenu: document.getElementById('export-menu'), exportMenu: document.getElementById('export-menu'),
@@ -94,14 +93,13 @@ function initEventListeners() {
// 批量验证Token // 批量验证Token
elements.batchValidateBtn.addEventListener('click', handleBatchValidate); elements.batchValidateBtn.addEventListener('click', handleBatchValidate);
// 批量上传CPA
elements.batchUploadCpaBtn.addEventListener('click', handleBatchUploadCpa);
// 批量检测订阅 // 批量检测订阅
elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription); 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); elements.batchDeleteBtn.addEventListener('click', handleBatchDelete);
@@ -320,15 +318,12 @@ function renderAccounts(accounts) {
<button class="btn btn-ghost btn-sm" onclick="refreshToken(${account.id})" title="刷新Token"> <button class="btn btn-ghost btn-sm" onclick="refreshToken(${account.id})" title="刷新Token">
🔄 🔄
</button> </button>
<button class="btn btn-ghost btn-sm" onclick="uploadToCpa(${account.id})" title="上传到CPA"> <button class="btn btn-ghost btn-sm" onclick="uploadAccount(${account.id})" title="上传账号">
☁️ ☁️
</button> </button>
<button class="btn btn-ghost btn-sm" onclick="markSubscription(${account.id})" title="标记订阅"> <button class="btn btn-ghost btn-sm" onclick="markSubscription(${account.id})" title="标记订阅">
🏷️ 🏷️
</button> </button>
<button class="btn btn-ghost btn-sm" onclick="uploadToTm(${account.id})" title="上传到Team Manager">
🚀
</button>
<button class="btn btn-ghost btn-sm" onclick="viewAccount(${account.id})" title="查看详情"> <button class="btn btn-ghost btn-sm" onclick="viewAccount(${account.id})" title="查看详情">
👁️ 👁️
</button> </button>
@@ -465,17 +460,15 @@ function updateBatchButtons() {
elements.batchDeleteBtn.disabled = count === 0; elements.batchDeleteBtn.disabled = count === 0;
elements.batchRefreshBtn.disabled = count === 0; elements.batchRefreshBtn.disabled = count === 0;
elements.batchValidateBtn.disabled = count === 0; elements.batchValidateBtn.disabled = count === 0;
elements.batchUploadCpaBtn.disabled = count === 0; elements.batchUploadBtn.disabled = count === 0;
elements.batchCheckSubBtn.disabled = count === 0; elements.batchCheckSubBtn.disabled = count === 0;
elements.batchUploadTmBtn.disabled = count === 0;
elements.exportBtn.disabled = count === 0; elements.exportBtn.disabled = count === 0;
elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除'; elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除';
elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token'; elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
elements.batchValidateBtn.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.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅';
elements.batchUploadTmBtn.textContent = count > 0 ? `🚀 上传TM (${count})` : '🚀 上传TM';
} }
// 刷新单个账号Token // 刷新单个账号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 = `
<div class="modal-content" style="max-width:360px;">
<div class="modal-header">
<h3>☁️ 选择上传目标</h3>
<button class="modal-close" id="_upload-close">&times;</button>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:8px;">
${targets.map(t => `
<button class="btn btn-secondary" data-val="${t.value}" style="text-align:left;">${t.label}</button>
`).join('')}
</div>
</div>`;
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 // 上传单个账号到CPA
async function uploadToCpa(id) { async function uploadToCpa(id) {
const choice = await selectCpaService(); const choice = await selectCpaService();
@@ -843,8 +873,8 @@ async function handleBatchUploadCpa() {
const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到CPA吗`); const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到CPA吗`);
if (!confirmed) return; if (!confirmed) return;
elements.batchUploadCpaBtn.disabled = true; elements.batchUploadBtn.disabled = true;
elements.batchUploadCpaBtn.textContent = '上传中...'; elements.batchUploadBtn.textContent = '上传中...';
try { try {
const payload = buildBatchPayload(); 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 = '<div style="text-align:center;color:var(--text-muted)">加载中...</div>';
modal.classList.add('active');
let services = [];
try {
services = await api.get('/sub2api-services?enabled=true');
} catch (e) {
services = [];
}
if (services.length === 0) {
listEl.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:12px;">暂无已启用的 Sub2API 服务,将自动选择第一个</div>';
} else {
listEl.innerHTML = services.map(s => `
<div class="sub2api-service-item" data-id="${s.id}" style="
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
">
<div>
<div style="font-weight:500;">${escapeHtml(s.name)}</div>
<div style="font-size:0.8rem;color:var(--text-muted);">${escapeHtml(s.api_url)}</div>
</div>
<span class="badge" style="background:var(--primary);color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:10px;">选择</span>
</div>
`).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 上传 ============== // ============== 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 // 上传单账号到 Team Manager
async function uploadToTm(id) { async function uploadToTm(id) {
try { try {
@@ -932,8 +1083,8 @@ async function handleBatchUploadTm() {
const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Team Manager 吗?`); const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Team Manager 吗?`);
if (!confirmed) return; if (!confirmed) return;
elements.batchUploadTmBtn.disabled = true; elements.batchUploadBtn.disabled = true;
elements.batchUploadTmBtn.textContent = '上传中...'; elements.batchUploadBtn.textContent = '上传中...';
try { try {
const result = await api.post('/payment/accounts/batch-upload-tm', buildBatchPayload()); const result = await api.post('/payment/accounts/batch-upload-tm', buildBatchPayload());

View File

@@ -85,7 +85,13 @@ const elements = {
// 注册后自动操作 // 注册后自动操作
autoUploadCpa: document.getElementById('auto-upload-cpa'), autoUploadCpa: document.getElementById('auto-upload-cpa'),
cpaServiceSelectGroup: document.getElementById('cpa-service-select-group'), 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(); startAccountsPolling();
initVisibilityReconnect(); initVisibilityReconnect();
restoreActiveTask(); restoreActiveTask();
checkCpaEnabled(); initAutoUploadOptions();
}); });
// 检查 CPA 是否启用,未启用则禁用复选框;同时加载 CPA 服务列表 // 初始化注册后自动操作选项CPA / Sub2API / TM
async function checkCpaEnabled() { async function initAutoUploadOptions() {
if (!elements.autoUploadCpa) return; await Promise.all([
// 加载 CPA 服务列表,列表为空则禁用复选框 loadServiceSelect('/cpa-services?enabled=true', elements.cpaServiceSelect, elements.autoUploadCpa, elements.cpaServiceSelectGroup),
await loadCpaServiceOptions(); loadServiceSelect('/sub2api-services?enabled=true', elements.sub2apiServiceSelect, elements.autoUploadSub2api, elements.sub2apiServiceSelectGroup),
try { loadServiceSelect('/tm-services?enabled=true', elements.tmServiceSelect, elements.autoUploadTm, elements.tmServiceSelectGroup),
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';
}
});
}
} }
async function loadCpaServiceOptions() { // 通用:构建自定义多选下拉组件并处理联动
if (!elements.cpaServiceSelect) return; async function loadServiceSelect(apiPath, container, checkbox, selectGroup) {
if (!checkbox || !container) return;
let services = [];
try { try {
const services = await api.get('/cpa-services?enabled=true'); services = await api.get(apiPath);
const defaultOpt = '<option value="">自动选择(第一个启用的服务)</option>'; } catch (e) {}
const opts = services.map(s =>
`<option value="${s.id}">${s.name.replace(/</g,'&lt;')}</option>` if (!services || services.length === 0) {
checkbox.disabled = true;
checkbox.title = '请先在设置中添加对应服务';
const label = checkbox.closest('label');
if (label) label.style.opacity = '0.5';
container.innerHTML = '<div class="msd-empty">暂无可用服务</div>';
} else {
const items = services.map(s =>
`<label class="msd-item">
<input type="checkbox" value="${s.id}" checked>
<span>${escapeHtml(s.name)}</span>
</label>`
).join(''); ).join('');
elements.cpaServiceSelect.innerHTML = defaultOpt + opts; container.innerHTML = `
} catch (e) { <div class="msd-dropdown" id="${container.id}-dd">
// 加载失败静默处理,保持默认选项 <div class="msd-trigger" onclick="toggleMsd('${container.id}-dd')">
<span class="msd-label">全部 (${services.length})</span>
<span class="msd-arrow">▼</span>
</div>
<div class="msd-list">${items}</div>
</div>`;
// 监听 checkbox 变化,更新触发器文字
container.querySelectorAll('.msd-item input').forEach(cb => {
cb.addEventListener('change', () => updateMsdLabel(container.id + '-dd'));
});
// 点击外部关闭
document.addEventListener('click', (e) => {
const dd = document.getElementById(container.id + '-dd');
if (dd && !dd.contains(e.target)) dd.classList.remove('open');
}, true);
} }
// 联动显示/隐藏服务选择区
checkbox.addEventListener('change', () => {
if (selectGroup) selectGroup.style.display = checkbox.checked ? 'block' : 'none';
});
}
function toggleMsd(ddId) {
const dd = document.getElementById(ddId);
if (dd) dd.classList.toggle('open');
}
function updateMsdLabel(ddId) {
const dd = document.getElementById(ddId);
if (!dd) return;
const all = dd.querySelectorAll('.msd-item input');
const checked = dd.querySelectorAll('.msd-item input:checked');
const label = dd.querySelector('.msd-label');
if (!label) return;
if (checked.length === 0) label.textContent = '未选择';
else if (checked.length === all.length) label.textContent = `全部 (${all.length})`;
else label.textContent = Array.from(checked).map(c => c.nextElementSibling.textContent).join(', ');
}
// 获取自定义多选下拉中选中的服务 ID 列表
function getSelectedServiceIds(container) {
if (!container) return [];
return Array.from(container.querySelectorAll('.msd-item input:checked')).map(cb => parseInt(cb.value));
} }
// 事件监听 // 事件监听
@@ -382,12 +425,13 @@ async function handleStartRegistration(e) {
// 构建请求数据(代理从设置中自动获取) // 构建请求数据(代理从设置中自动获取)
const requestData = { const requestData = {
email_service_type: emailServiceType, email_service_type: emailServiceType,
auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false,
cpa_service_ids: elements.autoUploadCpa && elements.autoUploadCpa.checked ? getSelectedServiceIds(elements.cpaServiceSelect) : [],
auto_upload_sub2api: elements.autoUploadSub2api ? elements.autoUploadSub2api.checked : false,
sub2api_service_ids: elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSelectedServiceIds(elements.sub2apiServiceSelect) : [],
auto_upload_tm: elements.autoUploadTm ? elements.autoUploadTm.checked : false,
tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [],
}; };
// 带上指定 CPA 服务
if (requestData.auto_upload_cpa && elements.cpaServiceSelect && elements.cpaServiceSelect.value) {
requestData.cpa_service_id = parseInt(elements.cpaServiceSelect.value);
}
// 如果选择了数据库中的服务,传递 service_id // 如果选择了数据库中的服务,传递 service_id
if (serviceId && serviceId !== 'default') { if (serviceId && serviceId !== 'default') {
@@ -1085,7 +1129,12 @@ async function handleOutlookBatchRegistration() {
interval_max: intervalMax, interval_max: intervalMax,
concurrency: Math.min(50, Math.max(1, concurrency)), concurrency: Math.min(50, Math.max(1, concurrency)),
mode: mode, mode: mode,
auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false,
cpa_service_ids: elements.autoUploadCpa && elements.autoUploadCpa.checked ? getSelectedServiceIds(elements.cpaServiceSelect) : [],
auto_upload_sub2api: elements.autoUploadSub2api ? elements.autoUploadSub2api.checked : false,
sub2api_service_ids: elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSelectedServiceIds(elements.sub2apiServiceSelect) : [],
auto_upload_tm: elements.autoUploadTm ? elements.autoUploadTm.checked : false,
tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [],
}; };
addLog('info', `[系统] 正在启动 Outlook 批量注册 (${selectedIds.length} 个账户)...`); addLog('info', `[系统] 正在启动 Outlook 批量注册 (${selectedIds.length} 个账户)...`);

View File

@@ -48,9 +48,24 @@ const elements = {
cpaServiceForm: document.getElementById('cpa-service-form'), cpaServiceForm: document.getElementById('cpa-service-form'),
cpaServiceModalTitle: document.getElementById('cpa-service-modal-title'), cpaServiceModalTitle: document.getElementById('cpa-service-modal-title'),
testCpaServiceBtn: document.getElementById('test-cpa-service-btn'), testCpaServiceBtn: document.getElementById('test-cpa-service-btn'),
// Team Manager 设置 // Sub2API 服务管理
tmForm: document.getElementById('tm-form'), addSub2ApiServiceBtn: document.getElementById('add-sub2api-service-btn'),
testTmBtn: document.getElementById('test-tm-btn'), sub2ApiServicesTable: document.getElementById('sub2api-services-table'),
sub2ApiServiceEditModal: document.getElementById('sub2api-service-edit-modal'),
closeSub2ApiServiceModal: document.getElementById('close-sub2api-service-modal'),
cancelSub2ApiServiceBtn: document.getElementById('cancel-sub2api-service-btn'),
sub2ApiServiceForm: document.getElementById('sub2api-service-form'),
sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'),
testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'),
// Team Manager 服务管理
addTmServiceBtn: document.getElementById('add-tm-service-btn'),
tmServicesTable: document.getElementById('tm-services-table'),
tmServiceEditModal: document.getElementById('tm-service-edit-modal'),
closeTmServiceModal: document.getElementById('close-tm-service-modal'),
cancelTmServiceBtn: document.getElementById('cancel-tm-service-btn'),
tmServiceForm: document.getElementById('tm-service-form'),
tmServiceModalTitle: document.getElementById('tm-service-modal-title'),
testTmServiceBtn: document.getElementById('test-tm-service-btn'),
// 验证码设置 // 验证码设置
emailCodeForm: document.getElementById('email-code-form'), emailCodeForm: document.getElementById('email-code-form'),
// Outlook 设置 // Outlook 设置
@@ -70,6 +85,8 @@ document.addEventListener('DOMContentLoaded', () => {
loadDatabaseInfo(); loadDatabaseInfo();
loadProxies(); loadProxies();
loadCpaServices(); loadCpaServices();
loadSub2ApiServices();
loadTmServices();
initEventListeners(); initEventListeners();
}); });
@@ -226,12 +243,26 @@ function initEventListeners() {
if (elements.webuiSettingsForm) { if (elements.webuiSettingsForm) {
elements.webuiSettingsForm.addEventListener('submit', handleSaveWebuiSettings); elements.webuiSettingsForm.addEventListener('submit', handleSaveWebuiSettings);
} }
// Team Manager 设置 // Team Manager 服务管理
if (elements.tmForm) { if (elements.addTmServiceBtn) {
elements.tmForm.addEventListener('submit', handleSaveTm); elements.addTmServiceBtn.addEventListener('click', () => openTmServiceModal());
} }
if (elements.testTmBtn) { if (elements.closeTmServiceModal) {
elements.testTmBtn.addEventListener('click', handleTestTm); elements.closeTmServiceModal.addEventListener('click', closeTmServiceModal);
}
if (elements.cancelTmServiceBtn) {
elements.cancelTmServiceBtn.addEventListener('click', closeTmServiceModal);
}
if (elements.tmServiceEditModal) {
elements.tmServiceEditModal.addEventListener('click', (e) => {
if (e.target === elements.tmServiceEditModal) closeTmServiceModal();
});
}
if (elements.tmServiceForm) {
elements.tmServiceForm.addEventListener('submit', handleSaveTmService);
}
if (elements.testTmServiceBtn) {
elements.testTmServiceBtn.addEventListener('click', handleTestTmService);
} }
// CPA 服务管理 // CPA 服务管理
@@ -255,6 +286,28 @@ function initEventListeners() {
if (elements.testCpaServiceBtn) { if (elements.testCpaServiceBtn) {
elements.testCpaServiceBtn.addEventListener('click', handleTestCpaService); elements.testCpaServiceBtn.addEventListener('click', handleTestCpaService);
} }
// Sub2API 服务管理
if (elements.addSub2ApiServiceBtn) {
elements.addSub2ApiServiceBtn.addEventListener('click', () => openSub2ApiServiceModal());
}
if (elements.closeSub2ApiServiceModal) {
elements.closeSub2ApiServiceModal.addEventListener('click', closeSub2ApiServiceModal);
}
if (elements.cancelSub2ApiServiceBtn) {
elements.cancelSub2ApiServiceBtn.addEventListener('click', closeSub2ApiServiceModal);
}
if (elements.sub2ApiServiceEditModal) {
elements.sub2ApiServiceEditModal.addEventListener('click', (e) => {
if (e.target === elements.sub2ApiServiceEditModal) closeSub2ApiServiceModal();
});
}
if (elements.sub2ApiServiceForm) {
elements.sub2ApiServiceForm.addEventListener('submit', handleSaveSub2ApiService);
}
if (elements.testSub2ApiServiceBtn) {
elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService);
}
} }
// 加载设置 // 加载设置
@@ -283,8 +336,6 @@ async function loadSettings() {
// 加载 Outlook 设置 // 加载 Outlook 设置
loadOutlookSettings(); loadOutlookSettings();
// 加载 Team Manager 设置
loadTmSettings();
// Web UI 访问密码提示 // Web UI 访问密码提示
if (data.webui?.has_access_password) { if (data.webui?.has_access_password) {
@@ -998,73 +1049,167 @@ async function handleTestDynamicProxy() {
} }
} }
// ============== Team Manager 设置 ============== // ============== Team Manager 服务管理 ==============
async function loadTmSettings() { async function loadTmServices() {
if (!elements.tmServicesTable) return;
try { try {
const data = await api.get('/settings/team-manager'); const services = await api.get('/tm-services');
document.getElementById('tm-enabled').checked = data.enabled || false; renderTmServicesTable(services);
document.getElementById('tm-api-url').value = data.api_url || ''; } catch (e) {
document.getElementById('tm-api-key').value = ''; elements.tmServicesTable.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--danger-color);">${e.message}</td></tr>`;
document.getElementById('tm-api-key').placeholder = data.has_api_key ? '已配置,留空保持不变' : '请输入 API Key';
} catch (error) {
console.error('加载 Team Manager 设置失败:', error);
} }
} }
async function handleSaveTm(e) { function renderTmServicesTable(services) {
if (!services || services.length === 0) {
elements.tmServicesTable.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">暂无 Team Manager 服务,点击「添加服务」新增</td></tr>';
return;
}
elements.tmServicesTable.innerHTML = services.map(s => `
<tr>
<td>${escapeHtml(s.name)}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">${escapeHtml(s.api_url)}</td>
<td>
<span class="badge" style="background:${s.enabled ? 'var(--success-color)' : 'var(--border)'};color:${s.enabled ? '#fff' : 'var(--text-muted)'};font-size:0.75rem;padding:2px 8px;border-radius:10px;">
${s.enabled ? '启用' : '禁用'}
</span>
</td>
<td style="text-align:center;">${s.priority}</td>
<td style="white-space:nowrap;">
<button class="btn btn-secondary btn-sm" onclick="editTmService(${s.id})">编辑</button>
<button class="btn btn-secondary btn-sm" onclick="testTmServiceById(${s.id})">测试</button>
<button class="btn btn-danger btn-sm" onclick="deleteTmService(${s.id}, '${escapeHtml(s.name)}')">删除</button>
</td>
</tr>
`).join('');
}
function openTmServiceModal(service = null) {
document.getElementById('tm-service-id').value = service ? service.id : '';
document.getElementById('tm-service-name').value = service ? service.name : '';
document.getElementById('tm-service-url').value = service ? service.api_url : '';
document.getElementById('tm-service-key').value = '';
document.getElementById('tm-service-priority').value = service ? service.priority : 0;
document.getElementById('tm-service-enabled').checked = service ? service.enabled : true;
if (service) {
document.getElementById('tm-service-key').placeholder = service.has_key ? '已配置,留空保持不变' : '请输入 API Key';
} else {
document.getElementById('tm-service-key').placeholder = '请输入 API Key';
}
elements.tmServiceModalTitle.textContent = service ? '编辑 Team Manager 服务' : '添加 Team Manager 服务';
elements.tmServiceEditModal.classList.add('active');
}
function closeTmServiceModal() {
elements.tmServiceEditModal.classList.remove('active');
}
async function editTmService(id) {
try {
const service = await api.get(`/tm-services/${id}`);
openTmServiceModal(service);
} catch (e) {
toast.error('获取服务信息失败: ' + e.message);
}
}
async function handleSaveTmService(e) {
e.preventDefault(); e.preventDefault();
const data = { const id = document.getElementById('tm-service-id').value;
enabled: document.getElementById('tm-enabled').checked, const name = document.getElementById('tm-service-name').value.trim();
api_url: document.getElementById('tm-api-url').value, const apiUrl = document.getElementById('tm-service-url').value.trim();
api_key: document.getElementById('tm-api-key').value || '' const apiKey = document.getElementById('tm-service-key').value.trim();
}; const priority = parseInt(document.getElementById('tm-service-priority').value) || 0;
try { const enabled = document.getElementById('tm-service-enabled').checked;
await api.post('/settings/team-manager', data);
toast.success('Team Manager 设置已保存'); if (!name || !apiUrl) {
loadTmSettings(); toast.error('名称和 API URL 不能为空');
} catch (error) { return;
toast.error('保存失败: ' + error.message);
} }
} if (!id && !apiKey) {
toast.error('新增服务时 API Key 不能为空');
async function handleTestTm() {
const apiUrl = document.getElementById('tm-api-url').value;
const apiKey = document.getElementById('tm-api-key').value;
if (!apiUrl) {
toast.error('请先填写 API URL');
return; return;
} }
let keyToTest = apiKey;
if (!keyToTest) {
const saved = await api.get('/settings/team-manager');
if (!saved.has_api_key) {
toast.error('请先填写 API Key');
return;
}
keyToTest = 'use_saved_key';
}
elements.testTmBtn.disabled = true;
elements.testTmBtn.innerHTML = '<span class="loading-spinner"></span> 测试中...';
try { try {
const result = await api.post('/settings/team-manager/test', { const payload = { name, api_url: apiUrl, priority, enabled };
api_url: apiUrl, if (apiKey) payload.api_key = apiKey;
api_key: keyToTest
}); if (id) {
await api.patch(`/tm-services/${id}`, payload);
toast.success('服务已更新');
} else {
payload.api_key = apiKey;
await api.post('/tm-services', payload);
toast.success('服务已添加');
}
closeTmServiceModal();
loadTmServices();
} catch (e) {
toast.error('保存失败: ' + e.message);
}
}
async function deleteTmService(id, name) {
const confirmed = await confirm(`确定要删除 Team Manager 服务「${name}」吗?`);
if (!confirmed) return;
try {
await api.delete(`/tm-services/${id}`);
toast.success('已删除');
loadTmServices();
} catch (e) {
toast.error('删除失败: ' + e.message);
}
}
async function testTmServiceById(id) {
try {
const result = await api.post(`/tm-services/${id}/test`);
if (result.success) { if (result.success) {
toast.success(result.message); toast.success(result.message);
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
} catch (error) { } catch (e) {
toast.error('测试失败: ' + error.message); toast.error('测试失败: ' + e.message);
}
}
async function handleTestTmService() {
const apiUrl = document.getElementById('tm-service-url').value.trim();
const apiKey = document.getElementById('tm-service-key').value.trim();
const id = document.getElementById('tm-service-id').value;
if (!apiUrl) {
toast.error('请先填写 API URL');
return;
}
if (!id && !apiKey) {
toast.error('请先填写 API Key');
return;
}
elements.testTmServiceBtn.disabled = true;
elements.testTmServiceBtn.textContent = '测试中...';
try {
let result;
if (id && !apiKey) {
result = await api.post(`/tm-services/${id}/test`);
} else {
result = await api.post('/tm-services/test-connection', { api_url: apiUrl, api_key: apiKey });
}
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (e) {
toast.error('测试失败: ' + e.message);
} finally { } finally {
elements.testTmBtn.disabled = false; elements.testTmServiceBtn.disabled = false;
elements.testTmBtn.textContent = '🔌 测试连接'; elements.testTmServiceBtn.textContent = '🔌 测试连接';
} }
} }
@@ -1096,7 +1241,7 @@ function renderCpaServicesTable(services) {
</span> </span>
</td> </td>
<td style="text-align:center;">${s.priority}</td> <td style="text-align:center;">${s.priority}</td>
<td> <td style="white-space:nowrap;">
<button class="btn btn-secondary btn-sm" onclick="editCpaService(${s.id})">编辑</button> <button class="btn btn-secondary btn-sm" onclick="editCpaService(${s.id})">编辑</button>
<button class="btn btn-secondary btn-sm" onclick="testCpaServiceById(${s.id})">测试</button> <button class="btn btn-secondary btn-sm" onclick="testCpaServiceById(${s.id})">测试</button>
<button class="btn btn-danger btn-sm" onclick="deleteCpaService(${s.id}, '${escapeHtml(s.name)}')">删除</button> <button class="btn btn-danger btn-sm" onclick="deleteCpaService(${s.id}, '${escapeHtml(s.name)}')">删除</button>
@@ -1230,6 +1375,170 @@ async function handleTestCpaService() {
} }
} }
// ============================================================================
// Sub2API 服务管理
// ============================================================================
let _sub2apiEditingId = null;
async function loadSub2ApiServices() {
try {
const services = await api.get('/sub2api-services');
renderSub2ApiServices(services);
} catch (e) {
if (elements.sub2ApiServicesTable) {
elements.sub2ApiServicesTable.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">加载失败</td></tr>';
}
}
}
function renderSub2ApiServices(services) {
if (!elements.sub2ApiServicesTable) return;
if (!services || services.length === 0) {
elements.sub2ApiServicesTable.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">暂无 Sub2API 服务,点击「添加服务」新增</td></tr>';
return;
}
elements.sub2ApiServicesTable.innerHTML = services.map(s => `
<tr>
<td>${escapeHtml(s.name)}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">${escapeHtml(s.api_url)}</td>
<td>
<span class="badge" style="background:${s.enabled ? 'var(--success-color)' : 'var(--border)'};color:${s.enabled ? '#fff' : 'var(--text-muted)'};font-size:0.75rem;padding:2px 8px;border-radius:10px;">
${s.enabled ? '启用' : '禁用'}
</span>
</td>
<td style="text-align:center;">${s.priority}</td>
<td style="white-space:nowrap;">
<button class="btn btn-secondary btn-sm" onclick="editSub2ApiService(${s.id})">编辑</button>
<button class="btn btn-secondary btn-sm" onclick="testSub2ApiServiceById(${s.id})">测试</button>
<button class="btn btn-danger btn-sm" onclick="deleteSub2ApiService(${s.id}, '${escapeHtml(s.name)}')">删除</button>
</td>
</tr>
`).join('');
}
function openSub2ApiServiceModal(svc = null) {
_sub2apiEditingId = svc ? svc.id : null;
elements.sub2ApiServiceModalTitle.textContent = svc ? '编辑 Sub2API 服务' : '添加 Sub2API 服务';
elements.sub2ApiServiceForm.reset();
document.getElementById('sub2api-service-id').value = svc ? svc.id : '';
if (svc) {
document.getElementById('sub2api-service-name').value = svc.name || '';
document.getElementById('sub2api-service-url').value = svc.api_url || '';
document.getElementById('sub2api-service-priority').value = svc.priority ?? 0;
document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false;
document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key';
}
elements.sub2ApiServiceEditModal.classList.add('active');
}
function closeSub2ApiServiceModal() {
elements.sub2ApiServiceEditModal.classList.remove('active');
elements.sub2ApiServiceForm.reset();
_sub2apiEditingId = null;
}
async function editSub2ApiService(id) {
try {
const svc = await api.get(`/sub2api-services/${id}`);
openSub2ApiServiceModal(svc);
} catch (e) {
toast.error('加载失败: ' + e.message);
}
}
async function deleteSub2ApiService(id, name) {
if (!confirm(`确认删除 Sub2API 服务「${name}」?`)) return;
try {
await api.delete(`/sub2api-services/${id}`);
toast.success('服务已删除');
loadSub2ApiServices();
} catch (e) {
toast.error('删除失败: ' + e.message);
}
}
async function handleSaveSub2ApiService(e) {
e.preventDefault();
const id = document.getElementById('sub2api-service-id').value;
const data = {
name: document.getElementById('sub2api-service-name').value,
api_url: document.getElementById('sub2api-service-url').value,
api_key: document.getElementById('sub2api-service-key').value || undefined,
priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0,
enabled: document.getElementById('sub2api-service-enabled').checked,
};
if (!id && !data.api_key) {
toast.error('请填写 API Key');
return;
}
if (!data.api_key) delete data.api_key;
try {
if (id) {
await api.patch(`/sub2api-services/${id}`, data);
toast.success('服务已更新');
} else {
await api.post('/sub2api-services', data);
toast.success('服务已添加');
}
closeSub2ApiServiceModal();
loadSub2ApiServices();
} catch (e) {
toast.error('保存失败: ' + e.message);
}
}
async function testSub2ApiServiceById(id) {
try {
const result = await api.post(`/sub2api-services/${id}/test`);
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (e) {
toast.error('测试失败: ' + e.message);
}
}
async function handleTestSub2ApiService() {
const apiUrl = document.getElementById('sub2api-service-url').value.trim();
const apiKey = document.getElementById('sub2api-service-key').value.trim();
const id = document.getElementById('sub2api-service-id').value;
if (!apiUrl) {
toast.error('请先填写 API URL');
return;
}
if (!id && !apiKey) {
toast.error('请先填写 API Key');
return;
}
elements.testSub2ApiServiceBtn.disabled = true;
elements.testSub2ApiServiceBtn.textContent = '测试中...';
try {
let result;
if (id && !apiKey) {
result = await api.post(`/sub2api-services/${id}/test`);
} else {
result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey });
}
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (e) {
toast.error('测试失败: ' + e.message);
} finally {
elements.testSub2ApiServiceBtn.disabled = false;
elements.testSub2ApiServiceBtn.textContent = '🔌 测试连接';
}
}
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const d = document.createElement('div'); const d = document.createElement('div');

View File

@@ -125,15 +125,19 @@
<button class="btn btn-info" id="batch-validate-btn" disabled title="批量验证Token"> <button class="btn btn-info" id="batch-validate-btn" disabled title="批量验证Token">
✅ 验证Token ✅ 验证Token
</button> </button>
<button class="btn btn-success" id="batch-upload-cpa-btn" disabled title="批量上传到CPA">
☁️ 上传CPA
</button>
<button class="btn btn-info" id="batch-check-sub-btn" disabled title="批量检测订阅状态"> <button class="btn btn-info" id="batch-check-sub-btn" disabled title="批量检测订阅状态">
🔍 检测订阅 🔍 检测订阅
</button> </button>
<button class="btn btn-success" id="batch-upload-tm-btn" disabled title="批量上传到Team Manager"> <div class="dropdown">
🚀 上传TM <button class="btn btn-success" id="batch-upload-btn" disabled>
</button> ☁️ 上传
</button>
<div class="dropdown-menu" id="upload-menu">
<a href="#" class="dropdown-item" id="batch-upload-cpa-item">☁️ 上传到 CPA</a>
<a href="#" class="dropdown-item" id="batch-upload-sub2api-item">🔗 上传到 Sub2API</a>
<a href="#" class="dropdown-item" id="batch-upload-tm-item">🚀 上传到 Team Manager</a>
</div>
</div>
<button class="btn btn-danger" id="batch-delete-btn" disabled> <button class="btn btn-danger" id="batch-delete-btn" disabled>
🗑️ 批量删除 🗑️ 批量删除
</button> </button>
@@ -233,6 +237,26 @@
</div> </div>
</div> </div>
<!-- Sub2API 服务选择模态框 -->
<div class="modal" id="sub2api-service-modal">
<div class="modal-content" style="max-width: 480px;">
<div class="modal-header">
<h3>🔗 选择 Sub2API 服务</h3>
<button class="modal-close" id="close-sub2api-modal">&times;</button>
</div>
<div class="modal-body">
<p style="color: var(--text-muted); margin-bottom: 12px; font-size: 0.9rem;">选择要上传到的 Sub2API 服务,或自动选择第一个启用的服务。</p>
<div id="sub2api-service-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 300px; overflow-y: auto;">
<div style="text-align: center; color: var(--text-muted);">加载中...</div>
</div>
</div>
<div class="modal-footer" style="padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end;">
<button class="btn btn-secondary" id="sub2api-use-auto-btn">自动选择</button>
<button class="btn btn-secondary" id="cancel-sub2api-modal-btn">取消</button>
</div>
</div>
</div>
<script src="/static/js/utils.js"></script> <script src="/static/js/utils.js"></script>
<script src="/static/js/accounts.js"></script> <script src="/static/js/accounts.js"></script>
</body> </body>

View File

@@ -83,6 +83,53 @@
filter: blur(0); filter: blur(0);
} }
/* 自定义多选下拉 */
.multi-select-dropdown {
position: relative;
width: 100%;
font-size: 0.85rem;
}
.msd-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text-primary);
cursor: pointer;
user-select: none;
}
.msd-trigger:hover { border-color: var(--primary-color); }
.msd-arrow { font-size: 0.7rem; transition: transform 0.15s; }
.msd-dropdown.open .msd-arrow { transform: rotate(180deg); }
.msd-list {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
z-index: 100;
max-height: 150px;
overflow-y: auto;
padding: 4px 0;
}
.msd-dropdown.open .msd-list { display: block; }
.msd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
}
.msd-item:hover { background: var(--surface-hover); }
.msd-item input[type=checkbox] { margin: 0; cursor: pointer; }
.msd-empty { padding: 8px 12px; color: var(--text-muted); font-size: 0.8rem; }
/* 响应式 */ /* 响应式 */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.two-column-layout { .two-column-layout {
@@ -224,15 +271,32 @@
<div class="form-group" id="auto-upload-group"> <div class="form-group" id="auto-upload-group">
<label style="font-weight: 500; margin-bottom: var(--spacing-xs); display: block;">注册后自动操作</label> <label style="font-weight: 500; margin-bottom: var(--spacing-xs); display: block;">注册后自动操作</label>
<!-- CPA -->
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer;"> <label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer;">
<input type="checkbox" id="auto-upload-cpa"> <input type="checkbox" id="auto-upload-cpa">
<span>上传到 CPA</span> <span>上传到 CPA</span>
</label> </label>
<div id="cpa-service-select-group" style="display:none; margin-top: 8px; padding-left: 4px;"> <div id="cpa-service-select-group" style="display:none; margin-top: 6px; margin-bottom: 8px; padding-left: 4px;">
<label style="font-size:0.85rem; color:var(--text-muted); margin-bottom:4px; display:block;">选择 CPA 服务</label> <div class="multi-select-dropdown" id="cpa-service-select"></div>
<select id="cpa-service-select" style="width:100%; padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-primary); font-size:0.9rem;"> </div>
<option value="">使用全局配置</option>
</select> <!-- Sub2API -->
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer; margin-top: 6px;">
<input type="checkbox" id="auto-upload-sub2api">
<span>上传到 Sub2API</span>
</label>
<div id="sub2api-service-select-group" style="display:none; margin-top: 6px; margin-bottom: 8px; padding-left: 4px;">
<div class="multi-select-dropdown" id="sub2api-service-select"></div>
</div>
<!-- Team Manager -->
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer; margin-top: 6px;">
<input type="checkbox" id="auto-upload-tm">
<span>上传到 Team Manager</span>
</label>
<div id="tm-service-select-group" style="display:none; margin-top: 6px; padding-left: 4px;">
<div class="multi-select-dropdown" id="tm-service-select"></div>
</div> </div>
</div> </div>

View File

@@ -38,8 +38,7 @@
<div class="tabs"> <div class="tabs">
<button class="tab-btn active" data-tab="proxy">🌐 代理设置</button> <button class="tab-btn active" data-tab="proxy">🌐 代理设置</button>
<button class="tab-btn" data-tab="webui">🔒 访问控制</button> <button class="tab-btn" data-tab="webui">🔒 访问控制</button>
<button class="tab-btn" data-tab="cpa">☁️ CPA上传</button> <button class="tab-btn" data-tab="upload">☁️ 上传</button>
<button class="tab-btn" data-tab="team-manager">🚀 Team Manager</button>
<button class="tab-btn" data-tab="outlook">📮 Outlook配置</button> <button class="tab-btn" data-tab="outlook">📮 Outlook配置</button>
<button class="tab-btn" data-tab="registration">⚙️ 注册配置</button> <button class="tab-btn" data-tab="registration">⚙️ 注册配置</button>
<button class="tab-btn" data-tab="email-code">📧 验证码配置</button> <button class="tab-btn" data-tab="email-code">📧 验证码配置</button>
@@ -202,12 +201,12 @@
</div> </div>
</div> </div>
<!-- CPA 上传设置 --> <!-- 上传服务设置CPA + Sub2API + Team Manager -->
<div class="tab-content" id="cpa-tab"> <div class="tab-content" id="upload-tab">
<!-- CPA 服务管理 --> <!-- CPA 服务管理 -->
<div class="card"> <div class="card" style="margin-top: var(--spacing-lg);">
<div class="card-header"> <div class="card-header">
<h3>CPA 服务管理</h3> <h3>☁️ CPA 服务</h3>
<button class="btn btn-primary btn-sm" id="add-cpa-service-btn">+ 添加服务</button> <button class="btn btn-primary btn-sm" id="add-cpa-service-btn">+ 添加服务</button>
</div> </div>
<div class="card-body" style="padding: 0;"> <div class="card-body" style="padding: 0;">
@@ -215,11 +214,11 @@
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>名称</th> <th style="width:150px;">名称</th>
<th>API URL</th> <th>API URL</th>
<th style="width:80px;">状态</th> <th style="width:80px;">状态</th>
<th style="width:60px;">优先级</th> <th style="width:60px;text-align:center;">优先级</th>
<th style="width:160px;">操作</th> <th style="width:220px;">操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="cpa-services-table"> <tbody id="cpa-services-table">
@@ -229,6 +228,148 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Sub2API 服务管理 -->
<div class="card" style="margin-top: var(--spacing-lg);">
<div class="card-header">
<h3>🔗 Sub2API 服务</h3>
<button class="btn btn-primary btn-sm" id="add-sub2api-service-btn">+ 添加服务</button>
</div>
<div class="card-body" style="padding: 0;">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th style="width:150px;">名称</th>
<th>API URL</th>
<th style="width:80px;">状态</th>
<th style="width:60px;text-align:center;">优先级</th>
<th style="width:220px;">操作</th>
</tr>
</thead>
<tbody id="sub2api-services-table">
<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Team Manager 服务管理 -->
<div class="card" style="margin-top: var(--spacing-lg);">
<div class="card-header">
<h3>🚀 Team Manager 服务</h3>
<button class="btn btn-primary btn-sm" id="add-tm-service-btn">+ 添加服务</button>
</div>
<div class="card-body" style="padding: 0;">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th style="width:150px;">名称</th>
<th>API URL</th>
<th style="width:80px;">状态</th>
<th style="width:60px;text-align:center;">优先级</th>
<th style="width:220px;">操作</th>
</tr>
</thead>
<tbody id="tm-services-table">
<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Team Manager 服务编辑模态框 -->
<div class="modal" id="tm-service-edit-modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3 id="tm-service-modal-title">添加 Team Manager 服务</h3>
<button class="modal-close" id="close-tm-service-modal">&times;</button>
</div>
<div class="modal-body">
<form id="tm-service-form">
<input type="hidden" id="tm-service-id">
<div class="form-group">
<label for="tm-service-name">名称 *</label>
<input type="text" id="tm-service-name" placeholder="例如: 主服务" required>
</div>
<div class="form-group">
<label for="tm-service-url">API URL *</label>
<input type="text" id="tm-service-url" placeholder="https://tm.example.com" required>
</div>
<div class="form-group">
<label for="tm-service-key">API Key</label>
<input type="password" id="tm-service-key" placeholder="编辑时留空则保持原值" autocomplete="new-password">
</div>
<div class="form-row">
<div class="form-group">
<label for="tm-service-priority">优先级</label>
<input type="number" id="tm-service-priority" value="0" min="0">
<p class="hint">数字越小优先级越高</p>
</div>
<div class="form-group">
<label>&nbsp;</label>
<label style="margin-top:10px;display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="tm-service-enabled" checked> 启用
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存</button>
<button type="button" class="btn btn-secondary" id="test-tm-service-btn">🔌 测试连接</button>
<button type="button" class="btn btn-secondary" id="cancel-tm-service-btn">取消</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sub2API 服务编辑模态框 -->
<div class="modal" id="sub2api-service-edit-modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3 id="sub2api-service-modal-title">添加 Sub2API 服务</h3>
<button class="modal-close" id="close-sub2api-service-modal">&times;</button>
</div>
<div class="modal-body">
<form id="sub2api-service-form">
<input type="hidden" id="sub2api-service-id">
<div class="form-group">
<label for="sub2api-service-name">名称 *</label>
<input type="text" id="sub2api-service-name" placeholder="例如: 主服务" required>
</div>
<div class="form-group">
<label for="sub2api-service-url">API URL *</label>
<input type="text" id="sub2api-service-url" placeholder="http://host" required>
</div>
<div class="form-group">
<label for="sub2api-service-key">API Key</label>
<input type="password" id="sub2api-service-key" placeholder="编辑时留空则保持原值" autocomplete="new-password">
</div>
<div class="form-row">
<div class="form-group">
<label for="sub2api-service-priority">优先级</label>
<input type="number" id="sub2api-service-priority" value="0" min="0">
<p class="hint">数字越小优先级越高</p>
</div>
<div class="form-group">
<label>&nbsp;</label>
<label style="margin-top:10px;display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="sub2api-service-enabled" checked> 启用
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存</button>
<button type="button" class="btn btn-secondary" id="test-sub2api-service-btn">🔌 测试连接</button>
<button type="button" class="btn btn-secondary" id="cancel-sub2api-service-btn">取消</button>
</div>
</form>
</div>
</div>
</div> </div>
<!-- CPA 服务编辑模态框 --> <!-- CPA 服务编辑模态框 -->
@@ -276,44 +417,6 @@
</div> </div>
</div> </div>
<!-- Team Manager 设置 -->
<div class="tab-content" id="team-manager-tab">
<div class="card">
<div class="card-header">
<h3>Team Manager 配置</h3>
<span class="hint">配置 Team Manager 账号导入功能</span>
</div>
<div class="card-body">
<form id="tm-form">
<div class="form-group">
<label>
<input type="checkbox" id="tm-enabled" name="enabled">
启用 Team Manager 上传
</label>
<p class="hint">启用后可在账号管理页面将账号导入 Team Manager 平台</p>
</div>
<div class="form-group">
<label for="tm-api-url">API URL</label>
<input type="text" id="tm-api-url" name="api_url" placeholder="例如: https://tm.example.com">
<p class="hint">Team Manager 平台的 API 地址</p>
</div>
<div class="form-group">
<label for="tm-api-key">API Key</label>
<input type="password" id="tm-api-key" name="api_key" placeholder="留空则保持原值" autocomplete="new-password">
<p class="hint">Team Manager 平台的认证 Key</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存设置</button>
<button type="button" class="btn btn-secondary" id="test-tm-btn">🔌 测试连接</button>
</div>
</form>
</div>
</div>
</div>
<!-- Outlook 配置 --> <!-- Outlook 配置 -->
<div class="tab-content" id="outlook-tab"> <div class="tab-content" id="outlook-tab">
<div class="card"> <div class="card">

11
uv.lock generated
View File

@@ -164,6 +164,7 @@ dependencies = [
{ name = "curl-cffi" }, { name = "curl-cffi" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "path" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -194,6 +195,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.100.0" }, { name = "fastapi", specifier = ">=0.100.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "jinja2", specifier = ">=3.1.0" }, { name = "jinja2", specifier = ">=3.1.0" },
{ name = "path", specifier = ">=17.1.1" },
{ name = "playwright", marker = "extra == 'payment'", specifier = ">=1.40.0" }, { name = "playwright", marker = "extra == 'payment'", specifier = ">=1.40.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.18" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.18" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },
@@ -502,6 +504,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]]
name = "path"
version = "17.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/52/a7bdd5ef8488977d354b7915d1e75009bebbd04f73eff14e52372d5e9435/path-17.1.1.tar.gz", hash = "sha256:2dfcbfec8b4d960f3469c52acf133113c2a8bf12ac7b98d629fa91af87248d42", size = 50528, upload-time = "2025-07-27T20:40:23.79Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/50/11c9ee1ede64b45d687fd36eb8768dafc57afc78b4d83396920cfd69ed30/path-17.1.1-py3-none-any.whl", hash = "sha256:ec7e136df29172e5030dd07e037d55f676bdb29d15bfa09b80da29d07d3b9303", size = 23936, upload-time = "2025-07-27T20:40:22.453Z" },
]
[[package]] [[package]]
name = "pefile" name = "pefile"
version = "2024.8.26" version = "2024.8.26"