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