diff --git a/API.md b/API.md index c29beaa..afa293c 100644 --- a/API.md +++ b/API.md @@ -103,13 +103,13 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 | `GET` | `/system/network/port-forward/:id/endpoint-history` | `super` | 查询端点状态变化历史 | | `GET` | `/system/network/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 | | `GET` | `/system/network/events/stream` | `super` | SSE 推送已提交的 MQTT 状态变化 | -| `GET` | `/system/network/ddns/list` | `super` | 分页查询双栈自动 DDNS 绑定 | -| `GET` | `/system/network/ddns/source-options` | `super` | 查询 A/AAAA 的安全地址来源选项 | -| `GET` | `/system/network/ddns/provider-status` | `super` | 查询腾讯云云解析 DNS 配置状态,不返回凭据 | -| `POST` | `/system/network/ddns` | `super` | 新增本地 DDNS 自动更新绑定 | -| `PUT` | `/system/network/ddns/:id` | `super` | 修改并按需立即协调 DDNS 绑定 | -| `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 | -| `POST` | `/system/network/ddns/:id/retry` | `super` | 手动重试一条已启用 DDNS 绑定 | +| `GET` | `/system/network/ddns/list` | `super` | 分页查询双栈自动 DDNS 绑定 | +| `GET` | `/system/network/ddns/source-options` | `super` | 查询 A/AAAA 的安全地址来源选项 | +| `GET` | `/system/network/ddns/provider-status` | `super` | 查询腾讯云云解析 DNS 配置状态,不返回凭据 | +| `POST` | `/system/network/ddns` | `super` | 新增本地 DDNS 自动更新绑定 | +| `PUT` | `/system/network/ddns/:id` | `super` | 修改并按需立即协调 DDNS 绑定 | +| `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 | +| `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`。 @@ -129,21 +129,21 @@ Agent 状态响应额外包含可选的 `currentPublicIpv6/currentIpv6ObservedAt ## 环境变量分组 -| 分组 | 关键变量 | -| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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` | -| 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` | -| 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_*` | -| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | -| 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_*` | +| 分组 | 关键变量 | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 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` | +| 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` | +| 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_*` | +| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | +| 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_*` | | 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` | -| 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` | +| 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` | +| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | 真实密码、Token、OAuth secret 和生产 env 不提交到 Git。 @@ -361,17 +361,17 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退 ## MinIO -| 方法 | 路径 | 说明 | -| -------- | ----------------------- | ------------------------------- | -| `GET` | `/minio/check` | 检查连接和 bucket | -| `POST` | `/minio/bucket` | 创建 bucket | -| `POST` | `/minio/upload` | 上传文件,`multipart/form-data` | -| `GET` | `/minio/list` | 文件列表 | -| `GET` | `/minio/url` | 临时访问 URL | -| `GET` | `/minio/resource-proxy` | 代理读取资源 | -| `GET` | `/minio/download` | 下载文件流 | -| `DELETE` | `/minio/remove` | 删除文件 | -| `GET` | `/blog/live2d/:character/catalog.json` | 公开读取 Pio/Tia Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 | +| 方法 | 路径 | 说明 | +| -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/minio/check` | 检查连接和 bucket | +| `POST` | `/minio/bucket` | 创建 bucket | +| `POST` | `/minio/upload` | 上传文件,`multipart/form-data` | +| `GET` | `/minio/list` | 文件列表 | +| `GET` | `/minio/url` | 临时访问 URL | +| `GET` | `/minio/resource-proxy` | 代理读取资源 | +| `GET` | `/minio/download` | 下载文件流 | +| `DELETE` | `/minio/remove` | 删除文件 | +| `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 白名单防盗链 | `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/: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` | | `POST` | `/qqbot/message-push/subscriptions` | Subscription `Create` | | `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 }`。 -请求采用严格白名单: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,且不返回其内部细节。 -响应仅返回管理契约字段: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 -| 方法 | 路径 | 说明 | -| ----- | ----------------------------------------- | ------------------------------------------------------------------------------------- | +| 方法 | 路径 | 说明 | +| ----- | ----------------------------------------- | ------------------------------------------------------------------------------------------- | | `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 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 @@ -623,20 +624,20 @@ Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修 插件 key:`bilibili-card`。这是事件型内置插件,不新增在线命令;启用后仍需通过账号事件绑定让指定 QQBot 账号接收 `bilibili-card.message`。 -| event key | 触发来源 | 说明 | -| ------------------------ | -------- | -------------------------------------------------------------------- | -| `bilibili-card.message` | message | 从 QQ/NapCat `share/json/xml/lightapp` 卡片和文本中提取 Bilibili 链接 | +| event key | 触发来源 | 说明 | +| ----------------------- | -------- | --------------------------------------------------------------------- | +| `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` 内去重。 可配置键: -| 配置键 | 默认值 | 说明 | -| ------------------------------------------- | ------ | -------------------- | -| `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS` | 6000 | HTTP 请求超时毫秒 | -| `QQBOT_BILIBILI_CARD_MAX_REDIRECTS` | 5 | `b23.tv` 最大跳转数 | -| `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` | 600000 | 同视频去重毫秒 | -| `QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH` | 80 | 回复中简介最大字符数 | +| 配置键 | 默认值 | 说明 | +| ------------------------------------- | ------ | -------------------- | +| `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS` | 6000 | HTTP 请求超时毫秒 | +| `QQBOT_BILIBILI_CARD_MAX_REDIRECTS` | 5 | `b23.tv` 最大跳转数 | +| `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` | 600000 | 同视频去重毫秒 | +| `QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH` | 80 | 回复中简介最大字符数 | ### BangDream diff --git a/README.md b/README.md index 2a968ed..2f2027d 100644 --- a/README.md +++ b/README.md @@ -16,19 +16,19 @@ ## 功能模块 -| 模块 | 说明 | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 | -| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | -| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | +| 模块 | 说明 | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 | +| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | +| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | | `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、系统消息源/订阅/模板/账号绑定、耐久投递、发送/接收日志和插件平台 | -| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 | -| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | -| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 | -| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | -| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | -| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除,以及 Blog Live2D 运行包受控读取入口 | -| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 | +| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 | +| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | +| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 | +| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | +| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | +| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除,以及 Blog Live2D 运行包受控读取入口 | +| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 | ## 目录结构 @@ -56,19 +56,19 @@ ci/ Jenkins Agent/Docker 辅助文件 主要配置分组: -| 分组 | 变量 | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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` | -| 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` | -| 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_*` | -| 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_*` | +| 分组 | 变量 | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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` | +| 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` | +| 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_*` | +| 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` | -| 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` | -| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | +| 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` | +| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | `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 的技术服务名。 -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 小时为截止时间。 diff --git a/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts b/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts index f878460..cef9e2f 100644 --- a/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts +++ b/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts @@ -10,7 +10,9 @@ import { SystemMessageContractError, type SystemMessageDeliveryReadiness, type SystemMessageScalar, + type SystemMessageSourceAdapter, type SystemMessageSourceDefinition, + type SystemMessageSourceOptionsResponse, } 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 { NetworkDdnsRecord } from './network-ddns.entity'; @@ -44,7 +46,7 @@ type ResolvedSubscription = { @Injectable() export class NetworkStunMessageSourceAdapter - implements OnModuleDestroy, OnModuleInit + implements OnModuleDestroy, OnModuleInit, SystemMessageSourceAdapter { private registered = false; @@ -143,6 +145,17 @@ export class NetworkStunMessageSourceAdapter this.registered = false; } + /** 从已校验事件中提取 STUN 映射的稳定资源键。 */ + eventResourceKey(payload: Record): 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<{ canonicalConfig: Record; resourceKey: string; @@ -178,7 +191,8 @@ export class NetworkStunMessageSourceAdapter } } - async listSubscriptionOptions(): Promise> { + /** 将网络实体转换成供动态订阅表单使用的标准选项。 */ + async listSubscriptionOptions(): Promise { const [mappings, records] = await Promise.all([ this.mappingRepository.find({ order: { id: 'ASC', name: 'ASC' } }), this.ddnsRepository.find({ order: { id: 'ASC', name: 'ASC' } }), @@ -193,32 +207,61 @@ export class NetworkStunMessageSourceAdapter : undefined; const disabledReasonCode = ddnsOptionReason(record, mapping); return { + ...(record.portForwardId + ? { dependsOnValue: String(record.portForwardId) } + : {}), + disabled: disabledReasonCode !== null, disabledReasonCode, eligible: disabledReasonCode === null, fqdn: ddnsFqdn(record), id: String(record.id), + label: [record.name, ddnsFqdn(record), disabledReasonCode] + .filter((value) => value !== null) + .join(' · '), name: record.name, portForwardId: record.portForwardId ? String(record.portForwardId) : '', + value: String(record.id), }; }), portForwards: mappings.map((mapping) => { const { disabledReasonCode, eligible } = classifyStunEndpointSource(mapping); return { + disabled: !eligible, disabledReasonCode, eligible, externalPort: mapping.externalPort, id: String(mapping.id), internalPort: mapping.internalPort, + label: [ + mapping.name, + `${mapping.protocol.toUpperCase()}:${mapping.externalPort}`, + disabledReasonCode, + ] + .filter((value) => value !== null) + .join(' · '), name: mapping.name, protocol: mapping.protocol, + value: String(mapping.id), }; }), }; } + /** 从订阅配置中提取自有的字符串资源键,无效配置不参与匹配。 */ + subscriptionResourceKey(config: Record): null | string { + if ( + !isPlainRecord(config) || + !Object.prototype.hasOwnProperty.call(config, 'portForwardId') || + typeof config.portForwardId !== 'string' + ) { + return null; + } + return config.portForwardId; + } + validateEventPayload( payload: Record, ): Record { diff --git a/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts b/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts index f7ca74f..2a80951 100644 --- a/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts +++ b/src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service.ts @@ -14,6 +14,7 @@ import { type MessageSubscriptionInput, type MessageSubscriptionListQuery, type MessageSubscriptionView, + type SystemMessageSourceDefinition, } from '../../contract/message-push/qqbot-message-push.types'; import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity'; import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity'; @@ -238,12 +239,17 @@ export class QqbotMessageSubscriptionService { return subscription; } + /** 校验来源配置后生成稳定摘要和持久化字段。 */ private async normalizeInput( input: MessageSubscriptionInput, ): Promise { const adapter = this.sourceRegistry.get(input.sourceKey); - const normalized = await adapter.normalizeSubscriptionConfig( + const validatedSourceConfig = this.validateSourceConfig( input.sourceConfig, + adapter.definition, + ); + const normalized = await adapter.normalizeSubscriptionConfig( + validatedSourceConfig, ); const sourceConfig = this.sortConfig(normalized.canonicalConfig); const sourceConfigDigest = createHash('sha256') @@ -260,6 +266,35 @@ export class QqbotMessageSubscriptionService { }; } + /** 按来源元数据精确接收自有字符串字段,并拒绝缺失或额外字段。 */ + private validateSourceConfig( + input: Record, + definition: SystemMessageSourceDefinition, + ): Record { + 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 = {}; + 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): Record { return Object.fromEntries( Object.entries(config).sort(([left], [right]) => diff --git a/src/modules/qqbot/core/application/message-push/system-message-fanout.service.ts b/src/modules/qqbot/core/application/message-push/system-message-fanout.service.ts index 4bd73f3..e6623a2 100644 --- a/src/modules/qqbot/core/application/message-push/system-message-fanout.service.ts +++ b/src/modules/qqbot/core/application/message-push/system-message-fanout.service.ts @@ -4,7 +4,6 @@ import { KtDateTime } from '@/common'; import { SystemMessageContractError, type SystemMessageDeliveryReadiness, - type SystemMessageScalar, type SystemMessageSourceAdapter, } from '../../contract/message-push/qqbot-message-push.types'; import { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity'; @@ -25,7 +24,6 @@ import { import { SystemMessageSourceRegistry } from './system-message-source.registry'; 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 EVENT_EXPIRED_ERROR_CODE = 'fanout_expired'; const EVENT_RESOURCE_MISMATCH_ERROR_CODE = 'event_resource_mismatch'; @@ -114,8 +112,14 @@ export class SystemMessageFanoutService { try { const adapter = this.sourceRegistry.get(token.event.sourceKey); const payload = adapter.validateEventPayload(token.event.payload); - this.assertResourceIdentity(token.event, payload); - const subscriptions = await this.findMatchingSubscriptions(token.event); + this.assertResourceIdentity( + token.event, + adapter.eventResourceKey(payload), + ); + const subscriptions = await this.findMatchingSubscriptions( + token.event, + adapter, + ); let transientFailure = false; for (const subscription of subscriptions) { @@ -170,6 +174,7 @@ export class SystemMessageFanoutService { private async findMatchingSubscriptions( event: QqbotMessageEvent, + adapter: SystemMessageSourceAdapter, ): Promise { const subscriptions = await this.dataSource .getRepository(QqbotMessageSubscription) @@ -178,7 +183,7 @@ export class SystemMessageFanoutService { order: { id: 'ASC' }, }); return subscriptions.filter((subscription) => - this.matchesSubscription(subscription, event), + this.matchesSubscription(subscription, event, adapter), ); } @@ -210,12 +215,15 @@ export class SystemMessageFanoutService { where: { id: subscriptionId }, lock: { mode: 'pessimistic_write' }, }); - if (!subscription || !this.matchesSubscription(subscription, event)) { + if ( + !subscription || + !this.matchesSubscription(subscription, event, adapter) + ) { return 'handled'; } const payload = adapter.validateEventPayload(event.payload); - this.assertResourceIdentity(event, payload); + this.assertResourceIdentity(event, adapter.eventResourceKey(payload)); const readiness = await adapter.resolveDelivery({ eventPayload: payload, subscriptionConfig: subscription.sourceConfig, @@ -490,21 +498,21 @@ export class SystemMessageFanoutService { ); } + /** 确认适配器解析的事件资源与冻结事件身份一致。 */ private assertResourceIdentity( event: QqbotMessageEvent, - payload: Record, + resourceKey: string, ): void { - if ( - event.sourceKey === STUN_MAPPING_PORT_SOURCE && - payload.portForwardId !== event.resourceKey - ) { + if (resourceKey !== event.resourceKey) { throw new EventResourceMismatchError(); } } + /** 使用消息源适配器自己的资源键规则匹配订阅。 */ private matchesSubscription( subscription: QqbotMessageSubscription, event: QqbotMessageEvent, + adapter: SystemMessageSourceAdapter, ): boolean { const config = subscription.sourceConfig; return ( @@ -512,9 +520,7 @@ export class SystemMessageFanoutService { !subscription.isDeleted && subscription.sourceKey === event.sourceKey && !!config && - Object.prototype.hasOwnProperty.call(config, 'portForwardId') && - typeof config.portForwardId === 'string' && - config.portForwardId === event.resourceKey + adapter.subscriptionResourceKey(config) === event.resourceKey ); } @@ -569,6 +575,6 @@ export class SystemMessageFanoutService { class EventResourceMismatchError extends Error { constructor() { - super('validated port-forward identity does not match event resource'); + super('validated source identity does not match event resource'); } } diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts index 3e030c2..3fab4f8 100644 --- a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts @@ -25,6 +25,7 @@ import type { MessageTemplateView, StunMappingPortChangedOptionsResponse, SystemMessageSourceDefinition, + SystemMessageSourceOptionsResponse, } from './qqbot-message-push.types'; import { MessagePushEnabledDto, @@ -53,6 +54,15 @@ const SOURCE_READ_PERMISSIONS = [ 'QqBot:Account:MessagePush:Update', ] 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 = ( definition: SystemMessageSourceDefinition, ): SystemMessageSourceDefinition => ({ @@ -77,9 +87,33 @@ const cloneSourceDefinition = ( version: definition.version, }); -const allowlistStunOptions = ( - value: Record, -): StunMappingPortChangedOptionsResponse => { +const allowlistSourceOptions = ( + definition: SystemMessageSourceDefinition, + 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; return { ddnsRecords: input.ddnsRecords.map((item) => ({ @@ -100,27 +134,42 @@ const allowlistStunOptions = ( protocol: item.protocol, })), }; -}; +} const allowlistSubscription = ( view: MessageSubscriptionView, -): MessageSubscriptionView => ({ - createTime: view.createTime, - enabled: view.enabled, - id: view.id, - invalidReasonCode: view.invalidReasonCode, - name: view.name, - remark: view.remark, - sourceConfig: { - ddnsRecordId: view.sourceConfig.ddnsRecordId, - portForwardId: view.sourceConfig.portForwardId, - }, - sourceKey: view.sourceKey, - sourceName: view.sourceName, - sourceSummary: view.sourceSummary, - updateTime: view.updateTime, - valid: view.valid, -}); + 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, + 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 => ({ content: view.content, @@ -165,18 +214,23 @@ export class QqbotMessagePushController { 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') - @QqbotMessagePushPermission( - 'QqBot:MessageSubscription:Create', - 'QqBot:MessageSubscription:Update', - 'QqBot:Account:MessagePush:Create', - 'QqBot:Account:MessagePush:Update', - ) - async listStunSourceOptions() { + @QqbotMessagePushPermission(...SOURCE_OPTIONS_PERMISSIONS) + async listLegacyStunOptions() { const result = await this.sourceRegistry - .get('network.stun.mapping-port-changed') + .get(LEGACY_STUN_SOURCE_KEY) .listSubscriptionOptions(); - return vbenSuccess(allowlistStunOptions(result)); + return vbenSuccess(allowlistLegacyStunOptions(result)); } @Get('sources/:sourceKey') @@ -195,15 +249,27 @@ export class QqbotMessagePushController { ) async pageSubscriptions(@Query() query: MessageSubscriptionListQueryDto) { 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') @HttpCode(HttpStatus.OK) @QqbotMessagePushPermission('QqBot:MessageSubscription:Create') async createSubscription(@Body() body: MessageSubscriptionInputDto) { + const view = await this.subscriptionService.create(body); 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, @Body() body: MessageSubscriptionInputDto, ) { + const view = await this.subscriptionService.update(params.id, body); return vbenSuccess( 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, @Body() body: MessagePushEnabledDto, ) { + const view = await this.subscriptionService.setEnabled( + params.id, + body.enabled, + ); return vbenSuccess( allowlistSubscription( - await this.subscriptionService.setEnabled(params.id, body.enabled), + view, + this.sourceRegistry.get(view.sourceKey).definition, ), ); } diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts index 3aa1d55..ba9d5c8 100644 --- a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts @@ -15,6 +15,7 @@ import { Max, MaxLength, Min, + ValidateBy, ValidateNested, } 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}$/; const SOURCE_KEY_MAX_LENGTH = 128; +/** 判断消息源配置是否为仅含字符串值的普通键值对象。 */ +function isStringRecord(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value).every((item) => typeof item === 'string') + ); +} + const strictQueryBoolean = ({ value }: { value: unknown }): unknown => { if (value === 'true' || value === true) return true; if (value === 'false' || value === false) return false; @@ -86,18 +97,6 @@ export class MessageSubscriptionListQueryDto 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 { @IsString() @Length(1, 100) @@ -108,11 +107,13 @@ export class MessageSubscriptionInputDto { @Length(1, SOURCE_KEY_MAX_LENGTH) sourceKey: string; - @ValidateNested() - @Type(() => StunMappingPortChangedSubscriptionConfigDto) + @ValidateBy({ + name: 'isStringRecord', + validator: { validate: isStringRecord }, + }) @IsDefined() @IsObject() - sourceConfig: StunMappingPortChangedSubscriptionConfigDto; + sourceConfig: Record; @IsBoolean() enabled: boolean; diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts index 36187e3..ed27fd8 100644 --- a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts @@ -1,68 +1,359 @@ import type { EntityManager } from 'typeorm'; export type SystemMessageScalar = boolean | null | number | string; -export type SystemMessageFanoutStatus = 'accepted' | 'completed' | 'failed' | 'processing' | 'retry'; -export type SystemMessageDeliveryStatus = 'cancelled' | 'failed' | 'pending' | 'processing' | 'retry' | 'success' | 'superseded' | 'waiting_ddns'; +export type SystemMessageFanoutStatus = + | 'accepted' + | 'completed' + | 'failed' + | 'processing' + | 'retry'; +export type SystemMessageDeliveryStatus = + | 'cancelled' + | 'failed' + | 'pending' + | 'processing' + | 'retry' + | 'success' + | 'superseded' + | 'waiting_ddns'; export type QqbotMessagePushTargetType = 'group' | 'private'; -export type SystemMessageTemplateToken = { kind: 'text'; value: string } | { key: string; kind: 'variable' }; +export type SystemMessageTemplateToken = + | { kind: 'text'; value: string } + | { key: string; kind: 'variable' }; -export interface SystemMessageSourceVariableDefinition { description: string; example: string; key: string; label: string; type: 'boolean' | 'number' | 'string'; } -export interface SystemMessageSourceFieldDefinition { dependsOn?: string; key: string; label: string; optionCollection: 'ddnsRecords' | 'portForwards'; required: true; type: 'select'; } -export interface SystemMessageSourceDefinition { description: string; displayName: string; sourceKey: string; subscriptionFields: SystemMessageSourceFieldDefinition[]; variables: SystemMessageSourceVariableDefinition[]; version: 1; } -export interface StunMappingPortChangedSubscriptionConfig { ddnsRecordId: string; portForwardId: string; } -export interface StunMappingPortChangedOptionsResponse { ddnsRecords: Array<{ disabledReasonCode: null | string; eligible: boolean; fqdn: string; id: string; name: string; portForwardId: string; }>; portForwards: Array<{ disabledReasonCode: null | string; eligible: boolean; externalPort: number; id: string; internalPort: number; name: string; protocol: 'tcp' | 'udp'; }>; } +export interface SystemMessageSourceVariableDefinition { + description: string; + example: string; + key: string; + 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 MessageTemplateView { content: string; createTime: string; enabled: boolean; id: string; name: string; referenceCount: number; remark: null | string; sourceKey: string; sourceName: string; updateTime: string; } -export interface MessageTemplatePreview { renderedMessage: string; variables: Record; } -export interface QqbotMessagePublishTargetView { enabled: boolean; id: string; targetId: string; targetName: null | string; targetType: QqbotMessagePushTargetType; } -export interface QqbotMessagePublishBindingView { available: boolean; createTime: string; enabled: boolean; id: string; invalidReasonCode: null | string; sourceKey: string; sourceName: string; subscriptionId: string; subscriptionName: string; targets: QqbotMessagePublishTargetView[]; templateId: string; templateName: string; updateTime: string; } +export interface MessageSubscriptionView { + createTime: string; + enabled: boolean; + id: string; + invalidReasonCode: null | string; + name: string; + remark: null | string; + sourceConfig: Record; + sourceKey: string; + sourceName: string; + sourceSummary: string; + updateTime: string; + valid: boolean; +} +export interface MessageTemplateView { + content: string; + createTime: string; + enabled: boolean; + id: string; + name: string; + referenceCount: number; + remark: null | string; + sourceKey: string; + sourceName: string; + updateTime: string; +} +export interface MessageTemplatePreview { + renderedMessage: string; + variables: Record; +} +export interface QqbotMessagePublishTargetView { + enabled: boolean; + id: string; + targetId: string; + targetName: null | string; + targetType: QqbotMessagePushTargetType; +} +export interface QqbotMessagePublishBindingView { + available: boolean; + createTime: string; + enabled: boolean; + id: string; + invalidReasonCode: null | string; + sourceKey: string; + sourceName: string; + subscriptionId: string; + subscriptionName: string; + targets: QqbotMessagePublishTargetView[]; + templateId: string; + templateName: string; + updateTime: string; +} export type SystemMessageDeliveryReadiness = - | { reasonCode: null; status: 'ready'; variables: Record; } - | { reasonCode: 'ddns_not_synced'; status: 'waiting_ddns'; variables: Record; } - | { reasonCode: string; status: 'cancelled' | 'superseded'; variables?: never; }; + | { + reasonCode: null; + status: 'ready'; + variables: Record; + } + | { + reasonCode: 'ddns_not_synced'; + status: 'waiting_ddns'; + variables: Record; + } + | { + reasonCode: string; + status: 'cancelled' | 'superseded'; + variables?: never; + }; export interface SystemMessageSourceAdapter { readonly definition: SystemMessageSourceDefinition; - inspectSubscription(config: Record): Promise<{ invalidReasonCode: null | string; sourceSummary: string; valid: boolean; }>; - listSubscriptionOptions(): Promise>; - normalizeSubscriptionConfig(input: unknown): Promise<{ canonicalConfig: Record; resourceKey: string; sourceSummary: string; }>; - resolveDelivery(input: { eventPayload: Record; subscriptionConfig: Record; }): Promise; - validateEventPayload(payload: Record): Record; + eventResourceKey(payload: Record): string; + inspectSubscription(config: Record): Promise<{ + invalidReasonCode: null | string; + sourceSummary: string; + valid: boolean; + }>; + listSubscriptionOptions(): Promise; + normalizeSubscriptionConfig(input: unknown): Promise<{ + canonicalConfig: Record; + resourceKey: string; + sourceSummary: string; + }>; + resolveDelivery(input: { + eventPayload: Record; + subscriptionConfig: Record; + }): Promise; + subscriptionResourceKey(config: Record): null | string; + validateEventPayload( + payload: Record, + ): Record; } -export interface SystemMessageEventInput { eventId: string; occurredAt: string; payload: Record; resourceKey: string; sourceKey: string; } -export const SYSTEM_MESSAGE_EVENT_STAGER = Symbol('SYSTEM_MESSAGE_EVENT_STAGER'); -export interface SystemMessageEventStager { - stage(manager: EntityManager, input: SystemMessageEventInput): Promise<'accepted' | 'duplicate'>; +export interface SystemMessageEventInput { + eventId: string; + occurredAt: string; + payload: Record; + 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 { - notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise; + notifyDdnsSynced(input: { + appliedAddress: string; + ddnsRecordId: string; + }): Promise; requestDrain(): void; } -export interface MessageSubscriptionListQuery { enabled?: boolean; name?: string; pageNo?: number; pageSize?: number; sourceKey?: string; } -export interface MessageSubscriptionInput { enabled: boolean; name: string; remark?: string; sourceConfig: Record; sourceKey: string; } -export interface MessageTemplateListQuery { enabled?: boolean; name?: string; pageNo?: number; pageSize?: number; sourceKey?: string; } -export interface MessageTemplateInput { content: string; enabled: boolean; name: string; remark?: string; sourceKey: string; } -export interface QqbotMessagePublishTargetInput { targetId: string; targetName?: string; targetType: QqbotMessagePushTargetType; } -export interface QqbotMessagePublishBindingInput { enabled: boolean; subscriptionId: string; targets: QqbotMessagePublishTargetInput[]; templateId: string; } -export interface QqbotMessagePushTargetOption { label: string; targetId: string; targetType: QqbotMessagePushTargetType; } -export interface QqbotMessagePushTargetOptionsResponse { available: boolean; options: QqbotMessagePushTargetOption[]; reasonCode: null | string; } -export interface StrictPlainTextSendInput { attemptNumber: number; deliveryId: string; message: string; selfId: string; targetId: string; targetType: QqbotMessagePushTargetType; } -export interface QqbotSendAttemptErrorOptions { code: string; message: string; retryable: boolean; sendLogId: null | string; } +export interface MessageSubscriptionListQuery { + enabled?: boolean; + name?: string; + pageNo?: number; + pageSize?: number; + sourceKey?: string; +} +export interface MessageSubscriptionInput { + enabled: boolean; + name: string; + remark?: string; + sourceConfig: Record; + sourceKey: string; +} +export interface MessageTemplateListQuery { + enabled?: boolean; + name?: string; + pageNo?: number; + pageSize?: number; + sourceKey?: string; +} +export interface MessageTemplateInput { + content: string; + enabled: boolean; + name: string; + remark?: string; + sourceKey: string; +} +export interface QqbotMessagePublishTargetInput { + targetId: string; + targetName?: string; + targetType: QqbotMessagePushTargetType; +} +export interface QqbotMessagePublishBindingInput { + enabled: boolean; + subscriptionId: string; + targets: QqbotMessagePublishTargetInput[]; + templateId: string; +} +export interface QqbotMessagePushTargetOption { + label: string; + targetId: string; + targetType: QqbotMessagePushTargetType; +} +export interface QqbotMessagePushTargetOptionsResponse { + available: boolean; + options: QqbotMessagePushTargetOption[]; + reasonCode: null | string; +} +export interface StrictPlainTextSendInput { + attemptNumber: number; + deliveryId: string; + message: string; + selfId: string; + targetId: string; + targetType: QqbotMessagePushTargetType; +} +export interface QqbotSendAttemptErrorOptions { + code: string; + message: string; + retryable: boolean; + sendLogId: null | string; +} export class SystemMessageContractError extends Error { - 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 = { - binding: ['id', 'subscription_id', 'account_id', 'self_id', 'template_id', 'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time'], - delivery: ['id', 'message_event_id', 'publish_target_id', 'binding_id', 'subscription_id', 'self_id', 'target_type', 'target_id', 'template_id', 'template_content', 'variable_snapshot', 'rendered_message', 'status', 'attempt_count', 'next_attempt_at', 'processing_lease_until', 'send_log_id', 'last_error_code', 'last_error_message', 'expires_at', 'create_time', 'update_time'], - event: ['id', 'event_id', 'source_key', 'resource_key', 'occurred_at', 'payload', 'fanout_status', 'fanout_attempt_count', 'next_fanout_at', 'fanout_lease_until', 'last_error_code', 'last_error_message', 'create_time', 'update_time'], - subscription: ['id', 'name', 'source_key', 'source_config', 'source_config_digest', 'active_key', 'enabled', 'remark', 'is_deleted', 'create_time', 'update_time'], - target: ['id', 'binding_id', 'target_type', 'target_id', 'target_name', 'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time'], - template: ['id', 'name', 'source_key', 'content', 'enabled', 'remark', 'is_deleted', 'create_time', 'update_time'], + binding: [ + 'id', + 'subscription_id', + 'account_id', + 'self_id', + 'template_id', + 'active_key', + 'enabled', + 'is_deleted', + 'create_time', + 'update_time', + ], + delivery: [ + 'id', + 'message_event_id', + 'publish_target_id', + 'binding_id', + 'subscription_id', + 'self_id', + 'target_type', + 'target_id', + 'template_id', + 'template_content', + 'variable_snapshot', + 'rendered_message', + 'status', + 'attempt_count', + 'next_attempt_at', + 'processing_lease_until', + 'send_log_id', + 'last_error_code', + 'last_error_message', + 'expires_at', + 'create_time', + 'update_time', + ], + event: [ + 'id', + 'event_id', + 'source_key', + 'resource_key', + 'occurred_at', + 'payload', + 'fanout_status', + 'fanout_attempt_count', + 'next_fanout_at', + 'fanout_lease_until', + 'last_error_code', + 'last_error_message', + 'create_time', + 'update_time', + ], + subscription: [ + 'id', + 'name', + 'source_key', + 'source_config', + 'source_config_digest', + 'active_key', + 'enabled', + 'remark', + 'is_deleted', + 'create_time', + 'update_time', + ], + target: [ + 'id', + 'binding_id', + 'target_type', + 'target_id', + 'target_name', + 'active_key', + 'enabled', + 'is_deleted', + 'create_time', + 'update_time', + ], + template: [ + 'id', + 'name', + 'source_key', + 'content', + 'enabled', + 'remark', + 'is_deleted', + 'create_time', + 'update_time', + ], } as const; diff --git a/test/admin/network-management/network-stun-message-source.adapter.spec.ts b/test/admin/network-management/network-stun-message-source.adapter.spec.ts index 3915c05..744fcbc 100644 --- a/test/admin/network-management/network-stun-message-source.adapter.spec.ts +++ b/test/admin/network-management/network-stun-message-source.adapter.spec.ts @@ -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 () => { const { adapter } = createHarness(); 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(); 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: [ { + disabled: true, disabledReasonCode: 'UDP_REQUIRED', eligible: false, externalPort: 8213, id: mapping.id, internalPort: 8213, + label: '帕鲁新世界 · TCP:8213 · UDP_REQUIRED', name: '帕鲁新世界', protocol: 'tcp', + value: mapping.id, }, ], }); diff --git a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts index d3fe55f..d01443f 100644 --- a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts @@ -130,6 +130,7 @@ describe('QQBot core module contract', () => { 'GET /qqbot/accounts/:selfId/message-push/targets', 'GET /qqbot/message-push/sources', '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/subscriptions', 'GET /qqbot/message-push/templates', diff --git a/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts index 1515379..40cfe7d 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts @@ -55,6 +55,12 @@ const EXPECTED_ROUTE_PERMISSIONS: Record = { ], 'GET /qqbot/message-push/sources': 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': [ 'QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:Update', @@ -102,6 +108,7 @@ const EXPECTED_ROUTE_PERMISSIONS: Record = { }; const STRING_ID = '123456789012345678901234'; +const FORBIDDEN_FIELD_FIXTURE = 'test-only-redacted-value'; const SELF_ID = '12345'; const pinoLogger = { @@ -166,7 +173,7 @@ const sourceDefinition = () => ], variables: [ { - accessToken: 'must-not-leak', + accessToken: FORBIDDEN_FIELD_FIXTURE, description: 'Endpoint', example: 'example.test:10000', key: 'endpoint', @@ -330,24 +337,31 @@ describe('QQBot message-push management controllers', () => { ddnsRecords: [ { credential: 'must-not-leak', + dependsOnValue: '1234567890123456789', + disabled: false, disabledReasonCode: null, eligible: true, fqdn: 'example.test', id: STRING_ID, + label: 'DDNS · example.test', name: 'DDNS', portForwardId: '1234567890123456789', + value: STRING_ID, }, ], portForwards: [ { - accessToken: 'must-not-leak', + accessToken: FORBIDDEN_FIELD_FIXTURE, + disabled: false, disabledReasonCode: null, eligible: true, - externalPort: 10000, + externalPort: 10_000, id: '1234567890123456789', - internalPort: 10000, + internalPort: 8211, + label: 'STUN · UDP:10000', name: 'STUN', protocol: 'udp', + value: '1234567890123456789', }, ], }), @@ -386,7 +400,7 @@ describe('QQBot message-push management controllers', () => { 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([ QqbotMessagePushController, 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), + 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 () => { const sourceResponse = await request(apiUrl) .get('/qqbot/message-push/sources') @@ -601,10 +685,10 @@ describe('QQBot message-push management controllers', () => { .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) .put(`/qqbot/message-push/subscriptions/${STRING_ID}/enabled`) - .send({ enabled: false, secret: 'must-reject' }) + .send({ enabled: false, secret: FORBIDDEN_FIELD_FIXTURE }) .expect(400); await request(apiUrl) .post('/qqbot/message-push/subscriptions') @@ -612,14 +696,14 @@ describe('QQBot message-push management controllers', () => { ...subscriptionBody(), sourceConfig: { ...subscriptionBody().sourceConfig, - credential: 'must-reject', + credential: 123, }, }) .expect(400); const binding = bindingBody(); binding.targets[0] = { ...binding.targets[0], - accessToken: 'must-reject', + accessToken: FORBIDDEN_FIELD_FIXTURE, } as never; await request(apiUrl) .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 () => { targets.listTargetOptions.mockResolvedValueOnce({ - accessToken: 'must-not-leak', + accessToken: FORBIDDEN_FIELD_FIXTURE, available: true, options: [ { @@ -788,6 +872,11 @@ describe('QQBot message-push management controllers', () => { request(apiUrl) .get('/qqbot/message-push/sources/network.stun.mapping-port-changed') .expect(200), + request(apiUrl) + .get( + '/qqbot/message-push/sources/network.stun.mapping-port-changed/subscription-options', + ) + .expect(200), request(apiUrl) .get( '/qqbot/message-push/sources/network.stun.mapping-port-changed/options', @@ -813,6 +902,7 @@ describe('QQBot message-push management controllers', () => { sourceList, sourceDetail, options, + legacyStunOptions, subscriptionPage, templatePage, preview, @@ -854,6 +944,18 @@ describe('QQBot message-push management controllers', () => { 'portForwards', ]); 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', 'eligible', @@ -863,7 +965,7 @@ describe('QQBot message-push management controllers', () => { 'portForwardId', ].sort(), ); - expect(Object.keys(options.portForwards[0]).sort()).toEqual( + expect(Object.keys(legacyStunOptions.portForwards[0]).sort()).toEqual( [ 'disabledReasonCode', 'eligible', diff --git a/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts b/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts index f57fd5d..7154a09 100644 --- a/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-subscription.service.spec.ts @@ -84,10 +84,27 @@ function adapter( description: 'STUN 映射端口变化', displayName: 'STUN 端口变化', 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: [], version: 1, }, + eventResourceKey: jest.fn(), inspectSubscription: jest.fn(async (config: Record) => { void config; return inspect; @@ -102,6 +119,7 @@ function adapter( }; }), resolveDelivery: jest.fn(), + subscriptionResourceKey: jest.fn(), validateEventPayload: jest.fn(), }; } @@ -371,7 +389,7 @@ describe('QqbotMessageSubscriptionService', () => { enabled: true, name: '帕鲁端口变更', remark: '', - sourceConfig: { ...CONFIG, ignored: 'discarded' }, + sourceConfig: CONFIG, sourceKey: SOURCE_KEY, }), ).resolves.toEqual(expect.objectContaining({ sourceConfig: CONFIG })); @@ -383,10 +401,29 @@ describe('QqbotMessageSubscriptionService', () => { expect(saved.activeKey).toBe(`${SOURCE_KEY}:${expectedDigest}`); expect(source.normalizeSubscriptionConfig).toHaveBeenCalledWith({ ...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 () => { const existing = subscription(); const { service } = setup([existing]); diff --git a/test/modules/qqbot/message-push/qqbot-message-template.service.spec.ts b/test/modules/qqbot/message-push/qqbot-message-template.service.spec.ts index 37d90e9..fdb33e8 100644 --- a/test/modules/qqbot/message-push/qqbot-message-template.service.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-template.service.spec.ts @@ -96,10 +96,12 @@ function registry(): SystemMessageSourceRegistry { ], version: 1, }, + eventResourceKey: jest.fn(), inspectSubscription: jest.fn(), listSubscriptionOptions: jest.fn(), normalizeSubscriptionConfig: jest.fn(), resolveDelivery: jest.fn(), + subscriptionResourceKey: jest.fn(), validateEventPayload: jest.fn(), }); return value; diff --git a/test/modules/qqbot/message-push/system-message-delivery-runner.spec.ts b/test/modules/qqbot/message-push/system-message-delivery-runner.spec.ts index cf47ce9..0b11037 100644 --- a/test/modules/qqbot/message-push/system-message-delivery-runner.spec.ts +++ b/test/modules/qqbot/message-push/system-message-delivery-runner.spec.ts @@ -235,6 +235,7 @@ function setup(seed: Partial = {}) { ], version: 1, }, + eventResourceKey: jest.fn(), inspectSubscription: jest.fn(), listSubscriptionOptions: jest.fn(), normalizeSubscriptionConfig: jest.fn(), @@ -250,6 +251,7 @@ function setup(seed: Partial = {}) { }; }, ), + subscriptionResourceKey: jest.fn(), validateEventPayload: jest.fn( (payload) => payload as Record, ), diff --git a/test/modules/qqbot/message-push/system-message-event-stager.spec.ts b/test/modules/qqbot/message-push/system-message-event-stager.spec.ts index c4df77d..df691ce 100644 --- a/test/modules/qqbot/message-push/system-message-event-stager.spec.ts +++ b/test/modules/qqbot/message-push/system-message-event-stager.spec.ts @@ -20,10 +20,12 @@ function createRegistry() { variables: [], version: 1, }, + eventResourceKey: jest.fn(), inspectSubscription: jest.fn(), listSubscriptionOptions: jest.fn(), normalizeSubscriptionConfig: jest.fn(), resolveDelivery: jest.fn(), + subscriptionResourceKey: jest.fn(), validateEventPayload, }); return { registry, validateEventPayload }; diff --git a/test/modules/qqbot/message-push/system-message-fanout.service.spec.ts b/test/modules/qqbot/message-push/system-message-fanout.service.spec.ts index 7f5d8ab..64b6eda 100644 --- a/test/modules/qqbot/message-push/system-message-fanout.service.spec.ts +++ b/test/modules/qqbot/message-push/system-message-fanout.service.spec.ts @@ -23,6 +23,7 @@ import { const NOW = new Date('2026-07-24T00:00:00.000Z'); const SOURCE_KEY = 'network.stun.mapping-port-changed'; +const JOB_SOURCE_KEY = 'system.job.completed'; const RESOURCE_KEY = '9007199254740993'; type Store = { @@ -121,16 +122,21 @@ function target( }); } -function sourceAdapter(): jest.Mocked { +function sourceAdapter( + sourceKey = SOURCE_KEY, +): jest.Mocked { return { definition: { description: 'test', displayName: 'test', - sourceKey: SOURCE_KEY, + sourceKey, subscriptionFields: [], variables: [], version: 1, }, + eventResourceKey: jest.fn((payload) => + typeof payload.portForwardId === 'string' ? payload.portForwardId : '', + ), inspectSubscription: jest.fn(), listSubscriptionOptions: jest.fn(), normalizeSubscriptionConfig: jest.fn(), @@ -142,6 +148,12 @@ function sourceAdapter(): jest.Mocked { 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( (payload) => payload as Record, ), @@ -219,7 +231,7 @@ function normalizeConditionalWhere(where: Record) { ); } -function setup(seed: Partial = {}) { +function setup(seed: Partial = {}, adapter = sourceAdapter()) { let state: Store = { accounts: seed.accounts ?? [account()], bindings: seed.bindings ?? [binding()], @@ -229,7 +241,6 @@ function setup(seed: Partial = {}) { targets: seed.targets ?? [target()], templates: seed.templates ?? [template()], }; - const adapter = sourceAdapter(); const registry = new SystemMessageSourceRegistry(); registry.register(adapter); const query = { @@ -830,6 +841,42 @@ describe('SystemMessageFanoutService', () => { 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 () => { const fixture = setup({ accounts: [ diff --git a/test/modules/qqbot/message-push/system-message-source.registry.spec.ts b/test/modules/qqbot/message-push/system-message-source.registry.spec.ts index 5c1aef4..fcea49f 100644 --- a/test/modules/qqbot/message-push/system-message-source.registry.spec.ts +++ b/test/modules/qqbot/message-push/system-message-source.registry.spec.ts @@ -11,10 +11,12 @@ function createAdapter(sourceKey: string): SystemMessageSourceAdapter { variables: [], version: 1, }, + eventResourceKey: jest.fn(), inspectSubscription: jest.fn(), listSubscriptionOptions: jest.fn(), normalizeSubscriptionConfig: jest.fn(), resolveDelivery: jest.fn(), + subscriptionResourceKey: jest.fn(), validateEventPayload: jest.fn(), }; }