pbl_blueprint/docs/M1b-annex-impl.md
2026-09-16 16:25:33 +08:00

15 KiB
Raw Permalink Blame History

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 幂等 DDL4 张表 + 索引),含 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_idNone/空串/空白 → 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=forksource_template_id 记源、status=draftis_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_extkind + 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_typestring/int/float/bool/enum/json/date强转、requiredenum_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 被哪些蓝图引用」,删除前置校验/影响面分析只能全表扫 JSONM2 校验与 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_WHITELISTworld/scene/entity/script_engine/scense_game/drag/org/employee/pbl/external,白名单外 → PBL_REF_NOT_ALLOWED 拒绝落库(防任意表名注入式越权探测);
  • src_kindblueprint 或 7 类子对象;非 blueprint 时 src_id 必填;
  • rel_type ∈ uses/binds/embeds/derives_from/replacescardinality ∈ 1:1/1:n/n:1/n:mrequired=1 供 M2 校验:requiredmissing → 阻断(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 中不存在 worldscene 表写入,仅 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 文件尾部注释态 ALTERMODIFY 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()blockingrequired+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_modelearning_goal.bloom_level 扩展字段供 Rubric 加权
M8 domain_ext impact_of() 供 world/scene 删除前置校验(只读,不阻塞基表写)

9. 已知边界与后续

  1. resolve_refsreader 需应用层注入跨模块只读查询;未注入时只标 unknownM2 校验不应把 unknown 当失败(避免误报)。
  2. 平台公共模板升级不回灌已 fork 的租户副本(设计取舍:保护租户改动);如需「平台升级提示」,后续可加 source_template_id + version 差异比对接口,不在 M1b 范围。
  3. pbl_blueprint_ref 不建外键,孤儿边由 resolve_refsmissing + 定期清理任务处理(清理任务不在 M1b 范围)。
  4. DDL 中 tenant_id NULL 参与的唯一索引在 MySQL 下不去重,平台域唯一性依赖应用层 _ensure_code_unique / 定义创建前查重;如后续换 PostgreSQL 可用 NULLS NOT DISTINCT 收敛到 DB 层。