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

This commit is contained in:
agent.develop 2026-09-16 17:17:53 +08:00
parent b1aabe20a2
commit b6285dd6b5
15 changed files with 2209 additions and 138 deletions

View File

@ -11,7 +11,7 @@
返回 ERR_TENANT_MISSING绝不回落默认租户绝不跨租户可见
"""
__version__ = "1.0.0"
__version__ = "1.1.0" # M1b模板平台公共部分/子对象扩展/关联表
__module_name__ = "pbl_blueprint"
# ------------------------------------------------------------------ errors 内核14 符号)
@ -35,6 +35,37 @@ from .errors import ( # noqa: F401
# ------------------------------------------------------------------ 挂载入口
from .init import load_pbl_blueprint # noqa: F401
# ------------------------------------------------------------------ M1b模板平台公共部分 / 子对象扩展 / 关联表判定)
# 三处同步之「__init__ 导出」环节:函数定义(m1b_*.py) + 本文件导出 + init.py env 注册
from .m1b_init import ( # noqa: F401
M1B_TABLES,
M1B_MODEL_FILES,
M1B_CRUD_FILES,
M1B_DDL,
PLATFORM_EXT_DEFS,
PLATFORM_TEMPLATES,
init_m1b,
ensure_m1b_tables,
ensure_platform_ext_defs,
seed_platform_data,
load_m1b_models,
load_m1b_crud_defs,
)
from .m1b_api import M1B_ROUTES, dispatch as m1b_dispatch, register_m1b_routes # noqa: F401
from .m1b_db import ( # noqa: F401
PLATFORM_TENANT_KEY,
WRITE_PROTECTED_TABLES,
PBL_DB_WRITE_PROTECTED,
WriteProtectedError,
SorDB,
tenant_key as m1b_tenant_key,
assert_writable as m1b_assert_writable,
make_db as m1b_make_db,
)
from . import m1b_template # noqa: F401
from . import m1b_subobject # noqa: F401
from . import m1b_ref # noqa: F401
__all__ = [
"__version__",
"__module_name__",
@ -57,4 +88,17 @@ __all__ = [
"err",
# 挂载入口
"load_pbl_blueprint",
# ---- M1b挂载/建表/种子
"M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL",
"PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES",
"init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs",
"seed_platform_data", "load_m1b_models", "load_m1b_crud_defs",
# ---- M1b路由
"M1B_ROUTES", "m1b_dispatch", "register_m1b_routes",
# ---- M1b真实库适配 + Q-OPEN-3 写保护闸门
"PLATFORM_TENANT_KEY", "WRITE_PROTECTED_TABLES", "PBL_DB_WRITE_PROTECTED",
"WriteProtectedError", "SorDB", "m1b_tenant_key", "m1b_assert_writable",
"m1b_make_db",
# ---- M1b业务子模块
"m1b_template", "m1b_subobject", "m1b_ref",
]

View File

@ -14,13 +14,27 @@ from .subobject import (BUILTIN_SCHEMA, add_rel, delete_rel,
list_rels, list_subobjects, reorder_subobject,
upsert_subobject, validate_payload)
from . import m1b_init as _m1b_init
from .m1b_api import M1B_ROUTES, register_m1b_routes
from .m1b_init import (M1B_CRUD_FILES, M1B_DDL, M1B_MODEL_FILES, M1B_TABLES,
ensure_m1b_tables, init_m1b, load_m1b_crud_defs,
load_m1b_models, seed_platform_data)
MODULE_NAME = "pbl_blueprint"
TABLE_NAMES = list(_tables.TABLES.keys()) # 11 表
TABLE_NAMES = list(_tables.TABLES.keys()) # M1a 11 表
SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类
M1B_TABLE_NAMES = list(M1B_TABLES) # M1b 4 表
ALL_TABLE_NAMES = TABLE_NAMES + [x for x in M1B_TABLE_NAMES
if x not in TABLE_NAMES]
__all__ = [
"load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES",
"SUBOBJECT_TYPES", "BUILTIN_SCHEMA",
# ---- M1b 三处同步之「init.py env 注册」环节
"M1B_TABLES", "M1B_TABLE_NAMES", "ALL_TABLE_NAMES", "M1B_MODEL_FILES",
"M1B_CRUD_FILES", "M1B_DDL", "M1B_ROUTES",
"init_m1b", "ensure_m1b_tables", "register_m1b_routes",
"load_m1b_models", "load_m1b_crud_defs", "seed_platform_data",
"create_blueprint", "get_blueprint", "list_blueprints", "update_blueprint",
"delete_blueprint", "get_tree", "change_status", "set_quality_level",
"commit_version", "list_versions", "get_version", "get_delta",
@ -76,14 +90,51 @@ def _seed_builtin_schema(sor, dbname):
i * 10, esc(now_str()))))
def _m1b_register_callback(app):
"""解析应用层路由注册回调register_route / register / route / add_route 四选一。"""
if app is None:
return None
for name in ("register_route", "register", "route", "add_route"):
fn = getattr(app, name, None)
if callable(fn):
return fn
return None
def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):
dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入
if ensure:
ensure_tables(sor)
# -------------------------------------------------------- M1b 挂载
# 三处同步m1b_api.register_m1b_routes定义+ __init__.py导出+ 此处env 注册)
# Q-OPEN-3init_m1b 只建 M1b 自有 4 表,绝不触碰 world/scene/entity/script 基表。
m1b_result = {"ok": False, "skipped": True}
if kw.get("m1b", True):
try:
m1b_result = init_m1b(
db=kw.get("m1b_db"),
env=kw.get("env"),
register=_m1b_register_callback(app),
executor=kw.get("m1b_executor"),
seed=bool(kw.get("m1b_seed", ensure)),
)
except Exception as e: # M1b 失败不拖垮 M1a 挂载
m1b_result = {"ok": False,
"error": "%s: %s" % (type(e).__name__, e)}
if app is not None:
try:
app.register_module(MODULE_NAME, {
"tables": TABLE_NAMES,
"tables": ALL_TABLE_NAMES,
"m1b": {
"tables": M1B_TABLE_NAMES,
"model_files": M1B_MODEL_FILES,
"crud_files": M1B_CRUD_FILES,
"ddl": M1B_DDL,
"routes": [(m, p) for m, p, _ in M1B_ROUTES],
"init": m1b_result,
},
"subobject_types": list(SUBOBJECT_TYPES),
"api": {
"create": create_blueprint, "get": get_blueprint,
@ -100,6 +151,7 @@ def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):
except Exception:
pass
return {"module": MODULE_NAME, "tables": TABLE_NAMES,
"m1b_tables": M1B_TABLE_NAMES, "m1b": m1b_result,
"subobject_types": list(SUBOBJECT_TYPES), "loaded": True}

View File

@ -1,46 +1,190 @@
{
"table": "pbl_blueprint_ref",
"comment": "PBL 蓝图跨域关联表M1b 关联表判定落地Q-OPEN-3 决议——不在 world/scene/entity 等基表上加列,蓝图对外部域对象的引用一律落到本表,形成单向可判定的关联边",
"engine": "InnoDB",
"charset": "utf8mb4",
"multi_tenant": true,
"tenant_policy": {
"column": "tenant_id",
"nullable": false,
"fail_closed": true
"tblname": "pbl_blueprint_ref",
"params": {
"id": {
"type": "int",
"size": 20,
"notnull": true,
"default": 0,
"editable": false,
"query": false,
"summary": "物理主键bigint AUTO_INCREMENT"
},
"tenant_key": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": false,
"query": true,
"summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"
},
"tenant_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": null,
"editable": true,
"query": true,
"summary": "租户ID首业务字段读写强制打头缺失即 fail-closedNULL=平台公共模板携带的关联,非空=租户蓝图关联"
},
"ref_code": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "关联编码业务主键gen_code('BPR', tenant_id) 生成)"
},
"owner_kind": {
"type": "str",
"size": 16,
"notnull": true,
"default": "blueprint",
"editable": true,
"query": true,
"summary": "宿主类型 blueprint / template",
"code": "pbl_ext_owner_kind"
},
"blueprint_id": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "蓝图IDowner_kind=blueprint 时为 M1A blueprint_id=template 时复用本列存 template_code"
},
"subobject_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": true,
"query": true,
"summary": "关联所属的 *_ref 类子对象ID模板态存模板临时ID"
},
"ref_kind": {
"type": "str",
"size": 16,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "关联对象域 world / scene / entity / script",
"code": "pbl_ref_kind"
},
"ref_target_code": {
"type": "str",
"size": 64,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "外部对象业务编码(只读引用,无 FK不反查基表写操作 —— Q-OPEN-3 不改 world 表)"
},
"ref_target_id": {
"type": "str",
"size": 64,
"notnull": false,
"default": "",
"editable": false,
"query": true,
"summary": "外部对象物理ID快照解析成功时回填"
},
"ref_mode": {
"type": "str",
"size": 16,
"notnull": true,
"default": "readonly",
"editable": false,
"query": true,
"summary": "关联模式,恒为 readonly写入非 readonly 值被 m1b_ref 拒绝)",
"code": "pbl_ref_mode"
},
"ref_status": {
"type": "str",
"size": 16,
"notnull": true,
"default": "unresolved",
"editable": false,
"query": true,
"summary": "解析状态 unresolved 待解析 / resolved 已解析 / invalid 目标缺失或不可用",
"code": "pbl_ref_status"
},
"resolve_time": {
"type": "datetime",
"notnull": false,
"default": null,
"editable": false,
"query": false,
"summary": "解析时间"
},
"resolve_msg": {
"type": "str",
"size": 512,
"notnull": false,
"default": "",
"editable": false,
"query": false,
"summary": "解析信息invalid 时记录原因,供 M2 校验与教师端提示)"
},
"snapshot_json": {
"type": "text",
"notnull": false,
"default": "",
"editable": false,
"query": false,
"summary": "解析时刻外部对象关键字段只读快照JSON运行期免跨库联查"
},
"sort_no": {
"type": "int",
"notnull": true,
"default": 0,
"editable": true,
"query": true,
"summary": "排序号"
},
"creator_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": false,
"query": true,
"summary": "创建人"
},
"create_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "创建时间"
},
"update_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "更新时间"
}
},
"columns": [
{"name": "id", "type": "varchar(64)", "primary_key": true, "comment": "关联ID bpref-xxxx"},
{"name": "tenant_id", "type": "varchar(64)", "nullable": false, "index": true, "comment": "租户ID必填"},
{"name": "blueprint_id", "type": "varchar(64)", "nullable": false, "comment": "蓝图ID关联发起方恒为 pbl_blueprint.id"},
{"name": "version_id", "type": "varchar(64)", "nullable": true, "comment": "蓝图版本ID空=当前工作副本)"},
{"name": "src_kind", "type": "varchar(32)", "nullable": false, "default": "blueprint", "comment": "源对象类型blueprint 或 7 类子对象"},
{"name": "src_id", "type": "varchar(64)", "nullable": true, "comment": "源对象IDsrc_kind=blueprint 时可空,表示蓝图级关联)"},
{"name": "ref_domain", "type": "varchar(32)", "nullable": false, "comment": "被引用域world/scene/entity/script_engine/scense_game/drag/org/employee/external"},
{"name": "ref_table", "type": "varchar(64)", "nullable": false, "comment": "被引用表名(只读,不建外键,不改基表)"},
{"name": "ref_id", "type": "varchar(64)", "nullable": false, "comment": "被引用对象ID"},
{"name": "ref_snapshot", "type": "json", "nullable": true, "comment": "引用时的名称/编码快照,供离线与展示(避免跨域联查)"},
{"name": "rel_type", "type": "varchar(32)", "nullable": false, "default": "uses", "comment": "关联语义uses/binds/embeds/derives_from/replaces"},
{"name": "cardinality", "type": "varchar(16)", "nullable": false, "default": "n:1", "comment": "1:1/1:n/n:1/n:m"},
{"name": "required", "type": "tinyint(1)", "nullable": false, "default": "0", "comment": "是否强依赖(校验引擎 M2 用required=1 且引用失效 → 校验失败)"},
{"name": "resolve_status", "type": "varchar(16)", "nullable": false, "default": "unknown", "comment": "unknown/resolved/missing由 resolve_refs 判定,不缓存跨域写权限"},
{"name": "resolved_at", "type": "datetime", "nullable": true},
{"name": "seq", "type": "int", "nullable": false, "default": "0"},
{"name": "created_by", "type": "varchar(64)", "nullable": true},
{"name": "created_at", "type": "datetime", "nullable": true},
{"name": "updated_by", "type": "varchar(64)", "nullable": true},
{"name": "updated_at", "type": "datetime", "nullable": true},
{"name": "deleted", "type": "tinyint(1)", "nullable": false, "default": "0"}
"editable": [
"api/pbl_blueprint_ref/list.dspy",
"api/pbl_blueprint_ref/edit.dspy",
"api/pbl_blueprint_ref/view.dspy"
],
"indexes": [
{"name": "uk_pbl_bpref", "unique": true, "columns": ["tenant_id", "blueprint_id", "src_kind", "src_id", "ref_domain", "ref_table", "ref_id", "rel_type", "deleted"]},
{"name": "idx_pbl_bpref_bp", "columns": ["tenant_id", "blueprint_id", "deleted"]},
{"name": "idx_pbl_bpref_target", "columns": ["tenant_id", "ref_domain", "ref_table", "ref_id"], "comment": "反查:某 world/scene 被哪些蓝图引用(删除前置校验用,只读不写基表)"},
{"name": "idx_pbl_bpref_resolve", "columns": ["tenant_id", "resolve_status"]}
],
"design_notes": [
"Q-OPEN-3world 表不加 tenant_id/blueprint_id 列,跨域关联全部由本表承载,保证基表零侵入、可回滚",
"本表不建数据库外键(跨库/跨模块),引用有效性由 resolve_refs() 主动判定并写 resolve_status",
"反向影响面分析world 删除前检查)通过 idx_pbl_bpref_target 只读查询完成,不阻塞基表写"
"browserfields": [
"ref_code",
"owner_kind",
"blueprint_id",
"ref_kind",
"ref_target_code",
"ref_mode",
"ref_status",
"resolve_time"
]
}

View File

@ -1,48 +1,203 @@
{
"table": "pbl_blueprint_template",
"comment": "PBL 蓝图模板M1b支持平台公共模板 tenant_id IS NULL租户私有模板 tenant_id 非空)",
"engine": "InnoDB",
"charset": "utf8mb4",
"multi_tenant": true,
"tenant_policy": {
"column": "tenant_id",
"nullable": true,
"null_meaning": "platform_common",
"read": "租户可见 = (tenant_id = :tenant OR tenant_id IS NULL AND is_public = 1)",
"write": "平台公共模板写操作需 is_platform_admin租户模板写操作需 tenant_id 匹配",
"fail_closed": true
"tblname": "pbl_blueprint_template",
"params": {
"id": {
"type": "int",
"size": 20,
"notnull": true,
"default": 0,
"editable": false,
"query": false,
"summary": "物理主键bigint AUTO_INCREMENT"
},
"tenant_key": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": false,
"query": true,
"summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算,不接受外部传入"
},
"tenant_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": null,
"editable": true,
"query": true,
"summary": "租户ID首业务字段读写强制打头缺失即 fail-closedNULL=平台公共模板,非空=租户私有模板"
},
"template_code": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "模板编码业务主键gen_code('TPL', tenant_id) 生成)"
},
"template_version": {
"type": "int",
"notnull": true,
"default": 1,
"editable": true,
"query": true,
"summary": "模板版本(升级 append 新版本行,不覆盖旧版本 Q-OPEN-9"
},
"template_name": {
"type": "str",
"size": 128,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "模板名称"
},
"subject": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": true,
"query": true,
"summary": "学科",
"code": "pbl_subject"
},
"grade": {
"type": "str",
"size": 16,
"notnull": false,
"default": "",
"editable": true,
"query": true,
"summary": "学段",
"code": "pbl_grade"
},
"category": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": true,
"query": true,
"summary": "模板分类",
"code": "pbl_tpl_category"
},
"tpl_json": {
"type": "text",
"notnull": true,
"default": "",
"editable": true,
"query": false,
"summary": "模板内容7 类子对象数组 + 模板内临时 ID 父子引用)"
},
"tpl_hash": {
"type": "str",
"size": 64,
"notnull": true,
"default": "",
"editable": false,
"query": true,
"summary": "内容哈希 sha256(canonical_json(tpl_json)),确定性实例化校验"
},
"offline_flag": {
"type": "str",
"size": 1,
"notnull": true,
"default": "N",
"editable": true,
"query": true,
"summary": "离线兜底模板标记 Y/N",
"code": "pbl_yes_no"
},
"platform_flag": {
"type": "str",
"size": 1,
"notnull": true,
"default": "N",
"editable": true,
"query": true,
"summary": "平台公共标记 Y=平台公共模板(此时 tenant_id 必须为 NULL",
"code": "pbl_yes_no"
},
"ref_policy": {
"type": "str",
"size": 16,
"notnull": true,
"default": "warn",
"editable": true,
"query": true,
"summary": "外部引用校验策略 strict=阻断回滚 / warn=落库并标记Q-OPEN-4 阈值可配)",
"code": "pbl_ref_policy"
},
"tpl_status": {
"type": "str",
"size": 16,
"notnull": true,
"default": "active",
"editable": true,
"query": true,
"summary": "模板状态",
"code": "pbl_tpl_status"
},
"usage_count": {
"type": "int",
"notnull": true,
"default": 0,
"editable": false,
"query": true,
"summary": "实例化次数(供 M10 模板使用率分析,非权威计数)"
},
"remark": {
"type": "str",
"size": 512,
"notnull": false,
"default": "",
"editable": true,
"query": false,
"summary": "备注"
},
"creator_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": false,
"query": true,
"summary": "创建人rbac 用户ID"
},
"create_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "创建时间"
},
"update_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "更新时间"
}
},
"columns": [
{"name": "id", "type": "varchar(64)", "primary_key": true, "comment": "模板ID tpl-xxxx"},
{"name": "tenant_id", "type": "varchar(64)", "nullable": true, "index": true, "comment": "租户IDNULL=平台公共模板Q-OPEN-3 决议:不改 world 表,平台公共数据只落在 pbl_* 自有表)"},
{"name": "code", "type": "varchar(64)", "nullable": false, "comment": "模板编码,租户内唯一;平台公共模板在 tenant_id IS NULL 域内唯一"},
{"name": "name", "type": "varchar(200)", "nullable": false, "comment": "模板名称"},
{"name": "category", "type": "varchar(64)", "nullable": true, "comment": "分类stem/humanities/interdisciplinary/vocational/custom"},
{"name": "subject_tags", "type": "json", "nullable": true, "comment": "学科标签数组"},
{"name": "grade_range", "type": "varchar(64)", "nullable": true, "comment": "适用学段,如 G4-G6"},
{"name": "duration_hours", "type": "int", "nullable": true, "comment": "建议课时(小时)"},
{"name": "summary", "type": "varchar(1000)", "nullable": true, "comment": "模板简介"},
{"name": "content", "type": "json", "nullable": true, "comment": "模板内容driving_question/missions/roles/artifacts/assessment 等子对象骨架"},
{"name": "subobject_kinds", "type": "json", "nullable": true, "comment": "模板包含的子对象类型清单7类泛化契约"},
{"name": "ext_schema", "type": "json", "nullable": true, "comment": "扩展字段定义(字段名/类型/必填/枚举/默认值),实例化时用于校验"},
{"name": "is_public", "type": "tinyint(1)", "nullable": false, "default": "1", "comment": "平台公共模板是否对全部租户可见(仅 tenant_id IS NULL 时有意义)"},
{"name": "is_builtin", "type": "tinyint(1)", "nullable": false, "default": "0", "comment": "是否内置(内置模板禁止删除,仅可停用)"},
{"name": "source", "type": "varchar(32)", "nullable": false, "default": "platform", "comment": "来源platform/tenant/fork/import"},
{"name": "source_template_id", "type": "varchar(64)", "nullable": true, "comment": "fork 来源模板ID租户从平台公共模板派生时记录"},
{"name": "version", "type": "varchar(32)", "nullable": false, "default": "1.0.0", "comment": "模板版本号"},
{"name": "status", "type": "varchar(32)", "nullable": false, "default": "draft", "comment": "draft/published/deprecated"},
{"name": "usage_count", "type": "int", "nullable": false, "default": "0", "comment": "被实例化次数(平台公共模板跨租户累计)"},
{"name": "owner_id", "type": "varchar(64)", "nullable": true, "comment": "创建人/负责人"},
{"name": "created_by", "type": "varchar(64)", "nullable": true},
{"name": "created_at", "type": "datetime", "nullable": true},
{"name": "updated_by", "type": "varchar(64)", "nullable": true},
{"name": "updated_at", "type": "datetime", "nullable": true},
{"name": "deleted", "type": "tinyint(1)", "nullable": false, "default": "0", "comment": "软删除标记"}
"editable": [
"api/pbl_blueprint_template/list.dspy",
"api/pbl_blueprint_template/edit.dspy",
"api/pbl_blueprint_template/view.dspy"
],
"indexes": [
{"name": "uk_pbl_tpl_tenant_code", "unique": true, "columns": ["tenant_id", "code", "deleted"], "comment": "租户内编码唯一tenant_id NULL 时 MySQL 唯一索引不去重,故平台公共模板唯一性由应用层 ensure 校验兜底"},
{"name": "idx_pbl_tpl_status", "columns": ["tenant_id", "status", "deleted"]},
{"name": "idx_pbl_tpl_category", "columns": ["category"]}
],
"seed": "pbl_blueprint/json/seed_template_offline.json"
"browserfields": [
"template_code",
"template_name",
"template_version",
"category",
"subject",
"grade",
"platform_flag",
"tpl_status",
"usage_count"
]
}

View File

@ -0,0 +1,201 @@
{
"tblname": "pbl_ext_field_def",
"params": {
"id": {
"type": "int",
"size": 20,
"notnull": true,
"default": 0,
"editable": false,
"query": false,
"summary": "物理主键bigint AUTO_INCREMENT"
},
"tenant_key": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": false,
"query": true,
"summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"
},
"tenant_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": null,
"editable": true,
"query": true,
"summary": "租户ID首业务字段读写强制打头缺失即 fail-closedNULL=平台内置字段定义,非空=租户自有定义"
},
"def_code": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "定义编码业务主键gen_code('EFD', tenant_id) 生成)"
},
"subobject_type": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "适用子对象类型7 类之一;'*' 表示全部类型通用)",
"code": "pbl_subobject_type"
},
"field_key": {
"type": "str",
"size": 64,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "扩展字段键(写入 pbl_subobject_ext.ext_key^[a-z][a-z0-9_]{0,63}$"
},
"field_label": {
"type": "str",
"size": 128,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "字段显示名"
},
"value_type": {
"type": "str",
"size": 16,
"notnull": true,
"default": "string",
"editable": true,
"query": true,
"summary": "值类型 string/int/float/bool/json/date/enum",
"code": "pbl_ext_value_type"
},
"required_flag": {
"type": "str",
"size": 1,
"notnull": true,
"default": "N",
"editable": true,
"query": true,
"summary": "是否必填 Y=保存该子对象扩展时此键必须出现且非空",
"code": "pbl_yes_no"
},
"default_value": {
"type": "str",
"size": 512,
"notnull": false,
"default": "",
"editable": true,
"query": false,
"summary": "默认值(调用方未传该键时按 value_type 强转后落库)"
},
"enum_options": {
"type": "text",
"notnull": false,
"default": "",
"editable": true,
"query": false,
"summary": "枚举候选value_type=enum 时必填JSON 数组字符串)"
},
"max_length": {
"type": "int",
"notnull": true,
"default": 0,
"editable": true,
"query": false,
"summary": "最大长度0=不限制;>0 时对 string/json/enum 序列化长度做上限校验)"
},
"validate_regex": {
"type": "str",
"size": 256,
"notnull": false,
"default": "",
"editable": true,
"query": false,
"summary": "正则校验(非空时对 string 值 re.fullmatch不符则 PBL_EXT_VALUE_INVALID"
},
"platform_flag": {
"type": "str",
"size": 1,
"notnull": true,
"default": "N",
"editable": true,
"query": true,
"summary": "平台内置标记 Y=平台内置定义(此时 tenant_id 必须为 NULL",
"code": "pbl_yes_no"
},
"def_status": {
"type": "str",
"size": 16,
"notnull": true,
"default": "active",
"editable": true,
"query": true,
"summary": "定义状态disabled 不参与校验且拒绝新值写入,历史值只读保留)",
"code": "pbl_ext_def_status"
},
"sort_no": {
"type": "int",
"notnull": true,
"default": 0,
"editable": true,
"query": true,
"summary": "排序号"
},
"remark": {
"type": "str",
"size": 512,
"notnull": false,
"default": "",
"editable": true,
"query": false,
"summary": "备注"
},
"creator_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": false,
"query": true,
"summary": "创建人"
},
"create_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "创建时间"
},
"update_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "更新时间"
}
},
"editable": [
"api/pbl_ext_field_def/list.dspy",
"api/pbl_ext_field_def/edit.dspy",
"api/pbl_ext_field_def/view.dspy"
],
"browserfields": [
"def_code",
"subobject_type",
"field_key",
"field_label",
"value_type",
"required_flag",
"platform_flag",
"def_status",
"sort_no"
]
}

View File

@ -1,38 +1,180 @@
{
"table": "pbl_subobject_ext",
"comment": "PBL 子对象扩展M1b7 类泛化子对象的扩展字段实例 + 子对象间关联边。tenant_id 强制打头,不允许 NULL",
"engine": "InnoDB",
"charset": "utf8mb4",
"multi_tenant": true,
"tenant_policy": {
"column": "tenant_id",
"nullable": false,
"fail_closed": true,
"note": "子对象扩展属于租户业务数据严禁平台公共NULL平台级只允许 pbl_blueprint_template / pbl_ext_field_def"
"tblname": "pbl_subobject_ext",
"params": {
"id": {
"type": "int",
"size": 20,
"notnull": true,
"default": 0,
"editable": false,
"query": false,
"summary": "物理主键bigint AUTO_INCREMENT"
},
"tenant_key": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": false,
"query": true,
"summary": "租户键 = COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"
},
"tenant_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": null,
"editable": true,
"query": true,
"summary": "租户ID首业务字段读写强制打头缺失即 fail-closedNULL=平台公共模板的子对象扩展,非空=租户数据"
},
"ext_code": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "扩展记录编码业务主键gen_code('SXE', tenant_id) 生成)"
},
"owner_kind": {
"type": "str",
"size": 16,
"notnull": true,
"default": "blueprint",
"editable": true,
"query": true,
"summary": "宿主类型 blueprint=真实蓝图子对象 / template=模板内容子对象",
"code": "pbl_ext_owner_kind"
},
"owner_id": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "宿主IDblueprint→blueprint_idtemplate→template_code"
},
"owner_version": {
"type": "int",
"notnull": true,
"default": 1,
"editable": true,
"query": true,
"summary": "宿主版本blueprint→蓝图版本号template→template_version版本隔离避免跨版本串数据"
},
"subobject_type": {
"type": "str",
"size": 32,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "子对象类型7 类之一)",
"code": "pbl_subobject_type"
},
"subobject_ref": {
"type": "str",
"size": 64,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "子对象归一引用非空列blueprint→M1A subobject_idtemplate→模板内临时 ID参与唯一键杜绝 NULL 重复行"
},
"subobject_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": true,
"query": true,
"summary": "蓝图子对象ID冗余列owner_kind=blueprint 时与 subobject_ref 同值)"
},
"ext_key": {
"type": "str",
"size": 64,
"notnull": true,
"default": "",
"editable": true,
"query": true,
"summary": "扩展字段键(必须在 pbl_ext_field_def 中存在且 def_status=active否则 PBL_EXT_FIELD_UNDEFINED"
},
"ext_value": {
"type": "text",
"notnull": false,
"default": "",
"editable": true,
"query": false,
"summary": "扩展字段值统一字符串形态json/bool/date 按 value_type 序列化)"
},
"value_type": {
"type": "str",
"size": 16,
"notnull": true,
"default": "string",
"editable": false,
"query": true,
"summary": "值类型快照(写入时从 pbl_ext_field_def.value_type 快照,历史值可按当时类型解读)",
"code": "pbl_ext_value_type"
},
"field_def_id": {
"type": "int",
"size": 20,
"notnull": false,
"default": 0,
"editable": false,
"query": true,
"summary": "字段定义ID指向 pbl_ext_field_def.id逻辑关联无 FK"
},
"sort_no": {
"type": "int",
"notnull": true,
"default": 0,
"editable": true,
"query": true,
"summary": "排序号"
},
"creator_id": {
"type": "str",
"size": 32,
"notnull": false,
"default": "",
"editable": false,
"query": true,
"summary": "创建人"
},
"create_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "创建时间"
},
"update_time": {
"type": "datetime",
"notnull": true,
"default": null,
"editable": false,
"query": false,
"summary": "更新时间"
}
},
"columns": [
{"name": "id", "type": "varchar(64)", "primary_key": true, "comment": "扩展记录ID subext-xxxx"},
{"name": "tenant_id", "type": "varchar(64)", "nullable": false, "index": true, "comment": "租户ID必填缺失即拒绝"},
{"name": "blueprint_id", "type": "varchar(64)", "nullable": false, "comment": "所属蓝图ID"},
{"name": "version_id", "type": "varchar(64)", "nullable": true, "comment": "所属蓝图版本ID空=当前工作副本)"},
{"name": "subobject_kind", "type": "varchar(32)", "nullable": false, "comment": "子对象类型driving_question/mission/role/learner/artifact_def/problem/project/learning_goal"},
{"name": "subobject_id", "type": "varchar(64)", "nullable": false, "comment": "子对象主键(对应 pbl_* 子表 id"},
{"name": "ext_key", "type": "varchar(64)", "nullable": false, "comment": "扩展字段名(由 pbl_ext_field_def 定义)"},
{"name": "ext_value", "type": "json", "nullable": true, "comment": "扩展字段值(按定义类型序列化)"},
{"name": "value_type", "type": "varchar(16)", "nullable": false, "default": "string", "comment": "string/int/float/bool/enum/json/date"},
{"name": "source", "type": "varchar(32)", "nullable": false, "default": "manual", "comment": "manual/template_instantiate/agent/import"},
{"name": "source_template_id", "type": "varchar(64)", "nullable": true, "comment": "若由模板实例化产生,记录来源模板(含平台公共模板)"},
{"name": "seq", "type": "int", "nullable": false, "default": "0", "comment": "排序"},
{"name": "status", "type": "varchar(32)", "nullable": false, "default": "active", "comment": "active/deprecated"},
{"name": "created_by", "type": "varchar(64)", "nullable": true},
{"name": "created_at", "type": "datetime", "nullable": true},
{"name": "updated_by", "type": "varchar(64)", "nullable": true},
{"name": "updated_at", "type": "datetime", "nullable": true},
{"name": "deleted", "type": "tinyint(1)", "nullable": false, "default": "0"}
"editable": [
"api/pbl_subobject_ext/list.dspy",
"api/pbl_subobject_ext/edit.dspy",
"api/pbl_subobject_ext/view.dspy"
],
"indexes": [
{"name": "uk_pbl_subext", "unique": true, "columns": ["tenant_id", "blueprint_id", "subobject_kind", "subobject_id", "ext_key", "deleted"]},
{"name": "idx_pbl_subext_obj", "columns": ["tenant_id", "subobject_kind", "subobject_id"]},
{"name": "idx_pbl_subext_bp", "columns": ["tenant_id", "blueprint_id", "deleted"]}
"browserfields": [
"ext_code",
"owner_kind",
"owner_id",
"subobject_type",
"subobject_ref",
"ext_key",
"ext_value",
"value_type"
]
}

340
pbl_blueprint/m1b_db.py Normal file
View File

@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""M1b 真实库适配层sqlor 标准 API 唯一出口)+ Q-OPEN-3 写保护闸门。
本文件是 M1b 与真实 MariaDB 之间**唯一** DB 通道职责三件
1. ``tenant_key(tenant_id)``
统一计算索引打头列 tenant_key = COALESCE(tenant_id, '__PLATFORM__')
禁止调用方自行传入 tenant_key 平台公共行tenant_id IS NULL必须落到
'__PLATFORM__' 否则 MariaDB 唯一索引不对 NULL 去重平台模板/字段定义
会被重复插入
2. ``WRITE_PROTECTED_TABLES`` + ``assert_writable(table, sql)``
Q-OPEN-3 裁决不改 world 的物理闸门world / scene / entity / script
等复用域基表一律禁止 INSERT / UPDATE / DELETE / ALTER / DROP / TRUNCATE
任何越界写操作在 SQL 执行前被拦截并抛 ``PBL_DB_WRITE_PROTECTED``
``m1b_ref.assert_writable()`` 构成双重闸门业务层 + DB
3. ``SorDB``
sqlor 6 个标准 APIC/U/D/R/I/sqlExe包装成 m1b_common 适配器契约
query/insert/update/delete使 M1b 业务函数在**内存 FakeDB 与真实库**
下走同一套代码路径库名一律来自 ``ServerEnv().get_module_dbname``
pbl_blueprint.db.get_dbname 转发本文件不硬编码任何库名
sqlor API 使用约束铁律本模块只允许 sor.C / sor.U / sor.D / sor.R /
sor.I / sor.sqlExe 六个标准 API禁止编造 save/list/insert 等不存在的方法
"""
import json
import re
__all__ = [
"PLATFORM_TENANT_KEY", "WRITE_PROTECTED_TABLES", "M1B_TABLES",
"PBL_DB_WRITE_PROTECTED", "WriteProtectedError",
"tenant_key", "assert_writable", "guard_sql", "SorDB", "make_db",
"split_ddl",
]
# 平台公共域键tenant_id IS NULL 时统一落到该键,保证唯一索引可去重
PLATFORM_TENANT_KEY = "__PLATFORM__"
# Q-OPEN-3复用域基表零改动、零加列、零回写
WRITE_PROTECTED_TABLES = (
"world", "scene", "entity", "script",
"world_snapshot", "world_sync", "world_sync_task", "world_sync_log",
"scense", "scense_game",
)
# M1b 自有 4 表(与 models/m1b/*.json、sql/m1b_ddl.sql 一一对应)
M1B_TABLES = (
"pbl_blueprint_template",
"pbl_ext_field_def",
"pbl_subobject_ext",
"pbl_blueprint_ref",
)
PBL_DB_WRITE_PROTECTED = "PBL_DB_WRITE_PROTECTED"
_WRITE_VERBS = ("insert", "update", "delete", "replace", "alter", "drop",
"truncate", "create", "rename")
_TABLE_RE = re.compile(
r"(?:from|into|update|table|join)\s+`?([a-zA-Z_][a-zA-Z0-9_]*)`?",
re.IGNORECASE)
class WriteProtectedError(Exception):
"""Q-OPEN-3 写保护违规(错误码 PBL_DB_WRITE_PROTECTED"""
code = PBL_DB_WRITE_PROTECTED
def __init__(self, table, verb="", sql=""):
self.table = table
self.verb = verb
self.sql = sql
Exception.__init__(
self, "%s: 表 %s 属复用域基表Q-OPEN-3 裁决禁止任何写操作/结构变更"
"verb=%s" % (PBL_DB_WRITE_PROTECTED, table, verb or "?"))
def tenant_key(tenant_id):
"""索引打头列计算NULL/空 → '__PLATFORM__',其余原样字符串化。"""
if tenant_id is None:
return PLATFORM_TENANT_KEY
s = str(tenant_id).strip()
if not s or s.upper() in ("NULL", "NONE"):
return PLATFORM_TENANT_KEY
return s
def _tables_in_sql(sql):
return [m.group(1).lower() for m in _TABLE_RE.finditer(sql or "")]
def assert_writable(table, sql=""):
"""写前闸门:目标表(或 SQL 中出现的表)命中写保护清单即抛异常。
只读 SELECT 不经过本函数resolve_refs 的存在性校验走 SorDB.query
"""
names = [str(table or "").strip().lower().strip("`")]
names.extend(_tables_in_sql(sql))
for n in names:
if n and n in WRITE_PROTECTED_TABLES:
raise WriteProtectedError(n, "write", sql)
return True
def guard_sql(sql):
"""SQL 级闸门:写动词 + 写保护表 同时命中才拦截(纯 SELECT 放行)。"""
text = (sql or "").strip()
if not text:
return True
head = text.split(None, 1)[0].lower().strip("(")
if head not in _WRITE_VERBS:
# 非写语句SELECT/SHOW/DESC放行多语句里再逐段查
for seg in [s for s in text.split(";") if s.strip()]:
h = seg.strip().split(None, 1)[0].lower().strip("(")
if h in _WRITE_VERBS:
for t in _tables_in_sql(seg):
if t in WRITE_PROTECTED_TABLES:
raise WriteProtectedError(t, h, seg)
return True
for t in _tables_in_sql(text):
if t in WRITE_PROTECTED_TABLES:
raise WriteProtectedError(t, head, text)
return True
def split_ddl(ddl_text):
"""把 DDL 文件切成可逐条 sqlExe 的语句列表(去注释、去空段、幂等)。"""
out, buf = [], []
for raw in (ddl_text or "").splitlines():
line = raw.strip()
if not line or line.startswith("--"):
continue
buf.append(raw)
if line.endswith(";"):
stmt = "\n".join(buf).strip().rstrip(";").strip()
if stmt:
out.append(stmt)
buf = []
tail = "\n".join(buf).strip().rstrip(";").strip()
if tail:
out.append(tail)
return out
def _esc(v):
"""SQL 字面量转义sqlor sqlExe 拼接场景)。"""
if v is None:
return "NULL"
if isinstance(v, bool):
return "1" if v else "0"
if isinstance(v, (int, float)):
return str(v)
if isinstance(v, (dict, list)):
v = json.dumps(v, ensure_ascii=False)
s = str(v).replace("\\", "\\\\").replace("'", "\\'")
return "'%s'" % s
class _IsNull(object):
"""与 m1b_common.IS_NULL 同语义的哨兵(避免循环 import 各自判类型)。"""
__slots__ = ()
def __repr__(self):
return "IS_NULL"
IS_NULL = _IsNull()
class SorDB(object):
"""sqlor 标准 API 包装器 → m1b_common 适配器契约query/insert/update/delete
构造``SorDB(sor, dbname)``sor 必须是 sqlor 实例具备 C/U/D/R/I/sqlExe
所有写操作先过 ``assert_writable`` / ``guard_sql`` 双闸门
"""
def __init__(self, sor, dbname, strict_guard=True):
if sor is None:
raise RuntimeError("SorDB: sor 不能为空(请确认应用已注入 sqlor 实例)")
if not dbname:
raise RuntimeError(
"SorDB: dbname 为空 —— 库名必须由 ServerEnv().get_module_dbname("
"'pbl_blueprint') 注入,禁止硬编码;请检查应用 init() 是否已挂载")
self.sor = sor
self.dbname = dbname
self.strict_guard = strict_guard
# ------------------------------------------------------------ 读
def query(self, table, conds=None, order_by=None, limit=None):
where, args = self._where(conds)
sql = "SELECT * FROM `%s`" % table
if where:
sql += " WHERE " + where
if order_by:
desc = str(order_by).startswith("-")
col = str(order_by).lstrip("+-")
sql += " ORDER BY `%s` %s" % (col, "DESC" if desc else "ASC")
if limit is not None:
sql += " LIMIT %d" % int(limit)
rows = self.sor.sqlExe(self.dbname, sql, args) if args else \
self.sor.sqlExe(self.dbname, sql)
return [dict(r) for r in (rows or [])]
def select(self, table, conds=None, order_by=None, limit=None):
return self.query(table, conds, order_by=order_by, limit=limit)
def count(self, table, conds=None):
where, args = self._where(conds)
sql = "SELECT COUNT(1) AS c FROM `%s`" % table
if where:
sql += " WHERE " + where
rows = self.sor.sqlExe(self.dbname, sql, args) if args else \
self.sor.sqlExe(self.dbname, sql)
try:
return int((rows or [{}])[0].get("c", 0))
except Exception:
return 0
# ------------------------------------------------------------ 写
def insert(self, table, row):
assert_writable(table)
row = dict(row or {})
cols = list(row.keys())
if not cols:
return None
sql = "INSERT INTO `%s` (%s) VALUES (%s)" % (
table,
", ".join("`%s`" % c for c in cols),
", ".join(_esc(row[c]) for c in cols))
self.sor.sqlExe(self.dbname, sql)
return row.get("id")
def update(self, table, conds, values):
assert_writable(table)
values = dict(values or {})
if not values:
return 0
where, args = self._where(conds)
sets = ", ".join("`%s`=%s" % (k, _esc(v)) for k, v in values.items())
sql = "UPDATE `%s` SET %s" % (table, sets)
if where:
sql += " WHERE " + where
self.sor.sqlExe(self.dbname, sql, args)
return 1
def delete(self, table, conds):
assert_writable(table)
where, args = self._where(conds)
if not where:
raise RuntimeError("SorDB.delete: 拒绝无 WHERE 的全表删除(%s" % table)
self.sor.sqlExe(self.dbname, "DELETE FROM `%s` WHERE %s" % (table, where), args)
return 1
def sqlExe(self, sql, args=None):
"""裸 SQL 出口(建表 DDL / 批量种子)——同样过 Q-OPEN-3 闸门。"""
if self.strict_guard:
guard_sql(sql)
if args:
return self.sor.sqlExe(self.dbname, sql, args)
return self.sor.sqlExe(self.dbname, sql)
# 兼容 sqlor 原生 6 API 直通(供确需标准 API 的调用方)
def C(self, tbl, data):
assert_writable(tbl)
return self.sor.C(self.dbname, tbl, data)
def R(self, tbl, where="", args=None, fields="*", orderby="", limit=0, offset=0):
return self.sor.R(self.dbname, tbl, where, args, fields, orderby, limit, offset)
def U(self, tbl, data, where, args=None):
assert_writable(tbl)
return self.sor.U(self.dbname, tbl, data, where, args)
def D(self, tbl, where, args=None):
assert_writable(tbl)
return self.sor.D(self.dbname, tbl, where, args)
def I(self, sql, args=None):
if self.strict_guard:
guard_sql(sql)
return self.sor.I(self.dbname, sql, args) if args else self.sor.I(self.dbname, sql)
# ------------------------------------------------------------ 内部
@staticmethod
def _where(conds):
clauses, args = [], []
for k, v in (conds or {}).items():
col = str(k)
if isinstance(v, _IsNull) or v is IS_NULL:
clauses.append("`%s` IS NULL" % col)
continue
if v is None:
# None 作为等值条件在 SQL 里无意义 → 归一为 IS NULL
clauses.append("`%s` IS NULL" % col)
continue
if isinstance(v, (list, tuple, set)):
items = list(v)
if not items:
clauses.append("1=0")
continue
clauses.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(items))))
args.extend(items)
continue
if col.endswith("!"):
clauses.append("`%s` <> %%s" % col[:-1])
args.append(v)
continue
if col.endswith("~"):
clauses.append("`%s` LIKE %%s" % col[:-1])
args.append(v)
continue
clauses.append("`%s` = %%s" % col)
args.append(v)
return (" AND ".join(clauses), args)
def make_db(sor=None, dbname=None):
"""构造真实库适配器sor/dbname 缺省时从 pbl_blueprint.db 取(应用注入)。
取不到库名 返回 None调用方 fail-closed绝不回落默认库
"""
try:
from . import db as _db
except Exception:
_db = None
if sor is None and _db is not None:
try:
sor = _db.get_sor()
except Exception:
sor = None
if not dbname and _db is not None:
try:
dbname = _db.get_dbname()
except Exception:
dbname = None
if sor is None or not dbname:
return None
return SorDB(sor, dbname)

