fix: 回填目标机现场好代码——远端main残缺/空导致部署后模块不可用

This commit is contained in:
pipeline-agent 2026-08-30 13:26:14 +08:00
parent b325354acf
commit 4785665c46
31 changed files with 300 additions and 1050 deletions

12
.gitignore vendored
View File

@ -1,17 +1,9 @@
# Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.egg-info/ *.egg-info/
build/ build/
dist/ dist/
.venv/
# generated artifacts
mysql.ddl.sql
wwwroot/script_engine_list/
# editors
*.swp *.swp
*.swo *.swo
.idea/ models/mysql.ddl.sql
.vscode/ wwwroot/script_engine/

View File

@ -1,30 +1,24 @@
# script_engine 逻辑编程(脚本/规则引擎)模块 # script_engine 模块
脚本/规则引擎:`script_engine` 脚本表 CRUD + 执行/校验接口。独立模块,无业务依赖。 逻辑编程(脚本/规则引擎)模块:管理逻辑脚本/规则表 `script_engine`,提供脚本执行与语法校验接口。
## 功能 ## 功能
- 脚本表 CRUD:`create_script / update_script / delete_script / get_script / list_scripts`(含复数别名) - `script_engine` 表 CRUD:脚本名称、编码、所属世界/场景/绑定实体、脚本类型、触发事件、脚本内容、状态。
- 执行接口:`execute_script`(按 id 或 content+script_type,校验后执行) - `api/execute_script.dspy`:按 `script_type` 选择执行器执行脚本内容,返回 `{success, result}`。
- 校验接口:`validate_script_api`(仅校验,不落库不执行) - `api/validate_script.dspy`:按 `script_type` 做语法/格式校验,返回 `{valid, message}`。
- 脚本类型:`0`=Python,`1`=SQL
- Python 受限执行:AST 校验(禁 import/类/λ/async/await/对象方法调用)+ builtins 白名单
- SQL 仅允许只读单语句(SELECT/SHOW/DESCRIBE/EXPLAIN),禁写语句/多语句/危险关键字
## 数据表 ## 数据表
- `script_engine`:id(str32 PK)、script_name(str100)、script_type(str32 default '0')、content(text)、description(str255)、status(str32 default '1')、created_at、updated_at - `script_engine`(逻辑脚本表),脚本内容 `content` 使用 `text` 字段。
- 索引:idx_script_name、idx_script_type
- 编码:script_type→appcodes_kv parentid='script_type'(0=Python,1=SQL);status→parentid='script_status'(0=停用,1=启用)
## 集成 ## 脚本类型(appcodes script_type)
```python - `0` = Python 脚本(受限命名空间执行,通过 `result` 变量返回结果)
from script_engine.init import load_script_engine - `1` = 规则(JSON)(解析 JSON 作为结果)
load_script_engine() # 注册全部函数到 ServerEnv - `2` = 表达式(eval 求值)
```
- 取库名:`ServerEnv().get_module_dbname('script_engine')`(.py)/ `get_module_dbname('script_engine')`(.dspy 全局)
- REST:`wwwroot/api/script_create.dspy` 等 11 个端点
- RBAC:`scripts/load_path.py` 显式注册(无通配符)
## 构建 ## 挂载
宿主应用入口 `init()` 中调用 `load_script_engine()`。
## 安装
```bash ```bash
./build.sh # 1) 装 xls2ddl 2) models→mysql.ddl.sql 3) json→CRUD UI 4) wwwroot 软链 pip install .
``` ```

View File

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*- """script_engine 模块 —— 逻辑编程(脚本/规则引擎)。
"""script_engine module root package.
Delegates to the real package script_engine.script_engine so that 通过 load_script_engine() 挂载到宿主应用。
`import script_engine` exposes the full public API.
""" """
from script_engine.script_engine import * # noqa: F401,F403 from .script_engine.init import load_script_engine
from script_engine.script_engine import __all__ # noqa: F401
__all__ = ['load_script_engine']

View File

@ -1,56 +0,0 @@
#!/usr/bin/env bash
# script_engine module build - four steps:
# 1) install xls2ddl
# 2) models/*.json -> mysql.ddl.sql (json2ddl)
# 3) json/*.json -> generated CRUD UI (xls2ui)
# 4) symlink module wwwroot into host app wwwroot
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
PYTHON="${PYTHON:-python3}"
echo "[1/4] install xls2ddl..."
pip install xls2ddl
echo "[2/4] generate DDL from models/*.json..."
if [ -d models ]; then
$PYTHON -m xls2ddl.json2ddl mysql . > mysql.ddl.sql
fi
echo "[3/4] generate CRUD UI from json/*.json..."
if [ -d json ]; then
$PYTHON -m xls2ddl.xls2ui -m models -o wwwroot script_engine json/*.json
fi
echo "[4/4] symlink module wwwroot into host app wwwroot..."
# locate host app wwwroot: prefer sibling app dir under the same workspace
HOST_WWWROOT=""
for candidate in \
"$SCRIPT_DIR/../../apps/yuanjing/wwwroot" \
"$SCRIPT_DIR/../../yuanjing/wwwroot" \
"$SCRIPT_DIR/../yuanjing/wwwroot" \
"$HOME/repos/yuanjing/wwwroot"; do
if [ -d "$candidate" ]; then
HOST_WWWROOT="$(cd "$candidate" && pwd)"
break
fi
done
if [ -n "$HOST_WWWROOT" ]; then
mkdir -p "$HOST_WWWROOT/script_engine"
ln -sfn "$SCRIPT_DIR/wwwroot" "$HOST_WWWROOT/script_engine"
# link generated CRUD subdirs (script_engine_list) individually
for sub in "$SCRIPT_DIR/wwwroot"/*/; do
name="$(basename "$sub")"
case "$name" in
api|styles|scripts) continue ;;
esac
ln -sfn "$sub" "$HOST_WWWROOT/script_engine/$name"
done
echo "wwwroot linked to $HOST_WWWROOT/script_engine"
else
echo "WARN: host app wwwroot not found - skip symlink (deploy step will link)"
fi
echo "script_engine build done"

View File

@ -4,15 +4,16 @@
"parentid": "script_type", "parentid": "script_type",
"parentname": "脚本类型", "parentname": "脚本类型",
"items": [ "items": [
{"k": "0", "v": "Python"}, {"k": "0", "v": "Python脚本"},
{"k": "1", "v": "SQL"} {"k": "1", "v": "规则(JSON)"},
{"k": "2", "v": "表达式"}
] ]
}, },
{ {
"parentid": "script_status", "parentid": "script_status",
"parentname": "脚本状态", "parentname": "脚本状态",
"items": [ "items": [
{"k": "0", "v": "停用"}, {"k": "0", "v": "禁用"},
{"k": "1", "v": "启用"} {"k": "1", "v": "启用"}
] ]
} }

View File

@ -1,14 +0,0 @@
{
"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')}}"
}
}
}

View File

