import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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; config?: Record; } export interface CreateChannelGroupDto { code: string; name: string; carrier: string; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; } export interface CreateChannelGroupItemDto { groupId: string; channelId: string; carrier?: string; province?: string; priority?: number; weight?: number; isBackup?: boolean; rateLimitPerSecond?: number; } export interface CreateRouteRuleDto { tenantId?: string; applicationId?: string; groupId: string; channelId?: string; carrier?: string; province?: string; priority?: number; status?: string; } export interface CreateReportFieldDto { channelId: string; 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; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record; } export interface UpsertConnectionStateDto { tenantId?: 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; } @Injectable() export class ChannelsService { constructor(private readonly prisma: PrismaService) {} listChannels() { return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 }); } 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'); } return 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: data.cmppVersion ?? '3.0', rateLimitPerSecond: data.rateLimitPerSecond ?? 100, unitPrice: data.unitPrice ?? 0, status: data.status ?? 'active', config: data.config as Prisma.InputJsonValue | undefined, }, }); } 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, }, }); 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: source.status, config: source.config as Prisma.InputJsonValue | undefined, reportFields: { create: source.reportFields.map((field) => ({ 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, 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' }); } testChannel(channelId: string) { return { channelId, status: 'queued', message: 'Channel test request accepted as a phase-4 placeholder.', }; } 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' }, take: 100, }); } async listChannelLinkLogs(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' }, take: 100, }); } async upsertConnectionState(data: UpsertConnectionStateDto) { const payload = { tenantId: data.tenantId, status: data.status, desiredConnections: data.desiredConnections ?? 1, currentConnections: data.currentConnections ?? (data.status === 'online' || data.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: data.lastError, }; const state = await this.prisma.cmppConnectionState.upsert({ where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } }, update: payload, create: { channelId: data.channelId, connectionId: data.connectionId, ...payload, }, }); await this.prisma.operationLog.create({ data: { tenantId: data.tenantId, action: `cmpp_connection.${normalizeConnectionAction(data.status)}`, resource: 'cmpp_connection', resourceId: `${data.channelId}:${data.connectionId}`, detail: { status: data.status, desiredConnections: state.desiredConnections, currentConnections: state.currentConnections, lastError: state.lastError, } as Prisma.InputJsonValue, }, }); return state; } listGroups() { return this.prisma.smsChannelGroup.findMany({ include: { items: { include: { channel: true } } }, orderBy: { createdAt: 'desc' }, take: 100, }); } createGroup(data: CreateChannelGroupDto) { const retryTimeLimitHours = data.retryTimeLimitHours ?? 72; if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) { throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72'); } 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, }, }); } 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, }, }); } listRouteRules() { return this.prisma.channelRouteRule.findMany({ include: { group: true, channel: true }, orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], take: 100, }); } 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, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], take: 200, }); } createReportField(data: CreateReportFieldDto) { return this.prisma.channelReportField.create({ data: { channelId: data.channelId, code: data.code, name: data.name, fieldType: data.fieldType, required: data.required ?? false, description: data.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' }, take: 200, }); } 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' }, take: 100, }); } 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 statusAfter = data.statusAfter ?? (data.failedCount && data.failedCount > 0 ? 'rejected' : 'approved'); const imported = await this.prisma.reportReceiptImport.create({ data: { taskId, fileObjectId: data.fileObjectId, fileName: data.fileName, rowCount: data.rowCount ?? 0, successCount: data.successCount ?? 0, failedCount: data.failedCount ?? 0, status: 'imported', result: data.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' }, take: 200, }); } 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, }, }); } } function normalizeConnectionAction(status: string) { const normalized = status.toLowerCase(); if (['online', 'connected', 'open'].includes(normalized)) { 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'; } return 'updated'; } 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 normalizeLinkEvent(action: string) { if (action.includes('connected')) { return '新建'; } if (action.includes('heartbeat')) { return '心跳'; } if (action.includes('reconnecting')) { return '重连'; } if (action.includes('disconnected')) { return '断开'; } if (action.includes('copy')) { return '复制'; } if (action.includes('deleted')) { return '删除'; } return '更新'; }