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 { SmsApplicationConfigService } from './application-config.service'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsReportValidationService { constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {} async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record) { if (!drainageInfo) return drainageInfo; const fields = await this.applications.getApplicationReportFields(applicationId); return { ...drainageInfo, reportRequirementSnapshot: { capturedAt: new Date().toISOString(), applicationId, fields: fields.map((field) => ({ id: field.id, code: field.code, name: field.name, fieldType: field.fieldType, required: field.required, reportTypes: field.reportTypes, commonReportTypes: field.commonReportTypes, channels: field.channels, })), }, }; } async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record) { if (!drainageInfo) return; const fields = await this.applications.getApplicationReportFields(applicationId); const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {}; for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { const value = reportValueParts(signatureValues[field.code]); for (const channel of field.channels) { await this.prisma.signatureReportMaterial.upsert({ where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } }, update: value, create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value }, }); } } } async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record) { const fields = await this.applications.getApplicationReportFields(applicationId, 'signature'); const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {}; const missingSignature = fields .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')) .filter((field) => !hasReportValue(signatureValues[field.code])); if (missingSignature.length > 0) { throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`); } } async validateDrainageReportValues(applicationId?: string, reportValues: Record = {}) { const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage'); const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code])); if (missing.length > 0) { throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`); } } async activateDrainageReporting(itemId: string) { const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } }); if (!item) throw new NotFoundException('Drainage info not found'); if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined; if (!applicationId) return; const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage')) .filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both')); const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])); const values = isRecord(item.reportValues) ? item.reportValues : {}; await this.prisma.$transaction(async (tx) => { await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); for (const field of fields) { const value = reportValueParts(values[field.code]); for (const channel of field.channels) { await tx.drainageReportMaterial.create({ data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value }, }); } } const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task])); for (const channel of channels.values()) { const existing = existingByChannel.get(channel.id); const task = existing ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } }) : await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } }); await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' }, }); } for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) { await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } }); await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } }); } }); } async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') { await this.prisma.$transaction(async (tx) => { const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } }); if (!item) throw new NotFoundException('Drainage info not found'); await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } }); const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } }); for (const task of tasks.filter((current) => current.status !== statusAfter)) { await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } }); await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } }); } }); } }