feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+404
View File
@@ -0,0 +1,404 @@
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';
/** 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,
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 taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
})(),
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) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
const approved = statuses.filter((status) => status === 'approved').length;
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: statuses.length }];
}))];
})),
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' && (channel.carrier === carrier || channel.carrier === 'all'));
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length;
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: targets.length }];
})),
};
});
}
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,
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<typeof summary, 'total'>] = 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;
}
}