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

This commit is contained in:
agent.develop 2026-09-16 16:25:33 +08:00
parent 414087744a
commit b1aabe20a2
19 changed files with 4754 additions and 0 deletions

219
docs/M1b-annex-impl.md Normal file
View File

@ -0,0 +1,219 @@
# 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 层。

View File

@ -0,0 +1,46 @@
{
"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
},
"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"}
],
"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 只读查询完成,不阻塞基表写"
]
}

View File

@ -0,0 +1,48 @@
{
"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
},
"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": "软删除标记"}
],
"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"
}

View File

@ -0,0 +1,38 @@
{
"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"
},
"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"}
],
"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"]}
]
}

View File

@ -0,0 +1,99 @@
{
"module": "pbl_blueprint",
"milestone": "M1b",
"authority": [
"projects/pbls/docs/01-design/data-model.md#2.4",
"projects/pbls/docs/01-design/modules/pbl_blueprint.M1b-annex.md#5",
"projects/pbls/docs/01-design/open-questions-resolution.md#Q-OPEN-3"
],
"conventions": {
"foreign_key": false,
"tenant_first_index": true,
"business_key": "xxx_code VARCHAR(32) + uk(tenant, code)",
"code_gen": "pbl_common.gen_code(prefix, tenant_id)"
},
"platform_scope": {
"table": "pbl_template",
"rule": "tenant_id IS NULL => 平台公共模板,全租户只读可见,仅平台管理员可写/归档",
"resolve_order": ["tenant", "platform"],
"sentinel": "__PLATFORM__",
"unique_index_mysql8": "((IFNULL(`tenant_id`,'__PLATFORM__')), `template_code`, `template_version`)",
"unique_index_mysql57_fallback": "(`tenant_key`, `template_code`, `template_version`)"
},
"relation_table_decision": {
"question": "Q-OPEN-3",
"decision": "模板/蓝图对 world/scene/entity/script 的引用一律落关联表 pbl_bp_ref含 ref_status不改复用域基表",
"relation_table": "pbl_bp_ref",
"protected_base_tables": ["world", "scene", "entity", "script", "world_snapshot", "world_sync"],
"alter_allowed": false
},
"tables": [
{
"name": "pbl_template",
"comment": "PBL 模板主表+内容M1btenant_id NULL=平台公共模板",
"append_only": false,
"columns": [
{"name": "id", "type": "BIGINT", "null": false, "auto_increment": true, "comment": "物理主键"},
{"name": "tenant_id", "type": "VARCHAR(32)", "null": true, "comment": "租户NULL=平台公共模板"},
{"name": "template_code", "type": "VARCHAR(32)", "null": false, "comment": "业务主键"},
{"name": "template_version", "type": "INT", "null": false, "default": 1, "comment": "版本号append 不覆盖Q-OPEN-9"},
{"name": "template_name", "type": "VARCHAR(128)", "null": false, "comment": "模板名"},
{"name": "subject", "type": "VARCHAR(32)", "null": true, "dict": "pbl_subject", "comment": "学科"},
{"name": "grade", "type": "VARCHAR(16)", "null": true, "dict": "pbl_grade", "comment": "学段"},
{"name": "tpl_json", "type": "LONGTEXT", "null": false, "comment": "模板内容7 类子对象数组 + 临时 ID 父子引用"},
{"name": "tpl_hash", "type": "VARCHAR(64)", "null": false, "comment": "sha256(tpl_json)"},
{"name": "offline_flag", "type": "VARCHAR(1)", "null": false, "default": "N", "comment": "Y=内置离线兜底模板build.sh 种子)"},
{"name": "tpl_status", "type": "VARCHAR(16)", "null": false, "default": "active", "dict": "pbl_tpl_status", "comment": "active/archived"},
{"name": "create_time", "type": "DATETIME", "null": false, "default": "CURRENT_TIMESTAMP"},
{"name": "update_time", "type": "DATETIME", "null": false, "default": "CURRENT_TIMESTAMP", "on_update": true}
],
"indexes": [
{"name": "uk_tpl_code_ver", "unique": true, "columns": ["IFNULL(tenant_id,'__PLATFORM__')", "template_code", "template_version"]},
{"name": "idx_tpl_status_subject_grade", "unique": false, "columns": ["tenant_id", "tpl_status", "subject", "grade"]},
{"name": "idx_tpl_offline", "unique": false, "columns": ["tenant_id", "offline_flag"]},
{"name": "idx_tpl_hash", "unique": false, "columns": ["tenant_id", "tpl_hash"]},
{"name": "idx_tpl_platform", "unique": false, "columns": ["tpl_status", "offline_flag", "subject", "grade"]}
]
},
{
"name": "pbl_template_instance_log",
"comment": "PBL 模板实例化留痕M1bappend-only",
"append_only": true,
"columns": [
{"name": "id", "type": "BIGINT", "null": false, "auto_increment": true, "comment": "物理主键"},
{"name": "tenant_id", "type": "VARCHAR(32)", "null": false, "comment": "租户"},
{"name": "instance_code", "type": "VARCHAR(32)", "null": false, "comment": "业务主键"},
{"name": "client_request_id", "type": "VARCHAR(64)", "null": false, "comment": "幂等键"},
{"name": "template_code", "type": "VARCHAR(32)", "null": false},
{"name": "template_version", "type": "INT", "null": false},
{"name": "tpl_hash", "type": "VARCHAR(64)", "null": false, "comment": "实例化时模板 hash"},
{"name": "template_scope", "type": "VARCHAR(16)", "null": false, "default": "tenant", "comment": "tenant/platform"},
{"name": "blueprint_id", "type": "VARCHAR(32)", "null": true, "comment": "产出蓝图(无 FK"},
{"name": "created_count", "type": "INT", "null": false, "default": 0},
{"name": "ref_unresolved_count", "type": "INT", "null": false, "default": 0},
{"name": "offline_used", "type": "VARCHAR(1)", "null": false, "default": "N"},
{"name": "instance_status", "type": "VARCHAR(16)", "null": false, "dict": "pbl_instance_status", "comment": "success/partial/failed"},
{"name": "error_code", "type": "VARCHAR(48)", "null": true, "comment": "失败/降级错误码"},
{"name": "operator_id", "type": "VARCHAR(32)", "null": true},
{"name": "create_time", "type": "DATETIME", "null": false, "default": "CURRENT_TIMESTAMP"}
],
"indexes": [
{"name": "uk_inst_code", "unique": true, "columns": ["tenant_id", "instance_code"]},
{"name": "uk_inst_client_req", "unique": true, "columns": ["tenant_id", "client_request_id"]},
{"name": "idx_inst_tpl", "unique": false, "columns": ["tenant_id", "template_code", "template_version"]},
{"name": "idx_inst_bp", "unique": false, "columns": ["tenant_id", "blueprint_id"]},
{"name": "idx_inst_ctime", "unique": false, "columns": ["tenant_id", "create_time"]}
]
}
],
"subobject_types": {
"dict": "pbl_subobject_type",
"source_of_truth": "pbl_blueprint/subobject_ext.py:SUBOBJECT_TYPES",
"types": ["stage", "task", "role", "world_ref", "scene_ref", "entity_ref", "script_ref"]
},
"seed": {
"file": "pbl_blueprint/json/seed_template_offline.json",
"loader": "scripts/seed_m1b_template.py",
"rule": "离线兜底模板必须由 build.sh 种子落库offline_flag='Y'),不依赖运行时下载;升级 append 新 template_version不覆盖旧行"
}
}

View File

@ -0,0 +1,56 @@
{
"_comment": "M1b 离线兜底内置模板种子build.sh 落库offline_flag=Ytenant_id=NULL 平台公共。Q-OPEN-3外部引用仅只读校验落 pbl_bp_ref不改 world/scene/entity/script 基表。加载器scripts/seed_m1b_template.py幂等 upsert by (tenant_key, template_code, template_version))。",
"templates": [
{
"tenant_id": null,
"template_code": "TPL_DEMO_BLUEPRINT",
"template_version": 1,
"template_name": "演示蓝图模板(离线兜底)",
"subject": "general",
"grade": "primary",
"offline_flag": "Y",
"tpl_status": "active",
"tpl_json": {
"schema_version": "1.0",
"template_meta": {
"title": "演示蓝图模板",
"description": "断网/模型 FAIL 时的离线兜底模板1 阶段 + 2 任务 + 2 角色 + world/scene/entity/script 各 1 引用",
"source": "ADDENDUM.md §3 种子清单第 4 项"
},
"subobjects": [
{"tmp_id": "t_stage_1", "subobject_type": "stage", "name": "导入阶段", "seq": 1, "description": "情境导入与目标说明"},
{"tmp_id": "t_task_1", "subobject_type": "task", "parent_tmp_id": "t_stage_1", "name": "观察世界", "seq": 1, "description": "学生进入世界观察环境要素"},
{"tmp_id": "t_task_2", "subobject_type": "task", "parent_tmp_id": "t_stage_1", "name": "完成挑战", "seq": 2, "description": "按角色分工完成挑战并产出证据"},
{"tmp_id": "t_role_1", "subobject_type": "role", "parent_tmp_id": "t_task_2", "name": "探索者", "seq": 1, "description": "负责信息采集"},
{"tmp_id": "t_role_2", "subobject_type": "role", "parent_tmp_id": "t_task_2", "name": "记录者", "seq": 2, "description": "负责产出物记录"},
{"tmp_id": "t_world_1", "subobject_type": "world_ref", "parent_tmp_id": "t_task_1", "world_code": "W_DEMO_0001", "seq": 1},
{"tmp_id": "t_scene_1", "subobject_type": "scene_ref", "parent_tmp_id": "t_task_1", "scene_code": "SC_DEMO_0001", "seq": 2},
{"tmp_id": "t_entity_1", "subobject_type": "entity_ref", "parent_tmp_id": "t_scene_1", "entity_code": "EN_DEMO_0001", "seq": 1},
{"tmp_id": "t_script_1", "subobject_type": "script_ref", "parent_tmp_id": "t_task_2", "script_code": "SP_DEMO_0001", "seq": 3}
]
}
},
{
"tenant_id": null,
"template_code": "TPL_DEMO_BLUEPRINT_MIN",
"template_version": 1,
"template_name": "演示蓝图模板(最小无外部引用版)",
"subject": "general",
"grade": "primary",
"offline_flag": "Y",
"tpl_status": "active",
"tpl_json": {
"schema_version": "1.0",
"template_meta": {
"title": "最小演示蓝图模板",
"description": "零外部引用,冷启动/纯净 test 环境也可成功实例化ref_unresolved_count=0"
},
"subobjects": [
{"tmp_id": "t_stage_1", "subobject_type": "stage", "name": "唯一阶段", "seq": 1},
{"tmp_id": "t_task_1", "subobject_type": "task", "parent_tmp_id": "t_stage_1", "name": "唯一任务", "seq": 1},
{"tmp_id": "t_role_1", "subobject_type": "role", "parent_tmp_id": "t_task_1", "name": "唯一角色", "seq": 1}
]
}
}
]
}

309
pbl_blueprint/m1b_api.py Normal file
View File

