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
@@ -59,17 +59,20 @@ describe('ReportMaterialsService', () => {
let exportSequence = 0;
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-1' }), update: jest.fn().mockResolvedValue({}) },
reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
},
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用' } }),
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', pendingReport: true, materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用', status: 'active' } }),
update: jest.fn().mockResolvedValue({}),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: channels.map((channel, index) => ({ priority: index, channel })) } }]) },
reportMaterialBatchItem: { create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })) } }]) },
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
channelReportField: { findMany: jest.fn().mockResolvedValue([
{ code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
@@ -89,7 +92,7 @@ describe('ReportMaterialsService', () => {
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-1' }] });
const result = await service.createBatch({ idempotencyKey: 'report-batch:test-1', items: [{ reportType: 'signature', signatureId: 'signature-1', materialVersion: 3 }] });
expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2);
@@ -107,11 +110,14 @@ describe('ReportMaterialsService', () => {
it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => {
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-2' }), update: jest.fn().mockResolvedValue({}) },
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) },
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用' } }), update: jest.fn() },
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', pendingReport: true, materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用', status: 'active' } }), update: jest.fn() },
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
reportMaterialBatchItem: { create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) },
@@ -122,10 +128,38 @@ describe('ReportMaterialsService', () => {
const files = { upload: jest.fn().mockResolvedValue({ id: 'file-2', fileName: 'empty.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-2' }] });
await expect(service.createBatch({ idempotencyKey: 'report-batch:test-2', items: [{ reportType: 'signature', signatureId: 'signature-2', materialVersion: 1 }] }))
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) });
expect(result).toMatchObject({ status: 'partial_failed' });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'waiting_material', reason: '通道未配置当前资料类型的报备字段' }) });
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
});
it('reuses a completed operation for the same idempotency key without generating a second batch', async () => {
const prisma = {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: { findFirst: jest.fn().mockResolvedValue({ id: 'operation-existing', detail: { status: 'completed', fingerprint: expect.anything(), result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }) },
reportMaterialBatch: { create: jest.fn() },
};
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }];
const fingerprint = createFingerprint(items);
prisma.operationLog.findFirst.mockResolvedValueOnce({ id: 'operation-existing', detail: { status: 'completed', fingerprint, result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } });
await expect(service.createBatch({ idempotencyKey: 'report-batch:replay', items })).resolves.toMatchObject({ id: 'batch-existing', replayed: true, operationId: 'operation-existing' });
expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled();
});
it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => {
const prisma = { smsSignature: { findUnique: jest.fn() } };
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
await expect(service.preflightBatch({ items: [{ reportType: 'signature', signatureId: '' }] })).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_ITEM_INVALID' }) });
expect(prisma.smsSignature.findUnique).not.toHaveBeenCalled();
});
});
function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) {
const { createHash } = require('node:crypto') as typeof import('node:crypto');
return createHash('sha256').update(JSON.stringify(items.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: null, materialVersion: item.materialVersion })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
}