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

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

17
API.md
View File

@ -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 是 124 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1100 字符且不能全空白`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1100 个严格嵌套 target类型仅 `group/private``targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝query boolean 只接受字面量 `true/false`
请求采用严格白名单Snowflake/外键 ID 是 124 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1100 字符且不能全空白`sourceConfig` 必须是仅含字符串值的普通对象,其字段集合、必填项和领域校验由所选 `sourceKey``subscriptionFields` 与 adapter 决定,未知字段会拒绝。模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1100 个严格嵌套 target类型仅 `group/private``targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝query boolean 只接受字面量 `true/false`
管理边界将 `SystemMessageContractError` 仅转换为 Vben 安全错误,响应只包含其稳定 `code`,不会暴露原始消息、实体或 provider 对象:`unknown_message_source`、`mapping_not_found`、`ddns_not_found` 返回 HTTP 404重复、禁用、不可用、已取代、映射不匹配、DDNS 未同步、错误协议/管理状态及 OneBot 可用性或拒绝状态返回 HTTP 409其余来源、目标、模板、长度和契约校验错误返回 HTTP 400。非该领域错误维持 HTTP 500且不返回其内部细节。
响应仅返回管理契约字段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 | 同视频去重毫秒 |

View File

@ -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 小时为截止时间。

View File

@ -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> {

View File

@ -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]) =>

View File

@ -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');
}
}

View File

@ -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,
),
);
}

View File

@ -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;

View File

@ -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;

View File

@ -228,6 +228,29 @@ describe('NetworkStunMessageSourceAdapter', () => {
);
});
it('owns event and subscription resource-key extraction', () => {
const { adapter } = createHarness();
const payload = adapter.validateEventPayload(eventPayload());
expect(adapter.eventResourceKey(payload)).toBe('2041700000000000001');
expect(
adapter.subscriptionResourceKey({
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
}),
).toBe('2041700000000000001');
expect(
adapter.subscriptionResourceKey(
Object.create({ portForwardId: '2041700000000000001' }),
),
).toBeNull();
expect(
adapter.subscriptionResourceKey({
portForwardId: 2041700000000000001,
}),
).toBeNull();
});
it('maps an absent mapping to the locked subscription and inspection code', async () => {
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,
},
],
});

View File

@ -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',

View File

@ -55,6 +55,12 @@ const EXPECTED_ROUTE_PERMISSIONS: Record<string, string[]> = {
],
'GET /qqbot/message-push/sources': SOURCE_READ_PERMISSIONS,
'GET /qqbot/message-push/sources/: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',

View File

@ -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]);

View File

@ -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;

View File

@ -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>,
),

View File

@ -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 };

View File

@ -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: [

View File

@ -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(),
};
}