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
+305 -12
View File
@@ -84,8 +84,10 @@ function createPrismaMock() {
id: 'tpl-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
content: 'hello',
auditStatus: 'approved',
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
}),
findFirst: jest.fn().mockResolvedValue({
id: 'tpl-1',
@@ -112,6 +114,7 @@ function createPrismaMock() {
findFirst: jest.fn().mockResolvedValue(task),
findMany: jest.fn(),
update: jest.fn().mockResolvedValue(task),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsApiRequest: {
create: jest.fn().mockResolvedValue({ id: 'request-1' }),
@@ -214,6 +217,9 @@ function createPrismaMock() {
messageId: 'MSG-1',
deliveryType: 'receipt',
retryCount: 0,
manualRetryCount: 0,
status: 'failed',
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
lastError: null,
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
application: { cmppAccount: '100001' },
@@ -257,6 +263,7 @@ function createPrismaMock() {
}),
update: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findMany: jest.fn().mockResolvedValue([]),
},
gatewayDownstreamRecoveryStatus: {
findUnique: jest.fn().mockResolvedValue(null),
@@ -364,6 +371,10 @@ describe('SendChainService', () => {
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' },
});
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}',
auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
await service.createHttpBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'],
@@ -384,16 +395,17 @@ describe('SendChainService', () => {
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved',
signature: { id: 'sig-1', auditStatus: 'approved', reportStatus: 'reporting' },
content: '【签名】详情请访问 https://a.example/landing',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' },
});
prisma.smsDrainageInfo.findMany.mockResolvedValue([
{ id: 'drain-short', url: 'https://a.example', updatedAt: new Date('2026-07-01') },
{ id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') },
{ id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') },
{ id: 'drain-long', url: 'https://a.example/landing', auditStatus: 'approved', updatedAt: new Date('2026-07-02') },
]);
await service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '详情请访问 https://a.example/landing', phones: ['13800000001'],
content: '【签名】详情请访问 https://a.example/landing', phones: ['13800000001'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
@@ -401,6 +413,81 @@ describe('SendChainService', () => {
});
});
it('rejects a task when the submitted content no longer matches the selected approved template', async () => {
const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
content: '【签名】验证码${code}', auditStatus: 'approved',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '【签名】被篡改的正文', phones: ['13800000001'],
})).rejects.toThrow('短信内容与选定的审核模板不匹配');
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
});
it('rejects free content without an approved leading signature', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true,
customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send',
});
prisma.smsSignature.findFirst.mockResolvedValue(null);
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', content: '没有签名的自由内容', phones: ['13800000001'],
})).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头');
});
it('allows signed free content only when the application explicitly uses direct send', async () => {
const { service, prisma } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true,
customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send',
});
prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【签名】', auditStatus: 'approved' });
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】允许直接发送的自由内容', phones: ['13800000001'],
})).resolves.toBeDefined();
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ signatureId: 'sig-1' })],
});
});
it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => {
const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
content: '【签名】详情 https://blocked.example', auditStatus: 'approved',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
prisma.smsDrainageInfo.findMany.mockResolvedValue([
{ id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') },
]);
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: '【签名】详情 https://blocked.example', phones: ['13800000001'],
})).resolves.toBeDefined();
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
})],
});
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
});
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
@@ -437,6 +524,102 @@ describe('SendChainService', () => {
where: { batchTaskId: 'task-1', status: 'scheduled' },
data: { status: 'queued' },
});
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({ id: 'task-1', status: 'scheduled' }),
data: { status: 'scheduled_dispatching' },
});
});
it('atomically claims a due scheduled task so concurrent scanners only freeze and enqueue once', async () => {
const { service, prisma, billing } = createService();
const dueTask = {
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
status: 'scheduled', scheduledAt: new Date(Date.now() - 1_000), updatedAt: new Date(Date.now() - 1_000),
};
prisma.smsBatchTask.findMany.mockResolvedValue([dueTask]);
prisma.smsBatchTask.updateMany
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 0 });
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
const results = await Promise.all([
service.dispatchDueScheduledTasks(new Date()),
service.dispatchDueScheduledTasks(new Date()),
]);
expect(results.map((item) => item.dispatched).sort()).toEqual([0, 1]);
expect(billing.freeze).toHaveBeenCalledTimes(1);
expect(service.enqueueBatchTask).toHaveBeenCalledTimes(1);
});
it('recovers a stale claimed task without freezing its balance twice', async () => {
const { service, prisma, billing } = createService();
const now = new Date();
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
status: 'scheduled_dispatching', scheduledAt: new Date(now.getTime() - 300_000),
updatedAt: new Date(now.getTime() - 300_000),
}]);
prisma.accountTransaction.findFirst.mockResolvedValue({ id: 'frozen-transaction-1' });
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
await expect(service.dispatchDueScheduledTasks(now)).resolves.toEqual({
dispatched: 1,
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
});
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({ id: 'task-1', status: 'scheduled_dispatching', updatedAt: { lt: expect.any(Date) } }),
data: { status: 'scheduled_recovering' },
});
expect(billing.freeze).not.toHaveBeenCalled();
});
it('keeps a zero-fee task recoverable when queue enqueue fails after preparation', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-free', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
}]);
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-free', amountCents: 0, billingUnits: 1 }]);
service.enqueueBatchTask = jest.fn().mockRejectedValue(new Error('Redis unavailable'));
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
dispatched: 0,
results: [{ taskId: 'task-free', status: 'retrying', reason: 'Redis unavailable' }],
});
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
where: { id: 'task-free' },
data: { status: 'scheduled_dispatching', rejectReason: '调度将在超时后恢复:Redis unavailable' },
});
});
it('automatically scans and dispatches due scheduled tasks after application startup', async () => {
jest.useFakeTimers();
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
const { service } = createService();
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(1_000);
expect(dispatch).toHaveBeenCalledTimes(1);
await service.onModuleDestroy();
} finally {
if (previousReceiptEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousReceiptEnabled;
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
jest.useRealTimers();
}
});
it('cancels scheduled tasks before dispatch', async () => {
@@ -1155,7 +1338,7 @@ describe('SendChainService', () => {
}));
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: {
status: { in: ['pending', 'requeued'] },
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }],
},
data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }),
@@ -1734,13 +1917,16 @@ describe('SendChainService', () => {
operatorId: 'user-1',
});
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' }));
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
expect.objectContaining({ submitId: 'SUB-1' }),
'gateway:submit:requeue:dead-1:1',
);
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'pending' },
data: { status: 'requeueing' },
});
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
where: { id: 'dead-1' },
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'requeueing' },
data: expect.objectContaining({
status: 'requeued',
manualRetryCount: { increment: 1 },
@@ -1778,6 +1964,103 @@ describe('SendChainService', () => {
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
const { service, prisma } = createService();
await service.recordGatewaySubmitDeadLetter({
streamMessageId: '1710000000000-0',
failureCode: 'SUBMIT_PROCESSING_FAILED',
failureMessage: 'repeated report',
attempts: 3,
maxAttempts: 3,
});
expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith(expect.objectContaining({
update: expect.not.objectContaining({ status: expect.anything(), resolvedAt: expect.anything(), resolvedStatus: expect.anything() }),
}));
});
it('recovers a stale submit requeue with the same Redis idempotency key', async () => {
const { service, prisma } = createService();
const stale = {
...await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } }),
status: 'requeueing',
updatedAt: new Date('2026-07-21T07:00:00.000Z'),
};
prisma.gatewaySubmitDeadLetter.findMany.mockResolvedValue([stale]);
const publish = jest.spyOn(service as any, 'publishGatewaySubmitCommand').mockResolvedValue('1710000001000-0');
await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1, failed: 0 });
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(1, {
where: { id: 'dead-1', status: 'requeueing', updatedAt: stale.updatedAt },
data: { status: 'requeue_recovering' },
});
expect(publish).toHaveBeenCalledWith(
stale.commandPayload,
'gateway:submit:requeue:dead-1:1',
);
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(2, {
where: { id: 'dead-1', status: 'requeue_recovering' },
data: expect.objectContaining({
status: 'requeued',
manualRetryCount: { increment: 1 },
lastRetryStreamId: '1710000001000-0',
}),
});
});
it('atomically claims a downstream manual requeue so concurrent requests only call Gateway once', async () => {
const { service, prisma } = createService();
service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: true, sequenceId: '11', messageId: '22' });
prisma.cmppDownstreamDelivery.updateMany
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 0 });
const results = await Promise.allSettled([
service.requeueDownstreamDelivery('delivery-1'),
service.requeueDownstreamDelivery('delivery-1'),
]);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
expect(service['postGatewayControl']).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
where: {
id: 'delivery-1',
status: 'failed',
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
},
data: expect.objectContaining({
status: 'manual_requeueing',
manualRetryCount: { increment: 1 },
}),
});
});
it('recovers a stale downstream manual-requeue claim into the Gateway pending path', async () => {
const { service, prisma } = createService();
const updatedAt = new Date('2026-07-21T07:00:00.000Z');
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1', updatedAt }]);
await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1 });
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({
where: { status: 'manual_requeueing', updatedAt: { lt: expect.any(Date) } },
select: { id: true, updatedAt: true },
orderBy: { updatedAt: 'asc' },
take: 500,
});
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({
where: { id: 'delivery-1', status: 'manual_requeueing', updatedAt },
data: {
status: 'pending',
nextRetryAt: null,
lastError: '人工重投进程中断,已恢复为待投递',
},
});
});
it('records gateway downstream recovery statuses', async () => {
const { service, prisma } = createService();
@@ -2053,6 +2336,7 @@ describe('SendChainService', () => {
status: 'failed',
retryCount: 3,
manualRetryCount: 1,
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
lastError: 'downstream client is not connected',
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
application: { cmppAccount: '100001' },
@@ -2086,9 +2370,14 @@ describe('SendChainService', () => {
resourceId: 'delivery-1',
}),
});
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
id: 'delivery-1',
status: 'failed',
updatedAt: new Date('2026-07-21T08:00:00.000Z'),
}),
data: expect.objectContaining({
status: 'pending',
status: 'manual_requeueing',
retryCount: 0,
manualRetryCount: { increment: 1 },
lastRetriedAt: expect.any(Date),
@@ -2122,7 +2411,7 @@ describe('SendChainService', () => {
});
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled();
expect(service['postGatewayControl']).not.toHaveBeenCalled();
});
@@ -2231,11 +2520,13 @@ describe('SendChainService', () => {
it('starts the automatic receipt-timeout scan after application startup', async () => {
jest.useFakeTimers();
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const { service } = createService();
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(60_000);
expect(scan).toHaveBeenCalledWith({});
@@ -2244,6 +2535,8 @@ describe('SendChainService', () => {
} finally {
if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
jest.useRealTimers();
}
});