perf: batch gateway submits and isolate callbacks

This commit is contained in:
hectorzhao
2026-08-25 11:39:59 +08:00
parent c6f11014d6
commit 761c123b65
29 changed files with 1912 additions and 193 deletions
+160 -27
View File
@@ -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 () => {