approve: [M4b] pbl_agent_runtime LLM路由/Prompt防御/trace

This commit is contained in:
agent.develop 2026-09-18 18:04:38 +08:00
parent 4426f2204d
commit 1af5c3fd7d
18 changed files with 1942 additions and 1436 deletions

View File

@ -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 可见、悬挂过滤 |

View File

@ -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')}}"
}
}
}

View File

@ -1,42 +1,144 @@
{
"summary": [
"pbl_domain_ref",
"world/scene/entity 薄扩展关联表M8不改基表",
"id",
"记录复用平台 world/scene/entity 基表记录与 PBL 租户/蓝图/班级/团队的关联;基表无 tenant_idPBL 多租户隔离由本表补齐。UNIQUE(tenant_id,ref_type,ref_id)。权威 DDLdata-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": "租户IDPBL 多租户隔离维度,基表无此列)"},
{"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": "复用基表记录IDworld.id / scene.id / entity.id"},
{"name": "blueprint_id", "type": "varchar(64)", "notnull": false, "comment": "关联蓝图IDpbl_compiler apply_game_definition 落库后回写)"},
{"name": "class_id", "type": "varchar(64)", "notnull": false, "comment": "关联教学班IDpbl_governance.pbl_class"},
{"name": "team_id", "type": "varchar(64)", "notnull": false, "comment": "关联团队IDpbl_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": "设计 §2UNIQUE(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'"
}
]
}

View File

@ -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

View File

@ -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

View File

@ -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-agnosticmodule-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_ref1 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 标准 APIsor.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"

View File

@ -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/entityref_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 契约层据此回填 statusUS-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",
]

View File

@ -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_refdata-model.md §J1UNIQUE(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 模块本模块只读引用不声明不建表
铁律本模块**不创建/不修改** worldsceneentity 三张基础域基表
"""
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"],
}

View File

@ -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。

View File

@ -1,30 +1,65 @@
-- =====================================================================
-- pbl_domain_extM8— world/scene/entity 薄扩展
-- 权威 DDLprojects/pbls/docs/01-design/data-model.md §JJ1. 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 '复用基表记录IDworld.id / scene.id / entity.id',
blueprint_id VARCHAR(64) DEFAULT NULL COMMENT '关联蓝图IDpbl_blueprintcompiler apply 后回写)',
class_id VARCHAR(64) DEFAULT NULL COMMENT '关联教学班IDpbl_governance.pbl_class',
team_id VARCHAR(64) DEFAULT NULL COMMENT '关联团队IDpbl_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) 单向引用基表主键。
-- ---------------------------------------------------------------------

View File

@ -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_schemabase.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("铁律违规:禁止写基础域基表 %sop=%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):
"""构造 FakeSorwith_ref_table=False 时模拟关联表缺失场景。"""
tables = {"pbl_domain_ref": []} if with_ref_table else {}
return FakeSor(tables=tables, base_fixtures=base_fixtures)

View File

@ -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):

View File

@ -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'

View File

@ -1,6 +1,6 @@
# api/pbl_domain_ref_update.dspy — 契约端点update_ref设计 §3.1 接口 5
# 实现pbl_domain_ext/api.py:update_refenv.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)

View File

@ -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')))

View File

@ -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_contextenv.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,

View File

@ -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_refUNIQUE(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/entityM8",
"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')}}"
}
]
}