feat: 增加TCP NATMap菜单与消息模板

This commit is contained in:
sunlei 2026-07-27 15:00:21 +08:00
parent 83b874322a
commit 1c35fc9dbd
11 changed files with 318 additions and 7 deletions

4
API.md
View File

@ -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 来源定义与变量保持不变。 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 OutboxTCP 则在既有有效 tuple 的公网 IPv4 或端口任一变化时写独立 NATMap endpoint Outbox。首次 `published`、同 tuple 续期、`withdrawn`、同 tuple `restored`、重复事件和回滚事务都不产生 TCP 消息事件。事件使用生产者 `eventId` 幂等matching report/event 的 history、current/baseline 与 Outbox 保持既有事务关联门禁,事务提交后才调用 `requestDrain()` - UDP 只有一个既有有效端口直接变化为另一个有效端口的 `changed` 事件,才写既有 STUN OutboxTCP 则在既有有效 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、发布与回滚
既有环境使用本功能幂等增量入口 `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 号时不得任选目标,并应把真实投递单独标记为未验证。 回滚先停用全部发布绑定,再回滚 Admin 和 API并保留事件、投递及发送日志Network Agent、端口转发、STUN Keeper 和 DDNS 继续运行。Jenkins/K8s 通过只证明版本已部署,不能替代真实 CRUD、页面或事件到消息的功能验收。每次发布必须分别记录生产 SQL、真实 API、页面、Outbox/DDNS 和授权 QQ 投递证据;没有明确授权的 QQ 群或 QQ 号时不得任选目标,并应把真实投递单独标记为未验证。

View File

@ -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 的技术服务名。 同一模块提供腾讯云云解析 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 小时为截止时间。 `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 系统消息推送按以下顺序发布和回滚: 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` 行。 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` 角色授权。 3. 验证六表、17 个精确索引、默认模板、18 个页面/按钮菜单和活动 `super/admin` 角色授权。
4. 先发布并验证 API 健康检查、旧 QQBot 发送能力和新只读接口,再发布并验证 Admin。 4. 先发布并验证 API 健康检查、旧 QQBot 发送能力和新只读接口,再发布并验证 Admin。
5. 管理员显式创建订阅,并在每个发布账号中选择模板、配置群聊/私聊目标和启用绑定;随后用授权的非生产目标做一次有界 A→B 端口变化、DDNS 门禁、发送日志和幂等验收。 5. 管理员显式创建订阅,并在每个发布账号中选择模板、配置群聊/私聊目标和启用绑定;随后用授权的非生产目标做一次有界 A→B 端口变化、DDNS 门禁、发送日志和幂等验收。

View File

