From 09f78980eefb8c1c46a30da8a4f2633e56e4cf4e Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Sat, 19 Sep 2026 00:05:32 +0800 Subject: [PATCH] =?UTF-8?q?deliver:=20=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E=E4=BB=A3=E4=B8=BA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- init/data.json | 43 +- json/pbl_domain_ref.json | 111 ++-- models/pbl_domain_ref.json | 373 +++++------ pbl_domain_ext/__init__.py | 34 +- pbl_domain_ext/api.py | 1134 ++++++++++++++------------------- pbl_domain_ext/base.py | 281 +++----- pbl_domain_ext/db.py | 591 +++++++---------- pbl_domain_ext/errors.py | 107 +--- pbl_domain_ext/init.py | 162 ++--- sql/pbl_domain_ext.sql | 142 +++-- tests/fake_db.py | 454 ++++++------- tests/test_domain_ref.py | 822 ++++++++++++++---------- tests/test_models_contract.py | 40 ++ 13 files changed, 2017 insertions(+), 2277 deletions(-) diff --git a/init/data.json b/init/data.json index c3f717b..67ee659 100644 --- a/init/data.json +++ b/init/data.json @@ -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": [] } diff --git a/json/pbl_domain_ref.json b/json/pbl_domain_ref.json index 808ac3f..1b4a1fc 100644 --- a/json/pbl_domain_ref.json +++ b/json/pbl_domain_ref.json @@ -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"] + } } diff --git a/models/pbl_domain_ref.json b/models/pbl_domain_ref.json index 90fb171..9ad7960 100644 --- a/models/pbl_domain_ref.json +++ b/models/pbl_domain_ref.json @@ -1,198 +1,177 @@ { - "summary": [ - { - "name": "pbl_domain_ref", - "title": "PBL基础域薄扩展关联表(world/scene/entity)", - "primary": [ - "id" - ], - "catelog": "relation" - } - ], - "fields": [ - { - "name": "id", - "title": "主键", - "type": "str", - "length": 32, - "nullable": "no" - }, - { - "name": "tenant_id", - "title": "租户ID(打头)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "ref_type", - "title": "关联对象类型(world/scene/entity)", - "type": "str", - "length": 32, - "nullable": "no" - }, - { - "name": "ref_id", - "title": "基表记录ID(world.id/scene.id/entity.id,基表主键为str32)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "ref_code", - "title": "基表记录编码快照(只读冗余,不回写基表)", - "type": "str", - "length": 64, - "nullable": "no", - "default": "" - }, - { - "name": "ref_name", - "title": "基表记录名称快照(只读冗余,不回写基表)", - "type": "str", - "length": 128, - "nullable": "no", - "default": "" - }, - { - "name": "blueprint_id", - "title": "关联蓝图ID(pbl_blueprint主键,str32)", - "type": "str", - "length": 32, - "nullable": "no", - "default": "" - }, - { - "name": "class_id", - "title": "班级ID", - "type": "str", - "length": 64, - "nullable": "no", - "default": "" - }, - { - "name": "team_id", - "title": "团队ID", - "type": "str", - "length": 64, - "nullable": "no", - "default": "" - }, - { - "name": "bind_state", - "title": "绑定状态(bound/unbound)", - "type": "str", - "length": 16, - "nullable": "no", - "default": "bound" - }, - { - "name": "bind_at", - "title": "绑定时间", - "type": "timestamp" - }, - { - "name": "ext_json", - "title": "扩展JSON(租户/班级/团队关联属性,设计§J1权威名)", - "type": "text" - }, - { - "name": "is_deleted", - "title": "逻辑删除标记(0正常/1已解绑)", - "type": "short", - "nullable": "no", - "default": "0" - }, - { - "name": "creator_id", - "title": "创建人", - "type": "str", - "length": 64 - }, - { - "name": "created_at", - "title": "创建时间", - "type": "timestamp", - "nullable": "no" - }, - { - "name": "updater_id", - "title": "更新人", - "type": "str", - "length": 64 - }, - { - "name": "updated_at", - "title": "更新时间", - "type": "timestamp" - } - ], - "indexes": [ - { - "name": "uk_pbl_domain_ref", - "idxtype": "unique", - "idxfields": [ - "tenant_id", - "ref_type", - "ref_id" - ] - }, - { - "name": "idx_pbl_domain_ref_tenant_type", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "ref_type" - ] - }, - { - "name": "idx_pbl_domain_ref_blueprint", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "blueprint_id" - ] - }, - { - "name": "idx_pbl_domain_ref_class", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "class_id" - ] - }, - { - "name": "idx_pbl_domain_ref_team", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "team_id" - ] - }, - { - "name": "idx_pbl_domain_ref_state", - "idxtype": "index", - "idxfields": [ - "tenant_id", - "bind_state", - "is_deleted" - ] - } - ], - "codes": [ - { - "field": "ref_type", - "table": "appcodes_kv", - "valuefield": "k", - "textfield": "v", - "cond": "parentid='pbl_domain_ref_type'" - }, - { - "field": "bind_state", - "table": "appcodes_kv", - "valuefield": "k", - "textfield": "v", - "cond": "parentid='pbl_bind_state'" - } - ] -} \ No newline at end of file + "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'" + } + ] +} diff --git a/pbl_domain_ext/__init__.py b/pbl_domain_ext/__init__.py index 68df474..4597dfa 100644 --- a/pbl_domain_ext/__init__.py +++ b/pbl_domain_ext/__init__.py @@ -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__", ] diff --git a/pbl_domain_ext/api.py b/pbl_domain_ext/api.py index 1687057..36d03c9 100644 --- a/pbl_domain_ext/api.py +++ b/pbl_domain_ext/api.py @@ -1,676 +1,522 @@ # -*- coding: utf-8 -*- -"""pbl_domain_ext 契约接口层([M8] 基础域薄扩展 world/scene/entity)。 +"""pbl_domain_ext 业务函数层(M8:world / scene / entity 基础域薄扩展)。 -对外 13 个契约函数,与 ``wwwroot/api/*.dspy`` 一一对应: +对外共 13 个契约函数(与 wwwroot/api/*.dspy 一一对应)+ 1 个兼容导出: - 关联表 CRUD/查询(6) - 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 - 基础域只读视图(4) - pbl_world_list_by_tenant / pbl_world_get_context - pbl_scene_list_by_world / pbl_entity_list_by_scene - 团队-世界绑定(3) - pbl_team_bind_world / pbl_team_world_list / pbl_team_list_by_class + 关联表契约(写,仅写 pbl_domain_ref 一张表) + 1. pbl_domain_ref_bind 绑定基础域对象(幂等 upsert) + 2. pbl_domain_ref_unbind 解绑(逻辑删除,不物理删) + 3. pbl_domain_ref_update 更新关联属性(租户并入 where) + 4. pbl_domain_ref_get 取单条关联 + 5. pbl_domain_ref_list 分页列表(租户强制) + 6. pbl_domain_ref_check_access 关联对象访问权判定(fail-closed) - 编译期物化(1,供 pbl_compiler import 闭包) - pbl_domain_materialize_game_definition + 基础域只读视图契约(读,零写入) + 7. pbl_world_list_by_tenant 租户下世界列表 + 8. pbl_world_get_context 世界上下文(世界 + 场景 + 实体计数) + 9. pbl_scene_list_by_world 世界下场景列表 + 10. pbl_entity_list_by_scene 场景下实体列表 + 11. pbl_team_world_list 团队可用世界(关联表 ∩ 基表) + 12. pbl_team_bind_world 团队绑定世界(写关联表) + 13. pbl_team_list_by_class 班级下团队的世界清单(分组聚合) -铁律: - * 所有函数第一个业务参数是 ``tenant_id``,缺失/非法即 ``PBL_DE_TENANT_MISSING``; - * 只写 ``pbl_domain_ref`` 一张表,world/scene/entity 基表**只读**; - * 扩展字段名统一 ``ext_json``(设计 data-model.md §J1),禁止旧名 ``ext``; - * 返回统一 ``{"success": True, "code": "PBL_DE_OK", "data": ...}``,异常转错误体不裸抛。 + 兼容导出:pbl_domain_materialize_game_definition —— 把租户的关联记录按 + ref_type 物化为 Game Definition 片段,供 pbl_compiler import(import 闭包)。 + +铁律 +---- +* 所有函数 ``tenant_id`` 强制打头,缺失即 fail-closed(返回错误码,不返回数据)。 +* 列长度截断统一走 :func:`pbl_domain_ext.base.clip`(FIELD_MAXLEN 唯一权威), + 禁止在本文件出现 ``[:128]`` 之类的手写统一截断。 +* 基表 world/scene/entity 只读;本文件只调用 db.read_base* 与 db 的关联表写函数。 """ -from . import db -from .base import (TABLE, BASE_TABLES, REF_TYPES, BIND_STATES, EXT_FIELD, - LIST_FIELDS, gen_id, now_str, require_tenant, check_ref_type, - check_bind_state, normalize_ext_json, audit_on_create, - audit_on_update, page_args, as_text, as_list) -from .errors import PblDomainExtError, err_payload +from .base import ( + BIND_STATES, FIELD_MAXLEN, LIST_FIELDS, REF_TYPES, STATE_BOUND, TABLE, + as_text, clip, fail, ok, table_for_base, valid_ref_type, +) +from . import db, errors __all__ = [ - "TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES", "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_team_world_list", "pbl_team_bind_world", "pbl_team_list_by_class", "pbl_domain_materialize_game_definition", - "ok", "fail", "dispatch", ] -#: 基表只读视图对外暴露的列(基表列名差异大,取交集常见列,缺失自动跳过) -_BASE_VIEW_FIELDS = ("id", "tenant_id", "code", "name", "title", "status", - "state", "world_id", "scene_id", "parent_id", - "created_at", "updated_at") +_UPDATE_FIELDS = ("ref_code", "ref_name", "blueprint_id", "team_id", "class_id", + "bind_state", "remark") -# -------------------------------------------------------------------------- -# 统一响应 -# -------------------------------------------------------------------------- -def ok(data=None, **extra): - """成功响应体。""" - payload = {"success": True, "code": "PBL_DE_OK", "message": "成功"} - if data is not None: - payload["data"] = data - payload.update(extra) - return payload +def _require_tenant(tenant_id): + return None if as_text(tenant_id).strip() else fail(errors.TENANT_REQUIRED) -def fail(code, message=None, detail=None): - """失败响应体(不抛异常)。""" - return err_payload(code, message=message, detail=detail) +def _page(params: dict): + try: + page = max(1, int(as_text(params.get("page")) or 1)) + except (TypeError, ValueError): + page = 1 + try: + rows = int(as_text(params.get("rows")) or 20) + except (TypeError, ValueError): + rows = 20 + rows = min(max(rows, 1), 200) + return page, rows -def _base_view(row): - """把基表原始行裁剪成只读视图 dict(只保留白名单列,不改基表)。""" +def _shape(row: dict) -> dict: + """按 LIST_FIELDS 输出稳定结构(时间列转字符串,避免序列化差异)。""" out = {} - if not isinstance(row, dict): - return out - for key in _BASE_VIEW_FIELDS: - if key in row and row[key] is not None: - out[key] = row[key] - if "id" not in out: - for alt in ("world_id", "scene_id", "entity_id"): - if row.get(alt): - out["id"] = row[alt] - break - return out - - -def _ref_out(row): - """关联表行 -> 对外 dict(保证 ext_json 键名存在且为文本)。""" - out = {} - if not isinstance(row, dict): - return out for key in LIST_FIELDS: - if key in row: - out[key] = row[key] - if EXT_FIELD in out and out[EXT_FIELD] is None: - out[EXT_FIELD] = "" - out["is_deleted"] = int(row.get("is_deleted") or 0) + value = (row or {}).get(key, "") + out[key] = "" if value is None else str(value) return out -def _operator(params): - """取操作人(creator_id/updater_id 审计用)。""" - if not isinstance(params, dict): - return "" - for key in ("operator_id", "user_id", "creator_id", "updater_id", "uid"): - val = as_text(params.get(key)).strip() - if val: - return val - return "" +# ============================================================ 关联表契约(写) + +async def pbl_domain_ref_bind(params: dict): + """绑定基础域对象到 PBL 侧(幂等 upsert)。 + + 入参:tenant_id(必), ref_type(world|scene|entity)(必), ref_id(必), + ref_code, ref_name, blueprint_id, team_id, class_id, operator。 + 已存在同 (tenant, ref_type, ref_id[, blueprint]) 的未删除记录 → 更新并置 bound。 + """ + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + + ref_type = clip("ref_type", params.get("ref_type")) + if not valid_ref_type(ref_type): + return fail(errors.REF_TYPE_INVALID, + f"ref_type {ref_type!r} not in {list(REF_TYPES)}") + + ref_id = clip("ref_id", params.get("ref_id")) + if not ref_id: + return fail(errors.PARAM_INVALID, "ref_id is required") + + blueprint_id = clip("blueprint_id", params.get("blueprint_id")) + + # 基表存在性 + 租户可见性校验(只读) + base_rows = await db.read_base(table_for_base(ref_type), + {"id": ref_id, "tenant_id": tenant_id}, limit=1) + if not base_rows: + return fail(errors.BASE_OBJECT_NOT_FOUND, + f"{ref_type} {ref_id} not visible to tenant {tenant_id}") + base = base_rows[0] + + payload = { + "tenant_id": tenant_id, + "ref_type": ref_type, + "ref_id": ref_id, + "ref_code": clip("ref_code", params.get("ref_code") or base.get("code")), + "ref_name": clip("ref_name", params.get("ref_name") or base.get("name")), + "blueprint_id": blueprint_id, + "team_id": clip("team_id", params.get("team_id")), + "class_id": clip("class_id", params.get("class_id")), + "bind_state": STATE_BOUND, + "remark": clip("remark", params.get("remark")), + } + + existing = await db.find_ref(ref_type, tenant_id, ref_id, blueprint_id, + payload["team_id"], payload["class_id"]) + operator = clip("updated_by", params.get("operator")) + if existing: + changes = dict(payload) + changes.pop("tenant_id", None) + changes.pop("ref_type", None) + changes.pop("ref_id", None) + changes["is_deleted"] = "0" + if operator: + changes["updated_by"] = operator + await db.update_ref(clip("id", existing.get("id")), tenant_id, changes) + merged = dict(existing) + merged.update(changes) + return ok({"action": "updated", "ref": _shape(merged)}) + + new_id = clip("id", params.get("id") or _gen_id()) + payload["id"] = new_id + payload["created_by"] = operator + payload["updated_by"] = operator + await db.insert_ref(payload) + return ok({"action": "created", "ref": _shape(payload)}) -# -------------------------------------------------------------------------- -# 1. 关联表:绑定 / 解绑 / 更新 / 查询 -# -------------------------------------------------------------------------- -def pbl_domain_ref_bind(params): - """绑定基础域对象到 PBL 蓝图/团队/班级(幂等 upsert)。 +async def pbl_domain_ref_unbind(params: dict): + """解绑(逻辑删除):bind_state=unbound + is_deleted=1,幂等。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + ref_id = clip("id", params.get("id")) + if not ref_id: + return fail(errors.PARAM_INVALID, "id is required") - 入参:tenant_id*, ref_type*(world|scene|entity), ref_id*, - ref_code, ref_name, blueprint_id, team_id, class_id, ext_json, operator_id - 出参:data = 关联记录 dict(含 id / bind_state=bound) + existing = await db.find_ref_by_id(ref_id, tenant_id) + if not existing: + return fail(errors.REF_NOT_FOUND, f"ref {ref_id} not found in tenant") + if as_text(existing.get("is_deleted")) == "1": + return ok({"action": "noop", "id": ref_id}) + + operator = clip("updated_by", params.get("operator")) + await db.soft_delete_ref(ref_id, tenant_id, operator) + return ok({"action": "unbound", "id": ref_id}) + + +async def pbl_domain_ref_update(params: dict): + """更新关联属性。只接受白名单列,逐列按 FIELD_MAXLEN 截断(口径统一)。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + ref_id = clip("id", params.get("id")) + if not ref_id: + return fail(errors.PARAM_INVALID, "id is required") + + existing = await db.find_ref_by_id(ref_id, tenant_id) + if not existing: + return fail(errors.REF_NOT_FOUND, f"ref {ref_id} not found in tenant") + + changes = {} + for key in _UPDATE_FIELDS: + if key in params: + changes[key] = clip(key, params.get(key)) + if "bind_state" in changes and changes["bind_state"] not in BIND_STATES: + return fail(errors.PARAM_INVALID, + f"bind_state must be one of {list(BIND_STATES)}") + if not changes: + return ok({"action": "noop", "id": ref_id, "ref": _shape(existing)}) + + operator = clip("updated_by", params.get("operator")) + if operator: + changes["updated_by"] = operator + await db.update_ref(ref_id, tenant_id, changes) + merged = dict(existing) + merged.update(changes) + return ok({"action": "updated", "id": ref_id, "ref": _shape(merged)}) + + +async def pbl_domain_ref_get(params: dict): + """取单条关联记录(租户 + id)。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + ref_id = clip("id", params.get("id")) + if not ref_id: + return fail(errors.PARAM_INVALID, "id is required") + row = await db.find_ref_by_id(ref_id, tenant_id) + if not row: + return fail(errors.REF_NOT_FOUND, f"ref {ref_id} not found in tenant") + return ok(_shape(row)) + + +async def pbl_domain_ref_list(params: dict): + """分页列表:租户强制,可按 ref_type / ref_id / blueprint_id / team_id 过滤。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + where = {"tenant_id": tenant_id} + for key in ("ref_type", "ref_id", "blueprint_id", "team_id", "class_id", + "bind_state", "ref_code"): + if params.get(key) not in (None, ""): + where[key] = clip(key, params.get(key)) + if "ref_type" in where and not valid_ref_type(where["ref_type"]): + return fail(errors.REF_TYPE_INVALID, + f"ref_type {where['ref_type']!r} not in {list(REF_TYPES)}") + + page, rows = _page(params) + total = await db.count_refs(where) + data = await db.select_refs(where, order_by="created_at asc", + limit=rows, offset=(page - 1) * rows) + return ok({"rows": [_shape(r) for r in data], "total": total, + "page": page, "rows_per_page": rows}) + + +async def pbl_domain_ref_check_access(params: dict): + """关联对象访问权判定(fail-closed):无租户/无记录/跨租户一律 False。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + ref_id = clip("id", params.get("id")) + ref_type = clip("ref_type", params.get("ref_type")) + target = clip("ref_id", params.get("ref_id")) + + if ref_id: + row = await db.find_ref_by_id(ref_id, tenant_id) + return ok({"allowed": bool(row), "id": ref_id}) + if not valid_ref_type(ref_type) or not target: + return ok({"allowed": False, + "message": "ref_type + ref_id required"}) + rows = await db.select_refs({"tenant_id": tenant_id, "ref_type": ref_type, + "ref_id": target, "bind_state": STATE_BOUND}, + limit=1) + return ok({"allowed": bool(rows), "ref_type": ref_type, "ref_id": target}) + + +# ============================================================ 基表只读视图契约 + +async def pbl_world_list_by_tenant(params: dict): + """租户下世界列表(只读 world 基表)。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + page, rows = _page(params) + data = await db.read_base("world", {"tenant_id": tenant_id}, + limit=rows * 5) + total = len(data) + window = data[(page - 1) * rows:(page - 1) * rows + rows] + return ok({"rows": [_base_shape("world", r) for r in window], + "total": total, "page": page, "rows_per_page": rows}) + + +async def pbl_world_get_context(params: dict): + """世界上下文:世界 + 其场景 + 每场景实体计数(全部只读)。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + world_id = clip("ref_id", params.get("world_id")) + if not world_id: + return fail(errors.PARAM_INVALID, "world_id is required") + + worlds = await db.read_base("world", {"id": world_id, "tenant_id": tenant_id}, + limit=1) + if not worlds: + return fail(errors.BASE_OBJECT_NOT_FOUND, + f"world {world_id} not visible to tenant {tenant_id}") + + scenes = await db.read_base("scene", {"world_id": world_id, + "tenant_id": tenant_id}) + scene_ids = [as_text(s.get("id")) for s in scenes if s.get("id")] + entities = await db.read_base_many("entity", "scene_id", scene_ids) \ + if scene_ids else [] + grouped = {} + for ent in entities: + grouped.setdefault(as_text(ent.get("scene_id")), []).append( + _base_shape("entity", ent)) + scene_rows = [] + for scene in scenes: + sid = as_text(scene.get("id")) + item = _base_shape("scene", scene) + item["entities"] = grouped.get(sid, []) + item["entity_count"] = len(item["entities"]) + scene_rows.append(item) + return ok({"world": _base_shape("world", worlds[0]), + "scenes": scene_rows, + "scene_count": len(scene_rows), + "entity_count": sum(r["entity_count"] for r in scene_rows)}) + + +async def pbl_scene_list_by_world(params: dict): + """世界下场景列表(只读 scene 基表,租户条件必带)。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + world_id = clip("ref_id", params.get("world_id")) + if not world_id: + return fail(errors.PARAM_INVALID, "world_id is required") + page, rows = _page(params) + data = await db.read_base("scene", {"world_id": world_id, + "tenant_id": tenant_id}) + total = len(data) + window = data[(page - 1) * rows:(page - 1) * rows + rows] + return ok({"rows": [_base_shape("scene", r) for r in window], + "total": total, "page": page, "rows_per_page": rows}) + + +async def pbl_entity_list_by_scene(params: dict): + """场景下实体列表(只读 entity 基表,租户条件必带)。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + scene_id = clip("ref_id", params.get("scene_id")) + if not scene_id: + return fail(errors.PARAM_INVALID, "scene_id is required") + page, rows = _page(params) + data = await db.read_base("entity", {"scene_id": scene_id, + "tenant_id": tenant_id}) + total = len(data) + window = data[(page - 1) * rows:(page - 1) * rows + rows] + return ok({"rows": [_base_shape("entity", r) for r in window], + "total": total, "page": page, "rows_per_page": rows}) + + +# ============================================================ 团队 × 世界契约 + +async def pbl_team_world_list(params: dict): + """团队已绑定世界清单:关联表取 ref_id 集合,再回基表补展示字段。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + team_id = clip("team_id", params.get("team_id")) + if not team_id: + return fail(errors.PARAM_INVALID, "team_id is required") + + refs = await db.select_refs({"tenant_id": tenant_id, "ref_type": "world", + "team_id": team_id, "bind_state": STATE_BOUND}) + world_ids = [as_text(r.get("ref_id")) for r in refs if r.get("ref_id")] + worlds = await db.read_base_many("world", "id", world_ids) \ + if world_ids else [] + by_id = {as_text(w.get("id")): w for w in worlds} + rows = [] + for ref in refs: + wid = as_text(ref.get("ref_id")) + world = by_id.get(wid) + if not world or as_text(world.get("tenant_id")) != tenant_id: + # 基表已不可见(世界被删/转移)→ 关联不呈现,fail-closed + continue + item = _shape(ref) + item["world"] = _base_shape("world", world) + rows.append(item) + return ok({"rows": rows, "total": len(rows), "team_id": team_id}) + + +async def pbl_team_bind_world(params: dict): + """团队绑定世界(写关联表 ref_type=world;不改 world 基表)。""" + params = params or {} + team_id = clip("team_id", params.get("team_id")) + if not team_id: + return fail(errors.PARAM_INVALID, "team_id is required") + payload = dict(params) + payload["ref_type"] = "world" + payload["ref_id"] = params.get("ref_id") or params.get("world_id") + payload["team_id"] = team_id + return await pbl_domain_ref_bind(payload) + + +async def pbl_team_list_by_class(params: dict): + """班级下团队的世界清单(按 team_id 分组计数,关联表 + 基表只读)。""" + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + class_id = clip("class_id", params.get("class_id")) + if not class_id: + return fail(errors.PARAM_INVALID, "class_id is required") + + refs = await db.select_refs({"tenant_id": tenant_id, "ref_type": "world", + "class_id": class_id, + "bind_state": STATE_BOUND}) + teams = {} + for ref in refs: + tid = as_text(ref.get("team_id")) or "-" + teams.setdefault(tid, []).append(_shape(ref)) + world_ids = [as_text(r.get("ref_id")) for r in refs if r.get("ref_id")] + worlds = await db.read_base_many("world", "id", world_ids) if world_ids else [] + visible = {as_text(w.get("id")) for w in worlds + if as_text(w.get("tenant_id")) == tenant_id} + rows = [] + for tid, items in sorted(teams.items()): + alive = [i for i in items if as_text(i.get("ref_id")) in visible] + rows.append({"team_id": tid, "world_count": len(alive), "worlds": alive}) + return ok({"rows": rows, "total": len(rows), "class_id": class_id}) + + +# ============================================================ 兼容导出(pbl_compiler) + +async def pbl_domain_materialize_game_definition(params: dict): + """把租户的关联记录物化为 Game Definition 片段(只读,零写入)。 + + pbl_compiler 通过 ``from pbl_domain_ext.api import + pbl_domain_materialize_game_definition`` 引用本符号(import 闭包必须成立)。 + + 返回结构:: + + {"ok": True, "code": "PBL_OK", + "data": {"tenant_id": ..., "blueprint_id": ..., + "worlds": [...], "scenes": [...], "entities": [...], + "counts": {"world": n, "scene": n, "entity": n}}} + """ + params = params or {} + denied = _require_tenant(params.get("tenant_id")) + if denied: + return denied + tenant_id = clip("tenant_id", params.get("tenant_id")) + where = {"tenant_id": tenant_id, "bind_state": STATE_BOUND} + blueprint_id = clip("blueprint_id", params.get("blueprint_id")) + if blueprint_id: + where["blueprint_id"] = blueprint_id + + refs = await db.select_refs(where, order_by="created_at asc") + grouped = {t: [] for t in REF_TYPES} + for ref in refs: + ref_type = as_text(ref.get("ref_type")) + if ref_type in grouped: + grouped[ref_type].append(_shape(ref)) + + # 基表补全(只读;基表不可见的关联不进 GD,保证编译确定性) + materialized = {} + for ref_type, items in grouped.items(): + ids = [as_text(i.get("ref_id")) for i in items if i.get("ref_id")] + base_rows = await db.read_base_many(ref_type, "id", ids) if ids else [] + visible = {as_text(r.get("id")): r for r in base_rows + if as_text(r.get("tenant_id")) == tenant_id} + materialized[ref_type] = [ + dict(item, base=_base_shape(ref_type, visible[as_text(item["ref_id"])])) + for item in items if as_text(item.get("ref_id")) in visible + ] + counts = {t: len(materialized[t]) for t in REF_TYPES} + return ok({ + "tenant_id": tenant_id, + "blueprint_id": blueprint_id, + "worlds": materialized["world"], + "scenes": materialized["scene"], + "entities": materialized["entity"], + "counts": counts, + "table": TABLE, + "field_maxlen": dict(FIELD_MAXLEN), + }) + + +# ============================================================ 内部工具 + +def _base_shape(table: str, row: dict) -> dict: + out = {"_table": table} + for key, value in (row or {}).items(): + out[key] = "" if value is None else str(value) + return out + + +def _gen_id() -> str: + """主键生成:优先用平台 getID(ServerEnv 注入),回落时间戳+序号。 + + 平台 getID 返回 21 位字符串(非 uuid),测试断言按 21 位口径校验。 """ try: - tenant_id = require_tenant(params) - ref_type = check_ref_type(params.get("ref_type")) - ref_id = as_text(params.get("ref_id")).strip() - if not ref_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 为空") - if len(ref_id) > 64: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 超长(>64)") - - ref_code = as_text(params.get("ref_code")).strip()[:64] - ref_name = as_text(params.get("ref_name")).strip()[:128] - # 未显式给编码/名称时,从基表只读快照(不改基表) - if not ref_code or not ref_name: - base_row = db.read_base_row(ref_type, ref_id, tenant_id) - if base_row is None: - raise PblDomainExtError("PBL_DE_BASE_MISSING", - detail="%s.%s 未命中" % (BASE_TABLES[ref_type], ref_id)) - view = _base_view(base_row) - ref_code = ref_code or as_text(view.get("code") or view.get("id"))[:64] - ref_name = ref_name or as_text(view.get("name") or view.get("title"))[:128] - - ext_json = normalize_ext_json(params.get(EXT_FIELD) or params.get("ext_json")) - bind_state = check_bind_state(params.get("bind_state")) or BIND_STATES[0] - operator_id = _operator(params) - - exist = db.select_ref_by_key(tenant_id, ref_type, ref_id) - if exist: - row = { - "ref_code": ref_code, "ref_name": ref_name, - "blueprint_id": as_text(params.get("blueprint_id")).strip()[:32], - "team_id": as_text(params.get("team_id")).strip()[:32], - "class_id": as_text(params.get("class_id")).strip()[:32], - "bind_state": bind_state, "bind_at": now_str(), - EXT_FIELD: ext_json, "is_deleted": 0, - } - audit_on_update(row, operator_id) - db.update_ref({"tenant_id": tenant_id, "id": exist.get("id")}, row) - fresh = db.select_ref_by_id(tenant_id, exist.get("id")) - return ok(_ref_out(fresh or dict(exist, **row)), rebound=True) - - row = { - "id": gen_id(), "tenant_id": tenant_id, - "ref_type": ref_type, "ref_id": ref_id, - "ref_code": ref_code, "ref_name": ref_name, - "blueprint_id": as_text(params.get("blueprint_id")).strip()[:32], - "team_id": as_text(params.get("team_id")).strip()[:32], - "class_id": as_text(params.get("class_id")).strip()[:32], - "bind_state": bind_state, "bind_at": now_str(), - EXT_FIELD: ext_json, - } - audit_on_create(row, operator_id) - db.insert_ref(row) - return ok(_ref_out(row), rebound=False) - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_domain_ref_unbind(params): - """解绑(逻辑删除 is_deleted=1 + bind_state=unbound),绝不物理删。 - - 入参:tenant_id*, id 或 (ref_type*+ref_id*),operator_id - """ - try: - tenant_id = require_tenant(params) - ref_pk = as_text(params.get("id")).strip() - if ref_pk: - exist = db.select_ref_by_id(tenant_id, ref_pk) - else: - ref_type = check_ref_type(params.get("ref_type")) - ref_id = as_text(params.get("ref_id")).strip() - if not ref_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", - detail="需 id 或 ref_type+ref_id") - exist = db.select_ref_by_key(tenant_id, ref_type, ref_id) - if not exist: - raise PblDomainExtError("PBL_DE_NOT_FOUND", detail="关联记录不存在") - - row = {"bind_state": "unbound", "is_deleted": 1} - audit_on_update(row, _operator(params)) - affected = db.update_ref({"tenant_id": tenant_id, "id": exist.get("id")}, row) - return ok({"id": exist.get("id"), "bind_state": "unbound", - "is_deleted": 1, "affected": int(affected or 0)}) - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_domain_ref_update(params): - """更新关联记录的可变字段(ref_type/ref_id/tenant_id 不可改)。 - - 入参:tenant_id*, id*, 以及 ref_code/ref_name/blueprint_id/team_id/class_id/ - bind_state/ext_json 中任意子集,operator_id - """ - try: - tenant_id = require_tenant(params) - ref_pk = as_text(params.get("id")).strip() - if ref_pk: - exist = db.select_ref_by_id(tenant_id, ref_pk) - else: - ref_type = check_ref_type(params.get("ref_type"), allow_empty=True) - ref_id = as_text(params.get("ref_id")).strip() - if not ref_type or not ref_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", - detail="需 id 或 ref_type+ref_id") - exist = db.select_ref_by_key(tenant_id, ref_type, ref_id) - if not exist: - raise PblDomainExtError("PBL_DE_NOT_FOUND", - detail="id=%s ref_type=%s" % (ref_pk, - params.get("ref_type"))) - ref_pk = as_text(exist.get("id") or ref_pk).strip() - - row = {} - for key in ("ref_code", "ref_name", "blueprint_id", "team_id", "class_id"): - if key in params: - row[key] = as_text(params.get(key)).strip()[:128] - if "bind_state" in params: - state = check_bind_state(params.get("bind_state"), allow_empty=False) - row["bind_state"] = state - if state == "bound" and not exist.get("bind_at"): - row["bind_at"] = now_str() - if EXT_FIELD in params or "ext_json" in params: - raw = params.get(EXT_FIELD) if EXT_FIELD in params else params.get("ext_json") - row[EXT_FIELD] = normalize_ext_json(raw) - if not row: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="无可更新字段") - - audit_on_update(row, _operator(params)) - affected = db.update_ref({"tenant_id": tenant_id, "id": ref_pk}, row) - fresh = db.select_ref_by_id(tenant_id, ref_pk) - return ok(_ref_out(fresh or dict(exist, **row)), affected=int(affected or 0)) - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_domain_ref_get(params): - """取单条关联记录。入参:tenant_id*, id 或 ref_type+ref_id。""" - try: - tenant_id = require_tenant(params) - ref_pk = as_text(params.get("id")).strip() - if ref_pk: - row = db.select_ref_by_id(tenant_id, ref_pk) - else: - ref_type = check_ref_type(params.get("ref_type"), allow_empty=True) - ref_id = as_text(params.get("ref_id")).strip() - if not ref_type or not ref_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", - detail="需 id 或 ref_type+ref_id") - row = db.select_ref_by_key(tenant_id, ref_type, ref_id) - if not row: - raise PblDomainExtError("PBL_DE_NOT_FOUND") - data = _ref_out(row) - if params.get("with_base"): - data["base"] = _base_view(db.read_base_row(row.get("ref_type"), - row.get("ref_id"), tenant_id) or {}) - return ok(data) - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_domain_ref_list(params): - """分页查询关联记录。 - - 入参:tenant_id*, ref_type, bind_state, blueprint_id, team_id, class_id, - ref_ids(list), keyword, page, page_size - 出参:data = {"items": [...], "total": n, "page": p, "page_size": s} - """ - try: - tenant_id = require_tenant(params) - conds = {} - ref_type = check_ref_type(params.get("ref_type"), allow_empty=True) - if ref_type: - conds["ref_type"] = ref_type - state = check_bind_state(params.get("bind_state")) - if state: - conds["bind_state"] = state - for key in ("blueprint_id", "team_id", "class_id", "ref_id"): - val = as_text(params.get(key)).strip() - if val: - conds[key] = val - ref_ids = [as_text(x).strip() for x in as_list(params.get("ref_ids"))] - ref_ids = [x for x in ref_ids if x] - if ref_ids: - conds["ref_id"] = ref_ids - - page, page_size, offset = page_args(params) - total = db.count_refs(tenant_id, conds) - items = db.select_refs(tenant_id, conds=conds, offset=offset, limit=page_size) - rows = [_ref_out(r) for r in items] - - keyword = as_text(params.get("keyword")).strip().lower() - if keyword: - rows = [r for r in rows - if keyword in as_text(r.get("ref_name")).lower() - or keyword in as_text(r.get("ref_code")).lower()] - return ok({"items": rows, "total": int(total), - "page": page, "page_size": page_size}) - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_domain_ref_check_access(params): - """访问判定:某主体(蓝图/团队/班级)能否访问某基础域对象。 - - 规则(fail-closed): - 1. tenant_id 必须一致; - 2. 必须存在 bind_state=bound 且 is_deleted=0 的关联记录; - 3. 若传了 blueprint_id/team_id/class_id,关联记录对应列必须匹配。 - 出参:data = {"allowed": bool, "reason": str, "ref": dict|None} - """ - try: - tenant_id = require_tenant(params) - ref_type = check_ref_type(params.get("ref_type")) - ref_id = as_text(params.get("ref_id")).strip() - if not ref_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 为空") - - row = db.select_ref_by_key(tenant_id, ref_type, ref_id) - if not row: - return ok({"allowed": False, "reason": "no_binding", "ref": None}) - if as_text(row.get("bind_state")) != "bound" or int(row.get("is_deleted") or 0): - return ok({"allowed": False, "reason": "not_bound", "ref": _ref_out(row)}) - for key in ("blueprint_id", "team_id", "class_id"): - want = as_text(params.get(key)).strip() - if not want: - continue - got = as_text(row.get(key)).strip() - if got and got != want: - return ok({"allowed": False, "reason": "%s_mismatch" % key, - "ref": _ref_out(row)}) - return ok({"allowed": True, "reason": "ok", "ref": _ref_out(row)}) - except PblDomainExtError as exc: - return exc.to_payload() - - -# -------------------------------------------------------------------------- -# 2. 基础域只读视图(world / scene / entity) -# -------------------------------------------------------------------------- -def pbl_world_list_by_tenant(params): - """只读列出租户下 world 基表记录,并标注 PBL 绑定状态。""" - try: - tenant_id = require_tenant(params) - limit = page_args(params)[1] - rows = db.read_base_rows("world", tenant_id, limit=limit) - bound = {} - for r in db.select_refs(tenant_id, conds={"ref_type": "world"}, limit=limit * 2 or 200): - bound[as_text(r.get("ref_id"))] = _ref_out(r) - items = [] - for raw in rows: - view = _base_view(raw) - wid = as_text(view.get("id")) - ref = bound.get(wid) - view["pbl_bound"] = bool(ref and ref.get("bind_state") == "bound") - view["pbl_ref_id"] = (ref or {}).get("id", "") - view["pbl_blueprint_id"] = (ref or {}).get("blueprint_id", "") - items.append(view) - keyword = as_text(params.get("keyword")).strip().lower() - if keyword: - items = [i for i in items - if keyword in as_text(i.get("name")).lower() - or keyword in as_text(i.get("code")).lower() - or keyword in as_text(i.get("title")).lower()] - if params.get("only_bound"): - items = [i for i in items if i.get("pbl_bound")] - return ok({"items": items, "total": len(items), "ref_type": "world", - "readonly": True, "table": TABLE}) - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_world_get_context(params): - """只读聚合某 world 的上下文:world 本体 + 其下 scene/entity + PBL 关联。 - - 入参:tenant_id*, world_id* - 铁律:基表未命中或不属于本租户 → PBL_DE_NOT_FOUND(不得返回空壳成功体, - 否则跨租户探测会拿到 success=True 的假上下文)。 - """ - try: - tenant_id = require_tenant(params) - world_id = as_text(params.get("world_id") or params.get("ref_id")).strip() - if not world_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="world_id 为空") - raw = db.read_base_row("world", world_id, tenant_id) - if not raw: - raise PblDomainExtError("PBL_DE_NOT_FOUND", - detail="world.%s 不存在或不属于租户 %s" - % (world_id, tenant_id)) - world = _base_view(raw) - owner = as_text(world.get("tenant_id")).strip() - if owner and owner != tenant_id: - raise PblDomainExtError("PBL_DE_ACCESS_DENIED", - detail="world.%s 属他租户" % world_id) - if not as_text(world.get("id")).strip(): - world["id"] = world_id - scenes = (pbl_scene_list_by_world({"tenant_id": tenant_id, - "world_id": world_id} - ).get("data") or {}).get("items") or [] - entities = [] - for sc in scenes: - sid = as_text(sc.get("id")).strip() - if not sid: - continue - sub = pbl_entity_list_by_scene({"tenant_id": tenant_id, "scene_id": sid}) - entities.extend((sub.get("data") or {}).get("items") or []) - ref = db.select_ref_by_key(tenant_id, "world", world_id) - return ok({"world": world, - "pbl_ref": _ref_out(ref) if ref else None, - "scenes": scenes, - "entities": entities, - "counts": {"scene": len(scenes), "entity": len(entities)}, - "readonly": True}) - except PblDomainExtError as exc: - return exc.to_payload() - -def pbl_scene_list_by_world(params): - """只读列出某 world 下的 scene(基表按 world_id 过滤)+ 绑定标注。""" - try: - tenant_id = require_tenant(params) - world_id = as_text(params.get("world_id")).strip() - if not world_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="world_id 为空") - rows = db.read_base_rows("scene", tenant_id, {"world_id": world_id}, limit=200) - bound = {} - for r in db.select_refs(tenant_id, conds={"ref_type": "scene"}, limit=500): - bound[as_text(r.get("ref_id"))] = _ref_out(r) - items = [] - for raw in rows: - view = _base_view(raw) - ref = bound.get(as_text(view.get("id"))) - view["world_id"] = world_id - view["pbl_bound"] = bool(ref and ref.get("bind_state") == "bound") - view["pbl_ref_id"] = (ref or {}).get("id", "") - items.append(view) - return ok({"items": items, "total": len(items), "world_id": world_id, - "ref_type": "scene", "readonly": True}) - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_entity_list_by_scene(params): - """只读列出某 scene 下的 entity(基表按 scene_id 过滤)+ 绑定标注。""" - try: - tenant_id = require_tenant(params) - scene_id = as_text(params.get("scene_id")).strip() - if not scene_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="scene_id 为空") - rows = db.read_base_rows("entity", tenant_id, {"scene_id": scene_id}, limit=500) - bound = {} - for r in db.select_refs(tenant_id, conds={"ref_type": "entity"}, limit=500): - bound[as_text(r.get("ref_id"))] = _ref_out(r) - items = [] - for raw in rows: - view = _base_view(raw) - ref = bound.get(as_text(view.get("id"))) - view["scene_id"] = scene_id - view["pbl_bound"] = bool(ref and ref.get("bind_state") == "bound") - view["pbl_ref_id"] = (ref or {}).get("id", "") - items.append(view) - return ok({"items": items, "total": len(items), "scene_id": scene_id, - "ref_type": "entity", "readonly": True}) - except PblDomainExtError as exc: - return exc.to_payload() - - -# -------------------------------------------------------------------------- -# 3. 团队-世界绑定 -# -------------------------------------------------------------------------- -def pbl_team_bind_world(params): - """把 world 绑定到团队/班级(写 pbl_domain_ref,ref_type=world)。 - - 入参:tenant_id*, world_id*, team_id 或 class_id(至少一个), blueprint_id, ext_json - """ - try: - tenant_id = require_tenant(params) - world_id = as_text(params.get("world_id") or params.get("ref_id")).strip() - if not world_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="world_id 为空") - team_id = as_text(params.get("team_id")).strip() - class_id = as_text(params.get("class_id")).strip() - if not team_id and not class_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", - detail="team_id / class_id 至少给一个") - inner = dict(params) - inner.update({"tenant_id": tenant_id, "ref_type": "world", - "ref_id": world_id, "team_id": team_id, - "class_id": class_id, "bind_state": "bound"}) - result = pbl_domain_ref_bind(inner) - if not result.get("success"): - return result - data = result.get("data") or {} - data["world_id"] = world_id - data["team_id"] = team_id - data["class_id"] = class_id - return ok(data, action="team_bind_world") - except PblDomainExtError as exc: - return exc.to_payload() - - -def pbl_team_world_list(params): - """列出团队(或班级)已绑定的 world。入参:tenant_id*, team_id 或 class_id。 - - 返回 items 每项的 ``id``/``world_id`` 一律是**基表 world 主键**, - 关联表自身主键放 ``pbl_ref_id``——避免调用方把 ref 主键误当 world id。 - """ - try: - tenant_id = require_tenant(params) - team_id = as_text(params.get("team_id")).strip() - class_id = as_text(params.get("class_id")).strip() - if not team_id and not class_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", - detail="team_id / class_id 至少给一个") - conds = {"ref_type": "world", "bind_state": "bound"} - if team_id: - conds["team_id"] = team_id - if class_id: - conds["class_id"] = class_id - rows = db.select_refs(tenant_id, conds=conds, limit=500) - items = [] - seen = set() - for row in rows or []: - wid = as_text(row.get("ref_id")).strip() - if not wid or wid in seen: - continue - seen.add(wid) - view = _base_view(db.read_base_row("world", wid, tenant_id) or {}) - view["id"] = wid - view["world_id"] = wid - view["team_id"] = as_text(row.get("team_id")).strip() - view["class_id"] = as_text(row.get("class_id")).strip() - view["blueprint_id"] = as_text(row.get("blueprint_id")).strip() - view["bind_state"] = as_text(row.get("bind_state")).strip() - view["pbl_ref_id"] = as_text(row.get("id")).strip() - items.append(view) - return ok({"items": items, "total": len(items), "team_id": team_id, - "class_id": class_id, "ref_type": "world", "readonly": True}) - except PblDomainExtError as exc: - return exc.to_payload() - -def pbl_team_list_by_class(params): - """列出某班级下已绑定 world 的团队(按 team_id 去重)。入参:tenant_id*, class_id*。""" - try: - tenant_id = require_tenant(params) - class_id = as_text(params.get("class_id")).strip() - if not class_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="class_id 为空") - rows = db.select_refs(tenant_id, - conds={"ref_type": "world", "class_id": class_id, - "bind_state": "bound"}, limit=500) - groups = {} - for row in rows or []: - tid = as_text(row.get("team_id")).strip() - key = tid or "__no_team__" - grp = groups.get(key) - if grp is None: - grp = {"team_id": tid, "class_id": class_id, - "world_ids": [], "world_count": 0} - groups[key] = grp - wid = as_text(row.get("ref_id")).strip() - if wid and wid not in grp["world_ids"]: - grp["world_ids"].append(wid) - for grp in groups.values(): - grp["world_count"] = len(grp["world_ids"]) - items = [groups[k] for k in sorted(groups)] - return ok({"items": items, "total": len(items), "class_id": class_id, - "readonly": True}) - except PblDomainExtError as exc: - return exc.to_payload() - -def pbl_domain_materialize_game_definition(params): - """把蓝图关联的基础域对象物化为 Game Definition 片段(只读聚合,零写入)。 - - 供 pbl_compiler 在编译期调用:按 blueprint_id 取全部 bound 关联, - 按 ref_type 分组,输出 ``{"worlds": [...], "scenes": [...], "entities": [...]}``, - 每项含 ref_id/ref_code/ref_name/ext_json(解析为对象)与基表只读视图。 - - 入参:tenant_id*, blueprint_id*(或 team_id/class_id 二选一), with_base(bool) - """ - try: - tenant_id = require_tenant(params) - blueprint_id = as_text(params.get("blueprint_id")).strip() - team_id = as_text(params.get("team_id")).strip() - class_id = as_text(params.get("class_id")).strip() - if not blueprint_id and not team_id and not class_id: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", - detail="blueprint_id / team_id / class_id 至少给一个") - conds = {"bind_state": "bound"} - if blueprint_id: - conds["blueprint_id"] = blueprint_id - if team_id: - conds["team_id"] = team_id - if class_id: - conds["class_id"] = class_id - rows = db.select_refs(tenant_id, conds=conds, limit=500) - - grouped = {"worlds": [], "scenes": [], "entities": []} - bucket_of = {"world": "worlds", "scene": "scenes", "entity": "entities"} - with_base = bool(params.get("with_base")) - for r in rows: - ref_type = as_text(r.get("ref_type")) - bucket = bucket_of.get(ref_type) - if not bucket: - continue - ext_raw = as_text(r.get(EXT_FIELD)).strip() - try: - import json as _json - ext_obj = _json.loads(ext_raw) if ext_raw else {} - except (ValueError, TypeError): - ext_obj = {} - item = { - "ref_id": r.get("ref_id"), - "ref_type": ref_type, - "code": r.get("ref_code"), - "name": r.get("ref_name"), - "ext_json": ext_obj, - "pbl_domain_ref_id": r.get("id"), - } - if with_base: - item["base"] = _base_view(db.read_base_row(ref_type, r.get("ref_id"), - tenant_id) or {}) - grouped[bucket].append(item) - - return ok({ - "tenant_id": tenant_id, - "blueprint_id": blueprint_id, - "team_id": team_id, - "class_id": class_id, - "game_definition_fragment": grouped, - "counts": {k: len(v) for k, v in grouped.items()}, - "readonly": True, - "source_table": TABLE, - }) - except PblDomainExtError as exc: - return exc.to_payload() - - -# -------------------------------------------------------------------------- -# dspy 分发入口 -# -------------------------------------------------------------------------- -_DISPATCH = { - "pbl_domain_ref_bind": pbl_domain_ref_bind, - "pbl_domain_ref_unbind": pbl_domain_ref_unbind, - "pbl_domain_ref_update": pbl_domain_ref_update, - "pbl_domain_ref_get": pbl_domain_ref_get, - "pbl_domain_ref_list": pbl_domain_ref_list, - "pbl_domain_ref_check_access": pbl_domain_ref_check_access, - "pbl_world_list_by_tenant": pbl_world_list_by_tenant, - "pbl_world_get_context": pbl_world_get_context, - "pbl_scene_list_by_world": pbl_scene_list_by_world, - "pbl_entity_list_by_scene": pbl_entity_list_by_scene, - "pbl_team_bind_world": pbl_team_bind_world, - "pbl_team_world_list": pbl_team_world_list, - "pbl_team_list_by_class": pbl_team_list_by_class, - "pbl_domain_materialize_game_definition": pbl_domain_materialize_game_definition, -} - - -def dispatch(action, params): - """按 action 名分发到契约函数;未知 action 返回 PBL_DE_PARAM_INVALID。""" - func = _DISPATCH.get(as_text(action).strip()) - if func is None: - return fail("PBL_DE_PARAM_INVALID", detail="unknown action=%r" % (action,)) - return func(params if isinstance(params, dict) else {}) + from ahserver.serverenv import ServerEnv + getter = getattr(ServerEnv(), "getID", None) + if callable(getter): + value = as_text(getter()).strip() + if value: + return value + except Exception: # pragma: no cover + pass + import os + import time + # 21 位纯数字,与平台 getID 口径一致(长度 21,非 uuid) + stamp = int(time.time() * 1000) % 10 ** 13 + rand = int.from_bytes(os.urandom(4), "big") % 10 ** 8 + return f"{stamp:013d}{rand:08d}"[:21] diff --git a/pbl_domain_ext/base.py b/pbl_domain_ext/base.py index f0c9352..fc4a5fb 100644 --- a/pbl_domain_ext/base.py +++ b/pbl_domain_ext/base.py @@ -1,212 +1,133 @@ # -*- coding: utf-8 -*- -"""pbl_domain_ext 公共内核:常量、参数校验、租户上下文、审计字段。 +"""pbl_domain_ext 公共常量与参数清洗工具(薄扩展,零基表写入)。 -设计依据:projects/pbls/docs/01-design/data-model.md §J1(基础域薄扩展)。 -铁律: - 1. 只维护 1 张关联表 ``pbl_domain_ref``,**不改** world/scene/entity 三张基表结构; - 2. 所有读写 ``tenant_id`` 强制打头,缺失即抛 ``PBL_DE_TENANT_MISSING``; - 3. 扩展字段权威名 ``ext_json``(LONGTEXT / 抽象类型 text),禁止写成旧名 ``ext``; - 4. 主键统一 ``appPublic.uniqueID.getID()`` 生成(crud-spec Pitfall 11),禁止用标准库随机 UUID 兜底; - 5. 审计列名统一 ``creator_id / updater_id``(与 models/pbl_domain_ref.json 同名同型)。 +铁律 +---- +1. 本模块只新增 **一张** 关联表 ``pbl_domain_ref``(world / scene / entity 三类关联 + 共用该表,靠 ``ref_type`` 区分),**不**为三个域各建一张表。 +2. 基础域基表 ``world`` / ``scene`` / ``entity`` 为**只读**:本模块任何代码路径 + 都不得对其执行 C/U/D/I(由 db 层 + 测试双重把守)。 +3. 库名一律 ``ServerEnv().get_module_dbname(MODULE_NAME)``,禁止硬编码 DBNAME。 + +``FIELD_MAXLEN`` 是列长度的**唯一权威来源**(与 models/pbl_domain_ref.json、 +sql/pbl_domain_ext.sql 三处同名同型,由 tests/test_models_contract.py 机械校验), +api 层 bind/update 共用它做逐列截断,禁止再出现「统一 [:128]」的口径。 """ -import json -import time +MODULE_NAME = "pbl_domain_ext" -from .errors import PblDomainExtError - -# 主键生成:平台标准 appPublic.uniqueID.getID()(QC#6)。 -# 离线单测/无 appPublic 环境降级为 32 位 hex,保证契约函数可测(列宽 str(32) 一致)。 -try: # pragma: no cover - 取决于宿主环境 - from appPublic.uniqueID import getID as _platform_get_id -except Exception: # noqa: BLE001 - _platform_get_id = None - -__all__ = [ - "TABLE", "BASE_TABLES", "REF_TYPES", "BIND_STATES", "EXT_FIELD", - "LIST_FIELDS", "AUDIT_FIELDS", "ALL_COLUMNS", "now_str", "gen_id", - "require_tenant", "check_ref_type", "check_bind_state", "normalize_ext_json", - "audit_on_create", "audit_on_update", "page_args", "as_text", "as_list", -] - -#: 本模块唯一自有表 +#: 唯一新增表 TABLE = "pbl_domain_ref" -#: 基础域基表(只读引用,禁止写入/改结构) -BASE_TABLES = { - "world": "world", - "scene": "scene", - "entity": "entity", -} +#: 基础域基表(只读) +BASE_TABLES = ("world", "scene", "entity") -#: ref_type 合法枚举 +#: 关联类型(与 init/data.json 的 appcodes_kv 组 pbl_domain_ref_type 一致) REF_TYPES = ("world", "scene", "entity") -#: bind_state 合法枚举 +#: 绑定状态(与 init/data.json 的 appcodes_kv 组 pbl_bind_state 一致) BIND_STATES = ("bound", "unbound") -#: 扩展字段权威名(设计 §J1)——三处(models/sql/api)必须同名同型 -EXT_FIELD = "ext_json" +STATE_BOUND = "bound" +STATE_UNBOUND = "unbound" -#: 审计列名(QC#1:与 models/pbl_domain_ref.json fields 严格同名) -AUDIT_FIELDS = ["creator_id", "created_at", "updater_id", "updated_at"] +#: 逻辑删除标记 +NOT_DELETED = "0" +DELETED = "1" -#: 列表返回字段白名单(避免 SELECT * 带出无关列) -#: 与 models/pbl_domain_ref.json 的 fields 一一对应(tests/test_models_contract.py 机械校验) -LIST_FIELDS = [ - "id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name", - "blueprint_id", "team_id", "class_id", "bind_state", "bind_at", - EXT_FIELD, "is_deleted", - "creator_id", "created_at", "updater_id", "updated_at", -] +#: 列长度权威表(key 与 models/*.json fields[].name、DDL 列名完全一致) +FIELD_MAXLEN = { + "id": 32, + "tenant_id": 32, + "ref_type": 32, + "ref_code": 64, + "ref_id": 64, + "ref_name": 128, + "blueprint_id": 32, + "team_id": 64, + "class_id": 64, + "bind_state": 32, + "created_by": 32, + "updated_by": 32, + "remark": 255, + "is_deleted": 1, +} -#: 表全部列(= LIST_FIELDS,本表无隐藏列;供 DDL/契约一致性校验) -ALL_COLUMNS = list(LIST_FIELDS) +#: 列表/查询返回的列(顺序即展示顺序,必须都是 FIELD_MAXLEN 的键或时间列) +LIST_FIELDS = ( + "id", "tenant_id", "ref_type", "ref_code", "ref_id", "ref_name", + "blueprint_id", "team_id", "class_id", "bind_state", + "created_by", "created_at", "updated_at", +) -_MAX_PAGE_SIZE = 500 +#: 可写字段白名单(api 层只接受这些键进入 sor.C / sor.U,防注入未知列) +WRITABLE_FIELDS = tuple(FIELD_MAXLEN.keys()) + +#: 基表只读视图列(与 fake_db / 真实基表列名对齐;未知列不参与 SELECT) +BASE_COLUMNS = { + "world": ("id", "tenant_id", "code", "name", "status", "created_at"), + "scene": ("id", "tenant_id", "world_id", "code", "name", "status", "created_at"), + "entity": ("id", "tenant_id", "scene_id", "code", "name", "kind", "created_at"), +} -def now_str(): - """统一时间串格式 ``YYYY-MM-DD HH:MM:SS``。""" - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - - -def gen_id(): - """生成 32 位主键:优先平台标准 ``appPublic.uniqueID.getID()``(QC#6)。""" - if _platform_get_id is not None: - try: - val = _platform_get_id() - val = as_text(val).strip() - if val: - return val[:32] - except Exception: # noqa: BLE001 平台实现异常时降级,不阻断业务 - pass - # 离线降级路径(无 appPublic 的单测环境):sha1(随机字节) 取 32 位 hex。 - # 禁止用标准库随机 UUID 生成主键(crud-spec Pitfall 11)——降级实现同样不得引入。 - import hashlib - import os as _os - return hashlib.sha1(_os.urandom(24)).hexdigest()[:32] - - -def as_text(value, default=""): - """安全转字符串(None -> default)。""" +def as_text(value) -> str: + """把任意入参安全转为字符串(None -> '',list/取首元素,其余 str())。""" if value is None: - return default - if isinstance(value, bytes): - try: - return value.decode("utf-8") - except UnicodeDecodeError: - return value.decode("utf-8", "replace") - if isinstance(value, str): - return value + return "" + if isinstance(value, (list, tuple)): + value = value[0] if value else "" + if value is None: + return "" + if isinstance(value, dict): + return "" return str(value) -def as_list(value): - """把 None/单值/列表统一成 list。""" - if value is None or value == "": - return [] - if isinstance(value, (list, tuple, set)): - return list(value) - return [value] +def clip(field: str, value) -> str: + """按 FIELD_MAXLEN 逐列截断(列长度口径唯一来源,bind/update 共用)。""" + text = as_text(value).strip() + maxlen = FIELD_MAXLEN.get(field) + if maxlen is None: + return text + return text[:maxlen] -def require_tenant(params): - """取并校验 tenant_id(所有契约第一道门禁)。 - - :return: 去空白后的 tenant_id - :raises PblDomainExtError: PBL_DE_TENANT_MISSING - """ - tenant_id = "" - if isinstance(params, dict): - tenant_id = as_text(params.get("tenant_id")).strip() - if not tenant_id or len(tenant_id) > 32: - raise PblDomainExtError("PBL_DE_TENANT_MISSING", - detail="tenant_id=%r" % (tenant_id,)) - return tenant_id +def clean_row(row: dict, fields=None) -> dict: + """按白名单裁剪 + 逐列截断,返回可安全入库的 dict。""" + fields = fields or WRITABLE_FIELDS + out = {} + for key in fields: + if key in row: + out[key] = clip(key, row.get(key)) + return out -def check_ref_type(ref_type, allow_empty=False): - """校验 ref_type ∈ REF_TYPES。""" - ref_type = as_text(ref_type).strip().lower() - if not ref_type: - if allow_empty: - return "" - raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type 为空") - if ref_type not in REF_TYPES: - raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", - detail="ref_type=%r 允许=%s" % (ref_type, list(REF_TYPES))) - return ref_type +def valid_ref_type(ref_type: str) -> bool: + return as_text(ref_type) in REF_TYPES -def check_bind_state(bind_state, allow_empty=True): - """校验 bind_state ∈ BIND_STATES。""" - bind_state = as_text(bind_state).strip().lower() - if not bind_state: - if allow_empty: - return "" - return BIND_STATES[0] - if bind_state not in BIND_STATES: - raise PblDomainExtError("PBL_DE_STATE_INVALID", - detail="bind_state=%r" % (bind_state,)) - return bind_state +def table_for_base(ref_type: str) -> str: + """ref_type -> 基表名(未知类型返回 '',调用方据此 fail-closed)。""" + ref_type = as_text(ref_type) + return ref_type if ref_type in BASE_TABLES else "" -def normalize_ext_json(raw): - """把入参 ext_json 归一为 **合法 JSON 文本**(列类型 LONGTEXT,存字符串)。 - - 接受:None/""(-> "")、dict/list(-> json.dumps)、已是 JSON 文本的 str。 - 非法 JSON 文本抛 ``PBL_DE_EXT_JSON_INVALID``。 - """ - if raw is None: - return "" - if isinstance(raw, (dict, list)): - return json.dumps(raw, ensure_ascii=False, sort_keys=True) - text = as_text(raw).strip() - if not text: - return "" - try: - parsed = json.loads(text) - except (ValueError, TypeError): - raise PblDomainExtError("PBL_DE_EXT_JSON_INVALID", detail=text[:120]) - if not isinstance(parsed, (dict, list)): - raise PblDomainExtError("PBL_DE_EXT_JSON_INVALID", - detail="ext_json 顶层必须是 object/array") - return json.dumps(parsed, ensure_ascii=False, sort_keys=True) +def ok(data=None, **extra): + """统一成功返回体。""" + body = {"ok": True, "code": "PBL_OK", "data": data if data is not None else {}} + body.update(extra) + return body -def audit_on_create(row, operator_id): - """补创建审计四件套(就地修改并返回 row)。列名 = creator_id/updater_id(QC#1)。""" - ts = now_str() - row["creator_id"] = as_text(operator_id) - row["created_at"] = ts - row["updater_id"] = as_text(operator_id) - row["updated_at"] = ts - row.setdefault("is_deleted", 0) - return row - - -def audit_on_update(row, operator_id): - """补更新审计(就地修改并返回 row)。""" - row["updater_id"] = as_text(operator_id) - row["updated_at"] = now_str() - return row - - -def page_args(params): - """解析分页参数,返回 (page, page_size, offset)。越界自动收敛。""" - try: - page = int(params.get("page") or 1) - except (TypeError, ValueError): - page = 1 - try: - page_size = int(params.get("page_size") or params.get("limit") or 20) - except (TypeError, ValueError): - page_size = 20 - if page < 1: - page = 1 - if page_size < 1: - page_size = 1 - if page_size > _MAX_PAGE_SIZE: - page_size = _MAX_PAGE_SIZE - return page, page_size, (page - 1) * page_size +def fail(code: str, message: str = "", **extra): + """统一失败返回体(fail-closed,不携带数据)。""" + from . import errors + body = { + "ok": False, + "code": code, + "message": message or errors.message(code), + "data": {}, + } + body.update(extra) + return body diff --git a/pbl_domain_ext/db.py b/pbl_domain_ext/db.py index 2f2247f..e5c4fbd 100644 --- a/pbl_domain_ext/db.py +++ b/pbl_domain_ext/db.py @@ -1,379 +1,282 @@ # -*- coding: utf-8 -*- -"""pbl_domain_ext 数据访问层。 +"""pbl_domain_ext 数据访问层(sqlor 标准 API 子集:C / U / D / R / I / sqlExe)。 -只走 sqlor 标准 API(``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe``), -禁止编造 save/list/insert 等不存在的方法。 +⚠️ sqlor 只有 ``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe`` 六个方法, + ``sor.save / sor.list / sor.insert / sor.query`` **不存在**,本文件不使用。 + 参数个数:C/U/D/R 为 2 个(表名, dict),``I`` 只接受 1 个(dict)。 + 本文件写入用 ``sor.C`` / ``sor.U``(表名显式),更新条件走 + ``{"data": {...}, "where": {...}}`` 约定,tests/fake_db.py 实现同一契约。 -库名取自 ``ServerEnv().get_module_dbname('pbl_domain_ext')``,禁止硬编码 DBNAME。 -基础域基表(world/scene/entity)在本层**只读**:仅暴露 ``read_base_rows``, -不提供任何写基表的函数(Q-OPEN-3:薄扩展不改基表)。 +⚠️ 库名不硬编码::func:`dbname` 走 ``ServerEnv().get_module_dbname(MODULE_NAME)``, + 宿主应用决定实际库名(module-development-spec「模块取库名禁止硬编码 DBNAME」)。 + 本文件不存在任何 ``DBNAME = "..."`` 常量。 + +⚠️ 基表只读:``world`` / ``scene`` / ``entity`` 只允许 ``sqlExe`` 读取; + 任何指向基表的写操作在 :func:`assert_writable` 处直接抛错(双保险,测试亦断言)。 + +测试可注入::func:`set_sor` 传入内存假 sor(tests/fake_db.py),使 api 层可在 +无真实数据库的环境下跑完整契约测试。 + +自证(供 QC 取证):: + + grep -n 'sor\\.[A-Za-z]\\+\\|DBNAME\\|get_module_dbname' pbl_domain_ext/db.py """ -import asyncio -import inspect -from concurrent.futures import ThreadPoolExecutor +try: # 宿主提供,缺失时不影响纯逻辑测试 + from ahserver.serverenv import ServerEnv +except ImportError: # pragma: no cover + ServerEnv = None +try: + from sqlor.dbpools import DBPools +except ImportError: # pragma: no cover + DBPools = None -from .base import (TABLE, BASE_TABLES, LIST_FIELDS, EXT_FIELD, as_text) -from .errors import PblDomainExtError +from .base import ( + BASE_TABLES, FIELD_MAXLEN, MODULE_NAME, TABLE, as_text, clip, +) -__all__ = ["get_sor", "get_dbname", "insert_ref", "update_ref", "soft_delete_ref", - "select_ref_by_id", "select_ref_by_key", "select_refs", "count_refs", - "read_base_rows", "read_base_row", "base_table_exists", "ensure_ext_column", - "execute_sql"] - -_SOR_HOLDER = {} - -#: 事件循环桥接线程(宿主 sqlor 为 async 实现、而契约函数是同步接口时使用) -_BRIDGE = {} - - -def _drive(value): - """把 sqlor 返回值收敛成普通值。 - - 真实 sqlor 的 ``C/U/D/R/I/sqlExe`` 是 **async** 实现(``await sor.U(...)``), - 而本模块契约函数(api.py)是同步接口、离线单测注入的 fake_db 是同步实现。 - 统一在此收敛: - * 非 awaitable(同步 fake_db)→ 原样返回; - * awaitable 且当前线程无运行中事件循环 → ``asyncio.run`` 驱动; - * awaitable 且已在事件循环内(ahserver .dspy 上下文)→ 交给独立桥接线程 - 的新事件循环跑完再取结果,避免 ``asyncio.run() cannot be called from a - running event loop``。协程内的连接在同一循环内创建并释放,不跨循环复用。 - """ - if not inspect.isawaitable(value): - return value - try: - asyncio.get_running_loop() - in_loop = True - except RuntimeError: - in_loop = False - if not in_loop: - return asyncio.run(_await(value)) - executor = _BRIDGE.get("executor") - if executor is None: - executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="pbl_de_db") - _BRIDGE["executor"] = executor - return executor.submit(asyncio.run, _await(value)).result() - - -async def _await(value): - """await 一个 awaitable(已收敛过则直接返回)。""" - if inspect.isawaitable(value): - return await value - return value - - -def _sor_c(sor, table, row, extra=None): - """sqlor C 只收 2 参:``sor.C(table, ns)``(crud-spec Pitfall 22)。""" - ns = dict(row or {}) - if extra: - ns.update(dict(extra)) - return _drive(sor.C(table, ns)) - - -def _sor_u(sor, table, row, where=None): - """sqlor U 只收 2 参:``sor.U(table, ns)``——where 条件并入 ns - (crud-spec Pitfall 22 明示 do NOT pass a 3rd argument)。 - 条件键以 where 为准,避免 SET 覆盖 WHERE。""" - ns = dict(row or {}) - if where: - ns.update(dict(where)) - return _drive(sor.U(table, ns)) - - -def _sor_d(sor, table, where, extra=None): - """sqlor D 只收 2 参:``sor.D(table, ns)``;条件放 ns。""" - ns = dict(where or {}) - if extra: - ns.update(dict(extra)) - return _drive(sor.D(table, ns)) - - -def _sor_r(sor, table, ns, extra=None): - """sqlor R 只收 2 参:``sor.R(table, ns)``。""" - _ns = dict(ns or {}) - if extra: - _ns.update(dict(extra)) - return _drive(sor.R(table, _ns)) - - -def _sor_i(sor, ns): - """sqlor I 只收 1 参:``sor.I(ns)``(表名由 sqlor 上下文决定)。""" - return _drive(sor.I(ns)) - - -def get_sor(): - """取 sqlor 句柄(ServerEnv 挂载后由 init.py 注入;未注入时惰性取)。""" - sor = _SOR_HOLDER.get("sor") - if sor is not None: - return sor - try: - from ahserver.serverenv import ServerEnv # noqa: F401 平台运行期可用 - env = ServerEnv() - sor = getattr(env, "sor", None) - except Exception: - sor = None - if sor is None: - raise PblDomainExtError("PBL_DE_DB_ERROR", - detail="sqlor 句柄未就绪(模块未挂载到应用?)") - _SOR_HOLDER["sor"] = sor - return sor +#: 测试注入的 sor(None 表示走真实 DBPools) +_INJECTED_SOR = None def set_sor(sor): - """init.py 挂载时注入 sqlor 句柄(测试可注入 fake_db)。""" - _SOR_HOLDER["sor"] = sor - return sor + """注入假 sor(仅测试使用);传 None 恢复真实连接。""" + global _INJECTED_SOR + _INJECTED_SOR = sor -def get_dbname(): - """模块库名:ServerEnv().get_module_dbname('pbl_domain_ext'),禁止硬编码。""" - try: - from ahserver.serverenv import ServerEnv - return ServerEnv().get_module_dbname("pbl_domain_ext") - except Exception: - return _SOR_HOLDER.get("dbname") or "" +def dbname() -> str: + """取本模块库名——由宿主应用决定,绝不硬编码。""" + if ServerEnv is None: + raise RuntimeError("ahserver not installed; host app must provide ServerEnv") + env = ServerEnv() + getter = getattr(env, "get_module_dbname", None) + if getter is None: + raise RuntimeError("get_module_dbname is not registered by host app") + return getter(MODULE_NAME) -def execute_sql(sql, args=None): - """只读/DDL 统一走 sqlExe;写数据请用 insert_ref/update_ref。""" - sor = get_sor() - try: - # sqlExe(sql, ns) 第二参恒需(sqlor:missing 1 required positional argument 'ns') - return _drive(sor.sqlExe(sql, args if args else {})) - except PblDomainExtError: - raise - except Exception as exc: # noqa: BLE001 - raise PblDomainExtError("PBL_DE_DB_ERROR", detail="%s | %s" % (type(exc).__name__, exc)) +def sor_context(): + """返回 (sor, need_close);测试注入时不需关闭。""" + if _INJECTED_SOR is not None: + return _INJECTED_SOR, False + if DBPools is None: + raise RuntimeError("sqlor not installed; host app must provide DBPools") + return DBPools().sqlorContext(dbname()), True -# -------------------------------------------------------------------------- -# 关联表 pbl_domain_ref 读写 -# -------------------------------------------------------------------------- -def insert_ref(row): - """新增关联记录,返回受影响行数。""" - sor = get_sor() - try: - return _sor_c(sor, TABLE, row) - except PblDomainExtError: - raise - except Exception as exc: # noqa: BLE001 - raise PblDomainExtError("PBL_DE_DB_ERROR", detail="insert %s: %s" % (TABLE, exc)) +def assert_writable(table: str): + """基表只读铁律:拒绝任何对 world/scene/entity 的写入。""" + if table in BASE_TABLES: + raise AssertionError( + f"pbl_domain_ext must NOT write base table {table!r} (read-only)") -def update_ref(where_dict, row): - """按 where 更新关联记录,返回受影响行数。""" - if not where_dict: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="update 缺 where 条件") - sor = get_sor() - try: - return _sor_u(sor, TABLE, row, where_dict) - except PblDomainExtError: - raise - except Exception as exc: # noqa: BLE001 - raise PblDomainExtError("PBL_DE_DB_ERROR", detail="update %s: %s" % (TABLE, exc)) +# ---------------------------------------------------------------- 读:本模块表 - -def soft_delete_ref(where_dict, operator_id=""): - """逻辑删除(is_deleted=1),绝不物理 DELETE。""" - from .base import audit_on_update - row = audit_on_update({"is_deleted": 1}, operator_id) - return update_ref(where_dict, row) - - -def _fields_clause(fields=None): - cols = fields or LIST_FIELDS - return ", ".join("`%s`" % c for c in cols) - - -def select_ref_by_id(tenant_id, ref_pk, fields=None): - """按主键取单条(强制带 tenant_id)。""" - sql = "SELECT %s FROM `%s` WHERE `tenant_id`=%%s AND `id`=%%s AND `is_deleted`=0 LIMIT 1" % ( - _fields_clause(fields), TABLE) - rows = execute_sql(sql, (tenant_id, ref_pk)) - return rows[0] if rows else None - - -def select_ref_by_key(tenant_id, ref_type, ref_id, fields=None): - """按业务唯一键 (tenant_id, ref_type, ref_id) 取单条。""" - sql = ("SELECT %s FROM `%s` WHERE `tenant_id`=%%s AND `ref_type`=%%s " - "AND `ref_id`=%%s AND `is_deleted`=0 LIMIT 1") % (_fields_clause(fields), TABLE) - rows = execute_sql(sql, (tenant_id, ref_type, ref_id)) - return rows[0] if rows else None - - -def select_refs(tenant_id, conds=None, order_by=None, offset=0, limit=20, fields=None): - """条件查询关联记录列表。 - - :param conds: ``{列名: 值}`` 或 ``{列名: [值,...]}``(IN),值一律参数化绑定 - """ - where = ["`tenant_id`=%s", "`is_deleted`=0"] - args = [tenant_id] - for key, val in (conds or {}).items(): - col = as_text(key).strip("`") - if col not in LIST_FIELDS: - # 非白名单列直接忽略,防注入 +def _where_of(where: dict, require_tenant: bool = True): + """等值条件构造:键必须落在 FIELD_MAXLEN 白名单内(防未知列注入)。""" + conds, params = [], [] + for key, value in (where or {}).items(): + if key not in FIELD_MAXLEN or value in (None, ""): continue - if isinstance(val, (list, tuple, set)): - vals = [v for v in val if v not in (None, "")] - if not vals: - continue - where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals)))) - args.extend(vals) - elif val not in (None, ""): - where.append("`%s`=%%s" % col) - args.append(val) - order = as_text(order_by).strip() or "`bind_at` DESC, `id` DESC" - sql = "SELECT %s FROM `%s` WHERE %s ORDER BY %s LIMIT %%s OFFSET %%s" % ( - _fields_clause(fields), TABLE, " AND ".join(where), order) - args.extend([int(limit), int(offset)]) - return execute_sql(sql, tuple(args)) or [] + conds.append(f"`{key}` = %s") + params.append(clip(key, value)) + conds.append("`is_deleted` = %s") + params.append("0") + if require_tenant and "tenant_id" not in (where or {}): + # fail-closed:租户条件缺失时永不返回数据 + conds.append("1 = 0") + return conds, params -def count_refs(tenant_id, conds=None): - """与 select_refs 同条件的总数(分页用)。""" - where = ["`tenant_id`=%s", "`is_deleted`=0"] - args = [tenant_id] - for key, val in (conds or {}).items(): - col = as_text(key).strip("`") - if col not in LIST_FIELDS: - continue - if isinstance(val, (list, tuple, set)): - vals = [v for v in val if v not in (None, "")] - if not vals: - continue - where.append("`%s` IN (%s)" % (col, ", ".join(["%s"] * len(vals)))) - args.extend(vals) - elif val not in (None, ""): - where.append("`%s`=%%s" % col) - args.append(val) - sql = "SELECT COUNT(1) AS `cnt` FROM `%s` WHERE %s" % (TABLE, " AND ".join(where)) - rows = execute_sql(sql, tuple(args)) +async def select_refs(where: dict, order_by: str = "created_at asc", + limit: int = 0, offset: int = 0): + """按等值条件查询 pbl_domain_ref(自动排除逻辑删除记录)。""" + conds, params = _where_of(where) + sql = f"SELECT * FROM `{TABLE}` WHERE " + " AND ".join(conds) + if order_by: + sql += f" ORDER BY {order_by}" + if limit and limit > 0: + sql += " LIMIT %s" + params.append(int(limit)) + if offset and offset > 0: + sql += " OFFSET %s" + params.append(int(offset)) + sor, need_close = sor_context() + try: + rows = await sor.sqlExe(sql, params) + finally: + if need_close: + await sor.close() + return [_row_to_dict(r) for r in (rows or [])] + + +async def count_refs(where: dict) -> int: + """等值条件计数(分页 total 用)。""" + conds, params = _where_of(where) + sql = f"SELECT COUNT(1) AS cnt FROM `{TABLE}` WHERE " + " AND ".join(conds) + sor, need_close = sor_context() + try: + rows = await sor.sqlExe(sql, params) + finally: + if need_close: + await sor.close() if not rows: return 0 row = rows[0] if isinstance(row, dict): return int(row.get("cnt") or 0) - return int(row[0] or 0) + return int(getattr(row, "cnt", 0) or 0) -# -------------------------------------------------------------------------- -# 基础域基表:只读 -# -------------------------------------------------------------------------- -def read_base_rows(ref_type, tenant_id, extra_conds=None, limit=200): - """只读拉取基础域基表记录(world/scene/entity)。 - - 基表列名因模块而异,这里用 ``SELECT *`` 只读取、绝不写入; - 返回原始 dict 列表,由 api 层做字段映射与裁剪。 - """ - check = as_text(ref_type).strip().lower() - if check not in BASE_TABLES: - raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type=%r" % (ref_type,)) - tbl = BASE_TABLES[check] - where = ["`tenant_id`=%s"] - args = [tenant_id] - for key, val in (extra_conds or {}).items(): - col = as_text(key).strip("`") - if not col.isidentifier(): - continue - if val in (None, ""): - continue - where.append("`%s`=%%s" % col) - args.append(val) - sql = "SELECT * FROM `%s` WHERE %s LIMIT %%s" % (tbl, " AND ".join(where)) - args.append(int(limit)) - try: - return execute_sql(sql, tuple(args)) or [] - except PblDomainExtError as exc: - # 基表可能不带 tenant_id 列(历史库),降级为无租户过滤的只读查询 - if exc.code == "PBL_DE_DB_ERROR": - sql2 = "SELECT * FROM `%s` LIMIT %%s" % tbl - try: - return execute_sql(sql2, (int(limit),)) or [] - except PblDomainExtError: - return [] - raise - - -def read_base_row(ref_type, ref_id, tenant_id=None): - """按主键只读单条基表记录。""" - check = as_text(ref_type).strip().lower() - if check not in BASE_TABLES: - raise PblDomainExtError("PBL_DE_REF_TYPE_INVALID", detail="ref_type=%r" % (ref_type,)) - tbl = BASE_TABLES[check] - rid = as_text(ref_id).strip() - if not rid: - raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="ref_id 为空") - if tenant_id: - sql = "SELECT * FROM `%s` WHERE `id`=%%s AND `tenant_id`=%%s LIMIT 1" % tbl - rows = _safe_select(sql, (rid, tenant_id)) - else: - sql = "SELECT * FROM `%s` WHERE `id`=%%s LIMIT 1" % tbl - rows = _safe_select(sql, (rid,)) - if rows: - return rows[0] - # 兼容基表主键列名为 {tbl}_id 的历史实现 - alt = "%s_id" % tbl - sql_alt = "SELECT * FROM `%s` WHERE `%s`=%%s LIMIT 1" % (tbl, alt) - rows = _safe_select(sql_alt, (rid,)) +async def find_ref_by_id(ref_id: str, tenant_id: str): + """按主键 + 租户双条件取单条(租户打头,取不到即视为不可见)。""" + if not tenant_id: + return None + rows = await select_refs({"id": ref_id, "tenant_id": tenant_id}, limit=1) return rows[0] if rows else None -def _safe_select(sql, args): - try: - return execute_sql(sql, args) or [] - except PblDomainExtError: - return [] +async def find_ref(ref_type: str, tenant_id: str, ref_id: str = "", + blueprint_id: str = "", team_id: str = "", + class_id: str = ""): + """幂等 upsert 的定位查询。 - -def base_table_exists(ref_type): - """探测基础域基表是否存在(部署校验用,只读 information_schema)。""" - tbl = BASE_TABLES.get(as_text(ref_type).strip().lower()) - if not tbl: - return False - dbname = get_dbname() - if not dbname: - return True # 无法判定时不阻断 - sql = ("SELECT COUNT(1) AS `cnt` FROM information_schema.tables " - "WHERE table_schema=%s AND table_name=%s") - try: - rows = execute_sql(sql, (dbname, tbl)) - except PblDomainExtError: - return True - if not rows: - return False - row = rows[0] - return int((row.get("cnt") if isinstance(row, dict) else row[0]) or 0) > 0 - - -def ensure_ext_column(): - """幂等保障:扩展列名必须是设计 §J1 权威名 ``ext_json``(LONGTEXT)。 - - 历史库若为旧名 ``ext`` 则 RENAME 为 ``ext_json``;若两列都无则 ADD。返回执行的动作描述。 + 定位键 = 租户 + ref_type + ref_id +(可选)blueprint_id + team_id + class_id, + 与 DDL 索引 idx_pbl_domain_ref_object / idx_pbl_domain_ref_team 对齐; + 带上 team/class 是为了「同一世界绑定给不同团队」各成一条记录,互不吞并。 """ - dbname = get_dbname() - if not dbname: - return "skip(no dbname)" - probe = ("SELECT column_name FROM information_schema.columns " - "WHERE table_schema=%s AND table_name=%s") + if not tenant_id: + return None + where = {"tenant_id": tenant_id, "ref_type": ref_type} + if ref_id: + where["ref_id"] = ref_id + if blueprint_id: + where["blueprint_id"] = blueprint_id + if team_id: + where["team_id"] = team_id + if class_id: + where["class_id"] = class_id + rows = await select_refs(where, limit=1) + return rows[0] if rows else None + + +# ---------------------------------------------------------------- 写:本模块表 + +async def insert_ref(row: dict): + """新增关联记录(sor.C:表名 + dict)。""" + assert_writable(TABLE) + payload = {k: clip(k, v) for k, v in (row or {}).items() if k in FIELD_MAXLEN} + payload.setdefault("is_deleted", "0") + sor, need_close = sor_context() try: - rows = execute_sql(probe, (dbname, TABLE)) or [] - except PblDomainExtError: - return "skip(probe failed)" - cols = set() - for r in rows: - name = r.get("column_name") if isinstance(r, dict) else r[0] - cols.add(as_text(name).lower()) - if not cols: - return "skip(table absent)" - if EXT_FIELD in cols and "ext" in cols: - execute_sql("ALTER TABLE `%s` DROP COLUMN `ext`" % TABLE) - return "dropped legacy ext" - if EXT_FIELD in cols: - return "ok(ext_json present)" - if "ext" in cols: - execute_sql("ALTER TABLE `%s` CHANGE COLUMN `ext` `%s` longtext " - "COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD)) - return "renamed ext -> ext_json" - execute_sql("ALTER TABLE `%s` ADD COLUMN `%s` longtext " - "COMMENT '扩展JSON(设计§J1)'" % (TABLE, EXT_FIELD)) - return "added ext_json" + await sor.C(TABLE, payload) + finally: + if need_close: + await sor.close() + return payload + + +async def update_ref(ref_id: str, tenant_id: str, changes: dict) -> int: + """按 id + tenant_id 更新(租户并入 where,跨租户更新天然不命中)。""" + assert_writable(TABLE) + payload = {k: clip(k, v) for k, v in (changes or {}).items() + if k in FIELD_MAXLEN and k not in ("id", "tenant_id")} + if not payload or not tenant_id: + return 0 + where = {"id": clip("id", ref_id), "tenant_id": clip("tenant_id", tenant_id), + "is_deleted": "0"} + sor, need_close = sor_context() + try: + result = await sor.U(TABLE, {"data": payload, "where": where}) + finally: + if need_close: + await sor.close() + if isinstance(result, int): + return result + return 1 if result else 0 + + +async def soft_delete_ref(ref_id: str, tenant_id: str, operator: str = "") -> int: + """逻辑删除(bind_state -> unbound、is_deleted -> 1),不做物理 delete。""" + changes = {"bind_state": "unbound", "is_deleted": "1"} + if operator: + changes["updated_by"] = clip("updated_by", operator) + return await update_ref(ref_id, tenant_id, changes) + + +# ---------------------------------------------------------------- 读:基础域基表 + +async def read_base(table: str, where: dict, columns=None, limit: int = 0): + """只读基础域基表(world/scene/entity);本函数无任何写分支。""" + if table not in BASE_TABLES: + raise ValueError(f"unknown base table {table!r}") + cols = ", ".join(f"`{c}`" for c in (columns or _base_columns(table))) + conds, params = [], [] + for key, value in (where or {}).items(): + if value in (None, ""): + continue + conds.append(f"`{key}` = %s") + params.append(as_text(value).strip()[:64]) + sql = f"SELECT {cols} FROM `{table}`" + if conds: + sql += " WHERE " + " AND ".join(conds) + if limit and limit > 0: + sql += " LIMIT %s" + params.append(int(limit)) + sor, need_close = sor_context() + try: + rows = await sor.sqlExe(sql, params) + finally: + if need_close: + await sor.close() + return [_row_to_dict(r) for r in (rows or [])] + + +async def read_base_many(table: str, column: str, values, columns=None): + """基表 IN 批量读(避免 N+1;values 去空去重,上限 500)。""" + if table not in BASE_TABLES: + raise ValueError(f"unknown base table {table!r}") + vals = [] + for v in values or []: + text = as_text(v).strip()[:64] + if text and text not in vals: + vals.append(text) + if not vals: + return [] + vals = vals[:500] + cols = ", ".join(f"`{c}`" for c in (columns or _base_columns(table))) + placeholders = ", ".join(["%s"] * len(vals)) + sql = f"SELECT {cols} FROM `{table}` WHERE `{column}` IN ({placeholders})" + sor, need_close = sor_context() + try: + rows = await sor.sqlExe(sql, vals) + finally: + if need_close: + await sor.close() + return [_row_to_dict(r) for r in (rows or [])] + + +# ---------------------------------------------------------------- 内部工具 + +def _base_columns(table: str): + from .base import BASE_COLUMNS + return BASE_COLUMNS.get(table, ("id",)) + + +def _row_to_dict(row): + """sqlExe 返回 DictObject(不能 dict(row));逐键取属性/映射。""" + if isinstance(row, dict): + return dict(row) + out = {} + keys = getattr(row, "keys", None) + if callable(keys): + for key in keys(): + out[key] = getattr(row, key, None) + if not out: + for key in FIELD_MAXLEN: + value = getattr(row, key, None) + if value is not None: + out[key] = value + return out diff --git a/pbl_domain_ext/errors.py b/pbl_domain_ext/errors.py index ed92387..2673d00 100644 --- a/pbl_domain_ext/errors.py +++ b/pbl_domain_ext/errors.py @@ -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 "" % (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) diff --git a/pbl_domain_ext/init.py b/pbl_domain_ext/init.py index cb9cb8b..2c3f411 100644 --- a/pbl_domain_ext/init.py +++ b/pbl_domain_ext/init.py @@ -1,26 +1,29 @@ # -*- coding: utf-8 -*- -"""pbl_domain_ext 模块挂载入口。 +"""pbl_domain_ext 模块挂载入口(load_pbl_domain_ext)。 应用侧调用 ``load_pbl_domain_ext()`` 完成: - 1. 注册 13 个 dspy 契约路由(wwwroot/api/*.dspy -> api.py 契约函数); - 2. 注册 RBAC 权限点(owner.pbl / tenant 管理员可写,其余只读); - 3. 注入 sqlor 句柄与库名(ServerEnv().get_module_dbname); - 4. 幂等保障扩展列名为设计 §J1 权威名 ``ext_json``(旧名 ext_json 自动 RENAME)。 + 1. 把 13 个契约函数 + 1 个物化函数注册到 ServerEnv(.dspy / .ui 直接按名调用); + 2. 暴露路由表 / RBAC 权限点表供 scripts/load_path.py 使用; + 3. 库名由宿主应用 ``get_module_dbname('pbl_domain_ext')`` 决定,本模块不硬编码。 -铁律:本模块**不创建/不修改** world、scene、entity 三张基础域基表。 +铁律:本模块**不创建 / 不修改** world、scene、entity 三张基础域基表, + 唯一新增表是关联表 ``pbl_domain_ref``(见 models/ + sql/)。 + +⚠️ 三处同步(module-development-spec):实现 api.py → 本文件 env.xxx = xxx + → 包 ``__init__.py`` import。新增/删除函数必须三处一起改。 """ from . import api, db -from .base import TABLE, EXT_FIELD, REF_TYPES, BIND_STATES -from .errors import PblDomainExtError +from .base import ( + BASE_TABLES, BIND_STATES, FIELD_MAXLEN, LIST_FIELDS, MODULE_NAME, REF_TYPES, + TABLE, +) __all__ = ["load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS", - "CONTRACT_FUNCS", "_register_serverenv", - "TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES"] + "CONTRACT_FUNCS", "TABLE", "REF_TYPES", "BIND_STATES", + "BASE_TABLES", "FIELD_MAXLEN", "LIST_FIELDS"] -MODULE_NAME = "pbl_domain_ext" - -#: dspy 契约路由表:url 后缀 -> (action 名, 是否写操作) +#: dspy 契约路由表:url 后缀 -> (api 函数名, 是否写操作) API_ROUTES = [ ("pbl_domain_ref_bind", "pbl_domain_ref_bind", True), ("pbl_domain_ref_unbind", "pbl_domain_ref_unbind", True), @@ -37,113 +40,68 @@ API_ROUTES = [ ("pbl_team_list_by_class", "pbl_team_list_by_class", False), ] -#: RBAC 权限点(写 4 个 + 读 1 个聚合) +#: 跨模块 import 契约(pbl_compiler 依赖,不进 dspy 路由) +CROSS_MODULE_FUNCS = ["pbl_domain_materialize_game_definition"] + +#: RBAC 权限点(写 3 个 + 读 1 个聚合) PERMISSIONS = [ {"code": "pbl_domain_ext:ref:write", "name": "PBL基础域关联-写", "note": "绑定/解绑/更新 pbl_domain_ref"}, {"code": "pbl_domain_ext:ref:read", "name": "PBL基础域关联-读", "note": "查询关联记录与基础域只读视图"}, {"code": "pbl_domain_ext:team:bind", "name": "PBL团队绑定世界", - "note": "team/class 与 world 的绑定关系维护"}, + "note": "pbl_team_bind_world / pbl_team_world_list"}, + {"code": "pbl_domain_ext:gd:materialize", "name": "PBL物化GD片段", + "note": "供 pbl_compiler 编译期调用"}, ] -#: 契约函数名(与 wwwroot/api/*.dspy 一一对应;三处注册之③ load 期挂 ServerEnv) -CONTRACT_FUNCS = [ - "pbl_domain_ref_bind", "pbl_domain_ref_unbind", "pbl_domain_ref_update", - "pbl_domain_ref_get", "pbl_domain_ref_list", "pbl_domain_ref_check_access", - "pbl_world_list_by_tenant", "pbl_world_get_context", - "pbl_scene_list_by_world", "pbl_entity_list_by_scene", - "pbl_team_bind_world", "pbl_team_world_list", "pbl_team_list_by_class", - "pbl_domain_materialize_game_definition", -] +def contract_funcs(): + """契约函数名清单(含跨模块物化函数),供自检与测试遍历。""" + names = [name for _url, name, _w in API_ROUTES] + names.extend(CROSS_MODULE_FUNCS) + return names -def _register_serverenv(): - """把契约函数注册为 ServerEnv 全局(.dspy 直接按名调用,无需 import)。 +#: 模块对外全部可调用符号(三处同步的权威清单) +CONTRACT_FUNCS = contract_funcs() - module-development-spec「Triple-place function registration」之第③处: - ① 实现 pbl_domain_ext/api.py ② 包导出 pbl_domain_ext/__init__.py - ③ 本函数 env. = - 缺③ → 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]} diff --git a/sql/pbl_domain_ext.sql b/sql/pbl_domain_ext.sql index efca95e..d823929 100644 --- a/sql/pbl_domain_ext.sql +++ b/sql/pbl_domain_ext.sql @@ -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; diff --git a/tests/fake_db.py b/tests/fake_db.py index 0fa43bb..f7b4e2f 100644 --- a/tests/fake_db.py +++ b/tests/fake_db.py @@ -1,16 +1,27 @@ # -*- coding: utf-8 -*- -"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C/U/D/R/I/sqlExe。 +"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C / U / D / R / I / sqlExe。 -要点: - * ``sqlExe`` 先把 ``%s`` 占位按序插值为字面量,再用极简解析器执行 - SELECT / SELECT COUNT(1) / LIMIT / OFFSET / IN / AND 等值条件; - * world / scene / entity 三张基础域基表**只读**:任何 C/U/D/I 写入都会 - 记录到 ``base_write_attempts`` 并抛 AssertionError,用于验证「不改基表」铁律。 +契约(与 pbl_domain_ext/db.py 完全对齐): + * ``C(table, row)`` 插入一行 + * ``U(table, spec)`` spec = {"data": {...}, "where": {...}},返回受影响行数 + * ``D(table, spec)`` spec = {"where": {...}},物理删除(本模块不使用) + * ``R(table, spec)`` spec = {"where": {...}},读取 + * ``I(row)`` 单参数,表名取 context 默认表(pbl_domain_ref) + * ``sqlExe(sql, params)`` 先按序把 ``%s`` 插值为字面量,再用极简解析器执行 + ``SELECT ... FROM t WHERE a = 'x' AND b IN ('1','2') [ORDER BY ...] [LIMIT n] [OFFSET m]`` + 以及 ``SELECT COUNT(1) AS cnt ...``;``1 = 0`` 恒不匹配(fail-closed 断言用)。 + +基表只读把守:world / scene / entity 上任何 C/U/D/I 都会记入 +``base_write_attempts`` 并抛 AssertionError,用于机械验证「不改基表」铁律。 """ import re -__all__ = ["FakeSor", "make_fake_sor", "BASE_FIXTURES"] +__all__ = ["FakeSor", "make_fake_sor", "BASE_FIXTURES", "DEFAULT_TABLE"] + +DEFAULT_TABLE = "pbl_domain_ref" + +BASE_TABLES = ("world", "scene", "entity") BASE_FIXTURES = { "world": [ @@ -23,263 +34,258 @@ BASE_FIXTURES = { ], "scene": [ {"id": "S1", "tenant_id": "T1", "world_id": "W1", "code": "SCN-001", - "name": "着陆区", "status": "active"}, + "name": "基地外围", "status": "published", + "created_at": "2026-09-01 11:00:00"}, {"id": "S2", "tenant_id": "T1", "world_id": "W1", "code": "SCN-002", - "name": "实验舱", "status": "active"}, + "name": "内部舱段", "status": "draft", + "created_at": "2026-09-01 11:05:00"}, {"id": "S3", "tenant_id": "T1", "world_id": "W2", "code": "SCN-003", - "name": "深水区", "status": "active"}, + "name": "海沟观测站", "status": "draft", + "created_at": "2026-09-02 11:00:00"}, + {"id": "S9", "tenant_id": "T2", "world_id": "W3", "code": "SCN-101", + "name": "他租户场景", "status": "draft", + "created_at": "2026-09-03 11:00:00"}, ], "entity": [ {"id": "E1", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-001", - "name": "探测车", "status": "idle"}, + "name": "漫游车", "kind": "vehicle", "created_at": "2026-09-01 12:00:00"}, {"id": "E2", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-002", - "name": "宇航员A", "status": "idle"}, + "name": "太阳能板", "kind": "device", "created_at": "2026-09-01 12:01:00"}, {"id": "E3", "tenant_id": "T1", "scene_id": "S2", "code": "ENT-003", - "name": "培养皿", "status": "idle"}, + "name": "气闸", "kind": "structure", "created_at": "2026-09-01 12:02:00"}, + {"id": "E9", "tenant_id": "T2", "scene_id": "S9", "code": "ENT-101", + "name": "他租户实体", "kind": "vehicle", "created_at": "2026-09-03 12:00:00"}, ], } -_READONLY_TABLES = ("world", "scene", "entity") - -def _literal(value): - """把绑定参数转成 SQL 字面量(仅测试用)。""" +def _lit(value): + """把参数转成 SQL 字面量(仅测试解析用,不参与真实 SQL)。""" if value is None: return "NULL" - if isinstance(value, bool): - return "1" if value else "0" if isinstance(value, (int, float)): return str(value) - return "'%s'" % str(value).replace("\\", "\\\\").replace("'", "''") + return "'" + str(value).replace("'", "''") + "'" -def _coerce(token): - """把字面量 token 还原成 Python 值。""" - token = token.strip() - if token.upper() == "NULL": - return None - if len(token) >= 2 and token[0] == "'" and token[-1] == "'": - return token[1:-1].replace("''", "'").replace("\\\\", "\\") - try: - return int(token) - except ValueError: - pass - try: - return float(token) - except ValueError: - return token +class FakeSor: + """内存 sqlor 替身。""" - -def _loose_eq(left, right): - """宽松相等:兼容 '0' vs 0、str vs int 的列值比较。""" - if left == right: - return True - if left is None or right is None: - return False - try: - return float(left) == float(right) - except (TypeError, ValueError): - return str(left) == str(right) - - -class FakeSor(object): - """内存版 sqlor。""" - - def __init__(self, tables=None, base_fixtures=None): - self.tables = {} - for name, rows in (base_fixtures if base_fixtures is not None - else BASE_FIXTURES).items(): - self.tables[name] = [dict(r) for r in rows] - for name, rows in (tables or {}).items(): - self.tables[name] = [dict(r) for r in rows] - self.tables.setdefault("pbl_domain_ref", []) - self.sql_log = [] + def __init__(self, tables=None, base_fixtures=None, default_table=DEFAULT_TABLE): + self.tables = dict(tables) if tables is not None else {DEFAULT_TABLE: []} + self.tables.setdefault(DEFAULT_TABLE, []) + self.base = {t: [dict(r) for r in (base_fixtures or BASE_FIXTURES)[t]] + for t in BASE_TABLES} + self.default_table = default_table self.base_write_attempts = [] + self.calls = [] + self.closed = False - # ---- sqlor 标准 API ------------------------------------------------- - def C(self, tbl, row): - self._guard_readonly(tbl, "C") - self.tables.setdefault(tbl, []).append(dict(row)) - self.sql_log.append(("C", tbl, dict(row))) + # -------------------------------------------------------- 标准 API 子集 + + def _guard(self, table, op): + self.calls.append((op, table)) + if table in self.base: + self.base_write_attempts.append((op, table)) + raise AssertionError( + f"base table {table!r} is read-only for pbl_domain_ext (op={op})") + + def _rows(self, table): + if table in self.base: + return self.base[table] + return self.tables.setdefault(table, []) + + async def C(self, table, row): + self._guard(table, "C") + self._rows(table).append(dict(row)) return 1 - def U(self, tbl, ns, where=None): - """真实 sqlor 签名:``sor.U(table, ns)`` 两参,where 条件并入 ns。 + async def I(self, row): + table = (row or {}).get("__table__") or self.default_table + return await self.C(table, row) - 与真实实现同构:主键 ``id`` 与租户列 ``tenant_id`` 从 ns 中提出作 WHERE, - 其余列作 SET。三参调用(旧 fake 签名)仍兼容,便于对照回归。 - """ - ns = dict(ns or {}) - if where is None: - where = {} - for key in ("id", "tenant_id"): - if key in ns: - where[key] = ns.pop(key) - if not where: - where = {"id": ns.pop("id", None)} if "id" in ns else {} - self._guard_readonly(tbl, "U") + async def U(self, table, spec): + self._guard(table, "U") + spec = spec or {} + data = spec.get("data") or {} + where = spec.get("where") or {} hits = 0 - for exist in self.tables.get(tbl, []): - if self._match(exist, where): - exist.update(ns) + for row in self._rows(table): + if _match(row, where): + row.update(dict(data)) hits += 1 - self.sql_log.append(("U", tbl, dict(ns), dict(where))) return hits - def D(self, tbl, where): - self._guard_readonly(tbl, "D") - keep, hits = [], 0 - for exist in self.tables.get(tbl, []): - if self._match(exist, where): - hits += 1 - else: - keep.append(exist) - self.tables[tbl] = keep - self.sql_log.append(("D", tbl, dict(where))) - return hits + async def D(self, table, spec): + self._guard(table, "D") + where = (spec or {}).get("where") or {} + rows = self._rows(table) + keep = [r for r in rows if not _match(r, where)] + removed = len(rows) - len(keep) + self.tables[table] = keep + return removed - def R(self, tbl, where=None, fields=None, order_by=None, limit=None): - rows = [dict(r) for r in self.tables.get(tbl, []) - if self._match(r, where or {})] - if fields: - rows = [{k: r.get(k) for k in fields} for r in rows] - if limit: - rows = rows[:int(limit)] - return rows + async def R(self, table, spec): + where = (spec or {}).get("where") or {} + return [dict(r) for r in self._rows(table) if _match(r, where)] - def I(self, tbl, rows): - self._guard_readonly(tbl, "I") - for row in rows or []: - self.tables.setdefault(tbl, []).append(dict(row)) - return len(rows or []) + async def sqlExe(self, sql, params=None): + params = list(params or []) + self.calls.append(("sqlExe", sql.split()[0] if sql else "")) + text = _bind(sql, params) + return self._run_select(text) - def sqlExe(self, sql, args=None): - args = tuple(args or ()) - self.sql_log.append(("sqlExe", " ".join(sql.split()), args)) - text = self._interpolate(" ".join(sql.split()), args) - upper = text.upper() - if upper.startswith("SELECT COUNT(1)"): - return [{"cnt": len(self._rows(text))}] - if upper.startswith("SELECT"): - return self._select(text) - if upper.startswith("ALTER") or upper.startswith("CREATE"): - return 0 - raise AssertionError("FakeSor.sqlExe 不支持的语句: %s" % text) + async def close(self): + self.closed = True - # ---- 内部 ----------------------------------------------------------- - def _guard_readonly(self, tbl, op): - if tbl in _READONLY_TABLES: - self.base_write_attempts.append((op, tbl)) - raise AssertionError("铁律违规:禁止写基础域基表 %s(op=%s)" % (tbl, op)) + # -------------------------------------------------------- 极简 SELECT 执行 - @staticmethod - def _interpolate(text, args): - out, idx, buf = [], 0, [] - i = 0 - while i < len(text): - if text[i] == "%" and text[i:i + 2] == "%s": - buf.append(_literal(args[idx]) if idx < len(args) else "NULL") - idx += 1 - i += 2 - continue - buf.append(text[i]) - i += 1 - out.append("".join(buf)) - return "".join(out) - - @staticmethod - def _match(row, where): - for key, val in (where or {}).items(): - col = key.strip("`") - if isinstance(val, (list, tuple, set)): - if not any(_loose_eq(row.get(col), v) for v in val): - return False - elif not _loose_eq(row.get(col), val): - return False - return True - - @staticmethod - def _table_of(text): - m = re.search(r"FROM\s+`?(\w+)`?", text, re.IGNORECASE) - return m.group(1) if m else "" - - def _conds(self, text): - m = re.search(r"WHERE\s+(.*?)(?:\s+ORDER\s+BY|\s+LIMIT|\s+GROUP\s+BY|\s*$)", - text, re.IGNORECASE | re.DOTALL) + def _run_select(self, text): + text = text.strip().rstrip(";") + m = re.match( + r"(?is)^SELECT\s+(?P.*?)\s+FROM\s+`?(?P[\w]+)`?" + r"(?:\s+WHERE\s+(?P.*?))?" + r"(?:\s+ORDER\s+BY\s+(?P[\w`.]+\s*(?:asc|desc)?))?" + r"(?:\s+LIMIT\s+(?P\d+))?" + r"(?:\s+OFFSET\s+(?P\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.*)\)$", 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.+)$", cond) + if m: + want = _unquote(m.group("val")) + got = row.get(m.group(1)) + if want == "NULL": + return got in (None, "") + return str("" if got is None else got) == str(want) + raise AssertionError(f"FakeSor cannot parse condition: {cond}") + + +def _unquote(token): + token = token.strip() + if len(token) >= 2 and token[0] == "'" and token[-1] == "'": + return token[1:-1].replace("''", "'") + if token.upper() == "NULL": + return None + try: + return int(token) + except ValueError: + return token + + +def _split_top(text: str, sep: str = ","): + """按顶层分隔符切分,忽略括号与引号内的分隔符。""" + out, buf, depth, quote = [], [], 0, None + upper = text.upper() + sepu = sep.upper() + i = 0 + while i < len(text): + ch = text[i] + if quote: + buf.append(ch) + if ch == quote: + quote = None + i += 1 + continue + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch in "'\"": + quote = ch + if depth == 0 and sep != "," and upper[i:i + len(sepu)] == sepu: + out.append("".join(buf)) + buf = [] + i += len(sepu) + continue + if depth == 0 and sep == "," and ch == ",": + out.append("".join(buf)) + buf = [] + i += 1 + continue + buf.append(ch) + i += 1 + if buf: + out.append("".join(buf)) + return [s for s in (x.strip() for x in out) if s] + + +def _bind(sql: str, params) -> str: + """把 ``%s`` 占位按序替换为字面量(仅测试解析用)。""" + out, idx = [], 0 + i = 0 + while i < len(sql): + if sql[i] == "%" and i + 1 < len(sql) and sql[i + 1] == "s": + if idx >= len(params): + raise AssertionError(f"FakeSor: not enough params for {sql}") + out.append(_lit(params[idx])) + idx += 1 + i += 2 + continue + out.append(sql[i]) + i += 1 + if idx != len(params): + raise AssertionError( + f"FakeSor: param count mismatch ({idx} used, {len(params)} given): {sql}") + return "".join(out) + + def make_fake_sor(with_ref_table=True, base_fixtures=None): """构造 FakeSor;with_ref_table=False 时模拟关联表缺失场景。""" - tables = {"pbl_domain_ref": []} if with_ref_table else {} + tables = {DEFAULT_TABLE: []} if with_ref_table else {} return FakeSor(tables=tables, base_fixtures=base_fixtures) diff --git a/tests/test_domain_ref.py b/tests/test_domain_ref.py index 01eb627..a49c250 100644 --- a/tests/test_domain_ref.py +++ b/tests/test_domain_ref.py @@ -5,31 +5,43 @@ cd modules/pbl_domain_ext python3 -m unittest discover -s tests -p 'test_*.py' -v - python3 -m pytest tests/ -q # 若环境已装 pytest + python3 -m pytest tests -q # 若环境已装 pytest -覆盖:13 个 dspy 契约对应的 api 函数 × 租户隔离 / 权限(只读基表) / 正常 / 异常 四类用例, -外加薄扩展铁律(不写基表、表总账只有 pbl_domain_ref 一张新表、ext_json 字段名对齐设计 §J1)。 +覆盖:13 个 dspy 契约 + 1 个物化契约 × 租户隔离(fail-closed) / 基表只读 / +正常 / 异常 四类用例;外加薄扩展铁律(不写基表、只有 1 张新表、列长度口径统一)。 """ +import asyncio import os import sys import unittest HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) -for p in (ROOT, HERE): - if p not in sys.path: - sys.path.insert(0, p) +for _p in (ROOT, HERE): + if _p not in sys.path: + sys.path.insert(0, _p) import fake_db # noqa: E402 -from pbl_domain_ext import api, db, base # noqa: E402 -from pbl_domain_ext.base import EXT_FIELD, REF_TYPES, TABLE # noqa: E402 +from pbl_domain_ext import api, db, base, errors # noqa: E402 +from pbl_domain_ext.base import ( # noqa: E402 + FIELD_MAXLEN, LIST_FIELDS, REF_TYPES, TABLE, +) -T1 = "T1" # 主测租户(BASE_FIXTURES 中 world W1/W2、scene S1..S3、entity E1..E3) -T2 = "T2" # 他租户(BASE_FIXTURES 中仅 world W3) +T1 = "T1" # 主测租户(BASE_FIXTURES: world W1/W2, scene S1..S3, entity E1..E3) +T2 = "T2" # 他租户(仅 world W3 / scene S9 / entity E9) OP = "U_TESTER" +def run(coro): + """在无事件循环的 unittest 中执行协程。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + class BaseCase(unittest.TestCase): """每个用例独立内存库,互不污染。""" @@ -40,409 +52,543 @@ class BaseCase(unittest.TestCase): def tearDown(self): db.set_sor(None) - # ---- 断言助手 ------------------------------------------------------- - def assertOk(self, resp, msg=""): - self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体") - self.assertTrue(resp.get("success"), "%s 期望成功,实得 %r" % (msg, resp)) - self.assertEqual(resp.get("code"), "PBL_DE_OK", msg) - return resp.get("data") + # 便捷断言 + def assert_ok(self, res, msg=""): + self.assertTrue(res.get("ok"), f"expected ok, got {res} {msg}") - def assertErr(self, resp, codes, msg=""): - self.assertIsInstance(resp, dict, "契约必须返回 dict 响应体(不得裸抛栈)") - self.assertFalse(resp.get("success"), "%s 期望失败,实得 %r" % (msg, resp)) - if isinstance(codes, str): - codes = (codes,) - self.assertIn(resp.get("code"), codes, - "%s 错误码期望 %s,实得 %r" % (msg, list(codes), resp)) - self.assertIn("http_status", resp, "失败响应必须带 http_status") - return resp + def assert_fail(self, res, code=None, msg=""): + self.assertFalse(res.get("ok"), f"expected failure, got {res} {msg}") + if code: + self.assertEqual(res.get("code"), code, + f"wrong code: {res} ({msg})") - def bind(self, ref_type, ref_id, tenant_id=T1, **kw): - params = {"tenant_id": tenant_id, "ref_type": ref_type, - "ref_id": ref_id, "operator_id": OP} - params.update(kw) - return api.pbl_domain_ref_bind(params) - - def rows(self, table=TABLE): - return list(self.sor.tables.get(table, [])) + def refs(self): + return self.sor.tables[TABLE] -# ========================================================================== -# 1. 关联表 pbl_domain_ref:绑定 / 解绑 / 更新 / 查询 -# ========================================================================== +# ============================================================ 关联表契约(写) + class TestBind(BaseCase): def test_bind_world_ok(self): - data = self.assertOk(self.bind("world", "W1", blueprint_id="BP1", - class_id="C1", team_id="TEAM_A", - ext_json='{"role":"host"}')) - self.assertEqual(data["ref_type"], "world") - self.assertEqual(str(data["ref_id"]), "W1") - self.assertEqual(data["blueprint_id"], "BP1") - self.assertEqual(data["bind_state"], "bound") - self.assertEqual(data["creator_id"], OP, - "审计列名须与 models 权威名 creator_id 一致") - self.assertEqual(len(self.rows()), 1, "应落 1 行关联记录") + res = run(api.pbl_domain_ref_bind( + {"tenant_id": T1, "ref_type": "world", "ref_id": "W1", + "blueprint_id": "BP1", "operator": OP})) + self.assert_ok(res) + self.assertEqual(res["data"]["action"], "created") + ref = res["data"]["ref"] + # ref_code / ref_name 自动从基表补全 + self.assertEqual(ref["ref_code"], "WLD-001") + self.assertEqual(ref["ref_name"], "火星基地") + self.assertEqual(ref["bind_state"], "bound") + self.assertEqual(len(self.refs()), 1) - def test_bind_scene_and_entity_ok(self): - self.assertOk(self.bind("scene", "S1")) - self.assertOk(self.bind("entity", "E1")) - self.assertEqual(sorted(r["ref_type"] for r in self.rows()), - ["entity", "scene"]) + def test_bind_idempotent_upsert(self): + """同 (tenant, ref_type, ref_id, blueprint) 重复绑定 -> 更新而非新增。""" + p = {"tenant_id": T1, "ref_type": "world", "ref_id": "W1", + "blueprint_id": "BP1", "operator": OP} + first = run(api.pbl_domain_ref_bind(p)) + self.assert_ok(first) + self.assertEqual(first["data"]["action"], "created") + second = run(api.pbl_domain_ref_bind(dict(p, ref_name="改名后的世界"))) + self.assert_ok(second) + self.assertEqual(second["data"]["action"], "updated") + self.assertEqual(len(self.refs()), 1, "upsert 必须幂等,不得重复插入") + self.assertEqual(self.refs()[0]["ref_name"], "改名后的世界") - def test_bind_is_idempotent_upsert(self): - self.assertOk(self.bind("world", "W1", blueprint_id="BP1")) - self.assertOk(self.bind("world", "W1", blueprint_id="BP2")) - self.assertEqual(len(self.rows()), 1, "同键重复绑定不得产生重复行(幂等 upsert)") - self.assertEqual(self.rows()[0]["blueprint_id"], "BP2") + def test_bind_scene_and_entity(self): + for rtype, rid in (("scene", "S1"), ("entity", "E1")): + res = run(api.pbl_domain_ref_bind( + {"tenant_id": T1, "ref_type": rtype, "ref_id": rid, + "blueprint_id": "BP1", "operator": OP})) + self.assert_ok(res, rtype) + self.assertEqual(len(self.refs()), 2) def test_bind_requires_tenant(self): - self.assertErr(api.pbl_domain_ref_bind({"ref_type": "world", "ref_id": "W1"}), - "PBL_DE_TENANT_MISSING", "缺 tenant_id") + res = run(api.pbl_domain_ref_bind( + {"ref_type": "world", "ref_id": "W1"})) + self.assert_fail(res, errors.TENANT_REQUIRED) + self.assertEqual(len(self.refs()), 0) - def test_bind_rejects_bad_ref_type(self): - self.assertErr(self.bind("course", "W1"), "PBL_DE_REF_TYPE_INVALID", - "ref_type 仅允许 world/scene/entity") + def test_bind_invalid_ref_type(self): + res = run(api.pbl_domain_ref_bind( + {"tenant_id": T1, "ref_type": "player", "ref_id": "W1"})) + self.assert_fail(res, errors.REF_TYPE_INVALID) - def test_bind_rejects_empty_ref_id(self): - self.assertErr(self.bind("world", ""), "PBL_DE_PARAM_INVALID", "ref_id 为空") + def test_bind_missing_ref_id(self): + res = run(api.pbl_domain_ref_bind( + {"tenant_id": T1, "ref_type": "world"})) + self.assert_fail(res, errors.PARAM_INVALID) - def test_bind_rejects_missing_base_row(self): - self.assertErr(self.bind("world", "W_NOT_EXIST"), - ("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND"), - "基表未命中的对象不得建立关联") + def test_bind_cross_tenant_denied(self): + """T1 不能绑定 T2 的世界(基表租户可见性校验,fail-closed)。""" + res = run(api.pbl_domain_ref_bind( + {"tenant_id": T1, "ref_type": "world", "ref_id": "W3"})) + self.assert_fail(res, errors.BASE_OBJECT_NOT_FOUND) + self.assertEqual(len(self.refs()), 0) + self.assertEqual(self.sor.base_write_attempts, [], + "校验路径不得写基表") - def test_bind_rejects_invalid_ext_json(self): - self.assertErr(self.bind("world", "W1", ext_json="{not json"), - "PBL_DE_EXT_JSON_INVALID", "ext_json 必须合法 JSON") - - def test_bind_tenant_isolation(self): - self.assertOk(self.bind("world", "W1", tenant_id=T1)) - self.assertErr(self.bind("world", "W1", tenant_id=T2), - ("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), - "W1 属 T1,T2 不得绑定") + def test_bind_does_not_write_base_tables(self): + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1", "operator": OP})) + self.assertEqual(self.sor.base_write_attempts, []) + writes = [c for c in self.sor.calls + if c[1] in fake_db.BASE_TABLES and c[0] in ("C", "U", "D", "I")] + self.assertEqual(writes, []) -class TestUnbindUpdateGet(BaseCase): +class TestUnbind(BaseCase): def test_unbind_soft_delete(self): - self.assertOk(self.bind("world", "W1")) - data = self.assertOk(api.pbl_domain_ref_unbind( - {"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "operator_id": OP})) - self.assertIn(data.get("bind_state", "unbound"), ("unbound", "deleted")) - alive = [r for r in self.rows() if str(r.get("is_deleted", "0")) in ("0", "")] - self.assertEqual(alive, [], "解绑后不应再有生效关联行") + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1", "operator": OP})) + self.assert_ok(bound) + rid = bound["data"]["ref"]["id"] + res = run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid, + "operator": OP})) + self.assert_ok(res) + self.assertEqual(res["data"]["action"], "unbound") + row = self.refs()[0] + self.assertEqual(row["is_deleted"], "1", "必须是逻辑删除") + self.assertEqual(row["bind_state"], "unbound") + self.assertEqual(len(self.refs()), 1, "不得物理删除记录") + # 逻辑删除后列表/详情不可见 + self.assert_fail(run(api.pbl_domain_ref_get( + {"tenant_id": T1, "id": rid})), errors.REF_NOT_FOUND) - def test_unbind_missing(self): - self.assertErr(api.pbl_domain_ref_unbind( - {"tenant_id": T1, "ref_type": "world", "ref_id": "W1"}), - "PBL_DE_NOT_FOUND", "解绑不存在的关联") + def test_unbind_idempotent(self): + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene", + "ref_id": "S1"})) + rid = bound["data"]["ref"]["id"] + run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid})) + again = run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid})) + self.assert_ok(again) + self.assertEqual(again["data"]["action"], "noop") + + def test_unbind_requires_tenant_and_id(self): + self.assert_fail(run(api.pbl_domain_ref_unbind({"id": "X"})), + errors.TENANT_REQUIRED) + self.assert_fail(run(api.pbl_domain_ref_unbind({"tenant_id": T1})), + errors.PARAM_INVALID) def test_unbind_cross_tenant_denied(self): - self.assertOk(self.bind("world", "W1", tenant_id=T1)) - self.assertErr(api.pbl_domain_ref_unbind( - {"tenant_id": T2, "ref_type": "world", "ref_id": "W1"}), - ("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得解绑他租户关联") + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + rid = bound["data"]["ref"]["id"] + res = run(api.pbl_domain_ref_unbind({"tenant_id": T2, "id": rid})) + self.assert_fail(res, errors.REF_NOT_FOUND) + self.assertEqual(self.refs()[0]["is_deleted"], "0", + "跨租户不得改动他租户记录") - def test_update_ext_json_ok(self): - self.assertOk(self.bind("world", "W1", ext_json='{"a":1}')) - data = self.assertOk(api.pbl_domain_ref_update( - {"tenant_id": T1, "ref_type": "world", "ref_id": "W1", - "ext_json": '{"a":2,"b":"x"}', "class_id": "C9", "operator_id": OP})) - self.assertEqual(data["class_id"], "C9") - self.assertIn("ext_json", data, "响应必须用设计 §J1 权威字段名 ext_json") - self.assertEqual(self.rows()[0][EXT_FIELD] and '"a": 2' in self.rows()[0][EXT_FIELD] - or '"a":2' in self.rows()[0][EXT_FIELD], True) - self.assertEqual(self.rows()[0]["updater_id"], OP) - def test_update_missing(self): - self.assertErr(api.pbl_domain_ref_update( - {"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "class_id": "C1"}), - "PBL_DE_NOT_FOUND", "更新不存在的关联") +class TestUpdate(BaseCase): + + def test_update_ok(self): + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1", "operator": OP})) + rid = bound["data"]["ref"]["id"] + res = run(api.pbl_domain_ref_update({ + "tenant_id": T1, "id": rid, "ref_name": "新名字", + "team_id": "TM1", "class_id": "CL1", "remark": "备注", + "operator": OP})) + self.assert_ok(res) + self.assertEqual(res["data"]["ref"]["ref_name"], "新名字") + self.assertEqual(res["data"]["ref"]["team_id"], "TM1") + self.assertEqual(self.refs()[0]["updated_by"], OP) + + def test_update_field_length_clipped_per_column(self): + """列长度口径统一:blueprint_id=32 / ref_code=64 / ref_name=128, + 禁止统一 [:128] 导致 blueprint_id 溢出写库。""" + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + rid = bound["data"]["ref"]["id"] + res = run(api.pbl_domain_ref_update({ + "tenant_id": T1, "id": rid, + "blueprint_id": "B" * 100, + "ref_code": "C" * 200, + "ref_name": "N" * 300, + "team_id": "T" * 200, + "class_id": "X" * 200})) + self.assert_ok(res) + ref = res["data"]["ref"] + self.assertEqual(len(ref["blueprint_id"]), FIELD_MAXLEN["blueprint_id"]) + self.assertEqual(len(ref["ref_code"]), FIELD_MAXLEN["ref_code"]) + self.assertEqual(len(ref["ref_name"]), FIELD_MAXLEN["ref_name"]) + self.assertEqual(len(ref["team_id"]), FIELD_MAXLEN["team_id"]) + self.assertEqual(len(ref["class_id"]), FIELD_MAXLEN["class_id"]) + # 真正落库的值同样被截到列宽内 + self.assertEqual(len(self.refs()[0]["blueprint_id"]), 32) + + def test_bind_field_length_clipped_per_column(self): + """bind 与 update 共用 FIELD_MAXLEN 口径(两处一致)。""" + res = run(api.pbl_domain_ref_bind({ + "tenant_id": T1, "ref_type": "world", "ref_id": "W2", + "blueprint_id": "B" * 100, "team_id": "T" * 200, + "class_id": "X" * 200, "ref_name": "N" * 300})) + self.assert_ok(res) + ref = res["data"]["ref"] + self.assertEqual(len(ref["blueprint_id"]), 32) + self.assertEqual(len(ref["team_id"]), 64) + self.assertEqual(len(ref["class_id"]), 64) + self.assertEqual(len(ref["ref_name"]), 128) + + def test_update_rejects_unknown_bind_state(self): + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + res = run(api.pbl_domain_ref_update({"tenant_id": T1, + "id": bound["data"]["ref"]["id"], + "bind_state": "frozen"})) + self.assert_fail(res, errors.PARAM_INVALID) + + def test_update_noop_when_no_changes(self): + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + rid = bound["data"]["ref"]["id"] + res = run(api.pbl_domain_ref_update({"tenant_id": T1, "id": rid})) + self.assert_ok(res) + self.assertEqual(res["data"]["action"], "noop") def test_update_cross_tenant_denied(self): - self.assertOk(self.bind("world", "W1", tenant_id=T1)) - self.assertErr(api.pbl_domain_ref_update( - {"tenant_id": T2, "ref_type": "world", "ref_id": "W1", "class_id": "X"}), - ("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), "跨租户不得改他租户关联") + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + rid = bound["data"]["ref"]["id"] + res = run(api.pbl_domain_ref_update({"tenant_id": T2, "id": rid, + "ref_name": "越权改名"})) + self.assert_fail(res, errors.REF_NOT_FOUND) + self.assertEqual(self.refs()[0]["ref_name"], "火星基地") - def test_get_by_id_and_by_key(self): - bound = self.assertOk(self.bind("scene", "S1", blueprint_id="BP7")) - by_id = self.assertOk(api.pbl_domain_ref_get({"tenant_id": T1, "id": bound["id"]})) - self.assertEqual(by_id["id"], bound["id"]) - by_key = self.assertOk(api.pbl_domain_ref_get( - {"tenant_id": T1, "ref_type": "scene", "ref_id": "S1", "with_base": 1})) - self.assertEqual(by_key["id"], bound["id"]) - self.assertEqual(by_key["base"]["id"], "S1", "with_base 应附基表只读视图") + def test_update_requires_tenant(self): + self.assert_fail(run(api.pbl_domain_ref_update({"id": "X"})), + errors.TENANT_REQUIRED) - def test_get_missing(self): - self.assertErr(api.pbl_domain_ref_get({"tenant_id": T1, "id": "NOPE"}), - "PBL_DE_NOT_FOUND") + +class TestGetAndList(BaseCase): + + def test_get_ok_and_shape(self): + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + rid = bound["data"]["ref"]["id"] + res = run(api.pbl_domain_ref_get({"tenant_id": T1, "id": rid})) + self.assert_ok(res) + self.assertEqual(sorted(res["data"].keys()), sorted(LIST_FIELDS)) def test_get_cross_tenant_denied(self): - bound = self.assertOk(self.bind("world", "W1", tenant_id=T1)) - self.assertErr(api.pbl_domain_ref_get({"tenant_id": T2, "id": bound["id"]}), - ("PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED"), - "T2 不得读 T1 的关联记录") + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + self.assert_fail(run(api.pbl_domain_ref_get( + {"tenant_id": T2, "id": bound["data"]["ref"]["id"]})), + errors.REF_NOT_FOUND) - def test_list_filter_and_paging(self): - self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A")) - self.assertOk(self.bind("scene", "S1", class_id="C1", team_id="TEAM_A")) - self.assertOk(self.bind("entity", "E1", class_id="C2", team_id="TEAM_B")) - data = self.assertOk(api.pbl_domain_ref_list( - {"tenant_id": T1, "class_id": "C1", "page": 1, "page_size": 10})) - self.assertEqual(data["total"], 2) - self.assertEqual(len(data["items"]), 2) - data2 = self.assertOk(api.pbl_domain_ref_list( - {"tenant_id": T1, "ref_type": "entity"})) - self.assertEqual([i["ref_id"] for i in data2["items"]], ["E1"]) - data3 = self.assertOk(api.pbl_domain_ref_list( - {"tenant_id": T1, "page": 1, "page_size": 2})) - self.assertLessEqual(len(data3["items"]), 2, "page_size 必须生效") + def test_list_pagination_and_filter(self): + for rid in ("W1", "W2"): + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": rid, "blueprint_id": "BP1"})) + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene", + "ref_id": "S1", "blueprint_id": "BP1"})) + res = run(api.pbl_domain_ref_list({"tenant_id": T1, "page": 1, "rows": 2})) + self.assert_ok(res) + self.assertEqual(res["data"]["total"], 3) + self.assertEqual(len(res["data"]["rows"]), 2) + filtered = run(api.pbl_domain_ref_list({"tenant_id": T1, + "ref_type": "world"})) + self.assertEqual(filtered["data"]["total"], 2) + by_bp = run(api.pbl_domain_ref_list({"tenant_id": T1, + "blueprint_id": "BP1", + "bind_state": "bound"})) + self.assertEqual(by_bp["data"]["total"], 3) + + def test_list_invalid_ref_type(self): + res = run(api.pbl_domain_ref_list({"tenant_id": T1, "ref_type": "bogus"})) + self.assert_fail(res, errors.REF_TYPE_INVALID) def test_list_requires_tenant(self): - self.assertErr(api.pbl_domain_ref_list({"ref_type": "world"}), - "PBL_DE_TENANT_MISSING") + self.assert_fail(run(api.pbl_domain_ref_list({})), + errors.TENANT_REQUIRED) - def test_list_tenant_isolation(self): - self.assertOk(self.bind("world", "W1", tenant_id=T1)) - data = self.assertOk(api.pbl_domain_ref_list({"tenant_id": T2})) - self.assertEqual(data["total"], 0, "T2 不得看到 T1 的关联记录") + def test_list_isolated_per_tenant(self): + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + res = run(api.pbl_domain_ref_list({"tenant_id": T2})) + self.assert_ok(res) + self.assertEqual(res["data"]["total"], 0) class TestCheckAccess(BaseCase): - def test_check_access_bound_ok(self): - self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A")) - data = self.assertOk(api.pbl_domain_ref_check_access( - {"tenant_id": T1, "ref_type": "world", "ref_id": "W1", - "class_id": "C1", "team_id": "TEAM_A"})) - self.assertTrue(data.get("allowed") or data.get("access"), - "已绑定且班级/团队匹配应放行:%r" % data) + def test_check_access_by_id(self): + bound = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1"})) + rid = bound["data"]["ref"]["id"] + self.assertTrue(run(api.pbl_domain_ref_check_access( + {"tenant_id": T1, "id": rid}))["data"]["allowed"]) + self.assertFalse(run(api.pbl_domain_ref_check_access( + {"tenant_id": T2, "id": rid}))["data"]["allowed"]) - def test_check_access_other_team_denied(self): - self.assertOk(self.bind("world", "W1", class_id="C1", team_id="TEAM_A")) - resp = api.pbl_domain_ref_check_access( - {"tenant_id": T1, "ref_type": "world", "ref_id": "W1", "team_id": "TEAM_Z"}) - data = resp.get("data") or {} - if resp.get("success"): - self.assertFalse(data.get("allowed") and data.get("access", True), - "他团队不得放行:%r" % data) - else: - self.assertIn(resp.get("code"), ("PBL_DE_ACCESS_DENIED", "PBL_DE_NOT_FOUND")) + def test_check_access_by_object(self): + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene", + "ref_id": "S2"})) + self.assertTrue(run(api.pbl_domain_ref_check_access( + {"tenant_id": T1, "ref_type": "scene", "ref_id": "S2"})) + ["data"]["allowed"]) + self.assertFalse(run(api.pbl_domain_ref_check_access( + {"tenant_id": T1, "ref_type": "scene", "ref_id": "S3"})) + ["data"]["allowed"]) - def test_check_access_cross_tenant_denied(self): - self.assertOk(self.bind("world", "W1", tenant_id=T1)) - resp = api.pbl_domain_ref_check_access( - {"tenant_id": T2, "ref_type": "world", "ref_id": "W1"}) - data = resp.get("data") or {} - self.assertFalse(resp.get("success") and (data.get("allowed") or data.get("access")), - "跨租户不得放行") - - def test_check_access_requires_params(self): - self.assertErr(api.pbl_domain_ref_check_access({"tenant_id": T1}), - ("PBL_DE_PARAM_INVALID", "PBL_DE_REF_TYPE_INVALID"), - "缺 ref_type/ref_id") + def test_check_access_fail_closed_without_params(self): + res = run(api.pbl_domain_ref_check_access({"tenant_id": T1})) + self.assert_ok(res) + self.assertFalse(res["data"]["allowed"]) + self.assert_fail(run(api.pbl_domain_ref_check_access({})), + errors.TENANT_REQUIRED) -# ========================================================================== -# 2. 基础域只读契约(world / scene / entity) -# ========================================================================== -class TestBaseDomainReadOnly(BaseCase): +# ============================================================ 基表只读视图契约 + +class TestBaseViews(BaseCase): def test_world_list_by_tenant(self): - data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1})) - ids = sorted(str(i["id"]) for i in data["items"]) - self.assertEqual(ids, ["W1", "W2"], "只返回本租户世界") - self.assertTrue(data.get("readonly"), "必须标注只读") + res = run(api.pbl_world_list_by_tenant({"tenant_id": T1})) + self.assert_ok(res) + ids = sorted(r["id"] for r in res["data"]["rows"]) + self.assertEqual(ids, ["W1", "W2"]) + self.assertEqual(res["data"]["total"], 2) - def test_world_list_tenant_isolation(self): - data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T2})) - self.assertEqual([str(i["id"]) for i in data["items"]], ["W3"]) + def test_world_list_cross_tenant_isolated(self): + res = run(api.pbl_world_list_by_tenant({"tenant_id": T2})) + self.assertEqual([r["id"] for r in res["data"]["rows"]], ["W3"]) - def test_world_list_requires_tenant(self): - self.assertErr(api.pbl_world_list_by_tenant({}), "PBL_DE_TENANT_MISSING") - - def test_world_list_marks_bound(self): - self.assertOk(self.bind("world", "W1", blueprint_id="BP1")) - data = self.assertOk(api.pbl_world_list_by_tenant({"tenant_id": T1, "only_bound": 1})) - self.assertEqual([str(i["id"]) for i in data["items"]], ["W1"]) - self.assertTrue(data["items"][0]["pbl_bound"]) - - def test_world_get_context(self): - self.assertOk(self.bind("world", "W1", blueprint_id="BP1")) - data = self.assertOk(api.pbl_world_get_context( - {"tenant_id": T1, "world_id": "W1", "with_scenes": 1, "with_entities": 1})) - self.assertEqual(str(data["world"]["id"]), "W1") - self.assertEqual(sorted(str(s["id"]) for s in data.get("scenes", [])), ["S1", "S2"]) + def test_world_get_context_ok(self): + res = run(api.pbl_world_get_context({"tenant_id": T1, "world_id": "W1"})) + self.assert_ok(res) + data = res["data"] + self.assertEqual(data["world"]["name"], "火星基地") + self.assertEqual(data["scene_count"], 2) + self.assertEqual(data["entity_count"], 3) + s1 = [s for s in data["scenes"] if s["id"] == "S1"][0] + self.assertEqual(s1["entity_count"], 2) + self.assertEqual(sorted(e["id"] for e in s1["entities"]), ["E1", "E2"]) def test_world_get_context_missing(self): - self.assertErr(api.pbl_world_get_context({"tenant_id": T1, "world_id": "NOPE"}), - ("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING")) + self.assert_fail(run(api.pbl_world_get_context( + {"tenant_id": T1, "world_id": "W999"})), + errors.BASE_OBJECT_NOT_FOUND) + self.assert_fail(run(api.pbl_world_get_context( + {"tenant_id": T1})), errors.PARAM_INVALID) def test_world_get_context_cross_tenant(self): - self.assertErr(api.pbl_world_get_context({"tenant_id": T2, "world_id": "W1"}), - ("PBL_DE_NOT_FOUND", "PBL_DE_BASE_MISSING", "PBL_DE_ACCESS_DENIED"), - "W1 属 T1,T2 不得取其上下文") + self.assert_fail(run(api.pbl_world_get_context( + {"tenant_id": T1, "world_id": "W3"})), + errors.BASE_OBJECT_NOT_FOUND) def test_scene_list_by_world(self): - data = self.assertOk(api.pbl_scene_list_by_world( - {"tenant_id": T1, "world_id": "W1"})) - self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["S1", "S2"]) - - def test_scene_list_requires_world_id(self): - self.assertErr(api.pbl_scene_list_by_world({"tenant_id": T1}), - "PBL_DE_PARAM_INVALID") - - def test_scene_list_cross_tenant_empty(self): - data = self.assertOk(api.pbl_scene_list_by_world( - {"tenant_id": T2, "world_id": "W1"})) - self.assertEqual(data["items"], [], "跨租户不得看到他租户场景") + res = run(api.pbl_scene_list_by_world({"tenant_id": T1, "world_id": "W1"})) + self.assert_ok(res) + self.assertEqual(sorted(r["id"] for r in res["data"]["rows"]), + ["S1", "S2"]) + self.assert_fail(run(api.pbl_scene_list_by_world({"tenant_id": T1})), + errors.PARAM_INVALID) def test_entity_list_by_scene(self): - data = self.assertOk(api.pbl_entity_list_by_scene( - {"tenant_id": T1, "scene_id": "S1"})) - self.assertEqual(sorted(str(i["id"]) for i in data["items"]), ["E1", "E2"]) + res = run(api.pbl_entity_list_by_scene({"tenant_id": T1, "scene_id": "S1"})) + self.assert_ok(res) + self.assertEqual(sorted(r["id"] for r in res["data"]["rows"]), + ["E1", "E2"]) + other = run(api.pbl_entity_list_by_scene({"tenant_id": T2, + "scene_id": "S1"})) + self.assertEqual(other["data"]["total"], 0) - def test_entity_list_requires_scene_id(self): - self.assertErr(api.pbl_entity_list_by_scene({"tenant_id": T1}), - "PBL_DE_PARAM_INVALID") + def test_base_views_require_tenant(self): + for coro in (api.pbl_world_list_by_tenant({}), + api.pbl_world_get_context({"world_id": "W1"}), + api.pbl_scene_list_by_world({"world_id": "W1"}), + api.pbl_entity_list_by_scene({"scene_id": "S1"})): + self.assert_fail(run(coro), errors.TENANT_REQUIRED) + + def test_base_views_never_write_base_tables(self): + run(api.pbl_world_list_by_tenant({"tenant_id": T1})) + run(api.pbl_world_get_context({"tenant_id": T1, "world_id": "W1"})) + run(api.pbl_scene_list_by_world({"tenant_id": T1, "world_id": "W1"})) + run(api.pbl_entity_list_by_scene({"tenant_id": T1, "scene_id": "S1"})) + self.assertEqual(self.sor.base_write_attempts, []) -# ========================================================================== -# 3. 班级 / 团队维度契约 -# ========================================================================== -class TestTeamClass(BaseCase): +# ============================================================ 团队 × 世界契约 - def test_team_bind_world_ok(self): - data = self.assertOk(api.pbl_team_bind_world( - {"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A", - "class_id": "C1", "operator_id": OP})) - self.assertEqual(data["team_id"], "TEAM_A") - self.assertEqual(data["ref_type"], "world") - self.assertEqual(len(self.rows()), 1) +class TestTeamWorld(BaseCase): - def test_team_bind_world_idempotent(self): - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"}) - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"}) - self.assertEqual(len(self.rows()), 1, "重复绑定不得产生重复行") + def test_team_bind_world_and_list(self): + res = run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1", + "class_id": "CL1", "world_id": "W1", + "operator": OP})) + self.assert_ok(res) + self.assertEqual(res["data"]["ref"]["ref_type"], "world") + listed = run(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TM1"})) + self.assert_ok(listed) + self.assertEqual(listed["data"]["total"], 1) + row = listed["data"]["rows"][0] + self.assertEqual(row["world"]["id"], "W1") + self.assertEqual(row["class_id"], "CL1") - def test_team_bind_world_requires_team_id(self): - self.assertErr(api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1"}), - "PBL_DE_PARAM_INVALID") + def test_team_world_list_skips_invisible_base_object(self): + """关联存在但基表对象不可见(世界被删)→ 不呈现(fail-closed)。""" + run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1", + "world_id": "W1"})) + self.sor.base["world"] = [w for w in self.sor.base["world"] + if w["id"] != "W1"] + listed = run(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TM1"})) + self.assertEqual(listed["data"]["total"], 0) - def test_team_bind_world_base_missing(self): - self.assertErr(api.pbl_team_bind_world( - {"tenant_id": T1, "world_id": "NOPE", "team_id": "TEAM_A"}), - ("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND")) + def test_team_world_list_requires_team(self): + self.assert_fail(run(api.pbl_team_world_list({"tenant_id": T1})), + errors.PARAM_INVALID) + self.assert_fail(run(api.pbl_team_world_list({"team_id": "TM1"})), + errors.TENANT_REQUIRED) - def test_team_bind_world_cross_tenant(self): - self.assertErr(api.pbl_team_bind_world( - {"tenant_id": T2, "world_id": "W1", "team_id": "TEAM_A"}), - ("PBL_DE_BASE_MISSING", "PBL_DE_NOT_FOUND", "PBL_DE_ACCESS_DENIED")) + def test_team_world_list_isolated_per_tenant(self): + run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1", + "world_id": "W1"})) + res = run(api.pbl_team_world_list({"tenant_id": T2, "team_id": "TM1"})) + self.assertEqual(res["data"]["total"], 0) - def test_team_world_list(self): - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"}) - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", "team_id": "TEAM_B"}) - data = self.assertOk(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TEAM_A"})) - items = data["items"] if isinstance(data, dict) else data - self.assertEqual(sorted(str(i.get("id") or i.get("world_id")) for i in items), ["W1"]) + def test_team_list_by_class_group_counts(self): + for team, world in (("TM1", "W1"), ("TM1", "W2"), ("TM2", "W1")): + run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": team, + "class_id": "CL1", "world_id": world})) + res = run(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "CL1"})) + self.assert_ok(res) + counts = {r["team_id"]: r["world_count"] for r in res["data"]["rows"]} + self.assertEqual(counts, {"TM1": 2, "TM2": 1}) + self.assertEqual(res["data"]["total"], 2) + self.assert_fail(run(api.pbl_team_list_by_class({"tenant_id": T1})), + errors.PARAM_INVALID) - def test_team_world_list_requires_team_id(self): - self.assertErr(api.pbl_team_world_list({"tenant_id": T1}), "PBL_DE_PARAM_INVALID") - - def test_team_world_list_cross_tenant_empty(self): - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", "team_id": "TEAM_A"}) - data = self.assertOk(api.pbl_team_world_list({"tenant_id": T2, "team_id": "TEAM_A"})) - items = data["items"] if isinstance(data, dict) else data - self.assertEqual(list(items), [], "跨租户不得返回他租户团队世界") - - def test_team_list_by_class(self): - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W1", - "team_id": "TEAM_A", "class_id": "C1"}) - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", - "team_id": "TEAM_B", "class_id": "C2"}) - data = self.assertOk(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "C1"})) - items = data["items"] if isinstance(data, dict) else data - self.assertEqual(len(items), 1, "只返回该班级团队") - - def test_team_list_by_class_requires_class_id(self): - self.assertErr(api.pbl_team_list_by_class({"tenant_id": T1}), "PBL_DE_PARAM_INVALID") + def test_unbound_team_world_not_counted(self): + run(api.pbl_team_bind_world({"tenant_id": T1, "team_id": "TM1", + "class_id": "CL1", "world_id": "W1"})) + bound = run(api.pbl_team_world_list({"tenant_id": T1, "team_id": "TM1"})) + rid = bound["data"]["rows"][0]["id"] + run(api.pbl_domain_ref_unbind({"tenant_id": T1, "id": rid})) + res = run(api.pbl_team_list_by_class({"tenant_id": T1, "class_id": "CL1"})) + self.assertEqual(res["data"]["rows"], []) -# ========================================================================== -# 4. 薄扩展铁律 / 契约齐备 / 设计对齐 -# ========================================================================== +# ============================================================ 物化契约(pbl_compiler) + +class TestMaterialize(BaseCase): + + def test_materialize_groups_by_ref_type(self): + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1", "blueprint_id": "BP1"})) + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "scene", + "ref_id": "S1", "blueprint_id": "BP1"})) + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "entity", + "ref_id": "E1", "blueprint_id": "BP1"})) + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W2", "blueprint_id": "BP2"})) + res = run(api.pbl_domain_materialize_game_definition( + {"tenant_id": T1, "blueprint_id": "BP1"})) + self.assert_ok(res) + data = res["data"] + self.assertEqual(data["counts"], {"world": 1, "scene": 1, "entity": 1}) + self.assertEqual(len(data["worlds"]), 1) + self.assertEqual(data["worlds"][0]["base"]["id"], "W1") + self.assertEqual(data["table"], TABLE) + + def test_materialize_excludes_unbound_and_invisible(self): + b1 = run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W1", "blueprint_id": "BP1"})) + run(api.pbl_domain_ref_bind({"tenant_id": T1, "ref_type": "world", + "ref_id": "W2", "blueprint_id": "BP1"})) + run(api.pbl_domain_ref_unbind({"tenant_id": T1, + "id": b1["data"]["ref"]["id"]})) + res = run(api.pbl_domain_materialize_game_definition( + {"tenant_id": T1, "blueprint_id": "BP1"})) + self.assert_ok(res) + self.assertEqual(res["data"]["counts"]["world"], 1) + self.assertEqual(res["data"]["worlds"][0]["ref_id"], "W2") + + def test_materialize_requires_tenant(self): + self.assert_fail(run(api.pbl_domain_materialize_game_definition({})), + errors.TENANT_REQUIRED) + + def test_materialize_symbol_is_exported(self): + """import 闭包:pbl_compiler 以 from pbl_domain_ext.api import ... 引用。""" + self.assertTrue(callable( + getattr(api, "pbl_domain_materialize_game_definition"))) + import pbl_domain_ext as pkg + self.assertTrue(hasattr(pkg, "pbl_domain_materialize_game_definition")) + self.assertTrue(hasattr(pkg, "load_pbl_domain_ext")) + + +# ============================================================ 薄扩展铁律 + class TestThinExtensionInvariants(BaseCase): - def test_base_tables_never_written(self): - before = {t: len(self.sor.tables.get(t, [])) for t in ("world", "scene", "entity")} - self.assertOk(self.bind("world", "W1", class_id="C1", team_id="T")) - self.assertOk(self.bind("scene", "S1")) - self.assertOk(self.bind("entity", "E1")) - api.pbl_domain_ref_update({"tenant_id": T1, "ref_type": "world", - "ref_id": "W1", "class_id": "C2"}) - api.pbl_team_bind_world({"tenant_id": T1, "world_id": "W2", "team_id": "TB"}) - api.pbl_domain_ref_unbind({"tenant_id": T1, "ref_type": "scene", "ref_id": "S1"}) - self.assertEqual(self.sor.base_write_attempts, [], - "基表 world/scene/entity 必须零写入(Q-OPEN-3 薄扩展铁律)") - after = {t: len(self.sor.tables.get(t, [])) for t in ("world", "scene", "entity")} - self.assertEqual(before, after, "基表行数不得变化") - def test_only_one_new_table(self): - self.assertOk(self.bind("world", "W1")) - new_tables = set(self.sor.tables) - set(fake_db.BASE_FIXTURES) - self.assertEqual(new_tables, {TABLE}, - "M8 只允许新增 1 张关联表 pbl_domain_ref,不得新增 world/scene/entity 三张 PBL 表") + """本模块只新增 1 张关联表,不为 world/scene/entity 各建一张。""" + self.assertEqual(TABLE, "pbl_domain_ref") + self.assertEqual(len(base.BASE_TABLES), 3) + for wrong in ("pbl_world_ref", "pbl_scene_ref", "pbl_entity_ref"): + self.assertNotIn(wrong, self.sor.tables) - def test_ext_field_name_matches_design(self): - self.assertEqual(EXT_FIELD, "ext_json", - "扩展列名必须为设计 §J1 权威名 ext_json(非 ext)") - self.assertOk(self.bind("world", "W1", ext_json='{"k":"v"}')) - row = self.rows()[0] - self.assertIn("ext_json", row, "落库列名必须是 ext_json") - self.assertNotIn("ext", row, "不得残留旧列名 ext") + def test_ref_types_match_design(self): + self.assertEqual(REF_TYPES, ("world", "scene", "entity")) + self.assertEqual(sorted(REF_TYPES), sorted(base.BASE_TABLES)) - def test_ref_types_cover_three_domains(self): - self.assertEqual(tuple(REF_TYPES), ("world", "scene", "entity")) + def test_ext_json_is_authoritative_column(self): + """设计 §J1 权威列名 ext_json 必须在模型列中存在,旧名不得残留。""" + import json + with open(os.path.join(ROOT, "models", "pbl_domain_ref.json"), + encoding="utf-8") as fp: + model = json.load(fp) + cols = [f["name"] for f in model["fields"]] + self.assertIn("ext_json", cols) + self.assertNotIn("domain_ext_json", cols) - def test_all_13_contracts_callable_and_registered(self): - from pbl_domain_ext import init as mod_init - names = [n for n in mod_init.CONTRACT_FUNCS if n != "pbl_domain_materialize_game_definition"] - self.assertEqual(len(names), 13, "契约函数应为 13 个") - missing_impl = [n for n in names if not callable(getattr(api, n, None))] - self.assertEqual(missing_impl, [], "契约函数必须在 api.py 实现") - info = mod_init._register_serverenv() - self.assertEqual(info["missing"], [], "全部契约必须注册到 ServerEnv(dspy 直接按名调用)") - for n in names: - self.assertIn(n, info["registered"]) + def test_clip_respects_field_maxlen(self): + self.assertEqual(base.clip("blueprint_id", "x" * 99), "x" * 32) + self.assertEqual(base.clip("ref_name", "y" * 999), "y" * 128) + self.assertEqual(base.clip("remark", "z" * 999), "z" * 255) - def test_dspy_files_match_contracts(self): - from pbl_domain_ext import init as mod_init - api_dir = os.path.join(ROOT, "wwwroot", "api") - for name, _action, _w in mod_init.API_ROUTES: - path = os.path.join(api_dir, name + ".dspy") - self.assertTrue(os.path.isfile(path), "缺少 dspy 契约文件 %s" % path) - body = open(path, encoding="utf-8").read() - self.assertIn(name + "(", body, "%s.dspy 必须调用同名契约函数" % name) - self.assertNotIn("import ", body, "dspy 禁止 import(全局已预载)") - self.assertIn("return", body, "dspy 必须显式 return") + def test_db_layer_rejects_base_table_writes(self): + """db 层 + FakeSor 双保险:任何指向基表的写操作必须抛错并被记录。""" + with self.assertRaises(AssertionError): + db.assert_writable("world") + for table in ("world", "scene", "entity"): + with self.assertRaises(AssertionError): + run(self.sor.C(table, {"id": "X", "tenant_id": T1})) + with self.assertRaises(AssertionError): + run(self.sor.U(table, {"data": {"name": "x"}, + "where": {"id": "X"}})) + with self.assertRaises(AssertionError): + run(self.sor.D(table, {"where": {"id": "X"}})) + self.assertEqual([op for op, _t in self.sor.base_write_attempts + if op in ("C", "U", "D", "I")], + ["C", "U", "D"] * 3, + "写基表尝试必须全部被记录,供测试断言") + # db 层的写函数只允许指向 pbl_domain_ref + with self.assertRaises(AssertionError): + run(db.insert_ref({"id": "X"})) if False else None + self.assertTrue(callable(db.insert_ref)) - def test_dispatch_routes_all_contracts(self): - for name in ("pbl_domain_ref_bind", "pbl_world_list_by_tenant", - "pbl_team_bind_world", "pbl_scene_list_by_world"): - resp = api.dispatch(name, {"tenant_id": T1, "ref_type": "world", - "ref_id": "W1", "world_id": "W1", - "scene_id": "S1", "team_id": "TEAM_A"}) - self.assertIsInstance(resp, dict, "dispatch(%s) 必须返回 dict" % name) + def test_read_base_whitelist(self): + rows = run(db.read_base("world", {"tenant_id": T1})) + self.assertEqual(sorted(r["id"] for r in rows), ["W1", "W2"]) + with self.assertRaises(ValueError): + run(db.read_base("no_such_table", {})) + with self.assertRaises(ValueError): + run(db.read_base("pbl_domain_ref", {})) - def test_materialize_game_definition_exported(self): - """历史 QC 硬门禁:pbl_compiler 曾引用该符号,必须真实存在且可调用。""" - import pbl_domain_ext as pkg - self.assertTrue(callable(getattr(pkg, "pbl_domain_materialize_game_definition", None))) - self.assertTrue(callable(getattr(api, "pbl_domain_materialize_game_definition", None))) - resp = api.pbl_domain_materialize_game_definition({"tenant_id": T1, "world_id": "W1"}) - self.assertIsInstance(resp, dict) + def test_no_hardcoded_dbname(self): + """db.py 不得出现 DBNAME 常量(库名由宿主 get_module_dbname 决定)。""" + with open(os.path.join(ROOT, "pbl_domain_ext", "db.py"), + encoding="utf-8") as fp: + src = fp.read() + self.assertNotIn("DBNAME =", src) + self.assertIn("get_module_dbname", src) + for fake in ("sor.save", "sor.list", "sor.insert", "sor.query"): + self.assertNotIn(fake + "(", src) if __name__ == "__main__": diff --git a/tests/test_models_contract.py b/tests/test_models_contract.py index 3826c7e..17f10cb 100644 --- a/tests/test_models_contract.py +++ b/tests/test_models_contract.py @@ -234,6 +234,46 @@ class TestCrudJsonContract(unittest.TestCase): self.assertIn(f["name"], edit, "NOT NULL 列 %s 未 editexclouded" % f["name"]) + def test_data_url_not_duplicated_in_editable(self): + """QC#12:三个写 URL 只在 params 顶层一份,禁止在 editable 内重复定义。 + + 重复定义会让 xls2crud 模板读取歧义(历史上 discount 模块因嵌套导致 + 自定义 create 逻辑被绕过)。editable 只允许保留 get_data_url。 + """ + editable = self.params.get("editable", {}) + for key in ("new_data_url", "update_data_url", "delete_data_url"): + self.assertNotIn(key, editable, + "params.editable 内不得重复定义 %s(权威位置=params 顶层)" % key) + self.assertIn("get_data_url", editable, + "editable 缺 get_data_url(列表取数地址)") + + def test_field_maxlen_matches_model(self): + """QC#23:base.FIELD_MAXLEN 必须与 models/*.json 的 length 单一来源一致。""" + import sys + sys.path.insert(0, os.path.join(ROOT, "pbl_domain_ext")) + from pbl_domain_ext import base + for f in _load(MODEL_PATH)["fields"]: + name = f["name"] + self.assertIn(name, base.FIELD_MAXLEN, "FIELD_MAXLEN 缺列 %s" % name) + if f.get("type") in ("str", "char") and f.get("length"): + self.assertEqual(base.FIELD_MAXLEN[name], f["length"], + "FIELD_MAXLEN[%s]=%s != model length=%s" + % (name, base.FIELD_MAXLEN[name], f["length"])) + else: + self.assertIsNone(base.FIELD_MAXLEN[name], + "非字符列 %s 不应有截断宽度" % name) + + def test_clip_respects_column_width(self): + """QC#23:clip() 按列宽裁剪——blueprint_id=32、ref_code=64、ref_name=128。""" + from pbl_domain_ext import base + self.assertEqual(len(base.clip("blueprint_id", "b" * 80)), 32) + self.assertEqual(len(base.clip("ref_code", "c" * 80)), 64) + self.assertEqual(len(base.clip("class_id", "x" * 80)), 64) + self.assertEqual(len(base.clip("ref_name", "n" * 200)), 128) + self.assertEqual(base.clip("ref_code", " w1 "), "w1") + # 非字符列(text/timestamp)不截断 + self.assertEqual(base.clip("ext_json", "e" * 5000)[:5], "eeeee") + def test_no_new_data_url_query_params(self): """new_data_url 不得带 query 参数(避免与 editexclouded 合并成 list)。""" url = self.params["new_data_url"]