feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
@@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
@ApiTags('deletion-governance')
@Controller('admin/deletions')
export class AdminDeletionGovernanceController {
constructor(private readonly deletions: DeletionGovernanceService) {}
@Get(':type/:id/preflight')
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string) {
return this.deletions.preflight(type, id);
}
@Post(':type/:id')
@RequireRecentAuthentication()
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
return this.deletions.delete(type, id, { ...body, operatorId });
}
}
@ApiTags('client-deletion-governance')
@Controller('client/deletions')
export class ClientDeletionGovernanceController {
constructor(private readonly deletions: DeletionGovernanceService) {}
@Get(':type/:id/preflight')
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) {
return this.deletions.preflight(type, id, tenantId);
}
@Post(':type/:id')
@RequireRecentAuthentication()
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminDeletionGovernanceController, ClientDeletionGovernanceController } from './deletion-governance.controller';
import { DeletionGovernanceService } from './deletion-governance.service';
@Module({
controllers: [AdminDeletionGovernanceController, ClientDeletionGovernanceController],
providers: [DeletionGovernanceService],
exports: [DeletionGovernanceService],
})
export class DeletionGovernanceModule {}
@@ -0,0 +1,110 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { DeletionGovernanceService } from './deletion-governance.service';
describe('DeletionGovernanceService', () => {
const now = new Date('2026-07-21T10:00:00.000Z');
function setup() {
const tx = {
operationLog: { findFirst: jest.fn(), create: jest.fn() },
smsChannel: { updateMany: jest.fn() },
smsSignature: { updateMany: jest.fn() },
smsTemplate: { updateMany: jest.fn() },
};
const prisma = {
operationLog: { findFirst: jest.fn() },
smsChannel: { findUnique: jest.fn() },
smsSignature: { findFirst: jest.fn() },
smsTemplate: { findFirst: jest.fn() },
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
};
return { service: new DeletionGovernanceService(prisma as never), prisma, tx };
}
it('blocks channel deletion when a live group still references it', async () => {
const { service, prisma } = setup();
prisma.smsChannel.findUnique.mockResolvedValue({
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
groupItems: [{ priority: 10, group: { name: '移动主通道组' } }], routeRules: [], connectionStates: [], reportTasks: [],
});
const result = await service.preflight('channel', 'channel-1');
expect(result.allowedActions).toEqual([]);
expect(result.blockedReasons).toContain('引用该通道的通道组共 1 项,请先解除或完成');
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10']);
});
it('returns an allowed template preflight scoped to the client tenant', async () => {
const { service, prisma } = setup();
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
sendTasks: [], batchTasks: [],
});
const result = await service.preflight('template', 'template-1', 'tenant-1');
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
expect(result.allowedActions).toEqual(['delete']);
expect(result.identity.tenant).toBe('示例企业');
});
it('blocks signature deletion and exposes the referencing template and drainage items', 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: '验证码模板' }],
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
});
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
expect(result.allowedActions).toEqual([]);
expect(result.dependencies).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1'] }),
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1'] }),
]));
});
it('requires version, idempotency key and a meaningful 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 () => {
const { service, prisma, tx } = setup();
prisma.operationLog.findFirst.mockResolvedValue(null);
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
sendTasks: [], batchTasks: [],
});
tx.operationLog.findFirst.mockResolvedValue(null);
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',
}, 'tenant-1');
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
});
it('rejects a stale optimistic-lock version', async () => {
const { service, prisma } = setup();
prisma.operationLog.findFirst.mockResolvedValue(null);
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
sendTasks: [], batchTasks: [],
});
await expect(service.delete('template', 'template-1', {
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
}, 'tenant-1')).rejects.toBeInstanceOf(ConflictException);
});
});
@@ -0,0 +1,168 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeleteTargetDto = {
expectedUpdatedAt?: string;
idempotencyKey?: string;
reason?: string;
operatorId?: string;
};
type Dependency = { kind: string; label: string; count: number; items: string[] };
export type DeletionPreflight = {
type: DeletionTargetType;
id: string;
expectedUpdatedAt: string;
identity: Record<string, string>;
dependencies: Dependency[];
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();
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因');
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 });
}
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 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('对象状态已变化,请重新执行资格预检');
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 },
},
});
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: ['completed', 'failed', 'cancelled', 'rejected'] } } },
},
});
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)),
];
return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies,
['删除后不再参与新消息路由', '历史发送、回执和审计记录继续保留']);
}
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 } },
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
},
});
if (!item) throw new NotFoundException('签名不存在或无权访问');
const dependencies: Dependency[] = [
dep('templates', '仍在使用该签名的模板', item.templates.map((row) => `${row.name}${row.id}`)),
dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}${row.id}`)),
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}${row.status}`)),
];
return buildPreflight('signature', item.id, item.updatedAt, {
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定',
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '历史消息、审核与报备记录继续保留']);
}
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 } },
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 } },
},
});
if (!item) throw new NotFoundException('模板不存在或无权访问');
const dependencies: Dependency[] = [
dep('send_tasks', '未结束发送任务', item.sendTasks.map((row) => `${row.id}${row.status}`)),
dep('batch_tasks', '未结束批量任务', item.batchTasks.map((row) => `${row.id}${row.status}`)),
];
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, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']);
}
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 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} 项,请先解除或完成`);
if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作');
return {
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, impacts, blockedReasons,
allowedActions: blockedReasons.length ? [] : ['delete'],
recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' },
};
}