From 5b34a4d8b19862131e146c0246ef6001da19b80a Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 03:29:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8BQQBot=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/qqbot-init.sql | 70 ++++++++++++ sql/refactor-v3/00-full-schema.sql | 104 ++++++++++++++++++ sql/refactor-v3/01-seed-core.sql | 17 +++ sql/refactor-v3/99-verify.sql | 23 ++++ sql/vben-admin-init.sql | 32 ++++++ .../message-push/qqbot-message-push.types.ts | 64 +++++++++++ .../core/contract/qqbot-core.contract.ts | 6 + .../qqbot-message-delivery.entity.ts | 37 +++++++ .../qqbot-message-event.entity.ts | 28 +++++ .../qqbot-message-publish-binding.entity.ts | 21 ++++ .../qqbot-message-publish-target.entity.ts | 22 ++++ .../qqbot-message-subscription.entity.ts | 22 ++++ .../qqbot-message-template.entity.ts | 19 ++++ src/modules/qqbot/core/qqbot-core.module.ts | 12 ++ .../core/qqbot-core-module-contract.spec.ts | 6 + .../qqbot-message-push-persistence.spec.ts | 83 ++++++++++++++ .../qqbot-message-push-sql.spec.ts | 97 ++++++++++++++++ 17 files changed, 663 insertions(+) create mode 100644 src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts create mode 100644 src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity.ts create mode 100644 src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity.ts create mode 100644 src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity.ts create mode 100644 src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity.ts create mode 100644 src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity.ts create mode 100644 src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity.ts create mode 100644 test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts create mode 100644 test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 10f7d9d..6d06190 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -1106,6 +1106,76 @@ SELECT role.`id`, menu.`id` FROM `admin_role` role JOIN `admin_menu` menu ON menu.`name` LIKE 'QqBot%' WHERE role.`role_code` IN ('super', 'admin') + AND role.`status` = 1 + AND role.`is_deleted` = 0 + AND menu.`is_deleted` = 0; + +CREATE TABLE IF NOT EXISTS `qqbot_message_subscription` ( + `id` bigint NOT NULL, `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) DEFAULT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, `remark` varchar(500) DEFAULT 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), + PRIMARY KEY (`id`), 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, `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) DEFAULT 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), PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_message_publish_binding` ( + `id` bigint NOT NULL, `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) DEFAULT 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), + PRIMARY KEY (`id`), 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, `binding_id` bigint NOT NULL, `target_type` varchar(16) NOT NULL, `target_id` varchar(64) NOT NULL, `target_name` varchar(120) DEFAULT NULL, + `active_key` varchar(300) DEFAULT 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), + PRIMARY KEY (`id`), 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, `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) DEFAULT NULL, `fanout_lease_until` datetime(6) DEFAULT NULL, `last_error_code` varchar(64) DEFAULT NULL, `last_error_message` varchar(500) DEFAULT 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), + PRIMARY KEY (`id`), 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`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_message_delivery` ( + `id` bigint NOT NULL, `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) DEFAULT NULL, `processing_lease_until` datetime(6) DEFAULT NULL, `send_log_id` bigint DEFAULT NULL, `last_error_code` varchar(64) DEFAULT NULL, `last_error_message` varchar(500) DEFAULT 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), + PRIMARY KEY (`id`), 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`),`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 +JOIN `admin_menu` menu ON menu.`name` LIKE 'QqBot%' +WHERE role.`role_code` IN ('super', 'admin') + AND role.`status` = 1 AND role.`is_deleted` = 0 AND menu.`is_deleted` = 0; diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index c59a362..ddf7925 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -1242,3 +1242,107 @@ CREATE TABLE IF NOT EXISTS qqbot_napcat_webui_gateway_audit ( KEY idx_napcat_webui_gateway_audit_account_event (account_id, event_type), KEY idx_napcat_webui_gateway_audit_admin_time (admin_user_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +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) +) 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; diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index ea95c6d..de5882a 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -630,3 +630,20 @@ ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), cooldown_seconds = VALUES(cooldown_seconds), is_deleted = 0; + +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), 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; diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index 077acf2..14946a7 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -22,6 +22,29 @@ SELECT 'napcat_login_event' AS table_name, COUNT(*) AS row_count FROM napcat_log SELECT 'napcat_risk_mode' AS table_name, COUNT(*) AS row_count FROM napcat_risk_mode; SELECT 'qqbot_plugin_task' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task; SELECT 'qqbot_plugin_task_run' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task_run; +SELECT 'qqbot_message_subscription' AS table_name, COUNT(*) AS row_count FROM qqbot_message_subscription; +SELECT 'qqbot_message_template' AS table_name, COUNT(*) AS row_count FROM qqbot_message_template; +SELECT 'qqbot_message_publish_binding' AS table_name, COUNT(*) AS row_count FROM qqbot_message_publish_binding; +SELECT 'qqbot_message_publish_target' AS table_name, COUNT(*) AS row_count FROM qqbot_message_publish_target; +SELECT 'qqbot_message_event' AS table_name, COUNT(*) AS row_count FROM qqbot_message_event; +SELECT 'qqbot_message_delivery' AS table_name, COUNT(*) AS row_count FROM qqbot_message_delivery; + +SELECT 'seed_qqbot_message_template' AS check_name, COUNT(*) AS matched_rows +FROM qqbot_message_template +WHERE id = 2041700000000200601 + AND name = 'STUN 映射端口变更默认模板' + AND source_key = 'network.stun.mapping-port-changed' + AND content = '当前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 auth_code IN ( + 'QqBot:MessageSubscription:List', 'QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:Update', 'QqBot:MessageSubscription:Delete', 'QqBot:MessageSubscription:Toggle', + 'QqBot:MessageTemplate:List', 'QqBot:MessageTemplate:Create', 'QqBot:MessageTemplate:Update', 'QqBot:MessageTemplate:Delete', 'QqBot:MessageTemplate:Toggle', 'QqBot:MessageTemplate:Preview', + 'QqBot:Account:MessagePush:List', 'QqBot:Account:MessagePush:Create', 'QqBot:Account:MessagePush:Update', 'QqBot:Account:MessagePush:Delete', 'QqBot:Account:MessagePush:Toggle' +) AND status = 1 AND is_deleted = 0; SELECT 'qqbot_napcat_webui_gateway_audit table exists' AS check_name, COUNT(*) AS matched_rows FROM information_schema.tables diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index fd96a29..6303120 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -399,4 +399,36 @@ VALUES (2041700000000010003, 2041700000000100010), (2041700000000010003, 2041700000000100011); +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`), `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 +JOIN `admin_menu` menu ON menu.`name` LIKE 'QqBot%' +WHERE role.`role_code` IN ('super', 'admin') + AND role.`status` = 1 + AND role.`is_deleted` = 0 + AND menu.`is_deleted` = 0; + SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts new file mode 100644 index 0000000..afea1e7 --- /dev/null +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts @@ -0,0 +1,64 @@ +import type { EntityManager } from 'typeorm'; + +export type SystemMessageScalar = boolean | null | number | string; +export type SystemMessageFanoutStatus = 'accepted' | 'completed' | 'failed' | 'processing' | 'retry'; +export type SystemMessageDeliveryStatus = 'cancelled' | 'failed' | 'pending' | 'processing' | 'retry' | 'success' | 'superseded' | 'waiting_ddns'; +export type QqbotMessagePushTargetType = 'group' | 'private'; +export type SystemMessageTemplateToken = { kind: 'text'; value: string } | { key: string; kind: 'variable' }; + +export interface SystemMessageSourceVariableDefinition { description: string; example: string; key: string; label: string; type: 'boolean' | 'number' | 'string'; } +export interface SystemMessageSourceFieldDefinition { dependsOn?: string; key: string; label: string; optionCollection: 'ddnsRecords' | 'portForwards'; required: true; type: 'select'; } +export interface SystemMessageSourceDefinition { description: string; displayName: string; sourceKey: string; subscriptionFields: SystemMessageSourceFieldDefinition[]; variables: SystemMessageSourceVariableDefinition[]; version: 1; } +export interface StunMappingPortChangedSubscriptionConfig { ddnsRecordId: string; portForwardId: string; } +export interface StunMappingPortChangedOptionsResponse { ddnsRecords: Array<{ disabledReasonCode: null | string; eligible: boolean; fqdn: string; id: string; name: string; portForwardId: string; }>; portForwards: Array<{ disabledReasonCode: null | string; eligible: boolean; externalPort: number; id: string; internalPort: number; name: string; protocol: 'tcp' | 'udp'; }>; } + +export interface MessageSubscriptionView { createTime: string; enabled: boolean; id: string; invalidReasonCode: null | string; name: string; remark: null | string; sourceConfig: StunMappingPortChangedSubscriptionConfig; sourceKey: string; sourceName: string; sourceSummary: string; updateTime: string; valid: boolean; } +export interface MessageTemplateView { content: string; createTime: string; enabled: boolean; id: string; name: string; referenceCount: number; remark: null | string; sourceKey: string; sourceName: string; updateTime: string; } +export interface MessageTemplatePreview { renderedMessage: string; variables: Record; } +export interface QqbotMessagePublishTargetView { enabled: boolean; id: string; targetId: string; targetName: null | string; targetType: QqbotMessagePushTargetType; } +export interface QqbotMessagePublishBindingView { available: boolean; createTime: string; enabled: boolean; id: string; invalidReasonCode: null | string; sourceKey: string; sourceName: string; subscriptionId: string; subscriptionName: string; targets: QqbotMessagePublishTargetView[]; templateId: string; templateName: string; updateTime: string; } + +export type SystemMessageDeliveryReadiness = + | { reasonCode: null; status: 'ready'; variables: Record; } + | { reasonCode: 'ddns_not_synced'; status: 'waiting_ddns'; variables: Record; } + | { reasonCode: string; status: 'cancelled' | 'superseded'; variables?: never; }; + +export interface SystemMessageSourceAdapter { + readonly definition: SystemMessageSourceDefinition; + inspectSubscription(config: Record): Promise<{ invalidReasonCode: null | string; sourceSummary: string; valid: boolean; }>; + listSubscriptionOptions(): Promise>; + normalizeSubscriptionConfig(input: unknown): Promise<{ canonicalConfig: Record; resourceKey: string; sourceSummary: string; }>; + resolveDelivery(input: { eventPayload: Record; subscriptionConfig: Record; }): Promise; + validateEventPayload(payload: Record): Record; +} + +export interface SystemMessageEventInput { eventId: string; occurredAt: string; payload: Record; resourceKey: string; sourceKey: string; } +export const SYSTEM_MESSAGE_EVENT_STAGER = Symbol('SYSTEM_MESSAGE_EVENT_STAGER'); +export interface SystemMessageEventStager { stage(manager: EntityManager, input: SystemMessageEventInput): Promise<'accepted' | 'duplicate'>; } +export const SYSTEM_MESSAGE_DELIVERY_COORDINATOR = Symbol('SYSTEM_MESSAGE_DELIVERY_COORDINATOR'); +export interface SystemMessageDeliveryCoordinator { notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise; requestDrain(): void; } + +export interface MessageSubscriptionListQuery { enabled?: boolean; name?: string; pageNo?: number; pageSize?: number; sourceKey?: string; } +export interface MessageSubscriptionInput { enabled: boolean; name: string; remark?: string; sourceConfig: Record; sourceKey: string; } +export interface MessageTemplateListQuery { enabled?: boolean; name?: string; pageNo?: number; pageSize?: number; sourceKey?: string; } +export interface MessageTemplateInput { content: string; enabled: boolean; name: string; remark?: string; sourceKey: string; } +export interface QqbotMessagePublishTargetInput { targetId: string; targetName?: string; targetType: QqbotMessagePushTargetType; } +export interface QqbotMessagePublishBindingInput { enabled: boolean; subscriptionId: string; targets: QqbotMessagePublishTargetInput[]; templateId: string; } +export interface QqbotMessagePushTargetOption { label: string; targetId: string; targetType: QqbotMessagePushTargetType; } +export interface QqbotMessagePushTargetOptionsResponse { available: boolean; options: QqbotMessagePushTargetOption[]; reasonCode: null | string; } +export interface StrictPlainTextSendInput { attemptNumber: number; deliveryId: string; message: string; selfId: string; targetId: string; targetType: QqbotMessagePushTargetType; } +export interface QqbotSendAttemptErrorOptions { code: string; message: string; retryable: boolean; sendLogId: null | string; } + +export class SystemMessageContractError extends Error { + /** Creates a stable, non-sensitive domain contract error. */ + constructor(public readonly code: string) { super(code); this.name = 'SystemMessageContractError'; } +} + +export const QQBOT_MESSAGE_PUSH_TABLE_CONTRACT = { + binding: ['id', 'subscription_id', 'account_id', 'self_id', 'template_id', 'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time'], + delivery: ['id', 'message_event_id', 'publish_target_id', 'binding_id', 'subscription_id', 'self_id', 'target_type', 'target_id', 'template_id', 'template_content', 'variable_snapshot', 'rendered_message', 'status', 'attempt_count', 'next_attempt_at', 'processing_lease_until', 'send_log_id', 'last_error_code', 'last_error_message', 'expires_at', 'create_time', 'update_time'], + event: ['id', 'event_id', 'source_key', 'resource_key', 'occurred_at', 'payload', 'fanout_status', 'fanout_attempt_count', 'next_fanout_at', 'fanout_lease_until', 'last_error_code', 'last_error_message', 'create_time', 'update_time'], + subscription: ['id', 'name', 'source_key', 'source_config', 'source_config_digest', 'active_key', 'enabled', 'remark', 'is_deleted', 'create_time', 'update_time'], + target: ['id', 'binding_id', 'target_type', 'target_id', 'target_name', 'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time'], + template: ['id', 'name', 'source_key', 'content', 'enabled', 'remark', 'is_deleted', 'create_time', 'update_time'], +} as const; diff --git a/src/modules/qqbot/core/contract/qqbot-core.contract.ts b/src/modules/qqbot/core/contract/qqbot-core.contract.ts index 62ed557..09fbfc8 100644 --- a/src/modules/qqbot/core/contract/qqbot-core.contract.ts +++ b/src/modules/qqbot/core/contract/qqbot-core.contract.ts @@ -12,6 +12,12 @@ export const QQBOT_CORE_DOMAIN_CONTRACT = { 'qqbot_send_task', 'qqbot_send_log', 'qqbot_dedupe_event', + 'qqbot_message_subscription', + 'qqbot_message_template', + 'qqbot_message_publish_binding', + 'qqbot_message_publish_target', + 'qqbot_message_event', + 'qqbot_message_delivery', ], status: { accountTable: 'qqbot_account', diff --git a/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity.ts b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity.ts new file mode 100644 index 0000000..db21d76 --- /dev/null +++ b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity.ts @@ -0,0 +1,37 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, KtDateTimeColumn, KtUpdateDateColumn } from '@/common'; +import type { QqbotMessagePushTargetType, SystemMessageDeliveryStatus, SystemMessageScalar } from '../../../contract/message-push/qqbot-message-push.types'; + +@Entity('qqbot_message_delivery') +@Index('uk_qqbot_message_delivery_event_target', ['messageEventId', 'publishTargetId'], { unique: true }) +@Index('idx_qqbot_message_delivery_dispatch', ['status', 'nextAttemptAt']) +@Index('idx_qqbot_message_delivery_lease', ['processingLeaseUntil']) +@Index('idx_qqbot_message_delivery_history', ['subscriptionId', 'messageEventId']) +export class QqbotMessageDelivery { + @PrimaryColumn({ type: 'bigint' }) id: string; + @Column({ name: 'message_event_id', type: 'bigint' }) messageEventId: string; + @Column({ name: 'publish_target_id', type: 'bigint' }) publishTargetId: string; + @Column({ name: 'binding_id', type: 'bigint' }) bindingId: string; + @Column({ name: 'subscription_id', type: 'bigint' }) subscriptionId: string; + @Column({ length: 64, name: 'self_id' }) selfId: string; + @Column({ length: 16, name: 'target_type' }) targetType: QqbotMessagePushTargetType; + @Column({ length: 64, name: 'target_id' }) targetId: string; + @Column({ name: 'template_id', type: 'bigint' }) templateId: string; + @Column({ name: 'template_content', type: 'text' }) templateContent: string; + @Column({ name: 'variable_snapshot', type: 'json' }) variableSnapshot: Record; + @Column({ name: 'rendered_message', type: 'text' }) renderedMessage: string; + @Column({ length: 32 }) status: SystemMessageDeliveryStatus; + @Column({ default: 0, name: 'attempt_count', type: 'int', unsigned: true }) attemptCount: number; + @KtDateTimeColumn({ name: 'next_attempt_at', nullable: true, precision: 6 }) nextAttemptAt: KtDateTime | null; + @KtDateTimeColumn({ name: 'processing_lease_until', nullable: true, precision: 6 }) processingLeaseUntil: KtDateTime | null; + @Column({ name: 'send_log_id', nullable: true, type: 'bigint' }) sendLogId: null | string; + @Column({ length: 64, name: 'last_error_code', nullable: true }) lastErrorCode: null | string; + @Column({ length: 500, name: 'last_error_message', nullable: true }) lastErrorMessage: null | string; + @KtDateTimeColumn({ name: 'expires_at', precision: 6 }) expiresAt: KtDateTime; + @KtCreateDateColumn({ name: 'create_time', precision: 6 }) createTime: KtDateTime; + @KtUpdateDateColumn({ name: 'update_time', precision: 6 }) updateTime: KtDateTime; + + /** Assigns the Snowflake primary key before this delivery task is persisted. */ + @BeforeInsert() + createId() { ensureSnowflakeId(this); } +} diff --git a/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity.ts b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity.ts new file mode 100644 index 0000000..823ec92 --- /dev/null +++ b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity.ts @@ -0,0 +1,28 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, KtDateTimeColumn, KtUpdateDateColumn } from '@/common'; +import type { SystemMessageFanoutStatus, SystemMessageScalar } from '../../../contract/message-push/qqbot-message-push.types'; + +@Entity('qqbot_message_event') +@Index('uk_qqbot_message_event_event_id', ['eventId'], { unique: true }) +@Index('idx_qqbot_message_event_dispatch', ['fanoutStatus', 'nextFanoutAt']) +@Index('idx_qqbot_message_event_lease', ['fanoutLeaseUntil']) +export class QqbotMessageEvent { + @PrimaryColumn({ type: 'bigint' }) id: string; + @Column({ length: 128, name: 'event_id' }) eventId: string; + @Column({ length: 128, name: 'source_key' }) sourceKey: string; + @Column({ length: 128, name: 'resource_key' }) resourceKey: string; + @KtDateTimeColumn({ name: 'occurred_at', precision: 6 }) occurredAt: KtDateTime; + @Column({ type: 'json' }) payload: Record; + @Column({ default: 'accepted', length: 32, name: 'fanout_status' }) fanoutStatus: SystemMessageFanoutStatus; + @Column({ default: 0, name: 'fanout_attempt_count', type: 'int', unsigned: true }) fanoutAttemptCount: number; + @KtDateTimeColumn({ name: 'next_fanout_at', nullable: true, precision: 6 }) nextFanoutAt: KtDateTime | null; + @KtDateTimeColumn({ name: 'fanout_lease_until', nullable: true, precision: 6 }) fanoutLeaseUntil: KtDateTime | null; + @Column({ length: 64, name: 'last_error_code', nullable: true }) lastErrorCode: null | string; + @Column({ length: 500, name: 'last_error_message', nullable: true }) lastErrorMessage: null | string; + @KtCreateDateColumn({ name: 'create_time', precision: 6 }) createTime: KtDateTime; + @KtUpdateDateColumn({ name: 'update_time', precision: 6 }) updateTime: KtDateTime; + + /** Assigns the Snowflake primary key before this outbox event is persisted. */ + @BeforeInsert() + createId() { ensureSnowflakeId(this); } +} diff --git a/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity.ts b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity.ts new file mode 100644 index 0000000..6b83144 --- /dev/null +++ b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity.ts @@ -0,0 +1,21 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, KtUpdateDateColumn } from '@/common'; + +@Entity('qqbot_message_publish_binding') +@Index('uk_qqbot_message_publish_binding_active_key', ['activeKey'], { unique: true }) +export class QqbotMessagePublishBinding { + @PrimaryColumn({ type: 'bigint' }) id: string; + @Column({ name: 'subscription_id', type: 'bigint' }) subscriptionId: string; + @Column({ name: 'account_id', type: 'bigint' }) accountId: string; + @Column({ length: 64, name: 'self_id' }) selfId: string; + @Column({ name: 'template_id', type: 'bigint' }) templateId: string; + @Column({ length: 255, name: 'active_key', nullable: true }) activeKey: null | string; + @Column({ default: true }) enabled: boolean; + @Column({ default: false, name: 'is_deleted' }) isDeleted: boolean; + @KtCreateDateColumn({ name: 'create_time', precision: 6 }) createTime: KtDateTime; + @KtUpdateDateColumn({ name: 'update_time', precision: 6 }) updateTime: KtDateTime; + + /** Assigns the Snowflake primary key before this account binding is persisted. */ + @BeforeInsert() + createId() { ensureSnowflakeId(this); } +} diff --git a/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity.ts b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity.ts new file mode 100644 index 0000000..367c853 --- /dev/null +++ b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity.ts @@ -0,0 +1,22 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, KtUpdateDateColumn } from '@/common'; +import type { QqbotMessagePushTargetType } from '../../../contract/message-push/qqbot-message-push.types'; + +@Entity('qqbot_message_publish_target') +@Index('uk_qqbot_message_publish_target_active_key', ['activeKey'], { unique: true }) +export class QqbotMessagePublishTarget { + @PrimaryColumn({ type: 'bigint' }) id: string; + @Column({ name: 'binding_id', type: 'bigint' }) bindingId: string; + @Column({ length: 16, name: 'target_type' }) targetType: QqbotMessagePushTargetType; + @Column({ length: 64, name: 'target_id' }) targetId: string; + @Column({ length: 120, name: 'target_name', nullable: true }) targetName: null | string; + @Column({ length: 300, name: 'active_key', nullable: true }) activeKey: null | string; + @Column({ default: true }) enabled: boolean; + @Column({ default: false, name: 'is_deleted' }) isDeleted: boolean; + @KtCreateDateColumn({ name: 'create_time', precision: 6 }) createTime: KtDateTime; + @KtUpdateDateColumn({ name: 'update_time', precision: 6 }) updateTime: KtDateTime; + + /** Assigns the Snowflake primary key before this publish target is persisted. */ + @BeforeInsert() + createId() { ensureSnowflakeId(this); } +} diff --git a/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity.ts b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity.ts new file mode 100644 index 0000000..6caaf6a --- /dev/null +++ b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity.ts @@ -0,0 +1,22 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, KtUpdateDateColumn } from '@/common'; + +@Entity('qqbot_message_subscription') +@Index('uk_qqbot_message_subscription_active_key', ['activeKey'], { unique: true }) +export class QqbotMessageSubscription { + @PrimaryColumn({ type: 'bigint' }) id: string; + @Column({ length: 100 }) name: string; + @Column({ length: 128, name: 'source_key' }) sourceKey: string; + @Column({ name: 'source_config', type: 'json' }) sourceConfig: Record; + @Column({ length: 64, name: 'source_config_digest', type: 'char' }) sourceConfigDigest: string; + @Column({ length: 255, name: 'active_key', nullable: true }) activeKey: null | string; + @Column({ default: true }) enabled: boolean; + @Column({ length: 500, nullable: true }) remark: null | string; + @Column({ default: false, name: 'is_deleted' }) isDeleted: boolean; + @KtCreateDateColumn({ name: 'create_time', precision: 6 }) createTime: KtDateTime; + @KtUpdateDateColumn({ name: 'update_time', precision: 6 }) updateTime: KtDateTime; + + /** Assigns the Snowflake primary key before this subscription is persisted. */ + @BeforeInsert() + createId() { ensureSnowflakeId(this); } +} diff --git a/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity.ts b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity.ts new file mode 100644 index 0000000..e85a10d --- /dev/null +++ b/src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity.ts @@ -0,0 +1,19 @@ +import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm'; +import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, KtUpdateDateColumn } from '@/common'; + +@Entity('qqbot_message_template') +export class QqbotMessageTemplate { + @PrimaryColumn({ type: 'bigint' }) id: string; + @Column({ length: 100 }) name: string; + @Column({ length: 128, name: 'source_key' }) sourceKey: string; + @Column({ type: 'text' }) content: string; + @Column({ default: true }) enabled: boolean; + @Column({ length: 500, nullable: true }) remark: null | string; + @Column({ default: false, name: 'is_deleted' }) isDeleted: boolean; + @KtCreateDateColumn({ name: 'create_time', precision: 6 }) createTime: KtDateTime; + @KtUpdateDateColumn({ name: 'update_time', precision: 6 }) updateTime: KtDateTime; + + /** Assigns the Snowflake primary key before this template is persisted. */ + @BeforeInsert() + createId() { ensureSnowflakeId(this); } +} diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index 1fbfa1f..22d620d 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -41,6 +41,12 @@ import { QqbotRateLimitService } from '@/modules/qqbot/core/application/send/qqb import { QqbotSendController } from '@/modules/qqbot/core/contract/send/qqbot-send.controller'; import { QqbotSendLog } from '@/modules/qqbot/core/infrastructure/persistence/send/qqbot-send-log.entity'; import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.service'; +import { QqbotMessageDelivery } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity'; +import { QqbotMessageEvent } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity'; +import { QqbotMessagePublishBinding } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity'; +import { QqbotMessagePublishTarget } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity'; +import { QqbotMessageSubscription } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity'; +import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity'; export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract'; @@ -55,6 +61,12 @@ export const QQBOT_CORE_ENTITIES = [ QqbotConversation, QqbotDedupe, QqbotMessage, + QqbotMessageDelivery, + QqbotMessageEvent, + QqbotMessagePublishBinding, + QqbotMessagePublishTarget, + QqbotMessageSubscription, + QqbotMessageTemplate, QqbotRule, QqbotSendLog, ]; diff --git a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts index b22ad4c..5604cd1 100644 --- a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts @@ -222,6 +222,12 @@ describe('QQBot core module contract', () => { 'QqbotConversation', 'QqbotDedupe', 'QqbotMessage', + 'QqbotMessageSubscription', + 'QqbotMessageTemplate', + 'QqbotMessagePublishBinding', + 'QqbotMessagePublishTarget', + 'QqbotMessageEvent', + 'QqbotMessageDelivery', 'QqbotRule', 'QqbotSendLog', ]), diff --git a/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts new file mode 100644 index 0000000..c09b4ad --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts @@ -0,0 +1,83 @@ +import { getMetadataArgsStorage } from 'typeorm'; +import { + QQBOT_CORE_ENTITIES, +} from '../../../../src/modules/qqbot/core/qqbot-core.module'; +import { QqbotMessageDelivery } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity'; +import { QqbotMessageEvent } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity'; +import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity'; +import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity'; +import { QqbotMessageSubscription } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity'; +import { QqbotMessageTemplate } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity'; + +const messagePushEntities = [ + QqbotMessageSubscription, + QqbotMessageTemplate, + QqbotMessagePublishBinding, + QqbotMessagePublishTarget, + QqbotMessageEvent, + QqbotMessageDelivery, +]; + +const expectedColumns = { + qqbot_message_delivery: [ + 'id', 'message_event_id', 'publish_target_id', 'binding_id', + 'subscription_id', 'self_id', 'target_type', 'target_id', 'template_id', + 'template_content', 'variable_snapshot', 'rendered_message', 'status', + 'attempt_count', 'next_attempt_at', 'processing_lease_until', + 'send_log_id', 'last_error_code', 'last_error_message', 'expires_at', + 'create_time', 'update_time', + ], + qqbot_message_event: [ + 'id', 'event_id', 'source_key', 'resource_key', 'occurred_at', 'payload', + 'fanout_status', 'fanout_attempt_count', 'next_fanout_at', + 'fanout_lease_until', 'last_error_code', 'last_error_message', + 'create_time', 'update_time', + ], + qqbot_message_publish_binding: [ + 'id', 'subscription_id', 'account_id', 'self_id', 'template_id', + 'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time', + ], + qqbot_message_publish_target: [ + 'id', 'binding_id', 'target_type', 'target_id', 'target_name', + 'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time', + ], + qqbot_message_subscription: [ + 'id', 'name', 'source_key', 'source_config', 'source_config_digest', + 'active_key', 'enabled', 'remark', 'is_deleted', 'create_time', + 'update_time', + ], + qqbot_message_template: [ + 'id', 'name', 'source_key', 'content', 'enabled', 'remark', + 'is_deleted', 'create_time', 'update_time', + ], +} as const; + +/** Reads the table name declared by a TypeORM entity. */ +const tableNameOf = (entity: object) => + getMetadataArgsStorage().tables.find((table) => table.target === entity) + ?.name; + +/** Reads the database column names declared by a TypeORM entity. */ +const columnNamesOf = (entity: object) => + getMetadataArgsStorage() + .columns.filter((column) => column.target === entity) + .map((column) => `${column.options.name || column.propertyName}`); + +describe('QQBot message-push persistence contract', () => { + it('registers the six message-push entities in QQBot Core', () => { + expect(QQBOT_CORE_ENTITIES).toEqual( + expect.arrayContaining(messagePushEntities), + ); + }); + + it('maps every required message-push column with string Snowflake IDs', () => { + for (const entity of messagePushEntities) { + const tableName = tableNameOf(entity); + + expect(tableName).toBeTruthy(); + expect(columnNamesOf(entity)).toEqual( + expect.arrayContaining([...expectedColumns[tableName as keyof typeof expectedColumns]]), + ); + } + }); +}); 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 new file mode 100644 index 0000000..3150ec2 --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts @@ -0,0 +1,97 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const tables = [ + 'qqbot_message_subscription', + 'qqbot_message_template', + 'qqbot_message_publish_binding', + 'qqbot_message_publish_target', + 'qqbot_message_event', + 'qqbot_message_delivery', +] as const; + +const menuEntries = [ + ['2041700000000100413', 'QqBotMessageSubscription', 'QqBot:MessageSubscription:List'], + ['2041700000000100414', 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List'], + ['2041700000000120461', 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List'], + ['2041700000000120462', 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create'], + ['2041700000000120463', 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update'], + ['2041700000000120464', 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete'], + ['2041700000000120465', 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle'], + ['2041700000000120471', 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List'], + ['2041700000000120472', 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create'], + ['2041700000000120473', 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update'], + ['2041700000000120474', 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete'], + ['2041700000000120475', 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle'], + ['2041700000000120476', 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview'], + ['2041700000000120481', 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List'], + ['2041700000000120482', 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create'], + ['2041700000000120483', 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update'], + ['2041700000000120484', 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete'], + ['2041700000000120485', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle'], +] as const; + +/** Reads SQL with case and quote differences removed for contract assertions. */ +const readNormalizedSql = (relativePath: string) => + readFileSync(resolve(process.cwd(), relativePath), 'utf8') + .toLowerCase() + .replace(/`/g, '') + .replace(/\s+/g, ' '); + +describe('QQBot message-push SQL contract', () => { + const bootstrapSql = readNormalizedSql('sql/qqbot-init.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'); + const vbenSql = readNormalizedSql('sql/vben-admin-init.sql'); + + it.each(tables)('declares %s in current and bootstrap schema SQL', (table) => { + expect(bootstrapSql).toContain(`create table if not exists ${table}`); + expect(schemaSql).toContain(`create table if not exists ${table}`); + }); + + it('keeps six-table indexes, datetime precision, and JSON fields aligned', () => { + for (const sql of [bootstrapSql, schemaSql]) { + expect(sql).toContain('uk_qqbot_message_subscription_active_key'); + expect(sql).toContain('uk_qqbot_message_publish_binding_active_key'); + expect(sql).toContain('uk_qqbot_message_publish_target_active_key'); + expect(sql).toContain('uk_qqbot_message_event_event_id'); + expect(sql).toContain('uk_qqbot_message_delivery_event_target'); + expect(sql).toContain('idx_qqbot_message_event_dispatch'); + expect(sql).toContain('idx_qqbot_message_event_lease'); + expect(sql).toContain('idx_qqbot_message_delivery_dispatch'); + expect(sql).toContain('idx_qqbot_message_delivery_lease'); + expect(sql).toContain('idx_qqbot_message_delivery_history'); + expect(sql).toContain('source_config json'); + expect(sql).toContain('payload json'); + expect(sql).toContain('variable_snapshot json'); + expect(sql).toContain('datetime(6)'); + } + }); + + it('seeds the default template and every stable menu node idempotently', () => { + for (const sql of [bootstrapSql, seedSql]) { + expect(sql).toContain('2041700000000200601'); + expect(sql).toContain('network.stun.mapping-port-changed'); + expect(sql).toContain('当前stun的端口已变更为${{endpoint}}'); + expect(sql).toContain('where not exists'); + } + + for (const [id, name, authCode] of menuEntries) { + for (const sql of [bootstrapSql, seedSql, vbenSql]) { + expect(sql).toContain(id); + expect(sql).toContain(name.toLowerCase()); + expect(sql).toContain(authCode.toLowerCase()); + } + } + }); + + it('verifies every persisted message-push table and seeded menu contract', () => { + for (const table of tables) { + expect(verifySql).toContain(table); + } + expect(verifySql).toContain('2041700000000200601'); + expect(verifySql).toContain('qqbot:messagesubscription:list'); + expect(verifySql).toContain('qqbot:account:messagepush:toggle'); + }); +});