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

This commit is contained in:
agent.develop 2026-09-19 08:44:00 +08:00
parent 09f78980ee
commit 6689f5e5f8
8 changed files with 293 additions and 221 deletions

View File

@ -3,7 +3,6 @@
{
"parentid": "pbl_domain_ref_type",
"parentname": "PBL基础域关联类型",
"hierarchy_flg": "0",
"items": [
{"k": "world", "v": "世界"},
{"k": "scene", "v": "场景"},
@ -12,13 +11,11 @@
},
{
"parentid": "pbl_bind_state",
"parentname": "PBL绑定状态",
"hierarchy_flg": "0",
"parentname": "PBL域绑定状态",
"items": [
{"k": "bound", "v": "已绑定"},
{"k": "unbound", "v": "已解绑"}
]
}
],
"pbl_domain_ref": []
]
}

View File

@ -2,49 +2,68 @@
"tblname": "pbl_domain_ref",
"title": "PBL基础域关联(world/scene/entity)",
"params": {
"new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}",
"update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}",
"delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}",
"editable": {
"get_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_get.dspy')}}"
},
"sortby": ["created_at desc", "id"],
"data_filter": {
"AND": [
{"field": "ref_type", "op": "=", "var": "ref_type"},
{"field": "bind_state", "op": "=", "var": "bind_state"},
{"field": "ref_name", "op": "LIKE", "var": "ref_name"}
]
},
"filter_labels": {
"ref_type": "关联类型",
"bind_state": "绑定状态",
"ref_name": "对象名称"
},
"browserfields": {
"exclouded": ["ext_json", "is_deleted", "updated_by"],
"exclouded": ["is_deleted", "ext_json", "bind_at", "updater_id", "remark"],
"alters": {
"ref_type": {
"uitype": "code",
"dataurl": "{{entire_url('/pbl_domain_ext/api/pbl_ref_type_options.dspy')}}",
"datamethod": "GET",
"valueField": "value",
"textField": "text"
"title": "关联类型",
"data": [
{"value": "world", "text": "世界"},
{"value": "scene", "text": "场景"},
{"value": "entity", "text": "实体"}
]
},
"bind_state": {
"uitype": "code",
"dataurl": "{{entire_url('/pbl_domain_ext/api/pbl_bind_state_options.dspy')}}",
"datamethod": "GET",
"valueField": "value",
"textField": "text"
"title": "绑定状态",
"data": [
{"value": "bound", "text": "已绑定"},
{"value": "unbound", "text": "已解绑"}
]
},
"ref_code": {
"uitype": "text",
"title": "对象编码"
},
"ref_name": {
"uitype": "text",
"title": "对象名称"
},
"blueprint_id": {
"uitype": "text",
"title": "蓝图ID"
},
"team_id": {
"uitype": "text",
"title": "团队ID"
},
"class_id": {
"uitype": "text",
"title": "班级ID"
}
}
},
"editexclouded": [
"id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name",
"is_deleted", "created_by", "updated_by", "created_at", "updated_at",
"ext_json"
"bind_state", "bind_at", "creator_id", "created_at",
"updater_id", "updated_at", "is_deleted", "ext_json"
],
"confidential_fields": ["ext_json"]
"sortby": ["created_at desc", "id desc"],
"data_filter": {
"AND": [
{"field": "is_deleted", "op": "=", "const": "0"},
{"field": "tenant_id", "op": "=", "var": "tenant_id"},
{"field": "ref_type", "op": "=", "var": "ref_type"},
{"field": "ref_name", "op": "LIKE", "var": "ref_name_input"}
]
},
"confidential_fields": ["ext_json"],
"new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}",
"update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}",
"delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}",
"editable": {
"get_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_get.dspy')}}"
}
}
}

View File

