deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
09f78980ee
commit
6689f5e5f8
@ -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": []
|
||||
]
|
||||
}
|
||||
|
||||
@ -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')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
"""主键生成:优先用平台 getID(ServerEnv 注入),回落时间戳+序号。
|
||||
|
||||
平台 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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
#: 测试注入的 sor(None 表示走真实 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 并入 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):
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@ -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}))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user