diff --git a/scripts/migrations/20260919_m11b_01_create_pbl_runtime_event.sql b/scripts/migrations/20260919_m11b_01_create_pbl_runtime_event.sql new file mode 100644 index 0000000..cb364c8 --- /dev/null +++ b/scripts/migrations/20260919_m11b_01_create_pbl_runtime_event.sql @@ -0,0 +1,50 @@ +-- 生成器: scripts/pbl_runtime_event_ddl.py --emit (M11b-1) +-- 开发说明: projects/pbls/docs/02-develop/dev-notes-m11b1-pbl_runtime_event.md +-- 幂等: CREATE ... IF NOT EXISTS / DROP ... IF EXISTS + +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) +); diff --git a/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql b/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql new file mode 100644 index 0000000..82a5d51 --- /dev/null +++ b/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql @@ -0,0 +1,87 @@ +-- 生成器: scripts/pbl_runtime_event_ddl.py --emit (M11b-1) +-- 开发说明: projects/pbls/docs/02-develop/dev-notes-m11b1-pbl_runtime_event.md +-- 幂等: CREATE ... IF NOT EXISTS / DROP ... IF EXISTS + +DELIMITER $$ + +-- append-only 断言过程:非白名单操作一律 SIGNAL 拒绝 +DROP PROCEDURE IF EXISTS `pbl_assert_append_only`; + +CREATE$$ + +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$$ + +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$$ + +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'@'%'$$ + +DELIMITER ; diff --git a/scripts/migrations/20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql b/scripts/migrations/20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql new file mode 100644 index 0000000..8430588 --- /dev/null +++ b/scripts/migrations/20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql @@ -0,0 +1,104 @@ +-- 生成器: scripts/pbl_runtime_event_ddl.py --emit (M11b-1) +-- 开发说明: projects/pbls/docs/02-develop/dev-notes-m11b1-pbl_runtime_event.md +-- 幂等: CREATE ... IF NOT EXISTS / DROP ... IF EXISTS + +DELIMITER $$ + +-- 1) 幂等补建未来月分区:pmax 存在时用 REORGANIZE PARTITION 拆出新月份(不丢数据) +DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`; + +CREATE$$ + +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$$ + +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$$ + +DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`; + +CREATE$$ + +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 ;