diff --git a/API.md b/API.md index 6081f4c..c29beaa 100644 --- a/API.md +++ b/API.md @@ -459,9 +459,9 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT #### SQL、发布与回滚 -既有环境使用幂等增量入口 `sql/qqbot-init.sql`;只有一次性、可丢弃的全量初始化环境才依次使用 `sql/refactor-v3/00-full-schema.sql`、`01-seed-core.sql`、`99-verify.sql`。发布顺序是:备份六表及相关菜单/角色授权行 → 应用对应 SQL 入口 → 验证表、唯一/调度索引、默认模板与权限 → 先验证 API 再发布 Admin → 创建订阅和逐账号绑定 → 使用授权非生产目标完成有界 A→B 验收。 +既有环境使用本功能幂等增量入口 `sql/qqbot-message-push-init.sql`,随后执行只读的 `sql/qqbot-message-push-verify.sql`;包含历史迁移的 `sql/qqbot-init.sql` 不能作为本功能生产迁移。只有一次性、可丢弃的全量初始化环境才依次使用 `sql/refactor-v3/00-full-schema.sql`、`01-seed-core.sql`、`99-verify.sql`。发布顺序是:备份六表及相关菜单/角色授权行 → 应用对应 SQL 入口 → 验证表、17 个精确索引、默认模板与权限 → 先验证 API 再发布 Admin → 创建订阅和逐账号绑定 → 使用授权非生产目标完成有界 A→B 验收。 -回滚先停用全部发布绑定,再回滚 Admin 和 API,并保留事件、投递及发送日志;Network Agent、端口转发、STUN Keeper 和 DDNS 继续运行。Jenkins/K8s 通过只证明版本已部署,不能替代真实 CRUD、页面或事件到消息的功能验收。当前已有实现和自动化 API 证据,但因缺少安全隔离的本地前置条件,真实本地 CRUD、Admin 页面、数据库支持的 Outbox/DDNS 流程和授权 QQ 投递仍未验证;本次文档变更没有推送、部署或执行生产 SQL。 +回滚先停用全部发布绑定,再回滚 Admin 和 API,并保留事件、投递及发送日志;Network Agent、端口转发、STUN Keeper 和 DDNS 继续运行。Jenkins/K8s 通过只证明版本已部署,不能替代真实 CRUD、页面或事件到消息的功能验收。每次发布必须分别记录生产 SQL、真实 API、页面、Outbox/DDNS 和授权 QQ 投递证据;没有明确授权的 QQ 群或 QQ 号时不得任选目标,并应把真实投递单独标记为未验证。 ### NapCat Runtime Profile diff --git a/README.md b/README.md index d89be69..2a968ed 100644 --- a/README.md +++ b/README.md @@ -233,14 +233,14 @@ bash scripts/bangdream-render-smoke.sh --operation-key bangdream.event.stage --t QQBot 系统消息推送按以下顺序发布和回滚: 1. 备份 `qqbot_message_subscription`、`qqbot_message_template`、`qqbot_message_publish_binding`、`qqbot_message_publish_target`、`qqbot_message_event`、`qqbot_message_delivery`,以及本功能相关的 `admin_menu` / `admin_role_menu` 行。 -2. 既有环境只应用审查后的幂等增量入口 `sql/qqbot-init.sql`;仅一次性、可丢弃的全量初始化环境按顺序使用 `sql/refactor-v3/00-full-schema.sql`、`01-seed-core.sql`、`99-verify.sql`,不要把两种入口混用。 -3. 验证六表、活动自然键与事件/事件-目标唯一键、事件和投递调度/租约索引、默认模板,以及页面/按钮菜单和角色授权。 +2. 既有环境只应用本功能的幂等增量入口 `sql/qqbot-message-push-init.sql`,随后执行只读的 `sql/qqbot-message-push-verify.sql`;不要把包含历史迁移的 `sql/qqbot-init.sql` 作为本功能生产迁移。仅一次性、可丢弃的全量初始化环境按顺序使用 `sql/refactor-v3/00-full-schema.sql`、`01-seed-core.sql`、`99-verify.sql`。 +3. 验证六表、17 个精确索引、默认模板、18 个页面/按钮菜单和活动 `super/admin` 角色授权。 4. 先发布并验证 API 健康检查、旧 QQBot 发送能力和新只读接口,再发布并验证 Admin。 5. 管理员显式创建订阅,并在每个发布账号中选择模板、配置群聊/私聊目标和启用绑定;随后用授权的非生产目标做一次有界 A→B 端口变化、DDNS 门禁、发送日志和幂等验收。 6. 回滚时先停用全部消息推送绑定,再回滚 Admin 和 API;保留事件、投递和 `qqbot_send_log` 历史供审计,不停止 Network Agent、端口转发、STUN Keeper 或 DDNS。 7. Jenkins/K8s 成功只属于部署证据,不能代替真实 CRUD、页面、Outbox/DDNS 或 QQ 投递功能验收。 -当前分支已有实现和自动化 API 证据;由于缺少安全隔离的本地前置条件,真实本地 CRUD、Admin 页面、数据库支持的 Outbox/DDNS 流程和授权 QQ 投递仍未验证。本次文档变更没有推送、部署或执行生产 SQL,不能据此声明功能已上线或已完整验收。 +每次发布记录必须分别写明代码部署、生产 SQL、真实 CRUD、Admin 页面、Outbox/DDNS 和授权 QQ 投递的证据;没有明确授权的 QQ 群或 QQ 号时不得任选目标,且必须把真实投递标记为未验证,不能用 Jenkins/K8s 成功替代。 ## 来源与许可证 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 4102e7d..716d54e 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -1163,12 +1163,12 @@ WHERE NOT EXISTS ( ); INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`) VALUES - (2041700000000100413,2041700000000100400,'QqBotMessageSubscription','/qqbot/message-subscription','/qqbot/message-subscription/list',NULL,'QqBot:MessageSubscription:List','menu','{"icon":"lucide:bell-ring","title":"系统消息订阅"}',1,10), - (2041700000000100414,2041700000000100400,'QqBotMessageTemplate','/qqbot/message-template','/qqbot/message-template/list',NULL,'QqBot:MessageTemplate:List','menu','{"icon":"lucide:message-square-plus","title":"系统消息模板"}',1,11), + (2041700000000100413,2041700000000100400,'QqBotMessageSubscription','/qqbot/message-subscription','/qqbot/message-subscription/list',NULL,'QqBot:MessageSubscription:List','menu','{"icon":"lucide:bell-ring","title":"消息订阅"}',1,10), + (2041700000000100414,2041700000000100400,'QqBotMessageTemplate','/qqbot/message-template','/qqbot/message-template/list',NULL,'QqBot:MessageTemplate:List','menu','{"icon":"lucide:message-square-plus","title":"消息模板"}',1,11), (2041700000000120461,2041700000000100413,'QqBotMessageSubscriptionList',NULL,NULL,NULL,'QqBot:MessageSubscription:List','button','{"title":"common.list"}',1,0),(2041700000000120462,2041700000000100413,'QqBotMessageSubscriptionCreate',NULL,NULL,NULL,'QqBot:MessageSubscription:Create','button','{"title":"common.create"}',1,0),(2041700000000120463,2041700000000100413,'QqBotMessageSubscriptionUpdate',NULL,NULL,NULL,'QqBot:MessageSubscription:Update','button','{"title":"common.edit"}',1,0),(2041700000000120464,2041700000000100413,'QqBotMessageSubscriptionDelete',NULL,NULL,NULL,'QqBot:MessageSubscription:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120465,2041700000000100413,'QqBotMessageSubscriptionToggle',NULL,NULL,NULL,'QqBot:MessageSubscription:Toggle','button','{"title":"启停"}',1,0), (2041700000000120471,2041700000000100414,'QqBotMessageTemplateList',NULL,NULL,NULL,'QqBot:MessageTemplate:List','button','{"title":"common.list"}',1,0),(2041700000000120472,2041700000000100414,'QqBotMessageTemplateCreate',NULL,NULL,NULL,'QqBot:MessageTemplate:Create','button','{"title":"common.create"}',1,0),(2041700000000120473,2041700000000100414,'QqBotMessageTemplateUpdate',NULL,NULL,NULL,'QqBot:MessageTemplate:Update','button','{"title":"common.edit"}',1,0),(2041700000000120474,2041700000000100414,'QqBotMessageTemplateDelete',NULL,NULL,NULL,'QqBot:MessageTemplate:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120475,2041700000000100414,'QqBotMessageTemplateToggle',NULL,NULL,NULL,'QqBot:MessageTemplate:Toggle','button','{"title":"启停"}',1,0),(2041700000000120476,2041700000000100414,'QqBotMessageTemplatePreview',NULL,NULL,NULL,'QqBot:MessageTemplate:Preview','button','{"title":"预览"}',1,0), (2041700000000120481,2041700000000100410,'QqBotAccountMessagePushList',NULL,NULL,NULL,'QqBot:Account:MessagePush:List','button','{"title":"common.list"}',1,0),(2041700000000120482,2041700000000100410,'QqBotAccountMessagePushCreate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Create','button','{"title":"common.create"}',1,0),(2041700000000120483,2041700000000100410,'QqBotAccountMessagePushUpdate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Update','button','{"title":"common.edit"}',1,0),(2041700000000120484,2041700000000100410,'QqBotAccountMessagePushDelete',NULL,NULL,NULL,'QqBot:Account:MessagePush:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120485,2041700000000100410,'QqBotAccountMessagePushToggle',NULL,NULL,NULL,'QqBot:Account:MessagePush:Toggle','button','{"title":"启停"}',1,0) -ON DUPLICATE KEY UPDATE `pid`=VALUES(`pid`),`path`=VALUES(`path`),`component`=VALUES(`component`),`redirect`=VALUES(`redirect`),`auth_code`=VALUES(`auth_code`),`type`=VALUES(`type`),`meta`=VALUES(`meta`),`status`=VALUES(`status`),`sort`=VALUES(`sort`),`is_deleted`=0; +ON DUPLICATE KEY UPDATE `pid`=VALUES(`pid`),`name`=VALUES(`name`),`path`=VALUES(`path`),`component`=VALUES(`component`),`redirect`=VALUES(`redirect`),`auth_code`=VALUES(`auth_code`),`type`=VALUES(`type`),`meta`=VALUES(`meta`),`status`=VALUES(`status`),`sort`=VALUES(`sort`),`is_deleted`=0; INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) SELECT role.`id`, menu.`id` diff --git a/sql/qqbot-message-push-init.sql b/sql/qqbot-message-push-init.sql new file mode 100644 index 0000000..0a8b31f --- /dev/null +++ b/sql/qqbot-message-push-init.sql @@ -0,0 +1,198 @@ +-- QQBot 系统消息推送生产增量迁移。 +-- 仅创建本功能六表、默认模板、菜单和角色授权,不执行历史 QQBot 迁移。 + +CREATE TABLE IF NOT EXISTS qqbot_message_subscription ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + source_key VARCHAR(128) NOT NULL, + source_config JSON NOT NULL, + source_config_digest CHAR(64) NOT NULL, + active_key VARCHAR(255) NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + remark VARCHAR(500) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_qqbot_message_subscription_active_key (active_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qqbot_message_template ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + source_key VARCHAR(128) NOT NULL, + content TEXT NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + remark VARCHAR(500) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qqbot_message_publish_binding ( + id BIGINT NOT NULL PRIMARY KEY, + subscription_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + self_id VARCHAR(64) NOT NULL, + template_id BIGINT NOT NULL, + active_key VARCHAR(255) NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_qqbot_message_publish_binding_active_key (active_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qqbot_message_publish_target ( + id BIGINT NOT NULL PRIMARY KEY, + binding_id BIGINT NOT NULL, + target_type VARCHAR(16) NOT NULL, + target_id VARCHAR(64) NOT NULL, + target_name VARCHAR(120) NULL, + active_key VARCHAR(300) NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_qqbot_message_publish_target_active_key (active_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qqbot_message_event ( + id BIGINT NOT NULL PRIMARY KEY, + event_id VARCHAR(128) NOT NULL, + source_key VARCHAR(128) NOT NULL, + resource_key VARCHAR(128) NOT NULL, + occurred_at DATETIME(6) NOT NULL, + payload JSON NOT NULL, + fanout_status VARCHAR(32) NOT NULL DEFAULT 'accepted', + fanout_attempt_count INT UNSIGNED NOT NULL DEFAULT 0, + next_fanout_at DATETIME(6) NULL, + fanout_lease_until DATETIME(6) NULL, + last_error_code VARCHAR(64) NULL, + last_error_message VARCHAR(500) NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_qqbot_message_event_event_id (event_id), + KEY idx_qqbot_message_event_dispatch (fanout_status, next_fanout_at), + KEY idx_qqbot_message_event_lease (fanout_lease_until), + KEY idx_qqbot_message_event_source_resource_order (source_key, resource_key, occurred_at, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qqbot_message_delivery ( + id BIGINT NOT NULL PRIMARY KEY, + message_event_id BIGINT NOT NULL, + publish_target_id BIGINT NOT NULL, + binding_id BIGINT NOT NULL, + subscription_id BIGINT NOT NULL, + self_id VARCHAR(64) NOT NULL, + target_type VARCHAR(16) NOT NULL, + target_id VARCHAR(64) NOT NULL, + template_id BIGINT NOT NULL, + template_content TEXT NOT NULL, + variable_snapshot JSON NOT NULL, + rendered_message TEXT NOT NULL, + status VARCHAR(32) NOT NULL, + attempt_count INT UNSIGNED NOT NULL DEFAULT 0, + next_attempt_at DATETIME(6) NULL, + processing_lease_until DATETIME(6) NULL, + send_log_id BIGINT NULL, + last_error_code VARCHAR(64) NULL, + last_error_message VARCHAR(500) NULL, + expires_at DATETIME(6) NOT NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_qqbot_message_delivery_event_target (message_event_id, publish_target_id), + KEY idx_qqbot_message_delivery_dispatch (status, next_attempt_at), + KEY idx_qqbot_message_delivery_lease (processing_lease_until), + KEY idx_qqbot_message_delivery_history (subscription_id, message_event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO qqbot_message_template (id, name, source_key, content, enabled, remark, is_deleted) +SELECT + 2041700000000200601, + 'STUN 映射端口变更默认模板', + 'network.stun.mapping-port-changed', + '当前STUN的端口已变更为${{endpoint}}', + 1, + '系统默认模板', + 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM qqbot_message_template + WHERE source_key = 'network.stun.mapping-port-changed' + AND name = 'STUN 映射端口变更默认模板' + AND is_deleted = 0 +); + +INSERT INTO admin_menu ( + id, + pid, + name, + path, + component, + redirect, + auth_code, + type, + meta, + status, + sort +) VALUES + (2041700000000100413,2041700000000100400,'QqBotMessageSubscription','/qqbot/message-subscription','/qqbot/message-subscription/list',NULL,'QqBot:MessageSubscription:List','menu','{"icon":"lucide:bell-ring","title":"消息订阅"}',1,10), + (2041700000000100414,2041700000000100400,'QqBotMessageTemplate','/qqbot/message-template','/qqbot/message-template/list',NULL,'QqBot:MessageTemplate:List','menu','{"icon":"lucide:message-square-plus","title":"消息模板"}',1,11), + (2041700000000120461,2041700000000100413,'QqBotMessageSubscriptionList',NULL,NULL,NULL,'QqBot:MessageSubscription:List','button','{"title":"common.list"}',1,0), + (2041700000000120462,2041700000000100413,'QqBotMessageSubscriptionCreate',NULL,NULL,NULL,'QqBot:MessageSubscription:Create','button','{"title":"common.create"}',1,0), + (2041700000000120463,2041700000000100413,'QqBotMessageSubscriptionUpdate',NULL,NULL,NULL,'QqBot:MessageSubscription:Update','button','{"title":"common.edit"}',1,0), + (2041700000000120464,2041700000000100413,'QqBotMessageSubscriptionDelete',NULL,NULL,NULL,'QqBot:MessageSubscription:Delete','button','{"title":"common.delete"}',1,0), + (2041700000000120465,2041700000000100413,'QqBotMessageSubscriptionToggle',NULL,NULL,NULL,'QqBot:MessageSubscription:Toggle','button','{"title":"启停"}',1,0), + (2041700000000120471,2041700000000100414,'QqBotMessageTemplateList',NULL,NULL,NULL,'QqBot:MessageTemplate:List','button','{"title":"common.list"}',1,0), + (2041700000000120472,2041700000000100414,'QqBotMessageTemplateCreate',NULL,NULL,NULL,'QqBot:MessageTemplate:Create','button','{"title":"common.create"}',1,0), + (2041700000000120473,2041700000000100414,'QqBotMessageTemplateUpdate',NULL,NULL,NULL,'QqBot:MessageTemplate:Update','button','{"title":"common.edit"}',1,0), + (2041700000000120474,2041700000000100414,'QqBotMessageTemplateDelete',NULL,NULL,NULL,'QqBot:MessageTemplate:Delete','button','{"title":"common.delete"}',1,0), + (2041700000000120475,2041700000000100414,'QqBotMessageTemplateToggle',NULL,NULL,NULL,'QqBot:MessageTemplate:Toggle','button','{"title":"启停"}',1,0), + (2041700000000120476,2041700000000100414,'QqBotMessageTemplatePreview',NULL,NULL,NULL,'QqBot:MessageTemplate:Preview','button','{"title":"预览"}',1,0), + (2041700000000120481,2041700000000100410,'QqBotAccountMessagePushList',NULL,NULL,NULL,'QqBot:Account:MessagePush:List','button','{"title":"common.list"}',1,0), + (2041700000000120482,2041700000000100410,'QqBotAccountMessagePushCreate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Create','button','{"title":"common.create"}',1,0), + (2041700000000120483,2041700000000100410,'QqBotAccountMessagePushUpdate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Update','button','{"title":"common.edit"}',1,0), + (2041700000000120484,2041700000000100410,'QqBotAccountMessagePushDelete',NULL,NULL,NULL,'QqBot:Account:MessagePush:Delete','button','{"title":"common.delete"}',1,0), + (2041700000000120485,2041700000000100410,'QqBotAccountMessagePushToggle',NULL,NULL,NULL,'QqBot:Account:MessagePush:Toggle','button','{"title":"启停"}',1,0) +ON DUPLICATE KEY UPDATE + pid = VALUES(pid), + name = VALUES(name), + path = VALUES(path), + component = VALUES(component), + redirect = VALUES(redirect), + auth_code = VALUES(auth_code), + type = VALUES(type), + meta = VALUES(meta), + status = VALUES(status), + sort = VALUES(sort), + is_deleted = 0; + +INSERT IGNORE INTO admin_role_menu (role_id, menu_id) +SELECT role.id, menu.id +FROM admin_role role +CROSS JOIN admin_menu menu +WHERE role.role_code IN ('super', 'admin') + AND role.status = 1 + AND role.is_deleted = 0 + AND menu.id IN ( + 2041700000000100413, + 2041700000000100414, + 2041700000000120461, + 2041700000000120462, + 2041700000000120463, + 2041700000000120464, + 2041700000000120465, + 2041700000000120471, + 2041700000000120472, + 2041700000000120473, + 2041700000000120474, + 2041700000000120475, + 2041700000000120476, + 2041700000000120481, + 2041700000000120482, + 2041700000000120483, + 2041700000000120484, + 2041700000000120485 + ) + AND menu.status = 1 + AND menu.is_deleted = 0; diff --git a/sql/qqbot-message-push-verify.sql b/sql/qqbot-message-push-verify.sql new file mode 100644 index 0000000..70eb75f --- /dev/null +++ b/sql/qqbot-message-push-verify.sql @@ -0,0 +1,226 @@ +SELECT table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qqbot_message_subscription', + 'qqbot_message_template', + 'qqbot_message_publish_binding', + 'qqbot_message_publish_target', + 'qqbot_message_event', + 'qqbot_message_delivery' + ) +ORDER BY table_name; + +SELECT table_name, index_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qqbot_message_subscription', + 'qqbot_message_template', + 'qqbot_message_publish_binding', + 'qqbot_message_publish_target', + 'qqbot_message_event', + 'qqbot_message_delivery' + ) +ORDER BY table_name, index_name; + +WITH required_index AS ( + SELECT 'qqbot_message_subscription' AS table_name, 'PRIMARY' AS index_name, 0 AS non_unique, 1 AS seq_in_index, 'id' AS column_name + UNION ALL SELECT 'qqbot_message_subscription', 'uk_qqbot_message_subscription_active_key', 0, 1, 'active_key' + UNION ALL SELECT 'qqbot_message_template', 'PRIMARY', 0, 1, 'id' + UNION ALL SELECT 'qqbot_message_publish_binding', 'PRIMARY', 0, 1, 'id' + UNION ALL SELECT 'qqbot_message_publish_binding', 'uk_qqbot_message_publish_binding_active_key', 0, 1, 'active_key' + UNION ALL SELECT 'qqbot_message_publish_target', 'PRIMARY', 0, 1, 'id' + UNION ALL SELECT 'qqbot_message_publish_target', 'uk_qqbot_message_publish_target_active_key', 0, 1, 'active_key' + UNION ALL SELECT 'qqbot_message_event', 'PRIMARY', 0, 1, 'id' + UNION ALL SELECT 'qqbot_message_event', 'uk_qqbot_message_event_event_id', 0, 1, 'event_id' + UNION ALL SELECT 'qqbot_message_event', 'idx_qqbot_message_event_dispatch', 1, 1, 'fanout_status' + UNION ALL SELECT 'qqbot_message_event', 'idx_qqbot_message_event_dispatch', 1, 2, 'next_fanout_at' + UNION ALL SELECT 'qqbot_message_event', 'idx_qqbot_message_event_lease', 1, 1, 'fanout_lease_until' + UNION ALL SELECT 'qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', 1, 1, 'source_key' + UNION ALL SELECT 'qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', 1, 2, 'resource_key' + UNION ALL SELECT 'qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', 1, 3, 'occurred_at' + UNION ALL SELECT 'qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', 1, 4, 'id' + UNION ALL SELECT 'qqbot_message_delivery', 'PRIMARY', 0, 1, 'id' + UNION ALL SELECT 'qqbot_message_delivery', 'uk_qqbot_message_delivery_event_target', 0, 1, 'message_event_id' + UNION ALL SELECT 'qqbot_message_delivery', 'uk_qqbot_message_delivery_event_target', 0, 2, 'publish_target_id' + UNION ALL SELECT 'qqbot_message_delivery', 'idx_qqbot_message_delivery_dispatch', 1, 1, 'status' + UNION ALL SELECT 'qqbot_message_delivery', 'idx_qqbot_message_delivery_dispatch', 1, 2, 'next_attempt_at' + UNION ALL SELECT 'qqbot_message_delivery', 'idx_qqbot_message_delivery_lease', 1, 1, 'processing_lease_until' + UNION ALL SELECT 'qqbot_message_delivery', 'idx_qqbot_message_delivery_history', 1, 1, 'subscription_id' + UNION ALL SELECT 'qqbot_message_delivery', 'idx_qqbot_message_delivery_history', 1, 2, 'message_event_id' +), +actual_index AS ( + SELECT table_name, index_name, non_unique, seq_in_index, column_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name IN ( + 'qqbot_message_subscription', + 'qqbot_message_template', + 'qqbot_message_publish_binding', + 'qqbot_message_publish_target', + 'qqbot_message_event', + 'qqbot_message_delivery' + ) +) +SELECT + 'qqbot_message_push_required_indexes' AS check_name, + COUNT(DISTINCT CONCAT(required_index.table_name, '/', required_index.index_name)) AS expected_index_count, + COUNT(*) AS expected_column_count, + COUNT(actual_index.index_name) AS matched_column_count, + COUNT(*) - COUNT(actual_index.index_name) AS missing_column_count, + ( + SELECT COUNT(*) + FROM actual_index unexpected + LEFT JOIN required_index expected + ON expected.table_name = unexpected.table_name + AND expected.index_name = unexpected.index_name + AND expected.non_unique = unexpected.non_unique + AND expected.seq_in_index = unexpected.seq_in_index + AND BINARY expected.column_name = BINARY unexpected.column_name + WHERE expected.table_name IS NULL + ) AS unexpected_column_count +FROM required_index +LEFT JOIN actual_index + ON actual_index.table_name = required_index.table_name + AND actual_index.index_name = required_index.index_name + AND actual_index.non_unique = required_index.non_unique + AND actual_index.seq_in_index = required_index.seq_in_index + AND BINARY actual_index.column_name = BINARY required_index.column_name; + +SELECT 'seed_qqbot_message_template' AS check_name, COUNT(*) AS matched_rows +FROM qqbot_message_template +WHERE id = 2041700000000200601 + AND BINARY name = BINARY 'STUN 映射端口变更默认模板' + AND BINARY source_key = BINARY 'network.stun.mapping-port-changed' + AND BINARY content = BINARY '当前STUN的端口已变更为${{endpoint}}' + AND enabled = 1 + AND is_deleted = 0; + +SELECT 'seed_qqbot_message_push_menu' AS check_name, COUNT(*) AS matched_rows +FROM admin_menu +WHERE id IN ( + 2041700000000100413, + 2041700000000100414, + 2041700000000120461, + 2041700000000120462, + 2041700000000120463, + 2041700000000120464, + 2041700000000120465, + 2041700000000120471, + 2041700000000120472, + 2041700000000120473, + 2041700000000120474, + 2041700000000120475, + 2041700000000120476, + 2041700000000120481, + 2041700000000120482, + 2041700000000120483, + 2041700000000120484, + 2041700000000120485 +) + AND status = 1 + AND is_deleted = 0; + +WITH expected_menu AS ( + SELECT 2041700000000100413 AS id, 2041700000000100400 AS pid, 'QqBotMessageSubscription' AS name, 'QqBot:MessageSubscription:List' AS auth_code, 10 AS sort + UNION ALL SELECT 2041700000000100414, 2041700000000100400, 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List', 11 + UNION ALL SELECT 2041700000000120461, 2041700000000100413, 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List', 0 + UNION ALL SELECT 2041700000000120462, 2041700000000100413, 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create', 0 + UNION ALL SELECT 2041700000000120463, 2041700000000100413, 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update', 0 + UNION ALL SELECT 2041700000000120464, 2041700000000100413, 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete', 0 + UNION ALL SELECT 2041700000000120465, 2041700000000100413, 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle', 0 + UNION ALL SELECT 2041700000000120471, 2041700000000100414, 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List', 0 + UNION ALL SELECT 2041700000000120472, 2041700000000100414, 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create', 0 + UNION ALL SELECT 2041700000000120473, 2041700000000100414, 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update', 0 + UNION ALL SELECT 2041700000000120474, 2041700000000100414, 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete', 0 + UNION ALL SELECT 2041700000000120475, 2041700000000100414, 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle', 0 + UNION ALL SELECT 2041700000000120476, 2041700000000100414, 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview', 0 + UNION ALL SELECT 2041700000000120481, 2041700000000100410, 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List', 0 + UNION ALL SELECT 2041700000000120482, 2041700000000100410, 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create', 0 + UNION ALL SELECT 2041700000000120483, 2041700000000100410, 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update', 0 + UNION ALL SELECT 2041700000000120484, 2041700000000100410, 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete', 0 + UNION ALL SELECT 2041700000000120485, 2041700000000100410, 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', 0 +) +SELECT 'seed_qqbot_message_push_menu_mismatch' AS check_name, COUNT(*) AS invalid_rows +FROM expected_menu expected +LEFT JOIN admin_menu actual ON actual.id = expected.id +WHERE actual.id IS NULL + OR NOT (BINARY actual.name <=> BINARY expected.name) + OR NOT (BINARY actual.auth_code <=> BINARY expected.auth_code) + OR actual.pid <> expected.pid + OR actual.sort <> expected.sort + OR actual.status <> 1 + OR actual.is_deleted <> 0; + +SELECT 'seed_qqbot_message_push_page_menu' AS check_name, COUNT(*) AS matched_rows +FROM admin_menu +WHERE ( + id = 2041700000000100413 + AND pid = 2041700000000100400 + AND BINARY name = BINARY 'QqBotMessageSubscription' + AND BINARY path = BINARY '/qqbot/message-subscription' + AND BINARY component = BINARY '/qqbot/message-subscription/list' + AND BINARY auth_code = BINARY 'QqBot:MessageSubscription:List' + AND BINARY JSON_UNQUOTE(JSON_EXTRACT(meta, '$.title')) = BINARY '消息订阅' + AND sort = 10 + ) + OR ( + id = 2041700000000100414 + AND pid = 2041700000000100400 + AND BINARY name = BINARY 'QqBotMessageTemplate' + AND BINARY path = BINARY '/qqbot/message-template' + AND BINARY component = BINARY '/qqbot/message-template/list' + AND BINARY auth_code = BINARY 'QqBot:MessageTemplate:List' + AND BINARY JSON_UNQUOTE(JSON_EXTRACT(meta, '$.title')) = BINARY '消息模板' + AND sort = 11 + ); + +SELECT 'seed_qqbot_message_push_role_grant_missing' AS check_name, COUNT(*) AS missing_rows +FROM admin_role role +CROSS JOIN admin_menu menu +LEFT JOIN admin_role_menu role_menu + ON role_menu.role_id = role.id + AND role_menu.menu_id = menu.id +WHERE role.role_code IN ('super', 'admin') + AND role.status = 1 + AND role.is_deleted = 0 + AND menu.id IN ( + 2041700000000100413, + 2041700000000100414, + 2041700000000120461, + 2041700000000120462, + 2041700000000120463, + 2041700000000120464, + 2041700000000120465, + 2041700000000120471, + 2041700000000120472, + 2041700000000120473, + 2041700000000120474, + 2041700000000120475, + 2041700000000120476, + 2041700000000120481, + 2041700000000120482, + 2041700000000120483, + 2041700000000120484, + 2041700000000120485 + ) + AND menu.status = 1 + AND menu.is_deleted = 0 + AND role_menu.menu_id IS NULL; + +SELECT 'duplicate_event_id_groups' AS check_name, COUNT(*) AS invalid_rows +FROM ( + SELECT event_id + FROM qqbot_message_event + GROUP BY event_id + HAVING COUNT(*) > 1 +) duplicate_events; + +SELECT 'duplicate_event_target_groups' AS check_name, COUNT(*) AS invalid_rows +FROM ( + SELECT message_event_id, publish_target_id + FROM qqbot_message_delivery + GROUP BY message_event_id, publish_target_id + HAVING COUNT(*) > 1 +) duplicate_deliveries; diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index 06a3036..44b41e8 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -641,12 +641,12 @@ WHERE NOT EXISTS ( ); INSERT INTO admin_menu (id, pid, name, path, component, redirect, auth_code, type, meta, status, sort) VALUES - (2041700000000100413,2041700000000100400,'QqBotMessageSubscription','/qqbot/message-subscription','/qqbot/message-subscription/list',NULL,'QqBot:MessageSubscription:List','menu','{"icon":"lucide:bell-ring","title":"系统消息订阅"}',1,10), - (2041700000000100414,2041700000000100400,'QqBotMessageTemplate','/qqbot/message-template','/qqbot/message-template/list',NULL,'QqBot:MessageTemplate:List','menu','{"icon":"lucide:message-square-plus","title":"系统消息模板"}',1,11), + (2041700000000100413,2041700000000100400,'QqBotMessageSubscription','/qqbot/message-subscription','/qqbot/message-subscription/list',NULL,'QqBot:MessageSubscription:List','menu','{"icon":"lucide:bell-ring","title":"消息订阅"}',1,10), + (2041700000000100414,2041700000000100400,'QqBotMessageTemplate','/qqbot/message-template','/qqbot/message-template/list',NULL,'QqBot:MessageTemplate:List','menu','{"icon":"lucide:message-square-plus","title":"消息模板"}',1,11), (2041700000000120461,2041700000000100413,'QqBotMessageSubscriptionList',NULL,NULL,NULL,'QqBot:MessageSubscription:List','button','{"title":"common.list"}',1,0),(2041700000000120462,2041700000000100413,'QqBotMessageSubscriptionCreate',NULL,NULL,NULL,'QqBot:MessageSubscription:Create','button','{"title":"common.create"}',1,0),(2041700000000120463,2041700000000100413,'QqBotMessageSubscriptionUpdate',NULL,NULL,NULL,'QqBot:MessageSubscription:Update','button','{"title":"common.edit"}',1,0),(2041700000000120464,2041700000000100413,'QqBotMessageSubscriptionDelete',NULL,NULL,NULL,'QqBot:MessageSubscription:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120465,2041700000000100413,'QqBotMessageSubscriptionToggle',NULL,NULL,NULL,'QqBot:MessageSubscription:Toggle','button','{"title":"启停"}',1,0), (2041700000000120471,2041700000000100414,'QqBotMessageTemplateList',NULL,NULL,NULL,'QqBot:MessageTemplate:List','button','{"title":"common.list"}',1,0),(2041700000000120472,2041700000000100414,'QqBotMessageTemplateCreate',NULL,NULL,NULL,'QqBot:MessageTemplate:Create','button','{"title":"common.create"}',1,0),(2041700000000120473,2041700000000100414,'QqBotMessageTemplateUpdate',NULL,NULL,NULL,'QqBot:MessageTemplate:Update','button','{"title":"common.edit"}',1,0),(2041700000000120474,2041700000000100414,'QqBotMessageTemplateDelete',NULL,NULL,NULL,'QqBot:MessageTemplate:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120475,2041700000000100414,'QqBotMessageTemplateToggle',NULL,NULL,NULL,'QqBot:MessageTemplate:Toggle','button','{"title":"启停"}',1,0),(2041700000000120476,2041700000000100414,'QqBotMessageTemplatePreview',NULL,NULL,NULL,'QqBot:MessageTemplate:Preview','button','{"title":"预览"}',1,0), (2041700000000120481,2041700000000100410,'QqBotAccountMessagePushList',NULL,NULL,NULL,'QqBot:Account:MessagePush:List','button','{"title":"common.list"}',1,0),(2041700000000120482,2041700000000100410,'QqBotAccountMessagePushCreate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Create','button','{"title":"common.create"}',1,0),(2041700000000120483,2041700000000100410,'QqBotAccountMessagePushUpdate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Update','button','{"title":"common.edit"}',1,0),(2041700000000120484,2041700000000100410,'QqBotAccountMessagePushDelete',NULL,NULL,NULL,'QqBot:Account:MessagePush:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120485,2041700000000100410,'QqBotAccountMessagePushToggle',NULL,NULL,NULL,'QqBot:Account:MessagePush:Toggle','button','{"title":"启停"}',1,0) -ON DUPLICATE KEY UPDATE pid = VALUES(pid), path = VALUES(path), component = VALUES(component), redirect = VALUES(redirect), auth_code = VALUES(auth_code), type = VALUES(type), meta = VALUES(meta), status = VALUES(status), sort = VALUES(sort), is_deleted = 0; +ON DUPLICATE KEY UPDATE pid = VALUES(pid), name = VALUES(name), path = VALUES(path), component = VALUES(component), redirect = VALUES(redirect), auth_code = VALUES(auth_code), type = VALUES(type), meta = VALUES(meta), status = VALUES(status), sort = VALUES(sort), is_deleted = 0; INSERT IGNORE INTO admin_role_menu (role_id, menu_id) SELECT role.id, menu.id diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index 911929f..e2a56fd 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -401,8 +401,8 @@ VALUES INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`) VALUES - (2041700000000100413, 2041700000000100400, 'QqBotMessageSubscription', '/qqbot/message-subscription', '/qqbot/message-subscription/list', NULL, 'QqBot:MessageSubscription:List', 'menu', '{"icon":"lucide:bell-ring","title":"系统消息订阅"}', 1, 10), - (2041700000000100414, 2041700000000100400, 'QqBotMessageTemplate', '/qqbot/message-template', '/qqbot/message-template/list', NULL, 'QqBot:MessageTemplate:List', 'menu', '{"icon":"lucide:message-square-plus","title":"系统消息模板"}', 1, 11), + (2041700000000100413, 2041700000000100400, 'QqBotMessageSubscription', '/qqbot/message-subscription', '/qqbot/message-subscription/list', NULL, 'QqBot:MessageSubscription:List', 'menu', '{"icon":"lucide:bell-ring","title":"消息订阅"}', 1, 10), + (2041700000000100414, 2041700000000100400, 'QqBotMessageTemplate', '/qqbot/message-template', '/qqbot/message-template/list', NULL, 'QqBot:MessageTemplate:List', 'menu', '{"icon":"lucide:message-square-plus","title":"消息模板"}', 1, 11), (2041700000000120461, 2041700000000100413, 'QqBotMessageSubscriptionList', NULL, NULL, NULL, 'QqBot:MessageSubscription:List', 'button', '{"title":"common.list"}', 1, 0), (2041700000000120462, 2041700000000100413, 'QqBotMessageSubscriptionCreate', NULL, NULL, NULL, 'QqBot:MessageSubscription:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120463, 2041700000000100413, 'QqBotMessageSubscriptionUpdate', NULL, NULL, NULL, 'QqBot:MessageSubscription:Update', 'button', '{"title":"common.edit"}', 1, 0), @@ -420,7 +420,7 @@ VALUES (2041700000000120484, 2041700000000100410, 'QqBotAccountMessagePushDelete', NULL, NULL, NULL, 'QqBot:Account:MessagePush:Delete', 'button', '{"title":"common.delete"}', 1, 0), (2041700000000120485, 2041700000000100410, 'QqBotAccountMessagePushToggle', NULL, NULL, NULL, 'QqBot:Account:MessagePush:Toggle', 'button', '{"title":"启停"}', 1, 0) ON DUPLICATE KEY UPDATE - `pid` = VALUES(`pid`), `path` = VALUES(`path`), `component` = VALUES(`component`), `redirect` = VALUES(`redirect`), `auth_code` = VALUES(`auth_code`), `type` = VALUES(`type`), `meta` = VALUES(`meta`), `status` = VALUES(`status`), `sort` = VALUES(`sort`), `is_deleted` = 0; + `pid` = VALUES(`pid`), `name` = VALUES(`name`), `path` = VALUES(`path`), `component` = VALUES(`component`), `redirect` = VALUES(`redirect`), `auth_code` = VALUES(`auth_code`), `type` = VALUES(`type`), `meta` = VALUES(`meta`), `status` = VALUES(`status`), `sort` = VALUES(`sort`), `is_deleted` = 0; INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) SELECT role.`id`, menu.`id` diff --git a/src/modules/qqbot/core/schema/README.md b/src/modules/qqbot/core/schema/README.md index be46f29..c27aa18 100644 --- a/src/modules/qqbot/core/schema/README.md +++ b/src/modules/qqbot/core/schema/README.md @@ -166,14 +166,17 @@ WHERE status IN ('waiting_ddns', 'pending', 'processing', 'retry'); Before applying SQL, take a transaction-consistent backup of all six `qqbot_message_*` push tables plus the relevant `admin_menu` and -`admin_role_menu` rows. Roll back by disabling publish bindings first, then -rolling back Admin and API while retaining events, deliveries, and send logs; -do not use `DROP TABLE` or history deletion as an application rollback. +`admin_role_menu` rows. Existing environments must apply only +`sql/qqbot-message-push-init.sql`, followed by the read-only +`sql/qqbot-message-push-verify.sql`; the broader `sql/qqbot-init.sql` contains +historical migrations and is not the production entry for this feature. Roll +back by disabling publish bindings first, then rolling back Admin and API while +retaining events, deliveries, and send logs; do not use `DROP TABLE` or history +deletion as an application rollback. Verification output must contain only counts or ID/index summaries. Never select/project or print raw `payload`, `rendered_message`, `target_id`, -credentials, provider objects, or production values. The implementation and -automated API evidence exist, but real local CRUD, browser pages, -database-backed Outbox/DDNS flow, and authorized QQ delivery remain unverified -because safe local prerequisites are unavailable; this documentation change -performed no push, deployment, or production SQL. +credentials, provider objects, or production values. Release evidence must +record database, HTTP, browser, Outbox/DDNS, and authorized QQ delivery gates +separately. Never choose a QQ target without explicit authorization, and never +use deployment success as a substitute for an unverified delivery gate. diff --git a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts index bb4475f..36ab2d9 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts @@ -9,6 +9,16 @@ type MenuEntry = readonly [ sort: string, ]; +type MessageMenuTitle = readonly [id: string, title: string]; + +type IndexDefinition = readonly [ + tableName: string, + indexName: string, + nonUnique: string, + sequence: string, + columnName: string, +]; + const messagePushTableDefinitions = { qqbot_message_subscription: { columns: [ @@ -118,6 +128,38 @@ const menuEntries: readonly MenuEntry[] = [ ['2041700000000120485', '2041700000000100410', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', '0'], ]; +const messageMenuTitles: readonly MessageMenuTitle[] = [ + ['2041700000000100413', '消息订阅'], + ['2041700000000100414', '消息模板'], +]; + +const requiredIndexDefinitions: readonly IndexDefinition[] = [ + ['qqbot_message_subscription', 'PRIMARY', '0', '1', 'id'], + ['qqbot_message_subscription', 'uk_qqbot_message_subscription_active_key', '0', '1', 'active_key'], + ['qqbot_message_template', 'PRIMARY', '0', '1', 'id'], + ['qqbot_message_publish_binding', 'PRIMARY', '0', '1', 'id'], + ['qqbot_message_publish_binding', 'uk_qqbot_message_publish_binding_active_key', '0', '1', 'active_key'], + ['qqbot_message_publish_target', 'PRIMARY', '0', '1', 'id'], + ['qqbot_message_publish_target', 'uk_qqbot_message_publish_target_active_key', '0', '1', 'active_key'], + ['qqbot_message_event', 'PRIMARY', '0', '1', 'id'], + ['qqbot_message_event', 'uk_qqbot_message_event_event_id', '0', '1', 'event_id'], + ['qqbot_message_event', 'idx_qqbot_message_event_dispatch', '1', '1', 'fanout_status'], + ['qqbot_message_event', 'idx_qqbot_message_event_dispatch', '1', '2', 'next_fanout_at'], + ['qqbot_message_event', 'idx_qqbot_message_event_lease', '1', '1', 'fanout_lease_until'], + ['qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', '1', '1', 'source_key'], + ['qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', '1', '2', 'resource_key'], + ['qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', '1', '3', 'occurred_at'], + ['qqbot_message_event', 'idx_qqbot_message_event_source_resource_order', '1', '4', 'id'], + ['qqbot_message_delivery', 'PRIMARY', '0', '1', 'id'], + ['qqbot_message_delivery', 'uk_qqbot_message_delivery_event_target', '0', '1', 'message_event_id'], + ['qqbot_message_delivery', 'uk_qqbot_message_delivery_event_target', '0', '2', 'publish_target_id'], + ['qqbot_message_delivery', 'idx_qqbot_message_delivery_dispatch', '1', '1', 'status'], + ['qqbot_message_delivery', 'idx_qqbot_message_delivery_dispatch', '1', '2', 'next_attempt_at'], + ['qqbot_message_delivery', 'idx_qqbot_message_delivery_lease', '1', '1', 'processing_lease_until'], + ['qqbot_message_delivery', 'idx_qqbot_message_delivery_history', '1', '1', 'subscription_id'], + ['qqbot_message_delivery', 'idx_qqbot_message_delivery_history', '1', '2', 'message_event_id'], +]; + const advanceSqlQuoteState = (sql: string, index: number, quoted: boolean) => { if (sql[index] !== "'") return { quoted, skipNext: false }; @@ -253,6 +295,19 @@ const extractMessagePushMenuRows = (sql: string) => { ] as MenuEntry); }; +const extractMessagePushMenuTitles = (sql: string): MessageMenuTitle[] => { + const statements = [...sql.matchAll(/insert into admin_menu \([^)]*\) values (.*?) on duplicate key update/gs)]; + const statement = statements.find((candidate) => candidate[1].includes(menuEntries[0][0])); + expect(statement).toBeTruthy(); + return extractSqlTuples(statement?.[1] || '') + .map(splitSqlTuple) + .filter((fields) => messageMenuTitles.some(([id]) => id === unquoteSqlValue(fields[0]))) + .map((fields) => [ + unquoteSqlValue(fields[0]), + JSON.parse(unquoteSqlValue(fields[8])).title, + ] as MessageMenuTitle); +}; + const extractMessagePushMenuRoleGrantStatement = (sql: string) => { const statements = splitSqlStatements(sql); const menuSeedIndex = statements.findIndex((statement) => @@ -299,6 +354,23 @@ const extractVerificationStatement = (sql: string, checkName: string) => { return statement || ''; }; +const extractRequiredIndexDefinitions = (statement: string): IndexDefinition[] => + [...statement.matchAll(/(?:select|union all select) '([^']+)'(?: as table_name)?, '([^']+)'(?: as index_name)?, (\d+)(?: as non_unique)?, (\d+)(?: as seq_in_index)?, '([^']+)'(?: as column_name)?/g)] + .map(([, tableName, indexName, nonUnique, sequence, columnName]) => + [tableName, indexName, nonUnique, sequence, columnName] as IndexDefinition); + +const summarizeIndexDefinitions = ( + required: readonly IndexDefinition[], + actual: readonly IndexDefinition[], +) => { + const requiredKeys = new Set(required.map((definition) => definition.join('\u0000'))); + const actualKeys = new Set(actual.map((definition) => definition.join('\u0000'))); + return { + missing: [...requiredKeys].filter((key) => !actualKeys.has(key)).length, + unexpected: [...actualKeys].filter((key) => !requiredKeys.has(key)).length, + }; +}; + const extractExpectedMenuCte = (statement: string) => { const cteStart = statement.match(/with expected_menu as \(/); expect(cteStart).toBeTruthy(); @@ -334,6 +406,8 @@ const extractExpectedMenuIds = (statement: string) => describe('QQBot message-push SQL contract', () => { const bootstrapSql = readNormalizedSql('sql/qqbot-init.sql'); + const migrationSql = readNormalizedSql('sql/qqbot-message-push-init.sql'); + const migrationVerifySql = readNormalizedSql('sql/qqbot-message-push-verify.sql'); const schemaSql = readNormalizedSql('sql/refactor-v3/00-full-schema.sql'); const seedSql = readNormalizedSql('sql/refactor-v3/01-seed-core.sql'); const verifySql = readNormalizedSql('sql/refactor-v3/99-verify.sql'); @@ -373,7 +447,7 @@ describe('QQBot message-push SQL contract', () => { it('keeps each table DDL and index tuple exact in current and refactor schemas', () => { for (const [table, expected] of Object.entries(messagePushTableDefinitions)) { - for (const sql of [bootstrapSql, schemaSql]) { + for (const sql of [bootstrapSql, schemaSql, migrationSql]) { const block = extractCreateTableBlock(sql, table); let expectedColumns = sql === bootstrapSql ? expected.columns.map((column) => column.endsWith(' null') && !column.endsWith('not null') @@ -381,7 +455,7 @@ describe('QQBot message-push SQL contract', () => { : column) : expected.columns; const expectedIndexes = [...expected.indexes]; - if (sql === schemaSql) { + if (sql !== bootstrapSql) { expectedColumns = expectedColumns.map((column, index) => index === 0 ? `${column} primary key` : column); @@ -408,7 +482,7 @@ describe('QQBot message-push SQL contract', () => { and name = 'STUN 映射端口变更默认模板' and is_deleted = 0 ); `); - for (const sql of [bootstrapSql, seedSql]) expect(sql).toContain(expectedTemplate); + for (const sql of [bootstrapSql, seedSql, migrationSql]) expect(sql).toContain(expectedTemplate); }); it('verifies the default template stable values with case-sensitive text comparisons', () => { @@ -433,16 +507,23 @@ describe('QQBot message-push SQL contract', () => { }); it('keeps every stable menu tuple together and exactly once in every seed file', () => { - for (const sql of [bootstrapSql, seedSql, vbenSql]) { + for (const sql of [bootstrapSql, seedSql, vbenSql, migrationSql]) { const rows = extractMessagePushMenuRows(sql); expect(rows).toHaveLength(18); expect(new Set(rows.map(([id]) => id)).size).toBe(18); expect(rows).toEqual(menuEntries); + expect(sql).toMatch(/name\s*=\s*values\(name\)/); + } + }); + + it('keeps the production menu titles aligned with the Admin route titles', () => { + for (const sql of [bootstrapSql, seedSql, vbenSql, migrationSql]) { + expect(extractMessagePushMenuTitles(sql)).toEqual(messageMenuTitles); } }); it('grants only the seeded message-push menus to active super and admin roles after every menu seed', () => { - for (const sql of [bootstrapSql, seedSql, vbenSql]) { + for (const sql of [bootstrapSql, seedSql, vbenSql, migrationSql]) { const roleGrant = extractMessagePushMenuRoleGrantStatement(sql); expect(roleGrant).toContain('insert ignore into admin_role_menu (role_id, menu_id)'); expect(extractMenuRoleGrantIds(roleGrant)).toEqual(menuEntries.map(([id]) => id)); @@ -454,16 +535,71 @@ describe('QQBot message-push SQL contract', () => { } }); + it('keeps the production migration scoped to message-push creation and idempotent seeds', () => { + expect(migrationSql).not.toMatch(/\b(?:alter table|drop table|drop column|truncate table|delete from)\b/); + expect(migrationSql).not.toContain('set foreign_key_checks = 0'); + expect(migrationSql.match(/create table if not exists qqbot_message_/g)).toHaveLength(6); + expect(migrationSql).toContain('on duplicate key update'); + expect(migrationSql).toContain('insert ignore into admin_role_menu'); + }); + + it('keeps the production verification scoped to safe summaries', () => { + for (const table of Object.keys(messagePushTableDefinitions)) { + expect(migrationVerifySql).toContain(`'${table}'`); + } + expect(extractVerificationStatement( + migrationVerifySql, + 'seed_qqbot_message_template', + )).toContain("binary content = binary '当前STUN的端口已变更为${{endpoint}}'"); + expect(extractVerificationStatement( + migrationVerifySql, + 'seed_qqbot_message_push_menu', + )).toContain('count(*) as matched_rows'); + const requiredIndexes = extractVerificationStatement( + migrationVerifySql, + 'qqbot_message_push_required_indexes', + ); + expect(extractRequiredIndexDefinitions(requiredIndexes)).toEqual(requiredIndexDefinitions); + expect(requiredIndexes).toContain('actual_index.non_unique = required_index.non_unique'); + expect(requiredIndexes).toContain('actual_index.seq_in_index = required_index.seq_in_index'); + expect(requiredIndexes).toContain('binary actual_index.column_name = binary required_index.column_name'); + expect(requiredIndexes).toContain('unexpected_column_count'); + const pageMenus = extractVerificationStatement( + migrationVerifySql, + 'seed_qqbot_message_push_page_menu', + ); + expect(pageMenus).toContain("binary json_unquote(json_extract(meta, '$.title')) = binary '消息订阅'"); + expect(pageMenus).toContain("binary json_unquote(json_extract(meta, '$.title')) = binary '消息模板'"); + expect(extractVerificationStatement( + migrationVerifySql, + 'seed_qqbot_message_push_role_grant_missing', + )).toContain('role_menu.menu_id is null'); + expect(migrationVerifySql).not.toMatch(/\b(?:insert|update|delete|alter|drop|truncate|replace)\b/); + }); + + it('rejects a same-name index whose uniqueness, sequence, or column definition drifts', () => { + const driftedDefinitions = requiredIndexDefinitions.map((definition) => + definition[1] === 'idx_qqbot_message_event_dispatch' && definition[3] === '2' + ? [definition[0], definition[1], definition[2], definition[3], 'occurred_at'] as IndexDefinition + : definition); + expect(summarizeIndexDefinitions(requiredIndexDefinitions, driftedDefinitions)).toEqual({ + missing: 1, + unexpected: 1, + }); + }); + it('uses an exact mismatch CTE and null-safe predicates for every stable menu tuple', () => { - const mismatch = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_mismatch'); - expect(extractMismatchMenuRows(mismatch)).toEqual(menuEntries); - expect(mismatch).toContain('left join admin_menu actual on actual.id = expected.id'); - expect(mismatch).toContain('not (binary actual.name <=> binary expected.name)'); - expect(mismatch).toContain('not (binary actual.auth_code <=> binary expected.auth_code)'); - expect(mismatch).toContain('actual.pid <> expected.pid'); - expect(mismatch).toContain('actual.sort <> expected.sort'); - expect(mismatch).toContain('actual.status <> 1'); - expect(mismatch).toContain('actual.is_deleted <> 0'); + for (const sql of [verifySql, migrationVerifySql]) { + const mismatch = extractVerificationStatement(sql, 'seed_qqbot_message_push_menu_mismatch'); + expect(extractMismatchMenuRows(mismatch)).toEqual(menuEntries); + expect(mismatch).toContain('left join admin_menu actual on actual.id = expected.id'); + expect(mismatch).toContain('not (binary actual.name <=> binary expected.name)'); + expect(mismatch).toContain('not (binary actual.auth_code <=> binary expected.auth_code)'); + expect(mismatch).toContain('actual.pid <> expected.pid'); + expect(mismatch).toContain('actual.sort <> expected.sort'); + expect(mismatch).toContain('actual.status <> 1'); + expect(mismatch).toContain('actual.is_deleted <> 0'); + } }); it('uses an exact cardinality CTE with expected, actual, and missing counts', () => {