# -*- coding: utf-8 -*- """drag 模块——拖拽编程画布。 提供: - drag_graph 画布表(blocks JSON + connections JSON + 编译产物 content) - drag_template 模板表 - 图校验(validate_graph)/编译(compile_graph)/块定义(get_block_defs) - 画布 CRUD + graph-save(保存即校验+编译) + compile + validate 端点函数 通过 load_drag() 挂载到 ServerEnv,宿主应用 load 后 /drag/api/*.dspy 可用。 取库名一律 ServerEnv().get_module_dbname('drag'),禁止硬编码。 注意:ahserver/appPublic 依赖全部为函数内惰性导入——保证 blocks/validator/ compiler 核心逻辑可在无宿主环境独立测试,部署时 ahserver 环境可用。 """ import json from .blocks import BLOCK_DEFS, get_block_defs from .validator import validate_graph from .compiler import compile_graph def _server_env(): """惰性获取 ServerEnv 单例(宿主环境)。""" from ahserver.serverenv import ServerEnv return ServerEnv() def _now(): from appPublic.timeutils import curDateString return curDateString() def _new_id(): from appPublic.getID import getID return getID() def _dumps(obj): from appPublic.jsonUtils import dumps return dumps(obj) async def get_drag_graph_dbname(): """drag 业务库名(宿主 get_module_dbname 决定)。""" try: return _server_env().get_module_dbname('drag') except Exception: return 'scense' # ═══════════════ 块定义(前端画布 + 校验 + 编译的唯一事实源) ═══════════════ async def get_block_defs_api(): """返回 {categories, blocks} 供前端渲染左侧块面板。""" return get_block_defs() # ═══════════════ 画布 CRUD ═══════════════ async def list_drag_graphs(params=None): """分页列表:{list, total},支持 name/status 过滤 + sort/order。""" params = params or {} dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: where, args = [], [] name = params.get('name') status = params.get('status') if name: where.append('name LIKE %s') args.append('%%%s%%' % name) if status: where.append('status = %s') args.append(status) wsql = (' WHERE ' + ' AND '.join(where)) if where else '' ns = {'page': int(params.get('page', 1) or 1), 'rows': int(params.get('rows', 20) or 20), 'sort': params.get('sort', 'updated_at'), 'order': params.get('order', 'desc')} recs = await sor.sqlPaging( 'SELECT id, name, description, status, created_at, updated_at ' 'FROM drag_graph%s' % wsql, ns, args) rows = [{'id': r.id, 'name': r.name, 'description': getattr(r, 'description', ''), 'status': r.status, 'created_at': str(getattr(r, 'created_at', '')), 'updated_at': str(getattr(r, 'updated_at', ''))} for r in recs] return {'list': rows, 'total': ns.get('total', len(rows))} except Exception as e: return {'code': 'DB_ERROR', 'message': '查询画布列表失败: %s' % e} async def get_drag_graph(ns): """获取画布详情(含 blocks/connections 解析)。""" gid = (ns or {}).get('id') if not gid: return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'} dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: recs = await sor.R('drag_graph', {'id': gid}) if not recs: return {'code': 'NOT_FOUND', 'message': '画布不存在: %s' % gid} r = recs[0] data = {'id': r.id, 'name': r.name, 'description': getattr(r, 'description', ''), 'status': r.status, 'created_at': str(getattr(r, 'created_at', '')), 'updated_at': str(getattr(r, 'updated_at', ''))} try: data['blocks'] = json.loads(r.blocks or '[]') except Exception: data['blocks'] = [] try: data['connections'] = json.loads(r.connections or '[]') except Exception: data['connections'] = [] data['content'] = r.content or '' return {'data': data} except Exception as e: return {'code': 'DB_ERROR', 'message': '查询画布失败: %s' % e} async def create_drag_graph(ns): """创建画布:校验 blocks/connections → 编译 → 落库。""" name = (ns or {}).get('name') if not name or not str(name).strip(): return {'code': 'PARAM_REQUIRED', 'message': '画布名称必填', 'field': 'name'} if len(str(name)) > 100: return {'code': 'FIELD_TOO_LONG', 'message': '画布名称不能超过 100 字符', 'field': 'name'} blocks = ns.get('blocks') conns = ns.get('connections') if isinstance(blocks, str): try: blocks = json.loads(blocks) except Exception: return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'} if isinstance(conns, str): try: conns = json.loads(conns) except Exception: return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'} if blocks is None: blocks = [] if conns is None: conns = [] graph = {'blocks': blocks, 'connections': conns} v = validate_graph(graph) result = compile_graph(graph, {'name': name}) gid = _new_id() content = result.get('content', '') if result.get('success') else '' dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: await sor.C('drag_graph', { 'id': gid, 'name': str(name).strip(), 'description': str(ns.get('description') or ''), 'blocks': _dumps(blocks), 'connections': _dumps(conns), 'content': content, 'status': '1', 'created_at': _now(), 'updated_at': _now()}) return {'success': True, 'id': gid, 'valid': v['valid'], 'errors': v['errors'], 'content': content} except Exception as e: return {'code': 'DB_ERROR', 'message': '保存画布失败: %s' % e} async def update_drag_graph(ns): """更新画布:校验+编译后写回 content。""" gid = (ns or {}).get('id') if not gid: return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'} blocks = ns.get('blocks') conns = ns.get('connections') if isinstance(blocks, str): try: blocks = json.loads(blocks) except Exception: return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'} if isinstance(conns, str): try: conns = json.loads(conns) except Exception: return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'} if blocks is None: blocks = [] if conns is None: conns = [] graph = {'blocks': blocks, 'connections': conns} v = validate_graph(graph) result = compile_graph(graph, {'name': ns.get('name') or gid}) content = result.get('content', '') if result.get('success') else '' dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: recs = await sor.R('drag_graph', {'id': gid}) if not recs: return {'code': 'NOT_FOUND', 'message': '画布不存在: %s' % gid} up = {'blocks': _dumps(blocks), 'connections': _dumps(conns), 'content': content, 'updated_at': _now()} if ns.get('name'): up['name'] = str(ns['name']).strip() if ns.get('description') is not None: up['description'] = str(ns['description']) if ns.get('status'): up['status'] = str(ns['status']) await sor.U('drag_graph', up, {'id': gid}) return {'success': True, 'id': gid, 'valid': v['valid'], 'errors': v['errors'], 'content': content} except Exception as e: return {'code': 'DB_ERROR', 'message': '更新画布失败: %s' % e} async def delete_drag_graph(ns): """删除画布。""" gid = (ns or {}).get('id') if not gid: return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'} dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: await sor.D('drag_graph', {'id': gid}) return {'success': True, 'id': gid} except Exception as e: return {'code': 'DB_ERROR', 'message': '删除画布失败: %s' % e} # ═══════════════ 画布保存(保存即校验+编译) ═══════════════ async def graph_save(ns): """保存画布(新建或更新):blocks/connections 校验+编译后落库。 ns: {id?, name, description?, blocks, connections} 返回 {success, id, valid, errors, content} """ gid = (ns or {}).get('id') if gid: return await update_drag_graph(ns) return await create_drag_graph(ns) # ═══════════════ 编译 / 校验 ═══════════════ async def graph_compile(ns): """编译画布 → script_engine 可执行脚本(不落库)。""" blocks = (ns or {}).get('blocks') conns = (ns or {}).get('connections') if isinstance(blocks, str): try: blocks = json.loads(blocks) except Exception: return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'} if isinstance(conns, str): try: conns = json.loads(conns) except Exception: return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'} graph = {'blocks': blocks or [], 'connections': conns or []} return compile_graph(graph, {'name': (ns or {}).get('name')}) async def graph_validate(ns): """校验画布 → {valid, errors, warnings}(不落库不编译)。""" blocks = (ns or {}).get('blocks') conns = (ns or {}).get('connections') if isinstance(blocks, str): try: blocks = json.loads(blocks) except Exception: return {'valid': False, 'errors': [{'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'}]} if isinstance(conns, str): try: conns = json.loads(conns) except Exception: return {'valid': False, 'errors': [{'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'}]} return validate_graph({'blocks': blocks or [], 'connections': conns or []}) # ═══════════════ 编译产物发布到 script_engine ═══════════════ async def graph_publish(ns): """把画布编译产物写入 script_engine(script_type=0 Python 可执行脚本)。 ns: {id 画布id, script_name?} 返回 {success, script_id, script_name} """ gid = (ns or {}).get('id') if not gid: return {'code': 'PARAM_REQUIRED', 'message': '缺少画布 id', 'field': 'id'} got = await get_drag_graph({'id': gid}) if got.get('code'): return got data = got['data'] result = compile_graph({'blocks': data['blocks'], 'connections': data['connections']}, {'name': data['name'], 'id': gid}) if not result.get('success'): return {'code': 'COMPILE_ERROR', 'message': '画布校验未通过,无法发布', 'errors': result.get('errors', [])} script_name = (ns or {}).get('script_name') or ('drag_%s' % data['name']) se = _server_env() try: create_script = getattr(se, 'create_script', None) or getattr(se, 'create_scripts', None) if create_script is None: return {'code': 'MODULE_MISSING', 'message': 'script_engine 未挂载,无法发布'} r = await create_script({'script_name': script_name, 'script_type': '0', 'content': result['content'], 'description': 'drag 画布编译产物 (graph=%s)' % gid}) return {'success': True, 'script_id': r.get('id'), 'script_name': script_name, 'script_result': r} except Exception as e: return {'code': 'DB_ERROR', 'message': '发布到 script_engine 失败: %s' % e} # ═══════════════ 模板 CRUD ═══════════════ async def list_drag_templates(params=None): """模板分页列表:{list, total}。""" params = params or {} dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: where, args = [], [] name = params.get('name') if name: where.append('name LIKE %s') args.append('%%%s%%' % name) wsql = (' WHERE ' + ' AND '.join(where)) if where else '' ns = {'page': int(params.get('page', 1) or 1), 'rows': int(params.get('rows', 20) or 20), 'sort': params.get('sort', 'updated_at'), 'order': params.get('order', 'desc')} recs = await sor.sqlPaging( 'SELECT id, name, category, description, created_at FROM drag_template%s' % wsql, ns, args) rows = [{'id': r.id, 'name': r.name, 'category': r.category, 'description': getattr(r, 'description', ''), 'created_at': str(getattr(r, 'created_at', ''))} for r in recs] return {'list': rows, 'total': ns.get('total', len(rows))} except Exception as e: return {'code': 'DB_ERROR', 'message': '查询模板失败: %s' % e} async def get_drag_template(ns): """模板详情(含 blocks/connections 解析)。""" tid = (ns or {}).get('id') if not tid: return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'} dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: recs = await sor.R('drag_template', {'id': tid}) if not recs: return {'code': 'NOT_FOUND', 'message': '模板不存在: %s' % tid} r = recs[0] data = {'id': r.id, 'name': r.name, 'category': r.category, 'description': getattr(r, 'description', '')} try: data['blocks'] = json.loads(r.blocks or '[]') except Exception: data['blocks'] = [] try: data['connections'] = json.loads(r.connections or '[]') except Exception: data['connections'] = [] return {'data': data} except Exception as e: return {'code': 'DB_ERROR', 'message': '查询模板失败: %s' % e} async def create_drag_template(ns): """创建模板:blocks/connections 校验。""" name = (ns or {}).get('name') if not name or not str(name).strip(): return {'code': 'PARAM_REQUIRED', 'message': '模板名称必填', 'field': 'name'} blocks = ns.get('blocks') or [] conns = ns.get('connections') or [] if isinstance(blocks, str): try: blocks = json.loads(blocks) except Exception: return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'} if isinstance(conns, str): try: conns = json.loads(conns) except Exception: return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'} v = validate_graph({'blocks': blocks, 'connections': conns}) dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: tid = _new_id() await sor.C('drag_template', { 'id': tid, 'name': str(name).strip(), 'category': str(ns.get('category') or '通用'), 'description': str(ns.get('description') or ''), 'blocks': _dumps(blocks), 'connections': _dumps(conns), 'created_at': _now(), 'updated_at': _now()}) return {'success': True, 'id': tid, 'valid': v['valid'], 'errors': v['errors']} except Exception as e: return {'code': 'DB_ERROR', 'message': '保存模板失败: %s' % e} async def update_drag_template(ns): """更新模板。""" tid = (ns or {}).get('id') if not tid: return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'} blocks = ns.get('blocks') conns = ns.get('connections') if isinstance(blocks, str): try: blocks = json.loads(blocks) except Exception: return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'} if isinstance(conns, str): try: conns = json.loads(conns) except Exception: return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'} dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: recs = await sor.R('drag_template', {'id': tid}) if not recs: return {'code': 'NOT_FOUND', 'message': '模板不存在: %s' % tid} up = {'updated_at': _now()} if blocks is not None: up['blocks'] = _dumps(blocks) if conns is not None: up['connections'] = _dumps(conns) if ns.get('name'): up['name'] = str(ns['name']).strip() if ns.get('category'): up['category'] = str(ns['category']) if ns.get('description') is not None: up['description'] = str(ns['description']) await sor.U('drag_template', up, {'id': tid}) return {'success': True, 'id': tid} except Exception as e: return {'code': 'DB_ERROR', 'message': '更新模板失败: %s' % e} async def delete_drag_template(ns): """删除模板。""" tid = (ns or {}).get('id') if not tid: return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'} dbname = await get_drag_graph_dbname() try: async with _server_env().sqlorContext(dbname) as sor: await sor.D('drag_template', {'id': tid}) return {'success': True, 'id': tid} except Exception as e: return {'code': 'DB_ERROR', 'message': '删除模板失败: %s' % e} async def template_use(ns): """应用模板:按模板生成新画布。ns: {template_id, name} → 新画布 id。""" tid = (ns or {}).get('template_id') or (ns or {}).get('id') if not tid: return {'code': 'PARAM_REQUIRED', 'message': '缺少 template_id', 'field': 'template_id'} got = await get_drag_template({'id': tid}) if got.get('code'): return got tpl = got['data'] return await create_drag_graph({'name': ns.get('name') or (tpl['name'] + '_副本'), 'description': '由模板 %s 生成' % tpl['name'], 'blocks': tpl['blocks'], 'connections': tpl['connections']}) # ═══════════════ 实体选择器(下拉数据源) ═══════════════ async def get_entity_options(ns=None): """实体下拉选项 [{value, text}]。复用 entity 模块的实体列表。""" se = _server_env() list_entities = getattr(se, 'list_entities', None) if list_entities is None: return [{'value': 'hero', 'text': 'hero(示例)'}, {'value': 'coin', 'text': 'coin(示例)'}] try: r = await list_entities({'page': 1, 'rows': 500}) items = r.get('list') or r.get('data') or [] if isinstance(r, list): items = r out = [] for it in items: if isinstance(it, dict): out.append({'value': it.get('id'), 'text': it.get('name') or it.get('id')}) else: out.append({'value': getattr(it, 'id', ''), 'text': getattr(it, 'name', '') or getattr(it, 'id', '')}) return out or [{'value': 'hero', 'text': 'hero(示例)'}, {'value': 'coin', 'text': 'coin(示例)'}] except Exception: return [{'value': 'hero', 'text': 'hero(示例)'}, {'value': 'coin', 'text': 'coin(示例)'}] async def get_template_category_options(ns=None): """模板分类下拉。""" return [{'value': '通用', 'text': '通用'}, {'value': '移动', 'text': '移动'}, {'value': '弹球', 'text': '弹球'}, {'value': '计时', 'text': '计时'}] # ═══════════════ 辅助:脚本校验白名单(供 script_engine 执行 drag 产物) ═══════════════ def _action_whitelist(): """drag 编译产物可调用的运行时动作白名单(宿主演绎层实现)。""" return ['move', 'rotate', 'scale', 'set_property', 'play_animation', 'play_sound', 'show_hide', 'camera', 'wait'] def load_drag(): """挂载 drag 模块函数到 ServerEnv(宿主 init() 调用)。""" env = _server_env() env.get_block_defs_api = get_block_defs_api env.list_drag_graphs = list_drag_graphs env.get_drag_graph = get_drag_graph env.create_drag_graph = create_drag_graph env.update_drag_graph = update_drag_graph env.delete_drag_graph = delete_drag_graph env.graph_save = graph_save env.graph_compile = graph_compile env.graph_validate = graph_validate env.graph_publish = graph_publish env.list_drag_templates = list_drag_templates env.get_drag_template = get_drag_template env.create_drag_template = create_drag_template env.update_drag_template = update_drag_template env.delete_drag_template = delete_drag_template env.template_use = template_use env.get_entity_options = get_entity_options env.get_template_category_options = get_template_category_options # CRUD 复数别名(CRUD 框架约定:create_xxx/update_xxx/delete_xxx) env.create_drag_graphs = create_drag_graph env.update_drag_graphs = update_drag_graph env.delete_drag_graphs = delete_drag_graph env.create_drag_templates = create_drag_template env.update_drag_templates = update_drag_template env.delete_drag_templates = delete_drag_template # 内部导出(测试/扩展用) env.drag_action_whitelist = _action_whitelist env.drag_validate_graph = validate_graph env.drag_compile_graph = compile_graph print('[drag] load_drag() OK: %d block types registered' % len(BLOCK_DEFS))