# -*- coding: utf-8 -*- """M11b-2c-A3 一次性补丁器:修 pbl_runtime_ext/tx_write.py 中 PBL-RTX-0004 的业务缺陷。 四段精确字符串替换;任一片段命中数 != 1 即报错退出(绝不静默跳过)。 改完由 py_compile + self_check + selftest + pytest 验证。 """ import io import os import sys TARGET = os.path.join("modules", "pbl_runtime_ext", "pbl_runtime_ext", "tx_write.py") # ---------------------------------------------------------------- 1) required_columns OLD_1 = '''def required_columns(table): """NOT NULL 且非自增的列(INSERT 必须显式给值,否则 MariaDB 严格模式报错)。""" spec = _model_spec(table) if not spec: return [] out = [] for fd in spec.get("fields") or []: if not isinstance(fd, dict): continue if fd.get("null") is False and not fd.get("auto_increment"): out.append(fd["name"]) return out ''' NEW_1 = '''def _is_not_null(fd): """字段是否声明 NOT NULL —— 兼容 models/*.json 并存的两种写法。 * ``{"null": false}`` :xlsx→json 产物写法(pbl_entity_state / pbl_world_state_snapshot) * ``{"nullable": "no"}`` :database-table-definition-spec 规范写法(pbl_runtime_event) M11b-2c-A3 修复(PBL-RTX-0004 **漏报侧**根因):此前只认 ``null: false``, 而 pbl_runtime_event 用的是规范写法 ``nullable: "no"``,使该表 ``required_columns()`` 恒为 ``[]`` —— build_row 的 INSERT 完整性门禁对事件表**完全失效** (该报的缺列不报,静默写出半空行)。 """ if fd.get("null") is False: return True if str(fd.get("nullable") or "").strip().lower() in ("no", "false", "0"): return True return False def _has_db_default(fd): """列是否在 DDL 中声明 DEFAULT(含 CURRENT_TIMESTAMP)。 ``NOT NULL DEFAULT x`` 的列在 INSERT 省略时由数据库补值,**不构成"缺 NOT NULL 列"**。 M11b-2c-A3 修复(PBL-RTX-0004 **误报侧**根因之二):pbl_runtime_event 的 event_code/idem_key/scene_id/entity_id/actor_id/causation_id/seq/source/state/ tx_group/broadcast/created_by/created_at/updated_at 等列均为 NOT NULL DEFAULT, 写路径按语义不填它们;若计入必填集合,合法调用会被误报缺列。 (与 _recognize nullable 写法配套:只修识别不修默认值排除,事件表主链路会被误挡。) """ dv = fd.get("default") if dv is None: return False s = str(dv).strip() if s == "" or s.upper() in ("NULL", "NONE"): return False return True def required_columns(table): """INSERT 必须**显式给值**的列:NOT NULL + 非自增 + 无数据库 DEFAULT。 自增列由数据库生成、NOT NULL DEFAULT 列由默认值补上,两者都不需要调用方提供; 纳入必填集合只会造成 PBL-RTX-0004 误报。真正的必填列(pbl_entity_state 的 tenant_id/session_id/entity_id/state_version/checksum/updated_by/created_at/ updated_at,pbl_runtime_event 的 event_uid/event_type)缺值时数据库严格模式会 直接报错,故必须在这里 fail-closed 拦住,不得放宽。 """ spec = _model_spec(table) if not spec: return [] out = [] for fd in spec.get("fields") or []: if not isinstance(fd, dict) or not fd.get("name"): continue if _is_not_null(fd) and not fd.get("auto_increment") and not _has_db_default(fd): out.append(fd["name"]) return out ''' # ---------------------------------------------------------------- 2) build_row OLD_2 = '''def build_row(table, wanted, strict=True): """按表真实列过滤字段。strict=True 时未知列直接抛错(防静默丢列写空行)。""" cols = table_columns(table) if not cols: if strict: raise rtx_db.RtxError("PBL-RTX-0003", "models/%s.json 不可读,拒绝按猜列写入" % table) return dict(wanted) unknown = [k for k in wanted if k not in cols] if unknown and strict: raise rtx_db.RtxError( "PBL-RTX-0003", "%s 未知列 %s(权威列=%s)" % (table, unknown, cols)) row = dict((k, v) for k, v in wanted.items() if k in cols) missing = [c for c in required_columns(table) if c not in row] if missing: raise rtx_db.RtxError("PBL-RTX-0004", "%s 缺少 NOT NULL 列 %s" % (table, missing)) return row ''' NEW_2 = '''def build_row(table, wanted, strict=True, full_row=True): """按表真实列过滤字段。strict=True 时未知列直接抛错(防静默丢列写空行)。 full_row=True(默认,**INSERT 语义**):额外校验 required_columns() 齐备, 缺列抛 PBL-RTX-0004。 full_row=False(**UPDATE SET 子句语义**):被改动的行在库中已存在,未出现在 SET 列表里的 NOT NULL 列保持原值,**不存在"缺列"**,因此不得套用 INSERT 的完整性 校验;同时禁止 SET 不可变列(tenant_id/id/created_at),违者抛 PBL-RTX-0006。 PBL-RTX-0004 闭环(M11b-2c-A3,A1 判定为业务实现缺陷):误报有两条独立路径—— (1) UPDATE 分支沿用 INSERT 校验(此前用 strict=False 绕,但 strict 只管未知列, 缺列校验照跑 → tenant_id/session_id/entity_id/created_at 必被误报)。 现按 full_row=False 构造:定位维度走 WHERE、created_at 保持建档原值。 (2) 必填集合把「自增列 / NOT NULL DEFAULT 列」也算进来 → required_columns() 已排除(见 _has_db_default)。 漏报侧同修:required_columns() 此前不认 ``nullable: "no"`` 写法,事件表门禁恒真。 修后语义:该报的(真缺 event_uid/event_type 等无默认 NOT NULL 列)一定报, 不该报的(UPDATE SET 子集、数据库能补值的列)一定不报。 """ cols = table_columns(table) if not cols: if strict: raise rtx_db.RtxError("PBL-RTX-0003", "models/%s.json 不可读,拒绝按猜列写入" % table) return dict(wanted) unknown = [k for k in wanted if k not in cols] if unknown and strict: raise rtx_db.RtxError( "PBL-RTX-0003", "%s 未知列 %s(权威列=%s)" % (table, unknown, cols)) row = dict((k, v) for k, v in wanted.items() if k in cols) if full_row: missing = [c for c in required_columns(table) if c not in row] if missing: raise rtx_db.RtxError("PBL-RTX-0004", "%s 缺少 NOT NULL 列 %s" % (table, missing)) else: immutable = [c for c in ("tenant_id", "id", "created_at") if c in row] if immutable: raise rtx_db.RtxError("PBL-RTX-0006", "%s UPDATE 禁止 SET 不可变列 %s" % (table, immutable)) return row ''' # ---------------------------------------------------------------- 3) UPDATE 调用点 OLD_3 = ''' "checksum": checksum, "updated_by": actor_id, "updated_at": now, }, strict=False)''' NEW_3 = ''' "checksum": checksum, "updated_by": actor_id, "updated_at": now, }, strict=True, full_row=False)''' # ---------------------------------------------------------------- 4) self_check 闭环门禁 OLD_4 = ''' for name, fn in (("apply_runtime_event", apply_runtime_event), ("poll_events", poll_events), ("read_states", read_states)):''' NEW_4 = ''' # ---- PBL-RTX-0004 闭环门禁(M11b-2c-A3):误报侧不报 + 漏报侧必报 ---- try: upd_row = build_row(STATE_TABLE, { STATE_COL_VALUE: "{}", EVENT_COL_STATE_VERSION: 2, "checksum": "c", "updated_by": "server", "updated_at": "2026-09-21 12:00:00", }, strict=True, full_row=False) expect = [STATE_COL_VALUE, EVENT_COL_STATE_VERSION, "checksum", "updated_by", "updated_at"] if sorted(upd_row.keys()) != sorted(expect): ok = False msgs.append("tx_write FAIL:UPDATE SET 列被意外裁剪 %s(应为 %s)" % (sorted(upd_row.keys()), sorted(expect))) else: msgs.append("tx_write PASS:UPDATE SET 语义不误报缺 NOT NULL 列" "(PBL-RTX-0004 误报侧已闭环)") except rtx_db.RtxError as exc: ok = False msgs.append("tx_write FAIL:UPDATE SET 仍被 %s 拒绝(PBL-RTX-0004 未闭环)" % exc.code) try: build_row(STATE_TABLE, {"session_id": 11, STATE_COL_KEY: "pos"}) ok = False msgs.append("tx_write FAIL:INSERT 真缺 NOT NULL 列未被拦(门禁恒真 = 漏报回归)") except rtx_db.RtxError as exc: if exc.code == "PBL-RTX-0004": msgs.append("tx_write PASS:INSERT 真缺列仍被 PBL-RTX-0004 拦下(fail-closed 未弱化)") else: ok = False msgs.append("tx_write FAIL:INSERT 缺列错误码异常 %s(应为 PBL-RTX-0004)" % exc.code) # 事件表用 nullable:"no" 写法,识别修复后必填集合必须非空(否则门禁又退回恒真) for tbl in (EVENT_TABLE, STATE_TABLE, SNAPSHOT_TABLE): req = required_columns(tbl) if not req: ok = False msgs.append("tx_write FAIL:%s required_columns 为空" "(模型 NOT NULL 写法未被识别,INSERT 门禁失效)" % tbl) else: msgs.append("tx_write PASS:%s 必填列 %d 个(已排除自增/DEFAULT 列):%s" % (tbl, len(req), ",".join(req))) # 只给「必填列真子集」必须被拦(事件表缺 event_type / 状态表缺 tenant_id) for tbl, partial in ((EVENT_TABLE, {EVENT_COL_UID: "u1"}), (STATE_TABLE, {"session_id": 11, STATE_COL_KEY: "pos", EVENT_COL_STATE_VERSION: 1, "checksum": "c", "updated_by": "a", "created_at": "2026-09-21", "updated_at": "2026-09-21"})): try: build_row(tbl, partial) ok = False msgs.append("tx_write FAIL:%s 只给部分必填列却通过校验" % tbl) except rtx_db.RtxError as exc: if exc.code != "PBL-RTX-0004": ok = False msgs.append("tx_write FAIL:%s 部分列错误码 %s" % (tbl, exc.code)) else: msgs.append("tx_write PASS:%s 部分必填列被 PBL-RTX-0004 拦下" % tbl) # 写路径实际使用的 INSERT 列集合必须通过(防修复过头把主链路挡死) try: build_row(EVENT_TABLE, { "tenant_id": "7", EVENT_COL_UID: "u", "session_id": 11, "world_id": 1, "actor_id": "server", "event_type": "move", EVENT_COL_PAYLOAD: "{}", "causation_id": "", EVENT_COL_STATE_VERSION: 0, EVENT_COL_SEQ: 1, "occurred_at": "2026-09-21 12:00:00", "created_at": "2026-09-21 12:00:00", "updated_at": "2026-09-21 12:00:00"}) build_row(SNAPSHOT_TABLE, { "tenant_id": "7", "session_id": 11, "snapshot_uid": "s", "state_fingerprint": "f", "entities_json": "{}", "events_through_seq": 1, "reason": "apply_runtime_event", "created_at": "2026-09-21", "updated_at": "2026-09-21"}) msgs.append("tx_write PASS:事件表/快照表 INSERT 写路径列集合通过校验(无回归)") except rtx_db.RtxError as exc: ok = False msgs.append("tx_write FAIL:写路径 INSERT 列集合被 %s 拒绝(修复过头)" % exc.code) # 不可变列守卫:SET tenant_id/created_at 必须被 PBL-RTX-0006 拒绝 try: build_row(STATE_TABLE, {"tenant_id": "7", STATE_COL_VALUE: "{}"}, strict=True, full_row=False) ok = False msgs.append("tx_write FAIL:UPDATE SET 不可变列未被拦") except rtx_db.RtxError as exc: if exc.code == "PBL-RTX-0006": msgs.append("tx_write PASS:UPDATE 禁止 SET 不可变列(PBL-RTX-0006)") else: ok = False msgs.append("tx_write FAIL:不可变列错误码 %s(应为 PBL-RTX-0006)" % exc.code) for name, fn in (("apply_runtime_event", apply_runtime_event), ("poll_events", poll_events), ("read_states", read_states)):''' def main(): with io.open(TARGET, "r", encoding="utf-8") as f: src = f.read() pairs = [("required_columns", OLD_1, NEW_1), ("build_row", OLD_2, NEW_2), ("_apply_state UPDATE 调用点", OLD_3, NEW_3), ("self_check 闭环门禁", OLD_4, NEW_4)] for tag, old, new in pairs: n = src.count(old) if n != 1: print("FAIL: 片段[%s] 命中 %d 次(应为 1)" % (tag, n)) return 2 src = src.replace(old, new) with io.open(TARGET, "w", encoding="utf-8") as f: f.write(src) print("OK: 已应用 %d 处补丁 -> %s" % (len(pairs), TARGET)) return 0 if __name__ == "__main__": sys.exit(main())