deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-19 00:05:32 +08:00
parent c67b0a9e29
commit 09f78980ee
13 changed files with 2017 additions and 2277 deletions

View File

@ -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": []
}

View File

@ -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"]
}
}

View File

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

View File

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

View File

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

View File

@ -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` 传入内存假 sortests/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
#: 测试注入的 sorNone 表示走真实 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) 第二参恒需sqlormissing 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+1values 去空去重,上限 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

View File

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

View File

@ -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')`` 决定本模块不硬编码
铁律本模块**不创建/不修改** worldsceneentity 三张基础域基表
铁律本模块**不创建 / 不修改** worldsceneentity 三张基础域基表
唯一新增表是关联表 ``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-specTriple-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]}

View File

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

View File

@ -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("铁律违规:禁止写基础域基表 %sop=%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):
"""构造 FakeSorwith_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)

View File

@ -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 属 T1T2 不得绑定")
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 属 T1T2 不得取其上下文")
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"], [], "全部契约必须注册到 ServerEnvdspy 直接按名调用)")
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__":

View File

@ -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#23base.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#23clip() 按列宽裁剪——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"]