fix: reassemble inbound CMPP long messages
This commit is contained in:
@@ -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 }]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user