approve: [M4b] pbl_agent_runtime LLM路由/Prompt防御/trace
This commit is contained in:
parent
4426f2204d
commit
1af5c3fd7d
@ -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_<name>` 与 `env.<name>` 双名;新增 `get_contract_map()` 输出「接口→实现位置→dspy 端点」映射;`CONTRACT_INTERFACES` 常量固化 13 项清单 | 测试 `test_contract_13_interfaces_all_callable`、`test_contract_map_covers_dspy_files`、`test_load_module_registers_env_functions`、`test_package_exports_match_init` 通过;映射表见 `skill/SKILL.md` §3 与 `README.md` |
|
||||
| 3 | 表定义四段式不合规:`summary` 为字符串而非数组 | `models/pbl_domain_ref.json` 改为 `{"summary":[表名,中文名,主键,说明],"fields":[...],"indexes":[...],"codes":[...]}`,summary 为 4 元素数组(array primary) | 测试 `test_model_json_four_sections` 断言 `isinstance(summary, list)` 且 `summary[0]=='pbl_domain_ref'`、主键唯一、UNIQUE 索引存在 |
|
||||
| 4 | `OWN_TABLES` 自相矛盾:声明 `pbl_tenant/pbl_class/pbl_team` 但 models/ 无定义 | 这 3 张表属 **pbl_governance** 模块,**不属本任务范围** → 从 `OWN_TABLES` 删除;同时删除误建的 `models/pbl_tenant.json`、`pbl_class.json`、`pbl_team.json` 与 `json/domain_ext_pbl_*.json`、`pbl_tenant_upsert/pbl_class_save/pbl_class_list/pbl_team_save/pbl_team_list` 等越界 dspy;团队成员改为**只读引用** `pbl_governance.pbl_team_member`(`api._fetch_team_members`,表缺失降级空列表) | `OWN_TABLES == ['pbl_domain_ref']` 与 `models/` 一一对应(测试断言);`init.py:READONLY_FOREIGN_TABLES=['pbl_team_member']` 明示只读;测试 `test_list_teams_by_class_members_from_governance`、`test_list_teams_without_governance_table_degrades` 通过 |
|
||||
| 5 | 契约端点缺失:无任何 `wwwroot/*.dspy` | 新建 **13 个** `wwwroot/api/*.dspy` 薄封装(与契约一一对应)+ `wwwroot/index.ui` 入口页;全部遵守 dspy 规范:**无 import**、**显式 return**、`debug()` 带文件名前缀、转发全部客户端参数(filters/ext 兼容 JSON 串与平铺字段,不硬编码 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 可见、悬挂过滤 |
|
||||
|
||||
@ -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')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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__",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 "<PblDomainExtError %s: %s>" % (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",
|
||||
]
|
||||
|
||||
@ -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"],
|
||||
}
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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) 单向引用基表主键。
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
366
tests/fake_db.py
366
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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')))
|
||||
|
||||
@ -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,
|
||||
|
||||
149
wwwroot/index.ui
149
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": "<div style='padding:10px 14px'><h3>PBL 基础域薄扩展契约面板</h3><p>world / scene / entity 三模块的 PBL 侧薄扩展:仅新增关联表 pbl_domain_ref 与只读查询契约,<b>不改三张基表结构</b>(Q-OPEN-3)。下列 13 个契约卡片 url 全部使用 entire_url() 生成,避免 RBAC 403 与路由错误。</p></div>"
|
||||
},
|
||||
{
|
||||
"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')}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user