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 { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsTemplateService { constructor(private readonly prisma: PrismaService, private readonly audit: SmsAuditService) {} 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' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), 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' }, ...(query.page && query.pageSize ? { skip: (query.page - 1) * query.pageSize, take: query.pageSize, } : {}), }); } async listTemplatesPage(query: TemplateListQuery) { 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.SmsTemplateWhereInput = { 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.nameKeyword ? { contains: query.nameKeyword } : undefined, content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo), 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, }; const [items, total] = await Promise.all([ this.listTemplates({ ...query, page, pageSize }), this.prisma.smsTemplate.count({ where }), ]); return { items, total, page, pageSize }; } listClientTemplates(tenantId: string | undefined, includeHistory = false) { return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); } async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { const variables = validateAndNormalizeTemplateVariables(data.content, data.variables); const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); if (!application || application.tenantId !== data.tenantId) { throw new BadRequestException('applicationId does not belong to the template tenant'); } await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content); return this.prisma.smsTemplate.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, signatureId: data.signatureId, name: data.name, content: data.content, category: data.category, auditStatus: options.initialAuditStatus, billingUnits: estimateBillingUnits(data.content), variables: { create: variables.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, tenantId?: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!template || (tenantId && template.tenantId !== tenantId)) { 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 !== undefined || data.applicationId !== undefined || data.content !== undefined) { await this.validateTemplateSignature( data.signatureId === undefined ? template.signatureId : data.signatureId, template.tenantId, data.applicationId ?? template.applicationId, data.content ?? template.content, ); } const variables = data.content !== undefined || data.variables !== undefined ? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables) : undefined; const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId) || (data.signatureId !== undefined && data.signatureId !== template.signatureId) || (data.content !== undefined && data.content !== template.content) || (data.category !== undefined && data.category !== template.category) || data.variables !== undefined; const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus; 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, rejectReason: auditStatus === 'pending' ? null : undefined, 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 updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found'); if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { throw new BadRequestException('当前审核状态不允许修改模板'); } const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId); await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_template', targetId: templateId, action: 'client_update_submit', statusBefore: current.auditStatus, statusAfter: 'pending', }); return updated; } async submitTemplate(templateId: string, tenantId?: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) { throw new NotFoundException('Template not found'); } await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content); const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: 'pending', rejectReason: null }, }); await this.audit.createAuditRecord({ tenantId: template.tenantId, targetType: 'sms_template', targetId: templateId, action: 'submit', statusBefore: template.auditStatus, statusAfter: 'pending', }); return updated; } async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) { if (!signatureId) { throw new BadRequestException('短信模板必须选择短信签名'); } const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId }, select: { tenantId: true, applicationId: true, name: true }, }); if (!signature || signature.tenantId !== tenantId) { throw new BadRequestException('signatureId does not belong to the template tenant'); } if (signature.applicationId && signature.applicationId !== applicationId) { throw new BadRequestException('signatureId does not belong to the template application'); } const signaturePrefix = normalizeSmsSignature(signature.name); if (!signaturePrefix || !content.startsWith(signaturePrefix)) { throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`); } } }