deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
33a50d2c94
commit
3e77958cd2
76
README.md
76
README.md
@ -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 退回逐条整改记录)。
|
||||
|
||||
82
docs/work-log-2026-09-18.md
Normal file
82
docs/work-log-2026-09-18.md
Normal file
@ -0,0 +1,82 @@
|
||||
# 工作日志 — pbl_domain_ext(M8)QC 退回整改轮 · 2026-09-18
|
||||
|
||||
## 1. 范围与背景
|
||||
|
||||
- 仓库/模块:`modules/pbl_domain_ext`(PBL 基础域薄扩展,M8,Wave 4)
|
||||
- 任务:`[M8] 基础域薄扩展 world/scene/entity`(task_kind=new_dev,PM 派发,带 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` × 3(QC #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
29
json/pbl_domain_ref.json
Normal 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 零修改、只读。"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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 基础域薄扩展(M8):world/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"]
|
||||
|
||||
@ -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_ext(M8)RBAC 路径注册。
|
||||
|
||||
约定:
|
||||
- 路径 = 模块自动路由 `/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 全部路径显式注册、无通配符、角色授权齐备')
|
||||
|
||||
141
skill/SKILL.md
141
skill/SKILL.md
@ -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`
|
||||
- 里程碑:M8(Wave 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_on:world、scene、entity(复用只读)、pbl_governance(班级/团队/成员只读)、pbl_common(租户上下文/错误码)
|
||||
- 被依赖:pbl_scense_ext(前端拉本租户世界列表)、pbl_runtime_ext(共享会话访问权校验)、pbl_compiler(apply 后 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 过滤 |
|
||||
|
||||
262
tests/fake_db.py
262
tests/fake_db.py
@ -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_schema,base.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
528
tests/test_domain_ref.py
Normal 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):
|
||||
"""接口 1:bind_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/5:unbind_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):
|
||||
"""接口 4:list_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):
|
||||
"""接口 10:check_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 端点 %s(QC #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)
|
||||
24
wwwroot/api/pbl_domain_ref_check_access.dspy
Normal file
24
wwwroot/api/pbl_domain_ref_check_access.dspy
Normal 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_access(env.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}
|
||||
37
wwwroot/api/pbl_domain_ref_list.dspy
Normal file
37
wwwroot/api/pbl_domain_ref_list.dspy
Normal file
@ -0,0 +1,37 @@
|
||||
# api/pbl_domain_ref_list.dspy — 契约端点:list_refs(设计 §3.1 接口 4)
|
||||
# 实现:pbl_domain_ext/api.py:list_refs(env.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)}
|
||||
36
wwwroot/api/pbl_domain_ref_update.dspy
Normal file
36
wwwroot/api/pbl_domain_ref_update.dspy
Normal file
@ -0,0 +1,36 @@
|
||||
# api/pbl_domain_ref_update.dspy — 契约端点:update_ref(设计 §3.1 接口 5)
|
||||
# 实现:pbl_domain_ext/api.py:update_ref(env.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)}
|
||||
23
wwwroot/api/pbl_entity_list_by_scene.dspy
Normal file
23
wwwroot/api/pbl_entity_list_by_scene.dspy
Normal 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_scene(env.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)}
|
||||
23
wwwroot/api/pbl_scene_list_by_world.dspy
Normal file
23
wwwroot/api/pbl_scene_list_by_world.dspy
Normal 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_world(env.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)}
|
||||
34
wwwroot/api/pbl_team_bind_world.dspy
Normal file
34
wwwroot/api/pbl_team_bind_world.dspy
Normal 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_world(env.pbl_bind_team_to_world)
|
||||
# 错误分支:PBL_E_NOT_FOUND(world 基表不存在)/ 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)}
|
||||
23
wwwroot/api/pbl_team_list_by_class.dspy
Normal file
23
wwwroot/api/pbl_team_list_by_class.dspy
Normal 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_class(env.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)}
|
||||
23
wwwroot/api/pbl_team_world_list.dspy
Normal file
23
wwwroot/api/pbl_team_world_list.dspy
Normal file
@ -0,0 +1,23 @@
|
||||
# api/pbl_team_world_list.dspy — 契约端点:get_team_worlds(设计 §3.3 接口 13)
|
||||
# 实现:pbl_domain_ext/api.py:get_team_worlds(env.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)}
|
||||
22
wwwroot/api/pbl_world_get_context.dspy
Normal file
22
wwwroot/api/pbl_world_get_context.dspy
Normal 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_context(env.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)}
|
||||
36
wwwroot/api/pbl_world_list_by_tenant.dspy
Normal file
36
wwwroot/api/pbl_world_list_by_tenant.dspy
Normal 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_tenant(env.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)}
|
||||
223
wwwroot/index.ui
223
wwwroot/index.ui
@ -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_ref(UNIQUE(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"}}]}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user