Files
lislgosms/api/src/sms-config/template.service.ts
T

219 lines
12 KiB
TypeScript

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 || (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} 开头`);
}
}
}