deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-16 19:36:42 +08:00
parent b6285dd6b5
commit d52c201dd4
13 changed files with 2212 additions and 376 deletions

View File

@ -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",

View File

@ -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 函数报 NameErrormodule-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}

View File

@ -1,53 +1,161 @@
{
"summary": "PBL 蓝图外部关联表M1b——Q-OPEN-3 裁决的物理落地。裁决结论:『不改 world 表』(含 scene / entity / script 等复用域基表一律零改动、零加列、零回写)。因此蓝图与复用域对象的全部关联关系单向落在本表:本表持有 ref_target_code 指向外部对象业务编码(逻辑关联,无 FOREIGN KEYref_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_statusresolved/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_kindblueprint / 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_kindworld / 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": "恒为 readonlyQ-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_statusunresolved 待解析 / 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": []
}

View File

@ -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_unresolvedQ-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'"
}
]
}

View File

@ -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_typestring/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_statusdisabled 的定义不参与校验且拒绝新值写入(历史值保留只读)"},
{"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'"
}
]
}

View File

@ -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_idtemplate=挂在模板内容子对象上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_kindblueprint / 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_idtemplate→模板内临时 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'"
}
]
}

View File

@ -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_refref_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_unresolvedQ-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_typestring/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_statusdisabled 不参与校验且拒绝新值写入(历史值只读保留)',
`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 子对象扩展表M1bEAV
-- 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_kindblueprint / 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_idtemplate→模板内临时 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 子对象扩展表M1bEAV 一行一键值)';
-- -----------------------------------------------------------------------------
-- 4) pbl_blueprint_ref —— PBL 蓝图外部关联表M1bQ-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_kindblueprint / 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_kindworld / 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 '恒为 readonlyQ-OPEN-3不改 world 表,只读引用);字典 pbl_ref_mode写入非 readonly 值将被 m1b_ref 拒绝',
`ref_status` varchar(16) NOT NULL DEFAULT 'unresolved' COMMENT '字典 pbl_ref_statusunresolved 待解析 / 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 蓝图外部关联表M1bQ-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 KEY0 条针对既有基表的语句。
-- 本文件只含 CREATE TABLE IF NOT EXISTS4 条),不含任何其它 DDL/DML 动词,
-- 因此对既有基表world / scene / entity / script 等)零影响。
-- 机械核验由 tools/m1b_selfcheck.py 的 check_ddl() 自动执行并输出证据。
-- =====================================================================

39
tests/_m1b_loader.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""M1b 测试引导器tests 专用,不属于模块运行时代码)。
背景pbl_blueprint/__init__.py eager import .init -> .api_blueprint
后者依赖 pbl_commonM1a 挂载链 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()

View File

@ -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

447
tests/test_m1b_realdb.py Normal file
View File

@ -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 = 全部通过 环境受限 SKIP1 = 有真实 FAIL2 = 依赖缺失且未 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 KEYQ-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 可解析=%susage_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())

View File

@ -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

View File

@ -0,0 +1,370 @@
# -*- coding: utf-8 -*-
"""M1b 表定义规范化工具:把 models/m1b/*.json 归一到 database-table-definition-spec 四段式。
背景QC 退回意见 #8M1b 首版 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 为唯一权威来源保证 modelsDDL 一一对应
1. 解析 DDL {table: {cols:[...], indexes:{name:(cols,unique)}, defaults:{col:default}}}
2. DDL 的具体类型映射为规范抽象类型varcharstr / bigintlong / longtexttext ...
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 必须有正整数 lengthDDL 类型 %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())

465
tools/m1b_selfcheck.py Normal file
View File

@ -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.editableQC#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 标准 APIsor.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_routesimport
E3 init.py load_pbl_blueprint 内引用 register_m1b_routesenv 注册/调用
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 APImodule-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: 含表定义段 %sjson/ 只放 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 ""))
# E4M1b 对外函数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__ 未导出 %sinit.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/F2M1b 改动不得越权破坏 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())