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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; import { ChannelConnectionService } from './channel-connection.service'; import { detectDrainageContent } from '../send-chain/drainage-content-detection'; /** R5 channel domain service composed behind ChannelsService. */ export class ChannelTestService { constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} async testChannel(channelId: string, data: TestChannelDto = {}) { const phoneNumbers = normalizeTestPhones(data); const content = normalizeTestContent(data.content); const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, include: { connectionStates: true }, }); if (!channel) { throw new NotFoundException('Channel not found'); } if (channel.status !== 'active') { throw new BadRequestException('通道未启用,不能发送测试短信'); } const connectedState = channel.connectionStates.find((state) => normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0, ); if (!connectedState) { throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送'); } const createdAt = new Date(); const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`; const drainageDetection = await detectDrainageContent(this.prisma, content); const results = []; for (const [index, phoneNumber] of phoneNumbers.entries()) { const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; const submitId = `SUB-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; const session = await this.prisma.cmppSubmitSession.upsert({ where: { sessionNo: `OPEN-${channel.id}` }, update: { submitTotal: { increment: 1 } }, create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 }, }); const messageRecord = await this.prisma.smsMessageRecord.create({ data: { messageId, phoneNumber, content, ...drainageDetection, billingUnits: calculateBillingUnits(content), unitPrice: 0, amountCents: 0, queuePriority: 'normal', channelId: channel.id, submitId, status: 'submit_queued', submitStatus: 'queued', }, }); await this.prisma.smsSubmitRecord.create({ data: { messageRecordId: messageRecord.id, channelId: channel.id, sessionId: session.id, submitId, submitStatus: 'queued', costUnitPrice: channel.unitPrice, costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits, }, }); const command = buildChannelTestSubmitCommand({ channel, content, phoneNumber, messageId, submitId, testNo, attempt: index, accessNo: data.accessNo, }); await this.connection.getGatewaySubmitQueue().add('submit-command', command); const streamMessageId = await this.connection.publishGatewaySubmitCommand(command); results.push({ phoneNumber, messageRecordId: messageRecord.id, submitId, streamMessageId, }); } await this.prisma.operationLog.create({ data: { userId: data.operatorId, action: 'sms_channel.test_submit', resource: 'sms_channel', resourceId: channel.id, detail: { testNo, phoneTotal: phoneNumbers.length, messageRecordIds: results.map((item) => item.messageRecordId), connectionId: connectedState.connectionId, } as Prisma.InputJsonValue, }, }); return { channelId, status: 'submit_queued', testNo, submitted: results.length, messages: results, queuedAt: createdAt, }; } }