219 lines
12 KiB
TypeScript
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 || 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} 开头`);
|
|
}
|
|
}
|
|
}
|