2026-03-09 14:05:00 +08:00
|
|
|
"""
|
|
|
|
|
Weibo-HotSign Task Scheduler Service
|
|
|
|
|
Celery Beat configuration for scheduled sign-in tasks
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
2026-03-09 16:10:29 +08:00
|
|
|
import sys
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Dict, List
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
from celery import Celery
|
|
|
|
|
from celery.schedules import crontab
|
2026-03-09 16:10:29 +08:00
|
|
|
from croniter import croniter
|
2026-03-09 14:05:00 +08:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
2026-03-09 16:10:29 +08:00
|
|
|
# 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 .config import settings
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2026-03-09 14:05:00 +08:00
|
|
|
|
|
|
|
|
# Create Celery app
|
|
|
|
|
celery_app = Celery(
|
|
|
|
|
"weibo_hot_sign_scheduler",
|
|
|
|
|
broker=settings.CELERY_BROKER_URL,
|
|
|
|
|
backend=settings.CELERY_RESULT_BACKEND,
|
2026-03-09 16:10:29 +08:00
|
|
|
include=["task_scheduler.app.tasks.signin_tasks"]
|
2026-03-09 14:05:00 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Celery configuration
|
|
|
|
|
celery_app.conf.update(
|
|
|
|
|
task_serializer="json",
|
|
|
|
|
accept_content=["json"],
|
|
|
|
|
result_serializer="json",
|
|
|
|
|
timezone="Asia/Shanghai",
|
|
|
|
|
enable_utc=True,
|
|
|
|
|
beat_schedule_filename="celerybeat-schedule",
|
|
|
|
|
beat_max_loop_interval=5,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TaskSchedulerService:
|
|
|
|
|
"""Service to manage scheduled tasks from database"""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
2026-03-09 16:10:29 +08:00
|
|
|
self.scheduled_tasks: Dict[str, dict] = {}
|
2026-03-09 14:05:00 +08:00
|
|
|
|
2026-03-09 16:10:29 +08:00
|
|
|
async def load_scheduled_tasks(self) -> List[Task]:
|
|
|
|
|
"""
|
|
|
|
|
Load enabled tasks from database and register them to Celery Beat.
|
|
|
|
|
Returns list of loaded tasks.
|
|
|
|
|
"""
|
2026-03-09 14:05:00 +08:00
|
|
|
try:
|
|
|
|
|
async with AsyncSessionLocal() as session:
|
2026-03-09 16:10:29 +08:00
|
|
|
# Query all enabled tasks with their accounts
|
|
|
|
|
stmt = (
|
|
|
|
|
select(Task, Account)
|
|
|
|
|
.join(Account, Task.account_id == Account.id)
|
|
|
|
|
.where(Task.is_enabled == True)
|
|
|
|
|
)
|
2026-03-09 14:05:00 +08:00
|
|
|
result = await session.execute(stmt)
|
2026-03-09 16:10:29 +08:00
|
|
|
task_account_pairs = result.all()
|
|
|
|
|
|
|
|
|
|
logger.info(f"📅 Loaded {len(task_account_pairs)} enabled tasks from database")
|
2026-03-09 14:05:00 +08:00
|
|
|
|
2026-03-09 16:10:29 +08:00
|
|
|
# Register tasks to Celery Beat dynamically
|
|
|
|
|
beat_schedule = {}
|
|
|
|
|
for task, account in task_account_pairs:
|
|
|
|
|
try:
|
|
|
|
|
# Validate cron expression
|
|
|
|
|
if not croniter.is_valid(task.cron_expression):
|
|
|
|
|
logger.warning(f"Invalid cron expression for task {task.id}: {task.cron_expression}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Create schedule entry
|
|
|
|
|
schedule_name = f"task_{task.id}"
|
|
|
|
|
beat_schedule[schedule_name] = {
|
|
|
|
|
"task": "task_scheduler.app.tasks.signin_tasks.execute_signin_task",
|
|
|
|
|
"schedule": self._parse_cron_to_celery(task.cron_expression),
|
|
|
|
|
"args": (task.id, task.account_id, task.cron_expression),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.scheduled_tasks[task.id] = {
|
|
|
|
|
"task_id": task.id,
|
|
|
|
|
"account_id": task.account_id,
|
|
|
|
|
"cron_expression": task.cron_expression,
|
|
|
|
|
"account_status": account.status,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Registered task {task.id} for account {account.weibo_user_id} with cron: {task.cron_expression}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to register task {task.id}: {e}")
|
|
|
|
|
continue
|
2026-03-09 14:05:00 +08:00
|
|
|
|
2026-03-09 16:10:29 +08:00
|
|
|
# Update Celery Beat schedule
|
|
|
|
|
celery_app.conf.beat_schedule.update(beat_schedule)
|
|
|
|
|
|
|
|
|
|
return [task for task, _ in task_account_pairs]
|
2026-03-09 14:05:00 +08:00
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-03-09 16:10:29 +08:00
|
|
|
logger.error(f"❌ Error loading tasks from database: {e}")
|
2026-03-09 14:05:00 +08:00
|
|
|
return []
|
2026-03-09 16:10:29 +08:00
|
|
|
|
|
|
|
|
def _parse_cron_to_celery(self, cron_expression: str) -> crontab:
|
|
|
|
|
"""
|
|
|
|
|
Parse cron expression string to Celery crontab schedule.
|
|
|
|
|
Format: minute hour day month day_of_week
|
|
|
|
|
"""
|
|
|
|
|
parts = cron_expression.split()
|
|
|
|
|
if len(parts) != 5:
|
|
|
|
|
raise ValueError(f"Invalid cron expression: {cron_expression}")
|
|
|
|
|
|
|
|
|
|
return crontab(
|
|
|
|
|
minute=parts[0],
|
|
|
|
|
hour=parts[1],
|
|
|
|
|
day_of_month=parts[2],
|
|
|
|
|
month_of_year=parts[3],
|
|
|
|
|
day_of_week=parts[4],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def add_task(self, task_id: str, account_id: str, cron_expression: str):
|
|
|
|
|
"""Dynamically add a new task to the schedule"""
|
|
|
|
|
try:
|
|
|
|
|
if not croniter.is_valid(cron_expression):
|
|
|
|
|
raise ValueError(f"Invalid cron expression: {cron_expression}")
|
|
|
|
|
|
|
|
|
|
schedule_name = f"task_{task_id}"
|
|
|
|
|
celery_app.conf.beat_schedule[schedule_name] = {
|
|
|
|
|
"task": "task_scheduler.app.tasks.signin_tasks.execute_signin_task",
|
|
|
|
|
"schedule": self._parse_cron_to_celery(cron_expression),
|
|
|
|
|
"args": (task_id, account_id, cron_expression),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.scheduled_tasks[task_id] = {
|
|
|
|
|
"task_id": task_id,
|
|
|
|
|
"account_id": account_id,
|
|
|
|
|
"cron_expression": cron_expression,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Added task {task_id} to schedule")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to add task {task_id}: {e}")
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
async def remove_task(self, task_id: str):
|
|
|
|
|
"""Dynamically remove a task from the schedule"""
|
|
|
|
|
try:
|
|
|
|
|
schedule_name = f"task_{task_id}"
|
|
|
|
|
if schedule_name in celery_app.conf.beat_schedule:
|
|
|
|
|
del celery_app.conf.beat_schedule[schedule_name]
|
|
|
|
|
logger.info(f"✅ Removed task {task_id} from schedule")
|
|
|
|
|
|
|
|
|
|
if task_id in self.scheduled_tasks:
|
|
|
|
|
del self.scheduled_tasks[task_id]
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to remove task {task_id}: {e}")
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
async def update_task(self, task_id: str, is_enabled: bool, cron_expression: str = None):
|
|
|
|
|
"""Update an existing task in the schedule"""
|
|
|
|
|
try:
|
|
|
|
|
if is_enabled:
|
|
|
|
|
# Re-add or update the task
|
|
|
|
|
async with AsyncSessionLocal() as session:
|
|
|
|
|
stmt = select(Task).where(Task.id == task_id)
|
|
|
|
|
result = await session.execute(stmt)
|
|
|
|
|
task = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if task:
|
|
|
|
|
await self.add_task(
|
|
|
|
|
task_id,
|
|
|
|
|
task.account_id,
|
|
|
|
|
cron_expression or task.cron_expression
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Remove the task
|
|
|
|
|
await self.remove_task(task_id)
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated task {task_id}, enabled={is_enabled}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to update task {task_id}: {e}")
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Global scheduler service instance
|
|
|
|
|
scheduler_service = TaskSchedulerService()
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
|
|
|
|
# Synchronous wrapper for async function
|
|
|
|
|
def sync_load_tasks():
|
2026-03-09 16:10:29 +08:00
|
|
|
"""Synchronous wrapper to load tasks on startup"""
|
2026-03-09 14:05:00 +08:00
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
try:
|
2026-03-09 16:10:29 +08:00
|
|
|
return loop.run_until_complete(scheduler_service.load_scheduled_tasks())
|
2026-03-09 14:05:00 +08:00
|
|
|
finally:
|
|
|
|
|
loop.close()
|
|
|
|
|
|
2026-03-09 16:10:29 +08:00
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
# Import task modules to register them
|
2026-03-09 16:10:29 +08:00
|
|
|
from .tasks import signin_tasks
|