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": [
{
"parentid": "script_type",
"parentname": "脚本类型",
"items": [
{"k": "0", "v": "Python"},
{"k": "1", "v": "SQL"}
]
},
{
"parentid": "script_status",
"parentname": "脚本状态",
"items": [
{"k": "0", "v": "停用"},
{"k": "1", "v": "启用"}
]
}
]
"appcodes": [
{
"parentid": "script_type",
"parentname": "脚本类型",
"items": [
{"k": "0", "v": "Python"},
{"k": "1", "v": "SQL"}
]
},
{
"parentid": "script_status",
"parentname": "脚本状态",
"items": [
{"k": "0", "v": "停用"},
{"k": "1", "v": "启用"}
]
}
]
}

View File

@ -1,23 +1,38 @@
{
"tblname": "script_engine",
"title": "脚本管理",
"params": {
"sortby": ["created_at desc"],
"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')}}",
"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')}}"
},
"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"]
}
"tblname": "script_engine",
"title": "脚本管理",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"exclouded": ["id", "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"],
"data_filter": {
"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": [
{
"name": "script_engine",
"title": "脚本表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "script_name", "title": "脚本名称", "type": "str", "length": 100, "nullable": "no"},
{"name": "script_type", "title": "脚本类型", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "content", "title": "脚本内容", "type": "text", "nullable": "no"},
{"name": "description", "title": "描述", "type": "str", "length": 255},
{"name": "status", "title": "状态", "type": "str", "length": 32, "nullable": "no", "default": "1"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_script_name", "idxtype": "index", "idxfields": ["script_name"]},
{"name": "idx_script_type", "idxtype": "index", "idxfields": ["script_type"]}
],
"codes": [
{"field": "script_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_type'"},
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_status'"}
]
"summary": [
{
"name": "script_engine",
"title": "脚本表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "script_name",
"title": "脚本名称",
"type": "str",
"length": 100,
"nullable": "no"
},
{
"name": "script_type",
"title": "脚本类型",
"type": "str",
"length": 32,
"nullable": "no",
"default": "0"
},
{
"name": "content",
"title": "脚本内容",
"type": "text",
"nullable": "no"
},
{
"name": "description",
"title": "描述",
"type": "str",
"length": 255
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 32,
"nullable": "no",
"default": "1"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"nullable": "no"
}
],
"indexes": [
{
"name": "idx_script_name",
"idxtype": "index",
"idxfields": ["script_name"]
},
{
"name": "idx_script_type",
"idxtype": "index",
"idxfields": ["script_type"]
}
],
"codes": [
{
"field": "script_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='script_type'"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='script_status'"
}
]
}

View File

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

View File

@ -1,183 +1,146 @@
# -*- coding: utf-8 -*-
"""script_engine execution engine.
Restricted script execution for script_type 0=Python / 1=SQL.
Python runs in a whitelisted-builtins namespace; imports, classes, lambdas,
async/await, generators and unsafe attribute access are forbidden.
Method calls (ast.Attribute) are allowed ONLY when the method name is on the
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).
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 python builtins (safe pure functions only)
ALLOWED_BUILTINS = {
'abs', 'all', 'any', 'bool', 'dict', 'divmod', 'enumerate', 'filter',
'float', 'int', 'isinstance', 'len', 'list', 'map', 'max', 'min', 'ord',
'chr', 'pow', 'range', 'repr', 'round', 'set', 'sorted', 'str', 'sum',
'tuple', 'type', 'zip',
# 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',
}
# whitelisted object methods. The receiver is always an object that already
# lives in the script namespace (a dict/list/str/int/float injected as
# ``params`` or created by the script itself); there is no way to reach
# os/subprocess/import machinery because dunder access is blocked and the
# builtins namespace is restricted to ALLOWED_BUILTINS. Every method NOT in
# 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',
# 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 never allowed in a script
FORBIDDEN_NODES = (
ast.Import, ast.ImportFrom, ast.ClassDef, ast.Lambda,
ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith, ast.Await,
ast.Yield, ast.YieldFrom, ast.Global, ast.Nonlocal,
# 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 keywords that indicate write / dangerous statements
SQL_FORBIDDEN_RE = re.compile(
r'\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|'
r'replace|call|exec|execute|merge|rename|lock|unlock|set|use)\b',
re.IGNORECASE,
# 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',
)
SQL_READONLY_RE = re.compile(r'^\s*(select|show|describe|desc|explain)\b', re.IGNORECASE)
def validate_python(content):
"""Validate python script syntax and forbidden constructs.
def _has_keyword(text, kw):
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
{'code': 1, 'message': ...}.
"""
if not content or not content.strip():
return {'code': 1, 'message': 'content is required'}
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(content, mode='exec')
tree = ast.parse(str(content))
except SyntaxError as e:
return {'code': 1, 'message': 'syntax error: %s' % e}
# rule 1 + 2: forbidden node kinds and dunder attribute access
return False, 'syntax error: %s' % str(e)
for node in ast.walk(tree):
if isinstance(node, FORBIDDEN_NODES):
return {'code': 1, 'message': 'forbidden construct: %s' % type(node).__name__}
if isinstance(node, ast.Attribute) and node.attr.startswith('__'):
return {'code': 1, 'message': 'forbidden attribute access: %s' % node.attr}
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, ''
# rule 3: calls
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
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
func = node.func
if isinstance(func, ast.Name):
if func.id not in ALLOWED_BUILTINS:
return {'code': 1, 'message': 'forbidden call: %s' % func.id}
elif isinstance(func, ast.Attribute):
if func.attr not in ALLOWED_METHODS:
return {'code': 1, 'message': 'forbidden method call: %s' % func.attr}
else:
return {'code': 1, 'message': 'forbidden call expression'}
# rule 4: attribute assignment / deletion
for node in ast.walk(tree):
targets = []
if isinstance(node, ast.Assign):
targets = node.targets
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
targets = [node.target]
elif isinstance(node, ast.Delete):
targets = node.targets
for t in targets:
for sub in ast.walk(t):
if isinstance(sub, ast.Attribute):
return {'code': 1, 'message': 'attribute assignment is forbidden'}
return {'code': 0, 'message': 'ok'}
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 execute_python(content, input_ns=None):
"""Execute python script in a restricted namespace.
The script may assign a final variable named `result` which is returned.
``input_ns`` (e.g. {'params': {...}}) is injected into the namespace so
scripts can read business inputs with ``params.get('key')``.
"""
blt = __builtins__ if isinstance(__builtins__, dict) else vars(__builtins__)
builtins_ns = {}
for k in ALLOWED_BUILTINS:
if k in blt:
builtins_ns[k] = blt[k]
namespace = {'__builtins__': builtins_ns}
if input_ns:
for k, v in input_ns.items():
if k and k != '__builtins__':
namespace[k] = v
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'}
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,283 +1,327 @@
# -*- coding: utf-8 -*-
"""script_engine module initialization.
"""script_engine module init.
Registers all module functions with ServerEnv inside load_script_engine() so
that .dspy / .ui files can call them directly. DB name is resolved via
ServerEnv().get_module_dbname('script_engine') -- never hardcoded.
Register script table CRUD + execute/validate interfaces to ServerEnv.
Functions:
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
from appPublic.timeUtils import curDateString
from appPublic.log import debug, exception
from ahserver import ServerEnv
from sqlor.dbpools import DBPools
from . import engine
try:
from ahserver 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 sqlor.dbpools import DBPools
from .engine import (
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():
return ServerEnv().get_module_dbname('script_engine')
return ServerEnv().get_module_dbname(MODULE)
def _clean_ns(ns):
"""Normalize client params: drop NaN/null placeholders and _text suffixes."""
data = ns.copy()
for k, v in list(data.items()):
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 _clean_params(params_kw):
if hasattr(params_kw, 'copy'):
return params_kw.copy()
return dict(params_kw or {})
def _err(code, message, field=''):
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()
def _check_name(name):
if not name:
return _err(1, 'script_name is required', 'script_name')
return 'script_name is required'
if len(name) > 100:
return _err(1, 'script_name too long (max 100)', 'script_name')
stype = data.get('script_type') or '0'
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 ''
if not content or not content.strip():
return _err(1, 'content is required', 'content')
v = await validate_script_api(request, {'script_type': stype, 'content': content})
if v.get('code') != 0:
return v
status = data.get('status') or '1'
if status not in ('0', '1'):
status = '1'
return 'script_name is too long (max 100)'
return ''
def _check_type(stype):
if stype not in (SCRIPT_TYPE_PYTHON, SCRIPT_TYPE_SQL):
return 'invalid script_type (0=python, 1=sql)'
return ''
def _validate_content(stype, content):
if stype == SCRIPT_TYPE_PYTHON:
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()
row = {
'id': data.get('id') or getID(),
rec = {
'id': getID(),
'script_name': name,
'script_type': stype,
'content': content,
'description': (data.get('description') or '')[:255],
'status': status,
'description': str(ns.get('description') or '')[:255],
'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, row)
async with DBPools().sqlorContext(dbname) as sor:
await sor.C(TABLE, rec)
except Exception as e:
exception('script_engine.create_script error: %s' % e)
return _err(1, 'create failed: %s' % e)
return _ok({'id': row['id']})
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, ns):
data = _clean_ns(ns)
sid = data.get('id') or ''
async def update_script(request, params_kw):
ns = _clean_params(params_kw)
sid = str(ns.get('id') or '').strip()
if not sid:
return _err(1, 'id is required', 'id')
name = (data.get('script_name') or '').strip()
if not name:
return _err(1, 'script_name is required', 'script_name')
if len(name) > 100:
return _err(1, 'script_name too long (max 100)', 'script_name')
stype = data.get('script_type') or '0'
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 ''
if not content or not content.strip():
return _err(1, 'content is required', 'content')
v = await validate_script_api(request, {'script_type': stype, 'content': content})
if v.get('code') != 0:
return v
status = data.get('status') or '1'
if status not in ('0', '1'):
status = '1'
row = {
'script_name': name,
'script_type': stype,
'content': content,
'description': (data.get('description') or '')[:255],
'status': status,
'updated_at': curDateString(),
}
return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''}
upd = {'id': sid, 'updated_at': curDateString()}
if ns.get('script_name') is not None:
name = str(ns.get('script_name') or '').strip()
err = _check_name(name)
if err:
return {'code': 1, 'message': err, 'field': 'script_name', 'detail': ''}
upd['script_name'] = name
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, {'id': sid, **row})
async with DBPools().sqlorContext(dbname) as sor:
await sor.U(TABLE, upd)
except Exception as e:
exception('script_engine.update_script error: %s' % e)
return _err(1, 'update failed: %s' % e)
return _ok({'id': sid})
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, ns):
data = _clean_ns(ns)
sid = data.get('id') or ''
async def delete_script(request, params_kw):
ns = _clean_params(params_kw)
sid = str(ns.get('id') or '').strip()
if not sid:
return _err(1, 'id is required', 'id')
return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''}
dbname = _dbname()
try:
async with DBPools().sqlorContext(_dbname()) as sor:
async with DBPools().sqlorContext(dbname) as sor:
await sor.D(TABLE, {'id': sid})
except Exception as e:
exception('script_engine.delete_script error: %s' % e)
return _err(1, 'delete failed: %s' % e)
return _ok({'id': sid})
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, ns):
data = _clean_ns(ns)
sid = data.get('id') or ''
async def get_script(request, params_kw):
ns = _clean_params(params_kw)
sid = str(ns.get('id') or '').strip()
if not sid:
return _err(1, 'id is required', 'id')
return {'code': 1, 'message': 'id is required', 'field': 'id', 'detail': ''}
dbname = _dbname()
try:
async with DBPools().sqlorContext(_dbname()) as sor:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': sid})
except Exception as e:
exception('script_engine.get_script error: %s' % e)
return _err(1, 'get failed: %s' % e)
debug('script_engine get_script error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
if not recs:
return _err(1, 'script not found', 'id')
return {'code': 1, 'message': 'script not found', 'field': 'id', 'detail': ''}
r = recs[0]
return _ok({
return {'code': 0, 'message': 'ok', 'data': {
'id': r.id,
'script_name': r.script_name,
'script_type': r.script_type,
'content': r.content,
'description': r.description,
'description': getattr(r, 'description', ''),
'status': r.status,
'created_at': r.created_at,
'updated_at': r.updated_at,
})
}}
async def list_scripts(request, ns):
data = _clean_ns(ns)
dbname = _dbname()
async def list_scripts(request, params_kw):
ns = _clean_params(params_kw)
try:
page = int(data.get('page') or 1)
rows = int(data.get('rows') or 20)
page = int(ns.get('page') or 1)
except (TypeError, ValueError):
page, rows = 1, 20
if page < 1:
page = 1
if rows < 1:
try:
rows = int(ns.get('rows') or ns.get('pagerows') or 20)
except (TypeError, ValueError):
rows = 20
if rows > 200:
rows = 200
keyword = (data.get('keyword') or '').strip()
cond = ''
params = {}
if keyword:
cond = ' where script_name like ${keyword}$ '
params['keyword'] = '%' + keyword + '%'
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:
cnt_recs = await sor.sqlExe('select count(*) as cnt from ' + TABLE + cond, params)
cnt = cnt_recs[0].cnt if cnt_recs else 0
sql = ('select id, script_name, script_type, description, status, '
'created_at, updated_at from ' + TABLE + cond +
' order by created_at desc limit ${offset}$, ${rows}$')
q = dict(params)
q['offset'] = offset
q['rows'] = rows
recs = await sor.sqlExe(sql, q)
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:
exception('script_engine.list_scripts error: %s' % e)
return _err(1, 'list failed: %s' % e)
items = [{
'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}}
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 validate_script_api(request, ns):
"""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)
async def _execute_sql(sql, params):
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:
dbname = _dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': sid})
except Exception as e:
exception('script_engine.execute_script query error: %s' % e)
return _err(1, 'query failed: %s' % e)
debug('script_engine execute_script load error: %s' % str(e))
return {'code': 1, 'message': str(e), 'field': '', 'detail': ''}
if not recs:
return _err(1, 'script not found', 'id')
r = recs[0]
content = r.content
stype = r.script_type
else:
content = data.get('content') or ''
stype = data.get('script_type') or '0'
if not content or not content.strip():
return _err(1, 'content is required', 'content')
if stype not in ('0', '1'):
return _err(1, 'invalid script_type, only 0=Python or 1=SQL allowed', 'script_type')
v = engine.validate(stype, content)
if v.get('code') != 0:
return _err(1, v['message'], 'content')
if stype == '0':
try:
ctrl = {'id', 'content', 'script_type', 'page', 'rows', 'keyword'}
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:
exception('script_engine.execute_script python error: %s' % e)
return _err(1, 'execute failed: %s' % e, 'content')
else:
try:
async with DBPools().sqlorContext(dbname) as sor:
out = await sor.sqlExe(content, {})
except Exception as e:
exception('script_engine.execute_script sql error: %s' % e)
return _err(1, 'execute failed: %s' % e, 'content')
return _ok({'result': out})
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 module functions with ServerEnv (the ONLY integration point)."""
env = env or ServerEnv()
"""Register all script_engine functions to ServerEnv."""
if env is None:
env = ServerEnv()
env.create_script = create_script
env.create_scripts = create_script
env.create_scripts = create_scripts
env.update_script = update_script
env.update_scripts = update_script
env.update_scripts = update_scripts
env.delete_script = delete_script
env.delete_scripts = delete_script
env.delete_scripts = delete_scripts
env.get_script = get_script
env.list_scripts = list_scripts
env.execute_script = execute_script
env.validate_script_api = validate_script_api
debug('script_engine module loaded')
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)
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:
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:
debug('get_search_script_type error: %s' % e)
return json.dumps(result, ensure_ascii=False)
debug('get_search_script_type.dspy error: %s' % str(e))
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:
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:
debug('get_search_status error: %s' % e)
return json.dumps(result, ensure_ascii=False)
debug('get_search_status.dspy error: %s' % str(e))
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)
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)
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)
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)
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)
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)
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)
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)
return result