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 }]);
+360 -9
View File
@@ -48,6 +48,12 @@ export interface GatewayInboundSubmitDto {
destId?: string;
sequenceId?: number;
remoteIp?: string;
longMessage?: {
reference: number;
total: number;
index: number;
format: number;
};
}
interface GatewayInboundSingleSubmitResult {
@@ -260,6 +266,9 @@ const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000;
const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000;
const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000;
const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000;
const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30;
const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
priority: 1,
@@ -279,6 +288,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private scheduledDispatchInitialTimer?: ReturnType<typeof setTimeout>;
private scheduledDispatchIntervalTimer?: ReturnType<typeof setInterval>;
private scheduledDispatchScanRunning = false;
private inboundLongMessageInitialTimer?: ReturnType<typeof setTimeout>;
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
constructor(
private readonly prisma: PrismaService,
@@ -312,6 +323,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
);
this.scheduledDispatchIntervalTimer.unref?.();
}
if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') {
this.inboundLongMessageInitialTimer = setTimeout(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
);
this.inboundLongMessageInitialTimer.unref?.();
this.inboundLongMessageIntervalTimer = setInterval(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
),
);
this.inboundLongMessageIntervalTimer.unref?.();
}
}
async onModuleDestroy() {
@@ -319,6 +349,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer);
if (this.inboundLongMessageInitialTimer) clearTimeout(this.inboundLongMessageInitialTimer);
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
@@ -2027,28 +2059,174 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
const dailyQuota = await this.tryReserveDailySendQuota(application.id, phoneNumbers.length);
if (data.longMessage) {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
validateInboundApplicationSrcId(data.srcId, application);
const collection = await this.collectInboundLongMessageFragment(data, application, phoneNumbers);
if (collection.response) {
return collection.response;
}
if (!collection.complete) {
return {
accepted: true,
messageId: collection.messageId,
status: 'fragment_pending',
fragmentPending: true,
receivedSegments: collection.receivedSegments,
segmentTotal: data.longMessage.total,
phoneCount: phoneNumbers.length,
messages: phoneNumbers.map((phoneNumber) => ({
phoneNumber,
messageId: collection.messageId,
status: 'fragment_pending',
})),
};
}
try {
const response = await this.recoverCompletedInboundLongMessageResponse(
collection.messageId,
phoneNumbers,
) ?? await this.submitCompleteInboundMessage({
...data,
content: collection.content,
sequenceId: collection.sequenceId,
longMessage: undefined,
}, phoneNumbers, application, collection.messageId);
await this.prisma.cmppInboundLongMessage.update({
where: { id: collection.groupId },
data: {
status: 'completed',
response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue,
completedAt: new Date(),
},
});
return response;
} catch (error) {
await this.prisma.cmppInboundLongMessage.update({
where: { id: collection.groupId },
data: {
status: 'rejected',
completedAt: new Date(),
},
}).catch(() => undefined);
throw error;
}
}
return this.submitCompleteInboundMessage(data, phoneNumbers, application);
}
private async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
const existing = await this.prisma.smsMessageRecord.findMany({
where: {
cmppSubmitGroupMessageId: messageId,
phoneNumber: { in: phoneNumbers },
},
select: {
id: true,
tenantId: true,
applicationId: true,
batchTaskId: true,
messageId: true,
phoneNumber: true,
status: true,
errorCode: true,
},
});
const byPhone = new Map(existing.map((item) => [item.phoneNumber, item]));
const ordered = phoneNumbers.map((phoneNumber) => byPhone.get(phoneNumber));
if (ordered.some((item) => !item)) {
return null;
}
const messages = ordered.map((item, index) => ({
phoneNumber: phoneNumbers[index],
messageId: item!.messageId,
messageRecordId: item!.id,
taskId: item!.batchTaskId ?? '',
status: item!.status,
}));
const first = ordered[0]!;
const dailyLimitRejected = ordered.every((item) => item!.errorCode === 'DAILY_LIMIT');
return {
accepted: !dailyLimitRejected,
tenantId: first.tenantId ?? '',
applicationId: first.applicationId ?? '',
taskId: first.batchTaskId ?? '',
messageId: first.messageId,
messageRecordId: first.id,
status: dailyLimitRejected ? 'rejected' : 'accepted',
result: dailyLimitRejected ? 8 : undefined,
phoneCount: messages.length,
messages,
};
}
private async submitCompleteInboundMessage(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
application: Awaited<ReturnType<SendChainService['findInboundApplication']>>,
requestedGroupMessageId?: string,
) {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
const persisted = requestedGroupMessageId
? await this.prisma.smsMessageRecord.findMany({
where: {
cmppSubmitGroupMessageId: requestedGroupMessageId,
phoneNumber: { in: phoneNumbers },
},
select: {
id: true,
tenantId: true,
applicationId: true,
batchTaskId: true,
messageId: true,
phoneNumber: true,
status: true,
errorCode: true,
},
})
: [];
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => !persistedByPhone.has(phoneNumber)).length;
const dailyQuota = missingPhoneCount > 0
? await this.tryReserveDailySendQuota(application.id, missingPhoneCount)
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
const dailyLimitRejection = dailyQuota.reserved
? undefined
: {
code: 'DAILY_LIMIT',
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${phoneNumbers.length}条超出剩余配额`,
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`,
};
const submitGroupMessageId = `MSG-${randomUUID()}`;
const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`;
const submissions = phoneNumbers.map((phoneNumber, index) => ({
phoneNumber,
messageId: index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`,
persisted: persistedByPhone.get(phoneNumber),
messageId: persistedByPhone.get(phoneNumber)?.messageId
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
}));
const results: GatewayInboundSingleSubmitResult[] = [];
const concurrency = 10;
for (let offset = 0; offset < submissions.length; offset += concurrency) {
const batch = submissions.slice(offset, offset + concurrency);
results.push(...await Promise.all(batch.map((submission) => this.submitInboundSingleMessage({
...data,
phoneNumber: submission.phoneNumber,
phoneNumbers: undefined,
}, submission.messageId, submitGroupMessageId, dailyLimitRejection))));
results.push(...await Promise.all(batch.map((submission) => submission.persisted
? Promise.resolve({
accepted: submission.persisted.errorCode !== 'DAILY_LIMIT',
tenantId: submission.persisted.tenantId ?? application.tenantId,
applicationId: submission.persisted.applicationId ?? application.id,
taskId: submission.persisted.batchTaskId ?? '',
messageId: submission.persisted.messageId,
messageRecordId: submission.persisted.id,
status: submission.persisted.status,
})
: this.submitInboundSingleMessage({
...data,
phoneNumber: submission.phoneNumber,
phoneNumbers: undefined,
}, submission.messageId, submitGroupMessageId, dailyLimitRejection))));
}
const first = results[0];
return {
@@ -2065,6 +2243,173 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
};
}
private async collectInboundLongMessageFragment(
data: GatewayInboundSubmitDto,
application: NonNullable<Awaited<ReturnType<SendChainService['findInboundApplication']>>>,
phoneNumbers: string[],
) {
const fragment = data.longMessage;
if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535
|| !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255
|| !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total
|| !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) {
throw new BadRequestException('CMPP long message fragment metadata is invalid');
}
const groupKey = createHash('sha256').update(JSON.stringify({
applicationId: application.id,
account: data.account,
srcId: data.srcId?.trim() ?? '',
phoneNumbers,
reference: fragment.reference,
total: fragment.total,
format: fragment.format,
})).digest('hex');
const contentHash = createHash('sha256').update(data.content).digest('hex');
const now = new Date();
const expiresAt = new Date(now.getTime() + positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS,
300,
) * 1000);
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`;
await tx.cmppInboundLongMessage.updateMany({
where: {
groupKey,
status: { in: ['collecting', 'processing'] },
expiresAt: { lte: now },
},
data: { status: 'expired', completedAt: now },
});
const recent = await tx.cmppInboundLongMessage.findFirst({
where: {
groupKey,
expiresAt: { gt: now },
},
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
orderBy: { createdAt: 'desc' },
});
const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index);
if (recent && ['completed', 'rejected'].includes(recent.status)
&& matchingRecentSegment?.contentHash === contentHash
&& matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) {
return {
complete: recent.status === 'completed',
groupId: recent.id,
messageId: recent.messageId,
receivedSegments: recent.segments.length,
response: recent.response as any,
content: recent.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId),
};
}
let group = recent && ['collecting', 'processing'].includes(recent.status) ? recent : null;
if (!group) {
group = await tx.cmppInboundLongMessage.create({
data: {
tenantId: application.tenantId,
applicationId: application.id,
groupKey,
account: data.account,
srcId: data.srcId?.trim() || null,
phoneNumbers,
concatReference: fragment.reference,
segmentTotal: fragment.total,
msgFmt: fragment.format,
messageId: `MSG-${randomUUID()}`,
expiresAt,
},
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
});
}
if (group.status === 'processing') {
const processingStaleMs = positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
) * 1000;
const complete = group.segments.length === fragment.total
&& group.segments.every((item, index) => item.segmentIndex === index + 1);
if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) {
await tx.cmppInboundLongMessage.update({
where: { id: group.id },
data: { status: 'processing', expiresAt },
});
return {
complete: true,
groupId: group.id,
messageId: group.messageId,
receivedSegments: group.segments.length,
response: null,
content: group.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId),
};
}
return {
complete: false,
groupId: group.id,
messageId: group.messageId,
receivedSegments: group.segments.length,
response: group.response as any,
content: '',
sequenceId: undefined,
};
}
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
if (existing && (existing.contentHash !== contentHash
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) {
throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`);
}
if (!existing) {
await tx.cmppInboundLongMessageSegment.create({
data: {
groupId: group.id,
segmentIndex: fragment.index,
sequenceId: data.sequenceId == null ? null : String(data.sequenceId),
content: data.content,
contentHash,
},
});
}
const segments = await tx.cmppInboundLongMessageSegment.findMany({
where: { groupId: group.id },
orderBy: { segmentIndex: 'asc' },
});
const complete = segments.length === fragment.total
&& segments.every((item, index) => item.segmentIndex === index + 1);
if (complete) {
await tx.cmppInboundLongMessage.update({
where: { id: group.id },
data: { status: 'processing', expiresAt },
});
}
return {
complete,
groupId: group.id,
messageId: group.messageId,
receivedSegments: segments.length,
response: null,
content: complete ? segments.map((item) => item.content).join('') : '',
sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId),
};
});
}
async expireInboundLongMessages(now = new Date()) {
return this.prisma.cmppInboundLongMessage.updateMany({
where: {
status: { in: ['collecting', 'processing'] },
expiresAt: { lte: now },
},
data: {
status: 'expired',
completedAt: now,
},
});
}
private async submitInboundSingleMessage(
data: GatewayInboundSubmitDto & { phoneNumber: string },
messageId: string,
@@ -3782,6 +4127,12 @@ function positiveInteger(value: string | undefined, fallback: number) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function parseOptionalSequenceId(value: string | null | undefined) {
if (!value) return undefined;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
}
function shanghaiDateKey(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',