814 lines
26 KiB
TypeScript
814 lines
26 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||
import { Prisma } from '@prisma/client';
|
||
import { randomUUID } from 'crypto';
|
||
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
|
||
import type { CreateChannelGroupItemDto, TestChannelDto } from './channels.contracts';
|
||
|
||
export function summarizeReportStatuses(statuses: string[]) {
|
||
return summarizeCommonReportStatuses(statuses);
|
||
}
|
||
|
||
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
||
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||
|
||
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||
|
||
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||
|
||
export const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090';
|
||
|
||
export const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
|
||
|
||
export const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
|
||
|
||
export const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
|
||
|
||
export const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000;
|
||
|
||
export const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000;
|
||
|
||
export const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000;
|
||
|
||
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
|
||
|
||
export const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3;
|
||
|
||
export const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000;
|
||
|
||
export const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
|
||
|
||
export const DEFAULT_CMPP_VERSION = '2.0';
|
||
|
||
export function normalizeTestPhones(data: TestChannelDto) {
|
||
const rawPhones = Array.isArray(data.phones)
|
||
? data.phones
|
||
: String(data.phoneNumber ?? data.phones ?? '').split(/[,\n,\s]+/u);
|
||
const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean);
|
||
const uniquePhones = Array.from(new Set(phones));
|
||
if (uniquePhones.length === 0) {
|
||
throw new BadRequestException('请填写测试手机号');
|
||
}
|
||
if (uniquePhones.length > 10) {
|
||
throw new BadRequestException('测试手机号最多允许 10 个');
|
||
}
|
||
for (const phone of uniquePhones) {
|
||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||
throw new BadRequestException(`手机号格式不正确:${phone}`);
|
||
}
|
||
}
|
||
return uniquePhones;
|
||
}
|
||
|
||
export function normalizeTestContent(content?: string) {
|
||
const normalized = (content ?? '').trim();
|
||
if (!normalized) {
|
||
throw new BadRequestException('请填写测试短信内容');
|
||
}
|
||
if (normalized.length > 1000) {
|
||
throw new BadRequestException('测试短信内容不能超过 1000 字符');
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function calculateBillingUnits(content: string) {
|
||
return Math.max(1, Math.ceil([...content].length / 67));
|
||
}
|
||
|
||
export function buildChannelTestSubmitCommand({
|
||
channel,
|
||
content,
|
||
phoneNumber,
|
||
messageId,
|
||
submitId,
|
||
testNo,
|
||
attempt,
|
||
accessNo,
|
||
}: {
|
||
channel: {
|
||
id: string;
|
||
code: string;
|
||
gatewayHost: string;
|
||
gatewayPort: number;
|
||
account: string;
|
||
passwordCipher: string;
|
||
srcId: string;
|
||
cmppVersion: string;
|
||
rateLimitPerSecond: number;
|
||
config?: Prisma.JsonValue | null;
|
||
};
|
||
content: string;
|
||
phoneNumber: string;
|
||
messageId: string;
|
||
submitId: string;
|
||
testNo: string;
|
||
attempt: number;
|
||
accessNo?: string;
|
||
}) {
|
||
const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId;
|
||
return {
|
||
schemaVersion: 'v1',
|
||
messageType: 'SubmitCommand',
|
||
traceId: randomUUID(),
|
||
messageId,
|
||
channelId: channel.id,
|
||
createdAt: new Date().toISOString(),
|
||
tenantId: 'platform-channel-test',
|
||
applicationId: 'admin-channel-test',
|
||
taskId: testNo,
|
||
submitId,
|
||
queuePriority: 'normal',
|
||
phoneNumber,
|
||
content,
|
||
signature: 'CHANNEL_TEST',
|
||
templateId: 'admin-channel-test',
|
||
billingUnits: calculateBillingUnits(content),
|
||
route: {
|
||
channelCode: channel.code,
|
||
cmppAccountCode: channel.account,
|
||
priority: attempt,
|
||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||
},
|
||
cmpp: {
|
||
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
|
||
srcId,
|
||
extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')),
|
||
registeredDelivery: 1,
|
||
msgFmt: 8,
|
||
},
|
||
upstream: {
|
||
gatewayHost: channel.gatewayHost,
|
||
gatewayPort: channel.gatewayPort,
|
||
account: channel.account,
|
||
passwordCipher: channel.passwordCipher,
|
||
cmppVersion: channel.cmppVersion,
|
||
desiredConnections: getPositiveRuntimeInteger(
|
||
getConfigValue(channel.config, 'desiredConnections'),
|
||
1,
|
||
'desiredConnections',
|
||
),
|
||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||
'heartbeatIntervalSeconds',
|
||
),
|
||
heartbeatMissThreshold: getPositiveRuntimeInteger(
|
||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||
'heartbeatMissThreshold',
|
||
),
|
||
},
|
||
retry: { attempt: 0, maxAttempts: 1 },
|
||
};
|
||
}
|
||
|
||
export function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) {
|
||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||
return config[key as keyof typeof config];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
export function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) {
|
||
const value = getConfigValue(config, key);
|
||
if (value === undefined || value === null || value === '') {
|
||
return fallback;
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
export function normalizeConnectionAction(status: string) {
|
||
const normalized = status.toLowerCase();
|
||
if (normalized === 'connected') {
|
||
return 'connected';
|
||
}
|
||
if (['heartbeat', 'active_test'].includes(normalized)) {
|
||
return 'heartbeat';
|
||
}
|
||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||
return 'reconnecting';
|
||
}
|
||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||
return 'disconnected';
|
||
}
|
||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||
return 'failed';
|
||
}
|
||
return 'updated';
|
||
}
|
||
|
||
export function normalizeCmppVersion(version?: string) {
|
||
const normalized = (version ?? DEFAULT_CMPP_VERSION).trim();
|
||
if (normalized === '2.0' || normalized === '3.0') {
|
||
return normalized;
|
||
}
|
||
throw new BadRequestException('cmppVersion must be 2.0 or 3.0');
|
||
}
|
||
|
||
export function normalizeGatewayConnectionStatus(status: string) {
|
||
const normalized = status.toLowerCase();
|
||
if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
|
||
return 'connected';
|
||
}
|
||
if (['connecting', 'connect_requested'].includes(normalized)) {
|
||
return 'connecting';
|
||
}
|
||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||
return 'reconnecting';
|
||
}
|
||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||
return 'disconnected';
|
||
}
|
||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||
return 'failed';
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function defaultChannelConnectionId(channelId: string) {
|
||
return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`;
|
||
}
|
||
|
||
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||
const value = Number(config.desiredConnections);
|
||
if (Number.isInteger(value) && value >= 1 && value <= 8) {
|
||
return value;
|
||
}
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
export type ChannelConnectionSettings = {
|
||
gatewayHost: string;
|
||
gatewayPort: number;
|
||
account: string;
|
||
passwordCipher: string;
|
||
cmppVersion: string;
|
||
config?: Prisma.JsonValue | Record<string, unknown> | null;
|
||
};
|
||
|
||
export 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;
|
||
}
|
||
|
||
export 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)
|
||
);
|
||
}
|
||
|
||
export 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,
|
||
})),
|
||
};
|
||
}
|
||
|
||
export function normalizeChannelRuntimeConfig(
|
||
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
|
||
incomingConfig?: Record<string, unknown> | null,
|
||
desiredConnections?: number,
|
||
windowSize?: number,
|
||
heartbeatIntervalSeconds?: number,
|
||
heartbeatMissThreshold?: number,
|
||
) {
|
||
const existing =
|
||
existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||
? (existingConfig as Record<string, unknown>)
|
||
: {};
|
||
const incoming =
|
||
incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {};
|
||
const base = { ...existing, ...incoming };
|
||
base.desiredConnections = boundedRuntimeInteger(
|
||
desiredConnections ?? base.desiredConnections,
|
||
1,
|
||
8,
|
||
1,
|
||
'desiredConnections',
|
||
);
|
||
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
|
||
base.connectionWarmupSeconds = boundedRuntimeInteger(
|
||
base.connectionWarmupSeconds,
|
||
0,
|
||
300,
|
||
30,
|
||
'connectionWarmupSeconds',
|
||
);
|
||
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(
|
||
base.connectionDrainTimeoutSeconds,
|
||
1,
|
||
600,
|
||
60,
|
||
'connectionDrainTimeoutSeconds',
|
||
);
|
||
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(
|
||
base.submitResponseTimeoutSeconds,
|
||
1,
|
||
300,
|
||
60,
|
||
'submitResponseTimeoutSeconds',
|
||
);
|
||
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(
|
||
base.connectionFailureCooldownSeconds,
|
||
1,
|
||
300,
|
||
30,
|
||
'connectionFailureCooldownSeconds',
|
||
);
|
||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||
'heartbeatIntervalSeconds',
|
||
);
|
||
base.heartbeatMissThreshold = getPositiveRuntimeInteger(
|
||
heartbeatMissThreshold ?? base.heartbeatMissThreshold,
|
||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||
'heartbeatMissThreshold',
|
||
);
|
||
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
|
||
base.serviceId = normalizeCmppServiceId(base.serviceId);
|
||
base.longMessageReceiptMode = normalizeLongMessageReceiptMode(base.longMessageReceiptMode);
|
||
return base;
|
||
}
|
||
|
||
function boundedRuntimeInteger(value: unknown, minimum: number, maximum: number, fallback: number, field: string) {
|
||
const normalized = value === undefined || value === null || value === '' ? fallback : Number(value);
|
||
if (!Number.isInteger(normalized) || normalized < minimum || normalized > maximum) {
|
||
throw new BadRequestException(`${field} must be an integer between ${minimum} and ${maximum}`);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function normalizeLongMessageReceiptMode(value: unknown) {
|
||
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
|
||
if (!['per_segment', 'message_level'].includes(normalized)) {
|
||
throw new BadRequestException('longMessageReceiptMode must be per_segment or message_level');
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function normalizeCmppServiceId(value: unknown) {
|
||
const normalized = String(value ?? 'SMS').trim() || 'SMS';
|
||
if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) {
|
||
throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters');
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function normalizeChannelRateLimit(value: unknown) {
|
||
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
|
||
if (normalized > 2000) {
|
||
throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000');
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function normalizeExtensionDigits(value: unknown) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return 0;
|
||
}
|
||
const normalized = Number(value);
|
||
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) {
|
||
throw new BadRequestException('extensionDigits must be an integer between 0 and 20');
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return fallback;
|
||
}
|
||
const normalized = Number(value);
|
||
if (!Number.isInteger(normalized) || normalized <= 0) {
|
||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function bullmqConnection() {
|
||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||
return {
|
||
host: redisUrl.hostname,
|
||
port: Number(redisUrl.port || 6379),
|
||
username: redisUrl.username || undefined,
|
||
password: redisUrl.password || undefined,
|
||
maxRetriesPerRequest: null,
|
||
};
|
||
}
|
||
|
||
export function getPositiveIntegerEnv(name: string, fallback: number) {
|
||
const value = Number(process.env[name]);
|
||
if (Number.isInteger(value) && value > 0) {
|
||
return value;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||
const lines = content
|
||
.replace(/^\uFEFF/, '')
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
if (lines.length === 0) {
|
||
throw new BadRequestException('Receipt file is empty');
|
||
}
|
||
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
||
const firstCells = splitReceiptLine(lines[0], separator);
|
||
const hasHeader = firstCells.some((cell) =>
|
||
['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()),
|
||
);
|
||
const header = hasHeader ? firstCells : [];
|
||
const rows = hasHeader ? lines.slice(1) : lines;
|
||
const statusIndex = findReceiptStatusIndex(header);
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
const resultRows = rows.map((line, index) => {
|
||
const cells = splitReceiptLine(line, separator);
|
||
const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? '';
|
||
const normalizedStatus = normalizeReceiptStatus(rawStatus);
|
||
if (normalizedStatus === 'success') {
|
||
successCount += 1;
|
||
} else {
|
||
failedCount += 1;
|
||
}
|
||
return {
|
||
rowNumber: hasHeader ? index + 2 : index + 1,
|
||
phone: cells[0] ?? '',
|
||
status: normalizedStatus,
|
||
rawStatus,
|
||
raw: cells,
|
||
};
|
||
});
|
||
return {
|
||
rowCount: resultRows.length,
|
||
successCount,
|
||
failedCount,
|
||
result: {
|
||
delimiter: separator === '\t' ? 'tab' : 'comma',
|
||
hasHeader,
|
||
rows: resultRows,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function splitReceiptLine(line: string, delimiter: ',' | '\t') {
|
||
if (delimiter === '\t') {
|
||
return line.split('\t').map((cell) => stripReceiptCell(cell));
|
||
}
|
||
const cells: string[] = [];
|
||
let current = '';
|
||
let quoted = false;
|
||
for (let index = 0; index < line.length; index += 1) {
|
||
const char = line[index];
|
||
const next = line[index + 1];
|
||
if (char === '"' && quoted && next === '"') {
|
||
current += '"';
|
||
index += 1;
|
||
} else if (char === '"') {
|
||
quoted = !quoted;
|
||
} else if (char === ',' && !quoted) {
|
||
cells.push(stripReceiptCell(current));
|
||
current = '';
|
||
} else {
|
||
current += char;
|
||
}
|
||
}
|
||
cells.push(stripReceiptCell(current));
|
||
return cells;
|
||
}
|
||
|
||
export function stripReceiptCell(value: string) {
|
||
return value.trim().replace(/^"|"$/g, '').trim();
|
||
}
|
||
|
||
export function findReceiptStatusIndex(header: string[]) {
|
||
if (header.length === 0) {
|
||
return 1;
|
||
}
|
||
const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase()));
|
||
return index >= 0 ? index : Math.max(0, header.length - 1);
|
||
}
|
||
|
||
export function normalizeReceiptStatus(value: string) {
|
||
const normalized = value.trim().toLowerCase();
|
||
if (
|
||
[
|
||
'success',
|
||
'succeeded',
|
||
'approved',
|
||
'completed',
|
||
'ok',
|
||
'pass',
|
||
'passed',
|
||
'通过',
|
||
'成功',
|
||
'已完成',
|
||
'报备成功',
|
||
].includes(normalized)
|
||
) {
|
||
return 'success';
|
||
}
|
||
if (
|
||
[
|
||
'failed',
|
||
'fail',
|
||
'rejected',
|
||
'reject',
|
||
'error',
|
||
'no',
|
||
'denied',
|
||
'驳回',
|
||
'失败',
|
||
'不通过',
|
||
'拒绝',
|
||
'报备失败',
|
||
].includes(normalized)
|
||
) {
|
||
return 'failed';
|
||
}
|
||
return 'failed';
|
||
}
|
||
|
||
export function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) {
|
||
if (rowCount <= 0 || successCount <= 0) {
|
||
return 'failed';
|
||
}
|
||
if (failedCount > 0) {
|
||
return 'partial';
|
||
}
|
||
return 'completed';
|
||
}
|
||
|
||
export type ChannelReportDeliveryRow = {
|
||
drainageIds?: string[] | null;
|
||
carrier: string | null;
|
||
channelId: string;
|
||
signatureId: string;
|
||
drainageInfoId: string | null;
|
||
total: number;
|
||
acceptedCount: number;
|
||
submitFailureCount: number;
|
||
successCount: number;
|
||
unknownCount: number;
|
||
failureCount: number;
|
||
lastSuccessfulSentAt: Date | null;
|
||
};
|
||
|
||
export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) {
|
||
const total = sumReportDelivery(rows, 'total');
|
||
const acceptedCount = sumReportDelivery(rows, 'acceptedCount');
|
||
const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount');
|
||
const successCount = sumReportDelivery(rows, 'successCount');
|
||
const unknownCount = sumReportDelivery(rows, 'unknownCount');
|
||
const failureCount = sumReportDelivery(rows, 'failureCount');
|
||
return {
|
||
total,
|
||
acceptedCount,
|
||
submitFailureCount,
|
||
submitFailureRate: percentage(submitFailureCount, total),
|
||
successCount,
|
||
successRate: percentage(successCount, acceptedCount),
|
||
unknownCount,
|
||
unknownRate: percentage(unknownCount, acceptedCount),
|
||
failureCount,
|
||
failureRate: percentage(failureCount, acceptedCount),
|
||
};
|
||
}
|
||
|
||
export function sumReportDelivery(
|
||
rows: ChannelReportDeliveryRow[],
|
||
key: keyof Pick<
|
||
ChannelReportDeliveryRow,
|
||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
||
>,
|
||
) {
|
||
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
||
}
|
||
|
||
export function percentage(count: number, total: number) {
|
||
return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0;
|
||
}
|
||
|
||
export function latestDate(values: Array<Date | null>) {
|
||
const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime());
|
||
return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
|
||
}
|
||
|
||
export function currentShanghaiDayRange(now = new Date()) {
|
||
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000);
|
||
const localDate = shifted.toISOString().slice(0, 10);
|
||
const startAt = new Date(`${localDate}T00:00:00+08:00`);
|
||
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
|
||
}
|
||
|
||
export function normalizeRetryTimeLimitMinutes(
|
||
minutes: number | undefined,
|
||
hours: number | undefined,
|
||
fallbackMinutes: number,
|
||
) {
|
||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function normalizeSpreadsheetSize(
|
||
value: number | undefined,
|
||
fallback: number,
|
||
minimum: number,
|
||
maximum: number,
|
||
) {
|
||
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||
}
|
||
|
||
export function normalizeBusinessCarrier(carrier?: string | null) {
|
||
const normalized = normalizeChannelCarrier(carrier);
|
||
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
|
||
throw new BadRequestException('carrier must be mobile, unicom, or telecom');
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
export function normalizeChannelCarrier(carrier?: string | null) {
|
||
const value = String(carrier ?? '')
|
||
.trim()
|
||
.toLowerCase();
|
||
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
||
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
|
||
return value;
|
||
}
|
||
|
||
export const SUPPORTED_CHANNEL_CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
|
||
|
||
export function normalizeChannelCarriers(carriers?: string[] | null, legacyCarrier?: string | null): string[] {
|
||
const source = carriers?.length
|
||
? carriers
|
||
: normalizeChannelCarrier(legacyCarrier ?? 'mobile') === 'all'
|
||
? [...SUPPORTED_CHANNEL_CARRIERS]
|
||
: [normalizeChannelCarrier(legacyCarrier ?? 'mobile')];
|
||
const normalized = [...new Set(source.map((carrier) => normalizeBusinessCarrier(carrier)))];
|
||
if (normalized.length === 0) throw new BadRequestException('至少选择一个运营商');
|
||
return SUPPORTED_CHANNEL_CARRIERS.filter((carrier) => normalized.includes(carrier));
|
||
}
|
||
|
||
export function legacyCarrierFromCapabilities(carriers: string[]) {
|
||
if (carriers.length === 1) return carriers[0];
|
||
if (carriers.length === SUPPORTED_CHANNEL_CARRIERS.length) return 'all';
|
||
// Old readers must fail closed for a two-carrier channel instead of treating
|
||
// it as three-network capable and accidentally routing unsupported traffic.
|
||
return 'multi';
|
||
}
|
||
|
||
export function isChannelCarrierCompatible(
|
||
channelCarrier: string | null | undefined,
|
||
groupCarrier: string,
|
||
carriers?: string[] | null,
|
||
) {
|
||
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
|
||
}
|
||
|
||
export function normalizeRegion(region?: string | null) {
|
||
return String(region ?? '')
|
||
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
|
||
.trim();
|
||
}
|
||
|
||
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
|
||
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
|
||
}
|
||
|
||
export function validateGroupItems(
|
||
groupCarrier: string,
|
||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||
channels: Map<
|
||
string,
|
||
{ id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }
|
||
>,
|
||
) {
|
||
const channelIds = new Set<string>();
|
||
const provinces = new Set<string>();
|
||
const nationalPriorities = new Set<number>();
|
||
for (const item of items) {
|
||
const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier;
|
||
if (itemCarrier !== groupCarrier) {
|
||
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
|
||
}
|
||
const channel = channels.get(item.channelId);
|
||
if (!channel) {
|
||
throw new NotFoundException('Channel not found');
|
||
}
|
||
if (channelIds.has(item.channelId)) {
|
||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||
}
|
||
channelIds.add(item.channelId);
|
||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
|
||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||
}
|
||
if (item.province) {
|
||
const province = normalizeRegion(item.province);
|
||
if (provinces.has(province)) {
|
||
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
|
||
}
|
||
provinces.add(province);
|
||
if (!isRegionCompatible(channel.sendRegion, item.province)) {
|
||
throw new BadRequestException('Province route must use a channel with the same sendRegion');
|
||
}
|
||
} else {
|
||
const priority = item.priority ?? 100;
|
||
if (nationalPriorities.has(priority)) {
|
||
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
|
||
}
|
||
nationalPriorities.add(priority);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function normalizeReportType(value?: string) {
|
||
if (value === 'signature' || value === 'drainage' || value === 'both') return value;
|
||
throw new BadRequestException('reportType must be signature, drainage or both');
|
||
}
|
||
|
||
export function normalizeLinkEvent(action: string) {
|
||
if (action.includes('connect_requested')) {
|
||
return '连接请求';
|
||
}
|
||
if (action.includes('connected')) {
|
||
return '连接成功';
|
||
}
|
||
if (action.includes('heartbeat')) {
|
||
return '心跳';
|
||
}
|
||
if (action.includes('reconnecting')) {
|
||
return '重连';
|
||
}
|
||
if (action.includes('disconnected')) {
|
||
return '断开';
|
||
}
|
||
if (action.includes('failed')) {
|
||
return '连接失败';
|
||
}
|
||
if (action.includes('copy')) {
|
||
return '复制';
|
||
}
|
||
if (action.includes('deleted')) {
|
||
return '删除';
|
||
}
|
||
return '更新';
|
||
}
|