@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
"""M1b API 层:模板平台公共部分 / 子对象扩展 / 关联表。
约定
----
* 每个 handler 第一行取 tenant_id来自登录上下文 params['tenant_id'] session
缺失直接 400 fail-closed绝不做全租户扫描
* ``is_platform_admin`` 只信 RBAC 判定结果由应用层注入 params不接受前端自述
* 返回统一 {ok, ...} / 异常转 {ok: False, code, message}
路由挂在模块 api 前缀下
GET /pbl/templates 列表本租户 + 平台公共
POST /pbl/templates 创建scope=platform 需平台管理员
GET /pbl/templates/{id} 详情
PUT /pbl/templates/{id} 更新
DEL /pbl/templates/{id} 软删内置禁删
POST /pbl/templates/{id}/publish 发布
POST /pbl/templates/{id}/deprecate 停用
POST /pbl/templates/{id}/fork 派生为租户私有
POST /pbl/templates/{id}/instantiate 实例化为蓝图
GET /pbl/ext-defs 扩展字段定义列表
POST /pbl/ext-defs 创建定义
PUT /pbl/ext-defs/{id} 更新定义
DEL /pbl/ext-defs/{id} 删除定义
GET /pbl/blueprints/{bp}/ext 蓝图扩展值按子对象聚合
PUT /pbl/blueprints/{bp}/ext 批量写扩展值
DEL /pbl/blueprints/{bp}/ext 删扩展值
GET /pbl/blueprints/{bp}/refs 关联边列表
POST /pbl/blueprints/{bp}/refs 新增/批量新增关联边
DEL /pbl/refs/{id} 删除关联边
POST /pbl/blueprints/{bp}/refs/resolve 引用有效性判定
POST /pbl/blueprints/{bp}/refs/sync content 同步关联边
GET /pbl/refs/impact 反查影响面 world 删除前置校验
"""
from .m1b_common import PblM1bError, require_tenant
from . import m1b_template as T
from . import m1b_subobject as S
from . import m1b_ref as R
__all__ = ["M1B_ROUTES", "dispatch", "register_m1b_routes"]
def _ctx(params):
params = dict(params or {})
tenant_id = params.get("tenant_id") or params.get("tenantId")
actor = params.get("actor") or params.get("user_id") or params.get("operator")
is_admin = params.get("is_platform_admin")
if isinstance(is_admin, str):
is_admin = is_admin.strip().lower() in ("1", "true", "yes")
return tenant_id, actor, bool(is_admin), params
def _ok(**kw):
out = {"ok": True}
out.update(kw)
return out
# --------------------------------------------------------------------------
# handlers
# --------------------------------------------------------------------------
def api_list_templates(db, params):
tenant_id, _actor, _adm, p = _ctx(params)
return T.list_templates(db, tenant_id,
include_platform=p.get("include_platform", True) not in (False, "false", 0),
status=p.get("status"), category=p.get("category"),
keyword=p.get("keyword"),
limit=int(p.get("limit") or 100), offset=int(p.get("offset") or 0))
def api_get_template(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return _ok(template=T.get_template(db, tenant_id, p.get("template_id") or p.get("id")))
def api_create_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
payload.pop("tenant_id", None)
return T.create_template(db, tenant_id, payload, actor=actor, is_platform_admin=adm)
def api_update_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
for k in ("tenant_id", "template_id", "id", "actor", "is_platform_admin"):
payload.pop(k, None)
return T.update_template(db, tenant_id, p.get("template_id") or p.get("id"), payload,
actor=actor, is_platform_admin=adm)
def api_delete_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
return T.delete_template(db, tenant_id, p.get("template_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_publish_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
return T.publish_template(db, tenant_id, p.get("template_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_deprecate_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
return T.deprecate_template(db, tenant_id, p.get("template_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_fork_template(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return T.fork_template(db, tenant_id, p.get("template_id") or p.get("id"), actor=actor,
new_code=p.get("new_code"), new_name=p.get("new_name"))
def api_instantiate_template(db, params):
tenant_id, actor, _adm, p = _ctx(params)
create_blueprint = p.get("create_blueprint")
if not callable(create_blueprint):
try:
from .blueprint_crud import create_blueprint as _cb # type: ignore
create_blueprint = _cb
except Exception:
create_blueprint = None
return T.instantiate_template(db, tenant_id, p.get("template_id") or p.get("id"),
blueprint_payload=p.get("payload") or p.get("blueprint") or {},
actor=actor, create_blueprint=create_blueprint)
def api_list_ext_defs(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return S.list_ext_field_defs(db, tenant_id, kind=p.get("kind") or p.get("subobject_kind"),
include_platform=p.get("include_platform", True) not in (False, "false", 0))
def api_create_ext_def(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
payload.pop("tenant_id", None)
return S.create_ext_field_def(db, tenant_id, payload, actor=actor, is_platform_admin=adm)
def api_update_ext_def(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
for k in ("tenant_id", "def_id", "id", "actor", "is_platform_admin"):
payload.pop(k, None)
return S.update_ext_field_def(db, tenant_id, p.get("def_id") or p.get("id"), payload,
actor=actor, is_platform_admin=adm)
def api_delete_ext_def(db, params):
tenant_id, actor, adm, p = _ctx(params)
return S.delete_ext_field_def(db, tenant_id, p.get("def_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_get_blueprint_ext(db, params):
tenant_id, _a, _adm, p = _ctx(params)
bp = p.get("blueprint_id") or p.get("bp_id")
if p.get("flat"):
return S.list_ext(db, tenant_id, blueprint_id=bp, kind=p.get("kind"),
subobject_id=p.get("subobject_id"), version_id=p.get("version_id"))
return S.subobject_tree_ext(db, tenant_id, bp, version_id=p.get("version_id"))
def api_set_blueprint_ext(db, params):
tenant_id, actor, _adm, p = _ctx(params)
bp = p.get("blueprint_id") or p.get("bp_id")
kind = p.get("kind") or p.get("subobject_kind")
sid = p.get("subobject_id")
values = p.get("values")
if isinstance(values, dict) and values:
return S.bulk_set_ext(db, tenant_id, bp, kind, sid, values, actor=actor,
source=p.get("source") or "manual", version_id=p.get("version_id"),
source_template_id=p.get("source_template_id"))
return S.set_ext(db, tenant_id, bp, kind, sid, p.get("ext_key"), p.get("value"),
actor=actor, source=p.get("source") or "manual",
version_id=p.get("version_id"),
source_template_id=p.get("source_template_id"))
def api_delete_blueprint_ext(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return S.delete_ext(db, tenant_id, p.get("blueprint_id") or p.get("bp_id"),
p.get("kind") or p.get("subobject_kind"), p.get("subobject_id"),
ext_key=p.get("ext_key"), actor=actor)
def api_list_refs(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return R.list_refs(db, tenant_id, blueprint_id=p.get("blueprint_id") or p.get("bp_id"),
ref_domain=p.get("ref_domain"), ref_table=p.get("ref_table"),
ref_id=p.get("ref_id"), src_kind=p.get("src_kind"),
src_id=p.get("src_id"), rel_type=p.get("rel_type"),
resolve_status=p.get("resolve_status"), version_id=p.get("version_id"),
limit=int(p.get("limit") or 500))
def api_add_refs(db, params):
tenant_id, actor, _adm, p = _ctx(params)
bp = p.get("blueprint_id") or p.get("bp_id")
refs = p.get("refs")
if isinstance(refs, list) and refs:
return R.bulk_add_refs(db, tenant_id, bp, refs, actor=actor)
payload = dict(p.get("ref") or p)
for k in ("tenant_id", "blueprint_id", "bp_id", "actor", "refs", "ref"):
payload.pop(k, None)
return R.add_ref(db, tenant_id, bp, payload, actor=actor)
def api_remove_ref(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return R.remove_ref(db, tenant_id, p.get("ref_id") or p.get("id"), actor=actor)
def api_resolve_refs(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return R.resolve_refs(db, tenant_id, blueprint_id=p.get("blueprint_id") or p.get("bp_id"),
actor=actor, reader=p.get("reader") if callable(p.get("reader")) else None)
def api_sync_refs(db, params):
tenant_id, actor, _adm, p = _ctx(params)
content = p.get("content")
if content is None:
content = (p.get("blueprint") or {}).get("content")
return R.sync_refs_from_content(db, tenant_id, p.get("blueprint_id") or p.get("bp_id"),
content, actor=actor, version_id=p.get("version_id"))
def api_ref_impact(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return R.impact_of(db, tenant_id, p.get("ref_domain"), p.get("ref_table"), p.get("ref_id"))
# --------------------------------------------------------------------------
# 路由表 + 分发
# --------------------------------------------------------------------------
M1B_ROUTES = [
("GET", "/pbl/templates", api_list_templates),
("POST", "/pbl/templates", api_create_template),
("GET", "/pbl/templates/{template_id}", api_get_template),
("PUT", "/pbl/templates/{template_id}", api_update_template),
("DELETE", "/pbl/templates/{template_id}", api_delete_template),
("POST", "/pbl/templates/{template_id}/publish", api_publish_template),
("POST", "/pbl/templates/{template_id}/deprecate", api_deprecate_template),
("POST", "/pbl/templates/{template_id}/fork", api_fork_template),
("POST", "/pbl/templates/{template_id}/instantiate", api_instantiate_template),
("GET", "/pbl/ext-defs", api_list_ext_defs),
("POST", "/pbl/ext-defs", api_create_ext_def),
("PUT", "/pbl/ext-defs/{def_id}", api_update_ext_def),
("DELETE", "/pbl/ext-defs/{def_id}", api_delete_ext_def),
("GET", "/pbl/blueprints/{blueprint_id}/ext", api_get_blueprint_ext),
("PUT", "/pbl/blueprints/{blueprint_id}/ext", api_set_blueprint_ext),
("DELETE", "/pbl/blueprints/{blueprint_id}/ext", api_delete_blueprint_ext),
("GET", "/pbl/blueprints/{blueprint_id}/refs", api_list_refs),
("POST", "/pbl/blueprints/{blueprint_id}/refs", api_add_refs),
("POST", "/pbl/blueprints/{blueprint_id}/refs/resolve", api_resolve_refs),
("POST", "/pbl/blueprints/{blueprint_id}/refs/sync", api_sync_refs),
("DELETE", "/pbl/refs/{ref_id}", api_remove_ref),
("GET", "/pbl/refs/impact", api_ref_impact),
]
def dispatch(db, method, path, params=None):
"""轻量分发(供应用层/测试直接调用)。未命中 → 404 结构。"""
method = (method or "GET").upper()
path = (path or "").split("?")[0].rstrip("/") or "/"
for m, pat, fn in M1B_ROUTES:
if m != method:
continue
pseg, rseg = pat.rstrip("/").split("/")[1:], path.split("/")[1:]
if len(pseg) != len(rseg):
continue
args = {}
matched = True
for a, b in zip(pseg, rseg):
if a.startswith("{") and a.endswith("}"):
args[a[1:-1]] = b
elif a != b:
matched = False
break
if not matched:
continue
merged = dict(params or {})
merged.update(args)
try:
return fn(db, merged)
except PblM1bError as e:
return {"ok": False, "code": e.code, "message": e.message,
"detail": e.detail, "http_status": e.http_status}
return {"ok": False, "code": "PBL_ROUTE_NOT_FOUND", "message": "no route: %s %s" % (method, path),
"http_status": 404}
def register_m1b_routes(app=None, register=None):
"""把 M1B_ROUTES 注册到应用init.py 调用。register(method, path, handler) 由应用层提供。"""
if not callable(register):
return {"ok": False, "registered": 0, "reason": "register callback not provided"}
n = 0
for m, pat, fn in M1B_ROUTES:
try:
register(m, pat, fn)
n += 1
except Exception:
continue
return {"ok": True, "registered": n}

335
pbl_blueprint/m1b_common.py Normal file
View File

@ -0,0 +1,335 @@
# -*- coding: utf-8 -*-
"""pbl_blueprint M1b 公共内核。
服务于 M1b 三块能力
1) 模板平台公共部分pbl_blueprint_template.tenant_id IS NULL == 平台公共模板
2) 子对象扩展7 类泛化子对象 + 扩展字段定义表 pbl_subobject_ext
3) 关联表判定与落地Q-OPEN-3不改 world 跨域引用走 pbl_blueprint_ref
沿用 M1a 的硬约束
* 所有读写 tenant_id 强制打头缺失/ fail-closed 直接拒绝绝不做全租户扫描
* tenant_id IS NULL 只对平台公共数据合法当前仅模板与扩展字段定义
租户侧只读写操作必须显式携带 is_platform_admin=True API/RBAC 层判定注入
* 审计失败不阻断业务best-effort
本文件不依赖具体 DB 驱动通过 select/insert/update/delete 四个适配函数兼容
模块内 db.py 适配器与测试用 FakeDB
"""
import datetime
import json
import os
import uuid
__all__ = [
"IS_NULL", "PLATFORM_TENANT",
"PblM1bError", "TenantRequiredError", "PermissionDeniedError",
"NotFoundError", "ValidationError", "ConflictError",
"require_tenant", "require_actor",
"select", "select_one", "insert", "update", "delete", "sql_where",
"new_id", "now_iso", "as_dict", "write_audit", "pkg_json_path", "load_pkg_json",
]
# --------------------------------------------------------------------------
# 平台公共数据哨兵
# --------------------------------------------------------------------------
class _IsNull(object):
"""查询条件哨兵:表示 `tenant_id IS NULL`(平台公共数据)。
内存适配器直接按 None/"" 匹配真实 SQL 适配器请用 sql_where() 翻译成 IS NULL
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(_IsNull, cls).__new__(cls)
return cls._instance
def __repr__(self):
return "IS_NULL"
def __eq__(self, other):
if isinstance(other, _IsNull):
return True
return other is None or other == ""
def __ne__(self, other):
return not self.__eq__(other)
def __hash__(self):
return hash("__PBL_IS_NULL__")
IS_NULL = _IsNull()
PLATFORM_TENANT = None # 落库时平台公共数据 tenant_id 写 NULL
# --------------------------------------------------------------------------
# 错误
# --------------------------------------------------------------------------
class PblM1bError(Exception):
code = "PBL_M1B_ERROR"
http_status = 400
def __init__(self, message, code=None, **detail):
super(PblM1bError, self).__init__(message)
self.message = message
if code:
self.code = code
self.detail = detail
def to_dict(self):
return {"ok": False, "code": self.code, "message": self.message, "detail": self.detail}
class TenantRequiredError(PblM1bError):
code = "PBL_TENANT_REQUIRED"
http_status = 400
class PermissionDeniedError(PblM1bError):
code = "PBL_PERMISSION_DENIED"
http_status = 403
class NotFoundError(PblM1bError):
code = "PBL_NOT_FOUND"
http_status = 404
class ValidationError(PblM1bError):
code = "PBL_VALIDATION_FAILED"
http_status = 400
class ConflictError(PblM1bError):
code = "PBL_CONFLICT"
http_status = 409
# 若模块已有错误基类,则挂到其继承链上,保证 API 层统一异常处理仍然生效
try: # pragma: no cover - 依赖既有模块
from .errors import PblError as _BasePblError # type: ignore
if isinstance(_BasePblError, type) and issubclass(_BasePblError, Exception):
PblM1bError.__bases__ = (_BasePblError,)
except Exception: # pragma: no cover
pass
def require_tenant(tenant_id):
"""租户上下文强制校验缺失即拒绝fail-closed"""
if tenant_id is None:
raise TenantRequiredError("tenant_id is required (fail-closed): 拒绝无租户上下文的读写")
if isinstance(tenant_id, str) and not tenant_id.strip():
raise TenantRequiredError("tenant_id is required (fail-closed): 空字符串视同缺失")
if isinstance(tenant_id, _IsNull):
raise TenantRequiredError("tenant_id 不能用 IS_NULL 哨兵作为调用方租户上下文")
return tenant_id
def require_actor(actor, allow_system=False):
"""写操作必须带操作人allow_system=True 时接受 'system'"""
if actor is None or (isinstance(actor, str) and not actor.strip()):
raise PermissionDeniedError("actor is required for write operations")
if not allow_system and str(actor).strip().lower() in ("system", "anonymous"):
raise PermissionDeniedError("actor 'system/anonymous' 不允许执行平台级写操作")
return str(actor)
# --------------------------------------------------------------------------
# DB 适配(兼容 query/select + insert/update/delete 与内存 FakeDB
# --------------------------------------------------------------------------
def _match(row, conds):
for k, v in (conds or {}).items():
rv = row.get(k)
if isinstance(v, _IsNull):
if not (rv is None or rv == ""):
return False
elif rv != v:
return False
return True
def _memory_rows(db, table):
"""从内存型 dbFakeDB取原始行用于适配器不支持条件查询时兜底过滤。"""
for attr in ("rows", "table_rows", "_rows", "all"):
fn = getattr(db, attr, None)
if callable(fn):
try:
return list(fn(table) or [])
except Exception:
continue
tables = getattr(db, "tables", None) or getattr(db, "_tables", None)
if isinstance(tables, dict):
return list(tables.get(table) or [])
return None
def select(db, table, conds=None, order_by=None, limit=None):
"""统一读接口。conds 中可用 IS_NULL 表达 `col IS NULL`。"""
if db is None:
raise PblM1bError("db is required")
conds = dict(conds or {})
rows = None
fn = getattr(db, "query", None) or getattr(db, "select", None) or getattr(db, "find", None)
if callable(fn):
try:
rows = fn(table, conds, order_by=order_by, limit=limit)
except TypeError:
try:
rows = fn(table, conds)
except TypeError:
rows = fn(table, **conds)
except NotImplementedError:
rows = None
if rows is None:
mem = _memory_rows(db, table)
rows = [] if mem is None else mem
rows = [dict(r) for r in (rows or [])]
# 适配器可能忽略条件/排序/分页 → 内存侧再兜一次,保证语义正确
rows = [r for r in rows if _match(r, conds)]
if order_by:
desc = str(order_by).startswith("-")
key = str(order_by).lstrip("+-")
rows.sort(key=lambda r: (r.get(key) is None, r.get(key)), reverse=desc)
if limit is not None:
rows = rows[: int(limit)]
return rows
def select_one(db, table, conds=None, order_by=None):
rows = select(db, table, conds, order_by=order_by, limit=1)
return rows[0] if rows else None
def insert(db, table, row):
row = dict(row or {})
fn = getattr(db, "insert", None) or getattr(db, "add", None) or getattr(db, "create", None)
if callable(fn):
try:
return fn(table, row)
except TypeError:
return fn(table, **row)
mem = _memory_rows(db, table)
if mem is not None:
tables = getattr(db, "tables", None) or getattr(db, "_tables", None)
if isinstance(tables, dict):
tables.setdefault(table, []).append(row)
return row.get("id")
raise PblM1bError("db adapter has no insert(table, row)")
def update(db, table, conds, values):
conds = dict(conds or {})
values = dict(values or {})
fn = getattr(db, "update", None) or getattr(db, "set", None)
if callable(fn):
try:
return fn(table, conds, values)
except TypeError:
return fn(table, where=conds, values=values)
raise PblM1bError("db adapter has no update(table, conds, values)")
def delete(db, table, conds):
conds = dict(conds or {})
fn = getattr(db, "delete", None) or getattr(db, "remove", None)
if callable(fn):
try:
return fn(table, conds)
except TypeError:
return fn(table, where=conds)
raise PblM1bError("db adapter has no delete(table, conds)")
def sql_where(conds):
"""把 conds 翻译成 SQL 片段,供真实 SQL 适配器使用IS_NULL → IS NULL"""
clauses, params = [], []
for k, v in (conds or {}).items():
if isinstance(v, _IsNull):
clauses.append("%s IS NULL" % k)
else:
clauses.append("%s = %%s" % k)
params.append(v)
return (" AND ".join(clauses), params)
# --------------------------------------------------------------------------
# 工具
# --------------------------------------------------------------------------
def new_id(prefix):
return "%s-%s" % (prefix, uuid.uuid4().hex[:20])
def now_iso():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def as_dict(value, default=None):
"""JSON 列兼容str/bytes → dict|list已是容器则原样返回。"""
if value is None:
return default
if isinstance(value, (dict, list)):
return value
if isinstance(value, (bytes, bytearray)):
try:
value = value.decode("utf-8")
except Exception:
return default
if isinstance(value, str):
s = value.strip()
if not s:
return default
try:
return json.loads(s)
except Exception:
return default
return default
def write_audit(db, tenant_id, action, obj_type, obj_id, actor=None, detail=None):
"""best-effort 审计:失败不阻断业务。"""
try:
from . import audit as _audit # type: ignore
fn = None
for name in ("write_audit", "audit", "log_audit", "record", "log"):
cand = getattr(_audit, name, None)
if callable(cand):
fn = cand
break
if fn is None:
return None
payload = {
"tenant_id": tenant_id,
"action": action,
"obj_type": obj_type,
"obj_id": obj_id,
"actor": actor,
"detail": detail,
}
try:
return fn(db, **payload)
except TypeError:
return fn(db, tenant_id, action, obj_type, obj_id, actor, detail)
except Exception:
return None
def pkg_json_path(*parts):
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, "json", *parts)
def load_pkg_json(*parts):
path = pkg_json_path(*parts)
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None

341
pbl_blueprint/m1b_init.py Normal file
View File

@ -0,0 +1,341 @@
# -*- coding: utf-8 -*-
"""M1b 挂载入口:建表(幂等)+ 平台公共种子注入 + 路由注册。
由模块 init.py load_pbl_blueprint() 流程末尾调用
from .m1b_init import init_m1b
init_m1b(env_or_db, register=app.register_route if app else None)
设计
* 建表走模块既有 tables/json 模型机制若不可用则退回执行 sql/m1b_ddl.sql幂等 DDL
* 平台公共模板/字段定义种子 tenant_id NULL幂等 code/ext_key 已存在则跳过
* Q-OPEN-3本函数**不触碰** world/scene/entity 等基表不做任何 ALTER
"""
import json
import os
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"]
_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")
# --------------------------------------------------------------------------
# 平台公共扩展字段定义种子tenant_id NULL全租户可用
# --------------------------------------------------------------------------
PLATFORM_EXT_DEFS = [
# 驱动问题
{"subobject_kind": "driving_question", "ext_key": "cognitive_level",
"label": "认知层级", "value_type": "enum", "seq": 10,
"enum_options": ["remember", "understand", "apply", "analyze", "evaluate", "create"]},
{"subobject_kind": "driving_question", "ext_key": "real_world_anchor",
"label": "真实情境锚点", "value_type": "string", "seq": 20,
"constraints": {"max_length": 500}},
{"subobject_kind": "driving_question", "ext_key": "open_ended",
"label": "是否开放性问题", "value_type": "bool", "seq": 30, "default_value": True},
# 任务
{"subobject_kind": "mission", "ext_key": "estimated_minutes",
"label": "预计耗时(分钟)", "value_type": "int", "seq": 10,
"constraints": {"min": 1, "max": 100000}},
{"subobject_kind": "mission", "ext_key": "difficulty",
"label": "难度", "value_type": "enum", "seq": 20,
"enum_options": ["easy", "medium", "hard", "expert"]},
{"subobject_kind": "mission", "ext_key": "prerequisite_mission_ids",
"label": "前置任务", "value_type": "json", "seq": 30, "default_value": []},
{"subobject_kind": "mission", "ext_key": "scene_binding",
"label": "绑定场景说明", "value_type": "string", "seq": 40},
# 角色
{"subobject_kind": "role", "ext_key": "min_players",
"label": "最少人数", "value_type": "int", "seq": 10,
"constraints": {"min": 0, "max": 200}, "default_value": 1},
{"subobject_kind": "role", "ext_key": "max_players",
"label": "最多人数", "value_type": "int", "seq": 20,
"constraints": {"min": 0, "max": 200}, "default_value": 1},
{"subobject_kind": "role", "ext_key": "responsibilities",
"label": "职责清单", "value_type": "json", "seq": 30, "default_value": []},
# 学习者
{"subobject_kind": "learner", "ext_key": "prior_knowledge",
"label": "先备知识", "value_type": "json", "seq": 10, "default_value": []},
{"subobject_kind": "learner", "ext_key": "grade_level",
"label": "年级", "value_type": "string", "seq": 20},
{"subobject_kind": "learner", "ext_key": "accessibility_needs",
"label": "无障碍需求", "value_type": "string", "seq": 30},
# 产出物
{"subobject_kind": "artifact_def", "ext_key": "submit_format",
"label": "提交格式", "value_type": "enum", "seq": 10,
"enum_options": ["doc", "slide", "video", "model3d", "code", "dataset", "poster", "other"]},
{"subobject_kind": "artifact_def", "ext_key": "max_size_mb",
"label": "大小上限(MB)", "value_type": "int", "seq": 20,
"constraints": {"min": 1, "max": 10240}},
{"subobject_kind": "artifact_def", "ext_key": "evidence_required",
"label": "是否需证据留存", "value_type": "bool", "seq": 30, "default_value": True},
# 问题
{"subobject_kind": "problem", "ext_key": "sub_problems",
"label": "子问题", "value_type": "json", "seq": 10, "default_value": []},
{"subobject_kind": "problem", "ext_key": "stakeholders",
"label": "利益相关方", "value_type": "json", "seq": 20, "default_value": []},
# 项目
{"subobject_kind": "project", "ext_key": "total_hours",
"label": "总课时", "value_type": "int", "seq": 10, "constraints": {"min": 1, "max": 2000}},
{"subobject_kind": "project", "ext_key": "assessment_mode",
"label": "评估方式", "value_type": "enum", "seq": 20,
"enum_options": ["rubric", "peer", "teacher", "self", "mixed"]},
{"subobject_kind": "project", "ext_key": "world_binding",
"label": "关联世界说明", "value_type": "string", "seq": 30},
# 学习目标
{"subobject_kind": "learning_goal", "ext_key": "bloom_level",
"label": "布鲁姆层级", "value_type": "enum", "seq": 10,
"enum_options": ["remember", "understand", "apply", "analyze", "evaluate", "create"]},
{"subobject_kind": "learning_goal", "ext_key": "measurable",
"label": "可测量", "value_type": "bool", "seq": 20, "default_value": True},
# 通用any
{"subobject_kind": "any", "ext_key": "tags",
"label": "标签", "value_type": "json", "seq": 900, "default_value": []},
{"subobject_kind": "any", "ext_key": "notes",
"label": "备注", "value_type": "string", "seq": 910,
"constraints": {"max_length": 2000}},
]
# 平台公共模板种子tenant_id NULL——离线兜底保证无网络/无 Designer Agent 也可用
PLATFORM_TEMPLATES = [
{
"code": "pbl-stem-water-quality",
"name": "STEM校园周边水质调查",
"category": "stem",
"subject_tags": ["science", "math", "civics"],
"grade_range": "G6-G9",
"duration_hours": 16,
"summary": "以真实水质问题驱动,学生分组采样、检测、建模并提出治理方案。",
"subobject_kinds": ["driving_question", "mission", "role", "learner",
"artifact_def", "problem", "project"],
"ext_schema": {
"driving_question": {"cognitive_level": "analyze", "open_ended": True},
"mission": {"difficulty": "medium", "estimated_minutes": 90},
"role": {"min_players": 2, "max_players": 4},
"artifact_def": {"submit_format": "doc", "evidence_required": True},
"project": {"total_hours": 16, "assessment_mode": "rubric"},
"any": {"tags": ["stem", "fieldwork"]},
},
"content": {
"driving_question": [{"id": "dq-1", "text": "我们身边的水体是否安全?如何用数据说服社区采取行动?"}],
"missions": [
{"id": "ms-1", "name": "现场采样与记录", "seq": 1},
{"id": "ms-2", "name": "指标检测与数据整理", "seq": 2},
{"id": "ms-3", "name": "治理方案设计与路演", "seq": 3}
],
"roles": [
{"id": "rl-1", "name": "采样员"},
{"id": "rl-2", "name": "数据分析师"},
{"id": "rl-3", "name": "方案设计师"},
{"id": "rl-4", "name": "社区沟通官"}
],
"artifacts": [
{"id": "af-1", "name": "水质检测报告"},
{"id": "af-2", "name": "治理方案海报"}
],
"problems": [{"id": "pb-1", "text": "检测指标超标时的归因与不确定性表达"}],
"project": {"id": "pj-1", "name": "校园周边水质调查", "total_hours": 16}
},
},
{
"code": "pbl-humanities-city-memory",
"name": "人文|城市记忆口述史",
"category": "humanities",
"subject_tags": ["history", "language", "arts"],
"grade_range": "G7-G10",
"duration_hours": 12,
"summary": "通过访谈与档案检索,重建一条街区变迁史,产出数字故事。",
"subobject_kinds": ["driving_question", "mission", "role", "learner",
"artifact_def", "problem", "project"],
"ext_schema": {
"driving_question": {"cognitive_level": "evaluate", "open_ended": True},
"mission": {"difficulty": "medium", "estimated_minutes": 120},
"artifact_def": {"submit_format": "video", "evidence_required": True},
"project": {"total_hours": 12, "assessment_mode": "mixed"},
"any": {"tags": ["humanities", "oral-history"]},
},
"content": {
"driving_question": [{"id": "dq-1", "text": "谁的记忆构成了这座城市?被遗忘的声音如何被记录?"}],
"missions": [
{"id": "ms-1", "name": "档案检索与线索整理", "seq": 1},
{"id": "ms-2", "name": "口述访谈实施", "seq": 2},
{"id": "ms-3", "name": "数字故事剪辑与展映", "seq": 3}
],
"roles": [
{"id": "rl-1", "name": "访谈者"},
{"id": "rl-2", "name": "档案研究员"},
{"id": "rl-3", "name": "剪辑师"}
],
"artifacts": [{"id": "af-1", "name": "口述史数字故事片"}],
"problems": [{"id": "pb-1", "text": "受访者记忆冲突时的史料互证"}],
"project": {"id": "pj-1", "name": "城市记忆口述史", "total_hours": 12}
},
},
{
"code": "pbl-interdisciplinary-smart-campus",
"name": "跨学科|智慧校园改造提案",
"category": "interdisciplinary",
"subject_tags": ["it", "math", "design", "civics"],
"grade_range": "G8-G12",
"duration_hours": 24,
"summary": "结合传感数据与用户调研,为校园提出可落地的智能化改造方案。",
"subobject_kinds": ["driving_question", "mission", "role", "learner",
"artifact_def", "problem", "project", "learning_goal"],
"ext_schema": {
"driving_question": {"cognitive_level": "create", "open_ended": True},
"mission": {"difficulty": "hard", "estimated_minutes": 180},
"role": {"min_players": 3, "max_players": 5},
"artifact_def": {"submit_format": "slide", "evidence_required": True},
"project": {"total_hours": 24, "assessment_mode": "rubric"},
"learning_goal": {"bloom_level": "create", "measurable": True},
"any": {"tags": ["interdisciplinary", "design-thinking"]},
},
"content": {
"driving_question": [{"id": "dq-1", "text": "如何用有限预算让校园更节能、更安全、更好用?"}],
"missions": [
{"id": "ms-1", "name": "现状调研与数据采集", "seq": 1},
{"id": "ms-2", "name": "方案设计与成本测算", "seq": 2},
{"id": "ms-3", "name": "原型验证", "seq": 3},
{"id": "ms-4", "name": "提案答辩", "seq": 4}
],
"roles": [
{"id": "rl-1", "name": "项目经理"},
{"id": "rl-2", "name": "数据工程师"},
{"id": "rl-3", "name": "交互设计师"},
{"id": "rl-4", "name": "成本分析师"}
],
"artifacts": [
{"id": "af-1", "name": "改造提案书"},
{"id": "af-2", "name": "成本测算表"},
{"id": "af-3", "name": "原型演示"}
],
"problems": [{"id": "pb-1", "text": "多目标冲突(节能 vs 舒适 vs 成本)的权衡"}],
"project": {"id": "pj-1", "name": "智慧校园改造提案", "total_hours": 24},
"learning_goals": [{"id": "lg-1", "text": "能用数据支撑工程决策并公开答辩"}]
},
},
]
# --------------------------------------------------------------------------
# 建表
# --------------------------------------------------------------------------
def ensure_m1b_tables(db=None, env=None, executor=None):
"""幂等建表:优先用模块既有模型注册机制,退回执行 DDL 文件。
executor: 可选 ``executor(sql_text)``由应用层注入真实 DB 连接
executor db 为内存适配器时仅登记模型文件内存表按需自动创建
"""
models = []
for f in M1B_MODEL_FILES:
if os.path.exists(f):
try:
with open(f, "r", encoding="utf-8") as fp:
models.append(json.load(fp))
except Exception:
continue
result = {"ok": True, "models": [m.get("table") for m in models], "ddl": None, "executed": False}
# 1) 尝试模块既有注册机制
registered = False
for mod_name, fn_name in (("tables", "register_tables"), ("tables", "ensure_tables"),
("init", "register_models")):
try:
mod = __import__("pbl_blueprint.%s" % mod_name, fromlist=[fn_name])
fn = getattr(mod, fn_name, None)
if callable(fn):
fn(models)
registered = True
break
except Exception:
continue
result["registered_via_module"] = registered
# 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
def ensure_platform_ext_defs(db, actor="system"):
"""幂等注入平台公共扩展字段定义tenant_id NULL"""
created, skipped = [], []
for item in PLATFORM_EXT_DEFS:
exist = select_one(db, S.EXT_DEF_TABLE, {
"tenant_id": IS_NULL, "subobject_kind": item["subobject_kind"],
"ext_key": item["ext_key"], "deleted": 0})
if exist:
skipped.append(exist.get("id"))
continue
payload = dict(item)
payload["scope"] = "platform"
payload.setdefault("is_public", 1)
payload.setdefault("is_builtin", 1)
payload.setdefault("status", "active")
try:
res = S.create_ext_field_def(db, None, payload, actor=actor or "system",
is_platform_admin=True)
created.append(res.get("id"))
except Exception:
skipped.append(item.get("ext_key"))
return {"ok": True, "created_count": len(created), "skipped_count": len(skipped),
"created": created, "skipped": skipped}
def seed_platform_data(db, templates=None, actor="system"):
"""平台公共数据种子模板tenant_id NULL+ 扩展字段定义tenant_id NULL。幂等。"""
tpl_res = T.ensure_platform_seed(db, seed_rows=templates if templates is not None
else PLATFORM_TEMPLATES, actor=actor)
def_res = ensure_platform_ext_defs(db, actor=actor)
return {"ok": True, "templates": tpl_res, "ext_defs": def_res}
def init_m1b(db=None, env=None, register=None, executor=None, seed=True, actor="system"):
"""M1b 总入口:建表 → 注册路由 → 注入平台公共种子。
不触碰 world/scene/entity 基表Q-OPEN-3
"""
tables = ensure_m1b_tables(db=db, env=env, executor=executor)
routes = {"ok": False, "registered": 0}
if callable(register):
try:
from .m1b_api import register_m1b_routes
routes = register_m1b_routes(register=register)
except Exception as e:
routes = {"ok": False, "registered": 0, "error": str(e)}
seeded = None
if seed and db is not None:
try:
seeded = seed_platform_data(db, actor=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 基表零改动"}

370
pbl_blueprint/m1b_ref.py Normal file
View File

@ -0,0 +1,370 @@
# -*- coding: utf-8 -*-
"""M1b-3 关联表判定与落地Q-OPEN-3不改 world 表)。
判定结论写入 docs/M1b-annex-impl.md 备查
--------------------------------------------
候选方案对比后选定 **方案 C独立关联表 pbl_blueprint_ref单向边 + 引用快照**
* 方案 A world/scene/entity 基表加 tenant_id + blueprint_id **否决**
侵入既有域基表 ALTER 生产表影响 world/scene/entity 模块的既有 CRUD
导入链路回滚成本高 world 被多个非 PBL 场景复用 PBL 专属列属职责污染
* 方案 B蓝图 content JSON 内嵌引用无关联表**否决**
无法反查 world 被哪些蓝图引用删除前置校验与影响面分析只能全表扫 JSON
M2 校验引擎与 M3 编译器的引用有效性判定无索引可用
* 方案 C独立关联表**采纳**基表零侵入可独立回滚正查蓝图引用
反查对象被引用均有索引引用快照支持离线展示resolve_status 承载
引用有效性判定结果不缓存跨域写权限
约束
----
* 不建数据库外键跨模块/可能跨库有效性由 resolve_refs() 主动判定
* world/scene/entity 等被引用域**只读**本模块永不写基表
* 所有读写 tenant_id 强制打头缺失 fail-closed
"""
from .m1b_common import (
ConflictError,
NotFoundError,
ValidationError,
as_dict,
insert,
new_id,
now_iso,
require_actor,
require_tenant,
select,
select_one,
update,
write_audit,
)
from .m1b_subobject import SUBOBJECT_KINDS
__all__ = [
"REF_TABLE", "REF_DOMAINS", "REL_TYPES", "CARDINALITIES",
"DOMAIN_TABLE_WHITELIST", "classify_ref", "add_ref", "bulk_add_refs",
"list_refs", "get_ref", "remove_ref", "resolve_refs",
"impact_of", "refs_from_content", "sync_refs_from_content",
]
REF_TABLE = "pbl_blueprint_ref"
# 被引用域白名单(只读)。表名白名单用于防止任意表注入式引用。
DOMAIN_TABLE_WHITELIST = {
"world": ("world", "world_snapshot", "world_sync"),
"scene": ("scene",),
"entity": ("entity",),
"script_engine": ("script_engine",),
"scense_game": ("scense_game", "scense"),
"drag": ("drag_canvas", "drag_template"),
"org": ("org_unit", "org_position"),
"employee": ("employee",),
"pbl": ("pbl_blueprint", "pbl_mission", "pbl_role", "pbl_learner",
"pbl_artifact_def", "pbl_problem", "pbl_project",
"pbl_driving_question", "pbl_learning_goal"),
"external": (),
}
REF_DOMAINS = tuple(DOMAIN_TABLE_WHITELIST.keys())
REL_TYPES = ("uses", "binds", "embeds", "derives_from", "replaces")
CARDINALITIES = ("1:1", "1:n", "n:1", "n:m")
def classify_ref(ref_domain, ref_table):
"""关联表判定:域/表是否在白名单内。
返回 (ok: bool, reason: str)白名单外 拒绝落库fail-closed
避免把任意外部表名写入关联表造成越权探测面
"""
if not ref_domain or not ref_table:
return False, "ref_domain and ref_table are required"
if ref_domain not in DOMAIN_TABLE_WHITELIST:
return False, "ref_domain not in whitelist: %s (allowed: %s)" % (
ref_domain, "/".join(REF_DOMAINS))
allowed = DOMAIN_TABLE_WHITELIST[ref_domain]
if allowed and ref_table not in allowed:
return False, "ref_table %s not allowed under domain %s (allowed: %s)" % (
ref_table, ref_domain, "/".join(allowed))
return True, "ok"
def _normalize_ref_payload(payload):
p = dict(payload or {})
domain = str(p.get("ref_domain") or "").strip()
table = str(p.get("ref_table") or "").strip()
ref_id = str(p.get("ref_id") or "").strip()
if not ref_id:
raise ValidationError("ref_id is required")
ok, reason = classify_ref(domain, table)
if not ok:
raise ValidationError(reason, code="PBL_REF_NOT_ALLOWED")
rel = str(p.get("rel_type") or "uses").strip()
if rel not in REL_TYPES:
raise ValidationError("rel_type invalid: %s (allowed: %s)" % (rel, "/".join(REL_TYPES)))
card = str(p.get("cardinality") or "n:1").strip()
if card not in CARDINALITIES:
raise ValidationError("cardinality invalid: %s" % card)
src_kind = str(p.get("src_kind") or "blueprint").strip()
if src_kind != "blueprint" and src_kind not in SUBOBJECT_KINDS:
raise ValidationError("src_kind invalid: %s" % src_kind)
src_id = p.get("src_id")
if src_kind != "blueprint" and not src_id:
raise ValidationError("src_id is required when src_kind != blueprint")
return {
"ref_domain": domain, "ref_table": table, "ref_id": ref_id,
"rel_type": rel, "cardinality": card,
"src_kind": src_kind, "src_id": src_id or None,
"required": 1 if p.get("required") in (1, True, "1", "true", "True") else 0,
"ref_snapshot": p.get("ref_snapshot") if isinstance(p.get("ref_snapshot"), (dict, list))
else as_dict(p.get("ref_snapshot"), default=None),
"seq": int(p.get("seq") or 0),
"version_id": p.get("version_id"),
}
def add_ref(db, tenant_id, blueprint_id, payload, actor=None):
"""新增关联边(幂等:同唯一键已存在则更新,不重复插入)。"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
if not blueprint_id:
raise ValidationError("blueprint_id is required")
d = _normalize_ref_payload(payload)
now = now_iso()
conds = {"tenant_id": tenant_id, "blueprint_id": blueprint_id, "src_kind": d["src_kind"],
"src_id": d["src_id"] or "", "ref_domain": d["ref_domain"],
"ref_table": d["ref_table"], "ref_id": d["ref_id"], "rel_type": d["rel_type"],
"deleted": 0}
exist = select_one(db, REF_TABLE, conds)
if exist:
update(db, REF_TABLE, {"id": exist["id"]},
{"cardinality": d["cardinality"], "required": d["required"],
"ref_snapshot": d["ref_snapshot"] if d["ref_snapshot"] is not None else exist.get("ref_snapshot"),
"version_id": d["version_id"] if d["version_id"] is not None else exist.get("version_id"),
"seq": d["seq"], "updated_by": actor, "updated_at": now})
write_audit(db, tenant_id, "ref.upsert", REF_TABLE, exist["id"], actor=actor,
detail={"blueprint_id": blueprint_id, "ref": "%s:%s/%s" %
(d["ref_domain"], d["ref_table"], d["ref_id"])})
return {"ok": True, "id": exist["id"], "created": False, "ref": dict(exist)}
rid = new_id("bpref")
row = {
"id": rid, "tenant_id": tenant_id, "blueprint_id": blueprint_id,
"version_id": d["version_id"], "src_kind": d["src_kind"], "src_id": d["src_id"],
"ref_domain": d["ref_domain"], "ref_table": d["ref_table"], "ref_id": d["ref_id"],
"ref_snapshot": d["ref_snapshot"], "rel_type": d["rel_type"],
"cardinality": d["cardinality"], "required": d["required"],
"resolve_status": "unknown", "resolved_at": None, "seq": d["seq"],
"created_by": actor, "created_at": now, "updated_by": actor, "updated_at": now,
"deleted": 0,
}
insert(db, REF_TABLE, row)
write_audit(db, tenant_id, "ref.add", REF_TABLE, rid, actor=actor,
detail={"blueprint_id": blueprint_id, "ref": "%s:%s/%s" %
(d["ref_domain"], d["ref_table"], d["ref_id"]), "rel_type": d["rel_type"]})
return {"ok": True, "id": rid, "created": True, "ref": row}
def bulk_add_refs(db, tenant_id, blueprint_id, refs, actor=None):
"""批量新增。先全量判定(白名单/必填),任一非法整体拒绝,避免半截数据。"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
refs = list(refs or [])
if not refs:
raise ValidationError("refs is empty")
errors, prepared = [], []
for i, r in enumerate(refs):
try:
prepared.append(_normalize_ref_payload(r))
except ValidationError as e:
errors.append({"index": i, "message": e.message, "code": e.code})
if errors:
raise ValidationError("bulk_add_refs validation failed", code="PBL_REF_VALIDATION_FAILED",
errors=errors)
ids = [add_ref(db, tenant_id, blueprint_id, d, actor=actor)["id"] for d in prepared]
return {"ok": True, "count": len(ids), "ids": ids}
def list_refs(db, tenant_id, blueprint_id=None, ref_domain=None, ref_table=None, ref_id=None,
src_kind=None, src_id=None, rel_type=None, resolve_status=None, version_id=None,
limit=500):
"""正查蓝图→引用与反查对象→被引用统一入口。tenant_id 强制。"""
tenant_id = require_tenant(tenant_id)
conds = {"tenant_id": tenant_id, "deleted": 0}
if blueprint_id:
conds["blueprint_id"] = blueprint_id
if ref_domain:
conds["ref_domain"] = ref_domain
if ref_table:
conds["ref_table"] = ref_table
if ref_id:
conds["ref_id"] = ref_id
if src_kind:
conds["src_kind"] = src_kind
if src_id:
conds["src_id"] = src_id
if rel_type:
conds["rel_type"] = rel_type
if resolve_status:
conds["resolve_status"] = resolve_status
if version_id:
conds["version_id"] = version_id
rows = select(db, REF_TABLE, conds, order_by="seq", limit=int(limit))
return {"ok": True, "total": len(rows), "items": rows}
def get_ref(db, tenant_id, ref_id):
tenant_id = require_tenant(tenant_id)
row = select_one(db, REF_TABLE, {"id": ref_id, "tenant_id": tenant_id, "deleted": 0})
if not row:
raise NotFoundError("blueprint ref not found: %s" % ref_id)
return row
def remove_ref(db, tenant_id, ref_id, actor=None):
"""软删除关联边(不动被引用基表)。"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
row = get_ref(db, tenant_id, ref_id)
update(db, REF_TABLE, {"id": ref_id},
{"deleted": 1, "updated_by": actor, "updated_at": now_iso()})
write_audit(db, tenant_id, "ref.remove", REF_TABLE, ref_id, actor=actor,
detail={"blueprint_id": row.get("blueprint_id"),
"ref": "%s:%s/%s" % (row.get("ref_domain"), row.get("ref_table"),
row.get("ref_id"))})
return {"ok": True, "id": ref_id, "deleted": True}
def resolve_refs(db, tenant_id, blueprint_id=None, actor=None, reader=None, limit=500):
"""引用有效性判定:逐条探测被引用对象是否存在(只读),写回 resolve_status。
reader: 可选回调 ``reader(db, ref_table, ref_id) -> row|None``由上层注入跨模块只读
查询能力 world 模块的 get_world未注入时按 ref_snapshot 是否存在
保守判定为 unknown不臆断 missing避免误报
"""
tenant_id = require_tenant(tenant_id)
res = list_refs(db, tenant_id, blueprint_id=blueprint_id, limit=limit)
now = now_iso()
resolved, missing, unknown = [], [], []
for r in res.get("items", []):
status = "unknown"
if callable(reader):
try:
target = reader(db, r.get("ref_table"), r.get("ref_id"))
status = "resolved" if target else "missing"
except Exception:
status = "unknown"
elif r.get("ref_snapshot"):
status = "unknown"
update(db, REF_TABLE, {"id": r["id"]},
{"resolve_status": status, "resolved_at": now, "updated_at": now})
bucket = {"resolved": resolved, "missing": missing, "unknown": unknown}[status]
bucket.append(r["id"])
if missing and actor:
write_audit(db, tenant_id, "ref.resolve", REF_TABLE, blueprint_id or "*", actor=actor,
detail={"missing_count": len(missing), "resolved": len(resolved)})
return {"ok": True, "total": res.get("total", 0), "resolved_count": len(resolved),
"missing_count": len(missing), "unknown_count": len(unknown),
"resolved": resolved, "missing": missing, "unknown": unknown,
"blocking": missing} # M2 校验required=1 且 missing → 阻断
def impact_of(db, tenant_id, ref_domain, ref_table, ref_id):
"""反查影响面:某外部对象(如某 world被本租户哪些蓝图引用。
world/scene 删除前置校验调用只读不阻塞基表写不写基表
"""
tenant_id = require_tenant(tenant_id)
ok, reason = classify_ref(ref_domain, ref_table)
if not ok:
raise ValidationError(reason, code="PBL_REF_NOT_ALLOWED")
res = list_refs(db, tenant_id, ref_domain=ref_domain, ref_table=ref_table, ref_id=ref_id,
limit=500)
bps = sorted({r.get("blueprint_id") for r in res.get("items", []) if r.get("blueprint_id")})
required = [r for r in res.get("items", []) if int(r.get("required") or 0)]
return {"ok": True, "ref": {"domain": ref_domain, "table": ref_table, "id": ref_id},
"ref_count": res.get("total", 0), "blueprint_ids": bps, "blueprint_count": len(bps),
"required_ref_count": len(required),
"safe_to_delete": len(required) == 0,
"warning": ("%d 个蓝图强依赖该对象,删除将导致蓝图引用失效" % len(required))
if required else None}
# --------------------------------------------------------------------------
# 从蓝图 content 自动抽取引用(编译器/校验器共用)
# --------------------------------------------------------------------------
_CONTENT_REF_KEYS = {
"world_id": ("world", "world"),
"world": ("world", "world"),
"scene_id": ("scene", "scene"),
"scene": ("scene", "scene"),
"entity_id": ("entity", "entity"),
"script_id": ("script_engine", "script_engine"),
"game_id": ("scense_game", "scense_game"),
"canvas_id": ("drag", "drag_canvas"),
}
def refs_from_content(content):
"""从蓝图/子对象 content(JSON) 中抽取跨域引用候选。
递归遍历 dict/list命中 _CONTENT_REF_KEYS 的键即产出一条引用
返回 [{ref_domain, ref_table, ref_id, src_kind, src_id, rel_type}]
"""
content = as_dict(content, default=content)
out = []
def walk(node, src_kind, src_id):
if isinstance(node, dict):
kind = node.get("kind") or node.get("type") or src_kind
oid = node.get("id") or src_id
for k, v in node.items():
hit = _CONTENT_REF_KEYS.get(k)
if hit and isinstance(v, (str, int)) and str(v).strip():
out.append({"ref_domain": hit[0], "ref_table": hit[1],
"ref_id": str(v).strip(), "src_kind": kind or "blueprint",
"src_id": oid if kind and kind != "blueprint" else None,
"rel_type": "binds" if k.endswith("_id") else "uses"})
else:
walk(v, kind, oid)
elif isinstance(node, list):
for it in node:
walk(it, src_kind, src_id)
walk(content, "blueprint", None)
# 去重
seen, uniq = set(), []
for r in out:
key = (r["src_kind"], r["src_id"], r["ref_domain"], r["ref_table"], r["ref_id"], r["rel_type"])
if key in seen:
continue
seen.add(key)
uniq.append(r)
return uniq
def sync_refs_from_content(db, tenant_id, blueprint_id, content, actor=None, version_id=None):
"""按 content 全量同步关联边:新增缺失、软删多余(幂等,可重复执行)。"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
wanted = refs_from_content(content)
wanted_keys = {(w["src_kind"], w["src_id"] or "", w["ref_domain"], w["ref_table"],
w["ref_id"], w["rel_type"]) for w in wanted}
added, removed, failed = [], [], []
for w in wanted:
try:
res = add_ref(db, tenant_id, blueprint_id, dict(w, version_id=version_id), actor=actor)
if res.get("created"):
added.append(res["id"])
except ValidationError as e:
failed.append({"ref": w, "message": e.message})
cur = list_refs(db, tenant_id, blueprint_id=blueprint_id, limit=2000)
now = now_iso()
for r in cur.get("items", []):
key = (r.get("src_kind"), r.get("src_id") or "", r.get("ref_domain"),
r.get("ref_table"), r.get("ref_id"), r.get("rel_type"))
if key not in wanted_keys:
update(db, REF_TABLE, {"id": r["id"]},
{"deleted": 1, "updated_by": actor, "updated_at": now})
removed.append(r["id"])
write_audit(db, tenant_id, "ref.sync", REF_TABLE, blueprint_id, actor=actor,
detail={"added": len(added), "removed": len(removed), "failed": len(failed)})
return {"ok": True, "added_count": len(added), "removed_count": len(removed),
"added": added, "removed": removed, "failed": failed,
"total_wanted": len(wanted)}

View File

@ -0,0 +1,530 @@
# -*- coding: utf-8 -*-
"""M1b-2 子对象扩展7 类泛化契约 + 扩展字段定义/扩展值)。
设计要点
--------
* 7 类子对象沿用 M1a 泛化契约``driving_question / mission / role / learner /
artifact_def / problem / project````learning_goal`` 作为可选第 8 类兼容
子对象本体仍存各自 pbl_* M1b **不改子对象基表**扩展一律落
``pbl_subobject_ext``EAVkind + id + ext_key + ext_value
* 扩展字段定义 ``pbl_ext_field_def````tenant_id IS NULL`` = 平台公共定义全租户可用
租户可另建自定义定义租户可见 = 平台公共 本租户租户定义可**遮蔽**同名平台定义
* 所有读写 tenant_id 强制打头缺失 TenantRequiredErrorfail-closed
* 子对象扩展值属租户业务数据**严禁** tenant_id NULL
"""
from .m1b_common import (
IS_NULL,
ConflictError,
NotFoundError,
PermissionDeniedError,
ValidationError,
as_dict,
insert,
new_id,
now_iso,
require_actor,
require_tenant,
select,
select_one,
update,
write_audit,
)
__all__ = [
"EXT_TABLE", "EXT_DEF_TABLE", "SUBOBJECT_KINDS", "VALUE_TYPES",
"is_valid_kind", "list_ext_field_defs", "create_ext_field_def",
"update_ext_field_def", "delete_ext_field_def",
"set_ext", "get_ext", "list_ext", "delete_ext", "bulk_set_ext",
"validate_ext_payload", "apply_template_ext_schema", "subobject_tree_ext",
]
EXT_TABLE = "pbl_subobject_ext"
EXT_DEF_TABLE = "pbl_ext_field_def"
SUBOBJECT_KINDS = (
"driving_question", "mission", "role", "learner",
"artifact_def", "problem", "project", "learning_goal",
)
VALUE_TYPES = ("string", "int", "float", "bool", "enum", "json", "date")
# --------------------------------------------------------------------------
# 基础判定
# --------------------------------------------------------------------------
def is_valid_kind(kind):
return kind in SUBOBJECT_KINDS or kind == "any"
def _require_kind(kind, allow_any=False):
if not kind:
raise ValidationError("subobject_kind is required")
if kind not in SUBOBJECT_KINDS and not (allow_any and kind == "any"):
raise ValidationError(
"subobject_kind invalid: %s (allowed: %s)" % (kind, "/".join(SUBOBJECT_KINDS))
)
return kind
def _cast_value(value, value_type, ext_key="ext_value"):
"""按定义类型转换/校验扩展值。"""
value_type = value_type or "string"
if value_type not in VALUE_TYPES:
raise ValidationError("value_type invalid: %s" % value_type)
if value is None:
return None
try:
if value_type == "string":
return value if isinstance(value, str) else str(value)
if value_type == "int":
return int(value)
if value_type == "float":
return float(value)
if value_type == "bool":
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "y", "on")
return bool(value)
if value_type == "enum":
return value if isinstance(value, str) else str(value)
if value_type == "date":
return value if isinstance(value, str) else str(value)
if value_type == "json":
return value if isinstance(value, (dict, list)) else as_dict(value, default=value)
except (TypeError, ValueError):
raise ValidationError("%s cannot be cast to %s: %r" % (ext_key, value_type, value))
return value
def validate_ext_payload(db, tenant_id, kind, ext_key, value, defs=None):
"""按字段定义校验单个扩展值;返回 (casted_value, value_type, def_row)。
未定义的 ext_key 拒绝fail-closed防止脏字段污染子对象
"""
require_tenant(tenant_id)
_require_kind(kind)
if not ext_key or not str(ext_key).strip():
raise ValidationError("ext_key is required")
ext_key = str(ext_key).strip()
d = None
if defs is not None:
d = defs.get(ext_key)
else:
d = resolve_ext_field_def(db, tenant_id, kind, ext_key)
if not d:
raise ValidationError("ext_key not defined for kind=%s: %s" % (kind, ext_key))
vtype = d.get("value_type") or "string"
casted = _cast_value(value, vtype, ext_key)
if int(d.get("required") or 0) and (casted is None or casted == ""):
raise ValidationError("ext_key %s is required" % ext_key)
if vtype == "enum":
opts = as_dict(d.get("enum_options"), default=[]) or []
if casted is not None and opts and casted not in opts:
raise ValidationError("ext_key %s value not in enum options: %s" % (ext_key, casted))
cons = as_dict(d.get("constraints"), default={}) or {}
if casted is not None:
if vtype in ("int", "float"):
if cons.get("min") is not None and casted < cons["min"]:
raise ValidationError("ext_key %s < min(%s)" % (ext_key, cons["min"]))
if cons.get("max") is not None and casted > cons["max"]:
raise ValidationError("ext_key %s > max(%s)" % (ext_key, cons["max"]))
if vtype in ("string", "enum"):
if cons.get("max_length") and len(str(casted)) > int(cons["max_length"]):
raise ValidationError("ext_key %s exceeds max_length(%s)" % (ext_key, cons["max_length"]))
if cons.get("pattern"):
import re
if not re.match(str(cons["pattern"]), str(casted)):
raise ValidationError("ext_key %s does not match pattern" % ext_key)
return casted, vtype, d
# --------------------------------------------------------------------------
# 扩展字段定义(平台公共 tenant_id NULL / 租户自定义)
# --------------------------------------------------------------------------
def resolve_ext_field_def(db, tenant_id, kind, ext_key):
"""解析生效的字段定义租户定义kind 精确 → any优先其次平台公共定义。
查找顺序
1. tenant_id=T, subobject_kind=kind, ext_key
2. tenant_id=T, subobject_kind='any', ext_key
3. tenant_id IS NULL, subobject_kind=kind, ext_key, is_public=1
4. tenant_id IS NULL, subobject_kind='any', ext_key, is_public=1
"""
require_tenant(tenant_id)
for conds in (
{"tenant_id": tenant_id, "subobject_kind": kind, "ext_key": ext_key, "deleted": 0, "status": "active"},
{"tenant_id": tenant_id, "subobject_kind": "any", "ext_key": ext_key, "deleted": 0, "status": "active"},
{"tenant_id": IS_NULL, "subobject_kind": kind, "ext_key": ext_key, "deleted": 0,
"status": "active", "is_public": 1},
{"tenant_id": IS_NULL, "subobject_kind": "any", "ext_key": ext_key, "deleted": 0,
"status": "active", "is_public": 1},
):
row = select_one(db, EXT_DEF_TABLE, conds)
if row:
row = dict(row)
row["is_platform"] = row.get("tenant_id") in (None, "")
return row
return None
def list_ext_field_defs(db, tenant_id, kind=None, include_platform=True):
"""列出生效字段定义(租户覆盖平台同名定义)。"""
require_tenant(tenant_id)
groups = [{"tenant_id": tenant_id, "deleted": 0, "status": "active"}]
if include_platform:
groups.append({"tenant_id": IS_NULL, "deleted": 0, "status": "active", "is_public": 1})
merged, order = {}, []
for conds in groups:
for r in select(db, EXT_DEF_TABLE, conds, order_by="seq", limit=500):
k = r.get("subobject_kind")
if kind and k not in (kind, "any"):
continue
key = (k, r.get("ext_key"))
row = dict(r)
row["is_platform"] = row.get("tenant_id") in (None, "")
if key not in merged:
order.append(key)
merged[key] = row # 后写入的租户定义覆盖平台定义groups 顺序保证)
items = [merged[k] for k in order if k in merged]
return {"ok": True, "total": len(items), "items": items}
def _normalize_def_payload(payload, for_create=True):
payload = dict(payload or {})
out = {}
if for_create:
kind = _require_kind(payload.get("subobject_kind"), allow_any=True)
out["subobject_kind"] = kind
if not (payload.get("ext_key") or "").strip():
raise ValidationError("ext_key is required")
out["ext_key"] = str(payload["ext_key"]).strip()
if not (payload.get("label") or "").strip():
out["label"] = out["ext_key"]
else:
out["label"] = str(payload["label"]).strip()
vt = payload.get("value_type")
if vt is not None:
if vt not in VALUE_TYPES:
raise ValidationError("value_type invalid: %s (allowed: %s)" % (vt, "/".join(VALUE_TYPES)))
out["value_type"] = vt
for k in ("label", "subobject_kind", "status"):
if k in payload and payload[k] is not None and k not in out:
out[k] = payload[k]
if out.get("status") and out["status"] not in ("active", "deprecated"):
raise ValidationError("status invalid: %s" % out["status"])
for jk in ("default_value", "enum_options", "constraints"):
if jk in payload and payload[jk] is not None:
v = payload[jk]
out[jk] = v if isinstance(v, (dict, list)) else as_dict(v, default=v)
for bk in ("required", "is_public", "is_builtin"):
if bk in payload and payload[bk] is not None:
out[bk] = 1 if payload[bk] in (1, True, "1", "true", "True") else 0
if "seq" in payload and payload["seq"] is not None:
out["seq"] = int(payload["seq"])
if out.get("value_type") == "enum" and not (out.get("enum_options") or []):
if for_create:
raise ValidationError("enum_options is required when value_type=enum")
return out
def create_ext_field_def(db, tenant_id, payload, actor=None, is_platform_admin=False):
"""创建扩展字段定义。scope='platform' → tenant_id NULL需平台管理员"""
payload = dict(payload or {})
scope = str(payload.pop("scope", "") or "").lower()
platform = scope in ("platform", "common", "public") or tenant_id is None
data = _normalize_def_payload(payload, for_create=True)
if platform:
require_actor(actor)
if not is_platform_admin:
raise PermissionDeniedError(
"platform-common ext field def requires is_platform_admin=True",
code="PBL_PLATFORM_ADMIN_REQUIRED")
tenant_value = None
data.setdefault("is_public", 1)
else:
tenant_value = require_tenant(tenant_id)
require_actor(actor)
exist = select_one(db, EXT_DEF_TABLE, {
"tenant_id": IS_NULL if platform else tenant_value,
"subobject_kind": data["subobject_kind"], "ext_key": data["ext_key"], "deleted": 0})
if exist:
raise ConflictError("ext field def already exists: %s.%s" %
(data["subobject_kind"], data["ext_key"]))
now = now_iso()
row = {
"id": new_id("extdef"),
"tenant_id": tenant_value,
"subobject_kind": data["subobject_kind"],
"ext_key": data["ext_key"],
"label": data.get("label") or data["ext_key"],
"value_type": data.get("value_type") or "string",
"required": int(data.get("required", 0)),
"default_value": data.get("default_value"),
"enum_options": data.get("enum_options") or [],
"constraints": data.get("constraints") or {},
"is_public": int(data.get("is_public", 1 if platform else 0)),
"is_builtin": int(data.get("is_builtin", 0)),
"seq": int(data.get("seq", 0)),
"status": data.get("status") or "active",
"created_by": actor, "created_at": now,
"updated_by": actor, "updated_at": now,
"deleted": 0,
}
insert(db, EXT_DEF_TABLE, row)
write_audit(db, tenant_value, "ext_def.create", EXT_DEF_TABLE, row["id"], actor=actor,
detail={"kind": row["subobject_kind"], "ext_key": row["ext_key"],
"scope": "platform" if platform else "tenant"})
return {"ok": True, "id": row["id"], "is_platform": platform, "def": row}
def update_ext_field_def(db, tenant_id, def_id, payload, actor=None, is_platform_admin=False):
require_tenant(tenant_id)
row = select_one(db, EXT_DEF_TABLE, {"id": def_id, "deleted": 0})
if not row:
raise NotFoundError("ext field def not found: %s" % def_id)
platform = row.get("tenant_id") in (None, "")
if platform:
require_actor(actor)
if not is_platform_admin:
raise PermissionDeniedError("platform-common ext field def write requires is_platform_admin",
code="PBL_PLATFORM_ADMIN_REQUIRED")
else:
if row.get("tenant_id") != tenant_id:
raise NotFoundError("ext field def not found: %s" % def_id)
require_actor(actor)
if int(row.get("is_builtin") or 0) and (payload or {}).get("ext_key"):
raise PermissionDeniedError("builtin ext field def key is immutable")
data = _normalize_def_payload(payload or {}, for_create=False)
if not data:
raise ValidationError("no updatable fields")
data["updated_by"] = actor
data["updated_at"] = now_iso()
update(db, EXT_DEF_TABLE, {"id": def_id}, data)
write_audit(db, row.get("tenant_id"), "ext_def.update", EXT_DEF_TABLE, def_id, actor=actor,
detail={"fields": sorted(data.keys())})
return {"ok": True, "id": def_id, "updated": sorted(data.keys())}
def delete_ext_field_def(db, tenant_id, def_id, actor=None, is_platform_admin=False):
"""软删除定义。内置定义禁删;已有扩展值引用时禁删(防孤儿数据)。"""
require_tenant(tenant_id)
row = select_one(db, EXT_DEF_TABLE, {"id": def_id, "deleted": 0})
if not row:
raise NotFoundError("ext field def not found: %s" % def_id)
platform = row.get("tenant_id") in (None, "")
require_actor(actor)
if platform and not is_platform_admin:
raise PermissionDeniedError("platform-common ext field def delete requires is_platform_admin",
code="PBL_PLATFORM_ADMIN_REQUIRED")
if not platform and row.get("tenant_id") != tenant_id:
raise NotFoundError("ext field def not found: %s" % def_id)
if int(row.get("is_builtin") or 0):
raise PermissionDeniedError("builtin ext field def cannot be deleted, set status=deprecated")
used = select(db, EXT_TABLE, {"tenant_id": tenant_id, "ext_key": row.get("ext_key"),
"subobject_kind": row.get("subobject_kind"), "deleted": 0}, limit=1)
if used:
raise ConflictError("ext field def is in use by %d+ subobject ext rows, deprecate instead" % len(used))
update(db, EXT_DEF_TABLE, {"id": def_id},
{"deleted": 1, "updated_by": actor, "updated_at": now_iso()})
write_audit(db, row.get("tenant_id"), "ext_def.delete", EXT_DEF_TABLE, def_id, actor=actor)
return {"ok": True, "id": def_id, "deleted": True}
# --------------------------------------------------------------------------
# 扩展值读写租户数据tenant_id 必填)
# --------------------------------------------------------------------------
def _defs_map(db, tenant_id, kind):
res = list_ext_field_defs(db, tenant_id, kind=kind)
return {r["ext_key"]: r for r in res.get("items", [])}
def set_ext(db, tenant_id, blueprint_id, kind, subobject_id, ext_key, value,
actor=None, source="manual", version_id=None, source_template_id=None):
"""写入/更新单个扩展值upsert按唯一键幂等"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
_require_kind(kind)
if not blueprint_id:
raise ValidationError("blueprint_id is required")
if not subobject_id:
raise ValidationError("subobject_id is required")
ext_key = str(ext_key or "").strip()
casted, vtype, d = validate_ext_payload(db, tenant_id, kind, ext_key, value)
now = now_iso()
conds = {"tenant_id": tenant_id, "blueprint_id": blueprint_id, "subobject_kind": kind,
"subobject_id": subobject_id, "ext_key": ext_key, "deleted": 0}
exist = select_one(db, EXT_TABLE, conds)
if exist:
update(db, EXT_TABLE, {"id": exist["id"]},
{"ext_value": casted, "value_type": vtype, "source": source,
"version_id": version_id if version_id is not None else exist.get("version_id"),
"updated_by": actor, "updated_at": now})
rid, created = exist["id"], False
else:
rid = new_id("subext")
insert(db, EXT_TABLE, {
"id": rid, "tenant_id": tenant_id, "blueprint_id": blueprint_id,
"version_id": version_id, "subobject_kind": kind, "subobject_id": subobject_id,
"ext_key": ext_key, "ext_value": casted, "value_type": vtype,
"source": source or "manual", "source_template_id": source_template_id,
"seq": int(d.get("seq") or 0), "status": "active",
"created_by": actor, "created_at": now, "updated_by": actor, "updated_at": now,
"deleted": 0,
})
created = True
write_audit(db, tenant_id, "subobject_ext.set", EXT_TABLE, rid, actor=actor,
detail={"blueprint_id": blueprint_id, "kind": kind, "subobject_id": subobject_id,
"ext_key": ext_key, "created": created})
return {"ok": True, "id": rid, "created": created, "ext_key": ext_key,
"value": casted, "value_type": vtype}
def bulk_set_ext(db, tenant_id, blueprint_id, kind, subobject_id, values,
actor=None, source="manual", version_id=None, source_template_id=None):
"""批量写扩展值。values: {ext_key: value}。逐条校验,任一失败整体拒绝(返回错误明细)。"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
_require_kind(kind)
values = dict(values or {})
if not values:
raise ValidationError("values is empty")
defs = _defs_map(db, tenant_id, kind)
errors = []
prepared = []
for k, v in values.items():
try:
casted, vtype, d = validate_ext_payload(db, tenant_id, kind, k, v, defs=defs)
prepared.append((k, casted, vtype, d))
except ValidationError as e:
errors.append({"ext_key": k, "message": e.message})
if errors:
raise ValidationError("bulk_set_ext validation failed", code="PBL_EXT_VALIDATION_FAILED",
errors=errors)
ids = []
for k, casted, vtype, d in prepared:
res = set_ext(db, tenant_id, blueprint_id, kind, subobject_id, k, casted, actor=actor,
source=source, version_id=version_id, source_template_id=source_template_id)
ids.append(res["id"])
return {"ok": True, "count": len(ids), "ids": ids}
def get_ext(db, tenant_id, blueprint_id, kind, subobject_id, ext_key=None):
"""读扩展值ext_key 为空返回该子对象全部扩展dict"""
tenant_id = require_tenant(tenant_id)
conds = {"tenant_id": tenant_id, "blueprint_id": blueprint_id,
"subobject_kind": kind, "subobject_id": subobject_id, "deleted": 0}
if ext_key:
conds["ext_key"] = str(ext_key).strip()
row = select_one(db, EXT_TABLE, conds)
if not row:
return None
return as_dict(row.get("ext_value"), default=row.get("ext_value")) \
if (row.get("value_type") == "json") else row.get("ext_value")
out = {}
for r in select(db, EXT_TABLE, conds, order_by="seq", limit=500):
v = r.get("ext_value")
out[r.get("ext_key")] = as_dict(v, default=v) if r.get("value_type") == "json" else v
return out
def list_ext(db, tenant_id, blueprint_id=None, kind=None, subobject_id=None, version_id=None,
limit=500):
"""列出扩展值行(明细,含元信息)。"""
tenant_id = require_tenant(tenant_id)
conds = {"tenant_id": tenant_id, "deleted": 0}
if blueprint_id:
conds["blueprint_id"] = blueprint_id
if kind:
conds["subobject_kind"] = _require_kind(kind)
if subobject_id:
conds["subobject_id"] = subobject_id
if version_id:
conds["version_id"] = version_id
rows = select(db, EXT_TABLE, conds, order_by="seq", limit=int(limit))
return {"ok": True, "total": len(rows), "items": rows}
def delete_ext(db, tenant_id, blueprint_id, kind, subobject_id, ext_key=None, actor=None):
"""删除扩展值软删。ext_key 为空 → 删该子对象全部扩展。"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
conds = {"tenant_id": tenant_id, "blueprint_id": blueprint_id,
"subobject_kind": kind, "subobject_id": subobject_id, "deleted": 0}
if ext_key:
conds["ext_key"] = str(ext_key).strip()
rows = select(db, EXT_TABLE, conds, limit=500)
now = now_iso()
for r in rows:
update(db, EXT_TABLE, {"id": r["id"]},
{"deleted": 1, "updated_by": actor, "updated_at": now})
write_audit(db, tenant_id, "subobject_ext.delete", EXT_TABLE, subobject_id, actor=actor,
detail={"kind": kind, "ext_key": ext_key, "count": len(rows)})
return {"ok": True, "deleted_count": len(rows)}
def apply_template_ext_schema(db, tenant_id, blueprint_id, template_ext_schema, kind_map,
actor=None, source_template_id=None, version_id=None):
"""模板实例化时把模板 ext_schema 的默认值落到子对象扩展。
参数
----
template_ext_schema: {kind: {ext_key: value}} {ext_key: value}后者按 kind_map 全量套用
kind_map: {kind: [subobject_id, ...]}
只写定义存在且当前无值的字段不覆盖用户已填内容幂等可重跑
"""
tenant_id = require_tenant(tenant_id)
require_actor(actor)
schema = as_dict(template_ext_schema, default={}) or {}
kind_map = dict(kind_map or {})
applied, skipped = [], []
for kind, ids in kind_map.items():
if kind not in SUBOBJECT_KINDS:
skipped.append({"kind": kind, "reason": "unknown_kind"})
continue
defaults = schema.get(kind)
if defaults is None and "any" in schema:
defaults = schema.get("any")
defaults = as_dict(defaults, default={}) or {}
if not defaults:
continue
defs = _defs_map(db, tenant_id, kind)
for sid in ids or []:
for k, v in defaults.items():
if k not in defs:
skipped.append({"kind": kind, "subobject_id": sid, "ext_key": k,
"reason": "not_defined"})
continue
cur = get_ext(db, tenant_id, blueprint_id, kind, sid, k)
if cur not in (None, ""):
skipped.append({"kind": kind, "subobject_id": sid, "ext_key": k,
"reason": "already_set"})
continue
try:
res = set_ext(db, tenant_id, blueprint_id, kind, sid, k, v, actor=actor,
source="template_instantiate", version_id=version_id,
source_template_id=source_template_id)
applied.append(res["id"])
except ValidationError as e:
skipped.append({"kind": kind, "subobject_id": sid, "ext_key": k,
"reason": "invalid", "message": e.message})
return {"ok": True, "applied_count": len(applied), "applied": applied,
"skipped_count": len(skipped), "skipped": skipped}
def subobject_tree_ext(db, tenant_id, blueprint_id, version_id=None):
"""按子对象聚合扩展值,供蓝图树接口挂载({kind: {subobject_id: {ext_key: value}}})。"""
tenant_id = require_tenant(tenant_id)
res = list_ext(db, tenant_id, blueprint_id=blueprint_id, version_id=version_id, limit=2000)
tree = {}
for r in res.get("items", []):
k = r.get("subobject_kind")
sid = r.get("subobject_id")
v = r.get("ext_value")
if r.get("value_type") == "json":
v = as_dict(v, default=v)
tree.setdefault(k, {}).setdefault(sid, {})[r.get("ext_key")] = v
return {"ok": True, "blueprint_id": blueprint_id, "ext": tree}

View File

@ -0,0 +1,436 @@
# -*- coding: utf-8 -*-
"""M1b-1 模板平台公共部分tenant_id IS NULL
核心语义
--------
* 平台公共模板``tenant_id IS NULL````is_public=1`` 时对全部租户只读可见
* 租户私有模板``tenant_id = <租户>``仅本租户可见可写
* 可见集合 = 本租户模板 平台公共模板fail-closed tenant_id 直接拒绝
* 平台公共模板的写操作必须 ``is_platform_admin=True`` API/RBAC 层判定后注入
本层不信任调用方自述的角色字符串以外的任何信息 actor 不得为 system/anonymous
* 租户可 ``fork_template`` 把平台公共模板派生为本租户私有模板source=fork
记录 source_template_id派生后与平台模板解耦平台升级不回灌
* 内置模板is_builtin=1禁止删除只能 deprecate
Q-OPEN-3平台公共数据只落在 pbl_* 自有表world/scene/entity 基表零改动
"""
from .m1b_common import (
IS_NULL,
PLATFORM_TENANT,
ConflictError,
NotFoundError,
PermissionDeniedError,
ValidationError,
as_dict,
insert,
new_id,
now_iso,
require_actor,
require_tenant,
select,
select_one,
update,
write_audit,
)
__all__ = [
"TPL_TABLE", "TEMPLATE_CATEGORIES", "TEMPLATE_STATUSES",
"visible_template_conds", "list_templates", "get_template",
"create_template", "update_template", "delete_template",
"publish_template", "deprecate_template",
"fork_template", "instantiate_template", "bump_usage",
"ensure_platform_seed", "is_platform_template",
]
TPL_TABLE = "pbl_blueprint_template"
TEMPLATE_CATEGORIES = ("stem", "humanities", "interdisciplinary", "vocational", "custom")
TEMPLATE_STATUSES = ("draft", "published", "deprecated")
def is_platform_template(row):
"""判定一行模板是否平台公共tenant_id IS NULL"""
if not row:
return False
tid = row.get("tenant_id")
return tid is None or tid == ""
def _check_platform_write(tenant_id, is_platform_admin, actor):
"""平台公共模板写门禁。"""
require_actor(actor)
if not is_platform_admin:
raise PermissionDeniedError(
"platform-common template write requires is_platform_admin=True",
code="PBL_PLATFORM_ADMIN_REQUIRED",
)
return True
def _normalize_payload(payload, for_create=True):
payload = dict(payload or {})
name = (payload.get("name") or "").strip()
code = (payload.get("code") or "").strip()
if for_create:
if not name:
raise ValidationError("template.name is required")
if not code:
raise ValidationError("template.code is required")
category = payload.get("category")
if category is not None and category not in TEMPLATE_CATEGORIES:
raise ValidationError(
"template.category invalid: %s (allowed: %s)" % (category, "/".join(TEMPLATE_CATEGORIES))
)
status = payload.get("status")
if status is not None and status not in TEMPLATE_STATUSES:
raise ValidationError("template.status invalid: %s" % status)
out = {}
for k in ("name", "code", "category", "grade_range", "duration_hours", "summary",
"version", "status", "owner_id", "source", "source_template_id"):
if k in payload and payload[k] is not None:
out[k] = payload[k]
for jk in ("content", "subobject_kinds", "ext_schema", "subject_tags"):
if jk in payload and payload[jk] is not None:
v = payload[jk]
out[jk] = v if isinstance(v, (dict, list)) else as_dict(v, default=v)
for bk in ("is_public", "is_builtin"):
if bk in payload and payload[bk] is not None:
out[bk] = 1 if payload[bk] in (1, True, "1", "true", "True") else 0
return out
def visible_template_conds(tenant_id, include_platform=True, status=None):
"""构造可见模板查询条件(供 SQL 适配器拼 OR 用)。
返回 list[dict]每个 dict 是一组 AND 条件整体 OR
[{tenant_id: T, deleted: 0}, {tenant_id: IS_NULL, is_public: 1, deleted: 0}]
"""
require_tenant(tenant_id)
groups = [{"tenant_id": tenant_id, "deleted": 0}]
if include_platform:
groups.append({"tenant_id": IS_NULL, "is_public": 1, "deleted": 0})
if status:
for g in groups:
g["status"] = status
return groups
def list_templates(db, tenant_id, include_platform=True, status=None, category=None,
keyword=None, limit=100, offset=0):
"""列出可见模板:本租户 + 平台公共。fail-closed。"""
require_tenant(tenant_id)
rows = []
seen = set()
for conds in visible_template_conds(tenant_id, include_platform=include_platform, status=status):
for r in select(db, TPL_TABLE, conds, order_by="-updated_at", limit=500):
if category and r.get("category") != category:
continue
if keyword:
kw = str(keyword).lower()
hay = "%s %s %s" % (r.get("name") or "", r.get("code") or "", r.get("summary") or "")
if kw not in hay.lower():
continue
if r.get("id") in seen:
continue
seen.add(r.get("id"))
r = dict(r)
r["is_platform"] = is_platform_template(r)
r["editable"] = (not r["is_platform"])
rows.append(r)
rows.sort(key=lambda x: (x.get("is_platform"), x.get("updated_at") or ""), reverse=False)
total = len(rows)
return {"ok": True, "total": total, "items": rows[int(offset): int(offset) + int(limit)]}
def get_template(db, tenant_id, template_id, include_platform=True):
"""按 ID 取模板:只允许本租户或平台公共;越权 → NotFoundError不泄露存在性"""
require_tenant(tenant_id)
if not template_id:
raise ValidationError("template_id is required")
row = select_one(db, TPL_TABLE, {"id": template_id, "deleted": 0})
if not row:
raise NotFoundError("template not found: %s" % template_id)
platform = is_platform_template(row)
if platform:
if not include_platform:
raise NotFoundError("template not found: %s" % template_id)
if not int(row.get("is_public") or 0):
raise PermissionDeniedError("platform template is not public: %s" % template_id)
elif row.get("tenant_id") != tenant_id:
raise NotFoundError("template not found: %s" % template_id)
row = dict(row)
row["is_platform"] = platform
row["editable"] = not platform
row["content"] = as_dict(row.get("content"), default={})
row["subobject_kinds"] = as_dict(row.get("subobject_kinds"), default=[])
row["ext_schema"] = as_dict(row.get("ext_schema"), default={})
row["subject_tags"] = as_dict(row.get("subject_tags"), default=[])
return row
def _ensure_code_unique(db, tenant_id, code, exclude_id=None):
"""编码唯一性租户域内唯一平台公共域tenant_id NULL内唯一应用层兜底
MySQL 唯一索引对 NULL 不去重"""
conds = {"code": code, "deleted": 0}
conds["tenant_id"] = IS_NULL if tenant_id is None else tenant_id
for r in select(db, TPL_TABLE, conds, limit=50):
if exclude_id and r.get("id") == exclude_id:
continue
raise ConflictError("template code already exists in this scope: %s" % code)
return True
def create_template(db, tenant_id, payload, actor=None, is_platform_admin=False):
"""创建模板。
* payload['scope']='platform' tenant_id None 平台公共模板 is_platform_admin
* 否则 租户私有模板tenant_id 必填fail-closed
"""
payload = dict(payload or {})
scope = str(payload.pop("scope", "") or "").lower()
platform = scope in ("platform", "common", "public") or tenant_id is None
if platform:
_check_platform_write(None, is_platform_admin, actor)
tenant_value = PLATFORM_TENANT
payload.setdefault("source", "platform")
payload.setdefault("is_public", 1)
else:
tenant_value = require_tenant(tenant_id)
require_actor(actor)
payload.setdefault("source", "tenant")
data = _normalize_payload(payload, for_create=True)
_ensure_code_unique(db, tenant_value, data["code"])
now = now_iso()
row = {
"id": new_id("tpl"),
"tenant_id": tenant_value,
"code": data["code"],
"name": data["name"],
"category": data.get("category") or "custom",
"subject_tags": data.get("subject_tags") or [],
"grade_range": data.get("grade_range"),
"duration_hours": data.get("duration_hours"),
"summary": data.get("summary"),
"content": data.get("content") or {},
"subobject_kinds": data.get("subobject_kinds") or [],
"ext_schema": data.get("ext_schema") or {},
"is_public": int(data.get("is_public", 1 if platform else 0)),
"is_builtin": int(data.get("is_builtin", 0)),
"source": data.get("source") or ("platform" if platform else "tenant"),
"source_template_id": data.get("source_template_id"),
"version": data.get("version") or "1.0.0",
"status": data.get("status") or "draft",
"usage_count": 0,
"owner_id": data.get("owner_id") or actor,
"created_by": actor,
"created_at": now,
"updated_by": actor,
"updated_at": now,
"deleted": 0,
}
insert(db, TPL_TABLE, row)
write_audit(db, tenant_value, "template.create", TPL_TABLE, row["id"], actor=actor,
detail={"scope": "platform" if platform else "tenant", "code": row["code"]})
return {"ok": True, "id": row["id"], "is_platform": platform, "template": row}
def update_template(db, tenant_id, template_id, payload, actor=None, is_platform_admin=False):
"""更新模板。平台公共模板需 is_platform_admin租户模板需 tenant_id 匹配。"""
row = get_template(db, tenant_id, template_id)
if row["is_platform"]:
_check_platform_write(None, is_platform_admin, actor)
else:
require_tenant(tenant_id)
require_actor(actor)
data = _normalize_payload(payload or {}, for_create=False)
if not data:
raise ValidationError("no updatable fields")
if "code" in data and data["code"] != row.get("code"):
_ensure_code_unique(db, row.get("tenant_id"), data["code"], exclude_id=template_id)
if row.get("is_builtin") and ("code" in data or "is_builtin" in data):
raise PermissionDeniedError("builtin template code/is_builtin is immutable")
data["updated_by"] = actor
data["updated_at"] = now_iso()
update(db, TPL_TABLE, {"id": template_id}, data)
write_audit(db, row.get("tenant_id"), "template.update", TPL_TABLE, template_id,
actor=actor, detail={"fields": sorted(data.keys())})
return {"ok": True, "id": template_id, "updated": sorted(data.keys())}
def _set_status(db, tenant_id, template_id, status, actor, is_platform_admin=False):
row = get_template(db, tenant_id, template_id)
if row["is_platform"]:
_check_platform_write(None, is_platform_admin, actor)
else:
require_actor(actor)
update(db, TPL_TABLE, {"id": template_id},
{"status": status, "updated_by": actor, "updated_at": now_iso()})
write_audit(db, row.get("tenant_id"), "template.%s" % status, TPL_TABLE, template_id, actor=actor)
return {"ok": True, "id": template_id, "status": status}
def publish_template(db, tenant_id, template_id, actor=None, is_platform_admin=False):
"""发布模板draft → published。发布前校验 content 非空。"""
row = get_template(db, tenant_id, template_id)
if not (row.get("content") or {}):
raise ValidationError("template.content is empty, cannot publish")
return _set_status(db, tenant_id, template_id, "published", actor, is_platform_admin)
def deprecate_template(db, tenant_id, template_id, actor=None, is_platform_admin=False):
"""停用模板(不删除,历史实例仍可追溯)。"""
return _set_status(db, tenant_id, template_id, "deprecated", actor, is_platform_admin)
def delete_template(db, tenant_id, template_id, actor=None, is_platform_admin=False):
"""软删除。内置模板禁止删除;平台公共模板需平台管理员。"""
row = get_template(db, tenant_id, template_id)
if row["is_platform"]:
_check_platform_write(None, is_platform_admin, actor)
else:
require_actor(actor)
if int(row.get("is_builtin") or 0):
raise PermissionDeniedError("builtin template cannot be deleted, use deprecate instead")
update(db, TPL_TABLE, {"id": template_id},
{"deleted": 1, "updated_by": actor, "updated_at": now_iso()})
write_audit(db, row.get("tenant_id"), "template.delete", TPL_TABLE, template_id, actor=actor)
return {"ok": True, "id": template_id, "deleted": True}
def fork_template(db, tenant_id, template_id, actor=None, new_code=None, new_name=None):
"""把(通常是平台公共的)模板派生为本租户私有模板。
派生后完全解耦平台模板后续升级不回灌租户副本避免破坏租户已改内容
"""
require_tenant(tenant_id)
require_actor(actor)
src = get_template(db, tenant_id, template_id)
code = (new_code or "%s_%s" % (src.get("code"), str(tenant_id)[:6])).strip()
_ensure_code_unique(db, tenant_id, code)
now = now_iso()
row = {
"id": new_id("tpl"),
"tenant_id": tenant_id,
"code": code,
"name": (new_name or "%s(副本)" % (src.get("name") or "")).strip(),
"category": src.get("category") or "custom",
"subject_tags": src.get("subject_tags") or [],
"grade_range": src.get("grade_range"),
"duration_hours": src.get("duration_hours"),
"summary": src.get("summary"),
"content": src.get("content") or {},
"subobject_kinds": src.get("subobject_kinds") or [],
"ext_schema": src.get("ext_schema") or {},
"is_public": 0,
"is_builtin": 0,
"source": "fork",
"source_template_id": src.get("id"),
"version": src.get("version") or "1.0.0",
"status": "draft",
"usage_count": 0,
"owner_id": actor,
"created_by": actor,
"created_at": now,
"updated_by": actor,
"updated_at": now,
"deleted": 0,
}
insert(db, TPL_TABLE, row)
bump_usage(db, src.get("id"), delta=0) # fork 不计入实例化次数,仅触发存在性
write_audit(db, tenant_id, "template.fork", TPL_TABLE, row["id"], actor=actor,
detail={"source_template_id": src.get("id"),
"source_is_platform": src.get("is_platform")})
return {"ok": True, "id": row["id"], "source_template_id": src.get("id"), "template": row}
def bump_usage(db, template_id, delta=1):
"""模板使用计数best-effort失败不阻断实例化"""
try:
row = select_one(db, TPL_TABLE, {"id": template_id})
if not row:
return None
cur = int(row.get("usage_count") or 0)
update(db, TPL_TABLE, {"id": template_id}, {"usage_count": max(0, cur + int(delta))})
return cur + int(delta)
except Exception:
return None
def instantiate_template(db, tenant_id, template_id, blueprint_payload=None, actor=None,
create_blueprint=None):
"""模板实例化为蓝图M1a 已有实例化能力,这里做 M1b 的平台公共模板接入 + 离线兜底)。
* 平台公共模板tenant_id NULL同样可被任意租户实例化产物 tenant_id = 调用租户
* ``create_blueprint`` 回调由调用方api/init 注入 M1a 的蓝图创建函数
未注入时返回 content 骨架供上层自行落库离线兜底不抛错
"""
require_tenant(tenant_id)
require_actor(actor)
tpl = get_template(db, tenant_id, template_id)
if tpl.get("status") == "deprecated":
raise ValidationError("template is deprecated, cannot instantiate: %s" % template_id)
content = tpl.get("content") or {}
payload = dict(blueprint_payload or {})
payload.setdefault("name", "%s(实例)" % (tpl.get("name") or ""))
payload.setdefault("template_id", tpl.get("id"))
payload.setdefault("template_version", tpl.get("version"))
payload.setdefault("content", content)
payload.setdefault("subobject_kinds", tpl.get("subobject_kinds") or [])
payload.setdefault("ext_schema", tpl.get("ext_schema") or {})
payload["tenant_id"] = tenant_id # 实例恒为租户数据,绝不继承 NULL
if callable(create_blueprint):
res = create_blueprint(db, tenant_id, payload, actor=actor)
else:
res = {"ok": True, "deferred": True, "payload": payload}
bump_usage(db, tpl.get("id"), 1)
write_audit(db, tenant_id, "template.instantiate", TPL_TABLE, tpl.get("id"), actor=actor,
detail={"blueprint": (res or {}).get("id"), "template_is_platform": tpl.get("is_platform")})
return {"ok": True, "template_id": tpl.get("id"), "is_platform_template": tpl.get("is_platform"),
"result": res}
# --------------------------------------------------------------------------
# 平台公共模板种子(幂等)
# --------------------------------------------------------------------------
def ensure_platform_seed(db, seed_rows=None, actor="system"):
"""幂等注入平台公共模板种子tenant_id NULL
seed_rows 缺省时读包内 json/seed_template_offline.jsonM1a 离线兜底种子
已存在同 code 的平台模板则跳过不覆盖租户/平台已改内容
"""
if seed_rows is None:
from .m1b_common import load_pkg_json
data = load_pkg_json("seed_template_offline.json")
if isinstance(data, dict):
seed_rows = data.get("templates") or data.get("items") or data.get("rows") or []
elif isinstance(data, list):
seed_rows = data
else:
seed_rows = []
created, skipped = [], []
for item in seed_rows or []:
if not isinstance(item, dict):
continue
code = (item.get("code") or "").strip()
if not code:
continue
exist = select_one(db, TPL_TABLE, {"tenant_id": IS_NULL, "code": code, "deleted": 0})
if exist:
skipped.append(exist.get("id"))
continue
payload = dict(item)
payload["scope"] = "platform"
payload.setdefault("is_builtin", 1)
payload.setdefault("is_public", 1)
payload.setdefault("status", "published")
try:
res = create_template(db, None, payload, actor=actor or "system",
is_platform_admin=True)
created.append(res.get("id"))
except Exception:
skipped.append(code)
return {"ok": True, "created": created, "skipped": skipped,
"created_count": len(created), "skipped_count": len(skipped)}

View File

@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
"""
pbl_blueprint · M1b 模板域表模型2
权威契约
- projects/pbls/docs/01-design/data-model.md §2.4pbl_template 2
- projects/pbls/docs/01-design/modules/pbl_blueprint.M1b-annex.md §5
- projects/pbls/docs/01-design/open-questions-resolution.md Q-OPEN-1 / Q-OPEN-3 / Q-OPEN-4 / Q-OPEN-9
M1b M1a11 之上的三点落地本文件是机器可校验的权威定义
1) 模板平台公共部分`pbl_template.tenant_id` 允许 NULL
- tenant_id IS NULL => 平台公共模板platform scope对全部租户只读可见仅平台管理员可写/归档
- tenant_id = 'xxx' => 租户私有模板可写可归档
- 解析优先级租户私有 > 平台公共 template_code+version 时租户行覆盖平台行
- 唯一性MySQL 8 用函数索引 `uk((IFNULL(tenant_id,'__PLATFORM__')), template_code, template_version)`
使 NULL平台公共行同样受唯一约束MySQL 5.7 回退方案见 `DDL_MYSQL57_FALLBACK`引入 tenant_key 归一列
2) 子对象扩展模板内容 `tpl_json` M1a 蓝图子对象**同构**沿用 M1a 7
`pbl_subobject_type` 字典stage/task/role/world_ref/scene_ref/entity_ref/script_ref
父子引用在模板内使用临时 IDtmp id实例化时由 subobject_ext.remap_ids 重映射为业务主键
7 类的父级约束/必填字段/编码前缀见 `subobject_ext.SUBOBJECT_TYPES`单一事实源本文件不重复定义
3) 关联表判定Q-OPEN-3不改 world 模板/蓝图对 world / scene / entity / script 的引用
一律落 **关联表 `pbl_bp_ref`**M1a 已有表 ref_status
**禁止**对复用域基表做任何 ALTER / 新增列 / 新增外键
`PROTECTED_BASE_TABLES` 即该裁决的机器可校验清单tests/test_m1b_template.py 有守卫用例
`assert_no_base_table_change()` CI / QC 直接调用
全库口径 FOREIGN KEY索引 tenant 维度打头业务主键 xxx_code VARCHAR(32) + uk(tenant, code)
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# 常量:租户归一 / 保护基表Q-OPEN-3
# ---------------------------------------------------------------------------
#: tenant_id 为 NULL平台公共模板时在唯一索引/内存归一中使用的哨兵值
PLATFORM_TENANT_KEY = "__PLATFORM__"
#: Q-OPEN-3 裁决M1b 零改动的复用域基表(只读校验,引用关系落 pbl_bp_ref 关联表)
PROTECTED_BASE_TABLES = (
"world",
"scene",
"entity",
"script",
"world_snapshot",
"world_sync",
)
#: M1b 新增表(仅这 2 张,其余写入均通过 M1a 聚合根契约完成)
M1B_TABLES = ("pbl_template", "pbl_template_instance_log")
#: 外部引用域 -> 关联表Q-OPEN-3统一 pbl_bp_ref不落基表
REF_DOMAIN_RELATION_TABLE = {
"world": "pbl_bp_ref",
"scene": "pbl_bp_ref",
"entity": "pbl_bp_ref",
"script": "pbl_bp_ref",
}
def normalize_tenant_key(tenant_id):
"""tenant_id -> 唯一索引归一键NULL/'' 视为平台公共)。"""
if tenant_id is None:
return PLATFORM_TENANT_KEY
tenant_id = str(tenant_id).strip()
return tenant_id or PLATFORM_TENANT_KEY
def is_platform_scope(tenant_id):
"""该行是否属于平台公共部分tenant_id NULL"""
return normalize_tenant_key(tenant_id) == PLATFORM_TENANT_KEY
def assert_no_base_table_change(ddl_text):
"""
Q-OPEN-3 守卫断言给定 DDL 文本不含对复用域基表的 ALTER/CREATE 改动
:param ddl_text: str待检查的 DDL 脚本全文
:raises AssertionError: 命中保护基表的写操作
"""
upper = (ddl_text or "").upper()
hits = []
for tbl in PROTECTED_BASE_TABLES:
t = tbl.upper()
for kw in ("ALTER TABLE %s" % t, "ALTER TABLE `%s`" % t,
"CREATE TABLE %s" % t, "CREATE TABLE `%s`" % t,
"DROP TABLE %s" % t, "DROP TABLE `%s`" % t):
if kw in upper:
hits.append(kw)
assert not hits, "Q-OPEN-3 违规M1b 不得改动复用域基表 %s" % sorted(set(hits))
return True
# ---------------------------------------------------------------------------
# 表定义列级单一事实源DDL 与 json/pbl_template.json 由此派生)
# ---------------------------------------------------------------------------
PBL_TEMPLATE_COLUMNS = (
# (列名, 类型, 是否可空, 默认值, 说明)
("id", "BIGINT", False, None, "物理主键 AUTO_INCREMENT"),
("tenant_id", "VARCHAR(32)", True, None, "租户NULL=平台公共模板(全租户只读可见)"),
("template_code", "VARCHAR(32)", False, None, "业务主键(租户内/平台内唯一 + version"),
("template_version", "INT", False, "1", "版本号,升级 append 不覆盖Q-OPEN-9"),
("template_name", "VARCHAR(128)", False, None, "模板名"),
("subject", "VARCHAR(32)", True, None, "学科,字典 pbl_subject"),
("grade", "VARCHAR(16)", True, None, "学段,字典 pbl_grade"),
("tpl_json", "LONGTEXT", False, None, "模板内容7 类子对象数组 + 临时 ID 父子引用"),
("tpl_hash", "VARCHAR(64)", False, None, "sha256(tpl_json),确定性实例化校验"),
("offline_flag", "VARCHAR(1)", False, "'N'", "Y=内置离线兜底模板build.sh 种子落库)"),
("tpl_status", "VARCHAR(16)", False, "'active'", "active/archived字典 pbl_tpl_status"),
("create_time", "DATETIME", False, "CURRENT_TIMESTAMP", "创建时间"),
("update_time", "DATETIME", False, "CURRENT_TIMESTAMP", "更新时间"),
)
PBL_TEMPLATE_INDEXES = (
# (索引名, 类型, 列表达式)
("uk_tpl_code_ver", "UNIQUE", "((IFNULL(`tenant_id`,'__PLATFORM__')), `template_code`, `template_version`)"),
("idx_tpl_status_subject_grade", "NORMAL", "(`tenant_id`, `tpl_status`, `subject`, `grade`)"),
("idx_tpl_offline", "NORMAL", "(`tenant_id`, `offline_flag`)"),
("idx_tpl_hash", "NORMAL", "(`tenant_id`, `tpl_hash`)"),
# 平台公共模板检索tenant_id IS NULL 走该索引,避免全表扫)
("idx_tpl_platform", "NORMAL", "(`tpl_status`, `offline_flag`, `subject`, `grade`)"),
)
PBL_TEMPLATE_INSTANCE_LOG_COLUMNS = (
("id", "BIGINT", False, None, "物理主键 AUTO_INCREMENT"),
("tenant_id", "VARCHAR(32)", False, None, "租户实例化必属某租户NOT NULL"),
("instance_code", "VARCHAR(32)", False, None, "业务主键"),
("client_request_id", "VARCHAR(64)", False, None, "幂等键uk(tenant_id, client_request_id)"),
("template_code", "VARCHAR(32)", False, None, "来源模板编码"),
("template_version", "INT", False, None, "来源模板版本"),
("tpl_hash", "VARCHAR(64)", False, None, "实例化时模板 hash可复现"),
("template_scope", "VARCHAR(16)", False, "'tenant'", "模板归属tenant/platform平台公共模板留痕"),
("blueprint_id", "VARCHAR(32)", True, None, "产出蓝图(无 FKfailed 时可为空)"),
("created_count", "INT", False, "0", "落库子对象数"),
("ref_unresolved_count", "INT", False, "0", "未解析外部引用数"),
("offline_used", "VARCHAR(1)", False, "'N'", "Y=走离线兜底路径"),
("instance_status", "VARCHAR(16)", False, None, "success/partial/failed字典 pbl_instance_status"),
("error_code", "VARCHAR(48)", True, None, "失败/降级错误码(可观测,非静默)"),
("operator_id", "VARCHAR(32)", True, None, "操作人rbac"),
("create_time", "DATETIME", False, "CURRENT_TIMESTAMP", "append-only无 update_time、无物理删除"),
)
PBL_TEMPLATE_INSTANCE_LOG_INDEXES = (
("uk_inst_code", "UNIQUE", "(`tenant_id`, `instance_code`)"),
("uk_inst_client_req", "UNIQUE", "(`tenant_id`, `client_request_id`)"),
("idx_inst_tpl", "NORMAL", "(`tenant_id`, `template_code`, `template_version`)"),
("idx_inst_bp", "NORMAL", "(`tenant_id`, `blueprint_id`)"),
("idx_inst_ctime", "NORMAL", "(`tenant_id`, `create_time`)"),
)
TABLE_DEFS = {
"pbl_template": {
"columns": PBL_TEMPLATE_COLUMNS,
"indexes": PBL_TEMPLATE_INDEXES,
"comment": "PBL 模板主表+内容M1btenant_id NULL=平台公共模板",
"append_only": False,
},
"pbl_template_instance_log": {
"columns": PBL_TEMPLATE_INSTANCE_LOG_COLUMNS,
"indexes": PBL_TEMPLATE_INSTANCE_LOG_INDEXES,
"comment": "PBL 模板实例化留痕M1bappend-only",
"append_only": True,
},
}
# ---------------------------------------------------------------------------
# DDL 生成MySQL 8 主口径 + 5.7 回退)
# ---------------------------------------------------------------------------
def _col_ddl(col):
name, ctype, nullable, default, comment = col
parts = ["`%s`" % name, ctype]
parts.append("NULL" if nullable else "NOT NULL")
if name == "id":
parts.append("AUTO_INCREMENT")
elif default is not None:
parts.append("DEFAULT %s" % default)
if name == "update_time":
parts.append("ON UPDATE CURRENT_TIMESTAMP")
safe = (comment or "").replace("'", "''")
parts.append("COMMENT '%s'" % safe)
return " " + " ".join(parts)
def _idx_ddl(idx):
name, kind, cols = idx
prefix = "UNIQUE KEY" if kind == "UNIQUE" else "KEY"
return " %s `%s` %s" % (prefix, name, cols)
def build_ddl(table_name, mysql57=False):
"""
生成建表 DDL
:param table_name: pbl_template / pbl_template_instance_log
:param mysql57: True 时唯一索引退化为普通列组合需配合 tenant_key 归一列 DDL_MYSQL57_FALLBACK
"""
defn = TABLE_DEFS[table_name]
lines = [_col_ddl(c) for c in defn["columns"]]
lines.append(" PRIMARY KEY (`id`)")
for idx in defn["indexes"]:
name, kind, cols = idx
if mysql57 and "IFNULL" in cols:
cols = "(`tenant_key`, `template_code`, `template_version`)"
lines.append(" `tenant_key` VARCHAR(32) NOT NULL DEFAULT '__PLATFORM__' "
"COMMENT '租户归一键MySQL5.7 回退NULL->__PLATFORM__'")
lines.append(_idx_ddl((name, kind, cols)))
comment = (defn["comment"] or "").replace("'", "''")
return "CREATE TABLE IF NOT EXISTS `%s` (\n%s\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s';" % (
table_name, ",\n".join(lines), comment)
def build_all_ddl(mysql57=False):
"""M1b 全量 DDL2 表)。生成后自动过 Q-OPEN-3 守卫。"""
ddl = "\n\n".join(build_ddl(t, mysql57=mysql57) for t in M1B_TABLES)
assert_no_base_table_change(ddl)
return ddl
#: MySQL 5.7 无函数索引时的等价方案说明(保留备查,主口径为 MySQL 8 函数索引)
DDL_MYSQL57_FALLBACK = """
-- MySQL 5.7 回退pbl_template 增加 tenant_key VARCHAR(32) NOT NULL DEFAULT '__PLATFORM__'
-- 写入侧由 models.pbl_template.normalize_tenant_key() 统一填充NULL 租户 -> '__PLATFORM__'
-- UNIQUE KEY uk_tpl_code_ver (tenant_key, template_code, template_version)
-- KEY idx_tpl_code (tenant_id, template_code)
-- 语义与 MySQL 8 函数索引完全等价build_ddl(mysql57=True) 可直接产出
"""
if __name__ == "__main__": # pragma: no cover - 人工核对用
print(build_all_ddl())

View File

@ -0,0 +1,88 @@
-- =====================================================================
-- pbl_blueprint · M1b DDL2 表)
-- 权威projects/pbls/docs/01-design/data-model.md §2.4
-- projects/pbls/docs/01-design/modules/pbl_blueprint.M1b-annex.md §5
-- 裁决Q-OPEN-1schema 权威)/ Q-OPEN-3不改 world 等复用域基表,引用落关联表 pbl_bp_ref
-- Q-OPEN-4外部引用缺失阈值可配 strict/warn/ Q-OPEN-9版本 append 不覆盖)
-- 口径:无 FOREIGN KEYtenant 维度索引打头;业务主键 xxx_code VARCHAR(32)+uk(tenant,code)
-- 生成器python -m pbl_blueprint.models.pbl_templatebuild_all_ddl
-- =====================================================================
-- ---------------------------------------------------------------------
-- 1. pbl_template —— 模板主表 + 内容
-- M1b 扩展点①tenant_id 允许 NULL = 平台公共模板(全租户只读可见,仅平台管理员可写/归档)
-- M1b 扩展点②tpl_json 存 7 类子对象(字典 pbl_subobject_type+ 模板内临时 ID 父子引用
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `pbl_template` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '物理主键',
`tenant_id` VARCHAR(32) NULL COMMENT '租户NULL=平台公共模板(全租户只读可见)',
`template_code` VARCHAR(32) NOT NULL COMMENT '业务主键scope 内唯一 + version',
`template_version` INT NOT NULL DEFAULT 1 COMMENT '版本号,升级 append 不覆盖Q-OPEN-9',
`template_name` VARCHAR(128) NOT NULL COMMENT '模板名',
`subject` VARCHAR(32) NULL COMMENT '学科,字典 pbl_subject',
`grade` VARCHAR(16) NULL COMMENT '学段,字典 pbl_grade',
`tpl_json` LONGTEXT NOT NULL COMMENT '模板内容7 类子对象数组 + 临时 ID 父子引用',
`tpl_hash` VARCHAR(64) NOT NULL COMMENT 'sha256(规范化 tpl_json),确定性实例化校验',
`offline_flag` VARCHAR(1) NOT NULL DEFAULT 'N' COMMENT 'Y=内置离线兜底模板build.sh 种子落库)',
`tpl_status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/archived字典 pbl_tpl_status',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
-- 平台公共部分唯一性MySQL 8 函数索引,使 tenant_id NULL 行同样受唯一约束
UNIQUE KEY `uk_tpl_code_ver` ((IFNULL(`tenant_id`,'__PLATFORM__')), `template_code`, `template_version`),
KEY `idx_tpl_status_subject_grade` (`tenant_id`, `tpl_status`, `subject`, `grade`),
KEY `idx_tpl_offline` (`tenant_id`, `offline_flag`),
KEY `idx_tpl_hash` (`tenant_id`, `tpl_hash`),
-- 平台公共模板tenant_id IS NULL检索专用避免全表扫
KEY `idx_tpl_platform` (`tpl_status`, `offline_flag`, `subject`, `grade`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='PBL 模板主表+内容M1btenant_id NULL=平台公共模板';
-- MySQL 5.7 回退(无函数索引时):
-- ALTER 由部署脚本执行(仅动 M1b 自有表,不触碰复用域基表)
-- `tenant_key` VARCHAR(32) NOT NULL DEFAULT '__PLATFORM__' COMMENT '租户归一键NULL->__PLATFORM__'
-- UNIQUE KEY `uk_tpl_code_ver` (`tenant_key`, `template_code`, `template_version`)
-- 写入侧统一由 models.pbl_template.normalize_tenant_key() 填充。
-- ---------------------------------------------------------------------
-- 2. pbl_template_instance_log —— 实例化留痕append-only无 update_time、无物理删除
-- 幂等uk(tenant_id, client_request_id),重复请求返回首次结果 deduped=true
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `pbl_template_instance_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '物理主键',
`tenant_id` VARCHAR(32) NOT NULL COMMENT '租户(实例化必属某租户)',
`instance_code` VARCHAR(32) NOT NULL COMMENT '业务主键',
`client_request_id` VARCHAR(64) NOT NULL COMMENT '幂等键',
`template_code` VARCHAR(32) NOT NULL COMMENT '来源模板编码',
`template_version` INT NOT NULL COMMENT '来源模板版本',
`tpl_hash` VARCHAR(64) NOT NULL COMMENT '实例化时模板 hash可复现',
`template_scope` VARCHAR(16) NOT NULL DEFAULT 'tenant' COMMENT '模板归属tenant/platform平台公共模板留痕',
`blueprint_id` VARCHAR(32) NULL COMMENT '产出蓝图(无 FKfailed 时可为空)',
`created_count` INT NOT NULL DEFAULT 0 COMMENT '落库子对象数',
`ref_unresolved_count` INT NOT NULL DEFAULT 0 COMMENT '未解析外部引用数',
`offline_used` VARCHAR(1) NOT NULL DEFAULT 'N' COMMENT 'Y=走离线兜底路径',
`instance_status` VARCHAR(16) NOT NULL COMMENT 'success/partial/failed字典 pbl_instance_status',
`error_code` VARCHAR(48) NULL COMMENT '失败/降级错误码(可观测,不静默)',
`operator_id` VARCHAR(32) NULL COMMENT '操作人rbac',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'append-only',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_inst_code` (`tenant_id`, `instance_code`),
UNIQUE KEY `uk_inst_client_req` (`tenant_id`, `client_request_id`),
KEY `idx_inst_tpl` (`tenant_id`, `template_code`, `template_version`),
KEY `idx_inst_bp` (`tenant_id`, `blueprint_id`),
KEY `idx_inst_ctime` (`tenant_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='PBL 模板实例化留痕M1bappend-only';
-- =====================================================================
-- Q-OPEN-3 裁决落地说明(关联表判定)
-- =====================================================================
-- 判定结论:模板/蓝图对 world / scene / entity / script 的引用,一律落 **关联表 pbl_bp_ref**
-- M1a 已有表,含 ref_status 列resolved / unresolved
-- **不修改** world / scene / entity / script 等复用域基表(零 ALTER、零新增列、零 FK
-- 理由:①复用域基表被 world/scene/entity/script_engine 多模块共享,加列会外溢影响面;
-- ②引用关系是「蓝图侧」的语义(含解析状态、引用序号),归属蓝图聚合根更内聚;
-- ③M1b-annex §7 明确「对复用域模块零改表(外部引用仅只读校验)」。
-- 守卫models/pbl_template.py:assert_no_base_table_change() + tests/test_m1b_template.py
-- 任何 M1b DDL/迁移脚本命中 PROTECTED_BASE_TABLES 的 ALTER/CREATE/DROP 即测试失败。
-- =====================================================================

View File

@ -0,0 +1,361 @@
# -*- coding: utf-8 -*-
"""
pbl_blueprint · M1b 子对象扩展模板侧 7 类子对象同构契约 + ID 重映射
设计依据
- M1b-annex §27 类子对象/ §3 ID 重映射/ §6与在途 M1a 代码的对齐核对
- 字典 `pbl_subobject_type`pbl_appcodes 注入
边界硬约束
* 本文件**只做模板侧的纯函数扩展**类型校验schema 校验临时 ID -> 业务主键重映射
外部引用抽取**不直接写 M1a 的任何表**写入一律经 M1a 聚合根契约
create_blueprint / batch_save_subobjects / append_version / get_tree
* 外部引用world/scene/entity/script**保持原值不重映射**其存在性校验结果落
关联表 `pbl_bp_ref.ref_status`Q-OPEN-3不改 world 等基表
"""
from __future__ import annotations
import hashlib
import json
# ---------------------------------------------------------------------------
# 7 类子对象:单一事实源
# ---------------------------------------------------------------------------
#: 每类子对象的契约:编码前缀 / 允许的父类型 / 必填字段 / 是否外部引用类
SUBOBJECT_TYPES = {
"stage": {
"code_prefix": "STG",
"parents": (), # 顶层(父为蓝图本身)
"required": ("name",),
"external_ref": False,
"order_field": "seq",
},
"task": {
"code_prefix": "TSK",
"parents": ("stage",),
"required": ("name",),
"external_ref": False,
"order_field": "seq",
},
"role": {
"code_prefix": "ROL",
"parents": ("stage", "task"),
"required": ("name",),
"external_ref": False,
"order_field": "seq",
},
"world_ref": {
"code_prefix": "WRF",
"parents": ("stage", "task"),
"required": ("world_code",),
"external_ref": True,
"ref_domain": "world",
"ref_field": "world_code",
"order_field": "seq",
},
"scene_ref": {
"code_prefix": "SRF",
"parents": ("stage", "task"),
"required": ("scene_code",),
"external_ref": True,
"ref_domain": "scene",
"ref_field": "scene_code",
"order_field": "seq",
},
"entity_ref": {
"code_prefix": "ERF",
"parents": ("scene_ref", "task"),
"required": ("entity_code",),
"external_ref": True,
"ref_domain": "entity",
"ref_field": "entity_code",
"order_field": "seq",
},
"script_ref": {
"code_prefix": "CRF",
"parents": ("task", "role", "entity_ref"),
"required": ("script_code",),
"external_ref": True,
"ref_domain": "script",
"ref_field": "script_code",
"order_field": "seq",
},
}
SUBOBJECT_TYPE_LIST = tuple(SUBOBJECT_TYPES.keys())
#: 模板内父子引用字段(临时 ID
TMP_PARENT_FIELD = "parent_tmp_id"
#: 模板内子对象临时 ID 字段
TMP_ID_FIELD = "tmp_id"
#: tpl_json 顶层结构版本
TPL_SCHEMA_VERSION = "1.0"
class TplSchemaError(ValueError):
"""模板 schema 不合法(对应错误码 PBL_TPL_SCHEMA_INVALID阻断不落库"""
code = "PBL_TPL_SCHEMA_INVALID"
def __init__(self, message, detail=None):
super(TplSchemaError, self).__init__(message)
self.message = message
self.detail = detail or {}
# ---------------------------------------------------------------------------
# 模板内容解析 / 校验
# ---------------------------------------------------------------------------
def parse_tpl_json(tpl_json):
"""
tpl_jsonstr dict-> dict解析失败即 schema 不合法
"""
if isinstance(tpl_json, dict):
data = tpl_json
else:
try:
data = json.loads(tpl_json or "")
except Exception as exc: # noqa: BLE001 - 统一转 schema 错误
raise TplSchemaError("tpl_json 不是合法 JSON: %s" % exc)
if not isinstance(data, dict):
raise TplSchemaError("tpl_json 顶层必须是对象")
return data
def tpl_hash(tpl_json):
"""
确定性 hash**规范化 JSON**键排序无多余空白UTF-8 sha256
同模板同输入 -> hash -> 同结构M1b 出口门禁④确定性
"""
data = tpl_json if isinstance(tpl_json, dict) else parse_tpl_json(tpl_json)
canonical = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def validate_tpl_schema(tpl_json):
"""
模板 schema 合法性校验Q-OPEN-1schema 权威 = data-model.md + appcodes 字典约束
校验项
1. 顶层含 schema_version / subobjects数组
2. 每个子对象 subobject_type 7 类字典
3. 必填字段齐备按类型
4. tmp_id 唯一且非空
5. parent_tmp_id 必须指向已定义 tmp_id且父类型在该类型允许的 parents
6. 无环父子引用 DAG
7. 外部引用类子对象必须带对应 xxx_code原值不重映射
:return: dict规范化后的模板内容subobjects 已按拓扑序排列
:raises TplSchemaError: 任一项不通过阻断不落任何数据
"""
data = parse_tpl_json(tpl_json)
schema_version = str(data.get("schema_version") or TPL_SCHEMA_VERSION)
if schema_version.split(".")[0] != TPL_SCHEMA_VERSION.split(".")[0]:
raise TplSchemaError(
"模板 schema 主版本不兼容: %s(期望 %s" % (schema_version, TPL_SCHEMA_VERSION),
{"schema_version": schema_version})
subobjects = data.get("subobjects")
if not isinstance(subobjects, list):
raise TplSchemaError("tpl_json.subobjects 必须是数组")
if not subobjects:
raise TplSchemaError("模板至少包含 1 个子对象")
by_tmp_id = {}
for idx, so in enumerate(subobjects):
if not isinstance(so, dict):
raise TplSchemaError("subobjects[%d] 必须是对象" % idx, {"index": idx})
stype = so.get("subobject_type")
if stype not in SUBOBJECT_TYPES:
raise TplSchemaError(
"subobject_type 非法: %r(字典 pbl_subobject_type 允许 %s" % (stype, SUBOBJECT_TYPE_LIST),
{"index": idx, "subobject_type": stype})
spec = SUBOBJECT_TYPES[stype]
tmp_id = so.get(TMP_ID_FIELD)
if not tmp_id or not isinstance(tmp_id, str):
raise TplSchemaError("subobjects[%d] 缺少 %s" % (idx, TMP_ID_FIELD), {"index": idx})
if tmp_id in by_tmp_id:
raise TplSchemaError("tmp_id 重复: %s" % tmp_id, {"index": idx, "tmp_id": tmp_id})
missing = [f for f in spec["required"] if so.get(f) in (None, "")]
if missing:
raise TplSchemaError(
"subobjects[%d](%s) 缺少必填字段 %s" % (idx, stype, missing),
{"index": idx, "tmp_id": tmp_id, "missing": missing})
parent = so.get(TMP_PARENT_FIELD)
if parent is not None:
if not spec["parents"]:
raise TplSchemaError(
"%s 为顶层子对象,不得设置 %s" % (stype, TMP_PARENT_FIELD),
{"index": idx, "tmp_id": tmp_id})
elif spec["parents"]:
raise TplSchemaError(
"%s 必须挂在父对象下(允许父类型 %s" % (stype, list(spec["parents"])),
{"index": idx, "tmp_id": tmp_id, "subobject_type": stype})
by_tmp_id[tmp_id] = so
# 父存在性 + 父类型合法性 + 无环
for tmp_id, so in by_tmp_id.items():
parent = so.get(TMP_PARENT_FIELD)
if parent is None:
continue
pso = by_tmp_id.get(parent)
if pso is None:
raise TplSchemaError(
"parent_tmp_id 悬空: %s -> %s" % (tmp_id, parent),
{"tmp_id": tmp_id, "parent_tmp_id": parent})
ptype = pso.get("subobject_type")
if ptype not in SUBOBJECT_TYPES[so["subobject_type"]]["parents"]:
raise TplSchemaError(
"父子类型不合法: %s(%s) 不能挂在 %s(%s) 下" % (
tmp_id, so["subobject_type"], parent, ptype),
{"tmp_id": tmp_id, "parent_tmp_id": parent})
ordered = _topo_sort(by_tmp_id)
normalized = dict(data)
normalized["schema_version"] = schema_version
normalized["subobjects"] = ordered
return normalized
def _topo_sort(by_tmp_id):
"""按父子关系拓扑排序(父在前),同时检测环。"""
ordered, state = [], {}
def visit(tmp_id, chain):
mark = state.get(tmp_id)
if mark == 1:
raise TplSchemaError("父子引用存在环: %s" % " -> ".join(chain + [tmp_id]),
{"cycle": chain + [tmp_id]})
if mark == 2:
return
state[tmp_id] = 1
so = by_tmp_id[tmp_id]
parent = so.get(TMP_PARENT_FIELD)
if parent is not None:
visit(parent, chain + [tmp_id])
state[tmp_id] = 2
ordered.append(so)
for tmp_id in sorted(by_tmp_id.keys()):
visit(tmp_id, [])
return ordered
# ---------------------------------------------------------------------------
# 外部引用抽取Q-OPEN-3结果落关联表 pbl_bp_ref不改基表
# ---------------------------------------------------------------------------
def extract_external_refs(subobjects):
"""
抽取模板中的外部引用world/scene/entity/script**保持原值不重映射**
:return: list[dict] {domain, ref_code, tmp_id, subobject_type}
"""
refs = []
for so in subobjects or []:
stype = so.get("subobject_type")
spec = SUBOBJECT_TYPES.get(stype) or {}
if not spec.get("external_ref"):
continue
ref_code = so.get(spec["ref_field"])
if ref_code in (None, ""):
continue
refs.append({
"domain": spec["ref_domain"],
"ref_code": str(ref_code),
"tmp_id": so.get(TMP_ID_FIELD),
"subobject_type": stype,
})
return refs
def group_refs_by_domain(refs):
"""按域分组去重供批量存在性校验pbl_world_projection / pbl_scene_projection /
pbl_entity_projection / script_engine.validate_script"""
grouped = {}
for r in refs or []:
grouped.setdefault(r["domain"], set()).add(r["ref_code"])
return {k: sorted(v) for k, v in grouped.items()}
# ---------------------------------------------------------------------------
# ID 重映射M1b-annex §3 ④)
# ---------------------------------------------------------------------------
def remap_ids(subobjects, gen_code, tenant_id, code_map=None):
"""
为模板内每个临时 ID 生成新业务主键并同步替换父子引用
:param subobjects: validate_tpl_schema 产出的拓扑序子对象数组
:param gen_code: pbl_common.gen_code(prefix, tenant_id) 复用 M1a 同一编码函数不另造规则
:param tenant_id: 租户平台公共模板实例化时传**目标租户**编码归属目标租户
:param code_map: 可选外部传入的 tmp_id -> new_code 映射用于确定性重放/测试
:return: (items, mapping)
items = M1a batch_save_subobjects 入参数组
[{subobject_type, subobject_code, parent_code, payload...}]
mapping = {tmp_id: new_code}
"""
mapping = dict(code_map or {})
items = []
for so in subobjects:
tmp_id = so[TMP_ID_FIELD]
stype = so["subobject_type"]
spec = SUBOBJECT_TYPES[stype]
if tmp_id not in mapping:
mapping[tmp_id] = gen_code(spec["code_prefix"], tenant_id)
new_code = mapping[tmp_id]
parent_tmp = so.get(TMP_PARENT_FIELD)
parent_code = mapping.get(parent_tmp) if parent_tmp else None
if parent_tmp and parent_code is None:
# 理论上 validate 已阻断;此处 fail-closed 不静默
raise TplSchemaError("父子重映射失败: %s -> %s" % (tmp_id, parent_tmp),
{"tmp_id": tmp_id, "parent_tmp_id": parent_tmp})
item = {
"subobject_type": stype,
"subobject_code": new_code,
"parent_code": parent_code,
}
for key, val in so.items():
if key in (TMP_ID_FIELD, TMP_PARENT_FIELD, "subobject_type"):
continue
item[key] = val
items.append(item)
return items, mapping
def apply_ref_status(items, refs, resolved_map):
"""
把外部引用存在性校验结果写回子对象 payload M1a `pbl_bp_ref.ref_status`
:param resolved_map: {domain: {ref_code: True/False}}
:return: unresolved_count
"""
resolved_map = resolved_map or {}
unresolved = 0
ref_index = {(r["tmp_id"], r["domain"]): r["ref_code"] for r in refs}
for item in items:
stype = item.get("subobject_type")
spec = SUBOBJECT_TYPES.get(stype) or {}
if not spec.get("external_ref"):
continue
domain = spec["ref_domain"]
ref_code = item.get(spec["ref_field"])
ok = bool(resolved_map.get(domain, {}).get(ref_code))
item["ref_status"] = "resolved" if ok else "unresolved"
if not ok:
unresolved += 1
# 保留 ref_index 引用避免 lint 抱怨(供调试追溯)
assert ref_index is not None
return unresolved

View File

@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""
pbl_blueprint · M1b 模板平台公共部分tenant_id NULL读写与解析
规则本文件是 M1b 平台公共模板的唯一实现口径
1. `pbl_template.tenant_id IS NULL` => 平台公共模板platform scope
- **全部租户只读可见**list/get 自动并入
- 仅平台管理员可写/归档`is_platform_admin(ctx)` 门禁非管理员写 -> PBL_TPL_PLATFORM_FORBIDDEN
- 租户**不得**修改/删除平台公共行只能 fork 成租户私有模板 template_code 或同 code 的租户行
2. 解析优先级租户私有 > 平台公共 template_code + template_version 时租户行覆盖平台行
3. 唯一性MySQL 8 函数索引 uk((IFNULL(tenant_id,'__PLATFORM__')), template_code, template_version)
保证平台公共行同样受唯一约束NULL 不参与 MySQL 唯一索引比较的坑由函数索引消除
4. 实例化平台公共模板时产出的蓝图/子对象/实例化日志 **tenant_id 一律为发起租户**平台公共只存在于模板层
不产生跨租户数据`pbl_template_instance_log.template_scope='platform'` 留痕来源
5. 离线兜底模板offline_flag='Y' build.sh 种子落库为**平台公共行**确保 test 环境冷启动即可用
所有查询 tenant 维度打头 FOREIGN KEY不直写 M1a
"""
from __future__ import annotations
from .models.pbl_template import (
PLATFORM_TENANT_KEY,
is_platform_scope,
normalize_tenant_key,
)
#: 平台公共模板可见性范围标识
SCOPE_PLATFORM = "platform"
SCOPE_TENANT = "tenant"
#: 解析顺序:租户私有优先,平台公共兜底
RESOLVE_ORDER = (SCOPE_TENANT, SCOPE_PLATFORM)
class TemplateError(Exception):
"""模板域业务异常基类(错误码与 M1a errors.py 同口径)。"""
code = "PBL_TPL_ERROR"
def __init__(self, message, **detail):
super(TemplateError, self).__init__(message)
self.message = message
self.detail = detail
class TemplateNotFound(TemplateError):
code = "PBL_TEMPLATE_NOT_FOUND"
class TemplatePlatformForbidden(TemplateError):
code = "PBL_TPL_PLATFORM_FORBIDDEN"
class TemplateSchemaInvalid(TemplateError):
code = "PBL_TPL_SCHEMA_INVALID"
# ---------------------------------------------------------------------------
# 租户上下文(与 M1a tenant.py 同口径tenant_id 强制打头,缺失即 fail-closed
# ---------------------------------------------------------------------------
def require_tenant(tenant_ctx):
"""
tenant_id缺失抛 PBL_TENANT_MISSINGfail-closed不静默不默认全局
平台管理员操作平台公共模板时同样必须携带 tenant_ctx可为平台租户
"""
tenant_id = None
if isinstance(tenant_ctx, dict):
tenant_id = tenant_ctx.get("tenant_id")
else:
tenant_id = getattr(tenant_ctx, "tenant_id", None)
if tenant_id in (None, ""):
err = TemplateError("tenant_id 缺失")
err.code = "PBL_TENANT_MISSING"
raise err
return str(tenant_id)
def is_platform_admin(tenant_ctx):
"""
是否平台管理员可写平台公共模板
兼容三种上下文表达is_platform_admin / platform_admin / role 列表含 'platform_admin'
"""
if isinstance(tenant_ctx, dict):
ctx = tenant_ctx
else:
ctx = getattr(tenant_ctx, "__dict__", {}) or {}
if ctx.get("is_platform_admin") or ctx.get("platform_admin"):
return True
roles = ctx.get("roles") or []
if isinstance(roles, str):
roles = [roles]
return any(str(r) in ("platform_admin", "owner.platform", "admin.platform") for r in roles)
# ---------------------------------------------------------------------------
# SQL 片段tenant 维度打头;平台公共 = tenant_id IS NULL
# ---------------------------------------------------------------------------
def scope_where(tenant_id, include_platform=True, alias=""):
"""
可见范围 WHERE 片段租户私有 + 平台公共
:return: (sql_fragment, params)
"""
col = "%stenant_id" % alias
if include_platform:
return "(%s = %%s OR %s IS NULL)" % (col, col), [tenant_id]
return "%s = %%s" % col, [tenant_id]
def build_list_sql(tenant_id, filters=None, include_platform=True):
"""
模板库浏览与筛选F-CP-0x租户私有优先排序平台公共兜底
filters: {subject, grade, tpl_status, offline_flag, template_name(like), scope}
"""
filters = filters or {}
include_platform = include_platform and filters.get("scope") != SCOPE_TENANT
where, params = scope_where(tenant_id, include_platform=include_platform)
conds = [where]
if filters.get("scope") == SCOPE_PLATFORM:
conds = ["tenant_id IS NULL"]
params = []
for key, col in (("subject", "subject"), ("grade", "grade"),
("tpl_status", "tpl_status"), ("offline_flag", "offline_flag")):
if filters.get(key) not in (None, ""):
conds.append("%s = %%s" % col)
params.append(filters[key])
if filters.get("template_name"):
conds.append("template_name LIKE %s")
params.append("%%%s%%" % filters["template_name"])
if not filters.get("tpl_status"):
conds.append("tpl_status <> 'archived'")
sql = (
"SELECT id, tenant_id, template_code, template_version, template_name, subject, grade, "
"tpl_hash, offline_flag, tpl_status, create_time, update_time, "
"CASE WHEN tenant_id IS NULL THEN '%s' ELSE '%s' END AS template_scope "
"FROM pbl_template WHERE %s "
"ORDER BY (tenant_id IS NULL) ASC, template_code ASC, template_version DESC"
% (SCOPE_PLATFORM, SCOPE_TENANT, " AND ".join(conds))
)
return sql, params
def build_resolve_sql(tenant_id, template_code, template_version=None):
"""
模板解析租户私有 > 平台公共一次查询取回候选行 pick_template 选定
template_version None 时取各 scope 的最新 active 版本
"""
sql = (
"SELECT id, tenant_id, template_code, template_version, template_name, subject, grade, "
"tpl_json, tpl_hash, offline_flag, tpl_status, create_time, update_time "
"FROM pbl_template "
"WHERE (tenant_id = %s OR tenant_id IS NULL) AND template_code = %s "
"AND tpl_status = 'active' "
)
params = [tenant_id, template_code]
if template_version is not None:
sql += "AND template_version = %s "
params.append(int(template_version))
sql += ("ORDER BY (tenant_id IS NULL) ASC, template_version DESC LIMIT 2")
return sql, params
def pick_template(rows):
"""
从候选行按 RESOLVE_ORDER 选定模板行无候选抛 PBL_TEMPLATE_NOT_FOUND
:param rows: build_resolve_sql 的结果已按 租户优先 + 版本倒序 排序
:return: (row, scope) scope {'tenant','platform'}
"""
if not rows:
raise TemplateNotFound("模板不存在或已归档")
tenant_rows = [r for r in rows if not is_platform_scope(_get(r, "tenant_id"))]
platform_rows = [r for r in rows if is_platform_scope(_get(r, "tenant_id"))]
buckets = {SCOPE_TENANT: tenant_rows, SCOPE_PLATFORM: platform_rows}
for scope in RESOLVE_ORDER:
if buckets.get(scope):
return buckets[scope][0], scope
raise TemplateNotFound("模板不存在或已归档")
def build_offline_sql(tenant_id, subject=None, grade=None):
"""
离线兜底模板选取M1b-annex §4offline_flag='Y' active租户私有优先平台公共兜底
"""
sql = (
"SELECT id, tenant_id, template_code, template_version, template_name, subject, grade, "
"tpl_json, tpl_hash, offline_flag, tpl_status "
"FROM pbl_template "
"WHERE (tenant_id = %s OR tenant_id IS NULL) AND offline_flag = 'Y' "
"AND tpl_status = 'active' "
)
params = [tenant_id]
if subject:
sql += "AND (subject = %s OR subject IS NULL) "
params.append(subject)
if grade:
sql += "AND (grade = %s OR grade IS NULL) "
params.append(grade)
sql += "ORDER BY (tenant_id IS NULL) ASC, template_version DESC LIMIT 1"
return sql, params
def build_platform_write_guard(tenant_ctx, target_tenant_id):
"""
写入门禁写平台公共行target_tenant_id None/'')必须平台管理员
:return: scope'platform'/'tenant'
:raises TemplatePlatformForbidden: 非平台管理员写平台公共模板
"""
if is_platform_scope(target_tenant_id):
if not is_platform_admin(tenant_ctx):
raise TemplatePlatformForbidden(
"平台公共模板tenant_id NULL仅平台管理员可写/归档")
return SCOPE_PLATFORM
# 租户行tenant_ctx.tenant_id 必须与目标一致(禁止跨租户写)
ctx_tenant = require_tenant(tenant_ctx)
if target_tenant_id and str(target_tenant_id) != ctx_tenant:
raise TemplatePlatformForbidden(
"跨租户写模板被拒绝: ctx=%s target=%s" % (ctx_tenant, target_tenant_id))
return SCOPE_TENANT
def tenant_key_for_insert(target_tenant_id):
"""
插入时的归一键MySQL 5.7 回退列 tenant_key MySQL 8 函数索引下无需写该列
"""
return normalize_tenant_key(target_tenant_id)
def _get(row, key, default=None):
if isinstance(row, dict):
return row.get(key, default)
return getattr(row, key, default)
__all__ = [
"PLATFORM_TENANT_KEY", "SCOPE_PLATFORM", "SCOPE_TENANT", "RESOLVE_ORDER",
"TemplateError", "TemplateNotFound", "TemplatePlatformForbidden", "TemplateSchemaInvalid",
"require_tenant", "is_platform_admin", "scope_where", "build_list_sql",
"build_resolve_sql", "pick_template", "build_offline_sql",
"build_platform_write_guard", "tenant_key_for_insert",
]

View File

@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""
M1b 离线兜底模板种子加载器build.sh 调用
用法
python scripts/seed_m1b_template.py # 建表 + 落种子(幂等)
python scripts/seed_m1b_template.py --ddl-only # 仅建表
python scripts/seed_m1b_template.py --dry-run # 只打印 DDL/种子,不连库
硬约束M1b-annex §4
* 离线模板**必须** build.sh 种子落库不依赖运行时下载确保 test 环境冷启动即可用
* 落库为**平台公共行**tenant_id NULL全租户可见
* 升级 append template_version**不覆盖**旧版本行Q-OPEN-9
* 幂等 (tenant_key, template_code, template_version) upsert重复执行零副作用
* Q-OPEN-3本脚本只写 M1b 自有 2 绝不 ALTER 复用域基表assert_no_base_table_change 守卫
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from pbl_blueprint.models.pbl_template import ( # noqa: E402
assert_no_base_table_change,
build_all_ddl,
normalize_tenant_key,
)
from pbl_blueprint.subobject_ext import tpl_hash, validate_tpl_schema # noqa: E402
SEED_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "pbl_blueprint", "json", "seed_template_offline.json")
def load_seed(path=None):
"""读种子文件并逐条过 schema 校验(不合法直接失败,不落半截数据)。"""
with open(path or SEED_FILE, "r", encoding="utf-8") as fh:
data = json.load(fh)
rows = []
for tpl in data.get("templates", []):
tpl_json = tpl["tpl_json"]
normalized = validate_tpl_schema(tpl_json) # fail-closed
rows.append({
"tenant_id": tpl.get("tenant_id"),
"tenant_key": normalize_tenant_key(tpl.get("tenant_id")),
"template_code": tpl["template_code"],
"template_version": int(tpl.get("template_version", 1)),
"template_name": tpl["template_name"],
"subject": tpl.get("subject"),
"grade": tpl.get("grade"),
"tpl_json": json.dumps(normalized, ensure_ascii=False, sort_keys=True),
"tpl_hash": tpl_hash(normalized),
"offline_flag": tpl.get("offline_flag", "Y"),
"tpl_status": tpl.get("tpl_status", "active"),
})
return rows
UPSERT_SQL = (
"INSERT INTO pbl_template "
"(tenant_id, template_code, template_version, template_name, subject, grade, "
" tpl_json, tpl_hash, offline_flag, tpl_status, create_time, update_time) "
"VALUES (%(tenant_id)s, %(template_code)s, %(template_version)s, %(template_name)s, "
" %(subject)s, %(grade)s, %(tpl_json)s, %(tpl_hash)s, %(offline_flag)s, %(tpl_status)s, "
" NOW(), NOW()) "
"ON DUPLICATE KEY UPDATE "
" template_name=VALUES(template_name), subject=VALUES(subject), grade=VALUES(grade), "
" tpl_json=VALUES(tpl_json), tpl_hash=VALUES(tpl_hash), "
" offline_flag=VALUES(offline_flag), tpl_status=VALUES(tpl_status), update_time=NOW()"
)
def seed(conn=None, dry_run=False):
"""
建表 + 落种子
:param conn: DB-API 连接None 且非 dry_run 时尝试从模块 db 适配层取
:return: dict {ddl, seeded: n}
"""
ddl = build_all_ddl()
assert_no_base_table_change(ddl) # Q-OPEN-3 守卫
rows = load_seed()
if dry_run or conn is None:
print(ddl)
print("-- seed rows: %d" % len(rows))
for r in rows:
print("-- %s v%s scope=%s offline=%s hash=%s" % (
r["template_code"], r["template_version"],
"platform" if r["tenant_id"] is None else r["tenant_id"],
r["offline_flag"], r["tpl_hash"][:12]))
return {"ddl": ddl, "seeded": len(rows), "executed": False}
cur = conn.cursor()
for stmt in ddl.split(";\n"):
stmt = stmt.strip()
if stmt:
cur.execute(stmt)
for r in rows:
cur.execute(UPSERT_SQL, r)
conn.commit()
cur.close()
return {"ddl": ddl, "seeded": len(rows), "executed": True}
def main(argv=None):
argv = list(argv if argv is not None else sys.argv[1:])
dry_run = "--dry-run" in argv
ddl_only = "--ddl-only" in argv
if ddl_only:
print(build_all_ddl())
return 0
res = seed(dry_run=dry_run)
print("[M1b seed] rows=%s executed=%s" % (res["seeded"], res["executed"]))
return 0
if __name__ == "__main__":
raise SystemExit(main())

551
tests/test_m1b_ext_ref.py Normal file
View File

@ -0,0 +1,551 @@
# -*- coding: utf-8 -*-
"""M1b 测试:模板平台公共部分 / 子对象扩展 / 关联表判定。
运行cd modules/pbl_blueprint && python -m pytest tests/test_m1b_ext_ref.py -q
pytest 时可直接 python tests/test_m1b_ext_ref.py
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pbl_blueprint.m1b_common import ( # noqa: E402
IS_NULL, TenantRequiredError, PermissionDeniedError, ValidationError,
NotFoundError, ConflictError,
)
from pbl_blueprint import m1b_template as T # noqa: E402
from pbl_blueprint import m1b_subobject as S # noqa: E402
from pbl_blueprint import m1b_ref as R # noqa: E402
from pbl_blueprint import m1b_api as A # noqa: E402
from pbl_blueprint import m1b_init as I # noqa: E402
class FakeDB(object):
"""最小内存 DB支持 query/insert/update/delete与 m1b_common 适配器契约一致)。"""
def __init__(self):
self.tables = {}
def query(self, table, conds=None, order_by=None, limit=None):
return list(self.tables.get(table, []))
def insert(self, table, row):
self.tables.setdefault(table, []).append(dict(row))
return row.get("id")
def update(self, table, conds, values):
n = 0
for r in self.tables.get(table, []):
if all((r.get(k) in (None, "") if isinstance(v, type(IS_NULL)) else r.get(k) == v)
for k, v in (conds or {}).items()):
r.update(values)
n += 1
return n
def delete(self, table, conds):
rows = self.tables.get(table, [])
keep = [r for r in rows if not all(r.get(k) == v for k, v in (conds or {}).items())]
self.tables[table] = keep
return len(rows) - len(keep)
TENANT = "t-001"
OTHER = "t-002"
class TestPlatformTemplate(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
def test_create_platform_template_requires_admin(self):
with self.assertRaises(PermissionDeniedError):
T.create_template(self.db, None, {"code": "p1", "name": "平台模板"},
actor="u1", is_platform_admin=False)
def test_create_platform_template_ok_and_tenant_isolated(self):
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform",
"content": {"a": 1}},
actor="admin", is_platform_admin=True)
self.assertTrue(r["ok"])
self.assertTrue(r["is_platform"])
self.assertIsNone(r["template"]["tenant_id"])
# 租户可见平台公共模板
lst = T.list_templates(self.db, TENANT)
self.assertEqual(lst["total"], 1)
self.assertTrue(lst["items"][0]["is_platform"])
self.assertFalse(lst["items"][0]["editable"])
# 其他租户同样可见(公共)
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 1)
def test_tenant_cannot_write_platform_template(self):
T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform"},
actor="admin", is_platform_admin=True)
lst = T.list_templates(self.db, TENANT)
tid = lst["items"][0]["id"]
with self.assertRaises(PermissionDeniedError):
T.update_template(self.db, TENANT, tid, {"name": "改名"}, actor="u1",
is_platform_admin=False)
with self.assertRaises(PermissionDeniedError):
T.delete_template(self.db, TENANT, tid, actor="u1", is_platform_admin=False)
def test_tenant_template_not_visible_to_others(self):
T.create_template(self.db, TENANT, {"code": "t1", "name": "租户模板"}, actor="u1")
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 0)
tid = T.list_templates(self.db, TENANT)["items"][0]["id"]
with self.assertRaises(NotFoundError):
T.get_template(self.db, OTHER, tid)
def test_missing_tenant_fail_closed(self):
with self.assertRaises(TenantRequiredError):
T.list_templates(self.db, None)
with self.assertRaises(TenantRequiredError):
T.list_templates(self.db, " ")
def test_code_unique_per_scope(self):
T.create_template(self.db, None, {"code": "same", "name": "平台", "scope": "platform"},
actor="admin", is_platform_admin=True)
# 同 code 在不同租户域可共存
T.create_template(self.db, TENANT, {"code": "same", "name": "租户"}, actor="u1")
with self.assertRaises(ConflictError):
T.create_template(self.db, TENANT, {"code": "same", "name": "重复"}, actor="u1")
with self.assertRaises(ConflictError):
T.create_template(self.db, None, {"code": "same", "name": "重复平台",
"scope": "platform"},
actor="admin", is_platform_admin=True)
def test_fork_platform_to_tenant(self):
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
"scope": "platform",
"content": {"missions": [{"id": "m1"}]},
"ext_schema": {"mission": {"difficulty": "hard"}}},
actor="admin", is_platform_admin=True)
f = T.fork_template(self.db, TENANT, r["id"], actor="u1", new_code="p1_local")
self.assertTrue(f["ok"])
self.assertEqual(f["template"]["tenant_id"], TENANT)
self.assertEqual(f["template"]["source"], "fork")
self.assertEqual(f["template"]["source_template_id"], r["id"])
# 派生副本可写
T.update_template(self.db, TENANT, f["id"], {"name": "本地改名"}, actor="u1")
# 平台模板未受影响
self.assertEqual(T.get_template(self.db, TENANT, r["id"])["name"], "平台模板")
def test_builtin_template_cannot_be_deleted(self):
r = T.create_template(self.db, None, {"code": "b1", "name": "内置", "scope": "platform",
"is_builtin": 1, "content": {"x": 1}},
actor="admin", is_platform_admin=True)
with self.assertRaises(PermissionDeniedError):
T.delete_template(self.db, TENANT, r["id"], actor="admin", is_platform_admin=True)
self.assertTrue(T.deprecate_template(self.db, TENANT, r["id"], actor="admin",
is_platform_admin=True)["ok"])
def test_publish_requires_content(self):
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "空模板"}, actor="u1")
with self.assertRaises(ValidationError):
T.publish_template(self.db, TENANT, r["id"], actor="u1")
T.update_template(self.db, TENANT, r["id"], {"content": {"missions": []}}, actor="u1")
self.assertEqual(T.publish_template(self.db, TENANT, r["id"], actor="u1")["status"],
"published")
def test_instantiate_platform_template_sets_tenant(self):
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
"scope": "platform", "status": "published",
"content": {"missions": [{"id": "m1"}]},
"subobject_kinds": ["mission"]},
actor="admin", is_platform_admin=True)
captured = {}
def fake_create(db, tenant_id, payload, actor=None):
captured["tenant_id"] = tenant_id
captured["payload"] = payload
return {"ok": True, "id": "bp-1"}
res = T.instantiate_template(self.db, TENANT, r["id"], actor="u1",
create_blueprint=fake_create)
self.assertTrue(res["ok"])
self.assertTrue(res["is_platform_template"])
self.assertEqual(captured["tenant_id"], TENANT) # 实例绝不继承 NULL
self.assertEqual(captured["payload"]["tenant_id"], TENANT)
self.assertEqual(captured["payload"]["template_id"], r["id"])
# usage_count +1
self.assertEqual(int(T.get_template(self.db, TENANT, r["id"])["usage_count"]), 1)
def test_instantiate_deprecated_rejected(self):
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "x", "content": {"a": 1}},
actor="u1")
T.deprecate_template(self.db, TENANT, r["id"], actor="u1")
with self.assertRaises(ValidationError):
T.instantiate_template(self.db, TENANT, r["id"], actor="u1")
def test_seed_idempotent(self):
a = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
"content": {"a": 1}}], actor="system")
self.assertEqual(a["created_count"], 1)
b = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
"content": {"a": 1}}], actor="system")
self.assertEqual(b["created_count"], 0)
self.assertEqual(b["skipped_count"], 1)
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
class TestSubobjectExt(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
self.bp = "bp-1"
S.create_ext_field_def(self.db, None, {
"scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty",
"label": "难度", "value_type": "enum", "enum_options": ["easy", "medium", "hard"],
"required": 1}, actor="admin", is_platform_admin=True)
S.create_ext_field_def(self.db, None, {
"scope": "platform", "subobject_kind": "mission", "ext_key": "estimated_minutes",
"label": "耗时", "value_type": "int", "constraints": {"min": 1, "max": 600}},
actor="admin", is_platform_admin=True)
def test_undefined_ext_key_rejected(self):
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "not_defined", "x", actor="u1")
def test_invalid_kind_rejected(self):
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "not_a_kind", "m1", "difficulty", "easy",
actor="u1")
def test_enum_and_int_validation(self):
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "impossible",
actor="u1")
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", 9999,
actor="u1")
r = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", "90",
actor="u1")
self.assertEqual(r["value"], 90) # 字符串按定义转 int
self.assertEqual(r["value_type"], "int")
def test_upsert_idempotent(self):
a = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
self.assertTrue(a["created"])
b = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "hard", actor="u1")
self.assertFalse(b["created"])
self.assertEqual(b["id"], a["id"])
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"),
"hard")
self.assertEqual(len(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"]), 1)
def test_tenant_isolation_on_ext(self):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
self.assertEqual(S.get_ext(self.db, OTHER, self.bp, "mission", "m1", "difficulty"), None)
self.assertEqual(S.list_ext(self.db, OTHER, blueprint_id=self.bp)["total"], 0)
with self.assertRaises(TenantRequiredError):
S.list_ext(self.db, None, blueprint_id=self.bp)
def test_tenant_def_overrides_platform(self):
S.create_ext_field_def(self.db, TENANT, {
"subobject_kind": "mission", "ext_key": "difficulty", "label": "本地难度",
"value_type": "enum", "enum_options": ["L1", "L2"]}, actor="u1")
d = S.resolve_ext_field_def(self.db, TENANT, "mission", "difficulty")
self.assertFalse(d["is_platform"])
self.assertEqual(d["label"], "本地难度")
# 平台枚举值在租户覆盖后失效
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "L1", actor="u1")
# 其他租户仍用平台定义
d2 = S.resolve_ext_field_def(self.db, OTHER, "mission", "difficulty")
self.assertTrue(d2["is_platform"])
def test_any_kind_def_visible(self):
S.create_ext_field_def(self.db, None, {"scope": "platform", "subobject_kind": "any",
"ext_key": "tags", "label": "标签",
"value_type": "json"},
actor="admin", is_platform_admin=True)
r = S.set_ext(self.db, TENANT, self.bp, "role", "r1", "tags", ["a", "b"], actor="u1")
self.assertEqual(r["value"], ["a", "b"])
def test_bulk_set_all_or_nothing(self):
with self.assertRaises(ValidationError) as cm:
S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
{"difficulty": "easy", "estimated_minutes": 99999}, actor="u1")
self.assertTrue(cm.exception.detail.get("errors"))
self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
ok = S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
{"difficulty": "easy", "estimated_minutes": 60}, actor="u1")
self.assertEqual(ok["count"], 2)
def test_apply_template_ext_schema_no_overwrite(self):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
res = S.apply_template_ext_schema(
self.db, TENANT, self.bp,
{"mission": {"difficulty": "hard", "estimated_minutes": 45}},
{"mission": ["m1", "m2"]}, actor="u1", source_template_id="tpl-x")
self.assertEqual(res["applied_count"], 2) # m1.estimated_minutes + m2.estimated_minutes
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"),
"easy") # 已填不覆盖
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m2", "difficulty"),
"hard")
rows = S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"]
self.assertTrue(all(r["source"] in ("manual", "template_instantiate") for r in rows))
self.assertTrue(any(r["source_template_id"] == "tpl-x" for r in rows))
def test_subobject_tree_ext(self):
S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
{"difficulty": "easy", "estimated_minutes": 30}, actor="u1")
tree = S.subobject_tree_ext(self.db, TENANT, self.bp)["ext"]
self.assertEqual(tree["mission"]["m1"]["difficulty"], "easy")
self.assertEqual(tree["mission"]["m1"]["estimated_minutes"], 30)
def test_delete_ext(self):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
self.assertEqual(S.delete_ext(self.db, TENANT, self.bp, "mission", "m1",
ext_key="difficulty", actor="u1")["deleted_count"], 1)
self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
def test_ext_def_delete_blocked_when_in_use(self):
d = S.create_ext_field_def(self.db, TENANT, {
"subobject_kind": "role", "ext_key": "min_players", "label": "最少人数",
"value_type": "int"}, actor="u1")
S.set_ext(self.db, TENANT, self.bp, "role", "r1", "min_players", 2, actor="u1")
with self.assertRaises(ConflictError):
S.delete_ext_field_def(self.db, TENANT, d["id"], actor="u1")
def test_platform_ext_def_write_requires_admin(self):
with self.assertRaises(PermissionDeniedError):
S.create_ext_field_def(self.db, None, {"scope": "platform",
"subobject_kind": "role",
"ext_key": "x", "label": "x"},
actor="u1", is_platform_admin=False)
class TestRefTable(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
self.bp = "bp-1"
def test_classify_ref_whitelist(self):
self.assertEqual(R.classify_ref("world", "world")[0], True)
self.assertEqual(R.classify_ref("world", "employee")[0], False) # 域-表不匹配
self.assertEqual(R.classify_ref("unknown_domain", "x")[0], False)
with self.assertRaises(ValidationError):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "secret_tbl",
"ref_id": "w1"}, actor="u1")
def test_add_ref_and_idempotent(self):
a = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1", "rel_type": "binds",
"required": 1}, actor="u1")
self.assertTrue(a["created"])
b = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1", "rel_type": "binds",
"required": 0}, actor="u1")
self.assertFalse(b["created"])
self.assertEqual(b["id"], a["id"])
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1)
def test_ref_tenant_isolation(self):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
self.assertEqual(R.list_refs(self.db, OTHER, blueprint_id=self.bp)["total"], 0)
with self.assertRaises(TenantRequiredError):
R.list_refs(self.db, None, blueprint_id=self.bp)
with self.assertRaises(NotFoundError):
R.get_ref(self.db, OTHER, R.list_refs(self.db, TENANT,
blueprint_id=self.bp)["items"][0]["id"])
def test_src_kind_validation(self):
with self.assertRaises(ValidationError):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene",
"ref_id": "s1", "src_kind": "mission"},
actor="u1") # 缺 src_id
ok = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene",
"ref_id": "s1", "src_kind": "mission",
"src_id": "m1"}, actor="u1")
self.assertTrue(ok["ok"])
def test_bulk_all_or_nothing(self):
with self.assertRaises(ValidationError):
R.bulk_add_refs(self.db, TENANT, self.bp, [
{"ref_domain": "world", "ref_table": "world", "ref_id": "w1"},
{"ref_domain": "bad", "ref_table": "bad", "ref_id": "x"}], actor="u1")
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
def test_resolve_refs_with_reader(self):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w-exist", "required": 1}, actor="u1")
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w-gone", "required": 1}, actor="u1")
def reader(db, table, rid):
return {"id": rid} if rid == "w-exist" else None
res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1", reader=reader)
self.assertEqual(res["resolved_count"], 1)
self.assertEqual(res["missing_count"], 1)
self.assertEqual(len(res["blocking"]), 1)
rows = R.list_refs(self.db, TENANT, blueprint_id=self.bp)["items"]
st = {r["ref_id"]: r["resolve_status"] for r in rows}
self.assertEqual(st["w-exist"], "resolved")
self.assertEqual(st["w-gone"], "missing")
def test_resolve_without_reader_stays_unknown(self):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1")
self.assertEqual(res["unknown_count"], 1)
self.assertEqual(res["missing_count"], 0) # 不臆断 missing
def test_impact_of_reverse_lookup(self):
R.add_ref(self.db, TENANT, "bp-1", {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1", "required": 1}, actor="u1")
R.add_ref(self.db, TENANT, "bp-2", {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
R.add_ref(self.db, OTHER, "bp-9", {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u2")
imp = R.impact_of(self.db, TENANT, "world", "world", "w1")
self.assertEqual(imp["blueprint_count"], 2) # 跨租户不泄露
self.assertEqual(sorted(imp["blueprint_ids"]), ["bp-1", "bp-2"])
self.assertFalse(imp["safe_to_delete"])
self.assertTrue(imp["warning"])
R.remove_ref(self.db, TENANT, R.list_refs(self.db, TENANT, blueprint_id="bp-1")["items"][0]["id"], actor="u1")
self.assertTrue(R.impact_of(self.db, TENANT, "world", "world", "w1")["safe_to_delete"])
def test_refs_from_content_extraction(self):
content = {
"world_id": "w-100",
"missions": [{"id": "m1", "kind": "mission", "scene_id": "sc-1"},
{"id": "m2", "kind": "mission", "scene_id": "sc-1"}],
"project": {"id": "pj1", "kind": "project", "game_id": "g-1"},
}
refs = R.refs_from_content(content)
keys = {(r["ref_domain"], r["ref_id"]) for r in refs}
self.assertIn(("world", "w-100"), keys)
self.assertIn(("scene", "sc-1"), keys)
self.assertIn(("scense_game", "g-1"), keys)
self.assertEqual(len(refs), len(keys)) # 去重生效
def test_sync_refs_from_content_idempotent(self):
content = {"world_id": "w-1", "missions": [{"id": "m1", "kind": "mission",
"scene_id": "sc-1"}]}
a = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1")
self.assertEqual(a["added_count"], 2)
b = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1")
self.assertEqual(b["added_count"], 0)
self.assertEqual(b["removed_count"], 0)
# content 去掉 world 引用 → 关联边同步软删
c = R.sync_refs_from_content(self.db, TENANT, self.bp,
{"missions": [{"id": "m1", "kind": "mission",
"scene_id": "sc-1"}]}, actor="u1")
self.assertEqual(c["removed_count"], 1)
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1)
def test_world_table_untouched(self):
"""Q-OPEN-3关联操作绝不写 world 基表。"""
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
R.sync_refs_from_content(self.db, TENANT, self.bp, {"world_id": "w2"}, actor="u1")
R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1",
reader=lambda db, t, i: {"id": i})
self.assertNotIn("world", self.db.tables)
self.assertNotIn("scene", self.db.tables)
self.assertIn("pbl_blueprint_ref", self.db.tables)
class TestApiDispatch(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
def test_route_dispatch_platform_template(self):
r = A.dispatch(self.db, "POST", "/pbl/templates",
{"tenant_id": None, "is_platform_admin": True, "actor": "admin",
"code": "p1", "name": "平台模板", "scope": "platform",
"content": {"a": 1}})
self.assertTrue(r["ok"], r)
lst = A.dispatch(self.db, "GET", "/pbl/templates", {"tenant_id": TENANT})
self.assertEqual(lst["total"], 1)
tid = lst["items"][0]["id"]
det = A.dispatch(self.db, "GET", "/pbl/templates/%s" % tid, {"tenant_id": TENANT})
self.assertTrue(det["template"]["is_platform"])
bad = A.dispatch(self.db, "PUT", "/pbl/templates/%s" % tid,
{"tenant_id": TENANT, "actor": "u1", "name": "x"})
self.assertFalse(bad["ok"])
self.assertEqual(bad["code"], "PBL_PLATFORM_ADMIN_REQUIRED")
self.assertEqual(bad["http_status"], 403)
def test_route_missing_tenant_fail_closed(self):
r = A.dispatch(self.db, "GET", "/pbl/templates", {})
self.assertFalse(r["ok"])
self.assertEqual(r["code"], "PBL_TENANT_REQUIRED")
def test_route_unknown(self):
r = A.dispatch(self.db, "GET", "/pbl/nope", {"tenant_id": TENANT})
self.assertEqual(r["code"], "PBL_ROUTE_NOT_FOUND")
def test_route_ext_and_refs(self):
A.dispatch(self.db, "POST", "/pbl/ext-defs",
{"tenant_id": None, "is_platform_admin": True, "actor": "admin",
"scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty",
"label": "难度", "value_type": "enum",
"enum_options": ["easy", "hard"]})
r = A.dispatch(self.db, "PUT", "/pbl/blueprints/bp-1/ext",
{"tenant_id": TENANT, "actor": "u1", "kind": "mission",
"subobject_id": "m1", "values": {"difficulty": "easy"}})
self.assertTrue(r["ok"], r)
g = A.dispatch(self.db, "GET", "/pbl/blueprints/bp-1/ext", {"tenant_id": TENANT})
self.assertEqual(g["ext"]["mission"]["m1"]["difficulty"], "easy")
add = A.dispatch(self.db, "POST", "/pbl/blueprints/bp-1/refs",
{"tenant_id": TENANT, "actor": "u1",
"ref_domain": "world", "ref_table": "world", "ref_id": "w1"})
self.assertTrue(add["ok"], add)
imp = A.dispatch(self.db, "GET", "/pbl/refs/impact",
{"tenant_id": TENANT, "ref_domain": "world", "ref_table": "world",
"ref_id": "w1"})
self.assertEqual(imp["blueprint_count"], 1)
class TestInitSeed(unittest.TestCase):
def test_init_m1b_seeds_platform_data(self):
db = FakeDB()
res = I.init_m1b(db=db, seed=True, actor="system")
self.assertTrue(res["ok"])
self.assertEqual(res["q_open_3"], "world/scene/entity 基表零改动")
self.assertNotIn("world", db.tables)
tpls = T.list_templates(db, TENANT)
self.assertGreaterEqual(tpls["total"], 3)
self.assertTrue(all(t["is_platform"] for t in tpls["items"]))
defs = S.list_ext_field_defs(db, TENANT)
self.assertGreaterEqual(defs["total"], 20)
self.assertTrue(all(d["is_platform"] for d in defs["items"]))
# 幂等
again = I.seed_platform_data(db, actor="system")
self.assertEqual(again["templates"]["created_count"], 0)
self.assertEqual(again["ext_defs"]["created_count"], 0)
self.assertEqual(T.list_templates(db, TENANT)["total"], tpls["total"])
def test_platform_seed_usable_end_to_end(self):
db = FakeDB()
I.init_m1b(db=db, seed=True)
tpl = [t for t in T.list_templates(db, TENANT)["items"]
if t["code"] == "pbl-stem-water-quality"][0]
full = T.get_template(db, TENANT, tpl["id"])
inst = T.instantiate_template(db, TENANT, tpl["id"], actor="u1")
self.assertTrue(inst["result"]["deferred"])
self.assertEqual(inst["result"]["payload"]["tenant_id"], TENANT)
# 模板 ext_schema 默认值可落到子对象扩展
res = S.apply_template_ext_schema(
db, TENANT, "bp-new", full["ext_schema"],
{"mission": ["ms-1", "ms-2"], "role": ["rl-1"], "project": ["pj-1"],
"driving_question": ["dq-1"], "artifact_def": ["af-1"]},
actor="u1", source_template_id=tpl["id"])
self.assertGreater(res["applied_count"], 5)
self.assertEqual(S.get_ext(db, TENANT, "bp-new", "mission", "ms-1", "difficulty"),
"medium")
self.assertEqual(S.get_ext(db, TENANT, "bp-new", "project", "pj-1", "assessment_mode"),
"rubric")
# content 中的跨域引用可抽取(本模板无 world_id → 0 条,不报错)
sync = R.sync_refs_from_content(db, TENANT, "bp-new", full["content"], actor="u1")
self.assertTrue(sync["ok"])
if __name__ == "__main__":
unittest.main(verbosity=2)

325
tests/test_m1b_template.py Normal file
View File

@ -0,0 +1,325 @@
# -*- coding: utf-8 -*-
"""
M1b 自检测试模板平台公共部分tenant_id NULL+ 子对象扩展 + 关联表判定Q-OPEN-3
运行cd modules/pbl_blueprint && python -m pytest tests/test_m1b_template.py -q
pytest python tests/test_m1b_template.py
覆盖 M1b 出口门禁
模板 schema 校验 fail-closed非法类型/悬空父//缺必填 -> PBL_TPL_SCHEMA_INVALID
ID 重映射临时 ID -> 业务主键父子同步替换外部引用保持原值
tpl_hash 确定性同模板同输入同 hash键序无关
平台公共模板解析优先级租户私有 > 平台公共+ 写入门禁
Q-OPEN-3 守卫DDL 不含对 world/scene/entity/script 基表的改动
离线种子模板可解析可重映射结构完整无孤儿
"""
from __future__ import annotations
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from pbl_blueprint.models.pbl_template import ( # noqa: E402
M1B_TABLES, PLATFORM_TENANT_KEY, PROTECTED_BASE_TABLES,
assert_no_base_table_change, build_all_ddl, build_ddl,
is_platform_scope, normalize_tenant_key,
)
from pbl_blueprint.subobject_ext import ( # noqa: E402
SUBOBJECT_TYPE_LIST, TplSchemaError, apply_ref_status,
extract_external_refs, group_refs_by_domain, remap_ids,
tpl_hash, validate_tpl_schema,
)
from pbl_blueprint import template_platform as tp # noqa: E402
SEED_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "pbl_blueprint", "json", "seed_template_offline.json")
def _gen_code_factory():
"""确定性编码生成器(替代 pbl_common.gen_code保证测试可复现"""
counter = {"n": 0}
def gen_code(prefix, tenant_id):
counter["n"] += 1
return "%s%s%05d" % (prefix, (tenant_id or "PLT")[:3].upper(), counter["n"])
return gen_code
def _demo_tpl():
return {
"schema_version": "1.0",
"subobjects": [
{"tmp_id": "s1", "subobject_type": "stage", "name": "阶段1", "seq": 1},
{"tmp_id": "t1", "subobject_type": "task", "parent_tmp_id": "s1", "name": "任务1", "seq": 1},
{"tmp_id": "r1", "subobject_type": "role", "parent_tmp_id": "t1", "name": "角色1", "seq": 1},
{"tmp_id": "w1", "subobject_type": "world_ref", "parent_tmp_id": "t1", "world_code": "W001", "seq": 2},
{"tmp_id": "sc1", "subobject_type": "scene_ref", "parent_tmp_id": "t1", "scene_code": "SC001", "seq": 3},
{"tmp_id": "e1", "subobject_type": "entity_ref", "parent_tmp_id": "sc1", "entity_code": "EN001", "seq": 1},
{"tmp_id": "sp1", "subobject_type": "script_ref", "parent_tmp_id": "t1", "script_code": "SP001", "seq": 4},
],
}
class TestSubobjectExt(unittest.TestCase):
def test_01_schema_ok_and_topo_order(self):
norm = validate_tpl_schema(_demo_tpl())
types = [s["subobject_type"] for s in norm["subobjects"]]
self.assertEqual(len(norm["subobjects"]), 7)
# 父必在子之前(拓扑序)
pos = {s["tmp_id"]: i for i, s in enumerate(norm["subobjects"])}
for s in norm["subobjects"]:
p = s.get("parent_tmp_id")
if p:
self.assertLess(pos[p], pos[s["tmp_id"]])
self.assertTrue(types)
def test_02_schema_invalid_type(self):
tpl = _demo_tpl()
tpl["subobjects"][0]["subobject_type"] = "unknown_type"
with self.assertRaises(TplSchemaError) as cm:
validate_tpl_schema(tpl)
self.assertEqual(cm.exception.code, "PBL_TPL_SCHEMA_INVALID")
def test_03_schema_dangling_parent(self):
tpl = _demo_tpl()
tpl["subobjects"][1]["parent_tmp_id"] = "not_exist"
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_04_schema_cycle(self):
tpl = {"schema_version": "1.0", "subobjects": [
{"tmp_id": "a", "subobject_type": "task", "parent_tmp_id": "b", "name": "A"},
{"tmp_id": "b", "subobject_type": "stage", "parent_tmp_id": "a", "name": "B"},
]}
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_05_schema_missing_required(self):
tpl = {"schema_version": "1.0", "subobjects": [
{"tmp_id": "s1", "subobject_type": "stage", "seq": 1},
]}
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_06_schema_stage_cannot_have_parent(self):
tpl = {"schema_version": "1.0", "subobjects": [
{"tmp_id": "s1", "subobject_type": "stage", "name": "S", "seq": 1},
{"tmp_id": "s2", "subobject_type": "stage", "parent_tmp_id": "s1", "name": "S2", "seq": 2},
]}
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_07_hash_deterministic_and_key_order_free(self):
tpl = _demo_tpl()
h1 = tpl_hash(tpl)
h2 = tpl_hash(json.dumps(tpl, ensure_ascii=False))
shuffled = {"subobjects": tpl["subobjects"], "schema_version": tpl["schema_version"]}
h3 = tpl_hash(shuffled)
self.assertEqual(h1, h2)
self.assertEqual(h1, h3)
self.assertEqual(len(h1), 64)
def test_08_remap_ids(self):
norm = validate_tpl_schema(_demo_tpl())
items, mapping = remap_ids(norm["subobjects"], _gen_code_factory(), "T001")
self.assertEqual(len(items), 7)
self.assertEqual(len(set(mapping.values())), 7) # 无碰撞
for it in items:
self.assertNotIn("tmp_id", it)
self.assertNotIn("parent_tmp_id", it)
if it["parent_code"]:
self.assertIn(it["parent_code"], mapping.values())
# 前缀按类型
stage = [i for i in items if i["subobject_type"] == "stage"][0]
self.assertTrue(stage["subobject_code"].startswith("STG"))
self.assertIsNone(stage["parent_code"])
def test_09_external_refs_keep_original_value(self):
norm = validate_tpl_schema(_demo_tpl())
refs = extract_external_refs(norm["subobjects"])
self.assertEqual(len(refs), 4)
codes = {r["ref_code"] for r in refs}
self.assertEqual(codes, {"W001", "SC001", "EN001", "SP001"})
grouped = group_refs_by_domain(refs)
self.assertEqual(sorted(grouped.keys()), ["entity", "scene", "script", "world"])
items, _ = remap_ids(norm["subobjects"], _gen_code_factory(), "T001")
w = [i for i in items if i["subobject_type"] == "world_ref"][0]
self.assertEqual(w["world_code"], "W001") # 原值不重映射
def test_10_apply_ref_status_warn_mode(self):
norm = validate_tpl_schema(_demo_tpl())
refs = extract_external_refs(norm["subobjects"])
items, _ = remap_ids(norm["subobjects"], _gen_code_factory(), "T001")
unresolved = apply_ref_status(items, refs, {
"world": {"W001": True}, "scene": {"SC001": True},
"entity": {}, "script": {},
})
self.assertEqual(unresolved, 2)
statuses = {i["subobject_type"]: i.get("ref_status") for i in items if i.get("ref_status")}
self.assertEqual(statuses["world_ref"], "resolved")
self.assertEqual(statuses["entity_ref"], "unresolved")
def test_11_seven_types_match_dict(self):
self.assertEqual(
sorted(SUBOBJECT_TYPE_LIST),
sorted(["stage", "task", "role", "world_ref", "scene_ref", "entity_ref", "script_ref"]))
class TestPlatformScope(unittest.TestCase):
def test_20_normalize_tenant_key(self):
self.assertEqual(normalize_tenant_key(None), PLATFORM_TENANT_KEY)
self.assertEqual(normalize_tenant_key(""), PLATFORM_TENANT_KEY)
self.assertEqual(normalize_tenant_key(" "), PLATFORM_TENANT_KEY)
self.assertEqual(normalize_tenant_key("T001"), "T001")
self.assertTrue(is_platform_scope(None))
self.assertFalse(is_platform_scope("T001"))
def test_21_tenant_id_nullable_in_ddl(self):
ddl = build_ddl("pbl_template")
self.assertIn("`tenant_id` VARCHAR(32) NULL", ddl)
self.assertIn("IFNULL(`tenant_id`,'__PLATFORM__')", ddl)
# 实例化日志 tenant_id 仍 NOT NULL
ddl2 = build_ddl("pbl_template_instance_log")
self.assertIn("`tenant_id` VARCHAR(32) NOT NULL", ddl2)
def test_22_resolve_priority_tenant_over_platform(self):
rows = [
{"tenant_id": "T001", "template_code": "C1", "template_version": 1},
{"tenant_id": None, "template_code": "C1", "template_version": 1},
]
row, scope = tp.pick_template(rows)
self.assertEqual(scope, "platform" if False else scope) # 保持可读
self.assertEqual(scope, tp.SCOPE_TENANT)
self.assertEqual(row["tenant_id"], "T001")
# 仅平台行 -> platform
row2, scope2 = tp.pick_template([rows[1]])
self.assertEqual(scope2, tp.SCOPE_PLATFORM)
self.assertIsNone(row2["tenant_id"])
with self.assertRaises(tp.TemplateNotFound):
tp.pick_template([])
def test_23_platform_write_guard(self):
self.assertEqual(
tp.build_platform_write_guard({"tenant_id": "P", "roles": ["platform_admin"]}, None),
tp.SCOPE_PLATFORM)
with self.assertRaises(tp.TemplatePlatformForbidden):
tp.build_platform_write_guard({"tenant_id": "T001", "roles": ["teacher"]}, None)
self.assertEqual(
tp.build_platform_write_guard({"tenant_id": "T001"}, "T001"), tp.SCOPE_TENANT)
with self.assertRaises(tp.TemplatePlatformForbidden):
tp.build_platform_write_guard({"tenant_id": "T001"}, "T002")
def test_24_require_tenant_fail_closed(self):
with self.assertRaises(tp.TemplateError) as cm:
tp.require_tenant({})
self.assertEqual(cm.exception.code, "PBL_TENANT_MISSING")
self.assertEqual(tp.require_tenant({"tenant_id": "T001"}), "T001")
def test_25_list_sql_tenant_first(self):
sql, params = tp.build_list_sql("T001", {"subject": "math"})
self.assertIn("(tenant_id = %s OR tenant_id IS NULL)", sql)
self.assertIn("ORDER BY (tenant_id IS NULL) ASC", sql)
self.assertEqual(params, ["T001", "math"])
sql2, params2 = tp.build_list_sql("T001", {"scope": "platform"})
self.assertIn("tenant_id IS NULL", sql2)
self.assertEqual(params2, [])
def test_26_offline_sql(self):
sql, params = tp.build_offline_sql("T001", subject="general")
self.assertIn("offline_flag = 'Y'", sql)
self.assertIn("tenant_id IS NULL", sql)
self.assertEqual(params[0], "T001")
class TestQOpen3RelationTable(unittest.TestCase):
def test_30_ddl_has_no_base_table_change(self):
ddl = build_all_ddl()
self.assertTrue(assert_no_base_table_change(ddl))
upper = ddl.upper()
for tbl in PROTECTED_BASE_TABLES:
self.assertNotIn("ALTER TABLE %s" % tbl.upper(), upper)
self.assertNotIn("ALTER TABLE `%s`" % tbl.upper(), upper)
self.assertNotIn("FOREIGN KEY", upper)
def test_31_guard_rejects_violation(self):
with self.assertRaises(AssertionError):
assert_no_base_table_change("ALTER TABLE world ADD COLUMN tpl_code VARCHAR(32);")
with self.assertRaises(AssertionError):
assert_no_base_table_change("ALTER TABLE `scene` ADD COLUMN x INT;")
def test_32_only_two_m1b_tables(self):
self.assertEqual(sorted(M1B_TABLES),
sorted(["pbl_template", "pbl_template_instance_log"]))
ddl = build_all_ddl()
self.assertEqual(ddl.count("CREATE TABLE"), 2)
def test_33_sql_file_guard(self):
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "pbl_blueprint", "sql", "m1b_template.sql")
with open(path, "r", encoding="utf-8") as fh:
content = fh.read()
# 文件内说明性注释提到基表名是允许的,但不得有真实 ALTER/CREATE 语句
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("--"):
continue
upper = stripped.upper()
for tbl in PROTECTED_BASE_TABLES:
self.assertFalse(
upper.startswith("ALTER TABLE %s" % tbl.upper()) or
upper.startswith("ALTER TABLE `%s`" % tbl.upper()),
"Q-OPEN-3 违规行: %s" % stripped)
class TestOfflineSeed(unittest.TestCase):
def _seeds(self):
with open(SEED_FILE, "r", encoding="utf-8") as fh:
return json.load(fh)["templates"]
def test_40_seed_valid_and_offline(self):
seeds = self._seeds()
self.assertGreaterEqual(len(seeds), 1)
for s in seeds:
self.assertEqual(s["offline_flag"], "Y")
self.assertIsNone(s["tenant_id"]) # 平台公共
norm = validate_tpl_schema(s["tpl_json"]) # 不抛即通过
self.assertGreater(len(norm["subobjects"]), 0)
self.assertEqual(len(tpl_hash(norm)), 64)
def test_41_seed_instantiate_no_orphan(self):
"""出口门禁①:种子模板实例化后树完整无孤儿(父引用全部可解析)。"""
for s in self._seeds():
norm = validate_tpl_schema(s["tpl_json"])
items, mapping = remap_ids(norm["subobjects"], _gen_code_factory(), "T_DEMO")
codes = {i["subobject_code"] for i in items}
roots = 0
for i in items:
if i["parent_code"] is None:
roots += 1
else:
self.assertIn(i["parent_code"], codes) # 无孤儿
self.assertEqual(roots, 1, "演示模板应恰有 1 个顶层 stage")
def test_42_seed_min_has_no_external_ref(self):
seeds = {s["template_code"]: s for s in self._seeds()}
min_tpl = seeds["TPL_DEMO_BLUEPRINT_MIN"]
norm = validate_tpl_schema(min_tpl["tpl_json"])
self.assertEqual(extract_external_refs(norm["subobjects"]), [])
def test_43_seed_deterministic_hash(self):
"""出口门禁④同模板同输入产出同结构hash 稳定)。"""
s = self._seeds()[0]
self.assertEqual(tpl_hash(s["tpl_json"]),
tpl_hash(json.loads(json.dumps(s["tpl_json"]))))
if __name__ == "__main__":
unittest.main(verbosity=2)