239 lines
12 KiB
Python
239 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""pbl_runtime_ext 表定义与幂等建表(M11a:4 表,单一真源)。
|
||
|
||
4 表(mariadb 方言,BIGINT AUTO_INCREMENT 主键,tenant_id 强制打头,无 FK/ENUM/TIMESTAMP):
|
||
* pbl_runtime_event —— 运行时事件流(append-only,idem_key 幂等唯一键)
|
||
* pbl_entity_state —— 实体状态(服务端权威,state_version 客户端禁写)
|
||
* pbl_world_state_snapshot —— 世界状态快照(单事务写入后广播基线)
|
||
* pbl_runtime_idempotency —— client_seq 幂等闸门(M11a 新增,唯一键
|
||
(tenant_id, session_id, scope, client_seq))
|
||
|
||
**单一真源**:本文件的 DDL 列表是唯一权威定义,`sql/pbl_runtime_ext.sql` 与
|
||
`models/*.json` 由 `scripts/gen_artifacts.py` 从本文件生成,三处不可能漂移。
|
||
表名常量(TBL_*)供代码引用,禁止在业务代码里再写裸表名字符串。
|
||
"""
|
||
|
||
TBL_RUNTIME_EVENT = "pbl_runtime_event"
|
||
TBL_ENTITY_STATE = "pbl_entity_state"
|
||
TBL_SNAPSHOT = "pbl_world_state_snapshot"
|
||
TBL_IDEMPOTENCY = "pbl_runtime_idempotency"
|
||
|
||
TABLES = [TBL_RUNTIME_EVENT, TBL_ENTITY_STATE, TBL_SNAPSHOT, TBL_IDEMPOTENCY]
|
||
|
||
# 表名常量 → 幂等闸门/状态权威链路用到的关键列(供自检与文档核对,避免列名漂移)
|
||
IDEMPOTENCY_COLUMNS = ["idempotency_id", "tenant_id", "session_id", "scope", "client_seq",
|
||
"principal_id", "request_fp", "state", "result_json", "error_code",
|
||
"created_at", "updated_at"]
|
||
ENTITY_STATE_VERSION_COLUMN = "state_version"
|
||
|
||
DDL = [
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS `pbl_runtime_event` (
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(多租户强制打头)',
|
||
`event_code` VARCHAR(64) NOT NULL COMMENT '事件编码',
|
||
`idem_key` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '幂等键(同键重复投递只落一条)',
|
||
`world_id` BIGINT NOT NULL DEFAULT 0 COMMENT '世界ID(引用 world 基表,只读)',
|
||
`session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '游戏会话ID(引用 scense 基表,只读)',
|
||
`entity_id` BIGINT NOT NULL DEFAULT 0 COMMENT '实体ID(引用 entity 基表,只读)',
|
||
`event_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '事件类型',
|
||
`payload` TEXT COMMENT '事件负载 JSON',
|
||
`seq_no` BIGINT NOT NULL DEFAULT 0 COMMENT '会话内单调序号',
|
||
`source` VARCHAR(32) NOT NULL DEFAULT 'runtime' COMMENT '来源:runtime/agent/script',
|
||
`state` VARCHAR(16) NOT NULL DEFAULT 'applied' COMMENT 'applied/rejected/rolled_back',
|
||
`tx_group` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '事务组(同组同事务)',
|
||
`broadcast` TINYINT NOT NULL DEFAULT 1 COMMENT '是否已广播 0/1',
|
||
`created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||
`created_at` DATETIME COMMENT '创建时间',
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_tenant_idem` (`tenant_id`, `idem_key`),
|
||
KEY `ix_tenant_session_seq` (`tenant_id`, `session_id`, `seq_no`),
|
||
KEY `ix_tenant_world` (`tenant_id`, `world_id`),
|
||
KEY `ix_tenant_type` (`tenant_id`, `event_type`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运行时事件流(append-only)'
|
||
""",
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS `pbl_entity_state` (
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(多租户强制打头)',
|
||
`state_code` VARCHAR(64) NOT NULL COMMENT '状态编码',
|
||
`world_id` BIGINT NOT NULL DEFAULT 0 COMMENT '世界ID',
|
||
`session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '会话ID',
|
||
`entity_id` BIGINT NOT NULL DEFAULT 0 COMMENT '实体ID(引用 entity 基表,只读)',
|
||
`state_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '状态键',
|
||
`state_value` TEXT COMMENT '状态值 JSON',
|
||
`state_version` BIGINT NOT NULL DEFAULT 0 COMMENT '服务端权威单调版本(客户端禁写)',
|
||
`last_event_id` BIGINT NOT NULL DEFAULT 0 COMMENT '最后触发事件ID',
|
||
`created_at` DATETIME COMMENT '创建时间',
|
||
`updated_at` DATETIME COMMENT '更新时间',
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_tenant_entity_key` (`tenant_id`, `session_id`, `entity_id`, `state_key`),
|
||
KEY `ix_tenant_world` (`tenant_id`, `world_id`),
|
||
KEY `ix_tenant_state_version` (`tenant_id`, `session_id`, `state_version`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实体状态薄扩展(服务端权威,state_version 客户端禁写)'
|
||
""",
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS `pbl_world_state_snapshot` (
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(多租户强制打头)',
|
||
`snapshot_code` VARCHAR(64) NOT NULL COMMENT '快照编码',
|
||
`world_id` BIGINT NOT NULL DEFAULT 0 COMMENT '世界ID',
|
||
`session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '会话ID',
|
||
`seq_no` BIGINT NOT NULL DEFAULT 0 COMMENT '快照对应事件序号',
|
||
`state` TEXT COMMENT '世界状态 JSON',
|
||
`entity_count` INT NOT NULL DEFAULT 0 COMMENT '实体数',
|
||
`checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '状态校验和',
|
||
`created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||
`created_at` DATETIME COMMENT '创建时间',
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_tenant_snapshot_code` (`tenant_id`, `snapshot_code`),
|
||
KEY `ix_tenant_session_seq` (`tenant_id`, `session_id`, `seq_no`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='世界状态快照'
|
||
""",
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS `pbl_runtime_idempotency` (
|
||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(多租户强制打头)',
|
||
`idempotency_id` VARCHAR(64) NOT NULL COMMENT '幂等记录业务主键',
|
||
`session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '会话ID(引用 scense 基表,只读)',
|
||
`scope` VARCHAR(64) NOT NULL DEFAULT '*' COMMENT '幂等作用域(world_id 或 world_id:channel,缺省 *)',
|
||
`client_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '客户端单调序号(>=1)',
|
||
`principal_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '提交者(服务端会话解析)',
|
||
`request_fp` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求体指纹(同 seq 不同内容判冲突)',
|
||
`state` VARCHAR(16) NOT NULL DEFAULT 'in_progress' COMMENT 'in_progress/completed/failed',
|
||
`result_json` TEXT COMMENT '已完成结果缓存(重放直接返回,不再写第二条事件)',
|
||
`error_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '失败错误码(failed 态)',
|
||
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间(epoch 毫秒)',
|
||
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间(epoch 毫秒)',
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_tenant_seq` (`tenant_id`, `session_id`, `scope`, `client_seq`),
|
||
UNIQUE KEY `uk_tenant_idem_id` (`tenant_id`, `idempotency_id`),
|
||
KEY `ix_tenant_scope_seq` (`tenant_id`, `session_id`, `scope`, `state`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运行时 client_seq 幂等闸门(M11a 服务端权威)'
|
||
""",
|
||
]
|
||
|
||
|
||
def table_names():
|
||
"""全部表名(list[str])。"""
|
||
return list(TABLES)
|
||
|
||
|
||
def get_ddl():
|
||
"""返回本模块全部建表语句(list[str])。"""
|
||
return [x.strip() for x in DDL]
|
||
|
||
|
||
def ddl_for(table):
|
||
"""按表名取建表语句;未知表名抛 KeyError(fail-closed,不静默返回空)。"""
|
||
for stmt in get_ddl():
|
||
if stmt.split("`")[1] == table:
|
||
return stmt
|
||
raise KeyError("未知表 %s(应为 %s 之一)" % (table, ",".join(TABLES)))
|
||
|
||
|
||
def columns_for(table):
|
||
"""按表名解析列名清单(从 DDL 单一真源解析,供 gen_artifacts / 自检使用)。"""
|
||
cols = []
|
||
for line in ddl_for(table).splitlines():
|
||
s = line.strip()
|
||
if s.startswith("`") and s.endswith(","):
|
||
cols.append(s.split("`")[1])
|
||
return cols
|
||
|
||
|
||
def ensure_tables(env=None, sor=None):
|
||
"""幂等建表(CREATE TABLE IF NOT EXISTS)。返回 (ok_list, bad_list)。"""
|
||
ok, bad = [], []
|
||
runner = sor
|
||
if runner is None and env is not None:
|
||
runner = getattr(env, "sor", None) or getattr(env, "db", None)
|
||
for stmt in get_ddl():
|
||
name = stmt.split("`")[1] if "`" in stmt else "?"
|
||
try:
|
||
if runner is not None and hasattr(runner, "sqlExe"):
|
||
runner.sqlExe(stmt)
|
||
elif env is not None and hasattr(env, "sqlExe"):
|
||
env.sqlExe(stmt)
|
||
else:
|
||
if "AUTO_INCREMENT" not in stmt or "tenant_id" not in stmt:
|
||
raise ValueError("DDL 形态不合规:%s" % name)
|
||
ok.append(name)
|
||
except Exception as exc: # noqa: BLE001
|
||
bad.append((name, str(exc)[:160]))
|
||
return ok, bad
|
||
|
||
|
||
def self_check():
|
||
"""离线自检:4 表齐全 / tenant_id 打头 / 无禁用方言 / 幂等唯一键在位 / 列名与代码一致。"""
|
||
msgs = []
|
||
all_ok = True
|
||
forbidden = ("BIGSERIAL", "SERIAL", "nextval", "FOREIGN KEY",
|
||
"REFERENCES", "ENUM(", "TIMESTAMP")
|
||
seen = []
|
||
for stmt in get_ddl():
|
||
name = stmt.split("`")[1]
|
||
seen.append(name)
|
||
body = stmt.upper()
|
||
if name not in TABLES:
|
||
all_ok = False
|
||
msgs.append("未知表 %s" % name)
|
||
for kw in forbidden:
|
||
if kw in body:
|
||
all_ok = False
|
||
msgs.append("%s 命中禁用方言 %s" % (name, kw))
|
||
cols = [ln.strip().split("`")[1] for ln in stmt.splitlines() if ln.strip().startswith("`")]
|
||
biz = [c for c in cols if c != "id"]
|
||
if not biz or biz[0] != "tenant_id":
|
||
all_ok = False
|
||
msgs.append("%s 首个业务列=%s(应为 tenant_id)" % (name, biz[:1]))
|
||
if "AUTO_INCREMENT" not in body:
|
||
all_ok = False
|
||
msgs.append("%s 缺 AUTO_INCREMENT 主键" % name)
|
||
if "IF NOT EXISTS" not in body:
|
||
all_ok = False
|
||
msgs.append("%s 非幂等建表(缺 IF NOT EXISTS)" % name)
|
||
|
||
if sorted(seen) != sorted(TABLES):
|
||
all_ok = False
|
||
msgs.append("DDL 表集合 %s 与 TABLES %s 不一致" % (sorted(seen), sorted(TABLES)))
|
||
|
||
# 事件表幂等唯一键
|
||
if "UNIQUE KEY `uk_tenant_idem`" not in ddl_for(TBL_RUNTIME_EVENT):
|
||
all_ok = False
|
||
msgs.append("pbl_runtime_event 缺幂等唯一键 uk_tenant_idem")
|
||
else:
|
||
msgs.append("pbl_runtime_event uk_tenant_idem 在位")
|
||
|
||
# 状态表:服务端权威版本列必须是 state_version(代码引用的真实列名)
|
||
state_cols = columns_for(TBL_ENTITY_STATE)
|
||
if ENTITY_STATE_VERSION_COLUMN not in state_cols:
|
||
all_ok = False
|
||
msgs.append("pbl_entity_state 缺列 %s(代码 MAX(state_version) 会 Unknown column)"
|
||
% ENTITY_STATE_VERSION_COLUMN)
|
||
else:
|
||
msgs.append("pbl_entity_state.%s 在位(服务端权威,客户端禁写)" % ENTITY_STATE_VERSION_COLUMN)
|
||
if "version" in state_cols and ENTITY_STATE_VERSION_COLUMN not in state_cols:
|
||
all_ok = False
|
||
msgs.append("pbl_entity_state 仍用旧列名 version,应统一为 state_version")
|
||
|
||
# 幂等闸门表:列 + 唯一键
|
||
idem_cols = columns_for(TBL_IDEMPOTENCY)
|
||
for col in IDEMPOTENCY_COLUMNS:
|
||
if col not in idem_cols:
|
||
all_ok = False
|
||
msgs.append("pbl_runtime_idempotency 缺列 %s(idempotency.py 读写依赖)" % col)
|
||
idem_ddl = ddl_for(TBL_IDEMPOTENCY)
|
||
if "UNIQUE KEY `uk_tenant_seq` (`tenant_id`, `session_id`, `scope`, `client_seq`)" not in idem_ddl:
|
||
all_ok = False
|
||
msgs.append("pbl_runtime_idempotency 缺唯一键 uk_tenant_seq(tenant_id,session_id,scope,client_seq)")
|
||
if all_ok and set(IDEMPOTENCY_COLUMNS) <= set(idem_cols):
|
||
msgs.append("pbl_runtime_idempotency 列/唯一键齐全(%d 列)" % len(idem_cols))
|
||
|
||
if len(TABLES) != 4:
|
||
all_ok = False
|
||
msgs.append("表数=%d 应为 4" % len(TABLES))
|
||
if all_ok:
|
||
msgs.append("SELF_CHECK pbl_runtime_ext.tables: PASS %d/%d" % (len(TABLES), len(TABLES)))
|
||
return all_ok, msgs
|