View File

@ -19,20 +19,38 @@ from .m1b_common import IS_NULL, insert, select_one, now_iso, new_id
from . import m1b_template as T
from . import m1b_subobject as S
__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "init_m1b", "ensure_m1b_tables",
"ensure_platform_ext_defs", "seed_platform_data"]
__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL",
"PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES",
"init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs",
"seed_platform_data", "load_m1b_models", "load_m1b_crud_defs"]
_HERE = os.path.dirname(os.path.abspath(__file__))
# 目录规约module-development-spec / database-table-definition-spec
# * 表定义(四段式 summary/fields/indexes/codes→ pbl_blueprint/models/m1b/*.json
# * CRUD 定义tblname + params.editable → pbl_blueprint/json/m1b/*.json
# * 幂等 DDL4 表 CREATE TABLE IF NOT EXISTS → <repo>/sql/m1b_ddl.sql
_HERE = os.path.dirname(os.path.abspath(__file__)) # .../pbl_blueprint/pbl_blueprint
_REPO = os.path.dirname(_HERE) # .../pbl_blueprint仓库根
M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext",
"pbl_blueprint_ref"]
# 表模型(四段式)——建表/字段校验的权威来源
M1B_MODEL_FILES = [
os.path.join(_HERE, "models", "m1b", "pbl_blueprint_template.json"),
os.path.join(_HERE, "models", "m1b", "pbl_ext_field_def.json"),
os.path.join(_HERE, "models", "m1b", "pbl_subobject_ext.json"),
os.path.join(_HERE, "models", "m1b", "pbl_blueprint_ref.json"),
]
# CRUD 定义tblname + params.editable——供 sqlor/前端 CRUD 装配
M1B_CRUD_FILES = [
os.path.join(_HERE, "json", "m1b", "pbl_blueprint_template.json"),
os.path.join(_HERE, "json", "m1b", "pbl_ext_field_def.json"),
os.path.join(_HERE, "json", "m1b", "pbl_subobject_ext.json"),
os.path.join(_HERE, "json", "m1b", "pbl_blueprint_ref.json"),
]
M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext",
"pbl_blueprint_ref"]
M1B_DDL = os.path.join(_HERE, "sql", "m1b_ddl.sql")
M1B_DDL = os.path.join(_REPO, "sql", "m1b_ddl.sql")
# --------------------------------------------------------------------------
@ -249,7 +267,8 @@ def ensure_m1b_tables(db=None, env=None, executor=None):
models.append(json.load(fp))
except Exception:
continue
result = {"ok": True, "models": [m.get("table") for m in models], "ddl": None, "executed": False}
result = {"ok": True, "models": [m.get("table") for m in models],
"ddl": None, "executed": 0, "statements": 0, "errors": []}
# 1) 尝试模块既有注册机制
registered = False
@ -266,25 +285,78 @@ def ensure_m1b_tables(db=None, env=None, executor=None):
continue
result["registered_via_module"] = registered
# 2) DDL 兜底
# 2) 幂等 DDLsql/m1b_ddl.sql4 表 CREATE TABLE IF NOT EXISTS
if os.path.exists(M1B_DDL):
try:
with open(M1B_DDL, "r", encoding="utf-8") as fp:
ddl = fp.read()
result["ddl"] = M1B_DDL
from .m1b_db import split_ddl, guard_sql
stmts = split_ddl(ddl)
result["statements"] = len(stmts)
# 执行通道优先级:显式 executor > 真实库 SorDBsqlor 标准 API
runner = None
if callable(executor):
for stmt in [s.strip() for s in ddl.split(";") if s.strip()
and not s.strip().startswith("--")]:
runner = executor
else:
try:
from .m1b_db import make_db
real = make_db()
if real is not None:
runner = real.sqlExe
result["channel"] = "sqlor(SorDB.sqlExe)"
except Exception:
runner = None
if runner is not None:
for stmt in stmts:
try:
executor(stmt)
except Exception:
continue
result["executed"] = True
except Exception:
pass
guard_sql(stmt) # Q-OPEN-3拦截任何复用域基表写/改
runner(stmt)
result["executed"] += 1
except Exception as e: # 单条失败不阻断其余(幂等重跑可补)
result["errors"].append("%s: %s" % (type(e).__name__, e))
result["ok"] = not result["errors"]
else:
result["channel"] = "none(内存适配器:表按需自动创建)"
except Exception as e:
result["ok"] = False
result["errors"].append("ddl_load: %s" % e)
return result
def load_m1b_models():
"""读取 4 张表的四段式表模型models/m1b/*.json"""
out = []
for f in M1B_MODEL_FILES:
if not os.path.exists(f):
continue
try:
with open(f, "r", encoding="utf-8") as fp:
d = json.load(fp)
d["_file"] = os.path.relpath(f, _REPO)
out.append(d)
except Exception:
continue
return out
def load_m1b_crud_defs():
"""读取 4 张表的 CRUD 定义json/m1b/*.jsontblname + params.editable"""
out = []
for f in M1B_CRUD_FILES:
if not os.path.exists(f):
continue
try:
with open(f, "r", encoding="utf-8") as fp:
d = json.load(fp)
d["_file"] = os.path.relpath(f, _REPO)
out.append(d)
except Exception:
continue
return out
def ensure_platform_ext_defs(db, actor="system"):
"""幂等注入平台公共扩展字段定义tenant_id NULL"""
created, skipped = [], []
@ -338,4 +410,7 @@ def init_m1b(db=None, env=None, register=None, executor=None, seed=True, actor="
except Exception as e:
seeded = {"ok": False, "error": str(e)}
return {"ok": True, "milestone": "M1b", "tables": tables, "routes": routes,
"seed": seeded, "q_open_3": "world/scene/entity 基表零改动"}
"seed": seeded,
"models_loaded": [m.get("_file") for m in load_m1b_models()],
"crud_defs_loaded": [c.get("tblname") for c in load_m1b_crud_defs()],
"q_open_3": "world/scene/entity/script 基表零改动、零加列、零回写"}