@ -1,38 +1,30 @@
{ {
"tblname": "script_engine", "tblname": "script_engine",
"title": "脚本管理", "title": "逻辑脚本",
"params": { "params": {
"sortby": ["created_at desc"], "sortby": ["updated_at desc"],
"editable": {
"new_data_url": "{{entire_url('../api/script_engine_create.dspy')}}",
"update_data_url": "{{entire_url('../api/script_engine_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/script_engine_delete.dspy')}}"
},
"browserfields": { "browserfields": {
"exclouded": ["id", "content"], "exclouded": ["id", "content"],
"alters": { "alters": {
"script_type": { "world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"},
"uitype": "code", "scene_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"},
"dataurl": "{{entire_url('../api/get_search_script_type.dspy')}}" "entity_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_entity_id.dspy')}}"},
}, "script_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_script_type.dspy')}}"},
"status": { "status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_status.dspy')}}"
}
} }
}, },
"editexclouded": ["id", "created_at", "updated_at"], "editexclouded": ["id", "created_at", "updated_at"],
"data_filter": { "data_filter": {
"AND": [ "AND": [
{"field": "script_name", "op": "LIKE", "var": "script_name"}, {"field": "name", "op": "LIKE", "var": "name"},
{"field": "script_type", "op": "=", "var": "script_type"}, {"field": "world_id", "op": "=", "var": "world_id"},
{"field": "status", "op": "=", "var": "status"} {"field": "entity_id", "op": "=", "var": "entity_id"}
] ]
},
"filter_labels": {
"script_name": "脚本名称",
"script_type": "脚本类型",
"status": "状态"
},
"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')}}"
} }
} }
} }

View File

@ -1,28 +1,28 @@
{ {
"summary": [ "summary": [{"name": "script", "title": "脚本定义表", "primary": ["id"], "catelog": "entity"}],
{
"name": "script",
"title": "脚本表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [ "fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "script_name", "title": "脚本名称", "type": "str", "length": 100, "nullable": "no"}, {"name": "world_id", "title": "所属世界ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "script_type", "title": "脚本类型", "type": "str", "length": 32, "nullable": "no", "default": "0"}, {"name": "name", "title": "脚本名称", "type": "str", "length": 255, "nullable": "no"},
{"name": "content", "title": "脚本内容", "type": "text", "nullable": "no"}, {"name": "code", "title": "脚本编码", "type": "str", "length": 64, "nullable": "no"},
{"name": "description", "title": "描述", "type": "str", "length": 255}, {"name": "script_type", "title": "脚本类型", "type": "str", "length": 16, "nullable": "no", "default": "lua"},
{"name": "status", "title": "状态", "type": "str", "length": 32, "nullable": "no", "default": "1"}, {"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": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
], ],
"indexes": [ "indexes": [
{"name": "idx_script_name", "idxtype": "index", "idxfields": ["script_name"]}, {"name": "idx_script_code", "idxtype": "unique", "idxfields": ["world_id", "code"]},
{"name": "idx_script_type", "idxtype": "index", "idxfields": ["script_type"]} {"name": "idx_script_world", "idxtype": "index", "idxfields": ["world_id"]},
{"name": "idx_script_bind", "idxtype": "index", "idxfields": ["bind_type", "bind_id"]}
], ],
"codes": [ "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": "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'"} {"field": "bind_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='bind_type'"}
] ]
} }

View File

