""" Weibo-HotSign Sign-in Task Definitions Celery tasks for scheduled sign-in operations """ import os import sys import asyncio import httpx import json import logging import redis from datetime import datetime from typing import Dict, Any, Optional from celery import current_task from sqlalchemy import select # Add parent directory to path for imports sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../..")) from shared.models.base import AsyncSessionLocal from shared.models.task import Task from shared.models.account import Account from shared.config import shared_settings from ..celery_app import celery_app from ..config import settings # Configure logger logger = logging.getLogger(__name__) # Redis client for distributed locks (可选) _redis_client = None def get_redis_client(): """获取 Redis 客户端,如果未启用则返回 None""" global _redis_client if not shared_settings.USE_REDIS: return None if _redis_client is None: try: _redis_client = redis.from_url(shared_settings.REDIS_URL, decode_responses=True) except Exception as e: logger.warning(f"Redis 连接失败: {e},分布式锁将被禁用") return None return _redis_client # 内存锁(当 Redis 不可用时) _memory_locks = {} class DistributedLock: """分布式锁(支持 Redis 或内存模式)""" def __init__(self, lock_key: str, timeout: int = 300): """ Initialize distributed lock Args: lock_key: Unique key for the lock timeout: Lock timeout in seconds (default 5 minutes) """ self.lock_key = f"lock:{lock_key}" self.timeout = timeout self.acquired = False self.redis_client = get_redis_client() def acquire(self) -> bool: """ Acquire the lock using Redis SETNX or memory dict Returns True if lock acquired, False otherwise """ try: if self.redis_client: # 使用 Redis result = self.redis_client.set(self.lock_key, "1", nx=True, ex=self.timeout) self.acquired = bool(result) else: # 使用内存锁(本地开发) if self.lock_key not in _memory_locks: _memory_locks[self.lock_key] = True self.acquired = True else: self.acquired = False return self.acquired except Exception as e: logger.error(f"Failed to acquire lock {self.lock_key}: {e}") return False def release(self): """Release the lock""" if self.acquired: try: if self.redis_client: # 使用 Redis self.redis_client.delete(self.lock_key) else: # 使用内存锁 if self.lock_key in _memory_locks: del _memory_locks[self.lock_key] self.acquired = False except Exception as e: logger.error(f"Failed to release lock {self.lock_key}: {e}") def __enter__(self): """Context manager entry""" if not self.acquire(): raise Exception(f"Failed to acquire lock: {self.lock_key}") return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit""" self.release() @celery_app.task(bind=True, max_retries=3, default_retry_delay=60) def execute_signin_task(self, task_id: str, account_id: str, cron_expression: str): """ Execute scheduled sign-in task for a specific account This task is triggered by Celery Beat based on cron schedule Uses distributed lock to prevent duplicate execution """ lock_key = f"signin_task:{task_id}:{account_id}" lock = DistributedLock(lock_key, timeout=300) # Try to acquire lock if not lock.acquire(): logger.warning(f"⚠️ Task {task_id} for account {account_id} is already running, skipping") return { "status": "skipped", "reason": "Task already running (distributed lock)", "account_id": account_id, "task_id": task_id } try: logger.info(f"🎯 Starting sign-in task {task_id} for account {account_id}") # Update task status current_task.update_state( state="PROGRESS", meta={ "current": 10, "total": 100, "status": "Initializing sign-in process...", "account_id": account_id } ) # Get account info from database account_info = _get_account_from_db(account_id) if not account_info: raise Exception(f"Account {account_id} not found in database") # Check if account is active if account_info["status"] not in ["pending", "active"]: logger.warning(f"Account {account_id} status is {account_info['status']}, skipping sign-in") return { "status": "skipped", "reason": f"Account status is {account_info['status']}", "account_id": account_id } # Call signin executor service result = _call_signin_executor(account_id, task_id) # Update task status current_task.update_state( state="SUCCESS", meta={ "current": 100, "total": 100, "status": "Sign-in completed successfully", "result": result, "account_id": account_id } ) logger.info(f"✅ Sign-in task {task_id} completed successfully for account {account_id}") return result except Exception as exc: logger.error(f"❌ Sign-in task {task_id} failed for account {account_id}: {exc}") # Retry logic with exponential backoff if self.request.retries < settings.MAX_RETRY_ATTEMPTS: retry_delay = settings.RETRY_DELAY_SECONDS * (2 ** self.request.retries) logger.info(f"🔄 Retrying task {task_id} (attempt {self.request.retries + 1}) in {retry_delay}s") # Release lock before retry lock.release() raise self.retry(exc=exc, countdown=retry_delay) # Final failure current_task.update_state( state="FAILURE", meta={ "current": 100, "total": 100, "status": f"Task failed after {settings.MAX_RETRY_ATTEMPTS} attempts", "error": str(exc), "account_id": account_id } ) raise exc finally: # Always release lock lock.release() def _get_account_from_db(account_id: str) -> Optional[Dict[str, Any]]: """ Query account information from database (replaces mock data). Returns account dict or None if not found. """ loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: return loop.run_until_complete(_async_get_account(account_id)) finally: loop.close() async def _async_get_account(account_id: str) -> Optional[Dict[str, Any]]: """Async helper to query account from database""" try: async with AsyncSessionLocal() as session: stmt = select(Account).where(Account.id == account_id) result = await session.execute(stmt) account = result.scalar_one_or_none() if not account: return None return { "id": account.id, "user_id": account.user_id, "weibo_user_id": account.weibo_user_id, "remark": account.remark, "status": account.status, "encrypted_cookies": account.encrypted_cookies, "iv": account.iv, } except Exception as e: logger.error(f"Error querying account {account_id}: {e}") return None @celery_app.task def schedule_daily_signin(): """ Daily sign-in task - queries database for enabled tasks """ logger.info("📅 Executing daily sign-in schedule") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: return loop.run_until_complete(_async_schedule_daily_signin()) finally: loop.close() async def _async_schedule_daily_signin(): """Async helper to query and schedule tasks""" try: async with AsyncSessionLocal() as session: # Query all enabled tasks stmt = ( select(Task, Account) .join(Account, Task.account_id == Account.id) .where(Task.is_enabled == True) .where(Account.status.in_(["pending", "active"])) ) result = await session.execute(stmt) task_account_pairs = result.all() results = [] for task, account in task_account_pairs: try: # Submit individual sign-in task for each account celery_task = execute_signin_task.delay( task_id=task.id, account_id=account.id, cron_expression=task.cron_expression ) results.append({ "account_id": account.id, "task_id": celery_task.id, "status": "submitted" }) except Exception as e: logger.error(f"Failed to submit task for account {account.id}: {e}") results.append({ "account_id": account.id, "status": "failed", "error": str(e) }) return { "scheduled_date": datetime.now().isoformat(), "accounts_processed": len(task_account_pairs), "results": results } except Exception as e: logger.error(f"Error in daily signin schedule: {e}") raise @celery_app.task def process_pending_tasks(): """ Process pending sign-in tasks from database Queries database for enabled tasks and submits them for execution """ logger.info("🔄 Processing pending sign-in tasks from database") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: return loop.run_until_complete(_async_process_pending_tasks()) finally: loop.close() async def _async_process_pending_tasks(): """Async helper to process pending tasks""" try: async with AsyncSessionLocal() as session: # Query enabled tasks that are due for execution stmt = ( select(Task, Account) .join(Account, Task.account_id == Account.id) .where(Task.is_enabled == True) .where(Account.status.in_(["pending", "active"])) ) result = await session.execute(stmt) task_account_pairs = result.all() tasks_submitted = 0 tasks_skipped = 0 for task, account in task_account_pairs: try: # Submit task for execution execute_signin_task.delay( task_id=task.id, account_id=account.id, cron_expression=task.cron_expression ) tasks_submitted += 1 except Exception as e: logger.error(f"Failed to submit task {task.id}: {e}") tasks_skipped += 1 result = { "processed_at": datetime.now().isoformat(), "tasks_found": len(task_account_pairs), "tasks_submitted": tasks_submitted, "tasks_skipped": tasks_skipped, "status": "completed" } logger.info(f"✅ Processed pending tasks: {result}") return result except Exception as e: logger.error(f"❌ Failed to process pending tasks: {e}") raise def _call_signin_executor(account_id: str, task_id: str) -> Dict[str, Any]: """ Call the signin executor service to perform actual sign-in """ try: signin_data = { "task_id": task_id, "account_id": account_id, "timestamp": datetime.now().isoformat(), "requested_by": "task_scheduler" } # Call signin executor service with httpx.Client(timeout=30.0) as client: response = client.post( f"{settings.SIGNIN_EXECUTOR_URL}/api/v1/signin/execute", json=signin_data ) if response.status_code == 200: result = response.json() logger.info(f"Sign-in executor response: {result}") return result else: raise Exception(f"Sign-in executor returned error: {response.status_code} - {response.text}") except httpx.RequestError as e: logger.error(f"Network error calling signin executor: {e}") raise Exception(f"Failed to connect to signin executor: {e}") except Exception as e: logger.error(f"Error calling signin executor: {e}") raise # Periodic task definitions for Celery Beat celery_app.conf.beat_schedule = { "daily-signin-at-8am": { "task": "app.tasks.signin_tasks.schedule_daily_signin", "schedule": { "hour": 8, "minute": 0, }, }, "process-pending-every-15-minutes": { "task": "app.tasks.signin_tasks.process_pending_tasks", "schedule": 900.0, # Every 15 minutes }, }