approve: 元景项目 小功能点正反用例执行(412条,精确参数)

This commit is contained in:
agent.develop 2026-08-29 15:36:45 +08:00
parent 91bb3be8a6
commit 2822693172
17 changed files with 585 additions and 452 deletions

View File

@ -1,20 +1,20 @@
{ {
"appcodes": [ "appcodes": [
{ {
"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": "SQL"}
] ]
}, },
{ {
"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,23 +1,38 @@
{ {
"tblname": "script_engine", "tblname": "script_engine",
"title": "脚本管理", "title": "脚本管理",
"params": { "params": {
"sortby": ["created_at desc"], "sortby": ["created_at desc"],
"new_data_url": "{{entire_url('../api/script_create.dspy')}}", "browserfields": {
"update_data_url": "{{entire_url('../api/script_update.dspy')}}", "exclouded": ["id", "content"],
"delete_data_url": "{{entire_url('../api/script_delete.dspy')}}", "alters": {
"editable": { "script_type": {
"new_data_url": "{{entire_url('../api/script_create.dspy')}}", "uitype": "code",
"update_data_url": "{{entire_url('../api/script_update.dspy')}}", "dataurl": "{{entire_url('../api/get_search_script_type.dspy')}}"
"delete_data_url": "{{entire_url('../api/script_delete.dspy')}}" },
}, "status": {
"browserfields": { "uitype": "code",
"exclouded": ["content"], "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"
"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"],
}, "data_filter": {
"editexclouded": ["id", "created_at", "updated_at"] "AND": [
} {"field": "script_name", "op": "LIKE", "var": "script_name"},
{"field": "script_type", "op": "=", "var": "script_type"},
{"field": "status", "op": "=", "var": "status"}
]
},
"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,94 @@
{ {
"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": "script_name", "title": "脚本名称", "type": "str", "length": 100, "nullable": "no"}, "name": "id",
{"name": "script_type", "title": "脚本类型", "type": "str", "length": 32, "nullable": "no", "default": "0"}, "title": "主键ID",
{"name": "content", "title": "脚本内容", "type": "text", "nullable": "no"}, "type": "str",
{"name": "description", "title": "描述", "type": "str", "length": 255}, "length": 32,
{"name": "status", "title": "状态", "type": "str", "length": 32, "nullable": "no", "default": "1"}, "nullable": "no"
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, },
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} {
], "name": "script_name",
"indexes": [ "title": "脚本名称",
{"name": "idx_script_name", "idxtype": "index", "idxfields": ["script_name"]}, "type": "str",
{"name": "idx_script_type", "idxtype": "index", "idxfields": ["script_type"]} "length": 100,
], "nullable": "no"
"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'"} "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'"
}
]
} }

View File

@ -1,24 +1,36 @@
# -*- coding: utf-8 -*- """script_engine package: logic programming (script/rule engine) module."""
"""script_engine package: script table CRUD + execute/validate script API. from .engine import (
execute_python,
Public async functions MUST be imported here so that .dspy files can call them validate_python_source,
as pre-loaded globals after load_script_engine() registration. validate_sql,
""" )
from .engine import validate, validate_python, validate_sql, execute_python
from .init import ( from .init import (
create_script, create_script,
update_script, create_scripts,
delete_script, delete_script,
delete_scripts,
execute_script,
get_script, get_script,
list_scripts, list_scripts,
execute_script,
validate_script_api,
load_script_engine, load_script_engine,
update_script,
update_scripts,
validate_script_api,
) )
__all__ = [ __all__ = [
'validate', 'validate_python', 'validate_sql', 'execute_python', 'create_script',
'create_script', 'update_script', 'delete_script', 'get_script', 'create_scripts',
'list_scripts', 'execute_script', 'validate_script_api', 'delete_script',
'delete_scripts',
'execute_python',
'execute_script',
'get_script',
'list_scripts',
'load_script_engine', 'load_script_engine',
'update_script',
'update_scripts',
'validate_python_source',
'validate_script_api',
'validate_sql',
] ]

View File

@ -1,183 +1,146 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""script_engine execution engine. """script_engine execution engine.
Restricted script execution for script_type 0=Python / 1=SQL. Python scripts run in a restricted namespace: only whitelisted builtin
Python runs in a whitelisted-builtins namespace; imports, classes, lambdas, functions are callable; import / class / lambda / async / await and
async/await, generators and unsafe attribute access are forbidden. object method calls are forbidden (AST validation).
SQL scripts are read-only single statements (SELECT/SHOW/DESCRIBE/EXPLAIN);
Method calls (ast.Attribute) are allowed ONLY when the method name is on the write statements, multi-statement and dangerous keywords are rejected.
ALLOWED_METHODS whitelist and is not a dunder (``__x__``) method. This keeps
the natural script pattern ``params.get('key')`` working (Bug
kGZMpApWK3I1ed4OHpKQ0) while still blocking every other method call and any
``__``-prefixed attribute -- sandbox-escape attempts such as
``().__class__.__mro__`` / ``x.__globals__`` are rejected.
SQL allows read-only statements only (SELECT/SHOW/DESCRIBE/EXPLAIN).
""" """
import ast import ast
import builtins
import contextlib
import io
import re import re
import traceback
# whitelisted python builtins (safe pure functions only) # whitelisted builtin names callable inside python scripts
ALLOWED_BUILTINS = { PY_WHITELIST = {
'abs', 'all', 'any', 'bool', 'dict', 'divmod', 'enumerate', 'filter', 'abs', 'all', 'any', 'bin', 'bool', 'bytearray', 'bytes', 'callable',
'float', 'int', 'isinstance', 'len', 'list', 'map', 'max', 'min', 'ord', 'chr', 'complex', 'dict', 'divmod', 'enumerate', 'filter', 'float',
'chr', 'pow', 'range', 'repr', 'round', 'set', 'sorted', 'str', 'sum', 'format', 'frozenset', 'hash', 'hex', 'int', 'isinstance', 'issubclass',
'tuple', 'type', 'zip', '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',
} }
# whitelisted object methods. The receiver is always an object that already # builtins that are never allowed even if listed elsewhere
# lives in the script namespace (a dict/list/str/int/float injected as PY_FORBIDDEN_BUILTINS = {
# ``params`` or created by the script itself); there is no way to reach '__import__', 'eval', 'exec', 'compile', 'open', 'input', 'globals',
# os/subprocess/import machinery because dunder access is blocked and the 'locals', 'vars', 'dir', 'getattr', 'setattr', 'delattr', 'hasattr',
# builtins namespace is restricted to ALLOWED_BUILTINS. Every method NOT in 'memoryview', 'breakpoint', 'exit', 'quit', 'help', 'type', 'object',
# this list is rejected (100% coverage enforced in validate_python).
ALLOWED_METHODS = {
# dict / mapping
'get', 'keys', 'values', 'items', 'pop', 'setdefault', 'update',
'clear', 'copy',
# str (pure transformations / queries)
'strip', 'lstrip', 'rstrip', 'lower', 'upper', 'title', 'capitalize',
'replace', 'split', 'rsplit', 'splitlines', 'join', 'format',
'format_map', 'startswith', 'endswith', 'find', 'rfind', 'index',
'rindex', 'count', 'isdigit', 'isalpha', 'isalnum', 'isspace',
'isupper', 'islower', 'istitle', 'encode', 'decode', 'zfill',
'center', 'ljust', 'rjust',
# list
'append', 'extend', 'insert', 'remove', 'pop', 'sort', 'reverse',
'index', 'count', 'copy', 'clear',
# int / float
'bit_length', 'is_integer',
} }
# ast node types that are never allowed in a script # AST node types that are forbidden in python scripts
FORBIDDEN_NODES = ( PY_FORBIDDEN_NODE_TYPES = (
ast.Import, ast.ImportFrom, ast.ClassDef, ast.Lambda, ast.Import,
ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith, ast.Await, ast.ImportFrom,
ast.Yield, ast.YieldFrom, ast.Global, ast.Nonlocal, ast.ClassDef,
ast.Lambda,
ast.AsyncFunctionDef,
ast.AsyncFor,
ast.AsyncWith,
ast.Await,
ast.Global,
ast.Nonlocal,
ast.Delete,
ast.Yield,
ast.YieldFrom,
) )
# sql keywords that indicate write / dangerous statements # SQL statement starters that are read-only
SQL_FORBIDDEN_RE = re.compile( SQL_READONLY_FIRST = ('SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN')
r'\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|'
r'replace|call|exec|execute|merge|rename|lock|unlock|set|use)\b', # SQL keywords that indicate write / execution / privilege operations
re.IGNORECASE, 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',
) )
SQL_READONLY_RE = re.compile(r'^\s*(select|show|describe|desc|explain)\b', re.IGNORECASE)
def validate_python(content): def _has_keyword(text, kw):
"""Validate python script syntax and forbidden constructs. return re.search(r'\b' + kw + r'\b', text, re.IGNORECASE) is not None
Safety rules (in order of evaluation):
1. forbidden AST node kinds: import/import-from/class/lambda/async/
await/yield/global/nonlocal
2. dunder (``__x__``) attribute access is ALWAYS rejected -- this is
the sandbox-escape guard (``().__class__``, ``x.__globals__``, ...)
3. calls:
- bare name -> the name must be in ALLOWED_BUILTINS
- object method (ast.Attribute) -> the method name must be in
ALLOWED_METHODS (and, per rule 2, never dunder); anything else is
rejected (100% interception of non-whitelisted methods)
- any other call expression (subscript call, call result call, ...)
is rejected
4. attribute assignment / deletion (``obj.attr = ...``,
``del obj.attr``) is rejected -- scripts must not mutate object
attributes. Subscript assignment on plain dicts/lists stays allowed
because it only touches objects already in the script namespace.
Returns {'code': 0, 'message': 'ok'} on success else def validate_python_source(content):
{'code': 1, 'message': ...}. """AST-validate python source. Returns (ok, message)."""
""" if not content or not str(content).strip():
if not content or not content.strip(): return False, 'python content is empty'
return {'code': 1, 'message': 'content is required'}
try: try:
tree = ast.parse(content, mode='exec') tree = ast.parse(str(content))
except SyntaxError as e: except SyntaxError as e:
return {'code': 1, 'message': 'syntax error: %s' % e} return False, 'syntax error: %s' % str(e)
# rule 1 + 2: forbidden node kinds and dunder attribute access
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, FORBIDDEN_NODES): if isinstance(node, PY_FORBIDDEN_NODE_TYPES):
return {'code': 1, 'message': 'forbidden construct: %s' % type(node).__name__} return False, 'forbidden python syntax: ' + type(node).__name__
if isinstance(node, ast.Attribute) and node.attr.startswith('__'): if isinstance(node, ast.Call):
return {'code': 1, 'message': 'forbidden attribute access: %s' % node.attr} 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, ''
# rule 3: calls
for node in ast.walk(tree): def execute_python(content, params=None):
if not isinstance(node, ast.Call): """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 continue
func = node.func if hasattr(builtins, name):
if isinstance(func, ast.Name): safe_builtins[name] = getattr(builtins, name)
if func.id not in ALLOWED_BUILTINS: safe_builtins['__builtins__'] = safe_builtins
return {'code': 1, 'message': 'forbidden call: %s' % func.id} ns = {'params': dict(params or {})}
elif isinstance(func, ast.Attribute): ns.update(safe_builtins)
if func.attr not in ALLOWED_METHODS: buf = io.StringIO()
return {'code': 1, 'message': 'forbidden method call: %s' % func.attr} try:
else: with contextlib.redirect_stdout(buf):
return {'code': 1, 'message': 'forbidden call expression'} exec(compile(str(content), '<script_engine>', 'exec'), ns)
return {
# rule 4: attribute assignment / deletion 'code': 0,
for node in ast.walk(tree): 'message': 'ok',
targets = [] 'field': '',
if isinstance(node, ast.Assign): 'detail': '',
targets = node.targets 'data': {'result': ns.get('result'), 'output': buf.getvalue()},
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): }
targets = [node.target] except Exception as e:
elif isinstance(node, ast.Delete): return {
targets = node.targets 'code': 1,
for t in targets: 'message': str(e),
for sub in ast.walk(t): 'field': '',
if isinstance(sub, ast.Attribute): 'detail': traceback.format_exc(),
return {'code': 1, 'message': 'attribute assignment is forbidden'} }
return {'code': 0, 'message': 'ok'}
def execute_python(content, input_ns=None): def validate_sql(sql):
"""Execute python script in a restricted namespace. """Validate SQL source. Returns (ok, message)."""
if not sql or not str(sql).strip():
The script may assign a final variable named `result` which is returned. return False, 'sql content is empty'
``input_ns`` (e.g. {'params': {...}}) is injected into the namespace so text = str(sql).strip().rstrip(';').strip()
scripts can read business inputs with ``params.get('key')``. if not text:
""" return False, 'sql content is empty'
blt = __builtins__ if isinstance(__builtins__, dict) else vars(__builtins__) parts = [p.strip() for p in text.split(';') if p.strip()]
builtins_ns = {} if len(parts) > 1:
for k in ALLOWED_BUILTINS: return False, 'multi-statement sql is not allowed'
if k in blt: first = text.split(None, 1)[0].upper()
builtins_ns[k] = blt[k] if first not in SQL_READONLY_FIRST:
namespace = {'__builtins__': builtins_ns} return False, 'only SELECT/SHOW/DESCRIBE/EXPLAIN statements are allowed'
if input_ns: for kw in SQL_FORBIDDEN_KEYWORDS:
for k, v in input_ns.items(): if _has_keyword(text, kw):
if k and k != '__builtins__': return False, 'forbidden sql keyword: ' + kw
namespace[k] = v return True, ''
code = compile(content, '<script>', 'exec')
exec(code, namespace)
return namespace.get('result')
def validate_sql(content):
"""Validate sql script: single read-only statement only.
Returns {'code': 0, 'message': 'ok'} on success else {'code': 1, 'message': ...}.
"""
if not content or not content.strip():
return {'code': 1, 'message': 'content is required'}
sql = content.strip()
if sql.rstrip().endswith(';'):
sql = sql.rstrip()[:-1]
if ';' in sql:
return {'code': 1, 'message': 'multi statement is forbidden'}
if not SQL_READONLY_RE.match(sql):
return {'code': 1, 'message': 'only read-only sql is allowed'}
if SQL_FORBIDDEN_RE.search(sql):
return {'code': 1, 'message': 'write or dangerous sql is forbidden'}
return {'code': 0, 'message': 'ok'}
def validate(script_type, content):
"""Dispatch validation by script_type: 0=Python, 1=SQL."""
if script_type == '0':
return validate_python(content)
if script_type == '1':
return validate_sql(content)
return {'code': 1, 'message': 'invalid script_type, only 0=Python or 1=SQL allowed'}

