feat: 注册STUN系统消息源
This commit is contained in:
parent
832046b3bc
commit
3dbc37e500
@ -18,6 +18,7 @@ import { NetworkManagementController } from '@/modules/admin/platform-config/net
|
|||||||
import { NetworkManagementEventStreamService } from '@/modules/admin/platform-config/network-management/network-management-event-stream.service';
|
import { NetworkManagementEventStreamService } from '@/modules/admin/platform-config/network-management/network-management-event-stream.service';
|
||||||
import { NetworkPortForward } from '@/modules/admin/platform-config/network-management/network-management.entity';
|
import { NetworkPortForward } from '@/modules/admin/platform-config/network-management/network-management.entity';
|
||||||
import { NetworkManagementService } from '@/modules/admin/platform-config/network-management/network-management.service';
|
import { NetworkManagementService } from '@/modules/admin/platform-config/network-management/network-management.service';
|
||||||
|
import { NetworkStunMessageSourceAdapter } from '@/modules/admin/platform-config/network-management/network-stun-message-source.adapter';
|
||||||
import { SystemLogController } from '@/modules/admin/platform-config/system-log/system-log.controller';
|
import { SystemLogController } from '@/modules/admin/platform-config/system-log/system-log.controller';
|
||||||
import { SystemLogService } from '@/modules/admin/platform-config/system-log/system-log.service';
|
import { SystemLogService } from '@/modules/admin/platform-config/system-log/system-log.service';
|
||||||
import { AdminTimezoneController } from '@/modules/admin/platform-config/timezone/admin-timezone.controller';
|
import { AdminTimezoneController } from '@/modules/admin/platform-config/timezone/admin-timezone.controller';
|
||||||
@ -88,6 +89,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
|
|||||||
NetworkManagementEventStreamService,
|
NetworkManagementEventStreamService,
|
||||||
NetworkDnsPodClient,
|
NetworkDnsPodClient,
|
||||||
NetworkDdnsService,
|
NetworkDdnsService,
|
||||||
|
NetworkStunMessageSourceAdapter,
|
||||||
NetworkAgentMqttService,
|
NetworkAgentMqttService,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
} from './network-dnspod.client';
|
} from './network-dnspod.client';
|
||||||
import { NetworkManagementEventStreamService } from './network-management-event-stream.service';
|
import { NetworkManagementEventStreamService } from './network-management-event-stream.service';
|
||||||
import { NetworkPortForward } from './network-management.entity';
|
import { NetworkPortForward } from './network-management.entity';
|
||||||
|
import { classifyStunEndpointSource } from './network-source-eligibility';
|
||||||
import type {
|
import type {
|
||||||
NetworkDdnsListQuery,
|
NetworkDdnsListQuery,
|
||||||
NetworkDdnsRecordInput,
|
NetworkDdnsRecordInput,
|
||||||
@ -757,21 +758,13 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private portForwardSourceOption(
|
private portForwardSourceOption(
|
||||||
mapping: NetworkPortForward,
|
mapping: NetworkPortForward,
|
||||||
): NetworkDdnsSourceOption {
|
): NetworkDdnsSourceOption {
|
||||||
let disabledReasonCode: null | string = null;
|
const { disabledReasonCode, eligible } =
|
||||||
if (mapping.isDeleted || mapping.desiredPresence !== 'present') {
|
classifyStunEndpointSource(mapping);
|
||||||
disabledReasonCode = 'SOURCE_DELETING';
|
|
||||||
} else if (mapping.protocol !== 'udp') {
|
|
||||||
disabledReasonCode = 'UDP_REQUIRED';
|
|
||||||
} else if (mapping.externalPort !== mapping.internalPort) {
|
|
||||||
disabledReasonCode = 'PORT_MISMATCH';
|
|
||||||
} else if (!mapping.keeperDesiredEnabled) {
|
|
||||||
disabledReasonCode = 'KEEPER_DISABLED';
|
|
||||||
}
|
|
||||||
const leaseValid =
|
const leaseValid =
|
||||||
isIP(mapping.currentPublicIpv4 || '') === 4 &&
|
isIP(mapping.currentPublicIpv4 || '') === 4 &&
|
||||||
!!mapping.currentValidUntil &&
|
!!mapping.currentValidUntil &&
|
||||||
new Date(mapping.currentValidUntil).getTime() > Date.now();
|
new Date(mapping.currentValidUntil).getTime() > Date.now();
|
||||||
const sourceUsable = disabledReasonCode === null && leaseValid;
|
const sourceUsable = eligible && leaseValid;
|
||||||
return {
|
return {
|
||||||
currentAddress: sourceUsable ? mapping.currentPublicIpv4 || null : null,
|
currentAddress: sourceUsable ? mapping.currentPublicIpv4 || null : null,
|
||||||
disabledReasonCode,
|
disabledReasonCode,
|
||||||
@ -996,13 +989,7 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const mapping = await this.mappingRepository.findOne({
|
const mapping = await this.mappingRepository.findOne({
|
||||||
where: { id: portForwardId as string, isDeleted: false },
|
where: { id: portForwardId as string, isDeleted: false },
|
||||||
});
|
});
|
||||||
if (
|
if (!mapping || !classifyStunEndpointSource(mapping).eligible) {
|
||||||
!mapping ||
|
|
||||||
mapping.desiredPresence !== 'present' ||
|
|
||||||
mapping.protocol !== 'udp' ||
|
|
||||||
mapping.externalPort !== mapping.internalPort ||
|
|
||||||
!mapping.keeperDesiredEnabled
|
|
||||||
) {
|
|
||||||
throwVbenError(
|
throwVbenError(
|
||||||
'A 记录来源必须是已启用 Keeper 的同端口 UDP 转发',
|
'A 记录来源必须是已启用 Keeper 的同端口 UDP 转发',
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
import type { NetworkPortForward } from './network-management.entity';
|
||||||
|
|
||||||
|
/** Stable reason returned when a mapping cannot structurally act as a STUN endpoint source. */
|
||||||
|
export type StunEndpointSourceDisabledReason =
|
||||||
|
| 'KEEPER_DISABLED'
|
||||||
|
| 'PORT_MISMATCH'
|
||||||
|
| 'SOURCE_DELETING'
|
||||||
|
| 'UDP_REQUIRED';
|
||||||
|
|
||||||
|
/** Structural, lease-independent STUN source classification shared by DDNS and message delivery. */
|
||||||
|
export type StunEndpointSourceEligibility = {
|
||||||
|
disabledReasonCode: null | StunEndpointSourceDisabledReason;
|
||||||
|
eligible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies whether a mapping may own a UDP STUN endpoint, excluding its current lease.
|
||||||
|
* @param mapping - Persisted port-forward mapping including desired Keeper configuration.
|
||||||
|
* @returns Stable structural eligibility and existing DDNS-compatible reason codes.
|
||||||
|
*/
|
||||||
|
export function classifyStunEndpointSource(
|
||||||
|
mapping: Pick<
|
||||||
|
NetworkPortForward,
|
||||||
|
| 'desiredPresence'
|
||||||
|
| 'externalPort'
|
||||||
|
| 'internalPort'
|
||||||
|
| 'isDeleted'
|
||||||
|
| 'keeperDesiredEnabled'
|
||||||
|
| 'protocol'
|
||||||
|
>,
|
||||||
|
): StunEndpointSourceEligibility {
|
||||||
|
let disabledReasonCode: null | StunEndpointSourceDisabledReason = null;
|
||||||
|
if (mapping.isDeleted || mapping.desiredPresence !== 'present') {
|
||||||
|
disabledReasonCode = 'SOURCE_DELETING';
|
||||||
|
} else if (mapping.protocol !== 'udp') {
|
||||||
|
disabledReasonCode = 'UDP_REQUIRED';
|
||||||
|
} else if (mapping.externalPort !== mapping.internalPort) {
|
||||||
|
disabledReasonCode = 'PORT_MISMATCH';
|
||||||
|
} else if (!mapping.keeperDesiredEnabled) {
|
||||||
|
disabledReasonCode = 'KEEPER_DISABLED';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
disabledReasonCode,
|
||||||
|
eligible: disabledReasonCode === null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,533 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
SystemMessageContractError,
|
||||||
|
type SystemMessageDeliveryReadiness,
|
||||||
|
type SystemMessageScalar,
|
||||||
|
type SystemMessageSourceDefinition,
|
||||||
|
} 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';
|
||||||
|
import { NetworkPortForward } from './network-management.entity';
|
||||||
|
import { classifyStunEndpointSource } from './network-source-eligibility';
|
||||||
|
|
||||||
|
const SOURCE_KEY = 'network.stun.mapping-port-changed';
|
||||||
|
const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/;
|
||||||
|
const RFC3339_PATTERN =
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||||
|
|
||||||
|
type StunSubscriptionConfig = {
|
||||||
|
ddnsRecordId: string;
|
||||||
|
portForwardId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StunEventPayload = {
|
||||||
|
changedAt: string;
|
||||||
|
currentPort: number;
|
||||||
|
portForwardId: string;
|
||||||
|
previousPort: number;
|
||||||
|
publicIpv4: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedSubscription = {
|
||||||
|
config: StunSubscriptionConfig;
|
||||||
|
ddnsRecord: NetworkDdnsRecord;
|
||||||
|
mapping: NetworkPortForward;
|
||||||
|
sourceSummary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts Network-owned STUN mappings and DDNS records to the QQBot source contract.
|
||||||
|
*
|
||||||
|
* It deliberately owns no event staging or delivery queue state; every delivery is
|
||||||
|
* recomputed from the current Network records to prevent stale endpoint sends.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NetworkStunMessageSourceAdapter
|
||||||
|
implements OnModuleDestroy, OnModuleInit
|
||||||
|
{
|
||||||
|
private registered = false;
|
||||||
|
|
||||||
|
/** Immutable public schema for the first Network-owned system message source. */
|
||||||
|
readonly definition: SystemMessageSourceDefinition = {
|
||||||
|
description: '当 UDP STUN 映射端口变更且 IPv4 DDNS 已同步时发送消息。',
|
||||||
|
displayName: 'STUN 映射端口变更',
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
subscriptionFields: [
|
||||||
|
{
|
||||||
|
key: 'portForwardId',
|
||||||
|
label: 'STUN 端口转发',
|
||||||
|
optionCollection: 'portForwards',
|
||||||
|
required: true,
|
||||||
|
type: 'select',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dependsOn: 'portForwardId',
|
||||||
|
key: 'ddnsRecordId',
|
||||||
|
label: 'IPv4 DDNS 记录',
|
||||||
|
optionCollection: 'ddnsRecords',
|
||||||
|
required: true,
|
||||||
|
type: 'select',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
description: '所选 DDNS 完整域名',
|
||||||
|
example: 'pal.kwitsukasa.top',
|
||||||
|
key: 'domain',
|
||||||
|
label: '域名',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: '新的公网映射端口',
|
||||||
|
example: '38213',
|
||||||
|
key: 'port',
|
||||||
|
label: '端口',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: '服务端组合的域名与端口',
|
||||||
|
example: 'pal.kwitsukasa.top:38213',
|
||||||
|
key: 'endpoint',
|
||||||
|
label: '访问端点',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: '端口转发显示名称',
|
||||||
|
example: '帕鲁新世界',
|
||||||
|
key: 'mappingName',
|
||||||
|
label: '映射名称',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: '变化前的公网映射端口',
|
||||||
|
example: '8213',
|
||||||
|
key: 'previousPort',
|
||||||
|
label: '原端口',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: '事件对应的公网 IPv4',
|
||||||
|
example: '203.0.113.10',
|
||||||
|
key: 'publicIpv4',
|
||||||
|
label: '公网 IPv4',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: '按上海时区格式化的变化时间',
|
||||||
|
example: '2026-07-23 20:30:00',
|
||||||
|
key: 'changedAt',
|
||||||
|
label: '变化时间',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the Network-owned source adapter.
|
||||||
|
* @param mappingRepository - Current port-forward and Keeper state reader.
|
||||||
|
* @param ddnsRepository - Current A-record DDNS binding reader.
|
||||||
|
* @param sourceRegistry - Core-owned process registry exported to Network.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(NetworkPortForward)
|
||||||
|
private readonly mappingRepository: Repository<NetworkPortForward>,
|
||||||
|
@InjectRepository(NetworkDdnsRecord)
|
||||||
|
private readonly ddnsRepository: Repository<NetworkDdnsRecord>,
|
||||||
|
private readonly sourceRegistry: SystemMessageSourceRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Registers this instance once after its owning Network module initializes. */
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (this.registered) return;
|
||||||
|
this.sourceRegistry.register(this);
|
||||||
|
this.registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregisters only this instance during Network module teardown. */
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (!this.registered) return;
|
||||||
|
this.sourceRegistry.unregister(this.definition.sourceKey, this);
|
||||||
|
this.registered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a submitted subscription to the two supported string identifiers.
|
||||||
|
* @param input - Untrusted admin configuration; unknown fields are intentionally discarded.
|
||||||
|
* @returns Canonical IDs, the mapping resource key, and a server-derived summary.
|
||||||
|
*/
|
||||||
|
async normalizeSubscriptionConfig(input: unknown): Promise<{
|
||||||
|
canonicalConfig: Record<string, string>;
|
||||||
|
resourceKey: string;
|
||||||
|
sourceSummary: string;
|
||||||
|
}> {
|
||||||
|
const resolved = await this.resolveSubscription(input);
|
||||||
|
return {
|
||||||
|
canonicalConfig: resolved.config,
|
||||||
|
resourceKey: resolved.config.portForwardId,
|
||||||
|
sourceSummary: resolved.sourceSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks current subscription validity without writing subscription or Network state.
|
||||||
|
* @param config - Persisted or submitted source configuration.
|
||||||
|
* @returns Stable validity result with a safe source summary for management UI.
|
||||||
|
*/
|
||||||
|
async inspectSubscription(config: Record<string, unknown>): Promise<{
|
||||||
|
invalidReasonCode: null | string;
|
||||||
|
sourceSummary: string;
|
||||||
|
valid: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const resolved = await this.resolveSubscription(config);
|
||||||
|
return {
|
||||||
|
invalidReasonCode: null,
|
||||||
|
sourceSummary: resolved.sourceSummary,
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
invalidReasonCode: messageSourceErrorCode(error),
|
||||||
|
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
|
||||||
|
valid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists current mapping and DDNS candidates with structural, lease-independent eligibility.
|
||||||
|
* @returns Locked `portForwards` and `ddnsRecords` option collections.
|
||||||
|
*/
|
||||||
|
async listSubscriptionOptions(): Promise<Record<string, unknown>> {
|
||||||
|
const [mappings, records] = await Promise.all([
|
||||||
|
this.mappingRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
|
||||||
|
this.ddnsRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
|
||||||
|
]);
|
||||||
|
const mappingsById = new Map(
|
||||||
|
mappings.map((mapping) => [String(mapping.id), mapping]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ddnsRecords: records.map((record) => {
|
||||||
|
const mapping = record.portForwardId
|
||||||
|
? mappingsById.get(String(record.portForwardId))
|
||||||
|
: undefined;
|
||||||
|
const disabledReasonCode = ddnsOptionReason(record, mapping);
|
||||||
|
return {
|
||||||
|
disabledReasonCode,
|
||||||
|
eligible: disabledReasonCode === null,
|
||||||
|
fqdn: ddnsFqdn(record),
|
||||||
|
id: String(record.id),
|
||||||
|
name: record.name,
|
||||||
|
portForwardId: record.portForwardId
|
||||||
|
? String(record.portForwardId)
|
||||||
|
: '',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
portForwards: mappings.map((mapping) => {
|
||||||
|
const { disabledReasonCode, eligible } =
|
||||||
|
classifyStunEndpointSource(mapping);
|
||||||
|
return {
|
||||||
|
disabledReasonCode,
|
||||||
|
eligible,
|
||||||
|
externalPort: mapping.externalPort,
|
||||||
|
id: String(mapping.id),
|
||||||
|
internalPort: mapping.internalPort,
|
||||||
|
name: mapping.name,
|
||||||
|
protocol: mapping.protocol,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects unsafe event data and retains only the source's permitted scalar fields.
|
||||||
|
* @param payload - Network event candidate before it is staged in the QQBot Outbox.
|
||||||
|
* @returns Canonical event variables with no client-controlled endpoint field.
|
||||||
|
*/
|
||||||
|
validateEventPayload(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Record<string, SystemMessageScalar> {
|
||||||
|
if (!isPlainRecord(payload)) {
|
||||||
|
throw new SystemMessageContractError('invalid_message_event_payload');
|
||||||
|
}
|
||||||
|
const changedAt = normalizeRfc3339(payload.changedAt);
|
||||||
|
const currentPort = normalizePort(payload.currentPort);
|
||||||
|
const previousPort = normalizePort(payload.previousPort);
|
||||||
|
const portForwardId = normalizeSnowflakeId(payload.portForwardId);
|
||||||
|
if (
|
||||||
|
typeof payload.publicIpv4 !== 'string' ||
|
||||||
|
isIP(payload.publicIpv4) !== 4 ||
|
||||||
|
currentPort === previousPort
|
||||||
|
) {
|
||||||
|
throw new SystemMessageContractError('invalid_message_event_payload');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
changedAt,
|
||||||
|
currentPort,
|
||||||
|
portForwardId,
|
||||||
|
previousPort,
|
||||||
|
publicIpv4: payload.publicIpv4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-evaluates a frozen event against the current mapping and DDNS state before sending.
|
||||||
|
* @param input - Event payload and persisted subscription configuration.
|
||||||
|
* @returns Ready variables, a DDNS wait result, or terminal cancellation/supersession.
|
||||||
|
*/
|
||||||
|
async resolveDelivery(input: {
|
||||||
|
eventPayload: Record<string, SystemMessageScalar>;
|
||||||
|
subscriptionConfig: Record<string, unknown>;
|
||||||
|
}): Promise<SystemMessageDeliveryReadiness> {
|
||||||
|
let resolved: ResolvedSubscription;
|
||||||
|
try {
|
||||||
|
resolved = await this.resolveSubscription(input.subscriptionConfig);
|
||||||
|
} catch (error) {
|
||||||
|
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
|
||||||
|
}
|
||||||
|
let event: StunEventPayload;
|
||||||
|
try {
|
||||||
|
event = this.validateEventPayload(input.eventPayload) as StunEventPayload;
|
||||||
|
} catch (error) {
|
||||||
|
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
|
||||||
|
}
|
||||||
|
if (event.portForwardId !== resolved.config.portForwardId) {
|
||||||
|
return { reasonCode: 'event_resource_mismatch', status: 'cancelled' };
|
||||||
|
}
|
||||||
|
if (!hasCurrentEndpoint(resolved.mapping)) {
|
||||||
|
return { reasonCode: 'endpoint_unavailable', status: 'superseded' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
resolved.mapping.currentPublicPort !== event.currentPort ||
|
||||||
|
resolved.mapping.currentPublicIpv4 !== event.publicIpv4
|
||||||
|
) {
|
||||||
|
return { reasonCode: 'endpoint_changed', status: 'superseded' };
|
||||||
|
}
|
||||||
|
const variables = deliveryVariables(resolved, event);
|
||||||
|
if (
|
||||||
|
resolved.ddnsRecord.syncStatus !== 'synced' ||
|
||||||
|
resolved.ddnsRecord.appliedAddress !== event.publicIpv4
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
reasonCode: 'ddns_not_synced',
|
||||||
|
status: 'waiting_ddns',
|
||||||
|
variables,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { reasonCode: null, status: 'ready', variables };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves and structurally validates both persisted resources selected by a subscription.
|
||||||
|
* @param input - Unknown configuration from an Admin request or persisted subscription.
|
||||||
|
* @returns Current resource rows and canonical identity when their relationship is valid.
|
||||||
|
*/
|
||||||
|
private async resolveSubscription(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ResolvedSubscription> {
|
||||||
|
if (!isPlainRecord(input)) {
|
||||||
|
throw new SystemMessageContractError('invalid_message_source_config');
|
||||||
|
}
|
||||||
|
let config: StunSubscriptionConfig;
|
||||||
|
try {
|
||||||
|
config = {
|
||||||
|
ddnsRecordId: normalizeSnowflakeId(input.ddnsRecordId),
|
||||||
|
portForwardId: normalizeSnowflakeId(input.portForwardId),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new SystemMessageContractError('invalid_message_source_config');
|
||||||
|
}
|
||||||
|
const mapping = await this.mappingRepository.findOne({
|
||||||
|
where: { id: config.portForwardId },
|
||||||
|
});
|
||||||
|
if (!mapping) {
|
||||||
|
throw new SystemMessageContractError('source_not_found');
|
||||||
|
}
|
||||||
|
const sourceEligibility = classifyStunEndpointSource(mapping);
|
||||||
|
if (!sourceEligibility.eligible) {
|
||||||
|
throw new SystemMessageContractError(
|
||||||
|
sourceEligibility.disabledReasonCode as string,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ddnsRecord = await this.ddnsRepository.findOne({
|
||||||
|
where: { id: config.ddnsRecordId },
|
||||||
|
});
|
||||||
|
const ddnsReason = ddnsOptionReason(ddnsRecord, mapping);
|
||||||
|
if (ddnsReason) {
|
||||||
|
throw new SystemMessageContractError(ddnsReason);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
ddnsRecord: ddnsRecord as NetworkDdnsRecord,
|
||||||
|
mapping,
|
||||||
|
sourceSummary: `${mapping.name} · ${ddnsFqdn(ddnsRecord as NetworkDdnsRecord)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that a value is a non-array object with string-addressable own values.
|
||||||
|
* @param value - Untrusted input at an adapter boundary.
|
||||||
|
* @returns Whether the value is safe to inspect as a plain record.
|
||||||
|
*/
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Snowflake at JSON boundaries without converting it to an unsafe number.
|
||||||
|
* @param value - Candidate string identifier.
|
||||||
|
* @returns The unchanged decimal string.
|
||||||
|
*/
|
||||||
|
function normalizeSnowflakeId(value: unknown): string {
|
||||||
|
if (typeof value !== 'string' || !SNOWFLAKE_ID_PATTERN.test(value)) {
|
||||||
|
throw new SystemMessageContractError('invalid_message_source_config');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an event port as an integer transport port.
|
||||||
|
* @param value - Candidate event scalar.
|
||||||
|
* @returns The safe port number.
|
||||||
|
*/
|
||||||
|
function normalizePort(value: unknown): number {
|
||||||
|
if (
|
||||||
|
typeof value !== 'number' ||
|
||||||
|
!Number.isInteger(value) ||
|
||||||
|
value < 1 ||
|
||||||
|
value > 65_535
|
||||||
|
) {
|
||||||
|
throw new SystemMessageContractError('invalid_message_event_payload');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a timezone-qualified event timestamp to canonical ISO UTC text.
|
||||||
|
* @param value - Candidate RFC3339 timestamp.
|
||||||
|
* @returns Canonical ISO string safe for later Shanghai rendering.
|
||||||
|
*/
|
||||||
|
function normalizeRfc3339(value: unknown): string {
|
||||||
|
if (typeof value !== 'string' || !RFC3339_PATTERN.test(value)) {
|
||||||
|
throw new SystemMessageContractError('invalid_message_event_payload');
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new SystemMessageContractError('invalid_message_event_payload');
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a persisted DDNS record is a usable structural subscription target.
|
||||||
|
* @param record - Candidate A-record binding, possibly absent or deleted.
|
||||||
|
* @param mapping - Mapping selected by the subscription, if currently present.
|
||||||
|
* @returns Null when structurally valid or a stable invalid-reason code.
|
||||||
|
*/
|
||||||
|
function ddnsOptionReason(
|
||||||
|
record: NetworkDdnsRecord | null | undefined,
|
||||||
|
mapping: NetworkPortForward | undefined,
|
||||||
|
): null | string {
|
||||||
|
if (!record) return 'ddns_not_found';
|
||||||
|
if (record.isDeleted) return 'ddns_deleted';
|
||||||
|
if (!record.enabled) return 'ddns_disabled';
|
||||||
|
if (record.recordType !== 'A') return 'ddns_a_required';
|
||||||
|
if (record.sourceType !== 'port_forward_ipv4')
|
||||||
|
return 'ddns_source_type_invalid';
|
||||||
|
if (!mapping || String(record.portForwardId) !== String(mapping.id)) {
|
||||||
|
return 'ddns_mapping_mismatch';
|
||||||
|
}
|
||||||
|
const source = classifyStunEndpointSource(mapping);
|
||||||
|
return source.disabledReasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forms the normalized fully-qualified hostname from persisted server-owned DDNS fields.
|
||||||
|
* @param record - Canonical DDNS binding.
|
||||||
|
* @returns Lowercase FQDN without a trailing dot.
|
||||||
|
*/
|
||||||
|
function ddnsFqdn(record: NetworkDdnsRecord): string {
|
||||||
|
const domain = record.domain.trim().toLowerCase().replace(/\.$/, '');
|
||||||
|
const subDomain = record.subDomain.trim().toLowerCase().replace(/\.$/, '');
|
||||||
|
return subDomain === '@' ? domain : `${subDomain}.${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks current Keeper endpoint readiness independently of structural subscription validity.
|
||||||
|
* @param mapping - Current mapping row after structural eligibility passed.
|
||||||
|
* @returns Whether a live public IPv4 endpoint is available at this instant.
|
||||||
|
*/
|
||||||
|
function hasCurrentEndpoint(mapping: NetworkPortForward): boolean {
|
||||||
|
return (
|
||||||
|
isIP(mapping.currentPublicIpv4 || '') === 4 &&
|
||||||
|
typeof mapping.currentPublicPort === 'number' &&
|
||||||
|
Number.isInteger(mapping.currentPublicPort) &&
|
||||||
|
mapping.currentPublicPort >= 1 &&
|
||||||
|
mapping.currentPublicPort <= 65_535 &&
|
||||||
|
!!mapping.currentValidUntil &&
|
||||||
|
new Date(mapping.currentValidUntil).getTime() > Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the whitelisted template variables from current Network records and frozen event data.
|
||||||
|
* @param resolved - Current structurally valid subscription resources.
|
||||||
|
* @param event - Validated immutable event payload.
|
||||||
|
* @returns Server-derived scalar values ready for formal template rendering.
|
||||||
|
*/
|
||||||
|
function deliveryVariables(
|
||||||
|
resolved: ResolvedSubscription,
|
||||||
|
event: StunEventPayload,
|
||||||
|
): Record<string, boolean | number | string> {
|
||||||
|
const domain = ddnsFqdn(resolved.ddnsRecord);
|
||||||
|
return {
|
||||||
|
changedAt: formatShanghaiDateTime(event.changedAt),
|
||||||
|
domain,
|
||||||
|
endpoint: `${domain}:${event.currentPort}`,
|
||||||
|
mappingName: resolved.mapping.name,
|
||||||
|
port: event.currentPort,
|
||||||
|
previousPort: event.previousPort,
|
||||||
|
publicIpv4: event.publicIpv4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an event timestamp as the locked Asia/Shanghai template variable value.
|
||||||
|
* @param value - Valid RFC3339 event timestamp.
|
||||||
|
* @returns `YYYY-MM-DD HH:mm:ss` assembled without locale punctuation assumptions.
|
||||||
|
*/
|
||||||
|
function formatShanghaiDateTime(value: string): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
minute: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
}).formatToParts(new Date(value));
|
||||||
|
const part = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||||
|
parts.find((item) => item.type === type)?.value || '';
|
||||||
|
return `${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts adapter validation failures to one stable, non-sensitive reason code.
|
||||||
|
* @param error - Unknown caught adapter or repository error.
|
||||||
|
* @returns Domain code for expected contract failures or a generic source-unavailable code.
|
||||||
|
*/
|
||||||
|
function messageSourceErrorCode(error: unknown): string {
|
||||||
|
return error instanceof SystemMessageContractError
|
||||||
|
? error.code
|
||||||
|
: 'message_source_unavailable';
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
SystemMessageContractError,
|
||||||
|
type SystemMessageSourceAdapter,
|
||||||
|
type SystemMessageSourceDefinition,
|
||||||
|
} from '../../contract/message-push/qqbot-message-push.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the process-local system message-source adapters registered by owning modules.
|
||||||
|
*
|
||||||
|
* Definitions are cloned on listing so management callers cannot mutate adapter state.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SystemMessageSourceRegistry {
|
||||||
|
private readonly adapters = new Map<string, SystemMessageSourceAdapter>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers one source adapter once for the current Nest process.
|
||||||
|
* @param adapter - Network, Core, or future module-owned source implementation.
|
||||||
|
* @throws {SystemMessageContractError} When its source key is already registered.
|
||||||
|
*/
|
||||||
|
register(adapter: SystemMessageSourceAdapter): void {
|
||||||
|
const key = adapter.definition.sourceKey;
|
||||||
|
if (this.adapters.has(key)) {
|
||||||
|
throw new SystemMessageContractError('duplicate_message_source');
|
||||||
|
}
|
||||||
|
this.adapters.set(key, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes only the exact adapter instance that registered the source key.
|
||||||
|
* @param sourceKey - Registered source identity.
|
||||||
|
* @param adapter - Instance performing module teardown.
|
||||||
|
*/
|
||||||
|
unregister(sourceKey: string, adapter: SystemMessageSourceAdapter): void {
|
||||||
|
if (this.adapters.get(sourceKey) === adapter) {
|
||||||
|
this.adapters.delete(sourceKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the adapter for a known source key.
|
||||||
|
* @param sourceKey - Stable source identity stored by subscriptions.
|
||||||
|
* @returns The registered adapter instance.
|
||||||
|
* @throws {SystemMessageContractError} When no source owns the requested key.
|
||||||
|
*/
|
||||||
|
get(sourceKey: string): SystemMessageSourceAdapter {
|
||||||
|
const adapter = this.adapters.get(sourceKey);
|
||||||
|
if (!adapter) {
|
||||||
|
throw new SystemMessageContractError('unknown_message_source');
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists source definitions in stable source-key order without exposing internal objects.
|
||||||
|
* @returns Detached definition snapshots for read-only management use.
|
||||||
|
*/
|
||||||
|
list(): SystemMessageSourceDefinition[] {
|
||||||
|
return [...this.adapters.values()]
|
||||||
|
.map(({ definition }) => structuredClone(definition))
|
||||||
|
.sort((left, right) => left.sourceKey.localeCompare(right.sourceKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,6 +47,7 @@ import { QqbotMessagePublishBinding } from '@/modules/qqbot/core/infrastructure/
|
|||||||
import { QqbotMessagePublishTarget } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
|
import { QqbotMessagePublishTarget } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
|
||||||
import { QqbotMessageSubscription } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
import { QqbotMessageSubscription } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||||
import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||||
|
import { SystemMessageSourceRegistry } from './application/message-push/system-message-source.registry';
|
||||||
|
|
||||||
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||||
|
|
||||||
@ -82,6 +83,7 @@ export const QQBOT_CORE_CONTROLLERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const QQBOT_CORE_PROVIDERS = [
|
export const QQBOT_CORE_PROVIDERS = [
|
||||||
|
SystemMessageSourceRegistry,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotBusService,
|
QqbotBusService,
|
||||||
QqbotCommandEngineService,
|
QqbotCommandEngineService,
|
||||||
@ -102,6 +104,7 @@ export const QQBOT_CORE_PROVIDERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const QQBOT_CORE_EXPORTS = [
|
export const QQBOT_CORE_EXPORTS = [
|
||||||
|
SystemMessageSourceRegistry,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotConfigService,
|
QqbotConfigService,
|
||||||
QqbotDashboardService,
|
QqbotDashboardService,
|
||||||
|
|||||||
@ -0,0 +1,228 @@
|
|||||||
|
import type { Repository } from 'typeorm';
|
||||||
|
import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
|
||||||
|
import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity';
|
||||||
|
import { NetworkStunMessageSourceAdapter } from '../../../src/modules/admin/platform-config/network-management/network-stun-message-source.adapter';
|
||||||
|
import { SystemMessageSourceRegistry } from '../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
|
||||||
|
|
||||||
|
type Harness = {
|
||||||
|
adapter: NetworkStunMessageSourceAdapter;
|
||||||
|
ddns: NetworkDdnsRecord;
|
||||||
|
mapping: NetworkPortForward;
|
||||||
|
registry: SystemMessageSourceRegistry;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Creates a mutable in-memory repository harness for the adapter contract. */
|
||||||
|
function createHarness(): Harness {
|
||||||
|
const mapping = Object.assign(new NetworkPortForward(), {
|
||||||
|
currentPublicIpv4: '203.0.113.10',
|
||||||
|
currentPublicPort: 38213,
|
||||||
|
currentValidUntil: new Date('2026-07-24T13:00:00.000Z'),
|
||||||
|
desiredPresence: 'present' as const,
|
||||||
|
externalPort: 8213,
|
||||||
|
id: '2041700000000000001',
|
||||||
|
internalPort: 8213,
|
||||||
|
isDeleted: false,
|
||||||
|
keeperDesiredEnabled: true,
|
||||||
|
name: '帕鲁新世界',
|
||||||
|
protocol: 'udp' as const,
|
||||||
|
});
|
||||||
|
const ddns = Object.assign(new NetworkDdnsRecord(), {
|
||||||
|
appliedAddress: '203.0.113.10',
|
||||||
|
domain: 'kwitsukasa.top',
|
||||||
|
enabled: true,
|
||||||
|
id: '2041700000000000002',
|
||||||
|
isDeleted: false,
|
||||||
|
name: '帕鲁域名',
|
||||||
|
portForwardId: mapping.id,
|
||||||
|
recordType: 'A' as const,
|
||||||
|
sourceType: 'port_forward_ipv4' as const,
|
||||||
|
subDomain: 'pal',
|
||||||
|
syncStatus: 'synced' as const,
|
||||||
|
});
|
||||||
|
const mappings = [mapping];
|
||||||
|
const records = [ddns];
|
||||||
|
const mappingRepository = {
|
||||||
|
find: jest.fn(async () => mappings),
|
||||||
|
findOne: jest.fn(
|
||||||
|
async ({ where }) =>
|
||||||
|
mappings.find((item) => item.id === where.id) || null,
|
||||||
|
),
|
||||||
|
} as unknown as Repository<NetworkPortForward>;
|
||||||
|
const recordRepository = {
|
||||||
|
find: jest.fn(async () => records),
|
||||||
|
findOne: jest.fn(
|
||||||
|
async ({ where }) => records.find((item) => item.id === where.id) || null,
|
||||||
|
),
|
||||||
|
} as unknown as Repository<NetworkDdnsRecord>;
|
||||||
|
const registry = new SystemMessageSourceRegistry();
|
||||||
|
return {
|
||||||
|
adapter: new NetworkStunMessageSourceAdapter(
|
||||||
|
mappingRepository,
|
||||||
|
recordRepository,
|
||||||
|
registry,
|
||||||
|
),
|
||||||
|
ddns,
|
||||||
|
mapping,
|
||||||
|
registry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a valid event payload, with optional untrusted-field overrides. */
|
||||||
|
function eventPayload(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
changedAt: '2026-07-24T12:30:00.000Z',
|
||||||
|
currentPort: 38213,
|
||||||
|
endpoint: 'attacker.example:1',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
previousPort: 8213,
|
||||||
|
publicIpv4: '203.0.113.10',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NetworkStunMessageSourceAdapter', () => {
|
||||||
|
it('registers once and only unregisters its own source instance', () => {
|
||||||
|
const { adapter, registry } = createHarness();
|
||||||
|
adapter.onModuleInit();
|
||||||
|
adapter.onModuleInit();
|
||||||
|
expect(registry.get(adapter.definition.sourceKey)).toBe(adapter);
|
||||||
|
adapter.onModuleDestroy();
|
||||||
|
expect(() => registry.get(adapter.definition.sourceKey)).toThrow(
|
||||||
|
'unknown_message_source',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only an enabled equal-port UDP Keeper and its linked enabled A record', async () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
await expect(
|
||||||
|
adapter.normalizeSubscriptionConfig({
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
ignored: 'removed',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
canonicalConfig: {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
},
|
||||||
|
resourceKey: '2041700000000000001',
|
||||||
|
sourceSummary: '帕鲁新世界 · pal.kwitsukasa.top',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tcp', (harness: Harness) => (harness.mapping.protocol = 'tcp')],
|
||||||
|
['unequal ports', (harness: Harness) => (harness.mapping.internalPort = 1)],
|
||||||
|
[
|
||||||
|
'disabled keeper',
|
||||||
|
(harness: Harness) => (harness.mapping.keeperDesiredEnabled = false),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'deleted mapping',
|
||||||
|
(harness: Harness) => (harness.mapping.isDeleted = true),
|
||||||
|
],
|
||||||
|
['disabled DDNS', (harness: Harness) => (harness.ddns.enabled = false)],
|
||||||
|
['deleted DDNS', (harness: Harness) => (harness.ddns.isDeleted = true)],
|
||||||
|
['non-A DDNS', (harness: Harness) => (harness.ddns.recordType = 'AAAA')],
|
||||||
|
[
|
||||||
|
'mismatched DDNS mapping',
|
||||||
|
(harness: Harness) =>
|
||||||
|
(harness.ddns.portForwardId = '2041700000000000003'),
|
||||||
|
],
|
||||||
|
])('rejects %s subscriptions', async (_name, mutate) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
mutate(harness);
|
||||||
|
await expect(
|
||||||
|
harness.adapter.normalizeSubscriptionConfig({
|
||||||
|
ddnsRecordId: harness.ddns.id,
|
||||||
|
portForwardId: harness.mapping.id,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed Snowflake IDs and strips unknown event/config fields', async () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
await expect(
|
||||||
|
adapter.normalizeSubscriptionConfig({
|
||||||
|
ddnsRecordId: 2041700000000000002,
|
||||||
|
portForwardId: 'not-an-id',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('invalid_message_source_config');
|
||||||
|
expect(adapter.validateEventPayload(eventPayload())).toEqual({
|
||||||
|
changedAt: '2026-07-24T12:30:00.000Z',
|
||||||
|
currentPort: 38213,
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
previousPort: 8213,
|
||||||
|
publicIpv4: '203.0.113.10',
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
adapter.validateEventPayload(eventPayload({ currentPort: '38213' })),
|
||||||
|
).toThrow('invalid_message_event_payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ready variables derived from the server-owned DDNS FQDN and Shanghai time', async () => {
|
||||||
|
const { adapter } = createHarness();
|
||||||
|
await expect(
|
||||||
|
adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload(),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: '2041700000000000002',
|
||||||
|
portForwardId: '2041700000000000001',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
reasonCode: null,
|
||||||
|
status: 'ready',
|
||||||
|
variables: {
|
||||||
|
changedAt: '2026-07-24 20:30:00',
|
||||||
|
domain: 'pal.kwitsukasa.top',
|
||||||
|
endpoint: 'pal.kwitsukasa.top:38213',
|
||||||
|
mappingName: '帕鲁新世界',
|
||||||
|
port: 38213,
|
||||||
|
previousPort: 8213,
|
||||||
|
publicIpv4: '203.0.113.10',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for DDNS, supersedes a replaced endpoint, and cancels a changed relationship', async () => {
|
||||||
|
const waiting = createHarness();
|
||||||
|
waiting.ddns.appliedAddress = null;
|
||||||
|
await expect(
|
||||||
|
waiting.adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload(),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: waiting.ddns.id,
|
||||||
|
portForwardId: waiting.mapping.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
reasonCode: 'ddns_not_synced',
|
||||||
|
status: 'waiting_ddns',
|
||||||
|
});
|
||||||
|
|
||||||
|
const superseded = createHarness();
|
||||||
|
superseded.mapping.currentPublicPort = 39000;
|
||||||
|
await expect(
|
||||||
|
superseded.adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload(),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: superseded.ddns.id,
|
||||||
|
portForwardId: superseded.mapping.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ status: 'superseded' });
|
||||||
|
|
||||||
|
const cancelled = createHarness();
|
||||||
|
cancelled.ddns.enabled = false;
|
||||||
|
await expect(
|
||||||
|
cancelled.adapter.resolveDelivery({
|
||||||
|
eventPayload: eventPayload(),
|
||||||
|
subscriptionConfig: {
|
||||||
|
ddnsRecordId: cancelled.ddns.id,
|
||||||
|
portForwardId: cancelled.mapping.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ status: 'cancelled' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import type { SystemMessageSourceAdapter } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
|
||||||
|
import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
|
||||||
|
|
||||||
|
/** Creates a minimal adapter whose definition can be safely registered. */
|
||||||
|
function createAdapter(sourceKey: string): SystemMessageSourceAdapter {
|
||||||
|
return {
|
||||||
|
definition: {
|
||||||
|
description: 'test',
|
||||||
|
displayName: sourceKey,
|
||||||
|
sourceKey,
|
||||||
|
subscriptionFields: [],
|
||||||
|
variables: [],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
inspectSubscription: jest.fn(),
|
||||||
|
listSubscriptionOptions: jest.fn(),
|
||||||
|
normalizeSubscriptionConfig: jest.fn(),
|
||||||
|
resolveDelivery: jest.fn(),
|
||||||
|
validateEventPayload: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SystemMessageSourceRegistry', () => {
|
||||||
|
it('rejects duplicate source registration and returns immutable definitions', () => {
|
||||||
|
const registry = new SystemMessageSourceRegistry();
|
||||||
|
const adapter = createAdapter('network.stun.mapping-port-changed');
|
||||||
|
registry.register(adapter);
|
||||||
|
|
||||||
|
expect(() => registry.register(adapter)).toThrow(
|
||||||
|
'duplicate_message_source',
|
||||||
|
);
|
||||||
|
expect(registry.list()).toEqual([adapter.definition]);
|
||||||
|
expect(() => registry.get('missing')).toThrow('unknown_message_source');
|
||||||
|
|
||||||
|
const [definition] = registry.list();
|
||||||
|
definition.displayName = 'mutated';
|
||||||
|
expect(
|
||||||
|
registry.get(adapter.definition.sourceKey).definition.displayName,
|
||||||
|
).toBe(adapter.definition.displayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts definitions and unregisters only the same adapter instance', () => {
|
||||||
|
const registry = new SystemMessageSourceRegistry();
|
||||||
|
const first = createAdapter('z.source');
|
||||||
|
const second = createAdapter('a.source');
|
||||||
|
const replacement = createAdapter('z.source');
|
||||||
|
registry.register(first);
|
||||||
|
registry.register(second);
|
||||||
|
|
||||||
|
registry.unregister('z.source', replacement);
|
||||||
|
expect(registry.list().map((definition) => definition.sourceKey)).toEqual([
|
||||||
|
'a.source',
|
||||||
|
'z.source',
|
||||||
|
]);
|
||||||
|
registry.unregister('z.source', first);
|
||||||
|
expect(registry.list().map((definition) => definition.sourceKey)).toEqual([
|
||||||
|
'a.source',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user