feat: support governed cascade deletion

This commit is contained in:
hectorzhao
2026-08-09 19:08:38 +08:00
parent 6add563ee8
commit 7804f64ced
9 changed files with 476 additions and 47 deletions
@@ -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;
}
+13 -4
View File
@@ -1926,17 +1926,26 @@
## 签名删除预检与多通道报备汇总修正(2026-08-09)
- 签名删除预检中的“未结束报备任务”只统计仍需处理的过程态任务;`approved``completed``failed``cancelled``rejected``abandoned``partial``partial_success`均属于已结束历史,不得仅因这些任务存在而阻止删除。模板、引流信息等其他真实依赖仍按原删除治理规则阻止删除
- 签名删除预检中的“未结束报备任务”只统计仍需处理的过程态任务;`approved``completed``failed``cancelled``rejected``abandoned``partial``partial_success`均属于已结束历史,不得作为活动关联项
- 签名及运营商报备汇总不得因单个目标通道失败就直接变为整体“报备失败”。全部当前目标通道通过时为“报备成功”;至少一个通过但尚未全部通过时为“部分成功”;没有通过且仍有其他目标待处理时为“报备中”;只有全部当前目标通道均为`failed/rejected`时才为整体“报备失败”。
- 每个通道的失败事实、失败原因和历史报备记录必须继续保留并展示;汇总状态修正只改变整体归因,不得覆盖或删除通道级失败证据。
## 通道组删除风险展示与历史保留(2026-08-09)
- 运营端删除通道组前,必须通过真实后端和数据库统计并展示:关联正常企业应用数、关联已删除企业应用数、组内通道数、等待供应商提交结果数。企业应用按不同`applicationId`去重;状态不是`deleted`的现存应用计为正常应用,状态`deleted`或应用记录已不存在的残留关联计为已删除应用
- 运营端删除通道组前,必须通过真实后端和数据库统计并展示:关联正常企业应用数、组内通道数、等待供应商提交结果数。企业应用按不同`applicationId`去重;状态为`deleted`或应用记录已不存在的残留关联可继续计入后台审计快照,但不得在删除弹窗展示
- “等待供应商提交结果”固定为该通道组下`SmsSubmitRecord.submitStatus = queued`的记录数,表示平台已选定该组但尚未收到供应商提交结果;该状态不按三个工作日自动完成,不能与最终回执超时口径混用。
- 正常应用关联、已删除应用残留关联、组内通道和等待提交记录均只作风险展示,不得隐藏、禁用或阻止“确认删除”;弹窗不要求输入通道组名称,不要求填写删除原因,由运营查看真实影响后确认。
- 正常应用关联、组内通道和等待提交记录均只作风险展示,不得禁用或阻止“确认删除”;弹窗不要求输入通道组名称,不要求填写删除原因,由运营查看真实影响后确认。
- 删除采用逻辑删除,将通道组状态置为`deleted`并从通道组列表及新短信选路中排除;不得删除组内通道配置、企业应用关联、发送记录、回执或审计数据,确保历史查询、回执处理及上行接入号匹配仍可追溯。
- 弹窗标题为“删除通道组:{通道组名称}”,正文依次展示上述项真实数量,并明确:“删除后该通道组不再参与新短信发送,历史配置、发送、回执和审计数据继续保留。”操作仅保留“取消”和“确认删除”。
- 弹窗标题为“删除通道组:{通道组名称}”,正文依次展示上述项真实数量,并明确:“删除后该通道组不再参与新短信发送,历史配置、发送、回执和审计数据继续保留。”操作仅保留“取消”和“确认删除”。
## 通道、签名与模板级联删除确认(2026-08-09)
- 通道、签名和模板删除原因统一为选填;未填写时仍允许删除,后端必须继续记录操作人、对象版本、幂等键、真实依赖快照和级联结果。删除仍采用逻辑删除,不物理清除历史发送、计费、审核、报备或审计数据。
- 删除签名时,若存在未删除短信模板、未删除引流信息或未结束报备任务,弹窗必须分别提供“同时删除关联的模板”“同时删除引流信息”“同时结束关联的报备任务”勾选项。发现的勾选项必须全部勾选后才允许确认;后端必须再次校验并在同一个`Serializable`事务中将关联模板和引流信息逻辑删除、将未结束报备任务置为`abandoned`,最后逻辑删除签名。
- 删除通道时,若存在未结束报备任务,必须提供“同时结束关联的报备任务”勾选项;勾选后在同一事务中将任务置为`abandoned`并逻辑删除通道。已有活动通道组引用、直接路由规则或活动网关连接仍属于不能由该勾选项解决的硬依赖,必须先处理后再删除。
- 运营端可以看到未结束报备任务的真实ID和状态;客户端也允许勾选“同时结束关联的报备任务”,但客户端专用预检响应和页面不得展示任务ID、状态、通道或其他内部详情,只展示统一说明:“发现关联的未结束报备任务。勾选后将全部置为‘放弃报备’,历史任务和报备记录继续保留。”
- 每一条被放弃的报备任务必须写`ChannelSignatureReportRecord`,保留变更前状态、`abandoned`变更后状态、操作人、原因和`deletion_governance`来源;级联删除的模板和引流信息必须留下子对象审计记录。删除通道并结束任务后,受影响的未删除签名必须在同一事务内按剩余有效通道重算报备汇总。
- 关联模板若仍存在真实未结束发送或批量任务,不得仅靠“同时删除关联的模板”绕过发送安全约束;模板本身删除也使用同一活动任务口径。`SmsSendTask``approved/rejected`按已结束审核任务处理,`SmsBatchTask`至少将`finished/canceled/rejected/failed/completed/cancelled`按终态处理,避免已结束历史被误判为活动任务。
## 发送质量矩阵与成功率色阶统一(2026-08-09)
+11 -3
View File
@@ -4415,15 +4415,23 @@ npm run verify:phase8
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-DELETE-SIGNATURE-004 | 对仅存在`approved``abandoned`等已结束报备任务,且无模板、引流信息或其他活动依赖的签名执行删除预检 | 已结束报备任务不出现在“未结束报备任务”中,删除预检允许继续;历史任务和记录仍保留 |
| TC-DELETE-SIGNATURE-005 | 对仍存在`pending``waiting_material``reporting``exporting`任务的签名执行删除预检 | 预检列出真实未结束任务ID和状态并阻止删除 |
| TC-DELETE-SIGNATURE-005 | 运营端对仍存在`pending``waiting_material``reporting``exporting`任务的签名执行删除预检 | 预检列出真实未结束任务ID和状态,出现“同时结束关联的报备任务”必选项;未勾选不能确认,勾选后可继续 |
| TC-DELETE-SIGNATURE-006 | 签名同时关联未删除模板、引流信息和未结束报备任务 | 分别出现三个级联勾选项;少勾选任意一项时确认按钮不可用且后端直接调用也拒绝,全部勾选后才可确认 |
| TC-DELETE-SIGNATURE-007 | 客户端删除存在未结束报备任务的签名 | 页面只展示统一的结束报备说明和勾选项;API及页面均不出现任务ID、状态、通道等内部详情;全部关联项勾选后允许确认 |
| TC-DELETE-SIGNATURE-008 | 全部勾选后删除同时关联模板、引流信息和过程态报备任务的签名 | 同一`Serializable`事务内模板、引流信息和签名均逻辑删除,任务置为`abandoned`;每条任务及子对象均有真实审计记录,历史消息、计费、审核和报备记录保留 |
| TC-DELETE-SIGNATURE-009 | 关联模板仍存在未结束发送或批量任务 | 即使勾选“同时删除关联的模板”仍由真实活动任务阻止删除,不中断或丢失正在处理的数据 |
| TC-DELETE-CHANNEL-010 | 通道仅关联未结束报备任务,没有活动组、直接路由或连接 | 出现“同时结束关联的报备任务”必选项;勾选后任务置为`abandoned`并写记录,通道逻辑删除,受影响有效签名按剩余有效通道重算汇总 |
| TC-DELETE-CHANNEL-011 | 通道仍存在活动通道组、直接路由或活动网关连接 | 报备任务勾选项不能绕过其他硬依赖,后端拒绝删除并返回真实阻断原因 |
| TC-DELETE-REASON-012 | 分别在运营端和客户端删除无硬依赖的通道、签名、模板,删除原因留空或填写内容 | 留空时允许删除;填写时原文进入审计详情,三类对象均不再要求至少4个字符 |
| TC-DELETE-TEMPLATE-013 | 模板只关联状态为`approved/rejected`的发送审核任务和`finished/canceled`的批量任务 | 已结束任务不阻止模板删除;真实过程态发送或批量任务仍阻止删除 |
## 2026-08-09 通道组删除风险展示与历史保留用例
| 用例编号 | 场景 | 预期结果 |
| --- | --- | --- |
| TC-CHANNEL-GROUP-DELETE-001 | 打开同时关联正常应用、已删除应用、多个通道和`queued`提交记录的通道组删除弹窗 | 后端按不同应用ID去重并返回四项真实数量;弹窗标题、数量单位、说明和按钮文案与需求一致 |
| TC-CHANNEL-GROUP-DELETE-001 | 打开同时关联正常应用、已删除应用、多个通道和`queued`提交记录的通道组删除弹窗 | 后端按不同应用ID去重并保留审计统计;弹窗只展示关联正常企业应用、组内通道、等待供应商提交结果三项真实数量标题、数量单位、说明和按钮文案与需求一致 |
| TC-CHANNEL-GROUP-DELETE-002 | 同一正常企业应用存在多条通道组关联 | “关联正常企业应用”只计1个,不按关联规则条数重复累计 |
| TC-CHANNEL-GROUP-DELETE-003 | 关联记录指向状态为`deleted`或已不存在的企业应用 | 两类均计入“关联已删除企业应用”,不计入正常应用 |
| TC-CHANNEL-GROUP-DELETE-003 | 关联记录指向状态为`deleted`或已不存在的企业应用 | 两类均计入正常应用,删除弹窗不展示“关联已删除企业应用”;后台审计快照仍可保留其真实数量 |
| TC-CHANNEL-GROUP-DELETE-004 | 通道组存在正常/已删除应用关联、组内通道或等待供应商提交记录后确认删除 | 所有业务依赖只展示不阻止;后端将通道组状态置为`deleted`并写操作审计,不物理删除关联和历史记录 |
| TC-CHANNEL-GROUP-DELETE-005 | 删除通道组后查询通道组列表并发送新短信 | 默认列表不再显示该组,新短信选路不再选择该组 |
| TC-CHANNEL-GROUP-DELETE-006 | 删除通道组后查询历史发送/回执/审计,或按历史通道接入号处理上行 | 组内通道、应用关联、发送、回执和审计数据仍存在,历史链路可追溯 |
+10
View File
@@ -3314,3 +3314,13 @@ git diff --check
- 新通道组删除影响接口已出现在部署后Swagger路径中,未认证访问返回401;前端生产包包含“等待供应商提交结果”和历史数据保留完整文案。对真实PostgreSQL最近三个活动通道组只读执行同口径统计,均得到正常应用2、已删除应用0、组内通道3、等待供应商提交0;没有点击或调用确认删除。现有浏览器无登录会话,未绕过验证码或伪造登录态,登录后UI弹窗交互仍可作为后续人工验收项。
- 发布前数据库状态为9条active通道、连接状态9条connected;重启后6条active通道恢复`connected 1/1`3条富泷通道返回供应商`authentication / connect response status: auth failed`。本轮未修改这些通道的账号、密码或启停状态,仅保留真实失败状态并报告。部署后API/Gateway error级journal均为0。
- npm审计报告根项目3项high、API项目3项moderate和4项high;专用安全门禁确认PostCSS补丁、React Router RSC未使用和brace expansion边界有效,未执行可能破坏兼容性的自动升级。本次没有发送、补发或重投真实短信,没有修改企业余额、客户连接或任何真实通道配置。
## 2026-08-09 通道、签名与模板级联删除确认(本地未提交)
- 通道组删除弹窗不再展示“关联已删除企业应用”,继续展示关联正常企业应用、组内通道和等待供应商提交结果;后端已有真实影响统计及删除审计快照保持不变。
- 通道、签名和模板删除原因统一改为选填。签名存在关联模板、引流信息或未结束报备任务时分别出现“同时删除关联的模板”“同时删除引流信息”“同时结束关联的报备任务”;通道存在未结束报备任务时出现结束报备勾选项。发现的级联项必须全部勾选后页面才允许确认,后端也独立复核所有布尔选项,不能绕过前端直接删除。
- 客户端允许同步结束签名关联的未结束报备任务,但客户端专用预检不返回任务ID、状态或通道详情,页面只展示统一处理说明。运营端仍可查看真实任务ID和状态。
- 所有级联动作与主对象逻辑删除在同一个`Serializable`事务完成;关联模板和引流信息逻辑删除,过程态报备任务置为`abandoned`并逐条写`ChannelSignatureReportRecord`,子对象另写操作日志。通道删除后,同事务按剩余未删除通道重算受影响签名报备汇总;活动通道组、直接路由、活动连接及关联模板的活动发送任务仍保持硬阻断。
- 修正模板活动任务终态口径:`SmsSendTask``approved/rejected``SmsBatchTask``finished/canceled/rejected/failed/completed/cancelled`不再被误判为未结束任务;真实过程态任务继续阻止模板或签名级联删除。无需新增数据库字段或migration。
- 使用Node.js v24运行删除治理定向1 suite / 11 tests全部通过;排除此前已确认依赖本机Redis的`send-chain.service.spec.ts`后,API其余32 suites / 324 tests全部通过。API TypeScript build、前端TypeScript`--noEmit --incremental false`、Vite v8.1.5生产构建(2535 modules,仅既有约2.04MB单chunk提示)和`git diff --check`均通过。
- 本轮未连接或修改预生产数据库,未执行任何真实通道、签名、模板、引流信息或报备任务删除,未发送、补发或重投真实短信,未修改真实通道、企业余额或客户连接。代码按要求保持未提交、未推送、未部署;既有`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`继续保护,不归因、不删除、不提交。
+20 -2
View File
@@ -6,7 +6,17 @@ export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[] };
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
export type DeletionRequiredSelection = {
action: DeletionResolutionAction;
dependencyKind: string;
label: string;
description: string;
count: number;
};
export type DeletionPreflight = {
type: DeletionTargetType;
@@ -14,13 +24,21 @@ export type DeletionPreflight = {
expectedUpdatedAt: string;
identity: Record<string, string>;
dependencies: DeletionDependency[];
requiredSelections: DeletionRequiredSelection[];
impacts: string[];
blockedReasons: string[];
allowedActions: Array<'delete'>;
recoverability: { mode: 'soft_delete'; description: string };
};
export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string };
export type DeleteTargetRequest = {
expectedUpdatedAt: string;
idempotencyKey: string;
reason?: string;
deleteAssociatedTemplates?: boolean;
deleteAssociatedDrainage?: boolean;
abandonAssociatedReportTasks?: boolean;
};
export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean };
@@ -205,7 +205,6 @@ export function AdminChannelGroupsPage() {
{deletionImpact ? (
<>
<span>{deletionImpact.normalApplicationCount} </span>
<span>{deletionImpact.deletedApplicationCount} </span>
<span>{deletionImpact.channelCount} </span>
<span>{deletionImpact.pendingSupplierSubmitCount} </span>
<p>
+37 -7
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react';
import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react';
import { adminApi, clientApi, type DeletionPreflight, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
import { adminApi, clientApi, type DeletionPreflight, type DeletionResolutionAction, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
import { createUuid } from '@/utils/randomId';
import { Button } from './Button';
import { Modal } from './Modal';
@@ -21,12 +21,13 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
const [preflight, setPreflight] = useState<DeletionPreflight | null>(null);
const [result, setResult] = useState<DeletionResult | null>(null);
const [reason, setReason] = useState('');
const [selections, setSelections] = useState<Set<DeletionResolutionAction>>(() => new Set());
const [error, setError] = useState('');
const [idempotencyKey, setIdempotencyKey] = useState('');
async function begin() {
const key = `delete:${targetType}:${targetId}:${createUuid()}`;
setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setError(''); setIdempotencyKey(key);
setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setSelections(new Set()); setError(''); setIdempotencyKey(key);
try {
const data = portal === 'admin'
? await adminApi.getDeletionPreflight(targetType, targetId)
@@ -38,10 +39,17 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
}
async function confirm() {
if (!preflight?.allowedActions.includes('delete') || reason.trim().length < 4) return;
if (!preflight?.allowedActions.includes('delete') || !allSelectionsConfirmed(preflight, selections)) return;
setSubmitting(true); setError('');
try {
const body = { expectedUpdatedAt: preflight.expectedUpdatedAt, idempotencyKey, reason: reason.trim() };
const body = {
expectedUpdatedAt: preflight.expectedUpdatedAt,
idempotencyKey,
reason: reason.trim() || undefined,
deleteAssociatedTemplates: selections.has('delete_associated_templates'),
deleteAssociatedDrainage: selections.has('delete_associated_drainage'),
abandonAssociatedReportTasks: selections.has('abandon_associated_report_tasks'),
};
const completed = portal === 'admin'
? await adminApi.deleteGovernedTarget(targetType, targetId, body)
: await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body);
@@ -56,10 +64,18 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
setOpen(false);
if (result) onCompleted?.(result);
}
function toggleSelection(action: DeletionResolutionAction, checked: boolean) {
setSelections((current) => {
const next = new Set(current);
if (checked) next.add(action); else next.delete(action);
return next;
});
}
const blocked = Boolean(preflight && !preflight.allowedActions.includes('delete'));
const selectionsConfirmed = Boolean(preflight && allSelectionsConfirmed(preflight, selections));
const footer = (requestClose: () => void) => result ? <Button onClick={close}></Button> : <>
<Button disabled={submitting} onClick={requestClose} variant="ghost"></Button>
<Button disabled={loading || submitting || blocked || !preflight || reason.trim().length < 4} icon={<Trash2 size={15} />} onClick={() => void confirm()} variant="danger">
<Button disabled={loading || submitting || blocked || !preflight || !selectionsConfirmed} icon={<Trash2 size={15} />} onClick={() => void confirm()} variant="danger">
{submitting ? '删除处理中…' : '确认删除'}
</Button>
</>;
@@ -77,12 +93,22 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
<div className="risk-action-identity">
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
</div>
<section><h3></h3>{preflight.dependencies.length ? <ul className="delete-risk-dependencies">{preflight.dependencies.map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} </span>{item.items.length ? <small>{item.items.join('')}</small> : <small></small>}</li>)}</ul> : null}</section>
<section><h3></h3>{preflight.dependencies.filter((item) => item.detailsVisible).length ? <ul className="delete-risk-dependencies">{preflight.dependencies.filter((item) => item.detailsVisible).map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} </span>{item.items.length ? <small>{item.items.join('')}</small> : <small></small>}</li>)}</ul> : null}</section>
{preflight.blockedReasons.length
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
: <p className="risk-action-passed"><ShieldCheck size={16} /></p>}
{preflight.requiredSelections.length ? <section className="delete-risk-selections"><h3></h3>{preflight.requiredSelections.map((selection) => (
<label key={selection.action}>
<input
checked={selections.has(selection.action)}
onChange={(event) => toggleSelection(selection.action, event.target.checked)}
type="checkbox"
/>
<span><strong>{selection.label}</strong><small>{selection.description}</small></span>
</label>
))}<p className="muted"></p></section> : null}
<section><h3></h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul><p className="muted">{preflight.recoverability.description}</p></section>
{!blocked ? <Textarea label="删除原因" onChange={(event) => setReason(event.target.value)} placeholder="至少填写 4 个字符,原因将写入审计记录" required value={reason} /> : null}
{!blocked ? <Textarea label="删除原因(选填)" onChange={(event) => setReason(event.target.value)} placeholder="如填写,将写入审计记录" value={reason} /> : null}
</> : null}
{error ? <p className="form-error" role="alert">{error}</p> : null}
</>
@@ -93,3 +119,7 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
}
const identityLabels: Record<string, string> = { name: '对象名称', id: '唯一标识', code: '通道编码', tenant: '所属企业', application: '短信应用', signature: '关联签名' };
function allSelectionsConfirmed(preflight: DeletionPreflight, selections: Set<DeletionResolutionAction>) {
return preflight.requiredSelections.every((selection) => selections.has(selection.action));
}
+6
View File
@@ -3460,6 +3460,12 @@
.delete-risk-dependencies { display: grid; gap: 8px; list-style: none; padding: 0 !important; }
.delete-risk-dependencies li { align-items: center; background: var(--color-surface-muted); border-radius: var(--radius-md); display: grid; gap: 3px; grid-template-columns: 1fr auto; padding: 10px 12px; }
.delete-risk-dependencies small { color: var(--color-text-muted); grid-column: 1 / -1; overflow-wrap: anywhere; }
.delete-risk-selections { display: grid; gap: 10px; }
.delete-risk-selections > h3, .delete-risk-selections > p { margin: 0; }
.delete-risk-selections label { align-items: flex-start; background: var(--color-surface-muted); border: 1px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; display: flex; gap: 10px; padding: 11px 12px; }
.delete-risk-selections input { flex: 0 0 auto; margin-top: 3px; }
.delete-risk-selections label > span { display: grid; gap: 4px; }
.delete-risk-selections small { color: var(--color-text-muted); line-height: 1.55; }
.delete-risk-action .ui-textarea { min-height: 88px; }
@media (max-width: 640px) {