perf(cmpp): add durable inbound fast path

This commit is contained in:
hectorzhao
2026-08-20 17:34:45 +08:00
parent 26ef67fb6a
commit 0b63bcd74e
29 changed files with 1136 additions and 87 deletions
+419 -54
View File
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { hostname } from 'node:os';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
@@ -17,11 +18,33 @@ import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDU
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
import { detectDrainageContent } from './drainage-content-detection';
type InboundWorkflowPayload = {
data: GatewayInboundSubmitDto;
phoneNumbers: string[];
submitGroupMessageId: string;
messageIds: string[];
};
type ClaimedInboundWorkflow = {
id: string;
requestKey: string;
applicationId: string;
attempts: number;
payload: Prisma.JsonValue;
};
/**
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
*/
export class SendInboundEntryService {
private readonly logger = new Logger('SendChainService');
private readonly inboundWorkflowWorkerId = `${hostname()}:${process.pid}:${randomUUID()}`;
private readonly inboundWorkflowTasks = new Set<Promise<void>>();
private inboundWorkflowTimer?: ReturnType<typeof setTimeout>;
private inboundWorkflowInFlight = 0;
private inboundWorkflowPumping = false;
private inboundWorkflowStopping = false;
private inboundWorkflowMetricsUpdatedAt = 0;
constructor(
private readonly prisma: PrismaService,
@@ -201,6 +224,26 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
};
}
try {
if (this.inboundFastPathEnabled()) {
const response = await this.measureInboundStage('inbox_persist', () => (
this.persistInboundWorkflow({
...data,
content: collection.content,
sequenceId: collection.sequenceId,
registeredDelivery: collection.registeredDelivery ? 1 : 0,
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;
}
const response = await this.measureInboundStage('complete_submit', async () => (
await this.facade.recoverCompletedInboundLongMessageResponse(
collection.messageId,
@@ -233,12 +276,90 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
throw error;
}
}
if (this.inboundFastPathEnabled()) {
return this.measureInboundStage(
'inbox_persist',
() => this.persistInboundWorkflow(data, phoneNumbers, application),
);
}
return this.measureInboundStage(
'complete_submit',
() => this.facade.submitCompleteInboundMessage(data, phoneNumbers, application),
);
}
private inboundFastPathEnabled() {
return process.env.CMPP_INBOUND_FAST_PATH_ENABLED === 'true';
}
private async persistInboundWorkflow(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
requestedGroupMessageId?: string,
) {
const requestKey = data.requestId?.trim();
if (!requestKey || requestKey.length > 160) {
throw new BadRequestException('CMPP inbound requestId is required for fast-path idempotency');
}
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 submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`;
const messageIds = phoneNumbers.map((_, index) => index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`);
const payload: InboundWorkflowPayload = {
data: JSON.parse(JSON.stringify(data)) as GatewayInboundSubmitDto,
phoneNumbers,
submitGroupMessageId,
messageIds,
};
const payloadJson = JSON.parse(JSON.stringify(payload)) as Prisma.InputJsonValue;
const payloadHash = createHash('sha256').update(JSON.stringify({
data: payload.data,
phoneNumbers,
requestedGroupMessageId: requestedGroupMessageId ?? null,
})).digest('hex');
const response = {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
taskId: '',
messageId: submitGroupMessageId,
messageRecordId: '',
status: 'accepted_pending',
phoneCount: phoneNumbers.length,
messages: phoneNumbers.map((phoneNumber, index) => ({
phoneNumber,
messageId: messageIds[index],
messageRecordId: '',
taskId: '',
status: 'accepted_pending',
})),
};
try {
await this.prisma.cmppInboundSubmissionInbox.create({
data: {
requestKey,
payloadHash,
tenantId: application.tenantId,
applicationId: application.id,
queuePriority: normalizeQueuePriority(application.queuePriority),
payload: payloadJson,
response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue,
},
});
return response;
} catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
const existing = await this.prisma.cmppInboundSubmissionInbox.findUnique({ where: { requestKey } });
if (!existing || existing.payloadHash !== payloadHash) {
throw new BadRequestException('CMPP inbound requestId conflicts with another payload');
}
return existing.response as typeof response;
}
}
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
const existing = await this.prisma.smsMessageRecord.findMany({
where: {
@@ -289,6 +410,8 @@ async submitCompleteInboundMessage(
phoneNumbers: string[],
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
requestedGroupMessageId?: string,
requestedMessageIds?: string[],
workflowKey?: string,
) {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
@@ -309,6 +432,7 @@ async submitCompleteInboundMessage(
phoneNumber: true,
status: true,
errorCode: true,
batchTask: { select: { status: true } },
},
})
: [];
@@ -318,7 +442,11 @@ async submitCompleteInboundMessage(
!persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber)
)).length;
const dailyQuota = missingPhoneCount > 0
? await this.facade.tryReserveDailySendQuota(application.id, missingPhoneCount)
? await this.facade.tryReserveDailySendQuota(
application.id,
missingPhoneCount,
workflowKey ? `${workflowKey}:daily-quota` : undefined,
)
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
return { persistedByPhone, phoneRejections, dailyQuota, missingPhoneCount };
});
@@ -336,13 +464,19 @@ async submitCompleteInboundMessage(
persisted: persistedByPhone.get(phoneNumber),
receiptRejection: phoneRejections.get(phoneNumber),
messageId: persistedByPhone.get(phoneNumber)?.messageId
?? requestedMessageIds?.[index]
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
workflowItemKey: workflowKey ? `${workflowKey}:message:${index}` : undefined,
}));
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) => submission.persisted
&& !(workflowKey && (
submission.persisted.status === 'validating'
|| (submission.persisted.status === 'queued' && submission.persisted.batchTask?.status !== 'queued')
))
? Promise.resolve({
accepted: submission.persisted.errorCode !== 'DAILY_LIMIT',
tenantId: submission.persisted.tenantId ?? application.tenantId,
@@ -356,7 +490,7 @@ async submitCompleteInboundMessage(
...data,
phoneNumber: submission.phoneNumber,
phoneNumbers: undefined,
}, submission.messageId, submitGroupMessageId, application, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection))));
}, submission.messageId, submitGroupMessageId, application, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection, submission.workflowItemKey))));
}
const first = results[0];
return {
@@ -553,6 +687,7 @@ async submitInboundSingleMessage(
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
synchronousRejection?: { code: string; reason: string },
receiptRejection?: { code: string; reason: string },
workflowItemKey?: string,
) {
// 入口已按账号取得并校验同一个应用快照;复用它可避免每个目标号码再次查询应用、企业和IP白名单。
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
@@ -573,60 +708,101 @@ async submitInboundSingleMessage(
phoneCount: 1,
unitPrice,
});
const task = await this.measureInboundStage('task_persist', () => this.prisma.smsBatchTask.create({
data: {
tenantId: application.tenantId,
applicationId: application.id,
templateId: template?.id,
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceType: 'cmpp',
content: data.content,
phoneTotal: 1,
status: synchronousRejection ? 'rejected' : 'validating',
auditStatus: synchronousRejection ? 'rejected' : undefined,
rejectReason: synchronousRejection?.reason,
progressTotal: 1,
},
}));
await this.measureInboundStage('api_request_persist', () => this.prisma.smsApiRequest.create({
data: {
tenantId: application.tenantId,
batchTaskId: task.id,
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceIp: data.remoteIp,
userAgent: 'cmpp-gateway',
payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account },
status: synchronousRejection ? 'rejected' : 'accepted',
},
}));
const drainageDetection = await this.measureInboundStage(
'content_detection',
() => detectDrainageContent(this.prisma, data.content),
);
const message = await this.measureInboundStage('message_persist', () => this.prisma.smsMessageRecord.create({
data: {
tenantId: application.tenantId,
batchTaskId: task.id,
applicationId: application.id,
templateId: template?.id,
messageId,
phoneNumber: data.phoneNumber,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: receiptRejection ? 0 : billing.unitPrice,
amountCents: receiptRejection ? 0 : billing.amountCents,
const workflowDigest = workflowItemKey
? createHash('sha256').update(workflowItemKey).digest('hex').slice(0, 32)
: undefined;
let recoveredExisting = false;
let persisted;
try {
persisted = await this.measureInboundStage('message_persist', () => this.prisma.$transaction(async (tx) => {
const task = await tx.smsBatchTask.create({
data: {
tenantId: application.tenantId,
applicationId: application.id,
templateId: template?.id,
taskNo: workflowDigest ? `BT-IN-${workflowDigest}` : `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceType: 'cmpp',
content: data.content,
phoneTotal: 1,
status: synchronousRejection ? 'rejected' : 'validating',
auditStatus: synchronousRejection ? 'rejected' : undefined,
rejectReason: synchronousRejection?.reason,
progressTotal: 1,
},
});
await tx.smsApiRequest.create({
data: {
tenantId: application.tenantId,
batchTaskId: task.id,
requestId: workflowDigest ? `REQ-IN-${workflowDigest}` : `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceIp: data.remoteIp,
userAgent: 'cmpp-gateway',
payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account },
status: synchronousRejection ? 'rejected' : 'accepted',
},
});
const message = await tx.smsMessageRecord.create({
data: {
tenantId: application.tenantId,
batchTaskId: task.id,
applicationId: application.id,
templateId: template?.id,
messageId,
phoneNumber: data.phoneNumber,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: receiptRejection ? 0 : billing.unitPrice,
amountCents: receiptRejection ? 0 : billing.amountCents,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
cmppSubmitGroupMessageId: submitGroupMessageId,
cmppRegisteredDelivery: data.registeredDelivery !== 0,
clientSrcId,
applicationExtension: application.cmppApplicationExtension,
status: synchronousRejection ? 'rejected' : 'validating',
errorCode: synchronousRejection?.code,
errorMessage: synchronousRejection?.reason,
},
});
return { task, message };
}));
} catch (error) {
if (!workflowItemKey || !(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
const existing = await this.prisma.smsMessageRecord.findUnique({
where: { messageId },
include: { batchTask: true },
});
if (!existing?.batchTask || existing.cmppSubmitGroupMessageId !== submitGroupMessageId
|| existing.phoneNumber !== data.phoneNumber || existing.applicationId !== application.id) {
throw error;
}
recoveredExisting = true;
persisted = { task: existing.batchTask, message: existing };
}
const { task, message } = persisted;
if (recoveredExisting && message.status === 'queued' && task.status !== 'queued') {
await this.facade.enqueueBatchTask(task.id, {
messageRecordId: message.id,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
cmppSubmitGroupMessageId: submitGroupMessageId,
cmppRegisteredDelivery: data.registeredDelivery !== 0,
clientSrcId,
applicationExtension: application.cmppApplicationExtension,
status: synchronousRejection ? 'rejected' : 'validating',
errorCode: synchronousRejection?.code,
errorMessage: synchronousRejection?.reason,
},
}));
});
}
if (recoveredExisting && message.status !== 'validating') {
return {
accepted: message.status !== 'rejected' && message.status !== 'failed',
tenantId: application.tenantId,
applicationId: application.id,
taskId: task.id,
messageId: message.messageId,
messageRecordId: message.id,
status: message.status,
};
}
if (synchronousRejection) {
return {
@@ -658,7 +834,7 @@ async submitInboundSingleMessage(
variables: options.templateId ? templateVariables : undefined,
phoneNumber: data.phoneNumber,
sourceType: 'cmpp',
});
}, workflowItemKey ? `${workflowItemKey}:frequency` : undefined);
return { drainageInfoId: drainage?.id, risk: evaluatedRisk };
});
if (risk.status === 'rejected') {
@@ -693,6 +869,7 @@ async submitInboundSingleMessage(
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 入站短信冻结',
idempotencyKey: workflowItemKey ? `${workflowItemKey}:freeze` : undefined,
});
}
return check;
@@ -736,7 +913,7 @@ async submitInboundSingleMessage(
content: data.content,
phoneNumber: data.phoneNumber,
sourceType: 'cmpp',
});
}, workflowItemKey ? `${workflowItemKey}:frequency` : undefined);
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
} else {
@@ -754,6 +931,7 @@ async submitInboundSingleMessage(
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 模板不匹配待审核短信冻结',
idempotencyKey: workflowItemKey ? `${workflowItemKey}:freeze` : undefined,
});
}
const reviewTask = risk.status === 'pending_review' && risk.task
@@ -813,7 +991,7 @@ async evaluateRiskWithPhoneFrequency(input: {
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}) {
}, reservationKey?: string) {
const risk = await this.riskReview.evaluateTask({
tenantId: input.tenantId,
applicationId: input.applicationId,
@@ -829,6 +1007,8 @@ async evaluateRiskWithPhoneFrequency(input: {
input.applicationId,
[input.phoneNumber],
input.sourceType,
new Date(),
reservationKey,
);
const rejection = frequencyRejections.get(input.phoneNumber);
return rejection
@@ -836,6 +1016,170 @@ async evaluateRiskWithPhoneFrequency(input: {
: risk;
}
startInboundWorkflowWorker() {
if (this.inboundWorkflowTimer || this.inboundWorkflowPumping || this.inboundWorkflowTasks.size > 0) {
return { status: 'already_started' };
}
this.inboundWorkflowStopping = false;
this.scheduleInboundWorkflowPump(0);
return { status: 'started' };
}
async stopInboundWorkflowWorker() {
this.inboundWorkflowStopping = true;
if (this.inboundWorkflowTimer) clearTimeout(this.inboundWorkflowTimer);
this.inboundWorkflowTimer = undefined;
await Promise.allSettled([...this.inboundWorkflowTasks]);
}
private scheduleInboundWorkflowPump(delayMs: number) {
if (this.inboundWorkflowStopping || this.inboundWorkflowTimer) return;
this.inboundWorkflowTimer = setTimeout(() => {
this.inboundWorkflowTimer = undefined;
void this.pumpInboundWorkflow();
}, delayMs);
this.inboundWorkflowTimer.unref?.();
}
private async pumpInboundWorkflow() {
if (this.inboundWorkflowStopping || this.inboundWorkflowPumping) return;
const concurrency = positiveInteger(process.env.API_INBOUND_WORKFLOW_CONCURRENCY, 32);
this.metrics?.setInboundWorkflowSlots(concurrency, this.inboundWorkflowInFlight);
const available = Math.max(0, concurrency - this.inboundWorkflowInFlight);
if (available === 0) return;
this.inboundWorkflowPumping = true;
try {
await this.refreshInboundWorkflowMetrics();
const claimed = await this.claimInboundWorkflows(available);
for (const item of claimed) {
this.inboundWorkflowInFlight += 1;
this.metrics?.setInboundWorkflowSlots(concurrency, this.inboundWorkflowInFlight);
const task = this.processClaimedInboundWorkflow(item)
.catch((error) => this.logger.error(`CMPP inbound workflow ${item.id} failed to settle: ${String(error)}`))
.finally(() => {
this.inboundWorkflowInFlight = Math.max(0, this.inboundWorkflowInFlight - 1);
this.metrics?.setInboundWorkflowSlots(concurrency, this.inboundWorkflowInFlight);
this.inboundWorkflowTasks.delete(task);
this.scheduleInboundWorkflowPump(0);
});
this.inboundWorkflowTasks.add(task);
}
if (claimed.length === 0) {
this.scheduleInboundWorkflowPump(positiveInteger(process.env.API_INBOUND_WORKFLOW_POLL_INTERVAL_MS, 100));
}
} catch (error) {
this.logger.error(`Failed to claim CMPP inbound workflow: ${String(error)}`);
this.scheduleInboundWorkflowPump(1000);
} finally {
this.inboundWorkflowPumping = false;
if (this.inboundWorkflowInFlight < concurrency && !this.inboundWorkflowTimer) {
this.scheduleInboundWorkflowPump(0);
}
}
}
private claimInboundWorkflows(limit: number) {
const staleSeconds = positiveInteger(process.env.API_INBOUND_WORKFLOW_STALE_SECONDS, 300);
return this.prisma.$queryRaw<ClaimedInboundWorkflow[]>(Prisma.sql`
WITH candidates AS (
SELECT id
FROM "CmppInboundSubmissionInbox"
WHERE (
status = 'pending'
AND "nextAttemptAt" <= NOW()
) OR (
status = 'processing'
AND "lockedAt" <= NOW() - make_interval(secs => ${staleSeconds})
)
-- Priority applications enter the same durable Inbox, but are claimed first while
-- preserving FIFO within each class. This keeps the V5 priority contract effective
-- before BullMQ without adding another non-durable queue.
ORDER BY CASE WHEN "queuePriority" = 'priority' THEN 0 ELSE 1 END, "createdAt" ASC
LIMIT ${limit}
FOR UPDATE SKIP LOCKED
)
UPDATE "CmppInboundSubmissionInbox" AS inbox
SET status = 'processing',
attempts = inbox.attempts + 1,
"lockedAt" = NOW(),
"lockedBy" = ${this.inboundWorkflowWorkerId},
"updatedAt" = NOW()
FROM candidates
WHERE inbox.id = candidates.id
RETURNING inbox.id, inbox."requestKey", inbox."applicationId", inbox.attempts, inbox.payload
`);
}
private async processClaimedInboundWorkflow(item: ClaimedInboundWorkflow) {
try {
const payload = parseInboundWorkflowPayload(item.payload);
const application = await this.facade.findInboundApplication(payload.data.account);
if (!application || application.id !== item.applicationId) {
throw new Error('CMPP inbound application no longer matches persisted workflow');
}
const result = await this.facade.submitCompleteInboundMessage(
payload.data,
payload.phoneNumbers,
application,
payload.submitGroupMessageId,
payload.messageIds,
item.requestKey,
);
const settled = await this.prisma.cmppInboundSubmissionInbox.updateMany({
where: { id: item.id, status: 'processing', lockedBy: this.inboundWorkflowWorkerId },
data: {
status: 'completed',
result: JSON.parse(JSON.stringify(result)) as Prisma.InputJsonValue,
completedAt: new Date(),
lockedAt: null,
lockedBy: null,
lastError: null,
},
});
if (settled.count !== 1) throw new Error('CMPP inbound workflow lease was lost before completion');
this.metrics?.recordInboundWorkflowResult('completed');
} catch (error) {
const reason = (error instanceof Error ? error.message : String(error)).slice(0, 2000);
const delayMs = Math.min(60_000, 250 * 2 ** Math.min(8, Math.max(0, item.attempts - 1)));
const released = await this.prisma.cmppInboundSubmissionInbox.updateMany({
where: { id: item.id, status: 'processing', lockedBy: this.inboundWorkflowWorkerId },
data: {
status: 'pending',
nextAttemptAt: new Date(Date.now() + delayMs),
lockedAt: null,
lockedBy: null,
lastError: reason,
},
});
if (released.count === 1) {
this.metrics?.recordInboundWorkflowResult('retry');
this.logger.warn(`CMPP inbound workflow ${item.id} will retry after attempt ${item.attempts}: ${reason}`);
return;
}
throw error;
}
}
private async refreshInboundWorkflowMetrics() {
const now = Date.now();
if (now - this.inboundWorkflowMetricsUpdatedAt < 5_000) return;
this.inboundWorkflowMetricsUpdatedAt = now;
const [pending, processing, oldest] = await Promise.all([
this.prisma.cmppInboundSubmissionInbox.count({ where: { status: 'pending' } }),
this.prisma.cmppInboundSubmissionInbox.count({ where: { status: 'processing' } }),
this.prisma.cmppInboundSubmissionInbox.findFirst({
where: { status: 'pending' },
select: { createdAt: true },
orderBy: { createdAt: 'asc' },
}),
]);
this.metrics?.setInboundWorkflowState(
pending,
processing,
oldest ? Math.max(0, (now - oldest.createdAt.getTime()) / 1000) : 0,
);
}
findInboundApplication(account: string) {
return this.prisma.smsApplication.findFirst({
where: { cmppAccount: account },
@@ -917,3 +1261,24 @@ async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, s
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
}
}
function parseInboundWorkflowPayload(value: Prisma.JsonValue): InboundWorkflowPayload {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('CMPP inbound workflow payload is invalid');
const data = value.data;
const phoneNumbers = value.phoneNumbers;
const submitGroupMessageId = value.submitGroupMessageId;
const messageIds = value.messageIds;
if (!data || typeof data !== 'object' || Array.isArray(data)
|| !Array.isArray(phoneNumbers) || phoneNumbers.some((item) => typeof item !== 'string')
|| typeof submitGroupMessageId !== 'string'
|| !Array.isArray(messageIds) || messageIds.some((item) => typeof item !== 'string')
|| phoneNumbers.length === 0 || phoneNumbers.length !== messageIds.length) {
throw new Error('CMPP inbound workflow payload fields are invalid');
}
return {
data: data as unknown as GatewayInboundSubmitDto,
phoneNumbers: phoneNumbers as string[],
submitGroupMessageId,
messageIds: messageIds as string[],
};
}