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` |
|
||||
| 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` |
|
||||
@ -362,7 +362,7 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退
|
||||
## MinIO
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | ----------------------- | ------------------------------- |
|
||||
| -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/minio/check` | 检查连接和 bucket |
|
||||
| `POST` | `/minio/bucket` | 创建 bucket |
|
||||
| `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/: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 发布接口。
|
||||
|
||||
#### 内部事件与投递生命周期
|
||||
|
||||
@ -466,7 +467,7 @@ 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 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。
|
||||
@ -624,7 +625,7 @@ Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修
|
||||
插件 key:`bilibili-card`。这是事件型内置插件,不新增在线命令;启用后仍需通过账号事件绑定让指定 QQBot 账号接收 `bilibili-card.message`。
|
||||
|
||||
| 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` 内去重。
|
||||
@ -632,7 +633,7 @@ Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修
|
||||
可配置键:
|
||||
|
||||
| 配置键 | 默认值 | 说明 |
|
||||
| ------------------------------------------- | ------ | -------------------- |
|
||||
| ------------------------------------- | ------ | -------------------- |
|
||||
| `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS` | 6000 | HTTP 请求超时毫秒 |
|
||||
| `QQBOT_BILIBILI_CARD_MAX_REDIRECTS` | 5 | `b23.tv` 最大跳转数 |
|
||||
| `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` | 600000 | 同视频去重毫秒 |
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
@ -57,7 +57,7 @@ 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` |
|
||||
@ -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 小时为截止时间。
|
||||
|
||||
|
||||
@ -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, 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<{
|
||||
canonicalConfig: Record<string, 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([
|
||||
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<string, unknown>): null | string {
|
||||
if (
|
||||
!isPlainRecord(config) ||
|
||||
!Object.prototype.hasOwnProperty.call(config, 'portForwardId') ||
|
||||
typeof config.portForwardId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return config.portForwardId;
|
||||
}
|
||||
|
||||
validateEventPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, SystemMessageScalar> {
|
||||
|
||||
@ -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<NormalizedSubscriptionInput> {
|
||||
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<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> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).sort(([left], [right]) =>
|
||||
|
||||
@ -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<QqbotMessageSubscription[]> {
|
||||
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<string, SystemMessageScalar>,
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, unknown>,
|
||||
): 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 => ({
|
||||
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: {
|
||||
ddnsRecordId: view.sourceConfig.ddnsRecordId,
|
||||
portForwardId: view.sourceConfig.portForwardId,
|
||||
},
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> {
|
||||
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<string, string>;
|
||||
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@ -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<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 interface MessageSubscriptionView {
|
||||
createTime: string;
|
||||
enabled: boolean;
|
||||
id: 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 =
|
||||
| { reasonCode: null; 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; };
|
||||
| {
|
||||
reasonCode: null;
|
||||
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 {
|
||||
readonly definition: SystemMessageSourceDefinition;
|
||||
inspectSubscription(config: Record<string, unknown>): Promise<{ invalidReasonCode: null | string; sourceSummary: string; valid: boolean; }>;
|
||||
listSubscriptionOptions(): Promise<Record<string, unknown>>;
|
||||
normalizeSubscriptionConfig(input: unknown): Promise<{ canonicalConfig: Record<string, string>; resourceKey: string; sourceSummary: string; }>;
|
||||
resolveDelivery(input: { eventPayload: Record<string, SystemMessageScalar>; subscriptionConfig: Record<string, unknown>; }): Promise<SystemMessageDeliveryReadiness>;
|
||||
validateEventPayload(payload: Record<string, unknown>): Record<string, SystemMessageScalar>;
|
||||
eventResourceKey(payload: Record<string, SystemMessageScalar>): string;
|
||||
inspectSubscription(config: Record<string, unknown>): Promise<{
|
||||
invalidReasonCode: null | string;
|
||||
sourceSummary: string;
|
||||
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 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<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 {
|
||||
notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise<void>;
|
||||
notifyDdnsSynced(input: {
|
||||
appliedAddress: string;
|
||||
ddnsRecordId: string;
|
||||
}): Promise<void>;
|
||||
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<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 interface MessageSubscriptionListQuery {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
sourceKey?: string;
|
||||
}
|
||||
export interface MessageSubscriptionInput {
|
||||
enabled: boolean;
|
||||
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 {
|
||||
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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -55,6 +55,12 @@ const EXPECTED_ROUTE_PERMISSIONS: Record<string, string[]> = {
|
||||
],
|
||||
'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<string, string[]> = {
|
||||
};
|
||||
|
||||
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<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 () => {
|
||||
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',
|
||||
|
||||
@ -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<string, unknown>) => {
|
||||
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]);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -235,6 +235,7 @@ function setup(seed: Partial<Store> = {}) {
|
||||
],
|
||||
version: 1,
|
||||
},
|
||||
eventResourceKey: jest.fn(),
|
||||
inspectSubscription: jest.fn(),
|
||||
listSubscriptionOptions: jest.fn(),
|
||||
normalizeSubscriptionConfig: jest.fn(),
|
||||
@ -250,6 +251,7 @@ function setup(seed: Partial<Store> = {}) {
|
||||
};
|
||||
},
|
||||
),
|
||||
subscriptionResourceKey: jest.fn(),
|
||||
validateEventPayload: jest.fn(
|
||||
(payload) => payload as Record<string, boolean | null | number | string>,
|
||||
),
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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<SystemMessageSourceAdapter> {
|
||||
function sourceAdapter(
|
||||
sourceKey = SOURCE_KEY,
|
||||
): jest.Mocked<SystemMessageSourceAdapter> {
|
||||
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<SystemMessageSourceAdapter> {
|
||||
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<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 = {
|
||||
accounts: seed.accounts ?? [account()],
|
||||
bindings: seed.bindings ?? [binding()],
|
||||
@ -229,7 +241,6 @@ function setup(seed: Partial<Store> = {}) {
|
||||
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: [
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user