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