View File

@ -0,0 +1,53 @@
{
"summary": "PBL 蓝图外部关联表M1b——Q-OPEN-3 裁决的物理落地。裁决结论:『不改 world 表』(含 scene / entity / script 等复用域基表一律零改动、零加列、零回写)。因此蓝图与复用域对象的全部关联关系单向落在本表:本表持有 ref_target_code 指向外部对象业务编码(逻辑关联,无 FOREIGN KEYref_mode 恒为 'readonly' 并由 m1b_ref.assert_writable() 与 m1b_db.WRITE_PROTECTED_TABLES 双重闸门强制(任何针对 world/scene/entity/script 的 INSERT/UPDATE/DELETE 在 SQL 执行前被拦截并抛 PBL_DB_WRITE_PROTECTED。resolve_refs() 仅做只读存在性校验并把结果写回本表 ref_statusresolved/unresolved/invalid校验策略 strict/warn 由 pbl_blueprint_template.ref_policy 决定Q-OPEN-4 阈值可配。snapshot_json 保存解析时刻的只读快照,避免运行期跨库联查。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。",
"fields": [
{"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"},
{"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"},
{"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台公共模板携带的关联;非空 = 租户蓝图关联"},
{"name": "ref_code", "type": "varchar", "len": 32, "label": "关联编码", "required": true, "nullable": false, "comment": "业务主键pbl_common.gen_code('BPR', tenant_id) 生成"},
{"name": "owner_kind", "type": "varchar", "len": 16, "label": "宿主类型", "required": true, "nullable": false, "default": "blueprint", "comment": "字典 pbl_ext_owner_kindblueprint / template"},
{"name": "blueprint_id", "type": "varchar", "len": 32, "label": "蓝图ID", "required": true, "nullable": false, "comment": "owner_kind=blueprint 时为 M1A blueprint_id=template 时复用本列存 template_code列名保留以对齐 M1A 语义,取值由 owner_kind 判定)"},
{"name": "subobject_id", "type": "varchar", "len": 32, "label": "子对象ID", "required": false, "nullable": true, "comment": "关联所属的 *_ref 类子对象ID模板态存模板临时ID"},
{"name": "ref_kind", "type": "varchar", "len": 16, "label": "关联对象域", "required": true, "nullable": false, "comment": "字典 pbl_ref_kindworld / scene / entity / script"},
{"name": "ref_target_code", "type": "varchar", "len": 64, "label": "外部对象编码", "required": true, "nullable": false, "comment": "复用域对象业务编码(只读引用,无 FK不反查基表写操作"},
{"name": "ref_target_id", "type": "varchar", "len": 64, "label": "外部对象ID", "required": false, "nullable": true, "comment": "解析成功时回填的对方物理ID快照"},
{"name": "ref_mode", "type": "varchar", "len": 16, "label": "关联模式", "required": true, "nullable": false, "default": "readonly", "comment": "恒为 readonlyQ-OPEN-3不改 world 表,只读引用);字典 pbl_ref_mode写入非 readonly 值将被 m1b_ref 拒绝"},
{"name": "ref_status", "type": "varchar", "len": 16, "label": "解析状态", "required": true, "nullable": false, "default": "unresolved", "comment": "字典 pbl_ref_statusunresolved 待解析 / resolved 已解析 / invalid 目标不存在或不可用"},
{"name": "resolve_time", "type": "datetime", "label": "解析时间", "required": false, "nullable": true},
{"name": "resolve_msg", "type": "varchar", "len": 512, "label": "解析信息", "required": false, "nullable": true, "comment": "invalid 时记录原因,供 M2 校验与教师端提示"},
{"name": "snapshot_json", "type": "text", "label": "只读快照", "required": false, "nullable": true, "comment": "解析时刻外部对象关键字段快照JSON运行期免跨库联查"},
{"name": "sort_no", "type": "int", "label": "排序号", "required": true, "nullable": false, "default": 0},
{"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true},
{"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false},
{"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false}
],
"indexes": [
{"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true},
{"name": "uk_ref_code", "fields": ["tenant_key", "ref_code"], "unique": true, "comment": "业务主键唯一"},
{"name": "uk_ref_slot", "fields": ["tenant_key", "owner_kind", "blueprint_id", "ref_kind", "ref_target_code"], "unique": true, "comment": "同宿主同域同目标只挂一条关联 → 保存幂等"},
{"name": "idx_ref_blueprint", "fields": ["tenant_key", "owner_kind", "blueprint_id", "ref_kind"], "unique": false, "comment": "按蓝图取全部关联(树渲染/编译主路径)"},
{"name": "idx_ref_target", "fields": ["tenant_key", "ref_kind", "ref_target_code"], "unique": false, "comment": "反查『某外部对象被哪些蓝图引用』(删除前影响面分析,只读)"},
{"name": "idx_ref_status", "fields": ["tenant_key", "ref_status"], "unique": false, "comment": "未解析/失效关联巡检"},
{"name": "idx_ref_tenant", "fields": ["tenant_id", "blueprint_id"], "unique": false, "comment": "按真实租户维度扫描/运维排查"}
],
"codes": {
"pbl_ref_kind": [
{"code": "world", "name": "世界"},
{"code": "scene", "name": "场景"},
{"code": "entity", "name": "实体"},
{"code": "script", "name": "脚本"}
],
"pbl_ref_status": [
{"code": "unresolved", "name": "待解析"},
{"code": "resolved", "name": "已解析"},
{"code": "invalid", "name": "目标缺失/不可用"}
],
"pbl_ref_mode": [
{"code": "readonly", "name": "只读引用Q-OPEN-3 唯一合法值)"}
],
"pbl_ext_owner_kind": [
{"code": "blueprint", "name": "蓝图"},
{"code": "template", "name": "模板"}
]
}
}

View File

@ -0,0 +1,54 @@
{
"summary": "PBL 蓝图模板表M1b。同时承载『租户私有模板』与『平台公共模板』tenant_id 为 NULL 且 platform_flag='Y' 即平台公共部分对全部租户可见tenant_id 非空为租户私有。唯一键参与列使用 tenant_key值 = COALESCE(tenant_id,'__PLATFORM__'),由 m1b_db.tenant_key() 统一计算,禁止调用方传入),以规避 MariaDB 唯一索引不对 NULL 去重导致平台模板可重复插入的问题tenant_key 同时满足『租户列索引打头』规约。模板升级 append 新版本行、不覆盖旧版本Q-OPEN-9。tpl_json 存 7 类子对象数组 + 模板内临时 ID 父子引用tpl_hash = sha256(canonical(tpl_json)) 保证确定性实例化。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。",
"fields": [
{"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"},
{"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算,不接受外部传入"},
{"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台公共模板M1b 模板平台公共部分);非空 = 租户私有模板"},
{"name": "template_code", "type": "varchar", "len": 32, "label": "模板编码", "required": true, "nullable": false, "comment": "业务主键pbl_common.gen_code('TPL', tenant_id) 生成"},
{"name": "template_version", "type": "int", "label": "模板版本", "required": true, "nullable": false, "default": 1, "comment": "升级 append 新版本,不覆盖旧行"},
{"name": "template_name", "type": "varchar", "len": 128, "label": "模板名称", "required": true, "nullable": false},
{"name": "subject", "type": "varchar", "len": 32, "label": "学科", "required": false, "nullable": true, "comment": "字典 pbl_subject"},
{"name": "grade", "type": "varchar", "len": 16, "label": "学段", "required": false, "nullable": true, "comment": "字典 pbl_grade"},
{"name": "category", "type": "varchar", "len": 32, "label": "模板分类", "required": false, "nullable": true, "comment": "字典 pbl_tpl_category"},
{"name": "tpl_json", "type": "longtext", "label": "模板内容", "required": true, "nullable": false, "comment": "7 类子对象数组 + 模板内临时 ID 父子引用stage/task/role/world_ref/scene_ref/entity_ref/script_ref"},
{"name": "tpl_hash", "type": "varchar", "len": 64, "label": "内容哈希", "required": true, "nullable": false, "comment": "sha256(canonical_json(tpl_json)),确定性实例化校验"},
{"name": "offline_flag", "type": "varchar", "len": 1, "label": "离线兜底模板", "required": true, "nullable": false, "default": "N", "comment": "Y=build.sh 种子落库的内置离线兜底模板,冷启动即可用"},
{"name": "platform_flag", "type": "varchar", "len": 1, "label": "平台公共标记", "required": true, "nullable": false, "default": "N", "comment": "Y=平台公共模板(此时 tenant_id 必须为 NULL字典 pbl_yes_no"},
{"name": "ref_policy", "type": "varchar", "len": 16, "label": "外部引用校验策略", "required": true, "nullable": false, "default": "warn", "comment": "strict=引用缺失阻断回滚warn=落库并标记 ref_unresolvedQ-OPEN-4 阈值可配);字典 pbl_ref_policy"},
{"name": "tpl_status", "type": "varchar", "len": 16, "label": "模板状态", "required": true, "nullable": false, "default": "active", "comment": "字典 pbl_tpl_status"},
{"name": "usage_count", "type": "int", "label": "实例化次数", "required": true, "nullable": false, "default": 0, "comment": "供 M10 模板使用率分析读取,非权威计数"},
{"name": "remark", "type": "varchar", "len": 512, "label": "备注", "required": false, "nullable": true},
{"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true, "comment": "rbac 用户ID"},
{"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false},
{"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false}
],
"indexes": [
{"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true},
{"name": "uk_tpl_code_ver", "fields": ["tenant_key", "template_code", "template_version"], "unique": true, "comment": "同租户(含平台域)同编码同版本唯一"},
{"name": "idx_tpl_status", "fields": ["tenant_key", "tpl_status", "subject", "grade"], "unique": false, "comment": "模板库浏览与筛选"},
{"name": "idx_tpl_offline", "fields": ["tenant_key", "offline_flag"], "unique": false, "comment": "离线兜底模板快速定位"},
{"name": "idx_tpl_hash", "fields": ["tenant_key", "tpl_hash"], "unique": false, "comment": "同内容去重/确定性校验"},
{"name": "idx_tpl_platform", "fields": ["platform_flag", "tpl_status"], "unique": false, "comment": "平台公共模板清单tenant_id IS NULL 场景)"},
{"name": "idx_tpl_tenant", "fields": ["tenant_id", "tpl_status"], "unique": false, "comment": "按真实租户维度扫描/运维排查"}
],
"codes": {
"pbl_tpl_status": [
{"code": "active", "name": "启用"},
{"code": "archived", "name": "已归档"},
{"code": "draft", "name": "草稿"}
],
"pbl_ref_policy": [
{"code": "strict", "name": "严格(引用缺失阻断)"},
{"code": "warn", "name": "宽松(引用缺失告警降级)"}
],
"pbl_tpl_category": [
{"code": "demo", "name": "演示模板"},
{"code": "subject", "name": "学科模板"},
{"code": "custom", "name": "自定义模板"}
],
"pbl_yes_no": [
{"code": "Y", "name": "是"},
{"code": "N", "name": "否"}
]
}
}

View File

@ -0,0 +1,62 @@
{
"summary": "PBL 子对象扩展字段定义表M1b——扩展字段元数据字典。定义『某类子对象允许挂哪些扩展字段、字段类型、是否必填、默认值、枚举候选、长度上限、正则校验』。m1b_subobject.save_subobject_ext / batch_save_subobject_ext 写入 pbl_subobject_ext 前必须先按本表做 fail-closed 校验:未定义的 field_key 一律拒绝PBL_EXT_FIELD_UNDEFINED类型/长度/枚举/正则不符一律拒绝PBL_EXT_VALUE_INVALID。platform_flag='Y' 且 tenant_id 为 NULL 表示平台内置字段定义,对全部租户可见;租户可在平台定义之外追加自有定义,但不得覆盖平台定义的 field_key唯一键 uk_def_type_key 以 tenant_key 打头天然隔离)。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。",
"fields": [
{"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"},
{"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"},
{"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台内置字段定义;非空 = 租户自有字段定义"},
{"name": "def_code", "type": "varchar", "len": 32, "label": "定义编码", "required": true, "nullable": false, "comment": "业务主键pbl_common.gen_code('EFD', tenant_id) 生成"},
{"name": "subobject_type", "type": "varchar", "len": 32, "label": "适用子对象类型", "required": true, "nullable": false, "comment": "字典 pbl_subobject_type 的 7 类之一;'*' 表示对全部子对象类型通用"},
{"name": "field_key", "type": "varchar", "len": 64, "label": "扩展字段键", "required": true, "nullable": false, "comment": "写入 pbl_subobject_ext.ext_key 的键名;^[a-z][a-z0-9_]{0,63}$"},
{"name": "field_label", "type": "varchar", "len": 128, "label": "字段显示名", "required": true, "nullable": false},
{"name": "value_type", "type": "varchar", "len": 16, "label": "值类型", "required": true, "nullable": false, "default": "string", "comment": "字典 pbl_ext_value_typestring/int/float/bool/json/date/enum"},
{"name": "required_flag", "type": "varchar", "len": 1, "label": "是否必填", "required": true, "nullable": false, "default": "N", "comment": "Y=保存该子对象扩展时此键必须出现且非空;字典 pbl_yes_no"},
{"name": "default_value", "type": "varchar", "len": 512, "label": "默认值", "required": false, "nullable": true, "comment": "调用方未传该键时按 value_type 强转后落库"},
{"name": "enum_options", "type": "text", "label": "枚举候选", "required": false, "nullable": true, "comment": "value_type='enum' 时必填JSON 数组字符串,如 [\"easy\",\"normal\",\"hard\"]"},
{"name": "max_length", "type": "int", "label": "最大长度", "required": true, "nullable": false, "default": 0, "comment": "0=不限制;>0 时对 string/json/enum 的序列化长度做上限校验"},
{"name": "validate_regex", "type": "varchar", "len": 256, "label": "正则校验", "required": false, "nullable": true, "comment": "非空时对 string 值做 re.fullmatch 校验,不符则 PBL_EXT_VALUE_INVALID"},
{"name": "platform_flag", "type": "varchar", "len": 1, "label": "平台内置标记", "required": true, "nullable": false, "default": "N", "comment": "Y=平台内置定义(此时 tenant_id 必须为 NULL字典 pbl_yes_no"},
{"name": "def_status", "type": "varchar", "len": 16, "label": "定义状态", "required": true, "nullable": false, "default": "active", "comment": "字典 pbl_ext_def_statusdisabled 的定义不参与校验且拒绝新值写入(历史值保留只读)"},
{"name": "sort_no", "type": "int", "label": "排序号", "required": true, "nullable": false, "default": 0},
{"name": "remark", "type": "varchar", "len": 512, "label": "备注", "required": false, "nullable": true},
{"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true},
{"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false},
{"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false}
],
"indexes": [
{"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true},
{"name": "uk_def_code", "fields": ["tenant_key", "def_code"], "unique": true, "comment": "业务主键唯一"},
{"name": "uk_def_type_key", "fields": ["tenant_key", "subobject_type", "field_key"], "unique": true, "comment": "同域同子对象类型下 field_key 唯一,杜绝重复定义"},
{"name": "idx_def_lookup", "fields": ["tenant_key", "def_status", "subobject_type", "sort_no"], "unique": false, "comment": "校验/渲染时按类型批量取启用定义"},
{"name": "idx_def_platform", "fields": ["platform_flag", "def_status"], "unique": false, "comment": "平台内置定义清单tenant_id IS NULL 场景)"},
{"name": "idx_def_tenant", "fields": ["tenant_id", "subobject_type"], "unique": false, "comment": "按真实租户维度扫描/运维排查"}
],
"codes": {
"pbl_ext_value_type": [
{"code": "string", "name": "字符串"},
{"code": "int", "name": "整数"},
{"code": "float", "name": "浮点数"},
{"code": "bool", "name": "布尔"},
{"code": "json", "name": "JSON对象/数组"},
{"code": "date", "name": "日期(YYYY-MM-DD)"},
{"code": "enum", "name": "枚举"}
],
"pbl_ext_def_status": [
{"code": "active", "name": "启用"},
{"code": "disabled", "name": "停用"}
],
"pbl_subobject_type": [
{"code": "stage", "name": "阶段"},
{"code": "task", "name": "任务"},
{"code": "role", "name": "角色"},
{"code": "world_ref", "name": "世界引用"},
{"code": "scene_ref", "name": "场景引用"},
{"code": "entity_ref", "name": "实体引用"},
{"code": "script_ref", "name": "脚本引用"},
{"code": "*", "name": "全部类型通用"}
],
"pbl_yes_no": [
{"code": "Y", "name": "是"},
{"code": "N", "name": "否"}
]
}
}

View File

@ -0,0 +1,56 @@
{
"summary": "PBL 子对象扩展表M1b——7 类子对象的泛化扩展属性存储EAV 结构一行一个扩展键值。设计前提M1a 的 pbl_bp_subobject 契约已冻结M1b 零改动 M1a 基表扩展属性一律外挂到本表M1b-annex §7『对 M1a 在途代码零改动要求』。owner_kind 区分挂载宿主blueprint=挂在真实蓝图子对象上subobject_ref 取 M1a 的 subobject_idtemplate=挂在模板内容子对象上subobject_ref 取 tpl_json 内的模板临时 ID实例化时由 m1b_subobject.remap_subobject_ref 重映射为真实 subobject_id。所有写入前必须经 pbl_ext_field_def 校验fail-closed。ext_value 统一以字符串形态存储,读取时按 value_type 反序列化。唯一键 uk_ext_slot 以 tenant_key 打头,保证同宿主同子对象同键只有一行(幂等 upsert。无 FOREIGN KEY、无 ENUM、无 TIMESTAMP。",
"fields": [
{"name": "id", "type": "bigint", "label": "物理主键", "primary": true, "auto": true, "required": true, "nullable": false, "comment": "AUTO_INCREMENT"},
{"name": "tenant_key", "type": "varchar", "len": 32, "label": "租户键", "required": true, "nullable": false, "comment": "= COALESCE(tenant_id,'__PLATFORM__');索引打头列;应用层计算"},
{"name": "tenant_id", "type": "varchar", "len": 32, "label": "租户ID", "required": false, "nullable": true, "default": null, "comment": "NULL = 平台公共模板的子对象扩展;非空 = 租户数据"},
{"name": "ext_code", "type": "varchar", "len": 32, "label": "扩展记录编码", "required": true, "nullable": false, "comment": "业务主键pbl_common.gen_code('SXE', tenant_id) 生成"},
{"name": "owner_kind", "type": "varchar", "len": 16, "label": "宿主类型", "required": true, "nullable": false, "comment": "字典 pbl_ext_owner_kindblueprint / template"},
{"name": "owner_id", "type": "varchar", "len": 32, "label": "宿主ID", "required": true, "nullable": false, "comment": "owner_kind=blueprint 时为 blueprint_id=template 时为 template_code"},
{"name": "owner_version", "type": "int", "label": "宿主版本", "required": true, "nullable": false, "default": 1, "comment": "blueprint→蓝图版本号template→template_version用于版本隔离避免跨版本串数据"},
{"name": "subobject_type", "type": "varchar", "len": 32, "label": "子对象类型", "required": true, "nullable": false, "comment": "字典 pbl_subobject_type 的 7 类之一"},
{"name": "subobject_ref", "type": "varchar", "len": 64, "label": "子对象归一引用", "required": true, "nullable": false, "comment": "非空归一列blueprint→M1A subobject_idtemplate→模板内临时 ID。参与唯一键杜绝 NULL 造成的重复行"},
{"name": "subobject_id", "type": "varchar", "len": 32, "label": "蓝图子对象ID", "required": false, "nullable": true, "comment": "冗余列owner_kind=blueprint 时与 subobject_ref 同值,便于按 M1A 子对象直查"},
{"name": "ext_key", "type": "varchar", "len": 64, "label": "扩展字段键", "required": true, "nullable": false, "comment": "必须在 pbl_ext_field_def 中存在且 def_status=active"},
{"name": "ext_value", "type": "longtext", "label": "扩展字段值", "required": false, "nullable": true, "comment": "统一字符串形态json/bool/date 按 value_type 序列化"},
{"name": "value_type", "type": "varchar", "len": 16, "label": "值类型快照", "required": true, "nullable": false, "default": "string", "comment": "写入时从 pbl_ext_field_def.value_type 快照,保证历史值可按当时类型解读;字典 pbl_ext_value_type"},
{"name": "field_def_id", "type": "bigint", "label": "字段定义ID", "required": false, "nullable": true, "comment": "指向 pbl_ext_field_def.id逻辑关联无 FK"},
{"name": "sort_no", "type": "int", "label": "排序号", "required": true, "nullable": false, "default": 0},
{"name": "creator_id", "type": "varchar", "len": 32, "label": "创建人", "required": false, "nullable": true},
{"name": "create_time", "type": "datetime", "label": "创建时间", "required": true, "nullable": false},
{"name": "update_time", "type": "datetime", "label": "更新时间", "required": true, "nullable": false}
],
"indexes": [
{"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true},
{"name": "uk_ext_code", "fields": ["tenant_key", "ext_code"], "unique": true, "comment": "业务主键唯一"},
{"name": "uk_ext_slot", "fields": ["tenant_key", "owner_kind", "owner_id", "owner_version", "subobject_type", "subobject_ref", "ext_key"], "unique": true, "comment": "同宿主同版本同子对象同键唯一 → 保存幂等upsert"},
{"name": "idx_ext_owner", "fields": ["tenant_key", "owner_kind", "owner_id", "owner_version", "subobject_type"], "unique": false, "comment": "按宿主+类型批量取扩展(树渲染主路径)"},
{"name": "idx_ext_subobject", "fields": ["tenant_key", "subobject_id"], "unique": false, "comment": "按 M1A 子对象直查扩展"},
{"name": "idx_ext_key", "fields": ["tenant_key", "ext_key"], "unique": false, "comment": "按扩展键横向统计M10 分析)"},
{"name": "idx_ext_tenant", "fields": ["tenant_id", "owner_kind", "owner_id"], "unique": false, "comment": "按真实租户维度扫描/运维排查"}
],
"codes": {
"pbl_ext_owner_kind": [
{"code": "blueprint", "name": "蓝图子对象扩展"},
{"code": "template", "name": "模板子对象扩展"}
],
"pbl_subobject_type": [
{"code": "stage", "name": "阶段"},
{"code": "task", "name": "任务"},
{"code": "role", "name": "角色"},
{"code": "world_ref", "name": "世界引用"},
{"code": "scene_ref", "name": "场景引用"},
{"code": "entity_ref", "name": "实体引用"},
{"code": "script_ref", "name": "脚本引用"}
],
"pbl_ext_value_type": [
{"code": "string", "name": "字符串"},
{"code": "int", "name": "整数"},
{"code": "float", "name": "浮点数"},
{"code": "bool", "name": "布尔"},
{"code": "json", "name": "JSON对象/数组"},
{"code": "date", "name": "日期(YYYY-MM-DD)"},
{"code": "enum", "name": "枚举"}
]
}
}

183
sql/m1b_ddl.sql Normal file
View File

@ -0,0 +1,183 @@
-- =============================================================================
-- pbl_blueprint M1b DDL —— 模板平台公共部分 / 子对象扩展 / 关联表判定
-- =============================================================================
-- 覆盖 4 张表(与 pbl_blueprint/models/m1b/*.json 四段式表模型一一对应):
-- 1) pbl_blueprint_template 模板表tenant_id NULL = 平台公共模板)
-- 2) pbl_ext_field_def 子对象扩展字段定义表元数据字典fail-closed 校验依据)
-- 3) pbl_subobject_ext 子对象扩展值表EAV一行一键值
-- 4) pbl_blueprint_ref 蓝图外部关联表Q-OPEN-3 裁决物理落地)
--
-- 设计铁律:
-- * 全部 CREATE TABLE IF NOT EXISTS / CREATE INDEX 幂等可重复执行build.sh 与
-- m1b_init.ensure_m1b_tables 双入口均执行本文件);
-- * 无 FOREIGN KEY跨模块/可能跨库,有效性由 m1b_ref.resolve_refs() 主动判定);
-- * 无 ENUM一律 varchar + 字典 codes见 models/m1b/*.json 的 codes 段);
-- * 无 TIMESTAMP一律 datetime避免 2038 与隐式时区转换);
-- * 租户列索引打头:所有业务索引首列为 tenant_key= COALESCE(tenant_id,'__PLATFORM__')
-- 或 tenant_id唯一键以 tenant_key 打头,规避 MariaDB 唯一索引不对 NULL 去重
-- 导致平台公共行tenant_id IS NULL可重复插入的问题
-- * Q-OPEN-3本文件**不含**任何针对 world / scene / entity / script 等复用域基表的
-- CREATE / ALTER / UPDATE / DELETE 语句——蓝图对外部对象的引用单向落在
-- pbl_blueprint_refref_mode 恒为 'readonly'。
-- =============================================================================
SET NAMES utf8mb4;
-- -----------------------------------------------------------------------------
-- 1) pbl_blueprint_template —— PBL 蓝图模板表M1b
-- tenant_id IS NULL 且 platform_flag='Y' → 平台公共模板,对全部租户可见
-- tenant_id 非空 → 租户私有模板
-- 模板升级 append 新版本行、不覆盖旧版本Q-OPEN-9
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `pbl_blueprint_template` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT',
`tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算,不接受外部传入',
`tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台公共模板M1b 模板平台公共部分);非空 = 租户私有模板',
`template_code` varchar(32) NOT NULL COMMENT '业务主键pbl_common.gen_code(''TPL'', tenant_id) 生成',
`template_version` int NOT NULL DEFAULT 1 COMMENT '模板版本,升级 append 新版本不覆盖旧行',
`template_name` varchar(128) NOT NULL COMMENT '模板名称',
`subject` varchar(32) DEFAULT NULL COMMENT '学科,字典 pbl_subject',
`grade` varchar(16) DEFAULT NULL COMMENT '学段,字典 pbl_grade',
`category` varchar(32) DEFAULT NULL COMMENT '模板分类,字典 pbl_tpl_category',
`tpl_json` longtext NOT NULL COMMENT '7 类子对象数组 + 模板内临时 ID 父子引用',
`tpl_hash` varchar(64) NOT NULL COMMENT 'sha256(canonical_json(tpl_json)),确定性实例化校验',
`offline_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=build.sh 种子落库的内置离线兜底模板',
`platform_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=平台公共模板(此时 tenant_id 必须为 NULL字典 pbl_yes_no',
`ref_policy` varchar(16) NOT NULL DEFAULT 'warn' COMMENT 'strict=引用缺失阻断回滚warn=落库并标记 ref_unresolvedQ-OPEN-4字典 pbl_ref_policy',
`tpl_status` varchar(16) NOT NULL DEFAULT 'active' COMMENT '模板状态,字典 pbl_tpl_status',
`usage_count` int NOT NULL DEFAULT 0 COMMENT '实例化次数(供 M10 分析,非权威计数)',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`creator_id` varchar(32) DEFAULT NULL COMMENT '创建人 rbac 用户ID',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tpl_code_ver` (`tenant_key`, `template_code`, `template_version`),
KEY `idx_tpl_status` (`tenant_key`, `tpl_status`, `subject`, `grade`),
KEY `idx_tpl_offline` (`tenant_key`, `offline_flag`),
KEY `idx_tpl_hash` (`tenant_key`, `tpl_hash`),
KEY `idx_tpl_platform` (`platform_flag`, `tpl_status`),
KEY `idx_tpl_tenant` (`tenant_id`, `tpl_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 蓝图模板表M1b含平台公共模板 tenant_id NULL';
-- -----------------------------------------------------------------------------
-- 2) pbl_ext_field_def —— PBL 子对象扩展字段定义表M1b
-- 扩展字段元数据字典:某类子对象允许挂哪些扩展键、类型、必填、默认值、
-- 枚举候选、长度上限、正则校验。
-- m1b_subobject.set_ext / bulk_set_ext 写 pbl_subobject_ext 前必须先按本表
-- fail-closed 校验:未定义键 → PBL_EXT_FIELD_UNDEFINED
-- 类型/长度/枚举/正则不符 → PBL_EXT_VALUE_INVALID。
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `pbl_ext_field_def` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT',
`tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算',
`tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台内置字段定义;非空 = 租户自有字段定义',
`def_code` varchar(32) NOT NULL COMMENT '业务主键pbl_common.gen_code(''EFD'', tenant_id) 生成',
`subobject_type` varchar(32) NOT NULL COMMENT '字典 pbl_subobject_type 的 7 类之一;''*'' 表示全部类型通用',
`field_key` varchar(64) NOT NULL COMMENT '写入 pbl_subobject_ext.ext_key 的键名;^[a-z][a-z0-9_]{0,63}$',
`field_label` varchar(128) NOT NULL COMMENT '字段显示名',
`value_type` varchar(16) NOT NULL DEFAULT 'string' COMMENT '字典 pbl_ext_value_typestring/int/float/bool/json/date/enum',
`required_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=保存该子对象扩展时此键必须出现且非空;字典 pbl_yes_no',
`default_value` varchar(512) DEFAULT NULL COMMENT '调用方未传该键时按 value_type 强转后落库',
`enum_options` text DEFAULT NULL COMMENT 'value_type=enum 时必填JSON 数组字符串,如 ["easy","normal","hard"]',
`max_length` int NOT NULL DEFAULT 0 COMMENT '0=不限制;>0 时对 string/json/enum 序列化长度做上限校验',
`validate_regex` varchar(256) DEFAULT NULL COMMENT '非空时对 string 值做 re.fullmatch 校验,不符则 PBL_EXT_VALUE_INVALID',
`platform_flag` varchar(1) NOT NULL DEFAULT 'N' COMMENT 'Y=平台内置定义(此时 tenant_id 必须为 NULL字典 pbl_yes_no',
`def_status` varchar(16) NOT NULL DEFAULT 'active' COMMENT '字典 pbl_ext_def_statusdisabled 不参与校验且拒绝新值写入(历史值只读保留)',
`sort_no` int NOT NULL DEFAULT 0 COMMENT '排序号',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`creator_id` varchar(32) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_def_code` (`tenant_key`, `def_code`),
UNIQUE KEY `uk_def_type_key` (`tenant_key`, `subobject_type`, `field_key`),
KEY `idx_def_lookup` (`tenant_key`, `def_status`, `subobject_type`, `sort_no`),
KEY `idx_def_platform` (`platform_flag`, `def_status`),
KEY `idx_def_tenant` (`tenant_id`, `subobject_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 子对象扩展字段定义表M1b扩展字段元数据字典';
-- -----------------------------------------------------------------------------
-- 3) pbl_subobject_ext —— PBL 子对象扩展表M1bEAV
-- M1a 的 pbl_bp_subobject 契约已冻结M1b 零改动 M1a 基表,
-- 扩展属性一律外挂到本表M1b-annex §7 对 M1a 在途代码零改动要求)。
-- owner_kind=blueprint → subobject_ref 取 M1a subobject_id
-- owner_kind=template → subobject_ref 取 tpl_json 内模板临时 ID
-- 实例化时由 m1b_subobject.remap_subobject_ref 重映射。
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `pbl_subobject_ext` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT',
`tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算',
`tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台公共模板的子对象扩展;非空 = 租户数据',
`ext_code` varchar(32) NOT NULL COMMENT '业务主键pbl_common.gen_code(''SXE'', tenant_id) 生成',
`owner_kind` varchar(16) NOT NULL COMMENT '字典 pbl_ext_owner_kindblueprint / template',
`owner_id` varchar(32) NOT NULL COMMENT 'owner_kind=blueprint 时为 blueprint_id=template 时为 template_code',
`owner_version` int NOT NULL DEFAULT 1 COMMENT 'blueprint→蓝图版本号template→template_version版本隔离避免跨版本串数据',
`subobject_type` varchar(32) NOT NULL COMMENT '字典 pbl_subobject_type 的 7 类之一',
`subobject_ref` varchar(64) NOT NULL COMMENT '非空归一列blueprint→M1A subobject_idtemplate→模板内临时 ID。参与唯一键杜绝 NULL 造成重复行',
`subobject_id` varchar(32) DEFAULT NULL COMMENT '冗余列owner_kind=blueprint 时与 subobject_ref 同值,便于按 M1A 子对象直查',
`ext_key` varchar(64) NOT NULL COMMENT '必须在 pbl_ext_field_def 中存在且 def_status=active',
`ext_value` longtext DEFAULT NULL COMMENT '统一字符串形态json/bool/date 按 value_type 序列化',
`value_type` varchar(16) NOT NULL DEFAULT 'string' COMMENT '写入时从 pbl_ext_field_def.value_type 快照,保证历史值可按当时类型解读;字典 pbl_ext_value_type',
`field_def_id` bigint DEFAULT NULL COMMENT '指向 pbl_ext_field_def.id逻辑关联无 FK',
`sort_no` int NOT NULL DEFAULT 0 COMMENT '排序号',
`creator_id` varchar(32) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ext_code` (`tenant_key`, `ext_code`),
UNIQUE KEY `uk_ext_slot` (`tenant_key`, `owner_kind`, `owner_id`, `owner_version`,
`subobject_type`, `subobject_ref`, `ext_key`),
KEY `idx_ext_owner` (`tenant_key`, `owner_kind`, `owner_id`, `owner_version`, `subobject_type`),
KEY `idx_ext_subobject` (`tenant_key`, `subobject_id`),
KEY `idx_ext_key` (`tenant_key`, `ext_key`),
KEY `idx_ext_tenant` (`tenant_id`, `owner_kind`, `owner_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 子对象扩展表M1bEAV 一行一键值)';
-- -----------------------------------------------------------------------------
-- 4) pbl_blueprint_ref —— PBL 蓝图外部关联表M1bQ-OPEN-3 裁决物理落地)
-- 裁决结论:『不改 world 表』(含 scene / entity / script 等复用域基表一律
-- 零改动、零加列、零回写)。蓝图与复用域对象的全部关联单向落在本表:
-- * ref_target_code 指向外部对象业务编码(逻辑关联,无 FOREIGN KEY
-- * ref_mode 恒为 'readonly',由 m1b_ref.assert_writable() 与
-- WRITE_PROTECTED_TABLES 双重闸门强制(任何针对 world/scene/entity/script
-- 的 INSERT/UPDATE/DELETE 在 SQL 执行前被拦截并抛 PBL_DB_WRITE_PROTECTED
-- * resolve_refs() 仅做只读存在性校验并把结果写回本表 ref_status
-- resolved / unresolved / invalid校验策略 strict/warn 由
-- pbl_blueprint_template.ref_policy 决定Q-OPEN-4 阈值可配);
-- * snapshot_json 保存解析时刻只读快照,避免运行期跨库联查。
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `pbl_blueprint_ref` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键 AUTO_INCREMENT',
`tenant_key` varchar(32) NOT NULL COMMENT '= COALESCE(tenant_id,''__PLATFORM__'');索引打头列;应用层计算',
`tenant_id` varchar(32) DEFAULT NULL COMMENT 'NULL = 平台公共模板携带的关联;非空 = 租户蓝图关联',
`ref_code` varchar(32) NOT NULL COMMENT '业务主键pbl_common.gen_code(''BPR'', tenant_id) 生成',
`owner_kind` varchar(16) NOT NULL DEFAULT 'blueprint' COMMENT '字典 pbl_ext_owner_kindblueprint / template',
`blueprint_id` varchar(32) NOT NULL COMMENT 'owner_kind=blueprint 时为 M1A blueprint_id=template 时复用本列存 template_code取值由 owner_kind 判定)',
`subobject_id` varchar(32) DEFAULT NULL COMMENT '关联所属的 *_ref 类子对象ID模板态存模板临时ID',
`ref_kind` varchar(16) NOT NULL COMMENT '字典 pbl_ref_kindworld / scene / entity / script',
`ref_target_code` varchar(64) NOT NULL COMMENT '复用域对象业务编码(只读引用,无 FK不反查基表写操作',
`ref_target_id` varchar(64) DEFAULT NULL COMMENT '解析成功时回填的对方物理ID快照',
`ref_mode` varchar(16) NOT NULL DEFAULT 'readonly' COMMENT '恒为 readonlyQ-OPEN-3不改 world 表,只读引用);字典 pbl_ref_mode写入非 readonly 值将被 m1b_ref 拒绝',
`ref_status` varchar(16) NOT NULL DEFAULT 'unresolved' COMMENT '字典 pbl_ref_statusunresolved 待解析 / resolved 已解析 / invalid 目标不存在或不可用',
`resolve_time` datetime DEFAULT NULL COMMENT '解析时间',
`resolve_msg` varchar(512) DEFAULT NULL COMMENT 'invalid 时记录原因,供 M2 校验与教师端提示',
`snapshot_json` text DEFAULT NULL COMMENT '解析时刻外部对象关键字段快照JSON运行期免跨库联查',
`sort_no` int NOT NULL DEFAULT 0 COMMENT '排序号',
`creator_id` varchar(32) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ref_code` (`tenant_key`, `ref_code`),
UNIQUE KEY `uk_ref_slot` (`tenant_key`, `owner_kind`, `blueprint_id`, `ref_kind`, `ref_target_code`),
KEY `idx_ref_blueprint` (`tenant_key`, `owner_kind`, `blueprint_id`, `ref_kind`),
KEY `idx_ref_target` (`tenant_key`, `ref_kind`, `ref_target_code`),
KEY `idx_ref_status` (`tenant_key`, `ref_status`),
KEY `idx_ref_tenant` (`tenant_id`, `blueprint_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 蓝图外部关联表M1bQ-OPEN-3 不改 world 表的只读关联落地)';
-- =============================================================================
-- 幂等性说明:以上 4 段均为 CREATE TABLE IF NOT EXISTS索引随表定义一并创建
-- 重复执行不报错、不产生重复索引。m1b_init.ensure_m1b_tables() 按 ';' 切分本文件
-- 逐条 sor.sqlExe(dbname, stmt) 执行(库名来自 ServerEnv().get_module_dbname
-- 本文件与本模块任何代码均不硬编码库名)。
-- =============================================================================

379
tools/patch_m1b.py Normal file
View File

@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
"""M1b 交付补丁脚本(一次性执行):
1) m1b_init.py表模型路径改指 models/m1b/*.json四段式CRUD 定义指 json/m1b/*.json
DDL 路径改指仓库根 sql/m1b_ddl.sql并接入真实库执行通道m1b_db.SorDB
2) __init__.py M1b 导出三处同步之__init__ 导出环节
3) init.py M1b env 注册三处同步之init.py env 注册环节
执行python3 tools/patch_m1b.py
"""
import io
import os
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PKG = os.path.join(REPO, "pbl_blueprint")
def read(p):
with io.open(p, "r", encoding="utf-8") as f:
return f.read()
def write(p, s):
with io.open(p, "w", encoding="utf-8") as f:
f.write(s)
print("WROTE %s (%d chars)" % (os.path.relpath(p, REPO), len(s)))
def sub_once(text, old, new, tag):
if old not in text:
raise SystemExit("PATCH FAIL [%s]: anchor not found" % tag)
if text.count(old) != 1:
raise SystemExit("PATCH FAIL [%s]: anchor not unique (%d)" % (tag, text.count(old)))
return text.replace(old, new)
# ---------------------------------------------------------------- 1) m1b_init.py
p = os.path.join(PKG, "m1b_init.py")
t = read(p)
old_head = '''_HERE = os.path.dirname(os.path.abspath(__file__))
M1B_MODEL_FILES = [
os.path.join(_HERE, "json", "m1b", "pbl_blueprint_template.json"),
os.path.join(_HERE, "json", "m1b", "pbl_ext_field_def.json"),
os.path.join(_HERE, "json", "m1b", "pbl_subobject_ext.json"),
os.path.join(_HERE, "json", "m1b", "pbl_blueprint_ref.json"),
]
M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext",
"pbl_blueprint_ref"]
M1B_DDL = os.path.join(_HERE, "sql", "m1b_ddl.sql")
'''
new_head = '''# 目录规约module-development-spec / database-table-definition-spec
# * 表定义(四段式 summary/fields/indexes/codes→ pbl_blueprint/models/m1b/*.json
# * CRUD 定义tblname + params.editable → pbl_blueprint/json/m1b/*.json
# * 幂等 DDL4 表 CREATE TABLE IF NOT EXISTS → <repo>/sql/m1b_ddl.sql
_HERE = os.path.dirname(os.path.abspath(__file__)) # .../pbl_blueprint/pbl_blueprint
_REPO = os.path.dirname(_HERE) # .../pbl_blueprint仓库根
M1B_TABLES = ["pbl_blueprint_template", "pbl_ext_field_def", "pbl_subobject_ext",
"pbl_blueprint_ref"]
# 表模型(四段式)——建表/字段校验的权威来源
M1B_MODEL_FILES = [
os.path.join(_HERE, "models", "m1b", "pbl_blueprint_template.json"),
os.path.join(_HERE, "models", "m1b", "pbl_ext_field_def.json"),
os.path.join(_HERE, "models", "m1b", "pbl_subobject_ext.json"),
os.path.join(_HERE, "models", "m1b", "pbl_blueprint_ref.json"),
]
# CRUD 定义tblname + params.editable——供 sqlor/前端 CRUD 装配
M1B_CRUD_FILES = [
os.path.join(_HERE, "json", "m1b", "pbl_blueprint_template.json"),
os.path.join(_HERE, "json", "m1b", "pbl_ext_field_def.json"),
os.path.join(_HERE, "json", "m1b", "pbl_subobject_ext.json"),
os.path.join(_HERE, "json", "m1b", "pbl_blueprint_ref.json"),
]
M1B_DDL = os.path.join(_REPO, "sql", "m1b_ddl.sql")
'''
t = sub_once(t, old_head, new_head, "m1b_init head")
t = sub_once(
t,
'__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "init_m1b", "ensure_m1b_tables",\n'
' "ensure_platform_ext_defs", "seed_platform_data"]',
'__all__ = ["M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL",\n'
' "PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES",\n'
' "init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs",\n'
' "seed_platform_data", "load_m1b_models", "load_m1b_crud_defs"]',
"m1b_init __all__")
# 建表接入真实库通道m1b_db.SorDB.sqlExe并做 Q-OPEN-3 闸门保护
old_ensure = ''' result = {"ok": True, "models": [m.get("table") for m in models], "ddl": None, "executed": False}
'''
new_ensure = ''' result = {"ok": True, "models": [m.get("table") for m in models],
"ddl": None, "executed": 0, "statements": 0, "errors": []}
'''
t = sub_once(t, old_ensure, new_ensure, "ensure result init")
old_ddl = ''' # 2) DDL 兜底
if os.path.exists(M1B_DDL):
try:
with open(M1B_DDL, "r", encoding="utf-8") as fp:
ddl = fp.read()
result["ddl"] = M1B_DDL
if callable(executor):
for stmt in [s.strip() for s in ddl.split(";") if s.strip()
and not s.strip().startswith("--")]:
try:
executor(stmt)
except Exception:
continue
result["executed"] = True
except Exception:
pass
return result
'''
new_ddl = ''' # 2) 幂等 DDLsql/m1b_ddl.sql4 表 CREATE TABLE IF NOT EXISTS
if os.path.exists(M1B_DDL):
try:
with open(M1B_DDL, "r", encoding="utf-8") as fp:
ddl = fp.read()
result["ddl"] = M1B_DDL
from .m1b_db import split_ddl, guard_sql
stmts = split_ddl(ddl)
result["statements"] = len(stmts)
# 执行通道优先级:显式 executor > 真实库 SorDBsqlor 标准 API
runner = None
if callable(executor):
runner = executor
else:
try:
from .m1b_db import make_db
real = make_db()
if real is not None:
runner = real.sqlExe
result["channel"] = "sqlor(SorDB.sqlExe)"
except Exception:
runner = None
if runner is not None:
for stmt in stmts:
try:
guard_sql(stmt) # Q-OPEN-3拦截任何复用域基表写/改
runner(stmt)
result["executed"] += 1
except Exception as e: # 单条失败不阻断其余(幂等重跑可补)
result["errors"].append("%s: %s" % (type(e).__name__, e))
result["ok"] = not result["errors"]
else:
result["channel"] = "none(内存适配器:表按需自动创建)"
except Exception as e:
result["ok"] = False
result["errors"].append("ddl_load: %s" % e)
return result
def load_m1b_models():
"""读取 4 张表的四段式表模型models/m1b/*.json"""
out = []
for f in M1B_MODEL_FILES:
if not os.path.exists(f):
continue
try:
with open(f, "r", encoding="utf-8") as fp:
d = json.load(fp)
d["_file"] = os.path.relpath(f, _REPO)
out.append(d)
except Exception:
continue
return out
def load_m1b_crud_defs():
"""读取 4 张表的 CRUD 定义json/m1b/*.jsontblname + params.editable"""
out = []
for f in M1B_CRUD_FILES:
if not os.path.exists(f):
continue
try:
with open(f, "r", encoding="utf-8") as fp:
d = json.load(fp)
d["_file"] = os.path.relpath(f, _REPO)
out.append(d)
except Exception:
continue
return out
'''
t = sub_once(t, old_ddl, new_ddl, "ensure ddl block")
# init_m1b 返回值补 CRUD 定义装载证据
t = sub_once(
t,
''' return {"ok": True, "milestone": "M1b", "tables": tables, "routes": routes,
"seed": seeded, "q_open_3": "world/scene/entity 基表零改动"}''',
''' return {"ok": True, "milestone": "M1b", "tables": tables, "routes": routes,
"seed": seeded,
"models_loaded": [m.get("_file") for m in load_m1b_models()],
"crud_defs_loaded": [c.get("tblname") for c in load_m1b_crud_defs()],
"q_open_3": "world/scene/entity/script 基表零改动、零加列、零回写"}''',
"init_m1b return")
write(p, t)
# ---------------------------------------------------------------- 2) __init__.py
p = os.path.join(PKG, "__init__.py")
t = read(p)
old = '''# ------------------------------------------------------------------ 挂载入口
from .init import load_pbl_blueprint # noqa: F401
'''
new = '''# ------------------------------------------------------------------ 挂载入口
from .init import load_pbl_blueprint # noqa: F401
# ------------------------------------------------------------------ M1b模板平台公共部分 / 子对象扩展 / 关联表判定)
# 三处同步之「__init__ 导出」环节:函数定义(m1b_*.py) + 本文件导出 + init.py env 注册
from .m1b_init import ( # noqa: F401
M1B_TABLES,
M1B_MODEL_FILES,
M1B_CRUD_FILES,
M1B_DDL,
PLATFORM_EXT_DEFS,
PLATFORM_TEMPLATES,
init_m1b,
ensure_m1b_tables,
ensure_platform_ext_defs,
seed_platform_data,
load_m1b_models,
load_m1b_crud_defs,
)
from .m1b_api import M1B_ROUTES, dispatch as m1b_dispatch, register_m1b_routes # noqa: F401
from .m1b_db import ( # noqa: F401
PLATFORM_TENANT_KEY,
WRITE_PROTECTED_TABLES,
PBL_DB_WRITE_PROTECTED,
WriteProtectedError,
SorDB,
tenant_key as m1b_tenant_key,
assert_writable as m1b_assert_writable,
make_db as m1b_make_db,
)
from . import m1b_template # noqa: F401
from . import m1b_subobject # noqa: F401
from . import m1b_ref # noqa: F401
'''
t = sub_once(t, old, new, "__init__ imports")
t = sub_once(
t,
''' # 挂载入口
"load_pbl_blueprint",
]''',
''' # 挂载入口
"load_pbl_blueprint",
# ---- M1b挂载/建表/种子
"M1B_TABLES", "M1B_MODEL_FILES", "M1B_CRUD_FILES", "M1B_DDL",
"PLATFORM_EXT_DEFS", "PLATFORM_TEMPLATES",
"init_m1b", "ensure_m1b_tables", "ensure_platform_ext_defs",
"seed_platform_data", "load_m1b_models", "load_m1b_crud_defs",
# ---- M1b路由
"M1B_ROUTES", "m1b_dispatch", "register_m1b_routes",
# ---- M1b真实库适配 + Q-OPEN-3 写保护闸门
"PLATFORM_TENANT_KEY", "WRITE_PROTECTED_TABLES", "PBL_DB_WRITE_PROTECTED",
"WriteProtectedError", "SorDB", "m1b_tenant_key", "m1b_assert_writable",
"m1b_make_db",
# ---- M1b业务子模块
"m1b_template", "m1b_subobject", "m1b_ref",
]''',
"__init__ __all__")
t = sub_once(
t, '__version__ = "1.0.0"', '__version__ = "1.1.0" # M1b模板平台公共部分/子对象扩展/关联表',
"__init__ version")
write(p, t)
# ---------------------------------------------------------------- 3) init.py
p = os.path.join(PKG, "init.py")
t = read(p)
old = '''MODULE_NAME = "pbl_blueprint"
TABLE_NAMES = list(_tables.TABLES.keys()) # 11 表
SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类
'''
new = '''from . import m1b_init as _m1b_init
from .m1b_api import M1B_ROUTES, register_m1b_routes
from .m1b_init import (M1B_CRUD_FILES, M1B_DDL, M1B_MODEL_FILES, M1B_TABLES,
ensure_m1b_tables, init_m1b, load_m1b_crud_defs,
load_m1b_models, seed_platform_data)
MODULE_NAME = "pbl_blueprint"
TABLE_NAMES = list(_tables.TABLES.keys()) # M1a 11 表
SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类
M1B_TABLE_NAMES = list(M1B_TABLES) # M1b 4 表
ALL_TABLE_NAMES = TABLE_NAMES + [t for t in M1B_TABLE_NAMES if t not in TABLE_NAMES]
'''
t = sub_once(t, old, new, "init.py imports")
t = sub_once(
t,
'''__all__ = [
"load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES",
"SUBOBJECT_TYPES", "BUILTIN_SCHEMA",''',
'''__all__ = [
"load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES",
"SUBOBJECT_TYPES", "BUILTIN_SCHEMA",
# ---- M1b 三处同步之「init.py env 注册」环节
"M1B_TABLES", "M1B_TABLE_NAMES", "ALL_TABLE_NAMES", "M1B_MODEL_FILES",
"M1B_CRUD_FILES", "M1B_DDL", "M1B_ROUTES",
"init_m1b", "ensure_m1b_tables", "register_m1b_routes",
"load_m1b_models", "load_m1b_crud_defs", "seed_platform_data",''',
"init.py __all__")
old_load = '''def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):
dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入
if ensure:
ensure_tables(sor)
if app is not None:
try:
app.register_module(MODULE_NAME, {
"tables": TABLE_NAMES,'''
new_load = '''def _m1b_register_callback(app):
"""解析应用层路由注册回调register_route / register / route 三选一。"""
if app is None:
return None
for name in ("register_route", "register", "route", "add_route"):
fn = getattr(app, name, None)
if callable(fn):
return fn
return None
def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):
dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入
if ensure:
ensure_tables(sor)
# ------------------------------------------------------------ M1b 挂载
# 三处同步m1b_api.register_m1b_routes定义+ __init__.py导出+ 此处env 注册)
# Q-OPEN-3init_m1b 内部只建 M1b 自有 4 表,绝不触碰 world/scene/entity/script 基表。
m1b_result = {"ok": False, "skipped": True}
if kw.get("m1b", True):
try:
m1b_result = init_m1b(
db=kw.get("m1b_db"),
env=kw.get("env"),
register=_m1b_register_callback(app),
executor=kw.get("m1b_executor"),
seed=bool(kw.get("m1b_seed", ensure)),
)
except Exception as e: # M1b 失败不拖垮 M1a 挂载
m1b_result = {"ok": False, "error": "%s: %s" % (type(e).__name__, e)}
if app is not None:
try:
app.register_module(MODULE_NAME, {
"tables": ALL_TABLE_NAMES,
"m1b": {
"tables": M1B_TABLE_NAMES,
"model_files": M1B_MODEL_FILES,
"crud_files": M1B_CRUD_FILES,
"ddl": M1B_DDL,
"routes": [(m, p) for m, p, _ in M1B_ROUTES],
"init": m1b_result,
},''',
t = sub_once(t, old_load, new_load, "init.py load_pbl_blueprint")
t = sub_once(
t,
''' return {"module": MODULE_NAME, "tables": TABLE_NAMES,
"subobject_types": list(SUBOBJECT_TYPES), "loaded": True}''',
''' return {"module": MODULE_NAME, "tables": TABLE_NAMES,
"m1b_tables": M1B_TABLE_NAMES, "m1b": m1b_result,
"subobject_types": list(SUBOBJECT_TYPES), "loaded": True}''',
"init.py return")
write(p, t)
print("PATCH OK")

131
tools/patch_m1b_init.py Normal file
View File

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""M1b 补丁脚本 2/2init.py 的 env 注册(三处同步第三环)。
patch_m1b.py 已完成 m1b_init.py __init__.py本脚本只改 init.py可重复执行
已打过补丁时检测到锚点缺失即跳过并报告不重复插入
执行python3 tools/patch_m1b_init.py
"""
import io
import os
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
P = os.path.join(REPO, "pbl_blueprint", "init.py")
def read(p):
with io.open(p, "r", encoding="utf-8") as f:
return f.read()
def write(p, s):
with io.open(p, "w", encoding="utf-8") as f:
f.write(s)
print("WROTE %s (%d chars)" % (os.path.relpath(p, REPO), len(s)))
t = read(P)
if "M1B_TABLE_NAMES" in t:
print("SKIP: init.py 已含 M1b 注册(幂等,不重复打补丁)")
raise SystemExit(0)
# ---- 1) import + 表清单
old = ('MODULE_NAME = "pbl_blueprint"\n'
'TABLE_NAMES = list(_tables.TABLES.keys()) # 11 表\n'
'SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类\n')
new = ('from . import m1b_init as _m1b_init\n'
'from .m1b_api import M1B_ROUTES, register_m1b_routes\n'
'from .m1b_init import (M1B_CRUD_FILES, M1B_DDL, M1B_MODEL_FILES, M1B_TABLES,\n'
' ensure_m1b_tables, init_m1b, load_m1b_crud_defs,\n'
' load_m1b_models, seed_platform_data)\n'
'\n'
'MODULE_NAME = "pbl_blueprint"\n'
'TABLE_NAMES = list(_tables.TABLES.keys()) # M1a 11 表\n'
'SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类\n'
'M1B_TABLE_NAMES = list(M1B_TABLES) # M1b 4 表\n'
'ALL_TABLE_NAMES = TABLE_NAMES + [x for x in M1B_TABLE_NAMES\n'
' if x not in TABLE_NAMES]\n')
assert t.count(old) == 1, "anchor1 not unique: %d" % t.count(old)
t = t.replace(old, new)
# ---- 2) __all__
old = ('__all__ = [\n'
' "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES",\n'
' "SUBOBJECT_TYPES", "BUILTIN_SCHEMA",')
new = ('__all__ = [\n'
' "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES",\n'
' "SUBOBJECT_TYPES", "BUILTIN_SCHEMA",\n'
' # ---- M1b 三处同步之「init.py env 注册」环节\n'
' "M1B_TABLES", "M1B_TABLE_NAMES", "ALL_TABLE_NAMES", "M1B_MODEL_FILES",\n'
' "M1B_CRUD_FILES", "M1B_DDL", "M1B_ROUTES",\n'
' "init_m1b", "ensure_m1b_tables", "register_m1b_routes",\n'
' "load_m1b_models", "load_m1b_crud_defs", "seed_platform_data",')
assert t.count(old) == 1, "anchor2 not unique: %d" % t.count(old)
t = t.replace(old, new)
# ---- 3) load_pbl_blueprintM1b 挂载 + env 注册
old = ('def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):\n'
' dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入\n'
' if ensure:\n'
' ensure_tables(sor)\n'
' if app is not None:\n'
' try:\n'
' app.register_module(MODULE_NAME, {\n'
' "tables": TABLE_NAMES,')
new = ('def _m1b_register_callback(app):\n'
' """解析应用层路由注册回调register_route / register / route / add_route 四选一。"""\n'
' if app is None:\n'
' return None\n'
' for name in ("register_route", "register", "route", "add_route"):\n'
' fn = getattr(app, name, None)\n'
' if callable(fn):\n'
' return fn\n'
' return None\n'
'\n'
'\n'
'def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw):\n'
' dbname = get_module_dbname(_MODULE_NAME) # noqa: F841 库名由应用注入\n'
' if ensure:\n'
' ensure_tables(sor)\n'
'\n'
' # -------------------------------------------------------- M1b 挂载\n'
' # 三处同步m1b_api.register_m1b_routes定义+ __init__.py导出+ 此处env 注册)\n'
' # Q-OPEN-3init_m1b 只建 M1b 自有 4 表,绝不触碰 world/scene/entity/script 基表。\n'
' m1b_result = {"ok": False, "skipped": True}\n'
' if kw.get("m1b", True):\n'
' try:\n'
' m1b_result = init_m1b(\n'
' db=kw.get("m1b_db"),\n'
' env=kw.get("env"),\n'
' register=_m1b_register_callback(app),\n'
' executor=kw.get("m1b_executor"),\n'
' seed=bool(kw.get("m1b_seed", ensure)),\n'
' )\n'
' except Exception as e: # M1b 失败不拖垮 M1a 挂载\n'
' m1b_result = {"ok": False,\n'
' "error": "%s: %s" % (type(e).__name__, e)}\n'
'\n'
' if app is not None:\n'
' try:\n'
' app.register_module(MODULE_NAME, {\n'
' "tables": ALL_TABLE_NAMES,\n'
' "m1b": {\n'
' "tables": M1B_TABLE_NAMES,\n'
' "model_files": M1B_MODEL_FILES,\n'
' "crud_files": M1B_CRUD_FILES,\n'
' "ddl": M1B_DDL,\n'
' "routes": [(m, p) for m, p, _ in M1B_ROUTES],\n'
' "init": m1b_result,\n'
' },')
assert t.count(old) == 1, "anchor3 not unique: %d" % t.count(old)
t = t.replace(old, new)
# ---- 4) 返回值
old = (' return {"module": MODULE_NAME, "tables": TABLE_NAMES,\n'
' "subobject_types": list(SUBOBJECT_TYPES), "loaded": True}')
new = (' return {"module": MODULE_NAME, "tables": TABLE_NAMES,\n'
' "m1b_tables": M1B_TABLE_NAMES, "m1b": m1b_result,\n'
' "subobject_types": list(SUBOBJECT_TYPES), "loaded": True}')
assert t.count(old) == 1, "anchor4 not unique: %d" % t.count(old)
t = t.replace(old, new)
write(P, t)
print("PATCH init.py OK")