fix: reassemble inbound CMPP long messages

This commit is contained in:
hectorzhao
2026-07-23 21:04:35 +08:00
parent f186aee00b
commit b29576fcd1
9 changed files with 1102 additions and 22 deletions
+361 -2
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service';
@@ -58,7 +59,7 @@ function createPrismaMock() {
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
},
};
return {
const prisma = {
tenant: {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
},
@@ -160,6 +161,16 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue(null),
},
cmppInboundLongMessage: {
create: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
cmppInboundLongMessageSegment: {
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
},
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
findMany: jest.fn().mockImplementation(({ where }) => Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId })))),
@@ -301,8 +312,13 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([]),
},
$queryRaw: jest.fn().mockResolvedValue([{ dailyLimit: 100000, usedCount: 2 }]),
$transaction: jest.fn((operations) => Promise.all(operations)),
$executeRaw: jest.fn().mockResolvedValue(1),
$transaction: jest.fn(),
};
prisma.$transaction.mockImplementation((operations: any) => typeof operations === 'function'
? operations(prisma)
: Promise.all(operations));
return prisma;
}
function createService(prisma = createPrismaMock()) {
@@ -850,6 +866,349 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
});
it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
const segments: Array<Record<string, any>> = [];
const group = {
id: 'long-group-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupKey: 'group-key',
account: '100001',
srcId: null,
phoneNumbers: ['13800000001'],
concatReference: 16,
segmentTotal: 2,
msgFmt: 8,
messageId: 'MSG-LONG-1',
status: 'collecting',
response: null,
expiresAt: new Date(Date.now() + 300_000),
createdAt: new Date(),
segments,
};
prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve(
segments.length ? { ...group, segments: [...segments] } : null,
));
prisma.cmppInboundLongMessage.create.mockResolvedValue(group);
prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => {
const segment = { id: `segment-${data.segmentIndex}`, ...data };
segments.push(segment);
return Promise.resolve(segment);
});
prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve(
[...segments].sort((a, b) => a.segmentIndex - b.segmentIndex),
));
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
Object.assign(group, data);
return Promise.resolve({ ...group });
});
const first = await service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: '【签名】第一片',
sequenceId: 101,
remoteIp: '127.0.0.1',
longMessage: { reference: 16, total: 2, index: 1, format: 8 },
});
expect(first).toEqual(expect.objectContaining({
accepted: true,
fragmentPending: true,
messageId: 'MSG-LONG-1',
receivedSegments: 1,
}));
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
const second = await service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: '第二片正文',
sequenceId: 102,
remoteIp: '127.0.0.1',
longMessage: { reference: 16, total: 2, index: 2, format: 8 },
});
expect(second).toEqual(expect.objectContaining({
accepted: true,
messageId: 'MSG-LONG-1',
messageRecordId: 'record-1',
}));
expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(1);
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ content: '【签名】第一片第二片正文', phoneTotal: 1 }),
});
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
content: '【签名】第一片第二片正文',
cmppSubmitSequenceId: '101',
cmppSubmitGroupMessageId: 'MSG-LONG-1',
}),
});
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({
content: '【签名】第一片第二片正文',
phoneCount: 1,
}));
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
where: { id: 'long-group-1' },
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
});
});
it('accepts out-of-order and duplicate CMPP long-message fragments but rejects conflicting duplicates', async () => {
const { service, prisma } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
const segments: Array<Record<string, any>> = [];
const group = {
id: 'long-group-2',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupKey: 'group-key-2',
account: '100001',
srcId: null,
phoneNumbers: ['13800000001'],
concatReference: 17,
segmentTotal: 2,
msgFmt: 8,
messageId: 'MSG-LONG-2',
status: 'collecting',
response: null,
expiresAt: new Date(Date.now() + 300_000),
createdAt: new Date(),
segments,
};
prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve(
segments.length ? { ...group, segments: [...segments] } : null,
));
prisma.cmppInboundLongMessage.create.mockResolvedValue(group);
prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => {
const segment = { id: `segment-${data.segmentIndex}`, ...data };
segments.push(segment);
return Promise.resolve(segment);
});
prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve(
[...segments].sort((a, b) => a.segmentIndex - b.segmentIndex),
));
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
Object.assign(group, data);
return Promise.resolve({ ...group });
});
const secondFragment = {
account: '100001',
phoneNumber: '13800000001',
content: '第二片正文',
sequenceId: 202,
remoteIp: '127.0.0.1',
longMessage: { reference: 17, total: 2, index: 2, format: 8 },
};
await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({
fragmentPending: true,
receivedSegments: 1,
}));
await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({
fragmentPending: true,
receivedSegments: 1,
}));
expect(prisma.cmppInboundLongMessageSegment.create).toHaveBeenCalledTimes(1);
await expect(service.submitInboundMessage({
...secondFragment,
content: '冲突的第二片',
})).rejects.toThrow('fragment 2 conflicts');
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: '【签名】第一片',
sequenceId: 201,
remoteIp: '127.0.0.1',
longMessage: { reference: 17, total: 2, index: 1, format: 8 },
})).resolves.toEqual(expect.objectContaining({
accepted: true,
messageId: 'MSG-LONG-2',
messageRecordId: 'record-1',
}));
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
content: '【签名】第一片第二片正文',
cmppSubmitSequenceId: '201',
}),
});
});
it('resumes a persistently complete CMPP long message after processing is interrupted by a restart', async () => {
const { service, prisma } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
const staleAt = new Date(Date.now() - 60_000);
const group = {
id: 'long-group-restart',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupKey: 'group-key-restart',
account: '100001',
srcId: null,
phoneNumbers: ['13800000001'],
concatReference: 18,
segmentTotal: 2,
msgFmt: 8,
messageId: 'MSG-LONG-RESTART',
status: 'processing',
response: null,
expiresAt: new Date(Date.now() + 300_000),
createdAt: staleAt,
updatedAt: staleAt,
segments: [
{
id: 'segment-restart-1',
groupId: 'long-group-restart',
segmentIndex: 1,
sequenceId: '301',
content: '【签名】第一片',
contentHash: createHash('sha256').update('【签名】第一片').digest('hex'),
},
{
id: 'segment-restart-2',
groupId: 'long-group-restart',
segmentIndex: 2,
sequenceId: '302',
content: '第二片正文',
contentHash: createHash('sha256').update('第二片正文').digest('hex'),
},
],
};
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group);
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
Object.assign(group, data, { updatedAt: new Date() });
return Promise.resolve({ ...group });
});
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: '第二片正文',
sequenceId: 302,
remoteIp: '127.0.0.1',
longMessage: { reference: 18, total: 2, index: 2, format: 8 },
})).resolves.toEqual(expect.objectContaining({
accepted: true,
messageId: 'MSG-LONG-RESTART',
messageRecordId: 'record-1',
}));
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
content: '【签名】第一片第二片正文',
cmppSubmitSequenceId: '301',
cmppSubmitGroupMessageId: 'MSG-LONG-RESTART',
}),
});
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
where: { id: 'long-group-restart' },
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
});
});
it('recovers the persisted SMS result after a restart without creating a duplicate main record', async () => {
const { service, prisma } = createService();
const staleAt = new Date(Date.now() - 60_000);
const group = {
id: 'long-group-after-record',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupKey: 'group-key-after-record',
account: '100001',
srcId: null,
phoneNumbers: ['13800000001'],
concatReference: 19,
segmentTotal: 2,
msgFmt: 8,
messageId: 'MSG-LONG-AFTER-RECORD',
status: 'processing',
response: null,
expiresAt: new Date(Date.now() + 300_000),
createdAt: staleAt,
updatedAt: staleAt,
segments: [
{
id: 'segment-after-record-1',
groupId: 'long-group-after-record',
segmentIndex: 1,
sequenceId: '401',
content: '【签名】第一片',
contentHash: createHash('sha256').update('【签名】第一片').digest('hex'),
},
{
id: 'segment-after-record-2',
groupId: 'long-group-after-record',
segmentIndex: 2,
sequenceId: '402',
content: '第二片正文',
contentHash: createHash('sha256').update('第二片正文').digest('hex'),
},
],
};
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group);
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
Object.assign(group, data, { updatedAt: new Date() });
return Promise.resolve({ ...group });
});
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'persisted-record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
batchTaskId: 'persisted-task-1',
messageId: 'MSG-LONG-AFTER-RECORD',
phoneNumber: '13800000001',
status: 'failed',
errorCode: 'SIGNATURE',
}]);
await expect(service.submitInboundMessage({
account: '100001',
phoneNumber: '13800000001',
content: '第二片正文',
sequenceId: 402,
remoteIp: '127.0.0.1',
longMessage: { reference: 19, total: 2, index: 2, format: 8 },
})).resolves.toEqual(expect.objectContaining({
accepted: true,
messageId: 'MSG-LONG-AFTER-RECORD',
messageRecordId: 'persisted-record-1',
taskId: 'persisted-task-1',
}));
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
where: { id: 'long-group-after-record' },
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
});
});
it('expires incomplete or interrupted CMPP long-message groups without creating SMS records', async () => {
const { service, prisma } = createService();
const now = new Date('2026-07-23T12:00:00.000Z');
prisma.cmppInboundLongMessage.updateMany.mockResolvedValue({ count: 2 });
await expect(service.expireInboundLongMessages(now)).resolves.toEqual({ count: 2 });
expect(prisma.cmppInboundLongMessage.updateMany).toHaveBeenCalledWith({
where: {
status: { in: ['collecting', 'processing'] },
expiresAt: { lte: now },
},
data: {
status: 'expired',
completedAt: now,
},
});
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
});
it('rejects the whole CMPP Submit synchronously while keeping per-destination audit records when the daily limit is exceeded', async () => {
const { service, prisma, billing } = createService();
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);