fix: 增加QQBot消息推送生产迁移

This commit is contained in:
sunlei 2026-07-25 12:12:42 +08:00
parent 227e5c0899
commit f8ca44f9d0
9 changed files with 599 additions and 36 deletions

4
API.md
View File

@ -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

View File

@ -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 成功替代
## 来源与许可证

View File

@ -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`

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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`

View File

@ -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.

View File

@ -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,8 +535,62 @@ 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');
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)');
@ -464,6 +599,7 @@ describe('QQBot message-push SQL contract', () => {
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', () => {