Files
000-platform/backend/app/routers/tasks.py
Admin 104487f082
All checks were successful
continuous-integration/drone/push Build is passing
feat: 实现定时任务系统
- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表
- 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能
- 实现安全的脚本执行器,支持沙箱环境和禁止危险操作
- 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式
- 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理
- 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
2026-01-28 16:38:19 +08:00

541 lines
19 KiB
Python

"""定时任务API路由"""
import json
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.scheduled_task import ScheduledTask, TaskLog, Secret
from ..services.scheduler import scheduler_service
from ..services.script_executor import ScriptExecutor
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
# ==================== Schemas ====================
class TaskCreate(BaseModel):
tenant_id: Optional[str] = None
task_name: str
task_type: str = 'script'
schedule_type: str = 'simple'
time_points: Optional[List[str]] = None
cron_expression: Optional[str] = None
webhook_url: Optional[str] = None
webhook_method: Optional[str] = 'POST'
webhook_headers: Optional[dict] = None
script_content: Optional[str] = None
script_timeout: Optional[int] = 300
input_params: Optional[dict] = None
retry_count: Optional[int] = 0
retry_interval: Optional[int] = 60
alert_on_failure: Optional[bool] = False
alert_webhook: Optional[str] = None
class TaskUpdate(BaseModel):
tenant_id: Optional[str] = None
task_name: Optional[str] = None
task_type: Optional[str] = None
schedule_type: Optional[str] = None
time_points: Optional[List[str]] = None
cron_expression: Optional[str] = None
webhook_url: Optional[str] = None
webhook_method: Optional[str] = None
webhook_headers: Optional[dict] = None
script_content: Optional[str] = None
script_timeout: Optional[int] = None
input_params: Optional[dict] = None
retry_count: Optional[int] = None
retry_interval: Optional[int] = None
alert_on_failure: Optional[bool] = None
alert_webhook: Optional[str] = None
status: Optional[int] = None
class SecretCreate(BaseModel):
tenant_id: Optional[str] = None
secret_key: str
secret_value: str
description: Optional[str] = None
class SecretUpdate(BaseModel):
secret_value: Optional[str] = None
description: Optional[str] = None
class TestScriptRequest(BaseModel):
script_content: str
tenant_id: Optional[str] = None
params: Optional[dict] = None
# ==================== Task CRUD ====================
@router.get("")
async def list_tasks(
tenant_id: Optional[str] = None,
status: Optional[int] = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取任务列表"""
query = db.query(ScheduledTask)
if tenant_id:
query = query.filter(ScheduledTask.tenant_id == tenant_id)
if status is not None:
query = query.filter(ScheduledTask.status == status)
total = query.count()
items = query.order_by(desc(ScheduledTask.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"items": [format_task(t) for t in items]
}
@router.get("/{task_id}")
async def get_task(task_id: int, db: Session = Depends(get_db)):
"""获取任务详情"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return format_task(task, include_content=True)
@router.post("")
async def create_task(data: TaskCreate, db: Session = Depends(get_db)):
"""创建任务"""
task = ScheduledTask(
tenant_id=data.tenant_id,
task_name=data.task_name,
task_type=data.task_type,
schedule_type=data.schedule_type,
time_points=json.dumps(data.time_points) if data.time_points else None,
cron_expression=data.cron_expression,
webhook_url=data.webhook_url,
webhook_method=data.webhook_method,
webhook_headers=json.dumps(data.webhook_headers) if data.webhook_headers else None,
script_content=data.script_content,
script_timeout=data.script_timeout,
input_params=json.dumps(data.input_params) if data.input_params else None,
retry_count=data.retry_count,
retry_interval=data.retry_interval,
alert_on_failure=1 if data.alert_on_failure else 0,
alert_webhook=data.alert_webhook,
status=1
)
db.add(task)
db.commit()
db.refresh(task)
# 添加到调度器
scheduler_service.add_task(task.id)
return {"success": True, "id": task.id}
@router.put("/{task_id}")
async def update_task(task_id: int, data: TaskUpdate, db: Session = Depends(get_db)):
"""更新任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 更新字段
if data.tenant_id is not None:
task.tenant_id = data.tenant_id
if data.task_name is not None:
task.task_name = data.task_name
if data.task_type is not None:
task.task_type = data.task_type
if data.schedule_type is not None:
task.schedule_type = data.schedule_type
if data.time_points is not None:
task.time_points = json.dumps(data.time_points)
if data.cron_expression is not None:
task.cron_expression = data.cron_expression
if data.webhook_url is not None:
task.webhook_url = data.webhook_url
if data.webhook_method is not None:
task.webhook_method = data.webhook_method
if data.webhook_headers is not None:
task.webhook_headers = json.dumps(data.webhook_headers)
if data.script_content is not None:
task.script_content = data.script_content
if data.script_timeout is not None:
task.script_timeout = data.script_timeout
if data.input_params is not None:
task.input_params = json.dumps(data.input_params)
if data.retry_count is not None:
task.retry_count = data.retry_count
if data.retry_interval is not None:
task.retry_interval = data.retry_interval
if data.alert_on_failure is not None:
task.alert_on_failure = 1 if data.alert_on_failure else 0
if data.alert_webhook is not None:
task.alert_webhook = data.alert_webhook
if data.status is not None:
task.status = data.status
db.commit()
# 更新调度器
if task.status == 1:
scheduler_service.add_task(task.id)
else:
scheduler_service.remove_task(task.id)
return {"success": True}
@router.delete("/{task_id}")
async def delete_task(task_id: int, db: Session = Depends(get_db)):
"""删除任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 从调度器移除
scheduler_service.remove_task(task_id)
# 删除相关日志
db.query(TaskLog).filter(TaskLog.task_id == task_id).delete()
db.delete(task)
db.commit()
return {"success": True}
@router.post("/{task_id}/toggle")
async def toggle_task(task_id: int, db: Session = Depends(get_db)):
"""启用/禁用任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task.status = 0 if task.status == 1 else 1
db.commit()
if task.status == 1:
scheduler_service.add_task(task.id)
else:
scheduler_service.remove_task(task.id)
return {"success": True, "status": task.status}
@router.post("/{task_id}/run")
async def run_task(task_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
"""立即执行任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
result = await scheduler_service.run_task_now(task_id)
return result
# ==================== Task Logs ====================
@router.get("/{task_id}/logs")
async def get_task_logs(
task_id: int,
status: Optional[str] = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取任务执行日志"""
query = db.query(TaskLog).filter(TaskLog.task_id == task_id)
if status:
query = query.filter(TaskLog.status == status)
total = query.count()
items = query.order_by(desc(TaskLog.started_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"items": [format_log(log) for log in items]
}
# ==================== Script Testing ====================
@router.post("/test-script")
async def test_script(data: TestScriptRequest, db: Session = Depends(get_db)):
"""测试脚本执行"""
executor = ScriptExecutor(db)
result = executor.test_script(
script_content=data.script_content,
task_id=0,
tenant_id=data.tenant_id,
params=data.params
)
return result
# ==================== SDK Documentation ====================
@router.get("/sdk-docs")
async def get_sdk_docs():
"""获取SDK文档"""
return {
"functions": [
{
"name": "log",
"signature": "log(message: str, level: str = 'INFO')",
"description": "记录日志",
"example": "log('处理完成', 'INFO')"
},
{
"name": "print",
"signature": "print(*args)",
"description": "打印输出",
"example": "print('Hello', 'World')"
},
{
"name": "ai",
"signature": "ai(prompt: str, system: str = None, model: str = None, temperature: float = 0.7)",
"description": "调用AI模型",
"example": "result = ai('生成一段问候语', system='你是友善的助手')"
},
{
"name": "dingtalk",
"signature": "dingtalk(webhook: str, content: str, title: str = None, at_all: bool = False)",
"description": "发送钉钉消息",
"example": "dingtalk(webhook_url, '# 标题\\n内容')"
},
{
"name": "wecom",
"signature": "wecom(webhook: str, content: str, msg_type: str = 'markdown')",
"description": "发送企微消息",
"example": "wecom(webhook_url, '消息内容')"
},
{
"name": "http_get",
"signature": "http_get(url: str, headers: dict = None, params: dict = None)",
"description": "发起GET请求",
"example": "resp = http_get('https://api.example.com/data')"
},
{
"name": "http_post",
"signature": "http_post(url: str, data: any = None, headers: dict = None)",
"description": "发起POST请求",
"example": "resp = http_post('https://api.example.com/submit', {'key': 'value'})"
},
{
"name": "db_query",
"signature": "db_query(sql: str, params: dict = None)",
"description": "执行只读SQL查询",
"example": "rows = db_query('SELECT * FROM users WHERE status = :status', {'status': 1})"
},
{
"name": "get_var",
"signature": "get_var(key: str, default: any = None)",
"description": "获取持久化变量",
"example": "counter = get_var('counter', 0)"
},
{
"name": "set_var",
"signature": "set_var(key: str, value: any)",
"description": "设置持久化变量",
"example": "set_var('counter', counter + 1)"
},
{
"name": "del_var",
"signature": "del_var(key: str)",
"description": "删除持久化变量",
"example": "del_var('temp_data')"
},
{
"name": "get_param",
"signature": "get_param(key: str, default: any = None)",
"description": "获取任务参数",
"example": "prompt = get_param('prompt', '默认提示词')"
},
{
"name": "get_params",
"signature": "get_params()",
"description": "获取所有任务参数",
"example": "params = get_params()"
},
{
"name": "get_tenants",
"signature": "get_tenants(app_code: str = None)",
"description": "获取租户列表",
"example": "tenants = get_tenants('notification-service')"
},
{
"name": "get_tenant_config",
"signature": "get_tenant_config(tenant_id: str, app_code: str, key: str = None)",
"description": "获取租户的应用配置",
"example": "webhook = get_tenant_config('tenant1', 'notification-service', 'dingtalk_webhook')"
},
{
"name": "get_all_tenant_configs",
"signature": "get_all_tenant_configs(app_code: str)",
"description": "获取所有租户的应用配置",
"example": "configs = get_all_tenant_configs('notification-service')"
},
{
"name": "get_secret",
"signature": "get_secret(key: str)",
"description": "获取密钥(优先租户级)",
"example": "api_key = get_secret('api_key')"
}
],
"variables": [
{"name": "task_id", "description": "当前任务ID"},
{"name": "tenant_id", "description": "当前租户ID"},
{"name": "trace_id", "description": "当前执行追踪ID"}
],
"libraries": [
{"name": "json", "description": "JSON处理"},
{"name": "re", "description": "正则表达式"},
{"name": "math", "description": "数学函数"},
{"name": "random", "description": "随机数"},
{"name": "hashlib", "description": "哈希函数"},
{"name": "base64", "description": "Base64编解码"},
{"name": "datetime", "description": "日期时间处理"},
{"name": "timedelta", "description": "时间差"},
{"name": "urlencode/quote/unquote", "description": "URL编码"}
]
}
# ==================== Secrets ====================
@router.get("/secrets")
async def list_secrets(
tenant_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""获取密钥列表"""
query = db.query(Secret)
if tenant_id:
query = query.filter(Secret.tenant_id == tenant_id)
items = query.order_by(desc(Secret.created_at)).all()
return {
"items": [
{
"id": s.id,
"tenant_id": s.tenant_id,
"secret_key": s.secret_key,
"description": s.description,
"created_at": s.created_at,
"updated_at": s.updated_at
}
for s in items
]
}
@router.post("/secrets")
async def create_secret(data: SecretCreate, db: Session = Depends(get_db)):
"""创建密钥"""
secret = Secret(
tenant_id=data.tenant_id,
secret_key=data.secret_key,
secret_value=data.secret_value,
description=data.description
)
db.add(secret)
db.commit()
db.refresh(secret)
return {"success": True, "id": secret.id}
@router.put("/secrets/{secret_id}")
async def update_secret(secret_id: int, data: SecretUpdate, db: Session = Depends(get_db)):
"""更新密钥"""
secret = db.query(Secret).filter(Secret.id == secret_id).first()
if not secret:
raise HTTPException(status_code=404, detail="密钥不存在")
if data.secret_value is not None:
secret.secret_value = data.secret_value
if data.description is not None:
secret.description = data.description
db.commit()
return {"success": True}
@router.delete("/secrets/{secret_id}")
async def delete_secret(secret_id: int, db: Session = Depends(get_db)):
"""删除密钥"""
secret = db.query(Secret).filter(Secret.id == secret_id).first()
if not secret:
raise HTTPException(status_code=404, detail="密钥不存在")
db.delete(secret)
db.commit()
return {"success": True}
# ==================== Helpers ====================
def format_task(task: ScheduledTask, include_content: bool = False) -> dict:
"""格式化任务数据"""
data = {
"id": task.id,
"tenant_id": task.tenant_id,
"task_name": task.task_name,
"task_type": task.task_type,
"schedule_type": task.schedule_type,
"time_points": json.loads(task.time_points) if task.time_points else [],
"cron_expression": task.cron_expression,
"status": task.status,
"last_run_at": task.last_run_at,
"last_run_status": task.last_run_status,
"retry_count": task.retry_count,
"retry_interval": task.retry_interval,
"alert_on_failure": bool(task.alert_on_failure),
"alert_webhook": task.alert_webhook,
"created_at": task.created_at,
"updated_at": task.updated_at
}
if include_content:
data["webhook_url"] = task.webhook_url
data["webhook_method"] = task.webhook_method
data["webhook_headers"] = json.loads(task.webhook_headers) if task.webhook_headers else None
data["script_content"] = task.script_content
data["script_timeout"] = task.script_timeout
data["input_params"] = json.loads(task.input_params) if task.input_params else None
return data
def format_log(log: TaskLog) -> dict:
"""格式化日志数据"""
return {
"id": log.id,
"task_id": log.task_id,
"tenant_id": log.tenant_id,
"trace_id": log.trace_id,
"status": log.status,
"started_at": log.started_at,
"finished_at": log.finished_at,
"duration_ms": log.duration_ms,
"output": log.output,
"error": log.error,
"retry_count": log.retry_count,
"created_at": log.created_at
}