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

220 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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()`
```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 被哪些蓝图引用」,删除前置校验/影响面分析只能全表扫 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_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 层。