# -*- coding: utf-8 -*- """单事务事件 + 状态写入与广播(M11b)。 核心契约 -------- append_event(env, tenant_id, world_id, session_id, entity_id, event_type, payload, idem_key=None, state_updates=None, tx_group=None) * **单事务**:事件行 + N 条实体状态行 + 1 条快照行在同一个 DB 事务内提交, 任一失败整体回滚(state='rolled_back'),不留半条脏数据。 * **幂等**:idem_key 命中 uk_tenant_idem 唯一键 → 返回既有事件,不重复写、不重复广播。 * **广播**:事务提交成功后才广播(broadcast=1);提交失败不广播。 * **写保护**:只写 pbl_* 自有表,绝不 UPDATE/DELETE scense_runtime/world/scene/entity 基表。 """ import hashlib import json import time import uuid EVENT_TABLE = "pbl_runtime_event" STATE_TABLE = "pbl_entity_state" SNAPSHOT_TABLE = "pbl_world_state_snapshot" BASE_TABLES_READONLY = ("world", "scene", "entity", "scense", "scense_runtime", "script_engine") class TxError(Exception): """单事务写入失败(已回滚)。""" def __init__(self, code, message): super(TxError, self).__init__(message) self.code = code self.message = message def _code(prefix): return "%s%s" % (prefix, uuid.uuid4().hex[:16]) def _now(): return time.strftime("%Y-%m-%d %H:%M:%S") def _dumps(obj): try: return json.dumps(obj, ensure_ascii=False, default=str) except (TypeError, ValueError): return str(obj) def _checksum(state): return hashlib.sha256(_dumps(state).encode("utf-8")).hexdigest()[:32] def _idem_key(tenant_id, session_id, event_type, payload): raw = "%s|%s|%s|%s" % (tenant_id, session_id, event_type, _dumps(payload)) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:64] def _get_sor(env): return getattr(env, "sor", None) or getattr(env, "db", None) def _tx(env): """取事务上下文管理器;平台无事务能力时返回 None(调用方走逐条写 + 补偿回滚)。""" sor = _get_sor(env) for attr in ("transaction", "tx", "begin"): fn = getattr(sor, attr, None) if sor is not None else None if callable(fn): return fn fn = getattr(env, attr, None) if callable(fn): return fn return None def find_by_idem(env, tenant_id, idem_key): """按幂等键查既有事件;无则 None。""" sor = _get_sor(env) if sor is None or not hasattr(sor, "R"): return None rows = sor.R(EVENT_TABLE, "tenant_id=%s AND idem_key=%s" % (int(tenant_id), "'%s'" % idem_key), limit=1) if rows: return rows[0] if isinstance(rows, list) else rows return None def next_seq(env, tenant_id, session_id): """会话内单调序号(max(seq_no)+1)。""" sor = _get_sor(env) if sor is None or not hasattr(sor, "R"): return int(time.time() * 1000) % 1000000 rows = sor.R(EVENT_TABLE, "tenant_id=%s AND session_id=%s" % (int(tenant_id), int(session_id)), fields="MAX(seq_no) AS mx", limit=1) try: mx = (rows[0] or {}).get("mx") if rows else None return int(mx or 0) + 1 except (TypeError, ValueError, IndexError): return 1 def _write_snapshot(env, tenant_id, world_id, session_id, seq_no, states): """写世界状态快照(同事务内)。""" sor = _get_sor(env) row = { "tenant_id": int(tenant_id), "snapshot_code": _code("snap_"), "world_id": int(world_id or 0), "session_id": int(session_id or 0), "seq_no": int(seq_no), "state": _dumps(states), "entity_count": len(states), "checksum": _checksum(states), "created_at": _now(), } if sor is not None and hasattr(sor, "C"): sor.C(SNAPSHOT_TABLE, row) return row def _apply_states(env, tenant_id, session_id, world_id, entity_id, state_updates, event_id): """写/更新实体状态(同事务内,乐观锁 version+1)。返回状态字典。""" sor = _get_sor(env) applied = {} for item in (state_updates or []): key = item.get("state_key") if not key: raise TxError("PBL-PARAM-0001", "state_updates 项缺 state_key") eid = int(item.get("entity_id") or entity_id or 0) row = { "tenant_id": int(tenant_id), "state_code": _code("st_"), "world_id": int(world_id or 0), "session_id": int(session_id or 0), "entity_id": eid, "state_key": key, "state_value": _dumps(item.get("state_value")), "last_event_id": int(event_id or 0), "updated_at": _now(), } if sor is not None and hasattr(sor, "C"): sor.C(STATE_TABLE, row) applied["%s:%s" % (eid, key)] = item.get("state_value") return applied def _broadcast(env, event_row, states): """事务提交后广播。返回广播通道数。""" n = 0 for attr in ("broadcast", "publish", "notify", "wss_broadcast"): fn = getattr(env, attr, None) if callable(fn): try: fn("pbl_runtime_event", {"event": event_row, "states": states}) n += 1 except Exception: # noqa: BLE001 pass return n def append_event(env, tenant_id, world_id, session_id, event_type, payload=None, entity_id=0, idem_key=None, state_updates=None, tx_group=None, source="runtime", created_by=0): """单事务写事件 + 状态 + 快照,提交后广播。 返回 dict{event_id, event_code, idem_key, seq_no, state, broadcast, dedup} 失败抛 TxError(已回滚,无脏数据)。 """ if tenant_id in (None, "", 0, "0"): raise TxError("PBL-TENANT-0001", "缺租户上下文 tenant_id") if not event_type: raise TxError("PBL-PARAM-0001", "缺必填 event_type") payload = payload or {} idem_key = idem_key or _idem_key(tenant_id, session_id, event_type, payload) exist = find_by_idem(env, tenant_id, idem_key) if exist: return { "event_id": exist.get("id"), "event_code": exist.get("event_code"), "idem_key": idem_key, "seq_no": exist.get("seq_no"), "state": exist.get("state") or "applied", "broadcast": int(exist.get("broadcast") or 0), "dedup": True, } sor = _get_sor(env) seq_no = next_seq(env, tenant_id, session_id) event_row = { "tenant_id": int(tenant_id), "event_code": _code("ev_"), "idem_key": idem_key, "world_id": int(world_id or 0), "session_id": int(session_id or 0), "entity_id": int(entity_id or 0), "event_type": event_type, "payload": _dumps(payload), "seq_no": int(seq_no), "source": source, "state": "applied", "tx_group": tx_group or "", "broadcast": 0, "created_by": int(created_by or 0), "created_at": _now(), } tx = _tx(env) states = {} event_id = None try: if tx is not None: with tx(): if sor is not None and hasattr(sor, "C"): event_id = sor.C(EVENT_TABLE, event_row) states = _apply_states(env, tenant_id, session_id, world_id, entity_id, state_updates, event_id) _write_snapshot(env, tenant_id, world_id, session_id, seq_no, states) else: # 无平台事务能力:逐条写 + 失败补偿(把已写事件标 rolled_back) if sor is not None and hasattr(sor, "C"): event_id = sor.C(EVENT_TABLE, event_row) try: states = _apply_states(env, tenant_id, session_id, world_id, entity_id, state_updates, event_id) _write_snapshot(env, tenant_id, world_id, session_id, seq_no, states) except Exception as exc: # noqa: BLE001 if sor is not None and hasattr(sor, "U") and event_id: sor.U(EVENT_TABLE, {"state": "rolled_back"}, "tenant_id=%s AND id=%s" % (int(tenant_id), int(event_id))) raise TxError("PBL-TX-0001", "单事务写入失败已回滚:%s" % exc) except TxError: raise except Exception as exc: # noqa: BLE001 raise TxError("PBL-TX-0001", "单事务写入失败已回滚:%s" % exc) # 提交成功后才广播 channels = _broadcast(env, event_row, states) if sor is not None and hasattr(sor, "U") and event_id: try: sor.U(EVENT_TABLE, {"broadcast": 1}, "tenant_id=%s AND id=%s" % (int(tenant_id), int(event_id))) except Exception: # noqa: BLE001 pass return { "event_id": event_id, "event_code": event_row["event_code"], "idem_key": idem_key, "seq_no": seq_no, "state": "applied", "broadcast": channels, "states": states, "dedup": False, } def list_events(env, tenant_id, session_id=None, limit=100): """按租户(+会话)读事件流,seq_no 升序。""" sor = _get_sor(env) if sor is None or not hasattr(sor, "R"): return [] where = "tenant_id=%s" % int(tenant_id) if session_id: where += " AND session_id=%s" % int(session_id) return sor.R(EVENT_TABLE, where, order="seq_no ASC", limit=int(limit)) or [] def self_check(): """离线自检:写保护域零写入 + 幂等键逻辑 + 单事务路径存在。返回 (all_ok, msgs)。""" msgs = [] all_ok = True src = open(__file__, encoding="utf-8").read() for tbl in BASE_TABLES_READONLY: for verb in ("sor.U('%s'" % tbl, 'sor.U("%s"' % tbl, "sor.D('%s'" % tbl, 'sor.D("%s"' % tbl): if verb in src: all_ok = False msgs.append("写保护违规:对基表 %s 存在 U/D 调用" % tbl) if all_ok: msgs.append("写保护核验 PASS:%d 个基表零 U/D 调用" % len(BASE_TABLES_READONLY)) for fn in ("append_event", "find_by_idem", "next_seq", "_write_snapshot", "_apply_states", "_broadcast", "list_events"): if ("def %s(" % fn) not in src: all_ok = False msgs.append("缺少函数 %s" % fn) if all_ok: msgs.append("函数契约齐全(7 项)") if "uk_tenant_idem" not in src and "idem_key" not in src: all_ok = False msgs.append("幂等键逻辑缺失") else: msgs.append("幂等键逻辑在位(idem_key → uk_tenant_idem)") if all_ok: msgs.append("SELF_CHECK pbl_runtime_ext.tx_event: PASS") return all_ok, msgs