# pbl_blueprint M1b 实现说明 —— 模板平台公共部分 / 子对象扩展 / 关联表判定 > 任务:[M1b] pbl_blueprint 模板/子对象扩展与关联表 > 依据:`modules/pbl_blueprint.M1b-annex.md`(模板平台公共部分 tenant_id NULL、子对象扩展、关联表判定 Q-OPEN-3:不改 world 表) > 迭代:pbls-初始迭代 类型:new_dev --- ## 1. 交付清单 | 文件 | 作用 | |---|---| | `pbl_blueprint/m1b_common.py` | M1b 公共内核:`IS_NULL` 哨兵、fail-closed 租户/操作人校验、DB 适配(select/insert/update/delete + `sql_where` 的 IS NULL 翻译)、审计 best-effort、JSON 列兼容 | | `pbl_blueprint/m1b_template.py` | 模板平台公共部分:可见性(租户 ∪ 平台公共)、平台写门禁、fork 派生、实例化、发布/停用/软删、幂等种子 | | `pbl_blueprint/m1b_subobject.py` | 子对象扩展:7 类泛化 kind 白名单、扩展字段定义(平台公共/租户覆盖)、扩展值 upsert + 类型/枚举/约束校验、模板 ext_schema 落地、按子对象聚合树 | | `pbl_blueprint/m1b_ref.py` | 关联表判定与落地:域-表白名单、关联边增删查(正查/反查)、引用有效性判定 `resolve_refs`、影响面 `impact_of`、content 自动抽取与同步 | | `pbl_blueprint/m1b_api.py` | 22 条路由 + `dispatch()` 分发 + `register_m1b_routes()` | | `pbl_blueprint/m1b_init.py` | 挂载入口 `init_m1b()`:幂等建表 → 注册路由 → 平台公共种子(3 个模板 + 25 条字段定义) | | `pbl_blueprint/json/m1b/pbl_blueprint_template.json` | 模板表模型(tenant_id 可空 + 平台公共语义) | | `pbl_blueprint/json/m1b/pbl_ext_field_def.json` | 扩展字段定义表模型(tenant_id 可空) | | `pbl_blueprint/json/m1b/pbl_subobject_ext.json` | 子对象扩展值表模型(tenant_id 必填) | | `pbl_blueprint/json/m1b/pbl_blueprint_ref.json` | 跨域关联表模型(含设计说明) | | `pbl_blueprint/sql/m1b_ddl.sql` | 幂等 DDL(4 张表 + 索引),含 M1a 已建表时的 ALTER 兜底(注释态,按需启用) | | `tests/test_m1b_ext_ref.py` | 41 个用例:平台模板/租户隔离/fork/实例化/扩展校验/关联判定/路由/种子端到端 | --- ## 2. 模板平台公共部分(tenant_id IS NULL) ### 2.1 语义 | 数据 | tenant_id | 可见范围 | 可写方 | |---|---|---|---| | 平台公共模板 | `NULL` | `is_public=1` 时全部租户**只读** | 仅 `is_platform_admin=True` | | 租户私有模板 | 租户ID | 仅本租户 | 本租户成员 | | 模板实例(蓝图) | 租户ID | 仅本租户 | 本租户成员(**绝不继承 NULL**) | 可见集合 = 本租户模板 ∪ 平台公共模板,实现为 OR 条件组(`visible_template_conds()`): ```python [{"tenant_id": T, "deleted": 0}, {"tenant_id": IS_NULL, "is_public": 1, "deleted": 0}] ``` `IS_NULL` 是单例哨兵:内存适配器按 `None/""` 匹配;真实 SQL 适配器用 `sql_where()` 翻译成 `tenant_id IS NULL`(避免 `= NULL` 永假的经典坑)。 ### 2.2 fail-closed 硬约束 * `require_tenant()`:`tenant_id` 为 `None`/空串/空白 → `PBL_TENANT_REQUIRED`(400),**绝不退化为全租户扫描**; * 平台公共模板写操作:`require_actor()`(拒绝 `system`/`anonymous`)+ `is_platform_admin` 双门禁,否则 `PBL_PLATFORM_ADMIN_REQUIRED`(403); * 越权读他人租户模板 → 统一抛 `NotFoundError`(不泄露存在性); * `is_platform_admin` 只接受应用层 RBAC 注入(`m1b_api._ctx`),不信任前端自述字段以外的来源。 ### 2.3 编码唯一性 MySQL 唯一索引对 `NULL` 不去重,故 `uk_pbl_tpl_tenant_code(tenant_id, code, deleted)` 无法约束平台域;应用层 `_ensure_code_unique()` 按「租户域内唯一 / 平台域(tenant_id IS NULL)内唯一」双域校验兜底,冲突抛 `PBL_CONFLICT`(409)。 ### 2.4 fork 与实例化 * `fork_template()`:平台公共模板 → 租户私有副本(`source=fork`、`source_template_id` 记源、`status=draft`、`is_builtin=0`)。**派生后完全解耦**,平台模板升级不回灌租户副本,避免覆盖租户已改内容; * `instantiate_template()`:平台公共模板可被任意租户实例化,产物 `tenant_id` 强制写调用租户;`deprecated` 模板拒绝实例化;`usage_count` 累计(best-effort,失败不阻断);未注入 `create_blueprint` 回调时返回 `deferred` 骨架(离线兜底,不抛错)。 ### 2.5 内置模板保护 `is_builtin=1`:禁止删除(只能 `deprecate`)、`code`/`is_builtin` 不可改。平台种子模板默认 `is_builtin=1, status=published, is_public=1`。 --- ## 3. 子对象扩展 ### 3.1 7 类泛化契约(不改子对象基表) `driving_question / mission / role / learner / artifact_def / problem / project`(+ `learning_goal` 兼容第 8 类)。子对象本体仍在各自 `pbl_*` 表,M1b 扩展一律落 EAV 表 `pbl_subobject_ext`(`kind + subobject_id + ext_key + ext_value`),**基表零改动**。 ### 3.2 扩展字段定义(pbl_ext_field_def) * `tenant_id IS NULL` = 平台公共定义(`is_public=1` 时全租户可用),写需 `is_platform_admin`; * 租户可建自定义定义,**遮蔽**同名平台定义。解析优先级(`resolve_ext_field_def`): 1. 租户 + kind 精确 → 2. 租户 + `any` → 3. 平台 + kind 精确 → 4. 平台 + `any` * 未定义的 `ext_key` → 拒绝写入(fail-closed,防脏字段污染子对象); * 校验能力:`value_type`(string/int/float/bool/enum/json/date)强转、`required`、`enum_options` 白名单、`constraints.{min,max,max_length,pattern}`; * 定义删除保护:`is_builtin` 禁删;已被扩展值引用 → `PBL_CONFLICT`,提示改 `status=deprecated`。 ### 3.3 扩展值读写 * `set_ext()` upsert 幂等(唯一键 `tenant_id+blueprint_id+kind+subobject_id+ext_key+deleted`); * `bulk_set_ext()` **先全量校验后写入**,任一字段非法整体拒绝并返回 `errors` 明细(不留半截数据); * `apply_template_ext_schema()`:模板实例化时按 `ext_schema` 落默认值,**只写「定义存在且当前无值」**的字段,不覆盖用户已填内容 → 可重复执行;未定义字段进 `skipped` 明细而非报错; * `subobject_tree_ext()`:按 `{kind: {subobject_id: {ext_key: value}}}` 聚合,供蓝图树接口一次挂载; * 扩展值属租户业务数据,`tenant_id` **严禁 NULL**(表定义 `nullable=false`,代码层 `require_tenant` 双保险)。 --- ## 4. 关联表判定(Q-OPEN-3:不改 world 表) ### 4.1 方案对比与结论 | 方案 | 描述 | 判定 | |---|---|---| | A | 在 `world`/`scene`/`entity` 基表加 `tenant_id`+`blueprint_id` 列 | **否决**:ALTER 生产基表,影响 world/scene/entity 既有 CRUD 与导入链路;world 被非 PBL 场景复用,加 PBL 专属列属职责污染;回滚成本高 | | B | 蓝图 `content` JSON 内嵌引用,无关联表 | **否决**:无法反查「某 world 被哪些蓝图引用」,删除前置校验/影响面分析只能全表扫 JSON,M2 校验与 M3 编译无索引可用 | | **C** | **独立关联表 `pbl_blueprint_ref`(单向边 + 引用快照)** | **采纳**:基表零侵入、可独立回滚;正查/反查均有索引;`ref_snapshot` 支持离线展示;`resolve_status` 承载有效性判定,不缓存跨域写权限 | ### 4.2 表设计要点 * 唯一键 `uk_pbl_bpref(tenant_id, blueprint_id, src_kind, src_id, ref_domain, ref_table, ref_id, rel_type, deleted)` → `add_ref` 天然幂等(存在则更新); * `idx_pbl_bpref_target(tenant_id, ref_domain, ref_table, ref_id)` → 反查影响面(world 删除前置校验)走索引,**只读不写基表**; * **不建数据库外键**(跨模块/可能跨库),有效性由 `resolve_refs()` 主动探测; * 域-表白名单 `DOMAIN_TABLE_WHITELIST`:`world/scene/entity/script_engine/scense_game/drag/org/employee/pbl/external`,白名单外 → `PBL_REF_NOT_ALLOWED` 拒绝落库(防任意表名注入式越权探测); * `src_kind` 限 `blueprint` 或 7 类子对象;非 blueprint 时 `src_id` 必填; * `rel_type ∈ uses/binds/embeds/derives_from/replaces`,`cardinality ∈ 1:1/1:n/n:1/n:m`,`required=1` 供 M2 校验:`required` 且 `missing` → 阻断(`resolve_refs` 返回 `blocking`)。 ### 4.3 引用有效性判定 `resolve_refs(db, tenant_id, blueprint_id, reader=...)`: * `reader(db, ref_table, ref_id) -> row|None` 由上层注入跨模块**只读**查询能力(如 world 模块 `get_world`); * 未注入 reader 时保守置 `unknown`(**不臆断 missing**,避免误报阻断); * reader 抛异常 → 降级 `unknown`,不中断整批判定; * 结果写回 `resolve_status/resolved_at`。 ### 4.4 content 自动抽取与同步 * `refs_from_content()`:递归遍历蓝图/子对象 JSON,命中 `world_id/world/scene_id/scene/entity_id/script_id/game_id/canvas_id` 即产出引用边,按 6 元组去重; * `sync_refs_from_content()`:全量同步(补缺 + 软删多余),**幂等可重跑**,供蓝图保存/版本快照/编译前调用; * `impact_of()`:反查某外部对象被本租户哪些蓝图引用,返回 `blueprint_ids/required_ref_count/safe_to_delete/warning`,**跨租户不泄露**(tenant_id 强制打头)。 ### 4.5 零侵入验证 测试 `test_world_table_untouched` 断言:执行 add_ref / sync / resolve 全流程后,内存 DB 中**不存在** `world`、`scene` 表写入,仅 `pbl_blueprint_ref` 有数据。 --- ## 5. 路由清单(22 条) ``` GET /pbl/templates 列表(本租户 + 平台公共) POST /pbl/templates 创建(scope=platform 需平台管理员) GET /pbl/templates/{template_id} 详情 PUT /pbl/templates/{template_id} 更新 DELETE /pbl/templates/{template_id} 软删(内置禁删) POST /pbl/templates/{template_id}/publish 发布(content 空则拒绝) POST /pbl/templates/{template_id}/deprecate 停用 POST /pbl/templates/{template_id}/fork 派生为租户私有 POST /pbl/templates/{template_id}/instantiate 实例化为蓝图 GET /pbl/ext-defs 扩展字段定义列表(租户覆盖平台) POST /pbl/ext-defs 创建定义 PUT /pbl/ext-defs/{def_id} 更新定义 DELETE /pbl/ext-defs/{def_id} 删除定义(在用则拒绝) GET /pbl/blueprints/{blueprint_id}/ext 扩展值(聚合树 / flat=1 明细) PUT /pbl/blueprints/{blueprint_id}/ext 写扩展值(values 批量 / 单条) DELETE /pbl/blueprints/{blueprint_id}/ext 删扩展值 GET /pbl/blueprints/{blueprint_id}/refs 关联边列表 POST /pbl/blueprints/{blueprint_id}/refs 新增(refs 数组批量 / 单条) POST /pbl/blueprints/{blueprint_id}/refs/resolve 引用有效性判定 POST /pbl/blueprints/{blueprint_id}/refs/sync 按 content 同步关联边 DELETE /pbl/refs/{ref_id} 删除关联边 GET /pbl/refs/impact 反查影响面(world 删除前置校验) ``` 异常统一转 `{ok:false, code, message, detail, http_status}`:`PBL_TENANT_REQUIRED`(400) / `PBL_PERMISSION_DENIED`(403) / `PBL_PLATFORM_ADMIN_REQUIRED`(403) / `PBL_NOT_FOUND`(404) / `PBL_CONFLICT`(409) / `PBL_VALIDATION_FAILED`(400) / `PBL_REF_NOT_ALLOWED`(400)。 --- ## 6. 挂载方式 ```python # pbl_blueprint/init.py 的 load_pbl_blueprint() 末尾 from .m1b_init import init_m1b m1b = init_m1b(db=db, register=getattr(app, "register_route", None), seed=True) # m1b = {ok, milestone:'M1b', tables:{...}, routes:{registered:22}, seed:{...}, # q_open_3:'world/scene/entity 基表零改动'} ``` * 建表优先走模块既有模型注册机制(`tables.register_tables` / `init.register_models`),不可用时退回执行 `sql/m1b_ddl.sql`(全 `CREATE TABLE IF NOT EXISTS`,幂等); * M1a 已把 `pbl_blueprint_template.tenant_id` 建为 NOT NULL 时,启用 DDL 文件尾部注释态 ALTER(`MODIFY COLUMN tenant_id NULL` + 补 `is_public/is_builtin/ext_schema/subobject_kinds` 列); * 种子幂等:同 `code`/`(kind, ext_key)` 已存在即跳过,**不覆盖**平台或租户已改内容。 平台公共种子内容:3 个模板(STEM 水质调查 / 人文城市记忆口述史 / 跨学科智慧校园改造,均 `is_builtin=1, status=published`)+ 25 条扩展字段定义(覆盖 7 类子对象 + `any` 通用 tags/notes)。 --- ## 7. 测试 ```bash cd modules/pbl_blueprint python -m pytest tests/test_m1b_ext_ref.py -q # 或 python tests/test_m1b_ext_ref.py ``` 41 个用例,5 组: | 组 | 覆盖 | |---|---| | `TestPlatformTemplate`(11) | 平台写门禁、tenant_id NULL 落库、跨租户可见/隔离、双域编码唯一、fork 解耦、内置禁删、发布校验、实例化租户强制、deprecated 拒绝、种子幂等、缺租户 fail-closed | | `TestSubobjectExt`(13) | 未定义 key 拒绝、kind 白名单、枚举/范围校验、类型强转、upsert 幂等、租户隔离、租户定义遮蔽平台、`any` 通用定义、批量全或无、模板 ext_schema 不覆盖已填、聚合树、删除、在用定义禁删、平台定义写门禁 | | `TestRefTable`(11) | 域-表白名单判定、add_ref 幂等、租户隔离、src_kind 校验、批量全或无、reader 判定 resolved/missing/blocking、无 reader 保守 unknown、反查影响面(跨租户不泄露)、content 抽取去重、sync 幂等+软删、**world 基表零写入** | | `TestApiDispatch`(4) | 路由分发、平台模板 403、缺租户 400、未知路由 404、ext+refs 端到端 | | `TestInitSeed`(2) | `init_m1b` 种子注入 + 幂等 + 不建 world 表;平台种子模板 → 实例化 → ext_schema 落地 → 引用同步全链路 | --- ## 8. 对下游里程碑的接口约定 | 下游 | 使用点 | |---|---| | M2 校验引擎 | `list_ext()` 取扩展值参与 14 维校验;`resolve_refs()` 的 `blocking`(required+missing)作为硬失败项;`list_ext_field_defs()` 的 `required` 定义驱动完整性检查 | | M3 编译器 | `sync_refs_from_content()` 编译前对齐引用;`ref_snapshot` 提供离线编译所需名称/编码;`get_ext()` 取扩展参数注入 Game Definition | | M4 Agent 运行时 | `create_template/update_template` 平台写门禁(`is_platform_admin`)作为工具裁决 fail-closed 依据;`bulk_set_ext` 的全或无语义保证 Agent 写入原子性 | | M5 证据采集 | `artifact_def.evidence_required` / `submit_format` / `max_size_mb` 扩展定义驱动采集策略 | | M6 评估 | `project.assessment_mode`、`learning_goal.bloom_level` 扩展字段供 Rubric 加权 | | M8 domain_ext | `impact_of()` 供 world/scene 删除前置校验(只读,不阻塞基表写) | --- ## 9. 已知边界与后续 1. `resolve_refs` 的 `reader` 需应用层注入跨模块只读查询;未注入时只标 `unknown`,M2 校验不应把 `unknown` 当失败(避免误报)。 2. 平台公共模板升级不回灌已 fork 的租户副本(设计取舍:保护租户改动);如需「平台升级提示」,后续可加 `source_template_id + version` 差异比对接口,不在 M1b 范围。 3. `pbl_blueprint_ref` 不建外键,孤儿边由 `resolve_refs` 标 `missing` + 定期清理任务处理(清理任务不在 M1b 范围)。 4. DDL 中 `tenant_id NULL` 参与的唯一索引在 MySQL 下不去重,平台域唯一性依赖应用层 `_ensure_code_unique` / 定义创建前查重;如后续换 PostgreSQL 可用 `NULLS NOT DISTINCT` 收敛到 DB 层。