From 58a27e52b4b2e0a56b6bf594af718e310c7cccc9 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Sat, 29 Aug 2026 12:27:29 +0800 Subject: [PATCH] =?UTF-8?q?approve:=20script=5Fengine=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=BC=80=E5=8F=91=EF=BC=88W-06=20=E8=84=9A=E6=9C=AC=E5=BC=95?= =?UTF-8?q?=E6=93=8E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- README.md | 43 +++-- build.sh | 41 ++++ init/data.json | 21 +- json/script.json | 14 ++ models/script.json | 54 +++--- pyproject.toml | 1 + script_engine/__init__.py | 22 +-- script_engine/engine.py | 107 +++++++++++ script_engine/init.py | 321 ++++++++++++++++++++----------- scripts/load_path.py | 88 ++++----- skill/SKILL.md | 42 ++-- wwwroot/api/execute_script.dspy | 4 +- wwwroot/api/script_create.dspy | 2 + wwwroot/api/script_delete.dspy | 2 + wwwroot/api/script_execute.dspy | 2 + wwwroot/api/script_get.dspy | 2 + wwwroot/api/script_list.dspy | 2 + wwwroot/api/script_update.dspy | 2 + wwwroot/api/script_validate.dspy | 2 + wwwroot/api/validate_script.dspy | 4 +- wwwroot/i18n/script_engine.json | 15 ++ wwwroot/index.ui | 19 +- wwwroot/script_list.ui | 42 ++++ 24 files changed, 577 insertions(+), 280 deletions(-) create mode 100644 build.sh create mode 100644 json/script.json create mode 100644 script_engine/engine.py create mode 100644 wwwroot/api/script_create.dspy create mode 100644 wwwroot/api/script_delete.dspy create mode 100644 wwwroot/api/script_execute.dspy create mode 100644 wwwroot/api/script_get.dspy create mode 100644 wwwroot/api/script_list.dspy create mode 100644 wwwroot/api/script_update.dspy create mode 100644 wwwroot/api/script_validate.dspy create mode 100644 wwwroot/i18n/script_engine.json create mode 100644 wwwroot/script_list.ui diff --git a/.gitignore b/.gitignore index c342dcb..f36f99f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ __pycache__/ *.egg-info/ build/ dist/ -*.swp -*.swo models/mysql.ddl.sql -wwwroot/script_engine/ +wwwroot/script/ +.venv/ diff --git a/README.md b/README.md index 905fe41..6c35ad3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,35 @@ -# script_engine 模块 +# script_engine 脚本引擎模块(W-06) -逻辑编程(脚本/规则引擎)模块:管理逻辑脚本/规则表 `script_engine`,提供脚本执行与语法校验接口。 +脚本引擎:脚本表 CRUD + `execute_script` / `validate_script` 接口。独立模块,无业务依赖,可与 world / scene / entity 链路并行开发。 ## 功能 -- `script_engine` 表 CRUD:脚本名称、编码、所属世界/场景/绑定实体、脚本类型、触发事件、脚本内容、状态。 -- `api/execute_script.dspy`:按 `script_type` 选择执行器执行脚本内容,返回 `{success, result}`。 -- `api/validate_script.dspy`:按 `script_type` 做语法/格式校验,返回 `{valid, message}`。 +- **script 表 CRUD**:新增 / 编辑 / 删除 / 详情 / 分页列表 +- **execute_script**:按脚本 id 取库内脚本(或直传 content),校验通过后执行 +- **validate_script**:校验脚本内容(语法 + 受限语法 + 调用白名单),不落库不执行 +- **编码字典**:`script_type`(0=Python / 1=JavaScript / 2=规则表达式)、`script_status`(0=停用 / 1=启用)经 init/data.json(Format B appcodes)幂等落库 ## 数据表 -- `script_engine`(逻辑脚本表),脚本内容 `content` 使用 `text` 字段。 +- `script`:id / script_name / script_type / content / description / status / created_at / updated_at -## 脚本类型(appcodes script_type) -- `0` = Python 脚本(受限命名空间执行,通过 `result` 变量返回结果) -- `1` = 规则(JSON)(解析 JSON 作为结果) -- `2` = 表达式(eval 求值) +## 接口约定 +- REST 统一前缀:`/api/*`(宿主自动路由 `/script_engine/api/xxx.dspy`) +- 错误结构:`{code, message, field, detail}`(code=0 成功,400 参数错误,404 不存在,500 内部错误,501 类型暂不支持) +- 分页:`{list, total}` +- 非法输入 100% 拦截,不落库、不执行 -## 挂载 -宿主应用入口 `init()` 中调用 `load_script_engine()`。 +## 集成 +1. `pip install .` +2. 宿主应用 `from script_engine.init import load_script_engine` → `init()` 内调用 `load_script_engine()` +3. `scripts/load_path.py` 登记 RBAC 路径;宿主 `load_path.py` 兜底 +4. `build.sh` 生成 DDL / CRUD UI / 链接 wwwroot -## 安装 -```bash -pip install . -``` +## 接口清单(wwwroot/api/) +| 路径 | 说明 | +|---|---| +| `/script_engine/api/script_create.dspy` | 新增脚本 | +| `/script_engine/api/script_update.dspy` | 编辑脚本 | +| `/script_engine/api/script_delete.dspy` | 删除脚本 | +| `/script_engine/api/script_get.dspy` | 脚本详情 | +| `/script_engine/api/script_list.dspy` | 分页列表({list,total}) | +| `/script_engine/api/script_execute.dspy` | 执行脚本 | +| `/script_engine/api/script_validate.dspy` | 校验脚本 | diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..d31f2d0 --- /dev/null +++ b/build.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# script_engine 模块构建脚本(由宿主应用 build.sh 集成调用) +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -d "$SCRIPT_DIR/models" ]; then + if command -v json2ddl >/dev/null 2>&1; then + json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql" + else + echo "[script_engine] json2ddl 不可用,跳过 DDL 生成" + fi +fi + +if [ -d "$SCRIPT_DIR/json" ]; then + if command -v xls2ui >/dev/null 2>&1; then + (cd "$SCRIPT_DIR" && xls2ui -m models -o wwwroot script_engine json/*.json) + else + echo "[script_engine] xls2ui 不可用,跳过 CRUD UI 生成" + fi +fi + +SAGE_ROOT="" +for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do + if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then + SAGE_ROOT="$(cd "$candidate" && pwd)" + break + fi +done +if [ -n "$SAGE_ROOT" ]; then + mkdir -p "$SAGE_ROOT/wwwroot/script_engine" + ln -sfn "$SCRIPT_DIR/wwwroot/index.ui" "$SAGE_ROOT/wwwroot/script_engine/index.ui" + mkdir -p "$SAGE_ROOT/wwwroot/script_engine/api" + for f in "$SCRIPT_DIR"/wwwroot/api/*.dspy; do + ln -sfn "$f" "$SAGE_ROOT/wwwroot/script_engine/api/$(basename "$f")" + done + echo "[script_engine] wwwroot 已链接到 $SAGE_ROOT/wwwroot/script_engine" +else + echo "[script_engine] 未找到宿主 wwwroot,跳过链接" +fi + +echo "[script_engine] build.sh 完成" diff --git a/init/data.json b/init/data.json index d4eb27a..fff09c5 100644 --- a/init/data.json +++ b/init/data.json @@ -1,21 +1,6 @@ { "appcodes": [ - { - "parentid": "script_type", - "parentname": "脚本类型", - "items": [ - {"k": "0", "v": "Python脚本"}, - {"k": "1", "v": "规则(JSON)"}, - {"k": "2", "v": "表达式"} - ] - }, - { - "parentid": "script_status", - "parentname": "脚本状态", - "items": [ - {"k": "0", "v": "禁用"}, - {"k": "1", "v": "启用"} - ] - } + {"parentid": "script_type", "parentname": "脚本类型", "items": [{"k": "0", "v": "Python"}, {"k": "1", "v": "JavaScript"}, {"k": "2", "v": "规则表达式"}]}, + {"parentid": "script_status", "parentname": "脚本状态", "items": [{"k": "0", "v": "停用"}, {"k": "1", "v": "启用"}]} ] -} +} \ No newline at end of file diff --git a/json/script.json b/json/script.json new file mode 100644 index 0000000..a0a232b --- /dev/null +++ b/json/script.json @@ -0,0 +1,14 @@ +{ + "tblname": "script", + "title": "脚本管理", + "params": { + "sortby": ["created_at desc"], + "browserfields": {"exclouded": ["content"]}, + "editexclouded": ["id", "created_at", "updated_at"], + "editable": { + "new_data_url": "{{entire_url('../api/script_create.dspy')}}", + "update_data_url": "{{entire_url('../api/script_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/script_delete.dspy')}}" + } + } +} \ No newline at end of file diff --git a/models/script.json b/models/script.json index 2cb290a..e7d16fa 100644 --- a/models/script.json +++ b/models/script.json @@ -1,28 +1,28 @@ { - "summary": [{"name": "script", "title": "脚本定义表", "primary": ["id"], "catelog": "entity"}], - "fields": [ - {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "world_id", "title": "所属世界ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "name", "title": "脚本名称", "type": "str", "length": 255, "nullable": "no"}, - {"name": "code", "title": "脚本编码", "type": "str", "length": 64, "nullable": "no"}, - {"name": "script_type", "title": "脚本类型", "type": "str", "length": 16, "nullable": "no", "default": "lua"}, - {"name": "bind_type", "title": "绑定对象类型", "type": "str", "length": 16, "nullable": "no", "default": "world"}, - {"name": "bind_id", "title": "绑定对象ID", "type": "str", "length": 32, "nullable": "yes"}, - {"name": "trigger_event", "title": "触发事件", "type": "str", "length": 64, "nullable": "yes"}, - {"name": "content", "title": "脚本内容", "type": "text", "nullable": "yes"}, - {"name": "enabled", "title": "是否启用", "type": "str", "length": 1, "nullable": "no", "default": "0"}, - {"name": "created_by", "title": "创建人ID", "type": "str", "length": 32, "nullable": "yes"}, - {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, - {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} - ], - "indexes": [ - {"name": "idx_script_code", "idxtype": "unique", "idxfields": ["world_id", "code"]}, - {"name": "idx_script_world", "idxtype": "index", "idxfields": ["world_id"]}, - {"name": "idx_script_bind", "idxtype": "index", "idxfields": ["bind_type", "bind_id"]} - ], - "codes": [ - {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, - {"field": "script_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_type'"}, - {"field": "bind_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='bind_type'"} - ] -} + "summary": [ + { + "name": "script", + "title": "脚本表", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "script_name", "title": "脚本名称", "type": "str", "length": 100, "nullable": "no"}, + {"name": "script_type", "title": "脚本类型", "type": "str", "length": 32, "nullable": "no", "default": "0"}, + {"name": "content", "title": "脚本内容", "type": "text", "nullable": "no"}, + {"name": "description", "title": "描述", "type": "str", "length": 255}, + {"name": "status", "title": "状态", "type": "str", "length": 32, "nullable": "no", "default": "1"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_script_name", "idxtype": "index", "idxfields": ["script_name"]}, + {"name": "idx_script_type", "idxtype": "index", "idxfields": ["script_type"]} + ], + "codes": [ + {"field": "script_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_type'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_status'"} + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d1b4b55..9156478 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "script_engine" version = "1.0.0" +description = "脚本引擎模块(W-06):脚本 CRUD + execute_script/validate_script" requires-python = ">=3.8" dependencies = ["sqlor", "bricks_for_python"] diff --git a/script_engine/__init__.py b/script_engine/__init__.py index 638b804..f96ec9d 100644 --- a/script_engine/__init__.py +++ b/script_engine/__init__.py @@ -1,18 +1,6 @@ -"""script_engine 包 —— 逻辑编程(脚本/规则引擎)模块实现。""" -from .init import ( - load_script_engine, - execute_script, - validate_script, - create_script, - update_script, - delete_script, -) +# -*- coding: utf-8 -*- +"""script_engine 模块——脚本引擎(W-06)。""" +from script_engine.engine import (validate_script as validate_script, execute_script_content as execute_script_content) +from script_engine.init import (load_script_engine as load_script_engine, create_script as create_script, update_script as update_script, delete_script as delete_script, list_scripts as list_scripts, get_script as get_script, execute_script as execute_script, validate_script_api as validate_script_api) -__all__ = [ - 'load_script_engine', - 'execute_script', - 'validate_script', - 'create_script', - 'update_script', - 'delete_script', -] +__all__ = ['load_script_engine','create_script','update_script','delete_script','list_scripts','get_script','execute_script','validate_script_api','validate_script','execute_script_content'] diff --git a/script_engine/engine.py b/script_engine/engine.py new file mode 100644 index 0000000..c912d1c --- /dev/null +++ b/script_engine/engine.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""script_engine.engine —— 脚本引擎核心(纯逻辑,无 DB 依赖)。""" +import ast +import builtins + +SCRIPT_TYPE_PYTHON = '0' +SCRIPT_TYPE_JS = '1' +SCRIPT_TYPE_RULE = '2' +VALID_SCRIPT_TYPES = (SCRIPT_TYPE_PYTHON, SCRIPT_TYPE_JS, SCRIPT_TYPE_RULE) + +_ALLOWED_BUILTINS = frozenset(['abs','all','any','bool','dict','divmod','enumerate','filter','float','format','frozenset','int','isinstance','issubclass','iter','len','list','map','max','min','next','object','pow','range','repr','reversed','round','set','slice','sorted','str','sum','tuple','zip']) + +_FORBIDDEN_NODES = (ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Lambda, ast.ClassDef, ast.Yield, ast.YieldFrom, ast.AsyncFunctionDef, ast.Await) + + +def _pair_check(text, open_ch, close_ch): + depth = 0 + for ch in text: + if ch == open_ch: + depth += 1 + elif ch == close_ch: + depth -= 1 + if depth < 0: + return False + return depth == 0 + + +def validate_python(content): + if not content or not content.strip(): + return False, '脚本内容不能为空' + try: + tree = ast.parse(content, mode='exec') + except SyntaxError as e: + return False, '语法错误: %s (第 %s 行)' % (e.msg or 'unknown', e.lineno or 0) + for node in ast.walk(tree): + if isinstance(node, _FORBIDDEN_NODES): + return False, '禁止使用的语法: %s (第 %s 行)' % (type(node).__name__, getattr(node, 'lineno', 0)) + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name): + if func.id not in _ALLOWED_BUILTINS: + return False, '禁止调用的函数: %s (第 %s 行)' % (func.id, getattr(node, 'lineno', 0)) + elif isinstance(func, ast.Attribute): + return False, '禁止调用对象方法: %s (第 %s 行)' % (func.attr, getattr(node, 'lineno', 0)) + return True, '' + + +def validate_js(content): + if not content or not content.strip(): + return False, '脚本内容不能为空' + if not _pair_check(content, '{', '}'): + return False, '大括号不配对' + if not _pair_check(content, '(', ')'): + return False, '圆括号不配对' + if not _pair_check(content, '[', ']'): + return False, '方括号不配对' + return True, '' + + +def validate_rule(content): + if not content or not content.strip(): + return False, '脚本内容不能为空' + text = content.strip() + if text.startswith('{') or text.startswith('['): + try: + import json as _json + _json.loads(text) + return True, '' + except Exception as e: + return False, '规则 JSON 解析失败: %s' % str(e) + if '->' not in text: + return False, '规则格式应为 JSON 或 "条件 -> 动作"' + return True, '' + + +def validate_script(content, script_type): + stype = str(script_type or SCRIPT_TYPE_PYTHON) + if stype not in VALID_SCRIPT_TYPES: + return False, '不支持的脚本类型: %s' % stype + if stype == SCRIPT_TYPE_PYTHON: + return validate_python(content) + if stype == SCRIPT_TYPE_JS: + return validate_js(content) + return validate_rule(content) + + +def execute_python(content, params): + safe_builtins = {} + for name in _ALLOWED_BUILTINS: + if hasattr(builtins, name): + safe_builtins[name] = getattr(builtins, name) + namespace = {'__name__': '__script_engine__', '__builtins__': safe_builtins, 'params': params} + code = compile(content, '', 'exec') + exec(code, namespace) + main_fn = namespace.get('main') + if callable(main_fn): + return main_fn(params) + return {'executed': True, 'scope': {k: v for k, v in namespace.items() if not k.startswith('__') and not callable(v)}} + + +def execute_script_content(content, script_type, params): + stype = str(script_type or SCRIPT_TYPE_PYTHON) + if stype == SCRIPT_TYPE_PYTHON: + return execute_python(content, params or {}) + if stype == SCRIPT_TYPE_JS: + raise NotImplementedError('JavaScript 类型暂不支持执行') + raise NotImplementedError('规则表达式类型暂不支持执行') diff --git a/script_engine/init.py b/script_engine/init.py index 4b3e445..fc263d0 100644 --- a/script_engine/init.py +++ b/script_engine/init.py @@ -1,134 +1,235 @@ -"""script_engine 模块初始化。 - -逻辑编程(脚本/规则引擎)领域模块,通过 load_script_engine() 挂载到宿主应用, -向 ServerEnv 注册业务函数: -- execute_script(script_id, context_json) 执行脚本 -- validate_script(content, script_type) 校验脚本语法 -- create_script / update_script / delete_script CRUD 业务逻辑 -""" -import json as _json - -from appPublic.uniqueID import getID -from appPublic.timeUtils import curDateString +# -*- coding: utf-8 -*- +"""script_engine.init —— 模块初始化与业务函数注册。""" from ahserver.serverenv import ServerEnv from sqlor.dbpools import DBPools - -_MODULE = 'script_engine' +from appPublic.uniqueID import getID +from appPublic.timeUtils import curDateString +from script_engine.engine import validate_script as _validate_content, execute_script_content as _execute_content, VALID_SCRIPT_TYPES def _dbname(): - """取模块库名(禁止硬编码,由宿主应用 get_module_dbname 决定)。""" - return ServerEnv().get_module_dbname(_MODULE) + return ServerEnv().get_module_dbname('script_engine') -async def _get_script(sor, script_id): - rows = await sor.sqlExe( - 'SELECT * FROM script_engine WHERE id = ${id}$', {'id': script_id}) - return rows[0] if rows else None +def _ok(data=None, message='ok'): + return {'code': 0, 'message': message, 'field': '', 'detail': '', 'data': data} -async def execute_script(script_id, context_json=None): - """执行指定脚本(逻辑编程运行时)。""" - if not script_id: - return {'success': False, 'error': '缺少 script_id 参数'} - - context = {} - if context_json: - if isinstance(context_json, str): - try: - context = _json.loads(context_json) - except Exception: - context = {} - elif isinstance(context_json, dict): - context = context_json - - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - script = await _get_script(sor, script_id) - if not script: - return {'success': False, 'error': '脚本不存在: %s' % script_id} - script_type = script.get('script_type') or '0' - content = script.get('content') or '' - try: - if script_type == '0': - loc = {'context': context, 'result': None} - exec(content, {'__builtins__': {}}, loc) - return {'success': True, 'result': loc.get('result')} - if script_type == '1': - return {'success': True, 'result': _json.loads(content)} - val = eval(content, {'__builtins__': {}}, {'context': context}) - return {'success': True, 'result': val} - except Exception as e: - return {'success': False, 'error': '%s: %s' % (type(e).__name__, e)} +def _err(code, message, field='', detail=''): + return {'code': code, 'message': message, 'field': field, 'detail': detail} -async def validate_script(content, script_type='0'): - """校验脚本语法。""" - script_type = script_type or '0' - content = content or '' +def _clean_str(value, default=''): + if value is None: + return default + s = str(value).strip() + return s if s else default + + +def _validate_input(script_name, script_type, content): + if not script_name: + return False, 'script_name', '脚本名称不能为空' + if len(script_name) > 100: + return False, 'script_name', '脚本名称长度不能超过 100' + if script_type not in VALID_SCRIPT_TYPES: + return False, 'script_type', '不支持的脚本类型: %s' % script_type + if not content or not content.strip(): + return False, 'content', '脚本内容不能为空' + if len(content) > 65535: + return False, 'content', '脚本内容长度不能超过 65535' + ok, err = _validate_content(content, script_type) + if not ok: + return False, 'content', err + return True, '', '' + + +async def create_script(request, params_kw): try: - if script_type == '0': - compile(content, '