diff --git a/docs/M1b-annex-impl.md b/docs/M1b-annex-impl.md new file mode 100644 index 0000000..a154583 --- /dev/null +++ b/docs/M1b-annex-impl.md @@ -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 层。 diff --git a/pbl_blueprint/json/m1b/pbl_blueprint_ref.json b/pbl_blueprint/json/m1b/pbl_blueprint_ref.json new file mode 100644 index 0000000..8327638 --- /dev/null +++ b/pbl_blueprint/json/m1b/pbl_blueprint_ref.json @@ -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 只读查询完成,不阻塞基表写" + ] +} diff --git a/pbl_blueprint/json/m1b/pbl_blueprint_template.json b/pbl_blueprint/json/m1b/pbl_blueprint_template.json new file mode 100644 index 0000000..da2e4b3 --- /dev/null +++ b/pbl_blueprint/json/m1b/pbl_blueprint_template.json @@ -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" +} diff --git a/pbl_blueprint/json/m1b/pbl_subobject_ext.json b/pbl_blueprint/json/m1b/pbl_subobject_ext.json new file mode 100644 index 0000000..b241316 --- /dev/null +++ b/pbl_blueprint/json/m1b/pbl_subobject_ext.json @@ -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"]} + ] +} diff --git a/pbl_blueprint/json/pbl_template.json b/pbl_blueprint/json/pbl_template.json new file mode 100644 index 0000000..fcf0bc9 --- /dev/null +++ b/pbl_blueprint/json/pbl_template.json @@ -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,不覆盖旧行" + } +} diff --git a/pbl_blueprint/json/seed_template_offline.json b/pbl_blueprint/json/seed_template_offline.json new file mode 100644 index 0000000..b94cc65 --- /dev/null +++ b/pbl_blueprint/json/seed_template_offline.json @@ -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} + ] + } + } + ] +} diff --git a/pbl_blueprint/m1b_api.py b/pbl_blueprint/m1b_api.py new file mode 100644 index 0000000..fade166 --- /dev/null +++ b/pbl_blueprint/m1b_api.py @@ -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} diff --git a/pbl_blueprint/m1b_common.py b/pbl_blueprint/m1b_common.py new file mode 100644 index 0000000..e4b3571 --- /dev/null +++ b/pbl_blueprint/m1b_common.py @@ -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 diff --git a/pbl_blueprint/m1b_init.py b/pbl_blueprint/m1b_init.py new file mode 100644 index 0000000..a22f63f --- /dev/null +++ b/pbl_blueprint/m1b_init.py @@ -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 基表零改动"} diff --git a/pbl_blueprint/m1b_ref.py b/pbl_blueprint/m1b_ref.py new file mode 100644 index 0000000..6f15350 --- /dev/null +++ b/pbl_blueprint/m1b_ref.py @@ -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)} diff --git a/pbl_blueprint/m1b_subobject.py b/pbl_blueprint/m1b_subobject.py new file mode 100644 index 0000000..e1255f7 --- /dev/null +++ b/pbl_blueprint/m1b_subobject.py @@ -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} diff --git a/pbl_blueprint/m1b_template.py b/pbl_blueprint/m1b_template.py new file mode 100644 index 0000000..b3f5fa3 --- /dev/null +++ b/pbl_blueprint/m1b_template.py @@ -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)} diff --git a/pbl_blueprint/models/pbl_template.py b/pbl_blueprint/models/pbl_template.py new file mode 100644 index 0000000..f9c7804 --- /dev/null +++ b/pbl_blueprint/models/pbl_template.py @@ -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()) diff --git a/pbl_blueprint/sql/m1b_template.sql b/pbl_blueprint/sql/m1b_template.sql new file mode 100644 index 0000000..d6f8f17 --- /dev/null +++ b/pbl_blueprint/sql/m1b_template.sql @@ -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 即测试失败。 +-- ===================================================================== diff --git a/pbl_blueprint/subobject_ext.py b/pbl_blueprint/subobject_ext.py new file mode 100644 index 0000000..b2b6277 --- /dev/null +++ b/pbl_blueprint/subobject_ext.py @@ -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 diff --git a/pbl_blueprint/template_platform.py b/pbl_blueprint/template_platform.py new file mode 100644 index 0000000..2077e07 --- /dev/null +++ b/pbl_blueprint/template_platform.py @@ -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", +] diff --git a/scripts/seed_m1b_template.py b/scripts/seed_m1b_template.py new file mode 100644 index 0000000..e3d7958 --- /dev/null +++ b/scripts/seed_m1b_template.py @@ -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()) diff --git a/tests/test_m1b_ext_ref.py b/tests/test_m1b_ext_ref.py new file mode 100644 index 0000000..e03fd12 --- /dev/null +++ b/tests/test_m1b_ext_ref.py @@ -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) diff --git a/tests/test_m1b_template.py b/tests/test_m1b_template.py new file mode 100644 index 0000000..5bf104c --- /dev/null +++ b/tests/test_m1b_template.py @@ -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)