import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'crypto'; import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; /** 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 > 0) { return value; } } return 1; } export type ChannelConnectionSettings = { gatewayHost: string; gatewayPort: number; account: string; passwordCipher: string; cmppVersion: string; config?: Prisma.JsonValue | Record | null; }; export function getRuntimeConfigInteger( config: Prisma.JsonValue | Record | null | undefined, key: string, fallback: number, ) { if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback; const value = Number((config as Record)[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 | null, incomingConfig?: Record | null, desiredConnections?: number, windowSize?: number, heartbeatIntervalSeconds?: number, heartbeatMissThreshold?: number, ) { const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) ? existingConfig as Record : {}; const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {}; const base = { ...existing, ...incoming }; base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections'); base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize'); 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; } 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 = { 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) { 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 function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) { const normalized = normalizeChannelCarrier(channelCarrier); return normalized === 'all' || normalized === 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>, channels: Map, ) { const channelIds = new Set(); const provinces = new Set(); const nationalPriorities = new Set(); 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)) { 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 summarizeReportStatuses(statuses: string[]) { if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 }; const approved = statuses.filter((status) => status === 'approved').length; let status = 'pending'; if (approved === statuses.length) status = 'approved'; else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed'; else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting'; else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material'; return { status, approved, total: statuses.length }; } 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 '更新'; }