VWED_server/services/script_service.py
2025-04-30 16:57:46 +08:00

818 lines
30 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 uuid
import datetime
import asyncio
import tempfile
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Any, Tuple, Union
import importlib.util
from sqlalchemy import func, and_, or_, desc, asc
from sqlalchemy.orm import Session
from data.models.script import VWEDScript, VWEDScriptVersion, VWEDScriptLog
from config.settings import settings
from utils.logger import get_logger
# 设置日志记录器
logger = get_logger("app.script_service")
# 创建脚本执行线程池
executor = ThreadPoolExecutor(max_workers=settings.SCRIPT_MAX_WORKERS)
# 正在运行的脚本任务
running_scripts: Dict[str, asyncio.Task] = {}
class ScriptService:
"""在线脚本管理服务类"""
@staticmethod
def get_script_list(
db: Session,
page: int = 1,
page_size: int = 20,
name: Optional[str] = None,
status: Optional[int] = None,
folder_path: Optional[str] = None,
tags: Optional[str] = None,
is_public: Optional[int] = None,
created_by: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
sort_field: str = "updated_on",
sort_order: str = "desc"
) -> Dict[str, Any]:
"""
获取脚本列表
Args:
db: 数据库会话
page: 页码
page_size: 每页记录数
name: 脚本名称,支持模糊查询
status: 脚本状态(1:启用, 0:禁用)
folder_path: 脚本所在目录路径
tags: 标签,支持模糊查询
is_public: 是否公开(1:是, 0:否)
created_by: 创建者
start_time: 创建时间范围的开始时间格式yyyy-MM-dd HH:mm:ss
end_time: 创建时间范围的结束时间格式yyyy-MM-dd HH:mm:ss
sort_field: 排序字段
sort_order: 排序方式(asc/desc)
Returns:
Dict[str, Any]: 包含脚本列表和分页信息的字典
"""
try:
# 构建查询
query = db.query(VWEDScript)
# 添加过滤条件
if name:
query = query.filter(VWEDScript.name.like(f"%{name}%"))
if status is not None:
query = query.filter(VWEDScript.status == status)
if folder_path:
query = query.filter(VWEDScript.folder_path == folder_path)
if tags:
query = query.filter(VWEDScript.tags.like(f"%{tags}%"))
if is_public is not None:
query = query.filter(VWEDScript.is_public == is_public)
if created_by:
query = query.filter(VWEDScript.created_by == created_by)
if start_time and end_time:
query = query.filter(
VWEDScript.created_on.between(start_time, end_time)
)
# 计算总数
total = query.count()
# 添加排序
if sort_order.lower() == "asc":
query = query.order_by(asc(getattr(VWEDScript, sort_field)))
else:
query = query.order_by(desc(getattr(VWEDScript, sort_field)))
# 分页
scripts = query.offset((page - 1) * page_size).limit(page_size).all()
# 转换为字典列表
script_list = []
for script in scripts:
script_dict = {
"id": script.id,
"name": script.name,
"folderPath": script.folder_path,
"fileName": script.file_name,
"description": script.description,
"version": script.version,
"status": script.status,
"isPublic": bool(script.is_public),
"tags": script.tags,
"createdBy": script.created_by,
"createdOn": script.created_on.strftime("%Y-%m-%d %H:%M:%S") if script.created_on else None,
"updatedBy": script.updated_by,
"updatedOn": script.updated_on.strftime("%Y-%m-%d %H:%M:%S") if script.updated_on else None
}
script_list.append(script_dict)
return {
"total": total,
"list": script_list
}
except Exception as e:
logger.error(f"获取脚本列表失败: {str(e)}")
raise
@staticmethod
def get_script_detail(db: Session, script_id: str) -> Dict[str, Any]:
"""
获取脚本详情
Args:
db: 数据库会话
script_id: 脚本ID
Returns:
Dict[str, Any]: 脚本详情字典
"""
try:
script = db.query(VWEDScript).filter(VWEDScript.id == script_id).first()
if not script:
raise ValueError(f"脚本不存在: {script_id}")
script_dict = {
"id": script.id,
"name": script.name,
"folderPath": script.folder_path,
"fileName": script.file_name,
"description": script.description,
"code": script.code,
"version": script.version,
"status": script.status,
"isPublic": bool(script.is_public),
"tags": script.tags,
"createdBy": script.created_by,
"createdOn": script.created_on.strftime("%Y-%m-%d %H:%M:%S") if script.created_on else None,
"updatedBy": script.updated_by,
"updatedOn": script.updated_on.strftime("%Y-%m-%d %H:%M:%S") if script.updated_on else None,
"testParams": script.test_params
}
return script_dict
except Exception as e:
logger.error(f"获取脚本详情失败: {str(e)}")
raise
@staticmethod
def create_script(
db: Session,
folder_path: str,
file_name: str,
code: str,
name: Optional[str] = None,
description: Optional[str] = None,
status: int = 1,
is_public: int = 1,
tags: Optional[str] = None,
test_params: Optional[str] = None,
created_by: Optional[str] = None
) -> Dict[str, Any]:
"""
创建脚本
Args:
db: 数据库会话
folder_path: 脚本所在目录路径
file_name: 脚本文件名
code: 脚本代码内容
name: 脚本名称
description: 脚本功能描述
status: 状态(1:启用, 0:禁用)
is_public: 是否公开(1:是, 0:否)
tags: 标签,用于分类查询
test_params: 测试参数(JSON格式)
created_by: 创建者
Returns:
Dict[str, Any]: 创建的脚本信息
"""
try:
# 检查文件名是否以.py结尾
if not file_name.endswith(".py"):
raise ValueError("脚本文件名必须以.py结尾")
# 如果name为空使用文件名(不含扩展名)作为name
if not name:
name = os.path.splitext(file_name)[0]
# 处理脚本保存路径
# 所有用户脚本都保存在scripts/user_save目录下
if not folder_path or folder_path == "/":
# 如果用户没有指定路径,使用默认根目录
folder_path = "/"
else:
# 确保路径以/开头
if not folder_path.startswith("/"):
folder_path = f"/{folder_path}"
# 确保路径以/结尾
if not folder_path.endswith("/"):
folder_path = f"{folder_path}/"
# 检查数据库中是否已存在相同路径和文件名的脚本
existing_script = db.query(VWEDScript).filter(
VWEDScript.folder_path == folder_path,
VWEDScript.file_name == file_name
).first()
if existing_script:
raise ValueError(f"脚本已存在: {folder_path}{file_name}")
# 检查物理文件是否已存在
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts", "user_save")
relative_path = folder_path.lstrip("/")
full_path = os.path.join(base_path, relative_path)
script_file_path = os.path.join(full_path, file_name)
if os.path.exists(script_file_path):
raise ValueError(f"脚本文件已存在: {folder_path}{file_name}")
# 生成唯一ID
script_id = str(uuid.uuid4())
# 创建脚本记录
script = VWEDScript(
id=script_id,
name=name,
folder_path=folder_path,
file_name=file_name,
description=description,
code=code,
version=1,
status=status,
is_public=is_public,
tags=tags,
created_by=created_by,
created_on=datetime.datetime.now(),
updated_by=created_by,
updated_on=datetime.datetime.now(),
test_params=test_params
)
db.add(script)
# 创建版本记录
version = VWEDScriptVersion(
id=str(uuid.uuid4()),
script_id=script_id,
version=1,
code=code,
change_log="初始版本",
created_by=created_by,
created_on=datetime.datetime.now()
)
db.add(version)
# 物理保存脚本文件
try:
# 确保目录存在
os.makedirs(full_path, exist_ok=True)
# 保存脚本文件
with open(script_file_path, "w", encoding="utf-8") as f:
f.write(code)
logger.info(f"脚本文件已保存到: {script_file_path}")
except Exception as e:
logger.error(f"保存脚本文件失败: {str(e)}")
# 继续执行,不影响数据库操作
db.commit()
return {"id": script_id}
except Exception as e:
db.rollback()
logger.error(f"创建脚本失败: {str(e)}")
raise
@staticmethod
def update_script(
db: Session,
script_id: str,
folder_path: Optional[str] = None,
file_name: Optional[str] = None,
code: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
status: Optional[int] = None,
is_public: Optional[int] = None,
tags: Optional[str] = None,
test_params: Optional[str] = None,
change_log: Optional[str] = None,
updated_by: Optional[str] = None
) -> Dict[str, Any]:
"""
更新脚本
Args:
db: 数据库会话
script_id: 脚本ID
folder_path: 脚本所在目录路径
file_name: 脚本文件名
code: 脚本代码内容
name: 脚本名称
description: 脚本功能描述
status: 状态(1:启用, 0:禁用)
is_public: 是否公开(1:是, 0:否)
tags: 标签,用于分类查询
test_params: 测试参数(JSON格式)
change_log: 版本变更说明
updated_by: 更新者
Returns:
Dict[str, Any]: 更新后的脚本版本信息
"""
try:
# 查询脚本
script = db.query(VWEDScript).filter(VWEDScript.id == script_id).first()
if not script:
raise ValueError(f"脚本不存在: {script_id}")
# 检查文件名是否以.py结尾
if file_name and not file_name.endswith(".py"):
raise ValueError("脚本文件名必须以.py结尾")
# 记录更新前的信息,用于文件操作
old_folder_path = script.folder_path
old_file_name = script.file_name
# 处理脚本保存路径
if folder_path is not None:
if not folder_path or folder_path == "/":
folder_path = "/"
else:
# 确保路径以/开头
if not folder_path.startswith("/"):
folder_path = f"/{folder_path}"
# 确保路径以/结尾
if not folder_path.endswith("/"):
folder_path = f"{folder_path}/"
# 只有代码内容变更才会创建新版本
create_new_version = False
# 更新脚本属性
if folder_path is not None:
script.folder_path = folder_path
if file_name is not None:
script.file_name = file_name
if code is not None and code != script.code:
script.code = code
create_new_version = True
if name is not None:
script.name = name
if description is not None:
script.description = description
if status is not None:
script.status = status
if is_public is not None:
script.is_public = is_public
if tags is not None:
script.tags = tags
if test_params is not None:
script.test_params = test_params
script.updated_by = updated_by
script.updated_on = datetime.datetime.now()
# 如果代码内容变更,创建新版本
if create_new_version:
# 增加版本号
script.version += 1
new_version = script.version
# 创建版本记录
version = VWEDScriptVersion(
id=str(uuid.uuid4()),
script_id=script_id,
version=new_version,
code=code,
change_log=change_log or f"更新到版本 {new_version}",
created_by=updated_by,
created_on=datetime.datetime.now()
)
db.add(version)
else:
new_version = script.version
# 物理更新脚本文件
try:
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts", "user_save")
# 处理文件移动或重命名
path_or_name_changed = (folder_path is not None and folder_path != old_folder_path) or \
(file_name is not None and file_name != old_file_name)
# 旧文件路径
old_relative_path = old_folder_path.lstrip("/")
old_full_path = os.path.join(base_path, old_relative_path)
old_script_file_path = os.path.join(old_full_path, old_file_name)
# 新文件路径
new_folder_path = folder_path if folder_path is not None else old_folder_path
new_file_name = file_name if file_name is not None else old_file_name
new_relative_path = new_folder_path.lstrip("/")
new_full_path = os.path.join(base_path, new_relative_path)
new_script_file_path = os.path.join(new_full_path, new_file_name)
# 确保目录存在
os.makedirs(new_full_path, exist_ok=True)
if path_or_name_changed:
# 如果旧文件存在,则移动或重命名
if os.path.exists(old_script_file_path):
# 如果新文件已存在,先删除
if os.path.exists(new_script_file_path):
os.remove(new_script_file_path)
# 移动文件
os.rename(old_script_file_path, new_script_file_path)
logger.info(f"脚本文件已从 {old_script_file_path} 移动到 {new_script_file_path}")
# 如果代码内容变更,更新文件内容
if code is not None:
with open(new_script_file_path, "w", encoding="utf-8") as f:
f.write(code)
logger.info(f"脚本文件内容已更新: {new_script_file_path}")
except Exception as e:
logger.error(f"更新脚本文件失败: {str(e)}")
# 继续执行,不影响数据库操作
db.commit()
return {"version": new_version}
except Exception as e:
db.rollback()
logger.error(f"更新脚本失败: {str(e)}")
raise
@staticmethod
def delete_script(db: Session, script_id: str) -> None:
"""
删除脚本
Args:
db: 数据库会话
script_id: 脚本ID
"""
try:
# 查询脚本
script = db.query(VWEDScript).filter(VWEDScript.id == script_id).first()
if not script:
raise ValueError(f"脚本不存在: {script_id}")
# 记录脚本文件路径信息,用于后续删除物理文件
folder_path = script.folder_path
file_name = script.file_name
# 删除版本记录
db.query(VWEDScriptVersion).filter(VWEDScriptVersion.script_id == script_id).delete()
# 删除执行日志
db.query(VWEDScriptLog).filter(VWEDScriptLog.script_id == script_id).delete()
# 删除脚本记录
db.delete(script)
# 物理删除脚本文件
try:
# 构建完整的物理文件路径
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts", "user_save")
relative_path = folder_path.lstrip("/")
full_path = os.path.join(base_path, relative_path)
script_file_path = os.path.join(full_path, file_name)
# 如果文件存在,则删除
if os.path.exists(script_file_path):
os.remove(script_file_path)
logger.info(f"脚本文件已删除: {script_file_path}")
# 检查目录是否为空,如果为空则删除目录
if os.path.exists(full_path) and not os.listdir(full_path):
os.rmdir(full_path)
logger.info(f"空目录已删除: {full_path}")
except Exception as e:
logger.error(f"删除脚本文件失败: {str(e)}")
# 继续执行,不影响数据库操作
db.commit()
except Exception as e:
db.rollback()
logger.error(f"删除脚本失败: {str(e)}")
raise
@staticmethod
async def run_script(
db: Session,
script_id: str,
params: Optional[Dict[str, Any]] = None,
task_record_id: Optional[str] = None,
block_record_id: Optional[str] = None
) -> Dict[str, Any]:
"""
运行脚本
Args:
db: 数据库会话
script_id: 脚本ID
params: 脚本执行参数
task_record_id: 关联的任务记录ID
block_record_id: 关联的任务块记录ID
Returns:
Dict[str, Any]: 脚本执行结果
"""
try:
# 查询脚本
script = db.query(VWEDScript).filter(VWEDScript.id == script_id).first()
if not script:
raise ValueError(f"脚本不存在: {script_id}")
# 检查脚本状态
if script.status != 1:
raise ValueError(f"脚本已禁用,无法执行: {script_id}")
# 创建日志记录
log_id = str(uuid.uuid4())
log = VWEDScriptLog(
id=log_id,
script_id=script_id,
version=script.version,
task_record_id=task_record_id,
block_record_id=block_record_id,
input_params=params,
started_on=datetime.datetime.now()
)
db.add(log)
db.commit()
# 在临时目录创建Python脚本文件
with tempfile.NamedTemporaryFile(suffix='.py', delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(script.code.encode('utf-8'))
# 执行脚本并获取结果
start_time = datetime.datetime.now()
# 创建异步任务执行脚本
loop = asyncio.get_event_loop()
task = loop.create_task(
ScriptService._execute_script(temp_path, params, log_id, db)
)
running_scripts[log_id] = task
# 等待执行完成或超时
try:
result = await asyncio.wait_for(task, timeout=settings.SCRIPT_TIMEOUT)
except asyncio.TimeoutError:
# 脚本执行超时,更新日志记录
log = db.query(VWEDScriptLog).filter(VWEDScriptLog.id == log_id).first()
if log:
log.status = 0
log.error_message = "脚本执行超时"
log.ended_on = datetime.datetime.now()
log.execution_time = int((datetime.datetime.now() - start_time).total_seconds() * 1000)
db.commit()
# 清理临时文件
try:
os.unlink(temp_path)
except:
pass
# 从运行中的脚本字典中移除
if log_id in running_scripts:
del running_scripts[log_id]
raise ValueError("脚本执行超时")
# 从运行中的脚本字典中移除
if log_id in running_scripts:
del running_scripts[log_id]
# 清理临时文件
try:
os.unlink(temp_path)
except:
pass
return {
"logId": log_id,
"result": result,
"executionTime": int((datetime.datetime.now() - start_time).total_seconds() * 1000)
}
except Exception as e:
logger.error(f"运行脚本失败: {str(e)}")
raise
@staticmethod
async def _execute_script(
script_path: str,
params: Optional[Dict[str, Any]],
log_id: str,
db: Session
) -> Dict[str, Any]:
"""
执行Python脚本文件
Args:
script_path: 脚本文件路径
params: 脚本参数
log_id: 日志ID
db: 数据库会话
Returns:
Dict[str, Any]: 脚本执行结果
"""
try:
# 在线程池中执行脚本
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
executor,
ScriptService._run_script_in_process,
script_path,
params
)
# 更新日志记录
log = db.query(VWEDScriptLog).filter(VWEDScriptLog.id == log_id).first()
if log:
log.status = 1
log.output_result = result
log.ended_on = datetime.datetime.now()
log.execution_time = int((log.ended_on - log.started_on).total_seconds() * 1000)
db.commit()
return result
except Exception as e:
error_message = str(e)
logger.error(f"脚本执行失败: {error_message}")
# 更新日志记录
log = db.query(VWEDScriptLog).filter(VWEDScriptLog.id == log_id).first()
if log:
log.status = 0
log.error_message = error_message
log.ended_on = datetime.datetime.now()
log.execution_time = int((log.ended_on - log.started_on).total_seconds() * 1000)
db.commit()
raise
@staticmethod
def _run_script_in_process(script_path: str, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""
在独立进程中执行Python脚本
Args:
script_path: 脚本文件路径
params: 脚本参数
Returns:
Dict[str, Any]: 脚本执行结果
"""
try:
# 加载模块
spec = importlib.util.spec_from_file_location("script_module", script_path)
if not spec or not spec.loader:
raise ValueError("无法加载脚本模块")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# 检查是否存在main函数
if hasattr(module, "main"):
# 调用main函数执行脚本
if params:
result = module.main(**params)
else:
result = module.main()
return {"status": "success", "output": result}
else:
# 没有main函数直接执行模块
return {"status": "success", "message": "脚本执行完成但未找到main函数"}
except Exception as e:
logger.error(f"脚本内部执行失败: {str(e)}")
raise ValueError(f"脚本内部执行失败: {str(e)}")
@staticmethod
async def stop_script(db: Session, log_id: str) -> None:
"""
停止正在执行的脚本
Args:
db: 数据库会话
log_id: 脚本执行日志ID
"""
try:
# 检查日志是否存在
log = db.query(VWEDScriptLog).filter(VWEDScriptLog.id == log_id).first()
if not log:
raise ValueError(f"脚本执行日志不存在: {log_id}")
# 检查脚本是否正在运行
if log_id not in running_scripts:
raise ValueError(f"脚本未在运行中: {log_id}")
# 取消任务
task = running_scripts[log_id]
task.cancel()
# 从运行中的脚本字典中移除
del running_scripts[log_id]
# 更新日志记录
log.status = 0
log.error_message = "脚本执行被手动终止"
log.ended_on = datetime.datetime.now()
if log.started_on:
log.execution_time = int((log.ended_on - log.started_on).total_seconds() * 1000)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"停止脚本执行失败: {str(e)}")
raise
@staticmethod
def get_script_log(db: Session, log_id: str) -> Dict[str, Any]:
"""
获取脚本执行日志详情
Args:
db: 数据库会话
log_id: 脚本执行日志ID
Returns:
Dict[str, Any]: 脚本执行日志详情
"""
try:
# 查询日志
log = db.query(VWEDScriptLog).join(
VWEDScript, VWEDScript.id == VWEDScriptLog.script_id
).filter(
VWEDScriptLog.id == log_id
).first()
if not log:
raise ValueError(f"脚本执行日志不存在: {log_id}")
# 查询脚本版本代码
version = db.query(VWEDScriptVersion).filter(
VWEDScriptVersion.script_id == log.script_id,
VWEDScriptVersion.version == log.version
).first()
log_dict = {
"id": log.id,
"scriptId": log.script_id,
"scriptName": log.script.name if log.script else None,
"version": log.version,
"taskRecordId": log.task_record_id,
"blockRecordId": log.block_record_id,
"inputParams": log.input_params,
"outputResult": log.output_result,
"status": log.status,
"errorMessage": log.error_message,
"executionTime": log.execution_time,
"startedOn": log.started_on.strftime("%Y-%m-%d %H:%M:%S") if log.started_on else None,
"endedOn": log.ended_on.strftime("%Y-%m-%d %H:%M:%S") if log.ended_on else None,
"code": version.code if version else None
}
return log_dict
except Exception as e:
logger.error(f"获取脚本执行日志详情失败: {str(e)}")
raise