import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomInt, randomUUID } from 'node:crypto'; import { isIpAllowed } from '../common/ip-allowlist'; import { assertMoneyUnits } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; import { automaticDeliveryMode } from '../open-api/delivery-mode'; import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts'; import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers'; import { SmsApplicationLifecycleService } from './application-lifecycle.service'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsApplicationConfigService { constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService) {} async listApplications(queryOrTenantId?: string | ApplicationListQuery) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; if (query.includeConnections) { await this.lifecycle.markTimedOutDownstreamConnections(); } const applications = await this.prisma.smsApplication.findMany({ where: { tenantId: query.tenantId, status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined, OR: query.keyword ? [ { name: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, ] : undefined, }, include: { tenant: true, ipAllowlist: true, httpConfig: true, }, omit: { secretHash: true, }, orderBy: { createdAt: 'desc' }, ...(query.page && query.pageSize ? { skip: (query.page - 1) * query.pageSize, take: query.pageSize, } : {}), }); if (!query.includeConnections) { return applications; } const applicationIds = applications.map((application) => application.id); const [connections, messageStats] = await Promise.all([ this.prisma.cmppDownstreamConnection.findMany({ where: { applicationId: { in: applicationIds }, status: 'connected' }, orderBy: { updatedAt: 'desc' }, }), this.prisma.smsMessageRecord.groupBy({ by: ['applicationId', 'status'], where: { applicationId: { in: applicationIds }, queuedAt: { gte: startOfToday() } }, _count: { _all: true }, }), ]); const disablingDetails = new Map((await Promise.all(applications .filter((application) => application.status === 'disabling') .map(async (application) => [application.id, await this.lifecycle.getApplicationDeactivationPreview(application.id)] as const)))); return applications.map((application) => { const appConnections = connections.filter((connection) => connection.applicationId === application.id); const appStats = messageStats.filter((item) => item.applicationId === application.id); const todayTotal = appStats.reduce((sum, item) => sum + item._count._all, 0); const delivered = appStats.find((item) => item.status === 'delivered')?._count._all ?? 0; return { ...application, cmppConnections: appConnections, cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status), sentToday: todayTotal, deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0, deactivation: disablingDetails.get(application.id) ?? null, }; }).sort((left, right) => right.sentToday - left.sentToday || left.name.localeCompare(right.name, 'zh-CN') || left.id.localeCompare(right.id)); } async listApplicationsPage(query: ApplicationListQuery) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsApplicationWhereInput = { tenantId: query.tenantId, status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined, OR: query.keyword ? [ { name: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, ] : undefined, }; const [items, total] = await Promise.all([ this.listApplications({ ...query, page, pageSize }), this.prisma.smsApplication.count({ where }), ]); return { items, total, page, pageSize }; } listApplicationOptions(tenantId?: string) { return this.prisma.smsApplication.findMany({ where: { tenantId, status: { not: 'deleted' } }, select: { id: true, tenantId: true, name: true, status: true }, orderBy: [{ name: 'asc' }, { id: 'asc' }], }); } async getApplication(applicationId: string, tenantId?: string) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, include: { tenant: true, ipAllowlist: true, httpConfig: true, }, }); if (!application || (tenantId && application.tenantId !== tenantId)) { throw new NotFoundException('Application not found'); } return application; } async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') { if (applicationId) await this.getApplication(applicationId); const [commonFields, routes] = await Promise.all([ this.prisma.commonReportField.findMany({ where: { status: 'active', reportType, drainageField: { status: 'active' }, }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }), applicationId ? this.prisma.channelRouteRule.findMany({ where: { applicationId, status: 'active' }, include: { group: { include: { items: { include: { channel: { include: { reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } }, }, }, }, }, }, }, }, orderBy: { priority: 'asc' }, }) : Promise.resolve([]), ]); type MergedReportField = { id: string; code: string; name: string; fieldType: string; required: boolean; description?: string | null; reportTypes: string[]; commonReportTypes: string[]; channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>; }; const merged = new Map(); const routeChannels = new Map(); for (const route of routes) { if (!route.group) continue; for (const item of route.group.items) { if (!routeChannels.has(item.channel.id)) { routeChannels.set(item.channel.id, { id: item.channel.id, code: item.channel.code, name: item.channel.name, groupId: route.group.id, groupName: route.group.name, }); } } } for (const configured of commonFields) { merged.set(configured.drainageField.id, { id: configured.drainageField.id, code: configured.drainageField.code, name: configured.drainageField.name, fieldType: configured.drainageField.fieldType, required: configured.required, description: configured.drainageField.description, reportTypes: [configured.reportType], commonReportTypes: [configured.reportType], channels: Array.from(routeChannels.values()).map((channel) => ({ ...channel, required: configured.required, reportType: configured.reportType, source: 'common' as const, })), }); } for (const route of routes) { if (!route.group) continue; for (const item of route.group.items) { for (const configured of item.channel.reportFields) { if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue; if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue; const key = configured.drainageField.id; const current: MergedReportField = merged.get(key) ?? { id: configured.drainageField.id, code: configured.drainageField.code, name: configured.drainageField.name, fieldType: configured.drainageField.fieldType, required: false, description: configured.drainageField.description, reportTypes: [], commonReportTypes: [], channels: [], }; current.required = current.required || configured.required; if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType); const existingChannel = current.channels.find((channel) => channel.id === item.channel.id); if (existingChannel) { existingChannel.required = existingChannel.required || configured.required; existingChannel.reportType = configured.reportType; existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source; } else { current.channels.push({ id: item.channel.id, code: item.channel.code, name: item.channel.name, groupId: route.group.id, groupName: route.group.name, required: configured.required, reportType: configured.reportType, source: 'channel', }); } merged.set(key, current); } } } return Array.from(merged.values()); } async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') { const fields = await this.getApplicationReportFields(applicationId, reportType); return fields.map(({ channels: _channels, commonReportTypes: _commonReportTypes, ...field }) => field); } async createApplication(data: CreateSmsApplicationDto) { assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价'); const secret = normalizeApplicationPassword(data.passwordCipher); const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); const interfaceType = normalizeApplicationInterfaceType(data.interfaceType); const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount(); const cmppEnterpriseCode = cmppAccount; const accessNumber = normalizeCmppAccessNumberConfig(data); await this.validateClientSrcIdAvailable(accessNumber.clientSrcId); return this.prisma.smsApplication.create({ data: { tenantId: data.tenantId, name: data.name, scene: data.scene, callbackUrl: data.callbackUrl, cmppAccount, cmppEnterpriseCode, cmppApplicationExtension: accessNumber.applicationExtension, cmppAccessNumberFillEnabled: accessNumber.fillEnabled, cmppAccessNumberFillPrefix: accessNumber.fillPrefix, cmppClientSrcId: accessNumber.clientSrcId, secretHash: secret, interfaceEnabled: data.interfaceEnabled ?? true, interfaceType, cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), customerUnitPrice: data.customerUnitPrice ?? 0, queuePriority, templateMismatchMode: data.templateMismatchMode ?? 'reject', downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true, downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true, 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 }, include: { httpConfig: true }, }); if (!application) { throw new NotFoundException('Application not found'); } if (data.customerUnitPrice !== undefined) { assertMoneyUnits(data.customerUnitPrice, '客户单价'); } const queuePriority = data.queuePriority === undefined ? undefined : normalizeApplicationQueuePriority(data.queuePriority); const cmppAccount = data.cmppAccount === undefined ? undefined : await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId); const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount; const interfaceType = data.interfaceType === undefined ? undefined : normalizeApplicationInterfaceType(data.interfaceType); const secretHash = data.passwordCipher === undefined ? undefined : normalizeApplicationPassword(data.passwordCipher); const accessNumberChanged = data.cmppApplicationExtension !== undefined || data.cmppAccessNumberFillEnabled !== undefined || data.cmppAccessNumberFillPrefix !== undefined; const accessNumber = accessNumberChanged ? normalizeCmppAccessNumberConfig(data, application) : undefined; if (accessNumber?.clientSrcId && accessNumber.clientSrcId !== application.cmppClientSrcId) { await this.validateClientSrcIdAvailable(accessNumber.clientSrcId, applicationId); } return this.prisma.$transaction(async (tx) => { if (data.ipAllowlist) { await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } }); } const updated = await tx.smsApplication.update({ where: { id: applicationId }, data: { name: data.name, scene: data.scene, callbackUrl: data.callbackUrl, cmppAccount, cmppEnterpriseCode, cmppApplicationExtension: accessNumber?.applicationExtension, cmppAccessNumberFillEnabled: accessNumber?.fillEnabled, cmppAccessNumberFillPrefix: accessNumber?.fillPrefix, cmppClientSrcId: accessNumber?.clientSrcId, secretHash, interfaceEnabled: data.interfaceEnabled, interfaceType, cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), customerUnitPrice: data.customerUnitPrice, queuePriority, templateMismatchMode: data.templateMismatchMode, downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled, downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled, status: data.status, ipAllowlist: data.ipAllowlist ? { create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })), } : undefined, }, include: { tenant: true, ipAllowlist: true }, }); if (data.interfaceEnabled !== undefined && application.httpConfig) { const deliveryMode = automaticDeliveryMode(data.interfaceEnabled, application.httpConfig.enabled); await tx.smsApplicationHttpConfig.update({ where: { applicationId }, data: { receiptDeliveryMode: deliveryMode, uplinkDeliveryMode: deliveryMode, }, }); } return updated; }); } 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 = generateApplicationPassword(); const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { secretHash: secret }, }); await this.lifecycle.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, { reason: data.reason, }); return { ...updated, secret }; } 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'); } if (tenantId && !application.interfaceEnabled) { throw new ForbiddenException('该企业应用未开通 CMPP 接口'); } return { applicationId: application.id, applicationName: application.name, tenantId: application.tenantId, tenantName: application.tenant.name, appCode: application.id, gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1', gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890), enterpriseCode: application.cmppEnterpriseCode, account: application.cmppAccount, passwordCipher: application.secretHash, srcId: application.cmppClientSrcId ?? '', applicationExtension: application.cmppApplicationExtension, accessNumberFillEnabled: application.cmppAccessNumberFillEnabled, accessNumberFillPrefix: application.cmppAccessNumberFillPrefix, interfaceEnabled: application.interfaceEnabled, interfaceType: application.interfaceType, maxConnections: application.cmppMaxConnections, heartbeatSeconds: 30, windowSize: application.cmppWindowSize, protocolVersion: application.interfaceType === 'cmpp20' ? 'CMPP2.0' : application.interfaceType, }; } async validateAndReserveCmppAccount(cmppAccount: string, currentApplicationId?: string) { if (!/^\d{6}$/.test(cmppAccount)) { throw new BadRequestException('cmppAccount must be a 6-digit number'); } const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } }); if (exists && exists.id !== currentApplicationId) { throw new BadRequestException('cmppAccount already exists'); } return cmppAccount; } async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string) { if (!clientSrcId) return; const exists = await this.prisma.smsApplication.findUnique({ where: { cmppClientSrcId: clientSrcId } }); if (exists && exists.id !== currentApplicationId) { throw new BadRequestException('client CMPP Src_Id already exists'); } } async generateCmppAccount() { for (let attempt = 0; attempt < 20; attempt += 1) { const cmppAccount = String(randomInt(100000, 1000000)); const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } }); if (!exists) { return cmppAccount; } } throw new BadRequestException('Unable to generate unique CMPP account'); } }