fix: harden channel retry attribution and operations UI

This commit is contained in:
hectorzhao
2026-07-26 21:33:58 +08:00
parent 059b38e8fe
commit 0857de09d8
27 changed files with 888 additions and 117 deletions
+102 -16
View File
@@ -331,6 +331,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
gatewayPort: gatewayPort ?? channel.gatewayPort,
account: data.account ?? channel.account,
passwordCipher: data.passwordCipher ?? channel.passwordCipher,
cmppVersion: cmppVersion ?? channel.cmppVersion,
config: config ?? channel.config,
});
const updated = await this.prisma.smsChannel.update({
where: { id: channelId },
data: {
@@ -374,20 +382,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
} as Prisma.InputJsonValue,
},
});
const connectionConfigChanged = [
'gatewayHost',
'gatewayPort',
'account',
'passwordCipher',
'cmppVersion',
'rateLimitPerSecond',
'desiredConnections',
'windowSize',
'heartbeatIntervalSeconds',
'heartbeatMissThreshold',
].some((key) => data[key as keyof UpdateChannelDto] !== undefined)
|| Boolean(data.config && ['desiredConnections', 'windowSize', 'heartbeatIntervalSeconds', 'heartbeatMissThreshold']
.some((key) => key in data.config!));
const updatedStatus = data.status ?? channel.status;
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
await this.requestChannelConnection(updated, 'channel_updated');
@@ -915,7 +909,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}
async updateGroup(groupId: string, data: UpdateChannelGroupDto) {
const current = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
const current = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
if (!current) {
throw new NotFoundException('Channel group not found');
}
@@ -959,10 +956,22 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
})),
});
}
return tx.smsChannelGroup.findUnique({
const updated = await tx.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
await tx.operationLog.create({
data: {
action: 'sms_channel_group.update',
resource: 'sms_channel_group',
resourceId: groupId,
detail: {
before: channelGroupAuditSnapshot(current),
after: updated ? channelGroupAuditSnapshot(updated) : null,
} as Prisma.InputJsonValue,
},
});
return updated;
});
}
@@ -1866,6 +1875,83 @@ function getDesiredConnections(config?: Prisma.JsonValue | null) {
return 1;
}
type ChannelConnectionSettings = {
gatewayHost: string;
gatewayPort: number;
account: string;
passwordCipher: string;
cmppVersion: string;
config?: Prisma.JsonValue | Record<string, unknown> | null;
};
function getRuntimeConfigInteger(
config: Prisma.JsonValue | Record<string, unknown> | null | undefined,
key: string,
fallback: number,
) {
if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback;
const value = Number((config as Record<string, unknown>)[key]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
function channelConnectionSettingsChanged(
before: ChannelConnectionSettings,
after: ChannelConnectionSettings,
) {
return before.gatewayHost !== after.gatewayHost
|| before.gatewayPort !== after.gatewayPort
|| before.account !== after.account
|| before.passwordCipher !== after.passwordCipher
|| before.cmppVersion !== after.cmppVersion
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
}
function channelGroupAuditSnapshot(group: {
code: string;
name: string;
carrier: string;
description?: string | null;
status: string;
retryEnabled: boolean;
retryTimeLimitMinutes: number;
items?: Array<{
channelId: string;
carrier?: string | null;
province?: string | null;
priority: number;
weight: number;
isBackup: boolean;
channel?: { code?: string; name?: string } | null;
}>;
}) {
return {
code: group.code,
name: group.name,
carrier: group.carrier,
description: group.description ?? null,
status: group.status,
retryEnabled: group.retryEnabled,
retryTimeLimitMinutes: group.retryTimeLimitMinutes,
items: (group.items ?? []).map((item) => ({
channelId: item.channelId,
channelCode: item.channel?.code ?? null,
channelName: item.channel?.name ?? null,
carrier: item.carrier ?? null,
province: item.province ?? null,
priority: item.priority,
weight: item.weight,
isBackup: item.isBackup,
})),
};
}
function normalizeChannelRuntimeConfig(
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
incomingConfig?: Record<string, unknown> | null,