fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由

This commit is contained in:
qyyfw committed 2026-07-11 11:40:39 +08:00
1 parent d95b847fee
commit e8e5ffa1a9
65 files changed
+35624 -339

No files matched your search

@@ -0,0 +1,662 @@
"""
自动化任务调度管理 API
Phase 3.2: 支持定时任务(cron)和条件触发(webhook/event)的自动化调度
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Body, BackgroundTasks
from sqlalchemy import or_, func, desc, asc, text
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
import uuid
import json
import logging
import asyncio
from ...database import get_db
from ...models import (
gen_uuid,
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
router = APIRouter()
logger = logging.getLogger("aiflow")
# ============================================================
# Pydantic Models
# ============================================================
class AutomationTaskCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: Optional[str] = ""
trigger_type: str = Field(default="cron", description="cron / webhook / event")
cron_expression: Optional[str] = None # e.g. "*/5 * * * *"
event_condition: Optional[Dict[str, Any]] = None # for event/webhook triggers
action_type: str = Field(default="ai_pipeline", description="ai_pipeline / webhook / notification / skill_execute")
action_config: Dict[str, Any] = Field(default_factory=dict)
is_enabled: bool = True
max_retries: int = Field(default=3, ge=0, le=10)
timeout_seconds: int = Field(default=300, ge=10, le=3600)
class AutomationTaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
trigger_type: Optional[str] = None
cron_expression: Optional[str] = None
event_condition: Optional[Dict[str, Any]] = None
action_type: Optional[str] = None
action_config: Optional[Dict[str, Any]] = None
is_enabled: Optional[bool] = None
max_retries: Optional[int] = Field(None, ge=0, le=10)
timeout_seconds: Optional[int] = Field(None, ge=10, le=3600)
# ============================================================
# Helper Functions
# ============================================================
TRIGGER_TYPE_MAP = {
"cron": "定时触发",
"webhook": "Webhook",
"event": "事件触发",
}
ACTION_TYPE_MAP = {
"ai_pipeline": "AI流水线",
"webhook": "Webhook回调",
"notification": "发送通知",
"skill_execute": "技能执行",
}
def task_to_dict(task) -> dict:
"""将AutomatedTask模型转换为字典"""
result = {
"id": task.id,
"name": task.name,
"description": getattr(task, "description", "") or "",
"trigger_type": task.trigger_type,
"trigger_type_label": TRIGGER_TYPE_MAP.get(task.trigger_type, task.trigger_type),
"cron_expression": getattr(task, "cron_expression", "") or "",
"action_type": task.action_type,
"action_type_label": ACTION_TYPE_MAP.get(task.action_type, task.action_type),
"is_enabled": task.is_enabled,
"status": task.status or "idle",
"max_retries": getattr(task, "max_retries", 3),
"timeout_seconds": getattr(task, "timeout_seconds", 300),
"last_run_at": task.last_run_at.isoformat() if task.last_run_at else None,
"next_run_at": task.next_run_at.isoformat() if task.next_run_at else None,
"total_runs": getattr(task, "total_runs", 0),
"success_runs": getattr(task, "success_runs", 0),
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
# Action config
action_config = getattr(task, "action_config", None)
if action_config:
try:
result["action_config"] = json.loads(action_config) if isinstance(action_config, str) else action_config
except Exception:
result["action_config"] = {}
else:
result["action_config"] = {}
# Event condition
event_condition = getattr(task, "event_condition", None)
if event_condition:
try:
result["event_condition"] = json.loads(event_condition) if isinstance(event_condition, str) else event_condition
except Exception:
result["event_condition"] = {}
else:
result["event_condition"] = {}
return result
def log_to_dict(log) -> dict:
"""将TaskExecutionLog转换为字典"""
return {
"id": log.id,
"task_id": log.task_id,
"task_name": getattr(log, "task_name", ""),
"status": log.status,
"trigger_type": getattr(log, "trigger_type", ""),
"started_at": log.started_at.isoformat() if log.started_at else None,
"finished_at": log.finished_at.isoformat() if log.finished_at else None,
"duration_ms": getattr(log, "duration_ms", 0),
"result_summary": getattr(log, "result_summary", "") or "",
"error_message": getattr(log, "error_message", "") or "",
"created_at": log.created_at.isoformat() if log.created_at else None,
}
def calculate_next_run(cron_expression: str) -> Optional[datetime]:
"""计算cron表达式的下次执行时间(简化版)"""
if not cron_expression:
return None
parts = cron_expression.strip().split()
if len(parts) != 5:
return None
now = datetime.now()
try:
minute, hour, day, month, weekday = parts
# Handle wildcards
next_run = now.replace(second=0, microsecond=0)
if minute != "*":
target_min = int(minute)
if target_min > now.minute:
next_run = next_run.replace(minute=target_min)
else:
next_run = next_run.replace(minute=target_min) + timedelta(hours=1)
if hour != "*":
target_hour = int(hour)
if target_hour < next_run.hour or (target_hour == next_run.hour and next_run.minute <= now.minute):
next_run = next_run.replace(hour=target_hour, minute=int(minute) if minute != "*" else 0) + timedelta(days=1)
else:
next_run = next_run.replace(hour=target_hour)
# Handle */N patterns for minute
if minute.startswith("*/"):
interval = int(minute[2:])
next_minute = ((now.minute // interval) + 1) * interval
if next_minute >= 60:
next_run = next_run.replace(minute=0) + timedelta(hours=1)
else:
next_run = next_run.replace(minute=next_minute)
if hour != "*":
next_run = next_run.replace(hour=int(hour))
# Handle */N patterns for hour
if hour.startswith("*/"):
interval = int(hour[2:])
next_hour = ((now.hour // interval) + 1) * interval
if next_hour >= 24:
next_run = next_run.replace(hour=0, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0) + timedelta(days=1)
else:
next_run = next_run.replace(hour=next_hour, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0)
if next_run <= now:
next_run += timedelta(minutes=1)
return next_run
except Exception as e:
logger.warning(f"Failed to calculate next run for '{cron_expression}': {e}")
return None
# ============================================================
# API Endpoints
# ============================================================
@router.get("/stats/overview", summary="自动化任务统计概览")
async def get_automation_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取自动化任务的统计信息"""
from ...models import AutomatedTask, TaskExecutionLog
total_tasks = db.query(func.count(AutomatedTask.id)).scalar() or 0
enabled_tasks = db.query(func.count(AutomatedTask.id)).filter(
AutomatedTask.is_enabled == True
).scalar() or 0
running_tasks = db.query(func.count(AutomatedTask.id)).filter(
AutomatedTask.status == "running"
).scalar() or 0
# By trigger type
by_trigger = {}
triggers = db.query(AutomatedTask.trigger_type, func.count(AutomatedTask.id)).group_by(
AutomatedTask.trigger_type
).all()
for t, c in triggers:
by_trigger[t] = c
# Recent execution stats (last 24h)
since = datetime.now() - timedelta(hours=24)
total_executions = db.query(func.count(TaskExecutionLog.id)).filter(
TaskExecutionLog.created_at >= since
).scalar() or 0
success_executions = db.query(func.count(TaskExecutionLog.id)).filter(
TaskExecutionLog.created_at >= since,
TaskExecutionLog.status == "success"
).scalar() or 0
return {
"code": 0,
"data": {
"total_tasks": total_tasks,
"enabled_tasks": enabled_tasks,
"running_tasks": running_tasks,
"by_trigger": by_trigger,
"recent_executions": total_executions,
"recent_success": success_executions,
},
"message": "ok"
}
@router.get("/", summary="获取自动化任务列表")
async def list_automation_tasks(
trigger_type: str = Query(None),
is_enabled: bool = Query(None),
keyword: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取自动化任务列表"""
from ...models import AutomatedTask
query = db.query(AutomatedTask)
if trigger_type:
query = query.filter(AutomatedTask.trigger_type == trigger_type)
if is_enabled is not None:
query = query.filter(AutomatedTask.is_enabled == is_enabled)
if keyword:
keyword_filter = or_(
AutomatedTask.name.ilike(f"%{keyword}%"),
AutomatedTask.description.ilike(f"%{keyword}%"),
)
query = query.filter(keyword_filter)
total = query.count()
items = query.order_by(desc(AutomatedTask.updated_at)) \
.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"items": [task_to_dict(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
},
"message": "ok"
}
@router.get("/{task_id}", summary="获取任务详情")
async def get_automation_task(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取单个自动化任务详情"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return {
"code": 0,
"data": task_to_dict(task),
"message": "ok"
}
@router.post("/", summary="创建自动化任务")
async def create_automation_task(
req: AutomationTaskCreate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建新的自动化任务"""
from ...models import AutomatedTask
# 检查名称重复
existing = db.query(AutomatedTask).filter(AutomatedTask.name == req.name).first()
if existing:
raise HTTPException(status_code=400, detail="任务名称已存在")
# 验证cron表达式格式
if req.trigger_type == "cron" and req.cron_expression:
parts = req.cron_expression.strip().split()
if len(parts) != 5:
raise HTTPException(status_code=400, detail="Cron表达式格式错误,需要5个字段")
next_run = None
if req.trigger_type == "cron" and req.cron_expression:
next_run = calculate_next_run(req.cron_expression)
task = AutomatedTask(
id=gen_uuid(),
name=req.name,
description=req.description or "",
trigger_type=req.trigger_type,
cron_expression=req.cron_expression,
action_type=req.action_type,
action_config=req.action_config,
is_enabled=req.is_enabled,
status="idle",
max_retries=req.max_retries,
timeout_seconds=req.timeout_seconds,
next_run_at=next_run,
event_condition=req.event_condition,
total_runs=0,
success_runs=0,
)
db.add(task)
db.commit()
db.refresh(task)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_automation_task",
target_type="automated_task",
target_id=task.id,
detail=f"创建了自动化任务: {task.name}"
)
return {
"code": 0,
"data": task_to_dict(task),
"message": "创建成功"
}
@router.put("/{task_id}", summary="更新自动化任务")
async def update_automation_task(
task_id: str,
req: AutomationTaskUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新自动化任务"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 名称重复检查
if req.name is not None and req.name != task.name:
existing = db.query(AutomatedTask).filter(
AutomatedTask.name == req.name,
AutomatedTask.id != task_id
).first()
if existing:
raise HTTPException(status_code=400, detail="任务名称已存在")
update_fields = ["name", "description", "trigger_type", "cron_expression",
"action_type", "is_enabled", "max_retries", "timeout_seconds"]
for field in update_fields:
value = getattr(req, field, None)
if value is not None:
setattr(task, field, value)
if req.action_config is not None:
task.action_config = req.action_config
if req.event_condition is not None:
task.event_condition = req.event_condition
# 重新计算下次执行时间
if req.cron_expression is not None or req.trigger_type is not None:
cron = req.cron_expression if req.cron_expression is not None else task.cron_expression
trigger = req.trigger_type if req.trigger_type is not None else task.trigger_type
if trigger == "cron" and cron:
task.next_run_at = calculate_next_run(cron)
task.updated_at = datetime.now()
db.commit()
db.refresh(task)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_automation_task",
target_type="automated_task",
target_id=task.id,
detail=f"更新了自动化任务: {task.name}"
)
return {
"code": 0,
"data": task_to_dict(task),
"message": "更新成功"
}
@router.delete("/{task_id}", summary="删除自动化任务")
async def delete_automation_task(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除自动化任务"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
name = task.name
db.delete(task)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_automation_task",
target_type="automated_task",
target_id=task_id,
detail=f"删除了自动化任务: {name}"
)
return {
"code": 0,
"data": None,
"message": f"已删除任务: {name}"
}
@router.put("/{task_id}/toggle", summary="启用/禁用自动化任务")
async def toggle_automation_task(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""切换任务启用/禁用状态"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task.is_enabled = not task.is_enabled
if not task.is_enabled:
task.next_run_at = None
elif task.trigger_type == "cron" and task.cron_expression:
task.next_run_at = calculate_next_run(task.cron_expression)
task.updated_at = datetime.now()
db.commit()
db.refresh(task)
action = "启用" if task.is_enabled else "禁用"
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="toggle_automation_task",
target_type="automated_task",
target_id=task.id,
detail=f"{action}了自动化任务: {task.name}"
)
return {
"code": 0,
"data": task_to_dict(task),
"message": f"已{action}"
}
@router.post("/{task_id}/execute", summary="手动执行任务")
async def execute_task_now(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""立即手动执行一个自动化任务"""
from ...models import AutomatedTask, TaskExecutionLog
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status == "running":
raise HTTPException(status_code=400, detail="任务正在执行中")
# 创建执行日志
exec_log = TaskExecutionLog(
id=gen_uuid(),
task_id=task.id,
task_name=task.name,
status="running",
trigger_type="manual",
started_at=datetime.now(),
)
db.add(exec_log)
# 更新任务状态
task.status = "running"
task.total_runs = (task.total_runs or 0) + 1
db.commit()
db.refresh(exec_log)
# 异步执行(简化版: 直接模拟执行)
import time
start = time.time()
try:
action_type = task.action_type
action_config = task.action_config or {}
# 模拟执行不同类型的操作
result_summary = ""
if action_type == "ai_pipeline":
result_summary = f"AI流水线执行完成: 处理了 {action_config.get('pipeline_name', 'default')} 管道"
elif action_type == "webhook":
url = action_config.get("url", "")
result_summary = f"Webhook调用完成: {url}"
elif action_type == "notification":
result_summary = f"通知发送完成: {action_config.get('channel', 'system')}"
elif action_type == "skill_execute":
result_summary = f"技能执行完成: {action_config.get('skill_name', 'unknown')}"
else:
result_summary = f"任务执行完成: {action_type}"
duration = int((time.time() - start) * 1000)
# 更新执行日志
exec_log.status = "success"
exec_log.finished_at = datetime.now()
exec_log.duration_ms = duration
exec_log.result_summary = result_summary
# 更新任务
task.status = "idle"
task.last_run_at = datetime.now()
task.success_runs = (task.success_runs or 0) + 1
if task.trigger_type == "cron" and task.cron_expression:
task.next_run_at = calculate_next_run(task.cron_expression)
db.commit()
return {
"code": 0,
"data": log_to_dict(exec_log),
"message": "执行成功"
}
except Exception as e:
duration = int((time.time() - start) * 1000)
exec_log.status = "failed"
exec_log.finished_at = datetime.now()
exec_log.duration_ms = duration
exec_log.error_message = str(e)
task.status = "idle"
db.commit()
raise HTTPException(status_code=500, detail=f"执行失败: {str(e)}")
@router.get("/{task_id}/history", summary="获取任务执行历史")
async def get_task_history(
task_id: str,
status: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取任务的执行历史记录"""
from ...models import TaskExecutionLog
query = db.query(TaskExecutionLog).filter(TaskExecutionLog.task_id == task_id)
if status:
query = query.filter(TaskExecutionLog.status == status)
total = query.count()
items = query.order_by(desc(TaskExecutionLog.created_at)) \
.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"items": [log_to_dict(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
},
"message": "ok"
}
@router.get("/history/all", summary="获取所有执行历史")
async def get_all_history(
status: str = Query(None),
task_id: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取所有任务的执行历史"""
from ...models import TaskExecutionLog
query = db.query(TaskExecutionLog)
if status:
query = query.filter(TaskExecutionLog.status == status)
if task_id:
query = query.filter(TaskExecutionLog.task_id == task_id)
total = query.count()
items = query.order_by(desc(TaskExecutionLog.created_at)) \
.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"items": [log_to_dict(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
},
"message": "ok"
}