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

This commit is contained in:
agent.develop 2026-09-18 12:12:52 +08:00
parent 33a50d2c94
commit 3e77958cd2
20 changed files with 1361 additions and 433 deletions

View File

@ -1,2 +1,76 @@
# pbl_domain_ext
# pbl_domain_ext — PBL 基础域薄扩展M8
world / scene / entity 三模块的 **PBL 侧薄扩展**:为复用平台的世界/场景/实体叠加
租户 / 蓝图 / 班级 / 团队关联维度。**不改三张基表结构、不侵入复用模块代码**Q-OPEN-3 裁决:零改表)。
## 核心事实
- **自有表 1 张**`pbl_domain_ref``UNIQUE(tenant_id, ref_type, ref_id)`
- 权威 DDL`projects/pbls/docs/01-design/data-model.md` §J1
- 表总账:`projects/pbls/pbls_spec.json``tables_by_module.pbl_domain_ext = 1``tables_total = 36`
- **契约 13 个**:设计 `projects/pbls/docs/01-design/modules/pbl_domain_ext.md` §3.1(5) + §3.2(5) + §3.3(3)
- **复用基表只读**world / scene / entity 零 ALTER、零写入
- **他模块表只读**`pbl_governance.pbl_team_member`(取团队成员,表缺失时降级空列表)
## 目录结构
```
pbl_domain_ext/
├── pbl_domain_ext/ # Python 包(模块名 = 包目录名,无 app.py
│ ├── __init__.py # ② 导出 load_pbl_domain_ext + 13 契约
│ ├── init.py # ③ load_pbl_domain_ext():注册 env.pbl_* + 设计原名OWN_TABLES
│ ├── api.py # ① 13 个契约接口实现(设计 §3
│ ├── base.py # 复用基表只读投影层(列名运行时探测、悬挂引用过滤)
│ ├── db.py # 数据访问适配SqlorAdapter 生产 / SqliteAdapter 测试)
│ └── errors.py # PBL_E_* 错误码 + 统一异常(优先复用 pbl_common
├── models/pbl_domain_ref.json # 表定义四段式summary 数组 / fields / indexes / codes
├── json/pbl_domain_ref.json # CRUD 浏览定义tblname + params.browserfields/editable
├── sql/pbl_domain_ext.sql # 建表 DDL对齐 data-model.md §J1
├── wwwroot/
│ ├── index.ui # 模块入口页(三组契约卡片导航)
│ └── api/*.dspy # 13 个契约端点薄封装(与 api.py 一一对应)
├── scripts/load_path.py # RBAC 路径显式注册13 dspy + 1 ui禁通配符
├── tests/ # 离线 sqlite 测试62 用例,覆盖 13 契约 + 薄扩展铁律)
├── skill/SKILL.md # Agent 必读规范(铁律 + 陷阱 + 契约映射)
├── pyproject.toml
└── README.md
```
## 契约清单(设计 §3 → 实现 → 端点)
| # | 接口 | 实现 | dspy 端点 |
|---|---|---|---|
| 1 | `bind_ref` | `api.py:bind_ref` | `api/pbl_domain_ref_bind.dspy` |
| 2 | `unbind_ref` | `api.py:unbind_ref` | `api/pbl_domain_ref_unbind.dspy` |
| 3 | `get_ref` | `api.py:get_ref` | `api/pbl_domain_ref_get.dspy` |
| 4 | `list_refs` | `api.py:list_refs` | `api/pbl_domain_ref_list.dspy` |
| 5 | `update_ref` | `api.py:update_ref` | `api/pbl_domain_ref_update.dspy` |
| 6 | `list_worlds_by_tenant` | `api.py:list_worlds_by_tenant` | `api/pbl_world_list_by_tenant.dspy` |
| 7 | `list_scenes_by_world` | `api.py:list_scenes_by_world` | `api/pbl_scene_list_by_world.dspy` |
| 8 | `list_entities_by_scene` | `api.py:list_entities_by_scene` | `api/pbl_entity_list_by_scene.dspy` |
| 9 | `get_world_with_pbl_context` | `api.py:get_world_with_pbl_context` | `api/pbl_world_get_context.dspy` |
| 10 | `check_ref_access` | `api.py:check_ref_access` | `api/pbl_domain_ref_check_access.dspy` |
| 11 | `list_teams_by_class` | `api.py:list_teams_by_class` | `api/pbl_team_list_by_class.dspy` |
| 12 | `bind_team_to_world` | `api.py:bind_team_to_world` | `api/pbl_team_bind_world.dspy` |
| 13 | `get_team_worlds` | `api.py:get_team_worlds` | `api/pbl_team_world_list.dspy` |
## 挂载
```python
from pbl_domain_ext import load_pbl_domain_ext
load_pbl_domain_ext(env) # 注册 13 契约到 ServerEnv
import sys; sys.path.append('modules/pbl_domain_ext/scripts')
import load_path; load_path.register(env) # RBAC 路径注册(幂等)
```
模块 host-agnostic只依赖 sqlor / ahserver ServerEnv / 自有表,不依赖宿主入口与配置。
## 验证
```bash
python3 -m py_compile pbl_domain_ext/*.py scripts/load_path.py tests/*.py
python3 tests/test_domain_ref.py # 62 tests OK
python3 scripts/load_path.py # 路径注册自检 OK
```
详见 `skill/SKILL.md`(铁律、陷阱、需求追溯)与 `docs/work-log-2026-09-18.md`(本轮 QC 退回逐条整改记录)。

View File