@ -2,93 +2,35 @@
"summary": [ "summary": [
{ {
"name": "script_engine", "name": "script_engine",
"title": "脚本表", "title": "逻辑脚本表",
"primary": ["id"], "primary": ["id"],
"catelog": "entity" "catelog": "entity"
} }
], ],
"fields": [ "fields": [
{ {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
"name": "id", {"name": "world_id", "title": "所属世界", "type": "str", "length": 32, "nullable": "no"},
"title": "主键ID", {"name": "scene_id", "title": "所属场景", "type": "str", "length": 32, "nullable": "yes"},
"type": "str", {"name": "entity_id", "title": "绑定实体", "type": "str", "length": 32, "nullable": "yes"},
"length": 32, {"name": "name", "title": "脚本名称", "type": "str", "length": 255, "nullable": "no"},
"nullable": "no" {"name": "code", "title": "脚本编码", "type": "str", "length": 64, "nullable": "no"},
}, {"name": "script_type", "title": "脚本类型", "type": "str", "length": 16, "nullable": "no", "default": "0"},
{ {"name": "trigger_event", "title": "触发事件", "type": "str", "length": 64, "nullable": "yes"},
"name": "script_name", {"name": "content", "title": "脚本内容", "type": "text", "nullable": "no"},
"title": "脚本名称", {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "0"},
"type": "str", {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
"length": 100, {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"}
"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": [ "indexes": [
{ {"name": "idx_script_code", "idxtype": "unique", "idxfields": ["code"]},
"name": "idx_script_name", {"name": "idx_script_world", "idxtype": "index", "idxfields": ["world_id"]},
"idxtype": "index", {"name": "idx_script_entity", "idxtype": "index", "idxfields": ["entity_id"]}
"idxfields": ["script_name"]
},
{
"name": "idx_script_type",
"idxtype": "index",
"idxfields": ["script_type"]
}
], ],
"codes": [ "codes": [
{ {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
"field": "script_type", {"field": "scene_id", "table": "scene", "valuefield": "id", "textfield": "name"},
"table": "appcodes_kv", {"field": "entity_id", "table": "entity", "valuefield": "id", "textfield": "name"},
"valuefield": "k", {"field": "script_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_type'"},
"textfield": "v", {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_status'"}
"cond": "parentid='script_type'"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='script_status'"
}
] ]
} }

View File

@ -5,7 +5,6 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "script_engine" name = "script_engine"
version = "1.0.0" version = "1.0.0"
description = "Logic programming (script/rule engine) module: script table CRUD + execute/validate interfaces (0=Python, 1=SQL)"
requires-python = ">=3.8" requires-python = ">=3.8"
dependencies = ["sqlor", "bricks_for_python"] dependencies = ["sqlor", "bricks_for_python"]

View File

@ -1,36 +1,18 @@
"""script_engine package: logic programming (script/rule engine) module.""" """script_engine 包 —— 逻辑编程(脚本/规则引擎)模块实现。"""
from .engine import (
execute_python,
validate_python_source,
validate_sql,
)
from .init import ( from .init import (
create_script,
create_scripts,
delete_script,
delete_scripts,
execute_script,
get_script,
list_scripts,
load_script_engine, load_script_engine,
execute_script,
validate_script,
create_script,
update_script, update_script,
update_scripts, delete_script,
validate_script_api,
) )
__all__ = [ __all__ = [
'create_script',
'create_scripts',
'delete_script',
'delete_scripts',
'execute_python',
'execute_script',
'get_script',
'list_scripts',
'load_script_engine', 'load_script_engine',
'execute_script',
'validate_script',
'create_script',
'update_script', 'update_script',
'update_scripts', 'delete_script',
'validate_python_source',
'validate_script_api',
'validate_sql',
] ]

View File

@ -1,146 +0,0 @@
# -*- coding: utf-8 -*-
"""script_engine execution engine.
Python scripts run in a restricted namespace: only whitelisted builtin
functions are callable; import / class / lambda / async / await and
object method calls are forbidden (AST validation).
SQL scripts are read-only single statements (SELECT/SHOW/DESCRIBE/EXPLAIN);
write statements, multi-statement and dangerous keywords are rejected.
"""
import ast
import builtins
import contextlib
import io
import re
import traceback
# whitelisted builtin names callable inside python scripts
PY_WHITELIST = {
'abs', 'all', 'any', 'bin', 'bool', 'bytearray', 'bytes', 'callable',
'chr', 'complex', 'dict', 'divmod', 'enumerate', 'filter', 'float',
'format', 'frozenset', 'hash', 'hex', 'int', 'isinstance', 'issubclass',
'iter', 'len', 'list', 'map', 'max', 'min', 'next', 'oct', 'ord', 'pow',
'print', 'range', 'repr', 'reversed', 'round', 'set', 'slice', 'sorted',
'str', 'sum', 'tuple', 'zip',
'True', 'False', 'None',
}
# builtins that are never allowed even if listed elsewhere
PY_FORBIDDEN_BUILTINS = {
'__import__', 'eval', 'exec', 'compile', 'open', 'input', 'globals',
'locals', 'vars', 'dir', 'getattr', 'setattr', 'delattr', 'hasattr',
'memoryview', 'breakpoint', 'exit', 'quit', 'help', 'type', 'object',
}
# AST node types that are forbidden in python scripts
PY_FORBIDDEN_NODE_TYPES = (
ast.Import,
ast.ImportFrom,
ast.ClassDef,
ast.Lambda,
ast.AsyncFunctionDef,
ast.AsyncFor,
ast.AsyncWith,
ast.Await,
ast.Global,
ast.Nonlocal,
ast.Delete,
ast.Yield,
ast.YieldFrom,
)
# SQL statement starters that are read-only
SQL_READONLY_FIRST = ('SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN')
# SQL keywords that indicate write / execution / privilege operations
SQL_FORBIDDEN_KEYWORDS = (
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE', 'TRUNCATE',
'GRANT', 'REVOKE', 'MERGE', 'REPLACE', 'RENAME', 'CALL', 'LOAD',
'LOCK', 'UNLOCK', 'SET', 'USE', 'BEGIN', 'COMMIT', 'ROLLBACK',
'SAVEPOINT', 'INTO', 'OUTFILE', 'INFILE', 'PREPARE', 'EXECUTE',
'DEALLOCATE', 'PROCEDURE', 'TRIGGER', 'EVENT', 'TEMPORARY', 'PARTITION',
)
def _has_keyword(text, kw):
return re.search(r'\b' + kw + r'\b', text, re.IGNORECASE) is not None
def validate_python_source(content):
"""AST-validate python source. Returns (ok, message)."""
if not content or not str(content).strip():
return False, 'python content is empty'
try:
tree = ast.parse(str(content))
except SyntaxError as e:
return False, 'syntax error: %s' % str(e)
for node in ast.walk(tree):
if isinstance(node, PY_FORBIDDEN_NODE_TYPES):
return False, 'forbidden python syntax: ' + type(node).__name__
if isinstance(node, ast.Call):
fn = node.func
if isinstance(fn, ast.Name):
if fn.id in PY_FORBIDDEN_BUILTINS:
return False, 'forbidden builtin: ' + fn.id
if fn.id not in PY_WHITELIST:
return False, 'unknown function: ' + fn.id
elif isinstance(fn, ast.Attribute):
return False, 'object method call is not allowed'
return True, ''
def execute_python(content, params=None):
"""Execute python source in a restricted namespace.
Script may read `params` dict and set `result`; stdout is captured.
Returns {code, message, field, detail, data}.
"""
ok, msg = validate_python_source(content)
if not ok:
return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''}
safe_builtins = {}
for name in PY_WHITELIST:
if name in ('True', 'False', 'None'):
continue
if hasattr(builtins, name):
safe_builtins[name] = getattr(builtins, name)
safe_builtins['__builtins__'] = safe_builtins
ns = {'params': dict(params or {})}
ns.update(safe_builtins)
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
exec(compile(str(content), '<script_engine>', 'exec'), ns)
return {
'code': 0,
'message': 'ok',
'field': '',
'detail': '',
'data': {'result': ns.get('result'), 'output': buf.getvalue()},
}
except Exception as e:
return {
'code': 1,
'message': str(e),
'field': '',
'detail': traceback.format_exc(),
}
def validate_sql(sql):
"""Validate SQL source. Returns (ok, message)."""
if not sql or not str(sql).strip():
return False, 'sql content is empty'
text = str(sql).strip().rstrip(';').strip()
if not text:
return False, 'sql content is empty'
parts = [p.strip() for p in text.split(';') if p.strip()]
if len(parts) > 1:
return False, 'multi-statement sql is not allowed'
first = text.split(None, 1)[0].upper()
if first not in SQL_READONLY_FIRST:
return False, 'only SELECT/SHOW/DESCRIBE/EXPLAIN statements are allowed'
for kw in SQL_FORBIDDEN_KEYWORDS:
if _has_keyword(text, kw):
return False, 'forbidden sql keyword: ' + kw
return True, ''

View File

@ -1,327 +1,134 @@
# -*- coding: utf-8 -*- """script_engine 模块初始化。
"""script_engine module init.
Register script table CRUD + execute/validate interfaces to ServerEnv. 逻辑编程(脚本/规则引擎)领域模块,通过 load_script_engine() 挂载到宿主应用,
Functions: 向 ServerEnv 注册业务函数:
create_script / update_script / delete_script / get_script / list_scripts - execute_script(script_id, context_json) 执行脚本
execute_script / validate_script_api - validate_script(content, script_type) 校验脚本语法
plural aliases: create_scripts / update_scripts / delete_scripts - create_script / update_script / delete_script CRUD 业务逻辑
Entry point: load_script_engine()
""" """
try: import json as _json
from ahserver.serverenv import ServerEnv
except ImportError: # pragma: no cover - alternate package layout
try:
from ahserver.serverEnv import ServerEnv
except ImportError: # pragma: no cover - alternate package layout
from sqlor.dbpools import ServerEnv
from appPublic.log import debug
from appPublic.timeUtils import curDateString
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString
from ahserver.serverenv import ServerEnv
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
from .engine import ( _MODULE = 'script_engine'
execute_python,
validate_python_source,
validate_sql,
)
MODULE = 'script_engine'
TABLE = 'script_engine'
SCRIPT_TYPE_PYTHON = '0'
SCRIPT_TYPE_SQL = '1'
STATUS_ENABLED = '1'
def _dbname(): def _dbname():
return ServerEnv().get_module_dbname(MODULE) """取模块库名(禁止硬编码,由宿主应用 get_module_dbname 决定)。"""
return ServerEnv().get_module_dbname(_MODULE)
def _clean_params(params_kw): async def _get_script(sor, script_id):
if hasattr(params_kw, 'copy'): rows = await sor.sqlExe(
return params_kw.copy() 'SELECT * FROM script_engine WHERE id = ${id}$', {'id': script_id})
return dict(params_kw or {}) return rows[0] if rows else None
def _check_name(name): async def execute_script(script_id, context_json=None):
if not name: """执行指定脚本(逻辑编程运行时)。"""
return 'script_name is required' if not script_id:
if len(name) > 100: return {'success': False, 'error': '缺少 script_id 参数'}
return 'script_name is too long (max 100)'
return '' 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 _check_type(stype): async def validate_script(content, script_type='0'):
if stype not in (SCRIPT_TYPE_PYTHON, SCRIPT_TYPE_SQL): """校验脚本语法。"""
return 'invalid script_type (0=python, 1=sql)' script_type = script_type or '0'
return '' content = content or ''
try:
if script_type == '0':
compile(content, '<script>', 'exec')
return {'valid': True, 'message': 'Python 脚本语法正确'}
if script_type == '1':
_json.loads(content or '{}')
return {'valid': True, 'message': 'JSON 规则格式正确'}
compile(content or '0', '<expr>', 'eval')
return {'valid': True, 'message': '表达式语法正确'}
except Exception as e:
return {'valid': False, 'message': '%s: %s' % (type(e).__name__, e)}
def _validate_content(stype, content): async def create_script(ns):
if stype == SCRIPT_TYPE_PYTHON: """新建脚本。"""
return validate_python_source(content) code = ns.get('code') or ''
return validate_sql(content) db = DBPools()
async with db.sqlorContext(_dbname()) as sor:
if code:
async def create_script(request, params_kw): rows = await sor.sqlExe(
ns = _clean_params(params_kw) 'SELECT id FROM script_engine WHERE code = ${code}$', {'code': code})
name = str(ns.get('script_name') or '').strip() if rows:
err = _check_name(name) return {'success': False, 'error': '脚本编码已存在'}
if err: ns['id'] = getID()
return {'code': 1, 'message': err, 'field': 'script_name', 'detail': ''}
stype = str(ns.get('script_type') or SCRIPT_TYPE_PYTHON)
err = _check_type(stype)
if err:
return {'code': 1, 'message': err, 'field': 'script_type', 'detail': ''}
content = str(ns.get('content') or '')
if not content.strip():
return {'code': 1, 'message': 'content is required', 'field': 'content', 'detail': ''}
ok, msg = _validate_content(stype, content)
if not ok:
return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''}
now = curDateString() now = curDateString()
rec = { ns['created_at'] = now
'id': getID(), ns['updated_at'] = now
'script_name': name, ns.setdefault('status', '0')
'script_type': stype, ns.setdefault('script_type', '0')
'content': content, await sor.C('script_engine', ns)
'description': str(ns.get('description') or '')[:255], return {'success': True, 'id': ns['id']}
'status': str(ns.get('status') or STATUS_ENABLED),
'created_at': now,
'updated_at': now,
}
dbname = _dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
await sor.C(TABLE, rec)
except Exception as e:
debug('script_engine create_script error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return {'code': 0, 'message': 'ok', 'data': {'id': rec['id']}}
async def update_script(request, params_kw): async def update_script(ns):
ns = _clean_params(params_kw) """更新脚本。"""
sid = str(ns.get('id') or '').strip() rid = ns.get('id')
if not sid: if not rid:
return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''} return {'success': False, 'error': '缺少 id 参数'}
upd = {'id': sid, 'updated_at': curDateString()} upd = {k: v for k, v in ns.items() if k != 'id'}
if ns.get('script_name') is not None: upd['id'] = rid
name = str(ns.get('script_name') or '').strip() upd['updated_at'] = curDateString()
err = _check_name(name) db = DBPools()
if err: async with db.sqlorContext(_dbname()) as sor:
return {'code': 1, 'message': err, 'field': 'script_name', 'detail': ''} await sor.U('script_engine', upd)
upd['script_name'] = name return {'success': True, 'id': rid}
if ns.get('script_type') is not None:
stype = str(ns.get('script_type'))
err = _check_type(stype)
if err:
return {'code': 1, 'message': err, 'field': 'script_type', 'detail': ''}
upd['script_type'] = stype
if ns.get('content') is not None:
stype = str(ns.get('script_type') or upd.get('script_type') or SCRIPT_TYPE_PYTHON)
content = str(ns.get('content') or '')
if not content.strip():
return {'code': 1, 'message': 'content is required', 'field': 'content', 'detail': ''}
ok, msg = _validate_content(stype, content)
if not ok:
return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''}
upd['content'] = content
if ns.get('description') is not None:
upd['description'] = str(ns.get('description'))[:255]
if ns.get('status') is not None:
upd['status'] = str(ns.get('status'))
dbname = _dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
await sor.U(TABLE, upd)
except Exception as e:
debug('script_engine update_script error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return {'code': 0, 'message': 'ok', 'data': {'id': sid}}
async def delete_script(request, params_kw): async def delete_script(ns):
ns = _clean_params(params_kw) """删除脚本。"""
sid = str(ns.get('id') or '').strip() rid = ns.get('id')
if not sid: if not rid:
return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''} return {'success': False, 'error': '缺少 id 参数'}
dbname = _dbname() db = DBPools()
try: async with db.sqlorContext(_dbname()) as sor:
async with DBPools().sqlorContext(dbname) as sor: await sor.D('script_engine', {'id': rid})
await sor.D(TABLE, {'id': sid}) return {'success': True, 'id': rid}
except Exception as e:
debug('script_engine delete_script error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return {'code': 0, 'message': 'ok', 'data': {'id': sid}}
async def get_script(request, params_kw): def load_script_engine():
ns = _clean_params(params_kw) """挂载 script_engine 模块到 ServerEnv。"""
sid = str(ns.get('id') or '').strip()
if not sid:
return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''}
dbname = _dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': sid})
except Exception as e:
debug('script_engine get_script error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
if not recs:
return {'code': 1, 'message': 'script not found', 'field': 'id', 'detail': ''}
r = recs[0]
return {'code': 0, 'message': 'ok', 'data': {
'id': r.id,
'script_name': r.script_name,
'script_type': r.script_type,
'content': r.content,
'description': getattr(r, 'description', ''),
'status': r.status,
'created_at': r.created_at,
'updated_at': r.updated_at,
}}
async def list_scripts(request, params_kw):
ns = _clean_params(params_kw)
try:
page = int(ns.get('page') or 1)
except (TypeError, ValueError):
page = 1
try:
rows = int(ns.get('rows') or ns.get('pagerows') or 20)
except (TypeError, ValueError):
rows = 20
page = max(page, 1)
rows = min(max(rows, 1), 500)
offset = (page - 1) * rows
conds = []
vals = {}
name = str(ns.get('script_name') or '').strip()
if name:
conds.append('script_name like ${script_name}$')
vals['script_name'] = '%' + name + '%'
stype = str(ns.get('script_type') or '').strip()
if stype:
conds.append('script_type = ${script_type}$')
vals['script_type'] = stype
status = str(ns.get('status') or '').strip()
if status:
conds.append('status = ${status}$')
vals['status'] = status
where = ''
if conds:
where = ' where ' + ' and '.join(conds)
dbname = _dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
cnts = await sor.sqlExe('select count(*) as cnt from ' + TABLE + where, vals)
total = int(cnts[0].cnt) if cnts else 0
recs = await sor.sqlExe(
'select id, script_name, script_type, description, status, '
'created_at, updated_at from ' + TABLE + where +
' order by created_at desc limit %d offset %d' % (rows, offset),
vals)
items = [{
'id': r.id,
'script_name': r.script_name,
'script_type': r.script_type,
'description': getattr(r, 'description', ''),
'status': r.status,
'created_at': r.created_at,
'updated_at': r.updated_at,
} for r in recs]
except Exception as e:
debug('script_engine list_scripts error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return {'code': 0, 'message': 'ok', 'data': {'list': items, 'total': total}}
async def _execute_sql(sql, params):
dbname = _dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(sql, dict(params or {}))
rows = list(recs)
except Exception as e:
debug('script_engine execute_sql error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return {'code': 0, 'message': 'ok', 'data': {'rows': rows, 'total': len(rows)}}
async def execute_script(request, params_kw):
"""Execute a script by id, or by content + script_type (validated first)."""
ns = _clean_params(params_kw)
sid = str(ns.get('id') or '').strip()
content = ns.get('content')
stype = str(ns.get('script_type') or SCRIPT_TYPE_PYTHON)
params = ns.get('params') or {}
if sid:
dbname = _dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': sid})
except Exception as e:
debug('script_engine execute_script load error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
if not recs:
return {'code': 1, 'message': 'script not found', 'field': 'id', 'detail': ''}
rec = recs[0]
if str(rec.status) != STATUS_ENABLED:
return {'code': 1, 'message': 'script is disabled', 'field': 'status', 'detail': ''}
content = rec.content
stype = str(rec.script_type)
err = _check_type(stype)
if err:
return {'code': 1, 'message': err, 'field': 'script_type', 'detail': ''}
if content is None or not str(content).strip():
return {'code': 1, 'message': 'content is required', 'field': 'content', 'detail': ''}
ok, msg = _validate_content(stype, str(content))
if not ok:
return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''}
if stype == SCRIPT_TYPE_PYTHON:
return execute_python(str(content), params)
return await _execute_sql(str(content), params)
async def validate_script_api(request, params_kw):
"""Validate only - does not save to db and does not execute."""
ns = _clean_params(params_kw)
content = str(ns.get('content') or '')
if not content.strip():
return {'code': 1, 'message': 'content is required', 'field': 'content', 'detail': ''}
stype = str(ns.get('script_type') or SCRIPT_TYPE_PYTHON)
err = _check_type(stype)
if err:
return {'code': 1, 'message': err, 'field': 'script_type', 'detail': ''}
ok, msg = _validate_content(stype, content)
if not ok:
return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''}
return {'code': 0, 'message': 'ok', 'data': {'valid': True}}
# plural aliases (CRUD framework convention: dspy wrappers use plural names)
create_scripts = create_script
update_scripts = update_script
delete_scripts = delete_script
def load_script_engine(env=None):
"""Register all script_engine functions to ServerEnv."""
if env is None:
env = ServerEnv() env = ServerEnv()
env.create_script = create_script
env.create_scripts = create_scripts
env.update_script = update_script
env.update_scripts = update_scripts
env.delete_script = delete_script
env.delete_scripts = delete_scripts
env.get_script = get_script
env.list_scripts = list_scripts
env.execute_script = execute_script env.execute_script = execute_script
env.validate_script_api = validate_script_api env.validate_script = validate_script
debug('script_engine module loaded') env.create_script = create_script
return env env.update_script = update_script
env.delete_script = delete_script

View File

@ -1,113 +0,0 @@
# -*- coding: utf-8 -*-
"""Regression tests for script_engine.validate_python (Bug kGZMpApWK3I1ed4OHpKQ0).
Validates that:
* params.get() and other whitelisted method calls now PASS validation
* every non-whitelisted method call is rejected (100% interception)
* dunder attribute access (sandbox escapes) is rejected
* attribute assignment / deletion is rejected
* forbidden constructs (import/class/lambda/async/...) are still rejected
Run (from modules/script_engine):
python script_engine/tests/test_validate_python.py
or:
python -m pytest script_engine/tests/test_validate_python.py -v
The engine module is loaded by file path so this test does not require the
ahserver/sqlor runtime environment.
"""
import importlib.util
import os
_HERE = os.path.dirname(os.path.abspath(__file__))
_ENGINE_PATH = os.path.join(os.path.dirname(_HERE), 'engine.py')
_spec = importlib.util.spec_from_file_location('_se_engine', _ENGINE_PATH)
_se_engine = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_se_engine)
validate_python = _se_engine.validate_python
execute_python = _se_engine.execute_python
_PASS = 0
_FAIL = 0
def check(content, expect_ok, why):
"""Assert validate_python(content) == expect_ok and print the outcome."""
global _PASS, _FAIL
r = validate_python(content)
ok = r.get('code') == 0
status = 'PASS' if ok == expect_ok else 'FAIL'
if ok == expect_ok:
_PASS += 1
else:
_FAIL += 1
print('%-4s expect_ok=%-5s %-52s -> %s' % (
status, expect_ok, why, r.get('message')))
def test_positive():
# the reported bug: params.get() must pass validation
check("result = params.get('age', 0) + 1", True, 'params.get')
check("result = params.get('name', '').strip().upper()", True, 'str chain on params.get')
check("result = sum(params.get('nums', []))", True, 'sum + params.get')
check("r = 0\nfor i in range(5):\n r += i\nresult = r", True, 'loop + range')
check("d = {'a': 1}\nd['b'] = 2\nresult = d.get('b')", True, 'dict subscript assign')
check("result = [x * 2 for x in range(3)]", True, 'list comprehension')
check("result = params.get('a', '').replace('x', 'y')", True, 'str.replace whitelisted')
check("result = params.get('l', []).append(1) or params.get('l')", True, 'list.append whitelisted')
def test_negative():
# forbidden constructs (must STILL be rejected)
check("import os\nresult = 1", False, 'import')
check("from os import system\nresult = 1", False, 'from-import')
check("class X:\n pass", False, 'class def')
check("f = lambda x: x", False, 'lambda')
check("async def f():\n pass", False, 'async def')
check("def g():\n yield 1", False, 'yield')
# non-whitelisted builtin calls
check("result = open('/etc/passwd')", False, 'open not whitelisted')
check("result = os.system('id')", False, 'os.system (name call)')
check("result = eval('1+1')", False, 'eval not whitelisted')
check("result = exec('x=1')", False, 'exec not whitelisted')
check("result = __import__('os')", False, '__import__ not whitelisted')
# dunder attribute access (sandbox escapes)
check("result = params.__class__", False, 'dunder read __class__')
check("result = ().__class__.__mro__", False, 'dunder escape chain')
check("result = params.get.__self__", False, 'dunder __self__')
check("result = (lambda: 1).__globals__", False, 'dunder __globals__')
# non-whitelisted object methods (100% interception)
check("result = params.popitem()", False, 'popitem not whitelisted')
check("result = params.foo()", False, 'arbitrary method foo')
check("result = params.get('a').isidentifier()", False, 'isidentifier not whitelisted')
check("result = [].index.__call__()", False, '__call__ dunder')
# attribute assignment / deletion
check("obj = params\nobj.x = 1", False, 'attribute assign')
check("obj = params\nobj.x += 1", False, 'attribute aug-assign')
check("obj = params\ndel obj.x", False, 'attribute delete')
# other call expressions
check("result = params['__class__']()", False, 'subscript call expression')
def test_execute():
out = execute_python("result = params.get('a', 0) + 10", {'params': {'a': 5}})
assert out == 15, 'execute_python params.get -> %r' % out
print('PASS execute_python params.get({a:5})+10 ->', out)
global _PASS
_PASS += 1
def main():
test_positive()
test_negative()
test_execute()
print('-' * 78)
print('TOTAL: %d passed, %d failed' % (_PASS, _FAIL))
if _FAIL:
raise SystemExit(1)
print('ALL TESTS PASSED')
if __name__ == '__main__':
main()

View File

@ -1,88 +1,64 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""script_engine RBAC path registration. """script_engine 模块 RBAC 权限注册(显式路径,禁用通配符)。"""
Run: python3 scripts/load_path.py (from module repo root)
Explicit paths only - NO wildcards.
"""
import os import os
import sys import sys
MODULE = 'script_engine' MODULE = 'script_engine'
# paths accessible to any (logged-out) visitor
PATHS_ANY = [
'/script_engine/menu.ui',
]
# paths accessible to any authenticated user
PATHS_LOGINED = [ PATHS_LOGINED = [
'/script_engine', '/{0}'.format(MODULE),
'/script_engine/index.ui', '/{0}/index.ui'.format(MODULE),
'/script_engine/script_execute_page.ui', '/{0}/script_engine'.format(MODULE),
'/script_engine/script_engine_list', '/{0}/script_engine/index.ui'.format(MODULE),
'/script_engine/script_engine_list/index.ui', '/{0}/script_engine/get_script_engine.dspy'.format(MODULE),
'/script_engine/script_engine_list/get_script_engine_list.dspy', '/{0}/script_engine/add_script_engine.dspy'.format(MODULE),
'/script_engine/script_engine_list/add_script_engine.dspy', '/{0}/script_engine/update_script_engine.dspy'.format(MODULE),
'/script_engine/script_engine_list/update_script_engine.dspy', '/{0}/script_engine/delete_script_engine.dspy'.format(MODULE),
'/script_engine/script_engine_list/delete_script_engine.dspy', '/{0}/api/execute_script.dspy'.format(MODULE),
'/script_engine/api/script_create.dspy', '/{0}/api/validate_script.dspy'.format(MODULE),
'/script_engine/api/script_update.dspy', '/{0}/api/script_engine_create.dspy'.format(MODULE),
'/script_engine/api/script_delete.dspy', '/{0}/api/script_engine_update.dspy'.format(MODULE),
'/script_engine/api/script_get.dspy', '/{0}/api/script_engine_delete.dspy'.format(MODULE),
'/script_engine/api/script_list.dspy', '/{0}/api/get_search_world_id.dspy'.format(MODULE),
'/script_engine/api/script_execute.dspy', '/{0}/api/get_search_scene_id.dspy'.format(MODULE),
'/script_engine/api/execute_script.dspy', '/{0}/api/get_search_entity_id.dspy'.format(MODULE),
'/script_engine/api/script_validate.dspy', '/{0}/api/get_search_script_type.dspy'.format(MODULE),
'/script_engine/api/validate_script.dspy', '/{0}/api/get_search_status.dspy'.format(MODULE),
'/script_engine/api/get_search_script_type.dspy',
'/script_engine/api/get_search_status.dspy',
] ]
# role-restricted paths PATHS_ANY = []
PATHS_ROLE = [
('/script_engine/api/script_create.dspy', 'owner.operator'),
('/script_engine/api/script_update.dspy', 'owner.operator'),
('/script_engine/api/script_delete.dspy', 'owner.operator'),
]
def find_sage_root(): def find_sage_root():
script_dir = os.path.dirname(os.path.abspath(__file__)) cur = os.path.dirname(os.path.abspath(__file__))
candidates = [ for _ in range(6):
os.path.join(script_dir, '..', '..'), if os.path.isdir(os.path.join(cur, 'wwwroot')) and os.path.isdir(os.path.join(cur, 'py3', 'bin')):
os.path.join(os.path.expanduser('~'), 'repos', 'sage'), return cur
os.path.join(os.path.expanduser('~'), 'sage'), parent = os.path.dirname(cur)
] if parent == cur:
for cand in candidates: break
cand = os.path.abspath(cand) cur = parent
if os.path.isdir(os.path.join(cand, 'wwwroot')) and os.path.isdir(os.path.join(cand, 'py3', 'bin')):
return cand
return None return None
def register(sage_root, path, role):
set_role_perm = os.path.join(sage_root, 'py3', 'bin', 'set_role_perm.py')
if not os.path.exists(set_role_perm):
print('set_role_perm.py not found: %s' % set_role_perm)
return
cmd = '%s %s %s %s' % (sys.executable, set_role_perm, role, path)
print('RUN: %s' % cmd)
os.system(cmd)
def main(): def main():
sage_root = find_sage_root() sage_root = find_sage_root()
if not sage_root: if not sage_root:
print('ERROR: sage root not found; skip RBAC registration') print('[{0}] Sage root not found, skip RBAC registration'.format(MODULE))
return
sys.path.insert(0, sage_root)
try:
from set_role_perm import set_role_perm
except ImportError:
print('[{0}] set_role_perm not found, skip RBAC registration'.format(MODULE))
return return
for path in PATHS_ANY: for path in PATHS_ANY:
register(sage_root, path, 'any') set_role_perm(path, 'any')
for path in PATHS_LOGINED: for path in PATHS_LOGINED:
register(sage_root, path, 'logined') set_role_perm(path, 'logined')
for path, role in PATHS_ROLE: print('[{0}] registered {1} any + {2} logined paths'.format(
register(sage_root, path, role) MODULE, len(PATHS_ANY), len(PATHS_LOGINED)))
print('script_engine RBAC registration done')
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,45 +1,33 @@
--- ---
name: script_engine name: script_engine
description: 逻辑编程(脚本/规则引擎)模块——脚本表 CRUD + execute_script/validate_script 接口,通过 load_script_engine() 挂载。脚本类型 0=Python / 1=SQL。 description: 逻辑编程(脚本/规则引擎)模块——脚本表 CRUD + execute_script/validate_script 接口,通过 load_script_engine() 挂载。
--- ---
# script_engine 模块 # script_engine 模块
脚本/规则引擎:脚本表(`script_engine`)CRUD + 执行/校验接口。独立模块,无业务依赖。 ## 概述
逻辑编程(脚本/规则引擎)模块。管理 `script_engine` 表(脚本/规则),提供脚本执行与语法校验接口。依赖 world、scene、entity 模块。
## 数据模型 ## 数据模型
表 `script_engine`:`id`、`world_id`(→world)、`scene_id`(→scene)、`entity_id`(→entity)、`name`、`code`(unique)、`script_type`、`trigger_event`、`content`(text)、`status`、`created_at`、`updated_at`。
- `script_engine`:`id`(str32 PK)、`script_name`(str100 not null)、`script_type`(str32 default '0')、`content`(text not null)、`description`(str255)、`status`(str32 default '1')、`created_at`、`updated_at` ## 关键接口
- 索引:`idx_script_name`、`idx_script_type` - `api/execute_script.dspy`:输入 `script_id` / `context_json`(可选),按 `script_type` 选择执行器执行 `content`,返回 `{success, result}`。
- 编码:`script_type` → appcodes_kv parentid='script_type'(0=Python,1=SQL);`status` → appcodes_kv parentid='script_status'(0=停用,1=启用) - `api/validate_script.dspy`:输入 `content` / `script_type`,语法/格式校验,返回 `{valid, message}`。
## 关键接口(ServerEnv 注册函数,.dspy 直接调用) ## 脚本类型编码(appcodes script_type)
- `0` = Python脚本(受限命名空间,通过 `result` 变量返回结果)
- `1` = 规则(JSON)(解析 JSON 作为结果)
- `2` = 表达式(eval 求值)
- `create_script / update_script / delete_script / get_script / list_scripts` ## 状态编码(appcodes script_status)
- `execute_script`(id 或 content+script_type,校验后执行) - `0` = 禁用、`1` = 启用
- `validate_script_api`(仅校验,不落库不执行)
- 复数别名 `create_scripts/update_scripts/delete_scripts` 与单数同实现(CRUD 框架约定)
## 错误结构与分页 ## 挂载
宿主应用入口 `init()` 调用 `load_script_engine()`,向 ServerEnv 注册 `execute_script` / `validate_script` / `create_script` / `update_script` / `delete_script`。
- 统一 `{code, message, field, detail}`;code=0 成功
- 分页 `{list, total}`(list_scripts 返回 data.list / data.total)
## REST 接口(wwwroot/api/*.dspy)
- script_create.dspy / script_update.dspy / script_delete.dspy / script_get.dspy / script_list.dspy
- script_execute.dspy / execute_script.dspy / script_validate.dspy / validate_script.dspy
- get_search_script_type.dspy / get_search_status.dspy(下拉 [{value,text}])
## 陷阱 ## 陷阱
- 脚本执行使用受限命名空间(`__builtins__={}`),Python 脚本须通过 `result` 变量返回结果。
- 取库名用 `ServerEnv().get_module_dbname('script_engine')`,禁止硬编码 DBNAME - `content` 为 `text` 字段:列表页隐藏(`browserfields.exclouded`),编辑页显示。
- 非法输入(空名/超长/非法类型/语法错误/受限语法/危险调用)100% 拦截,不落库不执行 - `code` 唯一索引,create 时校验重复。
- Python 执行受限命名空间:仅内置白名单函数,禁 import/类/λ/async/await/对象方法调用(engine.py ast 校验 + 白名单 builtins) - 取库名统一 `ServerEnv().get_module_dbname('script_engine')`,禁止硬编码 DBNAME。
- SQL 仅允许只读单语句(SELECT/SHOW/DESCRIBE/EXPLAIN),禁写语句/多语句/危险关键字
- .dspy 无 import/print/uuid/f-string,显式 return;helper 在 init.py
- init/data.json 为 Format B appcodes(parentid + items),幂等落库
## 依赖
- 无业务依赖;依赖基础包 sqlor / ahserver / appPublic

View File

@ -1,4 +1,4 @@
# execute_script.dspy - alias endpoint for script execution script_id = params_kw.get('script_id')
debug('execute_script.dspy: START params_kw=%s' % dict(params_kw)) context_json = params_kw.get('context_json')
result = await execute_script(request, params_kw) result = await execute_script(script_id, context_json)
return result return result

View File

@ -1,9 +1,9 @@
# get_search_script_type.dspy - dropdown for script_type (appcodes_kv parentid=script_type) result = [{'value': '', 'text': '全部'}]
debug('get_search_script_type.dspy: START')
try: try:
async with get_sor_context(request._run_ns, 'appbase') as sor: async with get_sor_context(request._run_ns, 'appbase') as sor:
recs = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='script_type' order by k", {}) rows = await sor.sqlExe(
return json.dumps([{'value': '', 'text': '全部'}] + list(recs), ensure_ascii=False) "select k as value, v as text from appcodes_kv where parentid='script_type' order by k", {})
result = [{'value': '', 'text': '全部'}] + list(rows)
except Exception as e: except Exception as e:
debug('get_search_script_type.dspy error: %s' % str(e)) debug('get_search_script_type error: %s' % e)
return json.dumps([{'value': '', 'text': '全部'}], ensure_ascii=False) return result

View File

@ -1,9 +1,9 @@
# get_search_status.dspy - dropdown for status (appcodes_kv parentid=script_status) result = [{'value': '', 'text': '全部'}]
debug('get_search_status.dspy: START')
try: try:
async with get_sor_context(request._run_ns, 'appbase') as sor: async with get_sor_context(request._run_ns, 'appbase') as sor:
recs = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='script_status' order by k", {}) rows = await sor.sqlExe(
return json.dumps([{'value': '', 'text': '全部'}] + list(recs), ensure_ascii=False) "select k as value, v as text from appcodes_kv where parentid='script_status' order by k", {})
result = [{'value': '', 'text': '全部'}] + list(rows)
except Exception as e: except Exception as e:
debug('get_search_status.dspy error: %s' % str(e)) debug('get_search_status error: %s' % e)
return json.dumps([{'value': '', 'text': '全部'}], ensure_ascii=False) return result

View File

@ -1,6 +0,0 @@
# script_create.dspy - create script (validates syntax before save)
debug('script_create.dspy: START params_kw=%s' % dict(params_kw))
result = await create_script(request, params_kw)
if result.get('code') == 0:
return {'status': 'ok', 'message': 'ok', 'data': result.get('data', {})}
return {'status': 'error', 'message': result.get('message', 'create failed'), 'field': result.get('field', ''), 'data': result}

View File

@ -1,6 +0,0 @@
# script_delete.dspy - delete script by id
debug('script_delete.dspy: START params_kw=%s' % dict(params_kw))
result = await delete_script(request, params_kw)
if result.get('code') == 0:
return {'status': 'ok', 'message': 'ok', 'data': result.get('data', {})}
return {'status': 'error', 'message': result.get('message', 'delete failed'), 'field': result.get('field', ''), 'data': result}

View File

@ -1,4 +0,0 @@
# script_execute.dspy - execute script by id or content+script_type
debug('script_execute.dspy: START params_kw=%s' % dict(params_kw))
result = await execute_script(request, params_kw)
return result

View File

@ -1,4 +0,0 @@
# script_get.dspy - get script detail by id
debug('script_get.dspy: START params_kw=%s' % dict(params_kw))
result = await get_script(request, params_kw)
return result

View File

@ -1,7 +0,0 @@
# script_list.dspy - paginated script list (returns DataViewer rows/total)
debug('script_list.dspy: START params_kw=%s' % dict(params_kw))
result = await list_scripts(request, params_kw)
if result.get('code') == 0:
data = result.get('data', {})
return {'status': 'ok', 'rows': data.get('list', []), 'total': data.get('total', 0)}
return {'status': 'error', 'message': result.get('message', 'list failed'), 'rows': [], 'total': 0}

View File

@ -1,6 +0,0 @@
# script_update.dspy - update script (validates syntax before save)
debug('script_update.dspy: START params_kw=%s' % dict(params_kw))
result = await update_script(request, params_kw)
if result.get('code') == 0:
return {'status': 'ok', 'message': 'ok', 'data': result.get('data', {})}
return {'status': 'error', 'message': result.get('message', 'update failed'), 'field': result.get('field', ''), 'data': result}

View File

@ -1,4 +0,0 @@
# script_validate.dspy - validate script content only (no save, no execute)
debug('script_validate.dspy: START params_kw=%s' % dict(params_kw))
result = await validate_script_api(request, params_kw)
return result

View File

@ -1,4 +1,4 @@
# validate_script.dspy - alias endpoint for script validation content = params_kw.get('content')
debug('validate_script.dspy: START params_kw=%s' % dict(params_kw)) script_type = params_kw.get('script_type', '0')
result = await validate_script_api(request, params_kw) result = await validate_script(content, script_type)
return result return result

View File

@ -1,15 +0,0 @@
{
"script_engine": {
"module": "脚本引擎",
"script_manage": "脚本管理",
"script_create": "新增脚本",
"script_update": "编辑脚本",
"script_delete": "删除脚本",
"script_execute": "执行脚本",
"script_validate": "校验脚本",
"script_type": "脚本类型",
"script_name": "脚本名称",
"script_content": "脚本内容",
"script_status": "脚本状态"
}
}

View File

@ -2,16 +2,16 @@
"widgettype": "VBox", "widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"}, "options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [ "subwidgets": [
{"widgettype": "Text", "options": {"label": "逻辑编程(脚本引擎)", "fontSize": "24px"}}, {"widgettype": "Text", "options": {"label": "逻辑编程", "fontSize": "24px"}},
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.script_engine_content", "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.script_engine_content",
"options": {"url": "{{entire_url('script_engine_list')}}", "mode": "replace"}}], "options": {"url": "{{entire_url('/script_engine/script_engine')}}"}, "mode": "replace"}],
"subwidgets": [{"widgettype": "Text", "options": {"label": "脚本管理(CRUD)"}}]}, "subwidgets": [{"widgettype": "Text", "options": {"label": "脚本列表", "fontSize": "16px"}}]},
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.script_engine_content", "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.script_engine_content",
"options": {"url": "{{entire_url('script_execute_page.ui')}}", "mode": "replace"}}], "options": {"url": "{{entire_url('/script_engine/script_engine/add_script_engine.dspy')}}"}, "mode": "replace"}],
"subwidgets": [{"widgettype": "Text", "options": {"label": "脚本执行 / 校验"}}]} "subwidgets": [{"widgettype": "Text", "options": {"label": "新建脚本", "fontSize": "16px"}}]}
]}, ]},
{"widgettype": "VBox", "id": "app.script_engine_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} {"widgettype": "VBox", "id": "app.script_engine_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
] ]

