414 lines
22 KiB
TypeScript
414 lines
22 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 { 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<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;
|
|
}
|
|
}
|