import { 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; 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; description?: string; status?: string; } 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) { return this.prisma.smsChannel.create({ data: { code: data.code, name: data.name, carrier: data.carrier, protocol: data.protocol ?? 'CMPP', gatewayHost: data.gatewayHost, gatewayPort: data.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, 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) { return this.prisma.smsChannelGroup.create({ data: { code: data.code, name: data.name, description: data.description, status: data.status ?? 'active', }, }); } addGroupItem(data: CreateChannelGroupItemDto) { return this.prisma.smsChannelGroupItem.create({ data: { groupId: data.groupId, channelId: data.channelId, carrier: data.carrier, 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, }); } createRouteRule(data: CreateRouteRuleDto) { return this.prisma.channelRouteRule.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, groupId: data.groupId, channelId: data.channelId, carrier: data.carrier, province: data.province, 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 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 '更新'; }