diff --git a/pbl_blueprint/__init__.py b/pbl_blueprint/__init__.py index 186135f..08d8805 100644 --- a/pbl_blueprint/__init__.py +++ b/pbl_blueprint/__init__.py @@ -51,6 +51,7 @@ from .m1b_init import ( # noqa: F401 load_m1b_models, load_m1b_crud_defs, ) +from .m1b_template import ensure_platform_seed # noqa: F401 # 三处同步之②导出:实现于 m1b_template.py from .m1b_api import M1B_ROUTES, dispatch as m1b_dispatch, register_m1b_routes # noqa: F401 from .m1b_db import ( # noqa: F401 PLATFORM_TENANT_KEY, @@ -92,6 +93,7 @@ __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", + "ensure_platform_seed", "seed_platform_data", "load_m1b_models", "load_m1b_crud_defs", # ---- M1b:路由 "M1B_ROUTES", "m1b_dispatch", "register_m1b_routes", diff --git a/pbl_blueprint/init.py b/pbl_blueprint/init.py index b81c23c..564127f 100644 --- a/pbl_blueprint/init.py +++ b/pbl_blueprint/init.py @@ -17,8 +17,9 @@ from .subobject import (BUILTIN_SCHEMA, add_rel, delete_rel, 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) + ensure_m1b_tables, ensure_platform_ext_defs, init_m1b, + load_m1b_crud_defs, load_m1b_models, seed_platform_data) +from .m1b_template import ensure_platform_seed MODULE_NAME = "pbl_blueprint" TABLE_NAMES = list(_tables.TABLES.keys()) # M1a 11 表 @@ -90,6 +91,54 @@ def _seed_builtin_schema(sor, dbname): i * 10, esc(now_str())))) +# --------------------------------------------------------------------------- +# M1b 三处同步之第三处:init.py env 注册 +# ① 实现:m1b_init.py / m1b_template.py / m1b_api.py +# ② 导出:pbl_blueprint/__init__.py +# ③ 注册:本文件 _m1b_register_env()(load_pbl_blueprint 内调用) +# 缺 ③ → .dspy/.ui 调用 M1b 函数报 NameError(module-development-spec 铁律)。 +# --------------------------------------------------------------------------- +M1B_ENV_EXPORTS = [ + "init_m1b", + "ensure_m1b_tables", + "ensure_platform_ext_defs", + "ensure_platform_seed", + "seed_platform_data", + "load_m1b_models", + "load_m1b_crud_defs", + "register_m1b_routes", +] + + +def _m1b_register_env(env=None): + """把 M1b 对外函数注册到 ServerEnv 单例(三处同步之第三处)。 + + env=None 时取 ServerEnv() 单例;ServerEnv 不可用(非 ahserver 宿主 / 单测环境) + 时 fail-soft 返回明细而不抛异常,避免阻断 M1a 既有加载链路。 + """ + if env is None: + try: + from ahserver.serverenv import ServerEnv + env = ServerEnv() + except Exception: + env = None + if env is None: + return {"registered": [], "failed": list(M1B_ENV_EXPORTS), + "reason": "ServerEnv 不可用(非 ahserver 宿主/单测环境)"} + registered, failed = [], [] + for name in M1B_ENV_EXPORTS: + fn = globals().get(name) + if fn is None: + failed.append(name) + continue + try: + setattr(env, name, fn) + registered.append(name) + except Exception: + failed.append(name) + return {"registered": registered, "failed": failed} + + def _m1b_register_callback(app): """解析应用层路由注册回调:register_route / register / route / add_route 四选一。""" if app is None: @@ -150,7 +199,9 @@ def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw): }) except Exception: pass + m1b_env_reg = _m1b_register_env(kw.get("env")) return {"module": MODULE_NAME, "tables": TABLE_NAMES, + "m1b_env_registered": m1b_env_reg, "m1b_tables": M1B_TABLE_NAMES, "m1b": m1b_result, "subobject_types": list(SUBOBJECT_TYPES), "loaded": True} diff --git a/pbl_blueprint/models/m1b/pbl_blueprint_ref.json b/pbl_blueprint/models/m1b/pbl_blueprint_ref.json index 3217c3a..6435132 100644 --- a/pbl_blueprint/models/m1b/pbl_blueprint_ref.json +++ b/pbl_blueprint/models/m1b/pbl_blueprint_ref.json @@ -1,53 +1,161 @@ { - "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。", + "summary": [ + { + "name": "pbl_blueprint_ref", + "title": "PBL蓝图外部关联", + "primary": [ + "id" + ], + "catelog": "relation", + "comment": "M1b 表;索引一律 tenant_key 打头(tenant_key=COALESCE(tenant_id,'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算);无 FOREIGN KEY;对 world/scene/entity/script 等复用域基表零改动(Q-OPEN-3)。" + } + ], "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} + { + "name": "id", + "title": "物理主键", + "type": "long", + "nullable": "no", + "comment": "AUTO_INCREMENT" + }, + { + "name": "tenant_key", + "title": "租户键", + "type": "str", + "length": 64, + "nullable": "no", + "default": "", + "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算" + }, + { + "name": "tenant_id", + "title": "租户ID", + "type": "str", + "length": 64, + "comment": "NULL = 平台公共模板携带的关联;非空 = 租户蓝图关联" + }, + { + "name": "blueprint_id", + "title": "蓝图ID", + "type": "long", + "nullable": "no", + "comment": "owner_kind=blueprint 时为 M1A blueprint_id;=template 时复用本列存 template_code(列名保留以对齐 M1A 语义,取值由 owner_kind 判定)" + }, + { + "name": "ref_type", + "title": "ref_type", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "ref_table", + "title": "ref_table", + "type": "str", + "length": 64, + "nullable": "no", + "default": "" + }, + { + "name": "ref_id", + "title": "ref_id", + "type": "str", + "length": 64, + "nullable": "no" + }, + { + "name": "ref_name", + "title": "ref_name", + "type": "str", + "length": 200 + }, + { + "name": "relation_kind", + "title": "relation_kind", + "type": "str", + "length": 32, + "nullable": "no", + "default": "reference" + }, + { + "name": "ref_payload", + "title": "ref_payload", + "type": "text" + }, + { + "name": "sort_no", + "title": "排序号", + "type": "int", + "nullable": "no", + "default": 0 + }, + { + "name": "status", + "title": "status", + "type": "str", + "length": 32, + "nullable": "no", + "default": "active" + }, + { + "name": "created_by", + "title": "created_by", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "created_at", + "type": "datetime" + }, + { + "name": "updated_by", + "title": "updated_by", + "type": "str", + "length": 64 + }, + { + "name": "updated_at", + "title": "updated_at", + "type": "datetime" + } ], "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": "按真实租户维度扫描/运维排查"} + { + "name": "idx_ref_blueprint", + "idxtype": "index", + "idxfields": [ + "blueprint_id", + "status" + ] + }, + { + "name": "idx_ref_table", + "idxtype": "index", + "idxfields": [ + "ref_table", + "ref_id" + ] + }, + { + "name": "idx_ref_tenant_target", + "idxtype": "index", + "idxfields": [ + "tenant_key", + "ref_type", + "ref_id" + ] + }, + { + "name": "uk_ref_tenant_bp_ref", + "idxtype": "unique", + "idxfields": [ + "tenant_key", + "blueprint_id", + "ref_type", + "ref_id" + ] + } ], - "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": "模板"} - ] - } + "codes": [] } diff --git a/pbl_blueprint/models/m1b/pbl_blueprint_template.json b/pbl_blueprint/models/m1b/pbl_blueprint_template.json index ebf8703..1c6550d 100644 --- a/pbl_blueprint/models/m1b/pbl_blueprint_template.json +++ b/pbl_blueprint/models/m1b/pbl_blueprint_template.json @@ -1,54 +1,196 @@ { - "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。", + "summary": [ + { + "name": "pbl_blueprint_template", + "title": "PBL蓝图模板", + "primary": [ + "id" + ], + "catelog": "entity", + "comment": "M1b 表;索引一律 tenant_key 打头(tenant_key=COALESCE(tenant_id,'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算);无 FOREIGN KEY;对 world/scene/entity/script 等复用域基表零改动(Q-OPEN-3)。" + } + ], "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} + { + "name": "id", + "title": "物理主键", + "type": "long", + "nullable": "no", + "comment": "AUTO_INCREMENT" + }, + { + "name": "tenant_key", + "title": "租户键", + "type": "str", + "length": 64, + "nullable": "no", + "default": "", + "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算,不接受外部传入" + }, + { + "name": "tenant_id", + "title": "租户ID", + "type": "str", + "length": 64, + "comment": "NULL = 平台公共模板(M1b 模板平台公共部分);非空 = 租户私有模板" + }, + { + "name": "template_code", + "title": "模板编码", + "type": "str", + "length": 64, + "nullable": "no", + "comment": "业务主键,pbl_common.gen_code('TPL', tenant_id) 生成" + }, + { + "name": "template_name", + "title": "模板名称", + "type": "str", + "length": 200, + "nullable": "no" + }, + { + "name": "scope", + "title": "scope", + "type": "str", + "length": 32, + "nullable": "no", + "default": "tenant" + }, + { + "name": "category", + "title": "模板分类", + "type": "str", + "length": 64, + "comment": "字典 pbl_tpl_category" + }, + { + "name": "description", + "title": "description", + "type": "text" + }, + { + "name": "blueprint_payload", + "title": "blueprint_payload", + "type": "text" + }, + { + "name": "subobject_payload", + "title": "subobject_payload", + "type": "text" + }, + { + "name": "ref_payload", + "title": "ref_payload", + "type": "text" + }, + { + "name": "version", + "title": "version", + "type": "str", + "length": 32, + "nullable": "no", + "default": "1.0.0" + }, + { + "name": "status", + "title": "status", + "type": "str", + "length": 32, + "nullable": "no", + "default": "draft" + }, + { + "name": "is_default", + "title": "is_default", + "type": "short", + "nullable": "no", + "default": 0 + }, + { + "name": "usage_count", + "title": "实例化次数", + "type": "int", + "nullable": "no", + "default": 0, + "comment": "供 M10 模板使用率分析读取,非权威计数" + }, + { + "name": "source_blueprint_id", + "title": "source_blueprint_id", + "type": "long" + }, + { + "name": "created_by", + "title": "created_by", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "created_at", + "type": "datetime" + }, + { + "name": "updated_by", + "title": "updated_by", + "type": "str", + "length": 64 + }, + { + "name": "updated_at", + "title": "updated_at", + "type": "datetime" + } ], "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": "按真实租户维度扫描/运维排查"} + { + "name": "idx_tpl_category", + "idxtype": "index", + "idxfields": [ + "tenant_key", + "category" + ] + }, + { + "name": "idx_tpl_scope", + "idxtype": "index", + "idxfields": [ + "scope", + "status" + ] + }, + { + "name": "idx_tpl_tenant_status", + "idxtype": "index", + "idxfields": [ + "tenant_key", + "status" + ] + }, + { + "name": "idx_tpl_updated", + "idxtype": "index", + "idxfields": [ + "updated_at" + ] + }, + { + "name": "uk_tpl_tenant_code", + "idxtype": "unique", + "idxfields": [ + "tenant_key", + "template_code" + ] + } ], - "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": "否"} - ] - } + "codes": [ + { + "field": "category", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='pbl_tpl_category'" + } + ] } diff --git a/pbl_blueprint/models/m1b/pbl_ext_field_def.json b/pbl_blueprint/models/m1b/pbl_ext_field_def.json index 1d91f78..71d82ae 100644 --- a/pbl_blueprint/models/m1b/pbl_ext_field_def.json +++ b/pbl_blueprint/models/m1b/pbl_ext_field_def.json @@ -1,62 +1,182 @@ { - "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。", + "summary": [ + { + "name": "pbl_ext_field_def", + "title": "PBL扩展字段定义", + "primary": [ + "id" + ], + "catelog": "dimession", + "comment": "M1b 表;索引一律 tenant_key 打头(tenant_key=COALESCE(tenant_id,'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算);无 FOREIGN KEY;对 world/scene/entity/script 等复用域基表零改动(Q-OPEN-3)。" + } + ], "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} + { + "name": "id", + "title": "物理主键", + "type": "long", + "nullable": "no", + "comment": "AUTO_INCREMENT" + }, + { + "name": "tenant_key", + "title": "租户键", + "type": "str", + "length": 64, + "nullable": "no", + "default": "", + "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算" + }, + { + "name": "tenant_id", + "title": "租户ID", + "type": "str", + "length": 64, + "comment": "NULL = 平台内置字段定义;非空 = 租户自有字段定义" + }, + { + "name": "subobject_type", + "title": "适用子对象类型", + "type": "str", + "length": 32, + "nullable": "no", + "comment": "字典 pbl_subobject_type 的 7 类之一;'*' 表示对全部子对象类型通用" + }, + { + "name": "field_key", + "title": "扩展字段键", + "type": "str", + "length": 64, + "nullable": "no", + "comment": "写入 pbl_subobject_ext.ext_key 的键名;^[a-z][a-z0-9_]{0,63}$" + }, + { + "name": "field_label", + "title": "字段显示名", + "type": "str", + "length": 200, + "nullable": "no" + }, + { + "name": "field_type", + "title": "field_type", + "type": "str", + "length": 16, + "nullable": "no", + "default": "string" + }, + { + "name": "required", + "title": "required", + "type": "short", + "nullable": "no", + "default": 0 + }, + { + "name": "default_value", + "title": "默认值", + "type": "str", + "length": 500, + "comment": "调用方未传该键时按 value_type 强转后落库" + }, + { + "name": "options_json", + "title": "options_json", + "type": "text" + }, + { + "name": "validation_json", + "title": "validation_json", + "type": "text" + }, + { + "name": "scope", + "title": "scope", + "type": "str", + "length": 32, + "nullable": "no", + "default": "tenant" + }, + { + "name": "sort_no", + "title": "排序号", + "type": "int", + "nullable": "no", + "default": 0 + }, + { + "name": "status", + "title": "status", + "type": "str", + "length": 32, + "nullable": "no", + "default": "active" + }, + { + "name": "created_by", + "title": "created_by", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "created_at", + "type": "datetime" + }, + { + "name": "updated_by", + "title": "updated_by", + "type": "str", + "length": 64 + }, + { + "name": "updated_at", + "title": "updated_at", + "type": "datetime" + } ], "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": "按真实租户维度扫描/运维排查"} + { + "name": "idx_def_scope", + "idxtype": "index", + "idxfields": [ + "scope", + "status" + ] + }, + { + "name": "idx_def_tenant", + "idxtype": "index", + "idxfields": [ + "tenant_key", + "status" + ] + }, + { + "name": "idx_def_type_status", + "idxtype": "index", + "idxfields": [ + "subobject_type", + "status" + ] + }, + { + "name": "uk_def_tenant_type_field", + "idxtype": "unique", + "idxfields": [ + "tenant_key", + "subobject_type", + "field_key" + ] + } ], - "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": "否"} - ] - } + "codes": [ + { + "field": "subobject_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='pbl_subobject_type'" + } + ] } diff --git a/pbl_blueprint/models/m1b/pbl_subobject_ext.json b/pbl_blueprint/models/m1b/pbl_subobject_ext.json index 2a213c7..2e11090 100644 --- a/pbl_blueprint/models/m1b/pbl_subobject_ext.json +++ b/pbl_blueprint/models/m1b/pbl_subobject_ext.json @@ -1,56 +1,172 @@ { - "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。", + "summary": [ + { + "name": "pbl_subobject_ext", + "title": "PBL子对象扩展属性", + "primary": [ + "id" + ], + "catelog": "entity", + "comment": "M1b 表;索引一律 tenant_key 打头(tenant_key=COALESCE(tenant_id,'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算);无 FOREIGN KEY;对 world/scene/entity/script 等复用域基表零改动(Q-OPEN-3)。" + } + ], "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} + { + "name": "id", + "title": "物理主键", + "type": "long", + "nullable": "no", + "comment": "AUTO_INCREMENT" + }, + { + "name": "tenant_key", + "title": "租户键", + "type": "str", + "length": 64, + "nullable": "no", + "default": "", + "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算" + }, + { + "name": "tenant_id", + "title": "租户ID", + "type": "str", + "length": 64, + "comment": "NULL = 平台公共模板的子对象扩展;非空 = 租户数据" + }, + { + "name": "blueprint_id", + "title": "blueprint_id", + "type": "long", + "nullable": "no" + }, + { + "name": "subobject_type", + "title": "子对象类型", + "type": "str", + "length": 32, + "nullable": "no", + "comment": "字典 pbl_subobject_type 的 7 类之一" + }, + { + "name": "subobject_id", + "title": "蓝图子对象ID", + "type": "str", + "length": 64, + "nullable": "no", + "comment": "冗余列,owner_kind=blueprint 时与 subobject_ref 同值,便于按 M1A 子对象直查" + }, + { + "name": "ext_key", + "title": "扩展字段键", + "type": "str", + "length": 64, + "nullable": "no", + "default": "", + "comment": "必须在 pbl_ext_field_def 中存在且 def_status=active" + }, + { + "name": "ext_value", + "title": "扩展字段值", + "type": "text", + "comment": "统一字符串形态;json/bool/date 按 value_type 序列化" + }, + { + "name": "ext_value_type", + "title": "ext_value_type", + "type": "str", + "length": 16, + "nullable": "no", + "default": "string" + }, + { + "name": "field_def_id", + "title": "字段定义ID", + "type": "long", + "comment": "指向 pbl_ext_field_def.id(逻辑关联,无 FK)" + }, + { + "name": "source", + "title": "source", + "type": "str", + "length": 32, + "nullable": "no", + "default": "manual" + }, + { + "name": "status", + "title": "status", + "type": "str", + "length": 32, + "nullable": "no", + "default": "active" + }, + { + "name": "created_by", + "title": "created_by", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "created_at", + "type": "datetime" + }, + { + "name": "updated_by", + "title": "updated_by", + "type": "str", + "length": 64 + }, + { + "name": "updated_at", + "title": "updated_at", + "type": "datetime" + } ], "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": "按真实租户维度扫描/运维排查"} + { + "name": "idx_ext_field_def", + "idxtype": "index", + "idxfields": [ + "field_def_id" + ] + }, + { + "name": "idx_ext_obj", + "idxtype": "index", + "idxfields": [ + "subobject_type", + "subobject_id" + ] + }, + { + "name": "idx_ext_tenant_bp_type", + "idxtype": "index", + "idxfields": [ + "tenant_key", + "blueprint_id", + "subobject_type" + ] + }, + { + "name": "uk_ext_tenant_obj_key", + "idxtype": "unique", + "idxfields": [ + "tenant_key", + "subobject_type", + "subobject_id", + "ext_key" + ] + } ], - "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": "枚举"} - ] - } + "codes": [ + { + "field": "subobject_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='pbl_subobject_type'" + } + ] } diff --git a/sql/m1b_ddl.sql b/sql/m1b_ddl.sql index 57b9d34..d6ca2cb 100644 --- a/sql/m1b_ddl.sql +++ b/sql/m1b_ddl.sql @@ -1,183 +1,157 @@ --- ============================================================================= --- 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 裁决物理落地) +-- ===================================================================== +-- PBL M1b DDL —— pbl_blueprint 模板 / 子对象扩展 / 关联表 +-- --------------------------------------------------------------------- +-- 权威文件:本文件是 M1b 唯一的 DDL 来源(supersedes sql/m1b_template.sql)。 +-- 方言 :MariaDB 10.x / MySQL 8.x —— InnoDB + utf8mb4 + bigint AUTO_INCREMENT +-- (不使用 BIGSERIAL / nextval 等 PostgreSQL 语法) +-- 幂等 :4 张表全部 CREATE TABLE IF NOT EXISTS;索引内联在建表语句中, +-- 表已存在时整条语句跳过,重复执行不报错、不产生副作用。 -- --- 设计铁律: --- * 全部 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'。 --- ============================================================================= +-- Q-OPEN-3 裁决落地(硬约束): +-- 1) 本文件只对 pbl_ 前缀的 4 张自有表建表,不触碰任何既有基表的结构与数据; +-- 2) 全文件零 FOREIGN KEY —— 跨表引用一律软引用(ref_table + ref_id 字符串); +-- 3) 基表扩展需求全部落到本文件的 pbl_subobject_ext / pbl_blueprint_ref。 +-- +-- 多租户约定: +-- tenant_id 允许 NULL —— NULL 表示「平台公共」(如平台公共模板); +-- tenant_key NOT NULL DEFAULT '' —— 由 tenant_id 归一化而来(NULL -> ''), +-- 所有 UNIQUE KEY 一律以 tenant_key 打头,规避 MySQL/MariaDB +-- 唯一索引把多个 NULL 视为互不相等而产生重复行的问题。 +-- +-- 表清单(4 张,与 models/m1b/*.json 四段式表定义一一对应): +-- 1. pbl_blueprint_template 蓝图模板(含平台公共模板) +-- 2. pbl_blueprint_ref 蓝图关联表(软引用外部对象) +-- 3. pbl_subobject_ext 子对象扩展值(KV,基表零改动) +-- 4. pbl_ext_field_def 扩展字段定义(元数据) +-- ===================================================================== -SET NAMES utf8mb4; --- ----------------------------------------------------------------------------- --- 1) pbl_blueprint_template —— PBL 蓝图模板表(M1b) --- tenant_id IS NULL 且 platform_flag='Y' → 平台公共模板,对全部租户可见 --- tenant_id 非空 → 租户私有模板 --- 模板升级 append 新版本行、不覆盖旧版本(Q-OPEN-9) --- ----------------------------------------------------------------------------- +-- --------------------------------------------------------------------- +-- 1/4 pbl_blueprint_template —— 蓝图模板(含平台公共模板 tenant_id IS NULL) +-- --------------------------------------------------------------------- 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 '更新时间', + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '租户键(归一化,空串=平台公共),唯一索引打头列', + `tenant_id` VARCHAR(64) DEFAULT NULL COMMENT '租户ID,NULL=平台公共模板', + `template_code` VARCHAR(64) NOT NULL COMMENT '模板编码(租户内唯一)', + `template_name` VARCHAR(200) NOT NULL COMMENT '模板名称', + `scope` VARCHAR(32) NOT NULL DEFAULT 'tenant' COMMENT '可见范围:platform=平台公共/tenant=租户私有', + `category` VARCHAR(64) DEFAULT NULL COMMENT '模板分类(学科/主题)', + `description` TEXT COMMENT '模板说明', + `blueprint_payload` LONGTEXT COMMENT '模板蓝图JSON快照(实例化时深拷贝)', + `subobject_payload` LONGTEXT COMMENT '模板子对象集合JSON快照', + `ref_payload` LONGTEXT COMMENT '模板关联表条目JSON快照(pbl_blueprint_ref)', + `version` VARCHAR(32) NOT NULL DEFAULT '1.0.0' COMMENT '模板版本号', + `status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '状态:draft/published/offline', + `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否租户默认模板:1=是', + `usage_count` INT NOT NULL DEFAULT 0 COMMENT '被实例化次数(统计)', + `source_blueprint_id` BIGINT DEFAULT NULL COMMENT '来源蓝图ID(由蓝图另存为模板时回填,软引用)', + `created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人', + `created_at` DATETIME DEFAULT NULL COMMENT '创建时间', + `updated_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人', + `updated_at` DATETIME DEFAULT 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)'; + UNIQUE KEY `uk_tpl_tenant_code` (`tenant_key`, `template_code`), + KEY `idx_tpl_tenant_status` (`tenant_key`, `status`), + KEY `idx_tpl_scope` (`scope`, `status`), + KEY `idx_tpl_category` (`tenant_key`, `category`), + KEY `idx_tpl_updated` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='PBL蓝图模板(M1b)'; --- ----------------------------------------------------------------------------- --- 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 保存解析时刻只读快照,避免运行期跨库联查。 --- ----------------------------------------------------------------------------- +-- --------------------------------------------------------------------- +-- 2/4 pbl_blueprint_ref —— 蓝图关联表(蓝图 <-> 外部对象 软引用) +-- Q-OPEN-3:不改任何既有基表,蓝图与基表对象的关系全部由本表承载 +-- (ref_table 记录被引用表名 + ref_id 记录被引用主键,只读引用零写入)。 +-- --------------------------------------------------------------------- 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 '更新时间', + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '租户键(归一化),唯一索引打头列', + `tenant_id` VARCHAR(64) DEFAULT NULL COMMENT '租户ID', + `blueprint_id` BIGINT NOT NULL COMMENT '蓝图ID(软引用 pbl_blueprint.id,不建外键)', + `ref_type` VARCHAR(32) NOT NULL COMMENT '引用类型:world/scene/entity/script/template/other', + `ref_table` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '被引用基表名(只读引用,零写入)', + `ref_id` VARCHAR(64) NOT NULL COMMENT '被引用对象主键(字符串以兼容异构基表)', + `ref_name` VARCHAR(200) DEFAULT NULL COMMENT '被引用对象名称快照(展示用,避免跨库JOIN)', + `relation_kind` VARCHAR(32) NOT NULL DEFAULT 'reference' COMMENT '关系语义:reference/derived/instantiated', + `ref_payload` TEXT COMMENT '关系扩展属性JSON', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序号', + `status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/inactive', + `created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人', + `created_at` DATETIME DEFAULT NULL COMMENT '创建时间', + `updated_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人', + `updated_at` DATETIME DEFAULT 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 表的只读关联落地)'; + UNIQUE KEY `uk_ref_tenant_bp_ref` (`tenant_key`, `blueprint_id`, `ref_type`, `ref_id`), + KEY `idx_ref_tenant_target` (`tenant_key`, `ref_type`, `ref_id`), + KEY `idx_ref_blueprint` (`blueprint_id`, `status`), + KEY `idx_ref_table` (`ref_table`, `ref_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='PBL蓝图关联表(M1b,Q-OPEN-3软引用)'; --- ============================================================================= --- 幂等性说明:以上 4 段均为 CREATE TABLE IF NOT EXISTS,索引随表定义一并创建, --- 重复执行不报错、不产生重复索引。m1b_init.ensure_m1b_tables() 按 ';' 切分本文件 --- 逐条 sor.sqlExe(dbname, stmt) 执行(库名来自 ServerEnv().get_module_dbname, --- 本文件与本模块任何代码均不硬编码库名)。 --- ============================================================================= + +-- --------------------------------------------------------------------- +-- 3/4 pbl_subobject_ext —— 子对象扩展(7 类子对象泛化契约的扩展值存储) +-- 基表零改动:扩展值以 KV 形式落本表,字段合法性由 pbl_ext_field_def 约束。 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_subobject_ext` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '租户键(归一化),唯一索引打头列', + `tenant_id` VARCHAR(64) DEFAULT NULL COMMENT '租户ID', + `blueprint_id` BIGINT NOT NULL COMMENT '所属蓝图ID(软引用)', + `subobject_type` VARCHAR(32) NOT NULL COMMENT '子对象类型(7类泛化契约之一)', + `subobject_id` VARCHAR(64) NOT NULL COMMENT '子对象在基表的主键(字符串以兼容异构基表)', + `ext_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '扩展字段键(空串=整对象级扩展)', + `ext_value` LONGTEXT COMMENT '扩展值(按 ext_value_type 解释)', + `ext_value_type` VARCHAR(16) NOT NULL DEFAULT 'string' COMMENT '值类型:string/number/bool/json/date', + `field_def_id` BIGINT DEFAULT NULL COMMENT '扩展字段定义ID(软引用 pbl_ext_field_def.id)', + `source` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源:manual/template/agent/import', + `status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/inactive', + `created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人', + `created_at` DATETIME DEFAULT NULL COMMENT '创建时间', + `updated_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人', + `updated_at` DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ext_tenant_obj_key` (`tenant_key`, `subobject_type`, `subobject_id`, `ext_key`), + KEY `idx_ext_tenant_bp_type` (`tenant_key`, `blueprint_id`, `subobject_type`), + KEY `idx_ext_obj` (`subobject_type`, `subobject_id`), + KEY `idx_ext_field_def` (`field_def_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='PBL子对象扩展值(M1b,基表零改动)'; + + +-- --------------------------------------------------------------------- +-- 4/4 pbl_ext_field_def —— 扩展字段定义(元数据:约束每类子对象可扩展哪些字段) +-- scope='platform' 且 tenant_id IS NULL 时为平台公共字段定义, +-- 租户级定义覆盖同 field_key 的平台定义(读取顺序:tenant -> platform)。 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_ext_field_def` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '租户键(归一化,空串=平台公共),唯一索引打头列', + `tenant_id` VARCHAR(64) DEFAULT NULL COMMENT '租户ID,NULL=平台公共字段定义', + `subobject_type` VARCHAR(32) NOT NULL COMMENT '适用的子对象类型', + `field_key` VARCHAR(64) NOT NULL COMMENT '扩展字段键(对应 pbl_subobject_ext.ext_key)', + `field_label` VARCHAR(200) NOT NULL COMMENT '字段显示名', + `field_type` VARCHAR(16) NOT NULL DEFAULT 'string' COMMENT '字段类型:string/number/bool/json/date/enum', + `required` TINYINT NOT NULL DEFAULT 0 COMMENT '是否必填:1=是', + `default_value` VARCHAR(500) DEFAULT NULL COMMENT '默认值', + `options_json` TEXT COMMENT '枚举选项JSON(field_type=enum 时有效)', + `validation_json` TEXT COMMENT '校验规则JSON(min/max/pattern/maxLength)', + `scope` VARCHAR(32) NOT NULL DEFAULT 'tenant' COMMENT '可见范围:platform/tenant', + `sort_no` INT NOT NULL DEFAULT 0 COMMENT '表单排序号', + `status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active/inactive', + `created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人', + `created_at` DATETIME DEFAULT NULL COMMENT '创建时间', + `updated_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人', + `updated_at` DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_def_tenant_type_field` (`tenant_key`, `subobject_type`, `field_key`), + KEY `idx_def_type_status` (`subobject_type`, `status`), + KEY `idx_def_scope` (`scope`, `status`), + KEY `idx_def_tenant` (`tenant_key`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='PBL扩展字段定义(M1b元数据)'; + +-- ===================================================================== +-- 文件结束:4 张表,0 个 FOREIGN KEY,0 条针对既有基表的语句。 +-- 本文件只含 CREATE TABLE IF NOT EXISTS(4 条),不含任何其它 DDL/DML 动词, +-- 因此对既有基表(world / scene / entity / script 等)零影响。 +-- 机械核验由 tools/m1b_selfcheck.py 的 check_ddl() 自动执行并输出证据。 +-- ===================================================================== diff --git a/tests/_m1b_loader.py b/tests/_m1b_loader.py new file mode 100644 index 0000000..8033c09 --- /dev/null +++ b/tests/_m1b_loader.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""M1b 测试引导器(tests 专用,不属于模块运行时代码)。 + +背景:pbl_blueprint/__init__.py 会 eager import .init -> .api_blueprint, +后者依赖 pbl_common(M1a 挂载链)。当 pbl_common 契约尚未对齐时, +任何 `from pbl_blueprint.m1b_xxx import ...` 都会在包初始化阶段炸掉, +导致 M1b 自包含代码无法被单测覆盖。 + +做法:在 sys.modules 里预置一个「空壳包」pbl_blueprint,只设 __path__ +指向真实包目录,不执行真实 __init__.py;随后 import pbl_blueprint.m1b_* +即按子模块直接加载(子模块间的相对导入照常工作)。 + +M1b 的 m1b_common / m1b_db / m1b_template / m1b_subobject / m1b_ref / +m1b_api / m1b_init 均不 import pbl_common,故本引导器不掩盖任何真实缺陷; +M1a 挂载链的集成验证由 tests/test_m1b_realdb.py 与部署阶段负责。 +""" +import os +import sys +import types + +_PKG_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pbl_blueprint") +_MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(_PKG_DIR))) + + +def bootstrap(): + """预置空壳包,返回包目录。可重复调用(幂等)。""" + if _MOD_ROOT not in sys.path: + sys.path.insert(0, _MOD_ROOT) + pkg = sys.modules.get("pbl_blueprint") + if pkg is None or not getattr(pkg, "_m1b_stub", False): + stub = types.ModuleType("pbl_blueprint") + stub.__path__ = [_PKG_DIR] + stub._m1b_stub = True + sys.modules["pbl_blueprint"] = stub + return _PKG_DIR + + +bootstrap() diff --git a/tests/test_m1b_ext_ref.py b/tests/test_m1b_ext_ref.py index e03fd12..1ed99ea 100644 --- a/tests/test_m1b_ext_ref.py +++ b/tests/test_m1b_ext_ref.py @@ -9,6 +9,7 @@ import os import sys import unittest +import _m1b_loader # noqa: F401 (空壳包引导,绕开 M1a 挂载链 eager import) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from pbl_blueprint.m1b_common import ( # noqa: E402 diff --git a/tests/test_m1b_realdb.py b/tests/test_m1b_realdb.py new file mode 100644 index 0000000..880515d --- /dev/null +++ b/tests/test_m1b_realdb.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""M1b 真实库集成测试(QC#5 整改)。 + +设计原则(对应 QC 退回意见 #5): + * 不再只有「纯内存 FakeDB 单测」。本文件在**可连库环境真实执行** DDL + CRUD, + 覆盖 4 张表的建表幂等、唯一键 tenant_key 打头去重、平台公共模板 tenant_id IS NULL、 + 子对象扩展 KV 读写、关联表软引用判定(Q-OPEN-3:零基表写入)。 + * 环境受限时(无 MySQL/MariaDB 或未提供连接参数)**显式 SKIP 并打印移交说明**, + 绝不把 SKIP 伪装成 PASS;移交部署验证的清单见 docs/M1b-qc-rework-evidence.md §5。 + +运行方式(三选一): + 1) 环境变量: + PBL_TEST_DB_HOST=127.0.0.1 PBL_TEST_DB_PORT=3306 \ + PBL_TEST_DB_USER=root PBL_TEST_DB_PASSWORD=xxx PBL_TEST_DB_NAME=pbl_test \ + python3 tests/test_m1b_realdb.py + 2) 配置文件:PBL_TEST_DB_DSN=mysql://user:pass@host:3306/dbname + 3) 无参数:自动探测 127.0.0.1:3306,连不上则 SKIP(退出码 0,但打印 SKIPPED)。 + +退出码:0 = 全部通过 或 环境受限 SKIP;1 = 有真实 FAIL;2 = 依赖缺失且未 SKIP 判定失败。 +""" + +import json +import os +import re +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _m1b_loader # noqa: F401 +import traceback +from datetime import datetime + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DDL_PATH = os.path.join(REPO, "sql", "m1b_ddl.sql") + +TENANT_A = "T_M1B_A" +TENANT_B = "T_M1B_B" +PLATFORM_KEY = "" # tenant_id NULL -> tenant_key '' +NOW = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + +RESULTS = [] + + +def log(ok, name, detail=""): + RESULTS.append((name, bool(ok), detail)) + print("[%s] %s :: %s" % ("PASS" if ok else "FAIL", name, detail)) + + +def skip(name, detail): + RESULTS.append((name, None, detail)) + print("[SKIP] %s :: %s" % (name, detail)) + + +# ------------------------------------------------------------------ 连接 +def resolve_dsn(): + dsn = os.environ.get("PBL_TEST_DB_DSN") + if dsn: + m = re.match(r"mysql(?:\+\w+)?://([^:]+):([^@]*)@([^:/]+):?(\d+)?/(.+)", dsn) + if not m: + raise ValueError("PBL_TEST_DB_DSN 格式非法: %s" % dsn) + return dict(host=m.group(3), port=int(m.group(4) or 3306), + user=m.group(1), password=m.group(2), database=m.group(5)) + if os.environ.get("PBL_TEST_DB_HOST"): + return dict(host=os.environ["PBL_TEST_DB_HOST"], + port=int(os.environ.get("PBL_TEST_DB_PORT", 3306)), + user=os.environ.get("PBL_TEST_DB_USER", "root"), + password=os.environ.get("PBL_TEST_DB_PASSWORD", ""), + database=os.environ.get("PBL_TEST_DB_NAME", "pbl_test")) + return dict(host="127.0.0.1", port=3306, user="root", password="", + database="pbl_test") + + +def connect(cfg): + """返回 (conn, driver_name);驱动缺失或连不上抛异常。""" + try: + import pymysql # type: ignore + conn = pymysql.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"], + password=cfg["password"], database=cfg["database"], + charset="utf8mb4", autocommit=True, + connect_timeout=5) + return conn, "pymysql" + except ImportError: + pass + try: + import MySQLdb # type: ignore + conn = MySQLdb.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"], + passwd=cfg["password"], db=cfg["database"], + charset="utf8mb4") + conn.autocommit(True) + return conn, "MySQLdb" + except ImportError: + pass + try: + import mariadb # type: ignore + conn = mariadb.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"], + password=cfg["password"], database=cfg["database"]) + conn.autocommit = True + return conn, "mariadb" + except ImportError: + raise RuntimeError("NO_DRIVER: 未安装 pymysql / MySQLdb / mariadb 任一驱动") + + +def split_ddl(sql_text): + body = re.sub(r"/\*.*?\*/", " ", sql_text, flags=re.S) + stmts, buf = [], [] + for line in body.splitlines(): + i = line.find("--") + if i >= 0: + line = line[:i] + buf.append(line) + if line.rstrip().endswith(";"): + s = "\n".join(buf).strip() + if s and s != ";": + stmts.append(s.rstrip(";")) + buf = [] + return [s for s in stmts if s.strip()] + + +# ------------------------------------------------------------------ 用例 +def t_ddl_idempotent(cur): + """T1: DDL 执行两遍均成功(CREATE TABLE IF NOT EXISTS 幂等)。""" + with open(DDL_PATH, "r", encoding="utf-8") as f: + stmts = split_ddl(f.read()) + for rnd in (1, 2): + for s in stmts: + cur.execute(s) + log(len(stmts) == 4, "T1_DDL幂等", + "执行 %d 条语句 × 2 轮无异常" % len(stmts)) + + +def t_tables_exist(cur): + """T2: 4 张表全部存在,且引擎/字符集符合 mariadb 方言。""" + cur.execute("""SELECT TABLE_NAME, ENGINE, TABLE_COLLATION + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('pbl_blueprint_template','pbl_blueprint_ref', + 'pbl_subobject_ext','pbl_ext_field_def')""") + rows = cur.fetchall() + names = sorted(r[0] for r in rows) + expect = sorted(['pbl_blueprint_template', 'pbl_blueprint_ref', + 'pbl_subobject_ext', 'pbl_ext_field_def']) + engines = set(r[1] for r in rows) + log(names == expect and engines == {"InnoDB"}, "T2_四表存在", + "表=%s 引擎=%s" % (names, engines)) + + +def t_no_foreign_key(cur): + """T3: 4 张表零 FOREIGN KEY(Q-OPEN-3)。""" + cur.execute("""SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND REFERENCED_TABLE_NAME IS NOT NULL + AND TABLE_NAME LIKE 'pbl\\_%'""") + n = cur.fetchone()[0] + log(n == 0, "T3_零外键", "information_schema 外键约束数=%d" % n) + + +def t_unique_key_tenant_first(cur): + """T4: 每个 UNIQUE 索引首列均为 tenant_key。""" + cur.execute("""SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'pbl\\_%' + AND NON_UNIQUE = 0 AND INDEX_NAME <> 'PRIMARY' + ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX""") + bad = [] + seen = {} + for t, ix, col, seq in cur.fetchall(): + if int(seq) == 1: + seen[(t, ix)] = col + for (t, ix), col in seen.items(): + if col != "tenant_key": + bad.append("%s.%s 首列=%s" % (t, ix, col)) + log(seen and not bad, "T4_唯一键tenant打头", + "唯一索引 %d 个,违规 %s" % (len(seen), bad or "无")) + + +def t_platform_template_null_tenant(cur): + """T5: 平台公共模板 tenant_id IS NULL 可插入,且 tenant_key='' 唯一去重生效。""" + cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code LIKE 'M1B_T5%%'") + cur.execute( + """INSERT INTO pbl_blueprint_template + (tenant_key, tenant_id, template_code, template_name, scope, status, + version, is_default, usage_count, created_at, updated_at) + VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""", + (PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板", NOW, NOW)) + cur.execute( + """SELECT id, tenant_id, scope FROM pbl_blueprint_template + WHERE tenant_key = %s AND template_code = 'M1B_T5_PLATFORM'""", + (PLATFORM_KEY,)) + row = cur.fetchone() + ok_null = row is not None and row[1] is None and row[2] == "platform" + + dup = False + try: + cur.execute( + """INSERT INTO pbl_blueprint_template + (tenant_key, tenant_id, template_code, template_name, scope, status, + version, is_default, usage_count, created_at, updated_at) + VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""", + (PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板重复", NOW, NOW)) + except Exception: + dup = True + log(ok_null and dup, "T5_平台模板NULL租户", + "tenant_id IS NULL 插入=%s;同 tenant_key+code 重复插入被唯一键拒绝=%s" + % (ok_null, dup)) + + +def t_tenant_isolation(cur): + """T6: 同 template_code 在不同租户下可共存(tenant_key 打头唯一键)。""" + cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code = 'M1B_T6_SAME'") + for tk, tid in ((TENANT_A, TENANT_A), (TENANT_B, TENANT_B)): + cur.execute( + """INSERT INTO pbl_blueprint_template + (tenant_key, tenant_id, template_code, template_name, scope, status, + version, is_default, usage_count, created_at, updated_at) + VALUES (%s, %s, 'M1B_T6_SAME', %s, 'tenant', 'draft', '1.0.0', 0, 0, %s, %s)""", + (tk, tid, "租户模板-" + tk, NOW, NOW)) + cur.execute("SELECT tenant_key FROM pbl_blueprint_template " + "WHERE template_code = 'M1B_T6_SAME' ORDER BY tenant_key") + got = [r[0] for r in cur.fetchall()] + log(got == [TENANT_A, TENANT_B], "T6_租户隔离", + "同 code 跨租户共存,tenant_key=%s" % got) + + +def t_subobject_ext_kv(cur): + """T7: 子对象扩展 KV 写入/读取/唯一键覆盖(基表零改动)。""" + cur.execute("DELETE FROM pbl_subobject_ext WHERE subobject_id LIKE 'M1B_T7%%'") + cur.execute( + """INSERT INTO pbl_subobject_ext + (tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id, + ext_key, ext_value, ext_value_type, source, status, created_at, updated_at) + VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ', + 'difficulty', '3', 'number', 'manual', 'active', %s, %s)""", + (TENANT_A, TENANT_A, NOW, NOW)) + cur.execute( + """SELECT ext_value, ext_value_type FROM pbl_subobject_ext + WHERE tenant_key = %s AND subobject_type = 'driving_question' + AND subobject_id = 'M1B_T7_OBJ' AND ext_key = 'difficulty'""", + (TENANT_A,)) + row = cur.fetchone() + ok_read = row is not None and row[0] == "3" and row[1] == "number" + + dup = False + try: + cur.execute( + """INSERT INTO pbl_subobject_ext + (tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id, + ext_key, ext_value, ext_value_type, source, status, created_at, updated_at) + VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ', + 'difficulty', '5', 'number', 'manual', 'active', %s, %s)""", + (TENANT_A, TENANT_A, NOW, NOW)) + except Exception: + dup = True + log(ok_read and dup, "T7_子对象扩展KV", + "读回=%s;同 (tenant,type,obj,key) 重复插入被拒=%s" % (ok_read, dup)) + + +def t_ext_field_def_platform_override(cur): + """T8: 平台公共字段定义 + 租户级覆盖(同 field_key 两条并存,读取顺序 tenant->platform)。""" + cur.execute("DELETE FROM pbl_ext_field_def WHERE field_key LIKE 'm1b_t8%%'") + cur.execute( + """INSERT INTO pbl_ext_field_def + (tenant_key, tenant_id, subobject_type, field_key, field_label, field_type, + required, scope, sort_no, status, created_at, updated_at) + VALUES ('', NULL, 'driving_question', 'm1b_t8_difficulty', '难度(平台)', + 'number', 0, 'platform', 10, 'active', %s, %s)""", (NOW, NOW)) + cur.execute( + """INSERT INTO pbl_ext_field_def + (tenant_key, tenant_id, subobject_type, field_key, field_label, field_type, + required, scope, sort_no, status, created_at, updated_at) + VALUES (%s, %s, 'driving_question', 'm1b_t8_difficulty', '难度(租户覆盖)', + 'number', 1, 'tenant', 10, 'active', %s, %s)""", + (TENANT_A, TENANT_A, NOW, NOW)) + cur.execute( + """SELECT tenant_key, field_label, required FROM pbl_ext_field_def + WHERE subobject_type = 'driving_question' AND field_key = 'm1b_t8_difficulty' + AND status = 'active' + ORDER BY CASE WHEN tenant_key = %s THEN 0 ELSE 1 END""", (TENANT_A,)) + rows = cur.fetchall() + ok = len(rows) == 2 and rows[0][0] == TENANT_A and int(rows[0][2]) == 1 + log(ok, "T8_字段定义平台+租户覆盖", + "命中 %d 条,优先级首条 tenant_key=%s required=%s" + % (len(rows), rows[0][0] if rows else None, rows[0][2] if rows else None)) + + +def t_blueprint_ref_softlink(cur): + """T9: 关联表软引用(ref_table/ref_id 字符串),且对基表零写入。""" + cur.execute("DELETE FROM pbl_blueprint_ref WHERE ref_id LIKE 'M1B_T9%%'") + before = _base_table_fingerprint(cur) + cur.execute( + """INSERT INTO pbl_blueprint_ref + (tenant_key, tenant_id, blueprint_id, ref_type, ref_table, ref_id, ref_name, + relation_kind, sort_no, status, created_at, updated_at) + VALUES (%s, %s, 900001, 'world', 'world', 'M1B_T9_WORLD', '测试世界', + 'reference', 1, 'active', %s, %s)""", + (TENANT_A, TENANT_A, NOW, NOW)) + cur.execute( + """SELECT ref_type, ref_table, ref_id, relation_kind FROM pbl_blueprint_ref + WHERE tenant_key = %s AND ref_id = 'M1B_T9_WORLD'""", (TENANT_A,)) + row = cur.fetchone() + after = _base_table_fingerprint(cur) + ok = (row is not None and row[0] == "world" and row[1] == "world" + and row[3] == "reference" and before == after) + log(ok, "T9_关联表软引用零基表写入", + "软引用行=%s;基表指纹前后一致=%s" % (row is not None, before == after)) + + +def _base_table_fingerprint(cur): + """基表存在性+行数指纹(若基表不存在则记为 absent,不影响判定)。""" + fp = {} + for t in ("world", "scene", "entity", "script"): + try: + cur.execute( + "SELECT COUNT(*) FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s", (t,)) + if cur.fetchone()[0] == 0: + fp[t] = "absent" + continue + cur.execute("SELECT COUNT(*) FROM `%s`" % t) + fp[t] = cur.fetchone()[0] + except Exception: + fp[t] = "error" + return json.dumps(fp, sort_keys=True) + + +def t_ref_unique_dedup(cur): + """T10: 同 (tenant, blueprint, ref_type, ref_id) 重复关联被唯一键拒绝。""" + dup = False + try: + cur.execute( + """INSERT INTO pbl_blueprint_ref + (tenant_key, tenant_id, blueprint_id, ref_type, ref_table, ref_id, + ref_name, relation_kind, sort_no, status, created_at, updated_at) + VALUES (%s, %s, 900001, 'world', 'world', 'M1B_T9_WORLD', '重复', + 'reference', 2, 'active', %s, %s)""", + (TENANT_A, TENANT_A, NOW, NOW)) + except Exception: + dup = True + log(dup, "T10_关联唯一去重", "重复关联被唯一键拒绝=%s" % dup) + + +def t_template_instantiate_payload(cur): + """T11: 模板实例化——payload 深拷贝到新蓝图关联条目(JSON 可解析)。""" + payload = json.dumps({"driving_question": [{"title": "如何减少碳排", "difficulty": 3}], + "artifact_def": [{"code": "A1"}]}, ensure_ascii=False) + cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code = 'M1B_T11_TPL'") + cur.execute( + """INSERT INTO pbl_blueprint_template + (tenant_key, tenant_id, template_code, template_name, scope, status, version, + is_default, usage_count, blueprint_payload, subobject_payload, ref_payload, + created_at, updated_at) + VALUES (%s, %s, 'M1B_T11_TPL', '实例化模板', 'tenant', 'published', '1.0.0', + 0, 0, %s, %s, %s, %s, %s)""", + (TENANT_A, TENANT_A, payload, payload, "[]", NOW, NOW)) + cur.execute("SELECT blueprint_payload, subobject_payload, usage_count " + "FROM pbl_blueprint_template WHERE tenant_key = %s " + "AND template_code = 'M1B_T11_TPL'", (TENANT_A,)) + bp, sp, usage = cur.fetchone() + ok_json = isinstance(json.loads(bp), dict) and isinstance(json.loads(sp), dict) + cur.execute("UPDATE pbl_blueprint_template SET usage_count = usage_count + 1, " + "updated_at = %s WHERE tenant_key = %s AND template_code = 'M1B_T11_TPL'", + (NOW, TENANT_A)) + cur.execute("SELECT usage_count FROM pbl_blueprint_template WHERE tenant_key = %s " + "AND template_code = 'M1B_T11_TPL'", (TENANT_A,)) + new_usage = cur.fetchone()[0] + log(ok_json and int(usage) == 0 and int(new_usage) == 1, "T11_模板实例化payload", + "payload JSON 可解析=%s;usage_count %s -> %s" % (ok_json, usage, new_usage)) + + +def t_cleanup(cur): + """T12: 清理测试数据(只删本测试写入的 pbl_ 行,绝不触碰基表)。""" + n = 0 + for sql in ( + "DELETE FROM pbl_blueprint_template WHERE template_code LIKE 'M1B_T%'", + "DELETE FROM pbl_subobject_ext WHERE subobject_id LIKE 'M1B_T%'", + "DELETE FROM pbl_ext_field_def WHERE field_key LIKE 'm1b_t8%'", + "DELETE FROM pbl_blueprint_ref WHERE ref_id LIKE 'M1B_T%'", + ): + rc = cur.execute(sql) # 只执行一次,返回受影响行数 + n += int(rc or 0) + log(True, "T12_测试数据清理", "清理 %d 行(仅 pbl_ 自有表,零基表操作)" % n) + + +CASES = [ + t_ddl_idempotent, t_tables_exist, t_no_foreign_key, t_unique_key_tenant_first, + t_platform_template_null_tenant, t_tenant_isolation, t_subobject_ext_kv, + t_ext_field_def_platform_override, t_blueprint_ref_softlink, t_ref_unique_dedup, + t_template_instantiate_payload, t_cleanup, +] + + +def main(): + print("=" * 72) + print("M1b 真实库集成测试 DDL=%s" % DDL_PATH) + print("=" * 72) + if not os.path.exists(DDL_PATH): + print("FATAL: 未找到 sql/m1b_ddl.sql") + return 2 + + try: + cfg = resolve_dsn() + conn, driver = connect(cfg) + except Exception as e: + msg = str(e) + print("-" * 72) + print("SKIPPED(环境受限,未连真实库):%s" % msg) + print("连接参数尝试:%s" % json.dumps( + {k: v for k, v in (resolve_dsn() or {}).items() if k != "password"}, + ensure_ascii=False)) + print("移交部署验证:本文件 12 个用例(T1~T12)须在部署环境执行," + "清单见 docs/M1b-qc-rework-evidence.md §5。") + print("复跑命令:PBL_TEST_DB_DSN=mysql://user:pass@host:3306/db " + "python3 tests/test_m1b_realdb.py") + for c in CASES: + skip(c.__name__, "环境受限未执行(不视为已完成自测)") + print("-" * 72) + print("结果:0 PASS / 0 FAIL / %d SKIP" % len(CASES)) + return 0 + + print("已连接真实库 driver=%s host=%s:%s db=%s" + % (driver, cfg["host"], cfg["port"], cfg["database"])) + print("-" * 72) + cur = conn.cursor() + failed = 0 + for case in CASES: + try: + case(cur) + except Exception as e: + failed += 1 + log(False, case.__name__, "异常 %s\n%s" % (e, traceback.format_exc())) + try: + cur.close() + conn.close() + except Exception: + pass + + total = len(RESULTS) + passed = sum(1 for _, ok, _ in RESULTS if ok is True) + fails = sum(1 for _, ok, _ in RESULTS if ok is False) + print("-" * 72) + print("结果:%d PASS / %d FAIL / 共 %d" % (passed, fails, total)) + for name, ok, detail in RESULTS: + if ok is False: + print(" FAIL %s :: %s" % (name, detail)) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_m1b_template.py b/tests/test_m1b_template.py index 5bf104c..c21e4e4 100644 --- a/tests/test_m1b_template.py +++ b/tests/test_m1b_template.py @@ -21,6 +21,7 @@ import os import sys import unittest +import _m1b_loader # noqa: F401 (空壳包引导,绕开 M1a 挂载链 eager import) sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) from pbl_blueprint.models.pbl_template import ( # noqa: E402 diff --git a/tools/m1b_normalize_models.py b/tools/m1b_normalize_models.py new file mode 100644 index 0000000..198cbc6 --- /dev/null +++ b/tools/m1b_normalize_models.py @@ -0,0 +1,370 @@ +# -*- coding: utf-8 -*- +"""M1b 表定义规范化工具:把 models/m1b/*.json 归一到 database-table-definition-spec 四段式。 + +背景(QC 退回意见 #8):M1b 首版 models/m1b/*.json 用了「summary 为长字符串 + +fields 用 varchar/bigint/longtext 具体类型 + codes 为 dict」的自创格式,既不符合 +database-table-definition-spec(四段式必须都是**数组**、type 必须是**抽象类型**), +也让 tools/m1b_selfcheck.py 的 B2/B3/B4/B5 检查直接抛 AttributeError。 + +本脚本做三件事,全部以 sql/m1b_ddl.sql 为唯一权威来源(保证 models↔DDL 一一对应): + +1. 解析 DDL → {table: {cols:[...], indexes:{name:(cols,unique)}, defaults:{col:default}}} +2. 把 DDL 的具体类型映射为规范抽象类型(varchar→str / bigint→long / longtext→text ...) +3. 生成规范四段式: + - summary : [{"name","title","primary":["id"],"catelog","comment"}] + - fields : [{"name","title","type","length","dec","nullable","default","comment"}] + - indexes : [{"name","idxtype":"unique|index","idxfields":[...]}] (PRIMARY 归 summary.primary) + - codes : [{"field","table":"appcodes_kv","valuefield":"k","textfield":"v", + "cond":"parentid='<字典组>'"}] (cond 必须 parentid=) + +旧文件里的中文 label / comment / 字典组名会被完整继承(不丢设计信息), +字典组名从旧 fields[].comment 的「字典 pbl_xxx」中提取。 + +用法: + python3 tools/m1b_normalize_models.py # 就地重写 models/m1b/*.json + python3 tools/m1b_normalize_models.py --check # 只校验不改写,违规 rc=1 +""" + +import json +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DDL = os.path.join(REPO, "sql", "m1b_ddl.sql") +MDIR = os.path.join(REPO, "pbl_blueprint", "models", "m1b") + +# 表中文名(summary[0].title)与分类(catelog) +TABLE_META = { + "pbl_blueprint_template": ("PBL蓝图模板", "entity"), + "pbl_blueprint_ref": ("PBL蓝图外部关联", "relation"), + "pbl_subobject_ext": ("PBL子对象扩展属性", "entity"), + "pbl_ext_field_def": ("PBL扩展字段定义", "dimession"), +} + +# DDL 具体类型 → 规范抽象类型(database-table-definition-spec 支持表) +TYPE_MAP = { + "varchar": "str", + "char": "char", + "tinytext": "text", + "text": "text", + "mediumtext": "text", + "longtext": "text", + "json": "text", + "tinyint": "short", + "smallint": "short", + "mediumint": "int", + "int": "int", + "integer": "int", + "bigint": "long", + "float": "float", + "double": "double", + "decimal": "double", + "numeric": "double", + "date": "date", + "time": "time", + "datetime": "datetime", + "timestamp": "timestamp", + "blob": "bin", + "longblob": "bin", + "varbinary": "bin", + "binary": "bin", +} + +# 需要 length 的抽象类型 +NEED_LENGTH = {"str", "char"} +# 需要 length + dec 的抽象类型 +NEED_DEC = {"float", "double", "ddouble", "decimal"} + + +def read_ddl(): + with open(DDL, "r", encoding="utf-8") as f: + return f.read() + + +def parse_ddl(body): + """解析 DDL → {table: {"cols":[(name,sqltype,len,dec,notnull,default)], + "indexes":{name:(cols,unique)}, "pk":[cols]}}""" + out = {} + pat = re.compile( + r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(\w+)`?\s*\((.*?)\n\)\s*ENGINE[^;]*;", + re.I | re.S) + for m in pat.finditer(body): + tname, inner = m.group(1), m.group(2) + cols, idx, pk = [], {}, [] + for raw in inner.splitlines(): + s = raw.strip().rstrip(",").strip() + if not s or s.startswith("--"): + continue + up = s.upper() + if up.startswith("PRIMARY KEY"): + pm = re.search(r"\(([^)]*)\)", s) + if pm: + pk = [c.strip().strip("`") for c in pm.group(1).split(",")] + continue + if up.startswith("UNIQUE KEY") or up.startswith("KEY ") \ + or up.startswith("INDEX ") or up.startswith("UNIQUE INDEX"): + nm = re.match( + r"(?:UNIQUE\s+)?(?:KEY|INDEX)\s+`?(\w+)`?\s*\(([^)]*)\)", s, re.I) + if nm: + cs = [c.strip().strip("`") for c in nm.group(2).split(",")] + idx[nm.group(1)] = (cs, up.startswith("UNIQUE")) + continue + if up.startswith("CONSTRAINT") or up.startswith("FOREIGN KEY"): + continue + cm = re.match(r"`(\w+)`\s+([A-Za-z]+)\s*(\(([^)]*)\))?(.*)$", s) + if not cm: + continue + name, sqltype, _paren, args, tail = cm.groups() + sqltype = sqltype.lower() + ln, dec = None, None + if args: + parts = [x.strip() for x in args.split(",")] + try: + ln = int(parts[0]) + except ValueError: + ln = None + if len(parts) > 1: + try: + dec = int(parts[1]) + except ValueError: + dec = None + notnull = "NOT NULL" in tail.upper() + dm = re.search(r"DEFAULT\s+('(?:[^']*)'|NULL|[\w.+-]+)", tail, re.I) + default = None + if dm: + dv = dm.group(1) + if dv.upper() == "NULL": + default = None + elif dv.startswith("'") and dv.endswith("'"): + default = dv[1:-1] + else: + try: + default = int(dv) + except ValueError: + default = dv + cols.append((name, sqltype, ln, dec, notnull, default)) + out[tname] = {"cols": cols, "indexes": idx, "pk": pk or ["id"]} + return out + + +def abstract_type(sqltype, ln, dec): + """DDL 具体类型 → (抽象类型, length, dec);未知类型抛错,禁止静默降级。""" + at = TYPE_MAP.get(sqltype) + if at is None: + raise ValueError("未登记的 DDL 类型:%s(请在 TYPE_MAP 中补映射)" % sqltype) + length = None + d = None + if at in NEED_LENGTH: + if not ln or ln <= 0: + raise ValueError("抽象类型 %s 必须有正整数 length(DDL 类型 %s)" % (at, sqltype)) + length = int(ln) + if at in NEED_DEC: + length = int(ln) if ln else 18 + d = int(dec) if dec else 2 + return at, length, d + + +def load_legacy(table): + """读旧 models 文件,返回 {col: {"title":..,"comment":..,"dict":..}} 供继承。""" + fp = os.path.join(MDIR, table + ".json") + info = {} + if not os.path.isfile(fp): + return info + try: + with open(fp, "r", encoding="utf-8") as f: + d = json.load(f) + except Exception: + return info + fields = d.get("fields") + if isinstance(fields, dict): # M1a 风格:fields 为 dict + items = [(k, v) for k, v in fields.items()] + elif isinstance(fields, list): # M1b 首版:fields 为 list + items = [(x.get("name"), x) for x in fields if isinstance(x, dict)] + else: + items = [] + for name, fd in items: + if not name: + continue + title = fd.get("label") or fd.get("title") or name + comment = fd.get("comment") or fd.get("summary") or "" + dm = re.search(r"字典\s+([A-Za-z_][\w]*)", comment) + info[name] = {"title": title, "comment": comment, + "dict": dm.group(1) if dm else None} + return info + + +def build_model(table, spec): + legacy = load_legacy(table) + title, catelog = TABLE_META.get(table, (table, "entity")) + + fields = [] + for (name, sqltype, ln, dec, notnull, default) in spec["cols"]: + at, length, d = abstract_type(sqltype, ln, dec) + lg = legacy.get(name, {}) + fd = { + "name": name, + "title": lg.get("title") or name, + "type": at, + } + if length is not None: + fd["length"] = length + if d is not None: + fd["dec"] = d + if notnull: + fd["nullable"] = "no" + if default is not None and default != "": + fd["default"] = default + elif default == "": + fd["default"] = "" + if lg.get("comment"): + fd["comment"] = lg["comment"] + fields.append(fd) + + indexes = [] + for iname in sorted(spec["indexes"]): + cols, uniq = spec["indexes"][iname] + indexes.append({ + "name": iname, + "idxtype": "unique" if uniq else "index", + "idxfields": list(cols), + }) + + codes = [] + seen = set() + for fd in fields: + grp = legacy.get(fd["name"], {}).get("dict") + if not grp or fd["name"] in seen: + continue + seen.add(fd["name"]) + codes.append({ + "field": fd["name"], + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='%s'" % grp, + }) + + return { + "summary": [{ + "name": table, + "title": title, + "primary": list(spec["pk"]), + "catelog": catelog, + "comment": "M1b 表;索引一律 tenant_key 打头(tenant_key=COALESCE(tenant_id," + "'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算);无 FOREIGN KEY;" + "对 world/scene/entity/script 等复用域基表零改动(Q-OPEN-3)。", + }], + "fields": fields, + "indexes": indexes, + "codes": codes, + } + + +def validate(model, table, spec): + """规范符合性自查,返回问题清单(空=通过)。""" + bad = [] + for seg in ("summary", "fields", "indexes", "codes"): + if not isinstance(model.get(seg), list): + bad.append("%s: %s 不是数组" % (table, seg)) + sm = model.get("summary") or [] + if not (sm and isinstance(sm[0], dict)): + bad.append("%s: summary[0] 缺失" % table) + else: + if sm[0].get("name") != table: + bad.append("%s: summary[0].name=%r != 表名" % (table, sm[0].get("name"))) + if not isinstance(sm[0].get("primary"), list) or not sm[0].get("primary"): + bad.append("%s: summary[0].primary 必须是非空数组" % table) + cols_ddl = [c[0] for c in spec["cols"]] + cols_model = [f.get("name") for f in model.get("fields", [])] + if cols_ddl != cols_model: + bad.append("%s: 字段顺序/集合与 DDL 不一致 仅DDL=%s 仅models=%s" + % (table, sorted(set(cols_ddl) - set(cols_model)), + sorted(set(cols_model) - set(cols_ddl)))) + for f in model.get("fields", []): + t = f.get("type") + if t not in TYPE_MAP.values(): + bad.append("%s.%s: 非法抽象类型 %r" % (table, f.get("name"), t)) + if t in NEED_LENGTH and not (isinstance(f.get("length"), int) and f["length"] > 0): + bad.append("%s.%s: %s 缺正整数 length" % (table, f.get("name"), t)) + if t in NEED_DEC: + if not (isinstance(f.get("length"), int) and f["length"] > 0): + bad.append("%s.%s: %s 缺 length" % (table, f.get("name"), t)) + if not (isinstance(f.get("dec"), int) and f["dec"] > 0): + bad.append("%s.%s: %s 缺 dec" % (table, f.get("name"), t)) + if f.get("name") == "id" and not (t == "str" and f.get("length", 0) >= 32 + or t == "long"): + bad.append("%s.id: 主键类型异常 %r" % (table, t)) + idx_model = {i.get("name"): (list(i.get("idxfields") or []), i.get("idxtype")) + for i in model.get("indexes", [])} + idx_ddl = {k: (list(v[0]), "unique" if v[1] else "index") + for k, v in spec["indexes"].items()} + if sorted(idx_model) != sorted(idx_ddl): + bad.append("%s: 索引名与 DDL 不一致 仅models=%s 仅DDL=%s" + % (table, sorted(set(idx_model) - set(idx_ddl)), + sorted(set(idx_ddl) - set(idx_model)))) + for k in idx_model: + if k in idx_ddl and idx_model[k] != idx_ddl[k]: + bad.append("%s.%s: 索引定义不一致 models=%s DDL=%s" + % (table, k, idx_model[k], idx_ddl[k])) + if not isinstance(idx_model[k][0], list) or not idx_model[k][0]: + bad.append("%s.%s: idxfields 必须是非空数组" % (table, k)) + if idx_model[k][0] and idx_model[k][0][0] != "tenant_key": + bad.append("%s.%s: 索引首列不是 tenant_key(%s)" + % (table, k, idx_model[k][0][0])) + for c in model.get("codes", []): + if c.get("table") == "appcodes_kv" and "parentid=" not in (c.get("cond") or ""): + bad.append("%s.codes[%s]: appcodes_kv 的 cond 必须用 parentid=" + % (table, c.get("field"))) + if "." in (c.get("table") or ""): + bad.append("%s.codes[%s]: table 禁用 module.table 点号写法" + % (table, c.get("field"))) + flds = [c.get("field") for c in model.get("codes", [])] + if len(flds) != len(set(flds)): + bad.append("%s: codes 存在重复 field(会触发 Duplicate column name)" % table) + return bad + + +def main(): + check_only = "--check" in sys.argv + body = read_ddl() + ddl = parse_ddl(body) + expect = sorted(TABLE_META) + if sorted(ddl) != expect: + print("FAIL DDL 表集合=%s 期望=%s" % (sorted(ddl), expect)) + return 1 + if not os.path.isdir(MDIR): + os.makedirs(MDIR) + all_bad = [] + for t in expect: + model = build_model(t, ddl[t]) + all_bad += validate(model, t, ddl[t]) + fp = os.path.join(MDIR, t + ".json") + if check_only: + old = None + if os.path.isfile(fp): + try: + with open(fp, "r", encoding="utf-8") as f: + old = json.load(f) + except Exception: + old = None + if old != model: + all_bad.append("%s: 磁盘文件与规范四段式不一致(需重写)" % t) + else: + with open(fp, "w", encoding="utf-8") as f: + json.dump(model, f, ensure_ascii=False, indent=2) + f.write("\n") + print("WROTE %s fields=%d indexes=%d codes=%d" + % (os.path.relpath(fp, REPO), len(model["fields"]), + len(model["indexes"]), len(model["codes"]))) + if all_bad: + print("VALIDATE_FAIL %d 项:" % len(all_bad)) + for b in all_bad: + print(" -", b) + return 1 + print("VALIDATE_OK 4 表 models 四段式全部符合 database-table-definition-spec") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/m1b_selfcheck.py b/tools/m1b_selfcheck.py new file mode 100644 index 0000000..eec34da --- /dev/null +++ b/tools/m1b_selfcheck.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""M1b 内容级自检工具(QC 取证用)。 + +用途:把 QC 退回意见 #3/#4/#6 要求的「内容级核查」变成可重复执行、可留证据的 +机械检查。运行: + + cd modules/pbl_blueprint && python3 tools/m1b_selfcheck.py + +退出码 0 = 全部通过;非 0 = 有 FAIL 项(打印明细)。 + +检查项: + A. DDL 合规(sql/m1b_ddl.sql) + A1 恰好 4 条 CREATE TABLE IF NOT EXISTS,且为 4 张目标表 + A2 零 FOREIGN KEY + A3 零 PostgreSQL 方言(BIGSERIAL / nextval / SERIAL) + A4 零对既有基表的 DDL/DML 动词(ALTER/UPDATE/DELETE/DROP/TRUNCATE/INSERT) + A5 每个 UNIQUE KEY 首列为 tenant_key(规避 NULL 不去重) + A6 mariadb 方言要素齐备(InnoDB + utf8mb4 + AUTO_INCREMENT) + B. 表模型合规(models/m1b/*.json) + B1 4 个文件均可 json.load + B2 四段式:summary(数组,含 primary) / fields(数组) / indexes(数组) / codes(数组) + B3 models 与 DDL 表名一一对应(双向) + B4 models 字段集合与 DDL 列集合一一对应(双向,逐表) + B5 models 索引集合与 DDL 索引集合一一对应(逐表,含 unique 标记) + C. CRUD 定义合规(json/m1b/*.json) + C1 4 个文件均可 json.load + C2 含 tblname 且指向本模块 4 表之一 + C3 含 params.editable(QC#3 指出的格式偏差) + C4 new/update/delete_data_url 在 params 顶层(非仅嵌套在 editable 内) + C5 json/ 下不含表定义特征键(summary/fields/indexes)——即 json/ 只放 CRUD + D. Python 源码内容级核查(pbl_blueprint/m1b_*.py) + D1 零硬编码库名(无 DBNAME = 'xxx' / dbname = 'xxx' 字面量赋值) + D2 取库名走 get_module_dbname + D3 只用 sqlor 标准 API(sor.C/U/D/R/I/sqlExe/sqlPaging),无编造 API + D4 全部 py_compile 通过 + D5 每个 .py 有效语句数 > 0(非空壳) + E. 三处同步注册核验(QC#4) + E1 m1b_api.py 定义 register_m1b_routes + E2 __init__.py 导出 register_m1b_routes(import 行) + E3 init.py 在 load_pbl_blueprint 内引用 register_m1b_routes(env 注册/调用) + E4 M1b 每个对外函数在三处齐备(实现 / __init__ 导出 / init.py 注册) + F. M1a 既有代码零越权改动(QC#6) + F1 git diff 中 M1a 文件(非 m1b_* / 非 models|json|sql|tests|docs/tools 的 + 既有 .py)只允许「追加式」改动:新增行不得删除既有 M1a 函数定义行 +""" + +import ast +import json +import os +import re +import subprocess +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +M1B_TABLES = [ + "pbl_blueprint_template", + "pbl_blueprint_ref", + "pbl_subobject_ext", + "pbl_ext_field_def", +] + +DDL_REL = os.path.join("sql", "m1b_ddl.sql") +MODELS_REL = os.path.join("pbl_blueprint", "models", "m1b") +JSON_REL = os.path.join("pbl_blueprint", "json", "m1b") + +# 编造的 sqlor API(module-development-spec 明确:只有 C/U/D/R/I/sqlExe) +FAKE_SQLOR = [ + "sqlor.save", "sor.save", "sqlor.list", "sor.list", "sqlor.one", "sor.one", + "sqlor.delete", "sor.delete", "sqlor.insert", "sor.insert", "sqlor.query", + "sor.query", "sqlor.update", "sor.update", "sqlor.create", "sor.create", + "sqlor.select", "sor.select", "sqlor.find", "sor.find", "sqlor.get", + "sor.get_one", "sor.fetch", "sqlor.fetch", +] + +# 允许的 sqlor 调用(白名单,用于 D3 正向取证) +OK_SQLOR = re.compile(r"\bsor\.(C|U|D|R|I|sqlExe|sqlPaging|sqlorContext)\s*\(") + +results = [] + + +def record(check_id, ok, detail): + results.append((check_id, bool(ok), detail)) + flag = "PASS" if ok else "FAIL" + print("[%s] %s :: %s" % (flag, check_id, detail)) + + +def p(*parts): + return os.path.join(REPO, *parts) + + +def read(rel): + with open(p(rel), "r", encoding="utf-8") as f: + return f.read() + + +def strip_sql_comments(sql): + """去掉 -- 行注释与 /* */ 块注释,避免注释文本干扰动词扫描。""" + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S) + out = [] + for line in sql.splitlines(): + idx = line.find("--") + if idx >= 0: + line = line[:idx] + out.append(line) + return "\n".join(out) + + +# ---------------------------------------------------------------- A. DDL +def check_ddl(): + if not os.path.exists(p(DDL_REL)): + record("A0", False, "sql/m1b_ddl.sql 不存在") + return None + raw = read(DDL_REL) + body = strip_sql_comments(raw) + + creates = re.findall( + r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(\w+)`?", body, flags=re.I) + record("A1", len(creates) == 4 and sorted(creates) == sorted(M1B_TABLES), + "CREATE TABLE IF NOT EXISTS 共 %d 条,表=%s" % (len(creates), creates)) + + fk = len(re.findall(r"FOREIGN\s+KEY", body, flags=re.I)) + record("A2", fk == 0, "FOREIGN KEY 出现 %d 次(应为 0)" % fk) + + pg = re.findall(r"\b(BIGSERIAL|SERIAL|nextval)\b", body, flags=re.I) + record("A3", not pg, "PostgreSQL 方言命中 %s(应为空)" % (pg or "无")) + + # A4:任何针对既有基表的 DDL/DML 动词;同时全文(去注释)不得出现这些动词 + verbs = re.findall( + r"\b(ALTER|UPDATE|DELETE|DROP|TRUNCATE|INSERT|REPLACE)\b", body, flags=re.I) + base_hits = re.findall( + r"\b(CREATE|ALTER|UPDATE|DELETE|DROP|TRUNCATE|INSERT)\b[^;]{0,120}?" + r"\b(world|scene|entity|script|world_snapshot|scense)\b", + body, flags=re.I) + record("A4", not verbs and not base_hits, + "去注释后 DDL/DML 动词命中 %s;基表语句命中 %s(均应为空)" + % (sorted(set(v.upper() for v in verbs)) or "无", base_hits or "无")) + + # A5:每个 UNIQUE KEY 首列必须是 tenant_key + bad_uk = [] + uks = re.findall(r"UNIQUE\s+KEY\s+`?(\w+)`?\s*\(([^)]*)\)", body, flags=re.I) + for name, cols in uks: + first = cols.split(",")[0].strip().strip("`").strip() + if first != "tenant_key": + bad_uk.append("%s(首列=%s)" % (name, first)) + record("A5", uks and not bad_uk, + "UNIQUE KEY 共 %d 个,首列非 tenant_key 的:%s" % (len(uks), bad_uk or "无")) + + need = ["ENGINE=InnoDB", "utf8mb4", "AUTO_INCREMENT"] + miss = [n for n in need if n.lower() not in body.lower()] + record("A6", not miss, "mariadb 方言要素缺失 %s(应为空)" % (miss or "无")) + + return body + + +# ------------------------------------------------------- B. models 四段式 +def parse_ddl_tables(body): + """从 DDL 正文解析 {table: {"cols": [...], "indexes": {name: (cols, unique)}}}""" + tables = {} + for m in re.finditer( + r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(\w+)`?\s*\((.*?)\n\)\s*ENGINE", + body, flags=re.I | re.S): + tname, inner = m.group(1), m.group(2) + cols, idx = [], {} + for line in inner.splitlines(): + s = line.strip().rstrip(",").strip() + if not s: + continue + up = s.upper() + if up.startswith("PRIMARY KEY"): + continue + if up.startswith("UNIQUE KEY") or up.startswith("KEY ") or up.startswith("INDEX "): + nm = re.match(r"(?:UNIQUE\s+)?(?:KEY|INDEX)\s+`?(\w+)`?\s*\(([^)]*)\)", + s, flags=re.I) + if nm: + cs = [c.strip().strip("`") for c in nm.group(2).split(",")] + idx[nm.group(1)] = (cs, up.startswith("UNIQUE")) + continue + cm = re.match(r"`(\w+)`", s) + if cm: + cols.append(cm.group(1)) + tables[tname] = {"cols": cols, "indexes": idx} + return tables + + +def check_models(ddl_tables): + mdir = p(MODELS_REL) + if not os.path.isdir(mdir): + record("B1", False, "models/m1b/ 目录不存在") + return + files = sorted(f for f in os.listdir(mdir) if f.endswith(".json")) + record("B1", len(files) == 4, "models/m1b/ JSON 文件 %d 个:%s" % (len(files), files)) + + model_tables = {} + four_ok, four_bad = True, [] + for fn in files: + try: + with open(os.path.join(mdir, fn), "r", encoding="utf-8") as f: + d = json.load(f) + except Exception as e: + four_ok = False + four_bad.append("%s: json.load 失败 %s" % (fn, e)) + continue + for seg in ("summary", "fields", "indexes", "codes"): + if not isinstance(d.get(seg), list): + four_ok = False + four_bad.append("%s: 缺 %s 数组" % (fn, seg)) + sm = d.get("summary") or [] + if not (sm and isinstance(sm[0], dict) and sm[0].get("primary")): + four_ok = False + four_bad.append("%s: summary[0].primary 缺失" % fn) + if sm: + model_tables[sm[0].get("table")] = d + record("B2", four_ok, "四段式(summary/fields/indexes/codes) 违规:%s" % (four_bad or "无")) + + only_model = sorted(set(model_tables) - set(ddl_tables)) + only_ddl = sorted(set(ddl_tables) - set(model_tables)) + record("B3", not only_model and not only_ddl, + "models↔DDL 表名对应:仅 models 有 %s,仅 DDL 有 %s(均应为空)" + % (only_model or "无", only_ddl or "无")) + + col_bad, idx_bad = [], [] + for t, d in model_tables.items(): + if t not in ddl_tables: + continue + mcols = [f.get("name") for f in d.get("fields", [])] + dcols = ddl_tables[t]["cols"] + if sorted(mcols) != sorted(dcols): + col_bad.append("%s: 仅models=%s 仅DDL=%s" + % (t, sorted(set(mcols) - set(dcols)), + sorted(set(dcols) - set(mcols)))) + midx = {} + for ix in d.get("indexes", []): + midx[ix.get("name")] = (ix.get("fields"), bool(ix.get("unique"))) + didx = ddl_tables[t]["indexes"] + if sorted(midx) != sorted(didx): + idx_bad.append("%s: 索引名不一致 仅models=%s 仅DDL=%s" + % (t, sorted(set(midx) - set(didx)), + sorted(set(didx) - set(midx)))) + else: + for k in midx: + if list(midx[k][0]) != list(didx[k][0]) or midx[k][1] != didx[k][1]: + idx_bad.append("%s.%s: models=%s DDL=%s" % (t, k, midx[k], didx[k])) + record("B4", not col_bad, "models↔DDL 列一一对应违规:%s" % (col_bad or "无")) + record("B5", not idx_bad, "models↔DDL 索引一一对应违规:%s" % (idx_bad or "无")) + + +# ---------------------------------------------------------- C. json CRUD +def check_crud(): + jdir = p(JSON_REL) + if not os.path.isdir(jdir): + record("C1", False, "json/m1b/ 目录不存在") + return + files = sorted(f for f in os.listdir(jdir) if f.endswith(".json")) + record("C1", len(files) == 4, "json/m1b/ JSON 文件 %d 个:%s" % (len(files), files)) + + bad_tbl, bad_edit, bad_url, bad_model = [], [], [], [] + for fn in files: + try: + with open(os.path.join(jdir, fn), "r", encoding="utf-8") as f: + d = json.load(f) + except Exception as e: + bad_tbl.append("%s: json.load 失败 %s" % (fn, e)) + continue + if d.get("tblname") not in M1B_TABLES: + bad_tbl.append("%s: tblname=%r" % (fn, d.get("tblname"))) + prm = d.get("params") or {} + if not isinstance(prm.get("editable"), dict): + bad_edit.append("%s: 缺 params.editable" % fn) + for k in ("new_data_url", "update_data_url", "delete_data_url"): + if not prm.get(k): + bad_url.append("%s: params 顶层缺 %s" % (fn, k)) + for seg in ("summary", "fields", "indexes"): + if seg in d: + bad_model.append("%s: 含表定义段 %s(json/ 只放 CRUD)" % (fn, seg)) + record("C2", not bad_tbl, "tblname 违规:%s" % (bad_tbl or "无")) + record("C3", not bad_edit, "params.editable 违规:%s" % (bad_edit or "无")) + record("C4", not bad_url, "params 顶层 *_data_url 违规:%s" % (bad_url or "无")) + record("C5", not bad_model, "json/ 混入表定义:%s" % (bad_model or "无")) + + +# ------------------------------------------------------- D. Python 源码 +def m1b_py_files(): + pkg = p("pbl_blueprint") + return sorted(f for f in os.listdir(pkg) + if f.startswith("m1b_") and f.endswith(".py")) + + +def check_python(): + files = m1b_py_files() + if not files: + record("D1", False, "未找到 pbl_blueprint/m1b_*.py") + return {} + + hard, no_dbname, fake, compile_bad, empty = [], [], [], [], [] + funcs = {} + hard_re = re.compile( + r"""^\s*(?:DBNAME|dbname|db_name|DATABASE|database)\s*=\s*['"][^'"]+['"]""", + re.M) + for fn in files: + src = read(os.path.join("pbl_blueprint", fn)) + if hard_re.search(src): + hard.append(fn) + if "get_module_dbname" not in src and "dbname" in src.lower(): + no_dbname.append(fn) + for api in FAKE_SQLOR: + if api in src: + fake.append("%s: %s" % (fn, api)) + try: + compile(src, fn, "exec") + except SyntaxError as e: + compile_bad.append("%s: %s" % (fn, e)) + try: + tree = ast.parse(src) + except SyntaxError: + continue + stmts = [n for n in ast.walk(tree) + if isinstance(n, ast.stmt) and not isinstance(n, (ast.Pass,))] + if len(stmts) < 3: + empty.append("%s: 有效语句 %d" % (fn, len(stmts))) + for n in tree.body: + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)): + funcs.setdefault(n.name, []).append(fn) + + record("D1", not hard, "硬编码库名文件:%s(应为空)" % (hard or "无")) + record("D2", not no_dbname, "未走 get_module_dbname 的文件:%s" % (no_dbname or "无")) + record("D3", not fake, "编造 sqlor API:%s(应为空;白名单 sor.C/U/D/R/I/sqlExe/sqlPaging)" + % (fake or "无")) + record("D4", not compile_bad, "py_compile 失败:%s" % (compile_bad or "无")) + record("D5", not empty, "疑似空壳文件:%s" % (empty or "无")) + return funcs + + +# --------------------------------------------------- E. 三处同步注册 +def check_registration(funcs): + api = read(os.path.join("pbl_blueprint", "m1b_api.py")) if os.path.exists( + p("pbl_blueprint", "m1b_api.py")) else "" + initpkg = read(os.path.join("pbl_blueprint", "__init__.py")) + initpy = read(os.path.join("pbl_blueprint", "init.py")) + + record("E1", "def register_m1b_routes" in api, + "m1b_api.py 定义 register_m1b_routes:%s" + % ("是" if "def register_m1b_routes" in api else "否")) + record("E2", "register_m1b_routes" in initpkg, + "__init__.py 导出 register_m1b_routes:%s" + % ("是" if "register_m1b_routes" in initpkg else "否")) + record("E3", "register_m1b_routes" in initpy, + "init.py 引用/注册 register_m1b_routes:%s" + % ("是" if "register_m1b_routes" in initpy else "否")) + + # E4:M1b 对外函数(m1b_*.py 顶层定义)三处齐备 + # ① 实现:m1b_*.py 中 def/async def + # ② 导出:__init__.py 出现该名(from .m1b_x import ... 或 __all__) + # ③ 注册:init.py 出现该名(import + env.xxx= 赋值 或 直接调用) + PUBLIC_PREFIX = ("m1b_", "init_m1b", "register_m1b", "ensure_m1b", + "load_m1b", "seed_platform", "ensure_platform") + public = sorted(n for n in funcs if n.startswith(PUBLIC_PREFIX)) + miss_exp = [n for n in public if not re.search(r"\b%s\b" % re.escape(n), initpkg)] + miss_reg = [n for n in public if not re.search(r"\b%s\b" % re.escape(n), initpy)] + # 注册形态取证:env.xxx = 赋值 或 函数调用 或 import 行 + reg_form = {} + for n in public: + forms = [] + if re.search(r"env\.%s\s*=" % re.escape(n), initpy): + forms.append("env赋值") + if re.search(r'["\']%s["\']' % re.escape(n), initpy) and \ + re.search(r"M1B_ENV_EXPORTS|setattr\(env", initpy): + forms.append("M1B_ENV_EXPORTS声明式env注册") + if re.search(r"\b%s\s*\(" % re.escape(n), initpy): + forms.append("调用") + if re.search(r"^\s*from\s+\.m1b_\w*\s+import[^\n]*\b%s\b" % re.escape(n), + initpy, re.M) or re.search(r"^\s*from\s+\.\s+import[^\n]*\b%s\b" + % re.escape(n), initpy, re.M): + forms.append("import") + reg_form[n] = forms or ["未注册"] + record("E4", not miss_exp and not miss_reg, + "M1b 对外函数 %d 个;__init__ 未导出 %s;init.py 未注册 %s" + % (len(public), miss_exp or "无", miss_reg or "无")) + for n in public: + record("E4.%s" % n, reg_form[n] != ["未注册"], + "init.py 注册形态=%s" % "/".join(reg_form[n])) + + +def check_m1a_untouched(): + """F1/F2:M1b 改动不得越权破坏 M1a 既有代码。 + + 界定: + * M1a 业务文件 = pbl_blueprint/ 下非 m1b_* 的 .py,且排除共享入口 + __init__.py / init.py(这两个文件是 module-development-spec 规定的 + 「三处同步」注册点,M1b 必须在此追加导出/注册,属合规追加而非越权)。 + * 判定:M1a 业务文件不得被修改/删除(git diff 无 M/D); + 共享入口只允许「追加式」改动——既有 def 行零删除。 + """ + SHARED = ("__init__.py", "init.py") + try: + out = subprocess.run(["git", "diff", "--name-status", "HEAD", "--", "pbl_blueprint/"], + cwd=REPO, capture_output=True, text=True, timeout=30) + lines = [l for l in out.stdout.splitlines() if l.strip()] + except Exception as e: + lines = [] + print(" (git diff 不可用:%s)" % e) + + m1a_biz_bad, shared_del = [], [] + for l in lines: + parts = l.split("\t") + if len(parts) < 2: + continue + st, path = parts[0], parts[-1] + base = os.path.basename(path) + if base.startswith("m1b_") or "/m1b/" in path or not base.endswith(".py"): + continue + if base in SHARED: + # 共享入口:只查「既有 def 行是否被删除」 + try: + d = subprocess.run(["git", "diff", "-U0", "HEAD", "--", path], + cwd=REPO, capture_output=True, text=True, timeout=30) + removed_defs = [x for x in d.stdout.splitlines() + if x.startswith("-") and not x.startswith("---") + and re.match(r"-\s*(async\s+)?def\s+\w+", x)] + if removed_defs: + shared_del.append("%s 删除既有函数 %d 个: %s" + % (base, len(removed_defs), removed_defs[:3])) + except Exception: + pass + continue + if st.startswith("M") or st.startswith("D"): + m1a_biz_bad.append("%s %s" % (st, path)) + + # F2 静态核验:M1a 关键入口与 11 表定义仍在 + initpy = read(os.path.join("pbl_blueprint", "init.py")) + initpkg = read(os.path.join("pbl_blueprint", "__init__.py")) + m1a_ok = ("load_pbl_blueprint" in initpy and "load_pbl_blueprint" in initpkg + and "TABLE_NAMES" in initpy) + record("F1", not m1a_biz_bad and not shared_del and m1a_ok, + "M1a 业务文件被改/删:%s;共享入口删除既有函数:%s;" + "load_pbl_blueprint+TABLE_NAMES 完好:%s" + % (m1a_biz_bad or "无", shared_del or "无", m1a_ok)) + + +def main(): + print("=" * 72) + print("M1b 内容级自检 repo=%s" % REPO) + print("=" * 72) + body = check_ddl() + ddl_tables = parse_ddl_tables(body) if body else {} + check_models(ddl_tables) + check_crud() + funcs = check_python() + check_registration(funcs) + check_m1a_untouched() + + total = len(results) + passed = sum(1 for _, ok, _ in results if ok) + print("-" * 72) + print("结果:%d/%d PASS" % (passed, total)) + for cid, ok, detail in results: + if not ok: + print(" FAIL %s :: %s" % (cid, detail)) + print("-" * 72) + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main())