@ -19,7 +19,8 @@ VALUES
(2041700000000120223, 2041700000000100207, 'SystemNetworkDdnsCreate', NULL, NULL, NULL, 'System:Network:Ddns:Create', 'button', '{"title":"common.create"}', 1, 10), (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), (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), (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 ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `name` = VALUES(`name`),
`pid` = VALUES(`pid`), `pid` = VALUES(`pid`),
@ -46,6 +47,7 @@ WHERE role.`role_code` <> 'super'
'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardDelete',
'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardNatmap',
'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardHistory', 'SystemNetworkPortForwardHistory',
'SystemNetworkDdnsList', 'SystemNetworkDdnsList',
@ -66,6 +68,7 @@ JOIN `admin_menu` menu ON menu.`name` IN (
'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardDelete',
'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardNatmap',
'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardHistory', 'SystemNetworkPortForwardHistory',
'SystemNetworkDdnsList', 'SystemNetworkDdnsList',

View File

@ -1162,6 +1162,13 @@ WHERE NOT EXISTS (
WHERE `source_key` = 'network.stun.mapping-port-changed' AND `name` = 'STUN 映射端口变更默认模板' AND `is_deleted` = 0 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 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), (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), (2041700000000100414,2041700000000100400,'QqBotMessageTemplate','/qqbot/message-template','/qqbot/message-template/list',NULL,'QqBot:MessageTemplate:List','menu','{"icon":"lucide:message-square-plus","title":"消息模板"}',1,11),

View File

@ -123,6 +123,23 @@ WHERE NOT EXISTS (
AND is_deleted = 0 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 ( INSERT INTO admin_menu (
id, id,
pid, pid,

View File

@ -97,6 +97,15 @@ WHERE id = 2041700000000200601
AND enabled = 1 AND enabled = 1
AND is_deleted = 0; 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 SELECT 'seed_qqbot_message_push_menu' AS check_name, COUNT(*) AS matched_rows
FROM admin_menu FROM admin_menu
WHERE id IN ( WHERE id IN (

View File

@ -150,6 +150,7 @@ INSERT INTO admin_menu (
(2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11), (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), (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),
( (
2041700000000100400, 2041700000000100400,
0, 0,
@ -220,6 +221,7 @@ WHERE role.role_code <> 'super'
'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardDelete',
'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardNatmap',
'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardHistory', 'SystemNetworkPortForwardHistory',
'SystemNetworkDdnsList', 'SystemNetworkDdnsList',
@ -640,6 +642,15 @@ WHERE NOT EXISTS (
AND is_deleted = 0 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 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), (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), (2041700000000100414,2041700000000100400,'QqBotMessageTemplate','/qqbot/message-template','/qqbot/message-template/list',NULL,'QqBot:MessageTemplate:List','menu','{"icon":"lucide:message-square-plus","title":"消息模板"}',1,11),

View File

@ -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_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 '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' 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_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_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; 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 enabled = 1
AND is_deleted = 0; 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 ( WITH expected_menu AS (
SELECT 2041700000000100413 AS id, 2041700000000100400 AS pid, 'QqBotMessageSubscription' AS name, 'QqBot:MessageSubscription:List' AS auth_code, 10 AS sort 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 2041700000000100414, 2041700000000100400, 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List', 11
@ -148,6 +158,26 @@ WHERE table_schema = DATABASE()
AND table_name = 'network_port_forward' AND table_name = 'network_port_forward'
AND index_name = 'uk_network_port_forward_active_key'; 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 SELECT 'index_network_ddns_record_active_key' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
@ -271,6 +301,92 @@ WHERE role.role_code <> 'super'
'System:Network:Ddns:Retry' '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 SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()

View File

@ -253,6 +253,7 @@ VALUES
(2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11), (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), (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),
(2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100), (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), (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), (2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
@ -379,6 +380,7 @@ WHERE `is_deleted` = 0
'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardDelete',
'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardNatmap',
'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardHistory', 'SystemNetworkPortForwardHistory',
'SystemNetworkDdnsList', 'SystemNetworkDdnsList',

View File

@ -21,16 +21,18 @@ describe('System network-management SQL', () => {
'Delete', 'Delete',
'Retry', 'Retry',
'Keeper', 'Keeper',
'Natmap',
'Probe', 'Probe',
'History', 'History',
]; ];
for (const sql of [menuSql, initSql]) { for (const sql of [menuSql, initSql, canonicalSeed]) {
expect(sql).toContain("'SystemNetwork'"); expect(sql).toContain("'SystemNetwork'");
expect(sql).toContain("'/system/network/list'"); expect(sql).toContain("'/system/network/list'");
for (const action of actions) { for (const action of actions) {
expect(sql).toContain(`'SystemNetworkPortForward${action}'`); expect(sql).toContain(`'SystemNetworkPortForward${action}'`);
expect(sql).toContain(`'System:Network:PortForward:${action}'`); expect(sql).toContain(`'System:Network:PortForward:${action}'`);
} }
expect(sql.match(/2041700000000120227/g)).toHaveLength(1);
expect(sql).not.toMatch(/PortForward(Check|Ensure)/); 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("role.`role_code` = 'super'"); expect(menuSql).toContain("role.`role_code` = 'super'");
expect(menuSql).toContain('ON DUPLICATE KEY UPDATE'); 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', () => { 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'"); 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', () => { 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( 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+/,
); );
}); });
}); });

View File

@ -405,6 +405,13 @@ const extractExpectedMenuIds = (statement: string) =>
[...extractExpectedMenuCte(statement).matchAll(/select (\d+)(?: as id)?/g)].map(([, id]) => id); [...extractExpectedMenuCte(statement).matchAll(/select (\d+)(?: as id)?/g)].map(([, id]) => id);
describe('QQBot message-push SQL contract', () => { 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 bootstrapSql = readNormalizedSql('sql/qqbot-init.sql');
const migrationSql = readNormalizedSql('sql/qqbot-message-push-init.sql'); const migrationSql = readNormalizedSql('sql/qqbot-message-push-init.sql');
const migrationVerifySql = readNormalizedSql('sql/qqbot-message-push-verify.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); 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', () => { it('verifies the default template stable values with case-sensitive text comparisons', () => {
const defaultTemplateVerify = extractVerificationStatement(verifySql, 'seed_qqbot_message_template'); const defaultTemplateVerify = extractVerificationStatement(verifySql, 'seed_qqbot_message_template');
expect(defaultTemplateVerify).toBe(normalizeSql(` 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', () => { it('verifies every message-push table with its own complete row-count statement', () => {
for (const table of Object.keys(messagePushTableDefinitions)) { for (const table of Object.keys(messagePushTableDefinitions)) {
const statement = extractVerificationStatement(verifySql, table); const statement = extractVerificationStatement(verifySql, table);
@ -543,6 +596,24 @@ describe('QQBot message-push SQL contract', () => {
expect(migrationSql).toContain('insert ignore into admin_role_menu'); 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', () => { it('keeps the production verification scoped to safe summaries', () => {
for (const table of Object.keys(messagePushTableDefinitions)) { for (const table of Object.keys(messagePushTableDefinitions)) {
expect(migrationVerifySql).toContain(`'${table}'`); expect(migrationVerifySql).toContain(`'${table}'`);
@ -551,6 +622,10 @@ describe('QQBot message-push SQL contract', () => {
migrationVerifySql, migrationVerifySql,
'seed_qqbot_message_template', 'seed_qqbot_message_template',
)).toContain("binary content = binary '当前STUN的端口已变更为${{endpoint}}'"); )).toContain("binary content = binary '当前STUN的端口已变更为${{endpoint}}'");
expect(extractVerificationStatement(
migrationVerifySql,
'seed_qqbot_tcp_natmap_message_template',
)).toContain("binary content = binary '当前 TCP NATMap 端点已变更为 ${{endpoint}}'");
expect(extractVerificationStatement( expect(extractVerificationStatement(
migrationVerifySql, migrationVerifySql,
'seed_qqbot_message_push_menu', 'seed_qqbot_message_push_menu',