9.7 KiB
9.7 KiB
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. 铁律(违反即退回)
- 不改基表:world/scene/entity 零 ALTER、零写入(只 SELECT)。禁止给基表加
tenant_id列——租户维度只落pbl_domain_ref。 - 不新增表:自有表恒为 1 张。
pbl_tenant/pbl_class/pbl_team属 pbl_governance,本模块只读引用,不得出现在OWN_TABLES/models/。 - OWN_TABLES ↔ models/ 一一对应:加/减表必须同步
init.py:OWN_TABLES、models/*.json、sql/*.sql、pbls_spec.json表总账。 - 三处同步注册:新增契约函数必须同时改 ①
api.py实现 ②__init__.py导入 ③init.py:load_pbl_domain_ext里env.xxx = xxx。漏 ② → ImportError;漏 ③ → dspyNameError。 - dspy 端点齐备:每个契约必须有
wwwroot/api/<name>.dspy薄封装(禁 import、显式return、debug()带文件名前缀、转发全部客户端参数不硬编码)。 - RBAC 显式注册:新增 .dspy/.ui 必须在
scripts/load_path.py的API_PATHS/UI_PATHS/ROLE_GRANTS三处登记,禁通配符,否则上线 403。 - 只用 sqlor 标准 API:
sor.C/U/D/R/I/sqlExe。禁编造save/list/insert/query/delete。 - 库名不硬编码:
ServerEnv().get_module_dbname('pbl_domain_ext')(见db.py:resolve_dbname)。 - SQL 占位符:模块内一律 sqlor 风格
${name}$;sqlite 测试适配器自动转:name(db.to_named_params)。
5. 陷阱(踩过的坑)
return不能写在async with块内 → 静默返回 None。db.SqlorAdapter.query/execute先在块内收集结果,退出块后再 return。- 基表列名不要猜:
base.resolve_column运行时探测(MySQLinformation_schema→ 退化 sqlitePRAGMA 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. 挂载与验证
# 应用入口 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 / 宿主):
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 过滤 |