import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { summarizeReportStatuses } from '../common/report-status'; import { PrismaService } from '../prisma/prisma.service'; import { normalizeChannelCarriers } from '../channels/channels.helpers'; export type DeletionTargetType = 'channel' | 'signature' | 'template'; export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks'; export type DeleteTargetDto = { expectedUpdatedAt?: string; idempotencyKey?: string; reason?: string; operatorId?: string; deleteAssociatedTemplates?: boolean; deleteAssociatedDrainage?: boolean; abandonAssociatedReportTasks?: boolean; }; type Dependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean }; type RequiredSelection = { action: DeletionResolutionAction; dependencyKind: string; label: string; description: string; count: number; }; // 报备任务、人工审核任务和批量发送任务使用不同的状态词汇。这里分别维护终态, // 是为了避免把已完成历史误判成活动依赖,也避免删除正在发送的数据配置。 const TERMINAL_REPORT_TASK_STATUSES = ['approved', 'completed', 'failed', 'cancelled', 'rejected', 'abandoned', 'partial', 'partial_success']; const TERMINAL_SEND_TASK_STATUSES = ['approved', 'rejected']; const TERMINAL_BATCH_TASK_STATUSES = ['finished', 'canceled', 'rejected', 'failed', 'completed', 'cancelled']; const RESOLUTION_COPY: Record> = { delete_associated_templates: { action: 'delete_associated_templates', label: '同时删除关联的模板', description: '发现关联的短信模板。勾选后将一并逻辑删除这些模板,历史发送和审核记录继续保留。', }, delete_associated_drainage: { action: 'delete_associated_drainage', label: '同时删除引流信息', description: '发现关联的引流信息。勾选后将一并逻辑删除这些引流信息,历史发送、审核和报备记录继续保留。', }, abandon_associated_report_tasks: { action: 'abandon_associated_report_tasks', label: '同时结束关联的报备任务', description: '发现关联的未结束报备任务。勾选后将全部置为“放弃报备”,历史任务和报备记录继续保留。', }, }; export type DeletionPreflight = { type: DeletionTargetType; id: string; expectedUpdatedAt: string; identity: Record; dependencies: Dependency[]; requiredSelections: RequiredSelection[]; impacts: string[]; blockedReasons: string[]; allowedActions: Array<'delete'>; recoverability: { mode: 'soft_delete'; description: string }; }; @Injectable() export class DeletionGovernanceService { constructor(private readonly prisma: PrismaService) {} async preflight(type: DeletionTargetType, id: string, tenantId?: string): Promise { this.assertType(type); if (type === 'channel') return this.channelPreflight(id, tenantId); if (type === 'signature') return this.signaturePreflight(id, tenantId); return this.templatePreflight(id, tenantId); } async delete(type: DeletionTargetType, id: string, body: DeleteTargetDto, tenantId?: string) { this.assertType(type); const expectedUpdatedAt = body.expectedUpdatedAt?.trim(); const idempotencyKey = body.idempotencyKey?.trim(); const reason = body.reason?.trim() || undefined; if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检'); const replay = await this.prisma.operationLog.findFirst({ where: { action: 'governance.delete', resource: type, resourceId: id, detail: { path: ['idempotencyKey'], equals: idempotencyKey }, }, orderBy: { createdAt: 'desc' }, }); if (replay) return { operationId: replay.id, status: 'deleted', replayed: true }; const preflight = await this.preflight(type, id, tenantId); if (preflight.expectedUpdatedAt !== expectedUpdatedAt) throw new ConflictException('对象已被其他操作更新,请重新检查删除影响'); if (!preflight.allowedActions.includes('delete')) { throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons }); } this.assertSelections(preflight.requiredSelections, body); return this.prisma.$transaction(async (tx) => { const existing = await tx.operationLog.findFirst({ where: { action: 'governance.delete', resource: type, resourceId: id, detail: { path: ['idempotencyKey'], equals: idempotencyKey }, }, }); if (existing) return { operationId: existing.id, status: 'deleted', replayed: true }; const cascade = type === 'channel' ? await this.prepareChannelDeletion(tx, id, body, reason) : type === 'signature' ? await this.prepareSignatureDeletion(tx, id, tenantId, body, reason) : await this.prepareTemplateDeletion(tx, id, tenantId); const updated = type === 'channel' ? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } }) : type === 'signature' ? await tx.smsSignature.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted', pendingReport: false } }) : await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } }); if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检'); if (type === 'channel') { for (const signatureId of cascade.affectedSignatureIds) await this.recomputeSignatureReportSummary(tx, signatureId); } const log = await tx.operationLog.create({ data: { tenantId: cascade.tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id, detail: { idempotencyKey, reason: reason ?? null, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts, selections: preflight.requiredSelections.map((selection) => selection.action), cascade: cascade.detail, } as Prisma.InputJsonValue, }, }); return { operationId: log.id, status: 'deleted', replayed: false }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); } private async channelPreflight(id: string, tenantId?: string): Promise { if (tenantId) throw new BadRequestException('客户端无权删除运营通道'); const item = await this.prisma.smsChannel.findUnique({ where: { id }, include: { groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } }, routeRules: { where: { status: 'active' } }, connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } }, reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } }, }, }); if (!item) throw new NotFoundException('通道不存在'); const dependencies: Dependency[] = [ dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority})`)), dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)), dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)), dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}(${row.status})`)), ]; return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies, ['删除后不再参与新消息路由', '所选未结束报备任务将置为“放弃报备”', '历史发送、回执和审计记录继续保留'], { report_tasks: 'abandon_associated_report_tasks' }); } private async signaturePreflight(id: string, tenantId?: string): Promise { const item = await this.prisma.smsSignature.findFirst({ where: { id, ...(tenantId ? { tenantId } : {}) }, include: { tenant: { select: { name: true } }, application: { select: { name: true } }, templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true, sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true, status: true } }, batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true, status: true } }, }, }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } }, reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } }, }, }); if (!item) throw new NotFoundException('签名不存在或无权访问'); const activeTemplateTasks = item.templates.flatMap((template) => [ ...template.sendTasks.map((task) => `${template.name}:发送任务 ${task.id}(${task.status})`), ...template.batchTasks.map((task) => `${template.name}:批量任务 ${task.id}(${task.status})`), ]); const dependencies: Dependency[] = [ dep('templates', '关联短信模板', item.templates.map((row) => `${row.name}(${row.id})`)), dep('template_active_tasks', '关联模板仍有未结束发送任务', activeTemplateTasks), dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}(${row.id})`)), dep('report_tasks', '未结束报备任务', tenantId ? [] : item.reportTasks.map((row) => `${row.id}(${row.status})`), item.reportTasks.length, !tenantId), ]; return buildPreflight('signature', item.id, item.updatedAt, { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定', }, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '勾选的关联配置将同步逻辑删除或结束', '历史消息、审核与报备记录继续保留'], { templates: 'delete_associated_templates', drainage: 'delete_associated_drainage', report_tasks: 'abandon_associated_report_tasks', }); } private async templatePreflight(id: string, tenantId?: string): Promise { const item = await this.prisma.smsTemplate.findFirst({ where: { id, ...(tenantId ? { tenantId } : {}) }, include: { tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } }, }, }); if (!item) throw new NotFoundException('模板不存在或无权访问'); return buildPreflight('template', item.id, item.updatedAt, { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name, signature: item.signature?.name ?? '未绑定', }, item.auditStatus, [], ['删除后不能用于新发送任务', '已创建任务继续使用保存的内容快照', '历史消息、计费和审核记录继续保留']); } private async prepareChannelDeletion(tx: Prisma.TransactionClient, id: string, body: DeleteTargetDto, reason?: string) { const item = await tx.smsChannel.findUnique({ where: { id }, include: { groupItems: { where: { group: { status: { not: 'deleted' } } }, select: { id: true } }, routeRules: { where: { status: 'active' }, select: { id: true } }, connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } }, select: { id: true } }, reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } }, }, }); if (!item) throw new NotFoundException('通道不存在'); const blockers = [ item.groupItems.length ? `引用该通道的通道组共 ${item.groupItems.length} 项,请先解除或完成` : '', item.routeRules.length ? `直接路由规则共 ${item.routeRules.length} 项,请先解除或完成` : '', item.connectionStates.length ? `活动网关连接共 ${item.connectionStates.length} 项,请先解除或完成` : '', ].filter(Boolean); if (blockers.length) throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: blockers }); this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label); const abandonReason = reason ?? '删除通道时同步放弃关联报备任务'; await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, abandonReason); return { tenantId: undefined, affectedSignatureIds: [...new Set(item.reportTasks.filter((task) => task.reportType === 'signature').map((task) => task.signatureId))], detail: { abandonedReportTaskIds: item.reportTasks.map((task) => task.id) }, }; } private async prepareSignatureDeletion(tx: Prisma.TransactionClient, id: string, tenantId: string | undefined, body: DeleteTargetDto, reason?: string) { const item = await tx.smsSignature.findFirst({ where: { id, ...(tenantId ? { tenantId } : {}) }, include: { templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true, sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true } }, batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true } }, }, }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true } }, reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } }, }, }); if (!item) throw new NotFoundException('签名不存在或无权访问'); const activeTemplateTaskCount = item.templates.reduce((sum, template) => sum + template.sendTasks.length + template.batchTasks.length, 0); if (activeTemplateTaskCount) { throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: [`关联模板仍有未结束发送任务共 ${activeTemplateTaskCount} 项,请先解除或完成`] }); } this.assertRuntimeSelection(item.templates.length, body.deleteAssociatedTemplates, RESOLUTION_COPY.delete_associated_templates.label); this.assertRuntimeSelection(item.drainageItems.length, body.deleteAssociatedDrainage, RESOLUTION_COPY.delete_associated_drainage.label); this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label); const templateIds = item.templates.map((template) => template.id); const drainageIds = item.drainageItems.map((drainage) => drainage.id); if (templateIds.length) { await tx.smsTemplate.updateMany({ where: { id: { in: templateIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } }); for (const template of item.templates) { await tx.operationLog.create({ data: { tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'template', resourceId: template.id, detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除关联模板' } as Prisma.InputJsonValue, }, }); } } if (drainageIds.length) { await tx.smsDrainageInfo.updateMany({ where: { id: { in: drainageIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted', pendingReport: false } }); for (const drainageId of drainageIds) { await tx.operationLog.create({ data: { tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'drainage', resourceId: drainageId, detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除引流信息' } as Prisma.InputJsonValue, }, }); } } await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, reason ?? '删除签名时同步放弃关联报备任务'); return { tenantId: item.tenantId, affectedSignatureIds: [] as string[], detail: { deletedTemplateIds: templateIds, deletedDrainageIds: drainageIds, abandonedReportTaskIds: item.reportTasks.map((task) => task.id) }, }; } private async prepareTemplateDeletion(tx: Prisma.TransactionClient, id: string, tenantId?: string) { const item = await tx.smsTemplate.findFirst({ where: { id, ...(tenantId ? { tenantId } : {}) }, select: { id: true, tenantId: true }, }); if (!item) throw new NotFoundException('模板不存在或无权访问'); return { tenantId: item.tenantId, affectedSignatureIds: [] as string[], detail: {} }; } private async abandonReportTasks( tx: Prisma.TransactionClient, tasks: Array<{ id: string; channelId: string; status: string }>, operatorId: string | undefined, reason: string, ) { for (const task of tasks) { // 每条任务分别留存状态前后值,便于解释一次级联删除为何结束了哪些报备任务。 await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason } }); await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'delete_cascade_abandon', statusBefore: task.status, statusAfter: 'abandoned', reason, operatorId, sourceEntry: 'deletion_governance', }, }); } } private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) { const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } }); if (!signature || signature.auditStatus === 'deleted') return; const routes = signature.applicationId ? await tx.channelRouteRule.findMany({ where: { applicationId: signature.applicationId, status: 'active' }, include: { group: { include: { items: { include: { channel: true } } } } }, }) : []; const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } }); const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); const fallbackChannels = tasks.map((task) => task.channel).filter((channel) => channel.status !== 'deleted'); const uniqueChannels = [...new Map((configuredChannels.length ? configuredChannels : fallbackChannels).map((channel) => [channel.id, channel])).values()]; const statuses = uniqueChannels.flatMap((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => ( tasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status ?? tasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status ?? 'pending' ))); const reportStatus = summarizeReportStatuses(statuses).status; await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } }); } private assertSelections(requiredSelections: RequiredSelection[], body: DeleteTargetDto) { const missing = requiredSelections.filter((selection) => !selectionSelected(selection.action, body)); if (missing.length) throw new BadRequestException(`请先确认:${missing.map((selection) => selection.label).join('、')}`); } private assertRuntimeSelection(count: number, selected: boolean | undefined, label: string) { if (count > 0 && selected !== true) throw new ConflictException(`关联数据已变化,请重新预检并勾选“${label}”`); } private assertType(type: string): asserts type is DeletionTargetType { if (!['channel', 'signature', 'template'].includes(type)) throw new BadRequestException('不支持的删除对象类型'); } } function dep(kind: string, label: string, items: string[], count = items.length, detailsVisible = true): Dependency { return { kind, label, count, items: detailsVisible ? items.slice(0, 8) : [], detailsVisible }; } function buildPreflight( type: DeletionTargetType, id: string, updatedAt: Date, identity: Record, status: string, dependencies: Dependency[], impacts: string[], resolutions: Partial> = {}, ): DeletionPreflight { const requiredSelections = dependencies.flatMap((dependency) => { const action = resolutions[dependency.kind]; if (!action || dependency.count === 0) return []; return [{ ...RESOLUTION_COPY[action], dependencyKind: dependency.kind, count: dependency.count }]; }); const blockedReasons = dependencies .filter((dependency) => dependency.count > 0 && !resolutions[dependency.kind]) .map((dependency) => `${dependency.label}共 ${dependency.count} 项,请先解除或完成`); if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作'); return { type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, requiredSelections, impacts, blockedReasons, allowedActions: blockedReasons.length ? [] : ['delete'], recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' }, }; } function selectionSelected(action: DeletionResolutionAction, body: DeleteTargetDto) { if (action === 'delete_associated_templates') return body.deleteAssociatedTemplates === true; if (action === 'delete_associated_drainage') return body.deleteAssociatedDrainage === true; return body.abandonAssociatedReportTasks === true; }