@ -0,0 +1,82 @@
# 工作日志 — pbl_domain_extM8QC 退回整改轮 · 2026-09-18
## 1. 范围与背景
- 仓库/模块:`modules/pbl_domain_ext`PBL 基础域薄扩展M8Wave 4
- 任务:`[M8] 基础域薄扩展 world/scene/entity`task_kind=new_devPM 派发,带 agent.qc 6 条退回意见)
- 设计权威:`projects/pbls/docs/01-design/modules/pbl_domain_ext.md``data-model.md` §J、`projects/pbls/pbls_spec.json`
- 本轮性质:**按 QC 退回意见重做交付件**(非 bug 修复,未走 bug 状态机)
## 2. QC 退回意见逐条整改
| # | QC 问题 | 整改动作 | 验证证据 |
|---|---|---|---|
| 1 | 表结构偏离设计:实交 3 张 `pbl_world_ref/pbl_scene_ref/pbl_entity_ref`,无 `pbl_domain_ref`,破坏 36 表总账 | **删除** 3 张偏离表模型;按 `data-model.md` §J1 建 **唯一自有表 `pbl_domain_ref`**`UNIQUE(tenant_id,ref_type,ref_id)` + 4 个辅助索引);`sql/pbl_domain_ext.sql` 重写为单表 DDL`init.py:OWN_TABLES=['pbl_domain_ref']` | `models/` 仅 1 个 json测试 `test_own_tables_single_and_matches_models``test_model_json_four_sections`(断言 UNIQUE 三元组)通过;表总账回归 `tables_by_module.pbl_domain_ext=1``tables_total=36`**无需修订 spec/data-model**(实现向设计对齐,未走设计变更) |
| 2 | 设计 §3 的 13 个契约接口无实现证据init.py 自述契约名(`pbl_*_ref_upsert`/`_list`/`pbl_domain_scope_resolve`)与设计不符 | `api.py` **逐条实现 13 个接口**§3.1×5 + §3.2×5 + §3.3×3函数名与设计**完全一致**`init.py` 注册 `env.pbl_<name>``env.<name>` 双名;新增 `get_contract_map()` 输出「接口→实现位置→dspy 端点」映射;`CONTRACT_INTERFACES` 常量固化 13 项清单 | 测试 `test_contract_13_interfaces_all_callable``test_contract_map_covers_dspy_files``test_load_module_registers_env_functions``test_package_exports_match_init` 通过;映射表见 `skill/SKILL.md` §3 与 `README.md` |
| 3 | 表定义四段式不合规:`summary` 为字符串而非数组 | `models/pbl_domain_ref.json` 改为 `{"summary":[表名,中文名,主键,说明],"fields":[...],"indexes":[...],"codes":[...]}`summary 为 4 元素数组array primary | 测试 `test_model_json_four_sections` 断言 `isinstance(summary, list)``summary[0]=='pbl_domain_ref'`、主键唯一、UNIQUE 索引存在 |
| 4 | `OWN_TABLES` 自相矛盾:声明 `pbl_tenant/pbl_class/pbl_team` 但 models/ 无定义 | 这 3 张表属 **pbl_governance** 模块,**不属本任务范围** → 从 `OWN_TABLES` 删除;同时删除误建的 `models/pbl_tenant.json``pbl_class.json``pbl_team.json``json/domain_ext_pbl_*.json``pbl_tenant_upsert/pbl_class_save/pbl_class_list/pbl_team_save/pbl_team_list` 等越界 dspy团队成员改为**只读引用** `pbl_governance.pbl_team_member``api._fetch_team_members`,表缺失降级空列表) | `OWN_TABLES == ['pbl_domain_ref']``models/` 一一对应(测试断言);`init.py:READONLY_FOREIGN_TABLES=['pbl_team_member']` 明示只读;测试 `test_list_teams_by_class_members_from_governance``test_list_teams_without_governance_table_degrades` 通过 |
| 5 | 契约端点缺失:无任何 `wwwroot/*.dspy` | 新建 **13 个** `wwwroot/api/*.dspy` 薄封装(与契约一一对应)+ `wwwroot/index.ui` 入口页;全部遵守 dspy 规范:**无 import**、**显式 return**、`debug()` 带文件名前缀、转发全部客户端参数filters/ext 兼容 JSON 串与平铺字段,不硬编码 dispatch、错误码→http_status 映射403/404/409/400/500 | dspy 审计脚本:`dspy files=13 audit_issues=NONE`(无 import / 有 return / debug 带文件名 / 无 print`test_contract_map_covers_dspy_files` 断言每个契约的 dspy 文件真实存在 |
| 6 | RBAC 路径注册缺失:`scripts/` 无变更,新端点上线即 403 | 重写 `scripts/load_path.py``API_PATHS`(13) + `UI_PATHS`(1) **逐条显式注册**(禁通配符)、`ROLE_GRANTS` 按 admin/pbl_teacher/pbl_student 分权(写操作仅教师/管理员,只读查询含学生)、`register(env)` 幂等注册、`selfcheck()` 自检、`__main__` 可执行校验 | `python3 scripts/load_path.py``paths=14 api=13 ui=1 / OK 全部路径显式注册、无通配符、角色授权齐备`;测试 `test_load_path_registers_all_dspy` 断言 wwwroot 下每个 .dspy 都已注册且无 `*` |
## 3. 本轮产出文件
**新增/重写(实现)**
- `pbl_domain_ext/api.py`13 契约实现,~600 行)
- `pbl_domain_ext/init.py``load_pbl_domain_ext` + `get_contract_map` + `OWN_TABLES`
- `pbl_domain_ext/__init__.py`(导出 13 契约 + load 函数,三处同步注册之 ②)
- `pbl_domain_ext/base.py`(复用基表**只读**投影:列名运行时探测、批量取行、子表遍历、悬挂过滤)
- `pbl_domain_ext/db.py``SqlorAdapter` 生产 / `SqliteAdapter` 测试;`${name}$``:name` 转换;`resolve_dbname``get_module_dbname`
- `pbl_domain_ext/errors.py`PBL_E_* 错误码 + HTTP 映射,优先复用 `pbl_common`,缺失时本地等价实现)
**新增/重写(数据与契约)**
- `models/pbl_domain_ref.json`四段式summary 数组)
- `json/pbl_domain_ref.json`CRUD 浏览定义 tblname+params
- `sql/pbl_domain_ext.sql`(单表 DDL对齐 §J1
- `wwwroot/api/*.dspy` × 13、`wwwroot/index.ui`
- `scripts/load_path.py`
**删除(偏离设计/越界产出)**
- `models/pbl_world_ref.json``pbl_scene_ref.json``pbl_entity_ref.json`QC #1
- `models/pbl_tenant.json``pbl_class.json``pbl_team.json` + `json/domain_ext_pbl_*.json` × 3QC #4,属 pbl_governance
- `wwwroot/api/pbl_tenant_upsert.dspy``pbl_class_save/list.dspy``pbl_team_save/list.dspy``pbl_domain_materialize_game_definition.dspy`(越界契约)
- `pbl_domain_ext/assoc.py``sql/pbl_domain_ext_assoc.sql``tests/test_assoc.py`(旧三表方案残留)
**测试与文档**
- `tests/fake_db.py`sqlite 夹具pbl_domain_ref + 三张基表替身 + pbl_team_member`raw_sql/scalar` 辅助)
- `tests/test_domain_ref.py`**62 用例**
- `skill/SKILL.md``README.md``pyproject.toml`、本工作日志
## 4. 关键技术决策
1. **实现向设计对齐,不走设计变更**QC #1 给了两条路(建单表 / 先改设计)。选**建 `pbl_domain_ref` 单表**——设计 §2、`data-model.md` §J1、`pbls_spec.json`1 表 / 36 总账)三处一致,改设计成本高且无收益。
2. **`ref_type + ref_id` 泛化关联**替代三张同构表:一张表承载 world/scene/entity 三类关联,`UNIQUE(tenant_id,ref_type,ref_id)` 保证同租户同记录唯一;`idx(ref_type,ref_id)` 支撑悬挂引用一致性左连接。
3. **租户隔离用「白名单先行」**:基表无 tenant_id → 先查 `pbl_domain_ref` 拿本租户 ref_id 白名单,再按白名单只读回查基表;未绑定即不可见(不是"查到再判权",从根上杜绝跨租户读基表)。
4. **基表列名运行时探测**:复用模块版本可能改列名(`world_id`/`worldid`/`parent_id``base.resolve_column``information_schema` → 退化 `PRAGMA`,避免写死列名导致上线即错。
5. **`check_ref_access` 返回 bool 不抛异常**运行时pbl_runtime_ext 共享会话)热路径每帧可能调用,异常开销大且调用方要 try 包裹;入参非法/无关联/不匹配/悬挂统一 False。但**基础设施异常**(基表探测失败)放行,避免因 DB 抖动把合法用户判为越权。
6. **软删而非物理删**`unbind_ref``is_deleted=1`,保留审计痕迹;重绑走"复活"分支(幂等),避免 UNIQUE 冲突。
7. **db 适配层双后端**:生产 `SqlorAdapter`(只用 `sor.R`/`sor.sqlExe`,结果在 `async with` 外 return测试 `SqliteAdapter`(同步驱动包 async使 62 用例可离线跑真实 SQL 逻辑而不依赖 MySQL/宿主挂载。
8. **`pbl_team_member` 只读降级**:团队成员属 pbl_governance本模块不建表不写入优先探宿主 read 契约,退化只读 SELECT表不存在则返回空 members`list_teams_by_class` 仍正常返回分组)。
## 5. 验证记录
| 验证项 | 命令 | 结果 |
|---|---|---|
| Python 语法 | `python3 -m py_compile pbl_domain_ext/*.py scripts/load_path.py tests/*.py` | PYCOMPILE OK |
| 单元/契约测试 | `python3 tests/test_domain_ref.py` | **Ran 62 tests — OK**0 fail / 0 error |
| RBAC 路径自检 | `python3 scripts/load_path.py` | `paths=14 api=13 ui=1` / OK 无通配符、授权齐备 |
| dspy 审计 | grep 脚本import/return/debug 前缀/print | `dspy files=13 audit_issues=NONE` |
| JSON 可解析 | index.ui / models / json | JSON parse OK |
| 假 sqlor API 扫描 | grep `sor.save/list/insert/query/delete/one` | NONE |
| 表总账对账 | `models/` 文件数 vs `OWN_TABLES` vs spec | 1 = 1 = `tables_by_module.pbl_domain_ext=1`36 总账不破) |
**测试覆盖要点**13 契约正常路径 + 错误分支VALIDATION/NOT_FOUND/DUPLICATE/FORBIDDEN、跨租户隔离US-21、分页与过滤、悬挂引用标记与过滤、软删与重绑幂等、`update_ref` 字段白名单、团队成员降级、**薄扩展铁律**(基表行数/列集合前后不变、禁止基表出现 tenant_id 列、三处同步注册、dspy 端点齐备、load_path 注册齐备。
**环境受限未跑项**(如实记录):
- 未启动 pbls 应用做 HTTP 端到端(宿主 `apps/pbls` 需 ServerEnv/MySQL/rbac 全量挂载,本地无该环境)→ 以 dspy 静态审计 + 契约层单测替代;
- 未在真实 MySQL 上执行 `sql/pbl_domain_ext.sql`(无库连接)→ DDL 逐列对齐 `data-model.md` §J1并在 sqlite 建等价表跑通全部 SQL 逻辑;
- `pbl_common` / `pbl_governance` 未挂载路径走的是本地降级分支errors 本地实现、members 空列表),已各有测试覆盖。
## 6. 当前分支/提交状态
- 分支:`main``modules/pbl_domain_ext`
- 本轮改动已落盘工作区;**git 收口由引擎在 PM 审核通过后统一执行**,本日志不自称已 commit/push以引擎回填的「git 收口核验」段为准)。

29
json/pbl_domain_ref.json Normal file
View File

@ -0,0 +1,29 @@
{
"tblname": "pbl_domain_ref",
"params": {
"browserfields": {
"id": {"label": "ID", "width": 70},
"tenant_id": {"label": "租户", "width": 110},
"ref_type": {"label": "关联类型", "width": 90, "code": "pbl_domain_ref_type"},
"ref_id": {"label": "基表记录ID", "width": 110},
"blueprint_id": {"label": "蓝图ID", "width": 130},
"class_id": {"label": "教学班", "width": 110},
"team_id": {"label": "团队", "width": 110},
"is_deleted": {"label": "已解绑", "width": 80},
"created_at": {"label": "创建时间", "width": 150},
"updated_at": {"label": "更新时间", "width": 150}
},
"editable": {
"tenant_id": {"label": "租户ID", "type": "text", "required": true, "comment": "PBL 多租户隔离维度,基表无此列"},
"ref_type": {"label": "关联类型", "type": "select", "required": true, "code": "pbl_domain_ref_type", "options": ["world", "scene", "entity"]},
"ref_id": {"label": "基表记录ID", "type": "number", "required": true, "comment": "world.id / scene.id / entity.id"},
"blueprint_id": {"label": "蓝图ID", "type": "text", "required": false},
"class_id": {"label": "教学班ID", "type": "text", "required": false},
"team_id": {"label": "团队ID", "type": "text", "required": false},
"ext": {"label": "扩展属性(JSON)", "type": "textarea", "required": false},
"is_deleted": {"label": "软删标记", "type": "number", "required": false, "default": 0}
},
"orderby": "id DESC",
"readonly_note": "运维浏览用;业务写入一律走契约接口 bind_ref/update_ref/unbind_ref含存在性校验与租户隔离禁止直接 CRUD 绕过校验。基表 world/scene/entity 零修改、只读。"
}
}

View File

@ -471,10 +471,10 @@ async def check_ref_access(ref_type, ref_id, tenant_id, class_id=None, team_id=N
return False
try:
alive = await _base.filter_existing_ids(ref_type, [rid])
if alive and rid not in alive:
return False
except Exception:
pass # 基表不可探测时不因基础设施问题拒绝访问
return True # 基表不可探测(基础设施问题)时不误判为悬挂
if rid not in alive:
return False # 悬挂引用:基表记录已被复用模块删除
return True

View File

@ -1,13 +1,24 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "pbl_domain_ext"
version = "0.1.0"
description = "world/scene/entity 薄扩展:租户/班级/团队关联M8不改基表"
requires-python = ">=3.9"
dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"]
version = "1.0.0"
description = "PBL 基础域薄扩展M8world/scene/entity 关联叠加pbl_domain_ref 单表,不改基表)+ 13 个租户隔离查询契约"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "Proprietary"}
authors = [{name = "PBL Agent OS Team"}]
keywords = ["pbl", "domain-ext", "world", "scene", "entity", "multi-tenant", "thin-extension"]
dependencies = []
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project.optional-dependencies]
dev = []
[tool.setuptools]
packages = ["pbl_domain_ext"]
include-package-data = true
[tool.setuptools.package-data]
pbl_domain_ext = ["../models/*.json", "../json/*.json", "../sql/*.sql"]

View File

