import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomBytes, createHash } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; export interface CreateSmsApplicationDto { tenantId: string; name: string; scene?: string; callbackUrl?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[]; } export type UpdateSmsApplicationDto = Partial> & { status?: string; }; export interface ReplaceApplicationRouteRulesDto { routes: Array<{ carrier: string; groupId: string; priority?: number; status?: string; }>; } export interface CreateSmsSignatureDto { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record; } export type UpdateSmsSignatureDto = Partial> & { auditStatus?: string; }; export interface CreateSignatureMaterialDto { signatureId: string; fileObjectId?: string; materialType: string; title: string; description?: string; } export interface CreateSmsTemplateDto { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }>; } export type UpdateSmsTemplateDto = Partial> & { auditStatus?: string; }; export interface ReviewDto { reviewerId?: string; reason?: string; } export interface StatusChangeDto { status?: string; operatorId?: string; reason?: string; } export interface TemplateListQuery { tenantId?: string; status?: string; keyword?: string; } export interface ApplicationListQuery { tenantId?: string; keyword?: string; includeConnections?: boolean; } @Injectable() export class SmsConfigService { constructor(private readonly prisma: PrismaService) {} async listApplications(queryOrTenantId?: string | ApplicationListQuery) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; return this.prisma.smsApplication.findMany({ where: { tenantId: query.tenantId, OR: query.keyword ? [ { name: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, ] : undefined, }, include: { tenant: true, ipAllowlist: true, messageRecords: { where: { queuedAt: { gte: startOfToday() } }, take: 1000 }, }, orderBy: { createdAt: 'desc' }, take: 100, }).then(async (applications) => { if (!query.includeConnections) { return applications; } const applicationIds = applications.map((application) => application.id); const connections = await this.prisma.cmppConnectionState.findMany({ where: { applicationId: { in: applicationIds } }, include: { channel: true }, orderBy: { updatedAt: 'desc' }, take: 500, }); return applications.map((application) => { const appConnections = connections.filter((connection) => connection.applicationId === application.id); const todayTotal = application.messageRecords.length; const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length; return { ...application, cmppConnections: appConnections, cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status), sentToday: todayTotal, deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0, }; }); }); } async getApplication(applicationId: string) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, include: { tenant: true, ipAllowlist: true, }, }); if (!application) { throw new NotFoundException('Application not found'); } return application; } createApplication(data: CreateSmsApplicationDto) { const secret = randomBytes(24).toString('hex'); return this.prisma.smsApplication.create({ data: { tenantId: data.tenantId, name: data.name, scene: data.scene, callbackUrl: data.callbackUrl, secretHash: hashSecret(secret), dailyLimit: data.dailyLimit, customerUnitPrice: data.customerUnitPrice ?? 0, maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000, templateMismatchMode: data.templateMismatchMode ?? 'reject', ipAllowlist: { create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })), }, }, include: { ipAllowlist: true }, }); } async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application) { throw new NotFoundException('Application not found'); } return this.prisma.$transaction(async (tx) => { if (data.ipAllowlist) { await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } }); } return tx.smsApplication.update({ where: { id: applicationId }, data: { name: data.name, scene: data.scene, callbackUrl: data.callbackUrl, dailyLimit: data.dailyLimit, customerUnitPrice: data.customerUnitPrice, maxPhonesPerTask: data.maxPhonesPerTask, templateMismatchMode: data.templateMismatchMode, status: data.status, ipAllowlist: data.ipAllowlist ? { create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })), } : undefined, }, include: { tenant: true, ipAllowlist: true }, }); }); } async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application) { throw new NotFoundException('Application not found'); } const routes = data.routes ?? []; if (routes.length === 0) { throw new BadRequestException('At least one carrier channel group is required'); } const carriers = new Set(); routes.forEach((route) => { if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) { throw new BadRequestException('carrier must be mobile, unicom or telecom'); } if (carriers.has(route.carrier)) { throw new BadRequestException('Duplicate carrier route is not allowed'); } carriers.add(route.carrier); }); const groups = await this.prisma.smsChannelGroup.findMany({ where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } }, select: { id: true, carrier: true }, }); const groupMap = new Map(groups.map((group) => [group.id, group])); routes.forEach((route) => { const group = groupMap.get(route.groupId); if (!group) { throw new BadRequestException(`channel group ${route.groupId} does not exist`); } if (group.carrier !== route.carrier) { throw new BadRequestException('channel group carrier must match route carrier'); } }); return this.prisma.$transaction(async (tx) => { await tx.channelRouteRule.deleteMany({ where: { applicationId, channelId: null, province: null, }, }); await tx.channelRouteRule.createMany({ data: routes.map((route, index) => ({ tenantId: application.tenantId, applicationId, groupId: route.groupId, carrier: route.carrier, priority: route.priority ?? (index + 1) * 10, status: route.status ?? 'active', })), }); return tx.channelRouteRule.findMany({ where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } }, orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], }); }); } async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application) { throw new NotFoundException('Application not found'); } const secret = randomBytes(24).toString('hex'); const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { secretHash: hashSecret(secret) }, }); await this.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, { reason: data.reason, }); return { ...updated, secret }; } async changeApplicationStatus(applicationId: string, data: StatusChangeDto) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application) { throw new NotFoundException('Application not found'); } const status = data.status ?? 'disabled'; const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { status } }); await this.writeOperationLog(application.tenantId, data.operatorId, `sms_application.${status}`, 'sms_application', applicationId, { statusBefore: application.status, statusAfter: status, reason: data.reason, }); return updated; } async listApplicationConnections(applicationId: string) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, include: { tenant: true }, }); if (!application) { throw new NotFoundException('Application not found'); } const connections = await this.prisma.cmppConnectionState.findMany({ where: { applicationId }, include: { channel: true }, orderBy: { updatedAt: 'desc' }, take: 100, }); return { application, connections, summary: { desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0), currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0), status: normalizeApplicationCmppStatus(connections, application.status), }, }; } async getApplicationCmppParams(applicationId: string, tenantId?: string) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, include: { tenant: true }, }); if (!application || (tenantId && application.tenantId !== tenantId)) { throw new NotFoundException('Application not found'); } const channel = await this.prisma.smsChannel.findFirst({ where: { status: { not: 'deleted' } }, orderBy: { createdAt: 'desc' }, }); return { applicationId: application.id, applicationName: application.name, tenantId: application.tenantId, tenantName: application.tenant.name, appCode: application.id, gatewayHost: channel?.gatewayHost ?? '', gatewayPort: channel?.gatewayPort ?? 0, enterpriseCode: channel?.enterpriseCode ?? application.tenant.code, account: channel?.account ?? application.tenant.code, passwordCipher: channel?.passwordCipher ?? application.secretHash, srcId: channel?.srcId ?? '', maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1, heartbeatSeconds: 30, windowSize: 16, protocolVersion: channel?.cmppVersion ?? '3.0', }; } async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application) { throw new NotFoundException('Application not found'); } const connection = await this.prisma.cmppConnectionState.findFirst({ where: { applicationId, connectionId }, }); if (!connection) { throw new NotFoundException('Connection not found'); } const updated = await this.prisma.cmppConnectionState.update({ where: { id: connection.id }, data: { status: 'disconnected', currentConnections: 0, lastDisconnectedAt: new Date(), lastError: data.reason, }, }); await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, { applicationId, reason: data.reason, }); return updated; } listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; return this.prisma.smsSignature.findMany({ where: { tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, OR: query.keyword ? [ { name: { contains: query.keyword } }, { purpose: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } }, ] : undefined, }, include: { materials: true, tenant: true, application: true }, orderBy: { createdAt: 'desc' }, take: 100, }); } createSignature(data: CreateSmsSignatureDto) { return this.prisma.smsSignature.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, name: data.name, purpose: data.purpose, drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined, }, }); } async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature) { throw new NotFoundException('Signature not found'); } return this.prisma.smsSignature.update({ where: { id: signatureId }, data: { applicationId: data.applicationId, name: data.name, purpose: data.purpose, auditStatus: data.auditStatus, drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined, }, include: { materials: true, tenant: true, application: true }, }); } createSignatureMaterial(data: CreateSignatureMaterialDto) { return this.prisma.signatureMaterial.create({ data: { signatureId: data.signatureId, fileObjectId: data.fileObjectId, materialType: data.materialType, title: data.title, description: data.description, }, }); } async submitSignature(signatureId: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature) { throw new NotFoundException('Signature not found'); } const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: 'pending', rejectReason: null }, }); await this.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', targetId: signatureId, action: 'submit', statusBefore: signature.auditStatus, statusAfter: 'pending', }); return updated; } listTemplates(queryOrTenantId?: string | TemplateListQuery) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; return this.prisma.smsTemplate.findMany({ where: { tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, OR: query.keyword ? [ { name: { contains: query.keyword } }, { content: { contains: query.keyword } }, { category: { contains: query.keyword } }, { application: { name: { contains: query.keyword } } }, { tenant: { name: { contains: query.keyword } } }, ] : undefined, }, include: { variables: true, application: true, tenant: true, signature: true }, orderBy: { createdAt: 'desc' }, take: 100, }); } createTemplate(data: CreateSmsTemplateDto) { return this.prisma.smsTemplate.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, signatureId: data.signatureId, name: data.name, content: data.content, category: data.category, billingUnits: estimateBillingUnits(data.content), variables: { create: (data.variables ?? inferTemplateVariables(data.content)).map((variable: TemplateVariableInput) => ({ name: variable.name, example: variable.example, required: variable.required ?? true, })), }, }, include: { variables: true, application: true, tenant: true, signature: true }, }); } async updateTemplate(templateId: string, data: UpdateSmsTemplateDto) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!template) { throw new NotFoundException('Template not found'); } if (data.applicationId) { const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); if (!application || application.tenantId !== template.tenantId) { throw new BadRequestException('applicationId does not belong to the template tenant'); } } if (data.signatureId) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: data.signatureId }, select: { tenantId: true } }); if (!signature || signature.tenantId !== template.tenantId) { throw new BadRequestException('signatureId does not belong to the template tenant'); } } const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined); return this.prisma.$transaction(async (tx) => { if (variables) { await tx.templateVariable.deleteMany({ where: { templateId } }); } return tx.smsTemplate.update({ where: { id: templateId }, data: { applicationId: data.applicationId, signatureId: data.signatureId, name: data.name, content: data.content, category: data.category, auditStatus: data.auditStatus, billingUnits: data.content ? estimateBillingUnits(data.content) : undefined, variables: variables ? { create: variables.map((variable) => ({ name: variable.name, example: variable.example, required: variable.required ?? true, })), } : undefined, }, include: { variables: true, application: true, tenant: true, signature: true }, }); }); } async submitTemplate(templateId: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!template) { throw new NotFoundException('Template not found'); } const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: 'pending', rejectReason: null }, }); await this.createAuditRecord({ tenantId: template.tenantId, targetType: 'sms_template', targetId: templateId, action: 'submit', statusBefore: template.auditStatus, statusAfter: 'pending', }); return updated; } listAuditRecords(targetType?: string, targetId?: string) { return this.prisma.auditRecord.findMany({ where: { targetType, targetId, }, orderBy: { createdAt: 'desc' }, take: 100, }); } approveSignature(signatureId: string, data: ReviewDto) { return this.reviewSignature(signatureId, 'approved', 'approve', data); } rejectSignature(signatureId: string, data: ReviewDto) { return this.reviewSignature(signatureId, 'rejected', 'reject', data); } approveTemplate(templateId: string, data: ReviewDto) { return this.reviewTemplate(templateId, 'approved', 'approve', data); } rejectTemplate(templateId: string, data: ReviewDto) { return this.reviewTemplate(templateId, 'rejected', 'reject', data); } async changeSignatureStatus(signatureId: string, data: StatusChangeDto) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature) { throw new NotFoundException('Signature not found'); } const status = data.status ?? 'deleted'; const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } }); await this.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, { statusBefore: signature.auditStatus, statusAfter: status, reason: data.reason, }); return updated; } async changeTemplateStatus(templateId: string, data: StatusChangeDto) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!template) { throw new NotFoundException('Template not found'); } const status = data.status ?? 'deleted'; const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } }); await this.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, { statusBefore: template.auditStatus, statusAfter: status, reason: data.reason, }); return updated; } private async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature) { throw new NotFoundException('Signature not found'); } const reviewerId = await this.resolveReviewerId(data.reviewerId); const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: statusAfter, rejectReason: statusAfter === 'rejected' ? data.reason : null, }, }); await this.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', targetId: signatureId, action, statusBefore: signature.auditStatus, statusAfter, reason: data.reason, reviewerId, }); return updated; } private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!template) { throw new NotFoundException('Template not found'); } const reviewerId = await this.resolveReviewerId(data.reviewerId); const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: statusAfter, rejectReason: statusAfter === 'rejected' ? data.reason : null, }, }); await this.createAuditRecord({ tenantId: template.tenantId, targetType: 'sms_template', targetId: templateId, action, statusBefore: template.auditStatus, statusAfter, reason: data.reason, reviewerId, }); return updated; } private async resolveReviewerId(reviewerId?: string) { if (!reviewerId) { return undefined; } const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } }); if (!reviewer) { throw new BadRequestException('reviewerId does not reference an existing user'); } return reviewerId; } private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) { return this.prisma.auditRecord.create({ data }); } private writeOperationLog( tenantId: string, userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record, ) { return this.prisma.operationLog.create({ data: { tenantId, userId, action, resource, resourceId, detail: detail as Prisma.InputJsonValue, }, }); } } interface TemplateVariableInput { name: string; example?: string; required?: boolean; } function hashSecret(secret: string) { return createHash('sha256').update(secret).digest('hex'); } function estimateBillingUnits(content: string) { const length = [...content].length; if (length <= 70) { return 1; } return Math.ceil(length / 67); } function inferTemplateVariables(content: string): TemplateVariableInput[] { const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? []; return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); } function startOfToday() { const date = new Date(); date.setHours(0, 0, 0, 0); return date; } function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) { if (applicationStatus !== 'active') { return 'inactive'; } if (connections.some((connection) => connection.status === 'connected' && connection.currentConnections > 0)) { return 'connected'; } if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) { return 'degraded'; } return 'disconnected'; }