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

This commit is contained in:
agent.develop 2026-09-18 00:05:05 +08:00
parent 04b2e5a6fd
commit c38ac15df9
10 changed files with 2174 additions and 148 deletions

View File

@ -0,0 +1,30 @@
{
"summary": "PBL 实体关联表(薄扩展):把基表 entity 绑定到 PBL 租户/班级/团队 + scene/world 父级归属,不改 entity 基表结构Q-OPEN-3",
"tblname": "pbl_entity_ref",
"fields": [
{"name": "id", "type": "bigint", "primary": true, "auto": true, "comment": "主键"},
{"name": "tenant_id", "type": "varchar(64)", "null": false, "comment": "PBL 租户ID强制打头"},
{"name": "entity_id", "type": "bigint", "null": false, "comment": "基表 entity.id只读引用不建外键"},
{"name": "scene_id", "type": "bigint", "null": false, "default": 0, "comment": "父级归属 scene.id0=未指定"},
{"name": "world_id", "type": "bigint", "null": false, "default": 0, "comment": "父级归属 world.id0=未指定"},
{"name": "class_id", "type": "bigint", "null": false, "default": 0, "comment": "归属班级 pbl_class.id"},
{"name": "team_id", "type": "bigint", "null": false, "default": 0, "comment": "归属团队 pbl_team.id"},
{"name": "visibility", "type": "varchar(16)", "null": false, "default": "team", "comment": "可见性 team/class/tenant"},
{"name": "role_code", "type": "varchar(64)", "null": false, "default": "", "comment": "角色绑定(该实体扮演的蓝图角色),空=通用"},
{"name": "source", "type": "varchar(32)", "null": false, "default": "manual", "comment": "来源 manual/compiler/import"},
{"name": "ext_json", "type": "text", "null": true, "comment": "PBL 侧扩展属性 JSON实体在 PBL 语义下的额外属性,不回写基表)"},
{"name": "created_by", "type": "varchar(64)", "null": true, "comment": "创建人"},
{"name": "created_at", "type": "datetime", "null": false, "default": "CURRENT_TIMESTAMP", "comment": "创建时间"},
{"name": "updated_at", "type": "datetime", "null": true, "comment": "更新时间"}
],
"indexes": [
{"name": "uk_pbl_entity_ref", "unique": true, "fields": ["tenant_id", "entity_id", "team_id", "role_code"], "comment": "幂等唯一键"},
{"name": "idx_pbl_entity_ref_scene", "unique": false, "fields": ["tenant_id", "scene_id"], "comment": "按场景查实体"},
{"name": "idx_pbl_entity_ref_world", "unique": false, "fields": ["tenant_id", "world_id"], "comment": "按世界查实体"},
{"name": "idx_pbl_entity_ref_team", "unique": false, "fields": ["tenant_id", "team_id"], "comment": "按团队查"}
],
"codes": {
"visibility": {"team": "团队可见", "class": "班级可见", "tenant": "租户可见"},
"source": {"manual": "人工绑定", "compiler": "Compiler 物化", "import": "批量导入"}
}
}

29
models/pbl_scene_ref.json Normal file
View File

@ -0,0 +1,29 @@
{
"summary": "PBL 场景关联表(薄扩展):把基表 scene 绑定到 PBL 租户/班级/团队 + world 父级归属,不改 scene 基表结构Q-OPEN-3",
"tblname": "pbl_scene_ref",
"fields": [
{"name": "id", "type": "bigint", "primary": true, "auto": true, "comment": "主键"},
{"name": "tenant_id", "type": "varchar(64)", "null": false, "comment": "PBL 租户ID强制打头"},
{"name": "scene_id", "type": "bigint", "null": false, "comment": "基表 scene.id只读引用不建外键"},
{"name": "world_id", "type": "bigint", "null": false, "default": 0, "comment": "父级归属 world.id0=未指定"},
{"name": "class_id", "type": "bigint", "null": false, "default": 0, "comment": "归属班级 pbl_class.id"},
{"name": "team_id", "type": "bigint", "null": false, "default": 0, "comment": "归属团队 pbl_team.id"},
{"name": "visibility", "type": "varchar(16)", "null": false, "default": "team", "comment": "可见性 team/class/tenant"},
{"name": "role_code", "type": "varchar(64)", "null": false, "default": "", "comment": "角色绑定,空=通用"},
{"name": "source", "type": "varchar(32)", "null": false, "default": "manual", "comment": "来源 manual/compiler/import"},
{"name": "ext_json", "type": "text", "null": true, "comment": "PBL 侧扩展属性 JSON"},
{"name": "created_by", "type": "varchar(64)", "null": true, "comment": "创建人"},
{"name": "created_at", "type": "datetime", "null": false, "default": "CURRENT_TIMESTAMP", "comment": "创建时间"},
{"name": "updated_at", "type": "datetime", "null": true, "comment": "更新时间"}
],
"indexes": [
{"name": "uk_pbl_scene_ref", "unique": true, "fields": ["tenant_id", "scene_id", "team_id", "role_code"], "comment": "幂等唯一键"},
{"name": "idx_pbl_scene_ref_world", "unique": false, "fields": ["tenant_id", "world_id"], "comment": "按世界查场景"},
{"name": "idx_pbl_scene_ref_class", "unique": false, "fields": ["tenant_id", "class_id"], "comment": "按班级查"},
{"name": "idx_pbl_scene_ref_team", "unique": false, "fields": ["tenant_id", "team_id"], "comment": "按团队查"}
],
"codes": {
"visibility": {"team": "团队可见", "class": "班级可见", "tenant": "租户可见"},
"source": {"manual": "人工绑定", "compiler": "Compiler 物化", "import": "批量导入"}
}
}

28
models/pbl_world_ref.json Normal file
View File

@ -0,0 +1,28 @@
{
"summary": "PBL 世界关联表(薄扩展):把基表 world 绑定到 PBL 租户/班级/团队,不改 world 基表结构Q-OPEN-3",
"tblname": "pbl_world_ref",
"fields": [
{"name": "id", "type": "bigint", "primary": true, "auto": true, "comment": "主键"},
{"name": "tenant_id", "type": "varchar(64)", "null": false, "comment": "PBL 租户ID强制打头所有查询必带"},
{"name": "world_id", "type": "bigint", "null": false, "comment": "基表 world.id只读引用不建外键以免锁基表"},
{"name": "class_id", "type": "bigint", "null": false, "default": 0, "comment": "归属班级 pbl_class.id0=未指定"},
{"name": "team_id", "type": "bigint", "null": false, "default": 0, "comment": "归属团队 pbl_team.id0=未指定"},
{"name": "visibility", "type": "varchar(16)", "null": false, "default": "team", "comment": "可见性 team/class/tenant"},
{"name": "role_code", "type": "varchar(64)", "null": false, "default": "", "comment": "角色绑定teacher/student/observer 或蓝图角色码),空=通用"},
{"name": "source", "type": "varchar(32)", "null": false, "default": "manual", "comment": "来源 manual/compiler/import"},
{"name": "ext_json", "type": "text", "null": true, "comment": "PBL 侧扩展属性 JSON不改基表的前提下承载额外字段"},
{"name": "created_by", "type": "varchar(64)", "null": true, "comment": "创建人"},
{"name": "created_at", "type": "datetime", "null": false, "default": "CURRENT_TIMESTAMP", "comment": "创建时间"},
{"name": "updated_at", "type": "datetime", "null": true, "comment": "更新时间"}
],
"indexes": [
{"name": "uk_pbl_world_ref", "unique": true, "fields": ["tenant_id", "world_id", "team_id", "role_code"], "comment": "幂等唯一键"},
{"name": "idx_pbl_world_ref_class", "unique": false, "fields": ["tenant_id", "class_id"], "comment": "按班级查"},
{"name": "idx_pbl_world_ref_team", "unique": false, "fields": ["tenant_id", "team_id"], "comment": "按团队查"},
{"name": "idx_pbl_world_ref_world", "unique": false, "fields": ["world_id"], "comment": "反查基表归属"}
],
"codes": {
"visibility": {"team": "团队可见", "class": "班级可见", "tenant": "租户可见"},
"source": {"manual": "人工绑定", "compiler": "Compiler 物化", "import": "批量导入"}
}
}

View File

