From 3e77958cd218dda390ad43d8bdb8c023ae99b7b3 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Fri, 18 Sep 2026 12:12:52 +0800 Subject: [PATCH] =?UTF-8?q?deliver:=20=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E=E4=BB=A3=E4=B8=BA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 76 ++- docs/work-log-2026-09-18.md | 82 +++ json/pbl_domain_ref.json | 29 + pbl_domain_ext/api.py | 6 +- pyproject.toml | 25 +- scripts/load_path.py | 141 +++-- skill/SKILL.md | 141 ++++- tests/fake_db.py | 262 ++++----- tests/test_domain_ref.py | 528 +++++++++++++++++++ wwwroot/api/pbl_domain_ref_check_access.dspy | 24 + wwwroot/api/pbl_domain_ref_list.dspy | 37 ++ wwwroot/api/pbl_domain_ref_update.dspy | 36 ++ wwwroot/api/pbl_entity_list_by_scene.dspy | 23 + wwwroot/api/pbl_scene_list_by_world.dspy | 23 + wwwroot/api/pbl_team_bind_world.dspy | 34 ++ wwwroot/api/pbl_team_list_by_class.dspy | 23 + wwwroot/api/pbl_team_world_list.dspy | 23 + wwwroot/api/pbl_world_get_context.dspy | 22 + wwwroot/api/pbl_world_list_by_tenant.dspy | 36 ++ wwwroot/index.ui | 223 +------- 20 files changed, 1361 insertions(+), 433 deletions(-) create mode 100644 docs/work-log-2026-09-18.md create mode 100644 json/pbl_domain_ref.json create mode 100644 tests/test_domain_ref.py create mode 100644 wwwroot/api/pbl_domain_ref_check_access.dspy create mode 100644 wwwroot/api/pbl_domain_ref_list.dspy create mode 100644 wwwroot/api/pbl_domain_ref_update.dspy create mode 100644 wwwroot/api/pbl_entity_list_by_scene.dspy create mode 100644 wwwroot/api/pbl_scene_list_by_world.dspy create mode 100644 wwwroot/api/pbl_team_bind_world.dspy create mode 100644 wwwroot/api/pbl_team_list_by_class.dspy create mode 100644 wwwroot/api/pbl_team_world_list.dspy create mode 100644 wwwroot/api/pbl_world_get_context.dspy create mode 100644 wwwroot/api/pbl_world_list_by_tenant.dspy diff --git a/README.md b/README.md index 0fc0a2c..c89ec1d 100644 --- a/README.md +++ b/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 退回逐条整改记录)。 diff --git a/docs/work-log-2026-09-18.md b/docs/work-log-2026-09-18.md new file mode 100644 index 0000000..635a585 --- /dev/null +++ b/docs/work-log-2026-09-18.md @@ -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_` 与 `env.` 双名;新增 `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 收口核验」段为准)。 diff --git a/json/pbl_domain_ref.json b/json/pbl_domain_ref.json new file mode 100644 index 0000000..9bf6291 --- /dev/null +++ b/json/pbl_domain_ref.json @@ -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 零修改、只读。" + } +} diff --git a/pbl_domain_ext/api.py b/pbl_domain_ext/api.py index 213a580..c42aa1a 100644 --- a/pbl_domain_ext/api.py +++ b/pbl_domain_ext/api.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 97c19ef..f1de77b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/load_path.py b/scripts/load_path.py index 2cf5d6d..749ac04 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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 全部路径显式注册、无通配符、角色授权齐备') diff --git a/skill/SKILL.md b/skill/SKILL.md index 658f705..9c248a2 100644 --- a/skill/SKILL.md +++ b/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/.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/.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 过滤 | diff --git a/tests/fake_db.py b/tests/fake_db.py index 1bc92f4..9878aab 100644 --- a/tests/fake_db.py +++ b/tests/fake_db.py @@ -1,186 +1,122 @@ -"""内存版 DbPort 实现 + 受限 SQL 解释器(仅供离线单测,不进生产路径)。 +"""tests/fake_db.py — 离线测试夹具:sqlite3 承载 pbl_domain_ref + 三张复用基表只读投影。 -支持 assoc.py 生成的全部 SQL 形态: -- SELECT 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.+?)\s+FROM\s+(?P\w+)(?P\s+WHERE\s+.+?)?" - r"(?:\s+ORDER\s+BY\s+(?P\w+))?(?:\s+LIMIT\s+(?P\d+))?" - r"(?:\s+OFFSET\s+(?P\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
\w+)\s*\((?P[^)]*)\)\s*VALUES\s*\((?P[^)]*)\)\s*$", - re.IGNORECASE | re.DOTALL, -) -_UPDATE_RE = re.compile( - r"^UPDATE\s+(?P
\w+)\s+SET\s+(?P.+?)\s+WHERE\s+(?P.+?)\s*$", - re.IGNORECASE | re.DOTALL, -) -_DELETE_RE = re.compile( - r"^DELETE\s+FROM\s+(?P
\w+)\s+WHERE\s+(?P.+?)\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 diff --git a/tests/test_domain_ref.py b/tests/test_domain_ref.py new file mode 100644 index 0000000..2d4ac92 --- /dev/null +++ b/tests/test_domain_ref.py @@ -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) diff --git a/wwwroot/api/pbl_domain_ref_check_access.dspy b/wwwroot/api/pbl_domain_ref_check_access.dspy new file mode 100644 index 0000000..bfecebb --- /dev/null +++ b/wwwroot/api/pbl_domain_ref_check_access.dspy @@ -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} diff --git a/wwwroot/api/pbl_domain_ref_list.dspy b/wwwroot/api/pbl_domain_ref_list.dspy new file mode 100644 index 0000000..483b2e4 --- /dev/null +++ b/wwwroot/api/pbl_domain_ref_list.dspy @@ -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)} diff --git a/wwwroot/api/pbl_domain_ref_update.dspy b/wwwroot/api/pbl_domain_ref_update.dspy new file mode 100644 index 0000000..c75016a --- /dev/null +++ b/wwwroot/api/pbl_domain_ref_update.dspy @@ -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)} diff --git a/wwwroot/api/pbl_entity_list_by_scene.dspy b/wwwroot/api/pbl_entity_list_by_scene.dspy new file mode 100644 index 0000000..0c79679 --- /dev/null +++ b/wwwroot/api/pbl_entity_list_by_scene.dspy @@ -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)} diff --git a/wwwroot/api/pbl_scene_list_by_world.dspy b/wwwroot/api/pbl_scene_list_by_world.dspy new file mode 100644 index 0000000..1af8083 --- /dev/null +++ b/wwwroot/api/pbl_scene_list_by_world.dspy @@ -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)} diff --git a/wwwroot/api/pbl_team_bind_world.dspy b/wwwroot/api/pbl_team_bind_world.dspy new file mode 100644 index 0000000..f28bf12 --- /dev/null +++ b/wwwroot/api/pbl_team_bind_world.dspy @@ -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)} diff --git a/wwwroot/api/pbl_team_list_by_class.dspy b/wwwroot/api/pbl_team_list_by_class.dspy new file mode 100644 index 0000000..330364a --- /dev/null +++ b/wwwroot/api/pbl_team_list_by_class.dspy @@ -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)} diff --git a/wwwroot/api/pbl_team_world_list.dspy b/wwwroot/api/pbl_team_world_list.dspy new file mode 100644 index 0000000..dfd8b83 --- /dev/null +++ b/wwwroot/api/pbl_team_world_list.dspy @@ -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)} diff --git a/wwwroot/api/pbl_world_get_context.dspy b/wwwroot/api/pbl_world_get_context.dspy new file mode 100644 index 0000000..1517835 --- /dev/null +++ b/wwwroot/api/pbl_world_get_context.dspy @@ -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)} diff --git a/wwwroot/api/pbl_world_list_by_tenant.dspy b/wwwroot/api/pbl_world_list_by_tenant.dspy new file mode 100644 index 0000000..3791df6 --- /dev/null +++ b/wwwroot/api/pbl_world_list_by_tenant.dspy @@ -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)} diff --git a/wwwroot/index.ui b/wwwroot/index.ui index cd22709..76acc8f 100644 --- a/wwwroot/index.ui +++ b/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"}}]}