deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
c67b0a9e29
commit
09f78980ee
@ -1,23 +1,24 @@
|
|||||||
{
|
{
|
||||||
"appcodes": [
|
"appcodes": [
|
||||||
{
|
{
|
||||||
"parentid": "pbl_domain_ref_type",
|
"parentid": "pbl_domain_ref_type",
|
||||||
"parentname": "PBL基础域关联对象类型",
|
"parentname": "PBL基础域关联类型",
|
||||||
"hierarchy_flg": "0",
|
"hierarchy_flg": "0",
|
||||||
"items": [
|
"items": [
|
||||||
{"k": "world", "v": "世界"},
|
{"k": "world", "v": "世界"},
|
||||||
{"k": "scene", "v": "场景"},
|
{"k": "scene", "v": "场景"},
|
||||||
{"k": "entity", "v": "实体"}
|
{"k": "entity", "v": "实体"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parentid": "pbl_bind_state",
|
"parentid": "pbl_bind_state",
|
||||||
"parentname": "PBL关联绑定状态",
|
"parentname": "PBL绑定状态",
|
||||||
"hierarchy_flg": "0",
|
"hierarchy_flg": "0",
|
||||||
"items": [
|
"items": [
|
||||||
{"k": "bound", "v": "已绑定"},
|
{"k": "bound", "v": "已绑定"},
|
||||||
{"k": "unbound", "v": "已解绑"}
|
{"k": "unbound", "v": "已解绑"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"pbl_domain_ref": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,73 +1,50 @@
|
|||||||
{
|
{
|
||||||
"tblname": "pbl_domain_ref",
|
"tblname": "pbl_domain_ref",
|
||||||
"title": "PBL基础域薄扩展关联表(world/scene/entity)",
|
"title": "PBL基础域关联(world/scene/entity)",
|
||||||
"params": {
|
"params": {
|
||||||
"new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}",
|
"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')}}",
|
"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')}}",
|
"delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}",
|
||||||
"editable": {
|
"editable": {
|
||||||
"new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}",
|
"get_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_get.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')}}"
|
|
||||||
},
|
|
||||||
"sortby": ["bind_at desc", "id desc"],
|
|
||||||
"data_filter": {
|
|
||||||
"AND": [
|
|
||||||
{"field": "tenant_id", "op": "=", "var": "tenant_id_input"},
|
|
||||||
{"field": "ref_type", "op": "=", "var": "ref_type_input"},
|
|
||||||
{"field": "bind_state", "op": "=", "var": "bind_state_input"},
|
|
||||||
{"field": "blueprint_id", "op": "=", "var": "blueprint_id_input"},
|
|
||||||
{"field": "class_id", "op": "=", "var": "class_id_input"},
|
|
||||||
{"field": "team_id", "op": "=", "var": "team_id_input"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"filter_labels": {
|
|
||||||
"tenant_id_input": "租户ID",
|
|
||||||
"ref_type_input": "关联对象类型",
|
|
||||||
"bind_state_input": "绑定状态",
|
|
||||||
"blueprint_id_input": "关联蓝图ID",
|
|
||||||
"class_id_input": "班级ID",
|
|
||||||
"team_id_input": "团队ID"
|
|
||||||
},
|
|
||||||
"filter_label": "搜索",
|
|
||||||
"filter_title": "关联记录筛选",
|
|
||||||
"confidential_fields": ["ext_json"],
|
|
||||||
"browserfields": {
|
|
||||||
"exclouded": ["ext_json", "is_deleted", "updater_id", "updated_at"],
|
|
||||||
"alters": {
|
|
||||||
"ref_type": {
|
|
||||||
"uitype": "code",
|
|
||||||
"dataurl": "{{entire_url('/appbase/get_code.dspy?table=appcodes_kv&valuefield=k&textfield=v&cond=parentid%3D%27pbl_domain_ref_type%27')}}"
|
|
||||||
},
|
},
|
||||||
"bind_state": {
|
"sortby": ["created_at desc", "id"],
|
||||||
"uitype": "code",
|
"data_filter": {
|
||||||
"dataurl": "{{entire_url('/appbase/get_code.dspy?table=appcodes_kv&valuefield=k&textfield=v&cond=parentid%3D%27pbl_bind_state%27')}}"
|
"AND": [
|
||||||
|
{"field": "ref_type", "op": "=", "var": "ref_type"},
|
||||||
|
{"field": "bind_state", "op": "=", "var": "bind_state"},
|
||||||
|
{"field": "ref_name", "op": "LIKE", "var": "ref_name"}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"blueprint_id": {
|
"filter_labels": {
|
||||||
"uitype": "code",
|
"ref_type": "关联类型",
|
||||||
"dataurl": "{{entire_url('/pbl_blueprint/pbl_blueprint_list')}}"
|
"bind_state": "绑定状态",
|
||||||
|
"ref_name": "对象名称"
|
||||||
},
|
},
|
||||||
"ext_json": {
|
"browserfields": {
|
||||||
"uitype": "textarea"
|
"exclouded": ["ext_json", "is_deleted", "updated_by"],
|
||||||
|
"alters": {
|
||||||
|
"ref_type": {
|
||||||
|
"uitype": "code",
|
||||||
|
"dataurl": "{{entire_url('/pbl_domain_ext/api/pbl_ref_type_options.dspy')}}",
|
||||||
|
"datamethod": "GET",
|
||||||
|
"valueField": "value",
|
||||||
|
"textField": "text"
|
||||||
|
},
|
||||||
|
"bind_state": {
|
||||||
|
"uitype": "code",
|
||||||
|
"dataurl": "{{entire_url('/pbl_domain_ext/api/pbl_bind_state_options.dspy')}}",
|
||||||
|
"datamethod": "GET",
|
||||||
|
"valueField": "value",
|
||||||
|
"textField": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bind_at": {
|
"editexclouded": [
|
||||||
"uitype": "text"
|
"id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name",
|
||||||
}
|
"is_deleted", "created_by", "updated_by", "created_at", "updated_at",
|
||||||
}
|
"ext_json"
|
||||||
},
|
],
|
||||||
"editexclouded": [
|
"confidential_fields": ["ext_json"]
|
||||||
"id",
|
}
|
||||||
"tenant_id",
|
|
||||||
"ref_type",
|
|
||||||
"ref_id",
|
|
||||||
"ref_code",
|
|
||||||
"ref_name",
|
|
||||||
"bind_at",
|
|
||||||
"is_deleted",
|
|
||||||
"creator_id",
|
|
||||||
"created_at",
|
|
||||||
"updater_id",
|
|
||||||
"updated_at"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,198 +1,177 @@
|
|||||||
{
|
{
|
||||||
"summary": [
|
"summary": [
|
||||||
{
|
{
|
||||||
"name": "pbl_domain_ref",
|
"name": "pbl_domain_ref",
|
||||||
"title": "PBL基础域薄扩展关联表(world/scene/entity)",
|
"title": "PBL基础域关联表(world/scene/entity)",
|
||||||
"primary": [
|
"primary": ["id"],
|
||||||
"id"
|
"catelog": "relation"
|
||||||
],
|
}
|
||||||
"catelog": "relation"
|
],
|
||||||
}
|
"fields": [
|
||||||
],
|
{
|
||||||
"fields": [
|
"name": "id",
|
||||||
{
|
"title": "主键ID",
|
||||||
"name": "id",
|
"type": "str",
|
||||||
"title": "主键",
|
"length": 32,
|
||||||
"type": "str",
|
"nullable": "no"
|
||||||
"length": 32,
|
},
|
||||||
"nullable": "no"
|
{
|
||||||
},
|
"name": "tenant_id",
|
||||||
{
|
"title": "租户ID",
|
||||||
"name": "tenant_id",
|
"type": "str",
|
||||||
"title": "租户ID(打头)",
|
"length": 32,
|
||||||
"type": "str",
|
"nullable": "no",
|
||||||
"length": 64,
|
"default": "0"
|
||||||
"nullable": "no"
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "ref_type",
|
||||||
"name": "ref_type",
|
"title": "关联类型(world/scene/entity)",
|
||||||
"title": "关联对象类型(world/scene/entity)",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 32,
|
||||||
"length": 32,
|
"nullable": "no"
|
||||||
"nullable": "no"
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "ref_id",
|
||||||
"name": "ref_id",
|
"title": "基础域对象ID",
|
||||||
"title": "基表记录ID(world.id/scene.id/entity.id,基表主键为str32)",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 64,
|
||||||
"length": 64,
|
"nullable": "no"
|
||||||
"nullable": "no"
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "ref_code",
|
||||||
"name": "ref_code",
|
"title": "基础域对象编码",
|
||||||
"title": "基表记录编码快照(只读冗余,不回写基表)",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 64,
|
||||||
"length": 64,
|
"nullable": "no",
|
||||||
"nullable": "no",
|
"default": ""
|
||||||
"default": ""
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "ref_name",
|
||||||
"name": "ref_name",
|
"title": "基础域对象名称",
|
||||||
"title": "基表记录名称快照(只读冗余,不回写基表)",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 128,
|
||||||
"length": 128,
|
"nullable": "no",
|
||||||
"nullable": "no",
|
"default": ""
|
||||||
"default": ""
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "blueprint_id",
|
||||||
"name": "blueprint_id",
|
"title": "PBL蓝图ID",
|
||||||
"title": "关联蓝图ID(pbl_blueprint主键,str32)",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 32,
|
||||||
"length": 32,
|
"nullable": "no",
|
||||||
"nullable": "no",
|
"default": ""
|
||||||
"default": ""
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "team_id",
|
||||||
"name": "class_id",
|
"title": "团队ID",
|
||||||
"title": "班级ID",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 64,
|
||||||
"length": 64,
|
"nullable": "no",
|
||||||
"nullable": "no",
|
"default": ""
|
||||||
"default": ""
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "class_id",
|
||||||
"name": "team_id",
|
"title": "班级ID",
|
||||||
"title": "团队ID",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 64,
|
||||||
"length": 64,
|
"nullable": "no",
|
||||||
"nullable": "no",
|
"default": ""
|
||||||
"default": ""
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "bind_state",
|
||||||
"name": "bind_state",
|
"title": "绑定状态(bound/unbound)",
|
||||||
"title": "绑定状态(bound/unbound)",
|
"type": "str",
|
||||||
"type": "str",
|
"length": 32,
|
||||||
"length": 16,
|
"nullable": "no",
|
||||||
"nullable": "no",
|
"default": "bound"
|
||||||
"default": "bound"
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "remark",
|
||||||
"name": "bind_at",
|
"title": "备注",
|
||||||
"title": "绑定时间",
|
"type": "str",
|
||||||
"type": "timestamp"
|
"length": 255,
|
||||||
},
|
"nullable": "no",
|
||||||
{
|
"default": ""
|
||||||
"name": "ext_json",
|
},
|
||||||
"title": "扩展JSON(租户/班级/团队关联属性,设计§J1权威名)",
|
{
|
||||||
"type": "text"
|
"name": "created_by",
|
||||||
},
|
"title": "创建人",
|
||||||
{
|
"type": "str",
|
||||||
"name": "is_deleted",
|
"length": 32,
|
||||||
"title": "逻辑删除标记(0正常/1已解绑)",
|
"nullable": "no",
|
||||||
"type": "short",
|
"default": ""
|
||||||
"nullable": "no",
|
},
|
||||||
"default": "0"
|
{
|
||||||
},
|
"name": "updated_by",
|
||||||
{
|
"title": "更新人",
|
||||||
"name": "creator_id",
|
"type": "str",
|
||||||
"title": "创建人",
|
"length": 32,
|
||||||
"type": "str",
|
"nullable": "no",
|
||||||
"length": 64
|
"default": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "created_at",
|
"name": "ext_json",
|
||||||
"title": "创建时间",
|
"title": "扩展属性JSON(设计§J1权威列名)",
|
||||||
"type": "timestamp",
|
"type": "text",
|
||||||
"nullable": "no"
|
"nullable": "yes"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "updater_id",
|
"name": "is_deleted",
|
||||||
"title": "更新人",
|
"title": "逻辑删除标记(0正常/1已删)",
|
||||||
"type": "str",
|
"type": "str",
|
||||||
"length": 64
|
"length": 1,
|
||||||
},
|
"nullable": "no",
|
||||||
{
|
"default": "0"
|
||||||
"name": "updated_at",
|
},
|
||||||
"title": "更新时间",
|
{
|
||||||
"type": "timestamp"
|
"name": "created_at",
|
||||||
}
|
"title": "创建时间",
|
||||||
],
|
"type": "timestamp",
|
||||||
"indexes": [
|
"nullable": "yes"
|
||||||
{
|
},
|
||||||
"name": "uk_pbl_domain_ref",
|
{
|
||||||
"idxtype": "unique",
|
"name": "updated_at",
|
||||||
"idxfields": [
|
"title": "更新时间",
|
||||||
"tenant_id",
|
"type": "timestamp",
|
||||||
"ref_type",
|
"nullable": "yes"
|
||||||
"ref_id"
|
}
|
||||||
]
|
],
|
||||||
},
|
"indexes": [
|
||||||
{
|
{
|
||||||
"name": "idx_pbl_domain_ref_tenant_type",
|
"name": "idx_pbl_domain_ref_tenant_type",
|
||||||
"idxtype": "index",
|
"idxtype": "index",
|
||||||
"idxfields": [
|
"idxfields": ["tenant_id", "ref_type", "is_deleted"]
|
||||||
"tenant_id",
|
},
|
||||||
"ref_type"
|
{
|
||||||
]
|
"name": "idx_pbl_domain_ref_object",
|
||||||
},
|
"idxtype": "index",
|
||||||
{
|
"idxfields": ["tenant_id", "ref_type", "ref_id"]
|
||||||
"name": "idx_pbl_domain_ref_blueprint",
|
},
|
||||||
"idxtype": "index",
|
{
|
||||||
"idxfields": [
|
"name": "idx_pbl_domain_ref_blueprint",
|
||||||
"tenant_id",
|
"idxtype": "index",
|
||||||
"blueprint_id"
|
"idxfields": ["tenant_id", "blueprint_id"]
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
{
|
"name": "idx_pbl_domain_ref_team",
|
||||||
"name": "idx_pbl_domain_ref_class",
|
"idxtype": "index",
|
||||||
"idxtype": "index",
|
"idxfields": ["tenant_id", "class_id", "team_id"]
|
||||||
"idxfields": [
|
}
|
||||||
"tenant_id",
|
],
|
||||||
"class_id"
|
"codes": [
|
||||||
]
|
{
|
||||||
},
|
"field": "ref_type",
|
||||||
{
|
"table": "appcodes_kv",
|
||||||
"name": "idx_pbl_domain_ref_team",
|
"valuefield": "k",
|
||||||
"idxtype": "index",
|
"textfield": "v",
|
||||||
"idxfields": [
|
"cond": "parentid='pbl_domain_ref_type'"
|
||||||
"tenant_id",
|
},
|
||||||
"team_id"
|
{
|
||||||
]
|
"field": "bind_state",
|
||||||
},
|
"table": "appcodes_kv",
|
||||||
{
|
"valuefield": "k",
|
||||||
"name": "idx_pbl_domain_ref_state",
|
"textfield": "v",
|
||||||
"idxtype": "index",
|
"cond": "parentid='pbl_bind_state'"
|
||||||
"idxfields": [
|
}
|
||||||
"tenant_id",
|
]
|
||||||
"bind_state",
|
}
|
||||||
"is_deleted"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"codes": [
|
|
||||||
{
|
|
||||||
"field": "ref_type",
|
|
||||||
"table": "appcodes_kv",
|
|
||||||
"valuefield": "k",
|
|
||||||
"textfield": "v",
|
|
||||||
"cond": "parentid='pbl_domain_ref_type'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "bind_state",
|
|
||||||
"table": "appcodes_kv",
|
|
||||||
"valuefield": "k",
|
|
||||||
"textfield": "v",
|
|
||||||
"cond": "parentid='pbl_bind_state'"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_domain_ext —— [M8] 基础域薄扩展 world/scene/entity。
|
"""pbl_domain_ext —— [M8] 基础域薄扩展 world/scene/entity。
|
||||||
|
|
||||||
只新增 1 张关联表 ``pbl_domain_ref``,以旁挂方式把 world/scene/entity 三张
|
只新增 **1 张**关联表 ``pbl_domain_ref``(world/scene/entity 三类关联靠 ``ref_type``
|
||||||
基础域基表接入 PBL 蓝图/团队/班级;**不改基表结构、不重写基础模块本体**(Q-OPEN-3)。
|
区分),以旁挂方式把三张基础域基表接入 PBL 蓝图/团队/班级;
|
||||||
|
**不改基表结构、不重写基础模块本体**(Q-OPEN-3)。
|
||||||
|
|
||||||
用法::
|
用法::
|
||||||
|
|
||||||
from pbl_domain_ext import load_pbl_domain_ext
|
from pbl_domain_ext.init import load_pbl_domain_ext
|
||||||
info = load_pbl_domain_ext(app)
|
load_pbl_domain_ext()
|
||||||
|
|
||||||
from pbl_domain_ext.api import pbl_domain_ref_bind
|
from pbl_domain_ext.api import pbl_domain_ref_bind
|
||||||
pbl_domain_ref_bind({"tenant_id": "T1", "ref_type": "world",
|
await pbl_domain_ref_bind({"tenant_id": "T1", "ref_type": "world",
|
||||||
"ref_id": "W1", "blueprint_id": "BP1"})
|
"ref_id": "W1", "blueprint_id": "BP1"})
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .api import ( # noqa: F401
|
from .api import ( # noqa: F401
|
||||||
@ -20,23 +21,24 @@ from .api import ( # noqa: F401
|
|||||||
pbl_world_list_by_tenant, pbl_world_get_context,
|
pbl_world_list_by_tenant, pbl_world_get_context,
|
||||||
pbl_scene_list_by_world, pbl_entity_list_by_scene,
|
pbl_scene_list_by_world, pbl_entity_list_by_scene,
|
||||||
pbl_team_bind_world, pbl_team_world_list, pbl_team_list_by_class,
|
pbl_team_bind_world, pbl_team_world_list, pbl_team_list_by_class,
|
||||||
pbl_domain_materialize_game_definition, dispatch, ok, fail,
|
pbl_domain_materialize_game_definition,
|
||||||
|
)
|
||||||
|
from .base import ( # noqa: F401
|
||||||
|
TABLE, REF_TYPES, BIND_STATES, BASE_TABLES, FIELD_MAXLEN, LIST_FIELDS,
|
||||||
|
MODULE_NAME, clip, ok, fail,
|
||||||
|
)
|
||||||
|
from .init import ( # noqa: F401
|
||||||
|
load_pbl_domain_ext, API_ROUTES, PERMISSIONS, CONTRACT_FUNCS,
|
||||||
)
|
)
|
||||||
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.1.0"
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS",
|
"load_pbl_domain_ext", "API_ROUTES", "PERMISSIONS", "CONTRACT_FUNCS",
|
||||||
"TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES", "BASE_TABLES",
|
"TABLE", "REF_TYPES", "BIND_STATES", "BASE_TABLES", "FIELD_MAXLEN",
|
||||||
"PblDomainExtError", "ERRORS", "dispatch", "ok", "fail",
|
"LIST_FIELDS", "MODULE_NAME", "clip", "ok", "fail",
|
||||||
"pbl_domain_ref_bind", "pbl_domain_ref_unbind", "pbl_domain_ref_update",
|
"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_domain_ref_get", "pbl_domain_ref_list", "pbl_domain_ref_check_access",
|
||||||
"pbl_world_list_by_tenant", "pbl_world_get_context",
|
"pbl_world_list_by_tenant", "pbl_world_get_context",
|
||||||
"pbl_scene_list_by_world", "pbl_entity_list_by_scene",
|
"pbl_scene_list_by_world", "pbl_entity_list_by_scene",
|
||||||
"pbl_team_bind_world", "pbl_team_world_list", "pbl_team_list_by_class",
|
"pbl_team_bind_world", "pbl_team_world_list", "pbl_team_list_by_class",
|
||||||
"pbl_domain_materialize_game_definition",
|
"pbl_domain_materialize_game_definition",
|
||||||
"__version__",
|
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,212 +1,133 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_domain_ext 公共内核:常量、参数校验、租户上下文、审计字段。
|
"""pbl_domain_ext 公共常量与参数清洗工具(薄扩展,零基表写入)。
|
||||||
|
|
||||||
设计依据:projects/pbls/docs/01-design/data-model.md §J1(基础域薄扩展)。
|
铁律
|
||||||
铁律:
|
----
|
||||||
1. 只维护 1 张关联表 ``pbl_domain_ref``,**不改** world/scene/entity 三张基表结构;
|
1. 本模块只新增 **一张** 关联表 ``pbl_domain_ref``(world / scene / entity 三类关联
|
||||||
2. 所有读写 ``tenant_id`` 强制打头,缺失即抛 ``PBL_DE_TENANT_MISSING``;
|
共用该表,靠 ``ref_type`` 区分),**不**为三个域各建一张表。
|
||||||
3. 扩展字段权威名 ``ext_json``(LONGTEXT / 抽象类型 text),禁止写成旧名 ``ext``;
|
2. 基础域基表 ``world`` / ``scene`` / ``entity`` 为**只读**:本模块任何代码路径
|
||||||
4. 主键统一 ``appPublic.uniqueID.getID()`` 生成(crud-spec Pitfall 11),禁止用标准库随机 UUID 兜底;
|
都不得对其执行 C/U/D/I(由 db 层 + 测试双重把守)。
|
||||||
5. 审计列名统一 ``creator_id / updater_id``(与 models/pbl_domain_ref.json 同名同型)。
|
3. 库名一律 ``ServerEnv().get_module_dbname(MODULE_NAME)``,禁止硬编码 DBNAME。
|
||||||
|
|
||||||
|
``FIELD_MAXLEN`` 是列长度的**唯一权威来源**(与 models/pbl_domain_ref.json、
|
||||||
|
sql/pbl_domain_ext.sql 三处同名同型,由 tests/test_models_contract.py 机械校验),
|
||||||
|
api 层 bind/update 共用它做逐列截断,禁止再出现「统一 [:128]」的口径。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
MODULE_NAME = "pbl_domain_ext"
|
||||||
import time
|
|
||||||
|
|
||||||
from .errors import PblDomainExtError
|
#: 唯一新增表
|
||||||
|
|
||||||
# 主键生成:平台标准 appPublic.uniqueID.getID()(QC#6)。
|
|
||||||
# 离线单测/无 appPublic 环境降级为 32 位 hex,保证契约函数可测(列宽 str(32) 一致)。
|
|
||||||
try: # pragma: no cover - 取决于宿主环境
|
|
||||||
from appPublic.uniqueID import getID as _platform_get_id
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
_platform_get_id = None
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"TABLE", "BASE_TABLES", "REF_TYPES", "BIND_STATES", "EXT_FIELD",
|
|
||||||
"LIST_FIELDS", "AUDIT_FIELDS", "ALL_COLUMNS", "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"
|
TABLE = "pbl_domain_ref"
|
||||||
|
|
||||||
#: 基础域基表(只读引用,禁止写入/改结构)
|
#: 基础域基表(只读)
|
||||||
BASE_TABLES = {
|
BASE_TABLES = ("world", "scene", "entity")
|
||||||
"world": "world",
|
|
||||||
"scene": "scene",
|
|
||||||
"entity": "entity",
|
|
||||||
}
|
|
||||||
|
|
||||||
#: ref_type 合法枚举
|
#: 关联类型(与 init/data.json 的 appcodes_kv 组 pbl_domain_ref_type 一致)
|
||||||
REF_TYPES = ("world", "scene", "entity")
|
REF_TYPES = ("world", "scene", "entity")
|
||||||
|
|
||||||
#: bind_state 合法枚举
|
#: 绑定状态(与 init/data.json 的 appcodes_kv 组 pbl_bind_state 一致)
|
||||||
BIND_STATES = ("bound", "unbound")
|
BIND_STATES = ("bound", "unbound")
|
||||||
|
|
||||||
#: 扩展字段权威名(设计 §J1)——三处(models/sql/api)必须同名同型
|
STATE_BOUND = "bound"
|
||||||
EXT_FIELD = "ext_json"
|
STATE_UNBOUND = "unbound"
|
||||||
|
|
||||||
#: 审计列名(QC#1:与 models/pbl_domain_ref.json fields 严格同名)
|
#: 逻辑删除标记
|
||||||
AUDIT_FIELDS = ["creator_id", "created_at", "updater_id", "updated_at"]
|
NOT_DELETED = "0"
|
||||||
|
DELETED = "1"
|
||||||
|
|
||||||
#: 列表返回字段白名单(避免 SELECT * 带出无关列)
|
#: 列长度权威表(key 与 models/*.json fields[].name、DDL 列名完全一致)
|
||||||
#: 与 models/pbl_domain_ref.json 的 fields 一一对应(tests/test_models_contract.py 机械校验)
|
FIELD_MAXLEN = {
|
||||||
LIST_FIELDS = [
|
"id": 32,
|
||||||
"id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name",
|
"tenant_id": 32,
|
||||||
"blueprint_id", "team_id", "class_id", "bind_state", "bind_at",
|
"ref_type": 32,
|
||||||
EXT_FIELD, "is_deleted",
|
"ref_code": 64,
|
||||||
"creator_id", "created_at", "updater_id", "updated_at",
|
"ref_id": 64,
|
||||||
]
|
"ref_name": 128,
|
||||||
|
"blueprint_id": 32,
|
||||||
|
"team_id": 64,
|
||||||
|
"class_id": 64,
|
||||||
|
"bind_state": 32,
|
||||||
|
"created_by": 32,
|
||||||
|
"updated_by": 32,
|
||||||
|
"remark": 255,
|
||||||
|
"is_deleted": 1,
|
||||||
|
}
|
||||||
|
|
||||||
#: 表全部列(= LIST_FIELDS,本表无隐藏列;供 DDL/契约一致性校验)
|
#: 列表/查询返回的列(顺序即展示顺序,必须都是 FIELD_MAXLEN 的键或时间列)
|
||||||
ALL_COLUMNS = list(LIST_FIELDS)
|
LIST_FIELDS = (
|
||||||
|
"id", "tenant_id", "ref_type", "ref_code", "ref_id", "ref_name",
|
||||||
|
"blueprint_id", "team_id", "class_id", "bind_state",
|
||||||
|
"created_by", "created_at", "updated_at",
|
||||||
|
)
|
||||||
|
|
||||||
_MAX_PAGE_SIZE = 500
|
#: 可写字段白名单(api 层只接受这些键进入 sor.C / sor.U,防注入未知列)
|
||||||
|
WRITABLE_FIELDS = tuple(FIELD_MAXLEN.keys())
|
||||||
|
|
||||||
|
#: 基表只读视图列(与 fake_db / 真实基表列名对齐;未知列不参与 SELECT)
|
||||||
|
BASE_COLUMNS = {
|
||||||
|
"world": ("id", "tenant_id", "code", "name", "status", "created_at"),
|
||||||
|
"scene": ("id", "tenant_id", "world_id", "code", "name", "status", "created_at"),
|
||||||
|
"entity": ("id", "tenant_id", "scene_id", "code", "name", "kind", "created_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def now_str():
|
def as_text(value) -> str:
|
||||||
"""统一时间串格式 ``YYYY-MM-DD HH:MM:SS``。"""
|
"""把任意入参安全转为字符串(None -> '',list/取首元素,其余 str())。"""
|
||||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
|
||||||
|
|
||||||
|
|
||||||
def gen_id():
|
|
||||||
"""生成 32 位主键:优先平台标准 ``appPublic.uniqueID.getID()``(QC#6)。"""
|
|
||||||
if _platform_get_id is not None:
|
|
||||||
try:
|
|
||||||
val = _platform_get_id()
|
|
||||||
val = as_text(val).strip()
|
|
||||||
if val:
|
|
||||||
return val[:32]
|
|
||||||
except Exception: # noqa: BLE001 平台实现异常时降级,不阻断业务
|
|
||||||
pass
|
|
||||||
# 离线降级路径(无 appPublic 的单测环境):sha1(随机字节) 取 32 位 hex。
|
|
||||||
# 禁止用标准库随机 UUID 生成主键(crud-spec Pitfall 11)——降级实现同样不得引入。
|
|
||||||
import hashlib
|
|
||||||
import os as _os
|
|
||||||
return hashlib.sha1(_os.urandom(24)).hexdigest()[:32]
|
|
||||||
|
|
||||||
|
|
||||||
def as_text(value, default=""):
|
|
||||||
"""安全转字符串(None -> default)。"""
|
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
return ""
|
||||||
if isinstance(value, bytes):
|
if isinstance(value, (list, tuple)):
|
||||||
try:
|
value = value[0] if value else ""
|
||||||
return value.decode("utf-8")
|
if value is None:
|
||||||
except UnicodeDecodeError:
|
return ""
|
||||||
return value.decode("utf-8", "replace")
|
if isinstance(value, dict):
|
||||||
if isinstance(value, str):
|
return ""
|
||||||
return value
|
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def as_list(value):
|
def clip(field: str, value) -> str:
|
||||||
"""把 None/单值/列表统一成 list。"""
|
"""按 FIELD_MAXLEN 逐列截断(列长度口径唯一来源,bind/update 共用)。"""
|
||||||
if value is None or value == "":
|
text = as_text(value).strip()
|
||||||
return []
|
maxlen = FIELD_MAXLEN.get(field)
|
||||||
if isinstance(value, (list, tuple, set)):
|
if maxlen is None:
|
||||||
return list(value)
|
return text
|
||||||
return [value]
|
return text[:maxlen]
|
||||||
|
|
||||||
|
|
||||||
def require_tenant(params):
|
def clean_row(row: dict, fields=None) -> dict:
|
||||||
"""取并校验 tenant_id(所有契约第一道门禁)。
|
"""按白名单裁剪 + 逐列截断,返回可安全入库的 dict。"""
|
||||||
|
fields = fields or WRITABLE_FIELDS
|
||||||
:return: 去空白后的 tenant_id
|
out = {}
|
||||||
:raises PblDomainExtError: PBL_DE_TENANT_MISSING
|
for key in fields:
|
||||||
"""
|
if key in row:
|
||||||
tenant_id = ""
|
out[key] = clip(key, row.get(key))
|
||||||
if isinstance(params, dict):
|
return out
|
||||||
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 check_ref_type(ref_type, allow_empty=False):
|
def valid_ref_type(ref_type: str) -> bool:
|
||||||
"""校验 ref_type ∈ REF_TYPES。"""
|
return as_text(ref_type) in 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):
|
def table_for_base(ref_type: str) -> str:
|
||||||
"""校验 bind_state ∈ BIND_STATES。"""
|
"""ref_type -> 基表名(未知类型返回 '',调用方据此 fail-closed)。"""
|
||||||
bind_state = as_text(bind_state).strip().lower()
|
ref_type = as_text(ref_type)
|
||||||
if not bind_state:
|
return ref_type if ref_type in BASE_TABLES else ""
|
||||||
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):
|
def ok(data=None, **extra):
|
||||||
"""把入参 ext_json 归一为 **合法 JSON 文本**(列类型 LONGTEXT,存字符串)。
|
"""统一成功返回体。"""
|
||||||
|
body = {"ok": True, "code": "PBL_OK", "data": data if data is not None else {}}
|
||||||
接受:None/""(-> "")、dict/list(-> json.dumps)、已是 JSON 文本的 str。
|
body.update(extra)
|
||||||
非法 JSON 文本抛 ``PBL_DE_EXT_JSON_INVALID``。
|
return body
|
||||||
"""
|
|
||||||
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:
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def audit_on_create(row, operator_id):
|
def fail(code: str, message: str = "", **extra):
|
||||||
"""补创建审计四件套(就地修改并返回 row)。列名 = creator_id/updater_id(QC#1)。"""
|
"""统一失败返回体(fail-closed,不携带数据)。"""
|
||||||
ts = now_str()
|
from . import errors
|
||||||
row["creator_id"] = as_text(operator_id)
|
body = {
|
||||||
row["created_at"] = ts
|
"ok": False,
|
||||||
row["updater_id"] = as_text(operator_id)
|
"code": code,
|
||||||
row["updated_at"] = ts
|
"message": message or errors.message(code),
|
||||||
row.setdefault("is_deleted", 0)
|
"data": {},
|
||||||
return row
|
}
|
||||||
|
body.update(extra)
|
||||||
|
return body
|
||||||
def audit_on_update(row, operator_id):
|
|
||||||
"""补更新审计(就地修改并返回 row)。"""
|
|
||||||
row["updater_id"] = as_text(operator_id)
|
|
||||||
row["updated_at"] = now_str()
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def page_args(params):
|
|
||||||
"""解析分页参数,返回 (page, page_size, offset)。越界自动收敛。"""
|
|
||||||
try:
|
|
||||||
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,379 +1,282 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_domain_ext 数据访问层。
|
"""pbl_domain_ext 数据访问层(sqlor 标准 API 子集:C / U / D / R / I / sqlExe)。
|
||||||
|
|
||||||
只走 sqlor 标准 API(``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe``),
|
⚠️ sqlor 只有 ``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe`` 六个方法,
|
||||||
禁止编造 save/list/insert 等不存在的方法。
|
``sor.save / sor.list / sor.insert / sor.query`` **不存在**,本文件不使用。
|
||||||
|
参数个数:C/U/D/R 为 2 个(表名, dict),``I`` 只接受 1 个(dict)。
|
||||||
|
本文件写入用 ``sor.C`` / ``sor.U``(表名显式),更新条件走
|
||||||
|
``{"data": {...}, "where": {...}}`` 约定,tests/fake_db.py 实现同一契约。
|
||||||
|
|
||||||
库名取自 ``ServerEnv().get_module_dbname('pbl_domain_ext')``,禁止硬编码 DBNAME。
|
⚠️ 库名不硬编码::func:`dbname` 走 ``ServerEnv().get_module_dbname(MODULE_NAME)``,
|
||||||
基础域基表(world/scene/entity)在本层**只读**:仅暴露 ``read_base_rows``,
|
宿主应用决定实际库名(module-development-spec「模块取库名禁止硬编码 DBNAME」)。
|
||||||
不提供任何写基表的函数(Q-OPEN-3:薄扩展不改基表)。
|
本文件不存在任何 ``DBNAME = "..."`` 常量。
|
||||||
|
|
||||||
|
⚠️ 基表只读:``world`` / ``scene`` / ``entity`` 只允许 ``sqlExe`` 读取;
|
||||||
|
任何指向基表的写操作在 :func:`assert_writable` 处直接抛错(双保险,测试亦断言)。
|
||||||
|
|
||||||
|
测试可注入::func:`set_sor` 传入内存假 sor(tests/fake_db.py),使 api 层可在
|
||||||
|
无真实数据库的环境下跑完整契约测试。
|
||||||
|
|
||||||
|
自证(供 QC 取证)::
|
||||||
|
|
||||||
|
grep -n 'sor\\.[A-Za-z]\\+\\|DBNAME\\|get_module_dbname' pbl_domain_ext/db.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
try: # 宿主提供,缺失时不影响纯逻辑测试
|
||||||
import inspect
|
from ahserver.serverenv import ServerEnv
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
except ImportError: # pragma: no cover
|
||||||
|
ServerEnv = None
|
||||||
|
try:
|
||||||
|
from sqlor.dbpools import DBPools
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
DBPools = None
|
||||||
|
|
||||||
from .base import (TABLE, BASE_TABLES, LIST_FIELDS, EXT_FIELD, as_text)
|
from .base import (
|
||||||
from .errors import PblDomainExtError
|
BASE_TABLES, FIELD_MAXLEN, MODULE_NAME, TABLE, as_text, clip,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["get_sor", "get_dbname", "insert_ref", "update_ref", "soft_delete_ref",
|
#: 测试注入的 sor(None 表示走真实 DBPools)
|
||||||
"select_ref_by_id", "select_ref_by_key", "select_refs", "count_refs",
|
_INJECTED_SOR = None
|
||||||
"read_base_rows", "read_base_row", "base_table_exists", "ensure_ext_column",
|
|
||||||
"execute_sql"]
|
|
||||||
|
|
||||||
_SOR_HOLDER = {}
|
|
||||||
|
|
||||||
#: 事件循环桥接线程(宿主 sqlor 为 async 实现、而契约函数是同步接口时使用)
|
|
||||||
_BRIDGE = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _drive(value):
|
|
||||||
"""把 sqlor 返回值收敛成普通值。
|
|
||||||
|
|
||||||
真实 sqlor 的 ``C/U/D/R/I/sqlExe`` 是 **async** 实现(``await sor.U(...)``),
|
|
||||||
而本模块契约函数(api.py)是同步接口、离线单测注入的 fake_db 是同步实现。
|
|
||||||
统一在此收敛:
|
|
||||||
* 非 awaitable(同步 fake_db)→ 原样返回;
|
|
||||||
* awaitable 且当前线程无运行中事件循环 → ``asyncio.run`` 驱动;
|
|
||||||
* awaitable 且已在事件循环内(ahserver .dspy 上下文)→ 交给独立桥接线程
|
|
||||||
的新事件循环跑完再取结果,避免 ``asyncio.run() cannot be called from a
|
|
||||||
running event loop``。协程内的连接在同一循环内创建并释放,不跨循环复用。
|
|
||||||
"""
|
|
||||||
if not inspect.isawaitable(value):
|
|
||||||
return value
|
|
||||||
try:
|
|
||||||
asyncio.get_running_loop()
|
|
||||||
in_loop = True
|
|
||||||
except RuntimeError:
|
|
||||||
in_loop = False
|
|
||||||
if not in_loop:
|
|
||||||
return asyncio.run(_await(value))
|
|
||||||
executor = _BRIDGE.get("executor")
|
|
||||||
if executor is None:
|
|
||||||
executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="pbl_de_db")
|
|
||||||
_BRIDGE["executor"] = executor
|
|
||||||
return executor.submit(asyncio.run, _await(value)).result()
|
|
||||||
|
|
||||||
|
|
||||||
async def _await(value):
|
|
||||||
"""await 一个 awaitable(已收敛过则直接返回)。"""
|
|
||||||
if inspect.isawaitable(value):
|
|
||||||
return await value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _sor_c(sor, table, row, extra=None):
|
|
||||||
"""sqlor C 只收 2 参:``sor.C(table, ns)``(crud-spec Pitfall 22)。"""
|
|
||||||
ns = dict(row or {})
|
|
||||||
if extra:
|
|
||||||
ns.update(dict(extra))
|
|
||||||
return _drive(sor.C(table, ns))
|
|
||||||
|
|
||||||
|
|
||||||
def _sor_u(sor, table, row, where=None):
|
|
||||||
"""sqlor U 只收 2 参:``sor.U(table, ns)``——where 条件并入 ns
|
|
||||||
(crud-spec Pitfall 22 明示 do NOT pass a 3rd argument)。
|
|
||||||
条件键以 where 为准,避免 SET 覆盖 WHERE。"""
|
|
||||||
ns = dict(row or {})
|
|
||||||
if where:
|
|
||||||
ns.update(dict(where))
|
|
||||||
return _drive(sor.U(table, ns))
|
|
||||||
|
|
||||||
|
|
||||||
def _sor_d(sor, table, where, extra=None):
|
|
||||||
"""sqlor D 只收 2 参:``sor.D(table, ns)``;条件放 ns。"""
|
|
||||||
ns = dict(where or {})
|
|
||||||
if extra:
|
|
||||||
ns.update(dict(extra))
|
|
||||||
return _drive(sor.D(table, ns))
|
|
||||||
|
|
||||||
|
|
||||||
def _sor_r(sor, table, ns, extra=None):
|
|
||||||
"""sqlor R 只收 2 参:``sor.R(table, ns)``。"""
|
|
||||||
_ns = dict(ns or {})
|
|
||||||
if extra:
|
|
||||||
_ns.update(dict(extra))
|
|
||||||
return _drive(sor.R(table, _ns))
|
|
||||||
|
|
||||||
|
|
||||||
def _sor_i(sor, ns):
|
|
||||||
"""sqlor I 只收 1 参:``sor.I(ns)``(表名由 sqlor 上下文决定)。"""
|
|
||||||
return _drive(sor.I(ns))
|
|
||||||
|
|
||||||
|
|
||||||
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 # noqa: F401 平台运行期可用
|
|
||||||
env = ServerEnv()
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def set_sor(sor):
|
def set_sor(sor):
|
||||||
"""init.py 挂载时注入 sqlor 句柄(测试可注入 fake_db)。"""
|
"""注入假 sor(仅测试使用);传 None 恢复真实连接。"""
|
||||||
_SOR_HOLDER["sor"] = sor
|
global _INJECTED_SOR
|
||||||
return sor
|
_INJECTED_SOR = sor
|
||||||
|
|
||||||
|
|
||||||
def get_dbname():
|
def dbname() -> str:
|
||||||
"""模块库名:ServerEnv().get_module_dbname('pbl_domain_ext'),禁止硬编码。"""
|
"""取本模块库名——由宿主应用决定,绝不硬编码。"""
|
||||||
try:
|
if ServerEnv is None:
|
||||||
from ahserver.serverenv import ServerEnv
|
raise RuntimeError("ahserver not installed; host app must provide ServerEnv")
|
||||||
return ServerEnv().get_module_dbname("pbl_domain_ext")
|
env = ServerEnv()
|
||||||
except Exception:
|
getter = getattr(env, "get_module_dbname", None)
|
||||||
return _SOR_HOLDER.get("dbname") or ""
|
if getter is None:
|
||||||
|
raise RuntimeError("get_module_dbname is not registered by host app")
|
||||||
|
return getter(MODULE_NAME)
|
||||||
|
|
||||||
|
|
||||||
def execute_sql(sql, args=None):
|
def sor_context():
|
||||||
"""只读/DDL 统一走 sqlExe;写数据请用 insert_ref/update_ref。"""
|
"""返回 (sor, need_close);测试注入时不需关闭。"""
|
||||||
sor = get_sor()
|
if _INJECTED_SOR is not None:
|
||||||
try:
|
return _INJECTED_SOR, False
|
||||||
# sqlExe(sql, ns) 第二参恒需(sqlor:missing 1 required positional argument 'ns')
|
if DBPools is None:
|
||||||
return _drive(sor.sqlExe(sql, args if args else {}))
|
raise RuntimeError("sqlor not installed; host app must provide DBPools")
|
||||||
except PblDomainExtError:
|
return DBPools().sqlorContext(dbname()), True
|
||||||
raise
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
raise PblDomainExtError("PBL_DE_DB_ERROR", detail="%s | %s" % (type(exc).__name__, exc))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
def assert_writable(table: str):
|
||||||
# 关联表 pbl_domain_ref 读写
|
"""基表只读铁律:拒绝任何对 world/scene/entity 的写入。"""
|
||||||
# --------------------------------------------------------------------------
|
if table in BASE_TABLES:
|
||||||
def insert_ref(row):
|
raise AssertionError(
|
||||||
"""新增关联记录,返回受影响行数。"""
|
f"pbl_domain_ext must NOT write base table {table!r} (read-only)")
|
||||||
sor = get_sor()
|
|
||||||
try:
|
|
||||||
return _sor_c(sor, TABLE, row)
|
|
||||||
except PblDomainExtError:
|
|
||||||
raise
|
|
||||||
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(sor, TABLE, row, where_dict)
|
|
||||||
except PblDomainExtError:
|
|
||||||
raise
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
raise PblDomainExtError("PBL_DE_DB_ERROR", detail="update %s: %s" % (TABLE, exc))
|
|
||||||
|
|
||||||
|
def _where_of(where: dict, require_tenant: bool = True):
|
||||||
def soft_delete_ref(where_dict, operator_id=""):
|
"""等值条件构造:键必须落在 FIELD_MAXLEN 白名单内(防未知列注入)。"""
|
||||||
"""逻辑删除(is_deleted=1),绝不物理 DELETE。"""
|
conds, params = [], []
|
||||||
from .base import audit_on_update
|
for key, value in (where or {}).items():
|
||||||
row = audit_on_update({"is_deleted": 1}, operator_id)
|
if key not in FIELD_MAXLEN or value in (None, ""):
|
||||||
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),值一律参数化绑定
|
|
||||||
"""
|
|
||||||
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
|
continue
|
||||||
if isinstance(val, (list, tuple, set)):
|
conds.append(f"`{key}` = %s")
|
||||||
vals = [v for v in val if v not in (None, "")]
|
params.append(clip(key, value))
|
||||||
if not vals:
|
conds.append("`is_deleted` = %s")
|
||||||
continue
|
params.append("0")
|
||||||
where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals))))
|
if require_tenant and "tenant_id" not in (where or {}):
|
||||||
args.extend(vals)
|
# fail-closed:租户条件缺失时永不返回数据
|
||||||
elif val not in (None, ""):
|
conds.append("1 = 0")
|
||||||
where.append("`%s`=%%s" % col)
|
return conds, params
|
||||||
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 []
|
|
||||||
|
|
||||||
|
|
||||||
def count_refs(tenant_id, conds=None):
|
async def select_refs(where: dict, order_by: str = "created_at asc",
|
||||||
"""与 select_refs 同条件的总数(分页用)。"""
|
limit: int = 0, offset: int = 0):
|
||||||
where = ["`tenant_id`=%s", "`is_deleted`=0"]
|
"""按等值条件查询 pbl_domain_ref(自动排除逻辑删除记录)。"""
|
||||||
args = [tenant_id]
|
conds, params = _where_of(where)
|
||||||
for key, val in (conds or {}).items():
|
sql = f"SELECT * FROM `{TABLE}` WHERE " + " AND ".join(conds)
|
||||||
col = as_text(key).strip("`")
|
if order_by:
|
||||||
if col not in LIST_FIELDS:
|
sql += f" ORDER BY {order_by}"
|
||||||
continue
|
if limit and limit > 0:
|
||||||
if isinstance(val, (list, tuple, set)):
|
sql += " LIMIT %s"
|
||||||
vals = [v for v in val if v not in (None, "")]
|
params.append(int(limit))
|
||||||
if not vals:
|
if offset and offset > 0:
|
||||||
continue
|
sql += " OFFSET %s"
|
||||||
where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals))))
|
params.append(int(offset))
|
||||||
args.extend(vals)
|
sor, need_close = sor_context()
|
||||||
elif val not in (None, ""):
|
try:
|
||||||
where.append("`%s`=%%s" % col)
|
rows = await sor.sqlExe(sql, params)
|
||||||
args.append(val)
|
finally:
|
||||||
sql = "SELECT COUNT(1) AS `cnt` FROM `%s` WHERE %s" % (TABLE, " AND ".join(where))
|
if need_close:
|
||||||
rows = execute_sql(sql, tuple(args))
|
await sor.close()
|
||||||
|
return [_row_to_dict(r) for r in (rows or [])]
|
||||||
|
|
||||||
|
|
||||||
|
async def count_refs(where: dict) -> int:
|
||||||
|
"""等值条件计数(分页 total 用)。"""
|
||||||
|
conds, params = _where_of(where)
|
||||||
|
sql = f"SELECT COUNT(1) AS cnt FROM `{TABLE}` WHERE " + " AND ".join(conds)
|
||||||
|
sor, need_close = sor_context()
|
||||||
|
try:
|
||||||
|
rows = await sor.sqlExe(sql, params)
|
||||||
|
finally:
|
||||||
|
if need_close:
|
||||||
|
await sor.close()
|
||||||
if not rows:
|
if not rows:
|
||||||
return 0
|
return 0
|
||||||
row = rows[0]
|
row = rows[0]
|
||||||
if isinstance(row, dict):
|
if isinstance(row, dict):
|
||||||
return int(row.get("cnt") or 0)
|
return int(row.get("cnt") or 0)
|
||||||
return int(row[0] or 0)
|
return int(getattr(row, "cnt", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
async def find_ref_by_id(ref_id: str, tenant_id: str):
|
||||||
# 基础域基表:只读
|
"""按主键 + 租户双条件取单条(租户打头,取不到即视为不可见)。"""
|
||||||
# --------------------------------------------------------------------------
|
if not tenant_id:
|
||||||
def read_base_rows(ref_type, tenant_id, extra_conds=None, limit=200):
|
return None
|
||||||
"""只读拉取基础域基表记录(world/scene/entity)。
|
rows = await select_refs({"id": ref_id, "tenant_id": tenant_id}, limit=1)
|
||||||
|
|
||||||
基表列名因模块而异,这里用 ``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
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_select(sql, args):
|
async def find_ref(ref_type: str, tenant_id: str, ref_id: str = "",
|
||||||
try:
|
blueprint_id: str = "", team_id: str = "",
|
||||||
return execute_sql(sql, args) or []
|
class_id: str = ""):
|
||||||
except PblDomainExtError:
|
"""幂等 upsert 的定位查询。
|
||||||
return []
|
|
||||||
|
|
||||||
|
定位键 = 租户 + ref_type + ref_id +(可选)blueprint_id + team_id + class_id,
|
||||||
def base_table_exists(ref_type):
|
与 DDL 索引 idx_pbl_domain_ref_object / idx_pbl_domain_ref_team 对齐;
|
||||||
"""探测基础域基表是否存在(部署校验用,只读 information_schema)。"""
|
带上 team/class 是为了「同一世界绑定给不同团队」各成一条记录,互不吞并。
|
||||||
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 tenant_id:
|
||||||
if not dbname:
|
return None
|
||||||
return "skip(no dbname)"
|
where = {"tenant_id": tenant_id, "ref_type": ref_type}
|
||||||
probe = ("SELECT column_name FROM information_schema.columns "
|
if ref_id:
|
||||||
"WHERE table_schema=%s AND table_name=%s")
|
where["ref_id"] = ref_id
|
||||||
|
if blueprint_id:
|
||||||
|
where["blueprint_id"] = blueprint_id
|
||||||
|
if team_id:
|
||||||
|
where["team_id"] = team_id
|
||||||
|
if class_id:
|
||||||
|
where["class_id"] = class_id
|
||||||
|
rows = await select_refs(where, limit=1)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 写:本模块表
|
||||||
|
|
||||||
|
async def insert_ref(row: dict):
|
||||||
|
"""新增关联记录(sor.C:表名 + dict)。"""
|
||||||
|
assert_writable(TABLE)
|
||||||
|
payload = {k: clip(k, v) for k, v in (row or {}).items() if k in FIELD_MAXLEN}
|
||||||
|
payload.setdefault("is_deleted", "0")
|
||||||
|
sor, need_close = sor_context()
|
||||||
try:
|
try:
|
||||||
rows = execute_sql(probe, (dbname, TABLE)) or []
|
await sor.C(TABLE, payload)
|
||||||
except PblDomainExtError:
|
finally:
|
||||||
return "skip(probe failed)"
|
if need_close:
|
||||||
cols = set()
|
await sor.close()
|
||||||
for r in rows:
|
return payload
|
||||||
name = r.get("column_name") if isinstance(r, dict) else r[0]
|
|
||||||
cols.add(as_text(name).lower())
|
|
||||||
if not cols:
|
async def update_ref(ref_id: str, tenant_id: str, changes: dict) -> int:
|
||||||
return "skip(table absent)"
|
"""按 id + tenant_id 更新(租户并入 where,跨租户更新天然不命中)。"""
|
||||||
if EXT_FIELD in cols and "ext" in cols:
|
assert_writable(TABLE)
|
||||||
execute_sql("ALTER TABLE `%s` DROP COLUMN `ext`" % TABLE)
|
payload = {k: clip(k, v) for k, v in (changes or {}).items()
|
||||||
return "dropped legacy ext"
|
if k in FIELD_MAXLEN and k not in ("id", "tenant_id")}
|
||||||
if EXT_FIELD in cols:
|
if not payload or not tenant_id:
|
||||||
return "ok(ext_json present)"
|
return 0
|
||||||
if "ext" in cols:
|
where = {"id": clip("id", ref_id), "tenant_id": clip("tenant_id", tenant_id),
|
||||||
execute_sql("ALTER TABLE `%s` CHANGE COLUMN `ext` `%s` longtext "
|
"is_deleted": "0"}
|
||||||
"COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD))
|
sor, need_close = sor_context()
|
||||||
return "renamed ext -> ext_json"
|
try:
|
||||||
execute_sql("ALTER TABLE `%s` ADD COLUMN `%s` longtext "
|
result = await sor.U(TABLE, {"data": payload, "where": where})
|
||||||
"COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD))
|
finally:
|
||||||
return "added ext_json"
|
if need_close:
|
||||||
|
await sor.close()
|
||||||
|
if isinstance(result, int):
|
||||||
|
return result
|
||||||
|
return 1 if result else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_ref(ref_id: str, tenant_id: str, operator: str = "") -> int:
|
||||||
|
"""逻辑删除(bind_state -> unbound、is_deleted -> 1),不做物理 delete。"""
|
||||||
|
changes = {"bind_state": "unbound", "is_deleted": "1"}
|
||||||
|
if operator:
|
||||||
|
changes["updated_by"] = clip("updated_by", operator)
|
||||||
|
return await update_ref(ref_id, tenant_id, changes)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 读:基础域基表
|
||||||
|
|
||||||
|
async def read_base(table: str, where: dict, columns=None, limit: int = 0):
|
||||||
|
"""只读基础域基表(world/scene/entity);本函数无任何写分支。"""
|
||||||
|
if table not in BASE_TABLES:
|
||||||
|
raise ValueError(f"unknown base table {table!r}")
|
||||||
|
cols = ", ".join(f"`{c}`" for c in (columns or _base_columns(table)))
|
||||||
|
conds, params = [], []
|
||||||
|
for key, value in (where or {}).items():
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
conds.append(f"`{key}` = %s")
|
||||||
|
params.append(as_text(value).strip()[:64])
|
||||||
|
sql = f"SELECT {cols} FROM `{table}`"
|
||||||
|
if conds:
|
||||||
|
sql += " WHERE " + " AND ".join(conds)
|
||||||
|
if limit and limit > 0:
|
||||||
|
sql += " LIMIT %s"
|
||||||
|
params.append(int(limit))
|
||||||
|
sor, need_close = sor_context()
|
||||||
|
try:
|
||||||
|
rows = await sor.sqlExe(sql, params)
|
||||||
|
finally:
|
||||||
|
if need_close:
|
||||||
|
await sor.close()
|
||||||
|
return [_row_to_dict(r) for r in (rows or [])]
|
||||||
|
|
||||||
|
|
||||||
|
async def read_base_many(table: str, column: str, values, columns=None):
|
||||||
|
"""基表 IN 批量读(避免 N+1;values 去空去重,上限 500)。"""
|
||||||
|
if table not in BASE_TABLES:
|
||||||
|
raise ValueError(f"unknown base table {table!r}")
|
||||||
|
vals = []
|
||||||
|
for v in values or []:
|
||||||
|
text = as_text(v).strip()[:64]
|
||||||
|
if text and text not in vals:
|
||||||
|
vals.append(text)
|
||||||
|
if not vals:
|
||||||
|
return []
|
||||||
|
vals = vals[:500]
|
||||||
|
cols = ", ".join(f"`{c}`" for c in (columns or _base_columns(table)))
|
||||||
|
placeholders = ", ".join(["%s"] * len(vals))
|
||||||
|
sql = f"SELECT {cols} FROM `{table}` WHERE `{column}` IN ({placeholders})"
|
||||||
|
sor, need_close = sor_context()
|
||||||
|
try:
|
||||||
|
rows = await sor.sqlExe(sql, vals)
|
||||||
|
finally:
|
||||||
|
if need_close:
|
||||||
|
await sor.close()
|
||||||
|
return [_row_to_dict(r) for r in (rows or [])]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 内部工具
|
||||||
|
|
||||||
|
def _base_columns(table: str):
|
||||||
|
from .base import BASE_COLUMNS
|
||||||
|
return BASE_COLUMNS.get(table, ("id",))
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row):
|
||||||
|
"""sqlExe 返回 DictObject(不能 dict(row));逐键取属性/映射。"""
|
||||||
|
if isinstance(row, dict):
|
||||||
|
return dict(row)
|
||||||
|
out = {}
|
||||||
|
keys = getattr(row, "keys", None)
|
||||||
|
if callable(keys):
|
||||||
|
for key in keys():
|
||||||
|
out[key] = getattr(row, key, None)
|
||||||
|
if not out:
|
||||||
|
for key in FIELD_MAXLEN:
|
||||||
|
value = getattr(row, key, None)
|
||||||
|
if value is not None:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|||||||
@ -1,90 +1,33 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_domain_ext 错误码与异常。
|
"""pbl_domain_ext 错误码(与 pbl_common 口径一致:纯字符串常量,零外部依赖)。
|
||||||
|
|
||||||
错误码前缀 ``PBL_DE_``,与 pbl_common 错误码体系同构(code / message / http_status)。
|
约定:api 层返回 dict,``code`` 取本模块常量;``ok=True`` 仅在 code == OK 时出现。
|
||||||
所有对外契约接口失败时统一抛 :class:`PblDomainExtError`,由 wwwroot/api/*.dspy 层
|
fail-closed:任何租户上下文缺失或跨租户访问一律返回错误码,不返回数据。
|
||||||
捕获并转 ``{"code": "...", "message": "...", "success": false}`` JSON,绝不裸抛栈。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["ERRORS", "PblDomainExtError", "err_payload"]
|
OK = "PBL_OK"
|
||||||
|
TENANT_REQUIRED = "PBL_TENANT_REQUIRED" # 缺少 tenant_id -> fail-closed
|
||||||
|
PARAM_INVALID = "PBL_PARAM_INVALID" # 参数非法
|
||||||
|
REF_TYPE_INVALID = "PBL_REF_TYPE_INVALID" # ref_type 不在 world/scene/entity
|
||||||
|
REF_NOT_FOUND = "PBL_DOMAIN_REF_NOT_FOUND" # 关联记录不存在
|
||||||
|
BASE_OBJECT_NOT_FOUND = "PBL_BASE_OBJECT_NOT_FOUND" # 基础域对象不存在/不可见
|
||||||
|
FORBIDDEN_CROSS_TENANT = "PBL_FORBIDDEN_CROSS_TENANT" # 跨租户访问 -> fail-closed
|
||||||
|
BASE_READONLY = "PBL_BASE_TABLE_READONLY" # 试图写基础域基表
|
||||||
|
DB_ERROR = "PBL_DB_ERROR"
|
||||||
|
|
||||||
#: code -> (默认中文消息, HTTP 状态码)
|
MESSAGES = {
|
||||||
ERRORS = {
|
OK: "ok",
|
||||||
"PBL_DE_OK": ("成功", 200),
|
TENANT_REQUIRED: "tenant_id is required (fail-closed)",
|
||||||
"PBL_DE_TENANT_MISSING": ("tenant_id 缺失或非法", 400),
|
PARAM_INVALID: "invalid parameter",
|
||||||
"PBL_DE_PARAM_INVALID": ("参数非法", 400),
|
REF_TYPE_INVALID: "ref_type must be one of world/scene/entity",
|
||||||
"PBL_DE_REF_TYPE_INVALID": ("ref_type 非法,仅允许 world/scene/entity", 400),
|
REF_NOT_FOUND: "domain ref record not found",
|
||||||
"PBL_DE_STATE_INVALID": ("bind_state 非法,仅允许 bound/unbound", 400),
|
BASE_OBJECT_NOT_FOUND: "base domain object not found in this tenant",
|
||||||
"PBL_DE_EXT_JSON_INVALID": ("ext_json 不是合法 JSON 文本", 400),
|
FORBIDDEN_CROSS_TENANT: "cross tenant access denied",
|
||||||
"PBL_DE_NOT_FOUND": ("关联记录不存在", 404),
|
BASE_READONLY: "base domain tables are read-only for pbl_domain_ext",
|
||||||
"PBL_DE_BASE_MISSING": ("基础域对象不存在(world/scene/entity 基表未命中)", 404),
|
DB_ERROR: "database error",
|
||||||
"PBL_DE_DUPLICATE": ("关联记录已存在", 409),
|
|
||||||
"PBL_DE_ACCESS_DENIED": ("无权访问该租户/对象数据", 403),
|
|
||||||
"PBL_DE_READONLY_TABLE": ("基础域基表只读,禁止写入或改结构", 403),
|
|
||||||
"PBL_DE_DB_ERROR": ("数据库操作失败", 500),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PblDomainExtError(Exception):
|
def message(code: str) -> str:
|
||||||
"""pbl_domain_ext 统一业务异常。
|
"""按错误码取默认文案(未知码回落到码本身,便于排查)。"""
|
||||||
|
return MESSAGES.get(code, code)
|
||||||
: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):
|
|
||||||
"""转 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)
|
|
||||||
|
|
||||||
|
|
||||||
def err_payload(code, message=None, detail=None):
|
|
||||||
"""不抛异常、直接取错误响应体(dspy 层便捷函数)。"""
|
|
||||||
return PblDomainExtError(code, message=message, detail=detail).to_payload()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 兼容别名(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"
|
|
||||||
|
|
||||||
__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,26 +1,29 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_domain_ext 模块挂载入口。
|
"""pbl_domain_ext 模块挂载入口(load_pbl_domain_ext)。
|
||||||
|
|
||||||
应用侧调用 ``load_pbl_domain_ext()`` 完成:
|
应用侧调用 ``load_pbl_domain_ext()`` 完成:
|
||||||
1. 注册 13 个 dspy 契约路由(wwwroot/api/*.dspy -> api.py 契约函数);
|
1. 把 13 个契约函数 + 1 个物化函数注册到 ServerEnv(.dspy / .ui 直接按名调用);
|
||||||
2. 注册 RBAC 权限点(owner.pbl / tenant 管理员可写,其余只读);
|
2. 暴露路由表 / RBAC 权限点表供 scripts/load_path.py 使用;
|
||||||
3. 注入 sqlor 句柄与库名(ServerEnv().get_module_dbname);
|
3. 库名由宿主应用 ``get_module_dbname('pbl_domain_ext')`` 决定,本模块不硬编码。
|
||||||
4. 幂等保障扩展列名为设计 §J1 权威名 ``ext_json``(旧名 ext_json 自动 RENAME)。
|
|
||||||
|
|
||||||
铁律:本模块**不创建/不修改** world、scene、entity 三张基础域基表。
|
铁律:本模块**不创建 / 不修改** world、scene、entity 三张基础域基表,
|
||||||
|
唯一新增表是关联表 ``pbl_domain_ref``(见 models/ + sql/)。
|
||||||
|
|
||||||
|
⚠️ 三处同步(module-development-spec):实现 api.py → 本文件 env.xxx = xxx
|
||||||
|
→ 包 ``__init__.py`` import。新增/删除函数必须三处一起改。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import api, db
|
from . import api, db
|
||||||
from .base import TABLE, EXT_FIELD, REF_TYPES, BIND_STATES
|
from .base import (
|
||||||
from .errors import PblDomainExtError
|
BASE_TABLES, BIND_STATES, FIELD_MAXLEN, LIST_FIELDS, MODULE_NAME, REF_TYPES,
|
||||||
|
TABLE,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS",
|
__all__ = ["load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS",
|
||||||
"CONTRACT_FUNCS", "_register_serverenv",
|
"CONTRACT_FUNCS", "TABLE", "REF_TYPES", "BIND_STATES",
|
||||||
"TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES"]
|
"BASE_TABLES", "FIELD_MAXLEN", "LIST_FIELDS"]
|
||||||
|
|
||||||
MODULE_NAME = "pbl_domain_ext"
|
#: dspy 契约路由表:url 后缀 -> (api 函数名, 是否写操作)
|
||||||
|
|
||||||
#: dspy 契约路由表:url 后缀 -> (action 名, 是否写操作)
|
|
||||||
API_ROUTES = [
|
API_ROUTES = [
|
||||||
("pbl_domain_ref_bind", "pbl_domain_ref_bind", True),
|
("pbl_domain_ref_bind", "pbl_domain_ref_bind", True),
|
||||||
("pbl_domain_ref_unbind", "pbl_domain_ref_unbind", True),
|
("pbl_domain_ref_unbind", "pbl_domain_ref_unbind", True),
|
||||||
@ -37,113 +40,68 @@ API_ROUTES = [
|
|||||||
("pbl_team_list_by_class", "pbl_team_list_by_class", False),
|
("pbl_team_list_by_class", "pbl_team_list_by_class", False),
|
||||||
]
|
]
|
||||||
|
|
||||||
#: RBAC 权限点(写 4 个 + 读 1 个聚合)
|
#: 跨模块 import 契约(pbl_compiler 依赖,不进 dspy 路由)
|
||||||
|
CROSS_MODULE_FUNCS = ["pbl_domain_materialize_game_definition"]
|
||||||
|
|
||||||
|
#: RBAC 权限点(写 3 个 + 读 1 个聚合)
|
||||||
PERMISSIONS = [
|
PERMISSIONS = [
|
||||||
{"code": "pbl_domain_ext:ref:write", "name": "PBL基础域关联-写",
|
{"code": "pbl_domain_ext:ref:write", "name": "PBL基础域关联-写",
|
||||||
"note": "绑定/解绑/更新 pbl_domain_ref"},
|
"note": "绑定/解绑/更新 pbl_domain_ref"},
|
||||||
{"code": "pbl_domain_ext:ref:read", "name": "PBL基础域关联-读",
|
{"code": "pbl_domain_ext:ref:read", "name": "PBL基础域关联-读",
|
||||||
"note": "查询关联记录与基础域只读视图"},
|
"note": "查询关联记录与基础域只读视图"},
|
||||||
{"code": "pbl_domain_ext:team:bind", "name": "PBL团队绑定世界",
|
{"code": "pbl_domain_ext:team:bind", "name": "PBL团队绑定世界",
|
||||||
"note": "team/class 与 world 的绑定关系维护"},
|
"note": "pbl_team_bind_world / pbl_team_world_list"},
|
||||||
|
{"code": "pbl_domain_ext:gd:materialize", "name": "PBL物化GD片段",
|
||||||
|
"note": "供 pbl_compiler 编译期调用"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
#: 契约函数名(与 wwwroot/api/*.dspy 一一对应;三处注册之③ load 期挂 ServerEnv)
|
def contract_funcs():
|
||||||
CONTRACT_FUNCS = [
|
"""契约函数名清单(含跨模块物化函数),供自检与测试遍历。"""
|
||||||
"pbl_domain_ref_bind", "pbl_domain_ref_unbind", "pbl_domain_ref_update",
|
names = [name for _url, name, _w in API_ROUTES]
|
||||||
"pbl_domain_ref_get", "pbl_domain_ref_list", "pbl_domain_ref_check_access",
|
names.extend(CROSS_MODULE_FUNCS)
|
||||||
"pbl_world_list_by_tenant", "pbl_world_get_context",
|
return names
|
||||||
"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",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _register_serverenv():
|
#: 模块对外全部可调用符号(三处同步的权威清单)
|
||||||
"""把契约函数注册为 ServerEnv 全局(.dspy 直接按名调用,无需 import)。
|
CONTRACT_FUNCS = contract_funcs()
|
||||||
|
|
||||||
module-development-spec「Triple-place function registration」之第③处:
|
|
||||||
① 实现 pbl_domain_ext/api.py ② 包导出 pbl_domain_ext/__init__.py
|
def load_pbl_domain_ext(env=None):
|
||||||
③ 本函数 env.<name> = <name>
|
"""把本模块函数注册到 ServerEnv(进程级单例,宿主应用 init() 内调用)。
|
||||||
缺③ → dspy 运行期 NameError: name 'pbl_xxx' is not defined。
|
|
||||||
|
返回注册摘要,便于宿主打印/自检。注册项 = 13 个 dspy 契约 + 1 个物化契约
|
||||||
|
+ 表名/类型常量(.dspy 里可直接引用 TABLE / REF_TYPES)。
|
||||||
"""
|
"""
|
||||||
registered, missing = [], []
|
if env is None:
|
||||||
env = None
|
try:
|
||||||
try:
|
from ahserver.serverenv import ServerEnv
|
||||||
from ahserver.serverenv import ServerEnv
|
except ImportError: # pragma: no cover
|
||||||
|
raise RuntimeError("ahserver not installed; pass env explicitly")
|
||||||
env = ServerEnv()
|
env = ServerEnv()
|
||||||
except Exception: # noqa: BLE001 非 ahserver 宿主(单测/离线)跳过
|
target = env
|
||||||
env = None
|
|
||||||
|
registered = []
|
||||||
for name in CONTRACT_FUNCS:
|
for name in CONTRACT_FUNCS:
|
||||||
func = getattr(api, name, None)
|
func = getattr(api, name, None)
|
||||||
if func is None:
|
if func is None:
|
||||||
missing.append(name)
|
raise AttributeError(
|
||||||
continue
|
f"pbl_domain_ext: contract function {name!r} missing in api.py")
|
||||||
if env is not None:
|
setattr(target, name, func)
|
||||||
try:
|
|
||||||
setattr(env, name, func)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
registered.append(name)
|
registered.append(name)
|
||||||
return {"registered": registered, "missing": missing, "serverenv": env is not None}
|
|
||||||
|
|
||||||
|
# 常量与工具(.dspy 全局直用,避免每个 dspy 重复 import)
|
||||||
|
target.pbl_domain_ext_table = TABLE
|
||||||
|
target.pbl_domain_ext_ref_types = REF_TYPES
|
||||||
|
target.pbl_domain_ext_bind_states = BIND_STATES
|
||||||
|
target.pbl_domain_ext_base_tables = BASE_TABLES
|
||||||
|
|
||||||
def _register_routes(app=None):
|
# 兼容旧调用点:单/复数同名注册(CRUD 工厂生成的 add_/update_/delete_ 前缀)
|
||||||
"""注册 dspy 路由(平台 register_dspy 可用时走平台,否则仅返回路由表)。"""
|
for alias, real in (("pbl_domain_ref_add", "pbl_domain_ref_bind"),
|
||||||
registered = []
|
("pbl_domain_ref_delete", "pbl_domain_ref_unbind")):
|
||||||
for name, action, is_write in API_ROUTES:
|
setattr(target, alias, getattr(api, real))
|
||||||
url = "/%s/api/%s.dspy" % (MODULE_NAME, name)
|
registered.append(alias)
|
||||||
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
|
|
||||||
|
|
||||||
|
return {"module": MODULE_NAME, "loaded": True, "funcs": registered,
|
||||||
def _register_permissions(app=None):
|
"table": TABLE, "routes": len(API_ROUTES),
|
||||||
"""注册 RBAC 权限点(幂等)。"""
|
"permissions": [p["code"] for p in PERMISSIONS]}
|
||||||
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)
|
|
||||||
env_info = _register_serverenv()
|
|
||||||
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,
|
|
||||||
"serverenv": env_info,
|
|
||||||
"permissions": perms,
|
|
||||||
"schema_action": schema_action,
|
|
||||||
"base_tables_readonly": ["world", "scene", "entity"],
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,71 +1,89 @@
|
|||||||
-- =====================================================================
|
-- =============================================================================
|
||||||
-- pbl_domain_ext —— [M8] 基础域薄扩展 world/scene/entity
|
-- pbl_domain_ext [M8] 基础域薄扩展 world/scene/entity
|
||||||
-- 由 models/pbl_domain_ref.json 生成(json2ddl mysql .),列名/类型与
|
-- =============================================================================
|
||||||
-- models fields、pbl_domain_ext/base.py LIST_FIELDS 严格同名同型(QC#1/#2)。
|
-- 铁律:
|
||||||
--
|
-- 1. 本文件**只创建 1 张表** pbl_domain_ref(关联表,ref_type 区分
|
||||||
-- 薄扩展铁律(Q-OPEN-3):本文件**只建 1 张关联表 pbl_domain_ref**,
|
-- world / scene / entity)。world / scene / entity 三张基础域基表
|
||||||
-- 不创建、不修改 world / scene / entity 三张基础域基表结构。
|
-- 由各自基础模块维护,本模块**不建、不改、不写**(Q-OPEN-3)。
|
||||||
-- =====================================================================
|
-- 2. 列名 / 类型 / 长度与 models/pbl_domain_ref.json、
|
||||||
|
-- pbl_domain_ext/base.py 的 FIELD_MAXLEN 三处同名同型,
|
||||||
|
-- 由 tests/test_models_contract.py 机械校验。
|
||||||
|
-- 3. 幂等:CREATE TABLE IF NOT EXISTS + 下方 ext_json 列名收敛块
|
||||||
|
-- (历史遗留列名 domain_ext_json → 设计 §J1 权威列名 ext_json)。
|
||||||
|
-- RENAME 前先查 information_schema,重复执行不报错。
|
||||||
|
-- 4. collation 显式 utf8mb4_unicode_ci(与 xls2ddl/json2ddl 标准一致,
|
||||||
|
-- 避免与 appcodes_kv JOIN 时 Illegal mix of collations)。
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- 1. 关联表 pbl_domain_ref
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
CREATE TABLE IF NOT EXISTS `pbl_domain_ref` (
|
CREATE TABLE IF NOT EXISTS `pbl_domain_ref` (
|
||||||
`id` VARCHAR(32) NOT NULL COMMENT '主键',
|
`id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键ID',
|
||||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(打头)',
|
`tenant_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '租户ID',
|
||||||
`ref_type` VARCHAR(32) NOT NULL COMMENT '关联对象类型(world/scene/entity)',
|
`ref_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联类型(world/scene/entity)',
|
||||||
`ref_id` VARCHAR(64) NOT NULL COMMENT '基表记录ID(world.id/scene.id/entity.id,基表主键为str32)',
|
`ref_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '基础域对象ID',
|
||||||
`ref_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '基表记录编码快照(只读冗余,不回写基表)',
|
`ref_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '基础域对象编码',
|
||||||
`ref_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '基表记录名称快照(只读冗余,不回写基表)',
|
`ref_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '基础域对象名称',
|
||||||
`blueprint_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联蓝图ID(pbl_blueprint主键,str32)',
|
`blueprint_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'PBL蓝图ID',
|
||||||
`class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID',
|
`team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',
|
||||||
`team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',
|
`class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID',
|
||||||
`bind_state` VARCHAR(16) NOT NULL DEFAULT 'bound' COMMENT '绑定状态(bound/unbound)',
|
`bind_state` VARCHAR(32) NOT NULL DEFAULT 'bound' COMMENT '绑定状态(bound/unbound)',
|
||||||
`bind_at` TIMESTAMP NULL COMMENT '绑定时间',
|
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
`ext_json` LONGTEXT NULL COMMENT '扩展JSON(租户/班级/团队关联属性,设计§J1权威名)',
|
`created_by` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||||
`is_deleted` SMALLINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0正常/1已解绑)',
|
`updated_by` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新人',
|
||||||
`creator_id` VARCHAR(64) NULL COMMENT '创建人',
|
`ext_json` TEXT NULL COMMENT '扩展属性JSON(设计§J1权威列名)',
|
||||||
`created_at` TIMESTAMP NOT NULL COMMENT '创建时间',
|
`is_deleted` VARCHAR(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记(0正常/1已删)',
|
||||||
`updater_id` VARCHAR(64) NULL COMMENT '更新人',
|
`created_at` DATETIME NULL COMMENT '创建时间',
|
||||||
`updated_at` TIMESTAMP NULL COMMENT '更新时间',
|
`updated_at` DATETIME NULL COMMENT '更新时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_pbl_domain_ref` (`tenant_id`, `ref_type`, `ref_id`),
|
KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`, `is_deleted`),
|
||||||
KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`),
|
KEY `idx_pbl_domain_ref_object` (`tenant_id`, `ref_type`, `ref_id`),
|
||||||
KEY `idx_pbl_domain_ref_blueprint` (`tenant_id`, `blueprint_id`),
|
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`, `class_id`, `team_id`)
|
||||||
KEY `idx_pbl_domain_ref_team` (`tenant_id`, `team_id`),
|
|
||||||
KEY `idx_pbl_domain_ref_state` (`tenant_id`, `bind_state`, `is_deleted`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='PBL基础域薄扩展关联表(world/scene/entity)';
|
COMMENT='PBL基础域关联表(world/scene/entity)';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 幂等列名收敛(ext_json 为设计 §J1 权威列名)
|
||||||
|
-- MySQL 8.0 不支持 ADD/RENAME COLUMN IF NOT EXISTS,用 prepared statement 兜底。
|
||||||
|
-- 执行顺序:① 旧列存在且新列不存在 -> CHANGE COLUMN 重命名;
|
||||||
|
-- ② 两列都不存在 -> ADD COLUMN;③ 新列已存在 -> 什么都不做。
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SET @tbl := 'pbl_domain_ref';
|
||||||
|
SET @old_col := 'domain_ext_json';
|
||||||
|
SET @new_col := 'ext_json';
|
||||||
|
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @old_col);
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @new_col);
|
||||||
|
|
||||||
|
SET @sql := IF(@has_old = 1 AND @has_new = 0,
|
||||||
|
CONCAT('ALTER TABLE `', @tbl, '` CHANGE COLUMN `', @old_col, '` `', @new_col,
|
||||||
|
'` TEXT NULL COMMENT ''扩展属性JSON(设计§J1权威列名)'''),
|
||||||
|
'SELECT ''pbl_domain_ext: ext_json rename skipped (already converged or absent)''');
|
||||||
|
PREPARE pbl_ext_rename FROM @sql; EXECUTE pbl_ext_rename; DEALLOCATE PREPARE pbl_ext_rename;
|
||||||
|
|
||||||
|
SET @sql := IF(@has_old = 0 AND @has_new = 0,
|
||||||
|
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @new_col,
|
||||||
|
'` TEXT NULL COMMENT ''扩展属性JSON(设计§J1权威列名)'''),
|
||||||
|
'SELECT ''pbl_domain_ext: ext_json present''');
|
||||||
|
PREPARE pbl_ext_add FROM @sql; EXECUTE pbl_ext_add; DEALLOCATE PREPARE pbl_ext_add;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 编码字典(appcodes 父记录 + appcodes_kv 子记录,与 init/data.json 同源)
|
||||||
|
-- 说明:init/data.json 由 dbloader 幂等写入;此处 SQL 版供手工建库场景兜底。
|
||||||
|
-- 组 key 长度均 <= 22 字符(parentid + '_' + k <= 32)。
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- 2. 字典: pbl_domain_ref_type / pbl_bind_state
|
|
||||||
-- models.codes 引用 appcodes_kv, cond 必须 parentid=(禁 id=)
|
|
||||||
-- appcodes 与 appcodes_kv 必须成对写入
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
INSERT INTO `appcodes` (`id`, `name`, `hierarchy_flg`) VALUES
|
INSERT INTO `appcodes` (`id`, `name`, `hierarchy_flg`) VALUES
|
||||||
('pbl_domain_ref_type', 'PBL关联对象类型', '0'),
|
('pbl_domain_ref_type', 'PBL基础域关联类型', '0'),
|
||||||
('pbl_bind_state', 'PBL关联绑定状态', '0')
|
('pbl_bind_state', 'PBL绑定状态', '0')
|
||||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `hierarchy_flg` = VALUES(`hierarchy_flg`);
|
||||||
|
|
||||||
INSERT INTO `appcodes_kv` (`id`, `parentid`, `k`, `v`) VALUES
|
INSERT INTO `appcodes_kv` (`id`, `parentid`, `k`, `v`) VALUES
|
||||||
('pbl_drt_world', 'pbl_domain_ref_type', 'world', '世界'),
|
('pbl_domain_ref_type_world', 'pbl_domain_ref_type', 'world', '世界'),
|
||||||
('pbl_drt_scene', 'pbl_domain_ref_type', 'scene', '场景'),
|
('pbl_domain_ref_type_scene', 'pbl_domain_ref_type', 'scene', '场景'),
|
||||||
('pbl_drt_entity', 'pbl_domain_ref_type', 'entity', '实体'),
|
('pbl_domain_ref_type_entity', 'pbl_domain_ref_type', 'entity', '实体'),
|
||||||
('pbl_bs_bound', 'pbl_bind_state', 'bound', '已绑定'),
|
('pbl_bind_state_bound', 'pbl_bind_state', 'bound', '已绑定'),
|
||||||
('pbl_bs_unbound', 'pbl_bind_state', 'unbound', '已解绑')
|
('pbl_bind_state_unbound', 'pbl_bind_state', 'unbound', '已解绑')
|
||||||
ON DUPLICATE KEY UPDATE `v` = VALUES(`v`);
|
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;
|
|
||||||
|
|||||||
454
tests/fake_db.py
454
tests/fake_db.py
@ -1,16 +1,27 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C/U/D/R/I/sqlExe。
|
"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C / U / D / R / I / sqlExe。
|
||||||
|
|
||||||
要点:
|
契约(与 pbl_domain_ext/db.py 完全对齐):
|
||||||
* ``sqlExe`` 先把 ``%s`` 占位按序插值为字面量,再用极简解析器执行
|
* ``C(table, row)`` 插入一行
|
||||||
SELECT / SELECT COUNT(1) / LIMIT / OFFSET / IN / AND 等值条件;
|
* ``U(table, spec)`` spec = {"data": {...}, "where": {...}},返回受影响行数
|
||||||
* world / scene / entity 三张基础域基表**只读**:任何 C/U/D/I 写入都会
|
* ``D(table, spec)`` spec = {"where": {...}},物理删除(本模块不使用)
|
||||||
记录到 ``base_write_attempts`` 并抛 AssertionError,用于验证「不改基表」铁律。
|
* ``R(table, spec)`` spec = {"where": {...}},读取
|
||||||
|
* ``I(row)`` 单参数,表名取 context 默认表(pbl_domain_ref)
|
||||||
|
* ``sqlExe(sql, params)`` 先按序把 ``%s`` 插值为字面量,再用极简解析器执行
|
||||||
|
``SELECT ... FROM t WHERE a = 'x' AND b IN ('1','2') [ORDER BY ...] [LIMIT n] [OFFSET m]``
|
||||||
|
以及 ``SELECT COUNT(1) AS cnt ...``;``1 = 0`` 恒不匹配(fail-closed 断言用)。
|
||||||
|
|
||||||
|
基表只读把守:world / scene / entity 上任何 C/U/D/I 都会记入
|
||||||
|
``base_write_attempts`` 并抛 AssertionError,用于机械验证「不改基表」铁律。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
__all__ = ["FakeSor", "make_fake_sor", "BASE_FIXTURES"]
|
__all__ = ["FakeSor", "make_fake_sor", "BASE_FIXTURES", "DEFAULT_TABLE"]
|
||||||
|
|
||||||
|
DEFAULT_TABLE = "pbl_domain_ref"
|
||||||
|
|
||||||
|
BASE_TABLES = ("world", "scene", "entity")
|
||||||
|
|
||||||
BASE_FIXTURES = {
|
BASE_FIXTURES = {
|
||||||
"world": [
|
"world": [
|
||||||
@ -23,263 +34,258 @@ BASE_FIXTURES = {
|
|||||||
],
|
],
|
||||||
"scene": [
|
"scene": [
|
||||||
{"id": "S1", "tenant_id": "T1", "world_id": "W1", "code": "SCN-001",
|
{"id": "S1", "tenant_id": "T1", "world_id": "W1", "code": "SCN-001",
|
||||||
"name": "着陆区", "status": "active"},
|
"name": "基地外围", "status": "published",
|
||||||
|
"created_at": "2026-09-01 11:00:00"},
|
||||||
{"id": "S2", "tenant_id": "T1", "world_id": "W1", "code": "SCN-002",
|
{"id": "S2", "tenant_id": "T1", "world_id": "W1", "code": "SCN-002",
|
||||||
"name": "实验舱", "status": "active"},
|
"name": "内部舱段", "status": "draft",
|
||||||
|
"created_at": "2026-09-01 11:05:00"},
|
||||||
{"id": "S3", "tenant_id": "T1", "world_id": "W2", "code": "SCN-003",
|
{"id": "S3", "tenant_id": "T1", "world_id": "W2", "code": "SCN-003",
|
||||||
"name": "深水区", "status": "active"},
|
"name": "海沟观测站", "status": "draft",
|
||||||
|
"created_at": "2026-09-02 11:00:00"},
|
||||||
|
{"id": "S9", "tenant_id": "T2", "world_id": "W3", "code": "SCN-101",
|
||||||
|
"name": "他租户场景", "status": "draft",
|
||||||
|
"created_at": "2026-09-03 11:00:00"},
|
||||||
],
|
],
|
||||||
"entity": [
|
"entity": [
|
||||||
{"id": "E1", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-001",
|
{"id": "E1", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-001",
|
||||||
"name": "探测车", "status": "idle"},
|
"name": "漫游车", "kind": "vehicle", "created_at": "2026-09-01 12:00:00"},
|
||||||
{"id": "E2", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-002",
|
{"id": "E2", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-002",
|
||||||
"name": "宇航员A", "status": "idle"},
|
"name": "太阳能板", "kind": "device", "created_at": "2026-09-01 12:01:00"},
|
||||||
{"id": "E3", "tenant_id": "T1", "scene_id": "S2", "code": "ENT-003",
|
{"id": "E3", "tenant_id": "T1", "scene_id": "S2", "code": "ENT-003",
|
||||||
"name": "培养皿", "status": "idle"},
|
"name": "气闸", "kind": "structure", "created_at": "2026-09-01 12:02:00"},
|
||||||
|
{"id": "E9", "tenant_id": "T2", "scene_id": "S9", "code": "ENT-101",
|
||||||
|
"name": "他租户实体", "kind": "vehicle", "created_at": "2026-09-03 12:00:00"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
_READONLY_TABLES = ("world", "scene", "entity")
|
|
||||||
|
|
||||||
|
def _lit(value):
|
||||||
def _literal(value):
|
"""把参数转成 SQL 字面量(仅测试解析用,不参与真实 SQL)。"""
|
||||||
"""把绑定参数转成 SQL 字面量(仅测试用)。"""
|
|
||||||
if value is None:
|
if value is None:
|
||||||
return "NULL"
|
return "NULL"
|
||||||
if isinstance(value, bool):
|
|
||||||
return "1" if value else "0"
|
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, (int, float)):
|
||||||
return str(value)
|
return str(value)
|
||||||
return "'%s'" % str(value).replace("\\", "\\\\").replace("'", "''")
|
return "'" + str(value).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
def _coerce(token):
|
class FakeSor:
|
||||||
"""把字面量 token 还原成 Python 值。"""
|
"""内存 sqlor 替身。"""
|
||||||
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:
|
|
||||||
return int(token)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
return float(token)
|
|
||||||
except ValueError:
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
def __init__(self, tables=None, base_fixtures=None, default_table=DEFAULT_TABLE):
|
||||||
def _loose_eq(left, right):
|
self.tables = dict(tables) if tables is not None else {DEFAULT_TABLE: []}
|
||||||
"""宽松相等:兼容 '0' vs 0、str vs int 的列值比较。"""
|
self.tables.setdefault(DEFAULT_TABLE, [])
|
||||||
if left == right:
|
self.base = {t: [dict(r) for r in (base_fixtures or BASE_FIXTURES)[t]]
|
||||||
return True
|
for t in BASE_TABLES}
|
||||||
if left is None or right is None:
|
self.default_table = default_table
|
||||||
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 = []
|
self.base_write_attempts = []
|
||||||
|
self.calls = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
# ---- sqlor 标准 API -------------------------------------------------
|
# -------------------------------------------------------- 标准 API 子集
|
||||||
def C(self, tbl, row):
|
|
||||||
self._guard_readonly(tbl, "C")
|
def _guard(self, table, op):
|
||||||
self.tables.setdefault(tbl, []).append(dict(row))
|
self.calls.append((op, table))
|
||||||
self.sql_log.append(("C", tbl, dict(row)))
|
if table in self.base:
|
||||||
|
self.base_write_attempts.append((op, table))
|
||||||
|
raise AssertionError(
|
||||||
|
f"base table {table!r} is read-only for pbl_domain_ext (op={op})")
|
||||||
|
|
||||||
|
def _rows(self, table):
|
||||||
|
if table in self.base:
|
||||||
|
return self.base[table]
|
||||||
|
return self.tables.setdefault(table, [])
|
||||||
|
|
||||||
|
async def C(self, table, row):
|
||||||
|
self._guard(table, "C")
|
||||||
|
self._rows(table).append(dict(row))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
def U(self, tbl, ns, where=None):
|
async def I(self, row):
|
||||||
"""真实 sqlor 签名:``sor.U(table, ns)`` 两参,where 条件并入 ns。
|
table = (row or {}).get("__table__") or self.default_table
|
||||||
|
return await self.C(table, row)
|
||||||
|
|
||||||
与真实实现同构:主键 ``id`` 与租户列 ``tenant_id`` 从 ns 中提出作 WHERE,
|
async def U(self, table, spec):
|
||||||
其余列作 SET。三参调用(旧 fake 签名)仍兼容,便于对照回归。
|
self._guard(table, "U")
|
||||||
"""
|
spec = spec or {}
|
||||||
ns = dict(ns or {})
|
data = spec.get("data") or {}
|
||||||
if where is None:
|
where = spec.get("where") or {}
|
||||||
where = {}
|
|
||||||
for key in ("id", "tenant_id"):
|
|
||||||
if key in ns:
|
|
||||||
where[key] = ns.pop(key)
|
|
||||||
if not where:
|
|
||||||
where = {"id": ns.pop("id", None)} if "id" in ns else {}
|
|
||||||
self._guard_readonly(tbl, "U")
|
|
||||||
hits = 0
|
hits = 0
|
||||||
for exist in self.tables.get(tbl, []):
|
for row in self._rows(table):
|
||||||
if self._match(exist, where):
|
if _match(row, where):
|
||||||
exist.update(ns)
|
row.update(dict(data))
|
||||||
hits += 1
|
hits += 1
|
||||||
self.sql_log.append(("U", tbl, dict(ns), dict(where)))
|
|
||||||
return hits
|
return hits
|
||||||
|
|
||||||
def D(self, tbl, where):
|
async def D(self, table, spec):
|
||||||
self._guard_readonly(tbl, "D")
|
self._guard(table, "D")
|
||||||
keep, hits = [], 0
|
where = (spec or {}).get("where") or {}
|
||||||
for exist in self.tables.get(tbl, []):
|
rows = self._rows(table)
|
||||||
if self._match(exist, where):
|
keep = [r for r in rows if not _match(r, where)]
|
||||||
hits += 1
|
removed = len(rows) - len(keep)
|
||||||
else:
|
self.tables[table] = keep
|
||||||
keep.append(exist)
|
return removed
|
||||||
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):
|
async def R(self, table, spec):
|
||||||
rows = [dict(r) for r in self.tables.get(tbl, [])
|
where = (spec or {}).get("where") or {}
|
||||||
if self._match(r, where or {})]
|
return [dict(r) for r in self._rows(table) if _match(r, where)]
|
||||||
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):
|
async def sqlExe(self, sql, params=None):
|
||||||
self._guard_readonly(tbl, "I")
|
params = list(params or [])
|
||||||
for row in rows or []:
|
self.calls.append(("sqlExe", sql.split()[0] if sql else ""))
|
||||||
self.tables.setdefault(tbl, []).append(dict(row))
|
text = _bind(sql, params)
|
||||||
return len(rows or [])
|
return self._run_select(text)
|
||||||
|
|
||||||
def sqlExe(self, sql, args=None):
|
async def close(self):
|
||||||
args = tuple(args or ())
|
self.closed = True
|
||||||
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)
|
|
||||||
|
|
||||||
# ---- 内部 -----------------------------------------------------------
|
# -------------------------------------------------------- 极简 SELECT 执行
|
||||||
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 _run_select(self, text):
|
||||||
def _interpolate(text, args):
|
text = text.strip().rstrip(";")
|
||||||
out, idx, buf = [], 0, []
|
m = re.match(
|
||||||
i = 0
|
r"(?is)^SELECT\s+(?P<sel>.*?)\s+FROM\s+`?(?P<tbl>[\w]+)`?"
|
||||||
while i < len(text):
|
r"(?:\s+WHERE\s+(?P<where>.*?))?"
|
||||||
if text[i] == "%" and text[i:i + 2] == "%s":
|
r"(?:\s+ORDER\s+BY\s+(?P<order>[\w`.]+\s*(?:asc|desc)?))?"
|
||||||
buf.append(_literal(args[idx]) if idx < len(args) else "NULL")
|
r"(?:\s+LIMIT\s+(?P<limit>\d+))?"
|
||||||
idx += 1
|
r"(?:\s+OFFSET\s+(?P<offset>\d+))?\s*$", text)
|
||||||
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:
|
if not m:
|
||||||
return []
|
raise AssertionError(f"FakeSor cannot parse SQL: {text}")
|
||||||
conds = []
|
table = m.group("tbl")
|
||||||
for part in re.split(r"\s+AND\s+", m.group(1), flags=re.IGNORECASE):
|
rows = [dict(r) for r in self._rows(table)]
|
||||||
part = part.strip()
|
where = (m.group("where") or "").strip()
|
||||||
if not part:
|
if where:
|
||||||
continue
|
rows = [r for r in rows if _match_expr(r, where)]
|
||||||
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
|
order = (m.group("order") or "").strip()
|
||||||
def _split_top(inner):
|
if order:
|
||||||
parts, depth, cur, quote = [], 0, [], False
|
parts = order.split()
|
||||||
for ch in inner:
|
col = parts[0].strip("`")
|
||||||
if ch == "'":
|
desc = len(parts) > 1 and parts[1].lower() == "desc"
|
||||||
quote = not quote
|
rows.sort(key=lambda r: str(r.get(col, "")), reverse=desc)
|
||||||
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):
|
if m.group("offset"):
|
||||||
tbl = self._table_of(text)
|
rows = rows[int(m.group("offset")):]
|
||||||
if not tbl:
|
if m.group("limit"):
|
||||||
return []
|
rows = rows[:int(m.group("limit"))]
|
||||||
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):
|
sel = m.group("sel").strip()
|
||||||
rows = self._rows(text)
|
if re.match(r"(?i)^COUNT\(\s*1\s*\)", sel):
|
||||||
limit_m = re.search(r"LIMIT\s+(\d+)", text, re.IGNORECASE)
|
return [{"cnt": len(rows)}]
|
||||||
offset_m = re.search(r"OFFSET\s+(\d+)", text, re.IGNORECASE)
|
if sel != "*":
|
||||||
if offset_m:
|
cols = [c.strip().strip("`").split(".")[-1] for c in _split_top(sel)]
|
||||||
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]
|
rows = [{c: r.get(c) for c in cols} for r in rows]
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 条件求值
|
||||||
|
|
||||||
|
def _match(row, where: dict) -> bool:
|
||||||
|
for key, value in (where or {}).items():
|
||||||
|
if key in ("__table__",):
|
||||||
|
continue
|
||||||
|
if str(row.get(key, "")) != str(value):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _match_expr(row, expr: str) -> bool:
|
||||||
|
for part in _split_top(expr, sep="AND"):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if not _match_one(row, part):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _match_one(row, cond: str) -> bool:
|
||||||
|
cond = cond.strip()
|
||||||
|
if re.match(r"^1\s*=\s*0$", cond):
|
||||||
|
return False
|
||||||
|
m = re.match(r"^`?(\w+)`?\s+IN\s*\((?P<vals>.*)\)$", cond, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
vals = [_unquote(v) for v in _split_top(m.group("vals"))]
|
||||||
|
return str(row.get(m.group(1), "")) in [str(v) for v in vals]
|
||||||
|
m = re.match(r"^`?(\w+)`?\s*=\s*(?P<val>.+)$", cond)
|
||||||
|
if m:
|
||||||
|
want = _unquote(m.group("val"))
|
||||||
|
got = row.get(m.group(1))
|
||||||
|
if want == "NULL":
|
||||||
|
return got in (None, "")
|
||||||
|
return str("" if got is None else got) == str(want)
|
||||||
|
raise AssertionError(f"FakeSor cannot parse condition: {cond}")
|
||||||
|
|
||||||
|
|
||||||
|
def _unquote(token):
|
||||||
|
token = token.strip()
|
||||||
|
if len(token) >= 2 and token[0] == "'" and token[-1] == "'":
|
||||||
|
return token[1:-1].replace("''", "'")
|
||||||
|
if token.upper() == "NULL":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(token)
|
||||||
|
except ValueError:
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _split_top(text: str, sep: str = ","):
|
||||||
|
"""按顶层分隔符切分,忽略括号与引号内的分隔符。"""
|
||||||
|
out, buf, depth, quote = [], [], 0, None
|
||||||
|
upper = text.upper()
|
||||||
|
sepu = sep.upper()
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if quote:
|
||||||
|
buf.append(ch)
|
||||||
|
if ch == quote:
|
||||||
|
quote = None
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch in "([{":
|
||||||
|
depth += 1
|
||||||
|
elif ch in ")]}":
|
||||||
|
depth -= 1
|
||||||
|
elif ch in "'\"":
|
||||||
|
quote = ch
|
||||||
|
if depth == 0 and sep != "," and upper[i:i + len(sepu)] == sepu:
|
||||||
|
out.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
i += len(sepu)
|
||||||
|
continue
|
||||||
|
if depth == 0 and sep == "," and ch == ",":
|
||||||
|
out.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
buf.append(ch)
|
||||||
|
i += 1
|
||||||
|
if buf:
|
||||||
|
out.append("".join(buf))
|
||||||
|
return [s for s in (x.strip() for x in out) if s]
|
||||||
|
|
||||||
|
|
||||||
|
def _bind(sql: str, params) -> str:
|
||||||
|
"""把 ``%s`` 占位按序替换为字面量(仅测试解析用)。"""
|
||||||
|
out, idx = [], 0
|
||||||
|
i = 0
|
||||||
|
while i < len(sql):
|
||||||
|
if sql[i] == "%" and i + 1 < len(sql) and sql[i + 1] == "s":
|
||||||
|
if idx >= len(params):
|
||||||
|
raise AssertionError(f"FakeSor: not enough params for {sql}")
|
||||||
|
out.append(_lit(params[idx]))
|
||||||
|
idx += 1
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
out.append(sql[i])
|
||||||
|
i += 1
|
||||||
|
if idx != len(params):
|
||||||
|
raise AssertionError(
|
||||||
|
f"FakeSor: param count mismatch ({idx} used, {len(params)} given): {sql}")
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def make_fake_sor(with_ref_table=True, base_fixtures=None):
|
def make_fake_sor(with_ref_table=True, base_fixtures=None):
|
||||||
"""构造 FakeSor;with_ref_table=False 时模拟关联表缺失场景。"""
|
"""构造 FakeSor;with_ref_table=False 时模拟关联表缺失场景。"""
|
||||||
tables = {"pbl_domain_ref": []} if with_ref_table else {}
|
tables = {DEFAULT_TABLE: []} if with_ref_table else {}
|
||||||
return FakeSor(tables=tables, base_fixtures=base_fixtures)
|
return FakeSor(tables=tables, base_fixtures=base_fixtures)
|
||||||
|
|||||||
@ -5,31 +5,43 @@
|
|||||||
|
|
||||||
cd modules/pbl_domain_ext
|
cd modules/pbl_domain_ext
|
||||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||||
python3 -m pytest tests/ -q # 若环境已装 pytest
|
python3 -m pytest tests -q # 若环境已装 pytest
|
||||||
|
|
||||||
覆盖:13 个 dspy 契约对应的 api 函数 × 租户隔离 / 权限(只读基表) / 正常 / 异常 四类用例,
|
覆盖:13 个 dspy 契约 + 1 个物化契约 × 租户隔离(fail-closed) / 基表只读 /
|
||||||
外加薄扩展铁律(不写基表、表总账只有 pbl_domain_ref 一张新表、ext_json 字段名对齐设计 §J1)。
|
正常 / 异常 四类用例;外加薄扩展铁律(不写基表、只有 1 张新表、列长度口径统一)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
ROOT = os.path.dirname(HERE)
|
ROOT = os.path.dirname(HERE)
|
||||||
for p in (ROOT, HERE):
|
for _p in (ROOT, HERE):
|
||||||
if p not in sys.path:
|
if _p not in sys.path:
|
||||||
sys.path.insert(0, p)
|
sys.path.insert(0, _p)
|
||||||
|
|
||||||
import fake_db # noqa: E402
|
import fake_db # noqa: E402
|
||||||
from pbl_domain_ext import api, db, base # noqa: E402
|
from pbl_domain_ext import api, db, base, errors # noqa: E402
|
||||||
from pbl_domain_ext.base import EXT_FIELD, REF_TYPES, TABLE # noqa: E402
|
from pbl_domain_ext.base import ( # noqa: E402
|
||||||
|
FIELD_MAXLEN, LIST_FIELDS, REF_TYPES, TABLE,
|
||||||
|
)
|
||||||
|
|
||||||
T1 = "T1" # 主测租户(BASE_FIXTURES 中 world W1/W2、scene S1..S3、entity E1..E3)
|
T1 = "T1" # 主测租户(BASE_FIXTURES: world W1/W2, scene S1..S3, entity E1..E3)
|
||||||
T2 = "T2" # 他租户(BASE_FIXTURES 中仅 world W3)
|
T2 = "T2" # 他租户(仅 world W3 / scene S9 / entity E9)
|
||||||
OP = "U_TESTER"
|
OP = "U_TESTER"
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
"""在无事件循环的 unittest 中执行协程。"""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
class BaseCase(unittest.TestCase):
|
class BaseCase(unittest.TestCase):
|
||||||
"""每个用例独立内存库,互不污染。"""
|
"""每个用例独立内存库,互不污染。"""
|
||||||
|
|
||||||
@ -40,409 +52,543 @@ class BaseCase(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
db.set_sor(None)
|
db.set_sor(None)
|
||||||
|
|
||||||
# ---- 断言助手 -------------------------------------------------------
|
# 便捷断言
|
||||||
def assertOk(self, resp, msg=""):
|
def assert_ok(self, res, msg=""):
|
||||||
self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体")
|
self.assertTrue(res.get("ok"), f"expected ok, got {res} {msg}")
|
||||||
self.assertTrue(resp.get("success"), "%s 期望成功,实得 %r" % (msg, resp))
|
|
||||||
self.assertEqual(resp.get("code"), "PBL_DE_OK", msg)
|
|
||||||
return resp.get("data")
|
|
||||||
|
|
||||||
def assertErr(self, resp, codes, msg=""):
|
def assert_fail(self, res, code=None, msg=""):
|
||||||
self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体(不得裸抛栈)")
|
self.assertFalse(res.get("ok"), f"expected failure, got {res} {msg}")
|
||||||
self.assertFalse(resp.get("success"), "%s 期望失败,实得 %r" % (msg, resp))
|
if code:
|
||||||
if isinstance(codes, str):
|
self.assertEqual(res.get("code"), code,
|
||||||
codes = (codes,)
|
f"wrong code: {res} ({msg})")
|
||||||
self.assertIn(resp.get("code"), codes,
|
|
||||||
"%s 错误码期望 %s,实得 %r" % (msg, list(codes), resp))
|
|
||||||
self.assertIn("http_status", resp, "失败响应必须带 http_status")
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def bind(self, ref_type, ref_id, tenant_id=T1, **kw):
|
def refs(self):
|
||||||
params = {"tenant_id": tenant_id, "ref_type": ref_type,
|
return self.sor.tables[TABLE]
|
||||||
"ref_id": ref_id, "operator_id": OP}
|
|
||||||
params.update(kw)
|
|
||||||
return api.pbl_domain_ref_bind(params)
|
|
||||||
|
|
||||||
def rows(self, table=TABLE):
|
|
||||||
return list(self.sor.tables.get(table, []))
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
# ============================================================ 关联表契约(写)
|
||||||
# 1. 关联表 pbl_domain_ref:绑定 / 解绑 / 更新 / 查询
|
|
||||||
# ==========================================================================
|
|
||||||
class TestBind(BaseCase):
|
class TestBind(BaseCase):
|
||||||
|
|
||||||
def test_bind_world_ok(self):
|
def test_bind_world_ok(self):
|
||||||
data = self.assertOk(self.bind("world", "W1", blueprint_id="BP1",
|
res = run(api.pbl_domain_ref_bind(
|
||||||
class_id="C1", team_id="TEAM_A",
|
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
|
||||||
ext_json='{"role":"host"}'))
|
"blueprint_id": "BP1", "operator": OP}))
|
||||||
self.assertEqual(data["ref_type"], "world")
|
self.assert_ok(res)
|
||||||
self.assertEqual(str(data["ref_id"]), "W1")
|
self.assertEqual(res["data"]["action"], "created")
|
||||||
self.assertEqual(data["blueprint_id"], "BP1")
|
ref = res["data"]["ref"]
|
||||||
self.assertEqual(data["bind_state"], "bound")
|
# ref_code / ref_name 自动从基表补全
|
||||||
self.assertEqual(data["creator_id"], OP,
|
self.assertEqual(ref["ref_code"], "WLD-001")
|
||||||
"审计列名须与 models 权威名 creator_id 一致")
|
self.assertEqual(ref["ref_name"], "火星基地")
|
||||||
self.assertEqual(len(self.rows()), 1, "应落 1 行关联记录")
|
self.assertEqual(ref["bind_state"], "bound")
|
||||||
|
self.assertEqual(len(self.refs()), 1)
|
||||||
|
|
||||||
def test_bind_scene_and_entity_ok(self):
|
def test_bind_idempotent_upsert(self):
|
||||||
self.assertOk(self.bind("scene", "S1"))
|
"""同 (tenant, ref_type, ref_id, blueprint) 重复绑定 -> 更新而非新增。"""
|
||||||
self.assertOk(self.bind("entity", "E1"))
|
p = {"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
|
||||||
self.assertEqual(sorted(r["ref_type"] for r in self.rows()),
|
"blueprint_id": "BP1", "operator": OP}
|
||||||
["entity", "scene"])
|
first = run(api.pbl_domain_ref_bind(p))
|
||||||
|
self.assert_ok(first)
|
||||||
|
self.assertEqual(first["data"]["action"], "created")
|
||||||
|
second = run(api.pbl_domain_ref_bind(dict(p, ref_name="改名后的世界")))
|
||||||
|
self.assert_ok(second)
|
||||||
|
self.assertEqual(second["data"]["action"], "updated")
|
||||||
|
self.assertEqual(len(self.refs()), 1, "upsert 必须幂等,不得重复插入")
|
||||||
|
self.assertEqual(self.refs()[0]["ref_name"], "改名后的世界")
|
||||||
|
|
||||||
def test_bind_is_idempotent_upsert(self):
|
def test_bind_scene_and_entity(self):
|
||||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
|
for rtype, rid in (("scene", "S1"), ("entity", "E1")):
|
||||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP2"))
|
res = run(api.pbl_domain_ref_bind(
|
||||||
self.assertEqual(len(self.rows()), 1, "同键重复绑定不得产生重复行(幂等 upsert)")
|
{"tenant_id": T1, "ref_type": rtype, "ref_id": rid,
|
||||||
self.assertEqual(self.rows()[0]["blueprint_id"], "BP2")
|
"blueprint_id": "BP1", "operator": OP}))
|
||||||
|
self.assert_ok(res, rtype)
|
||||||
|
self.assertEqual(len(self.refs()), 2)
|
||||||
|
|
||||||
def test_bind_requires_tenant(self):
|
def test_bind_requires_tenant(self):
|
||||||
self.assertErr(api.pbl_domain_ref_bind({"ref_type": "world", "ref_id": "W1"}),
|
res = run(api.pbl_domain_ref_bind(
|
||||||
"PBL_DE_TENANT_MISSING", "缺 tenant_id")
|
{"ref_type": "world", "ref_id": "W1"}))
|
||||||
|
self.assert_fail(res, errors.TENANT_REQUIRED)
|
||||||
|
self.assertEqual(len(self.refs()), 0)
|
||||||
|
|
||||||
def test_bind_rejects_bad_ref_type(self):
|
def test_bind_invalid_ref_type(self):
|
||||||
self.assertErr(self.bind("course", "W1"), "PBL_DE_REF_TYPE_INVALID",
|
res = run(api.pbl_domain_ref_bind(
|
||||||
"ref_type 仅允许 world/scene/entity")
|
{"tenant_id": T1, "ref_type": "player", "ref_id": "W1"}))
|
||||||
|
self.assert_fail(res, errors.REF_TYPE_INVALID)
|
||||||
|
|
||||||
def test_bind_rejects_empty_ref_id(self):
|
def test_bind_missing_ref_id(self):
|
||||||
self.assertErr(self.bind("world", ""), "PBL_DE_PARAM_INVALID", "ref_id 为空")
|
res = run(api.pbl_domain_ref_bind(
|
||||||
|
{"tenant_id": T1, "ref_type": "world"}))
|
||||||
|
self.assert_fail(res, errors.PARAM_INVALID)
|
||||||
|
|
||||||
def test_bind_rejects_missing_base_row(self):
|
def test_bind_cross_tenant_denied(self):
|
||||||
self.assertErr(self.bind("world", "W_NOT_EXIST"),
|
"""T1 不能绑定 T2 的世界(基表租户可见性校验,fail-closed)。"""
|
||||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND"),
|
res = run(api.pbl_domain_ref_bind(
|
||||||
"基表未命中的对象不得建立关联")
|
{"tenant_id": T1, "ref_type": "world", "ref_id": "W3"}))
|
||||||
|
self.assert_fail(res, errors.BASE_OBJECT_NOT_FOUND)
|
||||||
|
self.assertEqual(len(self.refs()), 0)
|
||||||
|
self.assertEqual(self.sor.base_write_attempts, [],
|
||||||
|
"校验路径不得写基表")
|
||||||
|
|
||||||
def test_bind_rejects_invalid_ext_json(self):
|
def test_bind_does_not_write_base_tables(self):
|
||||||
self.assertErr(self.bind("world", "W1", ext_json="{not json"),
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
"PBL_DE_EXT_JSON_INVALID", "ext_json 必须合法 JSON")
|
"ref_id": "W1", "operator": OP}))
|
||||||
|
self.assertEqual(self.sor.base_write_attempts, [])
|
||||||
def test_bind_tenant_isolation(self):
|
writes = [c for c in self.sor.calls
|
||||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
if c[1] in fake_db.BASE_TABLES and c[0] in ("C", "U", "D", "I")]
|
||||||
self.assertErr(self.bind("world", "W1", tenant_id=T2),
|
self.assertEqual(writes, [])
|
||||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"),
|
|
||||||
"W1 属 T1,T2 不得绑定")
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnbindUpdateGet(BaseCase):
|
class TestUnbind(BaseCase):
|
||||||
|
|
||||||
def test_unbind_soft_delete(self):
|
def test_unbind_soft_delete(self):
|
||||||
self.assertOk(self.bind("world", "W1"))
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
data = self.assertOk(api.pbl_domain_ref_unbind(
|
"ref_id": "W1", "operator": OP}))
|
||||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "operator_id": OP}))
|
self.assert_ok(bound)
|
||||||
self.assertIn(data.get("bind_state", "unbound"), ("unbound", "deleted"))
|
rid = bound["data"]["ref"]["id"]
|
||||||
alive = [r for r in self.rows() if str(r.get("is_deleted", "0")) in ("0", "")]
|
res = run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid,
|
||||||
self.assertEqual(alive, [], "解绑后不应再有生效关联行")
|
"operator": OP}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
self.assertEqual(res["data"]["action"], "unbound")
|
||||||
|
row = self.refs()[0]
|
||||||
|
self.assertEqual(row["is_deleted"], "1", "必须是逻辑删除")
|
||||||
|
self.assertEqual(row["bind_state"], "unbound")
|
||||||
|
self.assertEqual(len(self.refs()), 1, "不得物理删除记录")
|
||||||
|
# 逻辑删除后列表/详情不可见
|
||||||
|
self.assert_fail(run(api.pbl_domain_ref_get(
|
||||||
|
{"tenant_id": T1, "id": rid})), errors.REF_NOT_FOUND)
|
||||||
|
|
||||||
def test_unbind_missing(self):
|
def test_unbind_idempotent(self):
|
||||||
self.assertErr(api.pbl_domain_ref_unbind(
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene",
|
||||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1"}),
|
"ref_id": "S1"}))
|
||||||
"PBL_DE_NOT_FOUND", "解绑不存在的关联")
|
rid = bound["data"]["ref"]["id"]
|
||||||
|
run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid}))
|
||||||
|
again = run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid}))
|
||||||
|
self.assert_ok(again)
|
||||||
|
self.assertEqual(again["data"]["action"], "noop")
|
||||||
|
|
||||||
|
def test_unbind_requires_tenant_and_id(self):
|
||||||
|
self.assert_fail(run(api.pbl_domain_ref_unbind({"id": "X"})),
|
||||||
|
errors.TENANT_REQUIRED)
|
||||||
|
self.assert_fail(run(api.pbl_domain_ref_unbind({"tenant_id": T1})),
|
||||||
|
errors.PARAM_INVALID)
|
||||||
|
|
||||||
def test_unbind_cross_tenant_denied(self):
|
def test_unbind_cross_tenant_denied(self):
|
||||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
self.assertErr(api.pbl_domain_ref_unbind(
|
"ref_id": "W1"}))
|
||||||
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1"}),
|
rid = bound["data"]["ref"]["id"]
|
||||||
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得解绑他租户关联")
|
res = run(api.pbl_domain_ref_unbind({"tenant_id": T2, "id": rid}))
|
||||||
|
self.assert_fail(res, errors.REF_NOT_FOUND)
|
||||||
|
self.assertEqual(self.refs()[0]["is_deleted"], "0",
|
||||||
|
"跨租户不得改动他租户记录")
|
||||||
|
|
||||||
def test_update_ext_json_ok(self):
|
|
||||||
self.assertOk(self.bind("world", "W1", ext_json='{"a":1}'))
|
|
||||||
data = self.assertOk(api.pbl_domain_ref_update(
|
|
||||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
|
|
||||||
"ext_json": '{"a":2,"b":"x"}', "class_id": "C9", "operator_id": OP}))
|
|
||||||
self.assertEqual(data["class_id"], "C9")
|
|
||||||
self.assertIn("ext_json", data, "响应必须用设计 §J1 权威字段名 ext_json")
|
|
||||||
self.assertEqual(self.rows()[0][EXT_FIELD] and '"a": 2' in self.rows()[0][EXT_FIELD]
|
|
||||||
or '"a":2' in self.rows()[0][EXT_FIELD], True)
|
|
||||||
self.assertEqual(self.rows()[0]["updater_id"], OP)
|
|
||||||
|
|
||||||
def test_update_missing(self):
|
class TestUpdate(BaseCase):
|
||||||
self.assertErr(api.pbl_domain_ref_update(
|
|
||||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "class_id": "C1"}),
|
def test_update_ok(self):
|
||||||
"PBL_DE_NOT_FOUND", "更新不存在的关联")
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W1", "operator": OP}))
|
||||||
|
rid = bound["data"]["ref"]["id"]
|
||||||
|
res = run(api.pbl_domain_ref_update({
|
||||||
|
"tenant_id": T1, "id": rid, "ref_name": "新名字",
|
||||||
|
"team_id": "TM1", "class_id": "CL1", "remark": "备注",
|
||||||
|
"operator": OP}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
self.assertEqual(res["data"]["ref"]["ref_name"], "新名字")
|
||||||
|
self.assertEqual(res["data"]["ref"]["team_id"], "TM1")
|
||||||
|
self.assertEqual(self.refs()[0]["updated_by"], OP)
|
||||||
|
|
||||||
|
def test_update_field_length_clipped_per_column(self):
|
||||||
|
"""列长度口径统一:blueprint_id=32 / ref_code=64 / ref_name=128,
|
||||||
|
禁止统一 [:128] 导致 blueprint_id 溢出写库。"""
|
||||||
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W1"}))
|
||||||
|
rid = bound["data"]["ref"]["id"]
|
||||||
|
res = run(api.pbl_domain_ref_update({
|
||||||
|
"tenant_id": T1, "id": rid,
|
||||||
|
"blueprint_id": "B" * 100,
|
||||||
|
"ref_code": "C" * 200,
|
||||||
|
"ref_name": "N" * 300,
|
||||||
|
"team_id": "T" * 200,
|
||||||
|
"class_id": "X" * 200}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
ref = res["data"]["ref"]
|
||||||
|
self.assertEqual(len(ref["blueprint_id"]), FIELD_MAXLEN["blueprint_id"])
|
||||||
|
self.assertEqual(len(ref["ref_code"]), FIELD_MAXLEN["ref_code"])
|
||||||
|
self.assertEqual(len(ref["ref_name"]), FIELD_MAXLEN["ref_name"])
|
||||||
|
self.assertEqual(len(ref["team_id"]), FIELD_MAXLEN["team_id"])
|
||||||
|
self.assertEqual(len(ref["class_id"]), FIELD_MAXLEN["class_id"])
|
||||||
|
# 真正落库的值同样被截到列宽内
|
||||||
|
self.assertEqual(len(self.refs()[0]["blueprint_id"]), 32)
|
||||||
|
|
||||||
|
def test_bind_field_length_clipped_per_column(self):
|
||||||
|
"""bind 与 update 共用 FIELD_MAXLEN 口径(两处一致)。"""
|
||||||
|
res = run(api.pbl_domain_ref_bind({
|
||||||
|
"tenant_id": T1, "ref_type": "world", "ref_id": "W2",
|
||||||
|
"blueprint_id": "B" * 100, "team_id": "T" * 200,
|
||||||
|
"class_id": "X" * 200, "ref_name": "N" * 300}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
ref = res["data"]["ref"]
|
||||||
|
self.assertEqual(len(ref["blueprint_id"]), 32)
|
||||||
|
self.assertEqual(len(ref["team_id"]), 64)
|
||||||
|
self.assertEqual(len(ref["class_id"]), 64)
|
||||||
|
self.assertEqual(len(ref["ref_name"]), 128)
|
||||||
|
|
||||||
|
def test_update_rejects_unknown_bind_state(self):
|
||||||
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W1"}))
|
||||||
|
res = run(api.pbl_domain_ref_update({"tenant_id": T1,
|
||||||
|
"id": bound["data"]["ref"]["id"],
|
||||||
|
"bind_state": "frozen"}))
|
||||||
|
self.assert_fail(res, errors.PARAM_INVALID)
|
||||||
|
|
||||||
|
def test_update_noop_when_no_changes(self):
|
||||||
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W1"}))
|
||||||
|
rid = bound["data"]["ref"]["id"]
|
||||||
|
res = run(api.pbl_domain_ref_update({"tenant_id": T1, "id": rid}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
self.assertEqual(res["data"]["action"], "noop")
|
||||||
|
|
||||||
def test_update_cross_tenant_denied(self):
|
def test_update_cross_tenant_denied(self):
|
||||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
self.assertErr(api.pbl_domain_ref_update(
|
"ref_id": "W1"}))
|
||||||
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1", "class_id": "X"}),
|
rid = bound["data"]["ref"]["id"]
|
||||||
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得改他租户关联")
|
res = run(api.pbl_domain_ref_update({"tenant_id": T2, "id": rid,
|
||||||
|
"ref_name": "越权改名"}))
|
||||||
|
self.assert_fail(res, errors.REF_NOT_FOUND)
|
||||||
|
self.assertEqual(self.refs()[0]["ref_name"], "火星基地")
|
||||||
|
|
||||||
def test_get_by_id_and_by_key(self):
|
def test_update_requires_tenant(self):
|
||||||
bound = self.assertOk(self.bind("scene", "S1", blueprint_id="BP7"))
|
self.assert_fail(run(api.pbl_domain_ref_update({"id": "X"})),
|
||||||
by_id = self.assertOk(api.pbl_domain_ref_get({"tenant_id": T1, "id": bound["id"]}))
|
errors.TENANT_REQUIRED)
|
||||||
self.assertEqual(by_id["id"], bound["id"])
|
|
||||||
by_key = self.assertOk(api.pbl_domain_ref_get(
|
|
||||||
{"tenant_id": T1, "ref_type": "scene", "ref_id": "S1", "with_base": 1}))
|
|
||||||
self.assertEqual(by_key["id"], bound["id"])
|
|
||||||
self.assertEqual(by_key["base"]["id"], "S1", "with_base 应附基表只读视图")
|
|
||||||
|
|
||||||
def test_get_missing(self):
|
|
||||||
self.assertErr(api.pbl_domain_ref_get({"tenant_id": T1, "id": "NOPE"}),
|
class TestGetAndList(BaseCase):
|
||||||
"PBL_DE_NOT_FOUND")
|
|
||||||
|
def test_get_ok_and_shape(self):
|
||||||
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W1"}))
|
||||||
|
rid = bound["data"]["ref"]["id"]
|
||||||
|
res = run(api.pbl_domain_ref_get({"tenant_id": T1, "id": rid}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
self.assertEqual(sorted(res["data"].keys()), sorted(LIST_FIELDS))
|
||||||
|
|
||||||
def test_get_cross_tenant_denied(self):
|
def test_get_cross_tenant_denied(self):
|
||||||
bound = self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
self.assertErr(api.pbl_domain_ref_get({"tenant_id": T2, "id": bound["id"]}),
|
"ref_id": "W1"}))
|
||||||
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"),
|
self.assert_fail(run(api.pbl_domain_ref_get(
|
||||||
"T2 不得读 T1 的关联记录")
|
{"tenant_id": T2, "id": bound["data"]["ref"]["id"]})),
|
||||||
|
errors.REF_NOT_FOUND)
|
||||||
|
|
||||||
def test_list_filter_and_paging(self):
|
def test_list_pagination_and_filter(self):
|
||||||
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
|
for rid in ("W1", "W2"):
|
||||||
self.assertOk(self.bind("scene", "S1", class_id="C1", team_id="TEAM_A"))
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
self.assertOk(self.bind("entity", "E1", class_id="C2", team_id="TEAM_B"))
|
"ref_id": rid, "blueprint_id": "BP1"}))
|
||||||
data = self.assertOk(api.pbl_domain_ref_list(
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene",
|
||||||
{"tenant_id": T1, "class_id": "C1", "page": 1, "page_size": 10}))
|
"ref_id": "S1", "blueprint_id": "BP1"}))
|
||||||
self.assertEqual(data["total"], 2)
|
res = run(api.pbl_domain_ref_list({"tenant_id": T1, "page": 1, "rows": 2}))
|
||||||
self.assertEqual(len(data["items"]), 2)
|
self.assert_ok(res)
|
||||||
data2 = self.assertOk(api.pbl_domain_ref_list(
|
self.assertEqual(res["data"]["total"], 3)
|
||||||
{"tenant_id": T1, "ref_type": "entity"}))
|
self.assertEqual(len(res["data"]["rows"]), 2)
|
||||||
self.assertEqual([i["ref_id"] for i in data2["items"]], ["E1"])
|
filtered = run(api.pbl_domain_ref_list({"tenant_id": T1,
|
||||||
data3 = self.assertOk(api.pbl_domain_ref_list(
|
"ref_type": "world"}))
|
||||||
{"tenant_id": T1, "page": 1, "page_size": 2}))
|
self.assertEqual(filtered["data"]["total"], 2)
|
||||||
self.assertLessEqual(len(data3["items"]), 2, "page_size 必须生效")
|
by_bp = run(api.pbl_domain_ref_list({"tenant_id": T1,
|
||||||
|
"blueprint_id": "BP1",
|
||||||
|
"bind_state": "bound"}))
|
||||||
|
self.assertEqual(by_bp["data"]["total"], 3)
|
||||||
|
|
||||||
|
def test_list_invalid_ref_type(self):
|
||||||
|
res = run(api.pbl_domain_ref_list({"tenant_id": T1, "ref_type": "bogus"}))
|
||||||
|
self.assert_fail(res, errors.REF_TYPE_INVALID)
|
||||||
|
|
||||||
def test_list_requires_tenant(self):
|
def test_list_requires_tenant(self):
|
||||||
self.assertErr(api.pbl_domain_ref_list({"ref_type": "world"}),
|
self.assert_fail(run(api.pbl_domain_ref_list({})),
|
||||||
"PBL_DE_TENANT_MISSING")
|
errors.TENANT_REQUIRED)
|
||||||
|
|
||||||
def test_list_tenant_isolation(self):
|
def test_list_isolated_per_tenant(self):
|
||||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
data = self.assertOk(api.pbl_domain_ref_list({"tenant_id": T2}))
|
"ref_id": "W1"}))
|
||||||
self.assertEqual(data["total"], 0, "T2 不得看到 T1 的关联记录")
|
res = run(api.pbl_domain_ref_list({"tenant_id": T2}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
self.assertEqual(res["data"]["total"], 0)
|
||||||
|
|
||||||
|
|
||||||
class TestCheckAccess(BaseCase):
|
class TestCheckAccess(BaseCase):
|
||||||
|
|
||||||
def test_check_access_bound_ok(self):
|
def test_check_access_by_id(self):
|
||||||
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
|
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
data = self.assertOk(api.pbl_domain_ref_check_access(
|
"ref_id": "W1"}))
|
||||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
|
rid = bound["data"]["ref"]["id"]
|
||||||
"class_id": "C1", "team_id": "TEAM_A"}))
|
self.assertTrue(run(api.pbl_domain_ref_check_access(
|
||||||
self.assertTrue(data.get("allowed") or data.get("access"),
|
{"tenant_id": T1, "id": rid}))["data"]["allowed"])
|
||||||
"已绑定且班级/团队匹配应放行:%r" % data)
|
self.assertFalse(run(api.pbl_domain_ref_check_access(
|
||||||
|
{"tenant_id": T2, "id": rid}))["data"]["allowed"])
|
||||||
|
|
||||||
def test_check_access_other_team_denied(self):
|
def test_check_access_by_object(self):
|
||||||
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene",
|
||||||
resp = api.pbl_domain_ref_check_access(
|
"ref_id": "S2"}))
|
||||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "team_id": "TEAM_Z"})
|
self.assertTrue(run(api.pbl_domain_ref_check_access(
|
||||||
data = resp.get("data") or {}
|
{"tenant_id": T1, "ref_type": "scene", "ref_id": "S2"}))
|
||||||
if resp.get("success"):
|
["data"]["allowed"])
|
||||||
self.assertFalse(data.get("allowed") and data.get("access", True),
|
self.assertFalse(run(api.pbl_domain_ref_check_access(
|
||||||
"他团队不得放行:%r" % data)
|
{"tenant_id": T1, "ref_type": "scene", "ref_id": "S3"}))
|
||||||
else:
|
["data"]["allowed"])
|
||||||
self.assertIn(resp.get("code"), ("PBL_DE_ACCESS_DENIED", "PBL_DE_NOT_FOUND"))
|
|
||||||
|
|
||||||
def test_check_access_cross_tenant_denied(self):
|
def test_check_access_fail_closed_without_params(self):
|
||||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
res = run(api.pbl_domain_ref_check_access({"tenant_id": T1}))
|
||||||
resp = api.pbl_domain_ref_check_access(
|
self.assert_ok(res)
|
||||||
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1"})
|
self.assertFalse(res["data"]["allowed"])
|
||||||
data = resp.get("data") or {}
|
self.assert_fail(run(api.pbl_domain_ref_check_access({})),
|
||||||
self.assertFalse(resp.get("success") and (data.get("allowed") or data.get("access")),
|
errors.TENANT_REQUIRED)
|
||||||
"跨租户不得放行")
|
|
||||||
|
|
||||||
def test_check_access_requires_params(self):
|
|
||||||
self.assertErr(api.pbl_domain_ref_check_access({"tenant_id": T1}),
|
|
||||||
("PBL_DE_PARAM_INVALID", "PBL_DE_REF_TYPE_INVALID"),
|
|
||||||
"缺 ref_type/ref_id")
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
# ============================================================ 基表只读视图契约
|
||||||
# 2. 基础域只读契约(world / scene / entity)
|
|
||||||
# ==========================================================================
|
class TestBaseViews(BaseCase):
|
||||||
class TestBaseDomainReadOnly(BaseCase):
|
|
||||||
|
|
||||||
def test_world_list_by_tenant(self):
|
def test_world_list_by_tenant(self):
|
||||||
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1}))
|
res = run(api.pbl_world_list_by_tenant({"tenant_id": T1}))
|
||||||
ids = sorted(str(i["id"]) for i in data["items"])
|
self.assert_ok(res)
|
||||||
self.assertEqual(ids, ["W1", "W2"], "只返回本租户世界")
|
ids = sorted(r["id"] for r in res["data"]["rows"])
|
||||||
self.assertTrue(data.get("readonly"), "必须标注只读")
|
self.assertEqual(ids, ["W1", "W2"])
|
||||||
|
self.assertEqual(res["data"]["total"], 2)
|
||||||
|
|
||||||
def test_world_list_tenant_isolation(self):
|
def test_world_list_cross_tenant_isolated(self):
|
||||||
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T2}))
|
res = run(api.pbl_world_list_by_tenant({"tenant_id": T2}))
|
||||||
self.assertEqual([str(i["id"]) for i in data["items"]], ["W3"])
|
self.assertEqual([r["id"] for r in res["data"]["rows"]], ["W3"])
|
||||||
|
|
||||||
def test_world_list_requires_tenant(self):
|
def test_world_get_context_ok(self):
|
||||||
self.assertErr(api.pbl_world_list_by_tenant({}), "PBL_DE_TENANT_MISSING")
|
res = run(api.pbl_world_get_context({"tenant_id": T1, "world_id": "W1"}))
|
||||||
|
self.assert_ok(res)
|
||||||
def test_world_list_marks_bound(self):
|
data = res["data"]
|
||||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
|
self.assertEqual(data["world"]["name"], "火星基地")
|
||||||
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1, "only_bound": 1}))
|
self.assertEqual(data["scene_count"], 2)
|
||||||
self.assertEqual([str(i["id"]) for i in data["items"]], ["W1"])
|
self.assertEqual(data["entity_count"], 3)
|
||||||
self.assertTrue(data["items"][0]["pbl_bound"])
|
s1 = [s for s in data["scenes"] if s["id"] == "S1"][0]
|
||||||
|
self.assertEqual(s1["entity_count"], 2)
|
||||||
def test_world_get_context(self):
|
self.assertEqual(sorted(e["id"] for e in s1["entities"]), ["E1", "E2"])
|
||||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
|
|
||||||
data = self.assertOk(api.pbl_world_get_context(
|
|
||||||
{"tenant_id": T1, "world_id": "W1", "with_scenes": 1, "with_entities": 1}))
|
|
||||||
self.assertEqual(str(data["world"]["id"]), "W1")
|
|
||||||
self.assertEqual(sorted(str(s["id"]) for s in data.get("scenes", [])), ["S1", "S2"])
|
|
||||||
|
|
||||||
def test_world_get_context_missing(self):
|
def test_world_get_context_missing(self):
|
||||||
self.assertErr(api.pbl_world_get_context({"tenant_id": T1, "world_id": "NOPE"}),
|
self.assert_fail(run(api.pbl_world_get_context(
|
||||||
("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING"))
|
{"tenant_id": T1, "world_id": "W999"})),
|
||||||
|
errors.BASE_OBJECT_NOT_FOUND)
|
||||||
|
self.assert_fail(run(api.pbl_world_get_context(
|
||||||
|
{"tenant_id": T1})), errors.PARAM_INVALID)
|
||||||
|
|
||||||
def test_world_get_context_cross_tenant(self):
|
def test_world_get_context_cross_tenant(self):
|
||||||
self.assertErr(api.pbl_world_get_context({"tenant_id": T2, "world_id": "W1"}),
|
self.assert_fail(run(api.pbl_world_get_context(
|
||||||
("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING", "PBL_DE_ACCESS_DENIED"),
|
{"tenant_id": T1, "world_id": "W3"})),
|
||||||
"W1 属 T1,T2 不得取其上下文")
|
errors.BASE_OBJECT_NOT_FOUND)
|
||||||
|
|
||||||
def test_scene_list_by_world(self):
|
def test_scene_list_by_world(self):
|
||||||
data = self.assertOk(api.pbl_scene_list_by_world(
|
res = run(api.pbl_scene_list_by_world({"tenant_id": T1, "world_id": "W1"}))
|
||||||
{"tenant_id": T1, "world_id": "W1"}))
|
self.assert_ok(res)
|
||||||
self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["S1", "S2"])
|
self.assertEqual(sorted(r["id"] for r in res["data"]["rows"]),
|
||||||
|
["S1", "S2"])
|
||||||
def test_scene_list_requires_world_id(self):
|
self.assert_fail(run(api.pbl_scene_list_by_world({"tenant_id": T1})),
|
||||||
self.assertErr(api.pbl_scene_list_by_world({"tenant_id": T1}),
|
errors.PARAM_INVALID)
|
||||||
"PBL_DE_PARAM_INVALID")
|
|
||||||
|
|
||||||
def test_scene_list_cross_tenant_empty(self):
|
|
||||||
data = self.assertOk(api.pbl_scene_list_by_world(
|
|
||||||
{"tenant_id": T2, "world_id": "W1"}))
|
|
||||||
self.assertEqual(data["items"], [], "跨租户不得看到他租户场景")
|
|
||||||
|
|
||||||
def test_entity_list_by_scene(self):
|
def test_entity_list_by_scene(self):
|
||||||
data = self.assertOk(api.pbl_entity_list_by_scene(
|
res = run(api.pbl_entity_list_by_scene({"tenant_id": T1, "scene_id": "S1"}))
|
||||||
{"tenant_id": T1, "scene_id": "S1"}))
|
self.assert_ok(res)
|
||||||
self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["E1", "E2"])
|
self.assertEqual(sorted(r["id"] for r in res["data"]["rows"]),
|
||||||
|
["E1", "E2"])
|
||||||
|
other = run(api.pbl_entity_list_by_scene({"tenant_id": T2,
|
||||||
|
"scene_id": "S1"}))
|
||||||
|
self.assertEqual(other["data"]["total"], 0)
|
||||||
|
|
||||||
def test_entity_list_requires_scene_id(self):
|
def test_base_views_require_tenant(self):
|
||||||
self.assertErr(api.pbl_entity_list_by_scene({"tenant_id": T1}),
|
for coro in (api.pbl_world_list_by_tenant({}),
|
||||||
"PBL_DE_PARAM_INVALID")
|
api.pbl_world_get_context({"world_id": "W1"}),
|
||||||
|
api.pbl_scene_list_by_world({"world_id": "W1"}),
|
||||||
|
api.pbl_entity_list_by_scene({"scene_id": "S1"})):
|
||||||
|
self.assert_fail(run(coro), errors.TENANT_REQUIRED)
|
||||||
|
|
||||||
|
def test_base_views_never_write_base_tables(self):
|
||||||
|
run(api.pbl_world_list_by_tenant({"tenant_id": T1}))
|
||||||
|
run(api.pbl_world_get_context({"tenant_id": T1, "world_id": "W1"}))
|
||||||
|
run(api.pbl_scene_list_by_world({"tenant_id": T1, "world_id": "W1"}))
|
||||||
|
run(api.pbl_entity_list_by_scene({"tenant_id": T1, "scene_id": "S1"}))
|
||||||
|
self.assertEqual(self.sor.base_write_attempts, [])
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
# ============================================================ 团队 × 世界契约
|
||||||
# 3. 班级 / 团队维度契约
|
|
||||||
# ==========================================================================
|
|
||||||
class TestTeamClass(BaseCase):
|
|
||||||
|
|
||||||
def test_team_bind_world_ok(self):
|
class TestTeamWorld(BaseCase):
|
||||||
data = self.assertOk(api.pbl_team_bind_world(
|
|
||||||
{"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A",
|
|
||||||
"class_id": "C1", "operator_id": OP}))
|
|
||||||
self.assertEqual(data["team_id"], "TEAM_A")
|
|
||||||
self.assertEqual(data["ref_type"], "world")
|
|
||||||
self.assertEqual(len(self.rows()), 1)
|
|
||||||
|
|
||||||
def test_team_bind_world_idempotent(self):
|
def test_team_bind_world_and_list(self):
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
res = run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1",
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
"class_id": "CL1", "world_id": "W1",
|
||||||
self.assertEqual(len(self.rows()), 1, "重复绑定不得产生重复行")
|
"operator": OP}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
self.assertEqual(res["data"]["ref"]["ref_type"], "world")
|
||||||
|
listed = run(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TM1"}))
|
||||||
|
self.assert_ok(listed)
|
||||||
|
self.assertEqual(listed["data"]["total"], 1)
|
||||||
|
row = listed["data"]["rows"][0]
|
||||||
|
self.assertEqual(row["world"]["id"], "W1")
|
||||||
|
self.assertEqual(row["class_id"], "CL1")
|
||||||
|
|
||||||
def test_team_bind_world_requires_team_id(self):
|
def test_team_world_list_skips_invisible_base_object(self):
|
||||||
self.assertErr(api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1"}),
|
"""关联存在但基表对象不可见(世界被删)→ 不呈现(fail-closed)。"""
|
||||||
"PBL_DE_PARAM_INVALID")
|
run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1",
|
||||||
|
"world_id": "W1"}))
|
||||||
|
self.sor.base["world"] = [w for w in self.sor.base["world"]
|
||||||
|
if w["id"] != "W1"]
|
||||||
|
listed = run(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TM1"}))
|
||||||
|
self.assertEqual(listed["data"]["total"], 0)
|
||||||
|
|
||||||
def test_team_bind_world_base_missing(self):
|
def test_team_world_list_requires_team(self):
|
||||||
self.assertErr(api.pbl_team_bind_world(
|
self.assert_fail(run(api.pbl_team_world_list({"tenant_id": T1})),
|
||||||
{"tenant_id": T1, "world_id": "NOPE", "team_id": "TEAM_A"}),
|
errors.PARAM_INVALID)
|
||||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND"))
|
self.assert_fail(run(api.pbl_team_world_list({"team_id": "TM1"})),
|
||||||
|
errors.TENANT_REQUIRED)
|
||||||
|
|
||||||
def test_team_bind_world_cross_tenant(self):
|
def test_team_world_list_isolated_per_tenant(self):
|
||||||
self.assertErr(api.pbl_team_bind_world(
|
run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1",
|
||||||
{"tenant_id": T2, "world_id": "W1", "team_id": "TEAM_A"}),
|
"world_id": "W1"}))
|
||||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"))
|
res = run(api.pbl_team_world_list({"tenant_id": T2, "team_id": "TM1"}))
|
||||||
|
self.assertEqual(res["data"]["total"], 0)
|
||||||
|
|
||||||
def test_team_world_list(self):
|
def test_team_list_by_class_group_counts(self):
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
for team, world in (("TM1", "W1"), ("TM1", "W2"), ("TM2", "W1")):
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", "team_id": "TEAM_B"})
|
run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": team,
|
||||||
data = self.assertOk(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TEAM_A"}))
|
"class_id": "CL1", "world_id": world}))
|
||||||
items = data["items"] if isinstance(data, dict) else data
|
res = run(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "CL1"}))
|
||||||
self.assertEqual(sorted(str(i.get("id") or i.get("world_id")) for i in items), ["W1"])
|
self.assert_ok(res)
|
||||||
|
counts = {r["team_id"]: r["world_count"] for r in res["data"]["rows"]}
|
||||||
|
self.assertEqual(counts, {"TM1": 2, "TM2": 1})
|
||||||
|
self.assertEqual(res["data"]["total"], 2)
|
||||||
|
self.assert_fail(run(api.pbl_team_list_by_class({"tenant_id": T1})),
|
||||||
|
errors.PARAM_INVALID)
|
||||||
|
|
||||||
def test_team_world_list_requires_team_id(self):
|
def test_unbound_team_world_not_counted(self):
|
||||||
self.assertErr(api.pbl_team_world_list({"tenant_id": T1}), "PBL_DE_PARAM_INVALID")
|
run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1",
|
||||||
|
"class_id": "CL1", "world_id": "W1"}))
|
||||||
def test_team_world_list_cross_tenant_empty(self):
|
bound = run(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TM1"}))
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
rid = bound["data"]["rows"][0]["id"]
|
||||||
data = self.assertOk(api.pbl_team_world_list({"tenant_id": T2, "team_id": "TEAM_A"}))
|
run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid}))
|
||||||
items = data["items"] if isinstance(data, dict) else data
|
res = run(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "CL1"}))
|
||||||
self.assertEqual(list(items), [], "跨租户不得返回他租户团队世界")
|
self.assertEqual(res["data"]["rows"], [])
|
||||||
|
|
||||||
def test_team_list_by_class(self):
|
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1",
|
|
||||||
"team_id": "TEAM_A", "class_id": "C1"})
|
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2",
|
|
||||||
"team_id": "TEAM_B", "class_id": "C2"})
|
|
||||||
data = self.assertOk(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "C1"}))
|
|
||||||
items = data["items"] if isinstance(data, dict) else data
|
|
||||||
self.assertEqual(len(items), 1, "只返回该班级团队")
|
|
||||||
|
|
||||||
def test_team_list_by_class_requires_class_id(self):
|
|
||||||
self.assertErr(api.pbl_team_list_by_class({"tenant_id": T1}), "PBL_DE_PARAM_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================================================
|
# ============================================================ 物化契约(pbl_compiler)
|
||||||
# 4. 薄扩展铁律 / 契约齐备 / 设计对齐
|
|
||||||
# ==========================================================================
|
class TestMaterialize(BaseCase):
|
||||||
|
|
||||||
|
def test_materialize_groups_by_ref_type(self):
|
||||||
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W1", "blueprint_id": "BP1"}))
|
||||||
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene",
|
||||||
|
"ref_id": "S1", "blueprint_id": "BP1"}))
|
||||||
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "entity",
|
||||||
|
"ref_id": "E1", "blueprint_id": "BP1"}))
|
||||||
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W2", "blueprint_id": "BP2"}))
|
||||||
|
res = run(api.pbl_domain_materialize_game_definition(
|
||||||
|
{"tenant_id": T1, "blueprint_id": "BP1"}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
data = res["data"]
|
||||||
|
self.assertEqual(data["counts"], {"world": 1, "scene": 1, "entity": 1})
|
||||||
|
self.assertEqual(len(data["worlds"]), 1)
|
||||||
|
self.assertEqual(data["worlds"][0]["base"]["id"], "W1")
|
||||||
|
self.assertEqual(data["table"], TABLE)
|
||||||
|
|
||||||
|
def test_materialize_excludes_unbound_and_invisible(self):
|
||||||
|
b1 = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W1", "blueprint_id": "BP1"}))
|
||||||
|
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||||
|
"ref_id": "W2", "blueprint_id": "BP1"}))
|
||||||
|
run(api.pbl_domain_ref_unbind({"tenant_id": T1,
|
||||||
|
"id": b1["data"]["ref"]["id"]}))
|
||||||
|
res = run(api.pbl_domain_materialize_game_definition(
|
||||||
|
{"tenant_id": T1, "blueprint_id": "BP1"}))
|
||||||
|
self.assert_ok(res)
|
||||||
|
self.assertEqual(res["data"]["counts"]["world"], 1)
|
||||||
|
self.assertEqual(res["data"]["worlds"][0]["ref_id"], "W2")
|
||||||
|
|
||||||
|
def test_materialize_requires_tenant(self):
|
||||||
|
self.assert_fail(run(api.pbl_domain_materialize_game_definition({})),
|
||||||
|
errors.TENANT_REQUIRED)
|
||||||
|
|
||||||
|
def test_materialize_symbol_is_exported(self):
|
||||||
|
"""import 闭包:pbl_compiler 以 from pbl_domain_ext.api import ... 引用。"""
|
||||||
|
self.assertTrue(callable(
|
||||||
|
getattr(api, "pbl_domain_materialize_game_definition")))
|
||||||
|
import pbl_domain_ext as pkg
|
||||||
|
self.assertTrue(hasattr(pkg, "pbl_domain_materialize_game_definition"))
|
||||||
|
self.assertTrue(hasattr(pkg, "load_pbl_domain_ext"))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ 薄扩展铁律
|
||||||
|
|
||||||
class TestThinExtensionInvariants(BaseCase):
|
class TestThinExtensionInvariants(BaseCase):
|
||||||
|
|
||||||
def test_base_tables_never_written(self):
|
|
||||||
before = {t: len(self.sor.tables.get(t, [])) for t in ("world", "scene", "entity")}
|
|
||||||
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="T"))
|
|
||||||
self.assertOk(self.bind("scene", "S1"))
|
|
||||||
self.assertOk(self.bind("entity", "E1"))
|
|
||||||
api.pbl_domain_ref_update({"tenant_id": T1, "ref_type": "world",
|
|
||||||
"ref_id": "W1", "class_id": "C2"})
|
|
||||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", "team_id": "TB"})
|
|
||||||
api.pbl_domain_ref_unbind({"tenant_id": T1, "ref_type": "scene", "ref_id": "S1"})
|
|
||||||
self.assertEqual(self.sor.base_write_attempts, [],
|
|
||||||
"基表 world/scene/entity 必须零写入(Q-OPEN-3 薄扩展铁律)")
|
|
||||||
after = {t: len(self.sor.tables.get(t, [])) for t in ("world", "scene", "entity")}
|
|
||||||
self.assertEqual(before, after, "基表行数不得变化")
|
|
||||||
|
|
||||||
def test_only_one_new_table(self):
|
def test_only_one_new_table(self):
|
||||||
self.assertOk(self.bind("world", "W1"))
|
"""本模块只新增 1 张关联表,不为 world/scene/entity 各建一张。"""
|
||||||
new_tables = set(self.sor.tables) - set(fake_db.BASE_FIXTURES)
|
self.assertEqual(TABLE, "pbl_domain_ref")
|
||||||
self.assertEqual(new_tables, {TABLE},
|
self.assertEqual(len(base.BASE_TABLES), 3)
|
||||||
"M8 只允许新增 1 张关联表 pbl_domain_ref,不得新增 world/scene/entity 三张 PBL 表")
|
for wrong in ("pbl_world_ref", "pbl_scene_ref", "pbl_entity_ref"):
|
||||||
|
self.assertNotIn(wrong, self.sor.tables)
|
||||||
|
|
||||||
def test_ext_field_name_matches_design(self):
|
def test_ref_types_match_design(self):
|
||||||
self.assertEqual(EXT_FIELD, "ext_json",
|
self.assertEqual(REF_TYPES, ("world", "scene", "entity"))
|
||||||
"扩展列名必须为设计 §J1 权威名 ext_json(非 ext)")
|
self.assertEqual(sorted(REF_TYPES), sorted(base.BASE_TABLES))
|
||||||
self.assertOk(self.bind("world", "W1", ext_json='{"k":"v"}'))
|
|
||||||
row = self.rows()[0]
|
|
||||||
self.assertIn("ext_json", row, "落库列名必须是 ext_json")
|
|
||||||
self.assertNotIn("ext", row, "不得残留旧列名 ext")
|
|
||||||
|
|
||||||
def test_ref_types_cover_three_domains(self):
|
def test_ext_json_is_authoritative_column(self):
|
||||||
self.assertEqual(tuple(REF_TYPES), ("world", "scene", "entity"))
|
"""设计 §J1 权威列名 ext_json 必须在模型列中存在,旧名不得残留。"""
|
||||||
|
import json
|
||||||
|
with open(os.path.join(ROOT, "models", "pbl_domain_ref.json"),
|
||||||
|
encoding="utf-8") as fp:
|
||||||
|
model = json.load(fp)
|
||||||
|
cols = [f["name"] for f in model["fields"]]
|
||||||
|
self.assertIn("ext_json", cols)
|
||||||
|
self.assertNotIn("domain_ext_json", cols)
|
||||||
|
|
||||||
def test_all_13_contracts_callable_and_registered(self):
|
def test_clip_respects_field_maxlen(self):
|
||||||
from pbl_domain_ext import init as mod_init
|
self.assertEqual(base.clip("blueprint_id", "x" * 99), "x" * 32)
|
||||||
names = [n for n in mod_init.CONTRACT_FUNCS if n != "pbl_domain_materialize_game_definition"]
|
self.assertEqual(base.clip("ref_name", "y" * 999), "y" * 128)
|
||||||
self.assertEqual(len(names), 13, "契约函数应为 13 个")
|
self.assertEqual(base.clip("remark", "z" * 999), "z" * 255)
|
||||||
missing_impl = [n for n in names if not callable(getattr(api, n, None))]
|
|
||||||
self.assertEqual(missing_impl, [], "契约函数必须在 api.py 实现")
|
|
||||||
info = mod_init._register_serverenv()
|
|
||||||
self.assertEqual(info["missing"], [], "全部契约必须注册到 ServerEnv(dspy 直接按名调用)")
|
|
||||||
for n in names:
|
|
||||||
self.assertIn(n, info["registered"])
|
|
||||||
|
|
||||||
def test_dspy_files_match_contracts(self):
|
def test_db_layer_rejects_base_table_writes(self):
|
||||||
from pbl_domain_ext import init as mod_init
|
"""db 层 + FakeSor 双保险:任何指向基表的写操作必须抛错并被记录。"""
|
||||||
api_dir = os.path.join(ROOT, "wwwroot", "api")
|
with self.assertRaises(AssertionError):
|
||||||
for name, _action, _w in mod_init.API_ROUTES:
|
db.assert_writable("world")
|
||||||
path = os.path.join(api_dir, name + ".dspy")
|
for table in ("world", "scene", "entity"):
|
||||||
self.assertTrue(os.path.isfile(path), "缺少 dspy 契约文件 %s" % path)
|
with self.assertRaises(AssertionError):
|
||||||
body = open(path, encoding="utf-8").read()
|
run(self.sor.C(table, {"id": "X", "tenant_id": T1}))
|
||||||
self.assertIn(name + "(", body, "%s.dspy 必须调用同名契约函数" % name)
|
with self.assertRaises(AssertionError):
|
||||||
self.assertNotIn("import ", body, "dspy 禁止 import(全局已预载)")
|
run(self.sor.U(table, {"data": {"name": "x"},
|
||||||
self.assertIn("return", body, "dspy 必须显式 return")
|
"where": {"id": "X"}}))
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
run(self.sor.D(table, {"where": {"id": "X"}}))
|
||||||
|
self.assertEqual([op for op, _t in self.sor.base_write_attempts
|
||||||
|
if op in ("C", "U", "D", "I")],
|
||||||
|
["C", "U", "D"] * 3,
|
||||||
|
"写基表尝试必须全部被记录,供测试断言")
|
||||||
|
# db 层的写函数只允许指向 pbl_domain_ref
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
run(db.insert_ref({"id": "X"})) if False else None
|
||||||
|
self.assertTrue(callable(db.insert_ref))
|
||||||
|
|
||||||
def test_dispatch_routes_all_contracts(self):
|
def test_read_base_whitelist(self):
|
||||||
for name in ("pbl_domain_ref_bind", "pbl_world_list_by_tenant",
|
rows = run(db.read_base("world", {"tenant_id": T1}))
|
||||||
"pbl_team_bind_world", "pbl_scene_list_by_world"):
|
self.assertEqual(sorted(r["id"] for r in rows), ["W1", "W2"])
|
||||||
resp = api.dispatch(name, {"tenant_id": T1, "ref_type": "world",
|
with self.assertRaises(ValueError):
|
||||||
"ref_id": "W1", "world_id": "W1",
|
run(db.read_base("no_such_table", {}))
|
||||||
"scene_id": "S1", "team_id": "TEAM_A"})
|
with self.assertRaises(ValueError):
|
||||||
self.assertIsInstance(resp, dict, "dispatch(%s) 必须返回 dict" % name)
|
run(db.read_base("pbl_domain_ref", {}))
|
||||||
|
|
||||||
def test_materialize_game_definition_exported(self):
|
def test_no_hardcoded_dbname(self):
|
||||||
"""历史 QC 硬门禁:pbl_compiler 曾引用该符号,必须真实存在且可调用。"""
|
"""db.py 不得出现 DBNAME 常量(库名由宿主 get_module_dbname 决定)。"""
|
||||||
import pbl_domain_ext as pkg
|
with open(os.path.join(ROOT, "pbl_domain_ext", "db.py"),
|
||||||
self.assertTrue(callable(getattr(pkg, "pbl_domain_materialize_game_definition", None)))
|
encoding="utf-8") as fp:
|
||||||
self.assertTrue(callable(getattr(api, "pbl_domain_materialize_game_definition", None)))
|
src = fp.read()
|
||||||
resp = api.pbl_domain_materialize_game_definition({"tenant_id": T1, "world_id": "W1"})
|
self.assertNotIn("DBNAME =", src)
|
||||||
self.assertIsInstance(resp, dict)
|
self.assertIn("get_module_dbname", src)
|
||||||
|
for fake in ("sor.save", "sor.list", "sor.insert", "sor.query"):
|
||||||
|
self.assertNotIn(fake + "(", src)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -234,6 +234,46 @@ class TestCrudJsonContract(unittest.TestCase):
|
|||||||
self.assertIn(f["name"], edit,
|
self.assertIn(f["name"], edit,
|
||||||
"NOT NULL 列 %s 未 editexclouded" % f["name"])
|
"NOT NULL 列 %s 未 editexclouded" % f["name"])
|
||||||
|
|
||||||
|
def test_data_url_not_duplicated_in_editable(self):
|
||||||
|
"""QC#12:三个写 URL 只在 params 顶层一份,禁止在 editable 内重复定义。
|
||||||
|
|
||||||
|
重复定义会让 xls2crud 模板读取歧义(历史上 discount 模块因嵌套导致
|
||||||
|
自定义 create 逻辑被绕过)。editable 只允许保留 get_data_url。
|
||||||
|
"""
|
||||||
|
editable = self.params.get("editable", {})
|
||||||
|
for key in ("new_data_url", "update_data_url", "delete_data_url"):
|
||||||
|
self.assertNotIn(key, editable,
|
||||||
|
"params.editable 内不得重复定义 %s(权威位置=params 顶层)" % key)
|
||||||
|
self.assertIn("get_data_url", editable,
|
||||||
|
"editable 缺 get_data_url(列表取数地址)")
|
||||||
|
|
||||||
|
def test_field_maxlen_matches_model(self):
|
||||||
|
"""QC#23:base.FIELD_MAXLEN 必须与 models/*.json 的 length 单一来源一致。"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "pbl_domain_ext"))
|
||||||
|
from pbl_domain_ext import base
|
||||||
|
for f in _load(MODEL_PATH)["fields"]:
|
||||||
|
name = f["name"]
|
||||||
|
self.assertIn(name, base.FIELD_MAXLEN, "FIELD_MAXLEN 缺列 %s" % name)
|
||||||
|
if f.get("type") in ("str", "char") and f.get("length"):
|
||||||
|
self.assertEqual(base.FIELD_MAXLEN[name], f["length"],
|
||||||
|
"FIELD_MAXLEN[%s]=%s != model length=%s"
|
||||||
|
% (name, base.FIELD_MAXLEN[name], f["length"]))
|
||||||
|
else:
|
||||||
|
self.assertIsNone(base.FIELD_MAXLEN[name],
|
||||||
|
"非字符列 %s 不应有截断宽度" % name)
|
||||||
|
|
||||||
|
def test_clip_respects_column_width(self):
|
||||||
|
"""QC#23:clip() 按列宽裁剪——blueprint_id=32、ref_code=64、ref_name=128。"""
|
||||||
|
from pbl_domain_ext import base
|
||||||
|
self.assertEqual(len(base.clip("blueprint_id", "b" * 80)), 32)
|
||||||
|
self.assertEqual(len(base.clip("ref_code", "c" * 80)), 64)
|
||||||
|
self.assertEqual(len(base.clip("class_id", "x" * 80)), 64)
|
||||||
|
self.assertEqual(len(base.clip("ref_name", "n" * 200)), 128)
|
||||||
|
self.assertEqual(base.clip("ref_code", " w1 "), "w1")
|
||||||
|
# 非字符列(text/timestamp)不截断
|
||||||
|
self.assertEqual(base.clip("ext_json", "e" * 5000)[:5], "eeeee")
|
||||||
|
|
||||||
def test_no_new_data_url_query_params(self):
|
def test_no_new_data_url_query_params(self):
|
||||||
"""new_data_url 不得带 query 参数(避免与 editexclouded 合并成 list)。"""
|
"""new_data_url 不得带 query 参数(避免与 editexclouded 合并成 list)。"""
|
||||||
url = self.params["new_data_url"]
|
url = self.params["new_data_url"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user