deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
1af5c3fd7d
commit
7b8739eddc
@ -78,6 +78,9 @@
|
||||
"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')}}"
|
||||
}
|
||||
},
|
||||
"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')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ from .base import TABLE, EXT_FIELD, REF_TYPES, BIND_STATES
|
||||
from .errors import PblDomainExtError
|
||||
|
||||
__all__ = ["load_pbl_domain_ext", "MODULE_NAME", "API_ROUTES", "PERMISSIONS",
|
||||
"CONTRACT_FUNCS", "_register_serverenv",
|
||||
"TABLE", "EXT_FIELD", "REF_TYPES", "BIND_STATES"]
|
||||
|
||||
MODULE_NAME = "pbl_domain_ext"
|
||||
@ -47,6 +48,46 @@ PERMISSIONS = [
|
||||
]
|
||||
|
||||
|
||||
#: 契约函数名(与 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 _register_serverenv():
|
||||
"""把契约函数注册为 ServerEnv 全局(.dspy 直接按名调用,无需 import)。
|
||||
|
||||
module-development-spec「Triple-place function registration」之第③处:
|
||||
① 实现 pbl_domain_ext/api.py ② 包导出 pbl_domain_ext/__init__.py
|
||||
③ 本函数 env.<name> = <name>
|
||||
缺③ → dspy 运行期 NameError: name 'pbl_xxx' is not defined。
|
||||
"""
|
||||
registered, missing = [], []
|
||||
env = None
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
except Exception: # noqa: BLE001 非 ahserver 宿主(单测/离线)跳过
|
||||
env = None
|
||||
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
|
||||
registered.append(name)
|
||||
return {"registered": registered, "missing": missing, "serverenv": env is not None}
|
||||
|
||||
|
||||
def _register_routes(app=None):
|
||||
"""注册 dspy 路由(平台 register_dspy 可用时走平台,否则仅返回路由表)。"""
|
||||
registered = []
|
||||
@ -83,6 +124,7 @@ def load_pbl_domain_ext(app=None, sor=None, ensure_schema=True):
|
||||
"""
|
||||
if sor is not None:
|
||||
db.set_sor(sor)
|
||||
env_info = _register_serverenv()
|
||||
routes = _register_routes(app)
|
||||
perms = _register_permissions(app)
|
||||
|
||||
@ -100,6 +142,7 @@ def load_pbl_domain_ext(app=None, sor=None, ensure_schema=True):
|
||||
"ref_types": list(REF_TYPES),
|
||||
"bind_states": list(BIND_STATES),
|
||||
"routes": routes,
|
||||
"serverenv": env_info,
|
||||
"permissions": perms,
|
||||
"schema_action": schema_action,
|
||||
"base_tables_readonly": ["world", "scene", "entity"],
|
||||
|
||||
@ -1,528 +1,448 @@
|
||||
"""tests/test_domain_ref.py — M8 薄扩展 13 个契约接口的真实断言测试(离线 sqlite)。
|
||||
# -*- coding: utf-8 -*-
|
||||
"""[M8] pbl_domain_ext 契约测试(world/scene/entity 薄扩展)。
|
||||
|
||||
覆盖:
|
||||
§3.1 bind_ref / unbind_ref / get_ref / list_refs / update_ref
|
||||
§3.2 list_worlds_by_tenant / list_scenes_by_world / list_entities_by_scene
|
||||
/ get_world_with_pbl_context / check_ref_access
|
||||
§3.3 list_teams_by_class / bind_team_to_world / get_team_worlds
|
||||
+ 薄扩展铁律:不改基表(基表行数/结构前后一致)、跨租户隔离(US-21)、
|
||||
悬挂引用过滤、表总账(OWN_TABLES == models/*.json == 1 张 pbl_domain_ref)。
|
||||
运行(环境无 pytest 时用 unittest,二者皆可)::
|
||||
|
||||
运行:python3 tests/test_domain_ref.py
|
||||
cd modules/pbl_domain_ext
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
python3 -m pytest tests/ -q # 若环境已装 pytest
|
||||
|
||||
覆盖:13 个 dspy 契约对应的 api 函数 × 租户隔离 / 权限(只读基表) / 正常 / 异常 四类用例,
|
||||
外加薄扩展铁律(不写基表、表总账只有 pbl_domain_ref 一张新表、ext_json 字段名对齐设计 §J1)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, HERE)
|
||||
for p in (ROOT, HERE):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
import fake_db # noqa: E402
|
||||
import fake_db # noqa: E402
|
||||
from pbl_domain_ext import api, db, base # noqa: E402
|
||||
from pbl_domain_ext.base import EXT_FIELD, REF_TYPES, TABLE # noqa: E402
|
||||
|
||||
from pbl_domain_ext import api # noqa: E402
|
||||
from pbl_domain_ext import base as base_mod # noqa: E402
|
||||
from pbl_domain_ext import init as init_mod # noqa: E402
|
||||
from pbl_domain_ext.errors import (E_DUPLICATE, E_FORBIDDEN, E_NOT_FOUND, # noqa: E402
|
||||
E_VALIDATION, PblError)
|
||||
|
||||
T1 = 'T1'
|
||||
T2 = 'T2'
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
|
||||
T1 = "T1" # 主测租户(BASE_FIXTURES 中 world W1/W2、scene S1..S3、entity E1..E3)
|
||||
T2 = "T2" # 他租户(BASE_FIXTURES 中仅 world W3)
|
||||
OP = "U_TESTER"
|
||||
|
||||
|
||||
class BaseCase(unittest.TestCase):
|
||||
"""每个用例独立内存库,互不污染。"""
|
||||
|
||||
def setUp(self):
|
||||
self.conn, self.adapter = fake_db.setup()
|
||||
self.sor = fake_db.make_fake_sor()
|
||||
db.set_sor(self.sor)
|
||||
|
||||
def tearDown(self):
|
||||
fake_db.teardown()
|
||||
self.conn.close()
|
||||
db.set_sor(None)
|
||||
|
||||
def assertPblError(self, code, coro):
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
run(coro)
|
||||
exc = ctx.exception
|
||||
self.assertEqual(getattr(exc, 'code', None), code,
|
||||
'期望 %s,实际 %s(%s)' % (code, getattr(exc, 'code', None), exc))
|
||||
return exc
|
||||
# ---- 断言助手 -------------------------------------------------------
|
||||
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 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 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, []))
|
||||
|
||||
|
||||
class TestBindRef(BaseCase):
|
||||
"""接口 1:bind_ref"""
|
||||
# ==========================================================================
|
||||
# 1. 关联表 pbl_domain_ref:绑定 / 解绑 / 更新 / 查询
|
||||
# ==========================================================================
|
||||
class TestBind(BaseCase):
|
||||
|
||||
def test_bind_world_ok(self):
|
||||
ref = run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
|
||||
team_id='TEAM_A', ext_json={'stage': 2}, tenant_id=T1))
|
||||
self.assertIsNotNone(ref)
|
||||
self.assertEqual(ref['tenant_id'], T1)
|
||||
self.assertEqual(ref['ref_type'], 'world')
|
||||
self.assertEqual(ref['ref_id'], 1)
|
||||
self.assertEqual(ref['blueprint_id'], 'BP-1')
|
||||
self.assertEqual(ref['class_id'], 'CLS-1')
|
||||
self.assertEqual(ref['team_id'], 'TEAM_A')
|
||||
self.assertEqual(ref['ext_json'], {'stage': 2})
|
||||
self.assertEqual(ref['is_deleted'], 0)
|
||||
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["created_by"], OP)
|
||||
self.assertEqual(len(self.rows()), 1, "应落 1 行关联记录")
|
||||
|
||||
def test_bind_duplicate_raises(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
self.assertPblError(E_DUPLICATE, api.bind_ref('world', 1, tenant_id=T1))
|
||||
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_same_ref_other_tenant_ok(self):
|
||||
"""同一基表记录可被不同租户各自绑定(UNIQUE 含 tenant_id)。"""
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
ref2 = run(api.bind_ref('world', 1, tenant_id=T2))
|
||||
self.assertEqual(ref2['tenant_id'], T2)
|
||||
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_base_missing_raises_not_found(self):
|
||||
self.assertPblError(E_NOT_FOUND, api.bind_ref('world', 999, tenant_id=T1))
|
||||
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")
|
||||
|
||||
def test_bind_invalid_ref_type(self):
|
||||
self.assertPblError(E_VALIDATION, api.bind_ref('script', 1, tenant_id=T1))
|
||||
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_id(self):
|
||||
self.assertPblError(E_VALIDATION, api.bind_ref('world', 'abc', tenant_id=T1))
|
||||
self.assertPblError(E_VALIDATION, api.bind_ref('world', 0, tenant_id=T1))
|
||||
def test_bind_rejects_empty_ref_id(self):
|
||||
self.assertErr(self.bind("world", ""), "PBL_DE_PARAM_INVALID", "ref_id 为空")
|
||||
|
||||
def test_bind_missing_tenant(self):
|
||||
self.assertPblError(E_VALIDATION, api.bind_ref('world', 1))
|
||||
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_rebind_after_unbind_revives(self):
|
||||
run(api.bind_ref('scene', 11, class_id='CLS-1', tenant_id=T1))
|
||||
run(api.unbind_ref('scene', 11, tenant_id=T1))
|
||||
ref = run(api.bind_ref('scene', 11, class_id='CLS-2', tenant_id=T1))
|
||||
self.assertEqual(ref['class_id'], 'CLS-2')
|
||||
self.assertEqual(ref['is_deleted'], 0)
|
||||
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_ext_invalid_json_string(self):
|
||||
self.assertPblError(E_VALIDATION,
|
||||
api.bind_ref('world', 1, ext_json='{bad json', tenant_id=T1))
|
||||
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 不得绑定")
|
||||
|
||||
|
||||
class TestUnbindGetUpdate(BaseCase):
|
||||
"""接口 2/3/5:unbind_ref / get_ref / update_ref"""
|
||||
class TestUnbindUpdateGet(BaseCase):
|
||||
|
||||
def test_unbind_soft_delete(self):
|
||||
run(api.bind_ref('entity', 101, tenant_id=T1))
|
||||
self.assertTrue(run(api.unbind_ref('entity', 101, tenant_id=T1)))
|
||||
flag = fake_db.scalar(self.conn,
|
||||
"SELECT is_deleted FROM pbl_domain_ref WHERE ref_id=101")
|
||||
self.assertEqual(flag, 1, 'unbind 必须软删而非物理删除')
|
||||
self.assertPblError(E_NOT_FOUND, api.get_ref('entity', 101, tenant_id=T1))
|
||||
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, [], "解绑后不应再有生效关联行")
|
||||
|
||||
def test_unbind_missing(self):
|
||||
self.assertPblError(E_NOT_FOUND, api.unbind_ref('world', 1, tenant_id=T1))
|
||||
self.assertErr(api.pbl_domain_ref_unbind(
|
||||
{"tenant_id": T1, "ref_type": "world", "ref_id": "W1"}),
|
||||
"PBL_DE_NOT_FOUND", "解绑不存在的关联")
|
||||
|
||||
def test_get_ref_ok(self):
|
||||
run(api.bind_ref('world', 2, blueprint_id='BP-9', tenant_id=T1))
|
||||
ref = run(api.get_ref('world', 2, tenant_id=T1))
|
||||
self.assertEqual(ref['blueprint_id'], 'BP-9')
|
||||
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"), "跨租户不得解绑他租户关联")
|
||||
|
||||
def test_get_ref_cross_tenant_not_found(self):
|
||||
run(api.bind_ref('world', 2, tenant_id=T1))
|
||||
self.assertPblError(E_NOT_FOUND, api.get_ref('world', 2, tenant_id=T2))
|
||||
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]["updated_by"], OP)
|
||||
|
||||
def test_update_ref_ok(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
|
||||
ref = run(api.update_ref('world', 1, {'class_id': 'CLS-2', 'team_id': 'TEAM_B',
|
||||
'ext_json': {'k': 'v'}}, tenant_id=T1))
|
||||
self.assertEqual(ref['class_id'], 'CLS-2')
|
||||
self.assertEqual(ref['team_id'], 'TEAM_B')
|
||||
self.assertEqual(ref['ext_json'], {'k': 'v'})
|
||||
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", "更新不存在的关联")
|
||||
|
||||
def test_update_ref_rejects_key_fields(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
self.assertPblError(E_VALIDATION,
|
||||
api.update_ref('world', 1, {'ref_id': 2}, tenant_id=T1))
|
||||
self.assertPblError(E_VALIDATION,
|
||||
api.update_ref('world', 1, {'tenant_id': T2}, tenant_id=T1))
|
||||
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"), "跨租户不得改他租户关联")
|
||||
|
||||
def test_update_ref_missing(self):
|
||||
self.assertPblError(E_NOT_FOUND,
|
||||
api.update_ref('world', 1, {'class_id': 'X'}, tenant_id=T1))
|
||||
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_get_missing(self):
|
||||
self.assertErr(api.pbl_domain_ref_get({"tenant_id": T1, "id": "NOPE"}),
|
||||
"PBL_DE_NOT_FOUND")
|
||||
|
||||
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 的关联记录")
|
||||
|
||||
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_requires_tenant(self):
|
||||
self.assertErr(api.pbl_domain_ref_list({"ref_type": "world"}),
|
||||
"PBL_DE_TENANT_MISSING")
|
||||
|
||||
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 的关联记录")
|
||||
|
||||
|
||||
class TestListRefs(BaseCase):
|
||||
"""接口 4:list_refs(过滤 + 分页 + 悬挂引用标记)"""
|
||||
class TestCheckAccess(BaseCase):
|
||||
|
||||
def _seed(self):
|
||||
run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
|
||||
team_id='TEAM_A', tenant_id=T1))
|
||||
run(api.bind_ref('world', 2, blueprint_id='BP-1', class_id='CLS-1',
|
||||
team_id='TEAM_B', tenant_id=T1))
|
||||
run(api.bind_ref('scene', 11, blueprint_id='BP-2', class_id='CLS-1',
|
||||
tenant_id=T1))
|
||||
run(api.bind_ref('entity', 101, class_id='CLS-2', tenant_id=T1))
|
||||
run(api.bind_ref('world', 1, class_id='CLS-9', tenant_id=T2))
|
||||
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_list_all_tenant_scoped(self):
|
||||
self._seed()
|
||||
res = run(api.list_refs({}, 1, 20, tenant_id=T1))
|
||||
self.assertEqual(res['total'], 4, 'T1 只应看到自己的 4 条关联')
|
||||
self.assertTrue(all(i['tenant_id'] == T1 for i in res['items']))
|
||||
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_list_filter_by_type_and_class(self):
|
||||
self._seed()
|
||||
res = run(api.list_refs({'ref_type': 'world'}, 1, 20, tenant_id=T1))
|
||||
self.assertEqual(res['total'], 2)
|
||||
res2 = run(api.list_refs({'class_id': 'CLS-2'}, 1, 20, tenant_id=T1))
|
||||
self.assertEqual(res2['total'], 1)
|
||||
self.assertEqual(res2['items'][0]['ref_type'], 'entity')
|
||||
res3 = run(api.list_refs({'blueprint_id': 'BP-1'}, 1, 20, tenant_id=T1))
|
||||
self.assertEqual(res3['total'], 2)
|
||||
res4 = run(api.list_refs({'team_id': 'TEAM_B'}, 1, 20, tenant_id=T1))
|
||||
self.assertEqual(res4['total'], 1)
|
||||
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_list_pagination(self):
|
||||
self._seed()
|
||||
page1 = run(api.list_refs({}, 1, 2, tenant_id=T1))
|
||||
page2 = run(api.list_refs({}, 2, 2, tenant_id=T1))
|
||||
self.assertEqual(len(page1['items']), 2)
|
||||
self.assertEqual(len(page2['items']), 2)
|
||||
self.assertEqual(page1['total'], 4)
|
||||
ids1 = {i['id'] for i in page1['items']}
|
||||
ids2 = {i['id'] for i in page2['items']}
|
||||
self.assertFalse(ids1 & ids2, '分页结果不得重叠')
|
||||
|
||||
def test_list_page_size_capped(self):
|
||||
self._seed()
|
||||
res = run(api.list_refs({}, 1, 9999, tenant_id=T1))
|
||||
self.assertEqual(res['size'], api.MAX_PAGE_SIZE)
|
||||
|
||||
def test_list_invalid_ref_type_filter(self):
|
||||
self.assertPblError(E_VALIDATION,
|
||||
api.list_refs({'ref_type': 'bogus'}, 1, 20, tenant_id=T1))
|
||||
|
||||
def test_list_dangling_flag(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
self.conn.execute("DELETE FROM world WHERE id=1") # 复用模块删除基表记录
|
||||
self.conn.commit()
|
||||
res = run(api.list_refs({'ref_type': 'world'}, 1, 20, tenant_id=T1))
|
||||
self.assertEqual(res['total'], 1)
|
||||
self.assertTrue(res['items'][0]['dangling'], '基表已删的 ref 必须标记 dangling')
|
||||
self.assertEqual(res['valid_total'], 0)
|
||||
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")
|
||||
|
||||
|
||||
class TestTenantIsolationQueries(BaseCase):
|
||||
"""接口 6~9:租户隔离查询封装(基表 + 扩展联合)"""
|
||||
# ==========================================================================
|
||||
# 2. 基础域只读契约(world / scene / entity)
|
||||
# ==========================================================================
|
||||
class TestBaseDomainReadOnly(BaseCase):
|
||||
|
||||
def test_list_worlds_by_tenant_only_bound(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
|
||||
worlds = run(api.list_worlds_by_tenant(T1))
|
||||
self.assertEqual(len(worlds), 1)
|
||||
self.assertEqual(worlds[0]['id'], 1)
|
||||
self.assertEqual(worlds[0]['name'], '世界A-火星基地')
|
||||
self.assertEqual(worlds[0]['class_id'], 'CLS-1')
|
||||
self.assertIsNotNone(worlds[0]['pbl_ref'])
|
||||
# 未绑定的 world 2 对 T1 不可见
|
||||
self.assertNotIn(2, [w['id'] for w in worlds])
|
||||
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"), "必须标注只读")
|
||||
|
||||
def test_list_worlds_by_tenant_empty_when_no_binding(self):
|
||||
self.assertEqual(run(api.list_worlds_by_tenant(T2)), [])
|
||||
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_list_worlds_by_class_filter(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
|
||||
run(api.bind_ref('world', 2, class_id='CLS-2', tenant_id=T1))
|
||||
got = run(api.list_worlds_by_tenant(T1, class_id='CLS-2'))
|
||||
self.assertEqual([w['id'] for w in got], [2])
|
||||
def test_world_list_requires_tenant(self):
|
||||
self.assertErr(api.pbl_world_list_by_tenant({}), "PBL_DE_TENANT_MISSING")
|
||||
|
||||
def test_list_worlds_filters_dangling(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
self.conn.execute("DELETE FROM world WHERE id=1")
|
||||
self.conn.commit()
|
||||
self.assertEqual(run(api.list_worlds_by_tenant(T1)), [],
|
||||
'悬挂引用必须从联合结果中过滤')
|
||||
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_list_scenes_by_world_ok(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
run(api.bind_ref('scene', 11, tenant_id=T1))
|
||||
run(api.bind_ref('scene', 12, tenant_id=T1))
|
||||
scenes = run(api.list_scenes_by_world(1, T1))
|
||||
self.assertEqual(sorted(s['id'] for s in scenes), [11, 12])
|
||||
self.assertEqual(scenes[0]['world_id'], 1)
|
||||
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_list_scenes_only_bound_visible(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
run(api.bind_ref('scene', 11, tenant_id=T1)) # 12 未绑定
|
||||
scenes = run(api.list_scenes_by_world(1, T1))
|
||||
self.assertEqual([s['id'] for s in scenes], [11])
|
||||
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"))
|
||||
|
||||
def test_list_scenes_cross_tenant_forbidden(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
self.assertPblError(E_FORBIDDEN, api.list_scenes_by_world(1, T2))
|
||||
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 不得取其上下文")
|
||||
|
||||
def test_list_entities_by_scene_ok(self):
|
||||
run(api.bind_ref('scene', 11, tenant_id=T1))
|
||||
run(api.bind_ref('entity', 101, tenant_id=T1))
|
||||
run(api.bind_ref('entity', 102, tenant_id=T1))
|
||||
ents = run(api.list_entities_by_scene(11, T1))
|
||||
self.assertEqual(sorted(e['id'] for e in ents), [101, 102])
|
||||
self.assertEqual(ents[0]['kind'], 'vehicle')
|
||||
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_list_entities_cross_tenant_forbidden(self):
|
||||
run(api.bind_ref('scene', 11, tenant_id=T1))
|
||||
self.assertPblError(E_FORBIDDEN, api.list_entities_by_scene(11, T2))
|
||||
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_get_world_with_pbl_context_ok(self):
|
||||
run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
|
||||
team_id='TEAM_A', ext_json={'mode': 'coop'}, tenant_id=T1))
|
||||
ctx = run(api.get_world_with_pbl_context(1, T1))
|
||||
self.assertEqual(ctx['name'], '世界A-火星基地')
|
||||
self.assertEqual(ctx['pbl_context']['blueprint_id'], 'BP-1')
|
||||
self.assertEqual(ctx['pbl_context']['class_id'], 'CLS-1')
|
||||
self.assertEqual(ctx['pbl_context']['team_id'], 'TEAM_A')
|
||||
self.assertEqual(ctx['pbl_context']['ext_json'], {'mode': 'coop'})
|
||||
self.assertEqual(ctx['pbl_context']['tenant_id'], T1)
|
||||
def test_scene_list_cross_tenant_empty(self):
|
||||
data = self.assertOk(api.pbl_scene_list_by_world(
|
||||
{"tenant_id": T2, "world_id": "W1"}))
|
||||
self.assertEqual(data["items"], [], "跨租户不得看到他租户场景")
|
||||
|
||||
def test_get_world_context_cross_tenant_forbidden(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
self.assertPblError(E_FORBIDDEN, api.get_world_with_pbl_context(1, T2))
|
||||
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"])
|
||||
|
||||
def test_get_world_context_base_missing_not_found(self):
|
||||
run(api.bind_ref('world', 1, tenant_id=T1))
|
||||
self.conn.execute("DELETE FROM world WHERE id=1")
|
||||
self.conn.commit()
|
||||
self.assertPblError(E_NOT_FOUND, api.get_world_with_pbl_context(1, T1))
|
||||
def test_entity_list_requires_scene_id(self):
|
||||
self.assertErr(api.pbl_entity_list_by_scene({"tenant_id": T1}),
|
||||
"PBL_DE_PARAM_INVALID")
|
||||
|
||||
|
||||
class TestCheckRefAccess(BaseCase):
|
||||
"""接口 10:check_ref_access(租户+班级+团队三重匹配)"""
|
||||
# ==========================================================================
|
||||
# 3. 班级 / 团队维度契约
|
||||
# ==========================================================================
|
||||
class TestTeamClass(BaseCase):
|
||||
|
||||
def test_access_granted(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
self.assertTrue(run(api.check_ref_access('world', 1, T1)))
|
||||
self.assertTrue(run(api.check_ref_access('world', 1, T1,
|
||||
class_id='CLS-1', team_id='TEAM_A')))
|
||||
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)
|
||||
|
||||
def test_access_denied_cross_tenant(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
self.assertFalse(run(api.check_ref_access('world', 1, T2)))
|
||||
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_access_denied_wrong_class_or_team(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
self.assertFalse(run(api.check_ref_access('world', 1, T1, class_id='CLS-X')))
|
||||
self.assertFalse(run(api.check_ref_access('world', 1, T1, team_id='TEAM-X')))
|
||||
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_access_denied_unbound(self):
|
||||
self.assertFalse(run(api.check_ref_access('world', 2, T1)))
|
||||
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_access_denied_after_unbind(self):
|
||||
run(api.bind_ref('scene', 11, tenant_id=T1))
|
||||
run(api.unbind_ref('scene', 11, tenant_id=T1))
|
||||
self.assertFalse(run(api.check_ref_access('scene', 11, T1)))
|
||||
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_access_denied_invalid_input_no_raise(self):
|
||||
self.assertFalse(run(api.check_ref_access('bogus', 1, T1)))
|
||||
self.assertFalse(run(api.check_ref_access('world', 'x', T1)))
|
||||
self.assertFalse(run(api.check_ref_access('world', 1, None)))
|
||||
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_access_denied_dangling_base(self):
|
||||
run(api.bind_ref('entity', 101, tenant_id=T1))
|
||||
self.conn.execute("DELETE FROM entity WHERE id=101")
|
||||
self.conn.commit()
|
||||
self.assertFalse(run(api.check_ref_access('entity', 101, T1)))
|
||||
|
||||
|
||||
class TestTeamClassDimension(BaseCase):
|
||||
"""接口 11~13:团队/班级维度(US-13 共享世界支撑)"""
|
||||
|
||||
def test_list_teams_by_class_groups(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
run(api.bind_ref('scene', 11, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
run(api.bind_ref('world', 2, class_id='CLS-1', team_id='TEAM_B', tenant_id=T1))
|
||||
teams = run(api.list_teams_by_class('CLS-1', T1))
|
||||
self.assertEqual(len(teams), 2)
|
||||
by_id = {t['team_id']: t for t in teams}
|
||||
self.assertEqual(sorted(by_id['TEAM_A']['world_ids']), [1])
|
||||
self.assertEqual(sorted(by_id['TEAM_A']['scene_ids']), [11])
|
||||
self.assertEqual(by_id['TEAM_A']['entity_ids'], [])
|
||||
self.assertEqual(len(by_id['TEAM_A']['ref_ids']), 2)
|
||||
self.assertEqual(sorted(by_id['TEAM_B']['world_ids']), [2])
|
||||
|
||||
def test_list_teams_by_class_members_from_governance(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
teams = run(api.list_teams_by_class('CLS-1', T1))
|
||||
members = teams[0]['members']
|
||||
self.assertEqual(len(members), 2, 'members 应只读取自 pbl_governance.pbl_team_member')
|
||||
self.assertEqual({m['user_id'] for m in members}, {'stu01', 'stu02'})
|
||||
|
||||
def test_list_teams_by_class_tenant_scoped(self):
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
self.assertEqual(run(api.list_teams_by_class('CLS-1', T2)), [])
|
||||
|
||||
def test_list_teams_by_class_requires_class_id(self):
|
||||
self.assertPblError(E_VALIDATION, api.list_teams_by_class(None, T1))
|
||||
|
||||
def test_list_teams_without_governance_table_degrades(self):
|
||||
"""治理模块未部署(无 pbl_team_member 表)时降级为空成员列表,不报错。"""
|
||||
fake_db.teardown()
|
||||
self.conn.close()
|
||||
self.conn, self.adapter = fake_db.setup(with_team_member=False)
|
||||
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
|
||||
teams = run(api.list_teams_by_class('CLS-1', T1))
|
||||
self.assertEqual(len(teams), 1)
|
||||
self.assertEqual(teams[0]['members'], [])
|
||||
|
||||
def test_bind_team_to_world_creates_ref(self):
|
||||
ref = run(api.bind_team_to_world(1, 'TEAM_A', T1, class_id='CLS-1',
|
||||
blueprint_id='BP-1'))
|
||||
self.assertEqual(ref['ref_type'], 'world')
|
||||
self.assertEqual(ref['ref_id'], 1)
|
||||
self.assertEqual(ref['team_id'], 'TEAM_A')
|
||||
self.assertEqual(ref['class_id'], 'CLS-1')
|
||||
|
||||
def test_bind_team_to_world_updates_existing(self):
|
||||
run(api.bind_ref('world', 1, team_id='TEAM_A', tenant_id=T1))
|
||||
ref = run(api.bind_team_to_world(1, 'TEAM_B', T1))
|
||||
self.assertEqual(ref['team_id'], 'TEAM_B', '已有关联应幂等更新 team_id')
|
||||
cnt = fake_db.scalar(
|
||||
self.conn,
|
||||
"SELECT COUNT(*) FROM pbl_domain_ref WHERE ref_type='world' AND ref_id=1")
|
||||
self.assertEqual(cnt, 1, '不得产生重复关联行')
|
||||
|
||||
def test_bind_team_to_world_duplicate_same_team(self):
|
||||
run(api.bind_team_to_world(1, 'TEAM_A', T1))
|
||||
self.assertPblError(E_DUPLICATE, api.bind_team_to_world(1, 'TEAM_A', T1))
|
||||
|
||||
def test_bind_team_to_world_base_missing(self):
|
||||
self.assertPblError(E_NOT_FOUND, api.bind_team_to_world(999, 'TEAM_A', T1))
|
||||
|
||||
def test_bind_team_to_world_requires_team_id(self):
|
||||
self.assertPblError(E_VALIDATION, api.bind_team_to_world(1, None, T1))
|
||||
|
||||
def test_get_team_worlds(self):
|
||||
run(api.bind_team_to_world(1, 'TEAM_A', T1))
|
||||
run(api.bind_team_to_world(2, 'TEAM_B', T1))
|
||||
worlds = run(api.get_team_worlds('TEAM_A', T1))
|
||||
self.assertEqual([w['id'] for w in worlds], [1])
|
||||
self.assertEqual(worlds[0]['team_id'], 'TEAM_A')
|
||||
self.assertEqual(run(api.get_team_worlds('TEAM_A', T2)), [],
|
||||
'跨租户不得返回他租户团队世界')
|
||||
|
||||
def test_get_team_worlds_requires_team_id(self):
|
||||
self.assertPblError(E_VALIDATION, api.get_team_worlds('', T1))
|
||||
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")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 4. 薄扩展铁律 / 契约齐备 / 设计对齐
|
||||
# ==========================================================================
|
||||
class TestThinExtensionInvariants(BaseCase):
|
||||
"""薄扩展铁律:不改基表 + 表总账 + 契约齐备"""
|
||||
|
||||
def test_base_tables_untouched(self):
|
||||
before = {t: fake_db.scalar(self.conn, "SELECT COUNT(*) FROM %s" % t)
|
||||
for t in ('world', 'scene', 'entity')}
|
||||
run(api.bind_ref('world', 1, class_id='C', team_id='T', tenant_id=T1))
|
||||
run(api.bind_ref('scene', 11, tenant_id=T1))
|
||||
run(api.bind_ref('entity', 101, tenant_id=T1))
|
||||
run(api.update_ref('world', 1, {'class_id': 'C2'}, tenant_id=T1))
|
||||
run(api.bind_team_to_world(2, 'TEAM_B', T1))
|
||||
# 读路径(联合查询)——必须在解绑前跑,解绑后 scene 11 对本租户即 403
|
||||
run(api.list_worlds_by_tenant(T1))
|
||||
run(api.list_scenes_by_world(1, T1))
|
||||
run(api.list_entities_by_scene(11, T1))
|
||||
run(api.get_world_with_pbl_context(1, T1))
|
||||
run(api.check_ref_access('world', 1, T1))
|
||||
run(api.list_teams_by_class('C', T1))
|
||||
run(api.get_team_worlds('T', T1))
|
||||
run(api.list_refs({}, 1, 20, tenant_id=T1))
|
||||
# 写路径收尾:解绑(软删,只动自有表)
|
||||
run(api.unbind_ref('scene', 11, tenant_id=T1))
|
||||
run(api.unbind_ref('entity', 101, tenant_id=T1))
|
||||
after = {t: fake_db.scalar(self.conn, "SELECT COUNT(*) FROM %s" % t)
|
||||
for t in ('world', 'scene', 'entity')}
|
||||
self.assertEqual(before, after, '基表行数不得变化(零写入)')
|
||||
# 基表结构零 ALTER:列集合不变
|
||||
for table in ('world', 'scene', 'entity'):
|
||||
cols = [r[1] for r in fake_db.raw_sql(
|
||||
self.conn, "PRAGMA table_info(%s)" % table)]
|
||||
self.assertNotIn('tenant_id', cols, '禁止给基表加 tenant_id 列')
|
||||
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_own_tables_single_and_matches_models(self):
|
||||
self.assertEqual(init_mod.OWN_TABLES, ['pbl_domain_ref'])
|
||||
models_dir = os.path.join(ROOT, 'models')
|
||||
files = sorted(f for f in os.listdir(models_dir) if f.endswith('.json'))
|
||||
self.assertEqual(files, ['pbl_domain_ref.json'],
|
||||
'models/ 必须与 OWN_TABLES 一一对应(QC #4)')
|
||||
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 表")
|
||||
|
||||
def test_model_json_four_sections(self):
|
||||
path = os.path.join(ROOT, 'models', 'pbl_domain_ref.json')
|
||||
model = json.load(open(path, encoding='utf-8'))
|
||||
for key in ('summary', 'fields', 'indexes', 'codes'):
|
||||
self.assertIn(key, model, '表定义四段式缺 %s' % key)
|
||||
self.assertIsInstance(model['summary'], list, 'summary 必须是数组(QC #3)')
|
||||
self.assertTrue(all(isinstance(s, str) for s in model['summary']))
|
||||
self.assertEqual(model['summary'][0], 'pbl_domain_ref')
|
||||
primaries = [f for f in model['fields'] if f.get('primary')]
|
||||
self.assertEqual(len(primaries), 1)
|
||||
self.assertEqual(primaries[0]['name'], 'id')
|
||||
uniq = [i for i in model['indexes'] if i.get('unique') and not i.get('primary')]
|
||||
self.assertIn(['tenant_id', 'ref_type', 'ref_id'],
|
||||
[i['fields'] for i in uniq],
|
||||
'必须有 UNIQUE(tenant_id,ref_type,ref_id)(设计 §2)')
|
||||
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_contract_13_interfaces_all_callable(self):
|
||||
self.assertEqual(len(api.CONTRACT_INTERFACES), 13)
|
||||
for name in api.CONTRACT_INTERFACES:
|
||||
self.assertTrue(callable(getattr(api, name, None)), '缺实现:%s' % name)
|
||||
def test_ref_types_cover_three_domains(self):
|
||||
self.assertEqual(tuple(REF_TYPES), ("world", "scene", "entity"))
|
||||
|
||||
def test_contract_map_covers_dspy_files(self):
|
||||
cmap = init_mod.get_contract_map()
|
||||
self.assertEqual(len(cmap), 13)
|
||||
api_dir = os.path.join(ROOT, 'wwwroot', 'api')
|
||||
existing = set(os.listdir(api_dir))
|
||||
for name, (impl, dspy) in cmap.items():
|
||||
self.assertTrue(callable(getattr(api, name, None)), '缺实现:%s' % name)
|
||||
self.assertIn(os.path.basename(dspy), existing,
|
||||
'契约 %s 缺 dspy 端点 %s(QC #5)' % (name, dspy))
|
||||
self.assertTrue(impl.startswith('pbl_domain_ext/api.py:'))
|
||||
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_load_path_registers_all_dspy(self):
|
||||
sys.path.insert(0, os.path.join(ROOT, 'scripts'))
|
||||
import load_path as lp
|
||||
self.assertEqual(lp.selfcheck(), [], 'load_path 自检必须无问题(QC #6)')
|
||||
registered = set(lp.API_PATHS) | set(lp.UI_PATHS)
|
||||
api_dir = os.path.join(ROOT, 'wwwroot', 'api')
|
||||
for fname in os.listdir(api_dir):
|
||||
if fname.endswith('.dspy'):
|
||||
self.assertIn('/pbl_domain_ext/api/%s' % fname, registered,
|
||||
'端点 %s 未在 load_path.py 注册 → 上线 403' % fname)
|
||||
self.assertIn('/pbl_domain_ext/index.ui', registered)
|
||||
self.assertEqual(len(lp.API_PATHS), 13)
|
||||
for path in lp.ALL_PATHS:
|
||||
self.assertNotIn('*', path, '禁止通配符注册')
|
||||
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_load_module_registers_env_functions(self):
|
||||
env = init_mod.load_pbl_domain_ext()
|
||||
for name in api.CONTRACT_INTERFACES:
|
||||
self.assertTrue(callable(getattr(env, 'pbl_%s' % name, None)),
|
||||
'env.pbl_%s 未注册' % name)
|
||||
self.assertTrue(callable(getattr(env, name, None)),
|
||||
'env.%s(设计原名)未注册' % name)
|
||||
info = env.pbl_domain_ext_module_info
|
||||
self.assertEqual(info['own_tables'], ['pbl_domain_ref'])
|
||||
self.assertFalse(info['base_table_altered'])
|
||||
self.assertEqual(len(info['contracts']), 13)
|
||||
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_package_exports_match_init(self):
|
||||
def test_materialize_game_definition_exported(self):
|
||||
"""历史 QC 硬门禁:pbl_compiler 曾引用该符号,必须真实存在且可调用。"""
|
||||
import pbl_domain_ext as pkg
|
||||
for name in api.CONTRACT_INTERFACES:
|
||||
self.assertTrue(callable(getattr(pkg, name, None)),
|
||||
'__init__.py 未导出 %s(三处同步注册之 ②)' % name)
|
||||
self.assertTrue(callable(pkg.load_pbl_domain_ext))
|
||||
|
||||
def test_base_layer_readonly_projection(self):
|
||||
self.assertTrue(run(base_mod.base_exists('world', 1)))
|
||||
self.assertFalse(run(base_mod.base_exists('world', 999)))
|
||||
rows = run(base_mod.fetch_children('scene', 1))
|
||||
self.assertEqual(sorted(r['id'] for r in rows), [11, 12])
|
||||
col = run(base_mod.resolve_column('scene', base_mod.SCENE_PARENT_CANDIDATES))
|
||||
self.assertEqual(col, 'world_id')
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@ -1,29 +1,19 @@
|
||||
# api/pbl_domain_ref_bind.dspy — 契约端点:bind_ref(设计 §3.1 接口 1)
|
||||
# 实现:pbl_domain_ext/api.py:bind_ref(经 init.py 注册为 env.pbl_bind_ref)
|
||||
# 入参:ref_type(world/scene/entity) ref_id blueprint_id class_id team_id ext_json tenant_id
|
||||
# 说明:ext_json 原样透传(dict 或 JSON 字符串均可,由 api._dump_ext 统一校验/序列化)
|
||||
debug('pbl_domain_ref_bind.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_domain_ref_bind.dspy — 契约端点:绑定基础域对象到 PBL 蓝图/班级/团队(幂等 upsert)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_domain_ref_bind(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_domain_ref_bind)
|
||||
# 入参:tenant_id* ref_type*(world|scene|entity) ref_id* ref_code ref_name blueprint_id class_id team_id ext_json operator_id
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_domain_ref_bind.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_bind_ref(
|
||||
params_kw.get('ref_type'),
|
||||
params_kw.get('ref_id'),
|
||||
blueprint_id=params_kw.get('blueprint_id'),
|
||||
class_id=params_kw.get('class_id'),
|
||||
team_id=params_kw.get('team_id'),
|
||||
ext_json=params_kw.get('ext_json'),
|
||||
tenant_id=params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_domain_ref_bind.dspy: OK ref_type=%s ref_id=%s'
|
||||
% (params_kw.get('ref_type'), params_kw.get('ref_id')))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_domain_ref_bind(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_domain_ref_bind.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_domain_ref_bind.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_domain_ref_bind.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,24 +1,19 @@
|
||||
# api/pbl_domain_ref_check_access.dspy — 契约端点:check_ref_access(设计 §3.2 接口 10)
|
||||
# 实现:pbl_domain_ext/api.py:check_ref_access(env.pbl_check_ref_access)
|
||||
# 出参:bool(租户+班级+团队三重匹配;不抛异常,供运行时热路径判定 F-RT-02)
|
||||
debug('pbl_domain_ref_check_access.dspy: START params_kw=%s' % dict(params_kw))
|
||||
# api/pbl_domain_ref_check_access.dspy — 契约端点:访问判定:当前租户/班级/团队能否访问 ref_type+ref_id
|
||||
# 实现:pbl_domain_ext/api.py:pbl_domain_ref_check_access(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_domain_ref_check_access)
|
||||
# 入参:tenant_id* ref_type* ref_id* class_id team_id
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_domain_ref_check_access.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_allowed = await pbl_check_ref_access(
|
||||
params_kw.get('ref_type'),
|
||||
params_kw.get('ref_id'),
|
||||
params_kw.get('tenant_id'),
|
||||
class_id=params_kw.get('class_id'),
|
||||
team_id=params_kw.get('team_id'),
|
||||
)
|
||||
debug('pbl_domain_ref_check_access.dspy: OK allowed=%s' % _allowed)
|
||||
return {'success': True, 'error_code': None, 'message': 'ok',
|
||||
'data': {'allowed': bool(_allowed)},
|
||||
'allowed': bool(_allowed),
|
||||
'http_status': 200 if _allowed else 403}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_domain_ref_check_access(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_domain_ref_check_access.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
debug('pbl_domain_ref_check_access.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'data': {'allowed': False}, 'allowed': False, 'http_status': 500}
|
||||
debug('pbl_domain_ref_check_access.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
# api/pbl_domain_ref_get.dspy — 契约端点:get_ref(设计 §3.1 接口 3)
|
||||
# 实现:pbl_domain_ext/api.py:get_ref(env.pbl_get_ref)
|
||||
debug('pbl_domain_ref_get.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_domain_ref_get.dspy — 契约端点:取单条关联记录(with_base=1 时附基表只读视图)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_domain_ref_get(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_domain_ref_get)
|
||||
# 入参:tenant_id* id 或 ref_type+ref_id with_base
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_domain_ref_get.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_get_ref(
|
||||
params_kw.get('ref_type'),
|
||||
params_kw.get('ref_id'),
|
||||
tenant_id=params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_domain_ref_get.dspy: OK id=%s' % (_data or {}).get('id'))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_domain_ref_get(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_domain_ref_get.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_domain_ref_get.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_domain_ref_get.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,37 +1,19 @@
|
||||
# api/pbl_domain_ref_list.dspy — 契约端点:list_refs(设计 §3.1 接口 4)
|
||||
# 实现:pbl_domain_ext/api.py:list_refs(env.pbl_list_refs)
|
||||
# 入参:filters{ref_type,blueprint_id,class_id,team_id,ref_id} page size tenant_id
|
||||
debug('pbl_domain_ref_list.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
|
||||
# filters 兼容两种传法:整体 JSON 字符串 / 平铺字段(DSPY 必须转发全部客户端参数,禁硬编码)
|
||||
_filters = params_kw.get('filters')
|
||||
if isinstance(_filters, str) and _filters.strip():
|
||||
try:
|
||||
_filters = json.loads(_filters)
|
||||
except Exception:
|
||||
_filters = None
|
||||
if not isinstance(_filters, dict):
|
||||
_filters = {}
|
||||
for _k in ('ref_type', 'blueprint_id', 'class_id', 'team_id', 'ref_id'):
|
||||
if _k not in _filters and params_kw.get(_k) not in (None, ''):
|
||||
_filters[_k] = params_kw.get(_k)
|
||||
# api/pbl_domain_ref_list.dspy — 契约端点:分页查询关联记录
|
||||
# 实现:pbl_domain_ext/api.py:pbl_domain_ref_list(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_domain_ref_list)
|
||||
# 入参:tenant_id* ref_type bind_state blueprint_id team_id class_id ref_ids keyword page page_size
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_domain_ref_list.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_list_refs(
|
||||
_filters,
|
||||
params_kw.get('page', 1),
|
||||
params_kw.get('size', 20),
|
||||
tenant_id=params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_domain_ref_list.dspy: OK total=%s' % (_data or {}).get('total'))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_domain_ref_list(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_domain_ref_list.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_domain_ref_list.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_domain_ref_list.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
# api/pbl_domain_ref_unbind.dspy — 契约端点:unbind_ref(设计 §3.1 接口 2)
|
||||
# 实现:pbl_domain_ext/api.py:unbind_ref(env.pbl_unbind_ref);软删 is_deleted=1
|
||||
debug('pbl_domain_ref_unbind.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_domain_ref_unbind.dspy — 契约端点:解绑(软删 is_deleted=1,不级联删除基表 world/scene/entity)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_domain_ref_unbind(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_domain_ref_unbind)
|
||||
# 入参:tenant_id* id 或 ref_type+ref_id operator_id
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_domain_ref_unbind.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_unbind_ref(
|
||||
params_kw.get('ref_type'),
|
||||
params_kw.get('ref_id'),
|
||||
tenant_id=params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_domain_ref_unbind.dspy: OK result=%s' % _data)
|
||||
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_domain_ref_unbind(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_domain_ref_unbind.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_domain_ref_unbind.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_domain_ref_unbind.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,36 +1,19 @@
|
||||
# api/pbl_domain_ref_update.dspy — 契约端点:update_ref(设计 §3.1 接口 5)
|
||||
# 实现:pbl_domain_ext/api.py:update_ref(env.pbl_update_ref)
|
||||
# 入参:ref_type ref_id data{class_id,team_id,ext_json,blueprint_id} tenant_id
|
||||
debug('pbl_domain_ref_update.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
|
||||
_data_in = params_kw.get('data')
|
||||
if isinstance(_data_in, str) and _data_in.strip():
|
||||
try:
|
||||
_data_in = json.loads(_data_in)
|
||||
except Exception:
|
||||
_data_in = None
|
||||
if not isinstance(_data_in, dict):
|
||||
_data_in = {}
|
||||
for _k in ('blueprint_id', 'class_id', 'team_id', 'ext_json'):
|
||||
if _k not in _data_in and params_kw.get(_k) is not None:
|
||||
_data_in[_k] = params_kw.get(_k)
|
||||
# api/pbl_domain_ref_update.dspy — 契约端点:更新关联记录(ext_json / blueprint_id / class_id / team_id)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_domain_ref_update(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_domain_ref_update)
|
||||
# 入参:tenant_id* id 或 ref_type+ref_id ext_json blueprint_id class_id team_id operator_id
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_domain_ref_update.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_update_ref(
|
||||
params_kw.get('ref_type'),
|
||||
params_kw.get('ref_id'),
|
||||
_data_in,
|
||||
tenant_id=params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_domain_ref_update.dspy: OK id=%s' % (_data or {}).get('id'))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_domain_ref_update(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_domain_ref_update.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_domain_ref_update.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_domain_ref_update.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,23 +1,19 @@
|
||||
# api/pbl_entity_list_by_scene.dspy — 契约端点:list_entities_by_scene(设计 §3.2 接口 8)
|
||||
# 实现:pbl_domain_ext/api.py:list_entities_by_scene(env.pbl_list_entities_by_scene)
|
||||
# 越权:scene 非本租户绑定 → PBL_E_FORBIDDEN(403)(US-21)
|
||||
debug('pbl_entity_list_by_scene.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_entity_list_by_scene.dspy — 契约端点:只读:按 scene_id 列实体(基表 entity + 关联表过滤)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_entity_list_by_scene(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_entity_list_by_scene)
|
||||
# 入参:tenant_id* scene_id* keyword page page_size
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_entity_list_by_scene.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_list_entities_by_scene(
|
||||
params_kw.get('scene_id'),
|
||||
params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_entity_list_by_scene.dspy: OK count=%s' % len(_data or []))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok',
|
||||
'data': {'items': _data, 'total': len(_data or [])}}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_entity_list_by_scene(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_entity_list_by_scene.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_entity_list_by_scene.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_entity_list_by_scene.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,23 +1,19 @@
|
||||
# api/pbl_scene_list_by_world.dspy — 契约端点:list_scenes_by_world(设计 §3.2 接口 7)
|
||||
# 实现:pbl_domain_ext/api.py:list_scenes_by_world(env.pbl_list_scenes_by_world)
|
||||
# 越权:world 非本租户绑定 → PBL_E_FORBIDDEN(403)(US-21)
|
||||
debug('pbl_scene_list_by_world.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_scene_list_by_world.dspy — 契约端点:只读:按 world_id 列场景(基表 scene + 关联表过滤)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_scene_list_by_world(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_scene_list_by_world)
|
||||
# 入参:tenant_id* world_id* keyword page page_size
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_scene_list_by_world.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_list_scenes_by_world(
|
||||
params_kw.get('world_id'),
|
||||
params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_scene_list_by_world.dspy: OK count=%s' % len(_data or []))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok',
|
||||
'data': {'items': _data, 'total': len(_data or [])}}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_scene_list_by_world(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_scene_list_by_world.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_scene_list_by_world.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_scene_list_by_world.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,34 +1,19 @@
|
||||
# api/pbl_team_bind_world.dspy — 契约端点:bind_team_to_world(设计 §3.3 接口 12)
|
||||
# 实现:pbl_domain_ext/api.py:bind_team_to_world(env.pbl_bind_team_to_world)
|
||||
# 错误分支:PBL_E_NOT_FOUND(world 基表不存在)/ PBL_E_DUPLICATE(同团队重复绑定)
|
||||
debug('pbl_team_bind_world.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
|
||||
_ext = params_kw.get('ext_json')
|
||||
if isinstance(_ext, str) and _ext.strip():
|
||||
try:
|
||||
_ext = json.loads(_ext)
|
||||
except Exception:
|
||||
pass
|
||||
# api/pbl_team_bind_world.dspy — 契约端点:团队绑定世界(写 pbl_domain_ref ref_type=world team_id=...,幂等)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_team_bind_world(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_team_bind_world)
|
||||
# 入参:tenant_id* world_id* team_id* class_id operator_id
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_team_bind_world.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_bind_team_to_world(
|
||||
params_kw.get('world_id'),
|
||||
params_kw.get('team_id'),
|
||||
params_kw.get('tenant_id'),
|
||||
class_id=params_kw.get('class_id'),
|
||||
blueprint_id=params_kw.get('blueprint_id'),
|
||||
ext_json=_ext,
|
||||
)
|
||||
debug('pbl_team_bind_world.dspy: OK world_id=%s team_id=%s'
|
||||
% (params_kw.get('world_id'), params_kw.get('team_id')))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_team_bind_world(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_team_bind_world.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_team_bind_world.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_team_bind_world.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,23 +1,19 @@
|
||||
# api/pbl_team_list_by_class.dspy — 契约端点:list_teams_by_class(设计 §3.3 接口 11)
|
||||
# 实现:pbl_domain_ext/api.py:list_teams_by_class(env.pbl_list_teams_by_class)
|
||||
# 出参:list[{team_id,members[],ref_ids[],world_ids[],scene_ids[],entity_ids[]}](US-13 共享世界分组)
|
||||
debug('pbl_team_list_by_class.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_team_list_by_class.dspy — 契约端点:只读:班级下团队及其绑定世界
|
||||
# 实现:pbl_domain_ext/api.py:pbl_team_list_by_class(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_team_list_by_class)
|
||||
# 入参:tenant_id* class_id* keyword page page_size
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_team_list_by_class.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_list_teams_by_class(
|
||||
params_kw.get('class_id'),
|
||||
params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_team_list_by_class.dspy: OK teams=%s' % len(_data or []))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok',
|
||||
'data': {'items': _data, 'total': len(_data or [])}}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_team_list_by_class(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_team_list_by_class.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_team_list_by_class.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_team_list_by_class.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,23 +1,19 @@
|
||||
# api/pbl_team_world_list.dspy — 契约端点:get_team_worlds(设计 §3.3 接口 13)
|
||||
# 实现:pbl_domain_ext/api.py:get_team_worlds(env.pbl_get_team_worlds)
|
||||
# 出参:list[world+ref](仅本租户 + 本团队绑定的 world)
|
||||
debug('pbl_team_world_list.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_team_world_list.dspy — 契约端点:只读:团队可访问的世界清单
|
||||
# 实现:pbl_domain_ext/api.py:pbl_team_world_list(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_team_world_list)
|
||||
# 入参:tenant_id* team_id* class_id page page_size
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_team_world_list.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_get_team_worlds(
|
||||
params_kw.get('team_id'),
|
||||
params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_team_world_list.dspy: OK count=%s' % len(_data or []))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok',
|
||||
'data': {'items': _data, 'total': len(_data or [])}}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_team_world_list(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_team_world_list.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_team_world_list.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_team_world_list.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
# api/pbl_world_get_context.dspy — 契约端点:get_world_with_pbl_context(设计 §3.2 接口 9)
|
||||
# 实现:pbl_domain_ext/api.py:get_world_with_pbl_context(env.pbl_get_world_with_pbl_context)
|
||||
# 出参:world 基表字段 + pbl_context{tenant_id,blueprint_id,class_id,team_id,ext_json}
|
||||
debug('pbl_world_get_context.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
# api/pbl_world_get_context.dspy — 契约端点:只读:世界上下文聚合(world + 其下 scene/entity 关联)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_world_get_context(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_world_get_context)
|
||||
# 入参:tenant_id* world_id* with_scenes with_entities
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_world_get_context.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_get_world_with_pbl_context(
|
||||
params_kw.get('world_id'),
|
||||
params_kw.get('tenant_id'),
|
||||
)
|
||||
debug('pbl_world_get_context.dspy: OK world_id=%s' % params_kw.get('world_id'))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok', 'data': _data}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_world_get_context(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_world_get_context.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_world_get_context.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_world_get_context.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
@ -1,36 +1,19 @@
|
||||
# api/pbl_world_list_by_tenant.dspy — 契约端点:list_worlds_by_tenant(设计 §3.2 接口 6)
|
||||
# 实现:pbl_domain_ext/api.py:list_worlds_by_tenant(env.pbl_list_worlds_by_tenant)
|
||||
# 隔离逻辑:基表 world 无租户列 → 先查 pbl_domain_ref 白名单再只读回查基表
|
||||
debug('pbl_world_list_by_tenant.dspy: START params_kw=%s' % dict(params_kw))
|
||||
|
||||
_HTTP = {'PBL_E_VALIDATION': 400, 'PBL_E_NOT_FOUND': 404, 'PBL_E_DUPLICATE': 409,
|
||||
'PBL_E_FORBIDDEN': 403, 'PBL_E_INTERNAL': 500}
|
||||
|
||||
_filters = params_kw.get('filters')
|
||||
if isinstance(_filters, str) and _filters.strip():
|
||||
try:
|
||||
_filters = json.loads(_filters)
|
||||
except Exception:
|
||||
_filters = None
|
||||
if not isinstance(_filters, dict):
|
||||
_filters = {}
|
||||
for _k in ('blueprint_id', 'team_id'):
|
||||
if _k not in _filters and params_kw.get(_k) not in (None, ''):
|
||||
_filters[_k] = params_kw.get(_k)
|
||||
# api/pbl_world_list_by_tenant.dspy — 契约端点:只读:本租户可见世界(基表 world + 关联表过滤)
|
||||
# 实现:pbl_domain_ext/api.py:pbl_world_list_by_tenant(init.py 经 load_pbl_domain_ext() 注册为 ServerEnv 全局 env.pbl_world_list_by_tenant)
|
||||
# 入参:tenant_id* keyword status page page_size
|
||||
# 规范:module-development-spec §2.5 —— dspy 无 import、显式 return、转发**全部**客户端参数(禁硬编码 dispatch 字段)
|
||||
# 薄扩展铁律:只读写关联表 pbl_domain_ref,基表 world/scene/entity 一律只读(Q-OPEN-3)
|
||||
debug('pbl_world_list_by_tenant.dspy: START params_kw=%s' % (dict(params_kw) if params_kw else {}))
|
||||
|
||||
try:
|
||||
_data = await pbl_list_worlds_by_tenant(
|
||||
params_kw.get('tenant_id'),
|
||||
class_id=params_kw.get('class_id'),
|
||||
filters=_filters,
|
||||
)
|
||||
debug('pbl_world_list_by_tenant.dspy: OK count=%s' % len(_data or []))
|
||||
return {'success': True, 'error_code': None, 'message': 'ok',
|
||||
'data': {'items': _data, 'total': len(_data or [])}}
|
||||
_params = dict(params_kw) if params_kw else {}
|
||||
_resp = pbl_world_list_by_tenant(_params)
|
||||
if not isinstance(_resp, dict):
|
||||
_resp = {'success': False, 'code': 'PBL_DE_INTERNAL',
|
||||
'message': '契约函数返回非 dict', 'data': None}
|
||||
debug('pbl_world_list_by_tenant.dspy: DONE success=%s code=%s' % (_resp.get('success'), _resp.get('code')))
|
||||
return _resp
|
||||
except Exception as exc:
|
||||
_code = getattr(exc, 'code', None) or 'PBL_E_INTERNAL'
|
||||
_msg = getattr(exc, 'message', None) or str(exc)
|
||||
_detail = getattr(exc, 'detail', None) or {}
|
||||
debug('pbl_world_list_by_tenant.dspy: FAIL code=%s msg=%s' % (_code, _msg))
|
||||
return {'success': False, 'error_code': _code, 'message': _msg,
|
||||
'detail': _detail, 'http_status': _HTTP.get(_code, 500)}
|
||||
debug('pbl_world_list_by_tenant.dspy: FAIL %s' % format_exc())
|
||||
return {'success': False, 'code': 'PBL_DE_INTERNAL', 'message': str(exc),
|
||||
'detail': format_exc(), 'http_status': 500, 'data': None}
|
||||
|
||||
645
wwwroot/index.ui
645
wwwroot/index.ui
@ -1,129 +1,588 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "pbl_domain_ext_index",
|
||||
"title": "PBL 基础域薄扩展 world/scene/entity(M8)",
|
||||
"layout": "vbox",
|
||||
"items": [
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
"padding": "20px",
|
||||
"backgroundColor": "#F5F7FA"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"type": "html",
|
||||
"id": "hdr",
|
||||
"html": "<div style='padding:10px 14px'><h3>PBL 基础域薄扩展契约面板</h3><p>world / scene / entity 三模块的 PBL 侧薄扩展:仅新增关联表 pbl_domain_ref 与只读查询契约,<b>不改三张基表结构</b>(Q-OPEN-3)。下列 13 个契约卡片 url 全部使用 entire_url() 生成,避免 RBAC 403 与路由错误。</p></div>"
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "PBL 基础域薄扩展 (M8) — world / scene / entity 契约导航",
|
||||
"fontSize": "22px",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "grp_ref",
|
||||
"title": "一、关联表 pbl_domain_ref 契约(6)",
|
||||
"layout": "hbox",
|
||||
"items": [
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "薄扩展边界:仅新增关联表 pbl_domain_ref,不修改 world / scene / entity 三张基表结构 (Q-OPEN-3)。下列 13 个契约入口的 url 一律使用 entire_url 包裹的 /pbl_domain_ext/api/xxx.dspy 绝对路径(module-development-spec 2.1 / 2.3c),避免 RBAC 403 与路由错误。",
|
||||
"fontSize": "13px",
|
||||
"color": "#666666",
|
||||
"marginTop": "6px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "一、关联表 pbl_domain_ref 契约(6)",
|
||||
"fontSize": "16px",
|
||||
"marginTop": "18px",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {
|
||||
"gap": "16px",
|
||||
"minWidth": "250px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_ref_list",
|
||||
"text": "1. 关联列表 pbl_domain_ref_list",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_list.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_list.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "1. 关联记录列表 pbl_domain_ref_list",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "按 tenant_id / ref_type / class_id / team_id 分页查询关联记录",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_ref_get",
|
||||
"text": "2. 关联详情 pbl_domain_ref_get",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_get.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_get.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "2. 关联记录详情 pbl_domain_ref_get",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "按 id 取单条关联记录(含 ext_json 解析结果)",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_ref_bind",
|
||||
"text": "3. 绑定 pbl_domain_ref_bind",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "3. 绑定 pbl_domain_ref_bind",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "幂等建立 ref_type+ref_id 与蓝图/班级/团队的关联",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_ref_update",
|
||||
"text": "4. 更新 pbl_domain_ref_update",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "4. 更新 pbl_domain_ref_update",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "更新 ext_json / blueprint_id / class_id / team_id(租户与归属校验)",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_ref_unbind",
|
||||
"text": "5. 解绑 pbl_domain_ref_unbind",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "5. 解绑 pbl_domain_ref_unbind",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "删除关联记录(不级联删除基表 world/scene/entity)",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_ref_check_access",
|
||||
"text": "6. 访问判定 pbl_domain_ref_check_access",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_check_access.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_check_access.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "6. 访问判定 pbl_domain_ref_check_access",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "判定当前租户/班级/团队是否可访问指定 ref_type+ref_id",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "grp_world",
|
||||
"title": "二、world 薄扩展契约(2)",
|
||||
"layout": "hbox",
|
||||
"items": [
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "二、基础域只读查询契约 world / scene / entity(4)",
|
||||
"fontSize": "16px",
|
||||
"marginTop": "18px",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {
|
||||
"gap": "16px",
|
||||
"minWidth": "250px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_world_list",
|
||||
"text": "7. 租户世界列表 pbl_world_list_by_tenant",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_world_list_by_tenant.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_world_list_by_tenant.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "7. 世界列表 pbl_world_list_by_tenant",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "读基表 world + 关联表过滤,返回本租户可见世界",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_world_ctx",
|
||||
"text": "8. 世界上下文 pbl_world_get_context",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_world_get_context.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_world_get_context.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "8. 世界上下文 pbl_world_get_context",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "聚合 world 与其下 scene/entity 关联,供运行时取上下文",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_scene_list_by_world.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "9. 场景列表 pbl_scene_list_by_world",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "按 world_id 读基表 scene + 关联表过滤",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_entity_list_by_scene.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "10. 实体列表 pbl_entity_list_by_scene",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "按 scene_id 读基表 entity + 关联表过滤",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "grp_scene_entity",
|
||||
"title": "三、scene / entity 薄扩展契约(2)",
|
||||
"layout": "hbox",
|
||||
"items": [
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "三、班级 / 团队维度契约(3)",
|
||||
"fontSize": "16px",
|
||||
"marginTop": "18px",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {
|
||||
"gap": "16px",
|
||||
"minWidth": "250px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_scene_list",
|
||||
"text": "9. 世界下场景 pbl_scene_list_by_world",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_scene_list_by_world.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_team_bind_world.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "11. 团队绑定世界 pbl_team_bind_world",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "写 pbl_domain_ref(ref_type=world, team_id=...),幂等",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_entity_list",
|
||||
"text": "10. 场景下实体 pbl_entity_list_by_scene",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_entity_list_by_scene.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_team_list_by_class.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "12. 班级下团队 pbl_team_list_by_class",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "按 class_id 聚合关联表,返回团队与其绑定的世界",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.pbl_domain_ext_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_team_world_list.dspy')}}?_webbricks_=1"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "13. 团队世界清单 pbl_team_world_list",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "按 team_id 返回该团队可访问的世界列表",
|
||||
"fontSize": "12px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "grp_team",
|
||||
"title": "四、班级/团队关联契约(3)",
|
||||
"layout": "hbox",
|
||||
"items": [
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_team_bind",
|
||||
"text": "11. 团队绑定世界 pbl_team_bind_world",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_team_bind_world.dspy')}}"
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_team_world_list",
|
||||
"text": "12. 团队世界列表 pbl_team_world_list",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_team_world_list.dspy')}}"
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"id": "card_team_by_class",
|
||||
"text": "13. 班级团队列表 pbl_team_list_by_class",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_team_list_by_class.dspy')}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "dataviewer",
|
||||
"id": "ref_viewer",
|
||||
"title": "关联记录浏览(CRUD: json/pbl_domain_ref.json)",
|
||||
"url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_list.dspy')}}"
|
||||
"widgettype": "VBox",
|
||||
"id": "pbl_domain_ext_content",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"flex": "1",
|
||||
"marginTop": "20px",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "12px",
|
||||
"borderRadius": "6px"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user