- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表 - 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能 - 实现安全的脚本执行器,支持沙箱环境和禁止危险操作 - 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式 - 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理 - 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user