perf: batch gateway submits and isolate callbacks
This commit is contained in:
@@ -158,10 +158,12 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue(channel),
|
||||
},
|
||||
cmppSubmitSession: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'session-1' }),
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'session-1' }),
|
||||
},
|
||||
smsSubmitRecord: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }),
|
||||
findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve(
|
||||
@@ -329,6 +331,11 @@ function createPrismaMock() {
|
||||
lastError: 'downstream client is not connected',
|
||||
}),
|
||||
},
|
||||
gatewaySubmitOutbox: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'outbox-1', submitId: 'SUB-1', status: 'pending' }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
cmppInboundSubmissionInbox: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'inbox-1' }),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
@@ -926,6 +933,7 @@ describe('SendChainService', () => {
|
||||
|
||||
it('coalesces concurrent batch progress refreshes and keeps a trailing refresh', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.$executeRaw.mockResolvedValue(0);
|
||||
let resolveFirst: ((value: Array<{ status: string; _count: { _all: number } }>) => void) | undefined;
|
||||
prisma.smsMessageRecord.groupBy
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
|
||||
@@ -946,6 +954,37 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a known single-message CMPP task without grouping all message states', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
await service['submission'].refreshTaskProgress('task-cmpp-1', 'submit_queued');
|
||||
|
||||
expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'task-cmpp-1', sourceType: 'cmpp', phoneTotal: 1 },
|
||||
data: {
|
||||
progressTotal: 1,
|
||||
submittedTotal: 1,
|
||||
successTotal: 0,
|
||||
failedTotal: 0,
|
||||
unknownTotal: 0,
|
||||
timeoutTotal: 0,
|
||||
status: 'sending',
|
||||
},
|
||||
});
|
||||
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes callback progress for a single-message CMPP task in one direct SQL update', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.$executeRaw.mockResolvedValue(1);
|
||||
|
||||
await service['submission'].refreshTaskProgress('task-cmpp-callback');
|
||||
|
||||
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
|
||||
expect(prisma.smsBatchTask.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not expose CMPP internal tasks through client task detail or messages', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsBatchTask.findFirst.mockResolvedValue(null);
|
||||
@@ -2038,9 +2077,7 @@ describe('SendChainService', () => {
|
||||
|
||||
it('routes queued messages to gateway submit commands', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
|
||||
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
||||
expect.objectContaining({ submitted: true, messageRecordId: 'record-1', channelId: 'channel-1' }),
|
||||
@@ -2059,8 +2096,7 @@ describe('SendChainService', () => {
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', province: '山东', status: 'submit_queued' }),
|
||||
});
|
||||
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||
'submit-command',
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
@@ -2074,9 +2110,117 @@ describe('SendChainService', () => {
|
||||
}),
|
||||
);
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ messageId: 'MSG-1' }));
|
||||
expect(prisma.cmppSubmitSession.findUnique).toHaveBeenCalledWith({
|
||||
where: { sessionNo: 'OPEN-channel-1' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.cmppSubmitSession.upsert).not.toHaveBeenCalled();
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
|
||||
});
|
||||
|
||||
it('shadow-writes the durable submit Outbox without replacing the direct stream path', async () => {
|
||||
const previousShadow = process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
||||
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
||||
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = 'true';
|
||||
delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
||||
try {
|
||||
const { service, prisma } = createService();
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await service.processSendJob({ messageRecordId: 'record-1' });
|
||||
|
||||
expect(prisma.gatewaySubmitOutbox.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
messageRecordId: 'record-1',
|
||||
channelId: 'channel-1',
|
||||
payload: expect.objectContaining({ messageType: 'SubmitCommand', messageId: 'MSG-1' }),
|
||||
}),
|
||||
});
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (previousShadow === undefined) delete process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
||||
else process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = previousShadow;
|
||||
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
||||
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
|
||||
}
|
||||
});
|
||||
|
||||
it('uses only the durable Outbox when formal publishing is enabled', async () => {
|
||||
const previousShadow = process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
||||
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
||||
process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = 'true';
|
||||
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = 'true';
|
||||
try {
|
||||
const { service, prisma } = createService();
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await service.processSendJob({ messageRecordId: 'record-1' });
|
||||
|
||||
expect(prisma.gatewaySubmitOutbox.create).toHaveBeenCalledTimes(1);
|
||||
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousShadow === undefined) delete process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED;
|
||||
else process.env.SEND_SUBMIT_OUTBOX_SHADOW_ENABLED = previousShadow;
|
||||
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
||||
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
|
||||
}
|
||||
});
|
||||
|
||||
it('plans routes once and bulk-creates Submit records and Outbox rows for a Worker batch', async () => {
|
||||
const previousPublish = process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
||||
process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = 'true';
|
||||
try {
|
||||
const { service, prisma } = createService();
|
||||
const base = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
||||
const messages = [
|
||||
{ ...base, id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3n, carrier: null, province: null, batchTask: { sourceType: 'cmpp', phoneTotal: 1 } },
|
||||
{ ...base, id: 'record-2', batchTaskId: 'task-2', messageId: 'MSG-2', phoneNumber: '13800000002', amountCents: 3n, carrier: null, province: null, batchTask: { sourceType: 'cmpp', phoneTotal: 1 } },
|
||||
];
|
||||
const channel = {
|
||||
id: 'channel-1', code: 'CMPP-A', account: 'cmpp-account', srcId: '10690000',
|
||||
rateLimitPerSecond: 100, unitPrice: 3n, status: 'active', carrier: 'mobile', sendRegion: '全国',
|
||||
gatewayHost: '127.0.0.1', gatewayPort: 17890, passwordCipher: 'secret', cmppVersion: '3.0',
|
||||
config: { serviceId: 'SMS' },
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
reportTasks: [{ signatureId: 'sig-1', carrier: 'mobile', approvalScope: 'carrier_specific' }],
|
||||
};
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue(messages);
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([{
|
||||
tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-1',
|
||||
group: {
|
||||
name: '默认通道组', carrier: 'mobile', status: 'active',
|
||||
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel }],
|
||||
},
|
||||
}]);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const gatewaySubmit = (service as any).submission.gatewaySubmit;
|
||||
const result = await gatewaySubmit.processSendJobBatch([
|
||||
{ messageRecordId: 'record-1' }, { messageRecordId: 'record-2' },
|
||||
]);
|
||||
|
||||
expect(prisma.channelRouteRule.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.phoneSegment.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsSubmitRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1' }),
|
||||
expect.objectContaining({ messageRecordId: 'record-2', channelId: 'channel-1' }),
|
||||
]),
|
||||
});
|
||||
expect(prisma.gatewaySubmitOutbox.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ messageRecordId: 'record-1', payload: expect.objectContaining({ messageId: 'MSG-1' }) }),
|
||||
expect.objectContaining({ messageRecordId: 'record-2', payload: expect.objectContaining({ messageId: 'MSG-2' }) }),
|
||||
]),
|
||||
});
|
||||
expect(result.get('record-1')).toEqual(expect.objectContaining({ submitted: true }));
|
||||
expect(result.get('record-2')).toEqual(expect.objectContaining({ submitted: true }));
|
||||
} finally {
|
||||
if (previousPublish === undefined) delete process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED;
|
||||
else process.env.SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED = previousPublish;
|
||||
}
|
||||
});
|
||||
|
||||
it('appends the real application extension to the upstream channel base number', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const queuedMessage = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
||||
@@ -2085,14 +2229,11 @@ describe('SendChainService', () => {
|
||||
applicationExtension: '0001',
|
||||
clientSrcId: '000001',
|
||||
});
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
|
||||
await service.processSendJob({ messageRecordId: 'record-1' });
|
||||
|
||||
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||
'submit-command',
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cmpp: expect.objectContaining({ srcId: '106900000001' }) }),
|
||||
);
|
||||
});
|
||||
@@ -2105,17 +2246,14 @@ describe('SendChainService', () => {
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
...baseRoute,
|
||||
group: { ...baseRoute.group, items: [
|
||||
{ ...baseRoute.group.items[0], channelId: primary.id, priority: 1, channel: primary },
|
||||
{ ...baseRoute.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup },
|
||||
] },
|
||||
});
|
||||
prisma.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: backup.id }]);
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
|
||||
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id }));
|
||||
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ reportType: 'signature' }) }));
|
||||
expect(prisma.channelSignatureReportTask.findMany).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('sig-1');
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) }));
|
||||
});
|
||||
|
||||
@@ -2127,6 +2265,7 @@ describe('SendChainService', () => {
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
phoneNumber: '13800000001',
|
||||
})).rejects.toThrow('企业应用未配置对应运营商通道组');
|
||||
|
||||
@@ -2554,9 +2693,7 @@ describe('SendChainService', () => {
|
||||
queuedAt: new Date(),
|
||||
}, '回执失败补发')).resolves.toEqual(expect.objectContaining({ channelId: backup.id }));
|
||||
|
||||
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ signatureId: 'sig-direct' }),
|
||||
}));
|
||||
expect(JSON.stringify(prisma.channelRouteRule.findFirst.mock.calls.at(-1)?.[0])).toContain('sig-direct');
|
||||
expect(submitMessageToGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signatureId: 'sig-direct' }),
|
||||
expect.objectContaining({ channel: expect.objectContaining({ id: backup.id }) }),
|
||||
@@ -2617,7 +2754,7 @@ describe('SendChainService', () => {
|
||||
|
||||
expect(results.filter((result) => result.submitted)).toHaveLength(1);
|
||||
expect(results.filter((result) => result.duplicateRetry)).toHaveLength(2);
|
||||
expect(queueAdd).toHaveBeenCalledTimes(1);
|
||||
expect(queueAdd).not.toHaveBeenCalled();
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -3378,10 +3515,9 @@ describe('SendChainService', () => {
|
||||
|
||||
it('blocks submit when signature is not approved on the selected channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
|
||||
const route = await prisma.channelRouteRule.findFirst();
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({ ...route, group: { ...route.group, items: [] } });
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
@@ -3396,14 +3532,14 @@ describe('SendChainService', () => {
|
||||
amountCents: 3,
|
||||
status: 'queued',
|
||||
queuePriority: 'normal',
|
||||
batchTask: { sourceType: 'cmpp' },
|
||||
batchTask: { sourceType: 'cmpp', phoneTotal: 1 },
|
||||
template: { signature: { id: 'sig-1', name: '签名' } },
|
||||
});
|
||||
|
||||
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
||||
expect.objectContaining({ submitted: false, status: 'failed', reason: '无已报备通过且在线的可用通道' }),
|
||||
);
|
||||
expect(gatewayAdd).not.toHaveBeenCalled();
|
||||
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ receiptStatus: 'undelivered', errorCode: 'ROUTE' }),
|
||||
});
|
||||
@@ -3411,9 +3547,7 @@ describe('SendChainService', () => {
|
||||
|
||||
it('only selects channel group items allocated to the matched carrier', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
id: 'route-1',
|
||||
tenantId: 'tenant-1',
|
||||
@@ -3475,8 +3609,7 @@ describe('SendChainService', () => {
|
||||
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
||||
expect.objectContaining({ channelId: 'channel-all' }),
|
||||
);
|
||||
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||
'submit-command',
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }),
|
||||
);
|
||||
});
|
||||
@@ -4429,7 +4562,7 @@ describe('SendChainService', () => {
|
||||
where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null },
|
||||
data: { timeoutReceiptQueuedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
|
||||
expect(prisma.$executeRaw).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues an explicit HTTP failure webhook when a receipt times out', async () => {
|
||||
|
||||
Reference in New Issue
Block a user