feat: 脚本执行平台增强功能
Some checks failed
continuous-integration/drone/push Build is failing

- 新增重试和失败告警功能(支持自动重试N次,失败后钉钉/企微通知)
- 新增密钥管理(安全存储API Key等敏感信息)
- 新增脚本模板库(预置常用脚本模板)
- 新增脚本版本管理(自动保存历史版本,支持回滚)
- 新增执行统计(成功率、平均耗时、7日趋势)
- SDK 新增多租户遍历能力(get_tenants/get_tenant_config/get_all_tenant_configs)
- SDK 新增密钥读取方法(get_secret)
This commit is contained in:
2026-01-28 11:59:50 +08:00
parent 644255891e
commit 9b72e6127f
4 changed files with 1142 additions and 28 deletions

View File

@@ -33,6 +33,11 @@ class TaskCreate(BaseModel):
input_params: Optional[dict] = None
script_content: Optional[str] = None
is_enabled: bool = True
# 重试和告警
retry_count: int = 0
retry_interval: int = 60
alert_on_failure: bool = False
alert_webhook: Optional[str] = None
class TaskUpdate(BaseModel):
@@ -45,6 +50,11 @@ class TaskUpdate(BaseModel):
webhook_url: Optional[str] = None
input_params: Optional[dict] = None
script_content: Optional[str] = None
# 重试和告警
retry_count: Optional[int] = None
retry_interval: Optional[int] = None
alert_on_failure: Optional[bool] = None
alert_webhook: Optional[str] = None
class ScriptTestRequest(BaseModel):
@@ -52,6 +62,36 @@ class ScriptTestRequest(BaseModel):
script_content: str
# 密钥管理
class SecretCreate(BaseModel):
tenant_id: Optional[str] = None # None 表示全局
secret_key: str
secret_value: str
description: Optional[str] = None
class SecretUpdate(BaseModel):
secret_value: Optional[str] = None
description: Optional[str] = None
# 脚本模板
class TemplateCreate(BaseModel):
name: str
description: Optional[str] = None
category: Optional[str] = None
script_content: str
is_public: bool = True
class TemplateUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
script_content: Optional[str] = None
is_public: Optional[bool] = None
# API Endpoints
@router.get("")
@@ -136,9 +176,11 @@ async def create_task(
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)
cron_expression, execution_type, webhook_url, input_params, script_content, is_enabled,
retry_count, retry_interval, alert_on_failure, alert_webhook)
VALUES (:tenant_id, :task_name, :task_desc, :schedule_type, :time_points,
:cron_expression, :execution_type, :webhook_url, :input_params, :script_content, :is_enabled)
:cron_expression, :execution_type, :webhook_url, :input_params, :script_content, :is_enabled,
:retry_count, :retry_interval, :alert_on_failure, :alert_webhook)
"""),
{
"tenant_id": data.tenant_id,
@@ -151,7 +193,11 @@ async def create_task(
"webhook_url": data.webhook_url,
"input_params": input_params_json,
"script_content": data.script_content,
"is_enabled": 1 if data.is_enabled else 0
"is_enabled": 1 if data.is_enabled else 0,
"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
}
)
db.commit()
@@ -240,6 +286,42 @@ async def update_task(
if data.input_params is not None:
updates.append("input_params = :input_params")
params["input_params"] = json.dumps(data.input_params)
if data.retry_count is not None:
updates.append("retry_count = :retry_count")
params["retry_count"] = data.retry_count
if data.retry_interval is not None:
updates.append("retry_interval = :retry_interval")
params["retry_interval"] = data.retry_interval
if data.alert_on_failure is not None:
updates.append("alert_on_failure = :alert_on_failure")
params["alert_on_failure"] = 1 if data.alert_on_failure else 0
if data.alert_webhook is not None:
updates.append("alert_webhook = :alert_webhook")
params["alert_webhook"] = data.alert_webhook
# 如果更新了脚本内容,自动保存版本
if data.script_content is not None and data.script_content.strip():
# 获取当前最大版本号
version_result = db.execute(
text("SELECT COALESCE(MAX(version), 0) FROM platform_script_versions WHERE task_id = :task_id"),
{"task_id": task_id}
)
max_version = version_result.scalar() or 0
new_version = max_version + 1
# 插入新版本
db.execute(
text("""
INSERT INTO platform_script_versions (task_id, version, script_content, created_by)
VALUES (:task_id, :version, :script_content, :created_by)
"""),
{
"task_id": task_id,
"version": new_version,
"script_content": data.script_content,
"created_by": user.username if hasattr(user, 'username') else None
}
)
if updates:
db.execute(
@@ -450,25 +532,415 @@ async def get_sdk_docs():
"name": "log(message, level='INFO')",
"description": "记录日志",
"example": "log('任务执行完成')"
},
{
"name": "get_tenants(app_code=None)",
"description": "获取租户列表(多租户任务遍历)",
"example": "tenants = get_tenants('review-generator')"
},
{
"name": "get_tenant_config(tenant_id, app_code, key=None)",
"description": "获取指定租户的应用配置",
"example": "webhook = get_tenant_config('tenant1', 'my-app', 'dingtalk_webhook')"
},
{
"name": "get_all_tenant_configs(app_code)",
"description": "批量获取所有租户的应用配置",
"example": "all_configs = get_all_tenant_configs('my-app')"
},
{
"name": "get_secret(key)",
"description": "获取密钥(优先租户级,其次全局)",
"example": "api_key = get_secret('openai_api_key')"
}
],
"example_script": '''# 示例:每日推送 AI 生成的内容到钉钉
"example_script": '''# 示例:多租户批量推送
import json
# 获取历史数据
history = get_var('history', [])
# 获取所有订阅了某应用的租户配置
tenants = get_all_tenant_configs('daily-report')
# 调用 AI 生成内容
prompt = f"根据以下信息生成今日营销文案:{json.dumps(history[-5:], ensure_ascii=False)}"
content = ai(prompt, system="你是一个专业的营销文案专家")
# 发送到钉钉
dingtalk(
webhook="你的钉钉机器人Webhook",
content=content
)
# 记录日志
log(f"已发送: {content[:50]}...")
for tenant in tenants:
tenant_id = tenant['tenant_id']
tenant_name = tenant['tenant_name']
configs = tenant['configs']
# 获取该租户的钉钉 Webhook
webhook = configs.get('dingtalk_webhook')
if not webhook:
log(f"租户 {tenant_name} 未配置 webhook跳过")
continue
# 调用 AI 生成内容
content = ai(f"{tenant_name} 生成今日报告", system="你是报告生成专家")
# 发送
dingtalk(webhook=webhook, content=content)
log(f"已发送给租户: {tenant_name}")
'''
}
# ============ 密钥管理 ============
@router.get("/secrets")
async def list_secrets(
tenant_id: Optional[str] = None,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取密钥列表"""
if tenant_id:
result = db.execute(
text("SELECT id, tenant_id, secret_key, description, created_at FROM platform_secrets WHERE tenant_id = :tenant_id OR tenant_id IS NULL ORDER BY tenant_id, secret_key"),
{"tenant_id": tenant_id}
)
else:
result = db.execute(
text("SELECT id, tenant_id, secret_key, description, created_at FROM platform_secrets ORDER BY tenant_id, secret_key")
)
secrets = [dict(row) for row in result.mappings().all()]
return {"items": secrets}
@router.post("/secrets")
async def create_secret(
data: SecretCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建密钥"""
# 检查是否已存在
result = db.execute(
text("SELECT id FROM platform_secrets WHERE tenant_id <=> :tenant_id AND secret_key = :key"),
{"tenant_id": data.tenant_id, "key": data.secret_key}
)
if result.scalar():
raise HTTPException(status_code=400, detail="密钥已存在")
db.execute(
text("""
INSERT INTO platform_secrets (tenant_id, secret_key, secret_value, description)
VALUES (:tenant_id, :key, :value, :desc)
"""),
{
"tenant_id": data.tenant_id,
"key": data.secret_key,
"value": data.secret_value,
"desc": data.description
}
)
db.commit()
return {"message": "创建成功"}
@router.put("/secrets/{secret_id}")
async def update_secret(
secret_id: int,
data: SecretUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新密钥"""
updates = []
params = {"id": secret_id}
if data.secret_value is not None:
updates.append("secret_value = :value")
params["value"] = data.secret_value
if data.description is not None:
updates.append("description = :desc")
params["desc"] = data.description
if updates:
db.execute(
text(f"UPDATE platform_secrets SET {', '.join(updates)} WHERE id = :id"),
params
)
db.commit()
return {"message": "更新成功"}
@router.delete("/secrets/{secret_id}")
async def delete_secret(
secret_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除密钥"""
db.execute(text("DELETE FROM platform_secrets WHERE id = :id"), {"id": secret_id})
db.commit()
return {"message": "删除成功"}
# ============ 脚本模板 ============
@router.get("/templates")
async def list_templates(
category: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模板列表"""
if category:
result = db.execute(
text("SELECT id, name, description, category, is_public, created_by, created_at FROM platform_script_templates WHERE category = :category ORDER BY id DESC"),
{"category": category}
)
else:
result = db.execute(
text("SELECT id, name, description, category, is_public, created_by, created_at FROM platform_script_templates ORDER BY id DESC")
)
templates = [dict(row) for row in result.mappings().all()]
return {"items": templates}
@router.get("/templates/{template_id}")
async def get_template(
template_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模板详情"""
result = db.execute(
text("SELECT * FROM platform_script_templates WHERE id = :id"),
{"id": template_id}
)
template = result.mappings().first()
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return dict(template)
@router.post("/templates")
async def create_template(
data: TemplateCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建模板"""
db.execute(
text("""
INSERT INTO platform_script_templates (name, description, category, script_content, is_public, created_by)
VALUES (:name, :desc, :category, :content, :is_public, :created_by)
"""),
{
"name": data.name,
"desc": data.description,
"category": data.category,
"content": data.script_content,
"is_public": 1 if data.is_public else 0,
"created_by": user.username if hasattr(user, 'username') else None
}
)
db.commit()
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
template_id = result.scalar()
return {"id": template_id, "message": "创建成功"}
@router.put("/templates/{template_id}")
async def update_template(
template_id: int,
data: TemplateUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新模板"""
updates = []
params = {"id": template_id}
if data.name is not None:
updates.append("name = :name")
params["name"] = data.name
if data.description is not None:
updates.append("description = :desc")
params["desc"] = data.description
if data.category is not None:
updates.append("category = :category")
params["category"] = data.category
if data.script_content is not None:
updates.append("script_content = :content")
params["content"] = data.script_content
if data.is_public is not None:
updates.append("is_public = :is_public")
params["is_public"] = 1 if data.is_public else 0
if updates:
db.execute(
text(f"UPDATE platform_script_templates SET {', '.join(updates)} WHERE id = :id"),
params
)
db.commit()
return {"message": "更新成功"}
@router.delete("/templates/{template_id}")
async def delete_template(
template_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除模板"""
db.execute(text("DELETE FROM platform_script_templates WHERE id = :id"), {"id": template_id})
db.commit()
return {"message": "删除成功"}
# ============ 脚本版本管理 ============
@router.get("/{task_id}/versions")
async def list_versions(
task_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取任务的脚本版本列表"""
result = db.execute(
text("SELECT id, version, change_note, created_by, created_at FROM platform_script_versions WHERE task_id = :task_id ORDER BY version DESC"),
{"task_id": task_id}
)
versions = [dict(row) for row in result.mappings().all()]
return {"items": versions}
@router.get("/{task_id}/versions/{version}")
async def get_version(
task_id: int,
version: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取指定版本的脚本内容"""
result = db.execute(
text("SELECT * FROM platform_script_versions WHERE task_id = :task_id AND version = :version"),
{"task_id": task_id, "version": version}
)
ver = result.mappings().first()
if not ver:
raise HTTPException(status_code=404, detail="版本不存在")
return dict(ver)
@router.post("/{task_id}/versions/{version}/rollback")
async def rollback_version(
task_id: int,
version: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""回滚到指定版本"""
# 获取指定版本的脚本内容
result = db.execute(
text("SELECT script_content FROM platform_script_versions WHERE task_id = :task_id AND version = :version"),
{"task_id": task_id, "version": version}
)
ver = result.first()
if not ver:
raise HTTPException(status_code=404, detail="版本不存在")
script_content = ver[0]
# 更新任务脚本
db.execute(
text("UPDATE platform_scheduled_tasks SET script_content = :content WHERE id = :id"),
{"id": task_id, "content": script_content}
)
# 创建新版本记录
version_result = db.execute(
text("SELECT COALESCE(MAX(version), 0) FROM platform_script_versions WHERE task_id = :task_id"),
{"task_id": task_id}
)
max_version = version_result.scalar() or 0
new_version = max_version + 1
db.execute(
text("""
INSERT INTO platform_script_versions (task_id, version, script_content, change_note, created_by)
VALUES (:task_id, :version, :content, :note, :created_by)
"""),
{
"task_id": task_id,
"version": new_version,
"content": script_content,
"note": f"回滚到版本 {version}",
"created_by": user.username if hasattr(user, 'username') else None
}
)
db.commit()
# 重新加载任务
reload_task(task_id)
return {"message": f"已回滚到版本 {version},当前版本号 {new_version}"}
# ============ 统计数据 ============
@router.get("/{task_id}/stats")
async def get_task_stats(
task_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取任务执行统计"""
# 总执行次数
total_result = db.execute(
text("SELECT COUNT(*) FROM platform_task_logs WHERE task_id = :task_id"),
{"task_id": task_id}
)
total = total_result.scalar() or 0
# 成功次数
success_result = db.execute(
text("SELECT COUNT(*) FROM platform_task_logs WHERE task_id = :task_id AND status = 'success'"),
{"task_id": task_id}
)
success = success_result.scalar() or 0
# 失败次数
failed = total - success
# 成功率
success_rate = round(success / total * 100, 1) if total > 0 else 0
# 平均耗时(成功的任务)
avg_result = db.execute(
text("""
SELECT AVG(TIMESTAMPDIFF(SECOND, started_at, finished_at))
FROM platform_task_logs
WHERE task_id = :task_id AND status = 'success' AND finished_at IS NOT NULL
"""),
{"task_id": task_id}
)
avg_duration = avg_result.scalar()
avg_duration = round(float(avg_duration), 1) if avg_duration else 0
# 最近7天趋势
trend_result = db.execute(
text("""
SELECT DATE(started_at) as date,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
FROM platform_task_logs
WHERE task_id = :task_id AND started_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE(started_at)
ORDER BY date
"""),
{"task_id": task_id}
)
trend = [dict(row) for row in trend_result.mappings().all()]
return {
"total": total,
"success": success,
"failed": failed,
"success_rate": success_rate,
"avg_duration": avg_duration,
"trend": trend
}