Files
000-platform/backend/app/routers/tasks.py
Admin 644255891e
Some checks failed
continuous-integration/drone/push Build is failing
feat: 脚本执行平台功能
- 支持 Python 脚本定时执行(类似青龙面板)
- 内置 SDK:AI大模型、钉钉/企微通知、数据库查询、HTTP请求、变量存储
- 安全沙箱执行,禁用危险模块
- 前端脚本编辑器,支持测试执行
- SDK 文档查看
- 日志通过 TraceID 与 platform_logs 关联
2026-01-28 11:45:02 +08:00

475 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""定时任务管理路由"""
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..database import get_db
from .auth import get_current_user, require_operator
from ..models.user import User
from ..services.scheduler import (
add_task_to_scheduler,
remove_task_from_scheduler,
reload_task,
execute_task
)
router = APIRouter(prefix="/scheduled-tasks", tags=["定时任务"])
# Schemas
class TaskCreate(BaseModel):
tenant_id: str
task_name: str
task_desc: Optional[str] = None
schedule_type: str = "simple" # simple | cron
time_points: Optional[List[str]] = None # ["09:00", "14:00"]
cron_expression: Optional[str] = None # "0 9,14 * * *"
execution_type: str = "webhook" # webhook | script
webhook_url: Optional[str] = None
input_params: Optional[dict] = None
script_content: Optional[str] = None
is_enabled: bool = True
class TaskUpdate(BaseModel):
task_name: Optional[str] = None
task_desc: Optional[str] = None
schedule_type: Optional[str] = None
time_points: Optional[List[str]] = None
cron_expression: Optional[str] = None
execution_type: Optional[str] = None
webhook_url: Optional[str] = None
input_params: Optional[dict] = None
script_content: Optional[str] = None
class ScriptTestRequest(BaseModel):
tenant_id: str
script_content: str
# API Endpoints
@router.get("")
async def list_tasks(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取定时任务列表"""
# 构建查询
where_clauses = []
params = {}
if tenant_id:
where_clauses.append("tenant_id = :tenant_id")
params["tenant_id"] = tenant_id
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# 查询总数
count_result = db.execute(
text(f"SELECT COUNT(*) FROM platform_scheduled_tasks WHERE {where_sql}"),
params
)
total = count_result.scalar()
# 查询列表
params["offset"] = (page - 1) * size
params["limit"] = size
result = db.execute(
text(f"""
SELECT t.*, tn.name as tenant_name
FROM platform_scheduled_tasks t
LEFT JOIN platform_tenants tn ON t.tenant_id = tn.code
WHERE {where_sql}
ORDER BY t.id DESC
LIMIT :limit OFFSET :offset
"""),
params
)
tasks = [dict(row) for row in result.mappings().all()]
return {
"total": total,
"page": page,
"size": size,
"items": tasks
}
@router.post("")
async def create_task(
data: TaskCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建定时任务"""
# 验证调度配置
if data.schedule_type == "simple":
if not data.time_points or len(data.time_points) == 0:
raise HTTPException(status_code=400, detail="简单模式需要至少一个时间点")
elif data.schedule_type == "cron":
if not data.cron_expression:
raise HTTPException(status_code=400, detail="CRON模式需要提供表达式")
# 验证执行配置
if data.execution_type == "webhook":
if not data.webhook_url:
raise HTTPException(status_code=400, detail="Webhook模式需要提供URL")
elif data.execution_type == "script":
if not data.script_content:
raise HTTPException(status_code=400, detail="脚本模式需要提供脚本内容")
# 插入数据库
import json
time_points_json = json.dumps(data.time_points) if data.time_points else None
input_params_json = json.dumps(data.input_params) if data.input_params else None
db.execute(
text("""
INSERT INTO platform_scheduled_tasks
(tenant_id, task_name, task_desc, schedule_type, time_points,
cron_expression, execution_type, webhook_url, input_params, script_content, is_enabled)
VALUES (:tenant_id, :task_name, :task_desc, :schedule_type, :time_points,
:cron_expression, :execution_type, :webhook_url, :input_params, :script_content, :is_enabled)
"""),
{
"tenant_id": data.tenant_id,
"task_name": data.task_name,
"task_desc": data.task_desc,
"schedule_type": data.schedule_type,
"time_points": time_points_json,
"cron_expression": data.cron_expression,
"execution_type": data.execution_type,
"webhook_url": data.webhook_url,
"input_params": input_params_json,
"script_content": data.script_content,
"is_enabled": 1 if data.is_enabled else 0
}
)
db.commit()
# 获取新插入的ID
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
task_id = result.scalar()
# 如果启用,添加到调度器
if data.is_enabled:
reload_task(task_id)
return {"id": task_id, "message": "创建成功"}
@router.get("/{task_id}")
async def get_task(
task_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取任务详情"""
result = db.execute(
text("""
SELECT t.*, tn.name as tenant_name
FROM platform_scheduled_tasks t
LEFT JOIN platform_tenants tn ON t.tenant_id = tn.code
WHERE t.id = :id
"""),
{"id": task_id}
)
task = result.mappings().first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return dict(task)
@router.put("/{task_id}")
async def update_task(
task_id: int,
data: TaskUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新定时任务"""
# 检查任务是否存在
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
task = result.mappings().first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 构建更新语句
import json
updates = []
params = {"id": task_id}
if data.task_name is not None:
updates.append("task_name = :task_name")
params["task_name"] = data.task_name
if data.task_desc is not None:
updates.append("task_desc = :task_desc")
params["task_desc"] = data.task_desc
if data.schedule_type is not None:
updates.append("schedule_type = :schedule_type")
params["schedule_type"] = data.schedule_type
if data.time_points is not None:
updates.append("time_points = :time_points")
params["time_points"] = json.dumps(data.time_points)
if data.cron_expression is not None:
updates.append("cron_expression = :cron_expression")
params["cron_expression"] = data.cron_expression
if data.execution_type is not None:
updates.append("execution_type = :execution_type")
params["execution_type"] = data.execution_type
if data.webhook_url is not None:
updates.append("webhook_url = :webhook_url")
params["webhook_url"] = data.webhook_url
if data.script_content is not None:
updates.append("script_content = :script_content")
params["script_content"] = data.script_content
if data.input_params is not None:
updates.append("input_params = :input_params")
params["input_params"] = json.dumps(data.input_params)
if updates:
db.execute(
text(f"UPDATE platform_scheduled_tasks SET {', '.join(updates)} WHERE id = :id"),
params
)
db.commit()
# 重新加载调度器中的任务
reload_task(task_id)
return {"message": "更新成功"}
@router.delete("/{task_id}")
async def delete_task(
task_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除定时任务"""
# 检查任务是否存在
result = db.execute(
text("SELECT id FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
if not result.scalar():
raise HTTPException(status_code=404, detail="任务不存在")
# 从调度器移除
remove_task_from_scheduler(task_id)
# 删除日志
db.execute(
text("DELETE FROM platform_task_logs WHERE task_id = :id"),
{"id": task_id}
)
# 删除任务
db.execute(
text("DELETE FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
db.commit()
return {"message": "删除成功"}
@router.post("/{task_id}/toggle")
async def toggle_task(
task_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""启用/禁用任务"""
# 获取当前状态
result = db.execute(
text("SELECT is_enabled FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="任务不存在")
current_enabled = row[0]
new_enabled = 0 if current_enabled else 1
# 更新状态
db.execute(
text("UPDATE platform_scheduled_tasks SET is_enabled = :enabled WHERE id = :id"),
{"id": task_id, "enabled": new_enabled}
)
db.commit()
# 更新调度器
reload_task(task_id)
return {
"is_enabled": bool(new_enabled),
"message": "已启用" if new_enabled else "已禁用"
}
@router.post("/{task_id}/run")
async def run_task_now(
task_id: int,
background_tasks: BackgroundTasks,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""手动执行任务"""
# 检查任务是否存在
result = db.execute(
text("SELECT id FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
if not result.scalar():
raise HTTPException(status_code=404, detail="任务不存在")
# 在后台执行任务
background_tasks.add_task(asyncio.create_task, execute_task(task_id))
return {"message": "任务已触发执行"}
@router.get("/{task_id}/logs")
async def get_task_logs(
task_id: int,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取任务执行日志"""
# 查询总数
count_result = db.execute(
text("SELECT COUNT(*) FROM platform_task_logs WHERE task_id = :task_id"),
{"task_id": task_id}
)
total = count_result.scalar()
# 查询日志
result = db.execute(
text("""
SELECT * FROM platform_task_logs
WHERE task_id = :task_id
ORDER BY id DESC
LIMIT :limit OFFSET :offset
"""),
{"task_id": task_id, "limit": size, "offset": (page - 1) * size}
)
logs = [dict(row) for row in result.mappings().all()]
return {
"total": total,
"page": page,
"size": size,
"items": logs
}
@router.post("/test-script")
async def test_script(
data: ScriptTestRequest,
user: User = Depends(require_operator)
):
"""测试执行脚本(不记录日志)"""
from ..services.script_executor import test_script as run_test
if not data.script_content or not data.script_content.strip():
raise HTTPException(status_code=400, detail="脚本内容不能为空")
result = await run_test(
tenant_id=data.tenant_id,
script_content=data.script_content
)
return result.to_dict()
@router.get("/sdk-docs")
async def get_sdk_docs():
"""获取 SDK 文档"""
return {
"description": "脚本执行 SDK 文档",
"methods": [
{
"name": "ai(prompt, system=None, model='gemini-2.5-flash')",
"description": "调用大模型生成内容",
"example": "result = ai('帮我写一段营销文案')"
},
{
"name": "dingtalk(webhook, content, msg_type='text', at_mobiles=None)",
"description": "发送钉钉群消息",
"example": "dingtalk('https://oapi.dingtalk.com/robot/send?access_token=xxx', '消息内容')"
},
{
"name": "wecom(webhook, content, msg_type='text')",
"description": "发送企业微信群消息",
"example": "wecom('https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx', '消息内容')"
},
{
"name": "db(sql, params=None)",
"description": "执行 SQL 查询(仅支持 SELECT",
"example": "rows = db('SELECT * FROM users WHERE id = :id', {'id': 1})"
},
{
"name": "http_get(url, headers=None, params=None)",
"description": "发送 HTTP GET 请求",
"example": "data = http_get('https://api.example.com/data')"
},
{
"name": "http_post(url, data=None, json_data=None, headers=None)",
"description": "发送 HTTP POST 请求",
"example": "data = http_post('https://api.example.com/submit', json_data={'key': 'value'})"
},
{
"name": "get_var(key, default=None)",
"description": "获取存储的变量(跨执行持久化)",
"example": "count = get_var('run_count', 0)"
},
{
"name": "set_var(key, value)",
"description": "存储变量(跨执行持久化)",
"example": "set_var('run_count', count + 1)"
},
{
"name": "log(message, level='INFO')",
"description": "记录日志",
"example": "log('任务执行完成')"
}
],
"example_script": '''# 示例:每日推送 AI 生成的内容到钉钉
import json
# 获取历史数据
history = get_var('history', [])
# 调用 AI 生成内容
prompt = f"根据以下信息生成今日营销文案:{json.dumps(history[-5:], ensure_ascii=False)}"
content = ai(prompt, system="你是一个专业的营销文案专家")
# 发送到钉钉
dingtalk(
webhook="你的钉钉机器人Webhook",
content=content
)
# 记录日志
log(f"已发送: {content[:50]}...")
'''
}