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