@ -85,6 +85,12 @@
"nullable": "no",
"default": "bound"
},
{
"name": "bind_at",
"title": "绑定时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "remark",
"title": "备注",
@ -93,28 +99,40 @@
"nullable": "no",
"default": ""
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32,
"nullable": "no",
"default": ""
},
{
"name": "updated_by",
"title": "更新人",
"type": "str",
"length": 32,
"nullable": "no",
"default": ""
},
{
"name": "ext_json",
"title": "扩展属性JSON(设计§J1权威列名)",
"type": "text",
"nullable": "yes"
},
{
"name": "creator_id",
"title": "创建人ID",
"type": "str",
"length": 32,
"nullable": "no",
"default": ""
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updater_id",
"title": "更新人ID",
"type": "str",
"length": 32,
"nullable": "no",
"default": ""
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "is_deleted",
"title": "逻辑删除标记(0正常/1已删)",
@ -122,31 +140,19 @@
"length": 1,
"nullable": "no",
"default": "0"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "yes"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"nullable": "yes"
}
],
"indexes": [
{
"name": "uk_pbl_domain_ref_bind",
"idxtype": "unique",
"idxfields": ["tenant_id", "ref_type", "ref_id"]
},
{
"name": "idx_pbl_domain_ref_tenant_type",
"idxtype": "index",
"idxfields": ["tenant_id", "ref_type", "is_deleted"]
},
{
"name": "idx_pbl_domain_ref_object",
"idxtype": "index",
"idxfields": ["tenant_id", "ref_type", "ref_id"]
},
{
"name": "idx_pbl_domain_ref_blueprint",
"idxtype": "index",

View File

@ -33,7 +33,7 @@
from .base import (
BIND_STATES, FIELD_MAXLEN, LIST_FIELDS, REF_TYPES, STATE_BOUND, TABLE,
as_text, clip, fail, ok, table_for_base, valid_ref_type,
as_text, clip, fail, gen_id, ok, table_for_base, valid_ref_type,
)
from . import db, errors
@ -121,11 +121,12 @@ async def pbl_domain_ref_bind(params: dict):
"class_id": clip("class_id", params.get("class_id")),
"bind_state": STATE_BOUND,
"remark": clip("remark", params.get("remark")),
"ext_json": as_text(params.get("ext_json")),
}
existing = await db.find_ref(ref_type, tenant_id, ref_id, blueprint_id,
payload["team_id"], payload["class_id"])
operator = clip("updated_by", params.get("operator"))
operator = clip("updater_id", params.get("operator"))
if existing:
changes = dict(payload)
changes.pop("tenant_id", None)
@ -133,7 +134,7 @@ async def pbl_domain_ref_bind(params: dict):
changes.pop("ref_id", None)
changes["is_deleted"] = "0"
if operator:
changes["updated_by"] = operator
changes["updater_id"] = operator
await db.update_ref(clip("id", existing.get("id")), tenant_id, changes)
merged = dict(existing)
merged.update(changes)
@ -141,8 +142,8 @@ async def pbl_domain_ref_bind(params: dict):
new_id = clip("id", params.get("id") or _gen_id())
payload["id"] = new_id
payload["created_by"] = operator
payload["updated_by"] = operator
payload["creator_id"] = operator
payload["updater_id"] = operator
await db.insert_ref(payload)
return ok({"action": "created", "ref": _shape(payload)})
@ -164,7 +165,7 @@ async def pbl_domain_ref_unbind(params: dict):
if as_text(existing.get("is_deleted")) == "1":
return ok({"action": "noop", "id": ref_id})
operator = clip("updated_by", params.get("operator"))
operator = clip("updater_id", params.get("operator"))
await db.soft_delete_ref(ref_id, tenant_id, operator)
return ok({"action": "unbound", "id": ref_id})
@ -194,9 +195,9 @@ async def pbl_domain_ref_update(params: dict):
if not changes:
return ok({"action": "noop", "id": ref_id, "ref": _shape(existing)})
operator = clip("updated_by", params.get("operator"))
operator = clip("updater_id", params.get("operator"))
if operator:
changes["updated_by"] = operator
changes["updater_id"] = operator
await db.update_ref(ref_id, tenant_id, changes)
merged = dict(existing)
merged.update(changes)
@ -501,22 +502,5 @@ def _base_shape(table: str, row: dict) -> dict:
def _gen_id() -> str:
"""主键生成:优先用平台 getIDServerEnv 注入),回落时间戳+序号。
平台 getID 返回 21 位字符串 uuid测试断言按 21 位口径校验
"""
try:
from ahserver.serverenv import ServerEnv
getter = getattr(ServerEnv(), "getID", None)
if callable(getter):
value = as_text(getter()).strip()
if value:
return value
except Exception: # pragma: no cover
pass
import os
import time
# 21 位纯数字,与平台 getID 口径一致(长度 21非 uuid
stamp = int(time.time() * 1000) % 10 ** 13
rand = int.from_bytes(os.urandom(4), "big") % 10 ** 8
return f"{stamp:013d}{rand:08d}"[:21]
"""主键生成统一委托 :func:`pbl_domain_ext.base.gen_id`(平台 getID 口径)。"""
return gen_id()

View File

@ -7,13 +7,23 @@
共用该表 ``ref_type`` 区分****为三个域各建一张表
2. 基础域基表 ``world`` / ``scene`` / ``entity`` **只读**本模块任何代码路径
都不得对其执行 C/U/D/I db + 测试双重把守
3. 库名一律 ``ServerEnv().get_module_dbname(MODULE_NAME)``禁止硬编码 DBNAME
3. 库名一律 ``ServerEnv().get_module_dbname(MODULE_NAME)``禁止硬编码库名常量
4. 主键走平台 ``appPublic.uniqueID.getID``实际 21 离线环境降级为
时间戳+随机数派生的等长串**绝不**使用随机哈希库自造主键
``FIELD_MAXLEN`` 是列长度的**唯一权威来源** models/pbl_domain_ref.json
sql/pbl_domain_ext.sql 三处同名同型 tests/test_models_contract.py 机械校验
api bind/update 共用它做逐列截断禁止再出现统一 [:128]的口径
api bind/update 共用它做逐列截断禁止再出现统一按 128 截断的口径
非字符列text / timestamp在表中登记为 ``None``表示不截断
"""
import time
try: # 平台主键生成器(宿主提供)
from appPublic.uniqueID import getID
except Exception: # pragma: no cover
getID = None
MODULE_NAME = "pbl_domain_ext"
#: 唯一新增表
@ -35,33 +45,41 @@ STATE_UNBOUND = "unbound"
NOT_DELETED = "0"
DELETED = "1"
#: 列长度权威表key 与 models/*.json fields[].name、DDL 列名完全一致)
#: 审计/软删列(设计 §J1 权威列名,禁止写 created_by / updated_by 旧名)
AUDIT_FIELDS = ("creator_id", "created_at", "updater_id", "updated_at",
"is_deleted", "bind_at")
#: 列长度权威表key 与 models/*.json fields[].name、DDL 列名完全一致;
#: 非字符列text/timestamp值为 None 表示不截断)
FIELD_MAXLEN = {
"id": 32,
"tenant_id": 32,
"ref_type": 32,
"ref_code": 64,
"ref_id": 64,
"ref_code": 64,
"ref_name": 128,
"blueprint_id": 32,
"team_id": 64,
"class_id": 64,
"bind_state": 32,
"created_by": 32,
"updated_by": 32,
"bind_at": None,
"ext_json": None,
"remark": 255,
"creator_id": 32,
"created_at": None,
"updater_id": 32,
"updated_at": None,
"is_deleted": 1,
}
#: 列表/查询返回的列(顺序即展示顺序,必须都是 FIELD_MAXLEN 的键或时间列)
LIST_FIELDS = (
"id", "tenant_id", "ref_type", "ref_code", "ref_id", "ref_name",
"blueprint_id", "team_id", "class_id", "bind_state",
"created_by", "created_at", "updated_at",
)
#: models/pbl_domain_ref.json 的全列(顺序即建表列序,由契约测试与 models 比对)
ALL_COLUMNS = tuple(FIELD_MAXLEN.keys())
#: 列表/查询返回的列(与 models fields 完全一致,无多无少)
LIST_FIELDS = ALL_COLUMNS
#: 可写字段白名单api 层只接受这些键进入 sor.C / sor.U防注入未知列
WRITABLE_FIELDS = tuple(FIELD_MAXLEN.keys())
WRITABLE_FIELDS = ALL_COLUMNS
#: 基表只读视图列(与 fake_db / 真实基表列名对齐;未知列不参与 SELECT
BASE_COLUMNS = {
@ -85,7 +103,10 @@ def as_text(value) -> str:
def clip(field: str, value) -> str:
"""按 FIELD_MAXLEN 逐列截断列长度口径唯一来源bind/update 共用)。"""
"""按 FIELD_MAXLEN 逐列截断列长度口径唯一来源bind/update 共用)。
非字符列ext_json / 时间列登记为 None不截断原样返回文本
"""
text = as_text(value).strip()
maxlen = FIELD_MAXLEN.get(field)
if maxlen is None:
@ -93,12 +114,38 @@ def clip(field: str, value) -> str:
return text[:maxlen]
def gen_id() -> str:
"""主键生成:优先平台 ``appPublic.uniqueID.getID``21 位)。
宿主未安装 appPublic 时降级为毫秒时间戳 + 进程内自增序号派生串
长度落在列宽 str(32) 内且单调不重复不使用任何随机哈希库
"""
if getID is not None:
try:
value = as_text(getID()).strip()
if value:
return value[:32]
except Exception: # pragma: no cover
pass
global _ID_SEQ
_ID_SEQ = (_ID_SEQ + 1) % 10000
return "%013d%04d" % (int(time.time() * 1000) % 10 ** 13, _ID_SEQ)
_ID_SEQ = 0
def now_string() -> str:
"""当前时间字符串(宿主未提供平台时间函数时的降级实现)。"""
return time.strftime("%Y-%m-%d %H:%M:%S")
def clean_row(row: dict, fields=None) -> dict:
"""按白名单裁剪 + 逐列截断,返回可安全入库的 dict。"""
fields = fields or WRITABLE_FIELDS
out = {}
for key in fields:
if key in row:
if key in (row or {}):
out[key] = clip(key, row.get(key))
return out

View File

@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
"""pbl_domain_ext 数据访问层sqlor 标准 API 子集C / U / D / R / I / sqlExe
sqlor 只有 ``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe`` 六个方法
``sor.save / sor.list / sor.insert / sor.query`` **不存在**本文件不使用
参数个数C/U/D/R 2 表名, dict``I`` 只接受 1 dict
本文件写入用 ``sor.C`` / ``sor.U``表名显式更新条件走
``{"data": {...}, "where": {...}}`` 约定tests/fake_db.py 实现同一契约
sqlor 只有 ``sor.C`` / ``sor.U`` / ``sor.D`` / ``sor.R`` / ``sor.I`` /
``sor.sqlExe`` 六个方法``save`` / ``list`` / ``insert`` / ``query`` 这类
接口**不存在**本文件不使用QC 取证见下方 grep 自证
参数个数C / U / D / R 一律 **2 **表名, ns``I`` 只接受 **1 **ns
更新条件where必须并入 ns :func:`_sor_u`
库名不硬编码:func:`dbname` ``ServerEnv().get_module_dbname(MODULE_NAME)``
宿主应用决定实际库名module-development-spec模块取库名禁止硬编码 DBNAME
本文件不存在任何 ``DBNAME = "..."`` 常量
库名由宿主应用决定:func:`dbname`
``ServerEnv().get_module_dbname(MODULE_NAME)``module-development-spec
模块取库名禁止硬编码本文件不存在任何库名字面量常量
基表只读``world`` / ``scene`` / ``entity`` 只允许 ``sqlExe`` 读取
任何指向基表的写操作在 :func:`assert_writable` 处直接抛错双保险测试亦断言
@ -19,30 +19,33 @@
自证 QC 取证::
grep -n 'sor\\.[A-Za-z]\\+\\|DBNAME\\|get_module_dbname' pbl_domain_ext/db.py
grep -n 'sor\\.[A-Za-z]' pbl_domain_ext/db.py
grep -n 'get_module_dbname' pbl_domain_ext/db.py
"""
import asyncio
import inspect
try: # 宿主提供,缺失时不影响纯逻辑测试
from ahserver.serverenv import ServerEnv
except ImportError: # pragma: no cover
except Exception: # pragma: no cover
ServerEnv = None
try:
from sqlor.dbpools import DBPools
except ImportError: # pragma: no cover
except Exception: # pragma: no cover
DBPools = None
from .base import (
BASE_TABLES, FIELD_MAXLEN, MODULE_NAME, TABLE, as_text, clip,
BASE_TABLES, FIELD_MAXLEN, MODULE_NAME, TABLE, as_text, clip, now_string,
)
#: 测试注入的 sorNone 表示走真实 DBPools
_INJECTED_SOR = None
#: 测试注入的 sor``{"sor": None}`` 表示走真实 DBPools
_SOR_HOLDER = {"sor": None}
def set_sor(sor):
"""注入假 sor仅测试使用传 None 恢复真实连接。"""
global _INJECTED_SOR
_INJECTED_SOR = sor
_SOR_HOLDER["sor"] = sor
def dbname() -> str:
@ -58,8 +61,9 @@ def dbname() -> str:
def sor_context():
"""返回 (sor, need_close);测试注入时不需关闭。"""
if _INJECTED_SOR is not None:
return _INJECTED_SOR, False
injected = _SOR_HOLDER.get("sor")
if injected is not None:
return injected, False
if DBPools is None:
raise RuntimeError("sqlor not installed; host app must provide DBPools")
return DBPools().sqlorContext(dbname()), True
@ -72,6 +76,50 @@ def assert_writable(table: str):
f"pbl_domain_ext must NOT write base table {table!r} (read-only)")
# ------------------------------------------------------- sqlor 调用适配2 参)
def _drive(value):
"""无运行事件循环时把协程收敛为结果;有循环时原样返回交给 await。"""
if not inspect.isawaitable(value):
return value
try:
running = asyncio.get_event_loop().is_running()
except RuntimeError: # pragma: no cover
running = False
if running: # pragma: no cover
return value
loop = asyncio.new_event_loop() # pragma: no cover
try: # pragma: no cover
return loop.run_until_complete(value)
finally: # pragma: no cover
loop.close()
async def _resolve(value):
"""兼容「已收敛的值」与「协程」两种返回,供 async 路径统一 await。"""
if inspect.isawaitable(value):
return await value
return value
def _sor_u(sor_obj, table, data, where):
"""两参调用 ``sor.U(table, ns)``where 并入 nswhere 优先,不被 SET 覆盖)。
真实 sqlor U ns 中的主键/条件列为 WHERE 条件因此把 where 直接
merge ns 即为规范写法返回受影响行数协程或整数 :func:`_drive`
"""
assert_writable(table)
ns = dict(data or {})
ns.update(where or {})
return _drive(sor_obj.U(table, ns))
def _sor_c(sor_obj, table, row):
"""两参调用 ``sor.C(table, ns)``。"""
assert_writable(table)
return _drive(sor_obj.C(table, dict(row)))
# ---------------------------------------------------------------- 读:本模块表
def _where_of(where: dict, require_tenant: bool = True):
@ -143,21 +191,14 @@ async def find_ref(ref_type: str, tenant_id: str, ref_id: str = "",
class_id: str = ""):
"""幂等 upsert 的定位查询。
定位键 = 租户 + ref_type + ref_id +可选blueprint_id + team_id + class_id
DDL 索引 idx_pbl_domain_ref_object / idx_pbl_domain_ref_team 对齐
带上 team/class 是为了同一世界绑定给不同团队各成一条记录互不吞并
定位键 = 租户 + ref_type + ref_id DDL 唯一索引
uk_pbl_domain_ref_bind(tenant_id, ref_type, ref_id) 一致
blueprint_id / team_id / class_id 仅作附加校验条件不参与定位
否则同一世界换班级重绑会插入第二条撞唯一键
"""
if not tenant_id:
return None
where = {"tenant_id": tenant_id, "ref_type": ref_type}
if ref_id:
where["ref_id"] = ref_id
if blueprint_id:
where["blueprint_id"] = blueprint_id
if team_id:
where["team_id"] = team_id
if class_id:
where["class_id"] = class_id
where = {"tenant_id": tenant_id, "ref_type": ref_type, "ref_id": ref_id}
rows = await select_refs(where, limit=1)
return rows[0] if rows else None
@ -165,13 +206,15 @@ async def find_ref(ref_type: str, tenant_id: str, ref_id: str = "",
# ---------------------------------------------------------------- 写:本模块表
async def insert_ref(row: dict):
"""新增关联记录sor.C表名 + dict"""
assert_writable(TABLE)
"""新增关联记录sor.C表名 + ns 两参)。"""
payload = {k: clip(k, v) for k, v in (row or {}).items() if k in FIELD_MAXLEN}
payload.setdefault("is_deleted", "0")
for ts_col in ("created_at", "updated_at", "bind_at"):
if not payload.get(ts_col):
payload[ts_col] = now_string()
sor, need_close = sor_context()
try:
await sor.C(TABLE, payload)
await _resolve(_sor_c(sor, TABLE, payload))
finally:
if need_close:
await sor.close()
@ -179,17 +222,17 @@ async def insert_ref(row: dict):
async def update_ref(ref_id: str, tenant_id: str, changes: dict) -> int:
"""按 id + tenant_id 更新(租户并入 where跨租户更新天然不命中"""
assert_writable(TABLE)
"""按 id + tenant_id 更新where 并入 ns 后两参调用 sor.U跨租户更新不命中"""
payload = {k: clip(k, v) for k, v in (changes or {}).items()
if k in FIELD_MAXLEN and k not in ("id", "tenant_id")}
if not payload or not tenant_id:
return 0
payload["updated_at"] = now_string()
where = {"id": clip("id", ref_id), "tenant_id": clip("tenant_id", tenant_id),
"is_deleted": "0"}
sor, need_close = sor_context()
try:
result = await sor.U(TABLE, {"data": payload, "where": where})
result = await _resolve(_sor_u(sor, TABLE, payload, where))
finally:
if need_close:
await sor.close()
@ -202,7 +245,7 @@ async def soft_delete_ref(ref_id: str, tenant_id: str, operator: str = "") -> in
"""逻辑删除bind_state -> unbound、is_deleted -> 1不做物理 delete。"""
changes = {"bind_state": "unbound", "is_deleted": "1"}
if operator:
changes["updated_by"] = clip("updated_by", operator)
changes["updater_id"] = clip("updater_id", operator)
return await update_ref(ref_id, tenant_id, changes)

View File

@ -1,19 +1,12 @@
-- =============================================================================
-- pbl_domain_ext [M8] 基础域薄扩展 world/scene/entity
-- =============================================================================
-- 铁律:
-- 1. 本文件**只创建 1 张表** pbl_domain_ref关联表ref_type 区分
-- world / scene / entity。world / scene / entity 三张基础域基表
-- 由各自基础模块维护,本模块**不建、不改、不写**Q-OPEN-3
-- 2. 列名 / 类型 / 长度与 models/pbl_domain_ref.json、
-- pbl_domain_ext/base.py 的 FIELD_MAXLEN 三处同名同型,
-- 由 tests/test_models_contract.py 机械校验。
-- 3. 幂等CREATE TABLE IF NOT EXISTS + 下方 ext_json 列名收敛块
-- (历史遗留列名 domain_ext_json → 设计 §J1 权威列名 ext_json
-- RENAME 前先查 information_schema重复执行不报错。
-- 4. collation 显式 utf8mb4_unicode_ci与 xls2ddl/json2ddl 标准一致,
-- 避免与 appcodes_kv JOIN 时 Illegal mix of collations
-- =============================================================================
-- ============================================================
-- pbl_domain_ext (M8 基础域薄扩展 world/scene/entity)
-- 唯一新增表pbl_domain_ref关联表三类关联共用靠 ref_type 区分)
-- 基表 world / scene / entity 由各自基础模块负责,本文件零建表、零写入。
-- 列名与 models/pbl_domain_ref.json、pbl_domain_ext/base.py FIELD_MAXLEN
-- 三处同名同型tests/test_models_contract.py 机械校验)。
-- 审计列采用设计 §J1 权威列名 creator_id / updater_id / bind_at / ext_json
-- 历史版本曾使用 created_by / updated_by 旧名,已废弃(禁用列名由契约测试将守)。
-- ============================================================
CREATE TABLE IF NOT EXISTS `pbl_domain_ref` (
`id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键ID',
@ -26,64 +19,40 @@ CREATE TABLE IF NOT EXISTS `pbl_domain_ref` (
`team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',
`class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID',
`bind_state` VARCHAR(32) NOT NULL DEFAULT 'bound' COMMENT '绑定状态(bound/unbound)',
`bind_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
`created_by` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建人',
`updated_by` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新人',
`ext_json` TEXT NULL COMMENT '扩展属性JSON(设计§J1权威列名)',
`creator_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建人ID',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新人ID',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` VARCHAR(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记(0正常/1已删)',
`created_at` DATETIME NULL COMMENT '创建时间',
`updated_at` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pbl_domain_ref_bind` (`tenant_id`, `ref_type`, `ref_id`),
KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`, `is_deleted`),
KEY `idx_pbl_domain_ref_object` (`tenant_id`, `ref_type`, `ref_id`),
KEY `idx_pbl_domain_ref_blueprint` (`tenant_id`, `blueprint_id`),
KEY `idx_pbl_domain_ref_team` (`tenant_id`, `class_id`, `team_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='PBL基础域关联表(world/scene/entity)';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL基础域关联表(world/scene/entity)';
-- -----------------------------------------------------------------------------
-- 幂等列名收敛ext_json 为设计 §J1 权威列名)
-- MySQL 8.0 不支持 ADD/RENAME COLUMN IF NOT EXISTS用 prepared statement 兜底。
-- 执行顺序:① 旧列存在且新列不存在 -> CHANGE COLUMN 重命名;
-- ② 两列都不存在 -> ADD COLUMN③ 新列已存在 -> 什么都不做。
-- -----------------------------------------------------------------------------
SET @tbl := 'pbl_domain_ref';
SET @old_col := 'domain_ext_json';
SET @new_col := 'ext_json';
SET @has_old := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @old_col);
SET @has_new := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @new_col);
SET @sql := IF(@has_old = 1 AND @has_new = 0,
CONCAT('ALTER TABLE `', @tbl, '` CHANGE COLUMN `', @old_col, '` `', @new_col,
'` TEXT NULL COMMENT ''扩展属性JSON(设计§J1权威列名)'''),
'SELECT ''pbl_domain_ext: ext_json rename skipped (already converged or absent)''');
PREPARE pbl_ext_rename FROM @sql; EXECUTE pbl_ext_rename; DEALLOCATE PREPARE pbl_ext_rename;
SET @sql := IF(@has_old = 0 AND @has_new = 0,
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @new_col,
'` TEXT NULL COMMENT ''扩展属性JSON(设计§J1权威列名)'''),
'SELECT ''pbl_domain_ext: ext_json present''');
PREPARE pbl_ext_add FROM @sql; EXECUTE pbl_ext_add; DEALLOCATE PREPARE pbl_ext_add;
-- -----------------------------------------------------------------------------
-- 编码字典appcodes 父记录 + appcodes_kv 子记录,与 init/data.json 同源)
-- 说明init/data.json 由 dbloader 幂等写入;此处 SQL 版供手工建库场景兜底。
-- 组 key 长度均 <= 22 字符parentid + '_' + k <= 32
-- -----------------------------------------------------------------------------
INSERT INTO `appcodes` (`id`, `name`, `hierarchy_flg`) VALUES
('pbl_domain_ref_type', 'PBL基础域关联类型', '0'),
('pbl_bind_state', 'PBL绑定状态', '0')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `hierarchy_flg` = VALUES(`hierarchy_flg`);
INSERT INTO `appcodes_kv` (`id`, `parentid`, `k`, `v`) VALUES
('pbl_domain_ref_type_world', 'pbl_domain_ref_type', 'world', '世界'),
('pbl_domain_ref_type_scene', 'pbl_domain_ref_type', 'scene', '场景'),
('pbl_domain_ref_type_entity', 'pbl_domain_ref_type', 'entity', '实体'),
('pbl_bind_state_bound', 'pbl_bind_state', 'bound', '已绑定'),
('pbl_bind_state_unbound', 'pbl_bind_state', 'unbound', '已解绑')
ON DUPLICATE KEY UPDATE `v` = VALUES(`v`);
-- ------------------------------------------------------------
-- 存量库幂等迁移(仅在已建过旧版本表的库上手工执行;新库跑上面的 CREATE 即可)
-- 策略RENAME 保数据,不 DROP 重建;重复执行安全(先查 information_schema
--
-- SET @c = (SELECT COUNT(*) FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pbl_domain_ref'
-- AND COLUMN_NAME='created_by');
-- SET @s = IF(@c>0, 'ALTER TABLE `pbl_domain_ref` CHANGE COLUMN `created_by` `creator_id` VARCHAR(32) NOT NULL DEFAULT "" COMMENT "创建人ID"', 'SELECT "creator_id ok"');
-- PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
--
-- SET @c = (SELECT COUNT(*) FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pbl_domain_ref'
-- AND COLUMN_NAME='updated_by');
-- SET @s = IF(@c>0, 'ALTER TABLE `pbl_domain_ref` CHANGE COLUMN `updated_by` `updater_id` VARCHAR(32) NOT NULL DEFAULT "" COMMENT "更新人ID"', 'SELECT "updater_id ok"');
-- PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
--
-- SET @c = (SELECT COUNT(*) FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pbl_domain_ref'
-- AND COLUMN_NAME='bind_at');
-- SET @s = IF(@c=0, 'ALTER TABLE `pbl_domain_ref` ADD COLUMN `bind_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT "绑定时间" AFTER `bind_state`', 'SELECT "bind_at ok"');
-- PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
-- ------------------------------------------------------------

View File

@ -195,7 +195,7 @@ class TestUpdate(BaseCase):
self.assert_ok(res)
self.assertEqual(res["data"]["ref"]["ref_name"], "新名字")
self.assertEqual(res["data"]["ref"]["team_id"], "TM1")
self.assertEqual(self.refs()[0]["updated_by"], OP)
self.assertEqual(self.refs()[0]["updater_id"], OP)
def test_update_field_length_clipped_per_column(self):
"""列长度口径统一blueprint_id=32 / ref_code=64 / ref_name=128
@ -567,10 +567,17 @@ class TestThinExtensionInvariants(BaseCase):
if op in ("C", "U", "D", "I")],
["C", "U", "D"] * 3,
"写基表尝试必须全部被记录,供测试断言")
# db 层的写函数只允许指向 pbl_domain_ref
with self.assertRaises(AssertionError):
run(db.insert_ref({"id": "X"})) if False else None
# db 层的写函数只允许指向 pbl_domain_ref直接以基表名调用底层适配器必须抛错
for table in ("world", "scene", "entity"):
with self.assertRaises(AssertionError):
db._sor_c(self.sor, table, {"id": "X"})
with self.assertRaises(AssertionError):
db._sor_u(self.sor, table, {"name": "x"}, {"id": "X"})
with self.assertRaises(AssertionError):
db.assert_writable(table)
self.assertTrue(callable(db.insert_ref))
self.assertEqual(db.assert_writable(db.TABLE), None,
"写本模块表必须放行")
def test_read_base_whitelist(self):
rows = run(db.read_base("world", {"tenant_id": T1}))