From f983c8ace281f06d12ff5693bfa05e19e31070bb Mon Sep 17 00:00:00 2001 From: biubushy Date: Sat, 28 Mar 2026 12:33:18 +0800 Subject: [PATCH 1/3] feat: add scheduling functionality for registration tasks - Implemented a new scheduling form in the frontend to create and manage scheduled registration tasks. - Added backend utilities for parsing and validating schedule configurations. - Created a scheduler service to poll and execute due registration tasks. - Enhanced the UI with a schedule jobs table to display existing scheduled tasks and their statuses. - Introduced functions to handle scheduling logic, including interval and timepoint triggers. - Integrated the scheduling feature with existing registration workflows. --- src/database/crud.py | 192 +++++++++- src/database/models.py | 26 ++ src/database/session.py | 19 + src/web/app.py | 6 + src/web/routes/registration.py | 653 +++++++++++++++++++++++++-------- src/web/schedule_utils.py | 104 ++++++ src/web/scheduler.py | 124 +++++++ static/js/app.js | 386 ++++++++++++++++++- templates/index.html | 124 +++++++ 9 files changed, 1459 insertions(+), 175 deletions(-) create mode 100644 src/web/schedule_utils.py create mode 100644 src/web/scheduler.py diff --git a/src/database/crud.py b/src/database/crud.py index 3c6c8492..c1f17634 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from sqlalchemy import and_, or_, desc, asc, func -from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService +from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService, TeamManagerService, ScheduledRegistrationJob # ============================================================================ @@ -668,7 +668,6 @@ def create_tm_service( priority: int = 0, ): """创建 Team Manager 服务配置""" - from .models import TeamManagerService svc = TeamManagerService( name=name, api_url=api_url, @@ -684,13 +683,11 @@ def create_tm_service( 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) @@ -717,3 +714,190 @@ def delete_tm_service(db: Session, service_id: int) -> bool: db.delete(svc) db.commit() return True + + +# ============================================================================ +# 计划注册任务 CRUD +# ============================================================================ + +def create_scheduled_registration_job( + db: Session, + job_uuid: str, + name: str, + schedule_type: str, + schedule_config: Dict[str, Any], + registration_config: Dict[str, Any], + next_run_at: Optional[datetime], + enabled: bool = True, + timezone: str = 'local', + status: str = 'idle', +) -> ScheduledRegistrationJob: + """创建计划注册任务""" + job = ScheduledRegistrationJob( + job_uuid=job_uuid, + name=name, + enabled=enabled, + status=status, + schedule_type=schedule_type, + schedule_config=schedule_config, + registration_config=registration_config, + timezone=timezone, + next_run_at=next_run_at, + ) + db.add(job) + db.commit() + db.refresh(job) + return job + + +def get_scheduled_registration_job_by_uuid(db: Session, job_uuid: str) -> Optional[ScheduledRegistrationJob]: + """按 UUID 获取计划注册任务""" + return db.query(ScheduledRegistrationJob).filter(ScheduledRegistrationJob.job_uuid == job_uuid).first() + + +def get_scheduled_registration_job_by_id(db: Session, job_id: int) -> Optional[ScheduledRegistrationJob]: + """按 ID 获取计划注册任务""" + return db.query(ScheduledRegistrationJob).filter(ScheduledRegistrationJob.id == job_id).first() + + +def get_scheduled_registration_jobs( + db: Session, + enabled: Optional[bool] = None, + skip: int = 0, + limit: int = 100, +) -> List[ScheduledRegistrationJob]: + """获取计划注册任务列表""" + query = db.query(ScheduledRegistrationJob) + if enabled is not None: + query = query.filter(ScheduledRegistrationJob.enabled == enabled) + return query.order_by(ScheduledRegistrationJob.created_at.desc()).offset(skip).limit(limit).all() + + +def get_due_scheduled_registration_jobs(db: Session, now: datetime) -> List[ScheduledRegistrationJob]: + """获取到期的计划注册任务""" + return db.query(ScheduledRegistrationJob).filter( + ScheduledRegistrationJob.enabled == True, + ScheduledRegistrationJob.is_running == False, + ScheduledRegistrationJob.next_run_at.isnot(None), + ScheduledRegistrationJob.next_run_at <= now, + ).order_by(ScheduledRegistrationJob.next_run_at.asc(), ScheduledRegistrationJob.id.asc()).all() + + +def get_running_scheduled_registration_jobs(db: Session) -> List[ScheduledRegistrationJob]: + """获取执行中的计划注册任务""" + return db.query(ScheduledRegistrationJob).filter( + ScheduledRegistrationJob.is_running == True, + ).order_by(ScheduledRegistrationJob.updated_at.asc(), ScheduledRegistrationJob.id.asc()).all() + + +def update_scheduled_registration_job( + db: Session, + job_uuid: str, + **kwargs, +) -> Optional[ScheduledRegistrationJob]: + """更新计划注册任务""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + for key, value in kwargs.items(): + if hasattr(job, key): + setattr(job, key, value) + db.commit() + db.refresh(job) + return job + + +def delete_scheduled_registration_job(db: Session, job_uuid: str) -> bool: + """删除计划注册任务""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return False + db.delete(job) + db.commit() + return True + + +def claim_scheduled_registration_job( + db: Session, + job_uuid: str, + next_run_at: Optional[datetime], + now: datetime, +) -> Optional[ScheduledRegistrationJob]: + """抢占计划注册任务执行权""" + updated = db.query(ScheduledRegistrationJob).filter( + ScheduledRegistrationJob.job_uuid == job_uuid, + ScheduledRegistrationJob.enabled == True, + ScheduledRegistrationJob.is_running == False, + ).update({ + 'is_running': True, + 'status': 'running', + 'last_run_at': now, + 'next_run_at': next_run_at, + 'updated_at': now, + }) + if not updated: + db.rollback() + return None + db.commit() + return get_scheduled_registration_job_by_uuid(db, job_uuid) + + +def mark_scheduled_registration_job_success( + db: Session, + job_uuid: str, + now: datetime, + task_uuid: Optional[str] = None, + batch_id: Optional[str] = None, + status: str = 'scheduled', +) -> Optional[ScheduledRegistrationJob]: + """标记计划注册任务已成功触发执行""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + job.is_running = False + job.status = status + job.last_success_at = now + job.last_error = None + job.run_count = (job.run_count or 0) + 1 + job.consecutive_failures = 0 + job.last_triggered_task_uuid = task_uuid + job.last_triggered_batch_id = batch_id + db.commit() + db.refresh(job) + return job + + +def mark_scheduled_registration_job_failure( + db: Session, + job_uuid: str, + error_message: str, + now: datetime, +) -> Optional[ScheduledRegistrationJob]: + """标记计划注册任务执行失败""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + job.is_running = False + job.status = 'failed' + job.last_error = error_message + job.run_count = (job.run_count or 0) + 1 + job.consecutive_failures = (job.consecutive_failures or 0) + 1 + db.commit() + db.refresh(job) + return job + + +def mark_scheduled_registration_job_skipped( + db: Session, + job_uuid: str, + error_message: str, +) -> Optional[ScheduledRegistrationJob]: + """标记计划注册任务跳过执行""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + job.last_error = error_message + job.status = 'idle' if job.enabled else 'paused' + db.commit() + db.refresh(job) + return job diff --git a/src/database/models.py b/src/database/models.py index ff0e4443..b1835541 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -233,6 +233,32 @@ class TeamManagerService(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class ScheduledRegistrationJob(Base): + """计划注册任务表""" + __tablename__ = 'scheduled_registration_jobs' + + id = Column(Integer, primary_key=True, autoincrement=True) + job_uuid = Column(String(36), unique=True, nullable=False, index=True) + name = Column(String(100), nullable=False) + enabled = Column(Boolean, default=True, index=True) + status = Column(String(20), default='idle') + schedule_type = Column(String(20), nullable=False) + schedule_config = Column(JSONEncodedDict, nullable=False) + registration_config = Column(JSONEncodedDict, nullable=False) + timezone = Column(String(50), default='local') + next_run_at = Column(DateTime, index=True) + last_run_at = Column(DateTime) + last_success_at = Column(DateTime) + last_error = Column(Text) + run_count = Column(Integer, default=0) + consecutive_failures = Column(Integer, default=0) + is_running = Column(Boolean, default=False, index=True) + last_triggered_task_uuid = Column(String(36)) + last_triggered_batch_id = Column(String(36)) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class Proxy(Base): """代理列表表""" __tablename__ = 'proxies' diff --git a/src/database/session.py b/src/database/session.py index 7aa8a490..8fe3319f 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -117,6 +117,25 @@ def migrate_tables(self): ("bind_card_tasks", "publishable_key", "VARCHAR(255)"), ("bind_card_tasks", "client_secret", "TEXT"), ("bind_card_tasks", "bind_mode", "VARCHAR(30) DEFAULT 'semi_auto'"), + ("scheduled_registration_jobs", "job_uuid", "VARCHAR(36)"), + ("scheduled_registration_jobs", "name", "VARCHAR(100)"), + ("scheduled_registration_jobs", "enabled", "BOOLEAN DEFAULT 1"), + ("scheduled_registration_jobs", "status", "VARCHAR(20) DEFAULT 'idle'"), + ("scheduled_registration_jobs", "schedule_type", "VARCHAR(20)"), + ("scheduled_registration_jobs", "schedule_config", "TEXT"), + ("scheduled_registration_jobs", "registration_config", "TEXT"), + ("scheduled_registration_jobs", "timezone", "VARCHAR(50) DEFAULT 'local'"), + ("scheduled_registration_jobs", "next_run_at", "DATETIME"), + ("scheduled_registration_jobs", "last_run_at", "DATETIME"), + ("scheduled_registration_jobs", "last_success_at", "DATETIME"), + ("scheduled_registration_jobs", "last_error", "TEXT"), + ("scheduled_registration_jobs", "run_count", "INTEGER DEFAULT 0"), + ("scheduled_registration_jobs", "consecutive_failures", "INTEGER DEFAULT 0"), + ("scheduled_registration_jobs", "is_running", "BOOLEAN DEFAULT 0"), + ("scheduled_registration_jobs", "last_triggered_task_uuid", "VARCHAR(36)"), + ("scheduled_registration_jobs", "last_triggered_batch_id", "VARCHAR(36)"), + ("scheduled_registration_jobs", "created_at", "DATETIME"), + ("scheduled_registration_jobs", "updated_at", "DATETIME"), ] # 确保新表存在(create_tables 已处理,此处兜底) diff --git a/src/web/app.py b/src/web/app.py index b679341d..4890a9bb 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -22,6 +22,7 @@ from .routes import api_router from .routes.websocket import router as ws_router from .task_manager import task_manager +from .scheduler import scheduled_registration_service logger = logging.getLogger(__name__) @@ -269,6 +270,8 @@ async def periodic_log_cleanup(): # 启动时先执行一次,再开启定时任务 await run_log_cleanup_once() app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) + await scheduled_registration_service.start() + app.state.scheduled_registration_service = scheduled_registration_service logger.info("=" * 50) logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") @@ -282,6 +285,9 @@ async def shutdown_event(): cleanup_task = getattr(app.state, "log_cleanup_task", None) if cleanup_task: cleanup_task.cancel() + scheduler_service = getattr(app.state, "scheduled_registration_service", None) + if scheduler_service: + await scheduler_service.stop() logger.info("应用关闭,今天先收摊啦") return app diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index cb89f056..df34fab0 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -7,18 +7,19 @@ import uuid import random from datetime import datetime -from typing import List, Optional, Dict, Tuple +from typing import List, Optional, Dict, Tuple, Any from fastapi import APIRouter, HTTPException, Query, BackgroundTasks from pydantic import BaseModel, Field from ...database import crud from ...database.session import get_db -from ...database.models import RegistrationTask, Proxy +from ...database.models import RegistrationTask, ScheduledRegistrationJob, Proxy from ...core.register import RegistrationEngine, RegistrationResult from ...services import EmailServiceFactory, EmailServiceType from ...config.settings import get_settings from ..task_manager import task_manager +from ..schedule_utils import normalize_schedule_config, compute_next_run_at, describe_schedule logger = logging.getLogger(__name__) router = APIRouter() @@ -175,6 +176,50 @@ class OutlookBatchRegistrationResponse(BaseModel): service_ids: List[int] # 实际要注册的服务 ID +class ScheduledRegistrationRequest(BaseModel): + """创建或更新计划注册任务请求""" + name: str = Field(..., min_length=1, max_length=100) + enabled: bool = True + schedule_type: str + schedule_config: Dict[str, Any] + registration_config: Dict[str, Any] + timezone: str = "local" + + +class ScheduledRegistrationJobResponse(BaseModel): + """计划注册任务响应""" + id: int + job_uuid: str + name: str + enabled: bool + status: str + schedule_type: str + schedule_config: Dict[str, Any] + schedule_description: str + registration_config: Dict[str, Any] + timezone: Optional[str] = None + next_run_at: Optional[str] = None + last_run_at: Optional[str] = None + last_success_at: Optional[str] = None + last_error: Optional[str] = None + run_count: int + consecutive_failures: int + is_running: bool + last_triggered_task_uuid: Optional[str] = None + last_triggered_batch_id: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class ScheduledRegistrationJobListResponse(BaseModel): + """计划注册任务列表响应""" + total: int + jobs: List[ScheduledRegistrationJobResponse] + + # ============== Helper Functions ============== def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse: @@ -194,6 +239,34 @@ def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse: ) +def scheduled_job_to_response(job: ScheduledRegistrationJob) -> ScheduledRegistrationJobResponse: + """转换计划任务模型为响应""" + schedule_config = job.schedule_config or {} + return ScheduledRegistrationJobResponse( + id=job.id, + job_uuid=job.job_uuid, + name=job.name, + enabled=job.enabled, + status=job.status, + schedule_type=job.schedule_type, + schedule_config=schedule_config, + schedule_description=describe_schedule(job.schedule_type, schedule_config), + registration_config=job.registration_config or {}, + timezone=job.timezone, + next_run_at=job.next_run_at.isoformat() if job.next_run_at else None, + last_run_at=job.last_run_at.isoformat() if job.last_run_at else None, + last_success_at=job.last_success_at.isoformat() if job.last_success_at else None, + last_error=job.last_error, + run_count=job.run_count or 0, + consecutive_failures=job.consecutive_failures or 0, + is_running=bool(job.is_running), + last_triggered_task_uuid=job.last_triggered_task_uuid, + last_triggered_batch_id=job.last_triggered_batch_id, + created_at=job.created_at.isoformat() if job.created_at else None, + updated_at=job.updated_at.isoformat() if job.updated_at else None, + ) + + def _normalize_email_service_config( service_type: EmailServiceType, config: Optional[dict], @@ -817,41 +890,47 @@ async def run_batch_registration( ) -# ============== API Endpoints ============== - -@router.post("/start", response_model=RegistrationTaskResponse) -async def start_registration( - request: RegistrationTaskCreate, - background_tasks: BackgroundTasks -): - """ - 启动注册任务 - - - email_service_type: 邮箱服务类型 (tempmail, outlook, moe_mail) - - proxy: 代理地址 - - email_service_config: 邮箱服务配置(outlook 需要提供账户信息) - """ - # 验证邮箱服务类型 +def _validate_registration_request(email_service_type: str): + """校验邮箱服务类型。""" try: - EmailServiceType(request.email_service_type) - except ValueError: + EmailServiceType(email_service_type) + except ValueError as exc: raise HTTPException( status_code=400, - detail=f"无效的邮箱服务类型: {request.email_service_type}" - ) + detail=f"无效的邮箱服务类型: {email_service_type}" + ) from exc - # 创建任务 - task_uuid = str(uuid.uuid4()) +def _schedule_async_job(background_tasks: Optional[BackgroundTasks], coroutine_func, *args): + """统一调度后台异步任务。""" + if background_tasks is not None: + background_tasks.add_task(coroutine_func, *args) + return + + loop = task_manager.get_loop() + if loop is None: + loop = asyncio.get_event_loop() + task_manager.set_loop(loop) + loop.create_task(coroutine_func(*args)) + + +async def _start_single_registration_internal( + request: RegistrationTaskCreate, + background_tasks: Optional[BackgroundTasks] = None, +) -> RegistrationTaskResponse: + """启动单次注册任务。""" + _validate_registration_request(request.email_service_type) + + task_uuid = str(uuid.uuid4()) with get_db() as db: task = crud.create_registration_task( db, task_uuid=task_uuid, - proxy=request.proxy + proxy=request.proxy, ) - # 在后台运行注册任务 - background_tasks.add_task( + _schedule_async_job( + background_tasks, run_registration_task, task_uuid, request.email_service_type, @@ -867,35 +946,18 @@ async def start_registration( request.auto_upload_tm, request.tm_service_ids, ) - return task_to_response(task) -@router.post("/batch", response_model=BatchRegistrationResponse) -async def start_batch_registration( +async def _start_batch_registration_internal( request: BatchRegistrationRequest, - background_tasks: BackgroundTasks -): - """ - 启动批量注册任务 - - - count: 注册数量 (1-1000) - - email_service_type: 邮箱服务类型 - - proxy: 代理地址 - - interval_min: 最小间隔秒数 - - interval_max: 最大间隔秒数 - """ - # 验证参数 + background_tasks: Optional[BackgroundTasks] = None, +) -> BatchRegistrationResponse: + """启动普通批量注册任务。""" if request.count < 1 or request.count > 1000: raise HTTPException(status_code=400, detail="注册数量必须在 1-1000 之间") - try: - EmailServiceType(request.email_service_type) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"无效的邮箱服务类型: {request.email_service_type}" - ) + _validate_registration_request(request.email_service_type) if request.interval_min < 0 or request.interval_max < request.interval_min: raise HTTPException(status_code=400, detail="间隔时间参数无效") @@ -906,26 +968,24 @@ async def start_batch_registration( if request.mode not in ("parallel", "pipeline"): raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline") - # 创建批量任务 batch_id = str(uuid.uuid4()) task_uuids = [] with get_db() as db: for _ in range(request.count): task_uuid = str(uuid.uuid4()) - task = crud.create_registration_task( + crud.create_registration_task( db, task_uuid=task_uuid, - proxy=request.proxy + proxy=request.proxy, ) task_uuids.append(task_uuid) - # 获取所有任务 with get_db() as db: - tasks = [crud.get_registration_task(db, uuid) for uuid in task_uuids] + tasks = [crud.get_registration_task(db, item_uuid) for item_uuid in task_uuids] - # 在后台运行批量注册 - background_tasks.add_task( + _schedule_async_job( + background_tasks, run_batch_registration, batch_id, task_uuids, @@ -948,10 +1008,217 @@ async def start_batch_registration( return BatchRegistrationResponse( batch_id=batch_id, count=request.count, - tasks=[task_to_response(t) for t in tasks if t] + tasks=[task_to_response(task) for task in tasks if task], ) +async def _start_outlook_batch_registration_internal( + request: OutlookBatchRegistrationRequest, + background_tasks: Optional[BackgroundTasks] = None, +) -> OutlookBatchRegistrationResponse: + """启动 Outlook 批量注册任务。""" + from ...database.models import EmailService as EmailServiceModel + from ...database.models import Account + + if not request.service_ids: + raise HTTPException(status_code=400, detail="请选择至少一个 Outlook 账户") + + if request.interval_min < 0 or request.interval_max < request.interval_min: + raise HTTPException(status_code=400, detail="间隔时间参数无效") + + if not 1 <= request.concurrency <= 50: + raise HTTPException(status_code=400, detail="并发数必须在 1-50 之间") + + if request.mode not in ("parallel", "pipeline"): + raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline") + + actual_service_ids = request.service_ids + skipped_count = 0 + + if request.skip_registered: + actual_service_ids = [] + with get_db() as db: + for service_id in request.service_ids: + service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == service_id + ).first() + if not service: + continue + + config = service.config or {} + email = config.get("email") or service.name + existing_account = db.query(Account).filter(Account.email == email).first() + if existing_account: + skipped_count += 1 + else: + actual_service_ids.append(service_id) + + if not actual_service_ids: + return OutlookBatchRegistrationResponse( + batch_id="", + total=len(request.service_ids), + skipped=skipped_count, + to_register=0, + service_ids=[], + ) + + batch_id = str(uuid.uuid4()) + batch_tasks[batch_id] = { + "total": len(actual_service_ids), + "completed": 0, + "success": 0, + "failed": 0, + "skipped": 0, + "cancelled": False, + "service_ids": actual_service_ids, + "current_index": 0, + "logs": [], + "finished": False, + } + + _schedule_async_job( + background_tasks, + run_outlook_batch_registration, + batch_id, + actual_service_ids, + request.skip_registered, + request.proxy, + request.interval_min, + request.interval_max, + request.concurrency, + request.mode, + request.auto_upload_cpa, + request.cpa_service_ids, + request.auto_upload_sub2api, + request.sub2api_service_ids, + request.auto_upload_tm, + request.tm_service_ids, + ) + + return OutlookBatchRegistrationResponse( + batch_id=batch_id, + total=len(request.service_ids), + skipped=skipped_count, + to_register=len(actual_service_ids), + service_ids=actual_service_ids, + ) + + +async def dispatch_registration_config( + registration_config: Dict[str, Any], + background_tasks: Optional[BackgroundTasks] = None, +) -> Dict[str, Any]: + """按统一注册配置分发执行注册任务。""" + config = dict(registration_config or {}) + reg_mode = config.get('reg_mode') or 'single' + email_service_type = config.get('email_service_type') + if not email_service_type: + raise HTTPException(status_code=400, detail='缺少邮箱服务类型') + + if email_service_type == 'outlook_batch': + request = OutlookBatchRegistrationRequest( + service_ids=config.get('service_ids') or [], + skip_registered=bool(config.get('skip_registered', True)), + proxy=config.get('proxy'), + interval_min=int(config.get('interval_min') or 5), + interval_max=int(config.get('interval_max') or 30), + concurrency=int(config.get('concurrency') or 1), + mode=config.get('mode') or 'pipeline', + auto_upload_cpa=bool(config.get('auto_upload_cpa', False)), + cpa_service_ids=config.get('cpa_service_ids') or [], + auto_upload_sub2api=bool(config.get('auto_upload_sub2api', False)), + sub2api_service_ids=config.get('sub2api_service_ids') or [], + auto_upload_tm=bool(config.get('auto_upload_tm', False)), + tm_service_ids=config.get('tm_service_ids') or [], + ) + response = await _start_outlook_batch_registration_internal(request, background_tasks) + return { + 'kind': 'batch', + 'batch_id': response.batch_id, + 'payload': response.model_dump(), + } + + _validate_registration_request(email_service_type) + + if reg_mode == 'batch': + request = BatchRegistrationRequest( + count=int(config.get('batch_count') or 1), + email_service_type=email_service_type, + proxy=config.get('proxy'), + email_service_config=config.get('email_service_config'), + email_service_id=config.get('email_service_id'), + interval_min=int(config.get('interval_min') or 5), + interval_max=int(config.get('interval_max') or 30), + concurrency=int(config.get('concurrency') or 1), + mode=config.get('mode') or 'pipeline', + auto_upload_cpa=bool(config.get('auto_upload_cpa', False)), + cpa_service_ids=config.get('cpa_service_ids') or [], + auto_upload_sub2api=bool(config.get('auto_upload_sub2api', False)), + sub2api_service_ids=config.get('sub2api_service_ids') or [], + auto_upload_tm=bool(config.get('auto_upload_tm', False)), + tm_service_ids=config.get('tm_service_ids') or [], + ) + response = await _start_batch_registration_internal(request, background_tasks) + return { + 'kind': 'batch', + 'batch_id': response.batch_id, + 'payload': response.model_dump(), + } + + request = RegistrationTaskCreate( + email_service_type=email_service_type, + proxy=config.get('proxy'), + email_service_config=config.get('email_service_config'), + email_service_id=config.get('email_service_id'), + auto_upload_cpa=bool(config.get('auto_upload_cpa', False)), + cpa_service_ids=config.get('cpa_service_ids') or [], + auto_upload_sub2api=bool(config.get('auto_upload_sub2api', False)), + sub2api_service_ids=config.get('sub2api_service_ids') or [], + auto_upload_tm=bool(config.get('auto_upload_tm', False)), + tm_service_ids=config.get('tm_service_ids') or [], + ) + response = await _start_single_registration_internal(request, background_tasks) + return { + 'kind': 'single', + 'task_uuid': response.task_uuid, + 'payload': response.model_dump(), + } + + +# ============== API Endpoints ============== + +@router.post("/start", response_model=RegistrationTaskResponse) +async def start_registration( + request: RegistrationTaskCreate, + background_tasks: BackgroundTasks +): + """ + 启动注册任务 + + - email_service_type: 邮箱服务类型 (tempmail, outlook, moe_mail) + - proxy: 代理地址 + - email_service_config: 邮箱服务配置(outlook 需要提供账户信息) + """ + return await _start_single_registration_internal(request, background_tasks) + + +@router.post("/batch", response_model=BatchRegistrationResponse) +async def start_batch_registration( + request: BatchRegistrationRequest, + background_tasks: BackgroundTasks +): + """ + 启动批量注册任务 + + - count: 注册数量 (1-1000) + - email_service_type: 邮箱服务类型 + - proxy: 代理地址 + - interval_min: 最小间隔秒数 + - interval_max: 最大间隔秒数 + """ + return await _start_batch_registration_internal(request, background_tasks) + + @router.get("/batch/{batch_id}") async def get_batch_status(batch_id: str): """获取批量任务状态""" @@ -1466,102 +1733,7 @@ async def start_outlook_batch_registration( - interval_min: 最小间隔秒数 - interval_max: 最大间隔秒数 """ - from ...database.models import EmailService as EmailServiceModel - from ...database.models import Account - - # 验证参数 - if not request.service_ids: - raise HTTPException(status_code=400, detail="请选择至少一个 Outlook 账户") - - if request.interval_min < 0 or request.interval_max < request.interval_min: - raise HTTPException(status_code=400, detail="间隔时间参数无效") - - if not 1 <= request.concurrency <= 50: - raise HTTPException(status_code=400, detail="并发数必须在 1-50 之间") - - if request.mode not in ("parallel", "pipeline"): - raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline") - - # 过滤掉已注册的邮箱 - actual_service_ids = request.service_ids - skipped_count = 0 - - if request.skip_registered: - actual_service_ids = [] - with get_db() as db: - for service_id in request.service_ids: - service = db.query(EmailServiceModel).filter( - EmailServiceModel.id == service_id - ).first() - - if not service: - continue - - config = service.config or {} - email = config.get("email") or service.name - - # 检查是否已注册 - existing_account = db.query(Account).filter( - Account.email == email - ).first() - - if existing_account: - skipped_count += 1 - else: - actual_service_ids.append(service_id) - - if not actual_service_ids: - return OutlookBatchRegistrationResponse( - batch_id="", - total=len(request.service_ids), - skipped=skipped_count, - to_register=0, - service_ids=[] - ) - - # 创建批量任务 - batch_id = str(uuid.uuid4()) - - # 初始化批量任务状态 - batch_tasks[batch_id] = { - "total": len(actual_service_ids), - "completed": 0, - "success": 0, - "failed": 0, - "skipped": 0, - "cancelled": False, - "service_ids": actual_service_ids, - "current_index": 0, - "logs": [], - "finished": False - } - - # 在后台运行批量注册 - background_tasks.add_task( - run_outlook_batch_registration, - batch_id, - actual_service_ids, - request.skip_registered, - request.proxy, - request.interval_min, - request.interval_max, - request.concurrency, - request.mode, - request.auto_upload_cpa, - request.cpa_service_ids, - request.auto_upload_sub2api, - request.sub2api_service_ids, - request.auto_upload_tm, - request.tm_service_ids, - ) - - return OutlookBatchRegistrationResponse( - batch_id=batch_id, - total=len(request.service_ids), - skipped=skipped_count, - to_register=len(actual_service_ids), - service_ids=actual_service_ids - ) + return await _start_outlook_batch_registration_internal(request, background_tasks) @router.get("/outlook-batch/{batch_id}") @@ -1601,3 +1773,180 @@ async def cancel_outlook_batch(batch_id: str): task_manager.cancel_batch(batch_id) return {"success": True, "message": "批量任务取消请求已提交,正在让它们有序收工"} + + +@router.get("/schedules", response_model=ScheduledRegistrationJobListResponse) +async def list_scheduled_registration_jobs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + enabled: Optional[bool] = Query(None), +): + """获取计划注册任务列表。""" + offset = (page - 1) * page_size + with get_db() as db: + jobs = crud.get_scheduled_registration_jobs(db, enabled=enabled, skip=offset, limit=page_size) + total_query = db.query(ScheduledRegistrationJob) + if enabled is not None: + total_query = total_query.filter(ScheduledRegistrationJob.enabled == enabled) + total = total_query.count() + return ScheduledRegistrationJobListResponse( + total=total, + jobs=[scheduled_job_to_response(job) for job in jobs], + ) + + +@router.get("/schedules/{job_uuid}", response_model=ScheduledRegistrationJobResponse) +async def get_scheduled_registration_job(job_uuid: str): + """获取计划注册任务详情。""" + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + return scheduled_job_to_response(job) + + +@router.post("/schedules", response_model=ScheduledRegistrationJobResponse) +async def create_scheduled_registration_job(request: ScheduledRegistrationRequest): + """创建计划注册任务。""" + now = datetime.utcnow() + normalized_schedule_config = normalize_schedule_config(request.schedule_type, request.schedule_config, now) + next_run_at = compute_next_run_at(request.schedule_type, normalized_schedule_config, now) + registration_config = dict(request.registration_config or {}) + + with get_db() as db: + job = crud.create_scheduled_registration_job( + db, + job_uuid=str(uuid.uuid4()), + name=request.name.strip(), + enabled=request.enabled, + status='idle' if request.enabled else 'paused', + schedule_type=request.schedule_type, + schedule_config=normalized_schedule_config, + registration_config=registration_config, + timezone=request.timezone, + next_run_at=next_run_at if request.enabled else None, + ) + return scheduled_job_to_response(job) + + +@router.put("/schedules/{job_uuid}", response_model=ScheduledRegistrationJobResponse) +async def update_scheduled_registration_job(job_uuid: str, request: ScheduledRegistrationRequest): + """更新计划注册任务。""" + now = datetime.utcnow() + normalized_schedule_config = normalize_schedule_config(request.schedule_type, request.schedule_config, now) + next_run_at = compute_next_run_at(request.schedule_type, normalized_schedule_config, now) + + with get_db() as db: + existing = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not existing: + raise HTTPException(status_code=404, detail="计划任务不存在") + + job = crud.update_scheduled_registration_job( + db, + job_uuid, + name=request.name.strip(), + enabled=request.enabled, + status='idle' if request.enabled else 'paused', + schedule_type=request.schedule_type, + schedule_config=normalized_schedule_config, + registration_config=dict(request.registration_config or {}), + timezone=request.timezone, + next_run_at=next_run_at if request.enabled else None, + last_error=None, + ) + return scheduled_job_to_response(job) + + +@router.post("/schedules/{job_uuid}/enable", response_model=ScheduledRegistrationJobResponse) +async def enable_scheduled_registration_job(job_uuid: str): + """启用计划注册任务。""" + now = datetime.utcnow() + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + next_run_at = compute_next_run_at(job.schedule_type, job.schedule_config or {}, now) + updated = crud.update_scheduled_registration_job( + db, + job_uuid, + enabled=True, + status='idle', + next_run_at=next_run_at, + ) + return scheduled_job_to_response(updated) + + +@router.post("/schedules/{job_uuid}/pause", response_model=ScheduledRegistrationJobResponse) +async def pause_scheduled_registration_job(job_uuid: str): + """暂停计划注册任务。""" + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + updated = crud.update_scheduled_registration_job( + db, + job_uuid, + enabled=False, + status='paused', + next_run_at=None, + is_running=False, + ) + return scheduled_job_to_response(updated) + + +@router.post("/schedules/{job_uuid}/run") +async def run_scheduled_registration_job_now(job_uuid: str, background_tasks: BackgroundTasks): + """立即执行一次计划注册任务。""" + now = datetime.utcnow() + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + if job.is_running: + raise HTTPException(status_code=400, detail="计划任务正在执行中") + if job.enabled: + next_run_at = compute_next_run_at(job.schedule_type, job.schedule_config or {}, now) + else: + next_run_at = None + claimed = crud.claim_scheduled_registration_job(db, job_uuid, next_run_at, now) + if not claimed: + raise HTTPException(status_code=409, detail="计划任务状态已变化,请刷新后重试") + + try: + result = await dispatch_registration_config(claimed.registration_config or {}, background_tasks) + with get_db() as db: + crud.mark_scheduled_registration_job_success( + db, + job_uuid, + datetime.utcnow(), + task_uuid=result.get('task_uuid'), + batch_id=result.get('batch_id'), + ) + return { + 'success': True, + 'message': '计划任务已触发执行', + 'task_uuid': result.get('task_uuid'), + 'batch_id': result.get('batch_id'), + } + except Exception as exc: + with get_db() as db: + crud.mark_scheduled_registration_job_failure( + db, + job_uuid, + str(exc), + datetime.utcnow(), + ) + raise + + +@router.delete("/schedules/{job_uuid}") +async def delete_scheduled_registration_job(job_uuid: str): + """删除计划注册任务。""" + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + if job.is_running: + raise HTTPException(status_code=400, detail="无法删除执行中的计划任务") + crud.delete_scheduled_registration_job(db, job_uuid) + return {'success': True, 'message': '计划任务已删除'} diff --git a/src/web/schedule_utils.py b/src/web/schedule_utils.py new file mode 100644 index 00000000..f3f7822a --- /dev/null +++ b/src/web/schedule_utils.py @@ -0,0 +1,104 @@ +"""计划任务时间计算工具。""" + +from datetime import datetime, timedelta, time +from typing import Any, Dict, Optional + + +VALID_SCHEDULE_TYPES = {"interval", "timepoint"} + + +def parse_time_of_day(value: str) -> time: + """解析 HH:MM 格式的时间字符串。""" + try: + hour_text, minute_text = value.split(":", 1) + hour = int(hour_text) + minute = int(minute_text) + except Exception as exc: + raise ValueError("时间点格式必须为 HH:MM") from exc + + if not 0 <= hour <= 23 or not 0 <= minute <= 59: + raise ValueError("时间点必须在 00:00-23:59 之间") + + return time(hour=hour, minute=minute) + + +def parse_start_date(value: Optional[str], now: datetime) -> datetime.date: + """解析计划开始日期。""" + if not value: + return now.date() + + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError as exc: + raise ValueError("开始日期格式必须为 YYYY-MM-DD") from exc + + +def normalize_schedule_config( + schedule_type: str, + schedule_config: Optional[Dict[str, Any]], + now: Optional[datetime] = None, +) -> Dict[str, Any]: + """校验并标准化计划配置。""" + current_time = now or datetime.utcnow() + config = dict(schedule_config or {}) + + if schedule_type not in VALID_SCHEDULE_TYPES: + raise ValueError("计划类型必须为 interval 或 timepoint") + + if schedule_type == "interval": + interval_minutes = int(config.get("interval_minutes") or 0) + if interval_minutes < 1: + raise ValueError("固定间隔必须大于等于 1 分钟") + return {"interval_minutes": interval_minutes} + + every_n_days = int(config.get("every_n_days") or 0) + if every_n_days < 1: + raise ValueError("周期天数必须大于等于 1") + + time_of_day = config.get("time_of_day") or "" + parsed_time = parse_time_of_day(time_of_day) + start_date = parse_start_date(config.get("start_date"), current_time) + + return { + "every_n_days": every_n_days, + "time_of_day": parsed_time.strftime("%H:%M"), + "start_date": start_date.isoformat(), + } + + +def compute_next_run_at( + schedule_type: str, + schedule_config: Dict[str, Any], + now: Optional[datetime] = None, + reference_time: Optional[datetime] = None, +) -> datetime: + """根据计划配置计算下一次执行时间。""" + current_time = now or datetime.utcnow() + normalized = normalize_schedule_config(schedule_type, schedule_config, current_time) + + if schedule_type == "interval": + interval_delta = timedelta(minutes=normalized["interval_minutes"]) + candidate = (reference_time or current_time) + interval_delta + while candidate <= current_time: + candidate += interval_delta + return candidate + + every_n_days = normalized["every_n_days"] + time_of_day = parse_time_of_day(normalized["time_of_day"]) + start_date = parse_start_date(normalized.get("start_date"), current_time) + + candidate = datetime.combine(start_date, time_of_day) + anchor_time = reference_time or current_time + while candidate <= anchor_time: + candidate += timedelta(days=every_n_days) + while candidate <= current_time: + candidate += timedelta(days=every_n_days) + return candidate + + +def describe_schedule(schedule_type: str, schedule_config: Dict[str, Any]) -> str: + """生成人类可读的计划描述。""" + normalized = normalize_schedule_config(schedule_type, schedule_config) + if schedule_type == "interval": + return f"每 {normalized['interval_minutes']} 分钟触发" + return f"每 {normalized['every_n_days']} 天 {normalized['time_of_day']} 触发" diff --git a/src/web/scheduler.py b/src/web/scheduler.py new file mode 100644 index 00000000..89c783c5 --- /dev/null +++ b/src/web/scheduler.py @@ -0,0 +1,124 @@ +"""计划注册任务调度器。""" + +import asyncio +import logging +from datetime import datetime +from typing import Optional + +from ..database import crud +from ..database.session import get_db +from .routes.registration import dispatch_registration_config +from .schedule_utils import compute_next_run_at + +logger = logging.getLogger(__name__) + + +class ScheduledRegistrationService: + """计划注册任务调度服务。""" + + def __init__(self, poll_interval_seconds: int = 15): + self.poll_interval_seconds = max(5, poll_interval_seconds) + self._task: Optional[asyncio.Task] = None + self._running = False + + async def start(self): + """启动计划任务调度器。""" + if self._task and not self._task.done(): + return + self._running = True + self._task = asyncio.create_task(self._run_loop()) + logger.info("计划任务调度器已启动,轮询间隔 %s 秒", self.poll_interval_seconds) + + async def stop(self): + """停止计划任务调度器。""" + self._running = False + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + logger.info("计划任务调度器已停止") + + async def _run_loop(self): + """执行调度轮询循环。""" + while self._running: + try: + await self.poll_due_jobs() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning(f"计划任务轮询异常: {exc}") + await asyncio.sleep(self.poll_interval_seconds) + + async def poll_due_jobs(self): + """扫描并执行到期计划任务。""" + now = datetime.utcnow() + with get_db() as db: + due_jobs = crud.get_due_scheduled_registration_jobs(db, now) + due_job_uuids = [job.job_uuid for job in due_jobs] + running_jobs = crud.get_running_scheduled_registration_jobs(db) + running_job_uuids = [job.job_uuid for job in running_jobs if job.next_run_at and job.next_run_at <= now] + + for job_uuid in running_job_uuids: + with get_db() as db: + crud.mark_scheduled_registration_job_skipped( + db, + job_uuid, + "上一次执行尚未结束,已跳过本次触发", + ) + + for job_uuid in due_job_uuids: + await self.run_job(job_uuid) + + async def run_job(self, job_uuid: str): + """执行单个计划任务。""" + now = datetime.utcnow() + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job or not job.enabled: + return + + if job.is_running: + crud.mark_scheduled_registration_job_skipped( + db, + job_uuid, + "上一次执行尚未结束,已跳过本次触发", + ) + return + + next_run_at = compute_next_run_at( + job.schedule_type, + job.schedule_config or {}, + now, + reference_time=job.next_run_at or now, + ) + claimed_job = crud.claim_scheduled_registration_job(db, job_uuid, next_run_at, now) + if not claimed_job: + return + registration_config = claimed_job.registration_config or {} + + try: + result = await dispatch_registration_config(registration_config, None) + with get_db() as db: + crud.mark_scheduled_registration_job_success( + db, + job_uuid, + datetime.utcnow(), + task_uuid=result.get("task_uuid"), + batch_id=result.get("batch_id"), + ) + except Exception as exc: + logger.warning(f"计划任务执行失败 {job_uuid}: {exc}") + with get_db() as db: + crud.mark_scheduled_registration_job_failure( + db, + job_uuid, + str(exc), + datetime.utcnow(), + ) + + +scheduled_registration_service = ScheduledRegistrationService() diff --git a/static/js/app.js b/static/js/app.js index 8fd65b6d..dad0e783 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -14,6 +14,8 @@ let todayStatsResetInterval = null; let isBatchMode = false; let isOutlookBatchMode = false; let outlookAccounts = []; +let editingScheduleJobUuid = null; +let scheduledJobs = []; let taskCompleted = false; // 标记任务是否已完成 let batchCompleted = false; // 标记批量任务是否已完成 let taskFinalStatus = null; // 保存任务的最终状态 @@ -112,6 +114,20 @@ const elements = { autoUploadTm: document.getElementById('auto-upload-tm'), tmServiceSelectGroup: document.getElementById('tm-service-select-group'), tmServiceSelect: document.getElementById('tm-service-select'), + scheduleForm: document.getElementById('schedule-form'), + scheduleName: document.getElementById('schedule-name'), + scheduleTriggerType: document.getElementById('schedule-trigger-type'), + scheduleEnabled: document.getElementById('schedule-enabled'), + scheduleIntervalFields: document.getElementById('schedule-interval-fields'), + scheduleIntervalMinutes: document.getElementById('schedule-interval-minutes'), + scheduleTimepointFields: document.getElementById('schedule-timepoint-fields'), + scheduleEveryNDays: document.getElementById('schedule-every-n-days'), + scheduleTimeOfDay: document.getElementById('schedule-time-of-day'), + scheduleStartDate: document.getElementById('schedule-start-date'), + saveScheduleBtn: document.getElementById('save-schedule-btn'), + cancelScheduleEditBtn: document.getElementById('cancel-schedule-edit-btn'), + refreshSchedulesBtn: document.getElementById('refresh-schedules-btn'), + scheduleJobsTable: document.getElementById('schedule-jobs-table'), }; // 初始化 @@ -126,6 +142,8 @@ document.addEventListener('DOMContentLoaded', () => { initVisibilityReconnect(); restoreActiveTask(); initAutoUploadOptions(); + initScheduleForm(); + loadScheduledJobs(); }); // 初始化注册后自动操作选项(CPA / Sub2API / TM) @@ -239,6 +257,10 @@ function initEventListeners() { elements.outlookConcurrencyMode.addEventListener('change', () => { handleConcurrencyModeChange(elements.outlookConcurrencyMode, elements.outlookConcurrencyHint, elements.outlookIntervalGroup); }); + + if (elements.refreshSchedulesBtn) { + elements.refreshSchedulesBtn.addEventListener('click', () => loadScheduledJobs()); + } } // 加载可用的邮箱服务 @@ -250,6 +272,13 @@ async function loadAvailableServices() { // 更新邮箱服务选择框 updateEmailServiceOptions(); + if (editingScheduleJobUuid) { + const currentJob = scheduledJobs.find(item => item.job_uuid === editingScheduleJobUuid); + if (currentJob) { + setRegistrationConfigToForm(currentJob.registration_config || {}); + } + } + addLog('info', '[系统] 邮箱服务列表已加载'); } catch (error) { console.error('加载邮箱服务列表失败:', error); @@ -485,12 +514,347 @@ function handleConcurrencyModeChange(selectEl, hintEl, intervalGroupEl) { } } +function initScheduleForm() { + if (!elements.scheduleForm) return; + if (!elements.scheduleStartDate.value) { + elements.scheduleStartDate.value = new Date().toISOString().slice(0, 10); + } + elements.scheduleForm.addEventListener('submit', handleScheduleSubmit); + elements.scheduleTriggerType.addEventListener('change', updateScheduleTriggerFields); + elements.cancelScheduleEditBtn.addEventListener('click', resetScheduleForm); + updateScheduleTriggerFields(); +} + +function updateScheduleTriggerFields() { + if (!elements.scheduleTriggerType) return; + const triggerType = elements.scheduleTriggerType.value; + elements.scheduleIntervalFields.style.display = triggerType === 'interval' ? 'block' : 'none'; + elements.scheduleTimepointFields.style.display = triggerType === 'timepoint' ? 'block' : 'none'; +} + +function buildCurrentRegistrationConfig() { + const selectedValue = elements.emailService.value; + if (!selectedValue) { + throw new Error('请选择一个邮箱服务'); + } + + const [emailServiceType, serviceId] = selectedValue.split(':'); + const baseConfig = { + email_service_type: emailServiceType, + reg_mode: isOutlookBatchMode ? 'outlook_batch' : (isBatchMode ? 'batch' : 'single'), + 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) : [], + }; + + if (isOutlookBatchMode) { + const selectedIds = []; + document.querySelectorAll('.outlook-account-checkbox:checked').forEach(cb => { + selectedIds.push(parseInt(cb.value)); + }); + return { + ...baseConfig, + email_service_type: 'outlook_batch', + service_ids: selectedIds, + skip_registered: elements.outlookSkipRegistered.checked, + interval_min: parseInt(elements.outlookIntervalMin.value) || 5, + interval_max: parseInt(elements.outlookIntervalMax.value) || 30, + concurrency: Math.min(50, Math.max(1, parseInt(elements.outlookConcurrencyCount.value) || 3)), + mode: elements.outlookConcurrencyMode.value || 'pipeline', + }; + } + + if (serviceId && serviceId !== 'default') { + baseConfig.email_service_id = parseInt(serviceId); + } + + if (isBatchMode) { + return { + ...baseConfig, + batch_count: parseInt(elements.batchCount.value) || 1, + interval_min: parseInt(elements.intervalMin.value) || 5, + interval_max: parseInt(elements.intervalMax.value) || 30, + concurrency: Math.min(50, Math.max(1, parseInt(elements.concurrencyCount.value) || 1)), + mode: elements.concurrencyMode.value || 'pipeline', + }; + } + + return baseConfig; +} + +function buildScheduleConfig() { + const triggerType = elements.scheduleTriggerType.value; + if (triggerType === 'interval') { + return { + schedule_type: 'interval', + schedule_config: { + interval_minutes: parseInt(elements.scheduleIntervalMinutes.value) || 1, + }, + }; + } + + return { + schedule_type: 'timepoint', + schedule_config: { + every_n_days: parseInt(elements.scheduleEveryNDays.value) || 1, + time_of_day: elements.scheduleTimeOfDay.value || '09:00', + start_date: elements.scheduleStartDate.value || null, + }, + }; +} + +async function handleScheduleSubmit(e) { + e.preventDefault(); + + try { + const registrationConfig = buildCurrentRegistrationConfig(); + if (registrationConfig.email_service_type === 'outlook_batch' && (!registrationConfig.service_ids || registrationConfig.service_ids.length === 0)) { + toast.error('请至少选择一个 Outlook 账户后再保存计划'); + return; + } + + const { schedule_type, schedule_config } = buildScheduleConfig(); + const payload = { + name: (elements.scheduleName.value || '').trim(), + enabled: elements.scheduleEnabled.value === 'true', + schedule_type, + schedule_config, + registration_config: registrationConfig, + timezone: 'local', + }; + + if (!payload.name) { + toast.error('请输入计划名称'); + return; + } + + const endpoint = editingScheduleJobUuid + ? `/registration/schedules/${editingScheduleJobUuid}` + : '/registration/schedules'; + const method = editingScheduleJobUuid ? 'put' : 'post'; + + await api[method](endpoint, payload); + toast.success(editingScheduleJobUuid ? '计划任务已更新' : '计划任务已创建'); + resetScheduleForm(); + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +function resetScheduleForm() { + editingScheduleJobUuid = null; + if (!elements.scheduleForm) return; + elements.scheduleForm.reset(); + elements.scheduleEnabled.value = 'true'; + elements.scheduleTriggerType.value = 'interval'; + elements.scheduleIntervalMinutes.value = '60'; + elements.scheduleEveryNDays.value = '1'; + elements.scheduleTimeOfDay.value = '09:00'; + elements.scheduleStartDate.value = new Date().toISOString().slice(0, 10); + elements.saveScheduleBtn.textContent = '保存计划任务'; + elements.cancelScheduleEditBtn.style.display = 'none'; + updateScheduleTriggerFields(); +} + +function setRegistrationConfigToForm(config) { + const registrationConfig = config || {}; + const emailServiceType = registrationConfig.email_service_type || 'tempmail'; + const emailServiceId = registrationConfig.email_service_id; + const serviceValue = emailServiceType === 'outlook_batch' + ? 'outlook_batch:all' + : `${emailServiceType}:${emailServiceId ?? 'default'}`; + + elements.emailService.value = serviceValue; + handleServiceChange({ target: elements.emailService }); + + elements.autoUploadCpa.checked = !!registrationConfig.auto_upload_cpa; + elements.cpaServiceSelectGroup.style.display = elements.autoUploadCpa.checked ? 'block' : 'none'; + elements.autoUploadSub2api.checked = !!registrationConfig.auto_upload_sub2api; + elements.sub2apiServiceSelectGroup.style.display = elements.autoUploadSub2api.checked ? 'block' : 'none'; + elements.autoUploadTm.checked = !!registrationConfig.auto_upload_tm; + elements.tmServiceSelectGroup.style.display = elements.autoUploadTm.checked ? 'block' : 'none'; + + setSelectedServiceIds(elements.cpaServiceSelect, registrationConfig.cpa_service_ids || []); + setSelectedServiceIds(elements.sub2apiServiceSelect, registrationConfig.sub2api_service_ids || []); + setSelectedServiceIds(elements.tmServiceSelect, registrationConfig.tm_service_ids || []); + + if (emailServiceType === 'outlook_batch') { + isOutlookBatchMode = true; + elements.outlookBatchSection.style.display = 'block'; + elements.regModeGroup.style.display = 'none'; + elements.batchCountGroup.style.display = 'none'; + elements.batchOptions.style.display = 'none'; + if (Array.isArray(registrationConfig.service_ids) && registrationConfig.service_ids.length) { + const selectedSet = new Set(registrationConfig.service_ids.map(item => parseInt(item))); + document.querySelectorAll('.outlook-account-checkbox').forEach(cb => { + cb.checked = selectedSet.has(parseInt(cb.value)); + }); + } + elements.outlookSkipRegistered.checked = registrationConfig.skip_registered !== false; + elements.outlookIntervalMin.value = registrationConfig.interval_min || 5; + elements.outlookIntervalMax.value = registrationConfig.interval_max || 30; + elements.outlookConcurrencyCount.value = registrationConfig.concurrency || 3; + elements.outlookConcurrencyMode.value = registrationConfig.mode || 'pipeline'; + handleConcurrencyModeChange(elements.outlookConcurrencyMode, elements.outlookConcurrencyHint, elements.outlookIntervalGroup); + return; + } + + isOutlookBatchMode = false; + elements.outlookBatchSection.style.display = 'none'; + elements.regModeGroup.style.display = 'block'; + elements.regMode.value = registrationConfig.reg_mode === 'batch' ? 'batch' : 'single'; + handleModeChange({ target: elements.regMode }); + elements.batchCount.value = registrationConfig.batch_count || 1; + elements.intervalMin.value = registrationConfig.interval_min || 5; + elements.intervalMax.value = registrationConfig.interval_max || 30; + elements.concurrencyCount.value = registrationConfig.concurrency || 1; + elements.concurrencyMode.value = registrationConfig.mode || 'pipeline'; + handleConcurrencyModeChange(elements.concurrencyMode, elements.concurrencyHint, elements.intervalGroup); +} + +function setSelectedServiceIds(container, selectedIds) { + if (!container) return; + const selectedSet = new Set((selectedIds || []).map(item => parseInt(item))); + container.querySelectorAll('.msd-item input').forEach(cb => { + cb.checked = selectedSet.size === 0 ? true : selectedSet.has(parseInt(cb.value)); + }); + const dropdownId = `${container.id}-dd`; + updateMsdLabel(dropdownId); +} + +function formatScheduleDateTime(value) { + if (!value) return '-'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function getScheduleStatusClass(job) { + if (!job.enabled) return 'cancelled'; + if (job.status === 'failed') return 'failed'; + if (job.is_running || job.status === 'running') return 'running'; + if (job.status === 'scheduled') return 'completed'; + return 'pending'; +} + +async function loadScheduledJobs() { + if (!elements.scheduleJobsTable) return; + try { + const data = await api.get('/registration/schedules?page=1&page_size=100'); + scheduledJobs = data.jobs || []; + if (editingScheduleJobUuid) { + const currentJob = scheduledJobs.find(item => item.job_uuid === editingScheduleJobUuid); + if (currentJob) { + setRegistrationConfigToForm(currentJob.registration_config || {}); + } + } + renderScheduledJobs(); + } catch (error) { + elements.scheduleJobsTable.innerHTML = `
加载失败:${escapeHtml(error.message)}
`; + } +} + +function renderScheduledJobs() { + if (!elements.scheduleJobsTable) return; + if (!scheduledJobs.length) { + elements.scheduleJobsTable.innerHTML = `
🗓️
暂无计划任务
`; + return; + } + + elements.scheduleJobsTable.innerHTML = scheduledJobs.map(job => ` + + +
${escapeHtml(job.name)}
+
${job.enabled ? '已启用' : '已暂停'}
+ + ${escapeHtml(job.schedule_description || '-')} + ${escapeHtml(formatScheduleDateTime(job.next_run_at))} + ${escapeHtml(formatScheduleDateTime(job.last_run_at))} + ${escapeHtml(job.status || 'idle')} + +
+ + + + +
+ + + `).join(''); +} + +async function editScheduledJob(jobUuid) { + try { + const job = await api.get(`/registration/schedules/${jobUuid}`); + editingScheduleJobUuid = jobUuid; + elements.scheduleName.value = job.name || ''; + elements.scheduleEnabled.value = job.enabled ? 'true' : 'false'; + elements.scheduleTriggerType.value = job.schedule_type || 'interval'; + if (job.schedule_type === 'interval') { + elements.scheduleIntervalMinutes.value = job.schedule_config?.interval_minutes || 60; + } else { + elements.scheduleEveryNDays.value = job.schedule_config?.every_n_days || 1; + elements.scheduleTimeOfDay.value = job.schedule_config?.time_of_day || '09:00'; + elements.scheduleStartDate.value = job.schedule_config?.start_date || new Date().toISOString().slice(0, 10); + } + setRegistrationConfigToForm(job.registration_config || {}); + elements.saveScheduleBtn.textContent = '更新计划任务'; + elements.cancelScheduleEditBtn.style.display = 'block'; + updateScheduleTriggerFields(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } catch (error) { + toast.error(error.message); + } +} + +async function toggleScheduledJob(jobUuid, shouldEnable) { + try { + const endpoint = shouldEnable ? `/registration/schedules/${jobUuid}/enable` : `/registration/schedules/${jobUuid}/pause`; + await api.post(endpoint, {}); + toast.success(shouldEnable ? '计划任务已启用' : '计划任务已暂停'); + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +async function runScheduledJobNow(jobUuid) { + try { + const data = await api.post(`/registration/schedules/${jobUuid}/run`, {}); + toast.success(data.message || '计划任务已触发执行'); + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +async function deleteScheduledJob(jobUuid) { + try { + await api.delete(`/registration/schedules/${jobUuid}`); + toast.success('计划任务已删除'); + if (editingScheduleJobUuid === jobUuid) { + resetScheduleForm(); + } + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +window.editScheduledJob = editScheduledJob; +window.toggleScheduledJob = toggleScheduledJob; +window.runScheduledJobNow = runScheduledJobNow; +window.deleteScheduledJob = deleteScheduledJob; + // 开始注册 async function handleStartRegistration(e) { e.preventDefault(); - const selectedValue = elements.emailService.value; - if (!selectedValue) { + if (!elements.emailService.value) { toast.error('请选择一个邮箱服务'); return; } @@ -501,8 +865,6 @@ async function handleStartRegistration(e) { return; } - const [emailServiceType, serviceId] = selectedValue.split(':'); - // 禁用开始按钮 elements.startBtn.disabled = true; elements.cancelBtn.disabled = false; @@ -510,21 +872,7 @@ async function handleStartRegistration(e) { // 清空日志 elements.consoleLog.innerHTML = ''; - // 构建请求数据(代理从设置中自动获取) - const requestData = { - email_service_type: emailServiceType, - 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) : [], - }; - - // 如果选择了数据库中的服务,传递 service_id - if (serviceId && serviceId !== 'default') { - requestData.email_service_id = parseInt(serviceId); - } + const requestData = buildCurrentRegistrationConfig(); if (isBatchMode) { await handleBatchRegistration(requestData); diff --git a/templates/index.html b/templates/index.html index 0cf09508..4f1ea179 100644 --- a/templates/index.html +++ b/templates/index.html @@ -102,6 +102,33 @@ align-items: center; } + .panel-stack { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + } + + .schedule-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-sm) var(--spacing-md); + } + + .schedule-form-grid .form-group.full-width { + grid-column: 1 / -1; + } + + .schedule-table-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .schedule-muted { + color: var(--text-muted); + font-size: 0.75rem; + } + .today-stats-reset { font-size: 0.75rem; color: var(--text-muted); @@ -236,6 +263,10 @@ .today-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .schedule-form-grid { + grid-template-columns: 1fr; + } } @@ -296,6 +327,7 @@

📊 今日统计

+

📝 注册设置

@@ -444,6 +476,67 @@

📝 注册设置

+ +
+
+

⏰ 计划注册-上传

+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+
+
+
+
@@ -501,6 +594,37 @@

💻 监控台

+
+
+

🗓️ 计划任务列表

+ +
+
+ + + + + + + + + + + + + + + + +
计划名称触发方式下次执行最近执行状态操作
+
+
🗓️
+
暂无计划任务
+
+
+
+
+
From ffcb48fa59a4180553fba8e3c5ec67c5577f0ce7 Mon Sep 17 00:00:00 2001 From: biubushy Date: Sat, 28 Mar 2026 20:05:36 +0800 Subject: [PATCH 2/3] feat(upload): add independent new-api service integration - add standalone new-api service model, CRUD, routes, and frontend settings UI - implement official new-api login and Codex channel creation flow for upload actions - remove new-api reliance on Sub2API compatibility paths and wire registration auto-upload --- src/core/upload/new_api_upload.py | 225 ++++++++++++++++++++++ src/core/upload/sub2api_upload.py | 6 +- src/database/__init__.py | 3 +- src/database/crud.py | 68 ++++++- src/database/models.py | 16 ++ src/database/session.py | 9 + src/web/routes/__init__.py | 2 + src/web/routes/accounts.py | 77 +++++++- src/web/routes/registration.py | 73 ++++++- src/web/routes/upload/new_api_services.py | 200 +++++++++++++++++++ src/web/routes/upload/sub2api_services.py | 6 - static/js/accounts.js | 97 ++++++++++ static/js/app.js | 17 +- static/js/settings.js | 195 +++++++++++++++++++ templates/accounts.html | 1 + templates/index.html | 11 +- templates/settings.html | 75 ++++++++ 17 files changed, 1057 insertions(+), 24 deletions(-) create mode 100644 src/core/upload/new_api_upload.py create mode 100644 src/web/routes/upload/new_api_services.py diff --git a/src/core/upload/new_api_upload.py b/src/core/upload/new_api_upload.py new file mode 100644 index 00000000..bdd8aedb --- /dev/null +++ b/src/core/upload/new_api_upload.py @@ -0,0 +1,225 @@ +""" +new-api 账号上传功能 +""" + +import json +import logging +from datetime import datetime, timezone +from typing import List, Tuple + +from curl_cffi import requests as cffi_requests + +from ...database.models import Account +from ...database.session import get_db + +logger = logging.getLogger(__name__) + +CHANNEL_TYPE_CODEX = 57 +CHANNEL_GROUP_DEFAULT = "default" +DEFAULT_CODEX_MODELS = ['gpt-5', 'gpt-5-codex', 'gpt-5-codex-mini', 'gpt-5.1', 'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini', 'gpt-5.2', 'gpt-5.2-codex', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4', 'gpt-5.4-mini'] + + +def normalize_new_api_url(api_url: str) -> str: + """规范化 new-api 根地址。""" + return (api_url or "").rstrip("/") + + +def resolve_new_api_account_type(account: Account) -> str: + """解析 new-api 账号类型。""" + subscription_type = (getattr(account, "subscription_type", None) or "").lower() + if subscription_type == "team": + return "team" + + extra_data = getattr(account, "extra_data", None) or {} + if isinstance(extra_data, dict): + overview = extra_data.get("codex_overview") or {} + if isinstance(overview, dict): + plan_type = str(overview.get("plan_type") or "").lower() + if "codex" in plan_type: + return "codex" + for key in ("account_type", "type", "plan_type", "subscription_type"): + value = str(extra_data.get(key) or "").lower() + if "codex" in value: + return "codex" + if value in {"team", "plus", "pro", "oauth"}: + return value + + if subscription_type in {"plus", "pro"}: + return subscription_type + return "oauth" + + +def build_new_api_channel_key(account: Account) -> str: + """构建 new-api Codex 渠道 key。""" + expired = account.expires_at.astimezone(timezone.utc).isoformat() if account.expires_at else "" + payload = { + "access_token": account.access_token or "", + "refresh_token": account.refresh_token or "", + "account_id": account.account_id or "", + "email": account.email, + "type": resolve_new_api_account_type(account), + "expired": expired, + "last_refresh": datetime.now(timezone.utc).isoformat(), + } + return json.dumps(payload, ensure_ascii=False) + + +def build_new_api_channel_payload(account: Account) -> dict: + """构建 new-api 渠道创建载荷。""" + return { + "name": account.email, + "type": CHANNEL_TYPE_CODEX, + "key": build_new_api_channel_key(account), + "group": CHANNEL_GROUP_DEFAULT, + "models": ",".join(DEFAULT_CODEX_MODELS), + "status": 1, + } + + +def create_new_api_session(api_url: str, username: str, password: str): + """创建已登录的 new-api 会话。""" + session = cffi_requests.Session(impersonate="chrome110") + url = normalize_new_api_url(api_url) + "/api/user/login" + response = session.post( + url, + json={"username": username, "password": password}, + headers={"Content-Type": "application/json"}, + proxies=None, + timeout=15, + ) + return session, response + + +def ensure_new_api_login(api_url: str, username: str, password: str): + """校验 new-api 管理员登录状态。""" + if not api_url: + return False, "new-api URL 未配置", None, None + if not username: + return False, "new-api 用户名未配置", None, None + if not password: + return False, "new-api 密码未配置", None, None + + session, response = create_new_api_session(api_url, username, password) + if response.status_code != 200: + return False, f"登录失败: HTTP {response.status_code}", None, None + + try: + data = response.json() + except Exception: + return False, f"登录失败: {response.text[:200]}", None, None + + if not isinstance(data, dict) or not data.get("success"): + return False, data.get("message", "登录失败") if isinstance(data, dict) else "登录失败", None, None + + user_data = data.get("data") or {} + user_id = user_data.get("id") if isinstance(user_data, dict) else None + if user_id is None: + return False, "登录成功,但未返回用户 ID", None, None + + session.headers.update({"New-Api-User": str(user_id)}) + return True, "登录成功", session, user_id + + +def create_new_api_channel(api_url: str, session, account: Account) -> Tuple[bool, str]: + """在 new-api 中创建 Codex 渠道。""" + url = normalize_new_api_url(api_url) + "/api/channel/" + payload = { + "mode": "single", + "channel": build_new_api_channel_payload(account), + } + response = session.post( + url, + json=payload, + headers={"Content-Type": "application/json"}, + proxies=None, + timeout=20, + ) + if response.status_code != 200: + return False, f"创建渠道失败: HTTP {response.status_code}" + + try: + data = response.json() + except Exception: + return False, f"创建渠道失败: {response.text[:200]}" + + if isinstance(data, dict) and data.get("success"): + return True, "渠道创建成功" + if isinstance(data, dict): + return False, data.get("message", "创建渠道失败") + return False, "创建渠道失败" + + +def upload_to_new_api(accounts: List[Account], api_url: str, username: str, password: str) -> Tuple[bool, str]: + """上传账号列表到 new-api 平台。""" + if not accounts: + return False, "无可上传的账号" + + valid_accounts = [account for account in accounts if account.access_token] + if not valid_accounts: + return False, "所有账号均缺少 access_token,无法上传" + + ok, message, session, _user_id = ensure_new_api_login(api_url, username, password) + if not ok: + return False, message + + success_count = 0 + errors = [] + for account in valid_accounts: + created, created_message = create_new_api_channel(api_url, session, account) + if created: + success_count += 1 + else: + errors.append(f"{account.email}: {created_message}") + + if success_count == len(valid_accounts): + return True, f"成功上传 {success_count} 个账号" + if success_count == 0: + return False, "; ".join(errors[:3]) if errors else "上传失败" + return False, f"部分上传成功({success_count}/{len(valid_accounts)}): {'; '.join(errors[:3])}" + + +def batch_upload_to_new_api(account_ids: List[int], api_url: str, username: str, password: str) -> dict: + """批量上传指定 ID 的账号到 new-api 平台。""" + results = { + "success_count": 0, + "failed_count": 0, + "skipped_count": 0, + "details": [], + } + + with get_db() as db: + accounts = [] + for account_id in account_ids: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + results["failed_count"] += 1 + results["details"].append({"id": account_id, "email": None, "success": False, "error": "账号不存在"}) + continue + if not account.access_token: + results["skipped_count"] += 1 + results["details"].append({"id": account_id, "email": account.email, "success": False, "error": "缺少 access_token"}) + continue + accounts.append(account) + + if not accounts: + return results + + success, message = upload_to_new_api(accounts, api_url, username, password) + if success: + for account in accounts: + results["success_count"] += 1 + results["details"].append({"id": account.id, "email": account.email, "success": True, "message": message}) + else: + for account in accounts: + results["failed_count"] += 1 + results["details"].append({"id": account.id, "email": account.email, "success": False, "error": message}) + + return results + + +def test_new_api_connection(api_url: str, username: str, password: str) -> Tuple[bool, str]: + """测试 new-api 连接。""" + ok, message, _session, _user_id = ensure_new_api_login(api_url, username, password) + if not ok: + return False, message + return True, "new-api 连接测试成功" diff --git a/src/core/upload/sub2api_upload.py b/src/core/upload/sub2api_upload.py index 11d0f497..8df79609 100644 --- a/src/core/upload/sub2api_upload.py +++ b/src/core/upload/sub2api_upload.py @@ -22,7 +22,6 @@ def upload_to_sub2api( api_key: str, concurrency: int = 3, priority: int = 50, - target_type: str = "sub2api", ) -> Tuple[bool, str]: """ 上传账号列表到 Sub2API 平台(不走代理) @@ -90,7 +89,7 @@ def upload_to_sub2api( payload = { "data": { - "type": "newapi-data" if str(target_type).lower() == "newapi" else "sub2api-data", + "type": "sub2api-data", "version": 1, "exported_at": exported_at, "proxies": [], @@ -139,7 +138,6 @@ def batch_upload_to_sub2api( api_key: str, concurrency: int = 3, priority: int = 50, - target_type: str = "sub2api", ) -> dict: """ 批量上传指定 ID 的账号到 Sub2API 平台 @@ -171,7 +169,7 @@ def batch_upload_to_sub2api( if not accounts: return results - success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority, target_type) + success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority) if success: for acc in accounts: diff --git a/src/database/__init__.py b/src/database/__init__.py index 1ee05b91..643567f2 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -2,7 +2,7 @@ 数据库模块 """ -from .models import Base, Account, EmailService, RegistrationTask, Setting +from .models import Base, Account, EmailService, RegistrationTask, Setting, NewApiService from .session import get_db, init_database, get_session_manager, DatabaseSessionManager from . import crud @@ -12,6 +12,7 @@ 'EmailService', 'RegistrationTask', 'Setting', + 'NewApiService', 'get_db', 'init_database', 'get_session_manager', diff --git a/src/database/crud.py b/src/database/crud.py index c1f17634..1189ac67 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from sqlalchemy import and_, or_, desc, asc, func -from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService, TeamManagerService, ScheduledRegistrationJob +from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService, TeamManagerService, NewApiService, ScheduledRegistrationJob # ============================================================================ @@ -599,7 +599,6 @@ def create_sub2api_service( name: str, api_url: str, api_key: str, - target_type: str = 'sub2api', enabled: bool = True, priority: int = 0 ) -> Sub2ApiService: @@ -716,6 +715,71 @@ def delete_tm_service(db: Session, service_id: int) -> bool: return True +# ============================================================================ +# new-api 服务 CRUD +# ============================================================================ + +def create_new_api_service( + db: Session, + name: str, + api_url: str, + username: str, + password: str, + enabled: bool = True, + priority: int = 0, +) -> NewApiService: + """创建 new-api 服务配置""" + svc = NewApiService( + name=name, + api_url=api_url, + username=username, + password=password, + api_key='', + enabled=enabled, + priority=priority, + ) + db.add(svc) + db.commit() + db.refresh(svc) + return svc + + +def get_new_api_service_by_id(db: Session, service_id: int) -> Optional[NewApiService]: + """按 ID 获取 new-api 服务""" + return db.query(NewApiService).filter(NewApiService.id == service_id).first() + + +def get_new_api_services(db: Session, enabled: Optional[bool] = None) -> List[NewApiService]: + """获取 new-api 服务列表""" + query = db.query(NewApiService) + if enabled is not None: + query = query.filter(NewApiService.enabled == enabled) + return query.order_by(asc(NewApiService.priority), asc(NewApiService.id)).all() + + +def update_new_api_service(db: Session, service_id: int, **kwargs) -> Optional[NewApiService]: + """更新 new-api 服务配置""" + svc = get_new_api_service_by_id(db, service_id) + if not svc: + return None + for key, value in kwargs.items(): + if hasattr(svc, key): + setattr(svc, key, value) + db.commit() + db.refresh(svc) + return svc + + +def delete_new_api_service(db: Session, service_id: int) -> bool: + """删除 new-api 服务配置""" + svc = get_new_api_service_by_id(db, service_id) + if not svc: + return False + db.delete(svc) + db.commit() + return True + + # ============================================================================ # 计划注册任务 CRUD # ============================================================================ diff --git a/src/database/models.py b/src/database/models.py index b1835541..9c5d3e97 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -233,6 +233,22 @@ class TeamManagerService(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class NewApiService(Base): + """new-api 服务配置表""" + __tablename__ = 'new_api_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + api_url = Column(String(500), nullable=False) + username = Column(String(100)) + password = Column(Text) + api_key = Column(Text, nullable=False, default='') + 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 ScheduledRegistrationJob(Base): """计划注册任务表""" __tablename__ = 'scheduled_registration_jobs' diff --git a/src/database/session.py b/src/database/session.py index 8fe3319f..df9c9eeb 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -112,6 +112,15 @@ def migrate_tables(self): ("accounts", "cookies", "TEXT"), ("cpa_services", "proxy_url", "VARCHAR(1000)"), ("sub2api_services", "target_type", "VARCHAR(50) DEFAULT 'sub2api'"), + ("new_api_services", "name", "VARCHAR(100)"), + ("new_api_services", "api_url", "VARCHAR(500)"), + ("new_api_services", "username", "VARCHAR(100)"), + ("new_api_services", "password", "TEXT"), + ("new_api_services", "api_key", "TEXT"), + ("new_api_services", "enabled", "BOOLEAN DEFAULT 1"), + ("new_api_services", "priority", "INTEGER DEFAULT 0"), + ("new_api_services", "created_at", "DATETIME"), + ("new_api_services", "updated_at", "DATETIME"), ("proxies", "is_default", "BOOLEAN DEFAULT 0"), ("bind_card_tasks", "checkout_session_id", "VARCHAR(120)"), ("bind_card_tasks", "publishable_key", "VARCHAR(255)"), diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py index 70a2a574..6528766e 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -13,6 +13,7 @@ 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 +from .upload.new_api_services import router as new_api_services_router api_router = APIRouter() @@ -26,3 +27,4 @@ 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"]) +api_router.include_router(new_api_services_router, prefix="/new-api-services", tags=["new-api-services"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 4024d90e..fd33b137 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -25,6 +25,7 @@ from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa from ...core.upload.team_manager_upload import upload_to_team_manager, batch_upload_to_team_manager from ...core.upload.sub2api_upload import batch_upload_to_sub2api, upload_to_sub2api +from ...core.upload.new_api_upload import batch_upload_to_new_api, upload_to_new_api from ...core.dynamic_proxy import get_proxy_url_for_task from ...database import crud @@ -2105,7 +2106,6 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, - target_type=locals().get("target_type", "sub2api"), ) return results @@ -2147,7 +2147,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp success, message = upload_to_sub2api( [account], api_url, api_key, concurrency=concurrency, priority=priority, - target_type=locals().get("target_type", "sub2api") + target_type="sub2api" ) if success: return {"success": True, "message": message} @@ -2155,6 +2155,77 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp return {"success": False, "error": message} +class NewApiUploadRequest(BaseModel): + """单账号 new-api 上传请求""" + service_id: Optional[int] = None + + +class BatchNewApiUploadRequest(BaseModel): + """批量 new-api 上传请求""" + 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 + + +@router.post("/batch-upload-new-api") +async def batch_upload_accounts_to_new_api(request: BatchNewApiUploadRequest): + """批量上传账号到 new-api。""" + with get_db() as db: + if request.service_id: + service = crud.get_new_api_service_by_id(db, request.service_id) + else: + services = crud.get_new_api_services(db, enabled=True) + service = services[0] if services else None + + if not service: + raise HTTPException(status_code=400, detail="未找到可用的 new-api 服务,请先在设置中配置") + + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + return batch_upload_to_new_api( + ids, + service.api_url, + getattr(service, 'username', None), + getattr(service, 'password', None), + ) + + +@router.post("/{account_id}/upload-new-api") +async def upload_account_to_new_api(account_id: int, request: Optional[NewApiUploadRequest] = Body(default=None)): + """上传单个账号到 new-api。""" + service_id = request.service_id if request else None + + with get_db() as db: + if service_id: + service = crud.get_new_api_service_by_id(db, service_id) + else: + services = crud.get_new_api_services(db, enabled=True) + service = services[0] if services else None + + if not service: + raise HTTPException(status_code=400, detail="未找到可用的 new-api 服务,请先在设置中配置") + + 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_new_api( + [account], + service.api_url, + getattr(service, 'username', None), + getattr(service, 'password', None), + ) + return {"success": success, "message": message if success else None, "error": None if success else message} + + # ============== Team Manager 上传 ============== class UploadTMRequest(BaseModel): @@ -2186,7 +2257,6 @@ async def batch_upload_accounts_to_tm(request: BatchUploadTMRequest): api_url = svc.api_url api_key = svc.api_key - target_type = getattr(svc, "target_type", "sub2api") ids = resolve_account_ids( db, request.ids, request.select_all, @@ -2215,7 +2285,6 @@ async def upload_account_to_tm(account_id: int, request: Optional[UploadTMReques api_url = svc.api_url api_key = svc.api_key - target_type = getattr(svc, "target_type", "sub2api") account = crud.get_account_by_id(db, account_id) if not account: diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index df34fab0..90672ae6 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -73,11 +73,13 @@ class RegistrationTaskCreate(BaseModel): email_service_config: Optional[dict] = None email_service_id: Optional[int] = None auto_upload_cpa: bool = False - cpa_service_ids: List[int] = [] # 指定 CPA 服务 ID 列表,空则取第一个启用的 + cpa_service_ids: List[int] = [] auto_upload_sub2api: bool = False - sub2api_service_ids: List[int] = [] # 指定 Sub2API 服务 ID 列表 + sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False - tm_service_ids: List[int] = [] # 指定 TM 服务 ID 列表 + tm_service_ids: List[int] = [] + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] class BatchRegistrationRequest(BaseModel): @@ -97,6 +99,8 @@ class BatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] class RegistrationTaskResponse(BaseModel): @@ -165,6 +169,8 @@ class OutlookBatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] class OutlookBatchRegistrationResponse(BaseModel): @@ -297,7 +303,7 @@ def _normalize_email_service_config( return normalized -def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_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): +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, auto_upload_new_api: bool = False, new_api_service_ids: List[int] = None): """ 在线程池中执行的同步注册任务 @@ -584,6 +590,35 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: except Exception as tm_err: log_callback(f"[TM] 上传异常: {tm_err}") + if auto_upload_new_api: + try: + from ...core.upload.new_api_upload import upload_to_new_api + 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: + _new_api_ids = new_api_service_ids or [] + if not _new_api_ids: + _new_api_ids = [s.id for s in crud.get_new_api_services(db, enabled=True)] + if not _new_api_ids: + log_callback("[NewAPI] 无可用 new-api 服务,跳过上传") + for _sid in _new_api_ids: + try: + _svc = crud.get_new_api_service_by_id(db, _sid) + if not _svc: + continue + log_callback(f"[NewAPI] 正在把账号发往服务站: {_svc.name}") + _ok, _msg = upload_to_new_api( + [saved_account], + _svc.api_url, + getattr(_svc, 'username', None), + getattr(_svc, 'password', None), + ) + log_callback(f"[NewAPI] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") + except Exception as _e: + log_callback(f"[NewAPI] 异常({_sid}): {_e}") + except Exception as new_api_err: + log_callback(f"[NewAPI] 上传异常: {new_api_err}") + # 更新任务状态 crud.update_registration_task( db, task_uuid, @@ -628,7 +663,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: pass -async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_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): +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, auto_upload_new_api: bool = False, new_api_service_ids: List[int] = None): """ 异步执行注册任务 @@ -661,6 +696,8 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: sub2api_service_ids or [], auto_upload_tm, tm_service_ids or [], + auto_upload_new_api, + new_api_service_ids or [], ) except Exception as e: logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}") @@ -713,6 +750,8 @@ async def run_batch_parallel( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, ): """ 并行模式:所有任务同时提交,Semaphore 控制最大并发数 @@ -732,6 +771,7 @@ async def _run_one(idx: int, uuid: str): 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 [], + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids or [], ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -779,6 +819,8 @@ async def run_batch_pipeline( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, ): """ 流水线模式:每隔 interval 秒启动一个新任务,Semaphore 限制最大并发数 @@ -798,6 +840,7 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): 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 [], + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids or [], ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -869,6 +912,8 @@ async def run_batch_registration( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, ): """根据 mode 分发到并行或流水线执行""" if mode == "parallel": @@ -878,6 +923,7 @@ async def run_batch_registration( 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, + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids, ) else: await run_batch_pipeline( @@ -887,6 +933,7 @@ async def run_batch_registration( 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, + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids, ) @@ -945,6 +992,8 @@ async def _start_single_registration_internal( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.auto_upload_new_api, + request.new_api_service_ids, ) return task_to_response(task) @@ -1003,6 +1052,8 @@ async def _start_batch_registration_internal( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.auto_upload_new_api, + request.new_api_service_ids, ) return BatchRegistrationResponse( @@ -1093,6 +1144,8 @@ async def _start_outlook_batch_registration_internal( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.auto_upload_new_api, + request.new_api_service_ids, ) return OutlookBatchRegistrationResponse( @@ -1130,6 +1183,8 @@ async def dispatch_registration_config( sub2api_service_ids=config.get('sub2api_service_ids') or [], auto_upload_tm=bool(config.get('auto_upload_tm', False)), tm_service_ids=config.get('tm_service_ids') or [], + auto_upload_new_api=bool(config.get('auto_upload_new_api', False)), + new_api_service_ids=config.get('new_api_service_ids') or [], ) response = await _start_outlook_batch_registration_internal(request, background_tasks) return { @@ -1157,6 +1212,8 @@ async def dispatch_registration_config( sub2api_service_ids=config.get('sub2api_service_ids') or [], auto_upload_tm=bool(config.get('auto_upload_tm', False)), tm_service_ids=config.get('tm_service_ids') or [], + auto_upload_new_api=bool(config.get('auto_upload_new_api', False)), + new_api_service_ids=config.get('new_api_service_ids') or [], ) response = await _start_batch_registration_internal(request, background_tasks) return { @@ -1176,6 +1233,8 @@ async def dispatch_registration_config( sub2api_service_ids=config.get('sub2api_service_ids') or [], auto_upload_tm=bool(config.get('auto_upload_tm', False)), tm_service_ids=config.get('tm_service_ids') or [], + auto_upload_new_api=bool(config.get('auto_upload_new_api', False)), + new_api_service_ids=config.get('new_api_service_ids') or [], ) response = await _start_single_registration_internal(request, background_tasks) return { @@ -1673,6 +1732,8 @@ async def run_outlook_batch_registration( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, ): """ 异步执行 Outlook 批量注册任务,复用通用并发逻辑 @@ -1716,6 +1777,8 @@ async def run_outlook_batch_registration( sub2api_service_ids=sub2api_service_ids, auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, + auto_upload_new_api=auto_upload_new_api, + new_api_service_ids=new_api_service_ids, ) diff --git a/src/web/routes/upload/new_api_services.py b/src/web/routes/upload/new_api_services.py new file mode 100644 index 00000000..dff18e6c --- /dev/null +++ b/src/web/routes/upload/new_api_services.py @@ -0,0 +1,200 @@ +""" +new-api 服务管理 API 路由 +""" + +from typing import List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ....core.upload.new_api_upload import batch_upload_to_new_api, test_new_api_connection +from ....database import crud +from ....database.session import get_db + +router = APIRouter() + + +class NewApiServiceCreate(BaseModel): + name: str + api_url: str + username: str + password: str + enabled: bool = True + priority: int = 0 + + +class NewApiServiceUpdate(BaseModel): + name: Optional[str] = None + api_url: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +class NewApiServiceResponse(BaseModel): + id: int + name: str + api_url: str + username: Optional[str] = None + has_password: bool + enabled: bool + priority: int + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class NewApiTestRequest(BaseModel): + api_url: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + + +class NewApiUploadRequest(BaseModel): + account_ids: List[int] + service_id: Optional[int] = None + + +def _to_response(service) -> NewApiServiceResponse: + return NewApiServiceResponse( + id=service.id, + name=service.name, + api_url=service.api_url, + username=getattr(service, "username", None), + has_password=bool(getattr(service, "password", None)), + enabled=service.enabled, + priority=service.priority, + created_at=service.created_at.isoformat() if service.created_at else None, + updated_at=service.updated_at.isoformat() if service.updated_at else None, + ) + + +@router.get("", response_model=List[NewApiServiceResponse]) +async def list_new_api_services(enabled: Optional[bool] = None): + """获取 new-api 服务列表。""" + with get_db() as db: + services = crud.get_new_api_services(db, enabled=enabled) + return [_to_response(service) for service in services] + + +@router.post("", response_model=NewApiServiceResponse) +async def create_new_api_service(request: NewApiServiceCreate): + """新增 new-api 服务。""" + with get_db() as db: + service = crud.create_new_api_service( + db, + name=request.name, + api_url=request.api_url, + username=request.username, + password=request.password, + enabled=request.enabled, + priority=request.priority, + ) + return _to_response(service) + + +@router.get("/{service_id}", response_model=NewApiServiceResponse) +async def get_new_api_service(service_id: int): + """获取单个 new-api 服务详情。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + return _to_response(service) + + +@router.get("/{service_id}/full") +async def get_new_api_service_full(service_id: int): + """获取 new-api 服务完整配置。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + return { + "id": service.id, + "name": service.name, + "api_url": service.api_url, + "username": getattr(service, "username", None), + "password": getattr(service, "password", None), + "enabled": service.enabled, + "priority": service.priority, + } + + +@router.patch("/{service_id}", response_model=NewApiServiceResponse) +async def update_new_api_service(service_id: int, request: NewApiServiceUpdate): + """更新 new-api 服务配置。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + + 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.username is not None: + update_data["username"] = request.username + if request.password: + update_data["password"] = request.password + if request.enabled is not None: + update_data["enabled"] = request.enabled + if request.priority is not None: + update_data["priority"] = request.priority + + updated = crud.update_new_api_service(db, service_id, **update_data) + return _to_response(updated) + + +@router.delete("/{service_id}") +async def delete_new_api_service(service_id: int): + """删除 new-api 服务。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + crud.delete_new_api_service(db, service_id) + return {"success": True, "message": f"new-api 服务 {service.name} 已删除"} + + +@router.post("/{service_id}/test") +async def test_new_api_service(service_id: int): + """测试 new-api 服务连接。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + success, message = test_new_api_connection(service.api_url, getattr(service, 'username', None), getattr(service, 'password', None)) + return {"success": success, "message": message} + + +@router.post("/test-connection") +async def test_new_api_connection_direct(request: NewApiTestRequest): + """直接测试 new-api 连接。""" + if not request.api_url or not request.username or not request.password: + raise HTTPException(status_code=400, detail="api_url、username 和 password 不能为空") + success, message = test_new_api_connection(request.api_url, request.username, request.password) + return {"success": success, "message": message} + + +@router.post("/upload") +async def upload_accounts_to_new_api(request: NewApiUploadRequest): + """批量上传账号到 new-api 平台。""" + if not request.account_ids: + raise HTTPException(status_code=400, detail="账号 ID 列表不能为空") + + with get_db() as db: + if request.service_id: + service = crud.get_new_api_service_by_id(db, request.service_id) + else: + services = crud.get_new_api_services(db, enabled=True) + service = services[0] if services else None + + if not service: + raise HTTPException(status_code=400, detail="未找到可用的 new-api 服务") + + return batch_upload_to_new_api(request.account_ids, service.api_url, getattr(service, 'username', None), getattr(service, 'password', None)) diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index 653f4b19..ddd77592 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -19,7 +19,6 @@ class Sub2ApiServiceCreate(BaseModel): name: str api_url: str api_key: str - target_type: str = "sub2api" enabled: bool = True priority: int = 0 @@ -28,7 +27,6 @@ class Sub2ApiServiceUpdate(BaseModel): name: Optional[str] = None api_url: Optional[str] = None api_key: Optional[str] = None - target_type: Optional[str] = None enabled: Optional[bool] = None priority: Optional[int] = None @@ -91,7 +89,6 @@ async def create_sub2api_service(request: Sub2ApiServiceCreate): name=request.name, api_url=request.api_url, api_key=request.api_key, - target_type=request.target_type, enabled=request.enabled, priority=request.priority, ) @@ -120,7 +117,6 @@ async def get_sub2api_service_full(service_id: int): "name": svc.name, "api_url": svc.api_url, "api_key": svc.api_key, - "target_type": getattr(svc, "target_type", "sub2api"), "enabled": svc.enabled, "priority": svc.priority, } @@ -142,8 +138,6 @@ async def update_sub2api_service(service_id: int, request: Sub2ApiServiceUpdate) # api_key 留空则保持原值 if request.api_key: update_data["api_key"] = request.api_key - if request.target_type is not None: - update_data["target_type"] = request.target_type if request.enabled is not None: update_data["enabled"] = request.enabled if request.priority is not None: diff --git a/static/js/accounts.js b/static/js/accounts.js index bcce0243..820742d2 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -105,6 +105,10 @@ function initEventListeners() { document.getElementById('batch-upload-cpa-item').addEventListener('click', (e) => { e.preventDefault(); uploadMenu.classList.remove('active'); handleBatchUploadCpa(); }); document.getElementById('batch-upload-sub2api-item').addEventListener('click', (e) => { e.preventDefault(); uploadMenu.classList.remove('active'); handleBatchUploadSub2Api(); }); document.getElementById('batch-upload-tm-item').addEventListener('click', (e) => { e.preventDefault(); uploadMenu.classList.remove('active'); handleBatchUploadTm(); }); + const batchUploadNewApiItem = document.getElementById('batch-upload-new-api-item'); + if (batchUploadNewApiItem) { + batchUploadNewApiItem.addEventListener('click', (e) => { e.preventDefault(); uploadMenu.classList.remove('active'); handleBatchUploadNewApi(); }); + } // 批量删除 elements.batchDeleteBtn.addEventListener('click', handleBatchDelete); @@ -913,6 +917,7 @@ async function uploadAccount(id) { { label: '☁️ 上传到 CPA', value: 'cpa' }, { label: '🔗 上传到 Sub2API', value: 'sub2api' }, { label: '🚀 上传到 Team Manager', value: 'tm' }, + { label: '🆕 上传到 new-api', value: 'new-api' }, ]; const choice = await new Promise((resolve) => { @@ -942,6 +947,7 @@ async function uploadAccount(id) { if (choice === 'cpa') return uploadToCpa(id); if (choice === 'sub2api') return uploadToSub2Api(id); if (choice === 'tm') return uploadToTm(id); + if (choice === 'new-api') return uploadToNewApi(id); } // 上传单个账号到CPA @@ -1143,6 +1149,97 @@ async function handleBatchUploadSub2Api() { } } +// ============== new-api 上传 ============== + +function selectNewApiService() { + return new Promise(async (resolve) => { + const services = await api.get('/new-api-services?enabled=true').catch(() => []); + if (!services || services.length === 0) { + toast.warning('暂无已启用的 new-api 服务'); + resolve(null); + return; + } + if (services.length === 1) { + resolve({ service_id: services[0].id }); + return; + } + + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + modal.querySelector('#_newapi-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({ service_id: parseInt(btn.dataset.val) }); + }); + }); + }); +} + +async function uploadToNewApi(id) { + const choice = await selectNewApiService(); + if (choice === null) return; + try { + toast.info('正在上传到 new-api...'); + const payload = {}; + if (choice.service_id != null) payload.service_id = choice.service_id; + const result = await api.post(`/accounts/${id}/upload-new-api`, payload); + if (result.success) { + toast.success(result.message || '上传成功'); + loadAccounts(); + } else { + toast.error('上传失败: ' + (result.error || result.message || '未知错误')); + } + } catch (e) { + toast.error('上传失败: ' + e.message); + } +} + +async function handleBatchUploadNewApi() { + const count = getEffectiveCount(); + if (count === 0) return; + + const choice = await selectNewApiService(); + if (choice === null) return; + + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 new-api 吗?`); + 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-new-api', 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 (e) { + toast.error('批量上传失败: ' + e.message); + } finally { + updateBatchButtons(); + } +} + // ============== Team Manager 上传 ============== // 上传单账号到 Sub2API diff --git a/static/js/app.js b/static/js/app.js index dad0e783..405af175 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -114,6 +114,9 @@ const elements = { autoUploadTm: document.getElementById('auto-upload-tm'), tmServiceSelectGroup: document.getElementById('tm-service-select-group'), tmServiceSelect: document.getElementById('tm-service-select'), + autoUploadNewApi: document.getElementById('auto-upload-new-api'), + newApiServiceSelectGroup: document.getElementById('new-api-service-select-group'), + newApiServiceSelect: document.getElementById('new-api-service-select'), scheduleForm: document.getElementById('schedule-form'), scheduleName: document.getElementById('schedule-name'), scheduleTriggerType: document.getElementById('schedule-trigger-type'), @@ -146,12 +149,13 @@ document.addEventListener('DOMContentLoaded', () => { loadScheduledJobs(); }); -// 初始化注册后自动操作选项(CPA / Sub2API / TM) +// 初始化注册后自动操作选项(CPA / Sub2API / TM / new-api) async function initAutoUploadOptions() { await Promise.all([ loadServiceSelect('/cpa-services?enabled=true', elements.cpaServiceSelect, elements.autoUploadCpa, elements.cpaServiceSelectGroup), loadServiceSelect('/sub2api-services?enabled=true', elements.sub2apiServiceSelect, elements.autoUploadSub2api, elements.sub2apiServiceSelectGroup), loadServiceSelect('/tm-services?enabled=true', elements.tmServiceSelect, elements.autoUploadTm, elements.tmServiceSelectGroup), + loadServiceSelect('/new-api-services?enabled=true', elements.newApiServiceSelect, elements.autoUploadNewApi, elements.newApiServiceSelectGroup), ]); } @@ -548,6 +552,8 @@ function buildCurrentRegistrationConfig() { 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) : [], + auto_upload_new_api: elements.autoUploadNewApi ? elements.autoUploadNewApi.checked : false, + new_api_service_ids: elements.autoUploadNewApi && elements.autoUploadNewApi.checked ? getSelectedServiceIds(elements.newApiServiceSelect) : [], }; if (isOutlookBatchMode) { @@ -677,10 +683,17 @@ function setRegistrationConfigToForm(config) { elements.sub2apiServiceSelectGroup.style.display = elements.autoUploadSub2api.checked ? 'block' : 'none'; elements.autoUploadTm.checked = !!registrationConfig.auto_upload_tm; elements.tmServiceSelectGroup.style.display = elements.autoUploadTm.checked ? 'block' : 'none'; + if (elements.autoUploadNewApi) { + elements.autoUploadNewApi.checked = !!registrationConfig.auto_upload_new_api; + } + if (elements.newApiServiceSelectGroup && elements.autoUploadNewApi) { + elements.newApiServiceSelectGroup.style.display = elements.autoUploadNewApi.checked ? 'block' : 'none'; + } setSelectedServiceIds(elements.cpaServiceSelect, registrationConfig.cpa_service_ids || []); setSelectedServiceIds(elements.sub2apiServiceSelect, registrationConfig.sub2api_service_ids || []); setSelectedServiceIds(elements.tmServiceSelect, registrationConfig.tm_service_ids || []); + setSelectedServiceIds(elements.newApiServiceSelect, registrationConfig.new_api_service_ids || []); if (emailServiceType === 'outlook_batch') { isOutlookBatchMode = true; @@ -1762,6 +1775,8 @@ async function handleOutlookBatchRegistration() { 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) : [], + auto_upload_new_api: elements.autoUploadNewApi ? elements.autoUploadNewApi.checked : false, + new_api_service_ids: elements.autoUploadNewApi && elements.autoUploadNewApi.checked ? getSelectedServiceIds(elements.newApiServiceSelect) : [], }; addLog('info', `[系统] 正在启动 Outlook 批量注册 (${selectedIds.length} 个账户)...`); diff --git a/static/js/settings.js b/static/js/settings.js index 46aba297..2070b809 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -68,6 +68,15 @@ const elements = { tmServiceForm: document.getElementById('tm-service-form'), tmServiceModalTitle: document.getElementById('tm-service-modal-title'), testTmServiceBtn: document.getElementById('test-tm-service-btn'), + // new-api 服务管理 + addNewApiServiceBtn: document.getElementById('add-new-api-service-btn'), + newApiServicesTable: document.getElementById('new-api-services-table'), + newApiServiceEditModal: document.getElementById('new-api-service-edit-modal'), + closeNewApiServiceModal: document.getElementById('close-new-api-service-modal'), + cancelNewApiServiceBtn: document.getElementById('cancel-new-api-service-btn'), + newApiServiceForm: document.getElementById('new-api-service-form'), + newApiServiceModalTitle: document.getElementById('new-api-service-modal-title'), + testNewApiServiceBtn: document.getElementById('test-new-api-service-btn'), // 验证码设置 emailCodeForm: document.getElementById('email-code-form'), // Outlook 设置 @@ -89,6 +98,7 @@ document.addEventListener('DOMContentLoaded', () => { loadCpaServices(); loadSub2ApiServices(); loadTmServices(); + loadNewApiServices(); initEventListeners(); }); @@ -279,6 +289,27 @@ function initEventListeners() { elements.testTmServiceBtn.addEventListener('click', handleTestTmService); } + if (elements.addNewApiServiceBtn) { + elements.addNewApiServiceBtn.addEventListener('click', () => openNewApiServiceModal()); + } + if (elements.closeNewApiServiceModal) { + elements.closeNewApiServiceModal.addEventListener('click', closeNewApiServiceModal); + } + if (elements.cancelNewApiServiceBtn) { + elements.cancelNewApiServiceBtn.addEventListener('click', closeNewApiServiceModal); + } + if (elements.newApiServiceEditModal) { + elements.newApiServiceEditModal.addEventListener('click', (e) => { + if (e.target === elements.newApiServiceEditModal) closeNewApiServiceModal(); + }); + } + if (elements.newApiServiceForm) { + elements.newApiServiceForm.addEventListener('submit', handleSaveNewApiService); + } + if (elements.testNewApiServiceBtn) { + elements.testNewApiServiceBtn.addEventListener('click', handleTestNewApiService); + } + // CPA 服务管理 if (elements.addCpaServiceBtn) { elements.addCpaServiceBtn.addEventListener('click', () => openCpaServiceModal()); @@ -1596,3 +1627,167 @@ function escapeHtml(text) { d.textContent = text; return d.innerHTML; } + + +// ============== new-api 服务管理 ============== + +async function loadNewApiServices() { + if (!elements.newApiServicesTable) return; + try { + const services = await api.get('/new-api-services'); + renderNewApiServicesTable(services); + } catch (e) { + elements.newApiServicesTable.innerHTML = `${e.message}`; + } +} + +function renderNewApiServicesTable(services) { + if (!services || services.length === 0) { + elements.newApiServicesTable.innerHTML = '暂无 new-api 服务,点击「添加服务」新增'; + return; + } + elements.newApiServicesTable.innerHTML = services.map(s => ` + + ${escapeHtml(s.name)} + ${escapeHtml(s.api_url)}
${escapeHtml(s.username || '')} + ${s.enabled ? '✅' : '⭕'} + ${s.priority} + + + + + + + `).join(''); +} + +function openNewApiServiceModal(service = null) { + document.getElementById('new-api-service-id').value = service ? service.id : ''; + document.getElementById('new-api-service-name').value = service ? service.name : ''; + document.getElementById('new-api-service-url').value = service ? service.api_url : ''; + document.getElementById('new-api-service-username').value = service ? (service.username || '') : ''; + document.getElementById('new-api-service-password').value = ''; + document.getElementById('new-api-service-priority').value = service ? service.priority : 0; + document.getElementById('new-api-service-enabled').checked = service ? service.enabled : true; + if (service) { + document.getElementById('new-api-service-password').placeholder = service.has_password ? '已配置,留空保持不变' : '请输入管理员密码'; + } else { + document.getElementById('new-api-service-password').placeholder = '请输入管理员密码'; + } + elements.newApiServiceModalTitle.textContent = service ? '编辑 new-api 服务' : '添加 new-api 服务'; + elements.newApiServiceEditModal.classList.add('active'); +} + +function closeNewApiServiceModal() { + elements.newApiServiceEditModal.classList.remove('active'); +} + +async function editNewApiService(id) { + try { + const service = await api.get(`/new-api-services/${id}`); + openNewApiServiceModal(service); + } catch (e) { + toast.error('获取服务信息失败: ' + e.message); + } +} + +async function handleSaveNewApiService(e) { + e.preventDefault(); + const id = document.getElementById('new-api-service-id').value; + const name = document.getElementById('new-api-service-name').value.trim(); + const apiUrl = document.getElementById('new-api-service-url').value.trim(); + const username = document.getElementById('new-api-service-username').value.trim(); + const password = document.getElementById('new-api-service-password').value.trim(); + const priority = parseInt(document.getElementById('new-api-service-priority').value) || 0; + const enabled = document.getElementById('new-api-service-enabled').checked; + + if (!name || !apiUrl || !username) { + toast.error('名称、API URL 和管理员用户名不能为空'); + return; + } + if (!id && !password) { + toast.error('新增服务时管理员密码不能为空'); + return; + } + + try { + const payload = { name, api_url: apiUrl, username, priority, enabled }; + if (password) payload.password = password; + + if (id) { + await api.patch(`/new-api-services/${id}`, payload); + toast.success('服务已更新'); + } else { + payload.password = password; + await api.post('/new-api-services', payload); + toast.success('服务已添加'); + } + closeNewApiServiceModal(); + loadNewApiServices(); + } catch (e) { + toast.error('保存失败: ' + e.message); + } +} + +async function deleteNewApiService(id, name) { + const confirmed = await confirm(`确定要删除 new-api 服务「${name}」吗?`); + if (!confirmed) return; + try { + await api.delete(`/new-api-services/${id}`); + toast.success('已删除'); + loadNewApiServices(); + } catch (e) { + toast.error('删除失败: ' + e.message); + } +} + +async function testNewApiServiceById(id) { + try { + const result = await api.post(`/new-api-services/${id}/test`); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } +} + +async function handleTestNewApiService() { + const apiUrl = document.getElementById('new-api-service-url').value.trim(); + const username = document.getElementById('new-api-service-username').value.trim(); + const password = document.getElementById('new-api-service-password').value.trim(); + const id = document.getElementById('new-api-service-id').value; + + if (!apiUrl || !username) { + toast.error('请先填写 API URL 和管理员用户名'); + return; + } + if (!id && !password) { + toast.error('请先填写管理员密码'); + return; + } + + elements.testNewApiServiceBtn.disabled = true; + elements.testNewApiServiceBtn.textContent = '测试中...'; + + try { + let result; + if (id && !password) { + result = await api.post(`/new-api-services/${id}/test`); + } else { + result = await api.post('/new-api-services/test-connection', { api_url: apiUrl, username, password }); + } + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } finally { + elements.testNewApiServiceBtn.disabled = false; + elements.testNewApiServiceBtn.textContent = '🔌 测试连接'; + } +} diff --git a/templates/accounts.html b/templates/accounts.html index f16f7c6d..88d331c1 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -200,6 +200,7 @@

账号管理

☁️ 上传到 CPA 🔗 上传到 Sub2API 🚀 上传到 Team Manager + 🆕 上传到 new-api
+ +
+
+ + + + + + + + + + + + + +
名称API URL状态优先级操作
加载中...
+
+
+ @@ -384,6 +410,55 @@

添加 Sub2API 服务

+ + +