@ -1,45 +1,116 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_domain_ext RBAC 路径注册(硬门禁 6.6 / QC #11
"""scripts/load_path.py — pbl_domain_extM8RBAC 路径注册。
约定
- 路径 = 模块自动路由 `/pbl_domain_ext/api/<契约>.dspy`不带端口不带 /wss 前缀
- 角色 `logined` = 登录即可访问的读接口写接口按角色分级teacher/admin
- apps/pbls/build.sh 8 步调用 `register()`rbac CLI 不在位时打印清单不静默跳过
规则module-development-spec新增契约必须在此显式注册 .dspy/.ui 路径
**禁止通配符**'*' / 前缀模糊匹配一律不允许否则新端点上线即 403
路径与 wwwroot/ 下实际文件一一对应13 个契约 dspy + 1 index.ui
"""
import os
import subprocess
import sys
MODULE = 'pbl_domain_ext'
# (path, role)
PATHS = [
('/pbl_domain_ext/api/pbl_tenant_upsert.dspy', 'logined'),
('/pbl_domain_ext/api/pbl_class_save.dspy', 'logined'),
('/pbl_domain_ext/api/pbl_class_list.dspy', 'logined'),
('/pbl_domain_ext/api/pbl_team_save.dspy', 'logined'),
('/pbl_domain_ext/api/pbl_team_list.dspy', 'logined'),
('/pbl_domain_ext/api/pbl_domain_materialize_game_definition.dspy', 'logined'),
MODULE_NAME = 'pbl_domain_ext'
# 契约端点(设计 modules/pbl_domain_ext.md §3 的 13 个接口,一一对应)
API_PATHS = [
# §3.1 关联管理5
'/pbl_domain_ext/api/pbl_domain_ref_bind.dspy', # bind_ref
'/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy', # unbind_ref
'/pbl_domain_ext/api/pbl_domain_ref_get.dspy', # get_ref
'/pbl_domain_ext/api/pbl_domain_ref_list.dspy', # list_refs
'/pbl_domain_ext/api/pbl_domain_ref_update.dspy', # update_ref
# §3.2 租户隔离查询封装5
'/pbl_domain_ext/api/pbl_world_list_by_tenant.dspy', # list_worlds_by_tenant
'/pbl_domain_ext/api/pbl_scene_list_by_world.dspy', # list_scenes_by_world
'/pbl_domain_ext/api/pbl_entity_list_by_scene.dspy', # list_entities_by_scene
'/pbl_domain_ext/api/pbl_world_get_context.dspy', # get_world_with_pbl_context
'/pbl_domain_ext/api/pbl_domain_ref_check_access.dspy', # check_ref_access
# §3.3 团队/班级维度3
'/pbl_domain_ext/api/pbl_team_list_by_class.dspy', # list_teams_by_class
'/pbl_domain_ext/api/pbl_team_bind_world.dspy', # bind_team_to_world
'/pbl_domain_ext/api/pbl_team_world_list.dspy', # get_team_worlds
]
# 前端页面
UI_PATHS = [
'/pbl_domain_ext/index.ui',
]
def register():
tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py')
done, missing = 0, []
for path, role in PATHS:
if subprocess.call([sys.executable if os.environ.get('PY') else 'python3',
tool, role, path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
done += 1
else:
missing.append((path, role))
print('[%s] rbac paths: total=%d ok=%d pending=%d' %(len(PATHS), done, len(missing)))
for path, role in missing:
print(' PENDING %%-12s %s' %(role, path))
return len(missing) == 0
# 角色授权教师pbl_teacher可管理关联学生pbl_student只读查询 + 访问校验;
# 管理员admin全量。角色名沿用 pbl_governance / rbac 既有定义,本模块不新增角色。
ROLE_GRANTS = {
'/pbl_domain_ext/api/pbl_domain_ref_bind.dspy': ['admin', 'pbl_teacher'],
'/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy': ['admin', 'pbl_teacher'],
'/pbl_domain_ext/api/pbl_domain_ref_get.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/api/pbl_domain_ref_list.dspy': ['admin', 'pbl_teacher'],
'/pbl_domain_ext/api/pbl_domain_ref_update.dspy': ['admin', 'pbl_teacher'],
'/pbl_domain_ext/api/pbl_world_list_by_tenant.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/api/pbl_scene_list_by_world.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/api/pbl_entity_list_by_scene.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/api/pbl_world_get_context.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/api/pbl_domain_ref_check_access.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/api/pbl_team_list_by_class.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/api/pbl_team_bind_world.dspy': ['admin', 'pbl_teacher'],
'/pbl_domain_ext/api/pbl_team_world_list.dspy': ['admin', 'pbl_teacher', 'pbl_student'],
'/pbl_domain_ext/index.ui': ['admin', 'pbl_teacher', 'pbl_student'],
}
ALL_PATHS = API_PATHS + UI_PATHS
def get_paths():
"""返回本模块需注册的全部路径(供宿主 rbac 批量 load_path 使用)。"""
return list(ALL_PATHS)
def get_roles(path):
"""返回某路径的授权角色列表未注册路径返回空fail-closed不放行"""
return list(ROLE_GRANTS.get(path, []))
def register(env=None):
"""向宿主 rbac 注册路径幂等。env 缺省自动取 ServerEnv。"""
if env is None:
try:
from ahserver.serverenv import ServerEnv # type: ignore
env = ServerEnv()
except Exception:
env = None
registered = []
for path in ALL_PATHS:
roles = ROLE_GRANTS.get(path, [])
if env is not None:
for name in ('load_path', 'rbac_load_path', 'add_path', 'register_path'):
func = getattr(env, name, None)
if callable(func):
try:
func(path, roles)
except TypeError:
func(path)
break
registered.append({'path': path, 'roles': roles, 'module': MODULE_NAME})
return registered
def selfcheck():
"""自检路径无通配符、API/UI 全部有角色授权、无重复。"""
problems = []
seen = set()
for path in ALL_PATHS:
if '*' in path or '?' in path:
problems.append('通配符路径禁止注册: %s' % path)
if path in seen:
problems.append('重复注册: %s' % path)
seen.add(path)
if not ROLE_GRANTS.get(path):
problems.append('缺少角色授权: %s' % path)
if not path.startswith('/%s/' % MODULE_NAME):
problems.append('路径未落在模块前缀下: %s' % path)
return problems
if __name__ == '__main__':
sys.exit(0 if register() else 1)
issues = selfcheck()
print('module=%s paths=%d api=%d ui=%d' % (MODULE_NAME, len(ALL_PATHS),
len(API_PATHS), len(UI_PATHS)))
if issues:
for issue in issues:
print('FAIL %s' % issue)
raise SystemExit(1)
print('OK 全部路径显式注册、无通配符、角色授权齐备')

View File

@ -1,26 +1,125 @@
# pbl_domain_ext 模块技能(自动生成骨架 + 人工补充
# pbl_domain_ext — world/scene/entity 薄扩展M8
## 定位
world/scene/entity 薄扩展:租户/班级/团队关联M8不改基表
> Agent 必读技能文档。改本模块前先读完「铁律」与「陷阱」两节。
## 挂载
`from pbl_domain_ext.init import load_pbl_domain_ext``load_pbl_domain_ext()`(应用 app/pbls.py init() 中按序调用)
## 1. 模块定位
## 数据表3 张)
- `pbl_tenant`租户多租户边界第25章不改基表
- `pbl_class`:班级(花名册内联 roster_json
- `pbl_team`:团队(成员内联 members_json不改基表
PBL 侧对复用平台 **world / scene / entity** 三张基表的**薄扩展**:只加 1 张关联表 + 查询封装,
**不 ALTER 基表、不侵入 world/scene/entity 模块代码**。
## 契约接口6 个,路径 `/pbl_domain_ext/api/<name>.dspy`
- `pbl_tenant_upsert`
- `pbl_class_save`
- `pbl_class_list`
- `pbl_team_save`
- `pbl_team_list`
- `pbl_domain_materialize_game_definition`
- 里程碑M8Wave 4
- 自有表:**1 张** `pbl_domain_ref`(权威 DDL`projects/pbls/docs/01-design/data-model.md` §J1
- 表总账:`projects/pbls/pbls_spec.json``tables_by_module.pbl_domain_ext = 1``tables_total = 36`
- depends_onworld、scene、entity复用只读、pbl_governance班级/团队/成员只读、pbl_common租户上下文/错误码)
- 被依赖pbl_scense_ext前端拉本租户世界列表、pbl_runtime_ext共享会话访问权校验、pbl_compilerapply 后 bind_ref
## 陷阱
- 库名一律 `ServerEnv().get_module_dbname('pbl_domain_ext')`,禁止硬编码 DBNAME。
- sqlor 只有 `C/U/D/R/I/sqlExe`;查询走 pbl_common.api 的 q_all/q_one已适配
- 所有读写强制带 `tenant_id`pbl_common.api.tenant_id()),缺失即 fail-closed 报错。
- 新增契约需同步三处api.py 定义 + __init__.py 导出 + init.py env 注册 + scripts/load_path.py 路径。
## 2. 数据模型(唯一自有表)
`pbl_domain_ref` — world/scene/entity 关联叠加:
| 列 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK AI | 主键 |
| tenant_id | VARCHAR(64) NOT NULL | 租户ID**基表无此列**PBL 多租户隔离全靠它) |
| ref_type | VARCHAR(32) NOT NULL | `appcodes:pbl_domain_ref_type` = world / scene / entity |
| ref_id | BIGINT NOT NULL | 复用基表主键world.id / scene.id / entity.id |
| blueprint_id | VARCHAR(64) NULL | 关联蓝图compiler apply 后回写) |
| class_id | VARCHAR(64) NULL | 教学班pbl_governance.pbl_class |
| team_id | VARCHAR(64) NULL | 团队pbl_governance.pbl_team共享世界分组 |
| ext | JSON NULL | 扩展属性(薄扩展附加维度,**禁止改基表** |
| is_deleted | TINYINT NOT NULL 0 | 软删unbind_ref 置 1不物理删 |
| created_at / updated_at | TIMESTAMP | 时间戳 |
**唯一约束**`UNIQUE(tenant_id, ref_type, ref_id)`(设计 §2 硬性要求)。
索引:`(tenant_id,blueprint_id)` / `(tenant_id,class_id)` / `(tenant_id,team_id)` / `(ref_type,ref_id)`
模型文件:`models/pbl_domain_ref.json`(四段式 summary(array)/fields/indexes/codes
DDL`sql/pbl_domain_ext.sql`。CRUD 浏览定义:`json/pbl_domain_ref.json`(运维只读浏览用)。
## 3. 对外契约13 个,与设计 §3 一一对应)
| # | 设计接口 | 实现位置 | dspy 端点 | env 注册名 |
|---|---|---|---|---|
| 1 | bind_ref | `pbl_domain_ext/api.py:bind_ref` | `api/pbl_domain_ref_bind.dspy` | `pbl_bind_ref` / `bind_ref` |
| 2 | unbind_ref | `api.py:unbind_ref` | `api/pbl_domain_ref_unbind.dspy` | `pbl_unbind_ref` / `unbind_ref` |
| 3 | get_ref | `api.py:get_ref` | `api/pbl_domain_ref_get.dspy` | `pbl_get_ref` / `get_ref` |
| 4 | list_refs | `api.py:list_refs` | `api/pbl_domain_ref_list.dspy` | `pbl_list_refs` / `list_refs` |
| 5 | update_ref | `api.py:update_ref` | `api/pbl_domain_ref_update.dspy` | `pbl_update_ref` / `update_ref` |
| 6 | list_worlds_by_tenant | `api.py:list_worlds_by_tenant` | `api/pbl_world_list_by_tenant.dspy` | `pbl_list_worlds_by_tenant` |
| 7 | list_scenes_by_world | `api.py:list_scenes_by_world` | `api/pbl_scene_list_by_world.dspy` | `pbl_list_scenes_by_world` |
| 8 | list_entities_by_scene | `api.py:list_entities_by_scene` | `api/pbl_entity_list_by_scene.dspy` | `pbl_list_entities_by_scene` |
| 9 | get_world_with_pbl_context | `api.py:get_world_with_pbl_context` | `api/pbl_world_get_context.dspy` | `pbl_get_world_with_pbl_context` |
| 10 | check_ref_access | `api.py:check_ref_access` | `api/pbl_domain_ref_check_access.dspy` | `pbl_check_ref_access` |
| 11 | list_teams_by_class | `api.py:list_teams_by_class` | `api/pbl_team_list_by_class.dspy` | `pbl_list_teams_by_class` |
| 12 | bind_team_to_world | `api.py:bind_team_to_world` | `api/pbl_team_bind_world.dspy` | `pbl_bind_team_to_world` |
| 13 | get_team_worlds | `api.py:get_team_worlds` | `api/pbl_team_world_list.dspy` | `pbl_get_team_worlds` |
统一响应包体:`{success, error_code, message, data, detail?, http_status?}`
错误码:`PBL_E_VALIDATION`(400) / `PBL_E_NOT_FOUND`(404) / `PBL_E_DUPLICATE`(409) /
`PBL_E_FORBIDDEN`(403) / `PBL_E_INTERNAL`(500)。
### 关键语义
- **tenant_id 强制打头**:所有接口缺 tenant_id 且 pbl_common 上下文取不到 → `PBL_E_VALIDATION`
- **bind_ref 前置存在性校验**:基表无此记录 → `PBL_E_NOT_FOUND`(防悬挂引用);已绑定 → `PBL_E_DUPLICATE`;软删记录重绑 = 复活(幂等)。
- **unbind_ref 软删**`is_deleted=1`,不物理删除(保留审计痕迹)。
- **update_ref 白名单**:只允许改 `blueprint_id/class_id/team_id/ext`;试图改 `ref_type/ref_id/tenant_id``PBL_E_VALIDATION`
- **租户隔离**:基表无租户列 → 先查 `pbl_domain_ref` 得白名单,再按白名单**只读**回查基表;未绑定即不可见。跨租户 → `PBL_E_FORBIDDEN`US-21 要求 403/404
- **悬挂引用**`list_refs` 结果标 `dangling=True` 并给 `valid_total`联合查询6/7/8/9直接过滤掉基表已删的行`check_ref_access` 对悬挂 ref 返回 False。
- **check_ref_access 不抛异常**:返回 bool运行时热路径用入参非法/无关联/班级团队不匹配/悬挂 → False。
- **list_teams_by_class**:按 team_id 分组,`members` 只读取自 `pbl_governance.pbl_team_member`(表不存在时降级为空列表,**绝不报错、绝不写入他模块表**)。
- **bind_team_to_world**:已有 world 关联 → 幂等更新 team_id不产生重复行同团队重复绑定 → `PBL_E_DUPLICATE`
## 4. 铁律(违反即退回)
1. **不改基表**world/scene/entity 零 ALTER、零写入只 SELECT。禁止给基表加 `tenant_id` 列——租户维度只落 `pbl_domain_ref`
2. **不新增表**:自有表恒为 1 张。`pbl_tenant/pbl_class/pbl_team`**pbl_governance**,本模块只读引用,**不得**出现在 `OWN_TABLES`/`models/`
3. **OWN_TABLES ↔ models/ 一一对应**:加/减表必须同步 `init.py:OWN_TABLES``models/*.json``sql/*.sql``pbls_spec.json` 表总账。
4. **三处同步注册**:新增契约函数必须同时改 ① `api.py` 实现 ② `__init__.py` 导入 ③ `init.py:load_pbl_domain_ext``env.xxx = xxx`。漏 ② → ImportError漏 ③ → dspy `NameError`
5. **dspy 端点齐备**:每个契约必须有 `wwwroot/api/<name>.dspy` 薄封装(禁 import、显式 `return``debug()` 带文件名前缀、转发全部客户端参数不硬编码)。
6. **RBAC 显式注册**:新增 .dspy/.ui 必须在 `scripts/load_path.py``API_PATHS`/`UI_PATHS`/`ROLE_GRANTS` 三处登记,**禁通配符**,否则上线 403。
7. **只用 sqlor 标准 API**`sor.C/U/D/R/I/sqlExe`。禁编造 `save/list/insert/query/delete`
8. **库名不硬编码**`ServerEnv().get_module_dbname('pbl_domain_ext')`(见 `db.py:resolve_dbname`)。
9. **SQL 占位符**:模块内一律 sqlor 风格 `${name}$`sqlite 测试适配器自动转 `:name``db.to_named_params`)。
## 5. 陷阱(踩过的坑)
- **`return` 不能写在 `async with` 块内** → 静默返回 None。`db.SqlorAdapter.query/execute` 先在块内收集结果,退出块后再 return。
- **基表列名不要猜**`base.resolve_column` 运行时探测MySQL `information_schema` → 退化 sqlite `PRAGMA table_info`scene 父列候选 `world_id/worldid/parent_id`entity 候选 `scene_id/sceneid/parent_id`。改列名只改候选表,别写死。
- **sqlite row_factory 污染测试**`SqliteAdapter` 把 conn 的 row_factory 设成 dict 工厂,测试里取标量必须用 `fake_db.scalar()/raw_sql()`(临时摘掉工厂),否则 `row[0]``KeyError: 0`
- **悬挂判定别写反**`filter_existing_ids` 返回空集 = 基表已删 = 悬挂,必须拒绝;写成 `if alive and rid not in alive` 会漏判(空集时短路成"允许")。
- **基表不可探测 ≠ 拒绝访问**`check_ref_access` 里探测异常(基础设施问题)应放行,只有明确查到"基表无此行"才判悬挂。
- **py_compile 不能验 .dspy**(顶层 return/await 是合法的),.dspy 用 grep 审计:无 `import`、有显式 `return``debug()` 带文件名。
- **dspy 里 `json` 是预注入全局**,可直接 `json.loads`,不要 `import json`
## 6. 挂载与验证
```python
# 应用入口 apps/pbls/pbls.py 的 init() 中load_order 见 pbls_spec.json
from pbl_domain_ext import load_pbl_domain_ext
load_pbl_domain_ext(env) # 注册 13 个契约到 ServerEnv
# RBAC 路径注册
import sys; sys.path.append('modules/pbl_domain_ext/scripts')
import load_path
load_path.register(env) # 或宿主 rbac 批量读 load_path.get_paths()
```
离线验证(不依赖 MySQL / 宿主):
```bash
cd modules/pbl_domain_ext
python3 -m py_compile pbl_domain_ext/*.py scripts/load_path.py tests/*.py
python3 tests/test_domain_ref.py # 62 tests覆盖 13 契约 + 薄扩展铁律
python3 scripts/load_path.py # 路径注册自检(无通配符/授权齐备)
```
## 7. 需求追溯
| 需求锚点 | 落点 |
|---|---|
| M8 薄扩展不改基表 | `pbl_domain_ref` 单表 + `base.py` 只读投影(测试 `test_base_tables_untouched` 断言基表行数/列不变) |
| 第 25 章多租户 | `list_worlds_by_tenant` + `check_ref_access` + tenant_id 强制打头 |
| US-21 跨租户 403/404 | `PBL_E_FORBIDDEN`(403) / `PBL_E_NOT_FOUND`(404),测试 `test_*_cross_tenant_*` |
| US-13 共享世界团队分组 | `list_teams_by_class` + `bind_team_to_world` + `get_team_worlds` |
| F-CP-02 落库映射关联 | `bind_ref`compiler apply_game_definition 后调用) |
| F-RT-02 加入共享会话 | `check_ref_access`(团队/班级访问权,返回 bool 不抛异常) |
| 班级维度F-AS-03 | `class_id` 关联 + `list_refs` 按 class_id 过滤 |

View File

@ -1,186 +1,122 @@
"""内存版 DbPort 实现 + 受限 SQL 解释器(仅供离线单测,不进生产路径)
"""tests/fake_db.py — 离线测试夹具sqlite3 承载 pbl_domain_ref + 三张复用基表只读投影
支持 assoc.py 生成的全部 SQL 形态
- SELECT <cols|*> FROM t [WHERE a=%s AND b=%s ...] ORDER BY col LIMIT n OFFSET m
- SELECT COUNT(*) AS cnt FROM t [WHERE ...]
- INSERT INTO t (c1, c2, ...) VALUES (%s, %s, ...)
- UPDATE t SET c=%s, ..., updated_at=NOW() WHERE a=%s AND b=%s
- DELETE FROM t WHERE a=%s AND b=%s
用途不依赖生产 MySQL / 宿主挂载即可跑真实 SQL 逻辑唯一约束软删过滤分页
悬挂引用跨租户隔离通过 db.set_adapter(SqliteAdapter(conn)) 注入
任何无法解析的语句直接抛错测试里出现意外 SQL 形态必须暴露不能静默通过
同时充当不改基表铁律的守卫 world/scene/entity 的任何写操作立即 AssertionError
注意sqlite 不支持 information_schemabase.table_columns 会自动退化到 PRAGMA table_info
"""
from __future__ import annotations
import os
import sqlite3
import sys
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pbl_domain_ext.assoc import DbPort
from pbl_domain_ext import base as base_mod # noqa: E402
from pbl_domain_ext import db as db_mod # noqa: E402
from pbl_domain_ext.db import SqliteAdapter # noqa: E402
_SELECT_RE = re.compile(
r"^SELECT\s+(?P<cols>.+?)\s+FROM\s+(?P<table>\w+)(?P<where>\s+WHERE\s+.+?)?"
r"(?:\s+ORDER\s+BY\s+(?P<order>\w+))?(?:\s+LIMIT\s+(?P<limit>\d+))?"
r"(?:\s+OFFSET\s+(?P<offset>\d+))?\s*$",
re.IGNORECASE | re.DOTALL,
DDL_PBL_DOMAIN_REF = """
CREATE TABLE pbl_domain_ref (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id TEXT NOT NULL,
ref_type TEXT NOT NULL,
ref_id INTEGER NOT NULL,
blueprint_id TEXT,
class_id TEXT,
team_id TEXT,
ext TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tenant_id, ref_type, ref_id)
)
_INSERT_RE = re.compile(
r"^INSERT\s+INTO\s+(?P<table>\w+)\s*\((?P<cols>[^)]*)\)\s*VALUES\s*\((?P<vals>[^)]*)\)\s*$",
re.IGNORECASE | re.DOTALL,
)
_UPDATE_RE = re.compile(
r"^UPDATE\s+(?P<table>\w+)\s+SET\s+(?P<assigns>.+?)\s+WHERE\s+(?P<where>.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
_DELETE_RE = re.compile(
r"^DELETE\s+FROM\s+(?P<table>\w+)\s+WHERE\s+(?P<where>.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
_COND_RE = re.compile(r"(\w+)\s*=\s*%s")
_BASE_TABLES = ("world", "scene", "entity")
"""
# 复用基表(测试替身):结构对齐 world/scene/entity 关键列,本模块只读
DDL_WORLD = "CREATE TABLE world (id INTEGER PRIMARY KEY, name TEXT, owner TEXT)"
DDL_SCENE = "CREATE TABLE scene (id INTEGER PRIMARY KEY, name TEXT, world_id INTEGER)"
DDL_ENTITY = ("CREATE TABLE entity (id INTEGER PRIMARY KEY, name TEXT, "
"scene_id INTEGER, kind TEXT)")
DDL_TEAM_MEMBER = ("CREATE TABLE pbl_team_member (id INTEGER PRIMARY KEY, "
"tenant_id TEXT, team_id TEXT, user_id TEXT, role TEXT)")
def _split_top_level(text: str) -> List[str]:
"""按逗号切分(本模块 SQL 无嵌套函数参数含逗号,直接切即可)。"""
return [part.strip() for part in text.split(",") if part.strip()]
def build_conn(with_team_member=True):
conn = sqlite3.connect(':memory:')
conn.execute(DDL_PBL_DOMAIN_REF)
conn.execute(DDL_WORLD)
conn.execute(DDL_SCENE)
conn.execute(DDL_ENTITY)
if with_team_member:
conn.execute(DDL_TEAM_MEMBER)
conn.commit()
return conn
class FakeDb(DbPort):
"""表名 -> 行列表;行是 dict含自增 id。"""
def seed_base_data(conn):
"""灌入基表测试数据2 world / 3 scene / 4 entity。"""
conn.executemany("INSERT INTO world (id, name, owner) VALUES (?,?,?)", [
(1, '世界A-火星基地', 'teacher01'),
(2, '世界B-海洋生态', 'teacher02'),
])
conn.executemany("INSERT INTO scene (id, name, world_id) VALUES (?,?,?)", [
(11, '场景A1-着陆区', 1),
(12, '场景A2-实验舱', 1),
(21, '场景B1-珊瑚礁', 2),
])
conn.executemany(
"INSERT INTO entity (id, name, scene_id, kind) VALUES (?,?,?,?)", [
(101, '实体A1a-探测车', 11, 'vehicle'),
(102, '实体A1b-宇航员', 11, 'avatar'),
(121, '实体A2a-培养皿', 12, 'prop'),
(211, '实体B1a-海龟', 21, 'creature'),
])
conn.commit()
def __init__(self, tables: Optional[Sequence[str]] = None) -> None:
self.tables: Dict[str, List[Dict[str, Any]]] = {name: [] for name in (tables or [])}
self._seq: Dict[str, int] = {name: 0 for name in (tables or [])}
self.executed: List[Tuple[str, Tuple[Any, ...]]] = []
self.base_table_writes: List[str] = []
# ------------------------------------------------------------ 内部工具
def _ensure(self, table: str) -> List[Dict[str, Any]]:
if table not in self.tables:
self.tables[table] = []
self._seq[table] = 0
return self.tables[table]
def seed_team_members(conn, tenant_id='T1'):
conn.executemany(
"INSERT INTO pbl_team_member (tenant_id, team_id, user_id, role) VALUES (?,?,?,?)",
[(tenant_id, 'TEAM_A', 'stu01', 'leader'),
(tenant_id, 'TEAM_A', 'stu02', 'member'),
(tenant_id, 'TEAM_B', 'stu03', 'member')])
conn.commit()
def _guard_base(self, table: str, kind: str) -> None:
if table in _BASE_TABLES:
self.base_table_writes.append("%s:%s" % (kind, table))
raise AssertionError("铁律违规:薄扩展不得写基表 %s%s" % (table, kind))
@staticmethod
def _row_matches(row: Dict[str, Any], cols: Sequence[str], params: Sequence[Any]) -> bool:
for col, expected in zip(cols, params):
if str(row.get(col)) != str(expected):
return False
return True
def setup(with_team_member=True, seed=True):
"""构建夹具并注入适配器;返回 (conn, adapter)。"""
conn = build_conn(with_team_member=with_team_member)
if seed:
seed_base_data(conn)
if with_team_member:
seed_team_members(conn)
adapter = SqliteAdapter(conn)
db_mod.set_adapter(adapter)
db_mod.set_dbname('pbls_test')
base_mod.clear_cache()
return conn, adapter
def _where_rows(self, table: str, where_sql: str, params: Sequence[Any]) -> List[Dict[str, Any]]:
cols = _COND_RE.findall(where_sql or "")
return [row for row in self._ensure(table) if self._row_matches(row, cols, params)]
# ------------------------------------------------------------ DbPort
def select(self, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
self.executed.append((sql, tuple(params)))
text = " ".join(sql.split())
m = _SELECT_RE.match(text)
if not m:
raise AssertionError("FakeDb 无法解析 SELECT: %s" % sql)
table = m.group("table")
cols = m.group("cols").strip()
where = m.group("where") or ""
rows = [dict(r) for r in self._where_rows(table, where, tuple(params))]
def teardown():
db_mod.set_adapter(None)
db_mod.set_dbname(None)
base_mod.clear_cache()
if cols.upper().startswith("COUNT("):
return [{"cnt": len(rows)}]
order = m.group("order")
if order:
rows.sort(key=lambda r: (r.get(order) is None, r.get(order)))
offset = int(m.group("offset") or 0)
limit = m.group("limit")
rows = rows[offset:offset + int(limit)] if limit is not None else rows[offset:]
if cols != "*":
wanted = _split_top_level(cols)
rows = [{c: r.get(c) for c in wanted} for r in rows]
def raw_sql(conn, sql, params=()):
"""测试辅助:临时摘掉 dict row_factory按位置取标量/元组。"""
saved = conn.row_factory
conn.row_factory = None
try:
cur = conn.execute(sql, params)
rows = cur.fetchall()
cur.close()
return rows
finally:
conn.row_factory = saved
def insert(self, sql: str, params: Sequence[Any] = ()) -> int:
self.executed.append((sql, tuple(params)))
text = " ".join(sql.split())
m = _INSERT_RE.match(text)
if not m:
raise AssertionError("FakeDb 无法解析 INSERT: %s" % sql)
table = m.group("table")
self._guard_base(table, "INSERT")
cols = _split_top_level(m.group("cols"))
placeholders = _split_top_level(m.group("vals"))
if len(cols) != len(placeholders) or len(cols) != len(params):
raise AssertionError(
"INSERT 列数与参数数不一致: cols=%d ph=%d params=%d"
% (len(cols), len(placeholders), len(params))
)
rows = self._ensure(table)
self._seq[table] = self._seq.get(table, 0) + 1
row: Dict[str, Any] = {"id": self._seq[table]}
for col, value in zip(cols, params):
row[col] = value
uniq_cols = [c for c in ("tenant_id", "world_id", "scene_id", "entity_id", "team_id", "role_code")
if c in row]
for exist in rows:
if self._row_matches(exist, uniq_cols, [row[c] for c in uniq_cols]):
raise AssertionError("唯一键冲突(幂等 upsert 失效): %s %s" % (table, row))
rows.append(row)
return int(row["id"])
def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
self.executed.append((sql, tuple(params)))
text = " ".join(sql.split())
m = _UPDATE_RE.match(text)
if m:
table = m.group("table")
self._guard_base(table, "UPDATE")
assigns = _split_top_level(m.group("assigns"))
set_cols: List[str] = []
set_values: List[Any] = []
cursor = 0
for assign in assigns:
col, _, expr = assign.partition("=")
col = col.strip()
expr = expr.strip()
if expr == "%s":
set_cols.append(col)
set_values.append(params[cursor])
cursor += 1
elif expr.upper() == "NOW()":
set_cols.append(col)
set_values.append("NOW()")
else:
raise AssertionError("FakeDb 不支持的 SET 表达式: %s" % assign)
where_cols = _COND_RE.findall(m.group("where"))
where_values = list(params[cursor:cursor + len(where_cols)])
affected = 0
for row in self._ensure(table):
if not self._row_matches(row, where_cols, where_values):
continue
for col, value in zip(set_cols, set_values):
row[col] = value
affected += 1
return affected
m = _DELETE_RE.match(text)
if m:
table = m.group("table")
self._guard_base(table, "DELETE")
where_cols = _COND_RE.findall(m.group("where"))
keep: List[Dict[str, Any]] = []
affected = 0
for row in self._ensure(table):
if self._row_matches(row, where_cols, tuple(params)):
affected += 1
else:
keep.append(row)
self.tables[table] = keep
return affected
raise AssertionError("FakeDb 无法解析语句: %s" % sql)
def scalar(conn, sql, params=()):
rows = raw_sql(conn, sql, params)
return rows[0][0] if rows else None

528
tests/test_domain_ref.py Normal file
View File

@ -0,0 +1,528 @@
"""tests/test_domain_ref.py — M8 薄扩展 13 个契约接口的真实断言测试(离线 sqlite
覆盖
§3.1 bind_ref / unbind_ref / get_ref / list_refs / update_ref
§3.2 list_worlds_by_tenant / list_scenes_by_world / list_entities_by_scene
/ get_world_with_pbl_context / check_ref_access
§3.3 list_teams_by_class / bind_team_to_world / get_team_worlds
+ 薄扩展铁律不改基表基表行数/结构前后一致跨租户隔离US-21
悬挂引用过滤表总账OWN_TABLES == models/*.json == 1 pbl_domain_ref
运行python3 tests/test_domain_ref.py
"""
import asyncio
import json
import os
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
sys.path.insert(0, HERE)
import fake_db # noqa: E402
from pbl_domain_ext import api # noqa: E402
from pbl_domain_ext import base as base_mod # noqa: E402
from pbl_domain_ext import init as init_mod # noqa: E402
from pbl_domain_ext.errors import (E_DUPLICATE, E_FORBIDDEN, E_NOT_FOUND, # noqa: E402
E_VALIDATION, PblError)
T1 = 'T1'
T2 = 'T2'
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
class BaseCase(unittest.TestCase):
def setUp(self):
self.conn, self.adapter = fake_db.setup()
def tearDown(self):
fake_db.teardown()
self.conn.close()
def assertPblError(self, code, coro):
with self.assertRaises(Exception) as ctx:
run(coro)
exc = ctx.exception
self.assertEqual(getattr(exc, 'code', None), code,
'期望 %s,实际 %s(%s)' % (code, getattr(exc, 'code', None), exc))
return exc
class TestBindRef(BaseCase):
"""接口 1bind_ref"""
def test_bind_world_ok(self):
ref = run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_A', ext={'stage': 2}, tenant_id=T1))
self.assertIsNotNone(ref)
self.assertEqual(ref['tenant_id'], T1)
self.assertEqual(ref['ref_type'], 'world')
self.assertEqual(ref['ref_id'], 1)
self.assertEqual(ref['blueprint_id'], 'BP-1')
self.assertEqual(ref['class_id'], 'CLS-1')
self.assertEqual(ref['team_id'], 'TEAM_A')
self.assertEqual(ref['ext'], {'stage': 2})
self.assertEqual(ref['is_deleted'], 0)
def test_bind_duplicate_raises(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_DUPLICATE, api.bind_ref('world', 1, tenant_id=T1))
def test_bind_same_ref_other_tenant_ok(self):
"""同一基表记录可被不同租户各自绑定UNIQUE 含 tenant_id"""
run(api.bind_ref('world', 1, tenant_id=T1))
ref2 = run(api.bind_ref('world', 1, tenant_id=T2))
self.assertEqual(ref2['tenant_id'], T2)
def test_bind_base_missing_raises_not_found(self):
self.assertPblError(E_NOT_FOUND, api.bind_ref('world', 999, tenant_id=T1))
def test_bind_invalid_ref_type(self):
self.assertPblError(E_VALIDATION, api.bind_ref('script', 1, tenant_id=T1))
def test_bind_invalid_ref_id(self):
self.assertPblError(E_VALIDATION, api.bind_ref('world', 'abc', tenant_id=T1))
self.assertPblError(E_VALIDATION, api.bind_ref('world', 0, tenant_id=T1))
def test_bind_missing_tenant(self):
self.assertPblError(E_VALIDATION, api.bind_ref('world', 1))
def test_rebind_after_unbind_revives(self):
run(api.bind_ref('scene', 11, class_id='CLS-1', tenant_id=T1))
run(api.unbind_ref('scene', 11, tenant_id=T1))
ref = run(api.bind_ref('scene', 11, class_id='CLS-2', tenant_id=T1))
self.assertEqual(ref['class_id'], 'CLS-2')
self.assertEqual(ref['is_deleted'], 0)
def test_bind_ext_invalid_json_string(self):
self.assertPblError(E_VALIDATION,
api.bind_ref('world', 1, ext='{bad json', tenant_id=T1))
class TestUnbindGetUpdate(BaseCase):
"""接口 2/3/5unbind_ref / get_ref / update_ref"""
def test_unbind_soft_delete(self):
run(api.bind_ref('entity', 101, tenant_id=T1))
self.assertTrue(run(api.unbind_ref('entity', 101, tenant_id=T1)))
flag = fake_db.scalar(self.conn,
"SELECT is_deleted FROM pbl_domain_ref WHERE ref_id=101")
self.assertEqual(flag, 1, 'unbind 必须软删而非物理删除')
self.assertPblError(E_NOT_FOUND, api.get_ref('entity', 101, tenant_id=T1))
def test_unbind_missing(self):
self.assertPblError(E_NOT_FOUND, api.unbind_ref('world', 1, tenant_id=T1))
def test_get_ref_ok(self):
run(api.bind_ref('world', 2, blueprint_id='BP-9', tenant_id=T1))
ref = run(api.get_ref('world', 2, tenant_id=T1))
self.assertEqual(ref['blueprint_id'], 'BP-9')
def test_get_ref_cross_tenant_not_found(self):
run(api.bind_ref('world', 2, tenant_id=T1))
self.assertPblError(E_NOT_FOUND, api.get_ref('world', 2, tenant_id=T2))
def test_update_ref_ok(self):
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
ref = run(api.update_ref('world', 1, {'class_id': 'CLS-2', 'team_id': 'TEAM_B',
'ext': {'k': 'v'}}, tenant_id=T1))
self.assertEqual(ref['class_id'], 'CLS-2')
self.assertEqual(ref['team_id'], 'TEAM_B')
self.assertEqual(ref['ext'], {'k': 'v'})
def test_update_ref_rejects_key_fields(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_VALIDATION,
api.update_ref('world', 1, {'ref_id': 2}, tenant_id=T1))
self.assertPblError(E_VALIDATION,
api.update_ref('world', 1, {'tenant_id': T2}, tenant_id=T1))
def test_update_ref_missing(self):
self.assertPblError(E_NOT_FOUND,
api.update_ref('world', 1, {'class_id': 'X'}, tenant_id=T1))
class TestListRefs(BaseCase):
"""接口 4list_refs过滤 + 分页 + 悬挂引用标记)"""
def _seed(self):
run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_A', tenant_id=T1))
run(api.bind_ref('world', 2, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_B', tenant_id=T1))
run(api.bind_ref('scene', 11, blueprint_id='BP-2', class_id='CLS-1',
tenant_id=T1))
run(api.bind_ref('entity', 101, class_id='CLS-2', tenant_id=T1))
run(api.bind_ref('world', 1, class_id='CLS-9', tenant_id=T2))
def test_list_all_tenant_scoped(self):
self._seed()
res = run(api.list_refs({}, 1, 20, tenant_id=T1))
self.assertEqual(res['total'], 4, 'T1 只应看到自己的 4 条关联')
self.assertTrue(all(i['tenant_id'] == T1 for i in res['items']))
def test_list_filter_by_type_and_class(self):
self._seed()
res = run(api.list_refs({'ref_type': 'world'}, 1, 20, tenant_id=T1))
self.assertEqual(res['total'], 2)
res2 = run(api.list_refs({'class_id': 'CLS-2'}, 1, 20, tenant_id=T1))
self.assertEqual(res2['total'], 1)
self.assertEqual(res2['items'][0]['ref_type'], 'entity')
res3 = run(api.list_refs({'blueprint_id': 'BP-1'}, 1, 20, tenant_id=T1))
self.assertEqual(res3['total'], 2)
res4 = run(api.list_refs({'team_id': 'TEAM_B'}, 1, 20, tenant_id=T1))
self.assertEqual(res4['total'], 1)
def test_list_pagination(self):
self._seed()
page1 = run(api.list_refs({}, 1, 2, tenant_id=T1))
page2 = run(api.list_refs({}, 2, 2, tenant_id=T1))
self.assertEqual(len(page1['items']), 2)
self.assertEqual(len(page2['items']), 2)
self.assertEqual(page1['total'], 4)
ids1 = {i['id'] for i in page1['items']}
ids2 = {i['id'] for i in page2['items']}
self.assertFalse(ids1 & ids2, '分页结果不得重叠')
def test_list_page_size_capped(self):
self._seed()
res = run(api.list_refs({}, 1, 9999, tenant_id=T1))
self.assertEqual(res['size'], api.MAX_PAGE_SIZE)
def test_list_invalid_ref_type_filter(self):
self.assertPblError(E_VALIDATION,
api.list_refs({'ref_type': 'bogus'}, 1, 20, tenant_id=T1))
def test_list_dangling_flag(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.conn.execute("DELETE FROM world WHERE id=1") # 复用模块删除基表记录
self.conn.commit()
res = run(api.list_refs({'ref_type': 'world'}, 1, 20, tenant_id=T1))
self.assertEqual(res['total'], 1)
self.assertTrue(res['items'][0]['dangling'], '基表已删的 ref 必须标记 dangling')
self.assertEqual(res['valid_total'], 0)
class TestTenantIsolationQueries(BaseCase):
"""接口 6~9租户隔离查询封装基表 + 扩展联合)"""
def test_list_worlds_by_tenant_only_bound(self):
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
worlds = run(api.list_worlds_by_tenant(T1))
self.assertEqual(len(worlds), 1)
self.assertEqual(worlds[0]['id'], 1)
self.assertEqual(worlds[0]['name'], '世界A-火星基地')
self.assertEqual(worlds[0]['class_id'], 'CLS-1')
self.assertIsNotNone(worlds[0]['pbl_ref'])
# 未绑定的 world 2 对 T1 不可见
self.assertNotIn(2, [w['id'] for w in worlds])
def test_list_worlds_by_tenant_empty_when_no_binding(self):
self.assertEqual(run(api.list_worlds_by_tenant(T2)), [])
def test_list_worlds_by_class_filter(self):
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
run(api.bind_ref('world', 2, class_id='CLS-2', tenant_id=T1))
got = run(api.list_worlds_by_tenant(T1, class_id='CLS-2'))
self.assertEqual([w['id'] for w in got], [2])
def test_list_worlds_filters_dangling(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.conn.execute("DELETE FROM world WHERE id=1")
self.conn.commit()
self.assertEqual(run(api.list_worlds_by_tenant(T1)), [],
'悬挂引用必须从联合结果中过滤')
def test_list_scenes_by_world_ok(self):
run(api.bind_ref('world', 1, tenant_id=T1))
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.bind_ref('scene', 12, tenant_id=T1))
scenes = run(api.list_scenes_by_world(1, T1))
self.assertEqual(sorted(s['id'] for s in scenes), [11, 12])
self.assertEqual(scenes[0]['world_id'], 1)
def test_list_scenes_only_bound_visible(self):
run(api.bind_ref('world', 1, tenant_id=T1))
run(api.bind_ref('scene', 11, tenant_id=T1)) # 12 未绑定
scenes = run(api.list_scenes_by_world(1, T1))
self.assertEqual([s['id'] for s in scenes], [11])
def test_list_scenes_cross_tenant_forbidden(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_FORBIDDEN, api.list_scenes_by_world(1, T2))
def test_list_entities_by_scene_ok(self):
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.bind_ref('entity', 101, tenant_id=T1))
run(api.bind_ref('entity', 102, tenant_id=T1))
ents = run(api.list_entities_by_scene(11, T1))
self.assertEqual(sorted(e['id'] for e in ents), [101, 102])
self.assertEqual(ents[0]['kind'], 'vehicle')
def test_list_entities_cross_tenant_forbidden(self):
run(api.bind_ref('scene', 11, tenant_id=T1))
self.assertPblError(E_FORBIDDEN, api.list_entities_by_scene(11, T2))
def test_get_world_with_pbl_context_ok(self):
run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_A', ext={'mode': 'coop'}, tenant_id=T1))
ctx = run(api.get_world_with_pbl_context(1, T1))
self.assertEqual(ctx['name'], '世界A-火星基地')
self.assertEqual(ctx['pbl_context']['blueprint_id'], 'BP-1')
self.assertEqual(ctx['pbl_context']['class_id'], 'CLS-1')
self.assertEqual(ctx['pbl_context']['team_id'], 'TEAM_A')
self.assertEqual(ctx['pbl_context']['ext'], {'mode': 'coop'})
self.assertEqual(ctx['pbl_context']['tenant_id'], T1)
def test_get_world_context_cross_tenant_forbidden(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_FORBIDDEN, api.get_world_with_pbl_context(1, T2))
def test_get_world_context_base_missing_not_found(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.conn.execute("DELETE FROM world WHERE id=1")
self.conn.commit()
self.assertPblError(E_NOT_FOUND, api.get_world_with_pbl_context(1, T1))
class TestCheckRefAccess(BaseCase):
"""接口 10check_ref_access租户+班级+团队三重匹配)"""
def test_access_granted(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertTrue(run(api.check_ref_access('world', 1, T1)))
self.assertTrue(run(api.check_ref_access('world', 1, T1,
class_id='CLS-1', team_id='TEAM_A')))
def test_access_denied_cross_tenant(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertFalse(run(api.check_ref_access('world', 1, T2)))
def test_access_denied_wrong_class_or_team(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertFalse(run(api.check_ref_access('world', 1, T1, class_id='CLS-X')))
self.assertFalse(run(api.check_ref_access('world', 1, T1, team_id='TEAM-X')))
def test_access_denied_unbound(self):
self.assertFalse(run(api.check_ref_access('world', 2, T1)))
def test_access_denied_after_unbind(self):
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.unbind_ref('scene', 11, tenant_id=T1))
self.assertFalse(run(api.check_ref_access('scene', 11, T1)))
def test_access_denied_invalid_input_no_raise(self):
self.assertFalse(run(api.check_ref_access('bogus', 1, T1)))
self.assertFalse(run(api.check_ref_access('world', 'x', T1)))
self.assertFalse(run(api.check_ref_access('world', 1, None)))
def test_access_denied_dangling_base(self):
run(api.bind_ref('entity', 101, tenant_id=T1))
self.conn.execute("DELETE FROM entity WHERE id=101")
self.conn.commit()
self.assertFalse(run(api.check_ref_access('entity', 101, T1)))
class TestTeamClassDimension(BaseCase):
"""接口 11~13团队/班级维度US-13 共享世界支撑)"""
def test_list_teams_by_class_groups(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
run(api.bind_ref('scene', 11, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
run(api.bind_ref('world', 2, class_id='CLS-1', team_id='TEAM_B', tenant_id=T1))
teams = run(api.list_teams_by_class('CLS-1', T1))
self.assertEqual(len(teams), 2)
by_id = {t['team_id']: t for t in teams}
self.assertEqual(sorted(by_id['TEAM_A']['world_ids']), [1])
self.assertEqual(sorted(by_id['TEAM_A']['scene_ids']), [11])
self.assertEqual(by_id['TEAM_A']['entity_ids'], [])
self.assertEqual(len(by_id['TEAM_A']['ref_ids']), 2)
self.assertEqual(sorted(by_id['TEAM_B']['world_ids']), [2])
def test_list_teams_by_class_members_from_governance(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
teams = run(api.list_teams_by_class('CLS-1', T1))
members = teams[0]['members']
self.assertEqual(len(members), 2, 'members 应只读取自 pbl_governance.pbl_team_member')
self.assertEqual({m['user_id'] for m in members}, {'stu01', 'stu02'})
def test_list_teams_by_class_tenant_scoped(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertEqual(run(api.list_teams_by_class('CLS-1', T2)), [])
def test_list_teams_by_class_requires_class_id(self):
self.assertPblError(E_VALIDATION, api.list_teams_by_class(None, T1))
def test_list_teams_without_governance_table_degrades(self):
"""治理模块未部署(无 pbl_team_member 表)时降级为空成员列表,不报错。"""
fake_db.teardown()
self.conn.close()
self.conn, self.adapter = fake_db.setup(with_team_member=False)
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
teams = run(api.list_teams_by_class('CLS-1', T1))
self.assertEqual(len(teams), 1)
self.assertEqual(teams[0]['members'], [])
def test_bind_team_to_world_creates_ref(self):
ref = run(api.bind_team_to_world(1, 'TEAM_A', T1, class_id='CLS-1',
blueprint_id='BP-1'))
self.assertEqual(ref['ref_type'], 'world')
self.assertEqual(ref['ref_id'], 1)
self.assertEqual(ref['team_id'], 'TEAM_A')
self.assertEqual(ref['class_id'], 'CLS-1')
def test_bind_team_to_world_updates_existing(self):
run(api.bind_ref('world', 1, team_id='TEAM_A', tenant_id=T1))
ref = run(api.bind_team_to_world(1, 'TEAM_B', T1))
self.assertEqual(ref['team_id'], 'TEAM_B', '已有关联应幂等更新 team_id')
cnt = fake_db.scalar(
self.conn,
"SELECT COUNT(*) FROM pbl_domain_ref WHERE ref_type='world' AND ref_id=1")
self.assertEqual(cnt, 1, '不得产生重复关联行')
def test_bind_team_to_world_duplicate_same_team(self):
run(api.bind_team_to_world(1, 'TEAM_A', T1))
self.assertPblError(E_DUPLICATE, api.bind_team_to_world(1, 'TEAM_A', T1))
def test_bind_team_to_world_base_missing(self):
self.assertPblError(E_NOT_FOUND, api.bind_team_to_world(999, 'TEAM_A', T1))
def test_bind_team_to_world_requires_team_id(self):
self.assertPblError(E_VALIDATION, api.bind_team_to_world(1, None, T1))
def test_get_team_worlds(self):
run(api.bind_team_to_world(1, 'TEAM_A', T1))
run(api.bind_team_to_world(2, 'TEAM_B', T1))
worlds = run(api.get_team_worlds('TEAM_A', T1))
self.assertEqual([w['id'] for w in worlds], [1])
self.assertEqual(worlds[0]['team_id'], 'TEAM_A')
self.assertEqual(run(api.get_team_worlds('TEAM_A', T2)), [],
'跨租户不得返回他租户团队世界')
def test_get_team_worlds_requires_team_id(self):
self.assertPblError(E_VALIDATION, api.get_team_worlds('', T1))
class TestThinExtensionInvariants(BaseCase):
"""薄扩展铁律:不改基表 + 表总账 + 契约齐备"""
def test_base_tables_untouched(self):
before = {t: fake_db.scalar(self.conn, "SELECT COUNT(*) FROM %s" % t)
for t in ('world', 'scene', 'entity')}
run(api.bind_ref('world', 1, class_id='C', team_id='T', tenant_id=T1))
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.bind_ref('entity', 101, tenant_id=T1))
run(api.update_ref('world', 1, {'class_id': 'C2'}, tenant_id=T1))
run(api.bind_team_to_world(2, 'TEAM_B', T1))
# 读路径(联合查询)——必须在解绑前跑,解绑后 scene 11 对本租户即 403
run(api.list_worlds_by_tenant(T1))
run(api.list_scenes_by_world(1, T1))
run(api.list_entities_by_scene(11, T1))
run(api.get_world_with_pbl_context(1, T1))
run(api.check_ref_access('world', 1, T1))
run(api.list_teams_by_class('C', T1))
run(api.get_team_worlds('T', T1))
run(api.list_refs({}, 1, 20, tenant_id=T1))
# 写路径收尾:解绑(软删,只动自有表)
run(api.unbind_ref('scene', 11, tenant_id=T1))
run(api.unbind_ref('entity', 101, tenant_id=T1))
after = {t: fake_db.scalar(self.conn, "SELECT COUNT(*) FROM %s" % t)
for t in ('world', 'scene', 'entity')}
self.assertEqual(before, after, '基表行数不得变化(零写入)')
# 基表结构零 ALTER列集合不变
for table in ('world', 'scene', 'entity'):
cols = [r[1] for r in fake_db.raw_sql(
self.conn, "PRAGMA table_info(%s)" % table)]
self.assertNotIn('tenant_id', cols, '禁止给基表加 tenant_id 列')
def test_own_tables_single_and_matches_models(self):
self.assertEqual(init_mod.OWN_TABLES, ['pbl_domain_ref'])
models_dir = os.path.join(ROOT, 'models')
files = sorted(f for f in os.listdir(models_dir) if f.endswith('.json'))
self.assertEqual(files, ['pbl_domain_ref.json'],
'models/ 必须与 OWN_TABLES 一一对应QC #4')
def test_model_json_four_sections(self):
path = os.path.join(ROOT, 'models', 'pbl_domain_ref.json')
model = json.load(open(path, encoding='utf-8'))
for key in ('summary', 'fields', 'indexes', 'codes'):
self.assertIn(key, model, '表定义四段式缺 %s' % key)
self.assertIsInstance(model['summary'], list, 'summary 必须是数组QC #3')
self.assertTrue(all(isinstance(s, str) for s in model['summary']))
self.assertEqual(model['summary'][0], 'pbl_domain_ref')
primaries = [f for f in model['fields'] if f.get('primary')]
self.assertEqual(len(primaries), 1)
self.assertEqual(primaries[0]['name'], 'id')
uniq = [i for i in model['indexes'] if i.get('unique') and not i.get('primary')]
self.assertIn(['tenant_id', 'ref_type', 'ref_id'],
[i['fields'] for i in uniq],
'必须有 UNIQUE(tenant_id,ref_type,ref_id)(设计 §2')
def test_contract_13_interfaces_all_callable(self):
self.assertEqual(len(api.CONTRACT_INTERFACES), 13)
for name in api.CONTRACT_INTERFACES:
self.assertTrue(callable(getattr(api, name, None)), '缺实现:%s' % name)
def test_contract_map_covers_dspy_files(self):
cmap = init_mod.get_contract_map()
self.assertEqual(len(cmap), 13)
api_dir = os.path.join(ROOT, 'wwwroot', 'api')
existing = set(os.listdir(api_dir))
for name, (impl, dspy) in cmap.items():
self.assertTrue(callable(getattr(api, name, None)), '缺实现:%s' % name)
self.assertIn(os.path.basename(dspy), existing,
'契约 %s 缺 dspy 端点 %sQC #5' % (name, dspy))
self.assertTrue(impl.startswith('pbl_domain_ext/api.py:'))
def test_load_path_registers_all_dspy(self):
sys.path.insert(0, os.path.join(ROOT, 'scripts'))
import load_path as lp
self.assertEqual(lp.selfcheck(), [], 'load_path 自检必须无问题QC #6')
registered = set(lp.API_PATHS) | set(lp.UI_PATHS)
api_dir = os.path.join(ROOT, 'wwwroot', 'api')
for fname in os.listdir(api_dir):
if fname.endswith('.dspy'):
self.assertIn('/pbl_domain_ext/api/%s' % fname, registered,
'端点 %s 未在 load_path.py 注册 → 上线 403' % fname)
self.assertIn('/pbl_domain_ext/index.ui', registered)
self.assertEqual(len(lp.API_PATHS), 13)
for path in lp.ALL_PATHS:
self.assertNotIn('*', path, '禁止通配符注册')
def test_load_module_registers_env_functions(self):
env = init_mod.load_pbl_domain_ext()
for name in api.CONTRACT_INTERFACES:
self.assertTrue(callable(getattr(env, 'pbl_%s' % name, None)),
'env.pbl_%s 未注册' % name)
self.assertTrue(callable(getattr(env, name, None)),
'env.%s(设计原名)未注册' % name)
info = env.pbl_domain_ext_module_info
self.assertEqual(info['own_tables'], ['pbl_domain_ref'])
self.assertFalse(info['base_table_altered'])
self.assertEqual(len(info['contracts']), 13)
def test_package_exports_match_init(self):
import pbl_domain_ext as pkg
for name in api.CONTRACT_INTERFACES:
self.assertTrue(callable(getattr(pkg, name, None)),
'__init__.py 未导出 %s(三处同步注册之 ②)' % name)
self.assertTrue(callable(pkg.load_pbl_domain_ext))
def test_base_layer_readonly_projection(self):
self.assertTrue(run(base_mod.base_exists('world', 1)))
self.assertFalse(run(base_mod.base_exists('world', 999)))
rows = run(base_mod.fetch_children('scene', 1))
self.assertEqual(sorted(r['id'] for r in rows), [11, 12])
col = run(base_mod.resolve_column('scene', base_mod.SCENE_PARENT_CANDIDATES))
self.assertEqual(col, 'world_id')
if __name__ == '__main__':
unittest.main(verbosity=2)

View File

@ -0,0 +1,24 @@
# api/pbl_domain_ref_check_access.dspy — 契约端点check_ref_access设计 §3.2 接口 10
# 实现pbl_domain_ext/api.py:check_ref_accessenv.pbl_check_ref_access
# 出参bool租户+班级+团队三重匹配;不抛异常,供运行时热路径判定 F-RT-02
debug('pbl_domain_ref_check_access.dspy: START params_kw=%s' % dict(params_kw))
try:
_allowed = await pbl_check_ref_access(
params_kw.get('ref_type'),
params_kw.get('ref_id'),
params_kw.get('tenant_id'),
class_id=params_kw.get('class_id'),
team_id=params_kw.get('team_id'),
)
debug('pbl_domain_ref_check_access.dspy: OK allowed=%s' % _allowed)
return {'success': True, 'error_code': None, 'message': 'ok',
'data': {'allowed': bool(_allowed)},
'allowed': bool(_allowed),
'http_status': 200 if _allowed else 403}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
debug('pbl_domain_ref_check_access.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'data': {'allowed': False}, 'allowed': False, 'http_status': 500}

View File

@ -0,0 +1,37 @@
# api/pbl_domain_ref_list.dspy — 契约端点list_refs设计 §3.1 接口 4
# 实现pbl_domain_ext/api.py:list_refsenv.pbl_list_refs
# 入参filters{ref_type,blueprint_id,class_id,team_id,ref_id} page size tenant_id
debug('pbl_domain_ref_list.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
# filters 兼容两种传法:整体 JSON 字符串 / 平铺字段DSPY 必须转发全部客户端参数,禁硬编码)
_filters = params_kw.get('filters')
if isinstance(_filters, str) and _filters.strip():
try:
_filters = json.loads(_filters)
except Exception:
_filters = None
if not isinstance(_filters, dict):
_filters = {}
for _k in ('ref_type', 'blueprint_id', 'class_id', 'team_id', 'ref_id'):
if _k not in _filters and params_kw.get(_k) not in (None, ''):
_filters[_k] = params_kw.get(_k)
try:
_data = await pbl_list_refs(
_filters,
params_kw.get('page', 1),
params_kw.get('size', 20),
tenant_id=params_kw.get('tenant_id'),
)
debug('pbl_domain_ref_list.dspy: OK total=%s' % (_data or {}).get('total'))
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_domain_ref_list.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,36 @@
# api/pbl_domain_ref_update.dspy — 契约端点update_ref设计 §3.1 接口 5
# 实现pbl_domain_ext/api.py:update_refenv.pbl_update_ref
# 入参ref_type ref_id data{class_id,team_id,ext,blueprint_id} tenant_id
debug('pbl_domain_ref_update.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
_data_in = params_kw.get('data')
if isinstance(_data_in, str) and _data_in.strip():
try:
_data_in = json.loads(_data_in)
except Exception:
_data_in = None
if not isinstance(_data_in, dict):
_data_in = {}
for _k in ('blueprint_id', 'class_id', 'team_id', 'ext'):
if _k not in _data_in and params_kw.get(_k) is not None:
_data_in[_k] = params_kw.get(_k)
try:
_data = await pbl_update_ref(
params_kw.get('ref_type'),
params_kw.get('ref_id'),
_data_in,
tenant_id=params_kw.get('tenant_id'),
)
debug('pbl_domain_ref_update.dspy: OK id=%s' % (_data or {}).get('id'))
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_domain_ref_update.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,23 @@
# api/pbl_entity_list_by_scene.dspy — 契约端点list_entities_by_scene设计 §3.2 接口 8
# 实现pbl_domain_ext/api.py:list_entities_by_sceneenv.pbl_list_entities_by_scene
# 越权scene 非本租户绑定 → PBL_E_FORBIDDEN(403)US-21
debug('pbl_entity_list_by_scene.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
try:
_data = await pbl_list_entities_by_scene(
params_kw.get('scene_id'),
params_kw.get('tenant_id'),
)
debug('pbl_entity_list_by_scene.dspy: OK count=%s' % len(_data or []))
return {'success': True, 'error_code': None, 'message': 'ok',
'data': {'items': _data, 'total': len(_data or [])}}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_entity_list_by_scene.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,23 @@
# api/pbl_scene_list_by_world.dspy — 契约端点list_scenes_by_world设计 §3.2 接口 7
# 实现pbl_domain_ext/api.py:list_scenes_by_worldenv.pbl_list_scenes_by_world
# 越权world 非本租户绑定 → PBL_E_FORBIDDEN(403)US-21
debug('pbl_scene_list_by_world.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
try:
_data = await pbl_list_scenes_by_world(
params_kw.get('world_id'),
params_kw.get('tenant_id'),
)
debug('pbl_scene_list_by_world.dspy: OK count=%s' % len(_data or []))
return {'success': True, 'error_code': None, 'message': 'ok',
'data': {'items': _data, 'total': len(_data or [])}}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_scene_list_by_world.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,34 @@
# api/pbl_team_bind_world.dspy — 契约端点bind_team_to_world设计 §3.3 接口 12
# 实现pbl_domain_ext/api.py:bind_team_to_worldenv.pbl_bind_team_to_world
# 错误分支PBL_E_NOT_FOUNDworld 基表不存在)/ PBL_E_DUPLICATE同团队重复绑定
debug('pbl_team_bind_world.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
_ext = params_kw.get('ext')
if isinstance(_ext, str) and _ext.strip():
try:
_ext = json.loads(_ext)
except Exception:
pass
try:
_data = await pbl_bind_team_to_world(
params_kw.get('world_id'),
params_kw.get('team_id'),
params_kw.get('tenant_id'),
class_id=params_kw.get('class_id'),
blueprint_id=params_kw.get('blueprint_id'),
ext=_ext,
)
debug('pbl_team_bind_world.dspy: OK world_id=%s team_id=%s'
% (params_kw.get('world_id'), params_kw.get('team_id')))
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_team_bind_world.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,23 @@
# api/pbl_team_list_by_class.dspy — 契约端点list_teams_by_class设计 §3.3 接口 11
# 实现pbl_domain_ext/api.py:list_teams_by_classenv.pbl_list_teams_by_class
# 出参list[{team_id,members[],ref_ids[],world_ids[],scene_ids[],entity_ids[]}]US-13 共享世界分组)
debug('pbl_team_list_by_class.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
try:
_data = await pbl_list_teams_by_class(
params_kw.get('class_id'),
params_kw.get('tenant_id'),
)
debug('pbl_team_list_by_class.dspy: OK teams=%s' % len(_data or []))
return {'success': True, 'error_code': None, 'message': 'ok',
'data': {'items': _data, 'total': len(_data or [])}}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_team_list_by_class.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,23 @@
# api/pbl_team_world_list.dspy — 契约端点get_team_worlds设计 §3.3 接口 13
# 实现pbl_domain_ext/api.py:get_team_worldsenv.pbl_get_team_worlds
# 出参list[world+ref](仅本租户 + 本团队绑定的 world
debug('pbl_team_world_list.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
try:
_data = await pbl_get_team_worlds(
params_kw.get('team_id'),
params_kw.get('tenant_id'),
)
debug('pbl_team_world_list.dspy: OK count=%s' % len(_data or []))
return {'success': True, 'error_code': None, 'message': 'ok',
'data': {'items': _data, 'total': len(_data or [])}}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_team_world_list.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,22 @@
# api/pbl_world_get_context.dspy — 契约端点get_world_with_pbl_context设计 §3.2 接口 9
# 实现pbl_domain_ext/api.py:get_world_with_pbl_contextenv.pbl_get_world_with_pbl_context
# 出参world 基表字段 + pbl_context{tenant_id,blueprint_id,class_id,team_id,ext}
debug('pbl_world_get_context.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
try:
_data = await pbl_get_world_with_pbl_context(
params_kw.get('world_id'),
params_kw.get('tenant_id'),
)
debug('pbl_world_get_context.dspy: OK world_id=%s' % params_kw.get('world_id'))
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_world_get_context.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -0,0 +1,36 @@
# api/pbl_world_list_by_tenant.dspy — 契约端点list_worlds_by_tenant设计 §3.2 接口 6
# 实现pbl_domain_ext/api.py:list_worlds_by_tenantenv.pbl_list_worlds_by_tenant
# 隔离逻辑:基表 world 无租户列 → 先查 pbl_domain_ref 白名单再只读回查基表
debug('pbl_world_list_by_tenant.dspy: START params_kw=%s' % dict(params_kw))
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
_filters = params_kw.get('filters')
if isinstance(_filters, str) and _filters.strip():
try:
_filters = json.loads(_filters)
except Exception:
_filters = None
if not isinstance(_filters, dict):
_filters = {}
for _k in ('blueprint_id', 'team_id'):
if _k not in _filters and params_kw.get(_k) not in (None, ''):
_filters[_k] = params_kw.get(_k)
try:
_data = await pbl_list_worlds_by_tenant(
params_kw.get('tenant_id'),
class_id=params_kw.get('class_id'),
filters=_filters,
)
debug('pbl_world_list_by_tenant.dspy: OK count=%s' % len(_data or []))
return {'success': True, 'error_code': None, 'message': 'ok',
'data': {'items': _data, 'total': len(_data or [])}}
except Exception as exc:
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
_msg = getattr(exc, 'message', None) or str(exc)
_detail = getattr(exc, 'detail', None) or {}
debug('pbl_world_list_by_tenant.dspy: FAIL code=%s msg=%s' % (_code, _msg))
return {'success': False, 'error_code': _code, 'message': _msg,
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}

View File

@ -1,203 +1,20 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "20px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "world/scene/entity 薄扩展:租户/班级/团队关联M8不改基表",
"fontSize": "24px"
}
},
{
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "250px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pbl_domain_ext_content",
"options": {
"url": "{{entire_url('api/pbl_tenant_upsert.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_tenant_upsert"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pbl_domain_ext_content",
"options": {
"url": "{{entire_url('api/pbl_class_save.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_class_save"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pbl_domain_ext_content",
"options": {
"url": "{{entire_url('api/pbl_class_list.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_class_list"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pbl_domain_ext_content",
"options": {
"url": "{{entire_url('api/pbl_team_save.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_team_save"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pbl_domain_ext_content",
"options": {
"url": "{{entire_url('api/pbl_team_list.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_team_list"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pbl_domain_ext_content",
"options": {
"url": "{{entire_url('api/pbl_domain_materialize_game_definition.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_domain_materialize_game_definition"
}
}
]
}
]
},
{
"widgettype": "VBox",
"id": "pbl_domain_ext_content",
"options": {
"width": "100%",
"flex": "1",
"marginTop": "20px"
}
}
]
}
{"widgettype":"VBox","options":{"width":"100%","height":"100%","padding":"20px","backgroundColor":"#F5F7FA"},"subwidgets":[
{"widgettype":"Text","options":{"label":"PBL 域扩展M8— world / scene / entity 薄扩展","fontSize":"24px","fontWeight":"bold"}},
{"widgettype":"Text","options":{"label":"自有表 1 张pbl_domain_refUNIQUE(tenant_id,ref_type,ref_id));复用基表 world/scene/entity 零修改、只读投影。契约 13 个(设计 §3.1/§3.2/§3.3)。","fontSize":"13px","color":"#666666","marginTop":"6px"}},
{"widgettype":"ResponsableBox","options":{"gap":"16px","minWidth":"260px","marginTop":"20px"},"subwidgets":[
{"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer","borderRadius":"8px"},
"binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.pbl_domain_ext_content","options":{"url":"{{entire_url('api/pbl_domain_ref_list.dspy')}}"},"mode":"replace"}],
"subwidgets":[
{"widgettype":"Text","options":{"label":"关联管理§3.1","fontSize":"16px","fontWeight":"bold"}},
{"widgettype":"Text","options":{"label":"bind_ref / unbind_ref / get_ref / list_refs / update_ref","fontSize":"12px","color":"#666666","marginTop":"6px"}}]},
{"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer","borderRadius":"8px"},
"binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.pbl_domain_ext_content","options":{"url":"{{entire_url('api/pbl_world_list_by_tenant.dspy')}}"},"mode":"replace"}],
"subwidgets":[
{"widgettype":"Text","options":{"label":"租户隔离查询§3.2","fontSize":"16px","fontWeight":"bold"}},
{"widgettype":"Text","options":{"label":"list_worlds_by_tenant / list_scenes_by_world / list_entities_by_scene / get_world_with_pbl_context / check_ref_access","fontSize":"12px","color":"#666666","marginTop":"6px"}}]},
{"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer","borderRadius":"8px"},
"binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.pbl_domain_ext_content","options":{"url":"{{entire_url('api/pbl_team_list_by_class.dspy')}}"},"mode":"replace"}],
"subwidgets":[
{"widgettype":"Text","options":{"label":"团队/班级维度§3.3","fontSize":"16px","fontWeight":"bold"}},
{"widgettype":"Text","options":{"label":"list_teams_by_class / bind_team_to_world / get_team_worlds","fontSize":"12px","color":"#666666","marginTop":"6px"}}]}]},
{"widgettype":"VBox","id":"pbl_domain_ext_content","options":{"width":"100%","flex":"1","marginTop":"20px","backgroundColor":"#FFFFFF","padding":"16px","borderRadius":"8px"}}]}