fix: 通用化系统消息订阅来源契约
This commit is contained in:
parent
ba777ff207
commit
ceedb295a1
17
API.md
17
API.md
@ -130,7 +130,7 @@ 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` |
|
||||||
@ -362,7 +362,7 @@ 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` |
|
||||||
@ -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 是 1–24 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1–100 字符且不能全空白,`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`;模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1–100 个严格嵌套 target,类型仅 `group/private`,`targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝,query boolean 只接受字面量 `true/false`。
|
请求采用严格白名单:Snowflake/外键 ID 是 1–24 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1–100 字符且不能全空白;`sourceConfig` 必须是仅含字符串值的普通对象,其字段集合、必填项和领域校验由所选 `sourceKey` 的 `subscriptionFields` 与 adapter 决定,未知字段会拒绝。模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1–100 个严格嵌套 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 发布接口。
|
||||||
|
|
||||||
#### 内部事件与投递生命周期
|
#### 内部事件与投递生命周期
|
||||||
|
|
||||||
@ -466,7 +467,7 @@ 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 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。
|
||||||
@ -624,7 +625,7 @@ 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` 内去重。
|
||||||
@ -632,7 +633,7 @@ Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修
|
|||||||
可配置键:
|
可配置键:
|
||||||
|
|
||||||
| 配置键 | 默认值 | 说明 |
|
| 配置键 | 默认值 | 说明 |
|
||||||
| ------------------------------------------- | ------ | -------------------- |
|
| ------------------------------------- | ------ | -------------------- |
|
||||||
| `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 | 同视频去重毫秒 |
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
## 功能模块
|
## 功能模块
|
||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
|
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
|
||||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||||
@ -57,7 +57,7 @@ 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` |
|
||||||
@ -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 小时为截止时间。
|
||||||
|
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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]) =>
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
): MessageSubscriptionView => {
|
||||||
|
const input =
|
||||||
|
view.sourceConfig &&
|
||||||
|
typeof view.sourceConfig === 'object' &&
|
||||||
|
!Array.isArray(view.sourceConfig)
|
||||||
|
? view.sourceConfig
|
||||||
|
: {};
|
||||||
|
const sourceConfig = Object.fromEntries(
|
||||||
|
definition.subscriptionFields.flatMap((field) => {
|
||||||
|
const value = input[field.key];
|
||||||
|
return Object.prototype.hasOwnProperty.call(input, field.key) &&
|
||||||
|
typeof value === 'string'
|
||||||
|
? [[field.key, value]]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
createTime: view.createTime,
|
createTime: view.createTime,
|
||||||
enabled: view.enabled,
|
enabled: view.enabled,
|
||||||
id: view.id,
|
id: view.id,
|
||||||
invalidReasonCode: view.invalidReasonCode,
|
invalidReasonCode: view.invalidReasonCode,
|
||||||
name: view.name,
|
name: view.name,
|
||||||
remark: view.remark,
|
remark: view.remark,
|
||||||
sourceConfig: {
|
sourceConfig,
|
||||||
ddnsRecordId: view.sourceConfig.ddnsRecordId,
|
|
||||||
portForwardId: view.sourceConfig.portForwardId,
|
|
||||||
},
|
|
||||||
sourceKey: view.sourceKey,
|
sourceKey: view.sourceKey,
|
||||||
sourceName: view.sourceName,
|
sourceName: view.sourceName,
|
||||||
sourceSummary: view.sourceSummary,
|
sourceSummary: view.sourceSummary,
|
||||||
updateTime: view.updateTime,
|
updateTime: view.updateTime,
|
||||||
valid: view.valid,
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user