@ -1,27 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_domain_ext —— world/scene/entity 薄扩展:租户/班级/团队关联M8不改基表
"""pbl_domain_ext —— PBL 基础域薄扩展world/scene/entity 关联表 + 查询契约M8
注册三处同步之 必须导出 init.py 里的全部契约函数漏一行 .dspy 调用即 NameError
对外暴露
- load_pbl_domain_ext()模块挂载入口应用 init() 调用
- api契约层pbl_*_ref_upsert / pbl_*_ref_list / pbl_domain_scope_resolve / ...
- assoc读写内核不依赖 Sage 运行时可离线单测
铁律world / scene / entity 三张基表零 ALTER零写入Q-OPEN-3
"""
from pbl_domain_ext.init import load_pbl_domain_ext
from pbl_domain_ext.api import (
pbl_tenant_upsert,
pbl_class_save,
pbl_class_list,
pbl_team_save,
pbl_team_list,
pbl_domain_materialize_game_definition,
from __future__ import annotations
from . import api, assoc
from .init import (
API_NAMES,
MODULE_NAME,
OWN_TABLES,
READONLY_BASE_TABLES,
get_dbname,
load_cruds,
load_models,
load_module,
load_pbl_domain_ext,
)
__all__ = [
'load_pbl_domain_ext',
'pbl_tenant_upsert',
'pbl_class_save',
'pbl_class_list',
'pbl_team_save',
'pbl_team_list',
'pbl_domain_materialize_game_definition',
"api",
"assoc",
"load_pbl_domain_ext",
"load_module",
"load_models",
"load_cruds",
"get_dbname",
"MODULE_NAME",
"OWN_TABLES",
"READONLY_BASE_TABLES",
"API_NAMES",
]
__version__ = "2.0.0"

View File

@ -1,139 +1,464 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_domain_ext.api —— world/scene/entity 薄扩展M8Q-OPEN-3不改基表,只加关联表)。
"""pbl_domain_ext.api —— world/scene/entity 薄扩展契约面M8Q-OPEN-3不改基表
规则
- 基表 world/scene/entity ** ALTER**对既有 yuanjing 项目零影响PBL 侧一律通过
关联表 `pbl_tenant`/`pbl_class`/`pbl_team` + 本模块的映射函数建立溯源
- Game Definition 物化materialize= 把编译产物写入 world/scene/entity **只新增行**路径
通过基座模块契约函数ServerEnv 上已注册的 load_world/load_scene/load_entity 能力调用
不直接拼基表 SQL基座函数不在位时降级为 pbl 侧影子表并明确回报 degraded不静默假成功
铁律
- 基表 world / scene / entity ** ALTER零写入**PBL 侧归属关系一律落在本模块
自有表pbl_tenant / pbl_class / pbl_team + pbl_world_ref / pbl_scene_ref / pbl_entity_ref
- 所有契约 tenant_id 强制打头显式入参 > pbl_common 上下文租户 > fail-closed 报错
绝不静默兜底成默认租户
- 业务规则全部在 assoc.py可离线单测本层只做解包 调内核 统一响应体
历史版 api.py 依赖 pbl_common.api 中并不存在的符号actor_id/crud/json_dump/sql_rows/
sql_exec/sql_scalar/tenant_idimport ImportError本版改为只依赖 assoc 内核 +
可选的 pbl_common 上下文import 闭包闭合可离线单测
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec,
sql_rows, sql_scalar, tenant_id)
from . import assoc
from .assoc import (
ENTITY_REF,
SCENE_REF,
WORLD_REF,
DomainExtError,
default_db,
)
TEN = crud('pbl_tenant', 'pbl', ['tenant_uid', 'name', 'kind', 'plan', 'seat_limit', 'status',
'config_json'])
CLS = crud('pbl_class', 'pbl', ['class_uid', 'name', 'grade', 'teacher_id', 'student_count',
'roster_json', 'status'])
TEAM = crud('pbl_team', 'pbl', ['team_uid', 'class_id', 'name', 'mission_id', 'members_json',
'capacity', 'status', 'session_id'])
__all__ = [
"pbl_tenant_upsert",
"pbl_class_save",
"pbl_class_list",
"pbl_team_save",
"pbl_team_list",
"pbl_world_ref_upsert",
"pbl_scene_ref_upsert",
"pbl_entity_ref_upsert",
"pbl_world_ref_list",
"pbl_scene_ref_list",
"pbl_entity_ref_list",
"pbl_domain_ref_get",
"pbl_domain_ref_delete",
"pbl_domain_scope_resolve",
"pbl_domain_stats",
"pbl_domain_materialize_game_definition",
"call_api",
"API_NAMES",
]
# ---------------------------------------------------------------- 可选依赖(缺失不致命)
try: # pragma: no cover - 取决于运行环境
from pbl_common.context import get_tenant as _ctx_tenant
except Exception: # pragma: no cover
_ctx_tenant = None
try: # pragma: no cover
from pbl_common.errors import PBLError as _PBLError
except Exception: # pragma: no cover
class _PBLError(Exception):
code = "PBL_ERROR"
def __init__(self, code: str = "PBL_ERROR", message: str = "") -> None:
super().__init__(message)
self.code = code
self.message = message
def _loads(v, d=None):
if isinstance(v, (dict, list)):
return v
# ---------------------------------------------------------------- 归属主数据表字段
TENANT_FIELDS = ["tenant_uid", "name", "kind", "plan", "seat_limit", "status", "config_json"]
CLASS_FIELDS = ["class_uid", "name", "grade", "teacher_id", "student_count", "roster_json", "status"]
TEAM_FIELDS = ["team_uid", "class_id", "name", "mission_id", "members_json", "capacity",
"status", "session_id"]
_TABLE_ALIASES: Dict[str, str] = {
"world": WORLD_REF, "world_ref": WORLD_REF, "pbl_world_ref": WORLD_REF,
"scene": SCENE_REF, "scene_ref": SCENE_REF, "pbl_scene_ref": SCENE_REF,
"entity": ENTITY_REF, "entity_ref": ENTITY_REF, "pbl_entity_ref": ENTITY_REF,
}
_KEY_FIELD = {WORLD_REF: "world_id", SCENE_REF: "scene_id", ENTITY_REF: "entity_id"}
# ---------------------------------------------------------------- 响应体 / 上下文
def _ok(data: Any = None, **extra: Any) -> Dict[str, Any]:
payload: Dict[str, Any] = {"ok": True, "code": "OK"}
if data is not None:
payload["data"] = data
payload.update(extra)
return payload
def _fail(code: str, message: str, **detail: Any) -> Dict[str, Any]:
payload: Dict[str, Any] = {"ok": False, "code": code, "message": message}
if detail:
payload["detail"] = detail
return payload
def _guard(func):
"""统一异常 → 响应体DomainExtError / PBLError / ValueError"""
def wrapper(params: Dict[str, Any]) -> Dict[str, Any]:
try:
return func(params)
except DomainExtError as exc:
return _fail(exc.code, exc.message, **exc.detail)
except _PBLError as exc:
return _fail(getattr(exc, "code", "PBL_ERROR"), str(getattr(exc, "message", exc)))
except ValueError as exc:
return _fail(assoc.ERR_PARAM_REQUIRED, "参数解析失败: %s" % (exc,))
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
def _db(params: Optional[Dict[str, Any]] = None):
"""测试可注入 _db生产走 sqlor + ServerEnv().get_module_dbname('pbl_domain_ext')。"""
injected = (params or {}).get("_db")
return injected if injected is not None else default_db()
def _resolve_tenant(params: Dict[str, Any]) -> str:
"""租户解析:显式入参优先,其次 pbl_common 上下文,都没有 → fail-closed。"""
raw = params.get("tenant_id")
if raw is not None and str(raw).strip():
return str(raw).strip()
if _ctx_tenant is not None:
try:
ctx = _ctx_tenant()
if isinstance(ctx, str) and ctx.strip():
return ctx.strip()
if isinstance(ctx, dict):
value = ctx.get("tenant_id") or ctx.get("tenantid")
if value and str(value).strip():
return str(value).strip()
if ctx is not None and not isinstance(ctx, (str, dict)):
value = getattr(ctx, "tenant_id", None)
if value and str(value).strip():
return str(value).strip()
except Exception:
pass
raise DomainExtError(assoc.ERR_TENANT_REQUIRED,
"缺少 tenant_id入参未给且无 PBL 租户上下文(拒绝默认租户兜底)")
def _loads(value: Any, default: Any = None):
if isinstance(value, (dict, list)):
return value
if value is None or (isinstance(value, str) and not value.strip()):
return default if default is not None else {}
try:
return json.loads(v) if v else (d or {})
return json.loads(value)
except ValueError:
return d or {}
return default if default is not None else {}
async def _tid():
return await tenant_id()
def _dumps(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
async def pbl_tenant_upsert(**kw):
payload = dict(kw)
payload['config_json'] = json_dump(kw.get('config_json'))
payload.setdefault('tenant_uid', kw.get('tenant_uid') or await _tid())
payload.setdefault('status', 'active')
r = await TEN['upsert'](['tenant_uid'], **payload)
r['base_tables_altered'] = False
return r
# ================================================================ 归属主数据tenant/class/team
def _upsert_master(db, table: str, tenant: str, unique_field: str, fields: List[str],
params: Dict[str, Any]) -> Dict[str, Any]:
"""pbl_tenant / pbl_class / pbl_team 的幂等 upsert按 tenant_id + 业务唯一码)。"""
table = assoc._safe_col(table)
unique = assoc._safe_col(unique_field)
uid = str(params.get(unique_field) or "").strip()
if not uid:
raise DomainExtError(assoc.ERR_PARAM_REQUIRED, "缺少必填 %s" % unique_field)
cols = [c for c in fields if c != unique_field]
values = {c: params.get(c) for c in cols}
rows = db.select(
"SELECT * FROM %s WHERE tenant_id=%%s AND %s=%%s ORDER BY id LIMIT 1" % (table, unique),
(tenant, uid),
)
if rows:
assigns = ", ".join("%s=%%s" % assoc._safe_col(c) for c in cols)
sql = "UPDATE %s SET %s, updated_at=NOW() WHERE tenant_id=%%s AND id=%%s" % (table, assigns)
db.execute(sql, tuple([values[c] for c in cols] + [tenant, int(rows[0].get("id", 0))]))
return {"table": table, "action": "updated", "id": int(rows[0].get("id", 0)),
"tenant_id": tenant, unique_field: uid}
all_cols = ["tenant_id", unique] + cols
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
table, ", ".join(all_cols), ", ".join(["%s"] * len(all_cols)))
new_id = db.insert(sql, tuple([tenant, uid] + [values[c] for c in cols]))
return {"table": table, "action": "created", "id": int(new_id or 0),
"tenant_id": tenant, unique_field: uid}
async def pbl_class_save(**kw):
payload = dict(kw)
roster = _loads(kw.get('roster_json'), [])
payload['roster_json'] = json_dump(roster)
payload['student_count'] = len(roster) if roster else kw.get('student_count', 0)
payload.setdefault('class_uid', 'CL%s' % now_str().replace('-', '').replace(':', '')
.replace(' ', ''))
if kw.get('id'):
return await CLS['update'](**payload)
return await CLS['create'](**payload)
def _list_master(db, table: str, tenant: str, params: Dict[str, Any]) -> Dict[str, Any]:
table = assoc._safe_col(table)
conds = [("tenant_id", tenant)]
for field in ("class_id", "status", "kind", "session_id", "mission_id"):
if field in params and params[field] not in (None, ""):
conds.append((assoc._safe_col(field), params[field]))
items = assoc._select(db, table, conds, order_by="id",
limit=params.get("limit", assoc.DEFAULT_LIMIT),
offset=params.get("offset", 0))
return {
"table": table,
"tenant_id": tenant,
"items": items,
"total": assoc._count(db, table, conds),
"limit": assoc._clamp_limit(params.get("limit", assoc.DEFAULT_LIMIT)),
"offset": assoc._clamp_offset(params.get("offset", 0)),
}
async def pbl_class_list(**kw):
return await CLS['list'](**kw)
@_guard
def pbl_tenant_upsert(params: Dict[str, Any]) -> Dict[str, Any]:
"""租户主数据幂等写入tenant_uid 唯一)。基表零改动。"""
db = _db(params)
tenant = _resolve_tenant(params)
payload = dict(params)
payload["config_json"] = _dumps(_loads(params.get("config_json"), {}))
payload.setdefault("status", "active")
payload.setdefault("tenant_uid", tenant)
data = _upsert_master(db, "pbl_tenant", tenant, "tenant_uid", TENANT_FIELDS, payload)
data["base_tables_altered"] = False
return _ok(data)
async def pbl_team_save(**kw):
tid = await _tid()
members = _loads(kw.get('members_json'), [])
capacity = int(kw.get('capacity') or 8)
if len(members) > capacity:
raise PblError('PBL_TEAM_OVER_CAPACITY', '成员 %d 超容量 %d(单会话 2~8 人)'
% (len(members), capacity))
payload = dict(kw)
payload['members_json'] = json_dump(members)
payload.setdefault('team_uid', 'TM%s' % now_str().replace('-', '').replace(':', '')
.replace(' ', ''))
payload.setdefault('status', 'active')
if kw.get('id'):
return await TEAM['update'](**payload)
return await TEAM['create'](**payload)
@_guard
def pbl_class_save(params: Dict[str, Any]) -> Dict[str, Any]:
"""班级写入roster_json 决定 student_countclass_uid 缺省自动生成。"""
db = _db(params)
tenant = _resolve_tenant(params)
payload = dict(params)
roster = _loads(params.get("roster_json"), [])
payload["roster_json"] = _dumps(roster)
if isinstance(roster, list) and roster:
payload["student_count"] = len(roster)
payload.setdefault("class_uid", "CL%s" % assoc._norm_tenant(tenant).replace("-", "")[:8]
+ str(int(params.get("id") or 0)))
payload.setdefault("status", "active")
data = _upsert_master(db, "pbl_class", tenant, "class_uid", CLASS_FIELDS, payload)
return _ok(data)
async def pbl_team_list(**kw):
r = await TEAM['list'](**kw)
for row in r['data']:
row['members_json'] = _loads(row.get('members_json'), [])
row['member_count'] = len(row['members_json'])
return r
@_guard
def pbl_class_list(params: Dict[str, Any]) -> Dict[str, Any]:
db = _db(params)
tenant = _resolve_tenant(params)
data = _list_master(db, "pbl_class", tenant, params)
for row in data.get("items", []):
row["roster_json"] = _loads(row.get("roster_json"), [])
return _ok(data)
async def pbl_domain_materialize_game_definition(**kw):
"""Game Definition → world/scene/entity新增行 + 溯源映射),供 pbl_compiler 调用。"""
from ahserver.serverenv import ServerEnv
tid = await _tid()
gd_id = kw.get('id')
rows = await sql_rows('SELECT * FROM `pbl_game_definition` WHERE `tenant_id` = ${t}$'
' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': gd_id}, 'pbl')
if not rows:
raise PblError('PBL_GD_NOT_FOUND', 'Game Definition 不存在')
gd = rows[0]
definition = _loads(gd['definition_json'], {})
if gd.get('world_id'):
return {'ok': True, 'already': True, 'world_id': gd['world_id'],
'scene_id': gd['scene_id'], 'degraded': False}
env = ServerEnv()
world_fn = getattr(env, 'create_world', None) or getattr(env, 'create_worlds', None)
scene_fn = getattr(env, 'create_scene', None) or getattr(env, 'create_scenes', None)
entity_fn = getattr(env, 'create_entity', None) or getattr(env, 'create_entities', None)
degraded = not all(callable(f) for f in (world_fn, scene_fn, entity_fn))
title = 'PBL-%s-%s' % (gd['blueprint_id'], str(gd['content_fingerprint'])[:8])
if degraded:
# 基座函数未挂载(如离线单测):影子登记,明确回报,不假成功
world_id = await sql_scalar(
'SELECT MAX(`id`) AS m FROM `pbl_game_definition` WHERE `tenant_id` = ${t}$',
{'t': tid}, 'pbl', default=0)
return {'ok': True, 'world_id': None, 'scene_id': None, 'degraded': True,
'reason': 'base module loaders (world/scene/entity) not mounted',
'shadow_ref': world_id, 'title': title}
w = await _maybe(world_fn(name=title, description='compiled from game_definition %s' % gd_id,
mode=definition.get('world', {}).get('mode', 'shared')))
world_id = _pk(w)
s = await _maybe(scene_fn(name=title + '-scene', world_id=world_id))
scene_id = _pk(s)
for ent in definition.get('entities', []):
await _maybe(entity_fn(name=ent['entity_id'], world_id=world_id, scene_id=scene_id,
entity_type=ent['kind'],
capability_key=ent.get('capability_key')))
return {'ok': True, 'world_id': world_id, 'scene_id': scene_id, 'degraded': False,
'entity_rows': len(definition.get('entities', [])),
'base_tables_altered': False,
'note': '仅新增行world/scene/entity 表结构未改Q-OPEN-3'}
@_guard
def pbl_team_save(params: Dict[str, Any]) -> Dict[str, Any]:
"""团队写入:成员数不得超 capacity单会话 2~8 人约束由蓝图侧校验,这里只挡超容量)。"""
db = _db(params)
tenant = _resolve_tenant(params)
members = _loads(params.get("members_json"), [])
capacity = int(params.get("capacity") or 8)
if isinstance(members, list) and len(members) > capacity:
raise DomainExtError(assoc.ERR_CONFLICT,
"成员 %d 超容量 %d" % (len(members), capacity),
member_count=len(members), capacity=capacity)
payload = dict(params)
payload["members_json"] = _dumps(members)
payload["capacity"] = capacity
payload.setdefault("team_uid", "TM%s" % str(params.get("name") or "").strip()[:8]
+ str(int(params.get("class_id") or 0)))
payload.setdefault("status", "active")
data = _upsert_master(db, "pbl_team", tenant, "team_uid", TEAM_FIELDS, payload)
return _ok(data)
async def _maybe(v):
return await v if hasattr(v, '__await__') else v
@_guard
def pbl_team_list(params: Dict[str, Any]) -> Dict[str, Any]:
db = _db(params)
tenant = _resolve_tenant(params)
data = _list_master(db, "pbl_team", tenant, params)
for row in data.get("items", []):
row["members_json"] = _loads(row.get("members_json"), [])
row["member_count"] = len(row["members_json"]) if isinstance(row["members_json"], list) else 0
return _ok(data)
def _pk(res):
if isinstance(res, dict):
return res.get('id') or res.get('data') or res.get('world_id') or res.get('scene_id')
return res
# ================================================================ 三张关联表契约
@_guard
def pbl_world_ref_upsert(params: Dict[str, Any]) -> Dict[str, Any]:
"""把基表 world 绑定到 PBL 租户/班级/团队(不写 world 基表)。"""
db = _db(params)
tenant = _resolve_tenant(params)
data = assoc.upsert_world_ref(
db, tenant, params.get("world_id"),
class_id=params.get("class_id", 0), team_id=params.get("team_id", 0),
visibility=params.get("visibility"), role_code=params.get("role_code", ""),
source=params.get("source", assoc.SOURCE_MANUAL), ext=params.get("ext"),
created_by=params.get("created_by", ""),
)
data["base_tables_altered"] = False
return _ok(data)
@_guard
def pbl_scene_ref_upsert(params: Dict[str, Any]) -> Dict[str, Any]:
"""把基表 scene 绑定到 PBL 归属,可带 world_id 父级(不写 scene 基表)。"""
db = _db(params)
tenant = _resolve_tenant(params)
data = assoc.upsert_scene_ref(
db, tenant, params.get("scene_id"), world_id=params.get("world_id", 0),
class_id=params.get("class_id", 0), team_id=params.get("team_id", 0),
visibility=params.get("visibility"), role_code=params.get("role_code", ""),
source=params.get("source", assoc.SOURCE_MANUAL), ext=params.get("ext"),
created_by=params.get("created_by", ""),
)
data["base_tables_altered"] = False
return _ok(data)
@_guard
def pbl_entity_ref_upsert(params: Dict[str, Any]) -> Dict[str, Any]:
"""把基表 entity 绑定到 PBL 归属,可带 scene_id/world_id 父级(不写 entity 基表)。"""
db = _db(params)
tenant = _resolve_tenant(params)
data = assoc.upsert_entity_ref(
db, tenant, params.get("entity_id"), scene_id=params.get("scene_id", 0),
world_id=params.get("world_id", 0), class_id=params.get("class_id", 0),
team_id=params.get("team_id", 0), visibility=params.get("visibility"),
role_code=params.get("role_code", ""), source=params.get("source", assoc.SOURCE_MANUAL),
ext=params.get("ext"), created_by=params.get("created_by", ""),
)
data["base_tables_altered"] = False
return _ok(data)
def _ref_list(params: Dict[str, Any], table: str) -> Dict[str, Any]:
db = _db(params)
tenant = _resolve_tenant(params)
filters = params.get("filters")
if not isinstance(filters, dict):
filters = {k: params[k] for k in ("world_id", "scene_id", "entity_id", "class_id",
"team_id", "visibility", "role_code", "source")
if k in params}
return _ok(assoc.list_refs(db, table, tenant, filters,
limit=params.get("limit", assoc.DEFAULT_LIMIT),
offset=params.get("offset", 0)))
@_guard
def pbl_world_ref_list(params: Dict[str, Any]) -> Dict[str, Any]:
return _ref_list(params, WORLD_REF)
@_guard
def pbl_scene_ref_list(params: Dict[str, Any]) -> Dict[str, Any]:
return _ref_list(params, SCENE_REF)
@_guard
def pbl_entity_ref_list(params: Dict[str, Any]) -> Dict[str, Any]:
return _ref_list(params, ENTITY_REF)
@_guard
def pbl_domain_ref_get(params: Dict[str, Any]) -> Dict[str, Any]:
"""按 (table, tenant_id, 业务主键, team_id, role_code) 取单条关联记录。"""
db = _db(params)
tenant = _resolve_tenant(params)
key = str(params.get("table") or "").strip().lower()
if key not in _TABLE_ALIASES:
raise DomainExtError(assoc.ERR_PARAM_REQUIRED, "未知关联表别名: %r" % (params.get("table"),))
table = _TABLE_ALIASES[key]
key_col = _KEY_FIELD[table]
row = assoc.get_ref(db, table, tenant, params.get(key_col, params.get("id_value")),
team_id=params.get("team_id", 0), role_code=params.get("role_code", ""),
strict=bool(params.get("strict", True)))
return _ok(row, found=row is not None)
@_guard
def pbl_domain_ref_delete(params: Dict[str, Any]) -> Dict[str, Any]:
"""删除单条关联记录(只删 PBL 关联表,绝不动基表)。"""
db = _db(params)
tenant = _resolve_tenant(params)
key = str(params.get("table") or "").strip().lower()
if key not in _TABLE_ALIASES:
raise DomainExtError(assoc.ERR_PARAM_REQUIRED, "未知关联表别名: %r" % (params.get("table"),))
table = _TABLE_ALIASES[key]
affected = assoc.delete_ref(db, table, tenant, params.get("id"))
return _ok({"table": table, "deleted": int(affected or 0), "base_tables_altered": False})
@_guard
def pbl_domain_scope_resolve(params: Dict[str, Any]) -> Dict[str, Any]:
"""entity→scene→world 逐级回溯解析 PBL 归属作用域(供 runtime/assessment/kdb_ext 用)。"""
db = _db(params)
tenant = _resolve_tenant(params)
return _ok(assoc.resolve_scope(db, tenant, world_id=params.get("world_id"),
scene_id=params.get("scene_id"),
entity_id=params.get("entity_id")))
@_guard
def pbl_domain_stats(params: Dict[str, Any]) -> Dict[str, Any]:
"""租户维度关联计数(治理看板 / QC 核对)。"""
db = _db(params)
return _ok(assoc.domain_stats(db, _resolve_tenant(params)))
@_guard
def pbl_domain_materialize_game_definition(params: Dict[str, Any]) -> Dict[str, Any]:
"""Game Definition → 三张关联表归属记录(幂等物化,供 pbl_compiler 调用)。
与历史版差异历史版试图往 world/scene/entity 基表只新增行违反 Q-OPEN-3
不改基表决议且依赖不存在的 sql_rows本版只写 PBL 关联表基表零写入
"""
db = _db(params)
tenant = _resolve_tenant(params)
definition = params.get("game_definition")
if definition is None:
definition = params.get("definition")
data = assoc.bind_game_definition(
db, tenant, definition, class_id=params.get("class_id", 0),
team_id=params.get("team_id", 0), source=params.get("source", assoc.SOURCE_COMPILER),
created_by=params.get("created_by", ""),
)
data["base_tables_altered"] = False
return _ok(data)
# ================================================================ dspy 统一入口
_API_MAP = {
"pbl_tenant_upsert": pbl_tenant_upsert,
"pbl_class_save": pbl_class_save,
"pbl_class_list": pbl_class_list,
"pbl_team_save": pbl_team_save,
"pbl_team_list": pbl_team_list,
"pbl_world_ref_upsert": pbl_world_ref_upsert,
"pbl_scene_ref_upsert": pbl_scene_ref_upsert,
"pbl_entity_ref_upsert": pbl_entity_ref_upsert,
"pbl_world_ref_list": pbl_world_ref_list,
"pbl_scene_ref_list": pbl_scene_ref_list,
"pbl_entity_ref_list": pbl_entity_ref_list,
"pbl_domain_ref_get": pbl_domain_ref_get,
"pbl_domain_ref_delete": pbl_domain_ref_delete,
"pbl_domain_scope_resolve": pbl_domain_scope_resolve,
"pbl_domain_stats": pbl_domain_stats,
"pbl_domain_materialize_game_definition": pbl_domain_materialize_game_definition,
}
API_NAMES: List[str] = sorted(_API_MAP.keys())
def call_api(name: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""按接口名分发dspy 端点一行接入:`return call_api('pbl_world_ref_list', params_kw)`)。"""
handler = _API_MAP.get(str(name or "").strip())
if handler is None:
return _fail(assoc.ERR_PARAM_REQUIRED,
"未知接口: %r(可用 %s" % (name, ", ".join(API_NAMES)))
return handler(dict(params or {}))

