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

919 lines
37 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, summaryOnly = false) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
const signatures = await this.prisma.smsSignature.findMany({
where: {
id: query.signatureId,
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,
},
select: {
id: true,
tenantId: true,
applicationId: true,
name: true,
purpose: true,
drainageInfo: true,
auditStatus: true,
reportStatus: true,
rejectReason: true,
materialVersion: true,
pendingReport: true,
reportChangedAt: true,
createdAt: true,
updatedAt: true,
materials: !summaryOnly,
tenant: { select: { id: true, name: true, code: true, status: true } },
application: { select: { id: true, tenantId: true, name: true, status: true } },
drainageItems: {
where: { auditStatus: { not: 'deleted' } },
orderBy: { updatedAt: 'desc' },
select: {
id: true,
siteName: true,
url: true,
remark: true,
reportValues: !summaryOnly,
auditStatus: true,
rejectReason: true,
submittedAt: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
},
},
reportTasks: {
select: {
id: true,
signatureId: true,
channelId: true,
carrier: true,
status: true,
approvedAt: true,
approvalScope: true,
reportType: true,
drainageItemId: true,
},
},
reportBatchItems: {
where: { batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { reportType: true, materialVersion: true, snapshot: 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' },
select: {
applicationId: true,
group: {
select: {
status: true,
items: {
select: {
channel: {
select: {
id: true,
name: true,
carrier: true,
carriers: true,
status: true,
reportFields: { select: { status: true, reportType: 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 { reportBatchItems: _reportBatchItems, ...signatureView } = signature;
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const applicationChannels = [
...new Map(
routes
.filter((route) => route.applicationId === signature.applicationId && route.group?.status === 'active')
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status === 'active')
.map((channel) => [channel.id, channel]),
).values(),
];
const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature');
const generatedTargets = new Set<string>();
for (const item of (signature.reportBatchItems ?? []).filter(
(entry) => entry.reportType === 'signature' && entry.materialVersion === signature.materialVersion,
)) {
const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined;
if (!Array.isArray(businessKeys)) continue;
for (const value of businessKeys) {
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
if (!match) continue;
for (const carrier of match[2]
.split(',')
.map((entry) => entry.trim())
.filter(Boolean))
generatedTargets.add(`${match[1]}:${carrier}`);
}
}
const pendingReportTargets = applicationChannels
.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => ({ channel, carrier })),
)
.filter(({ channel, carrier }) => {
const task =
signatureTasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ??
signatureTasks.find(
(candidate) =>
candidate.channelId === channel.id &&
candidate.carrier === null &&
candidate.approvalScope === 'legacy_channel',
);
if (task?.status === 'abandoned') return false;
return !generatedTargets.has(`${channel.id}:${carrier}`) && !generatedTargets.has(`${channel.id}:legacy`);
});
const pendingReportBlockedReason =
signature.auditStatus !== 'approved'
? '审核通过后计算'
: !signature.applicationId
? '未绑定短信应用'
: signature.application?.status !== 'active'
? '短信应用未启用'
: applicationChannels.length === 0
? '暂无有效报备通道'
: !signature.pendingReport || pendingReportTargets.length === 0
? '当前资料版本无需生成批次'
: null;
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(),
}));
const view = {
...signatureView,
name: normalizeSmsSignature(signature.name),
drainageInfo: { ...legacyPayload, links: drainageLinks },
reportTargets: (() => {
const tasks = signatureTasks;
return applicationChannels.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',
};
}),
);
})(),
pendingReportDetailCount:
signature.auditStatus === 'approved' && signature.pendingReport ? pendingReportTargets.length : 0,
pendingReportMaterialVersion:
signature.auditStatus === 'approved' && signature.pendingReport ? signature.materialVersion : null,
pendingReportBlockedReason,
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)];
}),
),
};
if (!summaryOnly) return view;
const {
materials: _materials,
reportTasks: _reportTasks,
reportTargets: _reportTargets,
drainageReportTargets: _drainageReportTargets,
...summary
} = view;
return {
...summary,
drainageInfo: {
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link),
},
};
});
}
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, pendingReportDetailTotal] = await Promise.all([
this.listSignatures({ ...query, page, pageSize }, true),
this.prisma.smsSignature.count({ where }),
this.countPendingReportDetails(where),
]);
return { items, total, page, pageSize, pendingReportDetailTotal };
}
private async countPendingReportDetails(where: Prisma.SmsSignatureWhereInput) {
const signatures = await this.prisma.smsSignature.findMany({
where: { AND: [where, { auditStatus: 'approved', pendingReport: true }] },
select: {
id: true,
applicationId: true,
materialVersion: true,
application: { select: { status: true } },
reportTasks: {
where: { reportType: 'signature' },
select: { channelId: true, carrier: true, status: true, approvalScope: true },
},
reportBatchItems: {
where: { reportType: 'signature', batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { materialVersion: true, snapshot: true },
},
},
});
const applicationIds = [
...new Set(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' },
select: {
applicationId: true,
group: {
select: {
status: true,
items: {
select: {
channel: { select: { id: true, carrier: true, carriers: true, status: true } },
},
},
},
},
},
})
: [];
let total = 0;
for (const signature of signatures) {
if (!signature.applicationId || signature.application?.status !== 'active') continue;
const generatedTargets = new Set<string>();
for (const item of signature.reportBatchItems.filter(
(entry) => entry.materialVersion === signature.materialVersion,
)) {
const businessKeys = isRecord(item.snapshot) ? item.snapshot.businessKeys : undefined;
if (!Array.isArray(businessKeys)) continue;
for (const value of businessKeys) {
const match = typeof value === 'string' ? value.match(/:channel:([^:]+):carrier:([^:]+)$/) : null;
if (!match) continue;
for (const carrier of match[2].split(',').map((entry) => entry.trim()).filter(Boolean))
generatedTargets.add(`${match[1]}:${carrier}`);
}
}
const channels = [
...new Map(
routes
.filter((route) => route.applicationId === signature.applicationId && route.group?.status === 'active')
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status === 'active')
.map((channel) => [channel.id, channel]),
).values(),
];
for (const channel of channels) {
for (const carrier of normalizeChannelCarriers(channel.carriers, channel.carrier)) {
const task =
signature.reportTasks.find(
(candidate) => candidate.channelId === channel.id && candidate.carrier === carrier,
) ??
signature.reportTasks.find(
(candidate) =>
candidate.channelId === channel.id &&
candidate.carrier === null &&
candidate.approvalScope === 'legacy_channel',
);
if (task?.status === 'abandoned') continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
total += 1;
}
}
}
return total;
}
async getSignature(id: string) {
const [item] = await this.listSignatures({ signatureId: id });
if (!item || item.auditStatus === 'deleted') throw new NotFoundException('Signature not found');
return item;
}
async getSignatureReportTargets(id: string) {
const item = await this.getSignature(id);
return 'reportTargets' in item ? item.reportTargets ?? [] : [];
}
async getDrainageReportTargets(id: string) {
const drainage = await this.prisma.smsDrainageInfo.findUnique({
where: { id },
select: { id: true, signatureId: true, auditStatus: true },
});
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found');
const signature = await this.getSignature(drainage.signatureId);
return 'drainageReportTargets' in signature ? signature.drainageReportTargets?.[id] ?? [] : [];
}
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: {
notIn: ['deleted', 'disabled'],
...(query.status && query.status !== 'all' ? { equals: query.status } : {}),
},
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,
},
},
reportTasks: {
select: {
channelId: true,
carrier: true,
status: true,
approvalScope: true,
reportType: true,
drainageItemId: true,
},
},
_count: { select: { reportMaterials: true } },
},
orderBy: { updatedAt: 'desc' },
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
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 stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const applicationChannels = [
...new Map(
routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted')
.map((channel) => [channel.id, channel]),
).values(),
];
const signatureTasks = signature.reportTasks.filter((task) => task.reportType === 'signature');
const carrierReportSummary = Object.fromEntries(
['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = applicationChannels.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
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)];
}),
);
const drainageCarrierReportSummary = Object.fromEntries(
signature.drainageItems.map((item) => {
const targets = applicationChannels.filter(
(channel) =>
hasCommonDrainageFields ||
channel.reportFields.some(
(field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType),
),
);
const tasks = signature.reportTasks.filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === item.id,
);
return [
item.id,
Object.fromEntries(
['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
const statuses = carrierTargets.map(
(channel) => tasks.find((task) => task.channelId === channel.id)?.status ?? 'pending',
);
return [carrier, summarizeReportStatuses(statuses)];
}),
),
];
}),
);
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,
carrierReportSummary,
drainageCarrierReportSummary,
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: {
notIn: ['deleted', 'disabled'],
...(query.status && query.status !== 'all' ? { equals: query.status } : {}),
},
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,
reportMaterialChanged: true,
reportPoolAvailableAfter: signature.auditStatus === 'approved' ? ('immediate' as const) : ('approval' as const),
};
}
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(drainageInfo ?? null) !== 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: materialChanged ? { increment: 1 } : undefined,
pendingReport: materialChanged ? true : undefined,
reportChangedAt: materialChanged ? new Date() : undefined,
},
include: { materials: true, tenant: true, application: true },
});
await this.reportValidation.syncSignatureReportValues(
signatureId,
updated.applicationId ?? undefined,
drainageInfo,
);
return {
...updated,
reportMaterialChanged: materialChanged,
reportPoolAvailableAfter: updated.auditStatus === 'approved' ? ('immediate' as const) : ('approval' as const),
};
}
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 || signature.auditStatus === 'deleted' || (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;
}
}