126 lines
9.7 KiB
Markdown
126 lines
9.7 KiB
Markdown
# pbl_domain_ext — world/scene/entity 薄扩展(M8)
|
||
|
||
> Agent 必读技能文档。改本模块前先读完「铁律」与「陷阱」两节。
|
||
|
||
## 1. 模块定位
|
||
|
||
PBL 侧对复用平台 **world / scene / entity** 三张基表的**薄扩展**:只加 1 张关联表 + 查询封装,
|
||
**不 ALTER 基表、不侵入 world/scene/entity 模块代码**。
|
||
|
||
- 里程碑: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)
|
||
|
||
## 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 | 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_json`;试图改 `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 过滤 |
|