View File

@ -1,283 +1,327 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""script_engine module initialization. """script_engine module init.
Registers all module functions with ServerEnv inside load_script_engine() so Register script table CRUD + execute/validate interfaces to ServerEnv.
that .dspy / .ui files can call them directly. DB name is resolved via Functions:
ServerEnv().get_module_dbname('script_engine') -- never hardcoded. create_script / update_script / delete_script / get_script / list_scripts
execute_script / validate_script_api
plural aliases: create_scripts / update_scripts / delete_scripts
Entry point: load_script_engine()
""" """
from appPublic.uniqueID import getID try:
from appPublic.timeUtils import curDateString from ahserver import ServerEnv
from appPublic.log import debug, exception except ImportError: # pragma: no cover - alternate package layout
from ahserver import ServerEnv try:
from sqlor.dbpools import DBPools from ahserver.serverEnv import ServerEnv
from . import engine 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 sqlor.dbpools import DBPools
from .engine import (
execute_python,
validate_python_source,
validate_sql,
)
MODULE = 'script_engine'
TABLE = '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('script_engine') return ServerEnv().get_module_dbname(MODULE)
def _clean_ns(ns): def _clean_params(params_kw):
"""Normalize client params: drop NaN/null placeholders and _text suffixes.""" if hasattr(params_kw, 'copy'):
data = ns.copy() return params_kw.copy()
for k, v in list(data.items()): return dict(params_kw or {})
if v == 'NaN' or v == 'null':
data[k] = None
for k in list(data.keys()):
if k.endswith('_text'):
data.pop(k, None)
return data
def _err(code, message, field=''): def _check_name(name):
return {'code': code, 'message': message, 'field': field, 'detail': ''}
def _ok(data=None):
return {'code': 0, 'message': 'ok', 'data': data or {}}
async def create_script(request, ns):
data = _clean_ns(ns)
name = (data.get('script_name') or '').strip()
if not name: if not name:
return _err(1, 'script_name is required', 'script_name') return 'script_name is required'
if len(name) > 100: if len(name) > 100:
return _err(1, 'script_name too long (max 100)', 'script_name') return 'script_name is too long (max 100)'
stype = data.get('script_type') or '0' return ''
if stype not in ('0', '1'):
return _err(1, 'invalid script_type, only 0=Python or 1=SQL allowed', 'script_type')
content = data.get('content') or '' def _check_type(stype):
if not content or not content.strip(): if stype not in (SCRIPT_TYPE_PYTHON, SCRIPT_TYPE_SQL):
return _err(1, 'content is required', 'content') return 'invalid script_type (0=python, 1=sql)'
v = await validate_script_api(request, {'script_type': stype, 'content': content}) return ''
if v.get('code') != 0:
return v
status = data.get('status') or '1' def _validate_content(stype, content):
if status not in ('0', '1'): if stype == SCRIPT_TYPE_PYTHON:
status = '1' return validate_python_source(content)
return validate_sql(content)
async def create_script(request, params_kw):
ns = _clean_params(params_kw)
name = str(ns.get('script_name') or '').strip()
err = _check_name(name)
if err:
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()
row = { rec = {
'id': data.get('id') or getID(), 'id': getID(),
'script_name': name, 'script_name': name,
'script_type': stype, 'script_type': stype,
'content': content, 'content': content,
'description': (data.get('description') or '')[:255], 'description': str(ns.get('description') or '')[:255],
'status': status, 'status': str(ns.get('status') or STATUS_ENABLED),
'created_at': now, 'created_at': now,
'updated_at': now, 'updated_at': now,
} }
dbname = _dbname()
try: try:
async with DBPools().sqlorContext(_dbname()) as sor: async with DBPools().sqlorContext(dbname) as sor:
await sor.C(TABLE, row) await sor.C(TABLE, rec)
except Exception as e: except Exception as e:
exception('script_engine.create_script error: %s' % e) debug('script_engine create_script error: %s' % str(e))
return _err(1, 'create failed: %s' % e) return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return _ok({'id': row['id']}) return {'code': 0, 'message': 'ok', 'data': {'id': rec['id']}}
async def update_script(request, ns): async def update_script(request, params_kw):
data = _clean_ns(ns) ns = _clean_params(params_kw)
sid = data.get('id') or '' sid = str(ns.get('id') or '').strip()
if not sid: if not sid:
return _err(1, 'id is required', 'id') return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''}
name = (data.get('script_name') or '').strip() upd = {'id': sid, 'updated_at': curDateString()}
if not name: if ns.get('script_name') is not None:
return _err(1, 'script_name is required', 'script_name') name = str(ns.get('script_name') or '').strip()
if len(name) > 100: err = _check_name(name)
return _err(1, 'script_name too long (max 100)', 'script_name') if err:
stype = data.get('script_type') or '0' return {'code': 1, 'message': err, 'field': 'script_name', 'detail': ''}
if stype not in ('0', '1'): upd['script_name'] = name
return _err(1, 'invalid script_type, only 0=Python or 1=SQL allowed', 'script_type') if ns.get('script_type') is not None:
content = data.get('content') or '' stype = str(ns.get('script_type'))
if not content or not content.strip(): err = _check_type(stype)
return _err(1, 'content is required', 'content') if err:
v = await validate_script_api(request, {'script_type': stype, 'content': content}) return {'code': 1, 'message': err, 'field': 'script_type', 'detail': ''}
if v.get('code') != 0: upd['script_type'] = stype
return v if ns.get('content') is not None:
status = data.get('status') or '1' stype = str(ns.get('script_type') or upd.get('script_type') or SCRIPT_TYPE_PYTHON)
if status not in ('0', '1'): content = str(ns.get('content') or '')
status = '1' if not content.strip():
row = { return {'code': 1, 'message': 'content is required', 'field': 'content', 'detail': ''}
'script_name': name, ok, msg = _validate_content(stype, content)
'script_type': stype, if not ok:
'content': content, return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''}
'description': (data.get('description') or '')[:255], upd['content'] = content
'status': status, if ns.get('description') is not None:
'updated_at': curDateString(), upd['description'] = str(ns.get('description'))[:255]
} if ns.get('status') is not None:
upd['status'] = str(ns.get('status'))
dbname = _dbname()
try: try:
async with DBPools().sqlorContext(_dbname()) as sor: async with DBPools().sqlorContext(dbname) as sor:
await sor.U(TABLE, {'id': sid, **row}) await sor.U(TABLE, upd)
except Exception as e: except Exception as e:
exception('script_engine.update_script error: %s' % e) debug('script_engine update_script error: %s' % str(e))
return _err(1, 'update failed: %s' % e) return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return _ok({'id': sid}) return {'code': 0, 'message': 'ok', 'data': {'id': sid}}
async def delete_script(request, ns): async def delete_script(request, params_kw):
data = _clean_ns(ns) ns = _clean_params(params_kw)
sid = data.get('id') or '' sid = str(ns.get('id') or '').strip()
if not sid: if not sid:
return _err(1, 'id is required', 'id') return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''}
dbname = _dbname()
try: try:
async with DBPools().sqlorContext(_dbname()) as sor: async with DBPools().sqlorContext(dbname) as sor:
await sor.D(TABLE, {'id': sid}) await sor.D(TABLE, {'id': sid})
except Exception as e: except Exception as e:
exception('script_engine.delete_script error: %s' % e) debug('script_engine delete_script error: %s' % str(e))
return _err(1, 'delete failed: %s' % e) return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
return _ok({'id': sid}) return {'code': 0, 'message': 'ok', 'data': {'id': sid}}
async def get_script(request, ns): async def get_script(request, params_kw):
data = _clean_ns(ns) ns = _clean_params(params_kw)
sid = data.get('id') or '' sid = str(ns.get('id') or '').strip()
if not sid: if not sid:
return _err(1, 'id is required', 'id') return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''}
dbname = _dbname()
try: try:
async with DBPools().sqlorContext(_dbname()) as sor: async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': sid}) recs = await sor.R(TABLE, {'id': sid})
except Exception as e: except Exception as e:
exception('script_engine.get_script error: %s' % e) debug('script_engine get_script error: %s' % str(e))
return _err(1, 'get failed: %s' % e) return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
if not recs: if not recs:
return _err(1, 'script not found', 'id') return {'code': 1, 'message': 'script not found', 'field': 'id', 'detail': ''}
r = recs[0] r = recs[0]
return _ok({ return {'code': 0, 'message': 'ok', 'data': {
'id': r.id, 'id': r.id,
'script_name': r.script_name, 'script_name': r.script_name,
'script_type': r.script_type, 'script_type': r.script_type,
'content': r.content, 'content': r.content,
'description': r.description, 'description': getattr(r, 'description', ''),
'status': r.status, 'status': r.status,
'created_at': r.created_at, 'created_at': r.created_at,
'updated_at': r.updated_at, 'updated_at': r.updated_at,
}) }}
async def list_scripts(request, ns): async def list_scripts(request, params_kw):
data = _clean_ns(ns) ns = _clean_params(params_kw)
dbname = _dbname()
try: try:
page = int(data.get('page') or 1) page = int(ns.get('page') or 1)
rows = int(data.get('rows') or 20)
except (TypeError, ValueError): except (TypeError, ValueError):
page, rows = 1, 20
if page < 1:
page = 1 page = 1
if rows < 1: try:
rows = int(ns.get('rows') or ns.get('pagerows') or 20)
except (TypeError, ValueError):
rows = 20 rows = 20
if rows > 200: page = max(page, 1)
rows = 200 rows = min(max(rows, 1), 500)
keyword = (data.get('keyword') or '').strip()
cond = ''
params = {}
if keyword:
cond = ' where script_name like ${keyword}$ '
params['keyword'] = '%' + keyword + '%'
offset = (page - 1) * rows 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: try:
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
cnt_recs = await sor.sqlExe('select count(*) as cnt from ' + TABLE + cond, params) cnts = await sor.sqlExe('select count(*) as cnt from ' + TABLE + where, vals)
cnt = cnt_recs[0].cnt if cnt_recs else 0 total = int(cnts[0].cnt) if cnts else 0
sql = ('select id, script_name, script_type, description, status, ' recs = await sor.sqlExe(
'created_at, updated_at from ' + TABLE + cond + 'select id, script_name, script_type, description, status, '
' order by created_at desc limit ${offset}$, ${rows}$') 'created_at, updated_at from ' + TABLE + where +
q = dict(params) ' order by created_at desc limit %d offset %d' % (rows, offset),
q['offset'] = offset vals)
q['rows'] = rows items = [{
recs = await sor.sqlExe(sql, q) '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: except Exception as e:
exception('script_engine.list_scripts error: %s' % e) debug('script_engine list_scripts error: %s' % str(e))
return _err(1, 'list failed: %s' % e) return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
items = [{ return {'code': 0, 'message': 'ok', 'data': {'list': items, 'total': total}}
'id': r.id,
'script_name': r.script_name,
'script_type': r.script_type,
'description': r.description,
'status': r.status,
'created_at': r.created_at,
'updated_at': r.updated_at,
} for r in recs]
return {'code': 0, 'message': 'ok', 'data': {'list': items, 'total': cnt}}
async def validate_script_api(request, ns): async def _execute_sql(sql, params):
"""Validate only -- never persists, never executes."""
data = _clean_ns(ns)
stype = data.get('script_type') or '0'
content = data.get('content') or ''
if not content or not content.strip():
return _err(1, 'content is required', 'content')
v = engine.validate(stype, content)
if v.get('code') != 0:
return _err(1, v['message'], 'content')
return _ok({'script_type': stype, 'valid': True})
async def execute_script(request, ns):
"""Execute a script by id OR by inline content+script_type.
Python scripts receive the business params (everything except control
fields) as a ``params`` dict in the namespace, so scripts can read input
via ``params.get('key')``.
"""
data = _clean_ns(ns)
dbname = _dbname() dbname = _dbname()
sid = (data.get('id') or '').strip() 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: if sid:
dbname = _dbname()
try: try:
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': sid}) recs = await sor.R(TABLE, {'id': sid})
except Exception as e: except Exception as e:
exception('script_engine.execute_script query error: %s' % e) debug('script_engine execute_script load error: %s' % str(e))
return _err(1, 'query failed: %s' % e) return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
if not recs: if not recs:
return _err(1, 'script not found', 'id') return {'code': 1, 'message': 'script not found', 'field': 'id', 'detail': ''}
r = recs[0] rec = recs[0]
content = r.content if str(rec.status) != STATUS_ENABLED:
stype = r.script_type return {'code': 1, 'message': 'script is disabled', 'field': 'status', 'detail': ''}
else: content = rec.content
content = data.get('content') or '' stype = str(rec.script_type)
stype = data.get('script_type') or '0' err = _check_type(stype)
if not content or not content.strip(): if err:
return _err(1, 'content is required', 'content') return {'code': 1, 'message': err, 'field': 'script_type', 'detail': ''}
if stype not in ('0', '1'): if content is None or not str(content).strip():
return _err(1, 'invalid script_type, only 0=Python or 1=SQL allowed', 'script_type') return {'code': 1, 'message': 'content is required', 'field': 'content', 'detail': ''}
v = engine.validate(stype, content) ok, msg = _validate_content(stype, str(content))
if v.get('code') != 0: if not ok:
return _err(1, v['message'], 'content') return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''}
if stype == '0': if stype == SCRIPT_TYPE_PYTHON:
try: return execute_python(str(content), params)
ctrl = {'id', 'content', 'script_type', 'page', 'rows', 'keyword'} return await _execute_sql(str(content), params)
params = {k: v for k, v in data.items() if k not in ctrl}
out = engine.execute_python(content, input_ns={'params': params})
except Exception as e: async def validate_script_api(request, params_kw):
exception('script_engine.execute_script python error: %s' % e) """Validate only - does not save to db and does not execute."""
return _err(1, 'execute failed: %s' % e, 'content') ns = _clean_params(params_kw)
else: content = str(ns.get('content') or '')
try: if not content.strip():
async with DBPools().sqlorContext(dbname) as sor: return {'code': 1, 'message': 'content is required', 'field': 'content', 'detail': ''}
out = await sor.sqlExe(content, {}) stype = str(ns.get('script_type') or SCRIPT_TYPE_PYTHON)
except Exception as e: err = _check_type(stype)
exception('script_engine.execute_script sql error: %s' % e) if err:
return _err(1, 'execute failed: %s' % e, 'content') return {'code': 1, 'message': err, 'field': 'script_type', 'detail': ''}
return _ok({'result': out}) 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): def load_script_engine(env=None):
"""Register module functions with ServerEnv (the ONLY integration point).""" """Register all script_engine functions to ServerEnv."""
env = env or ServerEnv() if env is None:
env = ServerEnv()
env.create_script = create_script env.create_script = create_script
env.create_scripts = create_script env.create_scripts = create_scripts
env.update_script = update_script env.update_script = update_script
env.update_scripts = update_script env.update_scripts = update_scripts
env.delete_script = delete_script env.delete_script = delete_script
env.delete_scripts = delete_script env.delete_scripts = delete_scripts
env.get_script = get_script env.get_script = get_script
env.list_scripts = list_scripts 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_api = validate_script_api
debug('script_engine module loaded')
return env return env

View File

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

View File

@ -1,6 +1,9 @@
result = [] # get_search_script_type.dspy - dropdown for script_type (appcodes_kv parentid=script_type)
debug('get_search_script_type.dspy: START')
try: try:
result = [{'value': '', 'text': '全部'}, {'value': '0', 'text': 'Python'}, {'value': '1', 'text': 'SQL'}] 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", {})
return json.dumps([{'value': '', 'text': '全部'}] + list(recs), ensure_ascii=False)
except Exception as e: except Exception as e:
debug('get_search_script_type error: %s' % e) debug('get_search_script_type.dspy error: %s' % str(e))
return json.dumps(result, ensure_ascii=False) return json.dumps([{'value': '', 'text': '全部'}], ensure_ascii=False)

View File

@ -1,6 +1,9 @@
result = [] # get_search_status.dspy - dropdown for status (appcodes_kv parentid=script_status)
debug('get_search_status.dspy: START')
try: try:
result = [{'value': '', 'text': '全部'}, {'value': '1', 'text': '启用'}, {'value': '0', 'text': '停用'}] 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", {})
return json.dumps([{'value': '', 'text': '全部'}] + list(recs), ensure_ascii=False)
except Exception as e: except Exception as e:
debug('get_search_status error: %s' % e) debug('get_search_status.dspy error: %s' % str(e))
return json.dumps(result, ensure_ascii=False) return json.dumps([{'value': '', 'text': '全部'}], ensure_ascii=False)

View File

@ -1,2 +1,6 @@
# 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) result = await create_script(request, params_kw)
return result 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,2 +1,6 @@
# 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) result = await delete_script(request, params_kw)
return result 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,2 +1,4 @@
# 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) result = await execute_script(request, params_kw)
return result return result

View File

@ -1,2 +1,4 @@
# 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) result = await get_script(request, params_kw)
return result return result

View File

@ -1,2 +1,7 @@
# 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) result = await list_scripts(request, params_kw)
return result 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,2 +1,6 @@
# 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) result = await update_script(request, params_kw)
return result 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,2 +1,4 @@
# 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) result = await validate_script_api(request, params_kw)
return result return result

View File

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