Files
lislgosms/api/src/send-chain/send-inbound-entry.service.ts
T

1811 lines
79 KiB
TypeScript

import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
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';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import { MetricsService, type CmppInboundStage } from '../metrics/metrics.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
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;
};
type PersistedInboundWorkflowRow = {
validationError: string | null;
payloadHash: string | null;
response: Prisma.JsonValue | null;
};
type InboundApplication = NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>;
type InboundTemplate = Prisma.SmsTemplateGetPayload<{ include: { signature: true } }>;
type InboundBatchCandidate = {
item: ClaimedInboundWorkflow;
payload: InboundWorkflowPayload;
application: InboundApplication;
phoneNumber: string;
template?: InboundTemplate;
signatureId: string;
templateVariables?: Record<string, unknown>;
drainageInfoId?: string;
clientSrcId: string | null;
};
type InboundWorkflowResponse = {
accepted: boolean;
tenantId: string;
applicationId: string;
taskId: string;
messageId: string;
messageRecordId: string;
status: string;
phoneCount: number;
messages: Array<{ phoneNumber: string; messageId: string; messageRecordId: string; taskId: string; status: string }>;
};
/**
* 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,
private readonly billing: BillingService,
private readonly riskReview: RiskReviewService,
private readonly phoneFrequency: PhoneFrequencyService,
private readonly phoneRouting: PhoneRoutingLookupService,
private readonly facade: SendSubmissionService,
private readonly callbacks: SendSubmissionCallbacks,
private readonly metrics?: MetricsService,
) {}
private async measureInboundStage<T>(stage: CmppInboundStage, action: () => Promise<T>): Promise<T> {
const startedAt = this.metrics?.beginCmppInboundStage();
try {
const result = await action();
if (startedAt != null) this.metrics?.finishCmppInboundStage(startedAt, stage, 'success');
return result;
} catch (error) {
if (startedAt != null) this.metrics?.finishCmppInboundStage(startedAt, stage, 'error');
throw error;
}
}
private releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
remark: string,
) {
return this.callbacks.releaseMessageReservation(message, remark);
}
private recordCmppFailureReceipt(
message: {
id: string;
tenantId?: string | null;
batchTaskId?: string | null;
applicationId?: string | null;
messageId: string;
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
) {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
let tenantId: string | undefined;
let applicationId: string | undefined;
try {
const application = await this.facade.findInboundApplication(data.account);
tenantId = application?.tenantId;
applicationId = application?.id;
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' });
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
windowSize: application.cmppWindowSize,
status: 'authenticated',
};
} catch (error) {
await this.recordInboundConnectRequest(data, {
tenantId,
applicationId,
result: 'failed',
error: error instanceof Error ? error.message : 'unknown error',
});
throw error;
}
}
private recordInboundConnectRequest(
data: GatewayInboundAuthDto,
outcome: { tenantId?: string; applicationId?: string; result: 'authenticated' | 'failed'; error?: string },
) {
return this.prisma.operationLog.create({
data: {
tenantId: outcome.tenantId,
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: outcome.applicationId ?? data.account,
ipAddress: data.remoteIp?.trim() || undefined,
detail: {
direction: 'client_to_platform',
result: outcome.result,
applicationId: outcome.applicationId ?? null,
request: {
remoteIp: data.remoteIp?.trim() || null,
account: data.account,
// Standard CMPP sends AuthenticatorSource rather than a plaintext password; keep both fields truthful.
password: data.password ?? null,
authSource: data.authSource ?? null,
timestamp: data.timestamp ?? null,
version: data.version ?? null,
requestedVersion: data.requestedVersion ?? null,
},
error: outcome.error ?? null,
} as Prisma.InputJsonValue,
},
});
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
if (data.registeredDelivery != null && ![0, 1].includes(data.registeredDelivery)) {
throw new BadRequestException('CMPP Registered_Delivery must be 0 or 1');
}
const phoneNumbers = data.phoneNumbers?.length
? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim())
: data.phoneNumber
? [data.phoneNumber.trim()]
: [];
if (phoneNumbers.length === 0) {
throw new BadRequestException('CMPP submit phone number is invalid');
}
if (this.inboundFastPathEnabled() && !data.longMessage) {
return this.measureInboundStage(
'inbox_persist',
() => this.persistValidatedInboundWorkflow(data, phoneNumbers),
);
}
const application = await this.measureInboundStage(
'application_lookup',
() => this.facade.findInboundApplication(data.account),
);
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) {
throw new BadRequestException('CMPP account is disabled for new submissions');
}
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.measureInboundStage(
'long_message_fragment',
() => this.facade.collectInboundLongMessageFragment(data, application, phoneNumbers),
);
if (collection.response) {
return collection.response;
}
if (!collection.complete) {
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
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 {
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,
phoneNumbers,
) ?? await this.facade.submitCompleteInboundMessage({
...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;
} catch (error) {
await this.prisma.cmppInboundLongMessage.update({
where: { id: collection.groupId },
data: {
status: 'rejected',
completedAt: new Date(),
},
}).catch(() => undefined);
throw error;
}
}
return this.measureInboundStage(
'complete_submit',
() => this.facade.submitCompleteInboundMessage(data, phoneNumbers, application),
);
}
private inboundFastPathEnabled() {
return process.env.CMPP_INBOUND_FAST_PATH_ENABLED === 'true';
}
private async persistValidatedInboundWorkflow(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
) {
const requestKey = data.requestId?.trim();
if (!requestKey || requestKey.length > 160) {
throw new BadRequestException('CMPP inbound requestId is required for fast-path idempotency');
}
const submitGroupMessageId = `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 payloadHash = createHash('sha256').update(JSON.stringify({
data: payload.data,
phoneNumbers,
requestedGroupMessageId: null,
})).digest('hex');
const responseMessages = phoneNumbers.map((phoneNumber, index) => ({
phoneNumber,
messageId: messageIds[index],
messageRecordId: '',
taskId: '',
status: 'accepted_pending',
}));
const remoteIp = data.remoteIp?.replace(/^::ffff:/, '').trim() || null;
const submittedSrcId = data.srcId?.trim() ?? '';
// One indexed statement must both validate the current application state and create the
// durable Inbox row. Keeping those operations in one database snapshot prevents a disable
// racing between a separate SELECT and INSERT, while the unique request key remains the
// authoritative idempotency boundary.
const rows = await this.prisma.$queryRaw<PersistedInboundWorkflowRow[]>(Prisma.sql`
WITH application AS (
SELECT app.id,
app."tenantId",
app.status,
app."interfaceEnabled",
app."queuePriority",
app."cmppApplicationExtension",
app."cmppAccessNumberFillEnabled",
app."cmppAccessNumberFillPrefix",
app."cmppClientSrcId",
tenant.status AS "tenantStatus"
FROM "SmsApplication" AS app
JOIN "Tenant" AS tenant ON tenant.id = app."tenantId"
WHERE app."cmppAccount" = ${data.account}
LIMIT 1
), validation AS (
SELECT application.*,
CASE
WHEN application.status <> 'active'
OR application."tenantStatus" <> 'active'
OR NOT application."interfaceEnabled"
THEN 'CMPP account is disabled for new submissions'
WHEN ${remoteIp}::text IS NOT NULL
AND EXISTS (
SELECT 1 FROM "SmsApplicationIpAllowlist" allowlist
WHERE allowlist."applicationId" = application.id
)
AND NOT EXISTS (
SELECT 1
FROM "SmsApplicationIpAllowlist" allowlist
WHERE allowlist."applicationId" = application.id
AND (
(position('/' IN trim(allowlist."ipCidr")) = 0
AND regexp_replace(trim(allowlist."ipCidr"), '^::ffff:', '') = ${remoteIp})
OR (
trim(allowlist."ipCidr") ~ '^[0-9]{1,3}(\\.[0-9]{1,3}){3}/([0-9]|[12][0-9]|3[0-2])$'
AND ${remoteIp} ~ '^[0-9]{1,3}(\\.[0-9]{1,3}){3}$'
AND ${remoteIp}::inet <<= trim(allowlist."ipCidr")::cidr
)
)
)
THEN 'CMPP source IP is not in application allowlist'
WHEN coalesce(trim(application."cmppApplicationExtension"), '') <> ''
AND ${submittedSrcId} <> coalesce(
nullif(trim(application."cmppClientSrcId"), ''),
CASE WHEN application."cmppAccessNumberFillEnabled"
THEN coalesce(trim(application."cmppAccessNumberFillPrefix"), '')
ELSE ''
END || trim(application."cmppApplicationExtension")
)
THEN 'CMPP Src_Id must equal the access number assigned to this application: ' || coalesce(
nullif(trim(application."cmppClientSrcId"), ''),
CASE WHEN application."cmppAccessNumberFillEnabled"
THEN coalesce(trim(application."cmppAccessNumberFillPrefix"), '')
ELSE ''
END || trim(application."cmppApplicationExtension")
)
ELSE NULL
END AS "validationError"
FROM application
), inserted AS (
INSERT INTO "CmppInboundSubmissionInbox" (
id, "requestKey", "payloadHash", "tenantId", "applicationId", "queuePriority",
payload, response, status, attempts, "nextAttemptAt", "createdAt", "updatedAt"
)
SELECT ${randomUUID()}, ${requestKey}, ${payloadHash}, validation."tenantId", validation.id,
CASE WHEN validation."queuePriority" = 'priority' THEN 'priority' ELSE 'normal' END,
${JSON.stringify(payload)}::jsonb,
jsonb_build_object(
'accepted', true,
'tenantId', validation."tenantId",
'applicationId', validation.id,
'taskId', '',
'messageId', ${submitGroupMessageId}::text,
'messageRecordId', '',
'status', 'accepted_pending',
'phoneCount', ${phoneNumbers.length}::integer,
'messages', ${JSON.stringify(responseMessages)}::jsonb
),
'pending', 0,
(NOW() AT TIME ZONE 'UTC'),
(NOW() AT TIME ZONE 'UTC'),
(NOW() AT TIME ZONE 'UTC')
FROM validation
WHERE validation."validationError" IS NULL
ON CONFLICT ("requestKey") DO NOTHING
RETURNING "payloadHash", response
)
SELECT validation."validationError",
coalesce(inserted."payloadHash", existing."payloadHash") AS "payloadHash",
coalesce(inserted.response, existing.response) AS response
FROM validation
LEFT JOIN inserted ON true
LEFT JOIN "CmppInboundSubmissionInbox" existing
ON existing."requestKey" = ${requestKey}
LIMIT 1
`);
const row = rows[0];
if (!row) {
throw new BadRequestException('CMPP account is invalid');
}
if (row.validationError) {
throw new BadRequestException(row.validationError);
}
if (row.response == null) {
// A concurrent insert that wins after this statement's MVCC snapshot is not visible to
// the CTE. The unique key has already prevented duplication, so only that rare retry path
// needs a second read; normal submissions still use exactly one database round trip.
const concurrent = await this.prisma.cmppInboundSubmissionInbox.findUnique({ where: { requestKey } });
if (!concurrent || concurrent.payloadHash !== payloadHash) {
throw new BadRequestException('CMPP inbound requestId conflicts with another payload');
}
return concurrent.response as InboundWorkflowResponse;
}
if (row.payloadHash !== payloadHash) {
throw new BadRequestException('CMPP inbound requestId conflicts with another payload');
}
return row.response as InboundWorkflowResponse;
}
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: {
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,
};
}
async submitCompleteInboundMessage(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
requestedGroupMessageId?: string,
requestedMessageIds?: string[],
workflowKey?: string,
) {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
const precheck = await this.measureInboundStage('submission_precheck', async () => {
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,
batchTask: { select: { status: true } },
},
})
: [];
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
const phoneRejections = await this.facade.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers);
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => (
!persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber)
)).length;
const dailyQuota = missingPhoneCount > 0
? await this.facade.tryReserveDailySendQuota(
application.id,
missingPhoneCount,
workflowKey ? `${workflowKey}:daily-quota` : undefined,
)
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
return { persistedByPhone, phoneRejections, dailyQuota, missingPhoneCount };
});
const { persistedByPhone, phoneRejections, dailyQuota, missingPhoneCount } = precheck;
const dailyLimitRejection = dailyQuota.reserved
? undefined
: {
code: 'DAILY_LIMIT',
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`,
};
const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`;
const submissions = phoneNumbers.map((phoneNumber, index) => ({
phoneNumber,
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,
applicationId: submission.persisted.applicationId ?? application.id,
taskId: submission.persisted.batchTaskId ?? '',
messageId: submission.persisted.messageId,
messageRecordId: submission.persisted.id,
status: submission.persisted.status,
})
: this.facade.submitInboundSingleMessage({
...data,
phoneNumber: submission.phoneNumber,
phoneNumbers: undefined,
}, submission.messageId, submitGroupMessageId, application, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection, submission.workflowItemKey))));
}
const first = results[0];
return {
...first,
result: dailyLimitRejection ? 8 : undefined,
phoneCount: results.length,
messages: results.map((result, index) => ({
phoneNumber: phoneNumbers[index],
messageId: result.messageId,
messageRecordId: result.messageRecordId,
taskId: result.taskId,
status: result.status,
})),
};
}
async collectInboundLongMessageFragment(
data: GatewayInboundSubmitDto,
application: NonNullable<Awaited<ReturnType<SendSubmissionService['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),
registeredDelivery: recent.segments[0]?.registeredDelivery ?? true,
};
}
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),
registeredDelivery: group.segments[0]?.registeredDelivery ?? true,
};
}
return {
complete: false,
groupId: group.id,
messageId: group.messageId,
receivedSegments: group.segments.length,
response: group.response as any,
content: '',
sequenceId: undefined,
registeredDelivery: true,
};
}
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
if (existing && (existing.contentHash !== contentHash
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId))
|| existing.registeredDelivery !== (data.registeredDelivery !== 0))) {
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),
registeredDelivery: data.registeredDelivery !== 0,
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),
registeredDelivery: segments[0]?.registeredDelivery ?? true,
};
});
}
async expireInboundLongMessages(now = new Date()) {
return this.prisma.cmppInboundLongMessage.updateMany({
where: {
status: { in: ['collecting', 'processing'] },
expiresAt: { lte: now },
},
data: {
status: 'expired',
completedAt: now,
},
});
}
async submitInboundSingleMessage(
data: GatewayInboundSubmitDto & { phoneNumber: string },
messageId: string,
submitGroupMessageId: string,
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))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
const template = await this.measureInboundStage(
'template_match',
() => this.facade.resolveInboundTemplateCandidate(application.id, data.content),
);
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
const unitPrice = moneyToNumber(application.customerUnitPrice);
const queuePriority = normalizeQueuePriority(application.queuePriority);
const billing = this.billing.estimateSmsCost({
tenantId: application.tenantId,
applicationId: application.id,
content: data.content,
phoneCount: 1,
unitPrice,
});
const drainageDetection = await this.measureInboundStage(
'content_detection',
() => detectDrainageContent(this.prisma, data.content),
);
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,
});
}
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 {
accepted: false,
tenantId: application.tenantId,
applicationId: application.id,
taskId: task.id,
messageId: message.messageId,
messageRecordId: message.id,
status: 'rejected',
};
}
const reject = async (code: string, reason: string) => {
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
});
await this.recordCmppFailureReceipt(message, code, reason);
};
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
const { drainageInfoId, risk } = await this.measureInboundStage('risk_frequency', async () => {
const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content);
const evaluatedRisk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
templateId: options.templateId,
content: data.content,
variables: options.templateId ? templateVariables : undefined,
phoneNumber: data.phoneNumber,
sourceType: 'cmpp',
}, workflowItemKey ? `${workflowItemKey}:frequency` : undefined);
return { drainageInfoId: drainage?.id, risk: evaluatedRisk };
});
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
return;
}
if (risk.status === 'pending_review') {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
status: 'pending_review',
reviewTaskId: risk.task?.id,
signatureId: options.signatureId,
drainageInfoId,
},
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
});
return;
}
const accountCheck = await this.measureInboundStage('billing', async () => {
const check = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
});
if (check.canSend && billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 入站短信冻结',
idempotencyKey: workflowItemKey ? `${workflowItemKey}:freeze` : undefined,
});
}
return check;
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额不足');
return;
}
await this.measureInboundStage('queue_publish', async () => {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'queued', signatureId: options.signatureId, drainageInfoId },
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
});
await this.facade.enqueueBatchTask(task.id, {
messageRecordId: message.id,
queuePriority,
});
});
};
if (receiptRejection) {
await reject(receiptRejection.code, receiptRejection.reason);
} else if (application.status !== 'active' || application.tenant.status !== 'active') {
await reject('ACCOUNT', '企业或短信应用已停用');
} else if (!application.interfaceEnabled) {
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
} else if (application.tenant.certificationStatus !== 'approved') {
await reject('CERT', '企业认证未通过');
} else if (!template && application.templateMismatchMode === 'manual_review') {
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
if (!signature) {
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
} else {
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content);
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
content: data.content,
phoneNumber: data.phoneNumber,
sourceType: 'cmpp',
}, workflowItemKey ? `${workflowItemKey}:frequency` : undefined);
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
} else {
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额不足');
} else {
if (billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 模板不匹配待审核短信冻结',
idempotencyKey: workflowItemKey ? `${workflowItemKey}:freeze` : undefined,
});
}
const reviewTask = risk.status === 'pending_review' && risk.task
? await this.facade.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id)
: await this.riskReview.aggregateTemplateMismatch({
tenantId: application.tenantId,
applicationId: application.id,
account: data.account,
messageRecordId: message.id,
signatureId: signature.id,
content: data.content,
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: {
status: 'pending_review',
riskTaskId: reviewTask?.id,
auditStatus: 'pending',
reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核',
},
});
}
}
}
} else if (!template && application.templateMismatchMode === 'direct_send') {
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
if (!signature) {
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
} else {
await queueAfterRiskChecks({ signatureId: signature.id });
}
} else if (!template) {
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
} else if (template.auditStatus !== 'approved') {
await reject('TEMPLATE', '短信模板尚未审核通过');
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
await reject('SIGNATURE', '短信签名尚未审核通过');
} else {
await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id });
}
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
taskId: task.id,
messageId: message.messageId,
messageRecordId: message.id,
status: 'accepted',
};
}
async evaluateRiskWithPhoneFrequency(input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}, reservationKey?: string) {
const risk = await this.riskReview.evaluateTask({
tenantId: input.tenantId,
applicationId: input.applicationId,
templateId: input.templateId,
content: input.content,
variables: input.variables,
phones: [input.phoneNumber],
sourceType: input.sourceType,
});
if (risk.status === 'rejected') return risk;
const frequencyRejections = await this.phoneFrequency.reserve(
input.tenantId,
input.applicationId,
[input.phoneNumber],
input.sourceType,
new Date(),
reservationKey,
);
const rejection = frequencyRejections.get(input.phoneNumber);
return rejection
? { ...risk, status: 'rejected' as const, reason: rejection.reason }
: 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.measureInboundStage('worker_claim', () => this.claimInboundWorkflows(available));
const applications = claimed.length === 0
? []
: await this.prisma.smsApplication.findMany({
where: { id: { in: [...new Set(claimed.map((item) => item.applicationId))] } },
include: { tenant: true, ipAllowlist: true },
});
const applicationById = new Map(applications.map((application) => [application.id, application]));
const batchSize = positiveInteger(process.env.API_INBOUND_WORKFLOW_BATCH_SIZE, 64);
for (let offset = 0; offset < claimed.length; offset += batchSize) {
const batch = claimed.slice(offset, offset + batchSize);
this.inboundWorkflowInFlight += batch.length;
this.metrics?.setInboundWorkflowSlots(concurrency, this.inboundWorkflowInFlight);
const task = this.processClaimedInboundWorkflowBatch(batch, applicationById)
.catch((error) => this.logger.error(`CMPP inbound workflow batch failed to settle: ${String(error)}`))
.finally(() => {
this.inboundWorkflowInFlight = Math.max(0, this.inboundWorkflowInFlight - batch.length);
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() AT TIME ZONE 'UTC')
) OR (
status = 'processing'
AND "lockedAt" <= (NOW() AT TIME ZONE 'UTC') - 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() AT TIME ZONE 'UTC'),
"lockedBy" = ${this.inboundWorkflowWorkerId},
"updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM candidates
WHERE inbox.id = candidates.id
RETURNING inbox.id, inbox."requestKey", inbox."applicationId", inbox.attempts, inbox.payload
`);
}
private async processClaimedInboundWorkflowBatch(
items: ClaimedInboundWorkflow[],
applicationById: Map<string, InboundApplication>,
) {
if (process.env.API_INBOUND_WORKFLOW_BATCH_ENABLED === 'false' || items.length < 2) {
await Promise.all(items.map((item) => this.processClaimedInboundWorkflow(item, applicationById.get(item.applicationId))));
return;
}
let completed = new Set<string>();
try {
completed = await this.processCommonInboundWorkflowBatch(items, applicationById);
} catch (error) {
// The common path is wholly idempotent: quota/frequency reservations have
// stable keys, records have stable message/task keys, and BullMQ job IDs are
// message IDs. Falling back after a partial external failure repairs rather
// than duplicates the individual items.
this.logger.warn(`CMPP inbound common batch fell back to individual recovery: ${String(error)}`);
}
await Promise.all(items
.filter((item) => !completed.has(item.id))
.map((item) => this.processClaimedInboundWorkflow(item, applicationById.get(item.applicationId))));
}
private async processCommonInboundWorkflowBatch(
items: ClaimedInboundWorkflow[],
applicationById: Map<string, InboundApplication>,
) {
const parsed = items.flatMap((item) => {
try {
const payload = parseInboundWorkflowPayload(item.payload);
const application = applicationById.get(item.applicationId);
return payload.phoneNumbers.length === 1 && application ? [{ item, payload, application }] : [];
} catch {
return [];
}
});
if (parsed.length < 2) return new Set<string>();
const applicationIds = [...new Set(parsed.map((entry) => entry.application.id))];
const phones = [...new Set(parsed.map((entry) => entry.payload.phoneNumbers[0]))];
const messageIds = parsed.flatMap((entry) => entry.payload.messageIds);
const tenantIds = [...new Set(parsed.map((entry) => entry.application.tenantId))];
const [templates, signatures, globalBlacklist, enterpriseBlacklist, persisted] = await this.measureInboundStage('reference_preload', () => Promise.all([
this.prisma.smsTemplate.findMany({
where: { applicationId: { in: applicationIds }, auditStatus: 'approved', signature: { auditStatus: 'approved' } },
include: { signature: true },
orderBy: { updatedAt: 'desc' },
}),
this.prisma.smsSignature.findMany({
where: { applicationId: { in: applicationIds }, auditStatus: 'approved' },
select: { id: true, applicationId: true, name: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
}),
this.prisma.globalBlacklist.findMany({
where: { phoneNumber: { in: phones }, status: 'active' },
select: { phoneNumber: true },
}),
this.prisma.enterpriseBlacklist.findMany({
where: { tenantId: { in: tenantIds }, applicationId: { in: applicationIds }, phoneNumber: { in: phones }, status: 'active' },
select: { tenantId: true, applicationId: true, phoneNumber: true },
}),
this.prisma.smsMessageRecord.findMany({ where: { messageId: { in: messageIds } }, select: { messageId: true } }),
]));
const globalRejected = new Set(globalBlacklist.map((entry) => entry.phoneNumber));
const enterpriseRejected = new Set(enterpriseBlacklist.map((entry) => `${entry.tenantId}:${entry.applicationId}:${entry.phoneNumber}`));
const persistedIds = new Set(persisted.map((entry) => entry.messageId));
const templatesByApplication = new Map<string, InboundTemplate[]>();
for (const template of templates) {
const values = templatesByApplication.get(template.applicationId) ?? [];
values.push(template);
templatesByApplication.set(template.applicationId, values);
}
const signaturesByApplication = new Map<string, typeof signatures>();
for (const signature of signatures) {
if (!signature.applicationId) continue;
const values = signaturesByApplication.get(signature.applicationId) ?? [];
values.push(signature);
signaturesByApplication.set(signature.applicationId, values);
}
const candidates: InboundBatchCandidate[] = [];
for (const entry of parsed) {
const { item, payload, application } = entry;
const data = payload.data;
const phoneNumber = payload.phoneNumbers[0];
if (persistedIds.has(payload.messageIds[0])
|| application.cmppAccount !== data.account
|| application.status !== 'active'
|| application.tenant.status !== 'active'
|| !application.interfaceEnabled
|| application.tenant.certificationStatus !== 'approved'
|| !/^1\d{10}$/.test(phoneNumber)
|| globalRejected.has(phoneNumber)
|| enterpriseRejected.has(`${application.tenantId}:${application.id}:${phoneNumber}`)
// Paid messages retain the existing per-message account lock until the
// dedicated batch-ledger migration is introduced; never weaken billing
// correctness merely to increase the benchmark number.
|| moneyToNumber(application.customerUnitPrice) !== 0) continue;
try {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((allowlist) => allowlist.ipCidr))) continue;
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
const template = (templatesByApplication.get(application.id) ?? []).find((candidate) => (
candidate.content === data.content || matchTemplateContent(candidate.content, data.content) !== null
));
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : undefined;
let signatureId = template?.signature?.id;
if (!signatureId && application.templateMismatchMode === 'direct_send') {
const signatureName = data.content.match(/^【[^】]+】/)?.[0];
signatureId = (signaturesByApplication.get(application.id) ?? []).find((signature) => signature.name === signatureName)?.id;
}
if (!signatureId || (!template && application.templateMismatchMode !== 'direct_send')) continue;
const finalSignatureId = signatureId;
candidates.push({ item, payload, application, phoneNumber, template, signatureId: finalSignatureId, templateVariables, clientSrcId });
} catch {
// Invalid source IDs and other business rejections stay on the established
// individual path so their exact failure receipt remains unchanged.
}
}
if (candidates.length < 2) return new Set<string>();
const quota = await this.measureInboundStage('daily_quota', () => this.reserveDailyQuotaBatch(candidates));
const quotaApproved = candidates.filter((candidate) => quota.get(candidate.item.requestKey)?.reserved);
if (quotaApproved.length < 2) return new Set<string>();
const riskResults = await this.measureInboundStage('risk_frequency', () => this.riskReview.evaluateTasksBatch(quotaApproved.map((candidate) => ({
tenantId: candidate.application.tenantId,
applicationId: candidate.application.id,
templateId: candidate.template?.id,
content: candidate.payload.data.content,
variables: candidate.templateVariables,
phones: [candidate.phoneNumber],
sourceType: 'cmpp' as const,
}))));
const riskApproved = quotaApproved.filter((_, index) => riskResults[index]?.status === 'approved');
if (riskApproved.length < 2) return new Set<string>();
const frequency = await this.measureInboundStage('risk_frequency', () => this.phoneFrequency.reserveBatch(riskApproved.map((candidate) => ({
tenantId: candidate.application.tenantId,
applicationId: candidate.application.id,
phoneNumber: candidate.phoneNumber,
sourceType: 'cmpp',
reservationKey: `${candidate.item.requestKey}:message:0:frequency`,
}))));
const approved = riskApproved.filter((candidate) => (
(frequency.get(`${candidate.item.requestKey}:message:0:frequency`)?.size ?? 0) === 0
));
if (approved.length < 2) return new Set<string>();
const drainageRows = await this.prisma.smsDrainageInfo.findMany({
where: { signatureId: { in: [...new Set(approved.map((candidate) => candidate.signatureId))] }, auditStatus: { not: 'deleted' } },
select: { id: true, signatureId: true, url: true, updatedAt: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
});
const finalCandidates = approved.filter((candidate) => {
const matches = drainageRows
.filter((row) => row.signatureId === candidate.signatureId && row.url.trim() && candidate.payload.data.content.includes(row.url.trim()))
.sort((left, right) => right.url.trim().length - left.url.trim().length || right.updatedAt.getTime() - left.updatedAt.getTime());
if (matches.length > 1 && matches[0].url.trim().length === matches[1].url.trim().length) return false;
candidate.drainageInfoId = matches[0]?.id;
return true;
});
if (finalCandidates.length < 2) return new Set<string>();
return this.persistCommonInboundWorkflowBatch(finalCandidates);
}
private async reserveDailyQuotaBatch(candidates: InboundBatchCandidate[]) {
const usageDate = shanghaiDateKey();
const usageDateValue = new Date(`${usageDate}T00:00:00.000Z`);
return this.prisma.$transaction(async (tx) => {
const applicationIds = [...new Set(candidates.map((candidate) => candidate.application.id))].sort();
await tx.smsApplicationDailyUsage.createMany({
data: applicationIds.map((applicationId) => ({ id: randomUUID(), applicationId, usageDate: usageDateValue, usedCount: 0 })),
skipDuplicates: true,
});
const usage = await tx.$queryRaw<Array<{ applicationId: string; tenantId: string; dailyLimit: number; usedCount: number }>>(Prisma.sql`
SELECT usage."applicationId", application."tenantId",
COALESCE(application."dailyLimit", 100000)::integer AS "dailyLimit",
usage."usedCount"
FROM "SmsApplicationDailyUsage" usage
JOIN "SmsApplication" application ON application.id = usage."applicationId"
WHERE usage."usageDate" = ${usageDate}::date
AND usage."applicationId" IN (${Prisma.join(applicationIds)})
ORDER BY usage."applicationId"
FOR UPDATE OF usage
`);
const state = new Map(usage.map((row) => [row.applicationId, { ...row }]));
const keys = candidates.map((candidate) => `${candidate.item.requestKey}:daily-quota`);
const existing = await tx.smsApplicationDailyReservation.findMany({ where: { reservationKey: { in: keys } } });
const existingByKey = new Map(existing.map((row) => [row.reservationKey, row]));
const output = new Map<string, { reserved: boolean; dailyLimit: number; usedCount: number | null }>();
const inserts: Prisma.SmsApplicationDailyReservationCreateManyInput[] = [];
for (const candidate of candidates) {
const reservationKey = `${candidate.item.requestKey}:daily-quota`;
const replay = existingByKey.get(reservationKey);
if (replay) {
if (replay.applicationId !== candidate.application.id || replay.requestedCount !== 1) {
throw new Error('CMPP daily quota idempotency key conflicts with another reservation');
}
output.set(candidate.item.requestKey, { reserved: replay.reserved, dailyLimit: replay.dailyLimit, usedCount: replay.usedCount });
continue;
}
const current = state.get(candidate.application.id);
if (!current) throw new Error(`CMPP daily quota application ${candidate.application.id} disappeared`);
const reserved = current.usedCount + 1 <= current.dailyLimit;
if (reserved) current.usedCount += 1;
inserts.push({
id: randomUUID(), reservationKey, tenantId: candidate.application.tenantId,
applicationId: candidate.application.id, usageDate: usageDateValue, requestedCount: 1,
dailyLimit: current.dailyLimit, usedCount: reserved ? current.usedCount : null, reserved,
});
output.set(candidate.item.requestKey, { reserved, dailyLimit: current.dailyLimit, usedCount: reserved ? current.usedCount : null });
}
for (const row of state.values()) {
await tx.smsApplicationDailyUsage.update({
where: { applicationId_usageDate: { applicationId: row.applicationId, usageDate: usageDateValue } },
data: { usedCount: row.usedCount },
});
}
if (inserts.length) await tx.smsApplicationDailyReservation.createMany({ data: inserts });
return output;
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
}
private async persistCommonInboundWorkflowBatch(candidates: InboundBatchCandidate[]) {
const prepared = await Promise.all(candidates.map(async (candidate) => {
const workflowDigest = createHash('sha256').update(`${candidate.item.requestKey}:message:0`).digest('hex').slice(0, 32);
const taskId = randomUUID();
const messageRecordId = randomUUID();
const content = candidate.payload.data.content;
const drainageDetection = await detectDrainageContent(this.prisma, content);
return { candidate, workflowDigest, taskId, messageRecordId, content, drainageDetection };
}));
await this.measureInboundStage('message_persist', () => this.prisma.$transaction(async (tx) => {
await tx.smsBatchTask.createMany({
data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({
id: taskId,
tenantId: candidate.application.tenantId,
applicationId: candidate.application.id,
templateId: candidate.template?.id,
taskNo: `BT-IN-${workflowDigest}`,
sourceType: 'cmpp',
content,
phoneTotal: 1,
status: 'ready',
auditStatus: 'approved',
progressTotal: 1,
})),
});
await tx.smsApiRequest.createMany({
data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({
id: randomUUID(), tenantId: candidate.application.tenantId, batchTaskId: taskId,
requestId: `REQ-IN-${workflowDigest}`, sourceIp: candidate.payload.data.remoteIp,
userAgent: 'cmpp-gateway', payloadSummary: { phoneTotal: 1, contentLength: [...content].length, account: candidate.payload.data.account },
status: 'accepted',
})),
});
await tx.smsMessageRecord.createMany({
data: prepared.map(({ candidate, taskId, messageRecordId, content, drainageDetection }) => ({
id: messageRecordId,
tenantId: candidate.application.tenantId,
batchTaskId: taskId,
applicationId: candidate.application.id,
templateId: candidate.template?.id,
signatureId: candidate.signatureId,
drainageInfoId: candidate.drainageInfoId,
messageId: candidate.payload.messageIds[0],
phoneNumber: candidate.phoneNumber,
content,
...drainageDetection,
billingUnits: this.billing.estimateSmsCost({
tenantId: candidate.application.tenantId, applicationId: candidate.application.id,
content, phoneCount: 1, unitPrice: 0,
}).billingUnitsPerMessage,
unitPrice: 0,
amountCents: 0,
queuePriority: normalizeQueuePriority(candidate.application.queuePriority),
cmppSubmitSequenceId: candidate.payload.data.sequenceId == null ? null : String(candidate.payload.data.sequenceId),
cmppSubmitGroupMessageId: candidate.payload.submitGroupMessageId,
cmppRegisteredDelivery: candidate.payload.data.registeredDelivery !== 0,
clientSrcId: candidate.clientSrcId,
applicationExtension: candidate.application.cmppApplicationExtension,
status: 'queued',
})),
});
}));
await this.measureInboundStage('queue_publish', () => this.facade.getSendQueue().addBulk(prepared.map(({ candidate, messageRecordId }) => ({
name: 'send-message' as const,
data: { messageRecordId },
opts: {
jobId: messageRecordId,
attempts: 3,
priority: BULLMQ_PRIORITY[normalizeQueuePriority(candidate.application.queuePriority)],
},
}))));
await this.prisma.smsBatchTask.updateMany({
where: { id: { in: prepared.map((entry) => entry.taskId) }, status: 'ready' },
data: { status: 'queued' },
});
const results = prepared.map(({ candidate, taskId, messageRecordId }) => ({
accepted: true,
tenantId: candidate.application.tenantId,
applicationId: candidate.application.id,
taskId,
messageId: candidate.payload.messageIds[0],
messageRecordId,
status: 'accepted',
phoneCount: 1,
messages: [{
phoneNumber: candidate.phoneNumber,
messageId: candidate.payload.messageIds[0],
messageRecordId,
taskId,
status: 'accepted',
}],
}));
const values = prepared.map((entry, index) => Prisma.sql`(${entry.candidate.item.id}, ${JSON.stringify(results[index])}::jsonb)`);
const settled = await this.prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
UPDATE "CmppInboundSubmissionInbox" inbox
SET status = 'completed', result = updates.result, "completedAt" = (NOW() AT TIME ZONE 'UTC'),
"lockedAt" = NULL, "lockedBy" = NULL, "lastError" = NULL, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${Prisma.join(values)}) AS updates(id, result)
WHERE inbox.id = updates.id
AND inbox.status = 'processing'
AND inbox."lockedBy" = ${this.inboundWorkflowWorkerId}
RETURNING inbox.id
`);
const settledIds = new Set(settled.map((row) => row.id));
for (let index = 0; index < settled.length; index += 1) this.metrics?.recordInboundWorkflowResult('completed');
return settledIds;
}
private async processClaimedInboundWorkflow(item: ClaimedInboundWorkflow, application?: InboundApplication) {
try {
const payload = parseInboundWorkflowPayload(item.payload);
if (!application || application.cmppAccount !== payload.data.account) {
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 },
include: {
tenant: true,
ipAllowlist: true,
},
});
}
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
const exact = await this.prisma.smsTemplate.findFirst({
where: {
applicationId,
content,
auditStatus: 'approved',
signature: { auditStatus: 'approved' },
},
include: { signature: true },
orderBy: { updatedAt: 'desc' },
});
if (exact) return exact;
const variableTemplates = await this.prisma.smsTemplate.findMany({
where: {
applicationId,
content: { contains: '${' },
auditStatus: 'approved',
signature: { auditStatus: 'approved' },
},
include: { signature: true },
orderBy: { updatedAt: 'desc' },
});
return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null;
}
resolveInboundSignatureCandidate(applicationId: string, content: string) {
const match = content.match(/^【[^】]+】/);
if (!match?.[0]) return null;
return this.prisma.smsSignature.findFirst({
where: {
applicationId,
name: match[0],
auditStatus: 'approved',
},
orderBy: { updatedAt: 'desc' },
});
}
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
if (!signatureId) return undefined;
const candidates = await this.prisma.smsDrainageInfo.findMany({
where: { signatureId, auditStatus: { not: 'deleted' } },
select: { id: true, url: true, auditStatus: true, updatedAt: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
});
const matches = candidates
.map((item) => ({ ...item, normalizedUrl: item.url.trim() }))
.filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl))
.sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime());
if (matches.length === 0) return undefined;
const longestLength = matches[0].normalizedUrl.length;
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
if (longestMatches.length !== 1) {
throw new BadRequestException({
code: 'DRAINAGE_MATCH_AMBIGUOUS',
message: '短信内容同时匹配多条等长引流地址,无法确定报备资料',
drainageInfoIds: longestMatches.map((item) => item.id),
});
}
const matched = longestMatches[0];
return { id: matched.id, auditStatus: matched.auditStatus };
}
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
await this.prisma.smsMessageRecord.update({
where: { id: messageRecordId },
data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' },
});
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[],
};
}