pbl_domain_ext/docs/work-log-2026-09-18.md

104 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 工作日志 — pbl_domain_ext(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_<name>` 与 `env.<name>` 双名;新增 `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 兼容 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 收口核验」段为准)。
## 6. 测试执行证据(QC 历史 #3:仅 py_compile 无执行记录 → 本轮补实测)
- 执行命令:`python3 -m unittest discover -s tests -p 'test_*.py' -v`(环境无 pytest,site-packages 只读无法安装;unittest 为标准库等价执行器,用例本身即 unittest.TestCase,非降级替代)
- 实测结果:**Ran 62 tests — OK(0 failed / 0 error)**,完整逐条输出落盘 `docs/test-run-2026-09-18.txt`
- 覆盖维度(对应 QC 要求的「租户 / 权限 / 正常 / 异常」四类用例):
| 用例类 | 覆盖点 |
|---|---|
| `TestBindRef` | bind_ref 正常绑定、重复绑定 `PBL_E_DUPLICATE`、基表记录不存在 `PBL_E_NOT_FOUND`、ref_type 非法 `PBL_E_VALIDATION`、ext_json JSON 串与 dict 双形态 |
| `TestUnbindGetUpdate` | unbind 软删(is_deleted=1)、unbind 不存在、get_ref 正常、get_ref **跨租户返回 NOT_FOUND(不泄露存在性)**、update_ref 字段白名单 |
| `TestListRefs` | filters 组合(ref_type/blueprint_id/class_id/team_id)、分页 total/items、悬挂 ref 左连接过滤 |
| `TestTenantIsolationQueries` | list_worlds_by_tenant 仅返回本租户绑定、无绑定返回空、按 class 过滤、**list_scenes 跨租户 `PBL_E_FORBIDDEN`**、仅绑定 scene 可见、悬挂过滤 |
| `TestTeamContracts` | list_teams_by_class 成员聚合、bind_team_to_world 重复/不存在、get_team_worlds |
| `TestCheckRefAccess` | 租户匹配、班级/团队维度匹配、跨租户拒绝 |
| `TestThinExtensionInvariants` | **13 契约全部 callable**、contract_map 覆盖全部 dspy 文件、load 注册 env 函数、包导出与 init 一致、**OWN_TABLES 单表且与 models/ 一一对应**、模型四段式(summary 为数组 + UNIQUE 三元组)、**load_path 注册全部 dspy 且无通配符**、基表零 ALTER(只读投影) |
- 机械核验:`py_compile` 全部 .py = OK;dspy 审计(`grep '^import|^from' wwwroot/ --include='*.dspy'`)= 0 命中;13 个 dspy 均含显式 `return`;import 闭包实测 `CONTRACT_INTERFACES=13 missing=[]`、包 `__all__` 全可取;`grep pbl_domain_materialize_game_definition ../pbl_compiler/` = 0 命中(历史 #1 陈旧引用已不存在)。
## 7. 当前分支/提交状态
- 分支:`main`;本轮改动由引擎在交付收口时统一 commit(开发侧不自行 push)。
- 本轮工作树新增:`docs/test-run-2026-09-18.txt`(测试执行原始输出)+ 本日志 §6/§7 追加。