fix: 通用化系统消息订阅来源契约

This commit is contained in:
sunlei 2026-07-25 14:58:45 +08:00
parent ba777ff207
commit ceedb295a1
17 changed files with 888 additions and 203 deletions

93
API.md
View File

@ -103,13 +103,13 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| `GET` | `/system/network/port-forward/:id/endpoint-history` | `super` | 查询端点状态变化历史 | | `GET` | `/system/network/port-forward/:id/endpoint-history` | `super` | 查询端点状态变化历史 |
| `GET` | `/system/network/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 | | `GET` | `/system/network/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 |
| `GET` | `/system/network/events/stream` | `super` | SSE 推送已提交的 MQTT 状态变化 | | `GET` | `/system/network/events/stream` | `super` | SSE 推送已提交的 MQTT 状态变化 |
| `GET` | `/system/network/ddns/list` | `super` | 分页查询双栈自动 DDNS 绑定 | | `GET` | `/system/network/ddns/list` | `super` | 分页查询双栈自动 DDNS 绑定 |
| `GET` | `/system/network/ddns/source-options` | `super` | 查询 A/AAAA 的安全地址来源选项 | | `GET` | `/system/network/ddns/source-options` | `super` | 查询 A/AAAA 的安全地址来源选项 |
| `GET` | `/system/network/ddns/provider-status` | `super` | 查询腾讯云云解析 DNS 配置状态,不返回凭据 | | `GET` | `/system/network/ddns/provider-status` | `super` | 查询腾讯云云解析 DNS 配置状态,不返回凭据 |
| `POST` | `/system/network/ddns` | `super` | 新增本地 DDNS 自动更新绑定 | | `POST` | `/system/network/ddns` | `super` | 新增本地 DDNS 自动更新绑定 |
| `PUT` | `/system/network/ddns/:id` | `super` | 修改并按需立即协调 DDNS 绑定 | | `PUT` | `/system/network/ddns/:id` | `super` | 修改并按需立即协调 DDNS 绑定 |
| `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 | | `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 |
| `POST` | `/system/network/ddns/:id/retry` | `super` | 手动重试一条已启用 DDNS 绑定 | | `POST` | `/system/network/ddns/:id/retry` | `super` | 手动重试一条已启用 DDNS 绑定 |
新增和修改请求只接受名称、备注、`tcp|udp`、外部端口和内部端口;目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,请求体中的未知字段会返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store` 新增和修改请求只接受名称、备注、`tcp|udp`、外部端口和内部端口;目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,请求体中的未知字段会返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store`
@ -129,21 +129,21 @@ Agent 状态响应额外包含可选的 `currentPublicIpv6/currentIpv6ObservedAt
## 环境变量分组 ## 环境变量分组
| 分组 | 关键变量 | | 分组 | 关键变量 |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | | MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | | MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | | Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` | | QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | | Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
| Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_ENABLED`、`NETWORK_DDNS_DNSPOD_SECRET_ID`、`NETWORK_DDNS_DNSPOD_SECRET_KEY`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` | | Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_ENABLED`、`NETWORK_DDNS_DNSPOD_SECRET_ID`、`NETWORK_DDNS_DNSPOD_SECRET_KEY`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | | FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。 真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
@ -361,17 +361,17 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退
## MinIO ## MinIO
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| -------- | ----------------------- | ------------------------------- | | -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/minio/check` | 检查连接和 bucket | | `GET` | `/minio/check` | 检查连接和 bucket |
| `POST` | `/minio/bucket` | 创建 bucket | | `POST` | `/minio/bucket` | 创建 bucket |
| `POST` | `/minio/upload` | 上传文件,`multipart/form-data` | | `POST` | `/minio/upload` | 上传文件,`multipart/form-data` |
| `GET` | `/minio/list` | 文件列表 | | `GET` | `/minio/list` | 文件列表 |
| `GET` | `/minio/url` | 临时访问 URL | | `GET` | `/minio/url` | 临时访问 URL |
| `GET` | `/minio/resource-proxy` | 代理读取资源 | | `GET` | `/minio/resource-proxy` | 代理读取资源 |
| `GET` | `/minio/download` | 下载文件流 | | `GET` | `/minio/download` | 下载文件流 |
| `DELETE` | `/minio/remove` | 删除文件 | | `DELETE` | `/minio/remove` | 删除文件 |
| `GET` | `/blog/live2d/:character/catalog.json` | 公开读取 Pio/Tia Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 | | `GET` | `/blog/live2d/:character/catalog.json` | 公开读取 Pio/Tia Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 |
| `GET` | `/blog/live2d/:character/:family/*assetPath` | 公开读取 Pio/Tia Live2D 运行包资源,`character` 只允许 `pio`/`tia``family` 只允许 `moc`/`moc3`,按 Referer/Origin 白名单防盗链 | | `GET` | `/blog/live2d/:character/:family/*assetPath` | 公开读取 Pio/Tia Live2D 运行包资源,`character` 只允许 `pio`/`tia``family` 只允许 `moc`/`moc3`,按 Referer/Origin 白名单防盗链 |
`bucketName` 不传时使用 `MINIO_BUCKET` `bucketName` 不传时使用 `MINIO_BUCKET`
@ -423,7 +423,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/qqbot/message-push/sources` | Subscription `List/Create/Update`Template `List/Create/Update/Preview`Account MessagePush `List/Create/Update` | | `GET` | `/qqbot/message-push/sources` | Subscription `List/Create/Update`Template `List/Create/Update/Preview`Account MessagePush `List/Create/Update` |
| `GET` | `/qqbot/message-push/sources/:sourceKey` | 同 source list | | `GET` | `/qqbot/message-push/sources/:sourceKey` | 同 source list |
| `GET` | `/qqbot/message-push/sources/network.stun.mapping-port-changed/options` | Subscription `Create/Update`Account MessagePush `Create/Update` | | `GET` | `/qqbot/message-push/sources/:sourceKey/subscription-options` | Subscription `Create/Update`Account MessagePush `Create/Update` |
| `GET` | `/qqbot/message-push/sources/network.stun.mapping-port-changed/options` | 旧版 Admin 的一个发布周期兼容入口;权限同通用 subscription-options |
| `GET` | `/qqbot/message-push/subscriptions` | Subscription `List`Account MessagePush `List/Create/Update` | | `GET` | `/qqbot/message-push/subscriptions` | Subscription `List`Account MessagePush `List/Create/Update` |
| `POST` | `/qqbot/message-push/subscriptions` | Subscription `Create` | | `POST` | `/qqbot/message-push/subscriptions` | Subscription `Create` |
| `PUT` | `/qqbot/message-push/subscriptions/:id` | Subscription `Update` | | `PUT` | `/qqbot/message-push/subscriptions/:id` | Subscription `Update` |
@ -444,11 +445,11 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
完整权限码前缀分别为 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*`。订阅与模板列表返回 `data.items/data.total`source 与 binding 列表直接返回数组,其余接口返回单个对象或布尔值;全部 `POST` 使用 HTTP 200。账号离线或 OneBot 不可用时 targets 仍返回 HTTP 200 和 `{ available: false, options: [], reasonCode }` 完整权限码前缀分别为 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*`。订阅与模板列表返回 `data.items/data.total`source 与 binding 列表直接返回数组,其余接口返回单个对象或布尔值;全部 `POST` 使用 HTTP 200。账号离线或 OneBot 不可用时 targets 仍返回 HTTP 200 和 `{ available: false, options: [], reasonCode }`
请求采用严格白名单Snowflake/外键 ID 是 124 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1100 字符且不能全空白`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1100 个严格嵌套 target类型仅 `group/private``targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝query boolean 只接受字面量 `true/false` 请求采用严格白名单Snowflake/外键 ID 是 124 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1100 字符且不能全空白`sourceConfig` 必须是仅含字符串值的普通对象,其字段集合、必填项和领域校验由所选 `sourceKey``subscriptionFields` 与 adapter 决定,未知字段会拒绝。模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1100 个严格嵌套 target类型仅 `group/private``targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝query boolean 只接受字面量 `true/false`
管理边界将 `SystemMessageContractError` 仅转换为 Vben 安全错误,响应只包含其稳定 `code`,不会暴露原始消息、实体或 provider 对象:`unknown_message_source`、`mapping_not_found`、`ddns_not_found` 返回 HTTP 404重复、禁用、不可用、已取代、映射不匹配、DDNS 未同步、错误协议/管理状态及 OneBot 可用性或拒绝状态返回 HTTP 409其余来源、目标、模板、长度和契约校验错误返回 HTTP 400。非该领域错误维持 HTTP 500且不返回其内部细节。 管理边界将 `SystemMessageContractError` 仅转换为 Vben 安全错误,响应只包含其稳定 `code`,不会暴露原始消息、实体或 provider 对象:`unknown_message_source`、`mapping_not_found`、`ddns_not_found` 返回 HTTP 404重复、禁用、不可用、已取代、映射不匹配、DDNS 未同步、错误协议/管理状态及 OneBot 可用性或拒绝状态返回 HTTP 409其余来源、目标、模板、长度和契约校验错误返回 HTTP 400。非该领域错误维持 HTTP 500且不返回其内部细节。
响应仅返回管理契约字段source definition/field/variable 白名单;STUN 的 port-forward/DDNS 候选白名单subscription、template、preview、binding/target 和 target option 视图。不会返回 adapter、entity/repository、`activeKey`、digest、软删除字段、账号内部 ID、事件 payload/delivery/lease/retry 状态、凭据、access token、Provider/OneBot/MQTT 原始对象。系统事件只能通过 Nest 内部 Outbox stager 暂存,不存在 publish、event、delivery、fan-out、retry 或 worker HTTP 发布接口。 响应仅返回管理契约字段source definition/field/variable 白名单;通用 `subscription-options` 按来源声明的集合返回统一 `value/label/disabled/disabledReasonCode/dependsOnValue` 候选项;旧 STUN options 入口在兼容期内继续返回旧版 Admin 所需的专用字段。subscription 的 `sourceConfig` 只回显当前来源公开声明且值为字符串的字段;template、preview、binding/target 和 target option 仍使用各自视图白名单。不会返回 adapter、entity/repository、`activeKey`、digest、软删除字段、账号内部 ID、事件 payload/delivery/lease/retry 状态、凭据、access token、Provider/OneBot/MQTT 原始对象。系统事件只能通过 Nest 内部 Outbox stager 暂存,不存在 publish、event、delivery、fan-out、retry 或 worker HTTP 发布接口。
#### 内部事件与投递生命周期 #### 内部事件与投递生命周期
@ -465,8 +466,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
### NapCat Runtime Profile ### NapCat Runtime Profile
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| ----- | ----------------------------------------- | ------------------------------------------------------------------------------------- | | ----- | ----------------------------------------- | ------------------------------------------------------------------------------------------- |
| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和历史登录事件兼容表状态 | | `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和历史登录事件兼容表状态 |
该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 巡检告警状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段不触发登录、重建或修复动作。watchdog 不执行登录恢复:遇到 QQ 登录态离线只记录离线原因并通知 `super`,登录恢复统一由 Admin 手动「更新登录」触发session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 巡检告警状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段不触发登录、重建或修复动作。watchdog 不执行登录恢复:遇到 QQ 登录态离线只记录离线原因并通知 `super`,登录恢复统一由 Admin 手动「更新登录」触发session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。
@ -623,20 +624,20 @@ Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修
插件 key`bilibili-card`。这是事件型内置插件,不新增在线命令;启用后仍需通过账号事件绑定让指定 QQBot 账号接收 `bilibili-card.message` 插件 key`bilibili-card`。这是事件型内置插件,不新增在线命令;启用后仍需通过账号事件绑定让指定 QQBot 账号接收 `bilibili-card.message`
| event key | 触发来源 | 说明 | | event key | 触发来源 | 说明 |
| ------------------------ | -------- | -------------------------------------------------------------------- | | ----------------------- | -------- | --------------------------------------------------------------------- |
| `bilibili-card.message` | message | 从 QQ/NapCat `share/json/xml/lightapp` 卡片和文本中提取 Bilibili 链接 | | `bilibili-card.message` | message | 从 QQ/NapCat `share/json/xml/lightapp` 卡片和文本中提取 Bilibili 链接 |
插件会解析 `www.bilibili.com`、`m.bilibili.com` 和 `b23.tv`。短链通过插件平台受控 `resolveRedirect` host 能力限制跳转次数和超时;视频信息来自 Bilibili `x/web-interface/view`,回复首行使用视频封面 CQ image随后输出标题、UP 主、时长、播放/弹幕/点赞等文本摘要和标准视频链接。同一账号、同一会话、同一视频在 `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` 内去重。 插件会解析 `www.bilibili.com`、`m.bilibili.com` 和 `b23.tv`。短链通过插件平台受控 `resolveRedirect` host 能力限制跳转次数和超时;视频信息来自 Bilibili `x/web-interface/view`,回复首行使用视频封面 CQ image随后输出标题、UP 主、时长、播放/弹幕/点赞等文本摘要和标准视频链接。同一账号、同一会话、同一视频在 `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` 内去重。
可配置键: 可配置键:
| 配置键 | 默认值 | 说明 | | 配置键 | 默认值 | 说明 |
| ------------------------------------------- | ------ | -------------------- | | ------------------------------------- | ------ | -------------------- |
| `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS` | 6000 | HTTP 请求超时毫秒 | | `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS` | 6000 | HTTP 请求超时毫秒 |
| `QQBOT_BILIBILI_CARD_MAX_REDIRECTS` | 5 | `b23.tv` 最大跳转数 | | `QQBOT_BILIBILI_CARD_MAX_REDIRECTS` | 5 | `b23.tv` 最大跳转数 |
| `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` | 600000 | 同视频去重毫秒 | | `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` | 600000 | 同视频去重毫秒 |
| `QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH` | 80 | 回复中简介最大字符数 | | `QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH` | 80 | 回复中简介最大字符数 |
### BangDream ### BangDream

View File

@ -16,19 +16,19 @@
## 功能模块 ## 功能模块
| 模块 | 说明 | | 模块 | 说明 |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 | | `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | | `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | | `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、系统消息源/订阅/模板/账号绑定、耐久投递、发送/接收日志和插件平台 | | `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、系统消息源/订阅/模板/账号绑定、耐久投递、发送/接收日志和插件平台 |
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 | | `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | | `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 | | `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 |
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | | `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | | `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除以及 Blog Live2D 运行包受控读取入口 | | `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除以及 Blog Live2D 运行包受控读取入口 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 | | `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
## 目录结构 ## 目录结构
@ -56,19 +56,19 @@ ci/ Jenkins Agent/Docker 辅助文件
主要配置分组: 主要配置分组:
| 分组 | 变量 | | 分组 | 变量 |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | | MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | | MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | | QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
| Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | | Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
| Network Management | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_*`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` | | Network Management | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_*`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | | FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。 `DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
@ -84,7 +84,7 @@ System 网络管理以 MySQL 中的 TCP/UDP 端口转发期望状态为唯一事
同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录只从合格 UDP Keeper 的有效公网 IPv4 取值AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值DNS 值始终不包含端口。协调器只修改已存在、已启用、默认线路且唯一的 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 的有效公网 IPv4 取值AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值DNS 值始终不包含端口。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git`DNSPOD` 是腾讯云官方 SDK 的技术服务名。
QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;账号接口严格使用路由 `selfId`,不会回退到其他机器人。群聊和私聊目标分别使用 `group` / `private`Snowflake、QQ 账号和目标 ID 在 HTTP 与数据库边界始终保持字符串。系统事件只能通过内部 Outbox stager 暂存,不提供 publish/event/worker HTTP 路由管理响应仅返回字段白名单不暴露账号凭据、Provider/OneBot/MQTT 运行对象、原始事件载荷或内部持久化键。 QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;订阅表单字段、候选集合和资源匹配由所选系统消息源 adapter 声明,核心模块不写死 STUN 的 `portForwardId/ddnsRecordId`账号接口严格使用路由 `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 小时为截止时间。

View File

@ -10,7 +10,9 @@ import {
SystemMessageContractError, SystemMessageContractError,
type SystemMessageDeliveryReadiness, type SystemMessageDeliveryReadiness,
type SystemMessageScalar, type SystemMessageScalar,
type SystemMessageSourceAdapter,
type SystemMessageSourceDefinition, type SystemMessageSourceDefinition,
type SystemMessageSourceOptionsResponse,
} from '@/modules/qqbot/core/contract/message-push/qqbot-message-push.types'; } from '@/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
import { SystemMessageSourceRegistry } from '@/modules/qqbot/core/application/message-push/system-message-source.registry'; import { SystemMessageSourceRegistry } from '@/modules/qqbot/core/application/message-push/system-message-source.registry';
import { NetworkDdnsRecord } from './network-ddns.entity'; import { NetworkDdnsRecord } from './network-ddns.entity';
@ -44,7 +46,7 @@ type ResolvedSubscription = {
@Injectable() @Injectable()
export class NetworkStunMessageSourceAdapter export class NetworkStunMessageSourceAdapter
implements OnModuleDestroy, OnModuleInit implements OnModuleDestroy, OnModuleInit, SystemMessageSourceAdapter
{ {
private registered = false; private registered = false;
@ -143,6 +145,17 @@ export class NetworkStunMessageSourceAdapter
this.registered = false; this.registered = false;
} }
/** 从已校验事件中提取 STUN 映射的稳定资源键。 */
eventResourceKey(payload: Record<string, SystemMessageScalar>): string {
if (
!Object.prototype.hasOwnProperty.call(payload, 'portForwardId') ||
typeof payload.portForwardId !== 'string'
) {
throw new SystemMessageContractError('invalid_source_config');
}
return payload.portForwardId;
}
async normalizeSubscriptionConfig(input: unknown): Promise<{ async normalizeSubscriptionConfig(input: unknown): Promise<{
canonicalConfig: Record<string, string>; canonicalConfig: Record<string, string>;
resourceKey: string; resourceKey: string;
@ -178,7 +191,8 @@ export class NetworkStunMessageSourceAdapter
} }
} }
async listSubscriptionOptions(): Promise<Record<string, unknown>> { /** 将网络实体转换成供动态订阅表单使用的标准选项。 */
async listSubscriptionOptions(): Promise<SystemMessageSourceOptionsResponse> {
const [mappings, records] = await Promise.all([ const [mappings, records] = await Promise.all([
this.mappingRepository.find({ order: { id: 'ASC', name: 'ASC' } }), this.mappingRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
this.ddnsRepository.find({ order: { id: 'ASC', name: 'ASC' } }), this.ddnsRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
@ -193,32 +207,61 @@ export class NetworkStunMessageSourceAdapter
: undefined; : undefined;
const disabledReasonCode = ddnsOptionReason(record, mapping); const disabledReasonCode = ddnsOptionReason(record, mapping);
return { return {
...(record.portForwardId
? { dependsOnValue: String(record.portForwardId) }
: {}),
disabled: disabledReasonCode !== null,
disabledReasonCode, disabledReasonCode,
eligible: disabledReasonCode === null, eligible: disabledReasonCode === null,
fqdn: ddnsFqdn(record), fqdn: ddnsFqdn(record),
id: String(record.id), id: String(record.id),
label: [record.name, ddnsFqdn(record), disabledReasonCode]
.filter((value) => value !== null)
.join(' · '),
name: record.name, name: record.name,
portForwardId: record.portForwardId portForwardId: record.portForwardId
? String(record.portForwardId) ? String(record.portForwardId)
: '', : '',
value: String(record.id),
}; };
}), }),
portForwards: mappings.map((mapping) => { portForwards: mappings.map((mapping) => {
const { disabledReasonCode, eligible } = const { disabledReasonCode, eligible } =
classifyStunEndpointSource(mapping); classifyStunEndpointSource(mapping);
return { return {
disabled: !eligible,
disabledReasonCode, disabledReasonCode,
eligible, eligible,
externalPort: mapping.externalPort, externalPort: mapping.externalPort,
id: String(mapping.id), id: String(mapping.id),
internalPort: mapping.internalPort, internalPort: mapping.internalPort,
label: [
mapping.name,
`${mapping.protocol.toUpperCase()}:${mapping.externalPort}`,
disabledReasonCode,
]
.filter((value) => value !== null)
.join(' · '),
name: mapping.name, name: mapping.name,
protocol: mapping.protocol, protocol: mapping.protocol,
value: String(mapping.id),
}; };
}), }),
}; };
} }
/** 从订阅配置中提取自有的字符串资源键,无效配置不参与匹配。 */
subscriptionResourceKey(config: Record<string, unknown>): null | string {
if (
!isPlainRecord(config) ||
!Object.prototype.hasOwnProperty.call(config, 'portForwardId') ||
typeof config.portForwardId !== 'string'
) {
return null;
}
return config.portForwardId;
}
validateEventPayload( validateEventPayload(
payload: Record<string, unknown>, payload: Record<string, unknown>,
): Record<string, SystemMessageScalar> { ): Record<string, SystemMessageScalar> {

View File

@ -14,6 +14,7 @@ import {
type MessageSubscriptionInput, type MessageSubscriptionInput,
type MessageSubscriptionListQuery, type MessageSubscriptionListQuery,
type MessageSubscriptionView, type MessageSubscriptionView,
type SystemMessageSourceDefinition,
} from '../../contract/message-push/qqbot-message-push.types'; } from '../../contract/message-push/qqbot-message-push.types';
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity'; import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity'; import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
@ -238,12 +239,17 @@ export class QqbotMessageSubscriptionService {
return subscription; return subscription;
} }
/** 校验来源配置后生成稳定摘要和持久化字段。 */
private async normalizeInput( private async normalizeInput(
input: MessageSubscriptionInput, input: MessageSubscriptionInput,
): Promise<NormalizedSubscriptionInput> { ): Promise<NormalizedSubscriptionInput> {
const adapter = this.sourceRegistry.get(input.sourceKey); const adapter = this.sourceRegistry.get(input.sourceKey);
const normalized = await adapter.normalizeSubscriptionConfig( const validatedSourceConfig = this.validateSourceConfig(
input.sourceConfig, input.sourceConfig,
adapter.definition,
);
const normalized = await adapter.normalizeSubscriptionConfig(
validatedSourceConfig,
); );
const sourceConfig = this.sortConfig(normalized.canonicalConfig); const sourceConfig = this.sortConfig(normalized.canonicalConfig);
const sourceConfigDigest = createHash('sha256') const sourceConfigDigest = createHash('sha256')
@ -260,6 +266,35 @@ export class QqbotMessageSubscriptionService {
}; };
} }
/** 按来源元数据精确接收自有字符串字段,并拒绝缺失或额外字段。 */
private validateSourceConfig(
input: Record<string, unknown>,
definition: SystemMessageSourceDefinition,
): Record<string, string> {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new SystemMessageContractError('invalid_source_config');
}
const fields = new Map(
definition.subscriptionFields.map((field) => [field.key, field]),
);
const sourceConfig: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
if (!fields.has(key) || typeof value !== 'string') {
throw new SystemMessageContractError('invalid_source_config');
}
sourceConfig[key] = value;
}
for (const field of definition.subscriptionFields) {
if (
field.required &&
!Object.prototype.hasOwnProperty.call(sourceConfig, field.key)
) {
throw new SystemMessageContractError('invalid_source_config');
}
}
return sourceConfig;
}
private sortConfig(config: Record<string, string>): Record<string, string> { private sortConfig(config: Record<string, string>): Record<string, string> {
return Object.fromEntries( return Object.fromEntries(
Object.entries(config).sort(([left], [right]) => Object.entries(config).sort(([left], [right]) =>

View File

@ -4,7 +4,6 @@ import { KtDateTime } from '@/common';
import { import {
SystemMessageContractError, SystemMessageContractError,
type SystemMessageDeliveryReadiness, type SystemMessageDeliveryReadiness,
type SystemMessageScalar,
type SystemMessageSourceAdapter, type SystemMessageSourceAdapter,
} from '../../contract/message-push/qqbot-message-push.types'; } from '../../contract/message-push/qqbot-message-push.types';
import { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity'; import { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity';
@ -25,7 +24,6 @@ import {
import { SystemMessageSourceRegistry } from './system-message-source.registry'; import { SystemMessageSourceRegistry } from './system-message-source.registry';
import { SystemMessageTemplateRendererService } from './system-message-template-renderer.service'; import { SystemMessageTemplateRendererService } from './system-message-template-renderer.service';
const STUN_MAPPING_PORT_SOURCE = 'network.stun.mapping-port-changed';
const TRANSIENT_ERROR_CODE = 'fanout_transient_error'; const TRANSIENT_ERROR_CODE = 'fanout_transient_error';
const EVENT_EXPIRED_ERROR_CODE = 'fanout_expired'; const EVENT_EXPIRED_ERROR_CODE = 'fanout_expired';
const EVENT_RESOURCE_MISMATCH_ERROR_CODE = 'event_resource_mismatch'; const EVENT_RESOURCE_MISMATCH_ERROR_CODE = 'event_resource_mismatch';
@ -114,8 +112,14 @@ export class SystemMessageFanoutService {
try { try {
const adapter = this.sourceRegistry.get(token.event.sourceKey); const adapter = this.sourceRegistry.get(token.event.sourceKey);
const payload = adapter.validateEventPayload(token.event.payload); const payload = adapter.validateEventPayload(token.event.payload);
this.assertResourceIdentity(token.event, payload); this.assertResourceIdentity(
const subscriptions = await this.findMatchingSubscriptions(token.event); token.event,
adapter.eventResourceKey(payload),
);
const subscriptions = await this.findMatchingSubscriptions(
token.event,
adapter,
);
let transientFailure = false; let transientFailure = false;
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
@ -170,6 +174,7 @@ export class SystemMessageFanoutService {
private async findMatchingSubscriptions( private async findMatchingSubscriptions(
event: QqbotMessageEvent, event: QqbotMessageEvent,
adapter: SystemMessageSourceAdapter,
): Promise<QqbotMessageSubscription[]> { ): Promise<QqbotMessageSubscription[]> {
const subscriptions = await this.dataSource const subscriptions = await this.dataSource
.getRepository(QqbotMessageSubscription) .getRepository(QqbotMessageSubscription)
@ -178,7 +183,7 @@ export class SystemMessageFanoutService {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
return subscriptions.filter((subscription) => return subscriptions.filter((subscription) =>
this.matchesSubscription(subscription, event), this.matchesSubscription(subscription, event, adapter),
); );
} }
@ -210,12 +215,15 @@ export class SystemMessageFanoutService {
where: { id: subscriptionId }, where: { id: subscriptionId },
lock: { mode: 'pessimistic_write' }, lock: { mode: 'pessimistic_write' },
}); });
if (!subscription || !this.matchesSubscription(subscription, event)) { if (
!subscription ||
!this.matchesSubscription(subscription, event, adapter)
) {
return 'handled'; return 'handled';
} }
const payload = adapter.validateEventPayload(event.payload); const payload = adapter.validateEventPayload(event.payload);
this.assertResourceIdentity(event, payload); this.assertResourceIdentity(event, adapter.eventResourceKey(payload));
const readiness = await adapter.resolveDelivery({ const readiness = await adapter.resolveDelivery({
eventPayload: payload, eventPayload: payload,
subscriptionConfig: subscription.sourceConfig, subscriptionConfig: subscription.sourceConfig,
@ -490,21 +498,21 @@ export class SystemMessageFanoutService {
); );
} }
/** 确认适配器解析的事件资源与冻结事件身份一致。 */
private assertResourceIdentity( private assertResourceIdentity(
event: QqbotMessageEvent, event: QqbotMessageEvent,
payload: Record<string, SystemMessageScalar>, resourceKey: string,
): void { ): void {
if ( if (resourceKey !== event.resourceKey) {
event.sourceKey === STUN_MAPPING_PORT_SOURCE &&
payload.portForwardId !== event.resourceKey
) {
throw new EventResourceMismatchError(); throw new EventResourceMismatchError();
} }
} }
/** 使用消息源适配器自己的资源键规则匹配订阅。 */
private matchesSubscription( private matchesSubscription(
subscription: QqbotMessageSubscription, subscription: QqbotMessageSubscription,
event: QqbotMessageEvent, event: QqbotMessageEvent,
adapter: SystemMessageSourceAdapter,
): boolean { ): boolean {
const config = subscription.sourceConfig; const config = subscription.sourceConfig;
return ( return (
@ -512,9 +520,7 @@ export class SystemMessageFanoutService {
!subscription.isDeleted && !subscription.isDeleted &&
subscription.sourceKey === event.sourceKey && subscription.sourceKey === event.sourceKey &&
!!config && !!config &&
Object.prototype.hasOwnProperty.call(config, 'portForwardId') && adapter.subscriptionResourceKey(config) === event.resourceKey
typeof config.portForwardId === 'string' &&
config.portForwardId === event.resourceKey
); );
} }
@ -569,6 +575,6 @@ export class SystemMessageFanoutService {
class EventResourceMismatchError extends Error { class EventResourceMismatchError extends Error {
constructor() { constructor() {
super('validated port-forward identity does not match event resource'); super('validated source identity does not match event resource');
} }
} }

View File

@ -25,6 +25,7 @@ import type {
MessageTemplateView, MessageTemplateView,
StunMappingPortChangedOptionsResponse, StunMappingPortChangedOptionsResponse,
SystemMessageSourceDefinition, SystemMessageSourceDefinition,
SystemMessageSourceOptionsResponse,
} from './qqbot-message-push.types'; } from './qqbot-message-push.types';
import { import {
MessagePushEnabledDto, MessagePushEnabledDto,
@ -53,6 +54,15 @@ const SOURCE_READ_PERMISSIONS = [
'QqBot:Account:MessagePush:Update', 'QqBot:Account:MessagePush:Update',
] as const; ] as const;
const SOURCE_OPTIONS_PERMISSIONS = [
'QqBot:MessageSubscription:Create',
'QqBot:MessageSubscription:Update',
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
] as const;
const LEGACY_STUN_SOURCE_KEY = 'network.stun.mapping-port-changed';
const cloneSourceDefinition = ( const cloneSourceDefinition = (
definition: SystemMessageSourceDefinition, definition: SystemMessageSourceDefinition,
): SystemMessageSourceDefinition => ({ ): SystemMessageSourceDefinition => ({
@ -77,9 +87,33 @@ const cloneSourceDefinition = (
version: definition.version, version: definition.version,
}); });
const allowlistStunOptions = ( const allowlistSourceOptions = (
value: Record<string, unknown>, definition: SystemMessageSourceDefinition,
): StunMappingPortChangedOptionsResponse => { value: SystemMessageSourceOptionsResponse,
): SystemMessageSourceOptionsResponse =>
Object.fromEntries(
[
...new Set(
definition.subscriptionFields.map((field) => field.optionCollection),
),
].map((collection) => [
collection,
(value[collection] || []).map((item) => ({
...(item.dependsOnValue === undefined
? {}
: { dependsOnValue: item.dependsOnValue }),
disabled: item.disabled,
disabledReasonCode: item.disabledReasonCode,
label: item.label,
value: item.value,
})),
]),
);
/** 为旧版 Admin 保留一个发布周期的 STUN 候选项响应。 */
function allowlistLegacyStunOptions(
value: SystemMessageSourceOptionsResponse,
): StunMappingPortChangedOptionsResponse {
const input = value as unknown as StunMappingPortChangedOptionsResponse; const input = value as unknown as StunMappingPortChangedOptionsResponse;
return { return {
ddnsRecords: input.ddnsRecords.map((item) => ({ ddnsRecords: input.ddnsRecords.map((item) => ({
@ -100,27 +134,42 @@ const allowlistStunOptions = (
protocol: item.protocol, protocol: item.protocol,
})), })),
}; };
}; }
const allowlistSubscription = ( const allowlistSubscription = (
view: MessageSubscriptionView, view: MessageSubscriptionView,
): MessageSubscriptionView => ({ definition: SystemMessageSourceDefinition,
createTime: view.createTime, ): MessageSubscriptionView => {
enabled: view.enabled, const input =
id: view.id, view.sourceConfig &&
invalidReasonCode: view.invalidReasonCode, typeof view.sourceConfig === 'object' &&
name: view.name, !Array.isArray(view.sourceConfig)
remark: view.remark, ? view.sourceConfig
sourceConfig: { : {};
ddnsRecordId: view.sourceConfig.ddnsRecordId, const sourceConfig = Object.fromEntries(
portForwardId: view.sourceConfig.portForwardId, definition.subscriptionFields.flatMap((field) => {
}, const value = input[field.key];
sourceKey: view.sourceKey, return Object.prototype.hasOwnProperty.call(input, field.key) &&
sourceName: view.sourceName, typeof value === 'string'
sourceSummary: view.sourceSummary, ? [[field.key, value]]
updateTime: view.updateTime, : [];
valid: view.valid, }),
}); );
return {
createTime: view.createTime,
enabled: view.enabled,
id: view.id,
invalidReasonCode: view.invalidReasonCode,
name: view.name,
remark: view.remark,
sourceConfig,
sourceKey: view.sourceKey,
sourceName: view.sourceName,
sourceSummary: view.sourceSummary,
updateTime: view.updateTime,
valid: view.valid,
};
};
const allowlistTemplate = (view: MessageTemplateView): MessageTemplateView => ({ const allowlistTemplate = (view: MessageTemplateView): MessageTemplateView => ({
content: view.content, content: view.content,
@ -165,18 +214,23 @@ export class QqbotMessagePushController {
return vbenSuccess(this.sourceRegistry.list().map(cloneSourceDefinition)); return vbenSuccess(this.sourceRegistry.list().map(cloneSourceDefinition));
} }
/** 返回消息源动态订阅表单使用的标准候选项。 */
@Get('sources/:sourceKey/subscription-options')
@QqbotMessagePushPermission(...SOURCE_OPTIONS_PERMISSIONS)
async listSourceOptions(@Param() params: MessagePushSourceParamDto) {
const adapter = this.sourceRegistry.get(params.sourceKey);
const result = await adapter.listSubscriptionOptions();
return vbenSuccess(allowlistSourceOptions(adapter.definition, result));
}
/** 返回旧版 Admin 使用的 STUN 专用候选项。 */
@Get('sources/network.stun.mapping-port-changed/options') @Get('sources/network.stun.mapping-port-changed/options')
@QqbotMessagePushPermission( @QqbotMessagePushPermission(...SOURCE_OPTIONS_PERMISSIONS)
'QqBot:MessageSubscription:Create', async listLegacyStunOptions() {
'QqBot:MessageSubscription:Update',
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
)
async listStunSourceOptions() {
const result = await this.sourceRegistry const result = await this.sourceRegistry
.get('network.stun.mapping-port-changed') .get(LEGACY_STUN_SOURCE_KEY)
.listSubscriptionOptions(); .listSubscriptionOptions();
return vbenSuccess(allowlistStunOptions(result)); return vbenSuccess(allowlistLegacyStunOptions(result));
} }
@Get('sources/:sourceKey') @Get('sources/:sourceKey')
@ -195,15 +249,27 @@ export class QqbotMessagePushController {
) )
async pageSubscriptions(@Query() query: MessageSubscriptionListQueryDto) { async pageSubscriptions(@Query() query: MessageSubscriptionListQueryDto) {
const page = await this.subscriptionService.page(query); const page = await this.subscriptionService.page(query);
return vbenPage(page.items.map(allowlistSubscription), page.total); return vbenPage(
page.items.map((item) =>
allowlistSubscription(
item,
this.sourceRegistry.get(item.sourceKey).definition,
),
),
page.total,
);
} }
@Post('subscriptions') @Post('subscriptions')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@QqbotMessagePushPermission('QqBot:MessageSubscription:Create') @QqbotMessagePushPermission('QqBot:MessageSubscription:Create')
async createSubscription(@Body() body: MessageSubscriptionInputDto) { async createSubscription(@Body() body: MessageSubscriptionInputDto) {
const view = await this.subscriptionService.create(body);
return vbenSuccess( return vbenSuccess(
allowlistSubscription(await this.subscriptionService.create(body)), allowlistSubscription(
view,
this.sourceRegistry.get(view.sourceKey).definition,
),
); );
} }
@ -213,9 +279,11 @@ export class QqbotMessagePushController {
@Param() params: MessagePushIdParamDto, @Param() params: MessagePushIdParamDto,
@Body() body: MessageSubscriptionInputDto, @Body() body: MessageSubscriptionInputDto,
) { ) {
const view = await this.subscriptionService.update(params.id, body);
return vbenSuccess( return vbenSuccess(
allowlistSubscription( allowlistSubscription(
await this.subscriptionService.update(params.id, body), view,
this.sourceRegistry.get(view.sourceKey).definition,
), ),
); );
} }
@ -226,9 +294,14 @@ export class QqbotMessagePushController {
@Param() params: MessagePushIdParamDto, @Param() params: MessagePushIdParamDto,
@Body() body: MessagePushEnabledDto, @Body() body: MessagePushEnabledDto,
) { ) {
const view = await this.subscriptionService.setEnabled(
params.id,
body.enabled,
);
return vbenSuccess( return vbenSuccess(
allowlistSubscription( allowlistSubscription(
await this.subscriptionService.setEnabled(params.id, body.enabled), view,
this.sourceRegistry.get(view.sourceKey).definition,
), ),
); );
} }

View File

@ -15,6 +15,7 @@ import {
Max, Max,
MaxLength, MaxLength,
Min, Min,
ValidateBy,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
@ -22,6 +23,16 @@ export const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/;
export const QQ_TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/; export const QQ_TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/;
const SOURCE_KEY_MAX_LENGTH = 128; const SOURCE_KEY_MAX_LENGTH = 128;
/** 判断消息源配置是否为仅含字符串值的普通键值对象。 */
function isStringRecord(value: unknown): value is Record<string, string> {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.values(value).every((item) => typeof item === 'string')
);
}
const strictQueryBoolean = ({ value }: { value: unknown }): unknown => { const strictQueryBoolean = ({ value }: { value: unknown }): unknown => {
if (value === 'true' || value === true) return true; if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false; if (value === 'false' || value === false) return false;
@ -86,18 +97,6 @@ export class MessageSubscriptionListQueryDto extends MessagePushListQueryDto {}
export class MessageTemplateListQueryDto extends MessagePushListQueryDto {} export class MessageTemplateListQueryDto extends MessagePushListQueryDto {}
export class StunMappingPortChangedSubscriptionConfigDto {
[key: string]: unknown;
@IsString()
@Matches(SNOWFLAKE_ID_PATTERN)
portForwardId: string;
@IsString()
@Matches(SNOWFLAKE_ID_PATTERN)
ddnsRecordId: string;
}
export class MessageSubscriptionInputDto { export class MessageSubscriptionInputDto {
@IsString() @IsString()
@Length(1, 100) @Length(1, 100)
@ -108,11 +107,13 @@ export class MessageSubscriptionInputDto {
@Length(1, SOURCE_KEY_MAX_LENGTH) @Length(1, SOURCE_KEY_MAX_LENGTH)
sourceKey: string; sourceKey: string;
@ValidateNested() @ValidateBy({
@Type(() => StunMappingPortChangedSubscriptionConfigDto) name: 'isStringRecord',
validator: { validate: isStringRecord },
})
@IsDefined() @IsDefined()
@IsObject() @IsObject()
sourceConfig: StunMappingPortChangedSubscriptionConfigDto; sourceConfig: Record<string, string>;
@IsBoolean() @IsBoolean()
enabled: boolean; enabled: boolean;

View File

@ -1,68 +1,359 @@
import type { EntityManager } from 'typeorm'; import type { EntityManager } from 'typeorm';
export type SystemMessageScalar = boolean | null | number | string; export type SystemMessageScalar = boolean | null | number | string;
export type SystemMessageFanoutStatus = 'accepted' | 'completed' | 'failed' | 'processing' | 'retry'; export type SystemMessageFanoutStatus =
export type SystemMessageDeliveryStatus = 'cancelled' | 'failed' | 'pending' | 'processing' | 'retry' | 'success' | 'superseded' | 'waiting_ddns'; | 'accepted'
| 'completed'
| 'failed'
| 'processing'
| 'retry';
export type SystemMessageDeliveryStatus =
| 'cancelled'
| 'failed'
| 'pending'
| 'processing'
| 'retry'
| 'success'
| 'superseded'
| 'waiting_ddns';
export type QqbotMessagePushTargetType = 'group' | 'private'; export type QqbotMessagePushTargetType = 'group' | 'private';
export type SystemMessageTemplateToken = { kind: 'text'; value: string } | { key: string; kind: 'variable' }; 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 SystemMessageSourceVariableDefinition {
export interface SystemMessageSourceFieldDefinition { dependsOn?: string; key: string; label: string; optionCollection: 'ddnsRecords' | 'portForwards'; required: true; type: 'select'; } description: string;
export interface SystemMessageSourceDefinition { description: string; displayName: string; sourceKey: string; subscriptionFields: SystemMessageSourceFieldDefinition[]; variables: SystemMessageSourceVariableDefinition[]; version: 1; } example: string;
export interface StunMappingPortChangedSubscriptionConfig { ddnsRecordId: string; portForwardId: string; } key: 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'; }>; } label: string;
type: 'boolean' | 'number' | 'string';
}
export interface SystemMessageSourceFieldDefinition {
dependsOn?: string;
key: string;
label: string;
optionCollection: string;
required: boolean;
type: 'select';
}
export interface SystemMessageSourceDefinition {
description: string;
displayName: string;
sourceKey: string;
subscriptionFields: SystemMessageSourceFieldDefinition[];
variables: SystemMessageSourceVariableDefinition[];
version: 1;
}
export interface SystemMessageSourceOptionDefinition {
dependsOnValue?: string;
disabled: boolean;
disabledReasonCode: null | string;
label: string;
value: string;
}
export type SystemMessageSourceOptionsResponse = Record<
string,
SystemMessageSourceOptionDefinition[]
>;
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 MessageSubscriptionView {
export interface MessageTemplateView { content: string; createTime: string; enabled: boolean; id: string; name: string; referenceCount: number; remark: null | string; sourceKey: string; sourceName: string; updateTime: string; } createTime: string;
export interface MessageTemplatePreview { renderedMessage: string; variables: Record<string, boolean | number | string>; } enabled: boolean;
export interface QqbotMessagePublishTargetView { enabled: boolean; id: string; targetId: string; targetName: null | string; targetType: QqbotMessagePushTargetType; } id: string;
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; } invalidReasonCode: null | string;
name: string;
remark: null | string;
sourceConfig: Record<string, string>;
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<string, boolean | number | string>;
}
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 = export type SystemMessageDeliveryReadiness =
| { reasonCode: null; status: 'ready'; variables: Record<string, boolean | number | string>; } | {
| { reasonCode: 'ddns_not_synced'; status: 'waiting_ddns'; variables: Record<string, boolean | number | string>; } reasonCode: null;
| { reasonCode: string; status: 'cancelled' | 'superseded'; variables?: never; }; status: 'ready';
variables: Record<string, boolean | number | string>;
}
| {
reasonCode: 'ddns_not_synced';
status: 'waiting_ddns';
variables: Record<string, boolean | number | string>;
}
| {
reasonCode: string;
status: 'cancelled' | 'superseded';
variables?: never;
};
export interface SystemMessageSourceAdapter { export interface SystemMessageSourceAdapter {
readonly definition: SystemMessageSourceDefinition; readonly definition: SystemMessageSourceDefinition;
inspectSubscription(config: Record<string, unknown>): Promise<{ invalidReasonCode: null | string; sourceSummary: string; valid: boolean; }>; eventResourceKey(payload: Record<string, SystemMessageScalar>): string;
listSubscriptionOptions(): Promise<Record<string, unknown>>; inspectSubscription(config: Record<string, unknown>): Promise<{
normalizeSubscriptionConfig(input: unknown): Promise<{ canonicalConfig: Record<string, string>; resourceKey: string; sourceSummary: string; }>; invalidReasonCode: null | string;
resolveDelivery(input: { eventPayload: Record<string, SystemMessageScalar>; subscriptionConfig: Record<string, unknown>; }): Promise<SystemMessageDeliveryReadiness>; sourceSummary: string;
validateEventPayload(payload: Record<string, unknown>): Record<string, SystemMessageScalar>; valid: boolean;
}>;
listSubscriptionOptions(): Promise<SystemMessageSourceOptionsResponse>;
normalizeSubscriptionConfig(input: unknown): Promise<{
canonicalConfig: Record<string, string>;
resourceKey: string;
sourceSummary: string;
}>;
resolveDelivery(input: {
eventPayload: Record<string, SystemMessageScalar>;
subscriptionConfig: Record<string, unknown>;
}): Promise<SystemMessageDeliveryReadiness>;
subscriptionResourceKey(config: Record<string, unknown>): null | string;
validateEventPayload(
payload: Record<string, unknown>,
): Record<string, SystemMessageScalar>;
} }
export interface SystemMessageEventInput { eventId: string; occurredAt: string; payload: Record<string, SystemMessageScalar>; resourceKey: string; sourceKey: string; } export interface SystemMessageEventInput {
export const SYSTEM_MESSAGE_EVENT_STAGER = Symbol('SYSTEM_MESSAGE_EVENT_STAGER'); eventId: string;
export interface SystemMessageEventStager { occurredAt: string;
stage(manager: EntityManager, input: SystemMessageEventInput): Promise<'accepted' | 'duplicate'>; payload: Record<string, SystemMessageScalar>;
resourceKey: string;
sourceKey: string;
} }
export const SYSTEM_MESSAGE_DELIVERY_COORDINATOR = Symbol('SYSTEM_MESSAGE_DELIVERY_COORDINATOR'); 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 { export interface SystemMessageDeliveryCoordinator {
notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise<void>; notifyDdnsSynced(input: {
appliedAddress: string;
ddnsRecordId: string;
}): Promise<void>;
requestDrain(): void; requestDrain(): void;
} }
export interface MessageSubscriptionListQuery { enabled?: boolean; name?: string; pageNo?: number; pageSize?: number; sourceKey?: string; } export interface MessageSubscriptionListQuery {
export interface MessageSubscriptionInput { enabled: boolean; name: string; remark?: string; sourceConfig: Record<string, unknown>; sourceKey: string; } enabled?: boolean;
export interface MessageTemplateListQuery { enabled?: boolean; name?: string; pageNo?: number; pageSize?: number; sourceKey?: string; } name?: string;
export interface MessageTemplateInput { content: string; enabled: boolean; name: string; remark?: string; sourceKey: string; } pageNo?: number;
export interface QqbotMessagePublishTargetInput { targetId: string; targetName?: string; targetType: QqbotMessagePushTargetType; } pageSize?: number;
export interface QqbotMessagePublishBindingInput { enabled: boolean; subscriptionId: string; targets: QqbotMessagePublishTargetInput[]; templateId: string; } sourceKey?: string;
export interface QqbotMessagePushTargetOption { label: string; targetId: string; targetType: QqbotMessagePushTargetType; } }
export interface QqbotMessagePushTargetOptionsResponse { available: boolean; options: QqbotMessagePushTargetOption[]; reasonCode: null | string; } export interface MessageSubscriptionInput {
export interface StrictPlainTextSendInput { attemptNumber: number; deliveryId: string; message: string; selfId: string; targetId: string; targetType: QqbotMessagePushTargetType; } enabled: boolean;
export interface QqbotSendAttemptErrorOptions { code: string; message: string; retryable: boolean; sendLogId: null | string; } name: string;
remark?: string;
sourceConfig: Record<string, unknown>;
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 { export class SystemMessageContractError extends Error {
constructor(public readonly code: string) { super(code); this.name = 'SystemMessageContractError'; } constructor(public readonly code: string) {
super(code);
this.name = 'SystemMessageContractError';
}
} }
export const QQBOT_MESSAGE_PUSH_TABLE_CONTRACT = { 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'], binding: [
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'], 'id',
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',
subscription: ['id', 'name', 'source_key', 'source_config', 'source_config_digest', 'active_key', 'enabled', 'remark', 'is_deleted', 'create_time', 'update_time'], 'account_id',
target: ['id', 'binding_id', 'target_type', 'target_id', 'target_name', 'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time'], 'self_id',
template: ['id', 'name', 'source_key', 'content', 'enabled', 'remark', 'is_deleted', 'create_time', 'update_time'], '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; } as const;

View File

@ -228,6 +228,29 @@ describe('NetworkStunMessageSourceAdapter', () => {
); );
}); });
it('owns event and subscription resource-key extraction', () => {
const { adapter } = createHarness();
const payload = adapter.validateEventPayload(eventPayload());
expect(adapter.eventResourceKey(payload)).toBe('2041700000000000001');
expect(
adapter.subscriptionResourceKey({
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
}),
).toBe('2041700000000000001');
expect(
adapter.subscriptionResourceKey(
Object.create({ portForwardId: '2041700000000000001' }),
),
).toBeNull();
expect(
adapter.subscriptionResourceKey({
portForwardId: 2041700000000000001,
}),
).toBeNull();
});
it('maps an absent mapping to the locked subscription and inspection code', async () => { it('maps an absent mapping to the locked subscription and inspection code', async () => {
const { adapter } = createHarness(); const { adapter } = createHarness();
const config = { const config = {
@ -264,19 +287,36 @@ describe('NetworkStunMessageSourceAdapter', () => {
}); });
}); });
it('preserves uppercase Network eligibility reasons in subscription options', async () => { it('returns generic options with the temporary legacy STUN fields', async () => {
const { adapter, mapping } = createHarness(); const { adapter, mapping } = createHarness();
mapping.protocol = 'tcp'; mapping.protocol = 'tcp';
await expect(adapter.listSubscriptionOptions()).resolves.toMatchObject({ await expect(adapter.listSubscriptionOptions()).resolves.toEqual({
ddnsRecords: [
{
dependsOnValue: mapping.id,
disabled: true,
disabledReasonCode: 'UDP_REQUIRED',
eligible: false,
fqdn: 'pal.kwitsukasa.top',
id: '2041700000000000002',
label: '帕鲁域名 · pal.kwitsukasa.top · UDP_REQUIRED',
name: '帕鲁域名',
portForwardId: mapping.id,
value: '2041700000000000002',
},
],
portForwards: [ portForwards: [
{ {
disabled: true,
disabledReasonCode: 'UDP_REQUIRED', disabledReasonCode: 'UDP_REQUIRED',
eligible: false, eligible: false,
externalPort: 8213, externalPort: 8213,
id: mapping.id, id: mapping.id,
internalPort: 8213, internalPort: 8213,
label: '帕鲁新世界 · TCP:8213 · UDP_REQUIRED',
name: '帕鲁新世界', name: '帕鲁新世界',
protocol: 'tcp', protocol: 'tcp',
value: mapping.id,
}, },
], ],
}); });

View File

@ -130,6 +130,7 @@ describe('QQBot core module contract', () => {
'GET /qqbot/accounts/:selfId/message-push/targets', 'GET /qqbot/accounts/:selfId/message-push/targets',
'GET /qqbot/message-push/sources', 'GET /qqbot/message-push/sources',
'GET /qqbot/message-push/sources/:sourceKey', 'GET /qqbot/message-push/sources/:sourceKey',
'GET /qqbot/message-push/sources/:sourceKey/subscription-options',
'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options', 'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options',
'GET /qqbot/message-push/subscriptions', 'GET /qqbot/message-push/subscriptions',
'GET /qqbot/message-push/templates', 'GET /qqbot/message-push/templates',

View File

@ -55,6 +55,12 @@ const EXPECTED_ROUTE_PERMISSIONS: Record<string, string[]> = {
], ],
'GET /qqbot/message-push/sources': SOURCE_READ_PERMISSIONS, 'GET /qqbot/message-push/sources': SOURCE_READ_PERMISSIONS,
'GET /qqbot/message-push/sources/:sourceKey': SOURCE_READ_PERMISSIONS, 'GET /qqbot/message-push/sources/:sourceKey': SOURCE_READ_PERMISSIONS,
'GET /qqbot/message-push/sources/:sourceKey/subscription-options': [
'QqBot:MessageSubscription:Create',
'QqBot:MessageSubscription:Update',
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
],
'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options': [ 'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options': [
'QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:Create',
'QqBot:MessageSubscription:Update', 'QqBot:MessageSubscription:Update',
@ -102,6 +108,7 @@ const EXPECTED_ROUTE_PERMISSIONS: Record<string, string[]> = {
}; };
const STRING_ID = '123456789012345678901234'; const STRING_ID = '123456789012345678901234';
const FORBIDDEN_FIELD_FIXTURE = 'test-only-redacted-value';
const SELF_ID = '12345'; const SELF_ID = '12345';
const pinoLogger = { const pinoLogger = {
@ -166,7 +173,7 @@ const sourceDefinition = () =>
], ],
variables: [ variables: [
{ {
accessToken: 'must-not-leak', accessToken: FORBIDDEN_FIELD_FIXTURE,
description: 'Endpoint', description: 'Endpoint',
example: 'example.test:10000', example: 'example.test:10000',
key: 'endpoint', key: 'endpoint',
@ -330,24 +337,31 @@ describe('QQBot message-push management controllers', () => {
ddnsRecords: [ ddnsRecords: [
{ {
credential: 'must-not-leak', credential: 'must-not-leak',
dependsOnValue: '1234567890123456789',
disabled: false,
disabledReasonCode: null, disabledReasonCode: null,
eligible: true, eligible: true,
fqdn: 'example.test', fqdn: 'example.test',
id: STRING_ID, id: STRING_ID,
label: 'DDNS · example.test',
name: 'DDNS', name: 'DDNS',
portForwardId: '1234567890123456789', portForwardId: '1234567890123456789',
value: STRING_ID,
}, },
], ],
portForwards: [ portForwards: [
{ {
accessToken: 'must-not-leak', accessToken: FORBIDDEN_FIELD_FIXTURE,
disabled: false,
disabledReasonCode: null, disabledReasonCode: null,
eligible: true, eligible: true,
externalPort: 10000, externalPort: 10_000,
id: '1234567890123456789', id: '1234567890123456789',
internalPort: 10000, internalPort: 8211,
label: 'STUN · UDP:10000',
name: 'STUN', name: 'STUN',
protocol: 'udp', protocol: 'udp',
value: '1234567890123456789',
}, },
], ],
}), }),
@ -386,7 +400,7 @@ describe('QQBot message-push management controllers', () => {
await app?.close(); await app?.close();
}); });
it('exposes exactly the approved 20 routes with exact permission metadata', () => { it('exposes exactly the approved 21 routes with exact permission metadata', () => {
const routes = collectControllerRoutes([ const routes = collectControllerRoutes([
QqbotMessagePushController, QqbotMessagePushController,
QqbotAccountMessagePushController, QqbotAccountMessagePushController,
@ -412,6 +426,76 @@ describe('QQBot message-push management controllers', () => {
}); });
}); });
it('resolves options and subscription config from arbitrary source metadata', async () => {
const definition = {
description: '后台任务完成',
displayName: '任务完成',
sourceKey: 'system.job.completed',
subscriptionFields: [
{
key: 'jobId',
label: '任务',
optionCollection: 'jobs',
required: true,
type: 'select',
},
],
variables: [],
version: 1,
};
registry.get.mockReturnValueOnce({
definition,
listSubscriptionOptions: jest.fn().mockResolvedValue({
jobs: [
{
credential: 'must-not-leak',
disabled: false,
disabledReasonCode: null,
label: '夜间同步',
value: 'job-1',
},
],
}),
});
const options = await request(apiUrl)
.get(
'/qqbot/message-push/sources/system.job.completed/subscription-options',
)
.expect(200);
expect(options.body.data).toEqual({
jobs: [
{
disabled: false,
disabledReasonCode: null,
label: '夜间同步',
value: 'job-1',
},
],
});
subscriptions.page.mockResolvedValueOnce({
items: [
{
...(subscriptionView() as unknown as Record<string, unknown>),
sourceConfig: {
credential: 'must-not-leak',
jobId: 'job-1',
},
sourceKey: definition.sourceKey,
sourceName: definition.displayName,
},
],
total: 1,
});
registry.get.mockReturnValueOnce({ definition });
const subscriptionsResponse = await request(apiUrl)
.get('/qqbot/message-push/subscriptions')
.expect(200);
expect(subscriptionsResponse.body.data.items[0].sourceConfig).toEqual({
jobId: 'job-1',
});
});
it('uses page wrappers only for subscription and template pages', async () => { it('uses page wrappers only for subscription and template pages', async () => {
const sourceResponse = await request(apiUrl) const sourceResponse = await request(apiUrl)
.get('/qqbot/message-push/sources') .get('/qqbot/message-push/sources')
@ -601,10 +685,10 @@ describe('QQBot message-push management controllers', () => {
.expect(400); .expect(400);
}); });
it('rejects unknown enabled, sourceConfig, and target fields', async () => { it('rejects unknown enabled and target fields plus non-string source config', async () => {
await request(apiUrl) await request(apiUrl)
.put(`/qqbot/message-push/subscriptions/${STRING_ID}/enabled`) .put(`/qqbot/message-push/subscriptions/${STRING_ID}/enabled`)
.send({ enabled: false, secret: 'must-reject' }) .send({ enabled: false, secret: FORBIDDEN_FIELD_FIXTURE })
.expect(400); .expect(400);
await request(apiUrl) await request(apiUrl)
.post('/qqbot/message-push/subscriptions') .post('/qqbot/message-push/subscriptions')
@ -612,14 +696,14 @@ describe('QQBot message-push management controllers', () => {
...subscriptionBody(), ...subscriptionBody(),
sourceConfig: { sourceConfig: {
...subscriptionBody().sourceConfig, ...subscriptionBody().sourceConfig,
credential: 'must-reject', credential: 123,
}, },
}) })
.expect(400); .expect(400);
const binding = bindingBody(); const binding = bindingBody();
binding.targets[0] = { binding.targets[0] = {
...binding.targets[0], ...binding.targets[0],
accessToken: 'must-reject', accessToken: FORBIDDEN_FIELD_FIXTURE,
} as never; } as never;
await request(apiUrl) await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
@ -771,7 +855,7 @@ describe('QQBot message-push management controllers', () => {
it('returns only exact public response fields and recursively excludes secrets', async () => { it('returns only exact public response fields and recursively excludes secrets', async () => {
targets.listTargetOptions.mockResolvedValueOnce({ targets.listTargetOptions.mockResolvedValueOnce({
accessToken: 'must-not-leak', accessToken: FORBIDDEN_FIELD_FIXTURE,
available: true, available: true,
options: [ options: [
{ {
@ -788,6 +872,11 @@ describe('QQBot message-push management controllers', () => {
request(apiUrl) request(apiUrl)
.get('/qqbot/message-push/sources/network.stun.mapping-port-changed') .get('/qqbot/message-push/sources/network.stun.mapping-port-changed')
.expect(200), .expect(200),
request(apiUrl)
.get(
'/qqbot/message-push/sources/network.stun.mapping-port-changed/subscription-options',
)
.expect(200),
request(apiUrl) request(apiUrl)
.get( .get(
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options', '/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
@ -813,6 +902,7 @@ describe('QQBot message-push management controllers', () => {
sourceList, sourceList,
sourceDetail, sourceDetail,
options, options,
legacyStunOptions,
subscriptionPage, subscriptionPage,
templatePage, templatePage,
preview, preview,
@ -854,6 +944,18 @@ describe('QQBot message-push management controllers', () => {
'portForwards', 'portForwards',
]); ]);
expect(Object.keys(options.ddnsRecords[0]).sort()).toEqual( expect(Object.keys(options.ddnsRecords[0]).sort()).toEqual(
[
'dependsOnValue',
'disabled',
'disabledReasonCode',
'label',
'value',
].sort(),
);
expect(Object.keys(options.portForwards[0]).sort()).toEqual(
['disabled', 'disabledReasonCode', 'label', 'value'].sort(),
);
expect(Object.keys(legacyStunOptions.ddnsRecords[0]).sort()).toEqual(
[ [
'disabledReasonCode', 'disabledReasonCode',
'eligible', 'eligible',
@ -863,7 +965,7 @@ describe('QQBot message-push management controllers', () => {
'portForwardId', 'portForwardId',
].sort(), ].sort(),
); );
expect(Object.keys(options.portForwards[0]).sort()).toEqual( expect(Object.keys(legacyStunOptions.portForwards[0]).sort()).toEqual(
[ [
'disabledReasonCode', 'disabledReasonCode',
'eligible', 'eligible',

View File

@ -84,10 +84,27 @@ function adapter(
description: 'STUN 映射端口变化', description: 'STUN 映射端口变化',
displayName: 'STUN 端口变化', displayName: 'STUN 端口变化',
sourceKey: SOURCE_KEY, sourceKey: SOURCE_KEY,
subscriptionFields: [], subscriptionFields: [
{
key: 'portForwardId',
label: '端口转发',
optionCollection: 'portForwards',
required: true,
type: 'select',
},
{
dependsOn: 'portForwardId',
key: 'ddnsRecordId',
label: 'IPv4 DDNS',
optionCollection: 'ddnsRecords',
required: true,
type: 'select',
},
],
variables: [], variables: [],
version: 1, version: 1,
}, },
eventResourceKey: jest.fn(),
inspectSubscription: jest.fn(async (config: Record<string, unknown>) => { inspectSubscription: jest.fn(async (config: Record<string, unknown>) => {
void config; void config;
return inspect; return inspect;
@ -102,6 +119,7 @@ function adapter(
}; };
}), }),
resolveDelivery: jest.fn(), resolveDelivery: jest.fn(),
subscriptionResourceKey: jest.fn(),
validateEventPayload: jest.fn(), validateEventPayload: jest.fn(),
}; };
} }
@ -371,7 +389,7 @@ describe('QqbotMessageSubscriptionService', () => {
enabled: true, enabled: true,
name: '帕鲁端口变更', name: '帕鲁端口变更',
remark: '', remark: '',
sourceConfig: { ...CONFIG, ignored: 'discarded' }, sourceConfig: CONFIG,
sourceKey: SOURCE_KEY, sourceKey: SOURCE_KEY,
}), }),
).resolves.toEqual(expect.objectContaining({ sourceConfig: CONFIG })); ).resolves.toEqual(expect.objectContaining({ sourceConfig: CONFIG }));
@ -383,10 +401,29 @@ describe('QqbotMessageSubscriptionService', () => {
expect(saved.activeKey).toBe(`${SOURCE_KEY}:${expectedDigest}`); expect(saved.activeKey).toBe(`${SOURCE_KEY}:${expectedDigest}`);
expect(source.normalizeSubscriptionConfig).toHaveBeenCalledWith({ expect(source.normalizeSubscriptionConfig).toHaveBeenCalledWith({
...CONFIG, ...CONFIG,
ignored: 'discarded',
}); });
}); });
it('rejects source config fields that are missing, unknown, or non-string', async () => {
const { service, source } = setup();
for (const sourceConfig of [
{ portForwardId: CONFIG.portForwardId },
{ ...CONFIG, unexpected: 'must-reject' },
{ ...CONFIG, ddnsRecordId: 2041700000000000002 },
]) {
await expect(
service.create({
enabled: true,
name: '非法配置',
sourceConfig,
sourceKey: SOURCE_KEY,
}),
).rejects.toMatchObject({ code: 'invalid_source_config' });
}
expect(source.normalizeSubscriptionConfig).not.toHaveBeenCalled();
});
it('returns Vben HTTP 409 for an active duplicate and a duplicate-key create race', async () => { it('returns Vben HTTP 409 for an active duplicate and a duplicate-key create race', async () => {
const existing = subscription(); const existing = subscription();
const { service } = setup([existing]); const { service } = setup([existing]);

View File

@ -96,10 +96,12 @@ function registry(): SystemMessageSourceRegistry {
], ],
version: 1, version: 1,
}, },
eventResourceKey: jest.fn(),
inspectSubscription: jest.fn(), inspectSubscription: jest.fn(),
listSubscriptionOptions: jest.fn(), listSubscriptionOptions: jest.fn(),
normalizeSubscriptionConfig: jest.fn(), normalizeSubscriptionConfig: jest.fn(),
resolveDelivery: jest.fn(), resolveDelivery: jest.fn(),
subscriptionResourceKey: jest.fn(),
validateEventPayload: jest.fn(), validateEventPayload: jest.fn(),
}); });
return value; return value;

View File

@ -235,6 +235,7 @@ function setup(seed: Partial<Store> = {}) {
], ],
version: 1, version: 1,
}, },
eventResourceKey: jest.fn(),
inspectSubscription: jest.fn(), inspectSubscription: jest.fn(),
listSubscriptionOptions: jest.fn(), listSubscriptionOptions: jest.fn(),
normalizeSubscriptionConfig: jest.fn(), normalizeSubscriptionConfig: jest.fn(),
@ -250,6 +251,7 @@ function setup(seed: Partial<Store> = {}) {
}; };
}, },
), ),
subscriptionResourceKey: jest.fn(),
validateEventPayload: jest.fn( validateEventPayload: jest.fn(
(payload) => payload as Record<string, boolean | null | number | string>, (payload) => payload as Record<string, boolean | null | number | string>,
), ),

View File

@ -20,10 +20,12 @@ function createRegistry() {
variables: [], variables: [],
version: 1, version: 1,
}, },
eventResourceKey: jest.fn(),
inspectSubscription: jest.fn(), inspectSubscription: jest.fn(),
listSubscriptionOptions: jest.fn(), listSubscriptionOptions: jest.fn(),
normalizeSubscriptionConfig: jest.fn(), normalizeSubscriptionConfig: jest.fn(),
resolveDelivery: jest.fn(), resolveDelivery: jest.fn(),
subscriptionResourceKey: jest.fn(),
validateEventPayload, validateEventPayload,
}); });
return { registry, validateEventPayload }; return { registry, validateEventPayload };

View File

@ -23,6 +23,7 @@ import {
const NOW = new Date('2026-07-24T00:00:00.000Z'); const NOW = new Date('2026-07-24T00:00:00.000Z');
const SOURCE_KEY = 'network.stun.mapping-port-changed'; const SOURCE_KEY = 'network.stun.mapping-port-changed';
const JOB_SOURCE_KEY = 'system.job.completed';
const RESOURCE_KEY = '9007199254740993'; const RESOURCE_KEY = '9007199254740993';
type Store = { type Store = {
@ -121,16 +122,21 @@ function target(
}); });
} }
function sourceAdapter(): jest.Mocked<SystemMessageSourceAdapter> { function sourceAdapter(
sourceKey = SOURCE_KEY,
): jest.Mocked<SystemMessageSourceAdapter> {
return { return {
definition: { definition: {
description: 'test', description: 'test',
displayName: 'test', displayName: 'test',
sourceKey: SOURCE_KEY, sourceKey,
subscriptionFields: [], subscriptionFields: [],
variables: [], variables: [],
version: 1, version: 1,
}, },
eventResourceKey: jest.fn((payload) =>
typeof payload.portForwardId === 'string' ? payload.portForwardId : '',
),
inspectSubscription: jest.fn(), inspectSubscription: jest.fn(),
listSubscriptionOptions: jest.fn(), listSubscriptionOptions: jest.fn(),
normalizeSubscriptionConfig: jest.fn(), normalizeSubscriptionConfig: jest.fn(),
@ -142,6 +148,12 @@ function sourceAdapter(): jest.Mocked<SystemMessageSourceAdapter> {
variables: { endpoint: 'pal.example.com:38213' }, variables: { endpoint: 'pal.example.com:38213' },
}; };
}), }),
subscriptionResourceKey: jest.fn((config) =>
Object.prototype.hasOwnProperty.call(config, 'portForwardId') &&
typeof config.portForwardId === 'string'
? config.portForwardId
: null,
),
validateEventPayload: jest.fn( validateEventPayload: jest.fn(
(payload) => payload as Record<string, boolean | null | number | string>, (payload) => payload as Record<string, boolean | null | number | string>,
), ),
@ -219,7 +231,7 @@ function normalizeConditionalWhere(where: Record<string, unknown>) {
); );
} }
function setup(seed: Partial<Store> = {}) { function setup(seed: Partial<Store> = {}, adapter = sourceAdapter()) {
let state: Store = { let state: Store = {
accounts: seed.accounts ?? [account()], accounts: seed.accounts ?? [account()],
bindings: seed.bindings ?? [binding()], bindings: seed.bindings ?? [binding()],
@ -229,7 +241,6 @@ function setup(seed: Partial<Store> = {}) {
targets: seed.targets ?? [target()], targets: seed.targets ?? [target()],
templates: seed.templates ?? [template()], templates: seed.templates ?? [template()],
}; };
const adapter = sourceAdapter();
const registry = new SystemMessageSourceRegistry(); const registry = new SystemMessageSourceRegistry();
registry.register(adapter); registry.register(adapter);
const query = { const query = {
@ -830,6 +841,42 @@ describe('SystemMessageFanoutService', () => {
expect(fixture.adapter.resolveDelivery).toHaveBeenCalledTimes(1); expect(fixture.adapter.resolveDelivery).toHaveBeenCalledTimes(1);
}); });
it('matches a synthetic source through adapter-owned job resource keys', async () => {
const jobAdapter = sourceAdapter(JOB_SOURCE_KEY);
jobAdapter.eventResourceKey.mockImplementation((payload) =>
typeof payload.jobId === 'string' ? payload.jobId : '',
);
jobAdapter.subscriptionResourceKey.mockImplementation((config) =>
Object.prototype.hasOwnProperty.call(config, 'jobId') &&
typeof config.jobId === 'string'
? config.jobId
: null,
);
const fixture = setup(
{
events: [
event({
payload: { endpoint: 'job.example.com:443', jobId: RESOURCE_KEY },
sourceKey: JOB_SOURCE_KEY,
}),
],
subscriptions: [
subscription({
sourceConfig: { jobId: RESOURCE_KEY },
sourceKey: JOB_SOURCE_KEY,
}),
],
templates: [template({ sourceKey: JOB_SOURCE_KEY })],
},
jobAdapter,
);
await expect(fixture.service.runOnce(NOW)).resolves.toBe(1);
expect(fixture.adapter.resolveDelivery).toHaveBeenCalledTimes(1);
expect(fixture.deliveries()).toHaveLength(1);
expect(fixture.events()[0].fanoutStatus).toBe('completed');
});
it('isolates disabled/deleted or mismatched account, binding, template and target rows', async () => { it('isolates disabled/deleted or mismatched account, binding, template and target rows', async () => {
const fixture = setup({ const fixture = setup({
accounts: [ accounts: [

View File

@ -11,10 +11,12 @@ function createAdapter(sourceKey: string): SystemMessageSourceAdapter {
variables: [], variables: [],
version: 1, version: 1,
}, },
eventResourceKey: jest.fn(),
inspectSubscription: jest.fn(), inspectSubscription: jest.fn(),
listSubscriptionOptions: jest.fn(), listSubscriptionOptions: jest.fn(),
normalizeSubscriptionConfig: jest.fn(), normalizeSubscriptionConfig: jest.fn(),
resolveDelivery: jest.fn(), resolveDelivery: jest.fn(),
subscriptionResourceKey: jest.fn(),
validateEventPayload: jest.fn(), validateEventPayload: jest.fn(),
}; };
} }