diff --git a/docs/work-log-2026-09-18.md b/docs/work-log-2026-09-18.md index 15b1dd1..a2cc9c6 100644 --- a/docs/work-log-2026-09-18.md +++ b/docs/work-log-2026-09-18.md @@ -15,7 +15,7 @@ | 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 文件真实存在 | +| 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. 本轮产出文件 @@ -88,7 +88,7 @@ - 覆盖维度(对应 QC 要求的「租户 / 权限 / 正常 / 异常」四类用例): | 用例类 | 覆盖点 | |---|---| - | `TestBindRef` | bind_ref 正常绑定、重复绑定 `PBL_E_DUPLICATE`、基表记录不存在 `PBL_E_NOT_FOUND`、ref_type 非法 `PBL_E_VALIDATION`、ext JSON 串与 dict 双形态 | + | `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 可见、悬挂过滤 | diff --git a/json/pbl_domain_ref.json b/json/pbl_domain_ref.json index 9bf6291..341a7eb 100644 --- a/json/pbl_domain_ref.json +++ b/json/pbl_domain_ref.json @@ -1,29 +1,83 @@ { "tblname": "pbl_domain_ref", + "title": "PBL基础域关联(world/scene/entity)", "params": { + "sortby": [ + "created_at desc" + ], + "data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_list.dspy')}}", + "data_filter": { + "AND": [ + { + "field": "tenant_id", + "op": "=", + "var": "tenant_id" + }, + { + "field": "ref_type", + "op": "=", + "var": "ref_type" + }, + { + "field": "class_id", + "op": "=", + "var": "class_id" + }, + { + "field": "team_id", + "op": "=", + "var": "team_id" + } + ] + }, + "filter_labels": { + "tenant_id": "租户", + "ref_type": "对象类型", + "class_id": "班级", + "team_id": "团队" + }, + "filter_label": "搜索", + "filter_title": "关联记录筛选", + "confidential_fields": [ + "ext_json" + ], "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} + "exclouded": [ + "id", + "updated_by", + "updated_at" + ], + "alters": { + "ref_type": { + "uitype": "code", + "data": [ + { + "value": "world", + "text": "世界" + }, + { + "value": "scene", + "text": "场景" + }, + { + "value": "entity", + "text": "实体" + } + ] + } + } }, + "editexclouded": [ + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at" + ], "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 零修改、只读。" + "new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}", + "update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}", + "delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}" + } } } diff --git a/models/pbl_domain_ref.json b/models/pbl_domain_ref.json index f17042a..6db7816 100644 --- a/models/pbl_domain_ref.json +++ b/models/pbl_domain_ref.json @@ -1,42 +1,144 @@ { "summary": [ - "pbl_domain_ref", - "world/scene/entity 薄扩展关联表(M8,不改基表)", - "id", - "记录复用平台 world/scene/entity 基表记录与 PBL 租户/蓝图/班级/团队的关联;基表无 tenant_id,PBL 多租户隔离由本表补齐。UNIQUE(tenant_id,ref_type,ref_id)。权威 DDL:data-model.md §J1;表总账:pbls_spec.json tables_by_module.pbl_domain_ext=1、tables_total=36。" + { + "name": "pbl_domain_ref", + "title": "PBL基础域薄扩展关联表(world/scene/entity)", + "primary": [ + "id" + ], + "catelog": "relation" + } ], "fields": [ - {"name": "id", "type": "bigint", "primary": true, "autoincr": true, "notnull": true, "comment": "主键"}, - {"name": "tenant_id", "type": "varchar(64)", "notnull": true, "comment": "租户ID(PBL 多租户隔离维度,基表无此列)"}, - {"name": "ref_type", "type": "varchar(32)", "notnull": true, "comment": "appcodes:pbl_domain_ref_type(world/scene/entity)"}, - {"name": "ref_id", "type": "bigint", "notnull": true, "comment": "复用基表记录ID(world.id / scene.id / entity.id)"}, - {"name": "blueprint_id", "type": "varchar(64)", "notnull": false, "comment": "关联蓝图ID(pbl_compiler apply_game_definition 落库后回写)"}, - {"name": "class_id", "type": "varchar(64)", "notnull": false, "comment": "关联教学班ID(pbl_governance.pbl_class)"}, - {"name": "team_id", "type": "varchar(64)", "notnull": false, "comment": "关联团队ID(pbl_governance.pbl_team,共享世界成员分组)"}, - {"name": "ext", "type": "json", "notnull": false, "comment": "扩展属性(JSON,薄扩展附加维度,禁止改基表结构)"}, - {"name": "is_deleted", "type": "tinyint", "notnull": true, "default": "0", "comment": "软删标记:0=有效 1=已解绑(unbind_ref 置 1)"}, - {"name": "created_at", "type": "timestamp", "notnull": true, "default": "CURRENT_TIMESTAMP", "comment": "创建时间"}, - {"name": "updated_at", "type": "timestamp", "notnull": true, "default": "CURRENT_TIMESTAMP", "comment": "更新时间(ON UPDATE CURRENT_TIMESTAMP)"} + { + "name": "id", + "title": "主键", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "tenant_id", + "title": "租户ID(打头)", + "type": "str", + "length": 64, + "nullable": "no" + }, + { + "name": "ref_type", + "title": "关联对象类型(world/scene/entity)", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "ref_id", + "title": "基表记录ID(world.id/scene.id/entity.id)", + "type": "long", + "nullable": "no" + }, + { + "name": "blueprint_id", + "title": "关联蓝图ID", + "type": "long" + }, + { + "name": "class_id", + "title": "班级ID", + "type": "str", + "length": 64, + "nullable": "no", + "default": "" + }, + { + "name": "team_id", + "title": "团队ID", + "type": "str", + "length": 64, + "nullable": "no", + "default": "" + }, + { + "name": "ext_json", + "title": "扩展JSON(租户/班级/团队关联属性)", + "type": "text" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_by", + "title": "更新人", + "type": "str", + "length": 64 + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } ], "indexes": [ - {"name": "PRIMARY", "fields": ["id"], "unique": true, "primary": true}, - {"name": "uk_pbl_domain_ref", "fields": ["tenant_id", "ref_type", "ref_id"], "unique": true, "comment": "设计 §2:UNIQUE(tenant_id,ref_type,ref_id)"}, - {"name": "idx_pbl_domain_ref_bp", "fields": ["tenant_id", "blueprint_id"], "unique": false, "comment": "按蓝图查关联(compiler/analytics)"}, - {"name": "idx_pbl_domain_ref_class", "fields": ["tenant_id", "class_id"], "unique": false, "comment": "按教学班查关联(F-AS-03 班级维度)"}, - {"name": "idx_pbl_domain_ref_team", "fields": ["tenant_id", "team_id"], "unique": false, "comment": "按团队查关联(US-13 共享世界分组)"}, - {"name": "idx_pbl_domain_ref_type", "fields": ["ref_type", "ref_id"], "unique": false, "comment": "悬挂引用一致性检查(左连接基表)"} + { + "name": "uk_pbl_domain_ref", + "idxtype": "unique", + "idxfields": [ + "tenant_id", + "ref_type", + "ref_id", + "class_id", + "team_id" + ] + }, + { + "name": "idx_pbl_domain_ref_tenant_type", + "idxtype": "index", + "idxfields": [ + "tenant_id", + "ref_type" + ] + }, + { + "name": "idx_pbl_domain_ref_blueprint", + "idxtype": "index", + "idxfields": [ + "tenant_id", + "blueprint_id" + ] + }, + { + "name": "idx_pbl_domain_ref_class", + "idxtype": "index", + "idxfields": [ + "tenant_id", + "class_id" + ] + }, + { + "name": "idx_pbl_domain_ref_team", + "idxtype": "index", + "idxfields": [ + "tenant_id", + "team_id" + ] + } ], "codes": [ { - "code": "pbl_domain_ref_type", - "name": "域扩展关联类型", - "owner": "pbl_appcodes", - "items": [ - {"value": "world", "label": "世界(复用基表 world)"}, - {"value": "scene", "label": "场景(复用基表 scene)"}, - {"value": "entity", "label": "实体(复用基表 entity)"} - ], - "note": "编码由 pbl_appcodes 模块幂等注入,本模块只读引用,不重复写入编码表" + "field": "ref_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='pbl_domain_ref_type'" } ] } diff --git a/pbl_domain_ext/__init__.py b/pbl_domain_ext/__init__.py index 3a9df6c..68df474 100644 --- a/pbl_domain_ext/__init__.py +++ b/pbl_domain_ext/__init__.py @@ -1,34 +1,42 @@ -"""pbl_domain_ext — world/scene/entity 薄扩展(M8,不改基表)。 +# -*- coding: utf-8 -*- +"""pbl_domain_ext —— [M8] 基础域薄扩展 world/scene/entity。 -包导出(三处同步注册之 ②):init.py 中定义的 load_pbl_domain_ext 与 -api.py 中 13 个契约函数必须在此导入,否则 .dspy 调用报 NameError。 +只新增 1 张关联表 ``pbl_domain_ref``,以旁挂方式把 world/scene/entity 三张 +基础域基表接入 PBL 蓝图/团队/班级;**不改基表结构、不重写基础模块本体**(Q-OPEN-3)。 + +用法:: + + from pbl_domain_ext import load_pbl_domain_ext + info = load_pbl_domain_ext(app) + + from pbl_domain_ext.api import pbl_domain_ref_bind + pbl_domain_ref_bind({"tenant_id": "T1", "ref_type": "world", + "ref_id": "W1", "blueprint_id": "BP1"}) """ -from .api import (CONTRACT_INTERFACES, bind_ref, bind_team_to_world, - check_ref_access, get_ref, get_team_worlds, - get_world_with_pbl_context, list_entities_by_scene, list_refs, - list_scenes_by_world, list_teams_by_class, - list_worlds_by_tenant, unbind_ref, update_ref) -from .db import (SqliteAdapter, SqlorAdapter, execute, query, set_adapter, - set_dbname, to_named_params) -from .errors import (CODE_HTTP, E_DUPLICATE, E_FORBIDDEN, E_INTERNAL, - E_NOT_FOUND, E_VALIDATION, PblError, fail, is_pbl_error, ok) -from .init import (MODULE_NAME, OWN_TABLES, READONLY_BASE_TABLES, - READONLY_FOREIGN_TABLES, get_contract_map, - load_pbl_domain_ext) +from .api import ( # noqa: F401 + pbl_domain_ref_bind, pbl_domain_ref_unbind, pbl_domain_ref_update, + pbl_domain_ref_get, pbl_domain_ref_list, pbl_domain_ref_check_access, + pbl_world_list_by_tenant, pbl_world_get_context, + pbl_scene_list_by_world, pbl_entity_list_by_scene, + pbl_team_bind_world, pbl_team_world_list, pbl_team_list_by_class, + pbl_domain_materialize_game_definition, dispatch, ok, fail, +) +from .base import TABLE, EXT_FIELD, REF_TYPES, BIND_STATES, BASE_TABLES # noqa: F401 +from .errors import PblDomainExtError, ERRORS # noqa: F401 +from .init import load_pbl_domain_ext, MODULE_NAME, API_ROUTES, PERMISSIONS # noqa: F401 -__version__ = '1.0.0' +__version__ = "1.1.0" __all__ = [ - 'load_pbl_domain_ext', 'get_contract_map', 'MODULE_NAME', 'OWN_TABLES', - 'READONLY_BASE_TABLES', 'READONLY_FOREIGN_TABLES', 'CONTRACT_INTERFACES', - 'bind_ref', 'unbind_ref', 'get_ref', 'list_refs', 'update_ref', - 'list_worlds_by_tenant', 'list_scenes_by_world', 'list_entities_by_scene', - 'get_world_with_pbl_context', 'check_ref_access', 'list_teams_by_class', - 'bind_team_to_world', 'get_team_worlds', - 'query', 'execute', 'set_adapter', 'set_dbname', 'to_named_params', - 'SqlorAdapter', 'SqliteAdapter', - 'ok', 'fail', 'PblError', 'is_pbl_error', 'CODE_HTTP', - 'E_VALIDATION', 'E_NOT_FOUND', 'E_DUPLICATE', 'E_FORBIDDEN', 'E_INTERNAL', - '__version__', + "load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS", + "TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES", "BASE_TABLES", + "PblDomainExtError", "ERRORS", "dispatch", "ok", "fail", + "pbl_domain_ref_bind", "pbl_domain_ref_unbind", "pbl_domain_ref_update", + "pbl_domain_ref_get", "pbl_domain_ref_list", "pbl_domain_ref_check_access", + "pbl_world_list_by_tenant", "pbl_world_get_context", + "pbl_scene_list_by_world", "pbl_entity_list_by_scene", + "pbl_team_bind_world", "pbl_team_world_list", "pbl_team_list_by_class", + "pbl_domain_materialize_game_definition", + "__version__", ] diff --git a/pbl_domain_ext/api.py b/pbl_domain_ext/api.py index c42aa1a..639910d 100644 --- a/pbl_domain_ext/api.py +++ b/pbl_domain_ext/api.py @@ -1,632 +1,650 @@ -"""pbl_domain_ext.api — 设计 §3 契约接口实现(13 个,M8 薄扩展)。 +# -*- coding: utf-8 -*- +"""pbl_domain_ext 契约接口层([M8] 基础域薄扩展 world/scene/entity)。 -接口清单与代码位置映射(QC #2 要求逐条可追溯): - §3.1 关联管理 - 1 bind_ref → api.py:bind_ref - 2 unbind_ref → api.py:unbind_ref - 3 get_ref → api.py:get_ref - 4 list_refs → api.py:list_refs - 5 update_ref → api.py:update_ref - §3.2 租户隔离查询封装(基表 + 扩展联合) - 6 list_worlds_by_tenant → api.py:list_worlds_by_tenant - 7 list_scenes_by_world → api.py:list_scenes_by_world - 8 list_entities_by_scene → api.py:list_entities_by_scene - 9 get_world_with_pbl_context→ api.py:get_world_with_pbl_context - 10 check_ref_access → api.py:check_ref_access - §3.3 团队/班级维度(共享世界支撑) - 11 list_teams_by_class → api.py:list_teams_by_class - 12 bind_team_to_world → api.py:bind_team_to_world - 13 get_team_worlds → api.py:get_team_worlds +对外 13 个契约函数,与 ``wwwroot/api/*.dspy`` 一一对应: + + 关联表 CRUD/查询(6) + pbl_domain_ref_bind / pbl_domain_ref_unbind / pbl_domain_ref_update + pbl_domain_ref_get / pbl_domain_ref_list / pbl_domain_ref_check_access + 基础域只读视图(4) + pbl_world_list_by_tenant / pbl_world_get_context + pbl_scene_list_by_world / pbl_entity_list_by_scene + 团队-世界绑定(3) + pbl_team_bind_world / pbl_team_world_list / pbl_team_list_by_class + + 编译期物化(1,供 pbl_compiler import 闭包) + pbl_domain_materialize_game_definition 铁律: -- 所有读写 tenant_id 强制打头(缺失且无法从 pbl_common 租户上下文解析 → PBL_E_VALIDATION); -- 对 world/scene/entity 三张复用基表**只读**(零 ALTER、零写入);写入只落 pbl_domain_ref; -- 跨租户访问一律 PBL_E_FORBIDDEN(US-21:403/404),绝不返回他租户数据。 + * 所有函数第一个业务参数是 ``tenant_id``,缺失/非法即 ``PBL_DE_TENANT_MISSING``; + * 只写 ``pbl_domain_ref`` 一张表,world/scene/entity 基表**只读**; + * 扩展字段名统一 ``ext_json``(设计 data-model.md §J1),禁止旧名 ``ext``; + * 返回统一 ``{"success": True, "code": "PBL_DE_OK", "data": ...}``,异常转错误体不裸抛。 """ -import json as _json +from . import db +from .base import (TABLE, BASE_TABLES, REF_TYPES, BIND_STATES, EXT_FIELD, + LIST_FIELDS, gen_id, now_str, require_tenant, check_ref_type, + check_bind_state, normalize_ext_json, audit_on_create, + audit_on_update, page_args, as_text, as_list) +from .errors import PblDomainExtError, err_payload -from . import base as _base -from . import db as _db -from .errors import (E_DUPLICATE, E_FORBIDDEN, E_NOT_FOUND, E_VALIDATION, - raise_error) +__all__ = [ + "TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES", + "pbl_domain_ref_bind", "pbl_domain_ref_unbind", "pbl_domain_ref_update", + "pbl_domain_ref_get", "pbl_domain_ref_list", "pbl_domain_ref_check_access", + "pbl_world_list_by_tenant", "pbl_world_get_context", + "pbl_scene_list_by_world", "pbl_entity_list_by_scene", + "pbl_team_bind_world", "pbl_team_world_list", "pbl_team_list_by_class", + "pbl_domain_materialize_game_definition", + "ok", "fail", "dispatch", +] -REF_TYPE_WORLD = 'world' -REF_TYPE_SCENE = 'scene' -REF_TYPE_ENTITY = 'entity' -VALID_REF_TYPES = (REF_TYPE_WORLD, REF_TYPE_SCENE, REF_TYPE_ENTITY) - -TABLE = 'pbl_domain_ref' -MAX_PAGE_SIZE = 200 -DEFAULT_PAGE_SIZE = 20 - -_REF_COLUMNS = ('id', 'tenant_id', 'ref_type', 'ref_id', 'blueprint_id', - 'class_id', 'team_id', 'ext', 'is_deleted', 'created_at', - 'updated_at') -_UPDATABLE_COLUMNS = ('blueprint_id', 'class_id', 'team_id', 'ext') +#: 基表只读视图对外暴露的列(基表列名差异大,取交集常见列,缺失自动跳过) +_BASE_VIEW_FIELDS = ("id", "tenant_id", "code", "name", "title", "status", + "state", "world_id", "scene_id", "parent_id", + "created_at", "updated_at") # -------------------------------------------------------------------------- -# 内部工具 +# 统一响应 # -------------------------------------------------------------------------- -def _resolve_tenant(tenant_id): - """tenant_id 强制打头:显式入参优先,其次 pbl_common 租户上下文。""" - tid = tenant_id if tenant_id not in (None, '') else _tenant_from_context() - if tid in (None, ''): - raise_error(E_VALIDATION, 'tenant_id 缺失(PBL 多租户隔离要求 tenant_id 强制打头)') - return str(tid) +def ok(data=None, **extra): + """成功响应体。""" + payload = {"success": True, "code": "PBL_DE_OK", "message": "成功"} + if data is not None: + payload["data"] = data + payload.update(extra) + return payload -def _tenant_from_context(): - try: - from pbl_common.context import current_tenant_id # type: ignore - return current_tenant_id() - except Exception: - pass - try: - from pbl_common.tenant import get_current_tenant_id # type: ignore - return get_current_tenant_id() - except Exception: - return None +def fail(code, message=None, detail=None): + """失败响应体(不抛异常)。""" + return err_payload(code, message=message, detail=detail) -def _check_ref_type(ref_type): - if ref_type not in VALID_REF_TYPES: - raise_error(E_VALIDATION, - 'ref_type 非法(appcodes:pbl_domain_ref_type 仅允许 world/scene/entity)', - ref_type=ref_type, allowed=list(VALID_REF_TYPES)) - return ref_type - - -def _check_ref_id(ref_id): - try: - rid = int(ref_id) - except (TypeError, ValueError): - raise_error(E_VALIDATION, 'ref_id 必须为整数(复用基表主键)', ref_id=ref_id) - if rid <= 0: - raise_error(E_VALIDATION, 'ref_id 必须为正整数', ref_id=ref_id) - return rid - - -def _dump_ext(ext): - if ext is None: - return None - if isinstance(ext, str): - try: - _json.loads(ext) - return ext - except Exception: - raise_error(E_VALIDATION, 'ext 必须是合法 JSON 字符串或对象', ext=ext[:200]) - try: - return _json.dumps(ext, ensure_ascii=False) - except Exception: - raise_error(E_VALIDATION, 'ext 无法序列化为 JSON', ext_type=str(type(ext))) - - -def _load_ext(value): - if value in (None, ''): - return None - if isinstance(value, (dict, list)): - return value - try: - return _json.loads(value) - except Exception: - return {'_raw': str(value)} - - -def _row_to_dict(row): +def _base_view(row): + """把基表原始行裁剪成只读视图 dict(只保留白名单列,不改基表)。""" out = {} - for col in _REF_COLUMNS: - if col in row: - out[col] = row[col] - out['ext'] = _load_ext(out.get('ext')) - out['is_deleted'] = int(out.get('is_deleted') or 0) - if 'ref_id' in out and out['ref_id'] is not None: - out['ref_id'] = int(out['ref_id']) - return out - - -def _normalize_page(page, size): - try: - page = int(page or 1) - except (TypeError, ValueError): - page = 1 - try: - size = int(size or DEFAULT_PAGE_SIZE) - except (TypeError, ValueError): - size = DEFAULT_PAGE_SIZE - page = page if page > 0 else 1 - size = size if size > 0 else DEFAULT_PAGE_SIZE - if size > MAX_PAGE_SIZE: - size = MAX_PAGE_SIZE - return page, size - - -async def _select_ref(tenant_id, ref_type, ref_id, include_deleted=False): - sql = ("SELECT * FROM %s WHERE tenant_id=${tid}$ AND ref_type=${rtype}$ " - "AND ref_id=${rid}$" % TABLE) - if not include_deleted: - sql += " AND is_deleted=0" - sql += " LIMIT 1" - rows = await _db.query(sql, {'tid': tenant_id, 'rtype': ref_type, 'rid': int(ref_id)}) - return _row_to_dict(rows[0]) if rows else None - - -def _merge_ref_into(row, ref): - """基表行 + 扩展关联 → 联合视图(world+ref / scene+ref / entity+ref)。""" - merged = dict(row or {}) - if ref: - merged['pbl_ref'] = ref - merged['blueprint_id'] = ref.get('blueprint_id') - merged['class_id'] = ref.get('class_id') - merged['team_id'] = ref.get('team_id') - merged['pbl_ext'] = ref.get('ext') - merged['pbl_ref_id'] = ref.get('id') - else: - merged['pbl_ref'] = None - return merged - - -async def _refs_by_ids(tenant_id, ref_type, ref_ids): - ref_ids = [int(i) for i in ref_ids if i is not None] - if not ref_ids: - return {} - marks = ','.join('${r%d}$' % i for i in range(len(ref_ids))) - params = {'tid': tenant_id, 'rtype': ref_type} - for i, rid in enumerate(ref_ids): - params['r%d' % i] = int(rid) - rows = await _db.query( - "SELECT * FROM %s WHERE tenant_id=${tid}$ AND ref_type=${rtype}$ " - "AND is_deleted=0 AND ref_id IN (%s)" % (TABLE, marks), params) - return {int(r['ref_id']): _row_to_dict(r) for r in rows} - - -# -------------------------------------------------------------------------- -# §3.1 关联管理(1~5) -# -------------------------------------------------------------------------- -async def bind_ref(ref_type, ref_id, blueprint_id=None, class_id=None, - team_id=None, ext=None, tenant_id=None): - """[接口 1] 绑定 world/scene/entity 基表记录到 PBL 维度。 - - 错误分支:PBL_E_VALIDATION(ref_type/ref_id/tenant_id 非法)、 - PBL_E_NOT_FOUND(复用基表记录不存在)、PBL_E_DUPLICATE(已绑定)。 - """ - tenant_id = _resolve_tenant(tenant_id) - ref_type = _check_ref_type(ref_type) - rid = _check_ref_id(ref_id) - - if not await _base.base_exists(ref_type, rid): - raise_error(E_NOT_FOUND, '复用基表记录不存在,拒绝绑定(防悬挂引用)', - ref_type=ref_type, ref_id=rid, table=_base.ref_type_to_table(ref_type)) - - existed = await _select_ref(tenant_id, ref_type, rid, include_deleted=True) - if existed and int(existed.get('is_deleted') or 0) == 0: - raise_error(E_DUPLICATE, '该记录已绑定(UNIQUE(tenant_id,ref_type,ref_id))', - ref_type=ref_type, ref_id=rid, tenant_id=tenant_id) - - ext_text = _dump_ext(ext) - if existed: # 软删记录复活(幂等重绑) - await _db.execute( - "UPDATE %s SET is_deleted=0, blueprint_id=${bp}$, class_id=${cid}$, " - "team_id=${tm}$, ext=${ext}$ WHERE id=${id}$" % TABLE, - {'bp': blueprint_id, 'cid': class_id, 'tm': team_id, - 'ext': ext_text, 'id': int(existed['id'])}) - else: - await _db.execute( - "INSERT INTO %s (tenant_id, ref_type, ref_id, blueprint_id, class_id, " - "team_id, ext, is_deleted) VALUES (${tid}$,${rtype}$,${rid}$,${bp}$," - "${cid}$,${tm}$,${ext}$,0)" % TABLE, - {'tid': tenant_id, 'rtype': ref_type, 'rid': rid, 'bp': blueprint_id, - 'cid': class_id, 'tm': team_id, 'ext': ext_text}) - - return await _select_ref(tenant_id, ref_type, rid) - - -async def unbind_ref(ref_type, ref_id, tenant_id=None): - """[接口 2] 解绑(软删,is_deleted=1)。错误分支:PBL_E_NOT_FOUND。""" - tenant_id = _resolve_tenant(tenant_id) - ref_type = _check_ref_type(ref_type) - rid = _check_ref_id(ref_id) - existed = await _select_ref(tenant_id, ref_type, rid) - if not existed: - raise_error(E_NOT_FOUND, '关联记录不存在或已解绑', - ref_type=ref_type, ref_id=rid, tenant_id=tenant_id) - await _db.execute( - "UPDATE %s SET is_deleted=1 WHERE id=${id}$" % TABLE, {'id': int(existed['id'])}) - return True - - -async def get_ref(ref_type, ref_id, tenant_id=None): - """[接口 3] 取单条关联。错误分支:PBL_E_NOT_FOUND。""" - tenant_id = _resolve_tenant(tenant_id) - ref_type = _check_ref_type(ref_type) - rid = _check_ref_id(ref_id) - ref = await _select_ref(tenant_id, ref_type, rid) - if not ref: - raise_error(E_NOT_FOUND, '关联记录不存在', ref_type=ref_type, - ref_id=rid, tenant_id=tenant_id) - return ref - - -async def list_refs(filters=None, page=1, size=DEFAULT_PAGE_SIZE, tenant_id=None): - """[接口 4] 分页列举关联;filters 支持 ref_type/blueprint_id/class_id/team_id/ref_id。 - - 悬挂引用一致性(§4 风险):结果按 ref_type 分组回查基表, - 基表已删除的 ref 标记 dangling=True 并从有效计数中剔除(左连接过滤失效 ref)。 - """ - tenant_id = _resolve_tenant(tenant_id) - filters = dict(filters or {}) - page, size = _normalize_page(page, size) - - where = ["tenant_id=${tid}$", "is_deleted=0"] - params = {'tid': tenant_id} - for key, col in (('ref_type', 'ref_type'), ('blueprint_id', 'blueprint_id'), - ('class_id', 'class_id'), ('team_id', 'team_id'), - ('ref_id', 'ref_id')): - value = filters.get(key) - if value in (None, ''): - continue - if key == 'ref_type': - _check_ref_type(value) - params['f_%s' % col] = value - where.append("%s=${f_%s}$" % (col, col)) - where_sql = ' AND '.join(where) - - count_rows = await _db.query( - "SELECT COUNT(*) AS cnt FROM %s WHERE %s" % (TABLE, where_sql), params) - total = int(count_rows[0].get('cnt') or 0) if count_rows else 0 - - list_params = dict(params) - list_params['lim'] = size - list_params['ofs'] = (page - 1) * size - rows = await _db.query( - "SELECT * FROM %s WHERE %s ORDER BY id DESC LIMIT ${lim}$ OFFSET ${ofs}$" - % (TABLE, where_sql), list_params) - items = [_row_to_dict(r) for r in rows] - - # 悬挂引用标记(不回写基表,只读探测) - by_type = {} - for item in items: - by_type.setdefault(item['ref_type'], []).append(item['ref_id']) - for rtype, ids in by_type.items(): - try: - alive = await _base.filter_existing_ids(rtype, ids) - except Exception: - alive = set(ids) # 基表不可探测时不误判为悬挂 - for item in items: - if item['ref_type'] == rtype: - item['dangling'] = item['ref_id'] not in alive - - return {'items': items, 'total': total, 'page': page, 'size': size, - 'valid_total': sum(1 for i in items if not i.get('dangling'))} - - -async def update_ref(ref_type, ref_id, data=None, tenant_id=None): - """[接口 5] 更新关联(仅 blueprint_id/class_id/team_id/ext 可改)。 - - 错误分支:PBL_E_NOT_FOUND、PBL_E_VALIDATION(试图改 ref_type/ref_id/tenant_id)。 - """ - tenant_id = _resolve_tenant(tenant_id) - ref_type = _check_ref_type(ref_type) - rid = _check_ref_id(ref_id) - data = dict(data or {}) - illegal = [k for k in data if k not in _UPDATABLE_COLUMNS] - if illegal: - raise_error(E_VALIDATION, - '仅允许更新 blueprint_id/class_id/team_id/ext(薄扩展不改基表、不改关联键)', - illegal_fields=illegal, allowed=list(_UPDATABLE_COLUMNS)) - existed = await _select_ref(tenant_id, ref_type, rid) - if not existed: - raise_error(E_NOT_FOUND, '关联记录不存在', ref_type=ref_type, - ref_id=rid, tenant_id=tenant_id) - - sets, params = [], {'id': int(existed['id'])} - for col in _UPDATABLE_COLUMNS: - if col not in data: - continue - value = _dump_ext(data[col]) if col == 'ext' else data[col] - params['u_%s' % col] = value - sets.append("%s=${u_%s}$" % (col, col)) - if sets: - await _db.execute("UPDATE %s SET %s WHERE id=${id}$" % (TABLE, ', '.join(sets)), - params) - return await _select_ref(tenant_id, ref_type, rid) - - -# -------------------------------------------------------------------------- -# §3.2 租户隔离查询封装(6~10) -# -------------------------------------------------------------------------- -async def list_worlds_by_tenant(tenant_id, class_id=None, filters=None): - """[接口 6] 仅返回本租户已绑定的 world(基表 + ref 联合)。 - - 隔离逻辑:基表无租户列 → 先查 pbl_domain_ref 得到本租户 world id 白名单, - 再按白名单只读回查基表;未绑定即不可见(杜绝跨租户访问基表数据)。 - """ - tenant_id = _resolve_tenant(tenant_id) - filters = dict(filters or {}) - where = ["tenant_id=${tid}$", "ref_type=${rtype}$", "is_deleted=0"] - params = {'tid': tenant_id, 'rtype': REF_TYPE_WORLD} - if class_id not in (None, ''): - params['cid'] = class_id - where.append("class_id=${cid}$") - if filters.get('blueprint_id') not in (None, ''): - params['bp'] = filters['blueprint_id'] - where.append("blueprint_id=${bp}$") - if filters.get('team_id') not in (None, ''): - params['tm'] = filters['team_id'] - where.append("team_id=${tm}$") - - rows = await _db.query( - "SELECT * FROM %s WHERE %s ORDER BY id DESC" % (TABLE, ' AND '.join(where)), params) - refs = {int(r['ref_id']): _row_to_dict(r) for r in rows} - if not refs: - return [] - base_rows = await _base.fetch_rows_by_ids(REF_TYPE_WORLD, list(refs.keys())) - out = [] - for rid, ref in refs.items(): - row = base_rows.get(rid) - if not row: - continue # 悬挂引用:基表已删,过滤失效 ref - out.append(_merge_ref_into(row, ref)) - return out - - -async def list_scenes_by_world(world_id, tenant_id): - """[接口 7] 取某 world 下的 scene(基表 + ref 联合)。 - - 错误分支:PBL_E_FORBIDDEN(world 非本租户绑定 → US-21 越权)。 - """ - tenant_id = _resolve_tenant(tenant_id) - wid = _check_ref_id(world_id) - world_ref = await _select_ref(tenant_id, REF_TYPE_WORLD, wid) - if not world_ref: - raise_error(E_FORBIDDEN, 'world 不属于当前租户(跨租户访问被拒)', - ref_type=REF_TYPE_WORLD, ref_id=wid, tenant_id=tenant_id) - - child_rows = await _base.fetch_children(REF_TYPE_SCENE, wid) - refs = await _refs_by_ids(tenant_id, REF_TYPE_SCENE, - [r.get('id') for r in child_rows]) - out = [] - for row in child_rows: - rid = row.get('id') - ref = refs.get(int(rid)) if rid is not None else None - if not ref: - continue # 未绑定到本租户的 scene 一律不可见 - out.append(_merge_ref_into(row, ref)) - return out - - -async def list_entities_by_scene(scene_id, tenant_id): - """[接口 8] 取某 scene 下的 entity(基表 + ref 联合)。 - - 错误分支:PBL_E_FORBIDDEN(scene 非本租户绑定)。 - """ - tenant_id = _resolve_tenant(tenant_id) - sid = _check_ref_id(scene_id) - scene_ref = await _select_ref(tenant_id, REF_TYPE_SCENE, sid) - if not scene_ref: - raise_error(E_FORBIDDEN, 'scene 不属于当前租户(跨租户访问被拒)', - ref_type=REF_TYPE_SCENE, ref_id=sid, tenant_id=tenant_id) - - child_rows = await _base.fetch_children(REF_TYPE_ENTITY, sid) - refs = await _refs_by_ids(tenant_id, REF_TYPE_ENTITY, - [r.get('id') for r in child_rows]) - out = [] - for row in child_rows: - rid = row.get('id') - ref = refs.get(int(rid)) if rid is not None else None - if not ref: - continue - out.append(_merge_ref_into(row, ref)) - return out - - -async def get_world_with_pbl_context(world_id, tenant_id): - """[接口 9] world 基表字段 + PBL 上下文(blueprint_id/class_id/team_id)。 - - 错误分支:PBL_E_NOT_FOUND(基表无此 world)、PBL_E_FORBIDDEN(非本租户绑定)。 - """ - tenant_id = _resolve_tenant(tenant_id) - wid = _check_ref_id(world_id) - ref = await _select_ref(tenant_id, REF_TYPE_WORLD, wid) - if not ref: - raise_error(E_FORBIDDEN, 'world 不属于当前租户(跨租户访问被拒)', - ref_type=REF_TYPE_WORLD, ref_id=wid, tenant_id=tenant_id) - row = await _base.fetch_row_by_id(REF_TYPE_WORLD, wid) - if not row: - raise_error(E_NOT_FOUND, 'world 基表记录不存在(悬挂引用)', - ref_type=REF_TYPE_WORLD, ref_id=wid) - merged = _merge_ref_into(row, ref) - merged['pbl_context'] = { - 'tenant_id': tenant_id, - 'blueprint_id': ref.get('blueprint_id'), - 'class_id': ref.get('class_id'), - 'team_id': ref.get('team_id'), - 'ext': ref.get('ext'), - } - return merged - - -async def check_ref_access(ref_type, ref_id, tenant_id, class_id=None, team_id=None): - """[接口 10] 访问控制:租户 + 班级 + 团队三重匹配(F-RT-02 加入共享会话)。 - - 返回 bool(不抛异常,便于运行时热路径判定): - - 无有效关联 → False; - - 传了 class_id 而关联 class_id 不一致 → False; - - 传了 team_id 而关联 team_id 不一致 → False; - - 基表记录已删(悬挂)→ False。 - """ - try: - tenant_id = _resolve_tenant(tenant_id) - ref_type = _check_ref_type(ref_type) - rid = _check_ref_id(ref_id) - except Exception: - return False - ref = await _select_ref(tenant_id, ref_type, rid) - if not ref: - return False - if class_id not in (None, '') and str(ref.get('class_id') or '') != str(class_id): - return False - if team_id not in (None, '') and str(ref.get('team_id') or '') != str(team_id): - return False - try: - alive = await _base.filter_existing_ids(ref_type, [rid]) - except Exception: - return True # 基表不可探测(基础设施问题)时不误判为悬挂 - if rid not in alive: - return False # 悬挂引用:基表记录已被复用模块删除 - return True - - -# -------------------------------------------------------------------------- -# §3.3 团队/班级维度(11~13) -# -------------------------------------------------------------------------- -async def list_teams_by_class(class_id, tenant_id): - """[接口 11] 教学班下的团队分组(US-13 共享世界): - 返回 list[{team_id, members[], ref_ids[], world_ids[], scene_ids[], entity_ids[]}]。 - - members 来源:优先读 pbl_governance.pbl_team_member(只读),不可用时返回空列表 - (本模块不新增表、不写他模块表)。 - """ - tenant_id = _resolve_tenant(tenant_id) - if class_id in (None, ''): - raise_error(E_VALIDATION, 'class_id 必填') - rows = await _db.query( - "SELECT * FROM %s WHERE tenant_id=${tid}$ AND class_id=${cid}$ " - "AND is_deleted=0 ORDER BY id" % TABLE, {'tid': tenant_id, 'cid': str(class_id)}) - grouped = {} - for row in rows: - item = _row_to_dict(row) - team_id = item.get('team_id') or '__unassigned__' - bucket = grouped.setdefault(team_id, { - 'team_id': item.get('team_id'), 'class_id': item.get('class_id'), - 'tenant_id': tenant_id, 'members': [], 'ref_ids': [], - 'world_ids': [], 'scene_ids': [], 'entity_ids': [], - 'blueprint_ids': [], - }) - bucket['ref_ids'].append(item['id']) - bucket['%s_ids' % item['ref_type']].append(item['ref_id']) - if item.get('blueprint_id') and item['blueprint_id'] not in bucket['blueprint_ids']: - bucket['blueprint_ids'].append(item['blueprint_id']) - - teams = list(grouped.values()) - real_team_ids = [t['team_id'] for t in teams if t['team_id']] - members_map = await _fetch_team_members(tenant_id, real_team_ids) - for team in teams: - team['members'] = members_map.get(team['team_id'], []) - return teams - - -async def _fetch_team_members(tenant_id, team_ids): - """只读探测 pbl_governance 团队成员表(不存在/不可读时返回空,绝不写入)。""" - result = {tid: [] for tid in team_ids} - if not team_ids: - return result - reader = None - try: - from ahserver.serverenv import ServerEnv # type: ignore - env = ServerEnv() - for name in ('list_team_members', 'pbl_team_member_list', 'get_team_members'): - if callable(getattr(env, name, None)): - reader = getattr(env, name) + if not isinstance(row, dict): + return out + for key in _BASE_VIEW_FIELDS: + if key in row and row[key] is not None: + out[key] = row[key] + if "id" not in out: + for alt in ("world_id", "scene_id", "entity_id"): + if row.get(alt): + out["id"] = row[alt] break - except Exception: - reader = None - if callable(reader): - try: - data = await reader({'tenant_id': tenant_id, 'team_ids': team_ids}) - rows = data.get('items', data) if isinstance(data, dict) else data - for row in rows or []: - tid = str(row.get('team_id') or '') - if tid in result: - result[tid].append(row) - return result - except Exception: - pass - try: - marks = ','.join('${t%d}$' % i for i in range(len(team_ids))) - params = {'tid': tenant_id} - for i, tid in enumerate(team_ids): - params['t%d' % i] = str(tid) - rows = await _db.query( - "SELECT * FROM pbl_team_member WHERE tenant_id=${tid}$ " - "AND team_id IN (%s)" % marks, params) - for row in rows: - tid = str(row.get('team_id') or '') - if tid in result: - result[tid].append(row) - except Exception: - pass # 治理模块未部署时降级为空成员列表 - return result - - -async def bind_team_to_world(world_id, team_id, tenant_id, class_id=None, - blueprint_id=None, ext=None): - """[接口 12] 团队 ↔ 世界绑定(US-13 共享世界分组)。 - - 已存在 world 关联时更新其 team_id(幂等);不存在时新建关联。 - 错误分支:PBL_E_NOT_FOUND(world 基表记录不存在)、PBL_E_DUPLICATE(同团队重复绑定)。 - """ - tenant_id = _resolve_tenant(tenant_id) - wid = _check_ref_id(world_id) - if team_id in (None, ''): - raise_error(E_VALIDATION, 'team_id 必填') - team_id = str(team_id) - - if not await _base.base_exists(REF_TYPE_WORLD, wid): - raise_error(E_NOT_FOUND, 'world 基表记录不存在,拒绝绑定', - ref_type=REF_TYPE_WORLD, ref_id=wid) - - existed = await _select_ref(tenant_id, REF_TYPE_WORLD, wid, include_deleted=True) - if existed and int(existed.get('is_deleted') or 0) == 0: - if str(existed.get('team_id') or '') == team_id: - raise_error(E_DUPLICATE, '该团队已绑定此 world', - ref_type=REF_TYPE_WORLD, ref_id=wid, team_id=team_id) - sets = ["team_id=${tm}$"] - params = {'id': int(existed['id']), 'tm': team_id} - if class_id not in (None, ''): - sets.append("class_id=${cid}$") - params['cid'] = str(class_id) - if blueprint_id not in (None, ''): - sets.append("blueprint_id=${bp}$") - params['bp'] = str(blueprint_id) - if ext is not None: - sets.append("ext=${ext}$") - params['ext'] = _dump_ext(ext) - await _db.execute("UPDATE %s SET %s WHERE id=${id}$" % (TABLE, ', '.join(sets)), - params) - return await _select_ref(tenant_id, REF_TYPE_WORLD, wid) - - return await bind_ref(REF_TYPE_WORLD, wid, blueprint_id=blueprint_id, - class_id=class_id, team_id=team_id, ext=ext, - tenant_id=tenant_id) - - -async def get_team_worlds(team_id, tenant_id): - """[接口 13] 团队可见的 world 列表(基表 + ref 联合,跨租户不可见)。""" - tenant_id = _resolve_tenant(tenant_id) - if team_id in (None, ''): - raise_error(E_VALIDATION, 'team_id 必填') - rows = await _db.query( - "SELECT * FROM %s WHERE tenant_id=${tid}$ AND ref_type=${rtype}$ " - "AND team_id=${tm}$ AND is_deleted=0 ORDER BY id DESC" % TABLE, - {'tid': tenant_id, 'rtype': REF_TYPE_WORLD, 'tm': str(team_id)}) - refs = {int(r['ref_id']): _row_to_dict(r) for r in rows} - if not refs: - return [] - base_rows = await _base.fetch_rows_by_ids(REF_TYPE_WORLD, list(refs.keys())) - out = [] - for rid, ref in refs.items(): - row = base_rows.get(rid) - if not row: - continue - out.append(_merge_ref_into(row, ref)) return out -CONTRACT_INTERFACES = ( - 'bind_ref', 'unbind_ref', 'get_ref', 'list_refs', 'update_ref', - 'list_worlds_by_tenant', 'list_scenes_by_world', 'list_entities_by_scene', - 'get_world_with_pbl_context', 'check_ref_access', 'list_teams_by_class', - 'bind_team_to_world', 'get_team_worlds', -) +def _ref_out(row): + """关联表行 -> 对外 dict(保证 ext_json 键名存在且为文本)。""" + out = {} + if not isinstance(row, dict): + return out + for key in LIST_FIELDS: + if key in row: + out[key] = row[key] + if EXT_FIELD in out and out[EXT_FIELD] is None: + out[EXT_FIELD] = "" + out["is_deleted"] = int(row.get("is_deleted") or 0) + return out + + +def _operator(params): + """取操作人(creator_id/updater_id 审计用)。""" + if not isinstance(params, dict): + return "" + for key in ("operator_id", "user_id", "creator_id", "updater_id", "uid"): + val = as_text(params.get(key)).strip() + if val: + return val + return "" + + +# -------------------------------------------------------------------------- +# 1. 关联表:绑定 / 解绑 / 更新 / 查询 +# -------------------------------------------------------------------------- +def pbl_domain_ref_bind(params): + """绑定基础域对象到 PBL 蓝图/团队/班级(幂等 upsert)。 + + 入参:tenant_id*, ref_type*(world|scene|entity), ref_id*, + ref_code, ref_name, blueprint_id, team_id, class_id, ext_json, operator_id + 出参:data = 关联记录 dict(含 id / bind_state=bound) + """ + try: + tenant_id = require_tenant(params) + ref_type = check_ref_type(params.get("ref_type")) + ref_id = as_text(params.get("ref_id")).strip() + if not ref_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 为空") + if len(ref_id) > 64: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 超长(>64)") + + ref_code = as_text(params.get("ref_code")).strip()[:64] + ref_name = as_text(params.get("ref_name")).strip()[:128] + # 未显式给编码/名称时,从基表只读快照(不改基表) + if not ref_code or not ref_name: + base_row = db.read_base_row(ref_type, ref_id, tenant_id) + if base_row is None: + raise PblDomainExtError("PBL_DE_BASE_MISSING", + detail="%s.%s 未命中" % (BASE_TABLES[ref_type], ref_id)) + view = _base_view(base_row) + ref_code = ref_code or as_text(view.get("code") or view.get("id"))[:64] + ref_name = ref_name or as_text(view.get("name") or view.get("title"))[:128] + + ext_json = normalize_ext_json(params.get(EXT_FIELD) or params.get("ext_json")) + bind_state = check_bind_state(params.get("bind_state")) or BIND_STATES[0] + operator_id = _operator(params) + + exist = db.select_ref_by_key(tenant_id, ref_type, ref_id) + if exist: + row = { + "ref_code": ref_code, "ref_name": ref_name, + "blueprint_id": as_text(params.get("blueprint_id")).strip()[:32], + "team_id": as_text(params.get("team_id")).strip()[:32], + "class_id": as_text(params.get("class_id")).strip()[:32], + "bind_state": bind_state, "bind_at": now_str(), + EXT_FIELD: ext_json, "is_deleted": 0, + } + audit_on_update(row, operator_id) + db.update_ref({"tenant_id": tenant_id, "id": exist.get("id")}, row) + fresh = db.select_ref_by_id(tenant_id, exist.get("id")) + return ok(_ref_out(fresh or dict(exist, **row)), rebound=True) + + row = { + "id": gen_id(), "tenant_id": tenant_id, + "ref_type": ref_type, "ref_id": ref_id, + "ref_code": ref_code, "ref_name": ref_name, + "blueprint_id": as_text(params.get("blueprint_id")).strip()[:32], + "team_id": as_text(params.get("team_id")).strip()[:32], + "class_id": as_text(params.get("class_id")).strip()[:32], + "bind_state": bind_state, "bind_at": now_str(), + EXT_FIELD: ext_json, + } + audit_on_create(row, operator_id) + db.insert_ref(row) + return ok(_ref_out(row), rebound=False) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_domain_ref_unbind(params): + """解绑(逻辑删除 is_deleted=1 + bind_state=unbound),绝不物理删。 + + 入参:tenant_id*, id 或 (ref_type*+ref_id*),operator_id + """ + try: + tenant_id = require_tenant(params) + ref_pk = as_text(params.get("id")).strip() + if ref_pk: + exist = db.select_ref_by_id(tenant_id, ref_pk) + else: + ref_type = check_ref_type(params.get("ref_type")) + ref_id = as_text(params.get("ref_id")).strip() + if not ref_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", + detail="需 id 或 ref_type+ref_id") + exist = db.select_ref_by_key(tenant_id, ref_type, ref_id) + if not exist: + raise PblDomainExtError("PBL_DE_NOT_FOUND", detail="关联记录不存在") + + row = {"bind_state": "unbound", "is_deleted": 1} + audit_on_update(row, _operator(params)) + affected = db.update_ref({"tenant_id": tenant_id, "id": exist.get("id")}, row) + return ok({"id": exist.get("id"), "bind_state": "unbound", + "is_deleted": 1, "affected": int(affected or 0)}) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_domain_ref_update(params): + """更新关联记录的可变字段(ref_type/ref_id/tenant_id 不可改)。 + + 入参:tenant_id*, id*, 以及 ref_code/ref_name/blueprint_id/team_id/class_id/ + bind_state/ext_json 中任意子集,operator_id + """ + try: + tenant_id = require_tenant(params) + ref_pk = as_text(params.get("id")).strip() + if not ref_pk: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="id 为空") + exist = db.select_ref_by_id(tenant_id, ref_pk) + if not exist: + raise PblDomainExtError("PBL_DE_NOT_FOUND", detail="id=%s" % ref_pk) + + row = {} + for key in ("ref_code", "ref_name", "blueprint_id", "team_id", "class_id"): + if key in params: + row[key] = as_text(params.get(key)).strip()[:128] + if "bind_state" in params: + state = check_bind_state(params.get("bind_state"), allow_empty=False) + row["bind_state"] = state + if state == "bound" and not exist.get("bind_at"): + row["bind_at"] = now_str() + if EXT_FIELD in params or "ext_json" in params: + raw = params.get(EXT_FIELD) if EXT_FIELD in params else params.get("ext_json") + row[EXT_FIELD] = normalize_ext_json(raw) + if not row: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="无可更新字段") + + audit_on_update(row, _operator(params)) + affected = db.update_ref({"tenant_id": tenant_id, "id": ref_pk}, row) + fresh = db.select_ref_by_id(tenant_id, ref_pk) + return ok(_ref_out(fresh or dict(exist, **row)), affected=int(affected or 0)) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_domain_ref_get(params): + """取单条关联记录。入参:tenant_id*, id 或 ref_type+ref_id。""" + try: + tenant_id = require_tenant(params) + ref_pk = as_text(params.get("id")).strip() + if ref_pk: + row = db.select_ref_by_id(tenant_id, ref_pk) + else: + ref_type = check_ref_type(params.get("ref_type"), allow_empty=True) + ref_id = as_text(params.get("ref_id")).strip() + if not ref_type or not ref_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", + detail="需 id 或 ref_type+ref_id") + row = db.select_ref_by_key(tenant_id, ref_type, ref_id) + if not row: + raise PblDomainExtError("PBL_DE_NOT_FOUND") + data = _ref_out(row) + if params.get("with_base"): + data["base"] = _base_view(db.read_base_row(row.get("ref_type"), + row.get("ref_id"), tenant_id) or {}) + return ok(data) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_domain_ref_list(params): + """分页查询关联记录。 + + 入参:tenant_id*, ref_type, bind_state, blueprint_id, team_id, class_id, + ref_ids(list), keyword, page, page_size + 出参:data = {"items": [...], "total": n, "page": p, "page_size": s} + """ + try: + tenant_id = require_tenant(params) + conds = {} + ref_type = check_ref_type(params.get("ref_type"), allow_empty=True) + if ref_type: + conds["ref_type"] = ref_type + state = check_bind_state(params.get("bind_state")) + if state: + conds["bind_state"] = state + for key in ("blueprint_id", "team_id", "class_id", "ref_id"): + val = as_text(params.get(key)).strip() + if val: + conds[key] = val + ref_ids = [as_text(x).strip() for x in as_list(params.get("ref_ids"))] + ref_ids = [x for x in ref_ids if x] + if ref_ids: + conds["ref_id"] = ref_ids + + page, page_size, offset = page_args(params) + total = db.count_refs(tenant_id, conds) + items = db.select_refs(tenant_id, conds=conds, offset=offset, limit=page_size) + rows = [_ref_out(r) for r in items] + + keyword = as_text(params.get("keyword")).strip().lower() + if keyword: + rows = [r for r in rows + if keyword in as_text(r.get("ref_name")).lower() + or keyword in as_text(r.get("ref_code")).lower()] + return ok({"items": rows, "total": int(total), + "page": page, "page_size": page_size}) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_domain_ref_check_access(params): + """访问判定:某主体(蓝图/团队/班级)能否访问某基础域对象。 + + 规则(fail-closed): + 1. tenant_id 必须一致; + 2. 必须存在 bind_state=bound 且 is_deleted=0 的关联记录; + 3. 若传了 blueprint_id/team_id/class_id,关联记录对应列必须匹配。 + 出参:data = {"allowed": bool, "reason": str, "ref": dict|None} + """ + try: + tenant_id = require_tenant(params) + ref_type = check_ref_type(params.get("ref_type")) + ref_id = as_text(params.get("ref_id")).strip() + if not ref_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 为空") + + row = db.select_ref_by_key(tenant_id, ref_type, ref_id) + if not row: + return ok({"allowed": False, "reason": "no_binding", "ref": None}) + if as_text(row.get("bind_state")) != "bound" or int(row.get("is_deleted") or 0): + return ok({"allowed": False, "reason": "not_bound", "ref": _ref_out(row)}) + for key in ("blueprint_id", "team_id", "class_id"): + want = as_text(params.get(key)).strip() + if not want: + continue + got = as_text(row.get(key)).strip() + if got and got != want: + return ok({"allowed": False, "reason": "%s_mismatch" % key, + "ref": _ref_out(row)}) + return ok({"allowed": True, "reason": "ok", "ref": _ref_out(row)}) + except PblDomainExtError as exc: + return exc.to_payload() + + +# -------------------------------------------------------------------------- +# 2. 基础域只读视图(world / scene / entity) +# -------------------------------------------------------------------------- +def pbl_world_list_by_tenant(params): + """只读列出租户下 world 基表记录,并标注 PBL 绑定状态。""" + try: + tenant_id = require_tenant(params) + limit = page_args(params)[1] + rows = db.read_base_rows("world", tenant_id, limit=limit) + bound = {} + for r in db.select_refs(tenant_id, conds={"ref_type": "world"}, limit=limit * 2 or 200): + bound[as_text(r.get("ref_id"))] = _ref_out(r) + items = [] + for raw in rows: + view = _base_view(raw) + wid = as_text(view.get("id")) + ref = bound.get(wid) + view["pbl_bound"] = bool(ref and ref.get("bind_state") == "bound") + view["pbl_ref_id"] = (ref or {}).get("id", "") + view["pbl_blueprint_id"] = (ref or {}).get("blueprint_id", "") + items.append(view) + keyword = as_text(params.get("keyword")).strip().lower() + if keyword: + items = [i for i in items + if keyword in as_text(i.get("name")).lower() + or keyword in as_text(i.get("code")).lower() + or keyword in as_text(i.get("title")).lower()] + if params.get("only_bound"): + items = [i for i in items if i.get("pbl_bound")] + return ok({"items": items, "total": len(items), "ref_type": "world", + "readonly": True, "table": TABLE}) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_world_get_context(params): + """取世界上下文:world 只读视图 + 其下 scene/entity 关联 + PBL 绑定信息。""" + try: + tenant_id = require_tenant(params) + world_id = as_text(params.get("world_id") or params.get("ref_id") + or params.get("id")).strip() + if not world_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="world_id 为空") + + base = db.read_base_row("world", world_id, tenant_id) + world_view = _base_view(base or {}) + ref = db.select_ref_by_key(tenant_id, "world", world_id) + + scenes = [_base_view(r) for r in db.read_base_rows("scene", tenant_id, + {"world_id": world_id}, limit=200)] + scene_ids = [as_text(s.get("id")) for s in scenes if s.get("id")] + entities = [] + if params.get("with_entities") and scene_ids: + for raw in db.read_base_rows("entity", tenant_id, limit=500): + view = _base_view(raw) + if as_text(view.get("scene_id")) in scene_ids: + entities.append(view) + + return ok({ + "world": world_view, + "pbl_ref": _ref_out(ref) if ref else None, + "scenes": scenes, + "entities": entities, + "counts": {"scene": len(scenes), "entity": len(entities)}, + "readonly": True, + }) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_scene_list_by_world(params): + """只读列出某 world 下的 scene(基表按 world_id 过滤)+ 绑定标注。""" + try: + tenant_id = require_tenant(params) + world_id = as_text(params.get("world_id")).strip() + if not world_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="world_id 为空") + rows = db.read_base_rows("scene", tenant_id, {"world_id": world_id}, limit=200) + bound = {} + for r in db.select_refs(tenant_id, conds={"ref_type": "scene"}, limit=500): + bound[as_text(r.get("ref_id"))] = _ref_out(r) + items = [] + for raw in rows: + view = _base_view(raw) + ref = bound.get(as_text(view.get("id"))) + view["world_id"] = world_id + view["pbl_bound"] = bool(ref and ref.get("bind_state") == "bound") + view["pbl_ref_id"] = (ref or {}).get("id", "") + items.append(view) + return ok({"items": items, "total": len(items), "world_id": world_id, + "ref_type": "scene", "readonly": True}) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_entity_list_by_scene(params): + """只读列出某 scene 下的 entity(基表按 scene_id 过滤)+ 绑定标注。""" + try: + tenant_id = require_tenant(params) + scene_id = as_text(params.get("scene_id")).strip() + if not scene_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="scene_id 为空") + rows = db.read_base_rows("entity", tenant_id, {"scene_id": scene_id}, limit=500) + bound = {} + for r in db.select_refs(tenant_id, conds={"ref_type": "entity"}, limit=500): + bound[as_text(r.get("ref_id"))] = _ref_out(r) + items = [] + for raw in rows: + view = _base_view(raw) + ref = bound.get(as_text(view.get("id"))) + view["scene_id"] = scene_id + view["pbl_bound"] = bool(ref and ref.get("bind_state") == "bound") + view["pbl_ref_id"] = (ref or {}).get("id", "") + items.append(view) + return ok({"items": items, "total": len(items), "scene_id": scene_id, + "ref_type": "entity", "readonly": True}) + except PblDomainExtError as exc: + return exc.to_payload() + + +# -------------------------------------------------------------------------- +# 3. 团队-世界绑定 +# -------------------------------------------------------------------------- +def pbl_team_bind_world(params): + """把 world 绑定到团队/班级(写 pbl_domain_ref,ref_type=world)。 + + 入参:tenant_id*, world_id*, team_id 或 class_id(至少一个), blueprint_id, ext_json + """ + try: + tenant_id = require_tenant(params) + world_id = as_text(params.get("world_id") or params.get("ref_id")).strip() + if not world_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="world_id 为空") + team_id = as_text(params.get("team_id")).strip() + class_id = as_text(params.get("class_id")).strip() + if not team_id and not class_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", + detail="team_id / class_id 至少给一个") + inner = dict(params) + inner.update({"tenant_id": tenant_id, "ref_type": "world", + "ref_id": world_id, "team_id": team_id, + "class_id": class_id, "bind_state": "bound"}) + result = pbl_domain_ref_bind(inner) + if not result.get("success"): + return result + data = result.get("data") or {} + data["world_id"] = world_id + data["team_id"] = team_id + data["class_id"] = class_id + return ok(data, action="team_bind_world") + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_team_world_list(params): + """列出团队(或班级)已绑定的 world。入参:tenant_id*, team_id 或 class_id。""" + try: + tenant_id = require_tenant(params) + team_id = as_text(params.get("team_id")).strip() + class_id = as_text(params.get("class_id")).strip() + if not team_id and not class_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", + detail="team_id / class_id 至少给一个") + conds = {"ref_type": "world", "bind_state": "bound"} + if team_id: + conds["team_id"] = team_id + if class_id: + conds["class_id"] = class_id + rows = db.select_refs(tenant_id, conds=conds, limit=500) + items = [] + for r in rows: + item = _ref_out(r) + item["world_id"] = item.get("ref_id") + item["world_code"] = item.get("ref_code") + item["world_name"] = item.get("ref_name") + items.append(item) + return ok({"items": items, "total": len(items), + "team_id": team_id, "class_id": class_id}) + except PblDomainExtError as exc: + return exc.to_payload() + + +def pbl_team_list_by_class(params): + """按班级列出团队及其绑定世界数(聚合自 pbl_domain_ref,不新建表)。 + + 入参:tenant_id*, class_id* + 出参:data = {"items": [{"team_id", "class_id", "world_count", "worlds": [...]}]} + """ + try: + tenant_id = require_tenant(params) + class_id = as_text(params.get("class_id")).strip() + if not class_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="class_id 为空") + rows = db.select_refs(tenant_id, + conds={"class_id": class_id, "bind_state": "bound"}, + limit=500) + teams = {} + for r in rows: + team_id = as_text(r.get("team_id")).strip() or "(unassigned)" + bucket = teams.setdefault(team_id, {"team_id": team_id, + "class_id": class_id, + "world_count": 0, "worlds": []}) + if as_text(r.get("ref_type")) == "world": + bucket["world_count"] += 1 + bucket["worlds"].append({"world_id": r.get("ref_id"), + "code": r.get("ref_code"), + "name": r.get("ref_name")}) + items = sorted(teams.values(), key=lambda x: x["team_id"]) + return ok({"items": items, "total": len(items), "class_id": class_id}) + except PblDomainExtError as exc: + return exc.to_payload() + + +# -------------------------------------------------------------------------- +# 4. 编译期物化(pbl_compiler import 闭包所需符号) +# -------------------------------------------------------------------------- +def pbl_domain_materialize_game_definition(params): + """把蓝图关联的基础域对象物化为 Game Definition 片段(只读聚合,零写入)。 + + 供 pbl_compiler 在编译期调用:按 blueprint_id 取全部 bound 关联, + 按 ref_type 分组,输出 ``{"worlds": [...], "scenes": [...], "entities": [...]}``, + 每项含 ref_id/ref_code/ref_name/ext_json(解析为对象)与基表只读视图。 + + 入参:tenant_id*, blueprint_id*(或 team_id/class_id 二选一), with_base(bool) + """ + try: + tenant_id = require_tenant(params) + blueprint_id = as_text(params.get("blueprint_id")).strip() + team_id = as_text(params.get("team_id")).strip() + class_id = as_text(params.get("class_id")).strip() + if not blueprint_id and not team_id and not class_id: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", + detail="blueprint_id / team_id / class_id 至少给一个") + conds = {"bind_state": "bound"} + if blueprint_id: + conds["blueprint_id"] = blueprint_id + if team_id: + conds["team_id"] = team_id + if class_id: + conds["class_id"] = class_id + rows = db.select_refs(tenant_id, conds=conds, limit=500) + + grouped = {"worlds": [], "scenes": [], "entities": []} + bucket_of = {"world": "worlds", "scene": "scenes", "entity": "entities"} + with_base = bool(params.get("with_base")) + for r in rows: + ref_type = as_text(r.get("ref_type")) + bucket = bucket_of.get(ref_type) + if not bucket: + continue + ext_raw = as_text(r.get(EXT_FIELD)).strip() + try: + import json as _json + ext_obj = _json.loads(ext_raw) if ext_raw else {} + except (ValueError, TypeError): + ext_obj = {} + item = { + "ref_id": r.get("ref_id"), + "ref_type": ref_type, + "code": r.get("ref_code"), + "name": r.get("ref_name"), + "ext_json": ext_obj, + "pbl_domain_ref_id": r.get("id"), + } + if with_base: + item["base"] = _base_view(db.read_base_row(ref_type, r.get("ref_id"), + tenant_id) or {}) + grouped[bucket].append(item) + + return ok({ + "tenant_id": tenant_id, + "blueprint_id": blueprint_id, + "team_id": team_id, + "class_id": class_id, + "game_definition_fragment": grouped, + "counts": {k: len(v) for k, v in grouped.items()}, + "readonly": True, + "source_table": TABLE, + }) + except PblDomainExtError as exc: + return exc.to_payload() + + +# -------------------------------------------------------------------------- +# dspy 分发入口 +# -------------------------------------------------------------------------- +_DISPATCH = { + "pbl_domain_ref_bind": pbl_domain_ref_bind, + "pbl_domain_ref_unbind": pbl_domain_ref_unbind, + "pbl_domain_ref_update": pbl_domain_ref_update, + "pbl_domain_ref_get": pbl_domain_ref_get, + "pbl_domain_ref_list": pbl_domain_ref_list, + "pbl_domain_ref_check_access": pbl_domain_ref_check_access, + "pbl_world_list_by_tenant": pbl_world_list_by_tenant, + "pbl_world_get_context": pbl_world_get_context, + "pbl_scene_list_by_world": pbl_scene_list_by_world, + "pbl_entity_list_by_scene": pbl_entity_list_by_scene, + "pbl_team_bind_world": pbl_team_bind_world, + "pbl_team_world_list": pbl_team_world_list, + "pbl_team_list_by_class": pbl_team_list_by_class, + "pbl_domain_materialize_game_definition": pbl_domain_materialize_game_definition, +} + + +def dispatch(action, params): + """按 action 名分发到契约函数;未知 action 返回 PBL_DE_PARAM_INVALID。""" + func = _DISPATCH.get(as_text(action).strip()) + if func is None: + return fail("PBL_DE_PARAM_INVALID", detail="unknown action=%r" % (action,)) + return func(params if isinstance(params, dict) else {}) diff --git a/pbl_domain_ext/base.py b/pbl_domain_ext/base.py index 934eed8..56d868a 100644 --- a/pbl_domain_ext/base.py +++ b/pbl_domain_ext/base.py @@ -1,178 +1,184 @@ -"""pbl_domain_ext.base — 复用基表(world/scene/entity)**只读**投影层。 +# -*- coding: utf-8 -*- +"""pbl_domain_ext 公共内核:常量、参数校验、租户上下文、审计字段。 -设计约束(modules/pbl_domain_ext.md §4 / pbls_spec.json referenced_extension_rule): -- 三禁止:新增表、改既有列语义、删除列 → 本模块对基表**零 ALTER、零写入**; -- Phase 0/1 执行口径「零改表」:PBL 维度全部落 pbl_domain_ref; -- 优先经复用模块既有 read 契约取数(ServerEnv 上的 world/scene/entity 函数), - 宿主未挂载时退化为只读 SELECT(仍然零写入)。 - -悬挂引用一致性(§4 风险):基表记录被复用模块删除后 pbl_domain_ref 可能悬挂, -本层提供 ``filter_existing_ids`` 供 list_refs 左连接过滤失效 ref。 +设计依据:projects/pbls/docs/01-design/data-model.md §J1(基础域薄扩展)。 +铁律: + 1. 只维护 1 张关联表 ``pbl_domain_ref``,**不改** world/scene/entity 三张基表结构; + 2. 所有读写 ``tenant_id`` 强制打头,缺失即抛 ``PBL_DE_TENANT_MISSING``; + 3. 扩展字段权威名 ``ext_json``(LONGTEXT / 抽象类型 text),禁止写成旧名 ``ext``。 """ -from . import db -from .errors import E_INTERNAL, raise_error +import json +import time +import uuid -WORLD_TABLE = 'world' -SCENE_TABLE = 'scene' -ENTITY_TABLE = 'entity' +from .errors import PblDomainExtError -TABLE_BY_REF_TYPE = { - 'world': WORLD_TABLE, - 'scene': SCENE_TABLE, - 'entity': ENTITY_TABLE, +__all__ = [ + "TABLE", "BASE_TABLES", "REF_TYPES", "BIND_STATES", "EXT_FIELD", + "LIST_FIELDS", "now_str", "gen_id", "require_tenant", "check_ref_type", + "check_bind_state", "normalize_ext_json", "audit_on_create", "audit_on_update", + "page_args", "as_text", "as_list", +] + +#: 本模块唯一自有表 +TABLE = "pbl_domain_ref" + +#: 基础域基表(只读引用,禁止写入/改结构) +BASE_TABLES = { + "world": "world", + "scene": "scene", + "entity": "entity", } -# 基表父子关联列候选(不同版本复用模块列名可能不同,运行时探测,禁止猜死) -SCENE_PARENT_CANDIDATES = ('world_id', 'worldid', 'parent_id') -ENTITY_PARENT_CANDIDATES = ('scene_id', 'sceneid', 'parent_id') -WORLD_NAME_CANDIDATES = ('name', 'world_name', 'title') +#: ref_type 合法枚举 +REF_TYPES = ("world", "scene", "entity") -_column_cache = {} -_read_func_cache = {} +#: bind_state 合法枚举 +BIND_STATES = ("bound", "unbound") + +#: 扩展字段权威名(设计 §J1)——三处(models/sql/api)必须同名同型 +EXT_FIELD = "ext_json" + +#: 列表返回字段白名单(避免 SELECT * 带出无关列) +LIST_FIELDS = [ + "id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name", + "blueprint_id", "team_id", "class_id", "bind_state", "bind_at", + EXT_FIELD, "creator_id", "created_at", "updater_id", "updated_at", +] + +_MAX_PAGE_SIZE = 500 -def ref_type_to_table(ref_type): - table = TABLE_BY_REF_TYPE.get(ref_type) - if not table: - raise_error(E_INTERNAL, '未知 ref_type,无法映射复用基表', ref_type=ref_type) - return table +def now_str(): + """统一时间串格式 ``YYYY-MM-DD HH:MM:SS``。""" + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) -async def table_columns(table): - """探测基表列名(MySQL information_schema / sqlite PRAGMA),结果缓存。""" - if table in _column_cache: - return _column_cache[table] - cols = [] - try: - rows = await db.query( - "SELECT COLUMN_NAME AS col FROM information_schema.COLUMNS " - "WHERE TABLE_NAME=${tbl}$", {'tbl': table}) - cols = [str(r.get('col') or r.get('COLUMN_NAME') or '') for r in rows] - except Exception: - cols = [] - if not cols: +def gen_id(): + """32 位无连字符 uuid hex 主键。""" + return uuid.uuid4().hex + + +def as_text(value, default=""): + """安全转字符串(None -> default)。""" + if value is None: + return default + if isinstance(value, bytes): try: - rows = await db.query("PRAGMA table_info(%s)" % table) - cols = [str(r.get('name', '')) for r in rows] - except Exception as exc: - raise_error(E_INTERNAL, '无法探测复用基表列(基表可能未部署)', - table=table, reason=str(exc)) - cols = [c for c in cols if c] - _column_cache[table] = cols - return cols + return value.decode("utf-8") + except UnicodeDecodeError: + return value.decode("utf-8", "replace") + if isinstance(value, str): + return value + return str(value) -async def resolve_column(table, candidates, required=True): - """在候选列名中挑出基表真实存在的第一列(禁止凭记忆写死列名)。""" - cols = await table_columns(table) - lowered = {c.lower(): c for c in cols} - for cand in candidates: - hit = lowered.get(cand.lower()) - if hit: - return hit - if required: - raise_error(E_INTERNAL, '复用基表缺少预期关联列', table=table, - candidates=list(candidates), actual=cols) - return None +def as_list(value): + """把 None/单值/列表统一成 list。""" + if value is None or value == "": + return [] + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] -def clear_cache(): - _column_cache.clear() - _read_func_cache.clear() +def require_tenant(params): + """取并校验 tenant_id(所有契约第一道门禁)。 + + :return: 去空白后的 tenant_id + :raises PblDomainExtError: PBL_DE_TENANT_MISSING + """ + tenant_id = "" + if isinstance(params, dict): + tenant_id = as_text(params.get("tenant_id")).strip() + if not tenant_id or len(tenant_id) > 32: + raise PblDomainExtError("PBL_DE_TENANT_MISSING", + detail="tenant_id=%r" % (tenant_id,)) + return tenant_id -def _env_read_func(names): - """探测宿主 ServerEnv 上复用模块的既有 read 契约(只读函数)。""" - key = tuple(names) - if key in _read_func_cache: - return _read_func_cache[key] - func = None +def check_ref_type(ref_type, allow_empty=False): + """校验 ref_type ∈ REF_TYPES。""" + ref_type = as_text(ref_type).strip().lower() + if not ref_type: + if allow_empty: + return "" + raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type 为空") + if ref_type not in REF_TYPES: + raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", + detail="ref_type=%r 允许=%s" % (ref_type, list(REF_TYPES))) + return ref_type + + +def check_bind_state(bind_state, allow_empty=True): + """校验 bind_state ∈ BIND_STATES。""" + bind_state = as_text(bind_state).strip().lower() + if not bind_state: + if allow_empty: + return "" + return BIND_STATES[0] + if bind_state not in BIND_STATES: + raise PblDomainExtError("PBL_DE_STATE_INVALID", + detail="bind_state=%r" % (bind_state,)) + return bind_state + + +def normalize_ext_json(raw): + """把入参 ext_json 归一为 **合法 JSON 文本**(列类型 LONGTEXT,存字符串)。 + + 接受:None/""(-> "")、dict/list(-> json.dumps)、已是 JSON 文本的 str。 + 非法 JSON 文本抛 ``PBL_DE_EXT_JSON_INVALID``。 + """ + if raw is None: + return "" + if isinstance(raw, (dict, list)): + return json.dumps(raw, ensure_ascii=False, sort_keys=True) + text = as_text(raw).strip() + if not text: + return "" try: - from ahserver.serverenv import ServerEnv # type: ignore - env = ServerEnv() - for name in names: - candidate = getattr(env, name, None) - if callable(candidate): - func = candidate - break - except Exception: - func = None - _read_func_cache[key] = func - return func + parsed = json.loads(text) + except (ValueError, TypeError): + raise PblDomainExtError("PBL_DE_EXT_JSON_INVALID", detail=text[:120]) + if not isinstance(parsed, (dict, list)): + raise PblDomainExtError("PBL_DE_EXT_JSON_INVALID", + detail="ext_json 顶层必须是 object/array") + return json.dumps(parsed, ensure_ascii=False, sort_keys=True) -async def fetch_rows_by_ids(ref_type, ids): - """按 id 批量只读取基表行;返回 {id: row}。优先走复用模块 read 契约。""" - ids = [int(i) for i in ids if i is not None] - if not ids: - return {} - table = ref_type_to_table(ref_type) - reader = _env_read_func(('list_%ss' % ref_type, 'get_%s' % ref_type)) - if callable(reader): - try: - data = await reader({'ids': ids}) if _takes_dict(reader) else await reader(ids) - rows = _coerce_rows(data) - if rows: - return {int(r['id']): r for r in rows if r.get('id') is not None} - except Exception: - pass # 退化到只读 SELECT - marks = ','.join('${id%d}$' % i for i in range(len(ids))) - params = {'id%d' % i: int(v) for i, v in enumerate(ids)} - rows = await db.query( - "SELECT * FROM %s WHERE id IN (%s)" % (table, marks), params) - return {int(r['id']): r for r in rows if r.get('id') is not None} +def audit_on_create(row, operator_id): + """补创建审计四件套(就地修改并返回 row)。""" + ts = now_str() + row["creator_id"] = as_text(operator_id) + row["created_at"] = ts + row["updater_id"] = as_text(operator_id) + row["updated_at"] = ts + row.setdefault("is_deleted", 0) + return row -async def fetch_row_by_id(ref_type, ref_id): - got = await fetch_rows_by_ids(ref_type, [ref_id]) - return got.get(int(ref_id)) +def audit_on_update(row, operator_id): + """补更新审计(就地修改并返回 row)。""" + row["updater_id"] = as_text(operator_id) + row["updated_at"] = now_str() + return row -async def base_exists(ref_type, ref_id): - """bind_ref 前置存在性校验(§3.1 PBL_E_NOT_FOUND 分支)。""" - table = ref_type_to_table(ref_type) - rows = await db.query( - "SELECT id FROM %s WHERE id=${rid}$ LIMIT 1" % table, {'rid': int(ref_id)}) - return bool(rows) - - -async def filter_existing_ids(ref_type, ids): - """悬挂引用过滤:返回基表中仍存在的 id 集合。""" - got = await fetch_rows_by_ids(ref_type, ids) - return set(got.keys()) - - -async def fetch_children(ref_type, parent_id): - """按父记录取子基表行(world→scene、scene→entity),只读。""" - if ref_type == 'scene': - table = SCENE_TABLE - col = await resolve_column(SCENE_TABLE, SCENE_PARENT_CANDIDATES) - elif ref_type == 'entity': - table = ENTITY_TABLE - col = await resolve_column(ENTITY_TABLE, ENTITY_PARENT_CANDIDATES) - else: - raise_error(E_INTERNAL, 'fetch_children 仅支持 scene/entity', ref_type=ref_type) - return await db.query( - "SELECT * FROM %s WHERE %s=${pid}$" % (table, col), {'pid': int(parent_id)}) - - -def _takes_dict(func): +def page_args(params): + """解析分页参数,返回 (page, page_size, offset)。越界自动收敛。""" try: - import inspect - sig = inspect.signature(func) - params = list(sig.parameters.values()) - return len(params) == 1 and params[0].name in ('filters', 'params', 'ns', 'data') - except Exception: - return False - - -def _coerce_rows(data): - if isinstance(data, dict): - for key in ('items', 'rows', 'data', 'list'): - if isinstance(data.get(key), list): - return data[key] - return [data] - if isinstance(data, list): - return data - return [] + page = int(params.get("page") or 1) + except (TypeError, ValueError): + page = 1 + try: + page_size = int(params.get("page_size") or params.get("limit") or 20) + except (TypeError, ValueError): + page_size = 20 + if page < 1: + page = 1 + if page_size < 1: + page_size = 1 + if page_size > _MAX_PAGE_SIZE: + page_size = _MAX_PAGE_SIZE + return page, page_size, (page - 1) * page_size diff --git a/pbl_domain_ext/db.py b/pbl_domain_ext/db.py index c934b37..c20abd0 100644 --- a/pbl_domain_ext/db.py +++ b/pbl_domain_ext/db.py @@ -1,201 +1,295 @@ -"""pbl_domain_ext.db — 数据访问适配层(M8 薄扩展)。 +# -*- coding: utf-8 -*- +"""pbl_domain_ext 数据访问层。 -职责: -1. 统一 SQL 占位符风格:模块内 SQL 一律写 sqlor 风格 ``${name}$``; - 测试/异构后端可注入适配器(sqlite3 用 ``:name``),转换在适配器内完成。 -2. 统一取库名:``ServerEnv().get_module_dbname('pbl_domain_ext')``,禁止硬编码 DBNAME。 -3. 宿主可注入适配器(``set_adapter``),保证模块 host-agnostic(module-development-spec)。 +只走 sqlor 标准 API(``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe``), +禁止编造 save/list/insert 等不存在的方法。 -铁律:本模块对 world/scene/entity 三张复用基表**只读**(SELECT),零 ALTER、零写入; -所有写入只落自有表 pbl_domain_ref(1 张,data-model.md §J1)。 +库名取自 ``ServerEnv().get_module_dbname('pbl_domain_ext')``,禁止硬编码 DBNAME。 +基础域基表(world/scene/entity)在本层**只读**:仅暴露 ``read_base_rows``, +不提供任何写基表的函数(Q-OPEN-3:薄扩展不改基表)。 """ -import re +from .base import (TABLE, BASE_TABLES, LIST_FIELDS, EXT_FIELD, as_text) +from .errors import PblDomainExtError -from .errors import E_INTERNAL, raise_error +__all__ = ["get_sor", "get_dbname", "insert_ref", "update_ref", "soft_delete_ref", + "select_ref_by_id", "select_ref_by_key", "select_refs", "count_refs", + "read_base_rows", "read_base_row", "base_table_exists", "ensure_ext_column", + "execute_sql"] -MODULE_NAME = 'pbl_domain_ext' - -_PLACEHOLDER_RE = re.compile(r'\$\{(\w+)\}\$') - -_adapter = None -_dbname_override = None +_SOR_HOLDER = {} -def to_named_params(sql): - """sqlor 风格 ``${name}$`` → DB-API 命名风格 ``:name``(异构后端/测试用)。""" - return _PLACEHOLDER_RE.sub(lambda m: ':' + m.group(1), sql) - - -def set_adapter(adapter): - """注入数据访问适配器(宿主/测试)。adapter 需实现 async query/execute。""" - global _adapter - _adapter = adapter - return adapter - - -def get_adapter(): - return _adapter - - -def set_dbname(dbname): - """覆盖库名(仅测试/特殊宿主使用;生产走 get_module_dbname)。""" - global _dbname_override - _dbname_override = dbname - - -def resolve_dbname(): - """按 module-development-spec 取库名:ServerEnv().get_module_dbname(模块名)。""" - if _dbname_override: - return _dbname_override - last_err = None +def get_sor(): + """取 sqlor 句柄(ServerEnv 挂载后由 init.py 注入;未注入时惰性取)。""" + sor = _SOR_HOLDER.get("sor") + if sor is not None: + return sor try: - from ahserver.serverenv import ServerEnv # type: ignore + from sage import ServerEnv # noqa: F401 平台运行期可用 env = ServerEnv() - getter = getattr(env, 'get_module_dbname', None) - if callable(getter): - name = getter(MODULE_NAME) - if name: - return name - except Exception as exc: # pragma: no cover - 宿主未挂载时退化 - last_err = exc - try: # 部分宿主把 get_module_dbname 注入为内置全局 - import builtins - getter = getattr(builtins, 'get_module_dbname', None) - if callable(getter): - name = getter(MODULE_NAME) - if name: - return name - except Exception as exc: # pragma: no cover - last_err = exc - raise_error(E_INTERNAL, '无法解析模块库名(get_module_dbname 不可用)', - module=MODULE_NAME, reason=str(last_err) if last_err else 'no provider') + sor = getattr(env, "sor", None) + except Exception: + sor = None + if sor is None: + raise PblDomainExtError("PBL_DE_DB_ERROR", + detail="sqlor 句柄未就绪(模块未挂载到应用?)") + _SOR_HOLDER["sor"] = sor + return sor -class SqlorAdapter: - """生产适配器:sqlor 上下文(sor.R 查询 / sor.sqlExe 执行)。 +def set_sor(sor): + """init.py 挂载时注入 sqlor 句柄(测试可注入 fake_db)。""" + _SOR_HOLDER["sor"] = sor + return sor - 只用 sqlor 标准 API(sor.C/U/D/R/I/sqlExe),不编造 save/list/insert 等。 - 注意 pitfall:``return`` 不能写在 ``async with`` 块内(会静默返回 None), - 因此本类先在块内收集结果,退出块后再 return。 + +def get_dbname(): + """模块库名:ServerEnv().get_module_dbname('pbl_domain_ext'),禁止硬编码。""" + try: + from sage import ServerEnv + return ServerEnv().get_module_dbname("pbl_domain_ext") + except Exception: + return _SOR_HOLDER.get("dbname") or "" + + +def execute_sql(sql, args=None): + """只读/DDL 统一走 sqlExe;写数据请用 insert_ref/update_ref。""" + sor = get_sor() + try: + if args: + return sor.sqlExe(sql, args) + return sor.sqlExe(sql) + except PblDomainExtError: + raise + except Exception as exc: # noqa: BLE001 + raise PblDomainExtError("PBL_DE_DB_ERROR", detail="%s | %s" % (type(exc).__name__, exc)) + + +# -------------------------------------------------------------------------- +# 关联表 pbl_domain_ref 读写 +# -------------------------------------------------------------------------- +def insert_ref(row): + """新增关联记录,返回受影响行数。""" + sor = get_sor() + try: + return sor.C(TABLE, row) + except Exception as exc: # noqa: BLE001 + raise PblDomainExtError("PBL_DE_DB_ERROR", detail="insert %s: %s" % (TABLE, exc)) + + +def update_ref(where_dict, row): + """按 where 更新关联记录,返回受影响行数。""" + if not where_dict: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="update 缺 where 条件") + sor = get_sor() + try: + return sor.U(TABLE, row, where_dict) + except Exception as exc: # noqa: BLE001 + raise PblDomainExtError("PBL_DE_DB_ERROR", detail="update %s: %s" % (TABLE, exc)) + + +def soft_delete_ref(where_dict, operator_id=""): + """逻辑删除(is_deleted=1),绝不物理 DELETE。""" + from .base import audit_on_update + row = audit_on_update({"is_deleted": 1}, operator_id) + return update_ref(where_dict, row) + + +def _fields_clause(fields=None): + cols = fields or LIST_FIELDS + return ", ".join("`%s`" % c for c in cols) + + +def select_ref_by_id(tenant_id, ref_pk, fields=None): + """按主键取单条(强制带 tenant_id)。""" + sql = "SELECT %s FROM `%s` WHERE `tenant_id`=%%s AND `id`=%%s AND `is_deleted`=0 LIMIT 1" % ( + _fields_clause(fields), TABLE) + rows = execute_sql(sql, (tenant_id, ref_pk)) + return rows[0] if rows else None + + +def select_ref_by_key(tenant_id, ref_type, ref_id, fields=None): + """按业务唯一键 (tenant_id, ref_type, ref_id) 取单条。""" + sql = ("SELECT %s FROM `%s` WHERE `tenant_id`=%%s AND `ref_type`=%%s " + "AND `ref_id`=%%s AND `is_deleted`=0 LIMIT 1") % (_fields_clause(fields), TABLE) + rows = execute_sql(sql, (tenant_id, ref_type, ref_id)) + return rows[0] if rows else None + + +def select_refs(tenant_id, conds=None, order_by=None, offset=0, limit=20, fields=None): + """条件查询关联记录列表。 + + :param conds: ``{列名: 值}`` 或 ``{列名: [值,...]}``(IN),值一律参数化绑定 """ - - def __init__(self, dbname=None): - self._dbname = dbname - - @property - def dbname(self): - return self._dbname or resolve_dbname() - - def _context(self): - """按可用性依次探测 sqlor 上下文提供者。""" - errors = [] - try: - from sqlor import DBPools # type: ignore - pools = DBPools() - if hasattr(pools, 'sqlorContext'): - return pools.sqlorContext(self.dbname) - except Exception as exc: - errors.append('sqlor.DBPools: %s' % exc) - try: - from ahserver.serverenv import ServerEnv # type: ignore - env = ServerEnv() - for attr in ('sqlorContext', 'sqlor_context'): - if hasattr(env, attr): - return getattr(env, attr)(self.dbname) - db = getattr(env, 'db', None) - if db is not None and hasattr(db, 'sqlorContext'): - return db.sqlorContext(self.dbname) - except Exception as exc: - errors.append('ServerEnv: %s' % exc) - try: - import builtins - ctx = getattr(builtins, 'sqlorContext', None) - if callable(ctx): - return ctx(self.dbname) - except Exception as exc: - errors.append('builtin sqlorContext: %s' % exc) - raise_error(E_INTERNAL, '无可用 sqlor 上下文(宿主未挂载数据库层)', - module=MODULE_NAME, dbname=self.dbname, probes=errors) - - async def query(self, sql, params=None): - params = dict(params or {}) - rows = [] - async with self._context() as sor: - reader = getattr(sor, 'R', None) - if not callable(reader): - raise_error(E_INTERNAL, 'sqlor 上下文缺少 R() 查询接口') - rows = await reader(sql, params) - return _normalize_rows(rows) - - async def execute(self, sql, params=None): - params = dict(params or {}) - affected = 0 - async with self._context() as sor: - runner = getattr(sor, 'sqlExe', None) or getattr(sor, 'I', None) - if not callable(runner): - raise_error(E_INTERNAL, 'sqlor 上下文缺少 sqlExe()/I() 执行接口') - affected = await runner(sql, params) - return affected if isinstance(affected, int) else 0 - - -class SqliteAdapter: - """测试/离线适配器:sqlite3(同步驱动,包一层 async 接口)。 - - 用于 tests/ 下跑真实 SQL 逻辑(过滤/分页/唯一约束/软删/连接), - 不依赖生产 MySQL 与宿主挂载。 - """ - - def __init__(self, conn): - self._conn = conn - self._conn.row_factory = _dict_factory - - async def query(self, sql, params=None): - cur = self._conn.execute(to_named_params(sql), dict(params or {})) - rows = cur.fetchall() - cur.close() - return [dict(r) for r in rows] - - async def execute(self, sql, params=None): - cur = self._conn.execute(to_named_params(sql), dict(params or {})) - self._conn.commit() - affected = cur.rowcount - cur.close() - return affected if affected and affected > 0 else 0 - - -def _dict_factory(cursor, row): - return {col[0]: row[idx] for idx, col in enumerate(cursor.description)} - - -def _normalize_rows(rows): - """sqlor 返回值可能是 list[dict] / list[list] / DictObject,统一成 list[dict]。""" - if not rows: - return [] - out = [] - for row in rows: - if isinstance(row, dict): - out.append(dict(row)) - elif hasattr(row, '_asdict'): - out.append(dict(row._asdict())) - elif hasattr(row, 'keys'): - try: - out.append({k: row[k] for k in row.keys()}) + where = ["`tenant_id`=%s", "`is_deleted`=0"] + args = [tenant_id] + for key, val in (conds or {}).items(): + col = as_text(key).strip("`") + if col not in LIST_FIELDS: + # 非白名单列直接忽略,防注入 + continue + if isinstance(val, (list, tuple, set)): + vals = [v for v in val if v not in (None, "")] + if not vals: continue - except Exception: - out.append(dict(row)) - else: - out.append(dict(row)) - return out + where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals)))) + args.extend(vals) + elif val not in (None, ""): + where.append("`%s`=%%s" % col) + args.append(val) + order = as_text(order_by).strip() or "`bind_at` DESC, `id` DESC" + sql = "SELECT %s FROM `%s` WHERE %s ORDER BY %s LIMIT %%s OFFSET %%s" % ( + _fields_clause(fields), TABLE, " AND ".join(where), order) + args.extend([int(limit), int(offset)]) + return execute_sql(sql, tuple(args)) or [] -async def query(sql, params=None): - """模块内统一查询入口。""" - adapter = _adapter or SqlorAdapter() - return await adapter.query(sql, params) +def count_refs(tenant_id, conds=None): + """与 select_refs 同条件的总数(分页用)。""" + where = ["`tenant_id`=%s", "`is_deleted`=0"] + args = [tenant_id] + for key, val in (conds or {}).items(): + col = as_text(key).strip("`") + if col not in LIST_FIELDS: + continue + if isinstance(val, (list, tuple, set)): + vals = [v for v in val if v not in (None, "")] + if not vals: + continue + where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals)))) + args.extend(vals) + elif val not in (None, ""): + where.append("`%s`=%%s" % col) + args.append(val) + sql = "SELECT COUNT(1) AS `cnt` FROM `%s` WHERE %s" % (TABLE, " AND ".join(where)) + rows = execute_sql(sql, tuple(args)) + if not rows: + return 0 + row = rows[0] + if isinstance(row, dict): + return int(row.get("cnt") or 0) + return int(row[0] or 0) -async def execute(sql, params=None): - """模块内统一执行入口(写操作只允许落 pbl_domain_ref)。""" - adapter = _adapter or SqlorAdapter() - return await adapter.execute(sql, params) +# -------------------------------------------------------------------------- +# 基础域基表:只读 +# -------------------------------------------------------------------------- +def read_base_rows(ref_type, tenant_id, extra_conds=None, limit=200): + """只读拉取基础域基表记录(world/scene/entity)。 + + 基表列名因模块而异,这里用 ``SELECT *`` 只读取、绝不写入; + 返回原始 dict 列表,由 api 层做字段映射与裁剪。 + """ + check = as_text(ref_type).strip().lower() + if check not in BASE_TABLES: + raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type=%r" % (ref_type,)) + tbl = BASE_TABLES[check] + where = ["`tenant_id`=%s"] + args = [tenant_id] + for key, val in (extra_conds or {}).items(): + col = as_text(key).strip("`") + if not col.isidentifier(): + continue + if val in (None, ""): + continue + where.append("`%s`=%%s" % col) + args.append(val) + sql = "SELECT * FROM `%s` WHERE %s LIMIT %%s" % (tbl, " AND ".join(where)) + args.append(int(limit)) + try: + return execute_sql(sql, tuple(args)) or [] + except PblDomainExtError as exc: + # 基表可能不带 tenant_id 列(历史库),降级为无租户过滤的只读查询 + if exc.code == "PBL_DE_DB_ERROR": + sql2 = "SELECT * FROM `%s` LIMIT %%s" % tbl + try: + return execute_sql(sql2, (int(limit),)) or [] + except PblDomainExtError: + return [] + raise + + +def read_base_row(ref_type, ref_id, tenant_id=None): + """按主键只读单条基表记录。""" + check = as_text(ref_type).strip().lower() + if check not in BASE_TABLES: + raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type=%r" % (ref_type,)) + tbl = BASE_TABLES[check] + rid = as_text(ref_id).strip() + if not rid: + raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 为空") + if tenant_id: + sql = "SELECT * FROM `%s` WHERE `id`=%%s AND `tenant_id`=%%s LIMIT 1" % tbl + rows = _safe_select(sql, (rid, tenant_id)) + else: + sql = "SELECT * FROM `%s` WHERE `id`=%%s LIMIT 1" % tbl + rows = _safe_select(sql, (rid,)) + if rows: + return rows[0] + # 兼容基表主键列名为 {tbl}_id 的历史实现 + alt = "%s_id" % tbl + sql_alt = "SELECT * FROM `%s` WHERE `%s`=%%s LIMIT 1" % (tbl, alt) + rows = _safe_select(sql_alt, (rid,)) + return rows[0] if rows else None + + +def _safe_select(sql, args): + try: + return execute_sql(sql, args) or [] + except PblDomainExtError: + return [] + + +def base_table_exists(ref_type): + """探测基础域基表是否存在(部署校验用,只读 information_schema)。""" + tbl = BASE_TABLES.get(as_text(ref_type).strip().lower()) + if not tbl: + return False + dbname = get_dbname() + if not dbname: + return True # 无法判定时不阻断 + sql = ("SELECT COUNT(1) AS `cnt` FROM information_schema.tables " + "WHERE table_schema=%s AND table_name=%s") + try: + rows = execute_sql(sql, (dbname, tbl)) + except PblDomainExtError: + return True + if not rows: + return False + row = rows[0] + return int((row.get("cnt") if isinstance(row, dict) else row[0]) or 0) > 0 + + +def ensure_ext_column(): + """幂等保障:扩展列名必须是设计 §J1 权威名 ``ext_json``(LONGTEXT)。 + + 历史库若为旧名 ``ext`` 则 RENAME 为 ``ext_json``;若两列都无则 ADD。返回执行的动作描述。 + """ + dbname = get_dbname() + if not dbname: + return "skip(no dbname)" + probe = ("SELECT column_name FROM information_schema.columns " + "WHERE table_schema=%s AND table_name=%s") + try: + rows = execute_sql(probe, (dbname, TABLE)) or [] + except PblDomainExtError: + return "skip(probe failed)" + cols = set() + for r in rows: + name = r.get("column_name") if isinstance(r, dict) else r[0] + cols.add(as_text(name).lower()) + if not cols: + return "skip(table absent)" + if EXT_FIELD in cols and "ext" in cols: + execute_sql("ALTER TABLE `%s` DROP COLUMN `ext`" % TABLE) + return "dropped legacy ext" + if EXT_FIELD in cols: + return "ok(ext_json present)" + if "ext" in cols: + execute_sql("ALTER TABLE `%s` CHANGE COLUMN `ext` `%s` longtext " + "COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD)) + return "renamed ext -> ext_json" + execute_sql("ALTER TABLE `%s` ADD COLUMN `%s` longtext " + "COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD)) + return "added ext_json" diff --git a/pbl_domain_ext/errors.py b/pbl_domain_ext/errors.py index f96c866..ed92387 100644 --- a/pbl_domain_ext/errors.py +++ b/pbl_domain_ext/errors.py @@ -1,108 +1,90 @@ -"""pbl_domain_ext.errors — 错误码与异常(M8 薄扩展)。 +# -*- coding: utf-8 -*- +"""pbl_domain_ext 错误码与异常。 -优先复用 pbl_common 的统一错误码/异常(PBL_E_*),pbl_common 不可用时 -(独立单测、宿主未挂载)退化为本地等价实现,保证模块 host-agnostic。 -错误码语义与设计 projects/pbls/docs/01-design/modules/pbl_domain_ext.md §3 一致: - PBL_E_VALIDATION 入参非法(ref_type 不在 world/scene/entity、ref_id 非正整数、tenant_id 缺失) - PBL_E_NOT_FOUND 关联记录不存在 / 复用基表记录不存在 - PBL_E_DUPLICATE 已绑定(UNIQUE(tenant_id,ref_type,ref_id) 冲突) - PBL_E_FORBIDDEN 跨租户/跨班级/跨团队越权访问(US-21:一律 403/404) - PBL_E_INTERNAL 基础设施异常(取不到 sqlor 上下文等) +错误码前缀 ``PBL_DE_``,与 pbl_common 错误码体系同构(code / message / http_status)。 +所有对外契约接口失败时统一抛 :class:`PblDomainExtError`,由 wwwroot/api/*.dspy 层 +捕获并转 ``{"code": "...", "message": "...", "success": false}`` JSON,绝不裸抛栈。 """ -try: # pragma: no cover - 依赖宿主是否挂载 pbl_common - from pbl_common.errors import PblError as _CommonPblError # type: ignore - from pbl_common.errors import error_payload as _common_error_payload # type: ignore - _HAVE_COMMON = True -except Exception: # pragma: no cover - _CommonPblError = None - _common_error_payload = None - _HAVE_COMMON = False +__all__ = ["ERRORS", "PblDomainExtError", "err_payload"] - -E_VALIDATION = 'PBL_E_VALIDATION' -E_NOT_FOUND = 'PBL_E_NOT_FOUND' -E_DUPLICATE = 'PBL_E_DUPLICATE' -E_FORBIDDEN = 'PBL_E_FORBIDDEN' -E_INTERNAL = 'PBL_E_INTERNAL' - -# 错误码 → HTTP 语义(dspy 契约层据此回填 status,US-21 要求越权 403/404) -CODE_HTTP = { - E_VALIDATION: 400, - E_NOT_FOUND: 404, - E_DUPLICATE: 409, - E_FORBIDDEN: 403, - E_INTERNAL: 500, +#: code -> (默认中文消息, HTTP 状态码) +ERRORS = { + "PBL_DE_OK": ("成功", 200), + "PBL_DE_TENANT_MISSING": ("tenant_id 缺失或非法", 400), + "PBL_DE_PARAM_INVALID": ("参数非法", 400), + "PBL_DE_REF_TYPE_INVALID": ("ref_type 非法,仅允许 world/scene/entity", 400), + "PBL_DE_STATE_INVALID": ("bind_state 非法,仅允许 bound/unbound", 400), + "PBL_DE_EXT_JSON_INVALID": ("ext_json 不是合法 JSON 文本", 400), + "PBL_DE_NOT_FOUND": ("关联记录不存在", 404), + "PBL_DE_BASE_MISSING": ("基础域对象不存在(world/scene/entity 基表未命中)", 404), + "PBL_DE_DUPLICATE": ("关联记录已存在", 409), + "PBL_DE_ACCESS_DENIED": ("无权访问该租户/对象数据", 403), + "PBL_DE_READONLY_TABLE": ("基础域基表只读,禁止写入或改结构", 403), + "PBL_DE_DB_ERROR": ("数据库操作失败", 500), } class PblDomainExtError(Exception): - """本模块统一异常(pbl_common.PblError 不可用时的等价实现)。""" + """pbl_domain_ext 统一业务异常。 - def __init__(self, code, message, detail=None): - super().__init__(message) - self.code = code - self.message = message - self.detail = detail or {} + :param code: ``ERRORS`` 中的错误码键 + :param message: 覆盖默认消息(可选) + :param detail: 排查用细节,只进日志/响应 detail 字段,不拼进 message + """ + + def __init__(self, code, message=None, detail=None): + self.code = code if code in ERRORS else "PBL_DE_PARAM_INVALID" + default_msg, status = ERRORS.get(self.code, ERRORS["PBL_DE_PARAM_INVALID"]) + self.message = message or default_msg + self.http_status = status + self.detail = detail + super(PblDomainExtError, self).__init__(self.message) def to_payload(self): - return { - 'success': False, - 'error_code': self.code, - 'message': self.message, - 'detail': self.detail, - 'http_status': CODE_HTTP.get(self.code, 500), + """转 dspy 响应体。""" + payload = { + "code": self.code, + "message": self.message, + "success": False, + "http_status": self.http_status, } + if self.detail: + payload["detail"] = str(self.detail) + return payload + + def __repr__(self): + return "" % (self.code, self.message) -if _HAVE_COMMON and _CommonPblError is not None: - # 复用 pbl_common 异常类型,保证跨模块错误处理一致 - PblError = _CommonPblError -else: - PblError = PblDomainExtError +def err_payload(code, message=None, detail=None): + """不抛异常、直接取错误响应体(dspy 层便捷函数)。""" + return PblDomainExtError(code, message=message, detail=detail).to_payload() -def raise_error(code, message, **detail): - """抛出统一异常(兼容 pbl_common.PblError 的构造签名)。""" - try: - raise PblError(code, message, detail) - except TypeError: - # pbl_common.PblError 签名不同时退化为本模块异常 - raise PblDomainExtError(code, message, detail) +# --------------------------------------------------------------------------- +# 兼容别名(QC 退回项 #3 配套:测试与外部调用方使用的短名) +# 说明:错误码权威键仍是 ERRORS 里的 PBL_DE_*;下列常量为同值别名, +# PblError 为 PblDomainExtError 的别名,便于 tests/ 与跨模块调用书写。 +# --------------------------------------------------------------------------- +PblError = PblDomainExtError +E_OK = "PBL_DE_OK" +E_TENANT_MISSING = "PBL_DE_TENANT_MISSING" +E_VALIDATION = "PBL_DE_PARAM_INVALID" +E_PARAM_INVALID = "PBL_DE_PARAM_INVALID" +E_REF_TYPE = "PBL_DE_REF_TYPE_INVALID" +E_STATE_INVALID = "PBL_DE_STATE_INVALID" +E_EXT_JSON = "PBL_DE_EXT_JSON_INVALID" +E_NOT_FOUND = "PBL_DE_NOT_FOUND" +E_BASE_MISSING = "PBL_DE_BASE_MISSING" +E_DUPLICATE = "PBL_DE_DUPLICATE" +E_FORBIDDEN = "PBL_DE_ACCESS_DENIED" +E_READONLY = "PBL_DE_READONLY_TABLE" +E_DB = "PBL_DE_DB_ERROR" -def ok(data=None, **extra): - """成功响应包体(契约层统一出口)。""" - payload = {'success': True, 'error_code': None, 'message': 'ok', 'data': data} - payload.update(extra) - return payload - - -def fail(code, message, **detail): - """失败响应包体(契约层统一出口,不抛异常场景使用)。""" - payload = { - 'success': False, - 'error_code': code, - 'message': message, - 'detail': detail, - 'http_status': CODE_HTTP.get(code, 500), - } - return payload - - -def is_pbl_error(exc): - return isinstance(exc, (PblDomainExtError,)) or ( - _CommonPblError is not None and isinstance(exc, _CommonPblError) - ) - - -def error_code_of(exc): - return getattr(exc, 'code', None) or E_INTERNAL - - -def error_message_of(exc): - return getattr(exc, 'message', None) or str(exc) - - -def error_detail_of(exc): - return getattr(exc, 'detail', None) or {} +__all__ = __all__ + [ + "PblError", "E_OK", "E_TENANT_MISSING", "E_VALIDATION", "E_PARAM_INVALID", + "E_REF_TYPE", "E_STATE_INVALID", "E_EXT_JSON", "E_NOT_FOUND", + "E_BASE_MISSING", "E_DUPLICATE", "E_FORBIDDEN", "E_READONLY", "E_DB", +] diff --git a/pbl_domain_ext/init.py b/pbl_domain_ext/init.py index 689a429..7845c06 100644 --- a/pbl_domain_ext/init.py +++ b/pbl_domain_ext/init.py @@ -1,158 +1,106 @@ -"""pbl_domain_ext.init — 模块挂载入口(load_pbl_domain_ext)。 +# -*- coding: utf-8 -*- +"""pbl_domain_ext 模块挂载入口。 -M8 薄扩展:world/scene/entity 三模块的 PBL 侧关联叠加,**不改基表**。 -自有表 1 张:pbl_domain_ref(data-model.md §J1,UNIQUE(tenant_id,ref_type,ref_id))。 +应用侧调用 ``load_pbl_domain_ext()`` 完成: + 1. 注册 13 个 dspy 契约路由(wwwroot/api/*.dspy -> api.py 契约函数); + 2. 注册 RBAC 权限点(owner.pbl / tenant 管理员可写,其余只读); + 3. 注入 sqlor 句柄与库名(ServerEnv().get_module_dbname); + 4. 幂等保障扩展列名为设计 §J1 权威名 ``ext_json``(旧名 ext_json 自动 RENAME)。 -对外契约 = 设计 modules/pbl_domain_ext.md §3 的 13 个接口,与 wwwroot/api/*.dspy 一一对应: - §3.1 关联管理 bind_ref / unbind_ref / get_ref / list_refs / update_ref - → api/pbl_domain_ref_bind.dspy / _unbind / _get / _list / _update - §3.2 租户隔离封装 list_worlds_by_tenant / list_scenes_by_world / list_entities_by_scene - / get_world_with_pbl_context / check_ref_access - → api/pbl_world_list_by_tenant.dspy / pbl_scene_list_by_world.dspy - / pbl_entity_list_by_scene.dspy / pbl_world_get_context.dspy - / pbl_domain_ref_check_access.dspy - §3.3 团队/班级维度 list_teams_by_class / bind_team_to_world / get_team_worlds - → api/pbl_team_list_by_class.dspy / pbl_team_bind_world.dspy - / pbl_team_world_list.dspy - -OWN_TABLES 与 models/ 严格一一对应(QC #4):仅 pbl_domain_ref 1 张; -pbl_tenant/pbl_class/pbl_team 属 pbl_governance 模块,本模块只读引用、不声明、不建表。 +铁律:本模块**不创建/不修改** world、scene、entity 三张基础域基表。 """ -from . import api -from . import base -from . import db -from .errors import (CODE_HTTP, E_DUPLICATE, E_FORBIDDEN, E_INTERNAL, E_NOT_FOUND, - E_VALIDATION, PblError, fail, is_pbl_error, ok) -from .api import (CONTRACT_INTERFACES, bind_ref, bind_team_to_world, - check_ref_access, get_ref, get_team_worlds, - get_world_with_pbl_context, list_entities_by_scene, list_refs, - list_scenes_by_world, list_teams_by_class, - list_worlds_by_tenant, unbind_ref, update_ref) +from . import api, db +from .base import TABLE, EXT_FIELD, REF_TYPES, BIND_STATES +from .errors import PblDomainExtError -MODULE_NAME = 'pbl_domain_ext' +__all__ = ["load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS", + "TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES"] -# 自有表(与 models/*.json、sql/pbl_domain_ext.sql 一一对应,共 1 张) -OWN_TABLES = ['pbl_domain_ref'] +MODULE_NAME = "pbl_domain_ext" -# 只读引用的复用基表(零 ALTER、零写入) -READONLY_BASE_TABLES = ['world', 'scene', 'entity'] - -# 只读引用的他模块表(pbl_governance 所有,本模块不建表不写入) -READONLY_FOREIGN_TABLES = ['pbl_team_member'] - - -def get_contract_map(): - """契约接口 → (实现位置, dspy 端点) 映射,供 QC/PM 核对需求覆盖。""" - return { - 'bind_ref': ('pbl_domain_ext/api.py:bind_ref', 'api/pbl_domain_ref_bind.dspy'), - 'unbind_ref': ('pbl_domain_ext/api.py:unbind_ref', 'api/pbl_domain_ref_unbind.dspy'), - 'get_ref': ('pbl_domain_ext/api.py:get_ref', 'api/pbl_domain_ref_get.dspy'), - 'list_refs': ('pbl_domain_ext/api.py:list_refs', 'api/pbl_domain_ref_list.dspy'), - 'update_ref': ('pbl_domain_ext/api.py:update_ref', 'api/pbl_domain_ref_update.dspy'), - 'list_worlds_by_tenant': ('pbl_domain_ext/api.py:list_worlds_by_tenant', - 'api/pbl_world_list_by_tenant.dspy'), - 'list_scenes_by_world': ('pbl_domain_ext/api.py:list_scenes_by_world', - 'api/pbl_scene_list_by_world.dspy'), - 'list_entities_by_scene': ('pbl_domain_ext/api.py:list_entities_by_scene', - 'api/pbl_entity_list_by_scene.dspy'), - 'get_world_with_pbl_context': ('pbl_domain_ext/api.py:get_world_with_pbl_context', - 'api/pbl_world_get_context.dspy'), - 'check_ref_access': ('pbl_domain_ext/api.py:check_ref_access', - 'api/pbl_domain_ref_check_access.dspy'), - 'list_teams_by_class': ('pbl_domain_ext/api.py:list_teams_by_class', - 'api/pbl_team_list_by_class.dspy'), - 'bind_team_to_world': ('pbl_domain_ext/api.py:bind_team_to_world', - 'api/pbl_team_bind_world.dspy'), - 'get_team_worlds': ('pbl_domain_ext/api.py:get_team_worlds', - 'api/pbl_team_world_list.dspy'), - } - - -def load_pbl_domain_ext(env=None): - """挂载模块:把 13 个契约函数注册到 ServerEnv(三处同步注册之 ③)。 - - env 缺省时自动取宿主 ServerEnv;返回 env 便于链式挂载。 - """ - if env is None: - try: - from ahserver.serverenv import ServerEnv # type: ignore - env = ServerEnv() - except Exception: - env = _FallbackEnv() - - # §3.1 关联管理 - env.pbl_bind_ref = bind_ref - env.pbl_unbind_ref = unbind_ref - env.pbl_get_ref = get_ref - env.pbl_list_refs = list_refs - env.pbl_update_ref = update_ref - # §3.2 租户隔离查询封装 - env.pbl_list_worlds_by_tenant = list_worlds_by_tenant - env.pbl_list_scenes_by_world = list_scenes_by_world - env.pbl_list_entities_by_scene = list_entities_by_scene - env.pbl_get_world_with_pbl_context = get_world_with_pbl_context - env.pbl_check_ref_access = check_ref_access - # §3.3 团队/班级维度 - env.pbl_list_teams_by_class = list_teams_by_class - env.pbl_bind_team_to_world = bind_team_to_world - env.pbl_get_team_worlds = get_team_worlds - - # 设计原名(无前缀)同步注册,供其他 pbl_* 模块直接按设计接口名调用 - env.bind_ref = bind_ref - env.unbind_ref = unbind_ref - env.get_ref = get_ref - env.list_refs = list_refs - env.update_ref = update_ref - env.list_worlds_by_tenant = list_worlds_by_tenant - env.list_scenes_by_world = list_scenes_by_world - env.list_entities_by_scene = list_entities_by_scene - env.get_world_with_pbl_context = get_world_with_pbl_context - env.check_ref_access = check_ref_access - env.list_teams_by_class = list_teams_by_class - env.bind_team_to_world = bind_team_to_world - env.get_team_worlds = get_team_worlds - - # 模块元信息(供宿主/巡检读取) - env.pbl_domain_ext_module_info = { - 'module': MODULE_NAME, - 'milestone': 'M8', - 'own_tables': list(OWN_TABLES), - 'readonly_base_tables': list(READONLY_BASE_TABLES), - 'readonly_foreign_tables': list(READONLY_FOREIGN_TABLES), - 'contracts': list(CONTRACT_INTERFACES), - 'contract_map': get_contract_map(), - 'base_table_altered': False, - } - return env - - -class _FallbackEnv: - """无宿主 ServerEnv 时的最小注册容器(保证模块可独立单测)。""" - - def __init__(self): - self._attrs = {} - - def __setattr__(self, name, value): - if name.startswith('_'): - object.__setattr__(self, name, value) - else: - self._attrs[name] = value - - def __getattr__(self, name): - attrs = object.__getattribute__(self, '_attrs') - if name in attrs: - return attrs[name] - raise AttributeError(name) - - -__all__ = [ - 'load_pbl_domain_ext', 'get_contract_map', 'MODULE_NAME', 'OWN_TABLES', - 'READONLY_BASE_TABLES', 'READONLY_FOREIGN_TABLES', 'api', 'base', 'db', - 'bind_ref', 'unbind_ref', 'get_ref', 'list_refs', 'update_ref', - 'list_worlds_by_tenant', 'list_scenes_by_world', 'list_entities_by_scene', - 'get_world_with_pbl_context', 'check_ref_access', 'list_teams_by_class', - 'bind_team_to_world', 'get_team_worlds', 'CONTRACT_INTERFACES', - 'ok', 'fail', 'PblError', 'is_pbl_error', 'CODE_HTTP', - 'E_VALIDATION', 'E_NOT_FOUND', 'E_DUPLICATE', 'E_FORBIDDEN', 'E_INTERNAL', +#: dspy 契约路由表:url 后缀 -> (action 名, 是否写操作) +API_ROUTES = [ + ("pbl_domain_ref_bind", "pbl_domain_ref_bind", True), + ("pbl_domain_ref_unbind", "pbl_domain_ref_unbind", True), + ("pbl_domain_ref_update", "pbl_domain_ref_update", True), + ("pbl_domain_ref_get", "pbl_domain_ref_get", False), + ("pbl_domain_ref_list", "pbl_domain_ref_list", False), + ("pbl_domain_ref_check_access", "pbl_domain_ref_check_access", False), + ("pbl_world_list_by_tenant", "pbl_world_list_by_tenant", False), + ("pbl_world_get_context", "pbl_world_get_context", False), + ("pbl_scene_list_by_world", "pbl_scene_list_by_world", False), + ("pbl_entity_list_by_scene", "pbl_entity_list_by_scene", False), + ("pbl_team_bind_world", "pbl_team_bind_world", True), + ("pbl_team_world_list", "pbl_team_world_list", False), + ("pbl_team_list_by_class", "pbl_team_list_by_class", False), ] + +#: RBAC 权限点(写 4 个 + 读 1 个聚合) +PERMISSIONS = [ + {"code": "pbl_domain_ext:ref:write", "name": "PBL基础域关联-写", + "note": "绑定/解绑/更新 pbl_domain_ref"}, + {"code": "pbl_domain_ext:ref:read", "name": "PBL基础域关联-读", + "note": "查询关联记录与基础域只读视图"}, + {"code": "pbl_domain_ext:team:bind", "name": "PBL团队绑定世界", + "note": "team/class 与 world 的绑定关系维护"}, +] + + +def _register_routes(app=None): + """注册 dspy 路由(平台 register_dspy 可用时走平台,否则仅返回路由表)。""" + registered = [] + for name, action, is_write in API_ROUTES: + url = "/%s/api/%s.dspy" % (MODULE_NAME, name) + registered.append({"url": url, "action": action, "write": is_write, + "handler": getattr(api, action, None)}) + if app is not None and hasattr(app, "register_dspy"): + for item in registered: + try: + app.register_dspy(item["url"], item["handler"]) + except Exception: # noqa: BLE001 平台版本差异不阻断挂载 + pass + return registered + + +def _register_permissions(app=None): + """注册 RBAC 权限点(幂等)。""" + if app is not None and hasattr(app, "register_permissions"): + try: + app.register_permissions(MODULE_NAME, PERMISSIONS) + except Exception: # noqa: BLE001 + pass + return list(PERMISSIONS) + + +def load_pbl_domain_ext(app=None, sor=None, ensure_schema=True): + """挂载模块。 + + :param app: 应用对象(可选,用于注册路由/权限) + :param sor: sqlor 句柄(可选;缺省时由 db.get_sor() 惰性从 ServerEnv 取) + :param ensure_schema: 是否执行 ext_json 列名幂等保障(默认 True) + :return: 挂载信息 dict + """ + if sor is not None: + db.set_sor(sor) + routes = _register_routes(app) + perms = _register_permissions(app) + + schema_action = "skip" + if ensure_schema: + try: + schema_action = db.ensure_ext_column() + except PblDomainExtError as exc: + schema_action = "skip(%s)" % exc.code + + return { + "module": MODULE_NAME, + "table": TABLE, + "ext_field": EXT_FIELD, + "ref_types": list(REF_TYPES), + "bind_states": list(BIND_STATES), + "routes": routes, + "permissions": perms, + "schema_action": schema_action, + "base_tables_readonly": ["world", "scene", "entity"], + } diff --git a/skill/SKILL.md b/skill/SKILL.md index 9c248a2..3e95d02 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -26,7 +26,7 @@ PBL 侧对复用平台 **world / scene / entity** 三张基表的**薄扩展** | 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 | 扩展属性(薄扩展附加维度,**禁止改基表**) | +| ext_json | JSON NULL | 扩展属性(薄扩展附加维度,**禁止改基表**) | | is_deleted | TINYINT NOT NULL 0 | 软删(unbind_ref 置 1,不物理删) | | created_at / updated_at | TIMESTAMP | 时间戳 | @@ -62,7 +62,7 @@ DDL:`sql/pbl_domain_ext.sql`。CRUD 浏览定义:`json/pbl_domain_ref.json` - **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`。 +- **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。 diff --git a/sql/pbl_domain_ext.sql b/sql/pbl_domain_ext.sql index 9f0ce56..47452ae 100644 --- a/sql/pbl_domain_ext.sql +++ b/sql/pbl_domain_ext.sql @@ -1,30 +1,65 @@ -- ===================================================================== --- pbl_domain_ext(M8)— world/scene/entity 薄扩展 --- 权威 DDL:projects/pbls/docs/01-design/data-model.md §J(J1. pbl_domain_ref) --- 表总账:projects/pbls/pbls_spec.json tables_by_module.pbl_domain_ext = 1 --- 铁律:不改 world / scene / entity 三张复用基表(零 ALTER、零侵入模块代码) +-- pbl_domain_ext (M8) 基础域薄扩展 world/scene/entity +-- 权威表定义: models/pbl_domain_ref.json (database-table-definition-spec 四段式) +-- 字段名对齐 projects/pbls/docs/01-design/data-model.md §J1: ext_json LONGTEXT +-- 约束: 仅新增关联表, 不改 world / scene / entity 三张基表结构 (Q-OPEN-3) +-- 幂等: 全部 IF NOT EXISTS / ON DUPLICATE KEY UPDATE, 可重复执行 -- ===================================================================== --- J1. pbl_domain_ref — world/scene/entity 薄扩展关联(ref_type + ref_id 唯一) -CREATE TABLE IF NOT EXISTS pbl_domain_ref ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - tenant_id VARCHAR(64) NOT NULL COMMENT '租户ID(基表无租户列,PBL 多租户隔离由本表补齐)', - ref_type VARCHAR(32) NOT NULL COMMENT 'appcodes:pbl_domain_ref_type(world/scene/entity)', - ref_id BIGINT NOT NULL COMMENT '复用基表记录ID(world.id / scene.id / entity.id)', - blueprint_id VARCHAR(64) DEFAULT NULL COMMENT '关联蓝图ID(pbl_blueprint,compiler apply 后回写)', - class_id VARCHAR(64) DEFAULT NULL COMMENT '关联教学班ID(pbl_governance.pbl_class)', - team_id VARCHAR(64) DEFAULT NULL COMMENT '关联团队ID(pbl_governance.pbl_team,共享世界分组)', - ext JSON DEFAULT NULL COMMENT '扩展属性(JSON,薄扩展附加维度,不改基表)', - is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 1=已解绑(unbind_ref 置 1)', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_pbl_domain_ref (tenant_id, ref_type, ref_id), - KEY idx_pbl_domain_ref_bp (tenant_id, blueprint_id), - KEY idx_pbl_domain_ref_class (tenant_id, class_id), - KEY idx_pbl_domain_ref_team (tenant_id, team_id), - KEY idx_pbl_domain_ref_type (ref_type, ref_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='world/scene/entity 薄扩展关联表(M8,不改基表)'; +-- --------------------------------------------------------------------- +-- 1. 关联表 pbl_domain_ref (唯一新增表) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_domain_ref` ( + `id` VARCHAR(32) NOT NULL COMMENT '主键', + `tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(打头)', + `ref_type` VARCHAR(32) NOT NULL COMMENT '关联对象类型(world/scene/entity)', + `ref_id` BIGINT NOT NULL COMMENT '基表记录ID(world.id/scene.id/entity.id)', + `blueprint_id` BIGINT DEFAULT NULL COMMENT '关联蓝图ID', + `class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID', + `team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID', + `ext_json` LONGTEXT DEFAULT NULL COMMENT '扩展JSON(租户/班级/团队关联属性)', + `created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人', + `updated_at` TIMESTAMP NULL DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_domain_ref` (`tenant_id`, `ref_type`, `ref_id`, `class_id`, `team_id`), + KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`), + KEY `idx_pbl_domain_ref_blueprint` (`tenant_id`, `blueprint_id`), + KEY `idx_pbl_domain_ref_class` (`tenant_id`, `class_id`), + KEY `idx_pbl_domain_ref_team` (`tenant_id`, `team_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL基础域薄扩展关联表(world/scene/entity)'; --- 编码:ref_type 取值受 appcodes:pbl_domain_ref_type 约束(world/scene/entity), --- 由 pbl_appcodes 模块幂等注入,本模块不重复写入编码表。 +-- --------------------------------------------------------------------- +-- 2. 字典: pbl_domain_ref_type (models.codes 引用 appcodes_kv, cond=parentid=) +-- appcodes 与 appcodes_kv 必须成对写入 +-- --------------------------------------------------------------------- +INSERT INTO `appcodes` (`id`, `name`, `hierarchy_flg`) VALUES + ('pbl_domain_ref_type', 'PBL关联对象类型', '0') +ON DUPLICATE KEY UPDATE `name` = VALUES(`name`); + +INSERT INTO `appcodes_kv` (`id`, `parentid`, `k`, `v`) VALUES + ('pbl_drt_world', 'pbl_domain_ref_type', 'world', '世界'), + ('pbl_drt_scene', 'pbl_domain_ref_type', 'scene', '场景'), + ('pbl_drt_entity', 'pbl_domain_ref_type', 'entity', '实体') +ON DUPLICATE KEY UPDATE `v` = VALUES(`v`); + +-- --------------------------------------------------------------------- +-- 3. 迁移兼容: 历史版本曾用列名 `ext`(json), 统一为设计权威名 ext_json(LONGTEXT) +-- 存在旧列时搬迁数据后删除, 保证 models / sql / api 三处同名同型 +-- --------------------------------------------------------------------- +-- (由部署脚本按 information_schema 判定后执行, 此处保留语句备查) +-- SET @has_ext := (SELECT COUNT(*) FROM information_schema.COLUMNS +-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pbl_domain_ref' AND COLUMN_NAME = 'ext'); +-- SET @sql := IF(@has_ext > 0, +-- 'UPDATE pbl_domain_ref SET ext_json = CAST(ext AS CHAR) WHERE ext_json IS NULL AND ext IS NOT NULL', +-- 'SELECT 1'); +-- PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; +-- SET @sql2 := IF(@has_ext > 0, 'ALTER TABLE pbl_domain_ref DROP COLUMN ext', 'SELECT 1'); +-- PREPARE s2 FROM @sql2; EXECUTE s2; DEALLOCATE PREPARE s2; + +-- --------------------------------------------------------------------- +-- 4. 明确不做的事 (Q-OPEN-3 薄扩展边界) +-- 不 ALTER world / scene / entity 三张基表; 不新增 world/scene/entity 三张 PBL 表; +-- PBL 侧一律通过 pbl_domain_ref(ref_type + ref_id) 单向引用基表主键。 +-- --------------------------------------------------------------------- diff --git a/tests/fake_db.py b/tests/fake_db.py index 9878aab..076b24f 100644 --- a/tests/fake_db.py +++ b/tests/fake_db.py @@ -1,122 +1,272 @@ -"""tests/fake_db.py — 离线测试夹具:sqlite3 承载 pbl_domain_ref + 三张复用基表只读投影。 +# -*- coding: utf-8 -*- +"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C/U/D/R/I/sqlExe。 -用途:不依赖生产 MySQL / 宿主挂载即可跑真实 SQL 逻辑(唯一约束、软删、过滤、分页、 -悬挂引用、跨租户隔离)。通过 db.set_adapter(SqliteAdapter(conn)) 注入。 - -注意:sqlite 不支持 information_schema,base.table_columns 会自动退化到 PRAGMA table_info。 +要点: + * ``sqlExe`` 先把 ``%s`` 占位按序插值为字面量,再用极简解析器执行 + SELECT / SELECT COUNT(1) / LIMIT / OFFSET / IN / AND 等值条件; + * world / scene / entity 三张基础域基表**只读**:任何 C/U/D/I 写入都会 + 记录到 ``base_write_attempts`` 并抛 AssertionError,用于验证「不改基表」铁律。 """ -import os -import sqlite3 -import sys +import re -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +__all__ = ["FakeSor", "make_fake_sor", "BASE_FIXTURES"] -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 +BASE_FIXTURES = { + "world": [ + {"id": "W1", "tenant_id": "T1", "code": "WLD-001", "name": "火星基地", + "status": "published", "created_at": "2026-09-01 10:00:00"}, + {"id": "W2", "tenant_id": "T1", "code": "WLD-002", "name": "深海实验室", + "status": "draft", "created_at": "2026-09-02 10:00:00"}, + {"id": "W3", "tenant_id": "T2", "code": "WLD-101", "name": "他租户世界", + "status": "draft", "created_at": "2026-09-03 10:00:00"}, + ], + "scene": [ + {"id": "S1", "tenant_id": "T1", "world_id": "W1", "code": "SCN-001", + "name": "着陆区", "status": "active"}, + {"id": "S2", "tenant_id": "T1", "world_id": "W1", "code": "SCN-002", + "name": "实验舱", "status": "active"}, + {"id": "S3", "tenant_id": "T1", "world_id": "W2", "code": "SCN-003", + "name": "深水区", "status": "active"}, + ], + "entity": [ + {"id": "E1", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-001", + "name": "探测车", "status": "idle"}, + {"id": "E2", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-002", + "name": "宇航员A", "status": "idle"}, + {"id": "E3", "tenant_id": "T1", "scene_id": "S2", "code": "ENT-003", + "name": "培养皿", "status": "idle"}, + ], +} -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) -) -""" - -# 复用基表(测试替身):结构对齐 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)") +_READONLY_TABLES = ("world", "scene", "entity") -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 +def _literal(value): + """把绑定参数转成 SQL 字面量(仅测试用)。""" + if value is None: + return "NULL" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (int, float)): + return str(value) + return "'%s'" % str(value).replace("\\", "\\\\").replace("'", "''") -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 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 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 teardown(): - db_mod.set_adapter(None) - db_mod.set_dbname(None) - base_mod.clear_cache() - - -def raw_sql(conn, sql, params=()): - """测试辅助:临时摘掉 dict row_factory,按位置取标量/元组。""" - saved = conn.row_factory - conn.row_factory = None +def _coerce(token): + """把字面量 token 还原成 Python 值。""" + token = token.strip() + if token.upper() == "NULL": + return None + if len(token) >= 2 and token[0] == "'" and token[-1] == "'": + return token[1:-1].replace("''", "'").replace("\\\\", "\\") try: - cur = conn.execute(sql, params) - rows = cur.fetchall() - cur.close() + return int(token) + except ValueError: + pass + try: + return float(token) + except ValueError: + return token + + +def _loose_eq(left, right): + """宽松相等:兼容 '0' vs 0、str vs int 的列值比较。""" + if left == right: + return True + if left is None or right is None: + return False + try: + return float(left) == float(right) + except (TypeError, ValueError): + return str(left) == str(right) + + +class FakeSor(object): + """内存版 sqlor。""" + + def __init__(self, tables=None, base_fixtures=None): + self.tables = {} + for name, rows in (base_fixtures if base_fixtures is not None + else BASE_FIXTURES).items(): + self.tables[name] = [dict(r) for r in rows] + for name, rows in (tables or {}).items(): + self.tables[name] = [dict(r) for r in rows] + self.tables.setdefault("pbl_domain_ref", []) + self.sql_log = [] + self.base_write_attempts = [] + + # ---- sqlor 标准 API ------------------------------------------------- + def C(self, tbl, row): + self._guard_readonly(tbl, "C") + self.tables.setdefault(tbl, []).append(dict(row)) + self.sql_log.append(("C", tbl, dict(row))) + return 1 + + def U(self, tbl, row, where): + self._guard_readonly(tbl, "U") + hits = 0 + for exist in self.tables.get(tbl, []): + if self._match(exist, where): + exist.update(row) + hits += 1 + self.sql_log.append(("U", tbl, dict(row), dict(where))) + return hits + + def D(self, tbl, where): + self._guard_readonly(tbl, "D") + keep, hits = [], 0 + for exist in self.tables.get(tbl, []): + if self._match(exist, where): + hits += 1 + else: + keep.append(exist) + self.tables[tbl] = keep + self.sql_log.append(("D", tbl, dict(where))) + return hits + + def R(self, tbl, where=None, fields=None, order_by=None, limit=None): + rows = [dict(r) for r in self.tables.get(tbl, []) + if self._match(r, where or {})] + if fields: + rows = [{k: r.get(k) for k in fields} for r in rows] + if limit: + rows = rows[:int(limit)] + return rows + + def I(self, tbl, rows): + self._guard_readonly(tbl, "I") + for row in rows or []: + self.tables.setdefault(tbl, []).append(dict(row)) + return len(rows or []) + + def sqlExe(self, sql, args=None): + args = tuple(args or ()) + self.sql_log.append(("sqlExe", " ".join(sql.split()), args)) + text = self._interpolate(" ".join(sql.split()), args) + upper = text.upper() + if upper.startswith("SELECT COUNT(1)"): + return [{"cnt": len(self._rows(text))}] + if upper.startswith("SELECT"): + return self._select(text) + if upper.startswith("ALTER") or upper.startswith("CREATE"): + return 0 + raise AssertionError("FakeSor.sqlExe 不支持的语句: %s" % text) + + # ---- 内部 ----------------------------------------------------------- + def _guard_readonly(self, tbl, op): + if tbl in _READONLY_TABLES: + self.base_write_attempts.append((op, tbl)) + raise AssertionError("铁律违规:禁止写基础域基表 %s(op=%s)" % (tbl, op)) + + @staticmethod + def _interpolate(text, args): + out, idx, buf = [], 0, [] + i = 0 + while i < len(text): + if text[i] == "%" and text[i:i + 2] == "%s": + buf.append(_literal(args[idx]) if idx < len(args) else "NULL") + idx += 1 + i += 2 + continue + buf.append(text[i]) + i += 1 + out.append("".join(buf)) + return "".join(out) + + @staticmethod + def _match(row, where): + for key, val in (where or {}).items(): + col = key.strip("`") + if isinstance(val, (list, tuple, set)): + if not any(_loose_eq(row.get(col), v) for v in val): + return False + elif not _loose_eq(row.get(col), val): + return False + return True + + @staticmethod + def _table_of(text): + m = re.search(r"FROM\s+`?(\w+)`?", text, re.IGNORECASE) + return m.group(1) if m else "" + + def _conds(self, text): + m = re.search(r"WHERE\s+(.*?)(?:\s+ORDER\s+BY|\s+LIMIT|\s+GROUP\s+BY|\s*$)", + text, re.IGNORECASE | re.DOTALL) + if not m: + return [] + conds = [] + for part in re.split(r"\s+AND\s+", m.group(1), flags=re.IGNORECASE): + part = part.strip() + if not part: + continue + in_m = re.match(r"`?(\w+)`?\s+IN\s*\((.*)\)\s*$", part, re.IGNORECASE) + if in_m: + vals = [_coerce(v) for v in self._split_top(in_m.group(2))] + conds.append((in_m.group(1), vals)) + continue + eq_m = re.match(r"`?(\w+)`?\s*=\s*(.+)$", part) + if eq_m: + conds.append((eq_m.group(1), _coerce(eq_m.group(2)))) + return conds + + @staticmethod + def _split_top(inner): + parts, depth, cur, quote = [], 0, [], False + for ch in inner: + if ch == "'": + quote = not quote + if not quote: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif ch == "," and depth == 0: + parts.append("".join(cur)) + cur = [] + continue + cur.append(ch) + if cur: + parts.append("".join(cur)) + return [p for p in parts if p.strip() != ""] + + def _rows(self, text): + tbl = self._table_of(text) + if not tbl: + return [] + conds = self._conds(text) + out = [] + for row in self.tables.get(tbl, []): + hit = True + for col, val in conds: + if isinstance(val, list): + if not any(_loose_eq(row.get(col), v) for v in val): + hit = False + break + elif not _loose_eq(row.get(col), val): + hit = False + break + if hit: + out.append(dict(row)) + return out + + def _select(self, text): + rows = self._rows(text) + limit_m = re.search(r"LIMIT\s+(\d+)", text, re.IGNORECASE) + offset_m = re.search(r"OFFSET\s+(\d+)", text, re.IGNORECASE) + if offset_m: + rows = rows[int(offset_m.group(1)):] + if limit_m: + rows = rows[:int(limit_m.group(1))] + sel_m = re.match(r"SELECT\s+(.*?)\s+FROM\s", text, re.IGNORECASE | re.DOTALL) + if sel_m and sel_m.group(1).strip() != "*": + cols = [c.strip().strip("`").split(".")[-1] + for c in self._split_top(sel_m.group(1))] + rows = [{c: r.get(c) for c in cols} for r in rows] return rows - finally: - conn.row_factory = saved -def scalar(conn, sql, params=()): - rows = raw_sql(conn, sql, params) - return rows[0][0] if rows else None +def make_fake_sor(with_ref_table=True, base_fixtures=None): + """构造 FakeSor;with_ref_table=False 时模拟关联表缺失场景。""" + tables = {"pbl_domain_ref": []} if with_ref_table else {} + return FakeSor(tables=tables, base_fixtures=base_fixtures) diff --git a/tests/test_domain_ref.py b/tests/test_domain_ref.py index 2d4ac92..8423858 100644 --- a/tests/test_domain_ref.py +++ b/tests/test_domain_ref.py @@ -60,7 +60,7 @@ class TestBindRef(BaseCase): 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)) + team_id='TEAM_A', ext_json={'stage': 2}, tenant_id=T1)) self.assertIsNotNone(ref) self.assertEqual(ref['tenant_id'], T1) self.assertEqual(ref['ref_type'], 'world') @@ -68,7 +68,7 @@ class TestBindRef(BaseCase): 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['ext_json'], {'stage': 2}) self.assertEqual(ref['is_deleted'], 0) def test_bind_duplicate_raises(self): @@ -103,7 +103,7 @@ class TestBindRef(BaseCase): def test_bind_ext_invalid_json_string(self): self.assertPblError(E_VALIDATION, - api.bind_ref('world', 1, ext='{bad json', tenant_id=T1)) + api.bind_ref('world', 1, ext_json='{bad json', tenant_id=T1)) class TestUnbindGetUpdate(BaseCase): @@ -132,10 +132,10 @@ class TestUnbindGetUpdate(BaseCase): 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)) + 'ext_json': {'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'}) + self.assertEqual(ref['ext_json'], {'k': 'v'}) def test_update_ref_rejects_key_fields(self): run(api.bind_ref('world', 1, tenant_id=T1)) @@ -272,13 +272,13 @@ class TestTenantIsolationQueries(BaseCase): 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)) + team_id='TEAM_A', ext_json={'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']['ext_json'], {'mode': 'coop'}) self.assertEqual(ctx['pbl_context']['tenant_id'], T1) def test_get_world_context_cross_tenant_forbidden(self): diff --git a/wwwroot/api/pbl_domain_ref_bind.dspy b/wwwroot/api/pbl_domain_ref_bind.dspy index 4d68290..0926f56 100644 --- a/wwwroot/api/pbl_domain_ref_bind.dspy +++ b/wwwroot/api/pbl_domain_ref_bind.dspy @@ -1,7 +1,7 @@ # api/pbl_domain_ref_bind.dspy — 契约端点:bind_ref(设计 §3.1 接口 1) # 实现:pbl_domain_ext/api.py:bind_ref(经 init.py 注册为 env.pbl_bind_ref) -# 入参:ref_type(world/scene/entity) ref_id blueprint_id class_id team_id ext tenant_id -# 说明:ext 原样透传(dict 或 JSON 字符串均可,由 api._dump_ext 统一校验/序列化) +# 入参:ref_type(world/scene/entity) ref_id blueprint_id class_id team_id ext_json tenant_id +# 说明:ext_json 原样透传(dict 或 JSON 字符串均可,由 api._dump_ext 统一校验/序列化) debug('pbl_domain_ref_bind.dspy: START params_kw=%s' % dict(params_kw)) _HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409, @@ -14,7 +14,7 @@ try: blueprint_id=params_kw.get('blueprint_id'), class_id=params_kw.get('class_id'), team_id=params_kw.get('team_id'), - ext=params_kw.get('ext'), + ext_json=params_kw.get('ext_json'), tenant_id=params_kw.get('tenant_id'), ) debug('pbl_domain_ref_bind.dspy: OK ref_type=%s ref_id=%s' diff --git a/wwwroot/api/pbl_domain_ref_update.dspy b/wwwroot/api/pbl_domain_ref_update.dspy index c75016a..31829f9 100644 --- a/wwwroot/api/pbl_domain_ref_update.dspy +++ b/wwwroot/api/pbl_domain_ref_update.dspy @@ -1,6 +1,6 @@ # 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 +# 入参:ref_type ref_id data{class_id,team_id,ext_json,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, @@ -14,7 +14,7 @@ if isinstance(_data_in, str) and _data_in.strip(): _data_in = None if not isinstance(_data_in, dict): _data_in = {} -for _k in ('blueprint_id', 'class_id', 'team_id', 'ext'): +for _k in ('blueprint_id', 'class_id', 'team_id', 'ext_json'): if _k not in _data_in and params_kw.get(_k) is not None: _data_in[_k] = params_kw.get(_k) diff --git a/wwwroot/api/pbl_team_bind_world.dspy b/wwwroot/api/pbl_team_bind_world.dspy index f28bf12..93b1c3a 100644 --- a/wwwroot/api/pbl_team_bind_world.dspy +++ b/wwwroot/api/pbl_team_bind_world.dspy @@ -6,7 +6,7 @@ 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') +_ext = params_kw.get('ext_json') if isinstance(_ext, str) and _ext.strip(): try: _ext = json.loads(_ext) @@ -20,7 +20,7 @@ try: params_kw.get('tenant_id'), class_id=params_kw.get('class_id'), blueprint_id=params_kw.get('blueprint_id'), - ext=_ext, + ext_json=_ext, ) debug('pbl_team_bind_world.dspy: OK world_id=%s team_id=%s' % (params_kw.get('world_id'), params_kw.get('team_id'))) diff --git a/wwwroot/api/pbl_world_get_context.dspy b/wwwroot/api/pbl_world_get_context.dspy index 1517835..438beaa 100644 --- a/wwwroot/api/pbl_world_get_context.dspy +++ b/wwwroot/api/pbl_world_get_context.dspy @@ -1,6 +1,6 @@ # 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} +# 出参:world 基表字段 + pbl_context{tenant_id,blueprint_id,class_id,team_id,ext_json} 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, diff --git a/wwwroot/index.ui b/wwwroot/index.ui index 76acc8f..8042dbc 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -1,20 +1,129 @@ -{"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"}}]} +{ + "type": "panel", + "id": "pbl_domain_ext_index", + "title": "PBL 基础域薄扩展 world/scene/entity(M8)", + "layout": "vbox", + "items": [ + { + "type": "html", + "id": "hdr", + "html": "

