From 1c35fc9dbd245ca835fc0dc3d2e5a6e313a7c089 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 27 Jul 2026 15:00:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0TCP=20NATMap=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E6=B6=88=E6=81=AF=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 4 +- README.md | 4 +- sql/network-management-menu.sql | 5 +- sql/qqbot-init.sql | 7 ++ sql/qqbot-message-push-init.sql | 17 +++ sql/qqbot-message-push-verify.sql | 9 ++ sql/refactor-v3/01-seed-core.sql | 11 ++ sql/refactor-v3/99-verify.sql | 116 ++++++++++++++++++ sql/vben-admin-init.sql | 2 + .../admin/network-management-menu-sql.spec.ts | 75 ++++++++++- .../qqbot-message-push-sql.spec.ts | 75 +++++++++++ 11 files changed, 318 insertions(+), 7 deletions(-) diff --git a/API.md b/API.md index 082eff5..561869e 100644 --- a/API.md +++ b/API.md @@ -473,6 +473,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT TCP NATMap 独立消息源为 `network.tcp.natmap-endpoint-changed`、版本 `1`,订阅配置精确为 `{ tcpChannelId, ddnsRecordId }`,两个字段都必须是字符串 Snowflake ID,且 DDNS 必须是启用、未删除、绑定同一 TCP 通道的 A 记录。变量精确为 `endpoint`、`fqdn`、`publicIpv4`、`publicPort`、`previousPublicIpv4`、`previousPublicPort`、`portForwardName`;`endpoint` 使用事件端口冻结为 `FQDN:newPort`。DDNS 尚未同步到事件 IPv4 时为 `waiting_ddns`,当前 tuple 已变化或租约过期为 `superseded`,通道撤下、删除、停用 NATMap 或 DDNS 改绑为 `cancelled`。现有 STUN 来源定义与变量保持不变。 +初始化与增量 SQL 使用稳定 ID `2041700000000200602` 幂等创建 `TCP NATMap 端点变更默认模板`,正文固定为 `当前 TCP NATMap 端点已变更为 ${{endpoint}}`;既有 STUN 模板 ID、来源键和正文不得修改。TCP NATMap 独立操作权限为 `System:Network:PortForward:Natmap`,只授予活动 `super` 角色。 + #### 内部事件与投递生命周期 - UDP 只有一个既有有效端口直接变化为另一个有效端口的 `changed` 事件,才写既有 STUN Outbox;TCP 则在既有有效 tuple 的公网 IPv4 或端口任一变化时写独立 NATMap endpoint Outbox。首次 `published`、同 tuple 续期、`withdrawn`、同 tuple `restored`、重复事件和回滚事务都不产生 TCP 消息事件。事件使用生产者 `eventId` 幂等,matching report/event 的 history、current/baseline 与 Outbox 保持既有事务关联门禁,事务提交后才调用 `requestDrain()`。 @@ -482,7 +484,7 @@ TCP NATMap 独立消息源为 `network.tcp.natmap-endpoint-changed`、版本 `1` #### SQL、发布与回滚 -既有环境使用本功能幂等增量入口 `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 验收。 +既有环境使用本功能幂等增量入口 `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 个精确索引、STUN/TCP 默认模板与权限 → 先验证 API 再发布 Admin → 创建订阅和逐账号绑定 → 使用授权非生产目标完成有界 A→B 验收。 回滚先停用全部发布绑定,再回滚 Admin 和 API,并保留事件、投递及发送日志;Network Agent、端口转发、STUN Keeper 和 DDNS 继续运行。Jenkins/K8s 通过只证明版本已部署,不能替代真实 CRUD、页面或事件到消息的功能验收。每次发布必须分别记录生产 SQL、真实 API、页面、Outbox/DDNS 和授权 QQ 投递证据;没有明确授权的 QQ 群或 QQ 号时不得任选目标,并应把真实投递单独标记为未验证。 diff --git a/README.md b/README.md index 1e7f80f..db9b57e 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ v2 endpoint event 通常先于 matching reported 到达,因此 event-first 先 同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录可从合格 UDP Keeper 或 TCP NATMap 通道的有效公网 IPv4 取值,AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值;DNS 值始终不包含端口。A 来源以 `组名 / UDP Keeper` 或 `组名 / TCP NATMap` 展示;同步地址等于当前通道 IPv4 且当前公网端口有效时,管理响应额外返回 `accessEndpoint=FQDN:port`。仅端口变化只更新该派生端点,不调用腾讯云。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL,并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git;`DNSPOD` 是腾讯云官方 SDK 的技术服务名。 -QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;订阅表单字段、候选集合和资源匹配由所选系统消息源 adapter 声明,核心模块不写死 STUN 的 `portForwardId/ddnsRecordId` 或 TCP NATMap 的 `tcpChannelId/ddnsRecordId`。TCP 来源变量精确为 `endpoint/fqdn/publicIpv4/publicPort/previousPublicIpv4/previousPublicPort/portForwardName`,只有所选同通道 A 记录同步到事件 IPv4 后才能发送。账号接口严格使用路由 `selfId`,不会回退到其他机器人。群聊和私聊目标分别使用 `group` / `private`,Snowflake、QQ 账号和目标 ID 在 HTTP 与数据库边界始终保持字符串。系统事件只能通过内部 Outbox stager 暂存,不提供 publish/event/worker HTTP 路由;管理响应仅返回字段白名单,不暴露账号凭据、Provider/OneBot/MQTT 运行对象、原始事件载荷或内部持久化键。 +QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;订阅表单字段、候选集合和资源匹配由所选系统消息源 adapter 声明,核心模块不写死 STUN 的 `portForwardId/ddnsRecordId` 或 TCP NATMap 的 `tcpChannelId/ddnsRecordId`。TCP 来源变量精确为 `endpoint/fqdn/publicIpv4/publicPort/previousPublicIpv4/previousPublicPort/portForwardName`,只有所选同通道 A 记录同步到事件 IPv4 后才能发送;初始化与增量 SQL 幂等提供正文为 `当前 TCP NATMap 端点已变更为 ${{endpoint}}` 的默认模板,既有 STUN 模板保持不变。账号接口严格使用路由 `selfId`,不会回退到其他机器人。群聊和私聊目标分别使用 `group` / `private`,Snowflake、QQ 账号和目标 ID 在 HTTP 与数据库边界始终保持字符串。系统事件只能通过内部 Outbox stager 暂存,不提供 publish/event/worker HTTP 路由;管理响应仅返回字段白名单,不暴露账号凭据、Provider/OneBot/MQTT 运行对象、原始事件载荷或内部持久化键。 `qqbot_message_event` 同时承担事件 Outbox:扇出状态为 `accepted`、`processing`、`retry`、`completed`、`failed`;按目标冻结的 `qqbot_message_delivery` 状态为 `waiting_ddns`、`pending`、`processing`、`retry`、`success`、`failed`、`superseded`、`cancelled`。扇出和投递每次各领取最多 50 行,处理租约为 30 秒;进程启动后立即恢复,并每 5 秒扫描一次。`waiting_ddns` 每 60 秒持久化复检;临时错误从 10 秒开始指数退避,单次最长 15 分钟,并以事件发生后 24 小时为截止时间。 @@ -237,7 +237,7 @@ 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-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`。 +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`。发布前还需通过网络菜单 SQL 应用独立 `System:Network:PortForward:Natmap` 权限并确认只授予活动 `super`。 3. 验证六表、17 个精确索引、默认模板、18 个页面/按钮菜单和活动 `super/admin` 角色授权。 4. 先发布并验证 API 健康检查、旧 QQBot 发送能力和新只读接口,再发布并验证 Admin。 5. 管理员显式创建订阅,并在每个发布账号中选择模板、配置群聊/私聊目标和启用绑定;随后用授权的非生产目标做一次有界 A→B 端口变化、DDNS 门禁、发送日志和幂等验收。 diff --git a/sql/network-management-menu.sql b/sql/network-management-menu.sql index a663824..f9c6568 100644 --- a/sql/network-management-menu.sql +++ b/sql/network-management-menu.sql @@ -19,7 +19,8 @@ VALUES (2041700000000120223, 2041700000000100207, 'SystemNetworkDdnsCreate', NULL, NULL, NULL, 'System:Network:Ddns:Create', 'button', '{"title":"common.create"}', 1, 10), (2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11), (2041700000000120225, 2041700000000100207, 'SystemNetworkDdnsDelete', NULL, NULL, NULL, 'System:Network:Ddns:Delete', 'button', '{"title":"common.delete"}', 1, 12), - (2041700000000120226, 2041700000000100207, 'SystemNetworkDdnsRetry', NULL, NULL, NULL, 'System:Network:Ddns:Retry', 'button', '{"title":"common.retry"}', 1, 13) + (2041700000000120226, 2041700000000100207, 'SystemNetworkDdnsRetry', NULL, NULL, NULL, 'System:Network:Ddns:Retry', 'button', '{"title":"common.retry"}', 1, 13), + (2041700000000120227, 2041700000000100207, 'SystemNetworkPortForwardNatmap', NULL, NULL, NULL, 'System:Network:PortForward:Natmap', 'button', '{"title":"system.network.natmap"}', 1, 14) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `pid` = VALUES(`pid`), @@ -46,6 +47,7 @@ WHERE role.`role_code` <> 'super' 'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardNatmap', 'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardHistory', 'SystemNetworkDdnsList', @@ -66,6 +68,7 @@ JOIN `admin_menu` menu ON menu.`name` IN ( 'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardNatmap', 'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardHistory', 'SystemNetworkDdnsList', diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 716d54e..5cd47eb 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -1162,6 +1162,13 @@ WHERE NOT EXISTS ( WHERE `source_key` = 'network.stun.mapping-port-changed' AND `name` = 'STUN 映射端口变更默认模板' AND `is_deleted` = 0 ); +INSERT INTO `qqbot_message_template` (`id`, `name`, `source_key`, `content`, `enabled`, `remark`, `is_deleted`) +SELECT 2041700000000200602, 'TCP NATMap 端点变更默认模板', 'network.tcp.natmap-endpoint-changed', '当前 TCP NATMap 端点已变更为 ${{endpoint}}', 1, '系统默认模板', 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `qqbot_message_template` + WHERE `source_key` = 'network.tcp.natmap-endpoint-changed' AND `name` = 'TCP NATMap 端点变更默认模板' 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), diff --git a/sql/qqbot-message-push-init.sql b/sql/qqbot-message-push-init.sql index 0a8b31f..d97fb3a 100644 --- a/sql/qqbot-message-push-init.sql +++ b/sql/qqbot-message-push-init.sql @@ -123,6 +123,23 @@ WHERE NOT EXISTS ( AND is_deleted = 0 ); +INSERT INTO qqbot_message_template (id, name, source_key, content, enabled, remark, is_deleted) +SELECT + 2041700000000200602, + 'TCP NATMap 端点变更默认模板', + 'network.tcp.natmap-endpoint-changed', + '当前 TCP NATMap 端点已变更为 ${{endpoint}}', + 1, + '系统默认模板', + 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM qqbot_message_template + WHERE source_key = 'network.tcp.natmap-endpoint-changed' + AND name = 'TCP NATMap 端点变更默认模板' + AND is_deleted = 0 +); + INSERT INTO admin_menu ( id, pid, diff --git a/sql/qqbot-message-push-verify.sql b/sql/qqbot-message-push-verify.sql index 70eb75f..3125ff2 100644 --- a/sql/qqbot-message-push-verify.sql +++ b/sql/qqbot-message-push-verify.sql @@ -97,6 +97,15 @@ WHERE id = 2041700000000200601 AND enabled = 1 AND is_deleted = 0; +SELECT 'seed_qqbot_tcp_natmap_message_template' AS check_name, COUNT(*) AS matched_rows +FROM qqbot_message_template +WHERE id = 2041700000000200602 + AND BINARY name = BINARY 'TCP NATMap 端点变更默认模板' + AND BINARY source_key = BINARY 'network.tcp.natmap-endpoint-changed' + AND BINARY content = BINARY '当前 TCP NATMap 端点已变更为 ${{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 ( diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index 44b41e8..899e56b 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -150,6 +150,7 @@ INSERT INTO admin_menu ( (2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11), (2041700000000120225, 2041700000000100207, 'SystemNetworkDdnsDelete', NULL, NULL, NULL, 'System:Network:Ddns:Delete', 'button', '{"title":"common.delete"}', 1, 12), (2041700000000120226, 2041700000000100207, 'SystemNetworkDdnsRetry', NULL, NULL, NULL, 'System:Network:Ddns:Retry', 'button', '{"title":"common.retry"}', 1, 13), + (2041700000000120227, 2041700000000100207, 'SystemNetworkPortForwardNatmap', NULL, NULL, NULL, 'System:Network:PortForward:Natmap', 'button', '{"title":"system.network.natmap"}', 1, 14), ( 2041700000000100400, 0, @@ -220,6 +221,7 @@ WHERE role.role_code <> 'super' 'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardNatmap', 'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardHistory', 'SystemNetworkDdnsList', @@ -640,6 +642,15 @@ WHERE NOT EXISTS ( AND is_deleted = 0 ); +INSERT INTO qqbot_message_template (id, name, source_key, content, enabled, remark, is_deleted) +SELECT 2041700000000200602, 'TCP NATMap 端点变更默认模板', 'network.tcp.natmap-endpoint-changed', '当前 TCP NATMap 端点已变更为 ${{endpoint}}', 1, '系统默认模板', 0 +WHERE NOT EXISTS ( + SELECT 1 FROM qqbot_message_template + WHERE source_key = 'network.tcp.natmap-endpoint-changed' + AND name = 'TCP NATMap 端点变更默认模板' + 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), diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index 0617aee..e08de37 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -2,6 +2,7 @@ SELECT 'admin_user' AS table_name, COUNT(*) AS row_count FROM admin_user; SELECT 'admin_role' AS table_name, COUNT(*) AS row_count FROM admin_role; SELECT 'admin_menu' AS table_name, COUNT(*) AS row_count FROM admin_menu; SELECT 'network_port_forward' AS table_name, COUNT(*) AS row_count FROM network_port_forward; +SELECT 'network_port_forward_group' AS table_name, COUNT(*) AS row_count FROM network_port_forward_group; SELECT 'network_ddns_record' AS table_name, COUNT(*) AS row_count FROM network_ddns_record; SELECT 'network_agent_state' AS table_name, COUNT(*) AS row_count FROM network_agent_state; SELECT 'network_endpoint_history' AS table_name, COUNT(*) AS row_count FROM network_endpoint_history; @@ -38,6 +39,15 @@ WHERE id = 2041700000000200601 AND enabled = 1 AND is_deleted = 0; +SELECT 'seed_qqbot_tcp_natmap_message_template' AS check_name, COUNT(*) AS matched_rows +FROM qqbot_message_template +WHERE id = 2041700000000200602 + AND BINARY name = BINARY 'TCP NATMap 端点变更默认模板' + AND BINARY source_key = BINARY 'network.tcp.natmap-endpoint-changed' + AND BINARY content = BINARY '当前 TCP NATMap 端点已变更为 ${{endpoint}}' + AND enabled = 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 @@ -148,6 +158,26 @@ WHERE table_schema = DATABASE() AND table_name = 'network_port_forward' AND index_name = 'uk_network_port_forward_active_key'; +SELECT 'index_network_port_forward_active_group_protocol_key' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'network_port_forward' + AND index_name = 'uk_network_port_forward_active_group_protocol_key' + AND non_unique = 0; + +SELECT 'network_port_forward_active_group_protocol_key_conflicts' AS check_name, COUNT(*) AS invalid_rows +FROM ( + SELECT active_group_protocol_key + FROM network_port_forward + WHERE active_group_protocol_key IS NOT NULL + GROUP BY active_group_protocol_key + HAVING COUNT(*) > 1 +) conflicts; + +SELECT 'network_endpoint_history_mechanism_values' AS check_name, COUNT(*) AS invalid_rows +FROM network_endpoint_history +WHERE mechanism NOT IN ('udp_stun', 'tcp_natmap'); + SELECT 'index_network_ddns_record_active_key' AS check_name, COUNT(*) AS matched_rows FROM information_schema.statistics WHERE table_schema = DATABASE() @@ -271,6 +301,92 @@ WHERE role.role_code <> 'super' 'System:Network:Ddns:Retry' ); +SELECT 'seed_network_natmap_permission' AS check_name, COUNT(*) AS matched_rows +FROM admin_menu +WHERE id = 2041700000000120227 + AND BINARY name = BINARY 'SystemNetworkPortForwardNatmap' + AND BINARY auth_code = BINARY 'System:Network:PortForward:Natmap' + AND status = 1 + AND is_deleted = 0; + +SELECT 'seed_network_natmap_super_permission' AS check_name, COUNT(*) AS matched_rows +FROM admin_role_menu role_menu +JOIN admin_role role ON role.id = role_menu.role_id +JOIN admin_menu menu ON menu.id = role_menu.menu_id +WHERE role.role_code = 'super' + AND role.status = 1 + AND role.is_deleted = 0 + AND menu.id = 2041700000000120227 + AND BINARY menu.name = BINARY 'SystemNetworkPortForwardNatmap' + AND BINARY menu.auth_code = BINARY 'System:Network:PortForward:Natmap' + AND menu.status = 1 + AND menu.is_deleted = 0; + +SELECT 'network_natmap_non_super_permissions_should_be_zero' AS check_name, COUNT(*) AS invalid_rows +FROM admin_role_menu role_menu +JOIN admin_role role ON role.id = role_menu.role_id +JOIN admin_menu menu ON menu.id = role_menu.menu_id +WHERE role.role_code <> 'super' + AND role.is_deleted = 0 + AND menu.id = 2041700000000120227 + AND menu.is_deleted = 0; + +SELECT + 'network_8213_udp_channel_state' AS check_name, + channel.id, + channel.group_id, + channel.name, + channel.external_port, + channel.internal_port, + channel.protocol, + channel.target_ipv4, + channel.desired_presence, + channel.keeper_desired_enabled, + channel.keeper_status, + channel.desired_revision, + channel.reported_revision, + channel.current_public_ipv4, + channel.current_public_port, + channel.last_observed_ipv4, + channel.last_observed_port, + channel.is_deleted, + forwarding.protocol_mode AS group_protocol_mode, + forwarding.external_port AS group_external_port, + forwarding.internal_port AS group_internal_port, + forwarding.target_ipv4 AS group_target_ipv4, + forwarding.is_deleted AS group_is_deleted +FROM network_port_forward channel +JOIN network_port_forward_group forwarding ON forwarding.id = channel.group_id +WHERE channel.external_port = 8213 + AND channel.protocol = 'udp'; + +SELECT + 'network_8213_udp_ddns_state' AS check_name, + ddns.id, + ddns.port_forward_id, + ddns.name, + ddns.record_type, + ddns.source_type, + ddns.domain, + ddns.sub_domain, + ddns.enabled, + ddns.sync_status, + ddns.provider_record_id, + ddns.source_address, + ddns.applied_address, + ddns.retry_count, + ddns.next_retry_at, + ddns.last_attempt_at, + ddns.last_synced_at, + ddns.last_error_code, + ddns.last_error_message, + ddns.is_deleted +FROM network_ddns_record ddns +JOIN network_port_forward channel ON ddns.port_forward_id = channel.id +WHERE ddns.source_type = 'port_forward_ipv4' + AND channel.external_port = 8213 + AND channel.protocol = 'udp'; + SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows FROM information_schema.statistics WHERE table_schema = DATABASE() diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index e2a56fd..0532c4e 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -253,6 +253,7 @@ VALUES (2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11), (2041700000000120225, 2041700000000100207, 'SystemNetworkDdnsDelete', NULL, NULL, NULL, 'System:Network:Ddns:Delete', 'button', '{"title":"common.delete"}', 1, 12), (2041700000000120226, 2041700000000100207, 'SystemNetworkDdnsRetry', NULL, NULL, NULL, 'System:Network:Ddns:Retry', 'button', '{"title":"common.retry"}', 1, 13), + (2041700000000120227, 2041700000000100207, 'SystemNetworkPortForwardNatmap', NULL, NULL, NULL, 'System:Network:PortForward:Natmap', 'button', '{"title":"system.network.natmap"}', 1, 14), (2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100), (2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0), (2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0), @@ -379,6 +380,7 @@ WHERE `is_deleted` = 0 'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardNatmap', 'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardHistory', 'SystemNetworkDdnsList', diff --git a/test/admin/network-management-menu-sql.spec.ts b/test/admin/network-management-menu-sql.spec.ts index 264be8d..df2c56b 100644 --- a/test/admin/network-management-menu-sql.spec.ts +++ b/test/admin/network-management-menu-sql.spec.ts @@ -21,16 +21,18 @@ describe('System network-management SQL', () => { 'Delete', 'Retry', 'Keeper', + 'Natmap', 'Probe', 'History', ]; - for (const sql of [menuSql, initSql]) { + for (const sql of [menuSql, initSql, canonicalSeed]) { expect(sql).toContain("'SystemNetwork'"); expect(sql).toContain("'/system/network/list'"); for (const action of actions) { expect(sql).toContain(`'SystemNetworkPortForward${action}'`); expect(sql).toContain(`'System:Network:PortForward:${action}'`); } + expect(sql.match(/2041700000000120227/g)).toHaveLength(1); expect(sql).not.toMatch(/PortForward(Check|Ensure)/); } }); @@ -39,6 +41,13 @@ describe('System network-management SQL', () => { expect(menuSql).toContain("role.`role_code` <> 'super'"); expect(menuSql).toContain("role.`role_code` = 'super'"); expect(menuSql).toContain('ON DUPLICATE KEY UPDATE'); + expect(initSql).toContain('SELECT 2041700000000010001, `id`'); + expect(initSql).toMatch( + /AND `name` NOT IN \([\s\S]*?'SystemNetworkPortForwardNatmap'/, + ); + expect(canonicalSeed).toMatch( + /role\.role_code <> 'super'[\s\S]*?SystemNetworkPortForwardNatmap[\s\S]*?SELECT 2041700000000010001, id/, + ); }); it('defines and verifies all three control-plane tables and singleton seed', () => { @@ -57,10 +66,70 @@ describe('System network-management SQL', () => { expect(canonicalSeed).toContain("'nas-main'"); }); + it('verifies group/channel integrity, NATMap permission, and the exact 8213 UDP state', () => { + for (const checkName of [ + 'network_port_forward_group', + 'network_port_forward_active_group_protocol_key_conflicts', + 'network_endpoint_history_mechanism_values', + 'seed_network_natmap_permission', + 'seed_network_natmap_super_permission', + 'network_natmap_non_super_permissions_should_be_zero', + 'network_8213_udp_channel_state', + 'network_8213_udp_ddns_state', + ]) { + expect(canonicalVerify).toContain(`'${checkName}'`); + } + expect(canonicalVerify).toContain( + "'uk_network_port_forward_active_group_protocol_key'", + ); + expect(canonicalVerify).toMatch( + /external_port\s*=\s*8213[\s\S]*?protocol\s*=\s*'udp'/, + ); + expect(canonicalVerify).toMatch( + /port_forward_id\s*=\s*channel\.id[\s\S]*?channel\.external_port\s*=\s*8213/, + ); + const ddnsDefinition = canonicalSchema.match( + /CREATE TABLE IF NOT EXISTS network_ddns_record \(([\s\S]*?)\) ENGINE=/, + )?.[1]; + expect(ddnsDefinition).toBeDefined(); + for (const column of [ + 'port_forward_id', + 'record_type', + 'source_type', + 'domain', + 'sub_domain', + 'enabled', + 'sync_status', + 'provider_record_id', + 'source_address', + 'applied_address', + 'retry_count', + 'next_retry_at', + 'last_attempt_at', + 'last_synced_at', + 'last_error_code', + 'last_error_message', + 'is_deleted', + ]) { + expect(ddnsDefinition).toMatch(new RegExp(`\\b${column}\\b`)); + expect(canonicalVerify).toContain(`ddns.${column}`); + } + }); + it('contains no router credential or Palworld-specific schema', () => { - const allSql = [menuSql, schemaSql, canonicalSchema].join('\n'); + const allSql = [ + menuSql, + initSql, + schemaSql, + canonicalSchema, + canonicalSeed, + canonicalVerify, + ].join('\n'); expect(allSql).not.toMatch( - /router_password|xiaomi_password|palworld|PDC_/i, + /router_password|xiaomi_password|palworld|PDC_|-----BEGIN (?:CERTIFICATE|PRIVATE KEY)-----/i, + ); + expect(allSql).not.toMatch( + /NETWORK_DDNS_DNSPOD_SECRET_(?:ID|KEY)\s*=\s*\S+/, ); }); }); diff --git a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts index 36ab2d9..f878ba7 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts @@ -405,6 +405,13 @@ const extractExpectedMenuIds = (statement: string) => [...extractExpectedMenuCte(statement).matchAll(/select (\d+)(?: as id)?/g)].map(([, id]) => id); describe('QQBot message-push SQL contract', () => { + const tcpSourceAdapter = readFileSync( + resolve( + process.cwd(), + 'src/modules/admin/platform-config/network-management/network-tcp-natmap-message-source.adapter.ts', + ), + 'utf8', + ); 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'); @@ -485,6 +492,33 @@ describe('QQBot message-push SQL contract', () => { for (const sql of [bootstrapSql, seedSql, migrationSql]) expect(sql).toContain(expectedTemplate); }); + it('adds the exact TCP NATMap template without changing the STUN template', () => { + const expectedTcpTemplate = normalizeSql(` + insert into qqbot_message_template (id, name, source_key, content, enabled, remark, is_deleted) + select 2041700000000200602, 'TCP NATMap 端点变更默认模板', + 'network.tcp.natmap-endpoint-changed', '当前 TCP NATMap 端点已变更为 \${{endpoint}}', 1, '系统默认模板', 0 + where not exists ( + select 1 from qqbot_message_template + where source_key = 'network.tcp.natmap-endpoint-changed' + and name = 'TCP NATMap 端点变更默认模板' and is_deleted = 0 + ); + `); + for (const sql of [bootstrapSql, seedSql, migrationSql]) { + expect(sql).toContain(expectedTcpTemplate); + expect(sql.match(/2041700000000200602/g)).toHaveLength(1); + expect(sql).toContain( + "'network.stun.mapping-port-changed', '当前STUN的端口已变更为${{endpoint}}'", + ); + } + }); + + it('keeps the TCP template source key aligned with source version 1', () => { + expect(tcpSourceAdapter).toMatch( + /const SOURCE_KEY = 'network\.tcp\.natmap-endpoint-changed';/, + ); + expect(tcpSourceAdapter).toMatch(/version:\s*1,/); + }); + it('verifies the default template stable values with case-sensitive text comparisons', () => { const defaultTemplateVerify = extractVerificationStatement(verifySql, 'seed_qqbot_message_template'); expect(defaultTemplateVerify).toBe(normalizeSql(` @@ -499,6 +533,25 @@ describe('QQBot message-push SQL contract', () => { `)); }); + it('verifies the TCP template stable values in current and migration summaries', () => { + for (const sql of [verifySql, migrationVerifySql]) { + const tcpTemplateVerify = extractVerificationStatement( + sql, + 'seed_qqbot_tcp_natmap_message_template', + ); + expect(tcpTemplateVerify).toBe(normalizeSql(` + select 'seed_qqbot_tcp_natmap_message_template' as check_name, count(*) as matched_rows + from qqbot_message_template + where id = 2041700000000200602 + and binary name = binary 'TCP NATMap 端点变更默认模板' + and binary source_key = binary 'network.tcp.natmap-endpoint-changed' + and binary content = binary '当前 TCP NATMap 端点已变更为 \${{endpoint}}' + and enabled = 1 + and is_deleted = 0; + `)); + } + }); + it('verifies every message-push table with its own complete row-count statement', () => { for (const table of Object.keys(messagePushTableDefinitions)) { const statement = extractVerificationStatement(verifySql, table); @@ -543,6 +596,24 @@ describe('QQBot message-push SQL contract', () => { expect(migrationSql).toContain('insert ignore into admin_role_menu'); }); + it('does not seed real accounts, targets, provider credentials, or certificates', () => { + const sql = [ + bootstrapSql, + migrationSql, + migrationVerifySql, + seedSql, + verifySql, + vbenSql, + ].join('\n'); + expect(sql).not.toMatch( + /insert into qqbot_message_(?:publish_binding|publish_target)\b/, + ); + expect(sql).not.toMatch(/NETWORK_DDNS_DNSPOD_SECRET_(?:ID|KEY)\s*=\s*\S+/); + expect(sql).not.toMatch( + /router_password|xiaomi_password|-----BEGIN (?:CERTIFICATE|PRIVATE KEY)-----/i, + ); + }); + it('keeps the production verification scoped to safe summaries', () => { for (const table of Object.keys(messagePushTableDefinitions)) { expect(migrationVerifySql).toContain(`'${table}'`); @@ -551,6 +622,10 @@ describe('QQBot message-push SQL contract', () => { migrationVerifySql, 'seed_qqbot_message_template', )).toContain("binary content = binary '当前STUN的端口已变更为${{endpoint}}'"); + expect(extractVerificationStatement( + migrationVerifySql, + 'seed_qqbot_tcp_natmap_message_template', + )).toContain("binary content = binary '当前 TCP NATMap 端点已变更为 ${{endpoint}}'"); expect(extractVerificationStatement( migrationVerifySql, 'seed_qqbot_message_push_menu',