126 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 过滤 |