deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
c67b0a9e29
commit
09f78980ee
@ -1,23 +1,24 @@
|
||||
{
|
||||
"appcodes": [
|
||||
{
|
||||
"parentid": "pbl_domain_ref_type",
|
||||
"parentname": "PBL基础域关联对象类型",
|
||||
"hierarchy_flg": "0",
|
||||
"items": [
|
||||
{"k": "world", "v": "世界"},
|
||||
{"k": "scene", "v": "场景"},
|
||||
{"k": "entity", "v": "实体"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "pbl_bind_state",
|
||||
"parentname": "PBL关联绑定状态",
|
||||
"hierarchy_flg": "0",
|
||||
"items": [
|
||||
{"k": "bound", "v": "已绑定"},
|
||||
{"k": "unbound", "v": "已解绑"}
|
||||
]
|
||||
}
|
||||
]
|
||||
"appcodes": [
|
||||
{
|
||||
"parentid": "pbl_domain_ref_type",
|
||||
"parentname": "PBL基础域关联类型",
|
||||
"hierarchy_flg": "0",
|
||||
"items": [
|
||||
{"k": "world", "v": "世界"},
|
||||
{"k": "scene", "v": "场景"},
|
||||
{"k": "entity", "v": "实体"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "pbl_bind_state",
|
||||
"parentname": "PBL绑定状态",
|
||||
"hierarchy_flg": "0",
|
||||
"items": [
|
||||
{"k": "bound", "v": "已绑定"},
|
||||
{"k": "unbound", "v": "已解绑"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"pbl_domain_ref": []
|
||||
}
|
||||
|
||||
@ -1,73 +1,50 @@
|
||||
{
|
||||
"tblname": "pbl_domain_ref",
|
||||
"title": "PBL基础域薄扩展关联表(world/scene/entity)",
|
||||
"params": {
|
||||
"new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}",
|
||||
"update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}",
|
||||
"editable": {
|
||||
"new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}",
|
||||
"update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}"
|
||||
},
|
||||
"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')}}"
|
||||
"tblname": "pbl_domain_ref",
|
||||
"title": "PBL基础域关联(world/scene/entity)",
|
||||
"params": {
|
||||
"new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}",
|
||||
"update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}",
|
||||
"editable": {
|
||||
"get_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_get.dspy')}}"
|
||||
},
|
||||
"bind_state": {
|
||||
"uitype": "code",
|
||||
"dataurl": "{{entire_url('/appbase/get_code.dspy?table=appcodes_kv&valuefield=k&textfield=v&cond=parentid%3D%27pbl_bind_state%27')}}"
|
||||
"sortby": ["created_at desc", "id"],
|
||||
"data_filter": {
|
||||
"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": {
|
||||
"uitype": "code",
|
||||
"dataurl": "{{entire_url('/pbl_blueprint/pbl_blueprint_list')}}"
|
||||
"filter_labels": {
|
||||
"ref_type": "关联类型",
|
||||
"bind_state": "绑定状态",
|
||||
"ref_name": "对象名称"
|
||||
},
|
||||
"ext_json": {
|
||||
"uitype": "textarea"
|
||||
"browserfields": {
|
||||
"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": {
|
||||
"uitype": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editexclouded": [
|
||||
"id",
|
||||
"tenant_id",
|
||||
"ref_type",
|
||||
"ref_id",
|
||||
"ref_code",
|
||||
"ref_name",
|
||||
"bind_at",
|
||||
"is_deleted",
|
||||
"creator_id",
|
||||
"created_at",
|
||||
"updater_id",
|
||||
"updated_at"
|
||||
]
|
||||
}
|
||||
"editexclouded": [
|
||||
"id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name",
|
||||
"is_deleted", "created_by", "updated_by", "created_at", "updated_at",
|
||||
"ext_json"
|
||||
],
|
||||
"confidential_fields": ["ext_json"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,198 +1,177 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "pbl_domain_ref",
|
||||
"title": "PBL基础域薄扩展关联表(world/scene/entity)",
|
||||
"primary": [
|
||||
"id"
|
||||
],
|
||||
"catelog": "relation"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "主键",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "tenant_id",
|
||||
"title": "租户ID(打头)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "ref_type",
|
||||
"title": "关联对象类型(world/scene/entity)",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "ref_id",
|
||||
"title": "基表记录ID(world.id/scene.id/entity.id,基表主键为str32)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "ref_code",
|
||||
"title": "基表记录编码快照(只读冗余,不回写基表)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "ref_name",
|
||||
"title": "基表记录名称快照(只读冗余,不回写基表)",
|
||||
"type": "str",
|
||||
"length": 128,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "blueprint_id",
|
||||
"title": "关联蓝图ID(pbl_blueprint主键,str32)",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "class_id",
|
||||
"title": "班级ID",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "team_id",
|
||||
"title": "团队ID",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "bind_state",
|
||||
"title": "绑定状态(bound/unbound)",
|
||||
"type": "str",
|
||||
"length": 16,
|
||||
"nullable": "no",
|
||||
"default": "bound"
|
||||
},
|
||||
{
|
||||
"name": "bind_at",
|
||||
"title": "绑定时间",
|
||||
"type": "timestamp"
|
||||
},
|
||||
{
|
||||
"name": "ext_json",
|
||||
"title": "扩展JSON(租户/班级/团队关联属性,设计§J1权威名)",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "is_deleted",
|
||||
"title": "逻辑删除标记(0正常/1已解绑)",
|
||||
"type": "short",
|
||||
"nullable": "no",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "creator_id",
|
||||
"title": "创建人",
|
||||
"type": "str",
|
||||
"length": 64
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"title": "创建时间",
|
||||
"type": "timestamp",
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "updater_id",
|
||||
"title": "更新人",
|
||||
"type": "str",
|
||||
"length": 64
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"title": "更新时间",
|
||||
"type": "timestamp"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"name": "uk_pbl_domain_ref",
|
||||
"idxtype": "unique",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"ref_type",
|
||||
"ref_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_tenant_type",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"ref_type"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_blueprint",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"blueprint_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_class",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"class_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_team",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"team_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_state",
|
||||
"idxtype": "index",
|
||||
"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'"
|
||||
}
|
||||
]
|
||||
}
|
||||
"summary": [
|
||||
{
|
||||
"name": "pbl_domain_ref",
|
||||
"title": "PBL基础域关联表(world/scene/entity)",
|
||||
"primary": ["id"],
|
||||
"catelog": "relation"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "主键ID",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "tenant_id",
|
||||
"title": "租户ID",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "ref_type",
|
||||
"title": "关联类型(world/scene/entity)",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "ref_id",
|
||||
"title": "基础域对象ID",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "ref_code",
|
||||
"title": "基础域对象编码",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "ref_name",
|
||||
"title": "基础域对象名称",
|
||||
"type": "str",
|
||||
"length": 128,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "blueprint_id",
|
||||
"title": "PBL蓝图ID",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "team_id",
|
||||
"title": "团队ID",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "class_id",
|
||||
"title": "班级ID",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "bind_state",
|
||||
"title": "绑定状态(bound/unbound)",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no",
|
||||
"default": "bound"
|
||||
},
|
||||
{
|
||||
"name": "remark",
|
||||
"title": "备注",
|
||||
"type": "str",
|
||||
"length": 255,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "created_by",
|
||||
"title": "创建人",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "updated_by",
|
||||
"title": "更新人",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"name": "ext_json",
|
||||
"title": "扩展属性JSON(设计§J1权威列名)",
|
||||
"type": "text",
|
||||
"nullable": "yes"
|
||||
},
|
||||
{
|
||||
"name": "is_deleted",
|
||||
"title": "逻辑删除标记(0正常/1已删)",
|
||||
"type": "str",
|
||||
"length": 1,
|
||||
"nullable": "no",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"title": "创建时间",
|
||||
"type": "timestamp",
|
||||
"nullable": "yes"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"title": "更新时间",
|
||||
"type": "timestamp",
|
||||
"nullable": "yes"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_tenant_type",
|
||||
"idxtype": "index",
|
||||
"idxfields": ["tenant_id", "ref_type", "is_deleted"]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_object",
|
||||
"idxtype": "index",
|
||||
"idxfields": ["tenant_id", "ref_type", "ref_id"]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_blueprint",
|
||||
"idxtype": "index",
|
||||
"idxfields": ["tenant_id", "blueprint_id"]
|
||||
},
|
||||
{
|
||||
"name": "idx_pbl_domain_ref_team",
|
||||
"idxtype": "index",
|
||||
"idxfields": ["tenant_id", "class_id", "team_id"]
|
||||
}
|
||||
],
|
||||
"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 -*-
|
||||
"""pbl_domain_ext —— [M8] 基础域薄扩展 world/scene/entity。
|
||||
|
||||
只新增 1 张关联表 ``pbl_domain_ref``,以旁挂方式把 world/scene/entity 三张
|
||||
基础域基表接入 PBL 蓝图/团队/班级;**不改基表结构、不重写基础模块本体**(Q-OPEN-3)。
|
||||
只新增 **1 张**关联表 ``pbl_domain_ref``(world/scene/entity 三类关联靠 ``ref_type``
|
||||
区分),以旁挂方式把三张基础域基表接入 PBL 蓝图/团队/班级;
|
||||
**不改基表结构、不重写基础模块本体**(Q-OPEN-3)。
|
||||
|
||||
用法::
|
||||
|
||||
from pbl_domain_ext import load_pbl_domain_ext
|
||||
info = load_pbl_domain_ext(app)
|
||||
from pbl_domain_ext.init import load_pbl_domain_ext
|
||||
load_pbl_domain_ext()
|
||||
|
||||
from pbl_domain_ext.api import pbl_domain_ref_bind
|
||||
pbl_domain_ref_bind({"tenant_id": "T1", "ref_type": "world",
|
||||
"ref_id": "W1", "blueprint_id": "BP1"})
|
||||
await pbl_domain_ref_bind({"tenant_id": "T1", "ref_type": "world",
|
||||
"ref_id": "W1", "blueprint_id": "BP1"})
|
||||
"""
|
||||
|
||||
from .api import ( # noqa: F401
|
||||
@ -20,23 +21,24 @@ from .api import ( # noqa: F401
|
||||
pbl_world_list_by_tenant, pbl_world_get_context,
|
||||
pbl_scene_list_by_world, pbl_entity_list_by_scene,
|
||||
pbl_team_bind_world, pbl_team_world_list, pbl_team_list_by_class,
|
||||
pbl_domain_materialize_game_definition, dispatch, ok, fail,
|
||||
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__ = [
|
||||
"load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS",
|
||||
"TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES", "BASE_TABLES",
|
||||
"PblDomainExtError", "ERRORS", "dispatch", "ok", "fail",
|
||||
"load_pbl_domain_ext", "API_ROUTES", "PERMISSIONS", "CONTRACT_FUNCS",
|
||||
"TABLE", "REF_TYPES", "BIND_STATES", "BASE_TABLES", "FIELD_MAXLEN",
|
||||
"LIST_FIELDS", "MODULE_NAME", "clip", "ok", "fail",
|
||||
"pbl_domain_ref_bind", "pbl_domain_ref_unbind", "pbl_domain_ref_update",
|
||||
"pbl_domain_ref_get", "pbl_domain_ref_list", "pbl_domain_ref_check_access",
|
||||
"pbl_world_list_by_tenant", "pbl_world_get_context",
|
||||
"pbl_scene_list_by_world", "pbl_entity_list_by_scene",
|
||||
"pbl_team_bind_world", "pbl_team_world_list", "pbl_team_list_by_class",
|
||||
"pbl_domain_materialize_game_definition",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,212 +1,133 @@
|
||||
# -*- 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 三张基表结构;
|
||||
2. 所有读写 ``tenant_id`` 强制打头,缺失即抛 ``PBL_DE_TENANT_MISSING``;
|
||||
3. 扩展字段权威名 ``ext_json``(LONGTEXT / 抽象类型 text),禁止写成旧名 ``ext``;
|
||||
4. 主键统一 ``appPublic.uniqueID.getID()`` 生成(crud-spec Pitfall 11),禁止用标准库随机 UUID 兜底;
|
||||
5. 审计列名统一 ``creator_id / updater_id``(与 models/pbl_domain_ref.json 同名同型)。
|
||||
铁律
|
||||
----
|
||||
1. 本模块只新增 **一张** 关联表 ``pbl_domain_ref``(world / scene / entity 三类关联
|
||||
共用该表,靠 ``ref_type`` 区分),**不**为三个域各建一张表。
|
||||
2. 基础域基表 ``world`` / ``scene`` / ``entity`` 为**只读**:本模块任何代码路径
|
||||
都不得对其执行 C/U/D/I(由 db 层 + 测试双重把守)。
|
||||
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
|
||||
import time
|
||||
MODULE_NAME = "pbl_domain_ext"
|
||||
|
||||
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"
|
||||
|
||||
#: 基础域基表(只读引用,禁止写入/改结构)
|
||||
BASE_TABLES = {
|
||||
"world": "world",
|
||||
"scene": "scene",
|
||||
"entity": "entity",
|
||||
}
|
||||
#: 基础域基表(只读)
|
||||
BASE_TABLES = ("world", "scene", "entity")
|
||||
|
||||
#: ref_type 合法枚举
|
||||
#: 关联类型(与 init/data.json 的 appcodes_kv 组 pbl_domain_ref_type 一致)
|
||||
REF_TYPES = ("world", "scene", "entity")
|
||||
|
||||
#: bind_state 合法枚举
|
||||
#: 绑定状态(与 init/data.json 的 appcodes_kv 组 pbl_bind_state 一致)
|
||||
BIND_STATES = ("bound", "unbound")
|
||||
|
||||
#: 扩展字段权威名(设计 §J1)——三处(models/sql/api)必须同名同型
|
||||
EXT_FIELD = "ext_json"
|
||||
STATE_BOUND = "bound"
|
||||
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 * 带出无关列)
|
||||
#: 与 models/pbl_domain_ref.json 的 fields 一一对应(tests/test_models_contract.py 机械校验)
|
||||
LIST_FIELDS = [
|
||||
"id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name",
|
||||
"blueprint_id", "team_id", "class_id", "bind_state", "bind_at",
|
||||
EXT_FIELD, "is_deleted",
|
||||
"creator_id", "created_at", "updater_id", "updated_at",
|
||||
]
|
||||
#: 列长度权威表(key 与 models/*.json fields[].name、DDL 列名完全一致)
|
||||
FIELD_MAXLEN = {
|
||||
"id": 32,
|
||||
"tenant_id": 32,
|
||||
"ref_type": 32,
|
||||
"ref_code": 64,
|
||||
"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/契约一致性校验)
|
||||
ALL_COLUMNS = list(LIST_FIELDS)
|
||||
#: 列表/查询返回的列(顺序即展示顺序,必须都是 FIELD_MAXLEN 的键或时间列)
|
||||
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():
|
||||
"""统一时间串格式 ``YYYY-MM-DD HH:MM:SS``。"""
|
||||
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)。"""
|
||||
def as_text(value) -> str:
|
||||
"""把任意入参安全转为字符串(None -> '',list/取首元素,其余 str())。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
return value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return value.decode("utf-8", "replace")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return ""
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = value[0] if value else ""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, dict):
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def as_list(value):
|
||||
"""把 None/单值/列表统一成 list。"""
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return list(value)
|
||||
return [value]
|
||||
def clip(field: str, value) -> str:
|
||||
"""按 FIELD_MAXLEN 逐列截断(列长度口径唯一来源,bind/update 共用)。"""
|
||||
text = as_text(value).strip()
|
||||
maxlen = FIELD_MAXLEN.get(field)
|
||||
if maxlen is None:
|
||||
return text
|
||||
return text[:maxlen]
|
||||
|
||||
|
||||
def require_tenant(params):
|
||||
"""取并校验 tenant_id(所有契约第一道门禁)。
|
||||
|
||||
:return: 去空白后的 tenant_id
|
||||
:raises PblDomainExtError: PBL_DE_TENANT_MISSING
|
||||
"""
|
||||
tenant_id = ""
|
||||
if isinstance(params, dict):
|
||||
tenant_id = as_text(params.get("tenant_id")).strip()
|
||||
if not tenant_id or len(tenant_id) > 32:
|
||||
raise PblDomainExtError("PBL_DE_TENANT_MISSING",
|
||||
detail="tenant_id=%r" % (tenant_id,))
|
||||
return tenant_id
|
||||
def clean_row(row: dict, fields=None) -> dict:
|
||||
"""按白名单裁剪 + 逐列截断,返回可安全入库的 dict。"""
|
||||
fields = fields or WRITABLE_FIELDS
|
||||
out = {}
|
||||
for key in fields:
|
||||
if key in row:
|
||||
out[key] = clip(key, row.get(key))
|
||||
return out
|
||||
|
||||
|
||||
def check_ref_type(ref_type, allow_empty=False):
|
||||
"""校验 ref_type ∈ REF_TYPES。"""
|
||||
ref_type = as_text(ref_type).strip().lower()
|
||||
if not ref_type:
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type 为空")
|
||||
if ref_type not in REF_TYPES:
|
||||
raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID",
|
||||
detail="ref_type=%r 允许=%s" % (ref_type, list(REF_TYPES)))
|
||||
return ref_type
|
||||
def valid_ref_type(ref_type: str) -> bool:
|
||||
return as_text(ref_type) in REF_TYPES
|
||||
|
||||
|
||||
def check_bind_state(bind_state, allow_empty=True):
|
||||
"""校验 bind_state ∈ BIND_STATES。"""
|
||||
bind_state = as_text(bind_state).strip().lower()
|
||||
if not bind_state:
|
||||
if allow_empty:
|
||||
return ""
|
||||
return BIND_STATES[0]
|
||||
if bind_state not in BIND_STATES:
|
||||
raise PblDomainExtError("PBL_DE_STATE_INVALID",
|
||||
detail="bind_state=%r" % (bind_state,))
|
||||
return bind_state
|
||||
def table_for_base(ref_type: str) -> str:
|
||||
"""ref_type -> 基表名(未知类型返回 '',调用方据此 fail-closed)。"""
|
||||
ref_type = as_text(ref_type)
|
||||
return ref_type if ref_type in BASE_TABLES else ""
|
||||
|
||||
|
||||
def normalize_ext_json(raw):
|
||||
"""把入参 ext_json 归一为 **合法 JSON 文本**(列类型 LONGTEXT,存字符串)。
|
||||
|
||||
接受:None/""(-> "")、dict/list(-> json.dumps)、已是 JSON 文本的 str。
|
||||
非法 JSON 文本抛 ``PBL_DE_EXT_JSON_INVALID``。
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, (dict, list)):
|
||||
return json.dumps(raw, ensure_ascii=False, sort_keys=True)
|
||||
text = as_text(raw).strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
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 ok(data=None, **extra):
|
||||
"""统一成功返回体。"""
|
||||
body = {"ok": True, "code": "PBL_OK", "data": data if data is not None else {}}
|
||||
body.update(extra)
|
||||
return body
|
||||
|
||||
|
||||
def audit_on_create(row, operator_id):
|
||||
"""补创建审计四件套(就地修改并返回 row)。列名 = creator_id/updater_id(QC#1)。"""
|
||||
ts = now_str()
|
||||
row["creator_id"] = as_text(operator_id)
|
||||
row["created_at"] = ts
|
||||
row["updater_id"] = as_text(operator_id)
|
||||
row["updated_at"] = ts
|
||||
row.setdefault("is_deleted", 0)
|
||||
return row
|
||||
|
||||
|
||||
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
|
||||
def fail(code: str, message: str = "", **extra):
|
||||
"""统一失败返回体(fail-closed,不携带数据)。"""
|
||||
from . import errors
|
||||
body = {
|
||||
"ok": False,
|
||||
"code": code,
|
||||
"message": message or errors.message(code),
|
||||
"data": {},
|
||||
}
|
||||
body.update(extra)
|
||||
return body
|
||||
|
||||
@ -1,379 +1,282 @@
|
||||
# -*- 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``),
|
||||
禁止编造 save/list/insert 等不存在的方法。
|
||||
⚠️ sqlor 只有 ``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe`` 六个方法,
|
||||
``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。
|
||||
基础域基表(world/scene/entity)在本层**只读**:仅暴露 ``read_base_rows``,
|
||||
不提供任何写基表的函数(Q-OPEN-3:薄扩展不改基表)。
|
||||
⚠️ 库名不硬编码::func:`dbname` 走 ``ServerEnv().get_module_dbname(MODULE_NAME)``,
|
||||
宿主应用决定实际库名(module-development-spec「模块取库名禁止硬编码 DBNAME」)。
|
||||
本文件不存在任何 ``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
|
||||
import inspect
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
try: # 宿主提供,缺失时不影响纯逻辑测试
|
||||
from ahserver.serverenv import ServerEnv
|
||||
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 .errors import PblDomainExtError
|
||||
from .base import (
|
||||
BASE_TABLES, FIELD_MAXLEN, MODULE_NAME, TABLE, as_text, clip,
|
||||
)
|
||||
|
||||
__all__ = ["get_sor", "get_dbname", "insert_ref", "update_ref", "soft_delete_ref",
|
||||
"select_ref_by_id", "select_ref_by_key", "select_refs", "count_refs",
|
||||
"read_base_rows", "read_base_row", "base_table_exists", "ensure_ext_column",
|
||||
"execute_sql"]
|
||||
|
||||
_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
|
||||
#: 测试注入的 sor(None 表示走真实 DBPools)
|
||||
_INJECTED_SOR = None
|
||||
|
||||
|
||||
def set_sor(sor):
|
||||
"""init.py 挂载时注入 sqlor 句柄(测试可注入 fake_db)。"""
|
||||
_SOR_HOLDER["sor"] = sor
|
||||
return sor
|
||||
"""注入假 sor(仅测试使用);传 None 恢复真实连接。"""
|
||||
global _INJECTED_SOR
|
||||
_INJECTED_SOR = sor
|
||||
|
||||
|
||||
def get_dbname():
|
||||
"""模块库名:ServerEnv().get_module_dbname('pbl_domain_ext'),禁止硬编码。"""
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
return ServerEnv().get_module_dbname("pbl_domain_ext")
|
||||
except Exception:
|
||||
return _SOR_HOLDER.get("dbname") or ""
|
||||
def dbname() -> str:
|
||||
"""取本模块库名——由宿主应用决定,绝不硬编码。"""
|
||||
if ServerEnv is None:
|
||||
raise RuntimeError("ahserver not installed; host app must provide ServerEnv")
|
||||
env = ServerEnv()
|
||||
getter = getattr(env, "get_module_dbname", None)
|
||||
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):
|
||||
"""只读/DDL 统一走 sqlExe;写数据请用 insert_ref/update_ref。"""
|
||||
sor = get_sor()
|
||||
try:
|
||||
# sqlExe(sql, ns) 第二参恒需(sqlor:missing 1 required positional argument 'ns')
|
||||
return _drive(sor.sqlExe(sql, args if args else {}))
|
||||
except PblDomainExtError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise PblDomainExtError("PBL_DE_DB_ERROR", detail="%s | %s" % (type(exc).__name__, exc))
|
||||
def sor_context():
|
||||
"""返回 (sor, need_close);测试注入时不需关闭。"""
|
||||
if _INJECTED_SOR is not None:
|
||||
return _INJECTED_SOR, False
|
||||
if DBPools is None:
|
||||
raise RuntimeError("sqlor not installed; host app must provide DBPools")
|
||||
return DBPools().sqlorContext(dbname()), True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 关联表 pbl_domain_ref 读写
|
||||
# --------------------------------------------------------------------------
|
||||
def insert_ref(row):
|
||||
"""新增关联记录,返回受影响行数。"""
|
||||
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 assert_writable(table: str):
|
||||
"""基表只读铁律:拒绝任何对 world/scene/entity 的写入。"""
|
||||
if table in BASE_TABLES:
|
||||
raise AssertionError(
|
||||
f"pbl_domain_ext must NOT write base table {table!r} (read-only)")
|
||||
|
||||
|
||||
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 soft_delete_ref(where_dict, operator_id=""):
|
||||
"""逻辑删除(is_deleted=1),绝不物理 DELETE。"""
|
||||
from .base import audit_on_update
|
||||
row = audit_on_update({"is_deleted": 1}, operator_id)
|
||||
return update_ref(where_dict, row)
|
||||
|
||||
|
||||
def _fields_clause(fields=None):
|
||||
cols = fields or LIST_FIELDS
|
||||
return ", ".join("`%s`" % c for c in cols)
|
||||
|
||||
|
||||
def select_ref_by_id(tenant_id, ref_pk, fields=None):
|
||||
"""按主键取单条(强制带 tenant_id)。"""
|
||||
sql = "SELECT %s FROM `%s` WHERE `tenant_id`=%%s AND `id`=%%s AND `is_deleted`=0 LIMIT 1" % (
|
||||
_fields_clause(fields), TABLE)
|
||||
rows = execute_sql(sql, (tenant_id, ref_pk))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def select_ref_by_key(tenant_id, ref_type, ref_id, fields=None):
|
||||
"""按业务唯一键 (tenant_id, ref_type, ref_id) 取单条。"""
|
||||
sql = ("SELECT %s FROM `%s` WHERE `tenant_id`=%%s AND `ref_type`=%%s "
|
||||
"AND `ref_id`=%%s AND `is_deleted`=0 LIMIT 1") % (_fields_clause(fields), TABLE)
|
||||
rows = execute_sql(sql, (tenant_id, ref_type, ref_id))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def select_refs(tenant_id, conds=None, order_by=None, offset=0, limit=20, fields=None):
|
||||
"""条件查询关联记录列表。
|
||||
|
||||
:param conds: ``{列名: 值}`` 或 ``{列名: [值,...]}``(IN),值一律参数化绑定
|
||||
"""
|
||||
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:
|
||||
# 非白名单列直接忽略,防注入
|
||||
def _where_of(where: dict, require_tenant: bool = True):
|
||||
"""等值条件构造:键必须落在 FIELD_MAXLEN 白名单内(防未知列注入)。"""
|
||||
conds, params = [], []
|
||||
for key, value in (where or {}).items():
|
||||
if key not in FIELD_MAXLEN or value in (None, ""):
|
||||
continue
|
||||
if isinstance(val, (list, tuple, set)):
|
||||
vals = [v for v in val if v not in (None, "")]
|
||||
if not vals:
|
||||
continue
|
||||
where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals))))
|
||||
args.extend(vals)
|
||||
elif val not in (None, ""):
|
||||
where.append("`%s`=%%s" % col)
|
||||
args.append(val)
|
||||
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 []
|
||||
conds.append(f"`{key}` = %s")
|
||||
params.append(clip(key, value))
|
||||
conds.append("`is_deleted` = %s")
|
||||
params.append("0")
|
||||
if require_tenant and "tenant_id" not in (where or {}):
|
||||
# fail-closed:租户条件缺失时永不返回数据
|
||||
conds.append("1 = 0")
|
||||
return conds, params
|
||||
|
||||
|
||||
def count_refs(tenant_id, conds=None):
|
||||
"""与 select_refs 同条件的总数(分页用)。"""
|
||||
where = ["`tenant_id`=%s", "`is_deleted`=0"]
|
||||
args = [tenant_id]
|
||||
for key, val in (conds or {}).items():
|
||||
col = as_text(key).strip("`")
|
||||
if col not in LIST_FIELDS:
|
||||
continue
|
||||
if isinstance(val, (list, tuple, set)):
|
||||
vals = [v for v in val if v not in (None, "")]
|
||||
if not vals:
|
||||
continue
|
||||
where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals))))
|
||||
args.extend(vals)
|
||||
elif val not in (None, ""):
|
||||
where.append("`%s`=%%s" % col)
|
||||
args.append(val)
|
||||
sql = "SELECT COUNT(1) AS `cnt` FROM `%s` WHERE %s" % (TABLE, " AND ".join(where))
|
||||
rows = execute_sql(sql, tuple(args))
|
||||
async def select_refs(where: dict, order_by: str = "created_at asc",
|
||||
limit: int = 0, offset: int = 0):
|
||||
"""按等值条件查询 pbl_domain_ref(自动排除逻辑删除记录)。"""
|
||||
conds, params = _where_of(where)
|
||||
sql = f"SELECT * FROM `{TABLE}` WHERE " + " AND ".join(conds)
|
||||
if order_by:
|
||||
sql += f" ORDER BY {order_by}"
|
||||
if limit and limit > 0:
|
||||
sql += " LIMIT %s"
|
||||
params.append(int(limit))
|
||||
if offset and offset > 0:
|
||||
sql += " OFFSET %s"
|
||||
params.append(int(offset))
|
||||
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 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:
|
||||
return 0
|
||||
row = rows[0]
|
||||
if isinstance(row, dict):
|
||||
return int(row.get("cnt") or 0)
|
||||
return int(row[0] or 0)
|
||||
return int(getattr(row, "cnt", 0) or 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 基础域基表:只读
|
||||
# --------------------------------------------------------------------------
|
||||
def read_base_rows(ref_type, tenant_id, extra_conds=None, limit=200):
|
||||
"""只读拉取基础域基表记录(world/scene/entity)。
|
||||
|
||||
基表列名因模块而异,这里用 ``SELECT *`` 只读取、绝不写入;
|
||||
返回原始 dict 列表,由 api 层做字段映射与裁剪。
|
||||
"""
|
||||
check = as_text(ref_type).strip().lower()
|
||||
if check not in BASE_TABLES:
|
||||
raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type=%r" % (ref_type,))
|
||||
tbl = BASE_TABLES[check]
|
||||
where = ["`tenant_id`=%s"]
|
||||
args = [tenant_id]
|
||||
for key, val in (extra_conds or {}).items():
|
||||
col = as_text(key).strip("`")
|
||||
if not col.isidentifier():
|
||||
continue
|
||||
if val in (None, ""):
|
||||
continue
|
||||
where.append("`%s`=%%s" % col)
|
||||
args.append(val)
|
||||
sql = "SELECT * FROM `%s` WHERE %s LIMIT %%s" % (tbl, " AND ".join(where))
|
||||
args.append(int(limit))
|
||||
try:
|
||||
return execute_sql(sql, tuple(args)) or []
|
||||
except PblDomainExtError as exc:
|
||||
# 基表可能不带 tenant_id 列(历史库),降级为无租户过滤的只读查询
|
||||
if exc.code == "PBL_DE_DB_ERROR":
|
||||
sql2 = "SELECT * FROM `%s` LIMIT %%s" % tbl
|
||||
try:
|
||||
return execute_sql(sql2, (int(limit),)) or []
|
||||
except PblDomainExtError:
|
||||
return []
|
||||
raise
|
||||
|
||||
|
||||
def read_base_row(ref_type, ref_id, tenant_id=None):
|
||||
"""按主键只读单条基表记录。"""
|
||||
check = as_text(ref_type).strip().lower()
|
||||
if check not in BASE_TABLES:
|
||||
raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type=%r" % (ref_type,))
|
||||
tbl = BASE_TABLES[check]
|
||||
rid = as_text(ref_id).strip()
|
||||
if not rid:
|
||||
raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 为空")
|
||||
if tenant_id:
|
||||
sql = "SELECT * FROM `%s` WHERE `id`=%%s AND `tenant_id`=%%s LIMIT 1" % tbl
|
||||
rows = _safe_select(sql, (rid, tenant_id))
|
||||
else:
|
||||
sql = "SELECT * FROM `%s` WHERE `id`=%%s LIMIT 1" % tbl
|
||||
rows = _safe_select(sql, (rid,))
|
||||
if rows:
|
||||
return rows[0]
|
||||
# 兼容基表主键列名为 {tbl}_id 的历史实现
|
||||
alt = "%s_id" % tbl
|
||||
sql_alt = "SELECT * FROM `%s` WHERE `%s`=%%s LIMIT 1" % (tbl, alt)
|
||||
rows = _safe_select(sql_alt, (rid,))
|
||||
async def find_ref_by_id(ref_id: str, tenant_id: str):
|
||||
"""按主键 + 租户双条件取单条(租户打头,取不到即视为不可见)。"""
|
||||
if not tenant_id:
|
||||
return None
|
||||
rows = await select_refs({"id": ref_id, "tenant_id": tenant_id}, limit=1)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _safe_select(sql, args):
|
||||
try:
|
||||
return execute_sql(sql, args) or []
|
||||
except PblDomainExtError:
|
||||
return []
|
||||
async def find_ref(ref_type: str, tenant_id: str, ref_id: str = "",
|
||||
blueprint_id: str = "", team_id: str = "",
|
||||
class_id: str = ""):
|
||||
"""幂等 upsert 的定位查询。
|
||||
|
||||
|
||||
def base_table_exists(ref_type):
|
||||
"""探测基础域基表是否存在(部署校验用,只读 information_schema)。"""
|
||||
tbl = BASE_TABLES.get(as_text(ref_type).strip().lower())
|
||||
if not tbl:
|
||||
return False
|
||||
dbname = get_dbname()
|
||||
if not dbname:
|
||||
return True # 无法判定时不阻断
|
||||
sql = ("SELECT COUNT(1) AS `cnt` FROM information_schema.tables "
|
||||
"WHERE table_schema=%s AND table_name=%s")
|
||||
try:
|
||||
rows = execute_sql(sql, (dbname, tbl))
|
||||
except PblDomainExtError:
|
||||
return True
|
||||
if not rows:
|
||||
return False
|
||||
row = rows[0]
|
||||
return int((row.get("cnt") if isinstance(row, dict) else row[0]) or 0) > 0
|
||||
|
||||
|
||||
def ensure_ext_column():
|
||||
"""幂等保障:扩展列名必须是设计 §J1 权威名 ``ext_json``(LONGTEXT)。
|
||||
|
||||
历史库若为旧名 ``ext`` 则 RENAME 为 ``ext_json``;若两列都无则 ADD。返回执行的动作描述。
|
||||
定位键 = 租户 + ref_type + ref_id +(可选)blueprint_id + team_id + class_id,
|
||||
与 DDL 索引 idx_pbl_domain_ref_object / idx_pbl_domain_ref_team 对齐;
|
||||
带上 team/class 是为了「同一世界绑定给不同团队」各成一条记录,互不吞并。
|
||||
"""
|
||||
dbname = get_dbname()
|
||||
if not dbname:
|
||||
return "skip(no dbname)"
|
||||
probe = ("SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema=%s AND table_name=%s")
|
||||
if not tenant_id:
|
||||
return None
|
||||
where = {"tenant_id": tenant_id, "ref_type": ref_type}
|
||||
if ref_id:
|
||||
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:
|
||||
rows = execute_sql(probe, (dbname, TABLE)) or []
|
||||
except PblDomainExtError:
|
||||
return "skip(probe failed)"
|
||||
cols = set()
|
||||
for r in rows:
|
||||
name = r.get("column_name") if isinstance(r, dict) else r[0]
|
||||
cols.add(as_text(name).lower())
|
||||
if not cols:
|
||||
return "skip(table absent)"
|
||||
if EXT_FIELD in cols and "ext" in cols:
|
||||
execute_sql("ALTER TABLE `%s` DROP COLUMN `ext`" % TABLE)
|
||||
return "dropped legacy ext"
|
||||
if EXT_FIELD in cols:
|
||||
return "ok(ext_json present)"
|
||||
if "ext" in cols:
|
||||
execute_sql("ALTER TABLE `%s` CHANGE COLUMN `ext` `%s` longtext "
|
||||
"COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD))
|
||||
return "renamed ext -> ext_json"
|
||||
execute_sql("ALTER TABLE `%s` ADD COLUMN `%s` longtext "
|
||||
"COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD))
|
||||
return "added ext_json"
|
||||
await sor.C(TABLE, payload)
|
||||
finally:
|
||||
if need_close:
|
||||
await sor.close()
|
||||
return payload
|
||||
|
||||
|
||||
async def update_ref(ref_id: str, tenant_id: str, changes: dict) -> int:
|
||||
"""按 id + tenant_id 更新(租户并入 where,跨租户更新天然不命中)。"""
|
||||
assert_writable(TABLE)
|
||||
payload = {k: clip(k, v) for k, v in (changes or {}).items()
|
||||
if k in FIELD_MAXLEN and k not in ("id", "tenant_id")}
|
||||
if not payload or not tenant_id:
|
||||
return 0
|
||||
where = {"id": clip("id", ref_id), "tenant_id": clip("tenant_id", tenant_id),
|
||||
"is_deleted": "0"}
|
||||
sor, need_close = sor_context()
|
||||
try:
|
||||
result = await sor.U(TABLE, {"data": payload, "where": where})
|
||||
finally:
|
||||
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 -*-
|
||||
"""pbl_domain_ext 错误码与异常。
|
||||
"""pbl_domain_ext 错误码(与 pbl_common 口径一致:纯字符串常量,零外部依赖)。
|
||||
|
||||
错误码前缀 ``PBL_DE_``,与 pbl_common 错误码体系同构(code / message / http_status)。
|
||||
所有对外契约接口失败时统一抛 :class:`PblDomainExtError`,由 wwwroot/api/*.dspy 层
|
||||
捕获并转 ``{"code": "...", "message": "...", "success": false}`` JSON,绝不裸抛栈。
|
||||
约定:api 层返回 dict,``code`` 取本模块常量;``ok=True`` 仅在 code == OK 时出现。
|
||||
fail-closed:任何租户上下文缺失或跨租户访问一律返回错误码,不返回数据。
|
||||
"""
|
||||
|
||||
__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 状态码)
|
||||
ERRORS = {
|
||||
"PBL_DE_OK": ("成功", 200),
|
||||
"PBL_DE_TENANT_MISSING": ("tenant_id 缺失或非法", 400),
|
||||
"PBL_DE_PARAM_INVALID": ("参数非法", 400),
|
||||
"PBL_DE_REF_TYPE_INVALID": ("ref_type 非法,仅允许 world/scene/entity", 400),
|
||||
"PBL_DE_STATE_INVALID": ("bind_state 非法,仅允许 bound/unbound", 400),
|
||||
"PBL_DE_EXT_JSON_INVALID": ("ext_json 不是合法 JSON 文本", 400),
|
||||
"PBL_DE_NOT_FOUND": ("关联记录不存在", 404),
|
||||
"PBL_DE_BASE_MISSING": ("基础域对象不存在(world/scene/entity 基表未命中)", 404),
|
||||
"PBL_DE_DUPLICATE": ("关联记录已存在", 409),
|
||||
"PBL_DE_ACCESS_DENIED": ("无权访问该租户/对象数据", 403),
|
||||
"PBL_DE_READONLY_TABLE": ("基础域基表只读,禁止写入或改结构", 403),
|
||||
"PBL_DE_DB_ERROR": ("数据库操作失败", 500),
|
||||
MESSAGES = {
|
||||
OK: "ok",
|
||||
TENANT_REQUIRED: "tenant_id is required (fail-closed)",
|
||||
PARAM_INVALID: "invalid parameter",
|
||||
REF_TYPE_INVALID: "ref_type must be one of world/scene/entity",
|
||||
REF_NOT_FOUND: "domain ref record not found",
|
||||
BASE_OBJECT_NOT_FOUND: "base domain object not found in this tenant",
|
||||
FORBIDDEN_CROSS_TENANT: "cross tenant access denied",
|
||||
BASE_READONLY: "base domain tables are read-only for pbl_domain_ext",
|
||||
DB_ERROR: "database error",
|
||||
}
|
||||
|
||||
|
||||
class PblDomainExtError(Exception):
|
||||
"""pbl_domain_ext 统一业务异常。
|
||||
|
||||
: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",
|
||||
]
|
||||
def message(code: str) -> str:
|
||||
"""按错误码取默认文案(未知码回落到码本身,便于排查)。"""
|
||||
return MESSAGES.get(code, code)
|
||||
|
||||
@ -1,26 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_domain_ext 模块挂载入口。
|
||||
"""pbl_domain_ext 模块挂载入口(load_pbl_domain_ext)。
|
||||
|
||||
应用侧调用 ``load_pbl_domain_ext()`` 完成:
|
||||
1. 注册 13 个 dspy 契约路由(wwwroot/api/*.dspy -> api.py 契约函数);
|
||||
2. 注册 RBAC 权限点(owner.pbl / tenant 管理员可写,其余只读);
|
||||
3. 注入 sqlor 句柄与库名(ServerEnv().get_module_dbname);
|
||||
4. 幂等保障扩展列名为设计 §J1 权威名 ``ext_json``(旧名 ext_json 自动 RENAME)。
|
||||
1. 把 13 个契约函数 + 1 个物化函数注册到 ServerEnv(.dspy / .ui 直接按名调用);
|
||||
2. 暴露路由表 / RBAC 权限点表供 scripts/load_path.py 使用;
|
||||
3. 库名由宿主应用 ``get_module_dbname('pbl_domain_ext')`` 决定,本模块不硬编码。
|
||||
|
||||
铁律:本模块**不创建/不修改** 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 .base import TABLE, EXT_FIELD, REF_TYPES, BIND_STATES
|
||||
from .errors import PblDomainExtError
|
||||
from .base import (
|
||||
BASE_TABLES, BIND_STATES, FIELD_MAXLEN, LIST_FIELDS, MODULE_NAME, REF_TYPES,
|
||||
TABLE,
|
||||
)
|
||||
|
||||
__all__ = ["load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS",
|
||||
"CONTRACT_FUNCS", "_register_serverenv",
|
||||
"TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES"]
|
||||
"CONTRACT_FUNCS", "TABLE", "REF_TYPES", "BIND_STATES",
|
||||
"BASE_TABLES", "FIELD_MAXLEN", "LIST_FIELDS"]
|
||||
|
||||
MODULE_NAME = "pbl_domain_ext"
|
||||
|
||||
#: dspy 契约路由表:url 后缀 -> (action 名, 是否写操作)
|
||||
#: dspy 契约路由表:url 后缀 -> (api 函数名, 是否写操作)
|
||||
API_ROUTES = [
|
||||
("pbl_domain_ref_bind", "pbl_domain_ref_bind", 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),
|
||||
]
|
||||
|
||||
#: RBAC 权限点(写 4 个 + 读 1 个聚合)
|
||||
#: 跨模块 import 契约(pbl_compiler 依赖,不进 dspy 路由)
|
||||
CROSS_MODULE_FUNCS = ["pbl_domain_materialize_game_definition"]
|
||||
|
||||
#: RBAC 权限点(写 3 个 + 读 1 个聚合)
|
||||
PERMISSIONS = [
|
||||
{"code": "pbl_domain_ext:ref:write", "name": "PBL基础域关联-写",
|
||||
"note": "绑定/解绑/更新 pbl_domain_ref"},
|
||||
{"code": "pbl_domain_ext:ref:read", "name": "PBL基础域关联-读",
|
||||
"note": "查询关联记录与基础域只读视图"},
|
||||
{"code": "pbl_domain_ext:team:bind", "name": "PBL团队绑定世界",
|
||||
"note": "team/class 与 world 的绑定关系维护"},
|
||||
"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)
|
||||
CONTRACT_FUNCS = [
|
||||
"pbl_domain_ref_bind", "pbl_domain_ref_unbind", "pbl_domain_ref_update",
|
||||
"pbl_domain_ref_get", "pbl_domain_ref_list", "pbl_domain_ref_check_access",
|
||||
"pbl_world_list_by_tenant", "pbl_world_get_context",
|
||||
"pbl_scene_list_by_world", "pbl_entity_list_by_scene",
|
||||
"pbl_team_bind_world", "pbl_team_world_list", "pbl_team_list_by_class",
|
||||
"pbl_domain_materialize_game_definition",
|
||||
]
|
||||
def contract_funcs():
|
||||
"""契约函数名清单(含跨模块物化函数),供自检与测试遍历。"""
|
||||
names = [name for _url, name, _w in API_ROUTES]
|
||||
names.extend(CROSS_MODULE_FUNCS)
|
||||
return names
|
||||
|
||||
|
||||
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
|
||||
③ 本函数 env.<name> = <name>
|
||||
缺③ → dspy 运行期 NameError: name 'pbl_xxx' is not defined。
|
||||
|
||||
def load_pbl_domain_ext(env=None):
|
||||
"""把本模块函数注册到 ServerEnv(进程级单例,宿主应用 init() 内调用)。
|
||||
|
||||
返回注册摘要,便于宿主打印/自检。注册项 = 13 个 dspy 契约 + 1 个物化契约
|
||||
+ 表名/类型常量(.dspy 里可直接引用 TABLE / REF_TYPES)。
|
||||
"""
|
||||
registered, missing = [], []
|
||||
env = None
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
if env is None:
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
except ImportError: # pragma: no cover
|
||||
raise RuntimeError("ahserver not installed; pass env explicitly")
|
||||
env = ServerEnv()
|
||||
except Exception: # noqa: BLE001 非 ahserver 宿主(单测/离线)跳过
|
||||
env = None
|
||||
target = env
|
||||
|
||||
registered = []
|
||||
for name in CONTRACT_FUNCS:
|
||||
func = getattr(api, name, None)
|
||||
if func is None:
|
||||
missing.append(name)
|
||||
continue
|
||||
if env is not None:
|
||||
try:
|
||||
setattr(env, name, func)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
raise AttributeError(
|
||||
f"pbl_domain_ext: contract function {name!r} missing in api.py")
|
||||
setattr(target, name, func)
|
||||
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):
|
||||
"""注册 dspy 路由(平台 register_dspy 可用时走平台,否则仅返回路由表)。"""
|
||||
registered = []
|
||||
for name, action, is_write in API_ROUTES:
|
||||
url = "/%s/api/%s.dspy" % (MODULE_NAME, name)
|
||||
registered.append({"url": url, "action": action, "write": is_write,
|
||||
"handler": getattr(api, action, None)})
|
||||
if app is not None and hasattr(app, "register_dspy"):
|
||||
for item in registered:
|
||||
try:
|
||||
app.register_dspy(item["url"], item["handler"])
|
||||
except Exception: # noqa: BLE001 平台版本差异不阻断挂载
|
||||
pass
|
||||
return registered
|
||||
# 兼容旧调用点:单/复数同名注册(CRUD 工厂生成的 add_/update_/delete_ 前缀)
|
||||
for alias, real in (("pbl_domain_ref_add", "pbl_domain_ref_bind"),
|
||||
("pbl_domain_ref_delete", "pbl_domain_ref_unbind")):
|
||||
setattr(target, alias, getattr(api, real))
|
||||
registered.append(alias)
|
||||
|
||||
|
||||
def _register_permissions(app=None):
|
||||
"""注册 RBAC 权限点(幂等)。"""
|
||||
if app is not None and hasattr(app, "register_permissions"):
|
||||
try:
|
||||
app.register_permissions(MODULE_NAME, PERMISSIONS)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return list(PERMISSIONS)
|
||||
|
||||
|
||||
def load_pbl_domain_ext(app=None, sor=None, ensure_schema=True):
|
||||
"""挂载模块。
|
||||
|
||||
:param app: 应用对象(可选,用于注册路由/权限)
|
||||
:param sor: sqlor 句柄(可选;缺省时由 db.get_sor() 惰性从 ServerEnv 取)
|
||||
:param ensure_schema: 是否执行 ext_json 列名幂等保障(默认 True)
|
||||
:return: 挂载信息 dict
|
||||
"""
|
||||
if sor is not None:
|
||||
db.set_sor(sor)
|
||||
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"],
|
||||
}
|
||||
return {"module": MODULE_NAME, "loaded": True, "funcs": registered,
|
||||
"table": TABLE, "routes": len(API_ROUTES),
|
||||
"permissions": [p["code"] for p in PERMISSIONS]}
|
||||
|
||||
@ -1,71 +1,89 @@
|
||||
-- =====================================================================
|
||||
-- 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)。
|
||||
--
|
||||
-- 薄扩展铁律(Q-OPEN-3):本文件**只建 1 张关联表 pbl_domain_ref**,
|
||||
-- 不创建、不修改 world / scene / entity 三张基础域基表结构。
|
||||
-- =====================================================================
|
||||
-- =============================================================================
|
||||
-- pbl_domain_ext [M8] 基础域薄扩展 world/scene/entity
|
||||
-- =============================================================================
|
||||
-- 铁律:
|
||||
-- 1. 本文件**只创建 1 张表** pbl_domain_ref(关联表,ref_type 区分
|
||||
-- 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` (
|
||||
`id` VARCHAR(32) NOT NULL COMMENT '主键',
|
||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(打头)',
|
||||
`ref_type` VARCHAR(32) NOT NULL COMMENT '关联对象类型(world/scene/entity)',
|
||||
`ref_id` VARCHAR(64) NOT NULL COMMENT '基表记录ID(world.id/scene.id/entity.id,基表主键为str32)',
|
||||
`ref_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '基表记录编码快照(只读冗余,不回写基表)',
|
||||
`ref_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '基表记录名称快照(只读冗余,不回写基表)',
|
||||
`blueprint_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联蓝图ID(pbl_blueprint主键,str32)',
|
||||
`class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID',
|
||||
`team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',
|
||||
`bind_state` VARCHAR(16) NOT NULL DEFAULT 'bound' COMMENT '绑定状态(bound/unbound)',
|
||||
`bind_at` TIMESTAMP NULL COMMENT '绑定时间',
|
||||
`ext_json` LONGTEXT NULL COMMENT '扩展JSON(租户/班级/团队关联属性,设计§J1权威名)',
|
||||
`is_deleted` SMALLINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0正常/1已解绑)',
|
||||
`creator_id` VARCHAR(64) NULL COMMENT '创建人',
|
||||
`created_at` TIMESTAMP NOT NULL COMMENT '创建时间',
|
||||
`updater_id` VARCHAR(64) NULL COMMENT '更新人',
|
||||
`updated_at` TIMESTAMP NULL COMMENT '更新时间',
|
||||
`id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键ID',
|
||||
`tenant_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '租户ID',
|
||||
`ref_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联类型(world/scene/entity)',
|
||||
`ref_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '基础域对象ID',
|
||||
`ref_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '基础域对象编码',
|
||||
`ref_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '基础域对象名称',
|
||||
`blueprint_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'PBL蓝图ID',
|
||||
`team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',
|
||||
`class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID',
|
||||
`bind_state` VARCHAR(32) NOT NULL DEFAULT 'bound' COMMENT '绑定状态(bound/unbound)',
|
||||
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`created_by` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`updated_by` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新人',
|
||||
`ext_json` TEXT NULL COMMENT '扩展属性JSON(设计§J1权威列名)',
|
||||
`is_deleted` VARCHAR(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记(0正常/1已删)',
|
||||
`created_at` DATETIME NULL COMMENT '创建时间',
|
||||
`updated_at` DATETIME NULL COMMENT '更新时间',
|
||||
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`),
|
||||
KEY `idx_pbl_domain_ref_blueprint` (`tenant_id`, `blueprint_id`),
|
||||
KEY `idx_pbl_domain_ref_class` (`tenant_id`, `class_id`),
|
||||
KEY `idx_pbl_domain_ref_team` (`tenant_id`, `team_id`),
|
||||
KEY `idx_pbl_domain_ref_state` (`tenant_id`, `bind_state`, `is_deleted`)
|
||||
KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`, `is_deleted`),
|
||||
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_team` (`tenant_id`, `class_id`, `team_id`)
|
||||
) 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
|
||||
('pbl_domain_ref_type', 'PBL关联对象类型', '0'),
|
||||
('pbl_bind_state', 'PBL关联绑定状态', '0')
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||
('pbl_domain_ref_type', 'PBL基础域关联类型', '0'),
|
||||
('pbl_bind_state', 'PBL绑定状态', '0')
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `hierarchy_flg` = VALUES(`hierarchy_flg`);
|
||||
|
||||
INSERT INTO `appcodes_kv` (`id`, `parentid`, `k`, `v`) VALUES
|
||||
('pbl_drt_world', 'pbl_domain_ref_type', 'world', '世界'),
|
||||
('pbl_drt_scene', 'pbl_domain_ref_type', 'scene', '场景'),
|
||||
('pbl_drt_entity', 'pbl_domain_ref_type', 'entity', '实体'),
|
||||
('pbl_bs_bound', 'pbl_bind_state', 'bound', '已绑定'),
|
||||
('pbl_bs_unbound', 'pbl_bind_state', 'unbound', '已解绑')
|
||||
('pbl_domain_ref_type_world', 'pbl_domain_ref_type', 'world', '世界'),
|
||||
('pbl_domain_ref_type_scene', 'pbl_domain_ref_type', 'scene', '场景'),
|
||||
('pbl_domain_ref_type_entity', 'pbl_domain_ref_type', 'entity', '实体'),
|
||||
('pbl_bind_state_bound', 'pbl_bind_state', 'bound', '已绑定'),
|
||||
('pbl_bind_state_unbound', 'pbl_bind_state', 'unbound', '已解绑')
|
||||
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 -*-
|
||||
"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C/U/D/R/I/sqlExe。
|
||||
"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C / U / D / R / I / sqlExe。
|
||||
|
||||
要点:
|
||||
* ``sqlExe`` 先把 ``%s`` 占位按序插值为字面量,再用极简解析器执行
|
||||
SELECT / SELECT COUNT(1) / LIMIT / OFFSET / IN / AND 等值条件;
|
||||
* world / scene / entity 三张基础域基表**只读**:任何 C/U/D/I 写入都会
|
||||
记录到 ``base_write_attempts`` 并抛 AssertionError,用于验证「不改基表」铁律。
|
||||
契约(与 pbl_domain_ext/db.py 完全对齐):
|
||||
* ``C(table, row)`` 插入一行
|
||||
* ``U(table, spec)`` spec = {"data": {...}, "where": {...}},返回受影响行数
|
||||
* ``D(table, spec)`` spec = {"where": {...}},物理删除(本模块不使用)
|
||||
* ``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
|
||||
|
||||
__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 = {
|
||||
"world": [
|
||||
@ -23,263 +34,258 @@ BASE_FIXTURES = {
|
||||
],
|
||||
"scene": [
|
||||
{"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",
|
||||
"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",
|
||||
"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": [
|
||||
{"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",
|
||||
"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",
|
||||
"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 _literal(value):
|
||||
"""把绑定参数转成 SQL 字面量(仅测试用)。"""
|
||||
def _lit(value):
|
||||
"""把参数转成 SQL 字面量(仅测试解析用,不参与真实 SQL)。"""
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
return "'%s'" % str(value).replace("\\", "\\\\").replace("'", "''")
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _coerce(token):
|
||||
"""把字面量 token 还原成 Python 值。"""
|
||||
token = token.strip()
|
||||
if token.upper() == "NULL":
|
||||
return None
|
||||
if len(token) >= 2 and token[0] == "'" and token[-1] == "'":
|
||||
return token[1:-1].replace("''", "'").replace("\\\\", "\\")
|
||||
try:
|
||||
return int(token)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(token)
|
||||
except ValueError:
|
||||
return token
|
||||
class FakeSor:
|
||||
"""内存 sqlor 替身。"""
|
||||
|
||||
|
||||
def _loose_eq(left, right):
|
||||
"""宽松相等:兼容 '0' vs 0、str vs int 的列值比较。"""
|
||||
if left == right:
|
||||
return True
|
||||
if left is None or right is None:
|
||||
return False
|
||||
try:
|
||||
return float(left) == float(right)
|
||||
except (TypeError, ValueError):
|
||||
return str(left) == str(right)
|
||||
|
||||
|
||||
class FakeSor(object):
|
||||
"""内存版 sqlor。"""
|
||||
|
||||
def __init__(self, tables=None, base_fixtures=None):
|
||||
self.tables = {}
|
||||
for name, rows in (base_fixtures if base_fixtures is not None
|
||||
else BASE_FIXTURES).items():
|
||||
self.tables[name] = [dict(r) for r in rows]
|
||||
for name, rows in (tables or {}).items():
|
||||
self.tables[name] = [dict(r) for r in rows]
|
||||
self.tables.setdefault("pbl_domain_ref", [])
|
||||
self.sql_log = []
|
||||
def __init__(self, tables=None, base_fixtures=None, default_table=DEFAULT_TABLE):
|
||||
self.tables = dict(tables) if tables is not None else {DEFAULT_TABLE: []}
|
||||
self.tables.setdefault(DEFAULT_TABLE, [])
|
||||
self.base = {t: [dict(r) for r in (base_fixtures or BASE_FIXTURES)[t]]
|
||||
for t in BASE_TABLES}
|
||||
self.default_table = default_table
|
||||
self.base_write_attempts = []
|
||||
self.calls = []
|
||||
self.closed = False
|
||||
|
||||
# ---- sqlor 标准 API -------------------------------------------------
|
||||
def C(self, tbl, row):
|
||||
self._guard_readonly(tbl, "C")
|
||||
self.tables.setdefault(tbl, []).append(dict(row))
|
||||
self.sql_log.append(("C", tbl, dict(row)))
|
||||
# -------------------------------------------------------- 标准 API 子集
|
||||
|
||||
def _guard(self, table, op):
|
||||
self.calls.append((op, table))
|
||||
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
|
||||
|
||||
def U(self, tbl, ns, where=None):
|
||||
"""真实 sqlor 签名:``sor.U(table, ns)`` 两参,where 条件并入 ns。
|
||||
async def I(self, row):
|
||||
table = (row or {}).get("__table__") or self.default_table
|
||||
return await self.C(table, row)
|
||||
|
||||
与真实实现同构:主键 ``id`` 与租户列 ``tenant_id`` 从 ns 中提出作 WHERE,
|
||||
其余列作 SET。三参调用(旧 fake 签名)仍兼容,便于对照回归。
|
||||
"""
|
||||
ns = dict(ns or {})
|
||||
if where is None:
|
||||
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")
|
||||
async def U(self, table, spec):
|
||||
self._guard(table, "U")
|
||||
spec = spec or {}
|
||||
data = spec.get("data") or {}
|
||||
where = spec.get("where") or {}
|
||||
hits = 0
|
||||
for exist in self.tables.get(tbl, []):
|
||||
if self._match(exist, where):
|
||||
exist.update(ns)
|
||||
for row in self._rows(table):
|
||||
if _match(row, where):
|
||||
row.update(dict(data))
|
||||
hits += 1
|
||||
self.sql_log.append(("U", tbl, dict(ns), dict(where)))
|
||||
return hits
|
||||
|
||||
def D(self, tbl, where):
|
||||
self._guard_readonly(tbl, "D")
|
||||
keep, hits = [], 0
|
||||
for exist in self.tables.get(tbl, []):
|
||||
if self._match(exist, where):
|
||||
hits += 1
|
||||
else:
|
||||
keep.append(exist)
|
||||
self.tables[tbl] = keep
|
||||
self.sql_log.append(("D", tbl, dict(where)))
|
||||
return hits
|
||||
async def D(self, table, spec):
|
||||
self._guard(table, "D")
|
||||
where = (spec or {}).get("where") or {}
|
||||
rows = self._rows(table)
|
||||
keep = [r for r in rows if not _match(r, where)]
|
||||
removed = len(rows) - len(keep)
|
||||
self.tables[table] = keep
|
||||
return removed
|
||||
|
||||
def R(self, tbl, where=None, fields=None, order_by=None, limit=None):
|
||||
rows = [dict(r) for r in self.tables.get(tbl, [])
|
||||
if self._match(r, where or {})]
|
||||
if fields:
|
||||
rows = [{k: r.get(k) for k in fields} for r in rows]
|
||||
if limit:
|
||||
rows = rows[:int(limit)]
|
||||
return rows
|
||||
async def R(self, table, spec):
|
||||
where = (spec or {}).get("where") or {}
|
||||
return [dict(r) for r in self._rows(table) if _match(r, where)]
|
||||
|
||||
def I(self, tbl, rows):
|
||||
self._guard_readonly(tbl, "I")
|
||||
for row in rows or []:
|
||||
self.tables.setdefault(tbl, []).append(dict(row))
|
||||
return len(rows or [])
|
||||
async def sqlExe(self, sql, params=None):
|
||||
params = list(params or [])
|
||||
self.calls.append(("sqlExe", sql.split()[0] if sql else ""))
|
||||
text = _bind(sql, params)
|
||||
return self._run_select(text)
|
||||
|
||||
def sqlExe(self, sql, args=None):
|
||||
args = tuple(args or ())
|
||||
self.sql_log.append(("sqlExe", " ".join(sql.split()), args))
|
||||
text = self._interpolate(" ".join(sql.split()), args)
|
||||
upper = text.upper()
|
||||
if upper.startswith("SELECT COUNT(1)"):
|
||||
return [{"cnt": len(self._rows(text))}]
|
||||
if upper.startswith("SELECT"):
|
||||
return self._select(text)
|
||||
if upper.startswith("ALTER") or upper.startswith("CREATE"):
|
||||
return 0
|
||||
raise AssertionError("FakeSor.sqlExe 不支持的语句: %s" % text)
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
# ---- 内部 -----------------------------------------------------------
|
||||
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))
|
||||
# -------------------------------------------------------- 极简 SELECT 执行
|
||||
|
||||
@staticmethod
|
||||
def _interpolate(text, args):
|
||||
out, idx, buf = [], 0, []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if text[i] == "%" and text[i:i + 2] == "%s":
|
||||
buf.append(_literal(args[idx]) if idx < len(args) else "NULL")
|
||||
idx += 1
|
||||
i += 2
|
||||
continue
|
||||
buf.append(text[i])
|
||||
i += 1
|
||||
out.append("".join(buf))
|
||||
return "".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _match(row, where):
|
||||
for key, val in (where or {}).items():
|
||||
col = key.strip("`")
|
||||
if isinstance(val, (list, tuple, set)):
|
||||
if not any(_loose_eq(row.get(col), v) for v in val):
|
||||
return False
|
||||
elif not _loose_eq(row.get(col), val):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _table_of(text):
|
||||
m = re.search(r"FROM\s+`?(\w+)`?", text, re.IGNORECASE)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def _conds(self, text):
|
||||
m = re.search(r"WHERE\s+(.*?)(?:\s+ORDER\s+BY|\s+LIMIT|\s+GROUP\s+BY|\s*$)",
|
||||
text, re.IGNORECASE | re.DOTALL)
|
||||
def _run_select(self, text):
|
||||
text = text.strip().rstrip(";")
|
||||
m = re.match(
|
||||
r"(?is)^SELECT\s+(?P<sel>.*?)\s+FROM\s+`?(?P<tbl>[\w]+)`?"
|
||||
r"(?:\s+WHERE\s+(?P<where>.*?))?"
|
||||
r"(?:\s+ORDER\s+BY\s+(?P<order>[\w`.]+\s*(?:asc|desc)?))?"
|
||||
r"(?:\s+LIMIT\s+(?P<limit>\d+))?"
|
||||
r"(?:\s+OFFSET\s+(?P<offset>\d+))?\s*$", text)
|
||||
if not m:
|
||||
return []
|
||||
conds = []
|
||||
for part in re.split(r"\s+AND\s+", m.group(1), flags=re.IGNORECASE):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
in_m = re.match(r"`?(\w+)`?\s+IN\s*\((.*)\)\s*$", part, re.IGNORECASE)
|
||||
if in_m:
|
||||
vals = [_coerce(v) for v in self._split_top(in_m.group(2))]
|
||||
conds.append((in_m.group(1), vals))
|
||||
continue
|
||||
eq_m = re.match(r"`?(\w+)`?\s*=\s*(.+)$", part)
|
||||
if eq_m:
|
||||
conds.append((eq_m.group(1), _coerce(eq_m.group(2))))
|
||||
return conds
|
||||
raise AssertionError(f"FakeSor cannot parse SQL: {text}")
|
||||
table = m.group("tbl")
|
||||
rows = [dict(r) for r in self._rows(table)]
|
||||
where = (m.group("where") or "").strip()
|
||||
if where:
|
||||
rows = [r for r in rows if _match_expr(r, where)]
|
||||
|
||||
@staticmethod
|
||||
def _split_top(inner):
|
||||
parts, depth, cur, quote = [], 0, [], False
|
||||
for ch in inner:
|
||||
if ch == "'":
|
||||
quote = not quote
|
||||
if not quote:
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
elif ch == "," and depth == 0:
|
||||
parts.append("".join(cur))
|
||||
cur = []
|
||||
continue
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
parts.append("".join(cur))
|
||||
return [p for p in parts if p.strip() != ""]
|
||||
order = (m.group("order") or "").strip()
|
||||
if order:
|
||||
parts = order.split()
|
||||
col = parts[0].strip("`")
|
||||
desc = len(parts) > 1 and parts[1].lower() == "desc"
|
||||
rows.sort(key=lambda r: str(r.get(col, "")), reverse=desc)
|
||||
|
||||
def _rows(self, text):
|
||||
tbl = self._table_of(text)
|
||||
if not tbl:
|
||||
return []
|
||||
conds = self._conds(text)
|
||||
out = []
|
||||
for row in self.tables.get(tbl, []):
|
||||
hit = True
|
||||
for col, val in conds:
|
||||
if isinstance(val, list):
|
||||
if not any(_loose_eq(row.get(col), v) for v in val):
|
||||
hit = False
|
||||
break
|
||||
elif not _loose_eq(row.get(col), val):
|
||||
hit = False
|
||||
break
|
||||
if hit:
|
||||
out.append(dict(row))
|
||||
return out
|
||||
if m.group("offset"):
|
||||
rows = rows[int(m.group("offset")):]
|
||||
if m.group("limit"):
|
||||
rows = rows[:int(m.group("limit"))]
|
||||
|
||||
def _select(self, text):
|
||||
rows = self._rows(text)
|
||||
limit_m = re.search(r"LIMIT\s+(\d+)", text, re.IGNORECASE)
|
||||
offset_m = re.search(r"OFFSET\s+(\d+)", text, re.IGNORECASE)
|
||||
if offset_m:
|
||||
rows = rows[int(offset_m.group(1)):]
|
||||
if limit_m:
|
||||
rows = rows[:int(limit_m.group(1))]
|
||||
sel_m = re.match(r"SELECT\s+(.*?)\s+FROM\s", text, re.IGNORECASE | re.DOTALL)
|
||||
if sel_m and sel_m.group(1).strip() != "*":
|
||||
cols = [c.strip().strip("`").split(".")[-1]
|
||||
for c in self._split_top(sel_m.group(1))]
|
||||
sel = m.group("sel").strip()
|
||||
if re.match(r"(?i)^COUNT\(\s*1\s*\)", sel):
|
||||
return [{"cnt": len(rows)}]
|
||||
if sel != "*":
|
||||
cols = [c.strip().strip("`").split(".")[-1] for c in _split_top(sel)]
|
||||
rows = [{c: r.get(c) for c in cols} for r in 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):
|
||||
"""构造 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)
|
||||
|
||||
@ -5,31 +5,43 @@
|
||||
|
||||
cd modules/pbl_domain_ext
|
||||
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 函数 × 租户隔离 / 权限(只读基表) / 正常 / 异常 四类用例,
|
||||
外加薄扩展铁律(不写基表、表总账只有 pbl_domain_ref 一张新表、ext_json 字段名对齐设计 §J1)。
|
||||
覆盖:13 个 dspy 契约 + 1 个物化契约 × 租户隔离(fail-closed) / 基表只读 /
|
||||
正常 / 异常 四类用例;外加薄扩展铁律(不写基表、只有 1 张新表、列长度口径统一)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
for p in (ROOT, HERE):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
for _p in (ROOT, HERE):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import fake_db # noqa: E402
|
||||
from pbl_domain_ext import api, db, base # noqa: E402
|
||||
from pbl_domain_ext.base import EXT_FIELD, REF_TYPES, TABLE # noqa: E402
|
||||
from pbl_domain_ext import api, db, base, errors # 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)
|
||||
T2 = "T2" # 他租户(BASE_FIXTURES 中仅 world W3)
|
||||
T1 = "T1" # 主测租户(BASE_FIXTURES: world W1/W2, scene S1..S3, entity E1..E3)
|
||||
T2 = "T2" # 他租户(仅 world W3 / scene S9 / entity E9)
|
||||
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):
|
||||
"""每个用例独立内存库,互不污染。"""
|
||||
|
||||
@ -40,409 +52,543 @@ class BaseCase(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
db.set_sor(None)
|
||||
|
||||
# ---- 断言助手 -------------------------------------------------------
|
||||
def assertOk(self, resp, msg=""):
|
||||
self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体")
|
||||
self.assertTrue(resp.get("success"), "%s 期望成功,实得 %r" % (msg, resp))
|
||||
self.assertEqual(resp.get("code"), "PBL_DE_OK", msg)
|
||||
return resp.get("data")
|
||||
# 便捷断言
|
||||
def assert_ok(self, res, msg=""):
|
||||
self.assertTrue(res.get("ok"), f"expected ok, got {res} {msg}")
|
||||
|
||||
def assertErr(self, resp, codes, msg=""):
|
||||
self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体(不得裸抛栈)")
|
||||
self.assertFalse(resp.get("success"), "%s 期望失败,实得 %r" % (msg, resp))
|
||||
if isinstance(codes, str):
|
||||
codes = (codes,)
|
||||
self.assertIn(resp.get("code"), codes,
|
||||
"%s 错误码期望 %s,实得 %r" % (msg, list(codes), resp))
|
||||
self.assertIn("http_status", resp, "失败响应必须带 http_status")
|
||||
return resp
|
||||
def assert_fail(self, res, code=None, msg=""):
|
||||
self.assertFalse(res.get("ok"), f"expected failure, got {res} {msg}")
|
||||
if code:
|
||||
self.assertEqual(res.get("code"), code,
|
||||
f"wrong code: {res} ({msg})")
|
||||
|
||||
def bind(self, ref_type, ref_id, tenant_id=T1, **kw):
|
||||
params = {"tenant_id": tenant_id, "ref_type": ref_type,
|
||||
"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, []))
|
||||
def refs(self):
|
||||
return self.sor.tables[TABLE]
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 1. 关联表 pbl_domain_ref:绑定 / 解绑 / 更新 / 查询
|
||||
# ==========================================================================
|
||||
# ============================================================ 关联表契约(写)
|
||||
|
||||
class TestBind(BaseCase):
|
||||
|
||||
def test_bind_world_ok(self):
|
||||
data = self.assertOk(self.bind("world", "W1", blueprint_id="BP1",
|
||||
class_id="C1", team_id="TEAM_A",
|
||||
ext_json='{"role":"host"}'))
|
||||
self.assertEqual(data["ref_type"], "world")
|
||||
self.assertEqual(str(data["ref_id"]), "W1")
|
||||
self.assertEqual(data["blueprint_id"], "BP1")
|
||||
self.assertEqual(data["bind_state"], "bound")
|
||||
self.assertEqual(data["creator_id"], OP,
|
||||
"审计列名须与 models 权威名 creator_id 一致")
|
||||
self.assertEqual(len(self.rows()), 1, "应落 1 行关联记录")
|
||||
res = run(api.pbl_domain_ref_bind(
|
||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
|
||||
"blueprint_id": "BP1", "operator": OP}))
|
||||
self.assert_ok(res)
|
||||
self.assertEqual(res["data"]["action"], "created")
|
||||
ref = res["data"]["ref"]
|
||||
# ref_code / ref_name 自动从基表补全
|
||||
self.assertEqual(ref["ref_code"], "WLD-001")
|
||||
self.assertEqual(ref["ref_name"], "火星基地")
|
||||
self.assertEqual(ref["bind_state"], "bound")
|
||||
self.assertEqual(len(self.refs()), 1)
|
||||
|
||||
def test_bind_scene_and_entity_ok(self):
|
||||
self.assertOk(self.bind("scene", "S1"))
|
||||
self.assertOk(self.bind("entity", "E1"))
|
||||
self.assertEqual(sorted(r["ref_type"] for r in self.rows()),
|
||||
["entity", "scene"])
|
||||
def test_bind_idempotent_upsert(self):
|
||||
"""同 (tenant, ref_type, ref_id, blueprint) 重复绑定 -> 更新而非新增。"""
|
||||
p = {"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
|
||||
"blueprint_id": "BP1", "operator": OP}
|
||||
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):
|
||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
|
||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP2"))
|
||||
self.assertEqual(len(self.rows()), 1, "同键重复绑定不得产生重复行(幂等 upsert)")
|
||||
self.assertEqual(self.rows()[0]["blueprint_id"], "BP2")
|
||||
def test_bind_scene_and_entity(self):
|
||||
for rtype, rid in (("scene", "S1"), ("entity", "E1")):
|
||||
res = run(api.pbl_domain_ref_bind(
|
||||
{"tenant_id": T1, "ref_type": rtype, "ref_id": rid,
|
||||
"blueprint_id": "BP1", "operator": OP}))
|
||||
self.assert_ok(res, rtype)
|
||||
self.assertEqual(len(self.refs()), 2)
|
||||
|
||||
def test_bind_requires_tenant(self):
|
||||
self.assertErr(api.pbl_domain_ref_bind({"ref_type": "world", "ref_id": "W1"}),
|
||||
"PBL_DE_TENANT_MISSING", "缺 tenant_id")
|
||||
res = run(api.pbl_domain_ref_bind(
|
||||
{"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):
|
||||
self.assertErr(self.bind("course", "W1"), "PBL_DE_REF_TYPE_INVALID",
|
||||
"ref_type 仅允许 world/scene/entity")
|
||||
def test_bind_invalid_ref_type(self):
|
||||
res = run(api.pbl_domain_ref_bind(
|
||||
{"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):
|
||||
self.assertErr(self.bind("world", ""), "PBL_DE_PARAM_INVALID", "ref_id 为空")
|
||||
def test_bind_missing_ref_id(self):
|
||||
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):
|
||||
self.assertErr(self.bind("world", "W_NOT_EXIST"),
|
||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND"),
|
||||
"基表未命中的对象不得建立关联")
|
||||
def test_bind_cross_tenant_denied(self):
|
||||
"""T1 不能绑定 T2 的世界(基表租户可见性校验,fail-closed)。"""
|
||||
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):
|
||||
self.assertErr(self.bind("world", "W1", ext_json="{not json"),
|
||||
"PBL_DE_EXT_JSON_INVALID", "ext_json 必须合法 JSON")
|
||||
|
||||
def test_bind_tenant_isolation(self):
|
||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
||||
self.assertErr(self.bind("world", "W1", tenant_id=T2),
|
||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"),
|
||||
"W1 属 T1,T2 不得绑定")
|
||||
def test_bind_does_not_write_base_tables(self):
|
||||
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||
"ref_id": "W1", "operator": OP}))
|
||||
self.assertEqual(self.sor.base_write_attempts, [])
|
||||
writes = [c for c in self.sor.calls
|
||||
if c[1] in fake_db.BASE_TABLES and c[0] in ("C", "U", "D", "I")]
|
||||
self.assertEqual(writes, [])
|
||||
|
||||
|
||||
class TestUnbindUpdateGet(BaseCase):
|
||||
class TestUnbind(BaseCase):
|
||||
|
||||
def test_unbind_soft_delete(self):
|
||||
self.assertOk(self.bind("world", "W1"))
|
||||
data = self.assertOk(api.pbl_domain_ref_unbind(
|
||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "operator_id": OP}))
|
||||
self.assertIn(data.get("bind_state", "unbound"), ("unbound", "deleted"))
|
||||
alive = [r for r in self.rows() if str(r.get("is_deleted", "0")) in ("0", "")]
|
||||
self.assertEqual(alive, [], "解绑后不应再有生效关联行")
|
||||
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||
"ref_id": "W1", "operator": OP}))
|
||||
self.assert_ok(bound)
|
||||
rid = bound["data"]["ref"]["id"]
|
||||
res = run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid,
|
||||
"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):
|
||||
self.assertErr(api.pbl_domain_ref_unbind(
|
||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1"}),
|
||||
"PBL_DE_NOT_FOUND", "解绑不存在的关联")
|
||||
def test_unbind_idempotent(self):
|
||||
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene",
|
||||
"ref_id": "S1"}))
|
||||
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):
|
||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
||||
self.assertErr(api.pbl_domain_ref_unbind(
|
||||
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1"}),
|
||||
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得解绑他租户关联")
|
||||
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_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):
|
||||
self.assertErr(api.pbl_domain_ref_update(
|
||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "class_id": "C1"}),
|
||||
"PBL_DE_NOT_FOUND", "更新不存在的关联")
|
||||
class TestUpdate(BaseCase):
|
||||
|
||||
def test_update_ok(self):
|
||||
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):
|
||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
||||
self.assertErr(api.pbl_domain_ref_update(
|
||||
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1", "class_id": "X"}),
|
||||
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得改他租户关联")
|
||||
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": 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):
|
||||
bound = self.assertOk(self.bind("scene", "S1", blueprint_id="BP7"))
|
||||
by_id = self.assertOk(api.pbl_domain_ref_get({"tenant_id": T1, "id": bound["id"]}))
|
||||
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_update_requires_tenant(self):
|
||||
self.assert_fail(run(api.pbl_domain_ref_update({"id": "X"})),
|
||||
errors.TENANT_REQUIRED)
|
||||
|
||||
def test_get_missing(self):
|
||||
self.assertErr(api.pbl_domain_ref_get({"tenant_id": T1, "id": "NOPE"}),
|
||||
"PBL_DE_NOT_FOUND")
|
||||
|
||||
class TestGetAndList(BaseCase):
|
||||
|
||||
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):
|
||||
bound = self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
||||
self.assertErr(api.pbl_domain_ref_get({"tenant_id": T2, "id": bound["id"]}),
|
||||
("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"),
|
||||
"T2 不得读 T1 的关联记录")
|
||||
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||
"ref_id": "W1"}))
|
||||
self.assert_fail(run(api.pbl_domain_ref_get(
|
||||
{"tenant_id": T2, "id": bound["data"]["ref"]["id"]})),
|
||||
errors.REF_NOT_FOUND)
|
||||
|
||||
def test_list_filter_and_paging(self):
|
||||
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
|
||||
self.assertOk(self.bind("scene", "S1", class_id="C1", team_id="TEAM_A"))
|
||||
self.assertOk(self.bind("entity", "E1", class_id="C2", team_id="TEAM_B"))
|
||||
data = self.assertOk(api.pbl_domain_ref_list(
|
||||
{"tenant_id": T1, "class_id": "C1", "page": 1, "page_size": 10}))
|
||||
self.assertEqual(data["total"], 2)
|
||||
self.assertEqual(len(data["items"]), 2)
|
||||
data2 = self.assertOk(api.pbl_domain_ref_list(
|
||||
{"tenant_id": T1, "ref_type": "entity"}))
|
||||
self.assertEqual([i["ref_id"] for i in data2["items"]], ["E1"])
|
||||
data3 = self.assertOk(api.pbl_domain_ref_list(
|
||||
{"tenant_id": T1, "page": 1, "page_size": 2}))
|
||||
self.assertLessEqual(len(data3["items"]), 2, "page_size 必须生效")
|
||||
def test_list_pagination_and_filter(self):
|
||||
for rid in ("W1", "W2"):
|
||||
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||
"ref_id": rid, "blueprint_id": "BP1"}))
|
||||
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene",
|
||||
"ref_id": "S1", "blueprint_id": "BP1"}))
|
||||
res = run(api.pbl_domain_ref_list({"tenant_id": T1, "page": 1, "rows": 2}))
|
||||
self.assert_ok(res)
|
||||
self.assertEqual(res["data"]["total"], 3)
|
||||
self.assertEqual(len(res["data"]["rows"]), 2)
|
||||
filtered = run(api.pbl_domain_ref_list({"tenant_id": T1,
|
||||
"ref_type": "world"}))
|
||||
self.assertEqual(filtered["data"]["total"], 2)
|
||||
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):
|
||||
self.assertErr(api.pbl_domain_ref_list({"ref_type": "world"}),
|
||||
"PBL_DE_TENANT_MISSING")
|
||||
self.assert_fail(run(api.pbl_domain_ref_list({})),
|
||||
errors.TENANT_REQUIRED)
|
||||
|
||||
def test_list_tenant_isolation(self):
|
||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
||||
data = self.assertOk(api.pbl_domain_ref_list({"tenant_id": T2}))
|
||||
self.assertEqual(data["total"], 0, "T2 不得看到 T1 的关联记录")
|
||||
def test_list_isolated_per_tenant(self):
|
||||
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||
"ref_id": "W1"}))
|
||||
res = run(api.pbl_domain_ref_list({"tenant_id": T2}))
|
||||
self.assert_ok(res)
|
||||
self.assertEqual(res["data"]["total"], 0)
|
||||
|
||||
|
||||
class TestCheckAccess(BaseCase):
|
||||
|
||||
def test_check_access_bound_ok(self):
|
||||
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
|
||||
data = self.assertOk(api.pbl_domain_ref_check_access(
|
||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1",
|
||||
"class_id": "C1", "team_id": "TEAM_A"}))
|
||||
self.assertTrue(data.get("allowed") or data.get("access"),
|
||||
"已绑定且班级/团队匹配应放行:%r" % data)
|
||||
def test_check_access_by_id(self):
|
||||
bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world",
|
||||
"ref_id": "W1"}))
|
||||
rid = bound["data"]["ref"]["id"]
|
||||
self.assertTrue(run(api.pbl_domain_ref_check_access(
|
||||
{"tenant_id": T1, "id": rid}))["data"]["allowed"])
|
||||
self.assertFalse(run(api.pbl_domain_ref_check_access(
|
||||
{"tenant_id": T2, "id": rid}))["data"]["allowed"])
|
||||
|
||||
def test_check_access_other_team_denied(self):
|
||||
self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A"))
|
||||
resp = api.pbl_domain_ref_check_access(
|
||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "team_id": "TEAM_Z"})
|
||||
data = resp.get("data") or {}
|
||||
if resp.get("success"):
|
||||
self.assertFalse(data.get("allowed") and data.get("access", True),
|
||||
"他团队不得放行:%r" % data)
|
||||
else:
|
||||
self.assertIn(resp.get("code"), ("PBL_DE_ACCESS_DENIED", "PBL_DE_NOT_FOUND"))
|
||||
def test_check_access_by_object(self):
|
||||
run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene",
|
||||
"ref_id": "S2"}))
|
||||
self.assertTrue(run(api.pbl_domain_ref_check_access(
|
||||
{"tenant_id": T1, "ref_type": "scene", "ref_id": "S2"}))
|
||||
["data"]["allowed"])
|
||||
self.assertFalse(run(api.pbl_domain_ref_check_access(
|
||||
{"tenant_id": T1, "ref_type": "scene", "ref_id": "S3"}))
|
||||
["data"]["allowed"])
|
||||
|
||||
def test_check_access_cross_tenant_denied(self):
|
||||
self.assertOk(self.bind("world", "W1", tenant_id=T1))
|
||||
resp = api.pbl_domain_ref_check_access(
|
||||
{"tenant_id": T2, "ref_type": "world", "ref_id": "W1"})
|
||||
data = resp.get("data") or {}
|
||||
self.assertFalse(resp.get("success") and (data.get("allowed") or data.get("access")),
|
||||
"跨租户不得放行")
|
||||
|
||||
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")
|
||||
def test_check_access_fail_closed_without_params(self):
|
||||
res = run(api.pbl_domain_ref_check_access({"tenant_id": T1}))
|
||||
self.assert_ok(res)
|
||||
self.assertFalse(res["data"]["allowed"])
|
||||
self.assert_fail(run(api.pbl_domain_ref_check_access({})),
|
||||
errors.TENANT_REQUIRED)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 2. 基础域只读契约(world / scene / entity)
|
||||
# ==========================================================================
|
||||
class TestBaseDomainReadOnly(BaseCase):
|
||||
# ============================================================ 基表只读视图契约
|
||||
|
||||
class TestBaseViews(BaseCase):
|
||||
|
||||
def test_world_list_by_tenant(self):
|
||||
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1}))
|
||||
ids = sorted(str(i["id"]) for i in data["items"])
|
||||
self.assertEqual(ids, ["W1", "W2"], "只返回本租户世界")
|
||||
self.assertTrue(data.get("readonly"), "必须标注只读")
|
||||
res = run(api.pbl_world_list_by_tenant({"tenant_id": T1}))
|
||||
self.assert_ok(res)
|
||||
ids = sorted(r["id"] for r in res["data"]["rows"])
|
||||
self.assertEqual(ids, ["W1", "W2"])
|
||||
self.assertEqual(res["data"]["total"], 2)
|
||||
|
||||
def test_world_list_tenant_isolation(self):
|
||||
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T2}))
|
||||
self.assertEqual([str(i["id"]) for i in data["items"]], ["W3"])
|
||||
def test_world_list_cross_tenant_isolated(self):
|
||||
res = run(api.pbl_world_list_by_tenant({"tenant_id": T2}))
|
||||
self.assertEqual([r["id"] for r in res["data"]["rows"]], ["W3"])
|
||||
|
||||
def test_world_list_requires_tenant(self):
|
||||
self.assertErr(api.pbl_world_list_by_tenant({}), "PBL_DE_TENANT_MISSING")
|
||||
|
||||
def test_world_list_marks_bound(self):
|
||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP1"))
|
||||
data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1, "only_bound": 1}))
|
||||
self.assertEqual([str(i["id"]) for i in data["items"]], ["W1"])
|
||||
self.assertTrue(data["items"][0]["pbl_bound"])
|
||||
|
||||
def test_world_get_context(self):
|
||||
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_ok(self):
|
||||
res = run(api.pbl_world_get_context({"tenant_id": T1, "world_id": "W1"}))
|
||||
self.assert_ok(res)
|
||||
data = res["data"]
|
||||
self.assertEqual(data["world"]["name"], "火星基地")
|
||||
self.assertEqual(data["scene_count"], 2)
|
||||
self.assertEqual(data["entity_count"], 3)
|
||||
s1 = [s for s in data["scenes"] if s["id"] == "S1"][0]
|
||||
self.assertEqual(s1["entity_count"], 2)
|
||||
self.assertEqual(sorted(e["id"] for e in s1["entities"]), ["E1", "E2"])
|
||||
|
||||
def test_world_get_context_missing(self):
|
||||
self.assertErr(api.pbl_world_get_context({"tenant_id": T1, "world_id": "NOPE"}),
|
||||
("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING"))
|
||||
self.assert_fail(run(api.pbl_world_get_context(
|
||||
{"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):
|
||||
self.assertErr(api.pbl_world_get_context({"tenant_id": T2, "world_id": "W1"}),
|
||||
("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING", "PBL_DE_ACCESS_DENIED"),
|
||||
"W1 属 T1,T2 不得取其上下文")
|
||||
self.assert_fail(run(api.pbl_world_get_context(
|
||||
{"tenant_id": T1, "world_id": "W3"})),
|
||||
errors.BASE_OBJECT_NOT_FOUND)
|
||||
|
||||
def test_scene_list_by_world(self):
|
||||
data = self.assertOk(api.pbl_scene_list_by_world(
|
||||
{"tenant_id": T1, "world_id": "W1"}))
|
||||
self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["S1", "S2"])
|
||||
|
||||
def test_scene_list_requires_world_id(self):
|
||||
self.assertErr(api.pbl_scene_list_by_world({"tenant_id": T1}),
|
||||
"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"], [], "跨租户不得看到他租户场景")
|
||||
res = run(api.pbl_scene_list_by_world({"tenant_id": T1, "world_id": "W1"}))
|
||||
self.assert_ok(res)
|
||||
self.assertEqual(sorted(r["id"] for r in res["data"]["rows"]),
|
||||
["S1", "S2"])
|
||||
self.assert_fail(run(api.pbl_scene_list_by_world({"tenant_id": T1})),
|
||||
errors.PARAM_INVALID)
|
||||
|
||||
def test_entity_list_by_scene(self):
|
||||
data = self.assertOk(api.pbl_entity_list_by_scene(
|
||||
{"tenant_id": T1, "scene_id": "S1"}))
|
||||
self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["E1", "E2"])
|
||||
res = run(api.pbl_entity_list_by_scene({"tenant_id": T1, "scene_id": "S1"}))
|
||||
self.assert_ok(res)
|
||||
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):
|
||||
self.assertErr(api.pbl_entity_list_by_scene({"tenant_id": T1}),
|
||||
"PBL_DE_PARAM_INVALID")
|
||||
def test_base_views_require_tenant(self):
|
||||
for coro in (api.pbl_world_list_by_tenant({}),
|
||||
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):
|
||||
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)
|
||||
class TestTeamWorld(BaseCase):
|
||||
|
||||
def test_team_bind_world_idempotent(self):
|
||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
||||
self.assertEqual(len(self.rows()), 1, "重复绑定不得产生重复行")
|
||||
def test_team_bind_world_and_list(self):
|
||||
res = run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1",
|
||||
"class_id": "CL1", "world_id": "W1",
|
||||
"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):
|
||||
self.assertErr(api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1"}),
|
||||
"PBL_DE_PARAM_INVALID")
|
||||
def test_team_world_list_skips_invisible_base_object(self):
|
||||
"""关联存在但基表对象不可见(世界被删)→ 不呈现(fail-closed)。"""
|
||||
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):
|
||||
self.assertErr(api.pbl_team_bind_world(
|
||||
{"tenant_id": T1, "world_id": "NOPE", "team_id": "TEAM_A"}),
|
||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND"))
|
||||
def test_team_world_list_requires_team(self):
|
||||
self.assert_fail(run(api.pbl_team_world_list({"tenant_id": T1})),
|
||||
errors.PARAM_INVALID)
|
||||
self.assert_fail(run(api.pbl_team_world_list({"team_id": "TM1"})),
|
||||
errors.TENANT_REQUIRED)
|
||||
|
||||
def test_team_bind_world_cross_tenant(self):
|
||||
self.assertErr(api.pbl_team_bind_world(
|
||||
{"tenant_id": T2, "world_id": "W1", "team_id": "TEAM_A"}),
|
||||
("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"))
|
||||
def test_team_world_list_isolated_per_tenant(self):
|
||||
run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1",
|
||||
"world_id": "W1"}))
|
||||
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):
|
||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", "team_id": "TEAM_B"})
|
||||
data = self.assertOk(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TEAM_A"}))
|
||||
items = data["items"] if isinstance(data, dict) else data
|
||||
self.assertEqual(sorted(str(i.get("id") or i.get("world_id")) for i in items), ["W1"])
|
||||
def test_team_list_by_class_group_counts(self):
|
||||
for team, world in (("TM1", "W1"), ("TM1", "W2"), ("TM2", "W1")):
|
||||
run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": team,
|
||||
"class_id": "CL1", "world_id": world}))
|
||||
res = run(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "CL1"}))
|
||||
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):
|
||||
self.assertErr(api.pbl_team_world_list({"tenant_id": T1}), "PBL_DE_PARAM_INVALID")
|
||||
|
||||
def test_team_world_list_cross_tenant_empty(self):
|
||||
api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"})
|
||||
data = self.assertOk(api.pbl_team_world_list({"tenant_id": T2, "team_id": "TEAM_A"}))
|
||||
items = data["items"] if isinstance(data, dict) else data
|
||||
self.assertEqual(list(items), [], "跨租户不得返回他租户团队世界")
|
||||
|
||||
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")
|
||||
def test_unbound_team_world_not_counted(self):
|
||||
run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1",
|
||||
"class_id": "CL1", "world_id": "W1"}))
|
||||
bound = run(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TM1"}))
|
||||
rid = bound["data"]["rows"][0]["id"]
|
||||
run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid}))
|
||||
res = run(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "CL1"}))
|
||||
self.assertEqual(res["data"]["rows"], [])
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 4. 薄扩展铁律 / 契约齐备 / 设计对齐
|
||||
# ==========================================================================
|
||||
# ============================================================ 物化契约(pbl_compiler)
|
||||
|
||||
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):
|
||||
|
||||
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):
|
||||
self.assertOk(self.bind("world", "W1"))
|
||||
new_tables = set(self.sor.tables) - set(fake_db.BASE_FIXTURES)
|
||||
self.assertEqual(new_tables, {TABLE},
|
||||
"M8 只允许新增 1 张关联表 pbl_domain_ref,不得新增 world/scene/entity 三张 PBL 表")
|
||||
"""本模块只新增 1 张关联表,不为 world/scene/entity 各建一张。"""
|
||||
self.assertEqual(TABLE, "pbl_domain_ref")
|
||||
self.assertEqual(len(base.BASE_TABLES), 3)
|
||||
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):
|
||||
self.assertEqual(EXT_FIELD, "ext_json",
|
||||
"扩展列名必须为设计 §J1 权威名 ext_json(非 ext)")
|
||||
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_match_design(self):
|
||||
self.assertEqual(REF_TYPES, ("world", "scene", "entity"))
|
||||
self.assertEqual(sorted(REF_TYPES), sorted(base.BASE_TABLES))
|
||||
|
||||
def test_ref_types_cover_three_domains(self):
|
||||
self.assertEqual(tuple(REF_TYPES), ("world", "scene", "entity"))
|
||||
def test_ext_json_is_authoritative_column(self):
|
||||
"""设计 §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):
|
||||
from pbl_domain_ext import init as mod_init
|
||||
names = [n for n in mod_init.CONTRACT_FUNCS if n != "pbl_domain_materialize_game_definition"]
|
||||
self.assertEqual(len(names), 13, "契约函数应为 13 个")
|
||||
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_clip_respects_field_maxlen(self):
|
||||
self.assertEqual(base.clip("blueprint_id", "x" * 99), "x" * 32)
|
||||
self.assertEqual(base.clip("ref_name", "y" * 999), "y" * 128)
|
||||
self.assertEqual(base.clip("remark", "z" * 999), "z" * 255)
|
||||
|
||||
def test_dspy_files_match_contracts(self):
|
||||
from pbl_domain_ext import init as mod_init
|
||||
api_dir = os.path.join(ROOT, "wwwroot", "api")
|
||||
for name, _action, _w in mod_init.API_ROUTES:
|
||||
path = os.path.join(api_dir, name + ".dspy")
|
||||
self.assertTrue(os.path.isfile(path), "缺少 dspy 契约文件 %s" % path)
|
||||
body = open(path, encoding="utf-8").read()
|
||||
self.assertIn(name + "(", body, "%s.dspy 必须调用同名契约函数" % name)
|
||||
self.assertNotIn("import ", body, "dspy 禁止 import(全局已预载)")
|
||||
self.assertIn("return", body, "dspy 必须显式 return")
|
||||
def test_db_layer_rejects_base_table_writes(self):
|
||||
"""db 层 + FakeSor 双保险:任何指向基表的写操作必须抛错并被记录。"""
|
||||
with self.assertRaises(AssertionError):
|
||||
db.assert_writable("world")
|
||||
for table in ("world", "scene", "entity"):
|
||||
with self.assertRaises(AssertionError):
|
||||
run(self.sor.C(table, {"id": "X", "tenant_id": T1}))
|
||||
with self.assertRaises(AssertionError):
|
||||
run(self.sor.U(table, {"data": {"name": "x"},
|
||||
"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):
|
||||
for name in ("pbl_domain_ref_bind", "pbl_world_list_by_tenant",
|
||||
"pbl_team_bind_world", "pbl_scene_list_by_world"):
|
||||
resp = api.dispatch(name, {"tenant_id": T1, "ref_type": "world",
|
||||
"ref_id": "W1", "world_id": "W1",
|
||||
"scene_id": "S1", "team_id": "TEAM_A"})
|
||||
self.assertIsInstance(resp, dict, "dispatch(%s) 必须返回 dict" % name)
|
||||
def test_read_base_whitelist(self):
|
||||
rows = run(db.read_base("world", {"tenant_id": T1}))
|
||||
self.assertEqual(sorted(r["id"] for r in rows), ["W1", "W2"])
|
||||
with self.assertRaises(ValueError):
|
||||
run(db.read_base("no_such_table", {}))
|
||||
with self.assertRaises(ValueError):
|
||||
run(db.read_base("pbl_domain_ref", {}))
|
||||
|
||||
def test_materialize_game_definition_exported(self):
|
||||
"""历史 QC 硬门禁:pbl_compiler 曾引用该符号,必须真实存在且可调用。"""
|
||||
import pbl_domain_ext as pkg
|
||||
self.assertTrue(callable(getattr(pkg, "pbl_domain_materialize_game_definition", None)))
|
||||
self.assertTrue(callable(getattr(api, "pbl_domain_materialize_game_definition", None)))
|
||||
resp = api.pbl_domain_materialize_game_definition({"tenant_id": T1, "world_id": "W1"})
|
||||
self.assertIsInstance(resp, dict)
|
||||
def test_no_hardcoded_dbname(self):
|
||||
"""db.py 不得出现 DBNAME 常量(库名由宿主 get_module_dbname 决定)。"""
|
||||
with open(os.path.join(ROOT, "pbl_domain_ext", "db.py"),
|
||||
encoding="utf-8") as fp:
|
||||
src = fp.read()
|
||||
self.assertNotIn("DBNAME =", src)
|
||||
self.assertIn("get_module_dbname", src)
|
||||
for fake in ("sor.save", "sor.list", "sor.insert", "sor.query"):
|
||||
self.assertNotIn(fake + "(", src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -234,6 +234,46 @@ class TestCrudJsonContract(unittest.TestCase):
|
||||
self.assertIn(f["name"], edit,
|
||||
"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):
|
||||
"""new_data_url 不得带 query 参数(避免与 editexclouded 合并成 list)。"""
|
||||
url = self.params["new_data_url"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user