- 支持 Python 脚本定时执行(类似青龙面板) - 内置 SDK:AI大模型、钉钉/企微通知、数据库查询、HTTP请求、变量存储 - 安全沙箱执行,禁用危险模块 - 前端脚本编辑器,支持测试执行 - SDK 文档查看 - 日志通过 TraceID 与 platform_logs 关联
This commit is contained in:
262
backend/app/services/script_executor.py
Normal file
262
backend/app/services/script_executor.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""脚本执行器 - 安全执行 Python 脚本"""
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
class ScriptExecutionResult:
|
||||
"""脚本执行结果"""
|
||||
|
||||
def __init__(
|
||||
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',
|
||||
}
|
||||
|
||||
if name in FORBIDDEN_MODULES:
|
||||
raise ImportError(f"禁止导入模块: {name}")
|
||||
|
||||
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)
|
||||
|
||||
# 准备执行环境
|
||||
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'),
|
||||
}
|
||||
|
||||
# 捕获输出
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
sdk.log("脚本开始执行")
|
||||
|
||||
# 编译并执行脚本
|
||||
try:
|
||||
# 编译脚本
|
||||
code = compile(script_content, '<script>', 'exec')
|
||||
|
||||
# 执行(带超时)
|
||||
async def run_script():
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
exec(code, script_globals)
|
||||
|
||||
await asyncio.wait_for(run_script(), timeout=SCRIPT_TIMEOUT)
|
||||
|
||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
sdk.log(f"脚本执行完成,耗时 {execution_time}ms")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
error_msg = f"{type(e).__name__}: {str(e)}"
|
||||
|
||||
# 获取详细的错误堆栈
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
return ScriptExecutionResult(
|
||||
success=False,
|
||||
error=error_msg,
|
||||
logs=sdk.get_logs() if sdk else [],
|
||||
execution_time_ms=execution_time
|
||||
)
|
||||
|
||||
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')}"
|
||||
)
|
||||
Reference in New Issue
Block a user