-- =========================================================================== -- pbl_runtime_event 表 DDL + 按月 RANGE 分区 + append-only 约束(M11b-1) -- 由 scripts/pbl_runtime_event_ddl.py 生成,改表结构只改生成器再 --emit。 -- 目标引擎 mariadb(projects/pbls/env/test.json: db.engine=mariadb)。 -- 执行:mysql --protocol=tcp -u -p < 本文件 -- 说明:触发器含多语句体,须用 mysql CLI(DELIMITER)执行; -- 应用侧 ensure_tables() 只执行 A 段单语句 CREATE TABLE。 -- 开发说明:projects/pbls/docs/02-develop/dev-notes-m11b1-pbl_runtime_event.md -- =========================================================================== -- ---------------------------------------------------------------- A. 建表(分区) CREATE TABLE IF NOT EXISTS `pbl_runtime_event` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增ID(逻辑主键;物理主键=(id,created_at))', `tenant_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '租户ID(多租户强制打头)', `event_uid` VARCHAR(64) NOT NULL COMMENT '事件UID(uuid,应用层生成)', `event_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '事件编码', `idem_key` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '幂等键(同键重复投递只落一条)', `world_id` BIGINT NOT NULL DEFAULT 0 COMMENT '世界ID(引用 world 基表,只读)', `scene_id` BIGINT NOT NULL DEFAULT 0 COMMENT '场景/会话ID(引用 scense 基表,只读)', `session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '游戏会话ID(M11a 语义,与 scene_id 同域)', `entity_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '实体ID(引用 entity 基表,只读)', `actor_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '触发者(服务端会话解析,客户端禁填)', `event_type` VARCHAR(64) NOT NULL COMMENT '事件类型', `payload` TEXT NULL COMMENT '事件负载JSON(任务书字段)', `payload_json` LONGTEXT NULL COMMENT '事件负载JSON(M11a 写路径列名,与 payload 同义留宽)', `causation_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '因果链:引发本事件的事件UID', `seq` BIGINT NOT NULL DEFAULT 0 COMMENT '会话内单调序号(轮询游标,应用层生成)', `seq_no` BIGINT NOT NULL DEFAULT 0 COMMENT '会话内单调序号(M11a 写路径列名)', `source` VARCHAR(32) NOT NULL DEFAULT 'runtime' COMMENT '来源:runtime/agent/script/client_intent', `state` VARCHAR(16) NOT NULL DEFAULT 'applied' COMMENT 'applied/rejected/rolled_back', `state_version` BIGINT NOT NULL DEFAULT 0 COMMENT '本事件推进到的服务端权威版本', `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 NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(分区键,禁TIMESTAMP)', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间戳(append-only:永不 UPDATE)', `occurred_at` DATETIME NULL COMMENT '兼容列(M11a 旧分区键,新写入可空)', PRIMARY KEY (`id`, `created_at`), UNIQUE KEY `uk_event_uid` (`tenant_id`, `event_uid`, `created_at`), UNIQUE KEY `uk_tenant_idem` (`tenant_id`, `idem_key`, `created_at`), KEY `ix_scene_created` (`scene_id`, `created_at`), KEY `ix_tenant_session_seq` (`tenant_id`, `session_id`, `seq`), KEY `ix_tenant_session_seqno` (`tenant_id`, `session_id`, `seq_no`), KEY `ix_tenant_world_type` (`tenant_id`, `world_id`, `event_type`), KEY `ix_tx_group` (`tx_group`), KEY `ix_created_at` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='运行时事件流(append-only,按月RANGE分区,保留14个月)' PARTITION BY RANGE COLUMNS(`created_at`) ( PARTITION `p202609` VALUES LESS THAN (DATE '2026-10-01'), PARTITION `p202610` VALUES LESS THAN (DATE '2026-11-01'), PARTITION `p202611` VALUES LESS THAN (DATE '2026-12-01'), PARTITION `p202612` VALUES LESS THAN (DATE '2027-01-01'), PARTITION `p202701` VALUES LESS THAN (DATE '2027-02-01'), PARTITION `p202702` VALUES LESS THAN (DATE '2027-03-01'), PARTITION `pmax` VALUES LESS THAN (MAXVALUE) ); DELIMITER $$ -- ---------------------------------------------------------------- B. append-only -- append-only 断言过程:非白名单操作一律 SIGNAL 拒绝 DROP PROCEDURE IF EXISTS `pbl_assert_append_only`$$ CREATE PROCEDURE `pbl_assert_append_only`( IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255)) READS SQL DATA BEGIN IF p_table <> 'pbl_runtime_event' THEN RETURN; -- 只守护事件表,其他表不受影响 END IF; IF IFNULL(@pbl_guard_ctx, '') = 'append_only_admin' THEN RETURN; -- break-glass:一次性历史数据回填 END IF; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED', MYSQL_ERRNO = 45001; END$$ -- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方 DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_upd`$$ CREATE TRIGGER `trg_pbl_runtime_event_append_only_upd` BEFORE UPDATE ON `pbl_runtime_event` FOR EACH ROW BEGIN DECLARE v_sql TEXT; DECLARE v_msg VARCHAR(255); SET v_sql = ''; BEGIN DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_sql = ''; SELECT INFO INTO v_sql FROM information_schema.processlist WHERE ID = CONNECTION_ID(); END; IF IFNULL(@pbl_guard_ctx, '') = 'append_only_admin' THEN SET v_msg = CONCAT('break-glass update on ', 'pbl_runtime_event'); ELSE SET v_msg = CONCAT('UPDATE denied: ', LEFT(IFNULL(v_sql, 'n/a'), 120)); END IF; CALL pbl_assert_append_only('pbl_runtime_event', 'update', v_msg); IF IFNULL(@pbl_guard_ctx, '') <> 'append_only_admin' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_UPDATE', MYSQL_ERRNO = 45002; END IF; END$$ -- BEFORE DELETE 护栏 DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_del`$$ CREATE TRIGGER `trg_pbl_runtime_event_append_only_del` BEFORE DELETE ON `pbl_runtime_event` FOR EACH ROW BEGIN DECLARE v_sql TEXT; DECLARE v_msg VARCHAR(255); SET v_sql = ''; BEGIN DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_sql = ''; SELECT INFO INTO v_sql FROM information_schema.processlist WHERE ID = CONNECTION_ID(); END; IF IFNULL(@pbl_guard_ctx, '') = 'append_only_admin' THEN SET v_msg = CONCAT('break-glass delete on ', 'pbl_runtime_event'); ELSE SET v_msg = CONCAT('DELETE denied: ', LEFT(IFNULL(v_sql, 'n/a'), 120)); END IF; CALL pbl_assert_append_only('pbl_runtime_event', 'delete', v_msg); IF IFNULL(@pbl_guard_ctx, '') <> 'append_only_admin' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_DELETE', MYSQL_ERRNO = 45003; END IF; END$$ -- 权限层加固(可选,需 DBA 账号执行;app 账号只保留增/查) -- GRANT INSERT, SELECT, UPDATE, DELETE ON `pbls`.`pbl_runtime_event` TO NULL; -- 占位,勿执行 -- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%'; -- ---------------------------------------------------------------- C. 分区维护 -- 1) 幂等补建未来月分区:pmax 存在时用 REORGANIZE PARTITION 拆出新月份(不丢数据) DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`$$ CREATE PROCEDURE `pbl_runtime_event_ensure_partitions`(IN p_months_ahead INT) MODIFIES SQL DATA BEGIN DECLARE v_done INT DEFAULT 0; DECLARE v_name VARCHAR(16); DECLARE v_upper DATE; DECLARE v_exists INT DEFAULT 0; DECLARE v_sql TEXT; DECLARE cur CURSOR FOR WITH RECURSIVE ms(month_start) AS ( SELECT DATE_FORMAT(CURDATE(), '%Y-%m-01') UNION ALL SELECT DATE_ADD(month_start, INTERVAL 1 MONTH) FROM ms WHERE month_start < DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL p_months_ahead MONTH) ) SELECT CONCAT('p', DATE_FORMAT(month_start, '%Y%m')), DATE_ADD(month_start, INTERVAL 1 MONTH) FROM ms; DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1; OPEN cur; read_loop: LOOP FETCH cur INTO v_name, v_upper; IF v_done = 1 THEN LEAVE read_loop; END IF; SELECT COUNT(*) INTO v_exists FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pbl_runtime_event' AND PARTITION_NAME = v_name; IF v_exists = 0 THEN SET @pbl_part_sql = CONCAT( 'ALTER TABLE `pbl_runtime_event` REORGANIZE PARTITION `pmax` INTO (', 'PARTITION `', v_name, '` VALUES LESS THAN (DATE ''', v_upper, '''), ', 'PARTITION `pmax` VALUES LESS THAN (MAXVALUE))'); PREPARE pbl_part_st FROM @pbl_part_sql; EXECUTE pbl_part_st; DEALLOCATE PREPARE pbl_part_st; END IF; END LOOP; CLOSE cur; END$$ -- 2) 到期归档:整月 DROP PARTITION(append-only 表唯一合规的清理手段) DROP PROCEDURE IF EXISTS `pbl_runtime_event_prune_partitions`$$ CREATE PROCEDURE `pbl_runtime_event_prune_partitions`(IN p_keep_months INT) MODIFIES SQL DATA BEGIN DECLARE v_done INT DEFAULT 0; DECLARE v_name VARCHAR(64); DECLARE v_cutoff DATE; DECLARE cur CURSOR FOR SELECT PARTITION_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pbl_runtime_event' AND PARTITION_NAME IS NOT NULL AND PARTITION_NAME <> 'pmax' AND PARTITION_NAME < CONCAT('p', DATE_FORMAT(v_cutoff, '%Y%m')); DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1; SET v_cutoff = DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL -p_keep_months MONTH); OPEN cur; prune_loop: LOOP FETCH cur INTO v_name; IF v_done = 1 THEN LEAVE prune_loop; END IF; SET @pbl_drop_sql = CONCAT('ALTER TABLE `pbl_runtime_event` DROP PARTITION `', v_name, '`'); PREPARE pbl_drop_st FROM @pbl_drop_sql; EXECUTE pbl_drop_st; DEALLOCATE PREPARE pbl_drop_st; END LOOP; CLOSE cur; END$$ -- 3) 每月自动补分区(需 event_scheduler=ON;否则用 cron,见开发说明) -- SET GLOBAL event_scheduler = ON; DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`$$ CREATE EVENT IF NOT EXISTS `ev_pbl_runtime_event_partition_maintain` ON SCHEDULE EVERY 1 MONTH STARTS TIMESTAMP(DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01 03:00:00')) ON COMPLETION PRESERVE ENABLE COMMENT '每月预建未来 6 个月分区' DO CALL pbl_runtime_event_ensure_partitions(6)$$ DELIMITER ; -- ---------------------------------------------------------------- D. 一次性改造 -- 一次性改造(仅当 pbl_runtime_event 已存在且未分区时手工执行;先备份): -- 1) CREATE TABLE pbl_runtime_event_new ... (本文件 A 段 DDL,改名 _new) -- 2) SET @pbl_guard_ctx = 'append_only_admin'; -- break-glass,仅回填期间 -- INSERT INTO pbl_runtime_event_new (...) SELECT ... FROM pbl_runtime_event; -- SET @pbl_guard_ctx = NULL; -- 3) RENAME TABLE pbl_runtime_event TO pbl_runtime_event_m11a_bak, -- pbl_runtime_event_new TO pbl_runtime_event; -- 4) 重放 append-only 触发器段(B 段),核对 EXPLAIN 分区裁剪后删备份表。 -- 本脚本不自动执行改造:DROP/RENAME 生产表必须人工确认(见开发说明「回滚」段)。 SELECT 'm11b-1 migrate: 人工执行,见注释' AS note;