View File

@ -1,31 +0,0 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "脚本执行 / 校验", "fontSize": "20px"}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "16px"}, "subwidgets": [
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "16px", "flex": "1"}, "subwidgets": [
{"widgettype": "Text", "options": {"label": "脚本类型"}},
{"widgettype": "Select", "id": "script_type", "options": {
"data": [{"value": "0", "text": "Python"}, {"value": "1", "text": "SQL"}],
"value": "0"}},
{"widgettype": "Text", "options": {"label": "脚本内容"}},
{"widgettype": "Textarea", "id": "script_content", "options": {"width": "100%", "height": "220px", "placeholder": "Python: 设置 result 变量输出;SQL: 只读单条 SELECT/SHOW/DESCRIBE/EXPLAIN"}},
{"widgettype": "HBox", "options": {"gap": "12px"}, "subwidgets": [
{"widgettype": "Button", "id": "btn_validate", "options": {"label": "校验"},
"binds": [{"wid": "btn_validate", "event": "click", "actiontype": "urlwidget", "target": "script_result",
"options": {"method": "POST", "url": "{{entire_url('api/script_validate.dspy')}}",
"params": {"script_type": "{{params_kw.script_type}}", "content": "{{params_kw.script_content}}"}}}]},
{"widgettype": "Button", "id": "btn_execute", "options": {"label": "执行"},
"binds": [{"wid": "btn_execute", "event": "click", "actiontype": "urlwidget", "target": "script_result",
"options": {"method": "POST", "url": "{{entire_url('api/script_execute.dspy')}}",
"params": {"script_type": "{{params_kw.script_type}}", "content": "{{params_kw.script_content}}"}}}]}
]}
]},
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "16px", "flex": "1"}, "subwidgets": [
{"widgettype": "Text", "options": {"label": "执行结果"}},
{"widgettype": "VBox", "id": "script_result", "options": {"width": "100%", "minHeight": "260px", "backgroundColor": "#F7F7F7"}}
]}
]}
]
}

View File

@ -1,10 +0,0 @@
{"widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[
{"widgettype":"Text","options":{"label":"脚本管理","fontSize":"18px","marginBottom":"10px"}},
{"widgettype":"DataViewer","options":{"data_url":"{{entire_url('api/script_list.dspy')}}",
"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')}}",
"browserfields":{"exclouded":["content"],
"alters":{"script_type":{"uitype":"code","dataurl":"{{entire_url('api/get_search_script_type.dspy')}}"},
"status":{"uitype":"code","dataurl":"{{entire_url('api/get_search_status.dspy')}}"}}},
"editexclouded":["id","created_at","updated_at"]}}]}