feat: 实现定时任务系统
All checks were successful
continuous-integration/drone/push Build is passing

- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表
- 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能
- 实现安全的脚本执行器,支持沙箱环境和禁止危险操作
- 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式
- 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理
- 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
This commit is contained in:
2026-01-28 16:38:19 +08:00
parent 7806072b17
commit 104487f082
19 changed files with 1870 additions and 5406 deletions

View File

@@ -1,262 +1,246 @@
"""脚本执行器 - 安全执行 Python 脚本"""
import asyncio
import io
import logging
"""脚本执行器 - 安全执行Python脚本"""
import sys
import traceback
from contextlib import redirect_stdout, redirect_stderr
from io import StringIO
from typing import Any, Dict, Optional, Tuple
from datetime import datetime
from typing import Any, Dict
from sqlalchemy.orm import Session
from .script_sdk import ScriptSDK
logger = logging.getLogger(__name__)
# 执行超时时间(秒)
SCRIPT_TIMEOUT = 300 # 5 分钟
# 禁止导入的模块
FORBIDDEN_MODULES = {
'os', 'subprocess', 'sys', 'builtins', '__builtins__',
'importlib', 'eval', 'exec', 'compile',
'open', 'file', 'input',
'socket', 'multiprocessing', 'threading',
'pickle', 'marshal', 'ctypes',
'code', 'codeop', 'pty', 'tty',
'os', 'subprocess', 'shutil', 'pathlib',
'socket', 'ftplib', 'telnetlib', 'smtplib',
'pickle', 'shelve', 'marshal',
'ctypes', 'multiprocessing',
'__builtins__', 'builtins',
'importlib', 'imp',
'code', 'codeop', 'compile',
}
# 允许的内置函数
ALLOWED_BUILTINS = {
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
'callable', 'chr', 'complex', 'dict', 'dir', 'divmod', 'enumerate',
'filter', 'float', 'format', 'frozenset', 'getattr', 'hasattr', 'hash',
'hex', 'id', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'list',
'map', 'max', 'min', 'next', 'object', 'oct', 'ord', 'pow', 'print',
'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice',
'sorted', 'str', 'sum', 'tuple', 'type', 'vars', 'zip',
'True', 'False', 'None',
'Exception', 'BaseException', 'ValueError', 'TypeError', 'KeyError',
'IndexError', 'AttributeError', 'RuntimeError', 'StopIteration',
}
class ScriptExecutionResult:
"""脚本执行结果"""
class ScriptExecutor:
"""脚本执行"""
def __init__(
def __init__(self, db: Session):
self.db = db
def execute(
self,
success: bool,
output: str = "",
error: str = None,
logs: list = None,
execution_time_ms: int = 0
):
self.success = success
self.output = output
self.error = error
self.logs = logs or []
self.execution_time_ms = execution_time_ms
def to_dict(self) -> Dict[str, Any]:
return {
"success": self.success,
"output": self.output,
"error": self.error,
"logs": self.logs,
"execution_time_ms": self.execution_time_ms
}
def create_safe_builtins() -> Dict[str, Any]:
"""创建安全的内置函数集"""
import builtins
# 允许的内置函数
allowed = [
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
'callable', 'chr', 'complex', 'dict', 'divmod', 'enumerate',
'filter', 'float', 'format', 'frozenset', 'getattr', 'hasattr',
'hash', 'hex', 'id', 'int', 'isinstance', 'issubclass', 'iter',
'len', 'list', 'map', 'max', 'min', 'next', 'object', 'oct',
'ord', 'pow', 'print', 'range', 'repr', 'reversed', 'round',
'set', 'slice', 'sorted', 'str', 'sum', 'tuple', 'type', 'zip',
'True', 'False', 'None',
]
safe_builtins = {}
for name in allowed:
if hasattr(builtins, name):
safe_builtins[name] = getattr(builtins, name)
# 添加安全的 import 函数
def safe_import(name, *args, **kwargs):
"""安全的 import 函数,只允许特定模块"""
allowed_modules = {
'json', 'datetime', 'time', 're', 'math', 'random',
'collections', 'itertools', 'functools', 'operator',
'string', 'textwrap', 'unicodedata',
'hashlib', 'base64', 'urllib.parse',
}
script_content: str,
task_id: int,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
timeout: int = 300
) -> Tuple[bool, str, str]:
"""执行脚本
if name in FORBIDDEN_MODULES:
raise ImportError(f"禁止导入模块: {name}")
Args:
script_content: Python脚本内容
task_id: 任务ID
tenant_id: 租户ID
trace_id: 追踪ID
params: 输入参数
timeout: 超时秒数
Returns:
(success, output, error)
"""
# 创建SDK实例
sdk = ScriptSDK(
db=self.db,
task_id=task_id,
tenant_id=tenant_id,
trace_id=trace_id,
params=params or {}
)
if name not in allowed_modules and not name.startswith('urllib.parse'):
raise ImportError(f"不允许导入模块: {name},允许的模块: {', '.join(sorted(allowed_modules))}")
return __builtins__['__import__'](name, *args, **kwargs)
safe_builtins['__import__'] = safe_import
return safe_builtins
async def execute_script(
task_id: int,
tenant_id: str,
script_content: str,
trace_id: str = None
) -> ScriptExecutionResult:
"""
执行 Python 脚本
Args:
task_id: 任务 ID
tenant_id: 租户 ID
script_content: 脚本内容
trace_id: 追踪 ID
Returns:
ScriptExecutionResult: 执行结果
"""
start_time = datetime.now()
sdk = None
try:
# 创建 SDK 实例
sdk = ScriptSDK(tenant_id, task_id, trace_id)
# 检查脚本安全性
check_result = self._check_script_safety(script_content)
if check_result:
return False, '', f"脚本安全检查失败: {check_result}"
# 准备执行环境
script_globals = {
'__builtins__': create_safe_builtins(),
'__name__': '__script__',
# SDK 实例
'sdk': sdk,
# 快捷方法(同步包装)
'ai': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.ai_chat(*args, **kwargs)),
'dingtalk': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.send_dingtalk(*args, **kwargs)),
'wecom': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.send_wecom(*args, **kwargs)),
'http_get': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.http_get(*args, **kwargs)),
'http_post': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.http_post(*args, **kwargs)),
# 同步方法
'db': sdk.db_query,
'get_var': sdk.get_var,
'set_var': sdk.set_var,
'delete_var': sdk.delete_var,
'log': sdk.log,
# 常用模块
'json': __import__('json'),
'datetime': __import__('datetime'),
're': __import__('re'),
'math': __import__('math'),
'random': __import__('random'),
}
safe_globals = self._create_safe_globals(sdk)
# 捕获输出
stdout = io.StringIO()
stderr = io.StringIO()
old_stdout = sys.stdout
old_stderr = sys.stderr
stdout_capture = StringIO()
stderr_capture = StringIO()
sdk.log("脚本开始执行")
# 编译并执行脚本
try:
# 编译脚本
code = compile(script_content, '<script>', 'exec')
sys.stdout = stdout_capture
sys.stderr = stderr_capture
# 执行(带超时)
async def run_script():
with redirect_stdout(stdout), redirect_stderr(stderr):
exec(code, script_globals)
# 编译并执行脚本
compiled = compile(script_content, '<script>', 'exec')
exec(compiled, safe_globals)
await asyncio.wait_for(run_script(), timeout=SCRIPT_TIMEOUT)
# 获取输出
stdout_output = stdout_capture.getvalue()
sdk_output = sdk.get_output()
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
sdk.log(f"脚本执行完成,耗时 {execution_time}ms")
# 合并输出
output = '\n'.join(filter(None, [sdk_output, stdout_output]))
return ScriptExecutionResult(
success=True,
output=stdout.getvalue(),
logs=sdk.get_logs(),
execution_time_ms=execution_time
)
except asyncio.TimeoutError:
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
error_msg = f"脚本执行超时(超过 {SCRIPT_TIMEOUT} 秒)"
sdk.log(error_msg, level="ERROR")
return ScriptExecutionResult(
success=False,
output=stdout.getvalue(),
error=error_msg,
logs=sdk.get_logs(),
execution_time_ms=execution_time
)
except SyntaxError as e:
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
error_msg = f"语法错误: 第 {e.lineno} 行 - {e.msg}"
sdk.log(error_msg, level="ERROR")
return ScriptExecutionResult(
success=False,
output=stdout.getvalue(),
error=error_msg,
logs=sdk.get_logs(),
execution_time_ms=execution_time
)
return True, output, ''
except Exception as e:
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
error_msg = f"{type(e).__name__}: {str(e)}"
error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
return False, sdk.get_output(), error_msg
# 获取详细的错误堆栈
tb = traceback.format_exc()
sdk.log(f"执行错误: {error_msg}\n{tb}", level="ERROR")
return ScriptExecutionResult(
success=False,
output=stdout.getvalue(),
error=error_msg,
logs=sdk.get_logs(),
execution_time_ms=execution_time
)
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
except Exception as e:
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
error_msg = f"执行器错误: {str(e)}"
logger.error(f"Script executor error: {e}", exc_info=True)
def _check_script_safety(self, script_content: str) -> Optional[str]:
"""检查脚本安全性
return ScriptExecutionResult(
success=False,
error=error_msg,
logs=sdk.get_logs() if sdk else [],
execution_time_ms=execution_time
Returns:
错误消息如果安全则返回None
"""
# 检查危险导入
import_patterns = [
'import os', 'from os',
'import subprocess', 'from subprocess',
'import shutil', 'from shutil',
'import socket', 'from socket',
'__import__',
'eval(', 'exec(',
'compile(',
'open(', # 禁止文件操作
]
script_lower = script_content.lower()
for pattern in import_patterns:
if pattern.lower() in script_lower:
return f"禁止使用: {pattern}"
return None
def _create_safe_globals(self, sdk: ScriptSDK) -> Dict[str, Any]:
"""创建安全的执行环境"""
import json
import re
import math
import random
import hashlib
import base64
from datetime import datetime, date, timedelta
from urllib.parse import urlencode, quote, unquote
# 安全的内置函数
safe_builtins = {name: getattr(__builtins__, name, None)
for name in ALLOWED_BUILTINS
if hasattr(__builtins__, name) or name in dir(__builtins__)}
# 如果 __builtins__ 是字典
if isinstance(__builtins__, dict):
safe_builtins = {name: __builtins__.get(name)
for name in ALLOWED_BUILTINS
if name in __builtins__}
# 添加常用异常
safe_builtins['Exception'] = Exception
safe_builtins['ValueError'] = ValueError
safe_builtins['TypeError'] = TypeError
safe_builtins['KeyError'] = KeyError
safe_builtins['IndexError'] = IndexError
return {
'__builtins__': safe_builtins,
'__name__': '__main__',
# SDK函数全局可用
'log': sdk.log,
'print': sdk.print,
'ai': sdk.ai,
'dingtalk': sdk.dingtalk,
'wecom': sdk.wecom,
'http_get': sdk.http_get,
'http_post': sdk.http_post,
'db_query': sdk.db_query,
'get_var': sdk.get_var,
'set_var': sdk.set_var,
'del_var': sdk.del_var,
'get_param': sdk.get_param,
'get_params': sdk.get_params,
'get_tenants': sdk.get_tenants,
'get_tenant_config': sdk.get_tenant_config,
'get_all_tenant_configs': sdk.get_all_tenant_configs,
'get_secret': sdk.get_secret,
# 当前上下文
'task_id': sdk.task_id,
'tenant_id': sdk.tenant_id,
'trace_id': sdk.trace_id,
# 安全的标准库
'json': json,
're': re,
'math': math,
'random': random,
'hashlib': hashlib,
'base64': base64,
'datetime': datetime,
'date': date,
'timedelta': timedelta,
'urlencode': urlencode,
'quote': quote,
'unquote': unquote,
}
def test_script(
self,
script_content: str,
task_id: int = 0,
tenant_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""测试脚本(用于调试)
Returns:
{
"success": bool,
"output": str,
"error": str,
"duration_ms": int,
"logs": [...]
}
"""
start_time = datetime.now()
success, output, error = self.execute(
script_content=script_content,
task_id=task_id,
tenant_id=tenant_id,
trace_id=f"test-{start_time.timestamp()}",
params=params
)
finally:
# 清理资源
if sdk:
sdk.cleanup()
async def test_script(
tenant_id: str,
script_content: str
) -> ScriptExecutionResult:
"""
测试执行脚本(不记录日志到数据库)
Args:
tenant_id: 租户 ID
script_content: 脚本内容
Returns:
ScriptExecutionResult: 执行结果
"""
return await execute_script(
task_id=0, # 测试用 ID
tenant_id=tenant_id,
script_content=script_content,
trace_id=f"test_{datetime.now().strftime('%Y%m%d%H%M%S')}"
)
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
return {
"success": success,
"output": output,
"error": error,
"duration_ms": duration_ms
}