feat: strengthen risk controls and review workflows

This commit is contained in:
hectorzhao
2026-07-26 13:08:12 +08:00
parent b461532075
commit 2ce682c3fc
34 changed files with 2167 additions and 390 deletions
+147 -17
View File
@@ -402,6 +402,73 @@ describe('SendChainService', () => {
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('marks invalid and blacklisted client numbers as submit failures while sending valid numbers', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002', reason: '平台拒收' }]);
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
billingUnitsPerMessage: 1,
totalBillingUnits: 1,
unitPrice: 3,
amountCents: 3,
});
await service.createBatchTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
templateId: 'tpl-1',
content: 'hello',
phones: ['13800000001', '23800000002', '13800000002'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
expect.objectContaining({
phoneNumber: '23800000002',
status: 'submit_failed',
submitStatus: 'rejected',
errorCode: 'INVALID_PHONE',
amountCents: 0,
}),
expect.objectContaining({
phoneNumber: '13800000002',
status: 'submit_failed',
submitStatus: 'rejected',
errorCode: 'GLOBAL_BLACKLIST',
amountCents: 0,
}),
]),
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('persists the review task id on every message waiting for manual review', async () => {
const { service, prisma, riskReview } = createService();
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
status: 'pending_review',
reason: '命中人工审核规则',
task: { id: 'review-task-1' },
});
await service.createBatchTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
templateId: 'tpl-1',
content: 'hello',
phones: ['13800000001'],
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
phoneNumber: '13800000001',
status: 'pending_review',
reviewTaskId: 'review-task-1',
})],
});
});
it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => {
const { service, prisma, billing } = createService();
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
@@ -807,7 +874,7 @@ describe('SendChainService', () => {
})).rejects.toThrow('CMPP interface is disabled for this application');
});
it('does not create a CMPP downstream delivery when the application interface was disabled after bind', async () => {
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
@@ -835,12 +902,10 @@ describe('SendChainService', () => {
phoneNumber: '13800000001',
content: 'hello',
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
})).rejects.toThrow('CMPP account is disabled for new submissions');
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'validating' }) });
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }),
});
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
@@ -852,6 +917,7 @@ describe('SendChainService', () => {
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'active',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
@@ -882,7 +948,7 @@ describe('SendChainService', () => {
cmppAccount: '100001',
secretHash: 'secret-hash',
status: 'active',
interfaceEnabled: false,
interfaceEnabled: true,
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
@@ -919,8 +985,8 @@ describe('SendChainService', () => {
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => {
@@ -1292,19 +1358,37 @@ describe('SendChainService', () => {
expect(billing.freeze).not.toHaveBeenCalled();
});
it('rejects a multi-destination CMPP Submit before persistence when any destination is invalid', async () => {
it('returns a failure receipt for an invalid destination while other CMPP destinations continue', async () => {
const { service, prisma } = createService();
let messageIndex = 0;
prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({
id: `record-${++messageIndex}`,
...data,
}));
await expect(service.submitInboundMessage({
account: '100001',
phoneNumbers: ['13800000001', 'invalid'],
content: 'hello',
remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP submit phone number is invalid');
})).resolves.toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 }));
expect(prisma.smsApplication.findFirst).not.toHaveBeenCalled();
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
where: { id: 'record-2' },
data: expect.objectContaining({
status: 'failed',
receiptStatus: 'undelivered',
errorCode: 'INVALID_PHONE',
}),
});
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
messageRecordId: 'record-2',
receiptStatus: 'undelivered',
errorCode: 'INVALID_PHONE',
}),
});
});
it('accepts only the filled client Src_Id and snapshots the real application extension', async () => {
@@ -1571,7 +1655,8 @@ describe('SendChainService', () => {
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsSendTask.findUnique.mockResolvedValue({
id: 'review-task-1',
messageRecords: [{
});
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
@@ -1581,8 +1666,7 @@ describe('SendChainService', () => {
amountCents: 3,
billingUnits: 1,
batchTask: { id: 'task-1', sourceType: 'cmpp' },
}],
});
}]);
await expect(service.handleReviewDecision('review-task-1', 'approved', '审核通过')).resolves.toEqual({
reviewTaskId: 'review-task-1',
@@ -2697,6 +2781,52 @@ describe('SendChainService', () => {
}));
});
it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
cmppEnterpriseCode: 'SP0001',
secretHash: 'secret-hash',
status: 'disabling',
interfaceEnabled: true,
cmppMaxConnections: 2,
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: 'hello',
remoteIp: '127.0.0.1',
})).rejects.toThrow('disabled for new submissions');
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
});
it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'deleted',
interfaceEnabled: true,
tenant: { id: 'tenant-1', status: 'deleted', certificationStatus: 'approved' },
});
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([]);
await expect(service.listPendingDownstreamDeliveries({ account: '100001', limit: 100 }))
.resolves.toEqual([]);
});
it('persists each upstream SubmitResp segment before the aggregate result arrives', async () => {
const { service, prisma } = createService();