import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Queue } from 'bullmq'; import IORedis from 'ioredis'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'crypto'; import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; import { ChannelConnectionService } from './channel-connection.service'; /** R5 channel domain service composed behind ChannelsService. */ export class ChannelConfigurationService { constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} listChannels() { return this.prisma.smsChannel.findMany({ include: { connectionStates: true }, orderBy: { createdAt: 'desc' }, }); } async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsChannelWhereInput = { status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined, name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, }; const candidates = await this.prisma.smsChannel.findMany({ where, select: { id: true, name: true } }); const total = candidates.length; if (total === 0) return { items: [], total, page, pageSize }; const day = currentShanghaiDayRange(); const counts = await this.prisma.$queryRaw>(Prisma.sql` SELECT submit."channelId" AS "channelId", COUNT(*)::integer AS total FROM "SmsSubmitRecord" submit WHERE submit."channelId" IN (${Prisma.join(candidates.map((channel) => channel.id))}) AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout') GROUP BY submit."channelId" `); const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)])); // 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。 const pageIds = candidates .sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) || left.name.localeCompare(right.name, 'zh-CN') || left.id.localeCompare(right.id)) .slice((page - 1) * pageSize, page * pageSize) .map((channel) => channel.id); const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } }); const itemById = new Map(pageItems.map((item) => [item.id, item])); const items = pageIds.flatMap((id) => { const item = itemById.get(id); return item ? [item] : []; }); return { items, total, page, pageSize }; } async createChannel(data: CreateChannelDto) { assertMoneyUnits(data.unitPrice ?? 0, '通道单价'); const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => { const value = data[field as keyof CreateChannelDto]; return value === undefined || value === null || value === ''; }); if (missingFields.length > 0) { throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`); } const gatewayPort = Number(data.gatewayPort ?? 7890); if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) { throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); } const cmppVersion = normalizeCmppVersion(data.cmppVersion); const config = normalizeChannelRuntimeConfig( undefined, data.config, data.desiredConnections, data.windowSize, data.heartbeatIntervalSeconds, data.heartbeatMissThreshold, ); const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond); const carriers = normalizeChannelCarriers(data.carriers, data.carrier); const channel = await this.prisma.smsChannel.create({ data: { code: data.code, name: data.name, carrier: legacyCarrierFromCapabilities(carriers), carriers, sendRegion: data.sendRegion ?? '全国', protocol: 'CMPP', gatewayHost: data.gatewayHost, gatewayPort, enterpriseCode: data.enterpriseCode, account: data.account, passwordCipher: data.passwordCipher, srcId: data.srcId, cmppVersion, rateLimitPerSecond, unitPrice: data.unitPrice ?? 0, status: data.status ?? 'active', config: config as Prisma.InputJsonValue, }, }); if (channel.status === 'active') { await this.connection.requestChannelConnection(channel, 'channel_created'); } return channel; } async updateChannel(channelId: string, data: UpdateChannelDto) { const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); if (!channel) { throw new NotFoundException('Channel not found'); } if (data.unitPrice !== undefined) { assertMoneyUnits(data.unitPrice, '通道单价'); } const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort); if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) { throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); } const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined || data.heartbeatIntervalSeconds !== undefined || data.heartbeatMissThreshold !== undefined ? normalizeChannelRuntimeConfig( channel.config, data.config, data.desiredConnections, data.windowSize, data.heartbeatIntervalSeconds, data.heartbeatMissThreshold, ) : undefined; const rateLimitPerSecond = data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond); const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier); const carriers = data.carriers !== undefined || data.carrier !== undefined ? normalizeChannelCarriers(data.carriers, data.carrier) : existingCarriers; if (data.carriers !== undefined || data.carrier !== undefined) { const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier)); if (removed.length) { const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({ where: { channelId, group: { status: 'active', carrier: { in: removed } } }, include: { group: true }, }); if (blockingGroups.length) { throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`); } } } 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: { code: data.code, name: data.name, carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined, carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined, sendRegion: data.sendRegion, protocol: 'CMPP', gatewayHost: data.gatewayHost, gatewayPort, enterpriseCode: data.enterpriseCode, account: data.account, passwordCipher: data.passwordCipher, srcId: data.srcId, cmppVersion, rateLimitPerSecond, unitPrice: data.unitPrice, status: data.status, config: config as Prisma.InputJsonValue | undefined, }, }); await this.prisma.operationLog.create({ data: { action: 'sms_channel.update', resource: 'sms_channel', resourceId: channelId, detail: { before: { code: channel.code, name: channel.name, carrier: channel.carrier, carriers: channel.carriers, sendRegion: channel.sendRegion, gatewayHost: channel.gatewayHost, gatewayPort: channel.gatewayPort, enterpriseCode: channel.enterpriseCode, account: channel.account, srcId: channel.srcId, unitPrice: moneyToNumber(channel.unitPrice), }, after: data, } as Prisma.InputJsonValue, }, }); const updatedStatus = data.status ?? channel.status; if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) { await this.connection.requestChannelConnection(updated, 'channel_updated'); } else if (updatedStatus !== 'active' && channel.status === 'active') { await this.connection.requestChannelDisconnection(updated, 'channel_disabled'); } return updated; } async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) { const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); if (!channel) { throw new NotFoundException('Channel not found'); } const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } }); await this.prisma.operationLog.create({ data: { userId: data.operatorId, action: `sms_channel.${data.status}`, resource: 'sms_channel', resourceId: channelId, detail: { statusBefore: channel.status, statusAfter: data.status, reason: data.reason, } as Prisma.InputJsonValue, }, }); if (data.status === 'active') { await this.connection.requestChannelConnection(updated, 'channel_enabled', data.operatorId); } else if (channel.status === 'active' || data.status === 'deleted') { await this.connection.requestChannelDisconnection( updated, data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled', data.operatorId, ); } return updated; } }