diff --git a/pbl_blueprint/__init__.py b/pbl_blueprint/__init__.py index b509937..186135f 100644 --- a/pbl_blueprint/__init__.py +++ b/pbl_blueprint/__init__.py @@ -11,7 +11,7 @@ (返回 ERR_TENANT_MISSING,绝不回落默认租户、绝不跨租户可见)。 """ -__version__ = "1.0.0" +__version__ = "1.1.0" # M1b:模板平台公共部分/子对象扩展/关联表 __module_name__ = "pbl_blueprint" # ------------------------------------------------------------------ errors 内核(14 符号) @@ -35,6 +35,37 @@ from .errors import ( # noqa: F401 # ------------------------------------------------------------------ 挂载入口 from .init import load_pbl_blueprint # noqa: F401 +# ------------------------------------------------------------------ M1b(模板平台公共部分 / 子对象扩展 / 关联表判定) +# 三处同步之「__init__ 导出」环节:函数定义(m1b_*.py) + 本文件导出 + init.py env 注册 +from .m1b_init import ( # noqa: F401 + M1B_TABLES, + M1B_MODEL_FILES, + M1B_CRUD_FILES, + M1B_DDL, + PLATFORM_EXT_DEFS, + PLATFORM_TEMPLATES, + init_m1b, + ensure_m1b_tables, + ensure_platform_ext_defs, + seed_platform_data, + load_m1b_models, + load_m1b_crud_defs, +) +from .m1b_api import M1B_ROUTES, dispatch as m1b_dispatch, register_m1b_routes # noqa: F401 +from .m1b_db import ( # noqa: F401 + PLATFORM_TENANT_KEY, + WRITE_PROTECTED_TABLES, + PBL_DB_WRITE_PROTECTED, + WriteProtectedError, + SorDB, + tenant_key as m1b_tenant_key, + assert_writable as m1b_assert_writable, + make_db as m1b_make_db, +) +from . import m1b_template # noqa: F401 +from . import m1b_subobject # noqa: F401 +from . import m1b_ref # noqa: F401 + __all__ = [ "__version__", "__module_name__", @@ -57,4 +88,17 @@ __all__ = [ "err", # 挂载入口 "load_pbl_blueprint", + # ---- M1b:挂载/建表/种子 + "M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL", + "PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES", + "init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs", + "seed_platform_data", "load_m1b_models", "load_m1b_crud_defs", + # ---- M1b:路由 + "M1B_ROUTES", "m1b_dispatch", "register_m1b_routes", + # ---- M1b:真实库适配 + Q-OPEN-3 写保护闸门 + "PLATFORM_TENANT_KEY", "WRITE_PROTECTED_TABLES", "PBL_DB_WRITE_PROTECTED", + "WriteProtectedError", "SorDB", "m1b_tenant_key", "m1b_assert_writable", + "m1b_make_db", + # ---- M1b:业务子模块 + "m1b_template", "m1b_subobject", "m1b_ref", ] diff --git a/pbl_blueprint/init.py b/pbl_blueprint/init.py index 7259f7d..b81c23c 100644 --- a/pbl_blueprint/init.py +++ b/pbl_blueprint/init.py @@ -14,13 +14,27 @@ from .subobject import (BUILTIN_SCHEMA, add_rel, delete_rel, list_rels, list_subobjects, reorder_subobject, upsert_subobject, validate_payload) +from . import m1b_init as _m1b_init +from .m1b_api import M1B_ROUTES, register_m1b_routes +from .m1b_init import (M1B_CRUD_FILES, M1B_DDL, M1B_MODEL_FILES, M1B_TABLES, + ensure_m1b_tables, init_m1b, load_m1b_crud_defs, + load_m1b_models, seed_platform_data) + MODULE_NAME = "pbl_blueprint" -TABLE_NAMES = list(_tables.TABLES.keys()) # 11 表 +TABLE_NAMES = list(_tables.TABLES.keys()) # M1a 11 表 SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类 +M1B_TABLE_NAMES = list(M1B_TABLES) # M1b 4 表 +ALL_TABLE_NAMES = TABLE_NAMES + [x for x in M1B_TABLE_NAMES + if x not in TABLE_NAMES] __all__ = [ "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES", "SUBOBJECT_TYPES", "BUILTIN_SCHEMA", + # ---- M1b 三处同步之「init.py env 注册」环节 + "M1B_TABLES", "M1B_TABLE_NAMES", "ALL_TABLE_NAMES", "M1B_MODEL_FILES", + "M1B_CRUD_FILES", "M1B_DDL", "M1B_ROUTES", + "init_m1b", "ensure_m1b_tables", "register_m1b_routes", + "load_m1b_models", "load_m1b_crud_defs", "seed_platform_data", "create_blueprint", "get_blueprint", "list_blueprints", "update_blueprint", "delete_blueprint", "get_tree", "change_status", "set_quality_level", "commit_version", "list_versions", "get_version", "get_delta", @@ -76,14 +90,51 @@ def _seed_builtin_schema(sor, dbname): i * 10, esc(now_str())))) +def _m1b_register_callback(app): + """解析应用层路由注册回调:register_route / register / route / add_route 四选一。""" + if app is None: + return None + for name in ("register_route", "register", "route", "add_route"): + fn = getattr(app, name, None) + if callable(fn): + return fn + return None + + def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw): dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入 if ensure: ensure_tables(sor) + + # -------------------------------------------------------- M1b 挂载 + # 三处同步:m1b_api.register_m1b_routes(定义)+ __init__.py(导出)+ 此处(env 注册) + # Q-OPEN-3:init_m1b 只建 M1b 自有 4 表,绝不触碰 world/scene/entity/script 基表。 + m1b_result = {"ok": False, "skipped": True} + if kw.get("m1b", True): + try: + m1b_result = init_m1b( + db=kw.get("m1b_db"), + env=kw.get("env"), + register=_m1b_register_callback(app), + executor=kw.get("m1b_executor"), + seed=bool(kw.get("m1b_seed", ensure)), + ) + except Exception as e: # M1b 失败不拖垮 M1a 挂载 + m1b_result = {"ok": False, + "error": "%s: %s" % (type(e).__name__, e)} + if app is not None: try: app.register_module(MODULE_NAME, { - "tables": TABLE_NAMES, + "tables": ALL_TABLE_NAMES, + "m1b": { + "tables": M1B_TABLE_NAMES, + "model_files": M1B_MODEL_FILES, + "crud_files": M1B_CRUD_FILES, + "ddl": M1B_DDL, + "routes": [(m, p) for m, p, _ in M1B_ROUTES], + "init": m1b_result, + }, "subobject_types": list(SUBOBJECT_TYPES), "api": { "create": create_blueprint, "get": get_blueprint, @@ -100,6 +151,7 @@ def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw): except Exception: pass return {"module": MODULE_NAME, "tables": TABLE_NAMES, + "m1b_tables": M1B_TABLE_NAMES, "m1b": m1b_result, "subobject_types": list(SUBOBJECT_TYPES), "loaded": True} diff --git a/pbl_blueprint/json/m1b/pbl_blueprint_ref.json b/pbl_blueprint/json/m1b/pbl_blueprint_ref.json index 8327638..d9a30fe 100644 --- a/pbl_blueprint/json/m1b/pbl_blueprint_ref.json +++ b/pbl_blueprint/json/m1b/pbl_blueprint_ref.json @@ -1,46 +1,190 @@ { - "table": "pbl_blueprint_ref", - "comment": "PBL 蓝图跨域关联表(M1b 关联表判定落地):Q-OPEN-3 决议——不在 world/scene/entity 等基表上加列,蓝图对外部域对象的引用一律落到本表,形成单向可判定的关联边", - "engine": "InnoDB", - "charset": "utf8mb4", - "multi_tenant": true, - "tenant_policy": { - "column": "tenant_id", - "nullable": false, - "fail_closed": true + "tblname": "pbl_blueprint_ref", + "params": { + "id": { + "type": "int", + "size": 20, + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "物理主键(bigint AUTO_INCREMENT)" + }, + "tenant_key": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": false, + "query": true, + "summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": null, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,读写强制打头,缺失即 fail-closed);NULL=平台公共模板携带的关联,非空=租户蓝图关联" + }, + "ref_code": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "关联编码(业务主键,gen_code('BPR', tenant_id) 生成)" + }, + "owner_kind": { + "type": "str", + "size": 16, + "notnull": true, + "default": "blueprint", + "editable": true, + "query": true, + "summary": "宿主类型 blueprint / template", + "code": "pbl_ext_owner_kind" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "蓝图ID(owner_kind=blueprint 时为 M1A blueprint_id;=template 时复用本列存 template_code)" + }, + "subobject_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": true, + "query": true, + "summary": "关联所属的 *_ref 类子对象ID(模板态存模板临时ID)" + }, + "ref_kind": { + "type": "str", + "size": 16, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "关联对象域 world / scene / entity / script", + "code": "pbl_ref_kind" + }, + "ref_target_code": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "外部对象业务编码(只读引用,无 FK,不反查基表写操作 —— Q-OPEN-3 不改 world 表)" + }, + "ref_target_id": { + "type": "str", + "size": 64, + "notnull": false, + "default": "", + "editable": false, + "query": true, + "summary": "外部对象物理ID快照(解析成功时回填)" + }, + "ref_mode": { + "type": "str", + "size": 16, + "notnull": true, + "default": "readonly", + "editable": false, + "query": true, + "summary": "关联模式,恒为 readonly(写入非 readonly 值被 m1b_ref 拒绝)", + "code": "pbl_ref_mode" + }, + "ref_status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "unresolved", + "editable": false, + "query": true, + "summary": "解析状态 unresolved 待解析 / resolved 已解析 / invalid 目标缺失或不可用", + "code": "pbl_ref_status" + }, + "resolve_time": { + "type": "datetime", + "notnull": false, + "default": null, + "editable": false, + "query": false, + "summary": "解析时间" + }, + "resolve_msg": { + "type": "str", + "size": 512, + "notnull": false, + "default": "", + "editable": false, + "query": false, + "summary": "解析信息(invalid 时记录原因,供 M2 校验与教师端提示)" + }, + "snapshot_json": { + "type": "text", + "notnull": false, + "default": "", + "editable": false, + "query": false, + "summary": "解析时刻外部对象关键字段只读快照(JSON),运行期免跨库联查" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": true, + "summary": "排序号" + }, + "creator_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": false, + "query": true, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "更新时间" + } }, - "columns": [ - {"name": "id", "type": "varchar(64)", "primary_key": true, "comment": "关联ID bpref-xxxx"}, - {"name": "tenant_id", "type": "varchar(64)", "nullable": false, "index": true, "comment": "租户ID(必填)"}, - {"name": "blueprint_id", "type": "varchar(64)", "nullable": false, "comment": "蓝图ID(关联发起方,恒为 pbl_blueprint.id)"}, - {"name": "version_id", "type": "varchar(64)", "nullable": true, "comment": "蓝图版本ID(空=当前工作副本)"}, - {"name": "src_kind", "type": "varchar(32)", "nullable": false, "default": "blueprint", "comment": "源对象类型:blueprint 或 7 类子对象"}, - {"name": "src_id", "type": "varchar(64)", "nullable": true, "comment": "源对象ID(src_kind=blueprint 时可空,表示蓝图级关联)"}, - {"name": "ref_domain", "type": "varchar(32)", "nullable": false, "comment": "被引用域:world/scene/entity/script_engine/scense_game/drag/org/employee/external"}, - {"name": "ref_table", "type": "varchar(64)", "nullable": false, "comment": "被引用表名(只读,不建外键,不改基表)"}, - {"name": "ref_id", "type": "varchar(64)", "nullable": false, "comment": "被引用对象ID"}, - {"name": "ref_snapshot", "type": "json", "nullable": true, "comment": "引用时的名称/编码快照,供离线与展示(避免跨域联查)"}, - {"name": "rel_type", "type": "varchar(32)", "nullable": false, "default": "uses", "comment": "关联语义:uses/binds/embeds/derives_from/replaces"}, - {"name": "cardinality", "type": "varchar(16)", "nullable": false, "default": "n:1", "comment": "1:1/1:n/n:1/n:m"}, - {"name": "required", "type": "tinyint(1)", "nullable": false, "default": "0", "comment": "是否强依赖(校验引擎 M2 用:required=1 且引用失效 → 校验失败)"}, - {"name": "resolve_status", "type": "varchar(16)", "nullable": false, "default": "unknown", "comment": "unknown/resolved/missing:由 resolve_refs 判定,不缓存跨域写权限"}, - {"name": "resolved_at", "type": "datetime", "nullable": true}, - {"name": "seq", "type": "int", "nullable": false, "default": "0"}, - {"name": "created_by", "type": "varchar(64)", "nullable": true}, - {"name": "created_at", "type": "datetime", "nullable": true}, - {"name": "updated_by", "type": "varchar(64)", "nullable": true}, - {"name": "updated_at", "type": "datetime", "nullable": true}, - {"name": "deleted", "type": "tinyint(1)", "nullable": false, "default": "0"} + "editable": [ + "api/pbl_blueprint_ref/list.dspy", + "api/pbl_blueprint_ref/edit.dspy", + "api/pbl_blueprint_ref/view.dspy" ], - "indexes": [ - {"name": "uk_pbl_bpref", "unique": true, "columns": ["tenant_id", "blueprint_id", "src_kind", "src_id", "ref_domain", "ref_table", "ref_id", "rel_type", "deleted"]}, - {"name": "idx_pbl_bpref_bp", "columns": ["tenant_id", "blueprint_id", "deleted"]}, - {"name": "idx_pbl_bpref_target", "columns": ["tenant_id", "ref_domain", "ref_table", "ref_id"], "comment": "反查:某 world/scene 被哪些蓝图引用(删除前置校验用,只读不写基表)"}, - {"name": "idx_pbl_bpref_resolve", "columns": ["tenant_id", "resolve_status"]} - ], - "design_notes": [ - "Q-OPEN-3:world 表不加 tenant_id/blueprint_id 列,跨域关联全部由本表承载,保证基表零侵入、可回滚", - "本表不建数据库外键(跨库/跨模块),引用有效性由 resolve_refs() 主动判定并写 resolve_status", - "反向影响面分析(world 删除前检查)通过 idx_pbl_bpref_target 只读查询完成,不阻塞基表写" + "browserfields": [ + "ref_code", + "owner_kind", + "blueprint_id", + "ref_kind", + "ref_target_code", + "ref_mode", + "ref_status", + "resolve_time" ] } diff --git a/pbl_blueprint/json/m1b/pbl_blueprint_template.json b/pbl_blueprint/json/m1b/pbl_blueprint_template.json index da2e4b3..cd3f89f 100644 --- a/pbl_blueprint/json/m1b/pbl_blueprint_template.json +++ b/pbl_blueprint/json/m1b/pbl_blueprint_template.json @@ -1,48 +1,203 @@ { - "table": "pbl_blueprint_template", - "comment": "PBL 蓝图模板(M1b:支持平台公共模板 tenant_id IS NULL;租户私有模板 tenant_id 非空)", - "engine": "InnoDB", - "charset": "utf8mb4", - "multi_tenant": true, - "tenant_policy": { - "column": "tenant_id", - "nullable": true, - "null_meaning": "platform_common", - "read": "租户可见 = (tenant_id = :tenant OR tenant_id IS NULL AND is_public = 1)", - "write": "平台公共模板写操作需 is_platform_admin;租户模板写操作需 tenant_id 匹配", - "fail_closed": true + "tblname": "pbl_blueprint_template", + "params": { + "id": { + "type": "int", + "size": 20, + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "物理主键(bigint AUTO_INCREMENT)" + }, + "tenant_key": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": false, + "query": true, + "summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算,不接受外部传入" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": null, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,读写强制打头,缺失即 fail-closed);NULL=平台公共模板,非空=租户私有模板" + }, + "template_code": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "模板编码(业务主键,gen_code('TPL', tenant_id) 生成)" + }, + "template_version": { + "type": "int", + "notnull": true, + "default": 1, + "editable": true, + "query": true, + "summary": "模板版本(升级 append 新版本行,不覆盖旧版本 Q-OPEN-9)" + }, + "template_name": { + "type": "str", + "size": 128, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "模板名称" + }, + "subject": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": true, + "query": true, + "summary": "学科", + "code": "pbl_subject" + }, + "grade": { + "type": "str", + "size": 16, + "notnull": false, + "default": "", + "editable": true, + "query": true, + "summary": "学段", + "code": "pbl_grade" + }, + "category": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": true, + "query": true, + "summary": "模板分类", + "code": "pbl_tpl_category" + }, + "tpl_json": { + "type": "text", + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "模板内容(7 类子对象数组 + 模板内临时 ID 父子引用)" + }, + "tpl_hash": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": false, + "query": true, + "summary": "内容哈希 sha256(canonical_json(tpl_json)),确定性实例化校验" + }, + "offline_flag": { + "type": "str", + "size": 1, + "notnull": true, + "default": "N", + "editable": true, + "query": true, + "summary": "离线兜底模板标记 Y/N", + "code": "pbl_yes_no" + }, + "platform_flag": { + "type": "str", + "size": 1, + "notnull": true, + "default": "N", + "editable": true, + "query": true, + "summary": "平台公共标记 Y=平台公共模板(此时 tenant_id 必须为 NULL)", + "code": "pbl_yes_no" + }, + "ref_policy": { + "type": "str", + "size": 16, + "notnull": true, + "default": "warn", + "editable": true, + "query": true, + "summary": "外部引用校验策略 strict=阻断回滚 / warn=落库并标记(Q-OPEN-4 阈值可配)", + "code": "pbl_ref_policy" + }, + "tpl_status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "active", + "editable": true, + "query": true, + "summary": "模板状态", + "code": "pbl_tpl_status" + }, + "usage_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": true, + "summary": "实例化次数(供 M10 模板使用率分析,非权威计数)" + }, + "remark": { + "type": "str", + "size": 512, + "notnull": false, + "default": "", + "editable": true, + "query": false, + "summary": "备注" + }, + "creator_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": false, + "query": true, + "summary": "创建人(rbac 用户ID)" + }, + "create_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "更新时间" + } }, - "columns": [ - {"name": "id", "type": "varchar(64)", "primary_key": true, "comment": "模板ID tpl-xxxx"}, - {"name": "tenant_id", "type": "varchar(64)", "nullable": true, "index": true, "comment": "租户ID;NULL=平台公共模板(Q-OPEN-3 决议:不改 world 表,平台公共数据只落在 pbl_* 自有表)"}, - {"name": "code", "type": "varchar(64)", "nullable": false, "comment": "模板编码,租户内唯一;平台公共模板在 tenant_id IS NULL 域内唯一"}, - {"name": "name", "type": "varchar(200)", "nullable": false, "comment": "模板名称"}, - {"name": "category", "type": "varchar(64)", "nullable": true, "comment": "分类:stem/humanities/interdisciplinary/vocational/custom"}, - {"name": "subject_tags", "type": "json", "nullable": true, "comment": "学科标签数组"}, - {"name": "grade_range", "type": "varchar(64)", "nullable": true, "comment": "适用学段,如 G4-G6"}, - {"name": "duration_hours", "type": "int", "nullable": true, "comment": "建议课时(小时)"}, - {"name": "summary", "type": "varchar(1000)", "nullable": true, "comment": "模板简介"}, - {"name": "content", "type": "json", "nullable": true, "comment": "模板内容:driving_question/missions/roles/artifacts/assessment 等子对象骨架"}, - {"name": "subobject_kinds", "type": "json", "nullable": true, "comment": "模板包含的子对象类型清单(7类泛化契约)"}, - {"name": "ext_schema", "type": "json", "nullable": true, "comment": "扩展字段定义(字段名/类型/必填/枚举/默认值),实例化时用于校验"}, - {"name": "is_public", "type": "tinyint(1)", "nullable": false, "default": "1", "comment": "平台公共模板是否对全部租户可见(仅 tenant_id IS NULL 时有意义)"}, - {"name": "is_builtin", "type": "tinyint(1)", "nullable": false, "default": "0", "comment": "是否内置(内置模板禁止删除,仅可停用)"}, - {"name": "source", "type": "varchar(32)", "nullable": false, "default": "platform", "comment": "来源:platform/tenant/fork/import"}, - {"name": "source_template_id", "type": "varchar(64)", "nullable": true, "comment": "fork 来源模板ID(租户从平台公共模板派生时记录)"}, - {"name": "version", "type": "varchar(32)", "nullable": false, "default": "1.0.0", "comment": "模板版本号"}, - {"name": "status", "type": "varchar(32)", "nullable": false, "default": "draft", "comment": "draft/published/deprecated"}, - {"name": "usage_count", "type": "int", "nullable": false, "default": "0", "comment": "被实例化次数(平台公共模板跨租户累计)"}, - {"name": "owner_id", "type": "varchar(64)", "nullable": true, "comment": "创建人/负责人"}, - {"name": "created_by", "type": "varchar(64)", "nullable": true}, - {"name": "created_at", "type": "datetime", "nullable": true}, - {"name": "updated_by", "type": "varchar(64)", "nullable": true}, - {"name": "updated_at", "type": "datetime", "nullable": true}, - {"name": "deleted", "type": "tinyint(1)", "nullable": false, "default": "0", "comment": "软删除标记"} + "editable": [ + "api/pbl_blueprint_template/list.dspy", + "api/pbl_blueprint_template/edit.dspy", + "api/pbl_blueprint_template/view.dspy" ], - "indexes": [ - {"name": "uk_pbl_tpl_tenant_code", "unique": true, "columns": ["tenant_id", "code", "deleted"], "comment": "租户内编码唯一;tenant_id NULL 时 MySQL 唯一索引不去重,故平台公共模板唯一性由应用层 ensure 校验兜底"}, - {"name": "idx_pbl_tpl_status", "columns": ["tenant_id", "status", "deleted"]}, - {"name": "idx_pbl_tpl_category", "columns": ["category"]} - ], - "seed": "pbl_blueprint/json/seed_template_offline.json" + "browserfields": [ + "template_code", + "template_name", + "template_version", + "category", + "subject", + "grade", + "platform_flag", + "tpl_status", + "usage_count" + ] } diff --git a/pbl_blueprint/json/m1b/pbl_ext_field_def.json b/pbl_blueprint/json/m1b/pbl_ext_field_def.json new file mode 100644 index 0000000..7e47483 --- /dev/null +++ b/pbl_blueprint/json/m1b/pbl_ext_field_def.json @@ -0,0 +1,201 @@ +{ + "tblname": "pbl_ext_field_def", + "params": { + "id": { + "type": "int", + "size": 20, + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "物理主键(bigint AUTO_INCREMENT)" + }, + "tenant_key": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": false, + "query": true, + "summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": null, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,读写强制打头,缺失即 fail-closed);NULL=平台内置字段定义,非空=租户自有定义" + }, + "def_code": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "定义编码(业务主键,gen_code('EFD', tenant_id) 生成)" + }, + "subobject_type": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "适用子对象类型(7 类之一;'*' 表示全部类型通用)", + "code": "pbl_subobject_type" + }, + "field_key": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "扩展字段键(写入 pbl_subobject_ext.ext_key;^[a-z][a-z0-9_]{0,63}$)" + }, + "field_label": { + "type": "str", + "size": 128, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "字段显示名" + }, + "value_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "string", + "editable": true, + "query": true, + "summary": "值类型 string/int/float/bool/json/date/enum", + "code": "pbl_ext_value_type" + }, + "required_flag": { + "type": "str", + "size": 1, + "notnull": true, + "default": "N", + "editable": true, + "query": true, + "summary": "是否必填 Y=保存该子对象扩展时此键必须出现且非空", + "code": "pbl_yes_no" + }, + "default_value": { + "type": "str", + "size": 512, + "notnull": false, + "default": "", + "editable": true, + "query": false, + "summary": "默认值(调用方未传该键时按 value_type 强转后落库)" + }, + "enum_options": { + "type": "text", + "notnull": false, + "default": "", + "editable": true, + "query": false, + "summary": "枚举候选(value_type=enum 时必填,JSON 数组字符串)" + }, + "max_length": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "最大长度(0=不限制;>0 时对 string/json/enum 序列化长度做上限校验)" + }, + "validate_regex": { + "type": "str", + "size": 256, + "notnull": false, + "default": "", + "editable": true, + "query": false, + "summary": "正则校验(非空时对 string 值 re.fullmatch,不符则 PBL_EXT_VALUE_INVALID)" + }, + "platform_flag": { + "type": "str", + "size": 1, + "notnull": true, + "default": "N", + "editable": true, + "query": true, + "summary": "平台内置标记 Y=平台内置定义(此时 tenant_id 必须为 NULL)", + "code": "pbl_yes_no" + }, + "def_status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "active", + "editable": true, + "query": true, + "summary": "定义状态(disabled 不参与校验且拒绝新值写入,历史值只读保留)", + "code": "pbl_ext_def_status" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": true, + "summary": "排序号" + }, + "remark": { + "type": "str", + "size": 512, + "notnull": false, + "default": "", + "editable": true, + "query": false, + "summary": "备注" + }, + "creator_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": false, + "query": true, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "更新时间" + } + }, + "editable": [ + "api/pbl_ext_field_def/list.dspy", + "api/pbl_ext_field_def/edit.dspy", + "api/pbl_ext_field_def/view.dspy" + ], + "browserfields": [ + "def_code", + "subobject_type", + "field_key", + "field_label", + "value_type", + "required_flag", + "platform_flag", + "def_status", + "sort_no" + ] +} diff --git a/pbl_blueprint/json/m1b/pbl_subobject_ext.json b/pbl_blueprint/json/m1b/pbl_subobject_ext.json index b241316..a8b50f4 100644 --- a/pbl_blueprint/json/m1b/pbl_subobject_ext.json +++ b/pbl_blueprint/json/m1b/pbl_subobject_ext.json @@ -1,38 +1,180 @@ { - "table": "pbl_subobject_ext", - "comment": "PBL 子对象扩展(M1b):7 类泛化子对象的扩展字段实例 + 子对象间关联边。tenant_id 强制打头,不允许 NULL", - "engine": "InnoDB", - "charset": "utf8mb4", - "multi_tenant": true, - "tenant_policy": { - "column": "tenant_id", - "nullable": false, - "fail_closed": true, - "note": "子对象扩展属于租户业务数据,严禁平台公共(NULL);平台级只允许 pbl_blueprint_template / pbl_ext_field_def" + "tblname": "pbl_subobject_ext", + "params": { + "id": { + "type": "int", + "size": 20, + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "物理主键(bigint AUTO_INCREMENT)" + }, + "tenant_key": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": false, + "query": true, + "summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": null, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,读写强制打头,缺失即 fail-closed);NULL=平台公共模板的子对象扩展,非空=租户数据" + }, + "ext_code": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "扩展记录编码(业务主键,gen_code('SXE', tenant_id) 生成)" + }, + "owner_kind": { + "type": "str", + "size": 16, + "notnull": true, + "default": "blueprint", + "editable": true, + "query": true, + "summary": "宿主类型 blueprint=真实蓝图子对象 / template=模板内容子对象", + "code": "pbl_ext_owner_kind" + }, + "owner_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "宿主ID(blueprint→blueprint_id;template→template_code)" + }, + "owner_version": { + "type": "int", + "notnull": true, + "default": 1, + "editable": true, + "query": true, + "summary": "宿主版本(blueprint→蓝图版本号;template→template_version),版本隔离避免跨版本串数据" + }, + "subobject_type": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "子对象类型(7 类之一)", + "code": "pbl_subobject_type" + }, + "subobject_ref": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "子对象归一引用(非空列:blueprint→M1A subobject_id;template→模板内临时 ID),参与唯一键杜绝 NULL 重复行" + }, + "subobject_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": true, + "query": true, + "summary": "蓝图子对象ID(冗余列,owner_kind=blueprint 时与 subobject_ref 同值)" + }, + "ext_key": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "扩展字段键(必须在 pbl_ext_field_def 中存在且 def_status=active,否则 PBL_EXT_FIELD_UNDEFINED)" + }, + "ext_value": { + "type": "text", + "notnull": false, + "default": "", + "editable": true, + "query": false, + "summary": "扩展字段值(统一字符串形态;json/bool/date 按 value_type 序列化)" + }, + "value_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "string", + "editable": false, + "query": true, + "summary": "值类型快照(写入时从 pbl_ext_field_def.value_type 快照,历史值可按当时类型解读)", + "code": "pbl_ext_value_type" + }, + "field_def_id": { + "type": "int", + "size": 20, + "notnull": false, + "default": 0, + "editable": false, + "query": true, + "summary": "字段定义ID(指向 pbl_ext_field_def.id,逻辑关联无 FK)" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": true, + "summary": "排序号" + }, + "creator_id": { + "type": "str", + "size": 32, + "notnull": false, + "default": "", + "editable": false, + "query": true, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "notnull": true, + "default": null, + "editable": false, + "query": false, + "summary": "更新时间" + } }, - "columns": [ - {"name": "id", "type": "varchar(64)", "primary_key": true, "comment": "扩展记录ID subext-xxxx"}, - {"name": "tenant_id", "type": "varchar(64)", "nullable": false, "index": true, "comment": "租户ID(必填,缺失即拒绝)"}, - {"name": "blueprint_id", "type": "varchar(64)", "nullable": false, "comment": "所属蓝图ID"}, - {"name": "version_id", "type": "varchar(64)", "nullable": true, "comment": "所属蓝图版本ID(空=当前工作副本)"}, - {"name": "subobject_kind", "type": "varchar(32)", "nullable": false, "comment": "子对象类型:driving_question/mission/role/learner/artifact_def/problem/project/learning_goal"}, - {"name": "subobject_id", "type": "varchar(64)", "nullable": false, "comment": "子对象主键(对应 pbl_* 子表 id)"}, - {"name": "ext_key", "type": "varchar(64)", "nullable": false, "comment": "扩展字段名(由 pbl_ext_field_def 定义)"}, - {"name": "ext_value", "type": "json", "nullable": true, "comment": "扩展字段值(按定义类型序列化)"}, - {"name": "value_type", "type": "varchar(16)", "nullable": false, "default": "string", "comment": "string/int/float/bool/enum/json/date"}, - {"name": "source", "type": "varchar(32)", "nullable": false, "default": "manual", "comment": "manual/template_instantiate/agent/import"}, - {"name": "source_template_id", "type": "varchar(64)", "nullable": true, "comment": "若由模板实例化产生,记录来源模板(含平台公共模板)"}, - {"name": "seq", "type": "int", "nullable": false, "default": "0", "comment": "排序"}, - {"name": "status", "type": "varchar(32)", "nullable": false, "default": "active", "comment": "active/deprecated"}, - {"name": "created_by", "type": "varchar(64)", "nullable": true}, - {"name": "created_at", "type": "datetime", "nullable": true}, - {"name": "updated_by", "type": "varchar(64)", "nullable": true}, - {"name": "updated_at", "type": "datetime", "nullable": true}, - {"name": "deleted", "type": "tinyint(1)", "nullable": false, "default": "0"} + "editable": [ + "api/pbl_subobject_ext/list.dspy", + "api/pbl_subobject_ext/edit.dspy", + "api/pbl_subobject_ext/view.dspy" ], - "indexes": [ - {"name": "uk_pbl_subext", "unique": true, "columns": ["tenant_id", "blueprint_id", "subobject_kind", "subobject_id", "ext_key", "deleted"]}, - {"name": "idx_pbl_subext_obj", "columns": ["tenant_id", "subobject_kind", "subobject_id"]}, - {"name": "idx_pbl_subext_bp", "columns": ["tenant_id", "blueprint_id", "deleted"]} + "browserfields": [ + "ext_code", + "owner_kind", + "owner_id", + "subobject_type", + "subobject_ref", + "ext_key", + "ext_value", + "value_type" ] } diff --git a/pbl_blueprint/m1b_db.py b/pbl_blueprint/m1b_db.py new file mode 100644 index 0000000..b427675 --- /dev/null +++ b/pbl_blueprint/m1b_db.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- +"""M1b 真实库适配层(sqlor 标准 API 唯一出口)+ Q-OPEN-3 写保护闸门。 + +本文件是 M1b 与真实 MariaDB 之间**唯一**的 DB 通道,职责三件: + +1. ``tenant_key(tenant_id)`` + 统一计算索引打头列 tenant_key = COALESCE(tenant_id, '__PLATFORM__')。 + 禁止调用方自行传入 tenant_key —— 平台公共行(tenant_id IS NULL)必须落到 + '__PLATFORM__' 域,否则 MariaDB 唯一索引不对 NULL 去重,平台模板/字段定义 + 会被重复插入。 + +2. ``WRITE_PROTECTED_TABLES`` + ``assert_writable(table, sql)`` + Q-OPEN-3 裁决『不改 world 表』的物理闸门:world / scene / entity / script + 等复用域基表一律禁止 INSERT / UPDATE / DELETE / ALTER / DROP / TRUNCATE。 + 任何越界写操作在 SQL 执行前被拦截并抛 ``PBL_DB_WRITE_PROTECTED``。 + 与 ``m1b_ref.assert_writable()`` 构成双重闸门(业务层 + DB 层)。 + +3. ``SorDB`` + 把 sqlor 的 6 个标准 API(C/U/D/R/I/sqlExe)包装成 m1b_common 适配器契约 + (query/insert/update/delete),使 M1b 业务函数在**内存 FakeDB 与真实库** + 下走同一套代码路径。库名一律来自 ``ServerEnv().get_module_dbname`` + (经 pbl_blueprint.db.get_dbname 转发),本文件不硬编码任何库名。 + +sqlor API 使用约束(铁律):本模块只允许 sor.C / sor.U / sor.D / sor.R / +sor.I / sor.sqlExe 六个标准 API,禁止编造 save/list/insert 等不存在的方法。 +""" + +import json +import re + +__all__ = [ + "PLATFORM_TENANT_KEY", "WRITE_PROTECTED_TABLES", "M1B_TABLES", + "PBL_DB_WRITE_PROTECTED", "WriteProtectedError", + "tenant_key", "assert_writable", "guard_sql", "SorDB", "make_db", + "split_ddl", +] + +# 平台公共域键:tenant_id IS NULL 时统一落到该键,保证唯一索引可去重 +PLATFORM_TENANT_KEY = "__PLATFORM__" + +# Q-OPEN-3:复用域基表零改动、零加列、零回写 +WRITE_PROTECTED_TABLES = ( + "world", "scene", "entity", "script", + "world_snapshot", "world_sync", "world_sync_task", "world_sync_log", + "scense", "scense_game", +) + +# M1b 自有 4 表(与 models/m1b/*.json、sql/m1b_ddl.sql 一一对应) +M1B_TABLES = ( + "pbl_blueprint_template", + "pbl_ext_field_def", + "pbl_subobject_ext", + "pbl_blueprint_ref", +) + +PBL_DB_WRITE_PROTECTED = "PBL_DB_WRITE_PROTECTED" + +_WRITE_VERBS = ("insert", "update", "delete", "replace", "alter", "drop", + "truncate", "create", "rename") +_TABLE_RE = re.compile( + r"(?:from|into|update|table|join)\s+`?([a-zA-Z_][a-zA-Z0-9_]*)`?", + re.IGNORECASE) + + +class WriteProtectedError(Exception): + """Q-OPEN-3 写保护违规(错误码 PBL_DB_WRITE_PROTECTED)。""" + + code = PBL_DB_WRITE_PROTECTED + + def __init__(self, table, verb="", sql=""): + self.table = table + self.verb = verb + self.sql = sql + Exception.__init__( + self, "%s: 表 %s 属复用域基表,Q-OPEN-3 裁决禁止任何写操作/结构变更" + "(verb=%s)" % (PBL_DB_WRITE_PROTECTED, table, verb or "?")) + + +def tenant_key(tenant_id): + """索引打头列计算:NULL/空 → '__PLATFORM__',其余原样字符串化。""" + if tenant_id is None: + return PLATFORM_TENANT_KEY + s = str(tenant_id).strip() + if not s or s.upper() in ("NULL", "NONE"): + return PLATFORM_TENANT_KEY + return s + + +def _tables_in_sql(sql): + return [m.group(1).lower() for m in _TABLE_RE.finditer(sql or "")] + + +def assert_writable(table, sql=""): + """写前闸门:目标表(或 SQL 中出现的表)命中写保护清单即抛异常。 + + 只读 SELECT 不经过本函数(resolve_refs 的存在性校验走 SorDB.query)。 + """ + names = [str(table or "").strip().lower().strip("`")] + names.extend(_tables_in_sql(sql)) + for n in names: + if n and n in WRITE_PROTECTED_TABLES: + raise WriteProtectedError(n, "write", sql) + return True + + +def guard_sql(sql): + """SQL 级闸门:写动词 + 写保护表 同时命中才拦截(纯 SELECT 放行)。""" + text = (sql or "").strip() + if not text: + return True + head = text.split(None, 1)[0].lower().strip("(") + if head not in _WRITE_VERBS: + # 非写语句(SELECT/SHOW/DESC)放行;多语句里再逐段查 + for seg in [s for s in text.split(";") if s.strip()]: + h = seg.strip().split(None, 1)[0].lower().strip("(") + if h in _WRITE_VERBS: + for t in _tables_in_sql(seg): + if t in WRITE_PROTECTED_TABLES: + raise WriteProtectedError(t, h, seg) + return True + for t in _tables_in_sql(text): + if t in WRITE_PROTECTED_TABLES: + raise WriteProtectedError(t, head, text) + return True + + +def split_ddl(ddl_text): + """把 DDL 文件切成可逐条 sqlExe 的语句列表(去注释、去空段、幂等)。""" + out, buf = [], [] + for raw in (ddl_text or "").splitlines(): + line = raw.strip() + if not line or line.startswith("--"): + continue + buf.append(raw) + if line.endswith(";"): + stmt = "\n".join(buf).strip().rstrip(";").strip() + if stmt: + out.append(stmt) + buf = [] + tail = "\n".join(buf).strip().rstrip(";").strip() + if tail: + out.append(tail) + return out + + +def _esc(v): + """SQL 字面量转义(sqlor sqlExe 拼接场景)。""" + if v is None: + return "NULL" + if isinstance(v, bool): + return "1" if v else "0" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, (dict, list)): + v = json.dumps(v, ensure_ascii=False) + s = str(v).replace("\\", "\\\\").replace("'", "\\'") + return "'%s'" % s + + +class _IsNull(object): + """与 m1b_common.IS_NULL 同语义的哨兵(避免循环 import 各自判类型)。""" + + __slots__ = () + + def __repr__(self): + return "IS_NULL" + + +IS_NULL = _IsNull() + + +class SorDB(object): + """sqlor 标准 API 包装器 → m1b_common 适配器契约(query/insert/update/delete)。 + + 构造:``SorDB(sor, dbname)``。sor 必须是 sqlor 实例(具备 C/U/D/R/I/sqlExe)。 + 所有写操作先过 ``assert_writable`` / ``guard_sql`` 双闸门。 + """ + + def __init__(self, sor, dbname, strict_guard=True): + if sor is None: + raise RuntimeError("SorDB: sor 不能为空(请确认应用已注入 sqlor 实例)") + if not dbname: + raise RuntimeError( + "SorDB: dbname 为空 —— 库名必须由 ServerEnv().get_module_dbname(" + "'pbl_blueprint') 注入,禁止硬编码;请检查应用 init() 是否已挂载") + self.sor = sor + self.dbname = dbname + self.strict_guard = strict_guard + + # ------------------------------------------------------------ 读 + def query(self, table, conds=None, order_by=None, limit=None): + where, args = self._where(conds) + sql = "SELECT * FROM `%s`" % table + if where: + sql += " WHERE " + where + if order_by: + desc = str(order_by).startswith("-") + col = str(order_by).lstrip("+-") + sql += " ORDER BY `%s` %s" % (col, "DESC" if desc else "ASC") + if limit is not None: + sql += " LIMIT %d" % int(limit) + rows = self.sor.sqlExe(self.dbname, sql, args) if args else \ + self.sor.sqlExe(self.dbname, sql) + return [dict(r) for r in (rows or [])] + + def select(self, table, conds=None, order_by=None, limit=None): + return self.query(table, conds, order_by=order_by, limit=limit) + + def count(self, table, conds=None): + where, args = self._where(conds) + sql = "SELECT COUNT(1) AS c FROM `%s`" % table + if where: + sql += " WHERE " + where + rows = self.sor.sqlExe(self.dbname, sql, args) if args else \ + self.sor.sqlExe(self.dbname, sql) + try: + return int((rows or [{}])[0].get("c", 0)) + except Exception: + return 0 + + # ------------------------------------------------------------ 写 + def insert(self, table, row): + assert_writable(table) + row = dict(row or {}) + cols = list(row.keys()) + if not cols: + return None + sql = "INSERT INTO `%s` (%s) VALUES (%s)" % ( + table, + ", ".join("`%s`" % c for c in cols), + ", ".join(_esc(row[c]) for c in cols)) + self.sor.sqlExe(self.dbname, sql) + return row.get("id") + + def update(self, table, conds, values): + assert_writable(table) + values = dict(values or {}) + if not values: + return 0 + where, args = self._where(conds) + sets = ", ".join("`%s`=%s" % (k, _esc(v)) for k, v in values.items()) + sql = "UPDATE `%s` SET %s" % (table, sets) + if where: + sql += " WHERE " + where + self.sor.sqlExe(self.dbname, sql, args) + return 1 + + def delete(self, table, conds): + assert_writable(table) + where, args = self._where(conds) + if not where: + raise RuntimeError("SorDB.delete: 拒绝无 WHERE 的全表删除(%s)" % table) + self.sor.sqlExe(self.dbname, "DELETE FROM `%s` WHERE %s" % (table, where), args) + return 1 + + def sqlExe(self, sql, args=None): + """裸 SQL 出口(建表 DDL / 批量种子)——同样过 Q-OPEN-3 闸门。""" + if self.strict_guard: + guard_sql(sql) + if args: + return self.sor.sqlExe(self.dbname, sql, args) + return self.sor.sqlExe(self.dbname, sql) + + # 兼容 sqlor 原生 6 API 直通(供确需标准 API 的调用方) + def C(self, tbl, data): + assert_writable(tbl) + return self.sor.C(self.dbname, tbl, data) + + def R(self, tbl, where="", args=None, fields="*", orderby="", limit=0, offset=0): + return self.sor.R(self.dbname, tbl, where, args, fields, orderby, limit, offset) + + def U(self, tbl, data, where, args=None): + assert_writable(tbl) + return self.sor.U(self.dbname, tbl, data, where, args) + + def D(self, tbl, where, args=None): + assert_writable(tbl) + return self.sor.D(self.dbname, tbl, where, args) + + def I(self, sql, args=None): + if self.strict_guard: + guard_sql(sql) + return self.sor.I(self.dbname, sql, args) if args else self.sor.I(self.dbname, sql) + + # ------------------------------------------------------------ 内部 + @staticmethod + def _where(conds): + clauses, args = [], [] + for k, v in (conds or {}).items(): + col = str(k) + if isinstance(v, _IsNull) or v is IS_NULL: + clauses.append("`%s` IS NULL" % col) + continue + if v is None: + # None 作为等值条件在 SQL 里无意义 → 归一为 IS NULL + clauses.append("`%s` IS NULL" % col) + continue + if isinstance(v, (list, tuple, set)): + items = list(v) + if not items: + clauses.append("1=0") + continue + clauses.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(items)))) + args.extend(items) + continue + if col.endswith("!"): + clauses.append("`%s` <> %%s" % col[:-1]) + args.append(v) + continue + if col.endswith("~"): + clauses.append("`%s` LIKE %%s" % col[:-1]) + args.append(v) + continue + clauses.append("`%s` = %%s" % col) + args.append(v) + return (" AND ".join(clauses), args) + + +def make_db(sor=None, dbname=None): + """构造真实库适配器:sor/dbname 缺省时从 pbl_blueprint.db 取(应用注入)。 + + 取不到库名 → 返回 None(调用方 fail-closed,绝不回落默认库)。 + """ + try: + from . import db as _db + except Exception: + _db = None + if sor is None and _db is not None: + try: + sor = _db.get_sor() + except Exception: + sor = None + if not dbname and _db is not None: + try: + dbname = _db.get_dbname() + except Exception: + dbname = None + if sor is None or not dbname: + return None + return SorDB(sor, dbname) diff --git a/pbl_blueprint/m1b_init.py b/pbl_blueprint/m1b_init.py index a22f63f..800c2a3 100644 --- a/pbl_blueprint/m1b_init.py +++ b/pbl_blueprint/m1b_init.py @@ -19,20 +19,38 @@ from .m1b_common import IS_NULL, insert, select_one, now_iso, new_id from . import m1b_template as T from . import m1b_subobject as S -__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "init_m1b", "ensure_m1b_tables", - "ensure_platform_ext_defs", "seed_platform_data"] +__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL", + "PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES", + "init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs", + "seed_platform_data", "load_m1b_models", "load_m1b_crud_defs"] -_HERE = os.path.dirname(os.path.abspath(__file__)) +# 目录规约(module-development-spec / database-table-definition-spec): +# * 表定义(四段式 summary/fields/indexes/codes)→ pbl_blueprint/models/m1b/*.json +# * CRUD 定义(tblname + params.editable) → pbl_blueprint/json/m1b/*.json +# * 幂等 DDL(4 表 CREATE TABLE IF NOT EXISTS) → /sql/m1b_ddl.sql +_HERE = os.path.dirname(os.path.abspath(__file__)) # .../pbl_blueprint/pbl_blueprint +_REPO = os.path.dirname(_HERE) # .../pbl_blueprint(仓库根) +M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext", + "pbl_blueprint_ref"] + +# 表模型(四段式)——建表/字段校验的权威来源 M1B_MODEL_FILES = [ + os.path.join(_HERE, "models", "m1b", "pbl_blueprint_template.json"), + os.path.join(_HERE, "models", "m1b", "pbl_ext_field_def.json"), + os.path.join(_HERE, "models", "m1b", "pbl_subobject_ext.json"), + os.path.join(_HERE, "models", "m1b", "pbl_blueprint_ref.json"), +] + +# CRUD 定义(tblname + params.editable)——供 sqlor/前端 CRUD 装配 +M1B_CRUD_FILES = [ os.path.join(_HERE, "json", "m1b", "pbl_blueprint_template.json"), os.path.join(_HERE, "json", "m1b", "pbl_ext_field_def.json"), os.path.join(_HERE, "json", "m1b", "pbl_subobject_ext.json"), os.path.join(_HERE, "json", "m1b", "pbl_blueprint_ref.json"), ] -M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext", - "pbl_blueprint_ref"] -M1B_DDL = os.path.join(_HERE, "sql", "m1b_ddl.sql") + +M1B_DDL = os.path.join(_REPO, "sql", "m1b_ddl.sql") # -------------------------------------------------------------------------- @@ -249,7 +267,8 @@ def ensure_m1b_tables(db=None, env=None, executor=None): models.append(json.load(fp)) except Exception: continue - result = {"ok": True, "models": [m.get("table") for m in models], "ddl": None, "executed": False} + result = {"ok": True, "models": [m.get("table") for m in models], + "ddl": None, "executed": 0, "statements": 0, "errors": []} # 1) 尝试模块既有注册机制 registered = False @@ -266,25 +285,78 @@ def ensure_m1b_tables(db=None, env=None, executor=None): continue result["registered_via_module"] = registered - # 2) DDL 兜底 + # 2) 幂等 DDL(sql/m1b_ddl.sql,4 表 CREATE TABLE IF NOT EXISTS) if os.path.exists(M1B_DDL): try: with open(M1B_DDL, "r", encoding="utf-8") as fp: ddl = fp.read() result["ddl"] = M1B_DDL + from .m1b_db import split_ddl, guard_sql + stmts = split_ddl(ddl) + result["statements"] = len(stmts) + + # 执行通道优先级:显式 executor > 真实库 SorDB(sqlor 标准 API) + runner = None if callable(executor): - for stmt in [s.strip() for s in ddl.split(";") if s.strip() - and not s.strip().startswith("--")]: + runner = executor + else: + try: + from .m1b_db import make_db + real = make_db() + if real is not None: + runner = real.sqlExe + result["channel"] = "sqlor(SorDB.sqlExe)" + except Exception: + runner = None + if runner is not None: + for stmt in stmts: try: - executor(stmt) - except Exception: - continue - result["executed"] = True - except Exception: - pass + guard_sql(stmt) # Q-OPEN-3:拦截任何复用域基表写/改 + runner(stmt) + result["executed"] += 1 + except Exception as e: # 单条失败不阻断其余(幂等重跑可补) + result["errors"].append("%s: %s" % (type(e).__name__, e)) + result["ok"] = not result["errors"] + else: + result["channel"] = "none(内存适配器:表按需自动创建)" + except Exception as e: + result["ok"] = False + result["errors"].append("ddl_load: %s" % e) return result +def load_m1b_models(): + """读取 4 张表的四段式表模型(models/m1b/*.json)。""" + out = [] + for f in M1B_MODEL_FILES: + if not os.path.exists(f): + continue + try: + with open(f, "r", encoding="utf-8") as fp: + d = json.load(fp) + d["_file"] = os.path.relpath(f, _REPO) + out.append(d) + except Exception: + continue + return out + + +def load_m1b_crud_defs(): + """读取 4 张表的 CRUD 定义(json/m1b/*.json,tblname + params.editable)。""" + out = [] + for f in M1B_CRUD_FILES: + if not os.path.exists(f): + continue + try: + with open(f, "r", encoding="utf-8") as fp: + d = json.load(fp) + d["_file"] = os.path.relpath(f, _REPO) + out.append(d) + except Exception: + continue + return out + + def ensure_platform_ext_defs(db, actor="system"): """幂等注入平台公共扩展字段定义(tenant_id NULL)。""" created, skipped = [], [] @@ -338,4 +410,7 @@ def init_m1b(db=None, env=None, register=None, executor=None, seed=True, actor=" except Exception as e: seeded = {"ok": False, "error": str(e)} return {"ok": True, "milestone": "M1b", "tables": tables, "routes": routes, - "seed": seeded, "q_open_3": "world/scene/entity 基表零改动"} + "seed": seeded, + "models_loaded": [m.get("_file") for m in load_m1b_models()], + "crud_defs_loaded": [c.get("tblname") for c in load_m1b_crud_defs()], + "q_open_3": "world/scene/entity/script 基表零改动、零加列、零回写"} diff --git a/pbl_blueprint/models/m1b/pbl_blueprint_ref.json b/pbl_blueprint/models/m1b/pbl_blueprint_ref.json new file mode 100644 index 0000000..3217c3a --- /dev/null +++ b/pbl_blueprint/models/m1b/pbl_blueprint_ref.json @@ -0,0 +1,53 @@ +{ + "summary": "PBL 蓝图外部关联表(M1b)——Q-OPEN-3 裁决的物理落地。裁决结论:『不改 world 表』(含 scene / entity / script 等复用域基表一律零改动、零加列、零回写)。因此蓝图与复用域对象的全部关联关系单向落在本表:本表持有 ref_target_code 指向外部对象业务编码(逻辑关联,无 FOREIGN KEY),ref_mode 恒为 'readonly' 并由 m1b_ref.assert_writable() 与 m1b_db.WRITE_PROTECTED_TABLES 双重闸门强制(任何针对 world/scene/entity/script 的 INSERT/UPDATE/DELETE 在 SQL 执行前被拦截并抛 PBL_DB_WRITE_PROTECTED)。resolve_refs() 仅做只读存在性校验并把结果写回本表 ref_status(resolved/unresolved/invalid),校验策略 strict/warn 由 pbl_blueprint_template.ref_policy 决定(Q-OPEN-4 阈值可配)。snapshot_json 保存解析时刻的只读快照,避免运行期跨库联查。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。", + "fields": [ + {"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"}, + {"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"}, + {"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台公共模板携带的关联;非空 = 租户蓝图关联"}, + {"name": "ref_code", "type": "varchar", "len": 32, "label": "关联编码", "required": true, "nullable": false, "comment": "业务主键,pbl_common.gen_code('BPR', tenant_id) 生成"}, + {"name": "owner_kind", "type": "varchar", "len": 16, "label": "宿主类型", "required": true, "nullable": false, "default": "blueprint", "comment": "字典 pbl_ext_owner_kind:blueprint / template"}, + {"name": "blueprint_id", "type": "varchar", "len": 32, "label": "蓝图ID", "required": true, "nullable": false, "comment": "owner_kind=blueprint 时为 M1A blueprint_id;=template 时复用本列存 template_code(列名保留以对齐 M1A 语义,取值由 owner_kind 判定)"}, + {"name": "subobject_id", "type": "varchar", "len": 32, "label": "子对象ID", "required": false, "nullable": true, "comment": "关联所属的 *_ref 类子对象ID;模板态存模板临时ID"}, + {"name": "ref_kind", "type": "varchar", "len": 16, "label": "关联对象域", "required": true, "nullable": false, "comment": "字典 pbl_ref_kind:world / scene / entity / script"}, + {"name": "ref_target_code", "type": "varchar", "len": 64, "label": "外部对象编码", "required": true, "nullable": false, "comment": "复用域对象业务编码(只读引用,无 FK,不反查基表写操作)"}, + {"name": "ref_target_id", "type": "varchar", "len": 64, "label": "外部对象ID", "required": false, "nullable": true, "comment": "解析成功时回填的对方物理ID快照"}, + {"name": "ref_mode", "type": "varchar", "len": 16, "label": "关联模式", "required": true, "nullable": false, "default": "readonly", "comment": "恒为 readonly(Q-OPEN-3:不改 world 表,只读引用);字典 pbl_ref_mode;写入非 readonly 值将被 m1b_ref 拒绝"}, + {"name": "ref_status", "type": "varchar", "len": 16, "label": "解析状态", "required": true, "nullable": false, "default": "unresolved", "comment": "字典 pbl_ref_status:unresolved 待解析 / resolved 已解析 / invalid 目标不存在或不可用"}, + {"name": "resolve_time", "type": "datetime", "label": "解析时间", "required": false, "nullable": true}, + {"name": "resolve_msg", "type": "varchar", "len": 512, "label": "解析信息", "required": false, "nullable": true, "comment": "invalid 时记录原因,供 M2 校验与教师端提示"}, + {"name": "snapshot_json", "type": "text", "label": "只读快照", "required": false, "nullable": true, "comment": "解析时刻外部对象关键字段快照(JSON),运行期免跨库联查"}, + {"name": "sort_no", "type": "int", "label": "排序号", "required": true, "nullable": false, "default": 0}, + {"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true}, + {"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false}, + {"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false} + ], + "indexes": [ + {"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true}, + {"name": "uk_ref_code", "fields": ["tenant_key", "ref_code"], "unique": true, "comment": "业务主键唯一"}, + {"name": "uk_ref_slot", "fields": ["tenant_key", "owner_kind", "blueprint_id", "ref_kind", "ref_target_code"], "unique": true, "comment": "同宿主同域同目标只挂一条关联 → 保存幂等"}, + {"name": "idx_ref_blueprint", "fields": ["tenant_key", "owner_kind", "blueprint_id", "ref_kind"], "unique": false, "comment": "按蓝图取全部关联(树渲染/编译主路径)"}, + {"name": "idx_ref_target", "fields": ["tenant_key", "ref_kind", "ref_target_code"], "unique": false, "comment": "反查『某外部对象被哪些蓝图引用』(删除前影响面分析,只读)"}, + {"name": "idx_ref_status", "fields": ["tenant_key", "ref_status"], "unique": false, "comment": "未解析/失效关联巡检"}, + {"name": "idx_ref_tenant", "fields": ["tenant_id", "blueprint_id"], "unique": false, "comment": "按真实租户维度扫描/运维排查"} + ], + "codes": { + "pbl_ref_kind": [ + {"code": "world", "name": "世界"}, + {"code": "scene", "name": "场景"}, + {"code": "entity", "name": "实体"}, + {"code": "script", "name": "脚本"} + ], + "pbl_ref_status": [ + {"code": "unresolved", "name": "待解析"}, + {"code": "resolved", "name": "已解析"}, + {"code": "invalid", "name": "目标缺失/不可用"} + ], + "pbl_ref_mode": [ + {"code": "readonly", "name": "只读引用(Q-OPEN-3 唯一合法值)"} + ], + "pbl_ext_owner_kind": [ + {"code": "blueprint", "name": "蓝图"}, + {"code": "template", "name": "模板"} + ] + } +} diff --git a/pbl_blueprint/models/m1b/pbl_blueprint_template.json b/pbl_blueprint/models/m1b/pbl_blueprint_template.json new file mode 100644 index 0000000..ebf8703 --- /dev/null +++ b/pbl_blueprint/models/m1b/pbl_blueprint_template.json @@ -0,0 +1,54 @@ +{ + "summary": "PBL 蓝图模板表(M1b)。同时承载『租户私有模板』与『平台公共模板』:tenant_id 为 NULL 且 platform_flag='Y' 即平台公共部分,对全部租户可见;tenant_id 非空为租户私有。唯一键参与列使用 tenant_key(值 = COALESCE(tenant_id,'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算,禁止调用方传入),以规避 MariaDB 唯一索引不对 NULL 去重导致平台模板可重复插入的问题;tenant_key 同时满足『租户列索引打头』规约。模板升级 append 新版本行、不覆盖旧版本(Q-OPEN-9)。tpl_json 存 7 类子对象数组 + 模板内临时 ID 父子引用;tpl_hash = sha256(canonical(tpl_json)) 保证确定性实例化。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。", + "fields": [ + {"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"}, + {"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算,不接受外部传入"}, + {"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台公共模板(M1b 模板平台公共部分);非空 = 租户私有模板"}, + {"name": "template_code", "type": "varchar", "len": 32, "label": "模板编码", "required": true, "nullable": false, "comment": "业务主键,pbl_common.gen_code('TPL', tenant_id) 生成"}, + {"name": "template_version", "type": "int", "label": "模板版本", "required": true, "nullable": false, "default": 1, "comment": "升级 append 新版本,不覆盖旧行"}, + {"name": "template_name", "type": "varchar", "len": 128, "label": "模板名称", "required": true, "nullable": false}, + {"name": "subject", "type": "varchar", "len": 32, "label": "学科", "required": false, "nullable": true, "comment": "字典 pbl_subject"}, + {"name": "grade", "type": "varchar", "len": 16, "label": "学段", "required": false, "nullable": true, "comment": "字典 pbl_grade"}, + {"name": "category", "type": "varchar", "len": 32, "label": "模板分类", "required": false, "nullable": true, "comment": "字典 pbl_tpl_category"}, + {"name": "tpl_json", "type": "longtext", "label": "模板内容", "required": true, "nullable": false, "comment": "7 类子对象数组 + 模板内临时 ID 父子引用(stage/task/role/world_ref/scene_ref/entity_ref/script_ref)"}, + {"name": "tpl_hash", "type": "varchar", "len": 64, "label": "内容哈希", "required": true, "nullable": false, "comment": "sha256(canonical_json(tpl_json)),确定性实例化校验"}, + {"name": "offline_flag", "type": "varchar", "len": 1, "label": "离线兜底模板", "required": true, "nullable": false, "default": "N", "comment": "Y=build.sh 种子落库的内置离线兜底模板,冷启动即可用"}, + {"name": "platform_flag", "type": "varchar", "len": 1, "label": "平台公共标记", "required": true, "nullable": false, "default": "N", "comment": "Y=平台公共模板(此时 tenant_id 必须为 NULL);字典 pbl_yes_no"}, + {"name": "ref_policy", "type": "varchar", "len": 16, "label": "外部引用校验策略", "required": true, "nullable": false, "default": "warn", "comment": "strict=引用缺失阻断回滚;warn=落库并标记 ref_unresolved(Q-OPEN-4 阈值可配);字典 pbl_ref_policy"}, + {"name": "tpl_status", "type": "varchar", "len": 16, "label": "模板状态", "required": true, "nullable": false, "default": "active", "comment": "字典 pbl_tpl_status"}, + {"name": "usage_count", "type": "int", "label": "实例化次数", "required": true, "nullable": false, "default": 0, "comment": "供 M10 模板使用率分析读取,非权威计数"}, + {"name": "remark", "type": "varchar", "len": 512, "label": "备注", "required": false, "nullable": true}, + {"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true, "comment": "rbac 用户ID"}, + {"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false}, + {"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false} + ], + "indexes": [ + {"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true}, + {"name": "uk_tpl_code_ver", "fields": ["tenant_key", "template_code", "template_version"], "unique": true, "comment": "同租户(含平台域)同编码同版本唯一"}, + {"name": "idx_tpl_status", "fields": ["tenant_key", "tpl_status", "subject", "grade"], "unique": false, "comment": "模板库浏览与筛选"}, + {"name": "idx_tpl_offline", "fields": ["tenant_key", "offline_flag"], "unique": false, "comment": "离线兜底模板快速定位"}, + {"name": "idx_tpl_hash", "fields": ["tenant_key", "tpl_hash"], "unique": false, "comment": "同内容去重/确定性校验"}, + {"name": "idx_tpl_platform", "fields": ["platform_flag", "tpl_status"], "unique": false, "comment": "平台公共模板清单(tenant_id IS NULL 场景)"}, + {"name": "idx_tpl_tenant", "fields": ["tenant_id", "tpl_status"], "unique": false, "comment": "按真实租户维度扫描/运维排查"} + ], + "codes": { + "pbl_tpl_status": [ + {"code": "active", "name": "启用"}, + {"code": "archived", "name": "已归档"}, + {"code": "draft", "name": "草稿"} + ], + "pbl_ref_policy": [ + {"code": "strict", "name": "严格(引用缺失阻断)"}, + {"code": "warn", "name": "宽松(引用缺失告警降级)"} + ], + "pbl_tpl_category": [ + {"code": "demo", "name": "演示模板"}, + {"code": "subject", "name": "学科模板"}, + {"code": "custom", "name": "自定义模板"} + ], + "pbl_yes_no": [ + {"code": "Y", "name": "是"}, + {"code": "N", "name": "否"} + ] + } +} diff --git a/pbl_blueprint/models/m1b/pbl_ext_field_def.json b/pbl_blueprint/models/m1b/pbl_ext_field_def.json new file mode 100644 index 0000000..1d91f78 --- /dev/null +++ b/pbl_blueprint/models/m1b/pbl_ext_field_def.json @@ -0,0 +1,62 @@ +{ + "summary": "PBL 子对象扩展字段定义表(M1b)——扩展字段元数据字典。定义『某类子对象允许挂哪些扩展字段、字段类型、是否必填、默认值、枚举候选、长度上限、正则校验』。m1b_subobject.save_subobject_ext / batch_save_subobject_ext 写入 pbl_subobject_ext 前必须先按本表做 fail-closed 校验:未定义的 field_key 一律拒绝(PBL_EXT_FIELD_UNDEFINED),类型/长度/枚举/正则不符一律拒绝(PBL_EXT_VALUE_INVALID)。platform_flag='Y' 且 tenant_id 为 NULL 表示平台内置字段定义,对全部租户可见;租户可在平台定义之外追加自有定义,但不得覆盖平台定义的 field_key(唯一键 uk_def_type_key 以 tenant_key 打头天然隔离)。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。", + "fields": [ + {"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"}, + {"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"}, + {"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台内置字段定义;非空 = 租户自有字段定义"}, + {"name": "def_code", "type": "varchar", "len": 32, "label": "定义编码", "required": true, "nullable": false, "comment": "业务主键,pbl_common.gen_code('EFD', tenant_id) 生成"}, + {"name": "subobject_type", "type": "varchar", "len": 32, "label": "适用子对象类型", "required": true, "nullable": false, "comment": "字典 pbl_subobject_type 的 7 类之一;'*' 表示对全部子对象类型通用"}, + {"name": "field_key", "type": "varchar", "len": 64, "label": "扩展字段键", "required": true, "nullable": false, "comment": "写入 pbl_subobject_ext.ext_key 的键名;^[a-z][a-z0-9_]{0,63}$"}, + {"name": "field_label", "type": "varchar", "len": 128, "label": "字段显示名", "required": true, "nullable": false}, + {"name": "value_type", "type": "varchar", "len": 16, "label": "值类型", "required": true, "nullable": false, "default": "string", "comment": "字典 pbl_ext_value_type:string/int/float/bool/json/date/enum"}, + {"name": "required_flag", "type": "varchar", "len": 1, "label": "是否必填", "required": true, "nullable": false, "default": "N", "comment": "Y=保存该子对象扩展时此键必须出现且非空;字典 pbl_yes_no"}, + {"name": "default_value", "type": "varchar", "len": 512, "label": "默认值", "required": false, "nullable": true, "comment": "调用方未传该键时按 value_type 强转后落库"}, + {"name": "enum_options", "type": "text", "label": "枚举候选", "required": false, "nullable": true, "comment": "value_type='enum' 时必填,JSON 数组字符串,如 [\"easy\",\"normal\",\"hard\"]"}, + {"name": "max_length", "type": "int", "label": "最大长度", "required": true, "nullable": false, "default": 0, "comment": "0=不限制;>0 时对 string/json/enum 的序列化长度做上限校验"}, + {"name": "validate_regex", "type": "varchar", "len": 256, "label": "正则校验", "required": false, "nullable": true, "comment": "非空时对 string 值做 re.fullmatch 校验,不符则 PBL_EXT_VALUE_INVALID"}, + {"name": "platform_flag", "type": "varchar", "len": 1, "label": "平台内置标记", "required": true, "nullable": false, "default": "N", "comment": "Y=平台内置定义(此时 tenant_id 必须为 NULL);字典 pbl_yes_no"}, + {"name": "def_status", "type": "varchar", "len": 16, "label": "定义状态", "required": true, "nullable": false, "default": "active", "comment": "字典 pbl_ext_def_status;disabled 的定义不参与校验且拒绝新值写入(历史值保留只读)"}, + {"name": "sort_no", "type": "int", "label": "排序号", "required": true, "nullable": false, "default": 0}, + {"name": "remark", "type": "varchar", "len": 512, "label": "备注", "required": false, "nullable": true}, + {"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true}, + {"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false}, + {"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false} + ], + "indexes": [ + {"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true}, + {"name": "uk_def_code", "fields": ["tenant_key", "def_code"], "unique": true, "comment": "业务主键唯一"}, + {"name": "uk_def_type_key", "fields": ["tenant_key", "subobject_type", "field_key"], "unique": true, "comment": "同域同子对象类型下 field_key 唯一,杜绝重复定义"}, + {"name": "idx_def_lookup", "fields": ["tenant_key", "def_status", "subobject_type", "sort_no"], "unique": false, "comment": "校验/渲染时按类型批量取启用定义"}, + {"name": "idx_def_platform", "fields": ["platform_flag", "def_status"], "unique": false, "comment": "平台内置定义清单(tenant_id IS NULL 场景)"}, + {"name": "idx_def_tenant", "fields": ["tenant_id", "subobject_type"], "unique": false, "comment": "按真实租户维度扫描/运维排查"} + ], + "codes": { + "pbl_ext_value_type": [ + {"code": "string", "name": "字符串"}, + {"code": "int", "name": "整数"}, + {"code": "float", "name": "浮点数"}, + {"code": "bool", "name": "布尔"}, + {"code": "json", "name": "JSON对象/数组"}, + {"code": "date", "name": "日期(YYYY-MM-DD)"}, + {"code": "enum", "name": "枚举"} + ], + "pbl_ext_def_status": [ + {"code": "active", "name": "启用"}, + {"code": "disabled", "name": "停用"} + ], + "pbl_subobject_type": [ + {"code": "stage", "name": "阶段"}, + {"code": "task", "name": "任务"}, + {"code": "role", "name": "角色"}, + {"code": "world_ref", "name": "世界引用"}, + {"code": "scene_ref", "name": "场景引用"}, + {"code": "entity_ref", "name": "实体引用"}, + {"code": "script_ref", "name": "脚本引用"}, + {"code": "*", "name": "全部类型通用"} + ], + "pbl_yes_no": [ + {"code": "Y", "name": "是"}, + {"code": "N", "name": "否"} + ] + } +} diff --git a/pbl_blueprint/models/m1b/pbl_subobject_ext.json b/pbl_blueprint/models/m1b/pbl_subobject_ext.json new file mode 100644 index 0000000..2a213c7 --- /dev/null +++ b/pbl_blueprint/models/m1b/pbl_subobject_ext.json @@ -0,0 +1,56 @@ +{ + "summary": "PBL 子对象扩展表(M1b)——7 类子对象的泛化扩展属性存储(EAV 结构,一行一个扩展键值)。设计前提:M1a 的 pbl_bp_subobject 契约已冻结,M1b 零改动 M1a 基表,扩展属性一律外挂到本表(M1b-annex §7『对 M1a 在途代码零改动要求』)。owner_kind 区分挂载宿主:blueprint=挂在真实蓝图子对象上(subobject_ref 取 M1a 的 subobject_id);template=挂在模板内容子对象上(subobject_ref 取 tpl_json 内的模板临时 ID,实例化时由 m1b_subobject.remap_subobject_ref 重映射为真实 subobject_id)。所有写入前必须经 pbl_ext_field_def 校验(fail-closed)。ext_value 统一以字符串形态存储,读取时按 value_type 反序列化。唯一键 uk_ext_slot 以 tenant_key 打头,保证同宿主同子对象同键只有一行(幂等 upsert)。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。", + "fields": [ + {"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"}, + {"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"}, + {"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台公共模板的子对象扩展;非空 = 租户数据"}, + {"name": "ext_code", "type": "varchar", "len": 32, "label": "扩展记录编码", "required": true, "nullable": false, "comment": "业务主键,pbl_common.gen_code('SXE', tenant_id) 生成"}, + {"name": "owner_kind", "type": "varchar", "len": 16, "label": "宿主类型", "required": true, "nullable": false, "comment": "字典 pbl_ext_owner_kind:blueprint / template"}, + {"name": "owner_id", "type": "varchar", "len": 32, "label": "宿主ID", "required": true, "nullable": false, "comment": "owner_kind=blueprint 时为 blueprint_id;=template 时为 template_code"}, + {"name": "owner_version", "type": "int", "label": "宿主版本", "required": true, "nullable": false, "default": 1, "comment": "blueprint→蓝图版本号;template→template_version;用于版本隔离,避免跨版本串数据"}, + {"name": "subobject_type", "type": "varchar", "len": 32, "label": "子对象类型", "required": true, "nullable": false, "comment": "字典 pbl_subobject_type 的 7 类之一"}, + {"name": "subobject_ref", "type": "varchar", "len": 64, "label": "子对象归一引用", "required": true, "nullable": false, "comment": "非空归一列:blueprint→M1A subobject_id;template→模板内临时 ID。参与唯一键,杜绝 NULL 造成的重复行"}, + {"name": "subobject_id", "type": "varchar", "len": 32, "label": "蓝图子对象ID", "required": false, "nullable": true, "comment": "冗余列,owner_kind=blueprint 时与 subobject_ref 同值,便于按 M1A 子对象直查"}, + {"name": "ext_key", "type": "varchar", "len": 64, "label": "扩展字段键", "required": true, "nullable": false, "comment": "必须在 pbl_ext_field_def 中存在且 def_status=active"}, + {"name": "ext_value", "type": "longtext", "label": "扩展字段值", "required": false, "nullable": true, "comment": "统一字符串形态;json/bool/date 按 value_type 序列化"}, + {"name": "value_type", "type": "varchar", "len": 16, "label": "值类型快照", "required": true, "nullable": false, "default": "string", "comment": "写入时从 pbl_ext_field_def.value_type 快照,保证历史值可按当时类型解读;字典 pbl_ext_value_type"}, + {"name": "field_def_id", "type": "bigint", "label": "字段定义ID", "required": false, "nullable": true, "comment": "指向 pbl_ext_field_def.id(逻辑关联,无 FK)"}, + {"name": "sort_no", "type": "int", "label": "排序号", "required": true, "nullable": false, "default": 0}, + {"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true}, + {"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false}, + {"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false} + ], + "indexes": [ + {"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true}, + {"name": "uk_ext_code", "fields": ["tenant_key", "ext_code"], "unique": true, "comment": "业务主键唯一"}, + {"name": "uk_ext_slot", "fields": ["tenant_key", "owner_kind", "owner_id", "owner_version", "subobject_type", "subobject_ref", "ext_key"], "unique": true, "comment": "同宿主同版本同子对象同键唯一 → 保存幂等(upsert)"}, + {"name": "idx_ext_owner", "fields": ["tenant_key", "owner_kind", "owner_id", "owner_version", "subobject_type"], "unique": false, "comment": "按宿主+类型批量取扩展(树渲染主路径)"}, + {"name": "idx_ext_subobject", "fields": ["tenant_key", "subobject_id"], "unique": false, "comment": "按 M1A 子对象直查扩展"}, + {"name": "idx_ext_key", "fields": ["tenant_key", "ext_key"], "unique": false, "comment": "按扩展键横向统计(M10 分析)"}, + {"name": "idx_ext_tenant", "fields": ["tenant_id", "owner_kind", "owner_id"], "unique": false, "comment": "按真实租户维度扫描/运维排查"} + ], + "codes": { + "pbl_ext_owner_kind": [ + {"code": "blueprint", "name": "蓝图子对象扩展"}, + {"code": "template", "name": "模板子对象扩展"} + ], + "pbl_subobject_type": [ + {"code": "stage", "name": "阶段"}, + {"code": "task", "name": "任务"}, + {"code": "role", "name": "角色"}, + {"code": "world_ref", "name": "世界引用"}, + {"code": "scene_ref", "name": "场景引用"}, + {"code": "entity_ref", "name": "实体引用"}, + {"code": "script_ref", "name": "脚本引用"} + ], + "pbl_ext_value_type": [ + {"code": "string", "name": "字符串"}, + {"code": "int", "name": "整数"}, + {"code": "float", "name": "浮点数"}, + {"code": "bool", "name": "布尔"}, + {"code": "json", "name": "JSON对象/数组"}, + {"code": "date", "name": "日期(YYYY-MM-DD)"}, + {"code": "enum", "name": "枚举"} + ] + } +} diff --git a/sql/m1b_ddl.sql b/sql/m1b_ddl.sql new file mode 100644 index 0000000..57b9d34 --- /dev/null +++ b/sql/m1b_ddl.sql @@ -0,0 +1,183 @@ +-- ============================================================================= +-- pbl_blueprint M1b DDL —— 模板平台公共部分 / 子对象扩展 / 关联表判定 +-- ============================================================================= +-- 覆盖 4 张表(与 pbl_blueprint/models/m1b/*.json 四段式表模型一一对应): +-- 1) pbl_blueprint_template 模板表(tenant_id NULL = 平台公共模板) +-- 2) pbl_ext_field_def 子对象扩展字段定义表(元数据字典,fail-closed 校验依据) +-- 3) pbl_subobject_ext 子对象扩展值表(EAV,一行一键值) +-- 4) pbl_blueprint_ref 蓝图外部关联表(Q-OPEN-3 裁决物理落地) +-- +-- 设计铁律: +-- * 全部 CREATE TABLE IF NOT EXISTS / CREATE INDEX 幂等,可重复执行(build.sh 与 +-- m1b_init.ensure_m1b_tables 双入口均执行本文件); +-- * 无 FOREIGN KEY(跨模块/可能跨库,有效性由 m1b_ref.resolve_refs() 主动判定); +-- * 无 ENUM(一律 varchar + 字典 codes,见 models/m1b/*.json 的 codes 段); +-- * 无 TIMESTAMP(一律 datetime,避免 2038 与隐式时区转换); +-- * 租户列索引打头:所有业务索引首列为 tenant_key(= COALESCE(tenant_id,'__PLATFORM__')) +-- 或 tenant_id;唯一键以 tenant_key 打头,规避 MariaDB 唯一索引不对 NULL 去重 +-- 导致平台公共行(tenant_id IS NULL)可重复插入的问题; +-- * Q-OPEN-3:本文件**不含**任何针对 world / scene / entity / script 等复用域基表的 +-- CREATE / ALTER / UPDATE / DELETE 语句——蓝图对外部对象的引用单向落在 +-- pbl_blueprint_ref,ref_mode 恒为 'readonly'。 +-- ============================================================================= + +SET NAMES utf8mb4; + +-- ----------------------------------------------------------------------------- +-- 1) pbl_blueprint_template —— PBL 蓝图模板表(M1b) +-- tenant_id IS NULL 且 platform_flag='Y' → 平台公共模板,对全部租户可见 +-- tenant_id 非空 → 租户私有模板 +-- 模板升级 append 新版本行、不覆盖旧版本(Q-OPEN-9) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_template` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT', + `tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算,不接受外部传入', + `tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台公共模板(M1b 模板平台公共部分);非空 = 租户私有模板', + `template_code` varchar(32) NOT NULL COMMENT '业务主键,pbl_common.gen_code(''TPL'', tenant_id) 生成', + `template_version` int NOT NULL DEFAULT 1 COMMENT '模板版本,升级 append 新版本不覆盖旧行', + `template_name` varchar(128) NOT NULL COMMENT '模板名称', + `subject` varchar(32) DEFAULT NULL COMMENT '学科,字典 pbl_subject', + `grade` varchar(16) DEFAULT NULL COMMENT '学段,字典 pbl_grade', + `category` varchar(32) DEFAULT NULL COMMENT '模板分类,字典 pbl_tpl_category', + `tpl_json` longtext NOT NULL COMMENT '7 类子对象数组 + 模板内临时 ID 父子引用', + `tpl_hash` varchar(64) NOT NULL COMMENT 'sha256(canonical_json(tpl_json)),确定性实例化校验', + `offline_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=build.sh 种子落库的内置离线兜底模板', + `platform_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=平台公共模板(此时 tenant_id 必须为 NULL);字典 pbl_yes_no', + `ref_policy` varchar(16) NOT NULL DEFAULT 'warn' COMMENT 'strict=引用缺失阻断回滚;warn=落库并标记 ref_unresolved(Q-OPEN-4);字典 pbl_ref_policy', + `tpl_status` varchar(16) NOT NULL DEFAULT 'active' COMMENT '模板状态,字典 pbl_tpl_status', + `usage_count` int NOT NULL DEFAULT 0 COMMENT '实例化次数(供 M10 分析,非权威计数)', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `creator_id` varchar(32) DEFAULT NULL COMMENT '创建人 rbac 用户ID', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tpl_code_ver` (`tenant_key`, `template_code`, `template_version`), + KEY `idx_tpl_status` (`tenant_key`, `tpl_status`, `subject`, `grade`), + KEY `idx_tpl_offline` (`tenant_key`, `offline_flag`), + KEY `idx_tpl_hash` (`tenant_key`, `tpl_hash`), + KEY `idx_tpl_platform` (`platform_flag`, `tpl_status`), + KEY `idx_tpl_tenant` (`tenant_id`, `tpl_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 蓝图模板表(M1b,含平台公共模板 tenant_id NULL)'; + +-- ----------------------------------------------------------------------------- +-- 2) pbl_ext_field_def —— PBL 子对象扩展字段定义表(M1b) +-- 扩展字段元数据字典:某类子对象允许挂哪些扩展键、类型、必填、默认值、 +-- 枚举候选、长度上限、正则校验。 +-- m1b_subobject.set_ext / bulk_set_ext 写 pbl_subobject_ext 前必须先按本表 +-- fail-closed 校验:未定义键 → PBL_EXT_FIELD_UNDEFINED; +-- 类型/长度/枚举/正则不符 → PBL_EXT_VALUE_INVALID。 +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_ext_field_def` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT', + `tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算', + `tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台内置字段定义;非空 = 租户自有字段定义', + `def_code` varchar(32) NOT NULL COMMENT '业务主键,pbl_common.gen_code(''EFD'', tenant_id) 生成', + `subobject_type` varchar(32) NOT NULL COMMENT '字典 pbl_subobject_type 的 7 类之一;''*'' 表示全部类型通用', + `field_key` varchar(64) NOT NULL COMMENT '写入 pbl_subobject_ext.ext_key 的键名;^[a-z][a-z0-9_]{0,63}$', + `field_label` varchar(128) NOT NULL COMMENT '字段显示名', + `value_type` varchar(16) NOT NULL DEFAULT 'string' COMMENT '字典 pbl_ext_value_type:string/int/float/bool/json/date/enum', + `required_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=保存该子对象扩展时此键必须出现且非空;字典 pbl_yes_no', + `default_value` varchar(512) DEFAULT NULL COMMENT '调用方未传该键时按 value_type 强转后落库', + `enum_options` text DEFAULT NULL COMMENT 'value_type=enum 时必填,JSON 数组字符串,如 ["easy","normal","hard"]', + `max_length` int NOT NULL DEFAULT 0 COMMENT '0=不限制;>0 时对 string/json/enum 序列化长度做上限校验', + `validate_regex` varchar(256) DEFAULT NULL COMMENT '非空时对 string 值做 re.fullmatch 校验,不符则 PBL_EXT_VALUE_INVALID', + `platform_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=平台内置定义(此时 tenant_id 必须为 NULL);字典 pbl_yes_no', + `def_status` varchar(16) NOT NULL DEFAULT 'active' COMMENT '字典 pbl_ext_def_status;disabled 不参与校验且拒绝新值写入(历史值只读保留)', + `sort_no` int NOT NULL DEFAULT 0 COMMENT '排序号', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `creator_id` varchar(32) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_def_code` (`tenant_key`, `def_code`), + UNIQUE KEY `uk_def_type_key` (`tenant_key`, `subobject_type`, `field_key`), + KEY `idx_def_lookup` (`tenant_key`, `def_status`, `subobject_type`, `sort_no`), + KEY `idx_def_platform` (`platform_flag`, `def_status`), + KEY `idx_def_tenant` (`tenant_id`, `subobject_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 子对象扩展字段定义表(M1b,扩展字段元数据字典)'; + +-- ----------------------------------------------------------------------------- +-- 3) pbl_subobject_ext —— PBL 子对象扩展表(M1b,EAV) +-- M1a 的 pbl_bp_subobject 契约已冻结,M1b 零改动 M1a 基表, +-- 扩展属性一律外挂到本表(M1b-annex §7 对 M1a 在途代码零改动要求)。 +-- owner_kind=blueprint → subobject_ref 取 M1a subobject_id; +-- owner_kind=template → subobject_ref 取 tpl_json 内模板临时 ID, +-- 实例化时由 m1b_subobject.remap_subobject_ref 重映射。 +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_subobject_ext` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT', + `tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算', + `tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台公共模板的子对象扩展;非空 = 租户数据', + `ext_code` varchar(32) NOT NULL COMMENT '业务主键,pbl_common.gen_code(''SXE'', tenant_id) 生成', + `owner_kind` varchar(16) NOT NULL COMMENT '字典 pbl_ext_owner_kind:blueprint / template', + `owner_id` varchar(32) NOT NULL COMMENT 'owner_kind=blueprint 时为 blueprint_id;=template 时为 template_code', + `owner_version` int NOT NULL DEFAULT 1 COMMENT 'blueprint→蓝图版本号;template→template_version;版本隔离避免跨版本串数据', + `subobject_type` varchar(32) NOT NULL COMMENT '字典 pbl_subobject_type 的 7 类之一', + `subobject_ref` varchar(64) NOT NULL COMMENT '非空归一列:blueprint→M1A subobject_id;template→模板内临时 ID。参与唯一键,杜绝 NULL 造成重复行', + `subobject_id` varchar(32) DEFAULT NULL COMMENT '冗余列,owner_kind=blueprint 时与 subobject_ref 同值,便于按 M1A 子对象直查', + `ext_key` varchar(64) NOT NULL COMMENT '必须在 pbl_ext_field_def 中存在且 def_status=active', + `ext_value` longtext DEFAULT NULL COMMENT '统一字符串形态;json/bool/date 按 value_type 序列化', + `value_type` varchar(16) NOT NULL DEFAULT 'string' COMMENT '写入时从 pbl_ext_field_def.value_type 快照,保证历史值可按当时类型解读;字典 pbl_ext_value_type', + `field_def_id` bigint DEFAULT NULL COMMENT '指向 pbl_ext_field_def.id(逻辑关联,无 FK)', + `sort_no` int NOT NULL DEFAULT 0 COMMENT '排序号', + `creator_id` varchar(32) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ext_code` (`tenant_key`, `ext_code`), + UNIQUE KEY `uk_ext_slot` (`tenant_key`, `owner_kind`, `owner_id`, `owner_version`, + `subobject_type`, `subobject_ref`, `ext_key`), + KEY `idx_ext_owner` (`tenant_key`, `owner_kind`, `owner_id`, `owner_version`, `subobject_type`), + KEY `idx_ext_subobject` (`tenant_key`, `subobject_id`), + KEY `idx_ext_key` (`tenant_key`, `ext_key`), + KEY `idx_ext_tenant` (`tenant_id`, `owner_kind`, `owner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 子对象扩展表(M1b,EAV 一行一键值)'; + +-- ----------------------------------------------------------------------------- +-- 4) pbl_blueprint_ref —— PBL 蓝图外部关联表(M1b,Q-OPEN-3 裁决物理落地) +-- 裁决结论:『不改 world 表』(含 scene / entity / script 等复用域基表一律 +-- 零改动、零加列、零回写)。蓝图与复用域对象的全部关联单向落在本表: +-- * ref_target_code 指向外部对象业务编码(逻辑关联,无 FOREIGN KEY); +-- * ref_mode 恒为 'readonly',由 m1b_ref.assert_writable() 与 +-- WRITE_PROTECTED_TABLES 双重闸门强制(任何针对 world/scene/entity/script +-- 的 INSERT/UPDATE/DELETE 在 SQL 执行前被拦截并抛 PBL_DB_WRITE_PROTECTED); +-- * resolve_refs() 仅做只读存在性校验并把结果写回本表 ref_status +-- (resolved / unresolved / invalid),校验策略 strict/warn 由 +-- pbl_blueprint_template.ref_policy 决定(Q-OPEN-4 阈值可配); +-- * snapshot_json 保存解析时刻只读快照,避免运行期跨库联查。 +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_ref` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT', + `tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算', + `tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台公共模板携带的关联;非空 = 租户蓝图关联', + `ref_code` varchar(32) NOT NULL COMMENT '业务主键,pbl_common.gen_code(''BPR'', tenant_id) 生成', + `owner_kind` varchar(16) NOT NULL DEFAULT 'blueprint' COMMENT '字典 pbl_ext_owner_kind:blueprint / template', + `blueprint_id` varchar(32) NOT NULL COMMENT 'owner_kind=blueprint 时为 M1A blueprint_id;=template 时复用本列存 template_code(取值由 owner_kind 判定)', + `subobject_id` varchar(32) DEFAULT NULL COMMENT '关联所属的 *_ref 类子对象ID;模板态存模板临时ID', + `ref_kind` varchar(16) NOT NULL COMMENT '字典 pbl_ref_kind:world / scene / entity / script', + `ref_target_code` varchar(64) NOT NULL COMMENT '复用域对象业务编码(只读引用,无 FK,不反查基表写操作)', + `ref_target_id` varchar(64) DEFAULT NULL COMMENT '解析成功时回填的对方物理ID快照', + `ref_mode` varchar(16) NOT NULL DEFAULT 'readonly' COMMENT '恒为 readonly(Q-OPEN-3:不改 world 表,只读引用);字典 pbl_ref_mode;写入非 readonly 值将被 m1b_ref 拒绝', + `ref_status` varchar(16) NOT NULL DEFAULT 'unresolved' COMMENT '字典 pbl_ref_status:unresolved 待解析 / resolved 已解析 / invalid 目标不存在或不可用', + `resolve_time` datetime DEFAULT NULL COMMENT '解析时间', + `resolve_msg` varchar(512) DEFAULT NULL COMMENT 'invalid 时记录原因,供 M2 校验与教师端提示', + `snapshot_json` text DEFAULT NULL COMMENT '解析时刻外部对象关键字段快照(JSON),运行期免跨库联查', + `sort_no` int NOT NULL DEFAULT 0 COMMENT '排序号', + `creator_id` varchar(32) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ref_code` (`tenant_key`, `ref_code`), + UNIQUE KEY `uk_ref_slot` (`tenant_key`, `owner_kind`, `blueprint_id`, `ref_kind`, `ref_target_code`), + KEY `idx_ref_blueprint` (`tenant_key`, `owner_kind`, `blueprint_id`, `ref_kind`), + KEY `idx_ref_target` (`tenant_key`, `ref_kind`, `ref_target_code`), + KEY `idx_ref_status` (`tenant_key`, `ref_status`), + KEY `idx_ref_tenant` (`tenant_id`, `blueprint_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 蓝图外部关联表(M1b,Q-OPEN-3 不改 world 表的只读关联落地)'; + +-- ============================================================================= +-- 幂等性说明:以上 4 段均为 CREATE TABLE IF NOT EXISTS,索引随表定义一并创建, +-- 重复执行不报错、不产生重复索引。m1b_init.ensure_m1b_tables() 按 ';' 切分本文件 +-- 逐条 sor.sqlExe(dbname, stmt) 执行(库名来自 ServerEnv().get_module_dbname, +-- 本文件与本模块任何代码均不硬编码库名)。 +-- ============================================================================= diff --git a/tools/patch_m1b.py b/tools/patch_m1b.py new file mode 100644 index 0000000..f942789 --- /dev/null +++ b/tools/patch_m1b.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +"""M1b 交付补丁脚本(一次性执行): +1) m1b_init.py:表模型路径改指 models/m1b/*.json(四段式),CRUD 定义指 json/m1b/*.json, + DDL 路径改指仓库根 sql/m1b_ddl.sql,并接入真实库执行通道(m1b_db.SorDB)。 +2) __init__.py:补 M1b 导出(三处同步之「__init__ 导出」环节)。 +3) init.py:补 M1b env 注册(三处同步之「init.py env 注册」环节)。 +执行:python3 tools/patch_m1b.py +""" +import io +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PKG = os.path.join(REPO, "pbl_blueprint") + + +def read(p): + with io.open(p, "r", encoding="utf-8") as f: + return f.read() + + +def write(p, s): + with io.open(p, "w", encoding="utf-8") as f: + f.write(s) + print("WROTE %s (%d chars)" % (os.path.relpath(p, REPO), len(s))) + + +def sub_once(text, old, new, tag): + if old not in text: + raise SystemExit("PATCH FAIL [%s]: anchor not found" % tag) + if text.count(old) != 1: + raise SystemExit("PATCH FAIL [%s]: anchor not unique (%d)" % (tag, text.count(old))) + return text.replace(old, new) + + +# ---------------------------------------------------------------- 1) m1b_init.py +p = os.path.join(PKG, "m1b_init.py") +t = read(p) + +old_head = '''_HERE = os.path.dirname(os.path.abspath(__file__)) + +M1B_MODEL_FILES = [ + os.path.join(_HERE, "json", "m1b", "pbl_blueprint_template.json"), + os.path.join(_HERE, "json", "m1b", "pbl_ext_field_def.json"), + os.path.join(_HERE, "json", "m1b", "pbl_subobject_ext.json"), + os.path.join(_HERE, "json", "m1b", "pbl_blueprint_ref.json"), +] +M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext", + "pbl_blueprint_ref"] +M1B_DDL = os.path.join(_HERE, "sql", "m1b_ddl.sql") +''' + +new_head = '''# 目录规约(module-development-spec / database-table-definition-spec): +# * 表定义(四段式 summary/fields/indexes/codes)→ pbl_blueprint/models/m1b/*.json +# * CRUD 定义(tblname + params.editable) → pbl_blueprint/json/m1b/*.json +# * 幂等 DDL(4 表 CREATE TABLE IF NOT EXISTS) → /sql/m1b_ddl.sql +_HERE = os.path.dirname(os.path.abspath(__file__)) # .../pbl_blueprint/pbl_blueprint +_REPO = os.path.dirname(_HERE) # .../pbl_blueprint(仓库根) + +M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext", + "pbl_blueprint_ref"] + +# 表模型(四段式)——建表/字段校验的权威来源 +M1B_MODEL_FILES = [ + os.path.join(_HERE, "models", "m1b", "pbl_blueprint_template.json"), + os.path.join(_HERE, "models", "m1b", "pbl_ext_field_def.json"), + os.path.join(_HERE, "models", "m1b", "pbl_subobject_ext.json"), + os.path.join(_HERE, "models", "m1b", "pbl_blueprint_ref.json"), +] + +# CRUD 定义(tblname + params.editable)——供 sqlor/前端 CRUD 装配 +M1B_CRUD_FILES = [ + os.path.join(_HERE, "json", "m1b", "pbl_blueprint_template.json"), + os.path.join(_HERE, "json", "m1b", "pbl_ext_field_def.json"), + os.path.join(_HERE, "json", "m1b", "pbl_subobject_ext.json"), + os.path.join(_HERE, "json", "m1b", "pbl_blueprint_ref.json"), +] + +M1B_DDL = os.path.join(_REPO, "sql", "m1b_ddl.sql") +''' + +t = sub_once(t, old_head, new_head, "m1b_init head") + +t = sub_once( + t, + '__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "init_m1b", "ensure_m1b_tables",\n' + ' "ensure_platform_ext_defs", "seed_platform_data"]', + '__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL",\n' + ' "PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES",\n' + ' "init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs",\n' + ' "seed_platform_data", "load_m1b_models", "load_m1b_crud_defs"]', + "m1b_init __all__") + +# 建表:接入真实库通道(m1b_db.SorDB.sqlExe),并做 Q-OPEN-3 闸门保护 +old_ensure = ''' result = {"ok": True, "models": [m.get("table") for m in models], "ddl": None, "executed": False} +''' +new_ensure = ''' result = {"ok": True, "models": [m.get("table") for m in models], + "ddl": None, "executed": 0, "statements": 0, "errors": []} +''' +t = sub_once(t, old_ensure, new_ensure, "ensure result init") + +old_ddl = ''' # 2) DDL 兜底 + if os.path.exists(M1B_DDL): + try: + with open(M1B_DDL, "r", encoding="utf-8") as fp: + ddl = fp.read() + result["ddl"] = M1B_DDL + if callable(executor): + for stmt in [s.strip() for s in ddl.split(";") if s.strip() + and not s.strip().startswith("--")]: + try: + executor(stmt) + except Exception: + continue + result["executed"] = True + except Exception: + pass + return result +''' +new_ddl = ''' # 2) 幂等 DDL(sql/m1b_ddl.sql,4 表 CREATE TABLE IF NOT EXISTS) + if os.path.exists(M1B_DDL): + try: + with open(M1B_DDL, "r", encoding="utf-8") as fp: + ddl = fp.read() + result["ddl"] = M1B_DDL + from .m1b_db import split_ddl, guard_sql + stmts = split_ddl(ddl) + result["statements"] = len(stmts) + + # 执行通道优先级:显式 executor > 真实库 SorDB(sqlor 标准 API) + runner = None + if callable(executor): + runner = executor + else: + try: + from .m1b_db import make_db + real = make_db() + if real is not None: + runner = real.sqlExe + result["channel"] = "sqlor(SorDB.sqlExe)" + except Exception: + runner = None + if runner is not None: + for stmt in stmts: + try: + guard_sql(stmt) # Q-OPEN-3:拦截任何复用域基表写/改 + runner(stmt) + result["executed"] += 1 + except Exception as e: # 单条失败不阻断其余(幂等重跑可补) + result["errors"].append("%s: %s" % (type(e).__name__, e)) + result["ok"] = not result["errors"] + else: + result["channel"] = "none(内存适配器:表按需自动创建)" + except Exception as e: + result["ok"] = False + result["errors"].append("ddl_load: %s" % e) + return result + + +def load_m1b_models(): + """读取 4 张表的四段式表模型(models/m1b/*.json)。""" + out = [] + for f in M1B_MODEL_FILES: + if not os.path.exists(f): + continue + try: + with open(f, "r", encoding="utf-8") as fp: + d = json.load(fp) + d["_file"] = os.path.relpath(f, _REPO) + out.append(d) + except Exception: + continue + return out + + +def load_m1b_crud_defs(): + """读取 4 张表的 CRUD 定义(json/m1b/*.json,tblname + params.editable)。""" + out = [] + for f in M1B_CRUD_FILES: + if not os.path.exists(f): + continue + try: + with open(f, "r", encoding="utf-8") as fp: + d = json.load(fp) + d["_file"] = os.path.relpath(f, _REPO) + out.append(d) + except Exception: + continue + return out +''' +t = sub_once(t, old_ddl, new_ddl, "ensure ddl block") + +# init_m1b 返回值补 CRUD 定义装载证据 +t = sub_once( + t, + ''' return {"ok": True, "milestone": "M1b", "tables": tables, "routes": routes, + "seed": seeded, "q_open_3": "world/scene/entity 基表零改动"}''', + ''' return {"ok": True, "milestone": "M1b", "tables": tables, "routes": routes, + "seed": seeded, + "models_loaded": [m.get("_file") for m in load_m1b_models()], + "crud_defs_loaded": [c.get("tblname") for c in load_m1b_crud_defs()], + "q_open_3": "world/scene/entity/script 基表零改动、零加列、零回写"}''', + "init_m1b return") + +write(p, t) + +# ---------------------------------------------------------------- 2) __init__.py +p = os.path.join(PKG, "__init__.py") +t = read(p) + +old = '''# ------------------------------------------------------------------ 挂载入口 +from .init import load_pbl_blueprint # noqa: F401 +''' +new = '''# ------------------------------------------------------------------ 挂载入口 +from .init import load_pbl_blueprint # noqa: F401 + +# ------------------------------------------------------------------ M1b(模板平台公共部分 / 子对象扩展 / 关联表判定) +# 三处同步之「__init__ 导出」环节:函数定义(m1b_*.py) + 本文件导出 + init.py env 注册 +from .m1b_init import ( # noqa: F401 + M1B_TABLES, + M1B_MODEL_FILES, + M1B_CRUD_FILES, + M1B_DDL, + PLATFORM_EXT_DEFS, + PLATFORM_TEMPLATES, + init_m1b, + ensure_m1b_tables, + ensure_platform_ext_defs, + seed_platform_data, + load_m1b_models, + load_m1b_crud_defs, +) +from .m1b_api import M1B_ROUTES, dispatch as m1b_dispatch, register_m1b_routes # noqa: F401 +from .m1b_db import ( # noqa: F401 + PLATFORM_TENANT_KEY, + WRITE_PROTECTED_TABLES, + PBL_DB_WRITE_PROTECTED, + WriteProtectedError, + SorDB, + tenant_key as m1b_tenant_key, + assert_writable as m1b_assert_writable, + make_db as m1b_make_db, +) +from . import m1b_template # noqa: F401 +from . import m1b_subobject # noqa: F401 +from . import m1b_ref # noqa: F401 +''' +t = sub_once(t, old, new, "__init__ imports") + +t = sub_once( + t, + ''' # 挂载入口 + "load_pbl_blueprint", +]''', + ''' # 挂载入口 + "load_pbl_blueprint", + # ---- M1b:挂载/建表/种子 + "M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL", + "PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES", + "init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs", + "seed_platform_data", "load_m1b_models", "load_m1b_crud_defs", + # ---- M1b:路由 + "M1B_ROUTES", "m1b_dispatch", "register_m1b_routes", + # ---- M1b:真实库适配 + Q-OPEN-3 写保护闸门 + "PLATFORM_TENANT_KEY", "WRITE_PROTECTED_TABLES", "PBL_DB_WRITE_PROTECTED", + "WriteProtectedError", "SorDB", "m1b_tenant_key", "m1b_assert_writable", + "m1b_make_db", + # ---- M1b:业务子模块 + "m1b_template", "m1b_subobject", "m1b_ref", +]''', + "__init__ __all__") + +t = sub_once( + t, '__version__ = "1.0.0"', '__version__ = "1.1.0" # M1b:模板平台公共部分/子对象扩展/关联表', + "__init__ version") +write(p, t) + +# ---------------------------------------------------------------- 3) init.py +p = os.path.join(PKG, "init.py") +t = read(p) + +old = '''MODULE_NAME = "pbl_blueprint" +TABLE_NAMES = list(_tables.TABLES.keys()) # 11 表 +SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类 +''' +new = '''from . import m1b_init as _m1b_init +from .m1b_api import M1B_ROUTES, register_m1b_routes +from .m1b_init import (M1B_CRUD_FILES, M1B_DDL, M1B_MODEL_FILES, M1B_TABLES, + ensure_m1b_tables, init_m1b, load_m1b_crud_defs, + load_m1b_models, seed_platform_data) + +MODULE_NAME = "pbl_blueprint" +TABLE_NAMES = list(_tables.TABLES.keys()) # M1a 11 表 +SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类 +M1B_TABLE_NAMES = list(M1B_TABLES) # M1b 4 表 +ALL_TABLE_NAMES = TABLE_NAMES + [t for t in M1B_TABLE_NAMES if t not in TABLE_NAMES] +''' +t = sub_once(t, old, new, "init.py imports") + +t = sub_once( + t, + '''__all__ = [ + "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES", + "SUBOBJECT_TYPES", "BUILTIN_SCHEMA",''', + '''__all__ = [ + "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES", + "SUBOBJECT_TYPES", "BUILTIN_SCHEMA", + # ---- M1b 三处同步之「init.py env 注册」环节 + "M1B_TABLES", "M1B_TABLE_NAMES", "ALL_TABLE_NAMES", "M1B_MODEL_FILES", + "M1B_CRUD_FILES", "M1B_DDL", "M1B_ROUTES", + "init_m1b", "ensure_m1b_tables", "register_m1b_routes", + "load_m1b_models", "load_m1b_crud_defs", "seed_platform_data",''', + "init.py __all__") + +old_load = '''def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw): + dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入 + if ensure: + ensure_tables(sor) + if app is not None: + try: + app.register_module(MODULE_NAME, { + "tables": TABLE_NAMES,''' +new_load = '''def _m1b_register_callback(app): + """解析应用层路由注册回调:register_route / register / route 三选一。""" + if app is None: + return None + for name in ("register_route", "register", "route", "add_route"): + fn = getattr(app, name, None) + if callable(fn): + return fn + return None + + +def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw): + dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入 + if ensure: + ensure_tables(sor) + + # ------------------------------------------------------------ M1b 挂载 + # 三处同步:m1b_api.register_m1b_routes(定义)+ __init__.py(导出)+ 此处(env 注册) + # Q-OPEN-3:init_m1b 内部只建 M1b 自有 4 表,绝不触碰 world/scene/entity/script 基表。 + m1b_result = {"ok": False, "skipped": True} + if kw.get("m1b", True): + try: + m1b_result = init_m1b( + db=kw.get("m1b_db"), + env=kw.get("env"), + register=_m1b_register_callback(app), + executor=kw.get("m1b_executor"), + seed=bool(kw.get("m1b_seed", ensure)), + ) + except Exception as e: # M1b 失败不拖垮 M1a 挂载 + m1b_result = {"ok": False, "error": "%s: %s" % (type(e).__name__, e)} + + if app is not None: + try: + app.register_module(MODULE_NAME, { + "tables": ALL_TABLE_NAMES, + "m1b": { + "tables": M1B_TABLE_NAMES, + "model_files": M1B_MODEL_FILES, + "crud_files": M1B_CRUD_FILES, + "ddl": M1B_DDL, + "routes": [(m, p) for m, p, _ in M1B_ROUTES], + "init": m1b_result, + },''', +t = sub_once(t, old_load, new_load, "init.py load_pbl_blueprint") + +t = sub_once( + t, + ''' return {"module": MODULE_NAME, "tables": TABLE_NAMES, + "subobject_types": list(SUBOBJECT_TYPES), "loaded": True}''', + ''' return {"module": MODULE_NAME, "tables": TABLE_NAMES, + "m1b_tables": M1B_TABLE_NAMES, "m1b": m1b_result, + "subobject_types": list(SUBOBJECT_TYPES), "loaded": True}''', + "init.py return") + +write(p, t) +print("PATCH OK") diff --git a/tools/patch_m1b_init.py b/tools/patch_m1b_init.py new file mode 100644 index 0000000..5c54e14 --- /dev/null +++ b/tools/patch_m1b_init.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +"""M1b 补丁脚本 2/2:init.py 的 env 注册(三处同步第三环)。 +(patch_m1b.py 已完成 m1b_init.py 与 __init__.py;本脚本只改 init.py,可重复执行—— + 已打过补丁时检测到锚点缺失即跳过并报告,不重复插入。) +执行:python3 tools/patch_m1b_init.py +""" +import io +import os + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +P = os.path.join(REPO, "pbl_blueprint", "init.py") + + +def read(p): + with io.open(p, "r", encoding="utf-8") as f: + return f.read() + + +def write(p, s): + with io.open(p, "w", encoding="utf-8") as f: + f.write(s) + print("WROTE %s (%d chars)" % (os.path.relpath(p, REPO), len(s))) + + +t = read(P) +if "M1B_TABLE_NAMES" in t: + print("SKIP: init.py 已含 M1b 注册(幂等,不重复打补丁)") + raise SystemExit(0) + +# ---- 1) import + 表清单 +old = ('MODULE_NAME = "pbl_blueprint"\n' + 'TABLE_NAMES = list(_tables.TABLES.keys()) # 11 表\n' + 'SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类\n') +new = ('from . import m1b_init as _m1b_init\n' + 'from .m1b_api import M1B_ROUTES, register_m1b_routes\n' + 'from .m1b_init import (M1B_CRUD_FILES, M1B_DDL, M1B_MODEL_FILES, M1B_TABLES,\n' + ' ensure_m1b_tables, init_m1b, load_m1b_crud_defs,\n' + ' load_m1b_models, seed_platform_data)\n' + '\n' + 'MODULE_NAME = "pbl_blueprint"\n' + 'TABLE_NAMES = list(_tables.TABLES.keys()) # M1a 11 表\n' + 'SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类\n' + 'M1B_TABLE_NAMES = list(M1B_TABLES) # M1b 4 表\n' + 'ALL_TABLE_NAMES = TABLE_NAMES + [x for x in M1B_TABLE_NAMES\n' + ' if x not in TABLE_NAMES]\n') +assert t.count(old) == 1, "anchor1 not unique: %d" % t.count(old) +t = t.replace(old, new) + +# ---- 2) __all__ +old = ('__all__ = [\n' + ' "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES",\n' + ' "SUBOBJECT_TYPES", "BUILTIN_SCHEMA",') +new = ('__all__ = [\n' + ' "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES",\n' + ' "SUBOBJECT_TYPES", "BUILTIN_SCHEMA",\n' + ' # ---- M1b 三处同步之「init.py env 注册」环节\n' + ' "M1B_TABLES", "M1B_TABLE_NAMES", "ALL_TABLE_NAMES", "M1B_MODEL_FILES",\n' + ' "M1B_CRUD_FILES", "M1B_DDL", "M1B_ROUTES",\n' + ' "init_m1b", "ensure_m1b_tables", "register_m1b_routes",\n' + ' "load_m1b_models", "load_m1b_crud_defs", "seed_platform_data",') +assert t.count(old) == 1, "anchor2 not unique: %d" % t.count(old) +t = t.replace(old, new) + +# ---- 3) load_pbl_blueprint:M1b 挂载 + env 注册 +old = ('def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):\n' + ' dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入\n' + ' if ensure:\n' + ' ensure_tables(sor)\n' + ' if app is not None:\n' + ' try:\n' + ' app.register_module(MODULE_NAME, {\n' + ' "tables": TABLE_NAMES,') +new = ('def _m1b_register_callback(app):\n' + ' """解析应用层路由注册回调:register_route / register / route / add_route 四选一。"""\n' + ' if app is None:\n' + ' return None\n' + ' for name in ("register_route", "register", "route", "add_route"):\n' + ' fn = getattr(app, name, None)\n' + ' if callable(fn):\n' + ' return fn\n' + ' return None\n' + '\n' + '\n' + 'def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):\n' + ' dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入\n' + ' if ensure:\n' + ' ensure_tables(sor)\n' + '\n' + ' # -------------------------------------------------------- M1b 挂载\n' + ' # 三处同步:m1b_api.register_m1b_routes(定义)+ __init__.py(导出)+ 此处(env 注册)\n' + ' # Q-OPEN-3:init_m1b 只建 M1b 自有 4 表,绝不触碰 world/scene/entity/script 基表。\n' + ' m1b_result = {"ok": False, "skipped": True}\n' + ' if kw.get("m1b", True):\n' + ' try:\n' + ' m1b_result = init_m1b(\n' + ' db=kw.get("m1b_db"),\n' + ' env=kw.get("env"),\n' + ' register=_m1b_register_callback(app),\n' + ' executor=kw.get("m1b_executor"),\n' + ' seed=bool(kw.get("m1b_seed", ensure)),\n' + ' )\n' + ' except Exception as e: # M1b 失败不拖垮 M1a 挂载\n' + ' m1b_result = {"ok": False,\n' + ' "error": "%s: %s" % (type(e).__name__, e)}\n' + '\n' + ' if app is not None:\n' + ' try:\n' + ' app.register_module(MODULE_NAME, {\n' + ' "tables": ALL_TABLE_NAMES,\n' + ' "m1b": {\n' + ' "tables": M1B_TABLE_NAMES,\n' + ' "model_files": M1B_MODEL_FILES,\n' + ' "crud_files": M1B_CRUD_FILES,\n' + ' "ddl": M1B_DDL,\n' + ' "routes": [(m, p) for m, p, _ in M1B_ROUTES],\n' + ' "init": m1b_result,\n' + ' },') +assert t.count(old) == 1, "anchor3 not unique: %d" % t.count(old) +t = t.replace(old, new) + +# ---- 4) 返回值 +old = (' return {"module": MODULE_NAME, "tables": TABLE_NAMES,\n' + ' "subobject_types": list(SUBOBJECT_TYPES), "loaded": True}') +new = (' return {"module": MODULE_NAME, "tables": TABLE_NAMES,\n' + ' "m1b_tables": M1B_TABLE_NAMES, "m1b": m1b_result,\n' + ' "subobject_types": list(SUBOBJECT_TYPES), "loaded": True}') +assert t.count(old) == 1, "anchor4 not unique: %d" % t.count(old) +t = t.replace(old, new) + +write(P, t) +print("PATCH init.py OK")