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 { PrismaService } from '../prisma/prisma.service'; export interface CreateChannelDto { code: string; name: string; carrier?: string; sendRegion?: string; protocol?: string; gatewayHost: string; gatewayPort: number; enterpriseCode?: string; account: string; passwordCipher: string; srcId: string; cmppVersion?: string; rateLimitPerSecond?: number; unitPrice?: number; status?: string; desiredConnections?: number; windowSize?: number; config?: Record; } export type UpdateChannelDto = Partial; export interface CreateChannelGroupDto { code: string; name: string; carrier: string; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; } export interface CreateChannelGroupItemDto { groupId: string; channelId: string; carrier?: string; province?: string; priority?: number; weight?: number; isBackup?: boolean; rateLimitPerSecond?: number; } export interface UpdateChannelGroupDto { code?: string; name?: string; carrier?: string; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array>; } export interface CreateRouteRuleDto { tenantId?: string; applicationId?: string; groupId: string; channelId?: string; carrier?: string; province?: string; priority?: number; status?: string; } export interface CreateReportFieldDto { channelId: string; drainageFieldId: string; reportType: 'signature' | 'drainage' | 'both'; code?: string; name?: string; fieldType?: string; required?: boolean; description?: string; sortOrder?: number; status?: string; } export interface CreateReportMaterialDto { signatureId: string; channelId: string; fieldCode: string; fieldValue?: string; fileObjectId?: string; } export interface CreateReportTaskDto { tenantId: string; signatureId: string; channelId: string; createdById?: string; } export interface CreateReportExportDto { fileObjectId?: string; fileName: string; rowCount?: number; } export interface CreateReceiptImportDto { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record; } export interface UpsertConnectionStateDto { tenantId?: string; applicationId?: string; channelId: string; connectionId: string; status: string; desiredConnections?: number; currentConnections?: number; lastConnectedAt?: string; lastDisconnectedAt?: string; lastHeartbeatAt?: string; reconnectCount?: number; lastError?: string; } export interface ChangeChannelStatusDto { status: string; operatorId?: string; reason?: string; } export interface CopyChannelDto { name?: string; code?: string; operatorId?: string; } export interface TestChannelDto { phoneNumber?: string; phones?: string[] | string; content?: string; accessNo?: string; operatorId?: string; } const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands'; const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue'; const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands'; const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090'; const DEFAULT_CHANNEL_CONNECTION_ID = 'primary'; const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000; const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000; const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out'; const DEFAULT_CMPP_VERSION = '2.0'; @Injectable() export class ChannelsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ChannelsService.name); private gatewayConnectionQueue?: Queue; private gatewaySubmitQueue?: Queue; private redis?: IORedis; private connectionTimeoutTimer?: ReturnType; constructor(private readonly prisma: PrismaService) {} onModuleInit() { if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED === 'true') { return; } this.connectionTimeoutTimer = setInterval(() => { void this.markTimedOutConnectingChannels().catch((error) => { this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`); }); }, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS)); this.connectionTimeoutTimer.unref?.(); } async onModuleDestroy() { if (this.connectionTimeoutTimer) { clearInterval(this.connectionTimeoutTimer); } await this.gatewayConnectionQueue?.close(); await this.gatewaySubmitQueue?.close(); this.redis?.disconnect(); } listChannels() { return this.prisma.smsChannel.findMany({ include: { connectionStates: true }, orderBy: { createdAt: 'desc' }, }); } async createChannel(data: CreateChannelDto) { const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', '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); 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); const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond); const channel = await this.prisma.smsChannel.create({ data: { code: data.code, name: data.name, carrier: data.carrier, sendRegion: data.sendRegion ?? '全国', protocol: data.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.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'); } 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 ? normalizeChannelRuntimeConfig(channel.config, data.config, data.desiredConnections, data.windowSize) : undefined; const rateLimitPerSecond = data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond); const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { code: data.code, name: data.name, carrier: data.carrier, sendRegion: data.sendRegion, protocol: data.protocol, 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, sendRegion: channel.sendRegion, gatewayHost: channel.gatewayHost, gatewayPort: channel.gatewayPort, enterpriseCode: channel.enterpriseCode, account: channel.account, srcId: channel.srcId, unitPrice: channel.unitPrice, }, after: data, } as Prisma.InputJsonValue, }, }); 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.requestChannelConnection(updated, 'channel_enabled', data.operatorId); } return updated; } async copyChannel(channelId: string, data: CopyChannelDto = {}) { const source = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, include: { reportFields: true }, }); if (!source) { throw new NotFoundException('Channel not found'); } const suffix = Date.now().toString(36).toUpperCase(); const nextName = data.name ?? `${source.name}副本`; const nextCode = data.code ?? `${source.code}-COPY-${suffix}`; const copied = await this.prisma.$transaction(async (tx) => { const nextChannel = await tx.smsChannel.create({ data: { code: nextCode, name: nextName, carrier: source.carrier, protocol: source.protocol, gatewayHost: source.gatewayHost, gatewayPort: source.gatewayPort, enterpriseCode: source.enterpriseCode, account: source.account, passwordCipher: source.passwordCipher, srcId: source.srcId, sendRegion: source.sendRegion, cmppVersion: source.cmppVersion, rateLimitPerSecond: source.rateLimitPerSecond, unitPrice: source.unitPrice, status: 'disabled', config: source.config as Prisma.InputJsonValue | undefined, reportFields: { create: source.reportFields.map((field) => ({ drainageFieldId: field.drainageFieldId, reportType: field.reportType, code: field.code, name: field.name, fieldType: field.fieldType, required: field.required, description: field.description, sortOrder: field.sortOrder, status: field.status, })), }, }, include: { reportFields: true }, }); const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } }); if (reportMaterials.length > 0) { await tx.signatureReportMaterial.createMany({ data: reportMaterials.map((material) => ({ signatureId: material.signatureId, channelId: nextChannel.id, fieldCode: material.fieldCode, fieldValue: material.fieldValue, fileObjectId: material.fileObjectId, })), skipDuplicates: true, }); } await tx.operationLog.create({ data: { userId: data.operatorId, action: 'sms_channel.copy', resource: 'sms_channel', resourceId: nextChannel.id, detail: { sourceChannelId: source.id, sourceCode: source.code, sourceStatus: source.status, copiedStatus: 'disabled', copiedReportFields: source.reportFields.length, copiedReportMaterials: reportMaterials.length, } as Prisma.InputJsonValue, }, }); return nextChannel; }); return copied; } async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) { return this.changeChannelStatus(channelId, { ...data, status: 'deleted' }); } 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 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, billingUnits: calculateBillingUnits(content), unitPrice: 0, amountCents: 0, queuePriority: 'normal', channelId: channel.id, submitId, status: 'submit_queued', submitStatus: 'queued', errorMessage: '运营端通道测试短信', }, }); await this.prisma.smsSubmitRecord.create({ data: { messageRecordId: messageRecord.id, channelId: channel.id, sessionId: session.id, submitId, submitStatus: 'queued', }, }); const command = buildChannelTestSubmitCommand({ channel, content, phoneNumber, messageId, submitId, testNo, attempt: index, accessNo: data.accessNo, }); await this.getGatewaySubmitQueue().add('submit-command', command); const streamMessageId = await this.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, }; } listChannelMetrics(channelId: string) { return this.prisma.channelHealthMetric.findMany({ where: { channelId }, orderBy: { windowStart: 'desc' }, take: 100, }); } listChannelConnections(channelId: string) { return this.prisma.cmppConnectionState.findMany({ where: { channelId }, orderBy: { updatedAt: 'desc' }, }); } async listChannelConnectionLogs(channelId: string) { const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } }); if (!channel) { throw new NotFoundException('Channel not found'); } const [connectionStates, logs] = await Promise.all([ this.prisma.cmppConnectionState.findMany({ where: { channelId }, orderBy: { updatedAt: 'desc' }, take: 50, }), this.prisma.operationLog.findMany({ where: { OR: [ { resource: 'sms_channel', resourceId: channelId }, { resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } }, ], }, orderBy: { createdAt: 'desc' }, take: 100, }), ]); return { channelId, connectionStates, logs: logs.map((log) => ({ id: log.id, time: log.createdAt, event: normalizeLinkEvent(log.action), action: log.action, resourceId: log.resourceId, detail: log.detail, })), }; } listTenantConnections(tenantId: string) { return this.prisma.cmppConnectionState.findMany({ where: { tenantId }, include: { channel: true }, orderBy: { updatedAt: 'desc' }, }); } async upsertConnectionState(data: UpsertConnectionStateDto) { const status = normalizeGatewayConnectionStatus(data.status); if (data.applicationId) { const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); if (!application) { throw new BadRequestException('applicationId does not reference an existing application'); } if (data.tenantId && data.tenantId !== application.tenantId) { throw new BadRequestException('applicationId does not belong to tenantId'); } data.tenantId = application.tenantId; } const payload = { tenantId: data.tenantId, applicationId: data.applicationId, status, desiredConnections: data.desiredConnections ?? 1, currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0), lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined, lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined, lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined, reconnectCount: data.reconnectCount ?? 0, lastError: status === 'connected' ? null : data.lastError, }; const existing = await this.prisma.cmppConnectionState.findFirst({ where: { applicationId: data.applicationId ?? null, channelId: data.channelId, connectionId: data.connectionId, }, }); const state = existing ? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload }) : await this.prisma.cmppConnectionState.create({ data: { channelId: data.channelId, connectionId: data.connectionId, ...payload, }, }); await this.prisma.operationLog.create({ data: { tenantId: data.tenantId, action: `cmpp_connection.${normalizeConnectionAction(status)}`, resource: 'cmpp_connection', resourceId: `${data.channelId}:${data.connectionId}`, detail: { status, applicationId: state.applicationId, desiredConnections: state.desiredConnections, currentConnections: state.currentConnections, lastError: state.lastError, } as Prisma.InputJsonValue, }, }); return state; } async markTimedOutConnectingChannels(now = new Date()) { const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS); const cutoff = new Date(now.getTime() - timeoutMs); const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`; const states = await this.prisma.cmppConnectionState.findMany({ where: { status: 'connecting', updatedAt: { lte: cutoff }, }, select: { id: true, tenantId: true, applicationId: true, channelId: true, connectionId: true, desiredConnections: true, currentConnections: true, updatedAt: true, }, take: 100, }); let failed = 0; for (const state of states) { const result = await this.prisma.cmppConnectionState.updateMany({ where: { id: state.id, status: 'connecting', updatedAt: { lte: cutoff }, }, data: { status: 'failed', currentConnections: 0, lastDisconnectedAt: now, lastError, }, }); if (result.count === 0) { continue; } failed += result.count; await this.prisma.operationLog.create({ data: { tenantId: state.tenantId, action: 'cmpp_connection.failed', resource: 'cmpp_connection', resourceId: `${state.channelId}:${state.connectionId}`, detail: { reason: 'connect_timeout', applicationId: state.applicationId, status: 'failed', previousStatus: 'connecting', desiredConnections: state.desiredConnections, currentConnectionsBefore: state.currentConnections, currentConnections: 0, timeoutMs, lastError, } as Prisma.InputJsonValue, }, }); } return { checked: states.length, failed }; } listGroups() { return this.prisma.smsChannelGroup.findMany({ include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, orderBy: { createdAt: 'desc' }, }); } createGroup(data: CreateChannelGroupDto) { const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720); const carrier = normalizeBusinessCarrier(data.carrier); return this.prisma.smsChannelGroup.create({ data: { code: data.code, name: data.name, carrier, description: data.description, status: data.status ?? 'active', retryEnabled: data.retryEnabled ?? true, retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), retryTimeLimitMinutes, }, }); } async addGroupItem(data: CreateChannelGroupItemDto) { const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); if (!group) { throw new NotFoundException('Channel group not found'); } const groupCarrier = normalizeBusinessCarrier(group.carrier); const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier; if (itemCarrier !== groupCarrier) { throw new BadRequestException('Channel group items must use the same carrier as the channel group'); } const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); if (!channel) { throw new NotFoundException('Channel not found'); } if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); } if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) { throw new BadRequestException('Province route must use a channel with the same sendRegion'); } const existing = await this.prisma.smsChannelGroupItem.findFirst({ where: { groupId: data.groupId, channelId: data.channelId }, }); if (existing) { throw new BadRequestException('通道组内不能重复配置同一通道'); } if (data.province) { const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({ where: { groupId: data.groupId, province: data.province }, }); if (existingProvince) { throw new BadRequestException('同一通道组内同一省份只能配置一个通道'); } } else { const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({ where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 }, }); if (existingPriority) { throw new BadRequestException('同一通道组内全国通道优先级不能重复'); } } return this.prisma.smsChannelGroupItem.create({ data: { groupId: data.groupId, channelId: data.channelId, carrier: itemCarrier, province: data.province, priority: data.priority ?? 100, weight: data.weight ?? 1, isBackup: data.isBackup ?? false, rateLimitPerSecond: data.rateLimitPerSecond, }, }); } async updateGroup(groupId: string, data: UpdateChannelGroupDto) { const current = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } }); if (!current) { throw new NotFoundException('Channel group not found'); } const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes( data.retryTimeLimitMinutes, data.retryTimeLimitHours, current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60, ); const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier); const items = data.items ?? []; const channelIds = [...new Set(items.map((item) => item.channelId))]; const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } }); const channelById = new Map(channels.map((channel) => [channel.id, channel])); validateGroupItems(carrier, items, channelById); return this.prisma.$transaction(async (tx) => { await tx.smsChannelGroupItem.deleteMany({ where: { groupId } }); await tx.smsChannelGroup.update({ where: { id: groupId }, data: { code: data.code ?? current.code, name: data.name ?? current.name, carrier, description: data.description, status: data.status ?? current.status, retryEnabled: data.retryEnabled ?? current.retryEnabled, retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), retryTimeLimitMinutes, }, }); if (items.length > 0) { await tx.smsChannelGroupItem.createMany({ data: items.map((item) => ({ groupId, channelId: item.channelId, carrier, province: item.province, priority: item.priority ?? 100, weight: item.weight ?? 1, isBackup: item.isBackup ?? false, rateLimitPerSecond: item.rateLimitPerSecond, })), }); } return tx.smsChannelGroup.findUnique({ where: { id: groupId }, include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, }); }); } async deleteGroup(groupId: string) { const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } }); if (!group) { throw new NotFoundException('Channel group not found'); } const boundRoute = await this.prisma.channelRouteRule.findFirst({ where: { groupId, status: 'active', }, select: { id: true }, }); if (boundRoute) { throw new BadRequestException('Channel group is used by application route rules and cannot be deleted'); } await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } }); return this.prisma.smsChannelGroup.delete({ where: { id: groupId } }); } listRouteRules() { return this.prisma.channelRouteRule.findMany({ include: { group: true, channel: true }, orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], }); } async createRouteRule(data: CreateRouteRuleDto) { if (!data.applicationId) { throw new BadRequestException('applicationId is required for channel group routing'); } if (!data.carrier) { throw new BadRequestException('carrier is required for application channel group routing'); } const carrier = normalizeBusinessCarrier(data.carrier); if (data.channelId) { throw new BadRequestException('Route rules can only bind channel groups, not single channels'); } if (data.province) { throw new BadRequestException('Province routing must be configured inside the channel group'); } const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); if (!group) { throw new NotFoundException('Channel group not found'); } if (normalizeBusinessCarrier(group.carrier) !== carrier) { throw new BadRequestException('Route rule carrier must match the channel group carrier'); } return this.prisma.channelRouteRule.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, groupId: data.groupId, channelId: undefined, carrier, province: undefined, priority: data.priority ?? 100, status: data.status ?? 'active', }, }); } listReportFields(channelId?: string) { return this.prisma.channelReportField.findMany({ where: channelId ? { channelId } : undefined, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], }); } async createReportField(data: CreateReportFieldDto) { const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } }); if (!field || field.status !== 'active') { throw new BadRequestException('报备字段库字段不存在或已停用'); } const reportType = normalizeReportType(data.reportType); return this.prisma.channelReportField.create({ data: { channelId: data.channelId, drainageFieldId: field.id, reportType, code: field.code, name: field.name, fieldType: field.fieldType, required: data.required ?? field.required, description: data.description ?? field.description, sortOrder: data.sortOrder ?? 100, status: data.status ?? 'active', }, }); } listReportMaterials(signatureId?: string, channelId?: string) { return this.prisma.signatureReportMaterial.findMany({ where: { signatureId, channelId, }, orderBy: { createdAt: 'desc' }, }); } upsertReportMaterial(data: CreateReportMaterialDto) { return this.prisma.signatureReportMaterial.upsert({ where: { signatureId_channelId_fieldCode: { signatureId: data.signatureId, channelId: data.channelId, fieldCode: data.fieldCode, }, }, update: { fieldValue: data.fieldValue, fileObjectId: data.fileObjectId, }, create: { signatureId: data.signatureId, channelId: data.channelId, fieldCode: data.fieldCode, fieldValue: data.fieldValue, fileObjectId: data.fileObjectId, }, }); } listReportTasks(tenantId?: string, status?: string) { return this.prisma.channelSignatureReportTask.findMany({ where: { tenantId, status }, include: { signature: true, channel: true }, orderBy: { createdAt: 'desc' }, }); } async createReportTask(data: CreateReportTaskDto) { const task = await this.prisma.channelSignatureReportTask.create({ data: { tenantId: data.tenantId, signatureId: data.signatureId, channelId: data.channelId, createdById: data.createdById, status: 'pending', }, }); await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending'); return task; } async createReportExport(taskId: string, data: CreateReportExportDto) { const task = await this.getReportTaskOrThrow(taskId); const exported = await this.prisma.reportExportFile.create({ data: { taskId, fileObjectId: data.fileObjectId, fileName: data.fileName, rowCount: data.rowCount ?? 0, }, }); await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export'); return exported; } async importReportReceipt(taskId: string, data: CreateReceiptImportDto) { const task = await this.getReportTaskOrThrow(taskId); const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined; const rowCount = data.rowCount ?? parsed?.rowCount ?? 0; const successCount = data.successCount ?? parsed?.successCount ?? 0; const failedCount = data.failedCount ?? parsed?.failedCount ?? 0; const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount); const imported = await this.prisma.reportReceiptImport.create({ data: { taskId, fileObjectId: data.fileObjectId, fileName: data.fileName, rowCount, successCount, failedCount, status: 'imported', result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined, }, }); await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); await this.prisma.smsSignature.update({ where: { id: task.signatureId }, data: { reportStatus: statusAfter }, }); return imported; } listReportRecords(taskId?: string, channelId?: string) { return this.prisma.channelSignatureReportRecord.findMany({ where: { taskId, channelId }, orderBy: { createdAt: 'desc' }, }); } private async getReportTaskOrThrow(taskId: string) { const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId } }); if (!task) { throw new NotFoundException('Report task not found'); } return task; } private async updateReportTaskStatus( taskId: string, channelId: string, statusBefore: string, statusAfter: string, action: string, reason?: string, ) { await this.prisma.channelSignatureReportTask.update({ where: { id: taskId }, data: { status: statusAfter, reason }, }); await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason); } private recordReportTask( taskId: string, channelId: string, action: string, statusBefore: string | undefined, statusAfter: string, reason?: string, ) { return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action, statusBefore, statusAfter, reason, }, }); } private async requestChannelConnection( channel: { id: string; code: string; name: string; gatewayHost: string; gatewayPort: number; account: string; passwordCipher: string; srcId: string; cmppVersion: string; rateLimitPerSecond: number; config?: Prisma.JsonValue | null; }, reason: 'channel_created' | 'channel_enabled', operatorId?: string, ) { const desiredConnections = getDesiredConnections(channel.config); const connectionId = defaultChannelConnectionId(channel.id); const existing = await this.prisma.cmppConnectionState.findFirst({ where: { applicationId: null, channelId: channel.id, connectionId, }, }); const data = { applicationId: null, status: 'connecting', desiredConnections, currentConnections: 0, lastError: null, }; const state = existing ? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data }) : await this.prisma.cmppConnectionState.create({ data: { channelId: channel.id, connectionId, ...data, }, }); await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'cmpp_connection.connect_requested', resource: 'cmpp_connection', resourceId: `${channel.id}:${connectionId}`, detail: { reason, status: state.status, desiredConnections: state.desiredConnections, currentConnections: state.currentConnections, } as Prisma.InputJsonValue, }, }); const command = { schemaVersion: 'v1', messageType: 'ConnectChannel', traceId: randomUUID(), channelId: channel.id, connectionId, createdAt: new Date().toISOString(), reason, desiredConnections, channel: { code: channel.code, name: channel.name, gatewayHost: channel.gatewayHost, gatewayPort: channel.gatewayPort, account: channel.account, passwordCipher: channel.passwordCipher, srcId: channel.srcId, cmppVersion: channel.cmppVersion, rateLimitPerSecond: channel.rateLimitPerSecond, }, }; await this.getGatewayConnectionQueue().add('connect-channel', command, { jobId: `${connectionId}:connect` }); await this.notifyGatewayConnect(command); return state; } private getGatewayConnectionQueue() { this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() }); return this.gatewayConnectionQueue; } private getGatewaySubmitQueue() { this.gatewaySubmitQueue ??= new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() }); return this.gatewaySubmitQueue; } private getRedis() { if (!this.redis) { this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: null, }); } return this.redis; } private async publishGatewaySubmitCommand(command: unknown) { return this.getRedis().xadd( process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM, '*', 'messageType', 'SubmitCommand', 'data', JSON.stringify(command), ); } private async notifyGatewayConnect(command: Record) { const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, ''); let response: { ok: boolean; status: number; text: () => Promise }; try { response = await fetch(`${baseUrl}/connections/connect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command), }); } catch (error) { throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`); } if (!response.ok) { const responseText = await response.text(); throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`); } } } 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; } function normalizeTestContent(content?: string) { const normalized = (content ?? '').trim(); if (!normalized) { throw new BadRequestException('请填写测试短信内容'); } if (normalized.length > 1000) { throw new BadRequestException('测试短信内容不能超过 1000 字符'); } return normalized; } function calculateBillingUnits(content: string) { return Math.max(1, Math.ceil([...content].length / 67)); } 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'), }, retry: { attempt: 0, maxAttempts: 1 }, }; } 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; } 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); } 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'; } 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'); } function normalizeGatewayConnectionStatus(status: string) { const normalized = status.toLowerCase(); if (['online', 'open', 'connected'].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; } function defaultChannelConnectionId(channelId: string) { return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`; } 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; } function normalizeChannelRuntimeConfig( existingConfig?: Prisma.JsonValue | Record | null, incomingConfig?: Record | null, desiredConnections?: number, windowSize?: 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.extensionDigits = normalizeExtensionDigits(base.extensionDigits); return base; } 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; } 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; } 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; } 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, }; } function getPositiveIntegerEnv(name: string, fallback: number) { const value = Number(process.env[name]); if (Number.isInteger(value) && value > 0) { return value; } return fallback; } 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, }, }; } 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; } function stripReceiptCell(value: string) { return value.trim().replace(/^"|"$/g, '').trim(); } 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); } 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'; } function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) { if (rowCount <= 0 || successCount <= 0) { return 'failed'; } if (failedCount > 0) { return 'partial'; } return 'completed'; } 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; } 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; } 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; } function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) { const normalized = normalizeChannelCarrier(channelCarrier); return normalized === 'all' || normalized === groupCarrier; } function normalizeRegion(region?: string | null) { return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); } function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) { return normalizeRegion(channelRegion) === normalizeRegion(itemProvince); } 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); } } } function normalizeReportType(value?: string) { if (value === 'signature' || value === 'drainage' || value === 'both') return value; throw new BadRequestException('reportType must be signature, drainage or both'); } 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 '更新'; }