735
pbl_domain_ext/assoc.py Normal file
View File

@ -0,0 +1,735 @@
"""PBL 基础域薄扩展 —— world/scene/entity 关联表读写内核M8
设计约束Q-OPEN-3 决议 projects/pbls/docs/01-design/modules/pbl_domain_ext.md
1. **不改基表**world / scene / entity 三张基表结构与数据一律不动本模块只新增
三张 PBL 侧关联表pbl_world_ref / pbl_scene_ref / pbl_entity_ref承载归属关系
2. **租户强制打头**任何读写都必须带 tenant_id缺失/为空立即 fail-closed 抛错
不做默认租户兜底
3. **薄扩展**关联表只存归属tenant/class/team+ 可见性 + 角色绑定 + 扩展 JSON
业务状态仍留在基表与 scense_runtime不在本模块复制
4. **SQL 白名单**表名/列名一律经 _safe_col 校验值一律走占位符杜绝拼接注入
本文件不依赖 Sage 运行时sqlor/ServerEnv 仅在 SqlorDb 适配器里延迟导入
因此可被 tests/ 下的内存 DB 直接单测
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
MODULE_NAME = "pbl_domain_ext"
# ---------------------------------------------------------------- 关联表常量
WORLD_REF = "pbl_world_ref"
SCENE_REF = "pbl_scene_ref"
ENTITY_REF = "pbl_entity_ref"
REF_TABLES: Tuple[str, ...] = (WORLD_REF, SCENE_REF, ENTITY_REF)
# 基表(只读引用,本模块绝不写入 / 绝不 DDL
BASE_TABLES: Tuple[str, ...] = ("world", "scene", "entity")
REF_SCHEMA: Dict[str, Dict[str, Any]] = {
WORLD_REF: {"key": "world_id", "parents": (), "base": "world"},
SCENE_REF: {"key": "scene_id", "parents": ("world_id",), "base": "scene"},
ENTITY_REF: {"key": "entity_id", "parents": ("scene_id", "world_id"), "base": "entity"},
}
# ---------------------------------------------------------------- 可见性 / 来源
VISIBILITY_TEAM = "team"
VISIBILITY_CLASS = "class"
VISIBILITY_TENANT = "tenant"
VISIBILITY_VALUES: Tuple[str, ...] = (VISIBILITY_TEAM, VISIBILITY_CLASS, VISIBILITY_TENANT)
SOURCE_MANUAL = "manual"
SOURCE_COMPILER = "compiler"
SOURCE_IMPORT = "import"
SOURCE_VALUES: Tuple[str, ...] = (SOURCE_MANUAL, SOURCE_COMPILER, SOURCE_IMPORT)
# ---------------------------------------------------------------- 错误码
ERR_TENANT_REQUIRED = "PBL_DOMAIN_EXT_TENANT_REQUIRED"
ERR_PARAM_REQUIRED = "PBL_DOMAIN_EXT_PARAM_REQUIRED"
ERR_BAD_VISIBILITY = "PBL_DOMAIN_EXT_BAD_VISIBILITY"
ERR_NOT_FOUND = "PBL_DOMAIN_EXT_NOT_FOUND"
ERR_CONFLICT = "PBL_DOMAIN_EXT_CONFLICT"
ERR_DB_UNAVAILABLE = "PBL_DOMAIN_EXT_DB_UNAVAILABLE"
MAX_LIMIT = 200
DEFAULT_LIMIT = 50
_COL_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_SOURCE_RE = re.compile(r"^[A-Za-z0-9_\-]{1,32}$")
# role_code 允许蓝图角色码常见字符;显式禁掉引号/分号/空白,纵深防御(值本身仍走占位符)
_ROLE_RE = re.compile(r"^[A-Za-z0-9_\-.:@]{0,64}$")
class DomainExtError(Exception):
"""PBL 基础域薄扩展统一异常fail-closed带机器可读 code"""
def __init__(self, code: str, message: str, **detail: Any) -> None:
super().__init__(message)
self.code = code
self.message = message
self.detail: Dict[str, Any] = dict(detail)
def to_dict(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"code": self.code, "message": self.message}
if self.detail:
payload["detail"] = self.detail
return payload
# ================================================================ DB 适配层
class DbPort(object):
"""关联表读写所需的最小 DB 契约(便于单测注入内存实现)。"""
def select(self, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
raise NotImplementedError
def insert(self, sql: str, params: Sequence[Any] = ()) -> int:
raise NotImplementedError
def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
raise NotImplementedError
class SqlorDb(DbPort):
"""sqlor 适配器:库名走 ServerEnv().get_module_dbname禁止硬编码 DBNAME。"""
def __init__(self, dbname: Optional[str] = None) -> None:
self._dbname = dbname
self._sor: Any = None
@property
def sor(self) -> Any:
if self._sor is None:
try:
import sqlor as _sqlor # type: ignore
except Exception as exc: # pragma: no cover - 运行环境缺失
raise DomainExtError(ERR_DB_UNAVAILABLE, "sqlor 不可用: %s" % (exc,))
self._sor = _sqlor
return self._sor
@property
def dbname(self) -> str:
if self._dbname:
return self._dbname
try:
from appbase import ServerEnv # type: ignore
except Exception: # pragma: no cover
ServerEnv = None # type: ignore
if ServerEnv is not None:
try:
name = ServerEnv().get_module_dbname(MODULE_NAME)
if name:
return str(name)
except Exception: # pragma: no cover
pass
raise DomainExtError(
ERR_DB_UNAVAILABLE,
"无法解析模块库名ServerEnv().get_module_dbname('%s')" % MODULE_NAME,
)
def _run(self, sql: str, params: Sequence[Any]) -> Any:
args = tuple(params or ())
try:
return self.sor.sqlExe(self.dbname, sql, args)
except TypeError:
return self.sor.sqlExe(sql, args)
def select(self, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
return _normalize_rows(self._run(sql, params))
def insert(self, sql: str, params: Sequence[Any] = ()) -> int:
result = self._run(sql, params)
if isinstance(result, (list, tuple)) and result:
head = result[0]
if isinstance(head, dict):
for key in ("lastrowid", "insert_id", "id"):
if key in head:
return int(head[key] or 0)
try:
return int(head)
except (TypeError, ValueError):
return 0
if isinstance(result, dict):
for key in ("lastrowid", "insert_id", "id"):
if key in result:
return int(result[key] or 0)
try:
return int(result or 0)
except (TypeError, ValueError):
return 0
def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
result = self._run(sql, params)
if isinstance(result, (list, tuple)):
return len(result)
try:
return int(result or 0)
except (TypeError, ValueError):
return 0
def _normalize_rows(result: Any) -> List[Dict[str, Any]]:
"""把 sqlor 各种返回形态统一成 list[dict]。"""
if result is None:
return []
if isinstance(result, dict):
for key in ("rows", "data", "result", "items"):
if key in result:
return _normalize_rows(result[key])
return [result]
if isinstance(result, (list, tuple)):
rows: List[Dict[str, Any]] = []
for item in result:
if isinstance(item, dict):
rows.append(item)
elif isinstance(item, (list, tuple)):
rows.append({str(index): value for index, value in enumerate(item)})
return rows
return []
def default_db() -> DbPort:
"""缺省 DB生产路径sqlor + ServerEnv 库名映射。"""
return SqlorDb()
# ================================================================ 归一化 / 校验
def _norm_tenant(tenant_id: Any) -> str:
if tenant_id is None:
raise DomainExtError(ERR_TENANT_REQUIRED, "缺少 tenant_idPBL 全域强制租户打头)")
text = str(tenant_id).strip()
if not text:
raise DomainExtError(ERR_TENANT_REQUIRED, "tenant_id 不能为空字符串")
if len(text) > 64:
raise DomainExtError(ERR_TENANT_REQUIRED, "tenant_id 超长(>64", length=len(text))
return text
def _norm_id(value: Any, field: str, required: bool = True) -> int:
if value is None or (isinstance(value, str) and not value.strip()):
if required:
raise DomainExtError(ERR_PARAM_REQUIRED, "缺少必填参数 %s" % field, field=field)
return 0
try:
ivalue = int(str(value).strip())
except (TypeError, ValueError):
raise DomainExtError(ERR_PARAM_REQUIRED, "参数 %s 必须为整数,实际 %r" % (field, value), field=field)
if ivalue < 0:
raise DomainExtError(ERR_PARAM_REQUIRED, "参数 %s 不能为负数,实际 %d" % (field, ivalue), field=field)
if ivalue == 0 and required:
raise DomainExtError(ERR_PARAM_REQUIRED, "参数 %s 必须为正整数" % field, field=field)
return ivalue
def _norm_visibility(value: Any) -> str:
text = str(value or "").strip().lower()
if not text:
return VISIBILITY_TEAM
if text not in VISIBILITY_VALUES:
raise DomainExtError(
ERR_BAD_VISIBILITY,
"visibility 非法: %r,允许值 %s" % (value, "/".join(VISIBILITY_VALUES)),
)
return text
def _norm_role(role_code: Any) -> str:
text = str(role_code or "").strip()
if len(text) > 64:
raise DomainExtError(ERR_PARAM_REQUIRED, "role_code 超长(>64", length=len(text))
if text and not _ROLE_RE.match(text):
raise DomainExtError(
ERR_PARAM_REQUIRED,
"role_code 含非法字符: %r(限 [A-Za-z0-9_-.:@]≤64" % (role_code,),
)
return text
def _norm_source(value: Any) -> str:
text = str(value or SOURCE_MANUAL).strip()
if not _SOURCE_RE.match(text):
raise DomainExtError(ERR_PARAM_REQUIRED, "source 非法: %r(限 [A-Za-z0-9_-]{1,32}" % (value,))
return text
def _auto_visibility(class_id: int, team_id: int) -> str:
if team_id > 0:
return VISIBILITY_TEAM
if class_id > 0:
return VISIBILITY_CLASS
return VISIBILITY_TENANT
def _check_scope(visibility: str, class_id: int, team_id: int) -> None:
if visibility == VISIBILITY_TEAM and team_id <= 0:
raise DomainExtError(ERR_PARAM_REQUIRED, "visibility=team 时必须提供 team_id>0")
if visibility == VISIBILITY_CLASS and class_id <= 0:
raise DomainExtError(ERR_PARAM_REQUIRED, "visibility=class 时必须提供 class_id>0")
def _dump_ext(ext: Any) -> str:
if ext is None:
return ""
if isinstance(ext, str):
text = ext.strip()
if not text:
return ""
try:
json.loads(text)
except ValueError as exc:
raise DomainExtError(ERR_PARAM_REQUIRED, "ext 必须是合法 JSON 字符串: %s" % (exc,))
return text
try:
return json.dumps(ext, ensure_ascii=False, sort_keys=True)
except (TypeError, ValueError) as exc:
raise DomainExtError(ERR_PARAM_REQUIRED, "ext 无法序列化为 JSON: %s" % (exc,))
def _safe_col(name: Any) -> str:
text = str(name)
if not _COL_RE.match(text):
raise DomainExtError(ERR_PARAM_REQUIRED, "非法标识符(表名/列名): %r" % (name,))
return text
def _clamp_limit(limit: Any) -> int:
try:
value = int(limit)
except (TypeError, ValueError):
return DEFAULT_LIMIT
if value <= 0:
return DEFAULT_LIMIT
return min(value, MAX_LIMIT)
def _clamp_offset(offset: Any) -> int:
try:
value = int(offset)
except (TypeError, ValueError):
return 0
return max(0, min(value, 1000000))
def _columns(table: str) -> List[str]:
schema = REF_SCHEMA[table]
cols = ["tenant_id", schema["key"]]
cols.extend(schema["parents"])
cols.extend(["class_id", "team_id", "visibility", "role_code", "source", "ext_json", "created_by"])
return cols
# ================================================================ SQL 构造(受限子集)
def _build_where(conds: Sequence[Tuple[str, Any]]) -> Tuple[str, List[Any]]:
clauses: List[str] = []
params: List[Any] = []
for name, value in conds:
clauses.append("%s=%%s" % _safe_col(name))
params.append(value)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
return where, params
def _select(db: DbPort, table: str, conds: Sequence[Tuple[str, Any]],
order_by: str = "id", limit: Any = DEFAULT_LIMIT, offset: Any = 0,
cols: str = "*") -> List[Dict[str, Any]]:
where, params = _build_where(conds)
sql = "SELECT %s FROM %s%s ORDER BY %s LIMIT %d OFFSET %d" % (
cols, _safe_col(table), where, _safe_col(order_by), _clamp_limit(limit), _clamp_offset(offset),
)
return db.select(sql, tuple(params))
def _count(db: DbPort, table: str, conds: Sequence[Tuple[str, Any]]) -> int:
where, params = _build_where(conds)
sql = "SELECT COUNT(*) AS cnt FROM %s%s" % (_safe_col(table), where)
rows = db.select(sql, tuple(params))
if not rows:
return 0
first = rows[0]
for key in ("cnt", "count", "COUNT(*)"):
if key in first:
return int(first[key] or 0)
values = list(first.values())
return int(values[0] or 0) if values else 0
def _require_table(table: str) -> str:
name = _safe_col(table)
if name not in REF_SCHEMA:
raise DomainExtError(ERR_PARAM_REQUIRED, "未知关联表: %s(允许 %s" % (table, "/".join(REF_TABLES)))
return name
# ================================================================ 通用读写
def _find_ref(db: DbPort, table: str, tenant: str, key_id: int, team_id: int, role_code: str) -> Optional[Dict[str, Any]]:
key_col = REF_SCHEMA[table]["key"]
conds = [("tenant_id", tenant), (key_col, key_id), ("team_id", team_id), ("role_code", role_code)]
rows = _select(db, table, conds, order_by="id", limit=1, offset=0)
return rows[0] if rows else None
def upsert_ref(db: DbPort, table: str, tenant_id: Any, key_value: Any,
parents: Optional[Dict[str, Any]] = None, class_id: Any = 0, team_id: Any = 0,
visibility: Any = None, role_code: Any = "", source: Any = SOURCE_MANUAL,
ext: Any = None, created_by: Any = "") -> Dict[str, Any]:
"""关联表幂等写入:唯一键 (tenant_id, 业务主键, team_id, role_code)。
存在则更新归属/可见性/扩展不存在则插入返回 action=created|updated
"""
name = _require_table(table)
schema = REF_SCHEMA[name]
tenant = _norm_tenant(tenant_id)
key_col = schema["key"]
key_id = _norm_id(key_value, key_col)
given = dict(parents or {})
parent_values: Dict[str, int] = {}
for pname in schema["parents"]:
parent_values[pname] = _norm_id(given.get(pname, 0), pname, required=False)
for extra in given:
if extra not in schema["parents"]:
raise DomainExtError(ERR_PARAM_REQUIRED, "%s 不支持父级列 %s" % (name, extra))
cid = _norm_id(class_id, "class_id", required=False)
tid = _norm_id(team_id, "team_id", required=False)
vis = _norm_visibility(visibility if visibility is not None else _auto_visibility(cid, tid))
_check_scope(vis, cid, tid)
role = _norm_role(role_code)
src = _norm_source(source)
ext_text = _dump_ext(ext)
existing = _find_ref(db, name, tenant, key_id, tid, role)
base_result: Dict[str, Any] = {
"table": name,
"tenant_id": tenant,
"class_id": cid,
"team_id": tid,
"visibility": vis,
"role_code": role,
"source": src,
}
base_result[key_col] = key_id
base_result.update(parent_values)
if existing is not None:
assigns = ["class_id=%s", "visibility=%s", "source=%s", "ext_json=%s", "updated_at=NOW()"]
for pname in schema["parents"]:
assigns.append("%s=%%s" % _safe_col(pname))
sql = "UPDATE %s SET %s WHERE tenant_id=%%s AND id=%%s" % (name, ", ".join(assigns))
params: List[Any] = [cid, vis, src, ext_text]
for pname in schema["parents"]:
params.append(parent_values[pname])
params.append(tenant)
params.append(int(existing.get("id", 0)))
db.execute(sql, tuple(params))
base_result["action"] = "updated"
base_result["id"] = int(existing.get("id", 0))
return base_result
cols = _columns(name)
sql = "INSERT INTO %s (%s) VALUES (%s)" % (name, ", ".join(cols), ", ".join(["%s"] * len(cols)))
params = [tenant, key_id]
for pname in schema["parents"]:
params.append(parent_values[pname])
params.extend([cid, tid, vis, role, src, ext_text, str(created_by or "")])
new_id = db.insert(sql, tuple(params))
base_result["action"] = "created"
base_result["id"] = int(new_id or 0)
return base_result
def list_refs(db: DbPort, table: str, tenant_id: Any, filters: Optional[Dict[str, Any]] = None,
limit: Any = DEFAULT_LIMIT, offset: Any = 0) -> Dict[str, Any]:
"""关联表分页查询tenant_id 强制打头,过滤字段走白名单。"""
name = _require_table(table)
schema = REF_SCHEMA[name]
tenant = _norm_tenant(tenant_id)
conds: List[Tuple[str, Any]] = [("tenant_id", tenant)]
allowed = set(schema["parents"]) | {"class_id", "team_id", "visibility", "role_code", "source", schema["key"]}
for field, value in dict(filters or {}).items():
if field not in allowed:
raise DomainExtError(ERR_PARAM_REQUIRED, "不支持的过滤字段: %s(允许 %s" % (field, sorted(allowed)))
if value is None or (isinstance(value, str) and not value.strip()):
continue
conds.append((field, value))
total = _count(db, name, conds)
items = _select(db, name, conds, order_by="id", limit=limit, offset=offset)
return {
"table": name,
"tenant_id": tenant,
"total": total,
"limit": _clamp_limit(limit),
"offset": _clamp_offset(offset),
"items": items,
}
def get_ref(db: DbPort, table: str, tenant_id: Any, key_value: Any, team_id: Any = 0,
role_code: Any = "", strict: bool = True) -> Optional[Dict[str, Any]]:
name = _require_table(table)
tenant = _norm_tenant(tenant_id)
key_col = REF_SCHEMA[name]["key"]
key_id = _norm_id(key_value, key_col)
tid = _norm_id(team_id, "team_id", required=False)
role = _norm_role(role_code)
row = _find_ref(db, name, tenant, key_id, tid, role)
if row is None and strict:
raise DomainExtError(ERR_NOT_FOUND, "关联记录不存在: %s.%s=%d" % (name, key_col, key_id), table=name)
return row
def delete_ref(db: DbPort, table: str, tenant_id: Any, ref_id: Any) -> int:
name = _require_table(table)
tenant = _norm_tenant(tenant_id)
rid = _norm_id(ref_id, "id")
sql = "DELETE FROM %s WHERE tenant_id=%%s AND id=%%s" % name
return int(db.execute(sql, (tenant, rid)) or 0)
# ================================================================ 三表薄封装
def upsert_world_ref(db: DbPort, tenant_id: Any, world_id: Any, class_id: Any = 0, team_id: Any = 0,
visibility: Any = None, role_code: Any = "", source: Any = SOURCE_MANUAL,
ext: Any = None, created_by: Any = "") -> Dict[str, Any]:
return upsert_ref(db, WORLD_REF, tenant_id, world_id, None, class_id, team_id,
visibility, role_code, source, ext, created_by)
def upsert_scene_ref(db: DbPort, tenant_id: Any, scene_id: Any, world_id: Any = 0, class_id: Any = 0,
team_id: Any = 0, visibility: Any = None, role_code: Any = "",
source: Any = SOURCE_MANUAL, ext: Any = None, created_by: Any = "") -> Dict[str, Any]:
return upsert_ref(db, SCENE_REF, tenant_id, scene_id, {"world_id": world_id}, class_id, team_id,
visibility, role_code, source, ext, created_by)
def upsert_entity_ref(db: DbPort, tenant_id: Any, entity_id: Any, scene_id: Any = 0, world_id: Any = 0,
class_id: Any = 0, team_id: Any = 0, visibility: Any = None, role_code: Any = "",
source: Any = SOURCE_MANUAL, ext: Any = None, created_by: Any = "") -> Dict[str, Any]:
return upsert_ref(db, ENTITY_REF, tenant_id, entity_id, {"scene_id": scene_id, "world_id": world_id},
class_id, team_id, visibility, role_code, source, ext, created_by)
def list_world_refs(db: DbPort, tenant_id: Any, filters: Optional[Dict[str, Any]] = None,
limit: Any = DEFAULT_LIMIT, offset: Any = 0) -> Dict[str, Any]:
return list_refs(db, WORLD_REF, tenant_id, filters, limit, offset)
def list_scene_refs(db: DbPort, tenant_id: Any, filters: Optional[Dict[str, Any]] = None,
limit: Any = DEFAULT_LIMIT, offset: Any = 0) -> Dict[str, Any]:
return list_refs(db, SCENE_REF, tenant_id, filters, limit, offset)
def list_entity_refs(db: DbPort, tenant_id: Any, filters: Optional[Dict[str, Any]] = None,
limit: Any = DEFAULT_LIMIT, offset: Any = 0) -> Dict[str, Any]:
return list_refs(db, ENTITY_REF, tenant_id, filters, limit, offset)
def domain_stats(db: DbPort, tenant_id: Any) -> Dict[str, Any]:
"""租户维度的关联表计数(供治理看板 / QC 核对)。"""
tenant = _norm_tenant(tenant_id)
stats: Dict[str, Any] = {"tenant_id": tenant}
for name in REF_TABLES:
stats[name] = _count(db, name, [("tenant_id", tenant)])
stats["total"] = sum(int(stats[name]) for name in REF_TABLES)
return stats
# ================================================================ 作用域解析
def _absorb(target: Dict[str, Any], row: Dict[str, Any]) -> None:
"""把关联行的归属信息吸收进解析结果(已确定的值不被覆盖:越具体越优先)。"""
for field in ("team_id", "class_id"):
if not target.get(field) and row.get(field) is not None:
try:
value = int(row.get(field) or 0)
except (TypeError, ValueError):
value = 0
if value > 0:
target[field] = value
if not target.get("visibility") and row.get("visibility"):
target["visibility"] = str(row["visibility"])
def resolve_scope(db: DbPort, tenant_id: Any, world_id: Any = None, scene_id: Any = None,
entity_id: Any = None) -> Dict[str, Any]:
"""由 entity → scene → world 逐级回溯,解析出 PBL 归属作用域。
返回 {tenant_id, world, scene, entity, world_id, scene_id, entity_id,
team_id, class_id, visibility, resolved_from}三级都查不到即 fail-closed
"""
tenant = _norm_tenant(tenant_id)
wid = _norm_id(world_id, "world_id", required=False)
sid = _norm_id(scene_id, "scene_id", required=False)
eid = _norm_id(entity_id, "entity_id", required=False)
if wid <= 0 and sid <= 0 and eid <= 0:
raise DomainExtError(ERR_PARAM_REQUIRED, "world_id / scene_id / entity_id 至少提供一个正整数")
result: Dict[str, Any] = {
"tenant_id": tenant, "world": None, "scene": None, "entity": None,
"world_id": wid, "scene_id": sid, "entity_id": eid,
"team_id": 0, "class_id": 0, "visibility": "", "resolved_from": "",
}
if eid > 0:
row = get_ref(db, ENTITY_REF, tenant, eid, strict=False)
if row is not None:
result["entity"] = row
result["resolved_from"] = "entity"
_absorb(result, row)
if sid <= 0:
sid = _norm_id(row.get("scene_id"), "scene_id", required=False)
result["scene_id"] = sid
if wid <= 0:
wid = _norm_id(row.get("world_id"), "world_id", required=False)
result["world_id"] = wid
if sid > 0:
row = get_ref(db, SCENE_REF, tenant, sid, strict=False)
if row is not None:
result["scene"] = row
if not result["resolved_from"]:
result["resolved_from"] = "scene"
_absorb(result, row)
if wid <= 0:
wid = _norm_id(row.get("world_id"), "world_id", required=False)
result["world_id"] = wid
if wid > 0:
row = get_ref(db, WORLD_REF, tenant, wid, strict=False)
if row is not None:
result["world"] = row
if not result["resolved_from"]:
result["resolved_from"] = "world"
_absorb(result, row)
if not result["resolved_from"]:
raise DomainExtError(
ERR_NOT_FOUND,
"未找到任何 PBL 关联记录world=%s scene=%s entity=%s" % (wid, sid, eid),
world_id=wid, scene_id=sid, entity_id=eid,
)
return result
# ================================================================ Game Definition 物化
_ID_KEYS: Dict[str, Tuple[str, ...]] = {
WORLD_REF: ("world_id", "worldId", "world"),
SCENE_REF: ("scene_id", "sceneId", "scene", "scenes"),
ENTITY_REF: ("entity_id", "entityId", "entity", "entities"),
}
def _walk(node: Any) -> Iterable[Any]:
if isinstance(node, dict):
yield node
for value in node.values():
for sub in _walk(value):
yield sub
elif isinstance(node, (list, tuple)):
for item in node:
for sub in _walk(item):
yield sub
def _as_id_list(value: Any) -> List[int]:
out: List[int] = []
if isinstance(value, (list, tuple)):
for item in value:
out.extend(_as_id_list(item))
return out
if isinstance(value, dict):
for key in ("id", "world_id", "scene_id", "entity_id"):
if key in value:
out.extend(_as_id_list(value[key]))
break
return out
if isinstance(value, bool) or value is None:
return out
try:
ivalue = int(str(value).strip())
except (TypeError, ValueError):
return out
if ivalue > 0:
out.append(ivalue)
return out
def collect_ids(game_definition: Any, table: str) -> List[int]:
"""从 Compiler 产出的 Game Definition 里递归抽取某类基表 id去重保序"""
name = _require_table(table)
if isinstance(game_definition, (str, bytes)):
game_definition = json.loads(game_definition)
ids: List[int] = []
seen = set()
for node in _walk(game_definition):
for key in _ID_KEYS[name]:
if key in node:
for cand in _as_id_list(node[key]):
if cand not in seen:
seen.add(cand)
ids.append(cand)
return ids
def bind_game_definition(db: DbPort, tenant_id: Any, game_definition: Any, class_id: Any = 0,
team_id: Any = 0, source: Any = SOURCE_COMPILER,
created_by: Any = "") -> Dict[str, Any]:
"""把 Compiler 产出的 Game Definition 物化成三张关联表的归属记录(幂等)。
只写 PBL 关联表不回写 world/scene/entity 基表
"""
tenant = _norm_tenant(tenant_id)
if isinstance(game_definition, (str, bytes)):
text = game_definition.decode("utf-8") if isinstance(game_definition, bytes) else game_definition
if not text.strip():
raise DomainExtError(ERR_PARAM_REQUIRED, "game_definition 为空")
try:
game_definition = json.loads(text)
except ValueError as exc:
raise DomainExtError(ERR_PARAM_REQUIRED, "game_definition 不是合法 JSON: %s" % (exc,))
if not isinstance(game_definition, dict):
raise DomainExtError(ERR_PARAM_REQUIRED, "game_definition 必须是对象或 JSON 字符串")
cid = _norm_id(class_id, "class_id", required=False)
tid = _norm_id(team_id, "team_id", required=False)
vis = _auto_visibility(cid, tid)
src = _norm_source(source)
world_ids = collect_ids(game_definition, WORLD_REF)
scene_ids = collect_ids(game_definition, SCENE_REF)
entity_ids = collect_ids(game_definition, ENTITY_REF)
summary: Dict[str, Any] = {
"tenant_id": tenant, "class_id": cid, "team_id": tid, "visibility": vis, "source": src,
"world_refs": 0, "scene_refs": 0, "entity_refs": 0, "created": 0, "updated": 0, "skipped": [],
}
if not world_ids and not scene_ids and not entity_ids:
summary["skipped"].append("game_definition 中未发现 world/scene/entity id")
return summary
primary_world = world_ids[0] if world_ids else 0
if len(world_ids) > 1:
summary["skipped"].append("发现多个 world_id=%s,父级归属取首个 %d" % (world_ids, primary_world))
primary_scene = scene_ids[0] if len(scene_ids) == 1 else 0
if len(scene_ids) > 1:
summary["skipped"].append("发现多个 scene_id=%sentity 的 scene 父级留空(0)待人工确认" % (scene_ids,))
for wid in world_ids:
res = upsert_world_ref(db, tenant, wid, cid, tid, vis, "", src, None, created_by)
summary["world_refs"] += 1
summary[res["action"]] = summary.get(res["action"], 0) + 1
for sid in scene_ids:
res = upsert_scene_ref(db, tenant, sid, primary_world, cid, tid, vis, "", src, None, created_by)
summary["scene_refs"] += 1
summary[res["action"]] = summary.get(res["action"], 0) + 1
for eid in entity_ids:
res = upsert_entity_ref(db, tenant, eid, primary_scene, primary_world, cid, tid, vis, "", src, None, created_by)
summary["entity_refs"] += 1
summary[res["action"]] = summary.get(res["action"], 0) + 1
return summary

View File

@ -1,29 +1,124 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`load_pbl_domain_ext()` —— pbl_domain_ext 模块唯一挂载入口
"""`load_pbl_domain_ext()` —— pbl_domain_ext 模块唯一挂载入口M8
注册三处同步之 env.<契约名> = <契约名> 定义在 api.py 导出在 __init__.py
三处同步注册module-development-spec
表定义 models/*.jsonpbl_tenant/pbl_class/pbl_team + pbl_world_ref/pbl_scene_ref/pbl_entity_ref
CRUD 定义 json/*.json
契约端点 wwwroot/api/*.dspy 本文件把 api.py 的契约函数挂到 ServerEnv
铁律
- **不改基表**world / scene / entity 三张基表零 ALTER零写入Q-OPEN-3 决议
PBL 归属关系只落在本模块自有关联表
- 库名走 ServerEnv().get_module_dbname('pbl_domain_ext')禁止硬编码 DBNAME
- 所有契约 tenant_id 强制打头缺失即 fail-closed
本文件对 ahserver 的导入是**延迟且容错** Sage 运行时离线单测 / QC 静态核验
load_pbl_domain_ext() 仍返回挂载摘要并把错误记入 summary['env_error']不抛异常
"""
from ahserver.serverenv import ServerEnv
from pbl_domain_ext.api import (
pbl_tenant_upsert,
pbl_class_save,
pbl_class_list,
pbl_team_save,
pbl_team_list,
pbl_domain_materialize_game_definition,
from __future__ import annotations
)
import json
import os
from typing import Any, Dict, List, Optional
from . import api, assoc
MODULE_NAME = assoc.MODULE_NAME
MODULE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 本模块自有表(归属主数据 + 三张关联表)
OWN_TABLES: List[str] = [
"pbl_tenant",
"pbl_class",
"pbl_team",
assoc.WORLD_REF,
assoc.SCENE_REF,
assoc.ENTITY_REF,
]
# 只读引用的基表(本模块绝不 DDL / 绝不写入)
READONLY_BASE_TABLES: List[str] = list(assoc.BASE_TABLES)
# 契约清单(与 wwwroot/api/*.dspy 一一对应)
API_NAMES: List[str] = list(api.API_NAMES)
def load_pbl_domain_ext():
env = ServerEnv()
env.pbl_tenant_upsert = pbl_tenant_upsert
env.pbl_class_save = pbl_class_save
env.pbl_class_list = pbl_class_list
env.pbl_team_save = pbl_team_save
env.pbl_team_list = pbl_team_list
env.pbl_domain_materialize_game_definition = pbl_domain_materialize_game_definition
def get_module_dir() -> str:
return MODULE_DIR
return 'pbl_domain_ext'
def load_models() -> List[Dict[str, Any]]:
"""读取 models/*.json 表定义(供应用建表 / QC 核对字段)。"""
models: List[Dict[str, Any]] = []
model_dir = os.path.join(MODULE_DIR, "models")
if not os.path.isdir(model_dir):
return models
for fname in sorted(os.listdir(model_dir)):
if not fname.endswith(".json"):
continue
with open(os.path.join(model_dir, fname), "r", encoding="utf-8") as fp:
models.append(json.load(fp))
return models
def load_cruds() -> List[Dict[str, Any]]:
"""读取 json/*.json CRUD 定义。"""
cruds: List[Dict[str, Any]] = []
crud_dir = os.path.join(MODULE_DIR, "json")
if not os.path.isdir(crud_dir):
return cruds
for fname in sorted(os.listdir(crud_dir)):
if not fname.endswith(".json"):
continue
with open(os.path.join(crud_dir, fname), "r", encoding="utf-8") as fp:
cruds.append(json.load(fp))
return cruds
def get_dbname() -> str:
"""模块库名ServerEnv 映射优先,禁止硬编码。"""
return assoc.SqlorDb().dbname
def load_pbl_domain_ext(env: Optional[Any] = None) -> Dict[str, Any]:
"""挂载入口:应用 init() 里 `load_pbl_domain_ext()` 调用,返回挂载摘要。
env 缺省时尝试取 ahserver.ServerEnv()取不到离线环境则只返回摘要不抛错
"""
summary: Dict[str, Any] = {
"module": MODULE_NAME,
"module_dir": MODULE_DIR,
"own_tables": list(OWN_TABLES),
"readonly_base_tables": list(READONLY_BASE_TABLES),
"apis": list(API_NAMES),
"models": len(load_models()),
"cruds": len(load_cruds()),
"registered": [],
}
target = env
if target is None:
try:
from ahserver.serverenv import ServerEnv # type: ignore
target = ServerEnv()
except Exception as exc: # pragma: no cover - 离线环境
summary["env_error"] = "ahserver.ServerEnv 不可用: %s" % (exc,)
if target is not None:
for name in API_NAMES:
setattr(target, name, getattr(api, name))
summary["registered"].append(name)
try:
summary["dbname"] = get_dbname()
except assoc.DomainExtError as exc:
summary["dbname"] = None
summary["dbname_error"] = exc.to_dict()
return summary
# 兼容旧命名
load_module = load_pbl_domain_ext

View File

@ -0,0 +1,69 @@
-- ============================================================
-- pbl_domain_ext —— M8 基础域薄扩展 DDLworld/scene/entity 关联表)
-- 铁律:不修改 world / scene / entity 三张基表Q-OPEN-3 决议)
-- 关联表不建外键(避免锁基表 / 跨库),只存 id 引用
-- 方言MariaDB / MySQLBIGINT AUTO_INCREMENT
-- ============================================================
CREATE TABLE IF NOT EXISTS pbl_world_ref (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
tenant_id VARCHAR(64) NOT NULL COMMENT 'PBL 租户ID强制打头',
world_id BIGINT NOT NULL COMMENT '基表 world.id只读引用',
class_id BIGINT NOT NULL DEFAULT 0 COMMENT '归属班级 pbl_class.id',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '归属团队 pbl_team.id',
visibility VARCHAR(16) NOT NULL DEFAULT 'team' COMMENT '可见性 team/class/tenant',
role_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角色绑定,空=通用',
source VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源 manual/compiler/import',
ext_json TEXT NULL COMMENT 'PBL 侧扩展属性 JSON',
created_by VARCHAR(64) NULL COMMENT '创建人',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_world_ref (tenant_id, world_id, team_id, role_code),
KEY idx_pbl_world_ref_class (tenant_id, class_id),
KEY idx_pbl_world_ref_team (tenant_id, team_id),
KEY idx_pbl_world_ref_world (world_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 世界关联表(薄扩展,不改 world 基表)';
CREATE TABLE IF NOT EXISTS pbl_scene_ref (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
tenant_id VARCHAR(64) NOT NULL COMMENT 'PBL 租户ID强制打头',
scene_id BIGINT NOT NULL COMMENT '基表 scene.id只读引用',
world_id BIGINT NOT NULL DEFAULT 0 COMMENT '父级归属 world.id',
class_id BIGINT NOT NULL DEFAULT 0 COMMENT '归属班级 pbl_class.id',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '归属团队 pbl_team.id',
visibility VARCHAR(16) NOT NULL DEFAULT 'team' COMMENT '可见性 team/class/tenant',
role_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角色绑定,空=通用',
source VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源 manual/compiler/import',
ext_json TEXT NULL COMMENT 'PBL 侧扩展属性 JSON',
created_by VARCHAR(64) NULL COMMENT '创建人',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_scene_ref (tenant_id, scene_id, team_id, role_code),
KEY idx_pbl_scene_ref_world (tenant_id, world_id),
KEY idx_pbl_scene_ref_class (tenant_id, class_id),
KEY idx_pbl_scene_ref_team (tenant_id, team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 场景关联表(薄扩展,不改 scene 基表)';
CREATE TABLE IF NOT EXISTS pbl_entity_ref (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
tenant_id VARCHAR(64) NOT NULL COMMENT 'PBL 租户ID强制打头',
entity_id BIGINT NOT NULL COMMENT '基表 entity.id只读引用',
scene_id BIGINT NOT NULL DEFAULT 0 COMMENT '父级归属 scene.id',
world_id BIGINT NOT NULL DEFAULT 0 COMMENT '父级归属 world.id',
class_id BIGINT NOT NULL DEFAULT 0 COMMENT '归属班级 pbl_class.id',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '归属团队 pbl_team.id',
visibility VARCHAR(16) NOT NULL DEFAULT 'team' COMMENT '可见性 team/class/tenant',
role_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角色绑定,空=通用',
source VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源 manual/compiler/import',
ext_json TEXT NULL COMMENT 'PBL 侧扩展属性 JSON',
created_by VARCHAR(64) NULL COMMENT '创建人',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_entity_ref (tenant_id, entity_id, team_id, role_code),
KEY idx_pbl_entity_ref_scene (tenant_id, scene_id),
KEY idx_pbl_entity_ref_world (tenant_id, world_id),
KEY idx_pbl_entity_ref_team (tenant_id, team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL 实体关联表(薄扩展,不改 entity 基表)';

186
tests/fake_db.py Normal file
View File

@ -0,0 +1,186 @@
"""内存版 DbPort 实现 + 受限 SQL 解释器(仅供离线单测,不进生产路径)。
支持 assoc.py 生成的全部 SQL 形态
- SELECT <cols|*> FROM t [WHERE a=%s AND b=%s ...] ORDER BY col LIMIT n OFFSET m
- SELECT COUNT(*) AS cnt FROM t [WHERE ...]
- INSERT INTO t (c1, c2, ...) VALUES (%s, %s, ...)
- UPDATE t SET c=%s, ..., updated_at=NOW() WHERE a=%s AND b=%s
- DELETE FROM t WHERE a=%s AND b=%s
任何无法解析的语句直接抛错测试里出现意外 SQL 形态必须暴露不能静默通过
同时充当不改基表铁律的守卫 world/scene/entity 的任何写操作立即 AssertionError
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple
from pbl_domain_ext.assoc import DbPort
_SELECT_RE = re.compile(
r"^SELECT\s+(?P<cols>.+?)\s+FROM\s+(?P<table>\w+)(?P<where>\s+WHERE\s+.+?)?"
r"(?:\s+ORDER\s+BY\s+(?P<order>\w+))?(?:\s+LIMIT\s+(?P<limit>\d+))?"
r"(?:\s+OFFSET\s+(?P<offset>\d+))?\s*$",
re.IGNORECASE | re.DOTALL,
)
_INSERT_RE = re.compile(
r"^INSERT\s+INTO\s+(?P<table>\w+)\s*\((?P<cols>[^)]*)\)\s*VALUES\s*\((?P<vals>[^)]*)\)\s*$",
re.IGNORECASE | re.DOTALL,
)
_UPDATE_RE = re.compile(
r"^UPDATE\s+(?P<table>\w+)\s+SET\s+(?P<assigns>.+?)\s+WHERE\s+(?P<where>.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
_DELETE_RE = re.compile(
r"^DELETE\s+FROM\s+(?P<table>\w+)\s+WHERE\s+(?P<where>.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
_COND_RE = re.compile(r"(\w+)\s*=\s*%s")
_BASE_TABLES = ("world", "scene", "entity")
def _split_top_level(text: str) -> List[str]:
"""按逗号切分(本模块 SQL 无嵌套函数参数含逗号,直接切即可)。"""
return [part.strip() for part in text.split(",") if part.strip()]
class FakeDb(DbPort):
"""表名 -> 行列表;行是 dict含自增 id。"""
def __init__(self, tables: Optional[Sequence[str]] = None) -> None:
self.tables: Dict[str, List[Dict[str, Any]]] = {name: [] for name in (tables or [])}
self._seq: Dict[str, int] = {name: 0 for name in (tables or [])}
self.executed: List[Tuple[str, Tuple[Any, ...]]] = []
self.base_table_writes: List[str] = []
# ------------------------------------------------------------ 内部工具
def _ensure(self, table: str) -> List[Dict[str, Any]]:
if table not in self.tables:
self.tables[table] = []
self._seq[table] = 0
return self.tables[table]
def _guard_base(self, table: str, kind: str) -> None:
if table in _BASE_TABLES:
self.base_table_writes.append("%s:%s" % (kind, table))
raise AssertionError("铁律违规:薄扩展不得写基表 %s%s" % (table, kind))
@staticmethod
def _row_matches(row: Dict[str, Any], cols: Sequence[str], params: Sequence[Any]) -> bool:
for col, expected in zip(cols, params):
if str(row.get(col)) != str(expected):
return False
return True
def _where_rows(self, table: str, where_sql: str, params: Sequence[Any]) -> List[Dict[str, Any]]:
cols = _COND_RE.findall(where_sql or "")
return [row for row in self._ensure(table) if self._row_matches(row, cols, params)]
# ------------------------------------------------------------ DbPort
def select(self, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
self.executed.append((sql, tuple(params)))
text = " ".join(sql.split())
m = _SELECT_RE.match(text)
if not m:
raise AssertionError("FakeDb 无法解析 SELECT: %s" % sql)
table = m.group("table")
cols = m.group("cols").strip()
where = m.group("where") or ""
rows = [dict(r) for r in self._where_rows(table, where, tuple(params))]
if cols.upper().startswith("COUNT("):
return [{"cnt": len(rows)}]
order = m.group("order")
if order:
rows.sort(key=lambda r: (r.get(order) is None, r.get(order)))
offset = int(m.group("offset") or 0)
limit = m.group("limit")
rows = rows[offset:offset + int(limit)] if limit is not None else rows[offset:]
if cols != "*":
wanted = _split_top_level(cols)
rows = [{c: r.get(c) for c in wanted} for r in rows]
return rows
def insert(self, sql: str, params: Sequence[Any] = ()) -> int:
self.executed.append((sql, tuple(params)))
text = " ".join(sql.split())
m = _INSERT_RE.match(text)
if not m:
raise AssertionError("FakeDb 无法解析 INSERT: %s" % sql)
table = m.group("table")
self._guard_base(table, "INSERT")
cols = _split_top_level(m.group("cols"))
placeholders = _split_top_level(m.group("vals"))
if len(cols) != len(placeholders) or len(cols) != len(params):
raise AssertionError(
"INSERT 列数与参数数不一致: cols=%d ph=%d params=%d"
% (len(cols), len(placeholders), len(params))
)
rows = self._ensure(table)
self._seq[table] = self._seq.get(table, 0) + 1
row: Dict[str, Any] = {"id": self._seq[table]}
for col, value in zip(cols, params):
row[col] = value
uniq_cols = [c for c in ("tenant_id", "world_id", "scene_id", "entity_id", "team_id", "role_code")
if c in row]
for exist in rows:
if self._row_matches(exist, uniq_cols, [row[c] for c in uniq_cols]):
raise AssertionError("唯一键冲突(幂等 upsert 失效): %s %s" % (table, row))
rows.append(row)
return int(row["id"])
def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
self.executed.append((sql, tuple(params)))
text = " ".join(sql.split())
m = _UPDATE_RE.match(text)
if m:
table = m.group("table")
self._guard_base(table, "UPDATE")
assigns = _split_top_level(m.group("assigns"))
set_cols: List[str] = []
set_values: List[Any] = []
cursor = 0
for assign in assigns:
col, _, expr = assign.partition("=")
col = col.strip()
expr = expr.strip()
if expr == "%s":
set_cols.append(col)
set_values.append(params[cursor])
cursor += 1
elif expr.upper() == "NOW()":
set_cols.append(col)
set_values.append("NOW()")
else:
raise AssertionError("FakeDb 不支持的 SET 表达式: %s" % assign)
where_cols = _COND_RE.findall(m.group("where"))
where_values = list(params[cursor:cursor + len(where_cols)])
affected = 0
for row in self._ensure(table):
if not self._row_matches(row, where_cols, where_values):
continue
for col, value in zip(set_cols, set_values):
row[col] = value
affected += 1
return affected
m = _DELETE_RE.match(text)
if m:
table = m.group("table")
self._guard_base(table, "DELETE")
where_cols = _COND_RE.findall(m.group("where"))
keep: List[Dict[str, Any]] = []
affected = 0
for row in self._ensure(table):
if self._row_matches(row, where_cols, tuple(params)):
affected += 1
else:
keep.append(row)
self.tables[table] = keep
return affected
raise AssertionError("FakeDb 无法解析语句: %s" % sql)

514
tests/test_assoc.py Normal file
View File

@ -0,0 +1,514 @@
"""M8 基础域薄扩展离线单测(不依赖 Sage 运行时 / 不连库)。
运行cd modules/pbl_domain_ext && python3 -m pytest tests/ -q
python3 tests/test_assoc.py pytest 时直接跑
覆盖租户强制打头 / 幂等 upsert / 可见性推导与校验 / 过滤白名单 /
作用域回溯解析 / Game Definition 物化 / 不改基表铁律 / 契约层错误码
"""
from __future__ import annotations
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fake_db import FakeDb # noqa: E402 (tests/ 目录内导入)
from pbl_domain_ext import api, assoc # noqa: E402
from pbl_domain_ext.assoc import DomainExtError # noqa: E402
TENANT = "T-DEMO-001"
def new_db() -> FakeDb:
return FakeDb([assoc.WORLD_REF, assoc.SCENE_REF, assoc.ENTITY_REF])
class TestTenantGuard(unittest.TestCase):
"""租户强制打头:缺失/空/超长一律 fail-closed。"""
def test_missing_tenant_raises(self):
db = new_db()
for bad in (None, "", " "):
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, bad, 101)
self.assertEqual(ctx.exception.code, assoc.ERR_TENANT_REQUIRED)
def test_overlong_tenant_raises(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, "T" * 65, 101)
self.assertEqual(ctx.exception.code, assoc.ERR_TENANT_REQUIRED)
def test_missing_key_raises(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, TENANT, None)
self.assertEqual(ctx.exception.code, assoc.ERR_PARAM_REQUIRED)
def test_non_integer_key_raises(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_scene_ref(db, TENANT, "abc")
def test_negative_key_raises(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_entity_ref(db, TENANT, -3)
def test_list_without_tenant_raises(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.list_world_refs(db, None)
self.assertEqual(ctx.exception.code, assoc.ERR_TENANT_REQUIRED)
class TestUpsertIdempotent(unittest.TestCase):
def test_create_then_update_same_row(self):
db = new_db()
first = assoc.upsert_world_ref(db, TENANT, 101, class_id=7, team_id=9, role_code="teacher")
self.assertEqual(first["action"], "created")
self.assertGreater(first["id"], 0)
second = assoc.upsert_world_ref(db, TENANT, 101, class_id=8, team_id=9, role_code="teacher")
self.assertEqual(second["action"], "updated")
self.assertEqual(second["id"], first["id"])
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
self.assertEqual(db.tables[assoc.WORLD_REF][0]["class_id"], 8)
def test_different_role_is_different_row(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, role_code="teacher")
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, role_code="student")
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 2)
def test_different_tenant_isolated(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9)
assoc.upsert_world_ref(db, "T-OTHER", 101, team_id=9)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 2)
page = assoc.list_world_refs(db, TENANT)
self.assertEqual(page["total"], 1)
self.assertEqual(page["items"][0]["tenant_id"], TENANT)
def test_scene_and_entity_parents_persisted(self):
db = new_db()
assoc.upsert_scene_ref(db, TENANT, 202, world_id=101, team_id=9)
assoc.upsert_entity_ref(db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
scene_row = db.tables[assoc.SCENE_REF][0]
entity_row = db.tables[assoc.ENTITY_REF][0]
self.assertEqual(scene_row["world_id"], 101)
self.assertEqual(entity_row["scene_id"], 202)
self.assertEqual(entity_row["world_id"], 101)
def test_unknown_parent_column_rejected(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_ref(db, assoc.SCENE_REF, TENANT, 202, {"entity_id": 1})
def test_ext_json_serialized(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, ext={"stage": 2, "tags": ["a", "b"]})
stored = db.tables[assoc.WORLD_REF][0]["ext_json"]
self.assertEqual(json.loads(stored)["stage"], 2)
def test_ext_string_json_passthrough(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 102, team_id=9, ext='{"k": 1}')
self.assertEqual(json.loads(db.tables[assoc.WORLD_REF][0]["ext_json"])["k"], 1)
def test_ext_bad_json_rejected(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, ext="{not json")
def test_bad_source_rejected(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, source="drop table;")
class TestVisibility(unittest.TestCase):
def test_auto_visibility_by_scope(self):
db = new_db()
self.assertEqual(assoc.upsert_world_ref(db, TENANT, 1, team_id=5)["visibility"], "team")
self.assertEqual(assoc.upsert_world_ref(db, TENANT, 2, class_id=5)["visibility"], "class")
self.assertEqual(assoc.upsert_world_ref(db, TENANT, 3)["visibility"], "tenant")
def test_explicit_visibility_must_match_scope(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, TENANT, 4, visibility="team")
self.assertEqual(ctx.exception.code, assoc.ERR_PARAM_REQUIRED)
with self.assertRaises(DomainExtError):
assoc.upsert_world_ref(db, TENANT, 5, visibility="class")
def test_bad_visibility_value(self):
db = new_db()
with self.assertRaises(DomainExtError) as ctx:
assoc.upsert_world_ref(db, TENANT, 6, visibility="public")
self.assertEqual(ctx.exception.code, assoc.ERR_BAD_VISIBILITY)
class TestListAndFilter(unittest.TestCase):
def setUp(self):
self.db = new_db()
for i in range(1, 8):
assoc.upsert_world_ref(self.db, TENANT, 100 + i, class_id=7, team_id=9)
assoc.upsert_world_ref(self.db, "T-OTHER", 999, team_id=1)
def test_pagination(self):
page = assoc.list_world_refs(self.db, TENANT, limit=3, offset=0)
self.assertEqual(page["total"], 7)
self.assertEqual(len(page["items"]), 3)
page2 = assoc.list_world_refs(self.db, TENANT, limit=3, offset=6)
self.assertEqual(len(page2["items"]), 1)
def test_limit_clamped(self):
page = assoc.list_world_refs(self.db, TENANT, limit=99999)
self.assertEqual(page["limit"], assoc.MAX_LIMIT)
def test_filter_whitelist(self):
page = assoc.list_world_refs(self.db, TENANT, {"class_id": 7})
self.assertEqual(page["total"], 7)
with self.assertRaises(DomainExtError):
assoc.list_world_refs(self.db, TENANT, {"password": "x"})
def test_empty_filter_value_ignored(self):
page = assoc.list_world_refs(self.db, TENANT, {"role_code": "", "team_id": 9})
self.assertEqual(page["total"], 7)
def test_scene_filter_by_world(self):
assoc.upsert_scene_ref(self.db, TENANT, 201, world_id=101, team_id=9)
assoc.upsert_scene_ref(self.db, TENANT, 202, world_id=102, team_id=9)
page = assoc.list_scene_refs(self.db, TENANT, {"world_id": 101})
self.assertEqual(page["total"], 1)
self.assertEqual(page["items"][0]["scene_id"], 201)
class TestGetDelete(unittest.TestCase):
def test_get_strict_and_lenient(self):
db = new_db()
assoc.upsert_entity_ref(db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
row = assoc.get_ref(db, assoc.ENTITY_REF, TENANT, 303, team_id=9)
self.assertEqual(row["scene_id"], 202)
with self.assertRaises(DomainExtError) as ctx:
assoc.get_ref(db, assoc.ENTITY_REF, TENANT, 404, team_id=9)
self.assertEqual(ctx.exception.code, assoc.ERR_NOT_FOUND)
self.assertIsNone(assoc.get_ref(db, assoc.ENTITY_REF, TENANT, 404, team_id=9, strict=False))
def test_delete_only_own_tenant(self):
db = new_db()
res = assoc.upsert_world_ref(db, TENANT, 101, team_id=9)
assoc.upsert_world_ref(db, "T-OTHER", 101, team_id=9)
affected = assoc.delete_ref(db, assoc.WORLD_REF, TENANT, res["id"])
self.assertEqual(affected, 1)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
self.assertEqual(db.tables[assoc.WORLD_REF][0]["tenant_id"], "T-OTHER")
class TestScopeResolve(unittest.TestCase):
def setUp(self):
self.db = new_db()
assoc.upsert_world_ref(self.db, TENANT, 101, class_id=7, team_id=9)
assoc.upsert_scene_ref(self.db, TENANT, 202, world_id=101, class_id=7, team_id=9)
assoc.upsert_entity_ref(self.db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
def test_resolve_from_entity_backfills_parents(self):
scope = assoc.resolve_scope(self.db, TENANT, entity_id=303)
self.assertEqual(scope["resolved_from"], "entity")
self.assertEqual(scope["scene_id"], 202)
self.assertEqual(scope["world_id"], 101)
self.assertEqual(scope["team_id"], 9)
self.assertEqual(scope["class_id"], 7)
self.assertEqual(scope["visibility"], "team")
self.assertIsNotNone(scope["world"])
def test_resolve_from_scene_only(self):
scope = assoc.resolve_scope(self.db, TENANT, scene_id=202)
self.assertEqual(scope["resolved_from"], "scene")
self.assertEqual(scope["world_id"], 101)
def test_resolve_from_world_only(self):
scope = assoc.resolve_scope(self.db, TENANT, world_id=101)
self.assertEqual(scope["resolved_from"], "world")
self.assertEqual(scope["class_id"], 7)
def test_resolve_requires_one_id(self):
with self.assertRaises(DomainExtError) as ctx:
assoc.resolve_scope(self.db, TENANT)
self.assertEqual(ctx.exception.code, assoc.ERR_PARAM_REQUIRED)
def test_resolve_not_found_fail_closed(self):
with self.assertRaises(DomainExtError) as ctx:
assoc.resolve_scope(self.db, TENANT, entity_id=999)
self.assertEqual(ctx.exception.code, assoc.ERR_NOT_FOUND)
def test_resolve_cross_tenant_not_leaked(self):
with self.assertRaises(DomainExtError):
assoc.resolve_scope(self.db, "T-OTHER", entity_id=303)
def test_resolve_entity_without_scene_parent(self):
assoc.upsert_entity_ref(self.db, TENANT, 404, world_id=101, team_id=9)
scope = assoc.resolve_scope(self.db, TENANT, entity_id=404)
self.assertEqual(scope["resolved_from"], "entity")
self.assertEqual(scope["world_id"], 101)
self.assertIsNone(scope["scene"])
class TestMaterialize(unittest.TestCase):
DEFINITION = {
"game": {
"world_id": 101,
"scenes": [{"scene_id": 202, "entities": [{"entity_id": 303}, {"entityId": 304}]}],
},
"meta": {"worldId": 101},
}
def test_collect_ids_dedup(self):
ids = assoc.collect_ids(self.DEFINITION, assoc.WORLD_REF)
self.assertEqual(ids, [101])
self.assertEqual(assoc.collect_ids(self.DEFINITION, assoc.SCENE_REF), [202])
self.assertEqual(sorted(assoc.collect_ids(self.DEFINITION, assoc.ENTITY_REF)), [303, 304])
def test_collect_ids_from_json_string(self):
ids = assoc.collect_ids(json.dumps(self.DEFINITION), assoc.WORLD_REF)
self.assertEqual(ids, [101])
def test_bind_creates_all_refs(self):
db = new_db()
summary = assoc.bind_game_definition(db, TENANT, self.DEFINITION, class_id=7, team_id=9)
self.assertEqual(summary["world_refs"], 1)
self.assertEqual(summary["scene_refs"], 1)
self.assertEqual(summary["entity_refs"], 2)
self.assertEqual(summary["created"], 4)
self.assertEqual(summary["source"], "compiler")
self.assertEqual(len(db.tables[assoc.ENTITY_REF]), 2)
def test_bind_is_idempotent(self):
db = new_db()
assoc.bind_game_definition(db, TENANT, self.DEFINITION, team_id=9)
second = assoc.bind_game_definition(db, TENANT, json.dumps(self.DEFINITION), team_id=9)
self.assertEqual(second["updated"], 4)
self.assertEqual(second["created"], 0)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
def test_bind_rejects_empty_and_bad_json(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.bind_game_definition(db, TENANT, " ")
with self.assertRaises(DomainExtError):
assoc.bind_game_definition(db, TENANT, "{bad json")
with self.assertRaises(DomainExtError):
assoc.bind_game_definition(db, TENANT, [1, 2, 3])
def test_bind_without_ids_reports_skip(self):
db = new_db()
summary = assoc.bind_game_definition(db, TENANT, {"game": {"name": "x"}})
self.assertEqual(summary["world_refs"], 0)
self.assertTrue(summary["skipped"])
def test_multiple_scenes_leave_parent_zero(self):
db = new_db()
definition = {"scenes": [{"scene_id": 1}, {"scene_id": 2}], "entities": [{"entity_id": 5}]}
summary = assoc.bind_game_definition(db, TENANT, definition, team_id=9)
self.assertEqual(summary["scene_refs"], 2)
self.assertEqual(db.tables[assoc.ENTITY_REF][0]["scene_id"], 0)
self.assertTrue(any("多个 scene_id" in s for s in summary["skipped"]))
def test_multiple_worlds_take_first(self):
db = new_db()
definition = {"worlds": [{"world_id": 11}, {"world_id": 22}], "scenes": [{"scene_id": 5}]}
summary = assoc.bind_game_definition(db, TENANT, definition, team_id=9)
self.assertEqual(summary["world_refs"], 2)
self.assertEqual(db.tables[assoc.SCENE_REF][0]["world_id"], 11)
self.assertTrue(any("多个 world_id" in s for s in summary["skipped"]))
class TestBaseTableUntouched(unittest.TestCase):
"""铁律:任何路径都不得写 world/scene/entity 基表。"""
def test_no_base_table_write(self):
db = new_db()
assoc.upsert_world_ref(db, TENANT, 101, team_id=9)
assoc.upsert_scene_ref(db, TENANT, 202, world_id=101, team_id=9)
assoc.upsert_entity_ref(db, TENANT, 303, scene_id=202, world_id=101, team_id=9)
assoc.bind_game_definition(db, TENANT, TestMaterialize.DEFINITION, team_id=9)
assoc.resolve_scope(db, TENANT, entity_id=303)
self.assertEqual(db.base_table_writes, [])
for sql, _params in db.executed:
lowered = " ".join(sql.lower().split())
if lowered.startswith(("insert", "update", "delete")):
for base in ("world", "scene", "entity"):
self.assertFalse(lowered.startswith("insert into %s " % base), sql)
self.assertFalse(lowered.startswith("update %s " % base), sql)
self.assertFalse(lowered.startswith("delete from %s " % base), sql)
def test_all_writes_target_ref_tables_only(self):
db = new_db()
assoc.bind_game_definition(db, TENANT, TestMaterialize.DEFINITION, team_id=9)
write_tables = set()
for sql, _params in db.executed:
lowered = " ".join(sql.lower().split())
if lowered.startswith("insert into"):
write_tables.add(lowered.split()[2])
elif lowered.startswith("update"):
write_tables.add(lowered.split()[1])
elif lowered.startswith("delete from"):
write_tables.add(lowered.split()[2])
self.assertTrue(write_tables)
self.assertTrue(write_tables.issubset(set(assoc.REF_TABLES)), write_tables)
def test_table_name_injection_guard(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.list_refs(db, "pbl_world_ref; DROP TABLE world", TENANT)
with self.assertRaises(DomainExtError):
assoc.upsert_ref(db, "world", TENANT, 1)
def test_filter_column_injection_guard(self):
db = new_db()
with self.assertRaises(DomainExtError):
assoc.list_refs(db, assoc.WORLD_REF, TENANT, {"1=1 OR tenant_id": "x"})
def test_values_always_parameterized(self):
db = new_db()
evil = "x'; DROP TABLE pbl_world_ref; --"
assoc.upsert_world_ref(db, TENANT, 101, team_id=9, role_code=evil)
for sql, params in db.executed:
self.assertNotIn("DROP TABLE", sql)
self.assertEqual(db.tables[assoc.WORLD_REF][0]["role_code"], evil)
self.assertEqual(len(db.tables[assoc.WORLD_REF]), 1)
class TestApiContract(unittest.TestCase):
def setUp(self):
self.db = new_db()
def call(self, name, **params):
params["_db"] = self.db
return api.call_api(name, params)
def test_upsert_and_list_ok(self):
res = self.call("pbl_world_ref_upsert", tenant_id=TENANT, world_id=101, class_id=7, team_id=9)
self.assertTrue(res["ok"], res)
self.assertEqual(res["data"]["action"], "created")
page = self.call("pbl_world_ref_list", tenant_id=TENANT, team_id=9)
self.assertTrue(page["ok"])
self.assertEqual(page["data"]["total"], 1)
def test_scene_and_entity_upsert_ok(self):
scene = self.call("pbl_scene_ref_upsert", tenant_id=TENANT, scene_id=202, world_id=101, team_id=9)
entity = self.call("pbl_entity_ref_upsert", tenant_id=TENANT, entity_id=303, scene_id=202,
world_id=101, team_id=9, ext={"hp": 100})
self.assertTrue(scene["ok"], scene)
self.assertTrue(entity["ok"], entity)
self.assertEqual(entity["data"]["visibility"], "team")
def test_missing_tenant_returns_error_code(self):
res = self.call("pbl_world_ref_list", world_id=101)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_TENANT_REQUIRED)
def test_unknown_api(self):
res = api.call_api("pbl_not_exist", {"tenant_id": TENANT})
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_PARAM_REQUIRED)
def test_unknown_table_alias(self):
res = self.call("pbl_domain_ref_get", table="payroll", tenant_id=TENANT, id_value=1)
self.assertFalse(res["ok"])
def test_ref_get_and_delete(self):
created = self.call("pbl_scene_ref_upsert", tenant_id=TENANT, scene_id=202, world_id=101, team_id=9)
rid = created["data"]["id"]
got = self.call("pbl_domain_ref_get", table="scene", tenant_id=TENANT, scene_id=202, team_id=9)
self.assertTrue(got["ok"])
self.assertTrue(got["found"])
deleted = self.call("pbl_domain_ref_delete", table="pbl_scene_ref", tenant_id=TENANT, id=rid)
self.assertEqual(deleted["data"]["deleted"], 1)
missing = self.call("pbl_domain_ref_get", table="scene", tenant_id=TENANT, scene_id=202,
team_id=9, strict=False)
self.assertTrue(missing["ok"])
self.assertFalse(missing["found"])
def test_ref_get_strict_not_found(self):
res = self.call("pbl_domain_ref_get", table="world", tenant_id=TENANT, world_id=777)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_NOT_FOUND)
def test_scope_resolve_and_stats(self):
self.call("pbl_world_ref_upsert", tenant_id=TENANT, world_id=101, class_id=7, team_id=9)
self.call("pbl_scene_ref_upsert", tenant_id=TENANT, scene_id=202, world_id=101, team_id=9)
self.call("pbl_entity_ref_upsert", tenant_id=TENANT, entity_id=303, scene_id=202,
world_id=101, team_id=9)
scope = self.call("pbl_domain_scope_resolve", tenant_id=TENANT, entity_id=303)
self.assertTrue(scope["ok"])
self.assertEqual(scope["data"]["resolved_from"], "entity")
self.assertEqual(scope["data"]["world_id"], 101)
stats = self.call("pbl_domain_stats", tenant_id=TENANT)
self.assertEqual(stats["data"]["total"], 3)
self.assertEqual(stats["data"][assoc.ENTITY_REF], 1)
def test_materialize_contract(self):
res = self.call("pbl_domain_materialize_game_definition", tenant_id=TENANT,
game_definition=TestMaterialize.DEFINITION, class_id=7, team_id=9)
self.assertTrue(res["ok"], res)
self.assertEqual(res["data"]["world_refs"], 1)
self.assertEqual(res["data"]["entity_refs"], 2)
bad = self.call("pbl_domain_materialize_game_definition", tenant_id=TENANT,
game_definition="{oops")
self.assertFalse(bad["ok"])
self.assertEqual(bad["code"], assoc.ERR_PARAM_REQUIRED)
def test_materialize_missing_definition(self):
res = self.call("pbl_domain_materialize_game_definition", tenant_id=TENANT)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], assoc.ERR_PARAM_REQUIRED)
class TestInitRegistration(unittest.TestCase):
def test_module_metadata(self):
from pbl_domain_ext import init as init_mod
self.assertEqual(init_mod.MODULE_NAME, "pbl_domain_ext")
for table in (assoc.WORLD_REF, assoc.SCENE_REF, assoc.ENTITY_REF):
self.assertIn(table, init_mod.OWN_TABLES)
self.assertEqual(init_mod.READONLY_BASE_TABLES, ["world", "scene", "entity"])
self.assertIn("pbl_domain_scope_resolve", init_mod.API_NAMES)
def test_models_and_cruds_loadable(self):
from pbl_domain_ext import init as init_mod
models = init_mod.load_models()
names = {m.get("tblname") for m in models}
for table in (assoc.WORLD_REF, assoc.SCENE_REF, assoc.ENTITY_REF):
self.assertIn(table, names)
self.assertGreaterEqual(len(init_mod.load_cruds()), 3)
def test_load_entry_returns_summary_without_runtime(self):
from pbl_domain_ext import init as init_mod
summary = init_mod.load_pbl_domain_ext()
self.assertEqual(summary["module"], "pbl_domain_ext")
self.assertIsNone(summary["dbname"]) # 无 ServerEnv 时不抛,只记录错误
self.assertIn("dbname_error", summary)
def test_load_entry_registers_apis_on_env(self):
from pbl_domain_ext import init as init_mod
class FakeEnv(object):
def __init__(self):
self.apis = {}
def register_api(self, name, handler):
self.apis[name] = handler
env = FakeEnv()
init_mod.load_pbl_domain_ext(env)
self.assertIn("pbl_world_ref_list", env.apis)
if __name__ == "__main__":
unittest.main(verbosity=2)