Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Module - 定时任务模块
基于 APScheduler 实现的定时任务调度
"""
+714
View File
@@ -0,0 +1,714 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler API - 定时任务管理接口
提供定时任务的 CRUD 操作和管理功能
"""
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from scheduler.model import SchedulerJob, SchedulerLog
from scheduler.schema import (
SchedulerJobCreate,
SchedulerJobUpdate,
SchedulerJobResponse,
SchedulerJobSimple,
SchedulerJobBatchDeleteIn,
SchedulerJobBatchDeleteOut,
SchedulerJobBatchUpdateStatusIn,
SchedulerJobBatchUpdateStatusOut,
SchedulerJobExecuteIn,
SchedulerJobExecuteOut,
SchedulerJobStatisticsOut,
SchedulerJobSearchRequest,
SchedulerLogResponse,
SchedulerLogBatchDeleteIn,
SchedulerLogBatchDeleteOut,
SchedulerLogCleanIn,
SchedulerLogCleanOut,
SchedulerStatusOut,
)
from scheduler.service import scheduler_service
router = APIRouter(prefix="/scheduler", tags=["定时任务管理"])
def _build_job_response(job: SchedulerJob) -> SchedulerJobResponse:
"""构建任务响应"""
return SchedulerJobResponse(
id=job.id,
application_id=job.application_id,
name=job.name,
code=job.code,
description=job.description,
group=job.group,
trigger_type=job.trigger_type,
trigger_type_display=job.get_trigger_type_display(),
cron_expression=job.cron_expression,
interval_seconds=job.interval_seconds,
run_date=job.run_date,
task_func=job.task_func,
task_args=job.task_args,
task_kwargs=job.task_kwargs,
status=job.status,
status_display=job.get_status_display(),
priority=job.priority,
max_instances=job.max_instances,
max_retries=job.max_retries,
timeout=job.timeout,
coalesce=job.coalesce,
allow_concurrent=job.allow_concurrent,
total_run_count=job.total_run_count,
success_count=job.success_count,
failure_count=job.failure_count,
success_rate=job.get_success_rate(),
last_run_time=job.last_run_time,
next_run_time=job.next_run_time,
last_run_status=job.last_run_status,
last_run_result=job.last_run_result,
remark=job.remark,
sort=job.sort,
sys_create_datetime=job.sys_create_datetime,
sys_update_datetime=job.sys_update_datetime,
)
def _build_log_response(log: SchedulerLog) -> SchedulerLogResponse:
"""构建日志响应"""
return SchedulerLogResponse(
id=log.id,
job_id=log.job_id,
job_name=log.job_name,
job_code=log.job_code,
status=log.status,
status_display=log.get_status_display(),
start_time=log.start_time,
end_time=log.end_time,
duration=log.duration,
result=log.result,
exception=log.exception,
traceback=log.traceback,
hostname=log.hostname,
process_id=log.process_id,
retry_count=log.retry_count,
sys_create_datetime=log.sys_create_datetime,
)
# ==================== SchedulerJob APIs ====================
@router.post("/job", response_model=SchedulerJobResponse, summary="创建定时任务")
async def create_scheduler_job(data: SchedulerJobCreate, db: AsyncSession = Depends(get_db)):
"""创建新的定时任务"""
# 检查任务编码是否已存在
result = await db.execute(
select(SchedulerJob).where(
SchedulerJob.code == data.code,
SchedulerJob.is_deleted == False # noqa: E712
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"任务编码已存在: {data.code}")
# 创建任务
job = SchedulerJob(**data.model_dump())
db.add(job)
await db.commit()
await db.refresh(job)
# 如果任务是启用状态,添加到调度器
if job.is_enabled() and scheduler_service.is_running():
await scheduler_service.add_job(job)
return _build_job_response(job)
@router.get("/job/all", response_model=List[SchedulerJobSimple], summary="获取所有定时任务(简化版)")
async def get_all_scheduler_jobs(
application_id: str = Query(None, alias="applicationId", description="所属应用ID"),
db: AsyncSession = Depends(get_db),
):
"""获取所有定时任务(不分页,简化版)"""
conditions = [SchedulerJob.is_deleted == False] # noqa: E712
if application_id:
conditions.append(SchedulerJob.application_id == application_id)
else:
conditions.append(SchedulerJob.application_id.is_(None))
result = await db.execute(
select(SchedulerJob).where(*conditions).order_by(SchedulerJob.priority.desc(), SchedulerJob.name)
)
jobs = result.scalars().all()
return jobs
@router.get("/job", response_model=PaginatedResponse[SchedulerJobResponse], summary="获取定时任务列表")
async def get_scheduler_job_list(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize", description="每页数量"),
application_id: str = Query(None, alias="applicationId", description="所属应用ID"),
name: Optional[str] = Query(default=None, description="任务名称"),
code: Optional[str] = Query(default=None, description="任务编码"),
group: Optional[str] = Query(default=None, description="任务分组"),
trigger_type: Optional[str] = Query(default=None, description="触发器类型"),
status: Optional[int] = Query(default=None, description="任务状态"),
db: AsyncSession = Depends(get_db)
):
"""获取定时任务列表(分页)"""
filters = [SchedulerJob.is_deleted == False] # noqa: E712
# 应用过滤
if application_id:
filters.append(SchedulerJob.application_id == application_id)
else:
filters.append(SchedulerJob.application_id.is_(None))
if name:
filters.append(SchedulerJob.name.ilike(f"%{name}%"))
if code:
filters.append(SchedulerJob.code.ilike(f"%{code}%"))
if group:
filters.append(SchedulerJob.group == group)
if trigger_type:
filters.append(SchedulerJob.trigger_type == trigger_type)
if status is not None:
filters.append(SchedulerJob.status == status)
# 查询总数
count_result = await db.execute(
select(func.count(SchedulerJob.id)).where(*filters)
)
total = count_result.scalar()
# 查询数据
offset = (page - 1) * page_size
result = await db.execute(
select(SchedulerJob).where(*filters)
.order_by(SchedulerJob.priority.desc(), SchedulerJob.sys_update_datetime.desc())
.offset(offset).limit(page_size)
)
jobs = result.scalars().all()
return PaginatedResponse(
items=[_build_job_response(job) for job in jobs],
total=total
)
@router.post("/job/batch/delete", response_model=SchedulerJobBatchDeleteOut, summary="批量删除定时任务")
async def batch_delete_scheduler_jobs(
data: SchedulerJobBatchDeleteIn,
db: AsyncSession = Depends(get_db)
):
"""批量删除定时任务"""
success_count = 0
failed_ids = []
for job_id in data.ids:
try:
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.id == job_id)
)
job = result.scalar_one_or_none()
if job:
# 从调度器移除
if scheduler_service.is_running():
await scheduler_service.remove_job(job.code)
# 软删除
job.is_deleted = True
success_count += 1
else:
failed_ids.append(job_id)
except Exception:
failed_ids.append(job_id)
await db.commit()
return SchedulerJobBatchDeleteOut(count=success_count, failed_ids=failed_ids)
@router.post("/job/batch/update_status", response_model=SchedulerJobBatchUpdateStatusOut, summary="批量更新任务状态")
async def batch_update_scheduler_job_status(
data: SchedulerJobBatchUpdateStatusIn,
db: AsyncSession = Depends(get_db)
):
"""批量启用、禁用或暂停任务"""
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.id.in_(data.ids))
)
jobs = result.scalars().all()
count = 0
for job in jobs:
job.status = data.status
count += 1
# 同步更新调度器
if scheduler_service.is_running():
if job.is_enabled():
await scheduler_service.add_job(job)
elif job.is_paused():
await scheduler_service.pause_job(job.code)
else:
await scheduler_service.remove_job(job.code)
await db.commit()
return SchedulerJobBatchUpdateStatusOut(count=count)
@router.post("/job/execute", response_model=SchedulerJobExecuteOut, summary="立即执行任务")
async def execute_scheduler_job(
data: SchedulerJobExecuteIn,
db: AsyncSession = Depends(get_db)
):
"""立即执行指定任务(不影响正常调度)"""
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.id == data.job_id)
)
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
if not scheduler_service.is_running():
raise HTTPException(status_code=400, detail="调度器未运行")
# 立即执行任务
success = await scheduler_service.run_job_now(job.code)
if success:
return SchedulerJobExecuteOut(
success=True,
message=f"任务 {job.name} 将立即执行"
)
else:
return SchedulerJobExecuteOut(
success=False,
message=f"任务 {job.name} 执行失败,可能任务未在调度器中"
)
@router.post("/job/search", response_model=PaginatedResponse[SchedulerJobResponse], summary="搜索定时任务")
async def search_scheduler_jobs(
data: SchedulerJobSearchRequest,
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db)
):
"""搜索定时任务"""
keyword = data.keyword
filters = [
SchedulerJob.is_deleted == False, # noqa: E712
or_(
SchedulerJob.name.ilike(f"%{keyword}%"),
SchedulerJob.code.ilike(f"%{keyword}%"),
SchedulerJob.description.ilike(f"%{keyword}%"),
)
]
# 查询总数
count_result = await db.execute(
select(func.count(SchedulerJob.id)).where(*filters)
)
total = count_result.scalar()
# 查询数据
offset = (page - 1) * page_size
result = await db.execute(
select(SchedulerJob).where(*filters)
.order_by(SchedulerJob.priority.desc())
.offset(offset).limit(page_size)
)
jobs = result.scalars().all()
return PaginatedResponse(
items=[_build_job_response(job) for job in jobs],
total=total
)
@router.get("/job/statistics/data", response_model=SchedulerJobStatisticsOut, summary="获取任务统计信息")
async def get_scheduler_job_statistics(db: AsyncSession = Depends(get_db)):
"""获取任务统计信息"""
# 任务统计
total_result = await db.execute(
select(func.count(SchedulerJob.id)).where(SchedulerJob.is_deleted == False) # noqa: E712
)
total_jobs = total_result.scalar() or 0
enabled_result = await db.execute(
select(func.count(SchedulerJob.id)).where(
SchedulerJob.is_deleted == False, # noqa: E712
SchedulerJob.status == 1
)
)
enabled_jobs = enabled_result.scalar() or 0
disabled_result = await db.execute(
select(func.count(SchedulerJob.id)).where(
SchedulerJob.is_deleted == False, # noqa: E712
SchedulerJob.status == 0
)
)
disabled_jobs = disabled_result.scalar() or 0
paused_result = await db.execute(
select(func.count(SchedulerJob.id)).where(
SchedulerJob.is_deleted == False, # noqa: E712
SchedulerJob.status == 2
)
)
paused_jobs = paused_result.scalar() or 0
# 执行统计
total_exec_result = await db.execute(
select(func.count(SchedulerLog.id))
)
total_executions = total_exec_result.scalar() or 0
success_exec_result = await db.execute(
select(func.count(SchedulerLog.id)).where(SchedulerLog.status == 'success')
)
success_executions = success_exec_result.scalar() or 0
failed_exec_result = await db.execute(
select(func.count(SchedulerLog.id)).where(SchedulerLog.status == 'failed')
)
failed_executions = failed_exec_result.scalar() or 0
# 计算成功率
success_rate = round(success_executions / total_executions * 100, 2) if total_executions > 0 else 0
return SchedulerJobStatisticsOut(
total_jobs=total_jobs,
enabled_jobs=enabled_jobs,
disabled_jobs=disabled_jobs,
paused_jobs=paused_jobs,
total_executions=total_executions,
success_executions=success_executions,
failed_executions=failed_executions,
success_rate=success_rate,
)
@router.get("/job/{job_id}", response_model=SchedulerJobResponse, summary="获取定时任务详情")
async def get_scheduler_job(job_id: str, db: AsyncSession = Depends(get_db)):
"""获取单个定时任务的详细信息"""
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.id == job_id)
)
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
return _build_job_response(job)
@router.put("/job/{job_id}", response_model=SchedulerJobResponse, summary="更新定时任务")
async def update_scheduler_job(
job_id: str,
data: SchedulerJobUpdate,
db: AsyncSession = Depends(get_db)
):
"""更新定时任务"""
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.id == job_id)
)
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
# 检查任务编码是否已存在(排除自身)
if data.code:
code_result = await db.execute(
select(SchedulerJob).where(
SchedulerJob.code == data.code,
SchedulerJob.id != job_id,
SchedulerJob.is_deleted == False # noqa: E712
)
)
if code_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"任务编码已存在: {data.code}")
# 更新字段
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(job, key, value)
await db.commit()
await db.refresh(job)
# 同步更新调度器
if scheduler_service.is_running():
await scheduler_service.modify_job(job)
return _build_job_response(job)
@router.delete("/job/{job_id}", response_model=ResponseModel, summary="删除定时任务")
async def delete_scheduler_job(
job_id: str,
hard: bool = Query(default=False, description="是否物理删除"),
db: AsyncSession = Depends(get_db)
):
"""删除定时任务"""
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.id == job_id)
)
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="任务不存在")
# 从调度器移除
if scheduler_service.is_running():
await scheduler_service.remove_job(job.code)
if hard:
await db.delete(job)
else:
job.is_deleted = True
await db.commit()
return ResponseModel(message="删除成功")
# ==================== SchedulerLog APIs ====================
@router.get("/log", response_model=PaginatedResponse[SchedulerLogResponse], summary="获取任务执行日志列表")
async def get_scheduler_log_list(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize", description="每页数量"),
job_id: Optional[str] = Query(default=None, description="任务ID"),
job_code: Optional[str] = Query(default=None, description="任务编码"),
job_name: Optional[str] = Query(default=None, description="任务名称"),
status: Optional[str] = Query(default=None, description="执行状态"),
start_time_gte: Optional[datetime] = Query(default=None, alias="startTimeGte", description="开始时间>="),
start_time_lte: Optional[datetime] = Query(default=None, alias="startTimeLte", description="开始时间<="),
db: AsyncSession = Depends(get_db)
):
"""获取任务执行日志列表(分页)"""
filters = []
if job_id:
filters.append(SchedulerLog.job_id == job_id)
if job_code:
filters.append(SchedulerLog.job_code.ilike(f"%{job_code}%"))
if job_name:
filters.append(SchedulerLog.job_name.ilike(f"%{job_name}%"))
if status:
filters.append(SchedulerLog.status == status)
if start_time_gte:
filters.append(SchedulerLog.start_time >= start_time_gte)
if start_time_lte:
filters.append(SchedulerLog.start_time <= start_time_lte)
# 查询总数
count_result = await db.execute(
select(func.count(SchedulerLog.id)).where(*filters) if filters else select(func.count(SchedulerLog.id))
)
total = count_result.scalar()
# 查询数据
offset = (page - 1) * page_size
query = select(SchedulerLog).order_by(SchedulerLog.start_time.desc()).offset(offset).limit(page_size)
if filters:
query = select(SchedulerLog).where(*filters).order_by(SchedulerLog.start_time.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
logs = result.scalars().all()
return PaginatedResponse(
items=[_build_log_response(log) for log in logs],
total=total
)
@router.get("/log/by/job/{job_id}", response_model=PaginatedResponse[SchedulerLogResponse], summary="获取指定任务的执行日志")
async def get_scheduler_logs_by_job(
job_id: str,
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db)
):
"""获取指定任务的所有执行日志"""
# 查询总数
count_result = await db.execute(
select(func.count(SchedulerLog.id)).where(SchedulerLog.job_id == job_id)
)
total = count_result.scalar()
# 查询数据
offset = (page - 1) * page_size
result = await db.execute(
select(SchedulerLog).where(SchedulerLog.job_id == job_id)
.order_by(SchedulerLog.start_time.desc())
.offset(offset).limit(page_size)
)
logs = result.scalars().all()
return PaginatedResponse(
items=[_build_log_response(log) for log in logs],
total=total
)
@router.get("/log/{log_id}", response_model=SchedulerLogResponse, summary="获取任务执行日志详情")
async def get_scheduler_log(log_id: str, db: AsyncSession = Depends(get_db)):
"""获取单个任务执行日志的详细信息"""
result = await db.execute(
select(SchedulerLog).where(SchedulerLog.id == log_id)
)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="日志不存在")
return _build_log_response(log)
@router.delete("/log/{log_id}", response_model=ResponseModel, summary="删除任务执行日志")
async def delete_scheduler_log(log_id: str, db: AsyncSession = Depends(get_db)):
"""删除任务执行日志"""
result = await db.execute(
select(SchedulerLog).where(SchedulerLog.id == log_id)
)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="日志不存在")
await db.delete(log)
await db.commit()
return ResponseModel(message="删除成功")
@router.post("/log/batch/delete", response_model=SchedulerLogBatchDeleteOut, summary="批量删除任务执行日志")
async def batch_delete_scheduler_logs(
data: SchedulerLogBatchDeleteIn,
db: AsyncSession = Depends(get_db)
):
"""批量删除任务执行日志"""
result = await db.execute(
select(SchedulerLog).where(SchedulerLog.id.in_(data.ids))
)
logs = result.scalars().all()
count = len(logs)
for log in logs:
await db.delete(log)
await db.commit()
return SchedulerLogBatchDeleteOut(count=count)
@router.post("/log/clean", response_model=SchedulerLogCleanOut, summary="清理旧日志")
async def clean_scheduler_logs(
data: SchedulerLogCleanIn,
db: AsyncSession = Depends(get_db)
):
"""清理旧日志"""
cutoff_date = datetime.now() - timedelta(days=data.days)
filters = [SchedulerLog.start_time < cutoff_date]
if data.status:
filters.append(SchedulerLog.status == data.status)
result = await db.execute(
select(SchedulerLog).where(*filters)
)
logs = result.scalars().all()
count = len(logs)
for log in logs:
await db.delete(log)
await db.commit()
return SchedulerLogCleanOut(count=count)
# ==================== Scheduler Control APIs ====================
@router.post("/start", response_model=ResponseModel, summary="启动调度器")
async def start_scheduler():
"""启动调度器(注意:调度器应在应用启动时自动启动)"""
if scheduler_service.is_running():
raise HTTPException(status_code=400, detail="调度器已在运行中")
# APScheduler 4.x 中调度器应在 lifespan 中启动
raise HTTPException(status_code=400, detail="请重启应用以启动调度器")
@router.post("/shutdown", response_model=ResponseModel, summary="关闭调度器")
async def shutdown_scheduler():
"""关闭调度器(注意:调度器会在应用关闭时自动关闭)"""
if not scheduler_service.is_running():
raise HTTPException(status_code=400, detail="调度器未运行")
# APScheduler 4.x 中调度器应在 lifespan 中关闭
raise HTTPException(status_code=400, detail="请关闭应用以停止调度器")
@router.post("/pause", response_model=ResponseModel, summary="暂停调度器")
async def pause_scheduler():
"""暂停调度器(暂不支持)"""
raise HTTPException(status_code=400, detail="APScheduler 4.x 暂不支持暂停整个调度器")
@router.post("/resume", response_model=ResponseModel, summary="恢复调度器")
async def resume_scheduler():
"""恢复调度器(暂不支持)"""
raise HTTPException(status_code=400, detail="APScheduler 4.x 暂不支持恢复整个调度器")
@router.get("/status", response_model=SchedulerStatusOut, summary="获取调度器状态")
async def get_scheduler_status():
"""获取调度器状态"""
is_running = scheduler_service.is_running()
jobs = await scheduler_service.get_all_jobs() if is_running else []
return SchedulerStatusOut(
is_running=is_running,
job_count=len(jobs),
jobs=jobs,
)
@router.get("/log/{log_id}/stream", summary="实时日志流")
async def stream_task_log(log_id: str):
"""
通过 SSE 实时推送任务执行日志
Args:
log_id: 日志记录 ID
Returns:
SSE 事件流,包含任务执行过程中的日志消息
"""
from fastapi.responses import StreamingResponse
from scheduler.task_log_service import TaskLogService
import json
async def event_generator():
try:
async for log_entry in TaskLogService.subscribe(log_id):
yield f"data: {json.dumps(log_entry, ensure_ascii=False)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'level': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
)
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Model - 定时任务模型
用于管理定时任务和执行记录
"""
from sqlalchemy import Column, String, Integer, Boolean, Text, DateTime, Float, Index
from app.base_model import BaseModel
class SchedulerJob(BaseModel):
"""
定时任务模型 - 用于管理定时任务配置
功能特点:
1. 支持多种触发器类型(cron、interval、date
2. 支持任务启用/禁用
3. 支持任务分组管理
4. 支持任务优先级
5. 记录任务执行统计信息
6. 支持任务参数配置
"""
__tablename__ = "core_scheduler_job"
# 所属应用(逻辑外键关联 core_application
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
# 任务类型选择
TRIGGER_TYPE_CHOICES = {
'cron': 'Cron表达式',
'interval': '间隔执行',
'date': '指定时间',
}
# 任务状态选择
STATUS_CHOICES = {
0: '禁用',
1: '启用',
2: '暂停',
}
# 任务名称
name = Column(String(128), nullable=False, index=True, comment="任务名称")
# 任务编码(唯一标识)
code = Column(String(128), unique=True, nullable=False, index=True, comment="任务编码")
# 任务描述
description = Column(Text, nullable=True, comment="任务描述")
# 任务分组
group = Column(String(64), default='default', index=True, comment="任务分组")
# 触发器类型
trigger_type = Column(String(20), default='cron', index=True, comment="触发器类型")
# Cron 表达式(用于 cron 类型)
cron_expression = Column(String(128), nullable=True, comment="Cron表达式")
# 间隔时间(秒,用于 interval 类型)
interval_seconds = Column(Integer, nullable=True, comment="间隔时间(秒)")
# 指定执行时间(用于 date 类型)
run_date = Column(DateTime, nullable=True, comment="指定执行时间")
# 任务函数路径(如:scheduler.tasks.test_task
task_func = Column(String(256), nullable=False, comment="任务函数路径")
# 任务参数(JSON格式)
task_args = Column(Text, nullable=True, comment="任务位置参数(JSON数组格式)")
# 任务关键字参数(JSON格式)
task_kwargs = Column(Text, nullable=True, comment="任务关键字参数(JSON对象格式)")
# 任务状态
status = Column(Integer, default=0, index=True, comment="任务状态(0-禁用,1-启用,2-暂停)")
# 任务优先级(数字越大优先级越高)
priority = Column(Integer, default=0, index=True, comment="任务优先级")
# 最大实例数(同时运行的任务实例数)
max_instances = Column(Integer, default=1, comment="最大实例数")
# 错误重试次数
max_retries = Column(Integer, default=0, comment="错误重试次数")
# 超时时间(秒)
timeout = Column(Integer, nullable=True, comment="超时时间(秒)")
# 是否合并执行(如果上次未执行完,是否跳过本次)
coalesce = Column(Boolean, default=True, comment="是否合并执行")
# 是否允许并发执行
allow_concurrent = Column(Boolean, default=False, comment="是否允许并发执行")
# 执行统计
total_run_count = Column(Integer, default=0, comment="总执行次数")
success_count = Column(Integer, default=0, comment="成功次数")
failure_count = Column(Integer, default=0, comment="失败次数")
# 最后执行时间
last_run_time = Column(DateTime, nullable=True, comment="最后执行时间")
# 下次执行时间
next_run_time = Column(DateTime, nullable=True, comment="下次执行时间")
# 最后执行状态
last_run_status = Column(String(20), nullable=True, comment="最后执行状态")
# 最后执行结果
last_run_result = Column(Text, nullable=True, comment="最后执行结果")
# 备注
remark = Column(Text, nullable=True, comment="备注信息")
# 复合索引
__table_args__ = (
Index('ix_scheduler_job_status_trigger', 'status', 'trigger_type'),
Index('ix_scheduler_job_group_status', 'group', 'status'),
Index('ix_scheduler_job_priority_status', 'priority', 'status'),
Index('ix_scheduler_job_next_run_status', 'next_run_time', 'status'),
)
def __str__(self):
return f"{self.name} ({self.code})"
def is_enabled(self) -> bool:
"""判断任务是否启用"""
return self.status == 1
def is_paused(self) -> bool:
"""判断任务是否暂停"""
return self.status == 2
def is_disabled(self) -> bool:
"""判断任务是否禁用"""
return self.status == 0
def get_status_display(self) -> str:
"""获取状态的显示名称"""
return self.STATUS_CHOICES.get(self.status, '未知')
def get_trigger_type_display(self) -> str:
"""获取触发器类型的显示名称"""
return self.TRIGGER_TYPE_CHOICES.get(self.trigger_type, '未知')
def get_success_rate(self) -> float:
"""获取成功率"""
if self.total_run_count == 0:
return 0.0
return round(self.success_count / self.total_run_count * 100, 2)
class SchedulerLog(BaseModel):
"""
定时任务执行日志模型
功能特点:
1. 记录每次任务执行的详细信息
2. 记录执行时间、状态、结果
3. 记录异常信息
4. 支持日志查询和统计
"""
__tablename__ = "core_scheduler_log"
# 执行状态选择
STATUS_CHOICES = {
'pending': '等待执行',
'running': '执行中',
'success': '执行成功',
'failed': '执行失败',
'timeout': '执行超时',
'skipped': '跳过执行',
}
# 关联的任务ID(逻辑外键)
job_id = Column(String(36), nullable=False, index=True, comment="任务ID")
# 任务名称(冗余字段,便于查询)
job_name = Column(String(128), nullable=False, index=True, comment="任务名称")
# 任务编码(冗余字段,便于查询)
job_code = Column(String(128), nullable=False, index=True, comment="任务编码")
# 执行状态
status = Column(String(20), default='pending', index=True, comment="执行状态")
# 开始时间
start_time = Column(DateTime, nullable=False, index=True, comment="开始时间")
# 结束时间
end_time = Column(DateTime, nullable=True, comment="结束时间")
# 执行耗时(秒)
duration = Column(Float, nullable=True, comment="执行耗时(秒)")
# 执行结果
result = Column(Text, nullable=True, comment="执行结果")
# 异常信息
exception = Column(Text, nullable=True, comment="异常信息")
# 异常堆栈
traceback = Column(Text, nullable=True, comment="异常堆栈")
# 执行主机
hostname = Column(String(128), nullable=True, comment="执行主机")
# 进程ID
process_id = Column(Integer, nullable=True, comment="进程ID")
# 重试次数
retry_count = Column(Integer, default=0, comment="重试次数")
# 复合索引
__table_args__ = (
Index('ix_scheduler_log_job_status', 'job_id', 'status'),
Index('ix_scheduler_log_status_start', 'status', 'start_time'),
Index('ix_scheduler_log_code_start', 'job_code', 'start_time'),
)
def __str__(self):
return f"{self.job_name} - {self.status} - {self.start_time}"
def is_success(self) -> bool:
"""判断是否执行成功"""
return self.status == 'success'
def is_failed(self) -> bool:
"""判断是否执行失败"""
return self.status == 'failed'
def is_running(self) -> bool:
"""判断是否正在执行"""
return self.status == 'running'
def get_status_display(self) -> str:
"""获取状态的显示名称"""
return self.STATUS_CHOICES.get(self.status, '未知')
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Router - 定时任务模块路由
"""
from fastapi import APIRouter
from scheduler.api import router as scheduler_router
router = APIRouter()
# 注册调度器路由
router.include_router(scheduler_router)
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Schema - 定时任务数据验证和序列化
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.base_schema import CSTDatetime
# ==================== SchedulerJob Schemas ====================
class SchedulerJobBase(BaseModel):
"""定时任务基础Schema"""
application_id: Optional[str] = Field(None, description="所属应用ID")
name: str = Field(..., min_length=1, max_length=128, description="任务名称")
code: str = Field(..., min_length=1, max_length=128, description="任务编码")
description: Optional[str] = Field(None, description="任务描述")
group: str = Field(default="default", max_length=64, description="任务分组")
trigger_type: str = Field(..., description="触发器类型:cron/interval/date")
cron_expression: Optional[str] = Field(None, max_length=128, description="Cron表达式")
interval_seconds: Optional[int] = Field(None, ge=1, description="间隔时间(秒)")
run_date: Optional[datetime] = Field(None, description="指定执行时间")
task_func: str = Field(..., max_length=256, description="任务函数路径")
task_args: Optional[str] = Field(None, description="任务位置参数(JSON")
task_kwargs: Optional[str] = Field(None, description="任务关键字参数(JSON")
status: int = Field(default=0, description="任务状态:0-禁用,1-启用,2-暂停")
priority: int = Field(default=0, description="任务优先级")
max_instances: int = Field(default=1, ge=1, description="最大实例数")
max_retries: int = Field(default=0, ge=0, description="错误重试次数")
timeout: Optional[int] = Field(None, ge=1, description="超时时间(秒)")
coalesce: bool = Field(default=True, description="是否合并执行")
allow_concurrent: bool = Field(default=False, description="是否允许并发执行")
remark: Optional[str] = Field(None, description="备注信息")
sort: int = Field(default=0, description="排序")
@field_validator('trigger_type')
@classmethod
def validate_trigger_type(cls, v):
"""验证触发器类型"""
if v not in ['cron', 'interval', 'date']:
raise ValueError('触发器类型必须是 cron、interval 或 date')
return v
@field_validator('status')
@classmethod
def validate_status(cls, v):
"""验证状态"""
if v not in [0, 1, 2]:
raise ValueError('状态必须是 0(禁用)、1(启用)或 2(暂停)')
return v
@field_validator('code')
@classmethod
def validate_code(cls, v):
"""验证任务编码格式"""
if not v:
raise ValueError('任务编码不能为空')
if not v.replace('_', '').isalnum():
raise ValueError('任务编码只能包含字母、数字和下划线')
return v
class SchedulerJobCreate(SchedulerJobBase):
"""定时任务创建Schema"""
@field_validator('cron_expression')
@classmethod
def validate_cron_expression(cls, v, info):
"""验证 Cron 表达式"""
if info.data.get('trigger_type') == 'cron' and not v:
raise ValueError('Cron 类型任务必须提供 cron_expression')
return v
@field_validator('interval_seconds')
@classmethod
def validate_interval_seconds(cls, v, info):
"""验证间隔时间"""
if info.data.get('trigger_type') == 'interval' and not v:
raise ValueError('Interval 类型任务必须提供 interval_seconds')
return v
@field_validator('run_date')
@classmethod
def validate_run_date(cls, v, info):
"""验证指定时间"""
if info.data.get('trigger_type') == 'date' and not v:
raise ValueError('Date 类型任务必须提供 run_date')
return v
class SchedulerJobUpdate(BaseModel):
"""定时任务更新Schema - 所有字段可选"""
application_id: Optional[str] = Field(None, description="所属应用ID")
name: Optional[str] = Field(None, min_length=1, max_length=128, description="任务名称")
code: Optional[str] = Field(None, min_length=1, max_length=128, description="任务编码")
description: Optional[str] = Field(None, description="任务描述")
group: Optional[str] = Field(None, max_length=64, description="任务分组")
trigger_type: Optional[str] = Field(None, description="触发器类型")
cron_expression: Optional[str] = Field(None, max_length=128, description="Cron表达式")
interval_seconds: Optional[int] = Field(None, ge=1, description="间隔时间(秒)")
run_date: Optional[datetime] = Field(None, description="指定执行时间")
task_func: Optional[str] = Field(None, max_length=256, description="任务函数路径")
task_args: Optional[str] = Field(None, description="任务位置参数(JSON")
task_kwargs: Optional[str] = Field(None, description="任务关键字参数(JSON")
status: Optional[int] = Field(None, description="任务状态")
priority: Optional[int] = Field(None, description="任务优先级")
max_instances: Optional[int] = Field(None, ge=1, description="最大实例数")
max_retries: Optional[int] = Field(None, ge=0, description="错误重试次数")
timeout: Optional[int] = Field(None, ge=1, description="超时时间(秒)")
coalesce: Optional[bool] = Field(None, description="是否合并执行")
allow_concurrent: Optional[bool] = Field(None, description="是否允许并发执行")
remark: Optional[str] = Field(None, description="备注信息")
sort: Optional[int] = Field(None, description="排序")
class SchedulerJobResponse(BaseModel):
"""定时任务响应Schema"""
id: str
application_id: Optional[str] = None
name: str
code: str
description: Optional[str] = None
group: str
trigger_type: str
trigger_type_display: Optional[str] = None
cron_expression: Optional[str] = None
interval_seconds: Optional[int] = None
run_date: Optional[CSTDatetime] = None
task_func: str
task_args: Optional[str] = None
task_kwargs: Optional[str] = None
status: int
status_display: Optional[str] = None
priority: int
max_instances: int
max_retries: int
timeout: Optional[int] = None
coalesce: bool
allow_concurrent: bool
total_run_count: int
success_count: int
failure_count: int
success_rate: Optional[float] = None
last_run_time: Optional[CSTDatetime] = None
next_run_time: Optional[CSTDatetime] = None
last_run_status: Optional[str] = None
last_run_result: Optional[str] = None
remark: Optional[str] = None
sort: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class SchedulerJobSimple(BaseModel):
"""定时任务简化输出(用于选择器)"""
id: str
application_id: Optional[str] = None
name: str
code: str
group: str
status: int
model_config = ConfigDict(from_attributes=True)
class SchedulerJobBatchDeleteIn(BaseModel):
"""批量删除输入"""
ids: List[str] = Field(..., description="任务ID列表")
class SchedulerJobBatchDeleteOut(BaseModel):
"""批量删除输出"""
count: int = Field(..., description="删除成功数量")
failed_ids: List[str] = Field(default=[], description="删除失败的ID列表")
class SchedulerJobBatchUpdateStatusIn(BaseModel):
"""批量更新状态输入"""
ids: List[str] = Field(..., description="任务ID列表")
status: int = Field(..., description="目标状态:0-禁用,1-启用,2-暂停")
class SchedulerJobBatchUpdateStatusOut(BaseModel):
"""批量更新状态输出"""
count: int = Field(..., description="更新成功数量")
class SchedulerJobExecuteIn(BaseModel):
"""立即执行任务输入"""
job_id: str = Field(..., description="任务ID")
class SchedulerJobExecuteOut(BaseModel):
"""立即执行任务输出"""
success: bool = Field(..., description="是否成功")
message: str = Field(..., description="消息")
log_id: Optional[str] = Field(None, description="日志ID")
class SchedulerJobStatisticsOut(BaseModel):
"""任务统计输出"""
total_jobs: int = Field(..., description="总任务数")
enabled_jobs: int = Field(..., description="启用任务数")
disabled_jobs: int = Field(..., description="禁用任务数")
paused_jobs: int = Field(..., description="暂停任务数")
total_executions: int = Field(..., description="总执行次数")
success_executions: int = Field(..., description="成功执行次数")
failed_executions: int = Field(..., description="失败执行次数")
success_rate: float = Field(..., description="成功率")
class SchedulerJobSearchRequest(BaseModel):
"""搜索任务请求"""
keyword: str = Field(..., description="搜索关键词")
# ==================== SchedulerLog Schemas ====================
class SchedulerLogResponse(BaseModel):
"""定时任务日志响应Schema"""
id: str
job_id: str
job_name: str
job_code: str
status: str
status_display: Optional[str] = None
start_time: CSTDatetime
end_time: Optional[CSTDatetime] = None
duration: Optional[float] = None
result: Optional[str] = None
exception: Optional[str] = None
traceback: Optional[str] = None
hostname: Optional[str] = None
process_id: Optional[int] = None
retry_count: int
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class SchedulerLogBatchDeleteIn(BaseModel):
"""批量删除日志输入"""
ids: List[str] = Field(..., description="日志ID列表")
class SchedulerLogBatchDeleteOut(BaseModel):
"""批量删除日志输出"""
count: int = Field(..., description="删除成功数量")
class SchedulerLogCleanIn(BaseModel):
"""清理日志输入"""
days: int = Field(..., ge=1, description="保留最近N天的日志")
status: Optional[str] = Field(None, description="只清理指定状态的日志")
class SchedulerLogCleanOut(BaseModel):
"""清理日志输出"""
count: int = Field(..., description="清理数量")
# ==================== Scheduler Status Schemas ====================
class SchedulerStatusOut(BaseModel):
"""调度器状态输出"""
is_running: bool = Field(..., description="是否运行中")
job_count: int = Field(..., description="任务数量")
jobs: List[dict] = Field(default=[], description="任务列表")
+554
View File
@@ -0,0 +1,554 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Service - APScheduler 4.x 调度服务
基于 APScheduler 4.x 实现的定时任务调度核心服务
"""
import json
import logging
import os
import socket
from datetime import datetime
from typing import Optional, Dict, Any, List
from apscheduler import AsyncScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from app.config import settings
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SchedulerService:
"""
定时任务调度服务 (APScheduler 4.x)
功能特点:
1. 使用 APScheduler 4.x 的 AsyncScheduler
2. 支持多种触发器类型(cron、interval、date
3. 自动从数据库加载任务
4. 通过事件订阅监听任务执行
5. 自动更新任务状态和记录日志
"""
_instance = None
_scheduler: Optional[AsyncScheduler] = None
_running: bool = False
def __new__(cls):
"""单例模式"""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def get_scheduler(self) -> Optional[AsyncScheduler]:
"""获取调度器实例"""
return self._scheduler
def set_scheduler(self, scheduler: AsyncScheduler):
"""设置调度器实例"""
self._scheduler = scheduler
self._running = True
def is_running(self) -> bool:
"""判断调度器是否运行中"""
return self._running and self._scheduler is not None
def set_running(self, running: bool):
"""设置运行状态"""
self._running = running
async def load_jobs_from_db(self):
"""从数据库加载所有启用的任务"""
logger.info("开始从数据库加载任务...")
if not self._scheduler:
logger.warning("调度器未初始化,无法加载任务")
return
try:
from sqlalchemy import select, update
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob, SchedulerLog
async with AsyncSessionLocal() as db:
# 注意:启动时处理 running 日志的逻辑已移除
# 因为可能会因为数据库锁而阻塞启动
# running 状态的日志会在前端显示为"执行中",用户可手动处理
# 获取所有启用的任务
result = await db.execute(
select(SchedulerJob).where(
SchedulerJob.status == 1,
SchedulerJob.is_deleted == False # noqa: E712
)
)
jobs = result.scalars().all()
logger.info(f"查询到 {len(jobs)} 个启用的任务")
for job in jobs:
try:
success = await self.add_job(job)
if success:
logger.info(f"加载任务成功: {job.code}")
else:
logger.warning(f"加载任务返回失败: {job.code}")
except Exception as e:
logger.error(f"加载任务失败 {job.code}: {str(e)}")
# 启动定期清理任务
await self._start_cleanup_job()
logger.info(f"任务加载完成,共加载 {len(jobs)} 个任务")
except Exception as e:
logger.error(f"从数据库加载任务失败: {str(e)}")
async def _start_cleanup_job(self):
"""启动定期清理过期任务的定时任务"""
if not self._scheduler:
return
cleanup_job_id = '_scheduler_cleanup'
# 先注册任务
await self._scheduler.configure_task(cleanup_job_id, func=self._cleanup_expired_jobs_wrapper)
# 每天凌晨3点清理过期任务
await self._scheduler.add_schedule(
func_or_task_id=cleanup_job_id,
trigger=CronTrigger(hour=3, minute=0),
id=cleanup_job_id,
)
logger.info("定期清理任务已启动(每天凌晨3点)")
async def _cleanup_expired_jobs_wrapper(self):
"""清理过期任务的包装函数"""
await self.cleanup_expired_jobs(days=7)
async def add_job(self, job_obj) -> bool:
"""添加任务到调度器"""
if not self._scheduler:
logger.error("调度器未初始化")
return False
try:
# 构建触发器
trigger = self._build_trigger(job_obj)
if not trigger:
logger.error(f"无法为任务 {job_obj.code} 构建触发器")
return False
# 导入任务函数
task_func = self._import_task_func(job_obj.task_func)
if not task_func:
logger.error(f"无法导入任务函数: {job_obj.task_func}")
return False
# 解析任务参数
args = json.loads(job_obj.task_args) if job_obj.task_args else []
kwargs = json.loads(job_obj.task_kwargs) if job_obj.task_kwargs else {}
kwargs['job_code'] = job_obj.code
# 创建带日志记录的包装函数
wrapper_func = self._create_job_wrapper(task_func, job_obj.code, args, kwargs)
# APScheduler 4.x: 需要先使用 configure_task 注册任务
task_id = job_obj.code
await self._scheduler.configure_task(task_id, func=wrapper_func)
# 添加任务调度
await self._scheduler.add_schedule(
func_or_task_id=task_id,
trigger=trigger,
id=job_obj.code,
)
logger.info(f"任务 {job_obj.code} 已添加到调度器")
return True
except Exception as e:
logger.error(f"添加任务失败 {job_obj.code}: {str(e)}")
return False
def _create_job_wrapper(self, task_func, job_code: str, args: list, kwargs: dict):
"""创建带日志记录的任务包装函数"""
async def wrapper():
return await self._execute_job(task_func, job_code, args, kwargs)
return wrapper
async def _execute_job(self, task_func, job_code: str, args: list, kwargs: dict):
"""执行任务并记录日志"""
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob, SchedulerLog
from scheduler.task_log_service import TaskLogService, TaskLogger
from sqlalchemy import select
start_time = datetime.now()
exception_info = None
result = None
log_id = None
job_name = job_code
# 在任务开始执行时创建日志记录(状态为 running)
try:
async with AsyncSessionLocal() as db:
# 获取任务
query_result = await db.execute(
select(SchedulerJob).where(SchedulerJob.code == job_code)
)
job_obj = query_result.scalar_one_or_none()
if job_obj:
job_name = job_obj.name
# 创建执行日志(状态为 running)
log = SchedulerLog(
job_id=job_obj.id,
job_name=job_obj.name,
job_code=job_obj.code,
start_time=start_time,
status='running',
hostname=socket.gethostname(),
process_id=os.getpid(),
)
# 更新任务状态为执行中
job_obj.last_run_status = 'running'
db.add(log)
await db.commit()
await db.refresh(log)
log_id = log.id
# 发布任务开始事件
await TaskLogService.publish_start(log_id, job_name)
except Exception as e:
logger.error(f"创建任务执行日志失败: {str(e)}")
# 创建 TaskLogger 并注入到 kwargs
task_logger = None
if log_id:
task_logger = TaskLogger(str(log_id), job_name)
kwargs['task_logger'] = task_logger
# 执行任务
try:
if args:
result = await task_func(*args, **kwargs)
else:
result = await task_func(**kwargs)
except Exception as e:
exception_info = e
logger.error(f"任务 {job_code} 执行失败: {str(e)}")
end_time = datetime.now()
# 发布完成/错误事件
if log_id:
if exception_info:
await TaskLogService.publish_error(str(log_id), job_name, str(exception_info))
else:
await TaskLogService.publish_complete(str(log_id), job_name, str(result) if result else None)
# 更新日志和任务状态
try:
async with AsyncSessionLocal() as db:
# 获取任务
query_result = await db.execute(
select(SchedulerJob).where(SchedulerJob.code == job_code)
)
job_obj = query_result.scalar_one_or_none()
if job_obj:
# 更新执行日志
if log_id:
log_result = await db.execute(
select(SchedulerLog).where(SchedulerLog.id == log_id)
)
log = log_result.scalar_one_or_none()
if log:
log.end_time = end_time
log.duration = (end_time - start_time).total_seconds()
if exception_info:
log.status = 'failed'
log.exception = str(exception_info)
import traceback
log.traceback = traceback.format_exc()
else:
log.status = 'success'
log.result = str(result) if result else None
if exception_info:
# 执行失败
job_obj.last_run_status = 'failed'
job_obj.last_run_result = str(exception_info)
job_obj.failure_count += 1
else:
# 执行成功
job_obj.last_run_status = 'success'
job_obj.last_run_result = str(result) if result else None
job_obj.success_count += 1
job_obj.total_run_count += 1
job_obj.last_run_time = end_time
# 一次性任务(date 类型)执行后更新状态为禁用,但不删除
if job_obj.trigger_type == 'date':
job_obj.status = 0 # 禁用
job_obj.next_run_time = None
await db.commit()
except Exception as e:
logger.error(f"更新任务执行日志失败: {str(e)}")
if exception_info:
raise exception_info
return result
async def remove_job(self, job_code: str) -> bool:
"""从调度器移除任务"""
if not self._scheduler:
return False
try:
await self._scheduler.remove_schedule(job_code)
logger.info(f"任务 {job_code} 已从调度器移除")
return True
except Exception as e:
logger.error(f"移除任务失败 {job_code}: {str(e)}")
return False
async def pause_job(self, job_code: str) -> bool:
"""暂停任务"""
if not self._scheduler:
return False
try:
await self._scheduler.pause_schedule(job_code)
logger.info(f"任务 {job_code} 已暂停")
return True
except Exception as e:
logger.error(f"暂停任务失败 {job_code}: {str(e)}")
return False
async def resume_job(self, job_code: str) -> bool:
"""恢复任务"""
if not self._scheduler:
return False
try:
await self._scheduler.unpause_schedule(job_code)
logger.info(f"任务 {job_code} 已恢复")
return True
except Exception as e:
logger.error(f"恢复任务失败 {job_code}: {str(e)}")
return False
async def modify_job(self, job_obj) -> bool:
"""修改任务"""
try:
# 先移除旧任务
await self.remove_job(job_obj.code)
# 如果任务是启用状态,重新添加
if job_obj.is_enabled():
return await self.add_job(job_obj)
return True
except Exception as e:
logger.error(f"修改任务失败 {job_obj.code}: {str(e)}")
return False
async def run_job_now(self, job_code: str) -> bool:
"""立即执行任务"""
if not self._scheduler:
return False
try:
# 在 APScheduler 4.x 中,使用 run_job 立即执行
from sqlalchemy import select
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob
async with AsyncSessionLocal() as db:
result = await db.execute(
select(SchedulerJob).where(SchedulerJob.code == job_code)
)
job_obj = result.scalar_one_or_none()
if job_obj:
# 解析任务参数
args = json.loads(job_obj.task_args) if job_obj.task_args else []
kwargs = json.loads(job_obj.task_kwargs) if job_obj.task_kwargs else {}
kwargs['job_code'] = job_obj.code
# 导入并执行任务函数
task_func = self._import_task_func(job_obj.task_func)
if task_func:
await self._execute_job(task_func, job_code, args, kwargs)
logger.info(f"任务 {job_code} 已立即执行")
return True
return False
except Exception as e:
logger.error(f"立即执行任务失败 {job_code}: {str(e)}")
return False
async def get_job_info(self, job_code: str) -> Optional[Dict[str, Any]]:
"""获取任务信息"""
if not self._scheduler:
return None
try:
schedules = await self._scheduler.get_schedules()
for schedule in schedules:
if schedule.id == job_code:
return {
'id': schedule.id,
'next_run_time': schedule.next_fire_time.isoformat() if schedule.next_fire_time else None,
'trigger': str(schedule.trigger),
}
return None
except Exception as e:
logger.error(f"获取任务信息失败 {job_code}: {str(e)}")
return None
async def get_all_jobs(self) -> List[Dict[str, Any]]:
"""获取所有任务"""
if not self._scheduler:
return []
try:
schedules = await self._scheduler.get_schedules()
return [
{
'id': schedule.id,
'next_run_time': schedule.next_fire_time.isoformat() if schedule.next_fire_time else None,
'trigger': str(schedule.trigger),
}
for schedule in schedules
]
except Exception as e:
logger.error(f"获取所有任务失败: {str(e)}")
return []
def _build_trigger(self, job_obj):
"""构建触发器"""
try:
if job_obj.trigger_type == 'cron':
# Cron 触发器
parts = job_obj.cron_expression.split()
if len(parts) != 5:
logger.error(f"Cron 表达式格式错误: {job_obj.cron_expression}")
return None
return CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4],
)
elif job_obj.trigger_type == 'interval':
# 间隔触发器
return IntervalTrigger(seconds=job_obj.interval_seconds)
elif job_obj.trigger_type == 'date':
# 指定时间触发器
return DateTrigger(run_time=job_obj.run_date)
else:
logger.error(f"不支持的触发器类型: {job_obj.trigger_type}")
return None
except Exception as e:
logger.error(f"构建触发器失败: {str(e)}")
return None
def _import_task_func(self, task_path: str):
"""动态导入任务函数"""
try:
module_path, func_name = task_path.rsplit('.', 1)
module = __import__(module_path, fromlist=[func_name])
return getattr(module, func_name)
except Exception as e:
logger.error(f"导入任务函数失败 {task_path}: {str(e)}")
return None
async def _cleanup_one_time_job(self, db, job_obj):
"""清理一次性任务"""
try:
job_code = job_obj.code
# 从调度器移除
try:
await self._scheduler.remove_schedule(job_code)
except Exception:
pass
# 软删除数据库记录
job_obj.is_deleted = True
await db.commit()
logger.debug(f"一次性任务已清理: {job_code}")
except Exception as e:
logger.error(f"清理一次性任务失败 {job_obj.code}: {str(e)}")
async def cleanup_expired_jobs(self, days: int = 7) -> int:
"""
清理过期的一次性任务
Args:
days: 清理多少天前的任务,默认7天
"""
try:
from datetime import timedelta
from sqlalchemy import select
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerJob
cutoff = datetime.now() - timedelta(days=days)
async with AsyncSessionLocal() as db:
# 查找过期的一次性任务
result = await db.execute(
select(SchedulerJob).where(
SchedulerJob.trigger_type == 'date',
SchedulerJob.run_date < cutoff,
SchedulerJob.is_deleted == False # noqa: E712
)
)
expired_jobs = result.scalars().all()
count = len(expired_jobs)
if count > 0:
for job in expired_jobs:
# 从调度器移除
try:
await self._scheduler.remove_schedule(job.code)
except Exception:
pass
# 软删除
job.is_deleted = True
await db.commit()
logger.info(f"清理了 {count} 个过期的一次性任务")
return count
except Exception as e:
logger.error(f"清理过期任务失败: {str(e)}")
return 0
# 全局调度器服务实例
scheduler_service = SchedulerService()
@@ -0,0 +1,320 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
任务执行日志服务
通过 Redis Pub/Sub 实现任务执行过程中的实时日志推送
"""
import json
import asyncio
from datetime import datetime
from typing import Optional, AsyncGenerator
from dataclasses import dataclass, asdict
from enum import Enum
from utils.redis import RedisClient
class LogLevel(str, Enum):
"""日志级别"""
DEBUG = "debug"
INFO = "info"
WARNING = "warning"
ERROR = "error"
SUCCESS = "success"
@dataclass
class TaskLogEntry:
"""任务日志条目"""
timestamp: str
level: str
message: str
step: Optional[str] = None
progress: Optional[int] = None # 0-100
data: Optional[dict] = None
def to_dict(self) -> dict:
result = asdict(self)
# 移除 None 值
return {k: v for k, v in result.items() if v is not None}
class TaskLogService:
"""
任务日志服务
用于在任务执行过程中发布实时日志,前端通过 SSE 订阅
使用 Redis List 存储历史日志 + Pub/Sub 推送新消息
"""
# Redis 频道前缀
CHANNEL_PREFIX = "scheduler:task_log:"
# Redis List 前缀(存储历史日志)
LIST_PREFIX = "scheduler:task_log_history:"
# 日志过期时间(秒)- 1小时
LOG_EXPIRE_SECONDS = 3600
@classmethod
def _get_channel(cls, log_id: str) -> str:
"""获取 Redis Pub/Sub 频道名称"""
return f"{cls.CHANNEL_PREFIX}{log_id}"
@classmethod
def _get_list_key(cls, log_id: str) -> str:
"""获取 Redis List 键名"""
return f"{cls.LIST_PREFIX}{log_id}"
@classmethod
async def publish(
cls,
log_id: str,
message: str,
level: LogLevel = LogLevel.INFO,
step: Optional[str] = None,
progress: Optional[int] = None,
data: Optional[dict] = None
):
"""
发布日志消息
同时存储到 Redis List(历史)和发布到 Pub/Sub(实时)
Args:
log_id: 日志记录 ID
message: 日志消息
level: 日志级别
step: 当前步骤名称
progress: 进度百分比 (0-100)
data: 附加数据
"""
try:
redis = await RedisClient.get_client()
except Exception:
return
entry = TaskLogEntry(
timestamp=datetime.now().isoformat(),
level=level.value,
message=message,
step=step,
progress=progress,
data=data
)
entry_json = json.dumps(entry.to_dict(), ensure_ascii=False)
list_key = cls._get_list_key(log_id)
channel = cls._get_channel(log_id)
# 存储到 List(历史日志)
await redis.rpush(list_key, entry_json)
# 设置过期时间
await redis.expire(list_key, cls.LOG_EXPIRE_SECONDS)
# 发布到 Pub/Sub(实时推送)
await redis.publish(channel, entry_json)
@classmethod
async def publish_start(cls, log_id: str, job_name: str):
"""发布任务开始事件"""
await cls.publish(
log_id=log_id,
message=f"任务 {job_name} 开始执行",
level=LogLevel.INFO,
step="start",
progress=0
)
@classmethod
async def publish_complete(cls, log_id: str, job_name: str, result: Optional[str] = None):
"""发布任务完成事件"""
await cls.publish(
log_id=log_id,
message=f"任务 {job_name} 执行完成",
level=LogLevel.SUCCESS,
step="complete",
progress=100,
data={"result": result} if result else None
)
@classmethod
async def publish_error(cls, log_id: str, job_name: str, error: str):
"""发布任务错误事件"""
await cls.publish(
log_id=log_id,
message=f"任务 {job_name} 执行失败: {error}",
level=LogLevel.ERROR,
step="error",
data={"error": error}
)
@classmethod
async def get_history(cls, log_id: str) -> list:
"""
获取历史日志
Args:
log_id: 日志记录 ID
Returns:
日志条目列表
"""
try:
redis = await RedisClient.get_client()
except Exception:
return []
list_key = cls._get_list_key(log_id)
entries = await redis.lrange(list_key, 0, -1)
result = []
for entry_json in entries:
try:
result.append(json.loads(entry_json))
except json.JSONDecodeError:
pass
return result
@classmethod
async def subscribe(cls, log_id: str) -> AsyncGenerator[dict, None]:
"""
订阅日志消息
先返回历史日志,然后订阅新消息
Args:
log_id: 日志记录 ID
Yields:
日志条目字典
"""
try:
redis = await RedisClient.get_client()
except Exception:
return
list_key = cls._get_list_key(log_id)
channel = cls._get_channel(log_id)
# 先获取历史日志
history = await redis.lrange(list_key, 0, -1)
# 返回历史日志
for entry_json in history:
try:
data = json.loads(entry_json)
yield data
# 如果历史日志中已经有完成或错误事件,直接结束
if data.get("step") in ("complete", "error"):
return
except json.JSONDecodeError:
pass
# 订阅新消息
pubsub = redis.pubsub()
await pubsub.subscribe(channel)
try:
async for message in pubsub.listen():
if message["type"] == "message":
data = json.loads(message["data"])
yield data
# 如果是完成或错误事件,结束订阅
if data.get("step") in ("complete", "error"):
break
finally:
await pubsub.unsubscribe(channel)
await pubsub.close()
class TaskLogger:
"""
任务日志记录器
在任务函数中使用,用于记录执行过程中的日志
"""
def __init__(self, log_id: str, job_name: str = ""):
self.log_id = log_id
self.job_name = job_name
self._current_step = None
self._progress = 0
async def debug(self, message: str, **kwargs):
"""记录调试日志"""
await TaskLogService.publish(
log_id=self.log_id,
message=message,
level=LogLevel.DEBUG,
step=self._current_step,
progress=self._progress,
**kwargs
)
async def info(self, message: str, **kwargs):
"""记录信息日志"""
await TaskLogService.publish(
log_id=self.log_id,
message=message,
level=LogLevel.INFO,
step=self._current_step,
progress=self._progress,
**kwargs
)
async def warning(self, message: str, **kwargs):
"""记录警告日志"""
await TaskLogService.publish(
log_id=self.log_id,
message=message,
level=LogLevel.WARNING,
step=self._current_step,
progress=self._progress,
**kwargs
)
async def error(self, message: str, **kwargs):
"""记录错误日志"""
await TaskLogService.publish(
log_id=self.log_id,
message=message,
level=LogLevel.ERROR,
step=self._current_step,
progress=self._progress,
**kwargs
)
async def success(self, message: str, **kwargs):
"""记录成功日志"""
await TaskLogService.publish(
log_id=self.log_id,
message=message,
level=LogLevel.SUCCESS,
step=self._current_step,
progress=self._progress,
**kwargs
)
def set_step(self, step: str):
"""设置当前步骤"""
self._current_step = step
def set_progress(self, progress: int):
"""设置进度 (0-100)"""
self._progress = max(0, min(100, progress))
async def step(self, step: str, message: str, progress: Optional[int] = None):
"""
记录步骤日志
Args:
step: 步骤名称
message: 日志消息
progress: 进度百分比
"""
self._current_step = step
if progress is not None:
self._progress = progress
await self.info(message, step=step, progress=self._progress)
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Task Utils - 定时任务工具函数
提供任务执行中常用的工具函数
"""
import logging
logger = logging.getLogger(__name__)
class TaskLoggerWrapper:
"""
任务日志包装器
同时输出日志到控制台和实时推送到前端
"""
def __init__(self, job_code: str, task_logger=None):
"""
初始化日志包装器
Args:
job_code: 任务编码
task_logger: TaskLogger 实例(可选)
"""
self.job_code = job_code
self.task_logger = task_logger
async def info(self, message: str):
"""记录信息日志"""
logger.info(f"[{self.job_code}] {message}")
if self.task_logger:
await self.task_logger.info(message)
async def warning(self, message: str):
"""记录警告日志"""
logger.warning(f"[{self.job_code}] {message}")
if self.task_logger:
await self.task_logger.warning(message)
async def error(self, message: str):
"""记录错误日志"""
logger.error(f"[{self.job_code}] {message}")
if self.task_logger:
await self.task_logger.error(message)
async def debug(self, message: str):
"""记录调试日志"""
logger.debug(f"[{self.job_code}] {message}")
if self.task_logger:
await self.task_logger.debug(message)
+477
View File
@@ -0,0 +1,477 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Scheduler Tasks - 定时任务函数示例
定义可被调度器调用的任务函数
"""
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
async def test_task(job_code: str = None, word: str = None, **kwargs):
"""
测试任务
这是一个简单的测试任务,用于验证调度器是否正常工作。
Args:
job_code: 任务编码(由调度器自动传入)
**kwargs: 其他参数
"""
logger.info(f"[{job_code}-{word}] 测试任务执行开始: {datetime.now()}")
# 模拟任务执行
import asyncio
await asyncio.sleep(10)
logger.info(f"[{job_code}] 测试任务执行完成: {datetime.now()}")
return f"测试任务执行成功: {datetime.now()}"
async def cleanup_task(job_code: str = None, days: int = 30, **kwargs):
"""
清理任务
清理过期的日志数据。
Args:
job_code: 任务编码(由调度器自动传入)
days: 保留最近N天的数据
**kwargs: 其他参数
"""
logger.info(f"[{job_code}] 清理任务执行开始,保留最近 {days} 天数据")
try:
from datetime import timedelta
from sqlalchemy import select, delete
from app.database import AsyncSessionLocal
from scheduler.model import SchedulerLog
cutoff = datetime.now() - timedelta(days=days)
async with AsyncSessionLocal() as db:
# 删除过期日志
result = await db.execute(
select(SchedulerLog).where(SchedulerLog.start_time < cutoff)
)
logs = result.scalars().all()
count = len(logs)
for log in logs:
await db.delete(log)
await db.commit()
logger.info(f"[{job_code}] 清理任务执行完成,删除了 {count} 条日志")
return f"清理了 {count} 条过期日志"
except Exception as e:
logger.error(f"[{job_code}] 清理任务执行失败: {str(e)}")
raise
def sync_test_task(job_code: str = None, **kwargs):
"""
同步测试任务
这是一个同步任务示例,用于演示同步任务的使用。
Args:
job_code: 任务编码(由调度器自动传入)
**kwargs: 其他参数
"""
import time
logger.info(f"[{job_code}] 同步测试任务执行开始: {datetime.now()}")
# 模拟任务执行
time.sleep(1)
logger.info(f"[{job_code}] 同步测试任务执行完成: {datetime.now()}")
return f"同步测试任务执行成功: {datetime.now()}"
async def restore_database_task(
job_code: str = None,
file_path: str = './db_init.json',
app_name: str = None,
clear_before_restore: bool = True,
exclude_tables: list = None,
**kwargs
):
"""
数据库数据恢复任务
从 JSON 文件恢复数据库数据,类似 Django 的 loaddata 命令。
Args:
job_code: 任务编码(由调度器自动传入)
file_path: JSON 数据文件路径(必填)
app_name: 应用名称过滤(可选),如 core、scheduler,只恢复指定应用的数据
clear_before_restore: 恢复前是否清空目标表(默认 True)
exclude_tables: 排除的表名列表(可选),如 ['scheduler_log', 'core_operation_log']
**kwargs: 其他参数(包含 task_logger
Returns:
str: 恢复结果摘要
Raises:
ValueError: 文件路径未指定或文件不存在
Exception: 数据恢复过程中的错误
"""
exclude_tables = exclude_tables or ['core_city', 'core_street', 'core_village', 'core_area','core_province', 'core_scheduler_job', 'core_scheduler_log']
import json
from pathlib import Path
from typing import Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from app.database import AsyncSessionLocal, Base
from scheduler.task_utils import TaskLoggerWrapper
# 创建日志包装器:同时输出到控制台和实时日志
log = TaskLoggerWrapper(job_code, kwargs.get('task_logger'))
await log.info(f"数据库恢复任务开始执行")
# 验证文件路径
if not file_path:
raise ValueError("file_path 参数不能为空")
data_file = Path(file_path)
if not data_file.exists():
raise ValueError(f"数据文件不存在: {file_path}")
if not data_file.suffix.lower() == '.json':
raise ValueError(f"只支持 JSON 格式的数据文件: {file_path}")
await log.info(f"从文件加载数据: {file_path}")
def parse_datetime(value):
"""解析日期时间字符串"""
if isinstance(value, str):
# 尝试解析 ISO 格式的日期时间字符串
try:
# 支持多种格式
if 'T' in value or ' ' in value:
# 包含时间部分
return datetime.fromisoformat(value.replace('Z', '+00:00'))
elif len(value) == 10 and value.count('-') == 2:
# 只有日期部分 YYYY-MM-DD
from datetime import date
return date.fromisoformat(value)
except (ValueError, AttributeError):
pass
return value
async def auto_import_models():
"""自动加载所有模型类定义"""
import importlib
project_root = Path(__file__).parent.parent
scan_dirs = ["zq_demo", "core", "scheduler", "online_dev", "ai_platform"]
loaded_count = 0
for scan_dir in scan_dirs:
scan_path = project_root / scan_dir
if not scan_path.exists():
continue
for model_file in scan_path.rglob("*model.py"):
relative_path = model_file.relative_to(project_root)
module_path = str(relative_path.with_suffix("")).replace("/", ".").replace("\\", ".")
try:
importlib.import_module(module_path)
loaded_count += 1
except ImportError as e:
logger.warning(f"[{job_code}] 加载模型定义失败 {module_path}: {e}")
await log.info(f"已加载 {loaded_count} 个模型定义文件")
try:
# 自动加载所有模型类定义(不是导入数据)
await log.info(f"开始加载模型定义...")
await auto_import_models()
# 读取 JSON 文件
with open(data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
await log.info(f"读取到 {len(data)} 条记录")
# 构建模型映射
model_map: Dict[str, Any] = {}
for mapper in Base.registry.mappers:
model_class = mapper.class_
model_key = f"{model_class.__module__}.{model_class.__name__}"
model_map[model_key] = model_class
# 如果指定了 app_name,过滤数据
if app_name:
data = [item for item in data if item.get("model", "").startswith(app_name)]
await log.info(f"过滤后剩余 {len(data)} 条记录(应用: {app_name})")
# 排除指定的表
if exclude_tables:
original_count = len(data)
data = [
item for item in data
if not (model_map.get(item.get("model")) and
model_map[item.get("model")].__tablename__ in exclude_tables)
]
excluded_count = original_count - len(data)
if excluded_count > 0:
await log.info(f"排除 {excluded_count} 条记录(表: {', '.join(exclude_tables)})")
success_count = 0
error_count = 0
skipped_count = 0
from sqlalchemy import text
# 阶段1:清空表(每个表独立事务,避免长时间持有锁)
if clear_before_restore:
tables_to_clear = set()
for item in data:
model_name = item.get("model")
if model_name in model_map:
tables_to_clear.add(model_name)
# 按表名排序,便于追踪进度
sorted_tables = sorted(tables_to_clear, key=lambda x: model_map[x].__tablename__)
total_tables = len(sorted_tables)
await log.info(f"准备清空 {total_tables} 个表...")
# 每个表独立事务清空
for idx, model_name in enumerate(sorted_tables, 1):
model_class = model_map[model_name]
table_name = model_class.__tablename__
await log.info(f"[{idx}/{total_tables}] 清空表: {table_name}")
async with AsyncSessionLocal() as session:
try:
# 使用 TRUNCATE CASCADE 处理外键依赖
await session.execute(text(f'TRUNCATE TABLE "{table_name}" CASCADE'))
await session.commit()
except Exception as e:
await session.rollback()
# 如果 TRUNCATE 失败,尝试 DELETE
await log.warning(f"TRUNCATE 失败,使用 DELETE: {e}")
try:
await session.execute(delete(model_class))
await session.commit()
except Exception as e2:
await log.error(f"DELETE 也失败: {e2}")
await session.rollback()
await log.info(f"已清空 {total_tables} 个表")
# 阶段2:导入数据(分批提交,每批独立事务)
total_records = len(data)
await log.info(f"开始导入 {total_records} 条记录...")
batch_size = 500
batch = []
for idx, item in enumerate(data, 1):
model_name = item.get("model")
fields = item.get("fields", {})
if model_name not in model_map:
skipped_count += 1
continue
model_class = model_map[model_name]
# 转换日期时间字段(对所有字段值尝试转换)
for key, value in fields.items():
fields[key] = parse_datetime(value)
batch.append((model_class, fields))
# 每 batch_size 条提交一次
if len(batch) >= batch_size:
async with AsyncSessionLocal() as session:
try:
for model_class, fields in batch:
instance = model_class(**fields)
session.add(instance)
await session.commit()
success_count += len(batch)
except Exception as e:
await log.error(f"批量导入失败: {e}")
error_count += len(batch)
await session.rollback()
batch = []
progress = round(idx / total_records * 100, 1)
await log.info(f"进度: {progress}% ({idx}/{total_records}) - 成功: {success_count}, 失败: {error_count}, 跳过: {skipped_count}")
# 提交剩余的数据
if batch:
async with AsyncSessionLocal() as session:
try:
for model_class, fields in batch:
instance = model_class(**fields)
session.add(instance)
await session.commit()
success_count += len(batch)
except Exception as e:
await log.error(f"最后批次导入失败: {e}")
error_count += len(batch)
await session.rollback()
result = f"恢复完成: 成功 {success_count} 条, 失败 {error_count} 条, 跳过 {skipped_count}"
await log.info(f"{result}")
return result
except Exception as e:
await log.error(f"数据库恢复任务执行失败: {str(e)}")
raise
async def backup_database_task(
job_code: str = None,
output_path: str = None,
app_name: str = None,
exclude_tables: list = None,
**kwargs
):
"""
数据库数据备份任务
将数据库数据导出到 JSON 文件,类似 Django 的 dumpdata 命令。
Args:
job_code: 任务编码(由调度器自动传入)
output_path: 输出文件路径(可选),默认为 backups/backup_YYYYMMDD_HHMMSS.json
app_name: 应用名称过滤(可选),如 core、scheduler,只备份指定应用的数据
exclude_tables: 排除的表名列表(可选),如 ['scheduler_log', 'core_operation_log']
**kwargs: 其他参数
Returns:
str: 备份结果摘要,包含文件路径和记录数
"""
exclude_tables = exclude_tables or []
import json
from pathlib import Path
from decimal import Decimal
from datetime import date
from sqlalchemy import inspect
from app.database import AsyncSessionLocal, Base
logger.info(f"[{job_code}] 数据库备份任务开始执行")
class DateTimeEncoder(json.JSONEncoder):
"""自定义 JSON 编码器"""
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
def auto_import_models():
"""自动导入所有模型"""
import importlib
project_root = Path(__file__).parent.parent
scan_dirs = ["zq_demo", "core", "scheduler", "online_dev", "ai_platform"]
for scan_dir in scan_dirs:
scan_path = project_root / scan_dir
if not scan_path.exists():
continue
for model_file in scan_path.rglob("*model.py"):
relative_path = model_file.relative_to(project_root)
module_path = str(relative_path.with_suffix("")).replace("/", ".").replace("\\", ".")
try:
importlib.import_module(module_path)
except ImportError as e:
logger.warning(f"[{job_code}] 导入模型失败 {module_path}: {e}")
try:
# 自动导入所有模型
auto_import_models()
# 确定输出路径
if output_path:
output_file = Path(output_path)
else:
project_root = Path(__file__).parent.parent
backups_dir = project_root / "backups"
backups_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"backup_{app_name}_{timestamp}.json" if app_name else f"backup_{timestamp}.json"
output_file = backups_dir / filename
# 确保输出目录存在
output_file.parent.mkdir(parents=True, exist_ok=True)
all_data = []
async with AsyncSessionLocal() as session:
# 获取所有模型
models = []
for mapper in Base.registry.mappers:
model_class = mapper.class_
# 如果指定了 app_name,只导出该应用的模型
if app_name:
module_name = model_class.__module__
if not module_name.startswith(app_name):
continue
# 排除指定的表
if model_class.__tablename__ in exclude_tables:
logger.info(f"[{job_code}] 跳过表: {model_class.__tablename__}")
continue
models.append(model_class)
# 按表名排序
models.sort(key=lambda m: m.__tablename__)
logger.info(f"[{job_code}] 准备备份 {len(models)} 个表")
# 导出每个表
from sqlalchemy import select
for model_class in models:
logger.info(f"[{job_code}] 备份表: {model_class.__tablename__}")
result = await session.execute(select(model_class))
items = result.scalars().all()
for item in items:
item_dict = {}
for column in inspect(model_class).columns:
value = getattr(item, column.name)
item_dict[column.name] = value
all_data.append({
"model": f"{model_class.__module__}.{model_class.__name__}",
"pk": item.id if hasattr(item, 'id') else None,
"fields": item_dict
})
logger.info(f"[{job_code}] - 备份 {len(items)} 条记录")
# 写入文件
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(all_data, f, ensure_ascii=False, indent=2, cls=DateTimeEncoder)
result = f"备份完成: 共 {len(all_data)} 条记录,保存到 {output_file}"
logger.info(f"[{job_code}] {result}")
return result
except Exception as e:
logger.error(f"[{job_code}] 数据库备份任务执行失败: {str(e)}")
raise
@@ -0,0 +1,98 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
工作流执行任务 - 供定时任务调度器调用
"""
import logging
logger = logging.getLogger(__name__)
async def execute_workflow_task(
job_code: str = None,
workflow_code: str = None,
inputs: dict = None,
**kwargs
):
"""
执行工作流任务
在定时任务中调用已发布的数据处理/自动化工作流。
Args:
job_code: 任务编码(由调度器自动传入)
workflow_code: 要执行的工作流编码(必填)
inputs: 工作流输入变量
**kwargs: 其他参数(包含 task_logger
Returns:
dict: 执行结果
"""
if not workflow_code:
raise ValueError("workflow_code 参数不能为空,请在 task_kwargs 中配置")
inputs = inputs or {}
inputs['job_code'] = job_code
from app.database import AsyncSessionLocal
from ai_platform.services.workflow_service import AIWorkflowService
from ai_platform.models.workflow import AIWorkflow
from sqlalchemy import select
from scheduler.task_utils import TaskLoggerWrapper
log = TaskLoggerWrapper(job_code, kwargs.get('task_logger'))
await log.info(f"开始执行工作流: {workflow_code}")
async with AsyncSessionLocal() as db:
result = await db.execute(
select(AIWorkflow).where(
AIWorkflow.code == workflow_code,
AIWorkflow.is_deleted == False
)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise ValueError(f"工作流不存在: {workflow_code}")
if workflow.workflow_type not in ('data_process', 'automation'):
raise ValueError(
f"工作流类型不支持定时执行: {workflow.workflow_type}"
f"仅支持 data_process 和 automation 类型"
)
if workflow.status != 'published':
raise ValueError(f"工作流未发布: {workflow_code}")
await log.info(
f"工作流类型: {workflow.workflow_type}, "
f"工作流名称: {workflow.name}, "
f"输入变量: {list(inputs.keys())}"
)
service = AIWorkflowService(db)
run = await service.run_workflow(
workflow_id=str(workflow.id),
inputs=inputs,
trigger_type='api',
)
await log.info(
f"工作流执行完成: status={run.status}, "
f"elapsed_time={run.elapsed_time}ms, "
f"total_steps={run.total_steps}"
)
if run.status == 'failed':
raise RuntimeError(f"工作流执行失败: {run.error_message}")
return {
'run_id': str(run.id),
'workflow_code': workflow_code,
'workflow_name': workflow.name,
'status': run.status,
'elapsed_time': run.elapsed_time,
'total_steps': run.total_steps,
'total_tokens': run.total_tokens,
'outputs': run.outputs,
}