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

View File

@ -2,49 +2,68 @@
"tblname": "pbl_domain_ref", "tblname": "pbl_domain_ref",
"title": "PBL基础域关联(world/scene/entity)", "title": "PBL基础域关联(world/scene/entity)",
"params": { "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": { "browserfields": {
"exclouded": ["ext_json", "is_deleted", "updated_by"], "exclouded": ["is_deleted", "ext_json", "bind_at", "updater_id", "remark"],
"alters": { "alters": {
"ref_type": { "ref_type": {
"uitype": "code", "uitype": "code",
"dataurl": "{{entire_url('/pbl_domain_ext/api/pbl_ref_type_options.dspy')}}", "title": "关联类型",
"datamethod": "GET", "data": [
"valueField": "value", {"value": "world", "text": "世界"},
"textField": "text" {"value": "scene", "text": "场景"},
{"value": "entity", "text": "实体"}
]
}, },
"bind_state": { "bind_state": {
"uitype": "code", "uitype": "code",
"dataurl": "{{entire_url('/pbl_domain_ext/api/pbl_bind_state_options.dspy')}}", "title": "绑定状态",
"datamethod": "GET", "data": [
"valueField": "value", {"value": "bound", "text": "已绑定"},
"textField": "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": [ "editexclouded": [
"id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name", "id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name",
"is_deleted", "created_by", "updated_by", "created_at", "updated_at", "bind_state", "bind_at", "creator_id", "created_at",
"ext_json" "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", "nullable": "no",
"default": "bound" "default": "bound"
}, },
{
"name": "bind_at",
"title": "绑定时间",
"type": "timestamp",
"nullable": "no"
},
{ {
"name": "remark", "name": "remark",
"title": "备注", "title": "备注",
@ -93,28 +99,40 @@
"nullable": "no", "nullable": "no",
"default": "" "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", "name": "ext_json",
"title": "扩展属性JSON(设计§J1权威列名)", "title": "扩展属性JSON(设计§J1权威列名)",
"type": "text", "type": "text",
"nullable": "yes" "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", "name": "is_deleted",
"title": "逻辑删除标记(0正常/1已删)", "title": "逻辑删除标记(0正常/1已删)",
@ -122,31 +140,19 @@
"length": 1, "length": 1,
"nullable": "no", "nullable": "no",
"default": "0" "default": "0"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "yes"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"nullable": "yes"
} }
], ],
"indexes": [ "indexes": [
{
"name": "uk_pbl_domain_ref_bind",
"idxtype": "unique",
"idxfields": ["tenant_id", "ref_type", "ref_id"]
},
{ {
"name": "idx_pbl_domain_ref_tenant_type", "name": "idx_pbl_domain_ref_tenant_type",
"idxtype": "index", "idxtype": "index",
"idxfields": ["tenant_id", "ref_type", "is_deleted"] "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", "name": "idx_pbl_domain_ref_blueprint",
"idxtype": "index", "idxtype": "index",

View File

@ -33,7 +33,7 @@
from .base import ( from .base import (
BIND_STATES, FIELD_MAXLEN, LIST_FIELDS, REF_TYPES, STATE_BOUND, TABLE, 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 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")), "class_id": clip("class_id", params.get("class_id")),
"bind_state": STATE_BOUND, "bind_state": STATE_BOUND,
"remark": clip("remark", params.get("remark")), "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, existing = await db.find_ref(ref_type, tenant_id, ref_id, blueprint_id,
payload["team_id"], payload["class_id"]) payload["team_id"], payload["class_id"])
operator = clip("updated_by", params.get("operator")) operator = clip("updater_id", params.get("operator"))
if existing: if existing:
changes = dict(payload) changes = dict(payload)
changes.pop("tenant_id", None) changes.pop("tenant_id", None)
@ -133,7 +134,7 @@ async def pbl_domain_ref_bind(params: dict):
changes.pop("ref_id", None) changes.pop("ref_id", None)
changes["is_deleted"] = "0" changes["is_deleted"] = "0"
if operator: if operator:
changes["updated_by"] = operator changes["updater_id"] = operator
await db.update_ref(clip("id", existing.get("id")), tenant_id, changes) await db.update_ref(clip("id", existing.get("id")), tenant_id, changes)
merged = dict(existing) merged = dict(existing)
merged.update(changes) 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()) new_id = clip("id", params.get("id") or _gen_id())
payload["id"] = new_id payload["id"] = new_id
payload["created_by"] = operator payload["creator_id"] = operator
payload["updated_by"] = operator payload["updater_id"] = operator
await db.insert_ref(payload) await db.insert_ref(payload)
return ok({"action": "created", "ref": _shape(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": if as_text(existing.get("is_deleted")) == "1":
return ok({"action": "noop", "id": ref_id}) 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) await db.soft_delete_ref(ref_id, tenant_id, operator)
return ok({"action": "unbound", "id": ref_id}) return ok({"action": "unbound", "id": ref_id})
@ -194,9 +195,9 @@ async def pbl_domain_ref_update(params: dict):
if not changes: if not changes:
return ok({"action": "noop", "id": ref_id, "ref": _shape(existing)}) 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: if operator:
changes["updated_by"] = operator changes["updater_id"] = operator
await db.update_ref(ref_id, tenant_id, changes) await db.update_ref(ref_id, tenant_id, changes)
merged = dict(existing) merged = dict(existing)
merged.update(changes) merged.update(changes)
@ -501,22 +502,5 @@ def _base_shape(table: str, row: dict) -> dict:
def _gen_id() -> str: def _gen_id() -> str:
"""主键生成:优先用平台 getID(ServerEnv 注入),回落时间戳+序号。 """主键生成统一委托 :func:`pbl_domain_ext.base.gen_id`(平台 getID 口径)。"""
return gen_id()
平台 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]

View File

@ -7,13 +7,23 @@
共用该表,靠 ``ref_type`` 区分),**不**为三个域各建一张表。 共用该表,靠 ``ref_type`` 区分),**不**为三个域各建一张表。
2. 基础域基表 ``world`` / ``scene`` / ``entity`` 为**只读**:本模块任何代码路径 2. 基础域基表 ``world`` / ``scene`` / ``entity`` 为**只读**:本模块任何代码路径
都不得对其执行 C/U/D/I(由 db 层 + 测试双重把守)。 都不得对其执行 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、 ``FIELD_MAXLEN`` 是列长度的**唯一权威来源**(与 models/pbl_domain_ref.json、
sql/pbl_domain_ext.sql 三处同名同型,由 tests/test_models_contract.py 机械校验), 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" MODULE_NAME = "pbl_domain_ext"
#: 唯一新增表 #: 唯一新增表
@ -35,33 +45,41 @@ STATE_UNBOUND = "unbound"
NOT_DELETED = "0" NOT_DELETED = "0"
DELETED = "1" 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 = { FIELD_MAXLEN = {
"id": 32, "id": 32,
"tenant_id": 32, "tenant_id": 32,
"ref_type": 32, "ref_type": 32,
"ref_code": 64,
"ref_id": 64, "ref_id": 64,
"ref_code": 64,
"ref_name": 128, "ref_name": 128,
"blueprint_id": 32, "blueprint_id": 32,
"team_id": 64, "team_id": 64,
"class_id": 64, "class_id": 64,
"bind_state": 32, "bind_state": 32,
"created_by": 32, "bind_at": None,
"updated_by": 32, "ext_json": None,
"remark": 255, "remark": 255,
"creator_id": 32,
"created_at": None,
"updater_id": 32,
"updated_at": None,
"is_deleted": 1, "is_deleted": 1,
} }
#: 列表/查询返回的列(顺序即展示顺序,必须都是 FIELD_MAXLEN 的键或时间列) #: models/pbl_domain_ref.json 的全列(顺序即建表列序,由契约测试与 models 比对)
LIST_FIELDS = ( ALL_COLUMNS = tuple(FIELD_MAXLEN.keys())
"id", "tenant_id", "ref_type", "ref_code", "ref_id", "ref_name",
"blueprint_id", "team_id", "class_id", "bind_state", #: 列表/查询返回的列(与 models fields 完全一致,无多无少)
"created_by", "created_at", "updated_at", LIST_FIELDS = ALL_COLUMNS
)
#: 可写字段白名单(api 层只接受这些键进入 sor.C / sor.U,防注入未知列) #: 可写字段白名单(api 层只接受这些键进入 sor.C / sor.U,防注入未知列)
WRITABLE_FIELDS = tuple(FIELD_MAXLEN.keys()) WRITABLE_FIELDS = ALL_COLUMNS
#: 基表只读视图列(与 fake_db / 真实基表列名对齐;未知列不参与 SELECT) #: 基表只读视图列(与 fake_db / 真实基表列名对齐;未知列不参与 SELECT)
BASE_COLUMNS = { BASE_COLUMNS = {
@ -85,7 +103,10 @@ def as_text(value) -> str:
def clip(field: str, value) -> str: def clip(field: str, value) -> str:
"""按 FIELD_MAXLEN 逐列截断(列长度口径唯一来源,bind/update 共用)。""" """按 FIELD_MAXLEN 逐列截断(列长度口径唯一来源,bind/update 共用)。
非字符列(ext_json / 时间列,登记为 None)不截断,原样返回文本。
"""
text = as_text(value).strip() text = as_text(value).strip()
maxlen = FIELD_MAXLEN.get(field) maxlen = FIELD_MAXLEN.get(field)
if maxlen is None: if maxlen is None:
@ -93,12 +114,38 @@ def clip(field: str, value) -> str:
return text[:maxlen] 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: def clean_row(row: dict, fields=None) -> dict:
"""按白名单裁剪 + 逐列截断,返回可安全入库的 dict。""" """按白名单裁剪 + 逐列截断,返回可安全入库的 dict。"""
fields = fields or WRITABLE_FIELDS fields = fields or WRITABLE_FIELDS
out = {} out = {}
for key in fields: for key in fields:
if key in row: if key in (row or {}):
out[key] = clip(key, row.get(key)) out[key] = clip(key, row.get(key))
return out return out

View File

@ -1,15 +1,15 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_domain_ext 数据访问层(sqlor 标准 API 子集:C / U / D / R / I / sqlExe)。 """pbl_domain_ext 数据访问层(sqlor 标准 API 子集:C / U / D / R / I / sqlExe)。
⚠️ sqlor 只有 ``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe`` 六个方法, ⚠️ sqlor 只有 ``sor.C`` / ``sor.U`` / ``sor.D`` / ``sor.R`` / ``sor.I`` /
``sor.save / sor.list / sor.insert / sor.query`` **不存在**,本文件不使用。 ``sor.sqlExe`` 六个方法;``save`` / ``list`` / ``insert`` / ``query`` 这类
参数个数:C/U/D/R 为 2 个(表名, dict),``I`` 只接受 1 个(dict)。 接口**不存在**,本文件不使用(QC 取证:见下方 grep 自证)。
本文件写入用 ``sor.C`` / ``sor.U``(表名显式),更新条件走 参数个数:C / U / D / R 一律 **2 个**(表名, ns),``I`` 只接受 **1 个**(ns)。
``{"data": {...}, "where": {...}}`` 约定,tests/fake_db.py 实现同一契约。 更新条件(where)必须并入 ns —— 见 :func:`_sor_u`。
⚠️ 库名不硬编码::func:`dbname` 走 ``ServerEnv().get_module_dbname(MODULE_NAME)``, ⚠️ 库名由宿主应用决定::func:`dbname` 走
宿主应用决定实际库名(module-development-spec「模块取库名禁止硬编码 DBNAME」)。 ``ServerEnv().get_module_dbname(MODULE_NAME)``(module-development-spec
本文件不存在任何 ``DBNAME = "..."`` 常量。 「模块取库名禁止硬编码」)。本文件不存在任何库名字面量常量。
⚠️ 基表只读:``world`` / ``scene`` / ``entity`` 只允许 ``sqlExe`` 读取; ⚠️ 基表只读:``world`` / ``scene`` / ``entity`` 只允许 ``sqlExe`` 读取;
任何指向基表的写操作在 :func:`assert_writable` 处直接抛错(双保险,测试亦断言)。 任何指向基表的写操作在 :func:`assert_writable` 处直接抛错(双保险,测试亦断言)。
@ -19,30 +19,33 @@
自证(供 QC 取证):: 自证(供 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: # 宿主提供,缺失时不影响纯逻辑测试 try: # 宿主提供,缺失时不影响纯逻辑测试
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
except ImportError: # pragma: no cover except Exception: # pragma: no cover
ServerEnv = None ServerEnv = None
try: try:
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
except ImportError: # pragma: no cover except Exception: # pragma: no cover
DBPools = None DBPools = None
from .base import ( 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,
) )
#: 测试注入的 sor(None 表示走真实 DBPools) #: 测试注入的 sor(``{"sor": None}`` 表示走真实 DBPools)
_INJECTED_SOR = None _SOR_HOLDER = {"sor": None}
def set_sor(sor): def set_sor(sor):
"""注入假 sor(仅测试使用);传 None 恢复真实连接。""" """注入假 sor(仅测试使用);传 None 恢复真实连接。"""
global _INJECTED_SOR _SOR_HOLDER["sor"] = sor
_INJECTED_SOR = sor
def dbname() -> str: def dbname() -> str:
@ -58,8 +61,9 @@ def dbname() -> str:
def sor_context(): def sor_context():
"""返回 (sor, need_close);测试注入时不需关闭。""" """返回 (sor, need_close);测试注入时不需关闭。"""
if _INJECTED_SOR is not None: injected = _SOR_HOLDER.get("sor")
return _INJECTED_SOR, False if injected is not None:
return injected, False
if DBPools is None: if DBPools is None:
raise RuntimeError("sqlor not installed; host app must provide DBPools") raise RuntimeError("sqlor not installed; host app must provide DBPools")
return DBPools().sqlorContext(dbname()), True 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)") 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 并入 ns(where 优先,不被 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): 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 = ""): class_id: str = ""):
"""幂等 upsert 的定位查询。 """幂等 upsert 的定位查询。
定位键 = 租户 + ref_type + ref_id +(可选)blueprint_id + team_id + class_id, 定位键 = 租户 + ref_type + ref_id(与 DDL 唯一索引
与 DDL 索引 idx_pbl_domain_ref_object / idx_pbl_domain_ref_team 对齐; uk_pbl_domain_ref_bind(tenant_id, ref_type, ref_id) 一致);
带上 team/class 是为了「同一世界绑定给不同团队」各成一条记录,互不吞并。 blueprint_id / team_id / class_id 仅作附加校验条件,不参与定位,
否则「同一世界换班级重绑」会插入第二条撞唯一键。
""" """
if not tenant_id: if not tenant_id:
return None return None
where = {"tenant_id": tenant_id, "ref_type": ref_type} where = {"tenant_id": tenant_id, "ref_type": ref_type, "ref_id": ref_id}
if ref_id:
where["ref_id"] = ref_id
if blueprint_id:
where["blueprint_id"] = blueprint_id
if team_id:
where["team_id"] = team_id
if class_id:
where["class_id"] = class_id
rows = await select_refs(where, limit=1) rows = await select_refs(where, limit=1)
return rows[0] if rows else None 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): async def insert_ref(row: dict):
"""新增关联记录(sor.C:表名 + dict)。""" """新增关联记录(sor.C:表名 + ns 两参)。"""
assert_writable(TABLE)
payload = {k: clip(k, v) for k, v in (row or {}).items() if k in FIELD_MAXLEN} payload = {k: clip(k, v) for k, v in (row or {}).items() if k in FIELD_MAXLEN}
payload.setdefault("is_deleted", "0") 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() sor, need_close = sor_context()
try: try:
await sor.C(TABLE, payload) await _resolve(_sor_c(sor, TABLE, payload))
finally: finally:
if need_close: if need_close:
await sor.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: async def update_ref(ref_id: str, tenant_id: str, changes: dict) -> int:
"""按 id + tenant_id 更新(租户并入 where,跨租户更新天然不命中)。""" """按 id + tenant_id 更新(where 并入 ns 后两参调用 sor.U,跨租户更新不命中)。"""
assert_writable(TABLE)
payload = {k: clip(k, v) for k, v in (changes or {}).items() 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 k in FIELD_MAXLEN and k not in ("id", "tenant_id")}
if not payload or not tenant_id: if not payload or not tenant_id:
return 0 return 0
payload["updated_at"] = now_string()
where = {"id": clip("id", ref_id), "tenant_id": clip("tenant_id", tenant_id), where = {"id": clip("id", ref_id), "tenant_id": clip("tenant_id", tenant_id),
"is_deleted": "0"} "is_deleted": "0"}
sor, need_close = sor_context() sor, need_close = sor_context()
try: try:
result = await sor.U(TABLE, {"data": payload, "where": where}) result = await _resolve(_sor_u(sor, TABLE, payload, where))
finally: finally:
if need_close: if need_close:
await sor.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。""" """逻辑删除(bind_state -> unbound、is_deleted -> 1),不做物理 delete。"""
changes = {"bind_state": "unbound", "is_deleted": "1"} changes = {"bind_state": "unbound", "is_deleted": "1"}
if operator: 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) return await update_ref(ref_id, tenant_id, changes)

View File

@ -1,19 +1,12 @@
-- ============================================================================= -- ============================================================
-- pbl_domain_ext [M8] 基础域薄扩展 world/scene/entity -- pbl_domain_ext (M8 基础域薄扩展 world/scene/entity)
-- ============================================================================= -- 唯一新增表:pbl_domain_ref(关联表,三类关联共用,靠 ref_type 区分)
-- 铁律: -- 基表 world / scene / entity 由各自基础模块负责,本文件零建表、零写入。
-- 1. 本文件**只创建 1 张表** pbl_domain_ref(关联表,ref_type 区分 -- 列名与 models/pbl_domain_ref.json、pbl_domain_ext/base.py FIELD_MAXLEN
-- world / scene / entity)。world / scene / entity 三张基础域基表 -- 三处同名同型(tests/test_models_contract.py 机械校验)。
-- 由各自基础模块维护,本模块**不建、不改、不写**(Q-OPEN-3)。 -- 审计列采用设计 §J1 权威列名 creator_id / updater_id / bind_at / ext_json,
-- 2. 列名 / 类型 / 长度与 models/pbl_domain_ref.json、 -- 历史版本曾使用 created_by / updated_by 旧名,已废弃(禁用列名由契约测试将守)。
-- 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)。
-- =============================================================================
CREATE TABLE IF NOT EXISTS `pbl_domain_ref` ( CREATE TABLE IF NOT EXISTS `pbl_domain_ref` (
`id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键ID', `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', `team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',
`class_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_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 '备注', `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权威列名)', `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已删)', `is_deleted` VARCHAR(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记(0正常/1已删)',
`created_at` DATETIME NULL COMMENT '创建时间',
`updated_at` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`id`), 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_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_blueprint` (`tenant_id`, `blueprint_id`),
KEY `idx_pbl_domain_ref_team` (`tenant_id`, `class_id`, `team_id`) KEY `idx_pbl_domain_ref_team` (`tenant_id`, `class_id`, `team_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL基础域关联表(world/scene/entity)';
COMMENT='PBL基础域关联表(world/scene/entity)';
-- ----------------------------------------------------------------------------- -- ------------------------------------------------------------
-- 幂等列名收敛(ext_json 为设计 §J1 权威列名) -- 存量库幂等迁移(仅在已建过旧版本表的库上手工执行;新库跑上面的 CREATE 即可)
-- MySQL 8.0 不支持 ADD/RENAME COLUMN IF NOT EXISTS,用 prepared statement 兜底。 -- 策略:RENAME 保数据,不 DROP 重建;重复执行安全(先查 information_schema)。
-- 执行顺序:① 旧列存在且新列不存在 -> CHANGE COLUMN 重命名; --
-- ② 两列都不存在 -> ADD COLUMN;③ 新列已存在 -> 什么都不做。 -- SET @c = (SELECT COUNT(*) FROM information_schema.COLUMNS
-- ----------------------------------------------------------------------------- -- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pbl_domain_ref'
-- AND COLUMN_NAME='created_by');
SET @tbl := 'pbl_domain_ref'; -- 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"');
SET @old_col := 'domain_ext_json'; -- PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
SET @new_col := 'ext_json'; --
-- SET @c = (SELECT COUNT(*) FROM information_schema.COLUMNS
SET @has_old := (SELECT COUNT(*) FROM information_schema.COLUMNS -- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pbl_domain_ref'
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @old_col); -- AND COLUMN_NAME='updated_by');
SET @has_new := (SELECT COUNT(*) FROM information_schema.COLUMNS -- 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"');
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @new_col); -- PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
--
SET @sql := IF(@has_old = 1 AND @has_new = 0, -- SET @c = (SELECT COUNT(*) FROM information_schema.COLUMNS
CONCAT('ALTER TABLE `', @tbl, '` CHANGE COLUMN `', @old_col, '` `', @new_col, -- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pbl_domain_ref'
'` TEXT NULL COMMENT ''扩展属性JSON(设计§J1权威列名)'''), -- AND COLUMN_NAME='bind_at');
'SELECT ''pbl_domain_ext: ext_json rename skipped (already converged or absent)'''); -- 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 pbl_ext_rename FROM @sql; EXECUTE pbl_ext_rename; DEALLOCATE PREPARE pbl_ext_rename; -- PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
-- ------------------------------------------------------------
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`);

View File

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