feat: support governed cascade deletion
This commit is contained in:
@@ -7,9 +7,13 @@ describe('DeletionGovernanceService', () => {
|
||||
function setup() {
|
||||
const tx = {
|
||||
operationLog: { findFirst: jest.fn(), create: jest.fn() },
|
||||
smsChannel: { updateMany: jest.fn() },
|
||||
smsSignature: { updateMany: jest.fn() },
|
||||
smsTemplate: { updateMany: jest.fn() },
|
||||
smsChannel: { findUnique: jest.fn(), updateMany: jest.fn() },
|
||||
smsSignature: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||
smsTemplate: { findFirst: jest.fn(), updateMany: jest.fn() },
|
||||
smsDrainageInfo: { updateMany: jest.fn() },
|
||||
channelSignatureReportTask: { findMany: jest.fn(), update: jest.fn() },
|
||||
channelSignatureReportRecord: { create: jest.fn() },
|
||||
channelRouteRule: { findMany: jest.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
operationLog: { findFirst: jest.fn() },
|
||||
@@ -35,6 +39,42 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10)']);
|
||||
});
|
||||
|
||||
it('allows channel deletion with unfinished report tasks only after the cascade selection is confirmed', async () => {
|
||||
const { service, prisma, tx } = setup();
|
||||
const channel = {
|
||||
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
|
||||
groupItems: [], routeRules: [], connectionStates: [],
|
||||
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||
};
|
||||
prisma.smsChannel.findUnique.mockResolvedValue(channel);
|
||||
|
||||
const preflight = await service.preflight('channel', 'channel-1');
|
||||
|
||||
expect(preflight.allowedActions).toEqual(['delete']);
|
||||
expect(preflight.requiredSelections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'abandon_associated_report_tasks', count: 1 }),
|
||||
]));
|
||||
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.smsChannel.findUnique.mockResolvedValue(channel);
|
||||
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||
tx.smsChannel.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.smsSignature.findUnique.mockResolvedValue({ id: 'signature-1', applicationId: null, auditStatus: 'approved' });
|
||||
tx.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: 'channel-1', status: 'abandoned', channel: { id: 'channel-1', status: 'deleted' } }]);
|
||||
tx.smsSignature.update.mockResolvedValue({ id: 'signature-1' });
|
||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||
|
||||
await expect(service.delete('channel', 'channel-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-channel-1', abandonAssociatedReportTasks: true,
|
||||
})).resolves.toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }),
|
||||
}));
|
||||
expect(tx.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ data: { reportStatus: 'not_applicable' } }));
|
||||
});
|
||||
|
||||
it('returns an allowed template preflight scoped to the client tenant', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||
@@ -48,24 +88,53 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
|
||||
expect(result.allowedActions).toEqual(['delete']);
|
||||
expect(result.identity.tenant).toBe('示例企业');
|
||||
expect(result.requiredSelections).toEqual([]);
|
||||
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||
include: expect.objectContaining({
|
||||
sendTasks: expect.objectContaining({ where: { status: { notIn: ['approved', 'rejected'] } } }),
|
||||
batchTasks: expect.objectContaining({ where: { status: { notIn: expect.arrayContaining(['finished', 'canceled']) } } }),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks signature deletion and exposes the referencing template and drainage items', async () => {
|
||||
it('turns signature dependencies into mandatory cascade selections', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [{ id: 'template-1', name: '验证码模板' }],
|
||||
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
||||
});
|
||||
|
||||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||
|
||||
expect(result.allowedActions).toEqual([]);
|
||||
expect(result.allowedActions).toEqual(['delete']);
|
||||
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1)'] }),
|
||||
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1)'] }),
|
||||
]));
|
||||
expect(result.requiredSelections.map((item) => item.action)).toEqual([
|
||||
'delete_associated_templates', 'delete_associated_drainage',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not expose report task ids or statuses to the client but still requires confirmation', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [], drainageItems: [], reportTasks: [{ id: 'internal-task-1', status: 'reporting' }],
|
||||
});
|
||||
|
||||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||
|
||||
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'report_tasks', count: 1, items: [], detailsVisible: false }),
|
||||
]));
|
||||
expect(JSON.stringify(result)).not.toContain('internal-task-1');
|
||||
expect(result.requiredSelections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'abandon_associated_report_tasks' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('does not classify approved or abandoned report history as unfinished', async () => {
|
||||
@@ -91,10 +160,9 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(result.allowedActions).toEqual(['delete']);
|
||||
});
|
||||
|
||||
it('requires version, idempotency key and a meaningful reason', async () => {
|
||||
it('requires version and idempotency key but allows an omitted reason', async () => {
|
||||
const { service } = setup();
|
||||
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.delete('template', 'template-1', { expectedUpdatedAt: now.toISOString(), idempotencyKey: 'key', reason: '短' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('soft deletes once and writes an auditable operation number', async () => {
|
||||
@@ -106,11 +174,14 @@ describe('DeletionGovernanceService', () => {
|
||||
sendTasks: [], batchTasks: [],
|
||||
});
|
||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.smsTemplate.findFirst.mockResolvedValue({
|
||||
id: 'template-1', tenantId: 'tenant-1', sendTasks: [], batchTasks: [],
|
||||
});
|
||||
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||
|
||||
const result = await service.delete('template', 'template-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', reason: '测试删除治理', operatorId: 'user-1',
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', operatorId: 'user-1',
|
||||
}, 'tenant-1');
|
||||
|
||||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||
@@ -118,6 +189,52 @@ describe('DeletionGovernanceService', () => {
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
|
||||
});
|
||||
|
||||
it('cascades all selected signature dependencies in one transaction with task history', async () => {
|
||||
const { service, prisma, tx } = setup();
|
||||
const preflightItem = {
|
||||
id: 'signature-1', tenantId: 'tenant-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }],
|
||||
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||
};
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.smsDrainageInfo.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||
tx.smsSignature.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||
|
||||
const result = await service.delete('signature', 'signature-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-signature-1', operatorId: 'user-1',
|
||||
deleteAssociatedTemplates: true, deleteAssociatedDrainage: true, abandonAssociatedReportTasks: true,
|
||||
}, 'tenant-1');
|
||||
|
||||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
||||
expect(tx.smsDrainageInfo.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted', pendingReport: false } }));
|
||||
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'abandoned' }) }));
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ statusBefore: 'reporting', statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }) }));
|
||||
});
|
||||
|
||||
it('rejects deletion until every discovered cascade selection is confirmed', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }], drainageItems: [], reportTasks: [],
|
||||
});
|
||||
|
||||
await expect(service.delete('signature', 'signature-1', {
|
||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'missing-selection',
|
||||
}, 'tenant-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a stale optimistic-lock version', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
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';
|
||||
|
||||
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[] };
|
||||
type Dependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
|
||||
type RequiredSelection = {
|
||||
action: DeletionResolutionAction;
|
||||
dependencyKind: string;
|
||||
label: string;
|
||||
description: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
// Report tasks use more terminal values than generic send tasks. Keep this explicit so
|
||||
// completed approval and deliberately abandoned history do not block signature deletion.
|
||||
// 报备任务、人工审核任务和批量发送任务使用不同的状态词汇。这里分别维护终态,
|
||||
// 是为了避免把已完成历史误判成活动依赖,也避免删除正在发送的数据配置。
|
||||
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;
|
||||
@@ -23,6 +55,7 @@ export type DeletionPreflight = {
|
||||
expectedUpdatedAt: string;
|
||||
identity: Record<string, string>;
|
||||
dependencies: Dependency[];
|
||||
requiredSelections: RequiredSelection[];
|
||||
impacts: string[];
|
||||
blockedReasons: string[];
|
||||
allowedActions: Array<'delete'>;
|
||||
@@ -44,9 +77,8 @@ export class DeletionGovernanceService {
|
||||
this.assertType(type);
|
||||
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
|
||||
const idempotencyKey = body.idempotencyKey?.trim();
|
||||
const reason = body.reason?.trim();
|
||||
const reason = body.reason?.trim() || undefined;
|
||||
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
|
||||
if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因');
|
||||
|
||||
const replay = await this.prisma.operationLog.findFirst({
|
||||
where: {
|
||||
@@ -62,6 +94,7 @@ export class DeletionGovernanceService {
|
||||
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({
|
||||
@@ -72,6 +105,12 @@ export class DeletionGovernanceService {
|
||||
});
|
||||
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'
|
||||
@@ -79,10 +118,22 @@ export class DeletionGovernanceService {
|
||||
: 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, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
|
||||
detail: { idempotencyKey, reason, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts },
|
||||
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 };
|
||||
@@ -97,7 +148,7 @@ export class DeletionGovernanceService {
|
||||
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
|
||||
routeRules: { where: { status: 'active' } },
|
||||
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
|
||||
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } } },
|
||||
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||
},
|
||||
});
|
||||
if (!item) throw new NotFoundException('通道不存在');
|
||||
@@ -105,10 +156,11 @@ export class DeletionGovernanceService {
|
||||
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)),
|
||||
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> {
|
||||
@@ -116,20 +168,34 @@ export class DeletionGovernanceService {
|
||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||
include: {
|
||||
tenant: { select: { name: true } }, application: { select: { name: true } },
|
||||
templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, 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('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', '未结束报备任务', item.reportTasks.map((row) => `${row.id}(${row.status})`)),
|
||||
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, ['删除后不能用于新模板或发送', '历史消息、审核与报备记录继续保留']);
|
||||
}, 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> {
|
||||
@@ -137,8 +203,8 @@ export class DeletionGovernanceService {
|
||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||
include: {
|
||||
tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } },
|
||||
sendTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
|
||||
batchTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: 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 } },
|
||||
},
|
||||
});
|
||||
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
||||
@@ -152,21 +218,187 @@ export class DeletionGovernanceService {
|
||||
}, item.auditStatus, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']);
|
||||
}
|
||||
|
||||
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 } : {}) },
|
||||
include: {
|
||||
sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true } },
|
||||
batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
||||
const blockers = [
|
||||
item.sendTasks.length ? `未结束发送任务共 ${item.sendTasks.length} 项,请先解除或完成` : '',
|
||||
item.batchTasks.length ? `未结束批量任务共 ${item.batchTasks.length} 项,请先解除或完成` : '',
|
||||
].filter(Boolean);
|
||||
if (blockers.length) throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: blockers });
|
||||
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 taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
|
||||
const reportStatus = summarizeReportStatuses(uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending')).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[]): Dependency {
|
||||
return { kind, label, count: items.length, items: items.slice(0, 8) };
|
||||
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[]): DeletionPreflight {
|
||||
const blockedReasons = dependencies.filter((item) => item.count > 0).map((item) => `${item.label}共 ${item.count} 项,请先解除或完成`);
|
||||
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, impacts, blockedReasons,
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user