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 { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; import { summarizeReportStatuses } from '../common/report-status'; import { normalizeChannelCarriers } from '../channels/channels.helpers'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsSignatureService { constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {} async listSignatures(queryOrTenantId?: string | SignatureListQuery) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; const signatures = await this.prisma.smsSignature.findMany({ where: { tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), drainageItems: query.drainageKeyword ? { some: { auditStatus: { not: 'deleted' }, OR: [ { siteName: { contains: query.drainageKeyword } }, { url: { contains: query.drainageKeyword } }, { remark: { contains: query.drainageKeyword } }, ], }, } : undefined, 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, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, reportTasks: { include: { channel: true, drainageInfo: true } }, }, orderBy: { createdAt: 'desc' }, ...(query.page && query.pageSize ? { skip: (query.page - 1) * query.pageSize, take: query.pageSize, } : {}), }); const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: { in: applicationIds }, status: 'active' }, include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, }) : []; const hasCommonDrainageFields = await this.prisma.commonReportField.count({ where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } }, }).then((count) => count > 0); return signatures.map((signature) => { const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; const drainageLinks = signature.drainageItems.map((item) => ({ id: item.id, siteName: item.siteName, url: item.url, remark: item.remark ?? '', reportValues: isRecord(item.reportValues) ? item.reportValues : {}, auditStatus: item.auditStatus, rejectReason: item.rejectReason, submittedAt: item.submittedAt.toISOString(), reviewedAt: item.reviewedAt?.toISOString(), createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), })); return { ...signature, name: normalizeSmsSignature(signature.name), drainageInfo: { ...legacyPayload, links: drainageLinks }, reportTargets: (() => { const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); const tasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); return [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => ( normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel'); return { channel, channelId: channel.id, carrier, status: task?.status ?? 'pending', taskId: task?.id, approvedAt: task?.approvedAt, approvalScope: task?.approvalScope ?? 'carrier_specific' }; }) )); })(), drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => { const drainageItemId = drainageItem.id; const channels = routes .filter((route) => route.applicationId === signature.applicationId && route.group) .flatMap((route) => route.group!.items.map((item) => item.channel)) .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => { const task = taskByChannel.get(channel.id); return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : []; })]; })), drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => { const drainageItemId = drainageItem.id; const channels = routes .filter((route) => route.applicationId === signature.applicationId && route.group) .flatMap((route) => route.group!.items.map((item) => item.channel)) .filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)))); const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task])); return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []); return [carrier, summarizeReportStatuses(statuses)]; }))]; })), carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status ?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status ?? 'pending'); return [carrier, summarizeReportStatuses(statuses)]; })), }; }); } async listSignaturesPage(query: SignatureListQuery) { 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.SmsSignatureWhereInput = { tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), drainageItems: query.drainageKeyword ? { some: { auditStatus: { not: 'deleted' }, OR: [ { siteName: { contains: query.drainageKeyword } }, { url: { contains: query.drainageKeyword } }, { remark: { contains: query.drainageKeyword } }, ], }, } : undefined, OR: query.keyword ? [ { name: { contains: query.keyword } }, { purpose: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } }, ] : undefined, }; const [items, total] = await Promise.all([ this.listSignatures({ ...query, page, pageSize }), this.prisma.smsSignature.count({ where }), ]); return { items, total, page, pageSize }; } listSignatureOptions(tenantId?: string) { return this.prisma.smsSignature.findMany({ where: { tenantId, auditStatus: { not: 'deleted' } }, select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true }, orderBy: [{ name: 'asc' }, { id: 'asc' }], }); } async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { const signatures = await this.prisma.smsSignature.findMany({ where: { id: signatureId, tenantId, applicationId: query.applicationId, auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, OR: query.keyword?.trim() ? [ { name: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } }, { application: { name: { contains: query.keyword.trim() } } }, ] : undefined, }, select: { id: true, tenantId: true, applicationId: true, name: true, purpose: true, auditStatus: true, reportStatus: true, pendingReport: true, reportChangedAt: true, rejectReason: true, drainageInfo: true, createdAt: true, updatedAt: true, application: { select: { id: true, name: true, status: true } }, materials: { select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true }, }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' }, select: { id: true, siteName: true, url: true, remark: true, reportValues: true, auditStatus: true, rejectReason: true, submittedAt: true, reviewedAt: true, createdAt: true, updatedAt: true, }, }, _count: { select: { reportMaterials: true } }, }, orderBy: { updatedAt: 'desc' }, skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, take: query.pageSize, }); return signatures.map((signature) => { const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; return { id: signature.id, tenantId: signature.tenantId, applicationId: signature.applicationId, name: normalizeSmsSignature(signature.name), purpose: signature.purpose, auditStatus: signature.auditStatus, reportStatus: signature.reportStatus, pendingReport: signature.pendingReport, reportChangedAt: signature.reportChangedAt, rejectReason: signature.rejectReason, createdAt: signature.createdAt, updatedAt: signature.updatedAt, application: signature.application, materials: signature.materials, submittedMaterialCount: signature.materials.length + signature._count.reportMaterials, reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {}, drainageInfo: { links: signature.drainageItems.map((item) => ({ ...item, reportValues: isRecord(item.reportValues) ? item.reportValues : {}, })), }, }; }); } async getClientSignatureView(signatureId: string, tenantId?: string) { const [signature] = await this.listClientSignatures(tenantId, signatureId); if (!signature) throw new NotFoundException('Signature not found'); return signature; } async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { 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 filteredWhere: Prisma.SmsSignatureWhereInput = { tenantId, applicationId: query.applicationId, auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, OR: query.keyword?.trim() ? [ { name: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } }, { application: { name: { contains: query.keyword.trim() } } }, ] : undefined, }; const [items, total, statusCounts] = await Promise.all([ this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), this.prisma.smsSignature.count({ where: filteredWhere }), this.prisma.smsSignature.groupBy({ by: ['auditStatus'], where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, _count: { _all: true }, }), ]); const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 }; for (const item of statusCounts) { const count = item._count._all; summary.total += count; if (item.auditStatus in summary && item.auditStatus !== 'total') { summary[item.auditStatus as keyof Omit] = count; } } return { items, summary, total, page, pageSize }; } async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo); const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); const name = validateCompleteSmsSignature(data.name); const signature = await this.prisma.smsSignature.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, name, purpose: data.purpose, auditStatus: options.initialAuditStatus, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, }, }); await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); if (options.initialAuditStatus) { await this.audit.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', targetId: signature.id, action: 'admin_create_approved', statusAfter: options.initialAuditStatus, reason: '运营端新建签名自动审核通过', }); } return signature; } async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } await this.reportValidation.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo); const applicationId = data.applicationId ?? signature.applicationId ?? undefined; const drainageInfo = data.drainageInfo ? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo) : undefined; const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name); const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) || (name !== undefined && name !== normalizeSmsSignature(signature.name)) || (data.purpose !== undefined && data.purpose !== signature.purpose) || (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null)); const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { applicationId: data.applicationId, name, purpose: data.purpose, auditStatus, rejectReason: auditStatus === 'pending' ? null : undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, materialVersion: { increment: 1 }, pendingReport: true, reportChangedAt: new Date(), }, include: { materials: true, tenant: true, application: true }, }); await this.reportValidation.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo); return updated; } async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found'); if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { throw new BadRequestException('当前审核状态不允许修改签名'); } const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId); await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_signature', targetId: signatureId, action: 'client_update_submit', statusBefore: current.auditStatus, statusAfter: 'pending', }); return updated; } 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, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: 'pending', rejectReason: null }, }); await this.audit.createAuditRecord({ tenantId: signature.tenantId, targetType: 'sms_signature', targetId: signatureId, action: 'submit', statusBefore: signature.auditStatus, statusAfter: 'pending', }); return updated; } }