15 KiB
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()):
[{"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):- 租户 + kind 精确 → 2. 租户 +
any→ 3. 平台 + kind 精确 → 4. 平台 +any
- 租户 + kind 精确 → 2. 租户 +
- 未定义的
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. 挂载方式
# 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. 测试
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. 已知边界与后续
resolve_refs的reader需应用层注入跨模块只读查询;未注入时只标unknown,M2 校验不应把unknown当失败(避免误报)。- 平台公共模板升级不回灌已 fork 的租户副本(设计取舍:保护租户改动);如需「平台升级提示」,后续可加
source_template_id + version差异比对接口,不在 M1b 范围。 pbl_blueprint_ref不建外键,孤儿边由resolve_refs标missing+ 定期清理任务处理(清理任务不在 M1b 范围)。- DDL 中
tenant_id NULL参与的唯一索引在 MySQL 下不去重,平台域唯一性依赖应用层_ensure_code_unique/ 定义创建前查重;如后续换 PostgreSQL 可用NULLS NOT DISTINCT收敛到 DB 层。