Files
lislgosms/api/src/channels/channel-test.service.ts
T

121 lines
6.2 KiB
TypeScript

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,
};
}
}