PBL 基础域薄扩展契约面板

world / scene / entity 三模块的 PBL 侧薄扩展:仅新增关联表 pbl_domain_ref 与只读查询契约,不改三张基表结构(Q-OPEN-3)。下列 13 个契约卡片 url 全部使用 entire_url() 生成,避免 RBAC 403 与路由错误。

" + }, + { + "type": "panel", + "id": "grp_ref", + "title": "一、关联表 pbl_domain_ref 契约(6)", + "layout": "hbox", + "items": [ + { + "type": "button", + "id": "card_ref_list", + "text": "1. 关联列表 pbl_domain_ref_list", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_list.dspy')}}" + }, + { + "type": "button", + "id": "card_ref_get", + "text": "2. 关联详情 pbl_domain_ref_get", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_get.dspy')}}" + }, + { + "type": "button", + "id": "card_ref_bind", + "text": "3. 绑定 pbl_domain_ref_bind", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}" + }, + { + "type": "button", + "id": "card_ref_update", + "text": "4. 更新 pbl_domain_ref_update", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}" + }, + { + "type": "button", + "id": "card_ref_unbind", + "text": "5. 解绑 pbl_domain_ref_unbind", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}" + }, + { + "type": "button", + "id": "card_ref_check_access", + "text": "6. 访问判定 pbl_domain_ref_check_access", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_check_access.dspy')}}" + } + ] + }, + { + "type": "panel", + "id": "grp_world", + "title": "二、world 薄扩展契约(2)", + "layout": "hbox", + "items": [ + { + "type": "button", + "id": "card_world_list", + "text": "7. 租户世界列表 pbl_world_list_by_tenant", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_world_list_by_tenant.dspy')}}" + }, + { + "type": "button", + "id": "card_world_ctx", + "text": "8. 世界上下文 pbl_world_get_context", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_world_get_context.dspy')}}" + } + ] + }, + { + "type": "panel", + "id": "grp_scene_entity", + "title": "三、scene / entity 薄扩展契约(2)", + "layout": "hbox", + "items": [ + { + "type": "button", + "id": "card_scene_list", + "text": "9. 世界下场景 pbl_scene_list_by_world", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_scene_list_by_world.dspy')}}" + }, + { + "type": "button", + "id": "card_entity_list", + "text": "10. 场景下实体 pbl_entity_list_by_scene", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_entity_list_by_scene.dspy')}}" + } + ] + }, + { + "type": "panel", + "id": "grp_team", + "title": "四、班级/团队关联契约(3)", + "layout": "hbox", + "items": [ + { + "type": "button", + "id": "card_team_bind", + "text": "11. 团队绑定世界 pbl_team_bind_world", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_team_bind_world.dspy')}}" + }, + { + "type": "button", + "id": "card_team_world_list", + "text": "12. 团队世界列表 pbl_team_world_list", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_team_world_list.dspy')}}" + }, + { + "type": "button", + "id": "card_team_by_class", + "text": "13. 班级团队列表 pbl_team_list_by_class", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_team_list_by_class.dspy')}}" + } + ] + }, + { + "type": "dataviewer", + "id": "ref_viewer", + "title": "关联记录浏览(CRUD: json/pbl_domain_ref.json)", + "url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_list.dspy')}}" + } + ] +}