Files
lislgosms/api/src/deletion-governance/deletion-governance.service.ts
T

396 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DeletionResolutionAction, Omit<RequiredSelection, 'dependencyKind' | 'count'>> = {
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<string, string>;
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<DeletionPreflight> {
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<DeletionPreflight> {
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<DeletionPreflight> {
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<DeletionPreflight> {
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<string, string>,
status: string,
dependencies: Dependency[],
impacts: string[],
resolutions: Partial<Record<string, DeletionResolutionAction>> = {},
): 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;
}