VWED_server/services/online_script/script_engine_service.py

787 lines
35 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.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
脚本引擎服务
核心脚本执行引擎,支持多脚本并发运行和生命周期管理
"""
import os
import sys
import time
import uuid
import asyncio
import traceback
from datetime import datetime
from typing import Dict, Any, Optional, List
from pathlib import Path
from data.session import get_async_session
from data.models.script_file import VWEDScriptFile
from data.models.script_registry import VWEDScriptRegistry
from data.models.script_execution_log import VWEDScriptExecutionLog
from .script_registry_service import get_global_registry
from .device_handler_service import get_device_service
from utils.logger import get_logger
from config.settings import settings
logger = get_logger("services.script_engine")
class ScriptExecutionContext:
"""脚本执行上下文"""
def __init__(self, script_id: str, file_path: str, registry_service, file_id: int = None):
self.script_id = script_id
self.file_path = file_path
self.file_id = file_id
self.registry = registry_service
self.start_time = datetime.now()
self.variables = {}
self.logs = []
def log(self, level: str, message: str, **kwargs):
"""记录日志"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"level": level,
"message": message,
"script_id": self.script_id,
**kwargs
}
self.logs.append(log_entry)
logger.log(getattr(logger, level.lower(), logger.info), f"[{self.script_id}] {message}")
class MultiScriptEngine:
"""多脚本服务引擎"""
def __init__(self):
self.registry = get_global_registry()
self.running_scripts: Dict[str, Dict[str, Any]] = {} # {script_id: script_info}
self.script_contexts: Dict[str, ScriptExecutionContext] = {}
self.base_path = Path(settings.SCRIPT_SAVE_PATH)
self._initialized = False
async def _ensure_initialized(self):
"""确保引擎已初始化(延迟初始化)"""
if not self._initialized:
await self._initialize_cleanup_active_scripts()
self._initialized = True
async def start_script_service(self, script_path: str = None, script_id: int = None, start_params: Dict = None) -> Dict[str, Any]:
"""启动脚本服务 - 支持多脚本并发"""
import asyncio
# 确保引擎已初始化
await self._ensure_initialized()
try:
script_file = None
# 1. 根据参数类型获取脚本信息
if script_id is not None:
# 通过script_id查找脚本文件
async with get_async_session() as session:
from sqlalchemy import select
from sqlalchemy.orm import selectinload
result = await session.execute(
select(VWEDScriptFile)
.options(selectinload(VWEDScriptFile.project))
.where(VWEDScriptFile.id == script_id)
)
script_file = result.scalar_one_or_none()
if not script_file:
return {"success": False, "error": f"脚本文件不存在: script_id={script_id}"}
script_path = script_file.file_path
# 使用正确的文件路径拼接逻辑
full_script_path = Path(os.path.join(self.base_path, script_file.project.project_path, script_file.file_path))
elif script_path is not None:
# 传统方式通过script_path查找
full_script_path = Path(script_path)
else:
return {"success": False, "error": "必须提供script_id或script_path参数"}
# 2. 验证脚本文件存在
# print("full_script_path::::::::::::::::::", full_script_path, "============")
if not os.path.exists(full_script_path):
return {"success": False, "error": f"脚本文件不存在: {script_path}"}
# 3. 预检查脚本是否有boot函数
boot_check_result = await self._check_boot_function(full_script_path)
if not boot_check_result["success"]:
return boot_check_result
# 4. 确定脚本服务ID
if script_id is not None:
# 使用数据库中的script_id作为全局唯一标识
script_service_id = str(script_id)
else:
# 对于通过script_path启动的脚本保持原有生成逻辑
script_service_id = f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
# 5. 检查脚本是否已在运行
if script_service_id in self.running_scripts:
# 拒绝启动已运行的脚本
running_info = self.running_scripts[script_service_id]
return {
"success": False,
"error": f"脚本 {script_service_id} 已在运行中",
"running_info": {
"script_id": script_service_id,
"script_path": running_info["script_path"],
"started_at": running_info["started_at"],
"status": running_info["status"]
},
"message": "请先停止正在运行的实例"
}
# 6. 清理可能存在的数据库旧记录和注册项(清理残留的非活跃记录)
await self._cleanup_script_resources(script_service_id)
# 7. 异步启动脚本(不阻塞当前请求)
asyncio.create_task(self._async_start_script(
script_service_id, script_path, full_script_path, script_file, start_params
))
# 立即返回启动状态,不等待完成
return {
"success": True,
"script_id": script_service_id,
"message": f"脚本服务启动中: {script_path}",
"status": "starting"
}
except Exception as e:
logger.error(f"启动脚本服务失败: {e}", exc_info=True)
return {"success": False, "error": f"脚本服务启动失败: {str(e)}"}
async def stop_script_service(self, script_id: str) -> Dict[str, Any]:
"""停止特定脚本服务 - 关键功能"""
# 确保引擎已初始化
await self._ensure_initialized()
try:
if script_id not in self.running_scripts:
return {"success": False, "error": f"脚本 {script_id} 未运行"}
# 获取注册统计
registrations = self.registry.get_script_registrations(script_id)
# 清理该脚本的所有注册项 - 这是关键步骤
self.registry.clear_script_registrations(script_id)
# 清理设备处理器
device_service = get_device_service()
device_service.registry.clear_script_device_registrations(script_id)
# 从运行列表移除
script_info = self.running_scripts.pop(script_id)
# 清理上下文
if script_id in self.script_contexts:
del self.script_contexts[script_id]
# 更新数据库状态
await self._update_script_status(script_id, "stopped")
logger.info(f"脚本服务已停止: {script_id}")
return {
"success": True,
"message": f"脚本服务已停止 ({script_info['script_path']}),已清理 {registrations['apis']} 个接口、{registrations['functions']} 个函数、{registrations['events']} 个事件监听器、{registrations.get('devices', 0)} 个设备处理器",
"registrations": registrations
}
except Exception as e:
logger.error(f"停止脚本服务失败: {e}", exc_info=True)
return {"success": False, "error": f"停止脚本服务失败: {str(e)}"}
async def get_running_scripts(self) -> List[Dict[str, Any]]:
"""获取所有运行中的脚本"""
# 确保引擎已初始化
await self._ensure_initialized()
result = []
for script_id, script_info in self.running_scripts.items():
registrations = self.registry.get_script_registrations(script_id)
result.append({
"script_id": script_id,
"script_path": script_info["script_path"],
"started_at": script_info["started_at"],
"status": script_info["status"],
"registrations": registrations
})
return result
async def get_script_history(self, script_id: str) -> List[Dict[str, Any]]:
"""获取脚本的所有历史记录"""
try:
async with get_async_session() as session:
from sqlalchemy import select
result = await session.execute(
select(VWEDScriptRegistry)
.where(VWEDScriptRegistry.script_id == script_id)
.order_by(VWEDScriptRegistry.created_at.desc())
)
records = result.scalars().all()
return [record.to_dict() for record in records]
except Exception as e:
logger.error(f"获取脚本历史记录失败: {e}")
return []
async def get_active_script_record(self, script_id: str) -> Optional[Dict[str, Any]]:
"""获取脚本的活跃记录"""
try:
async with get_async_session() as session:
from sqlalchemy import select
result = await session.execute(
select(VWEDScriptRegistry)
.where(VWEDScriptRegistry.script_id == script_id)
.where(VWEDScriptRegistry.is_active == True)
)
record = result.scalar_one_or_none()
return record.to_dict() if record else None
except Exception as e:
logger.error(f"获取活跃脚本记录失败: {e}")
return None
def find_running_script_by_path(self, script_path: str) -> Optional[Dict[str, Any]]:
"""根据脚本路径查找运行中的脚本"""
for script_id, script_info in self.running_scripts.items():
if script_info["script_path"] == script_path:
return {"script_id": script_id, **script_info}
return None
def find_running_script_by_id(self, script_id: str) -> Optional[Dict[str, Any]]:
"""根据脚本ID查找运行中的脚本"""
if script_id in self.running_scripts:
return {"script_id": script_id, **self.running_scripts[script_id]}
return None
async def execute_script_function(self, script_id: str, function_name: str,
function_args: Any) -> Dict[str, Any]:
"""执行脚本中的指定函数"""
from .script_output_capture import capture_script_output, push_script_error
try:
if script_id not in self.running_scripts:
return {"success": False, "error": f"脚本 {script_id} 未运行"}
# 从注册中心获取函数
func_info = self.registry.get_function_info(function_name)
if not func_info:
return {"success": False, "error": f"函数 {function_name} 未注册"}
if func_info["script_id"] != script_id:
return {"success": False, "error": f"函数 {function_name} 不属于脚本 {script_id}"}
start_time = time.time()
# 使用输出捕获上下文管理器
with capture_script_output(script_id):
try:
handler = func_info["handler"]
# 处理参数
if func_info["is_async"]:
result = await handler(**function_args)
else:
result = handler(**function_args)
execution_time_ms = int((time.time() - start_time) * 1000)
# 更新统计
self.registry.update_function_call_stats(function_name, execution_time_ms, True)
# 记录执行日志
await self._log_execution(script_id, "function_call", function_name,
function_args, result, "success", execution_time_ms)
return {
"success": True,
"result": result,
"execution_time_ms": execution_time_ms
}
except Exception as e:
execution_time_ms = int((time.time() - start_time) * 1000)
error_message = str(e)
error_traceback = traceback.format_exc()
# 更新统计
self.registry.update_function_call_stats(function_name, execution_time_ms, False)
# 记录执行日志
await self._log_execution(script_id, "function_call", function_name,
function_args, None, "failed", execution_time_ms,
error_message, error_traceback)
# 推送错误信息到WebSocket
await push_script_error(script_id, f"函数 {function_name} 执行失败: {error_message}")
return {
"success": False,
"error": error_message,
"traceback": error_traceback,
"execution_time_ms": execution_time_ms
}
except Exception as e:
logger.error(f"执行脚本函数失败: {e}", exc_info=True)
return {"success": False, "error": f"执行脚本函数失败: {str(e)}"}
async def _load_and_execute_script(self, script_id: str, script_path: str,
context: ScriptExecutionContext) -> Dict[str, Any]:
"""加载并执行脚本"""
from .script_output_capture import capture_script_output, push_script_error
try:
# 读取脚本内容
with open(script_path, 'r', encoding='utf-8') as f:
script_code = f.read()
# 准备脚本全局环境
from .script_vwed_objects import create_vwed_object
script_globals = {
'__name__': '__main__',
'__file__': script_path,
'VWED': create_vwed_object(script_id), # 注入VWED统一对象
'context': context
}
# 使用输出捕获上下文管理器
with capture_script_output(script_id):
# 执行脚本
exec(script_code, script_globals)
# 保存脚本全局环境以供后续使用
context.globals = script_globals
return {"success": True, "message": "脚本加载成功"}
except Exception as e:
error_message = str(e)
error_traceback = traceback.format_exc()
logger.error(f"加载脚本失败: {error_message}")
await self._log_execution(script_id, "load_script", None, None, None,
"failed", 0, error_message, error_traceback)
# 将错误信息也推送到WebSocket
await push_script_error(script_id, f"脚本加载失败: {error_message}")
return {"success": False, "error": error_message, "traceback": error_traceback}
async def _execute_boot_function(self, script_id: str,
context: ScriptExecutionContext) -> Dict[str, Any]:
"""执行boot函数注册服务"""
import asyncio
import inspect
from .script_output_capture import capture_script_output, push_script_error
try:
if 'boot' not in context.globals:
return {"success": False, "error": "脚本缺少boot()入口函数"}
boot_function = context.globals['boot']
start_time = time.time()
# 使用输出捕获上下文管理器
with capture_script_output(script_id):
# 执行boot函数
if callable(boot_function):
# 检查boot函数是否是异步函数
if inspect.iscoroutinefunction(boot_function):
# 如果是异步函数直接await
await boot_function()
else:
# 如果是同步函数,在线程池中执行,避免阻塞事件循环
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, boot_function)
else:
return {"success": False, "error": "boot不是一个可调用函数"}
execution_time_ms = int((time.time() - start_time) * 1000)
# 记录执行日志
await self._log_execution(script_id, "boot_function", "boot", None, None,
"success", execution_time_ms)
return {"success": True, "execution_time_ms": execution_time_ms}
except Exception as e:
error_message = str(e)
error_traceback = traceback.format_exc()
logger.error(f"执行boot函数失败: {error_message}")
await self._log_execution(script_id, "boot_function", "boot", None, None,
"failed", 0, error_message, error_traceback)
# 推送错误信息到WebSocket
await push_script_error(script_id, f"boot函数执行失败: {error_message}")
return {"success": False, "error": error_message, "traceback": error_traceback}
async def _initialize_cleanup_active_scripts(self):
"""初始化时清理所有活跃状态的脚本记录"""
try:
logger.info("开始初始化清理所有活跃状态的脚本记录")
async with get_async_session() as session:
from sqlalchemy import update
try:
# 标记所有活跃的函数注册记录为非活跃
from data.models.script_function_registration import VWEDScriptFunctionRegistration
await session.execute(
update(VWEDScriptFunctionRegistration)
.where(VWEDScriptFunctionRegistration.is_active == True)
.values(is_active=False)
)
except ImportError:
logger.warning("VWEDScriptFunctionRegistration 模型未找到")
try:
# 标记所有活跃的API注册记录为非活跃
from data.models.script_api_registration import VWEDScriptAPIRegistration
await session.execute(
update(VWEDScriptAPIRegistration)
.where(VWEDScriptAPIRegistration.is_active == True)
.values(is_active=False)
)
except ImportError:
logger.warning("VWEDScriptAPIRegistration 模型未找到")
# 标记所有活跃的脚本注册中心记录为非活跃状态
result = await session.execute(
update(VWEDScriptRegistry)
.where(VWEDScriptRegistry.is_active == True)
.values(
is_active=False,
status='stopped',
stopped_at=datetime.now()
)
)
await session.commit()
logger.info(f"初始化清理完成,共标记 {result.rowcount} 个活跃脚本记录为非活跃状态")
except Exception as e:
logger.error(f"初始化清理活跃脚本记录失败: {e}", exc_info=True)
async def _cleanup_script_resources(self, script_id: str):
"""清理脚本相关资源并标记历史记录为非活跃状态"""
try:
# 1. 清理内存中的注册项
self.registry.clear_script_registrations(script_id)
# 2. 清理设备处理器
device_service = get_device_service()
device_service.registry.clear_script_device_registrations(script_id)
# 3. 清理运行记录和上下文
if script_id in self.running_scripts:
del self.running_scripts[script_id]
if script_id in self.script_contexts:
del self.script_contexts[script_id]
# 4. 标记历史记录为非活跃状态(保留历史记录,不删除)
async with get_async_session() as session:
from sqlalchemy import update
try:
from data.models.script_function_registration import VWEDScriptFunctionRegistration
# 标记函数注册记录为非活跃
await session.execute(
update(VWEDScriptFunctionRegistration)
.where(VWEDScriptFunctionRegistration.script_id == script_id)
.values(is_active=False)
)
except ImportError:
logger.warning("VWEDScriptFunctionRegistration 模型未找到")
try:
from data.models.script_api_registration import VWEDScriptAPIRegistration
# 标记API注册记录为非活跃
await session.execute(
update(VWEDScriptAPIRegistration)
.where(VWEDScriptAPIRegistration.script_id == script_id)
.values(is_active=False)
)
except ImportError:
logger.warning("VWEDScriptAPIRegistration 模型未找到")
# 标记脚本注册中心记录为非活跃状态
await session.execute(
update(VWEDScriptRegistry)
.where(VWEDScriptRegistry.script_id == script_id)
.where(VWEDScriptRegistry.is_active == True)
.values(
is_active=False,
status='stopped',
stopped_at=datetime.now()
)
)
await session.commit()
logger.info(f"已将脚本 {script_id} 的历史记录标记为非活跃状态")
except Exception as e:
logger.error(f"清理脚本资源失败: {script_id}, 错误: {e}", exc_info=True)
async def _cleanup_failed_script(self, script_id: str):
"""清理失败的脚本"""
try:
# 清理注册项
self.registry.clear_script_registrations(script_id)
# 清理运行记录
if script_id in self.running_scripts:
del self.running_scripts[script_id]
# 清理上下文
if script_id in self.script_contexts:
del self.script_contexts[script_id]
# 更新数据库状态
await self._update_script_status(script_id, "error")
except Exception as e:
logger.error(f"清理失败脚本出错: {e}")
async def _update_script_status(self, script_id: str, status: str, error_message: str = None):
"""更新脚本状态"""
try:
async with get_async_session() as session:
from sqlalchemy import select
# 查找活跃的脚本记录
result = await session.execute(
select(VWEDScriptRegistry)
.where(VWEDScriptRegistry.script_id == script_id)
.where(VWEDScriptRegistry.is_active == True)
)
registry_record = result.scalar_one_or_none()
if registry_record:
registry_record.status = status
if error_message:
registry_record.error_message = error_message
if status in ['stopped', 'error']:
registry_record.stopped_at = datetime.now()
registry_record.is_active = False # 停止时标记为非活跃
# 更新注册统计
registrations = self.registry.get_script_registrations(script_id)
# 传递所有支持的参数
supported_params = {
'apis': registrations.get('apis', 0),
'functions': registrations.get('functions', 0),
'events': registrations.get('events', 0),
'timers': registrations.get('timers', 0),
'devices': registrations.get('devices', 0)
}
registry_record.update_registration_counts(**supported_params)
await session.commit()
else:
logger.warning(f"未找到活跃的脚本记录: {script_id}")
except Exception as e:
logger.error(f"更新脚本状态失败: {e}")
async def _check_boot_function(self, script_path: str) -> Dict[str, Any]:
"""检查脚本是否包含boot函数且没有其他入口点"""
try:
with open(script_path, 'r', encoding='utf-8') as f:
script_content = f.read()
import re
# 1. 检查是否有boot函数定义支持同步和异步函数
boot_pattern = r'^\s*(async\s+)?def\s+boot\s*\('
has_boot = bool(re.search(boot_pattern, script_content, re.MULTILINE))
if not has_boot:
return {
"success": False,
"error": "脚本缺少boot()入口函数。请在脚本中定义boot()函数作为服务启动入口。"
}
# 2. 检查是否存在其他入口点(如 if __name__ == "__main__"
main_check_patterns = [
r'if\s+__name__\s*==\s*["\']__main__["\']\s*:', # if __name__ == "__main__":
r'if\s+__name__\s*==\s*["\']__main__["\']\s*\:', # 变体
r'if\s+__name__\s*==\s*"__main__"\s*:', # 双引号
r'if\s+__name__\s*==\s*\'__main__\'\s*:', # 单引号
]
for pattern in main_check_patterns:
if re.search(pattern, script_content, re.IGNORECASE):
return {
"success": False,
"error": "脚本中不能包含 'if __name__ == \"__main__\":' 等其他入口点。请仅使用boot()函数作为唯一入口点。"
}
# 3. 检查潜在的阻塞调用
blocking_check_result = self._check_for_blocking_calls(script_content)
if not blocking_check_result["success"]:
return blocking_check_result
return {"success": True}
except Exception as e:
return {"success": False, "error": f"检查boot函数失败: {str(e)}"}
def _check_for_blocking_calls(self, script_content: str) -> Dict[str, Any]:
"""检查脚本中潜在的阻塞调用"""
import re
# 定义潜在的阻塞函数调用模式
blocking_patterns = [
(r'time\.sleep\s*\(', 'time.sleep()', '建议在异步函数中使用 await asyncio.sleep() 代替'),
(r'input\s*\(', 'input()', '不建议在脚本中使用阻塞式输入'),
(r'\.join\s*\(\s*\)', '.join()', '线程join调用可能导致阻塞建议使用异步方式'),
(r'requests\.(get|post|put|delete|patch)', 'requests同步调用', '建议使用 aiohttp 或其他异步HTTP库'),
(r'\.wait\s*\(\s*\)', '.wait()', 'wait调用可能导致阻塞'),
]
warnings = []
for pattern, description, suggestion in blocking_patterns:
if re.search(pattern, script_content, re.IGNORECASE):
warnings.append(f"检测到潜在阻塞调用: {description} - {suggestion}")
# 检查是否在异步函数中使用了time.sleep
async_boot_with_time_sleep = re.search(
r'async\s+def\s+boot\s*\([^)]*\):[^}]*time\.sleep\s*\(',
script_content,
re.DOTALL | re.MULTILINE
)
if async_boot_with_time_sleep:
return {
"success": False,
"error": "在异步boot()函数中检测到 time.sleep() 调用,这会阻塞事件循环。请使用 'await asyncio.sleep()' 代替。"
}
# 如果有警告但不是致命错误,记录警告但允许继续
if warnings:
return {
"success": False,
"error": "在boot()函数中检测到 time.sleep() 调用,这会阻塞事件循环。请使用 'await asyncio.sleep()' 代替。"
}
return {"success": True, "warnings": warnings}
async def _async_start_script(self, script_service_id: str, script_path: str,
full_script_path: Path, script_file, start_params: Dict = None):
"""异步启动脚本的实际逻辑"""
try:
# 创建执行上下文
context = ScriptExecutionContext(script_service_id, script_path, self.registry,
script_file.id if script_file else None)
self.script_contexts[script_service_id] = context
# 设置全局注册中心上下文
self.registry.set_current_script(script_service_id)
self.registry.add_active_script(script_service_id, script_path)
# 创建数据库记录(新记录默认为活跃状态)
async with get_async_session() as session:
registry_record = VWEDScriptRegistry(
script_id=script_service_id,
file_id=script_file.id if script_file else None,
script_path=script_path,
status='starting',
is_active=True, # 新记录为活跃状态
start_params=start_params or {},
host_info={
"hostname": os.environ.get('COMPUTERNAME', os.environ.get('HOSTNAME', 'unknown')),
"platform": sys.platform,
"python_version": sys.version
}
)
session.add(registry_record)
await session.commit()
# 加载并执行脚本
result = await self._load_and_execute_script(script_service_id, full_script_path, context)
if result["success"]:
# 调用boot函数注册服务
boot_result = await self._execute_boot_function(script_service_id, context)
if not boot_result["success"]:
await self._cleanup_failed_script(script_service_id)
logger.error(f"脚本 {script_service_id} boot函数执行失败: {boot_result['error']}")
return
# 更新运行状态
self.running_scripts[script_service_id] = {
"script_path": script_path,
"started_at": context.start_time.isoformat(),
"status": "running",
"context": context
}
# 更新数据库状态
await self._update_script_status(script_service_id, "running")
# 获取注册统计
registrations = self.registry.get_script_registrations(script_service_id)
logger.info(f"脚本服务启动成功: {script_service_id}, 已注册 {registrations['apis']} 个接口,{registrations['functions']} 个函数")
else:
await self._cleanup_failed_script(script_service_id)
logger.error(f"脚本 {script_service_id} 加载失败: {result['error']}")
except Exception as e:
logger.error(f"异步启动脚本失败: {e}", exc_info=True)
await self._cleanup_failed_script(script_service_id)
async def _log_execution(self, script_id: str, execution_type: str, function_name: str,
input_params: Any, output_result: Any, status: str,
duration_ms: int, error_message: str = None,
error_traceback: str = None):
"""记录执行日志"""
try:
# 从执行上下文获取file_id
context = self.script_contexts.get(script_id)
file_id = context.file_id if context else None
async with get_async_session() as session:
log_entry = VWEDScriptExecutionLog(
script_id=script_id,
file_id=file_id,
execution_type=execution_type,
function_name=function_name,
input_params=input_params,
output_result=output_result,
status=status,
error_message=error_message,
error_traceback=error_traceback,
duration_ms=duration_ms
)
session.add(log_entry)
await session.commit()
except Exception as e:
logger.error(f"记录执行日志失败: {e}")
# 全局脚本引擎实例
_script_engine = MultiScriptEngine()
def get_script_engine() -> MultiScriptEngine:
"""获取脚本引擎实例"""
return _script_engine