1811 lines
79 KiB
TypeScript
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[],
|
|
};
|
|
}
|