diff --git a/init/data.json b/init/data.json index 67ee659..0574d38 100644 --- a/init/data.json +++ b/init/data.json @@ -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": [] + ] } diff --git a/json/pbl_domain_ref.json b/json/pbl_domain_ref.json index 1b4a1fc..d23649d 100644 --- a/json/pbl_domain_ref.json +++ b/json/pbl_domain_ref.json @@ -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')}}" + } } } diff --git a/models/pbl_domain_ref.json b/models/pbl_domain_ref.json index 9ad7960..605fcc6 100644 --- a/models/pbl_domain_ref.json +++ b/models/pbl_domain_ref.json @@ -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", diff --git a/pbl_domain_ext/api.py b/pbl_domain_ext/api.py index 36d03c9..e0d60cd 100644 --- a/pbl_domain_ext/api.py +++ b/pbl_domain_ext/api.py @@ -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() diff --git a/pbl_domain_ext/base.py b/pbl_domain_ext/base.py index fc4a5fb..4c6f01f 100644 --- a/pbl_domain_ext/base.py +++ b/pbl_domain_ext/base.py @@ -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 diff --git a/pbl_domain_ext/db.py b/pbl_domain_ext/db.py index e5c4fbd..39efa19 100644 --- a/pbl_domain_ext/db.py +++ b/pbl_domain_ext/db.py @@ -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) diff --git a/sql/pbl_domain_ext.sql b/sql/pbl_domain_ext.sql index d823929..c9937d6 100644 --- a/sql/pbl_domain_ext.sql +++ b/sql/pbl_domain_ext.sql @@ -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; +-- ------------------------------------------------------------ diff --git a/tests/test_domain_ref.py b/tests/test_domain_ref.py index a49c250..a6bc0dc 100644 --- a/tests/test_domain_ref.py +++ b/tests/test_domain_ref.py @@ -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}))