876 lines
35 KiB
TypeScript
876 lines
35 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 { 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 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';
|
|
|
|
/**
|
|
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
|
*/
|
|
export class SendInboundEntryService {
|
|
private readonly logger = new Logger('SendChainService');
|
|
|
|
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 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,
|
|
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');
|
|
}
|
|
|
|
const application = await 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.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 {
|
|
const response = 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.facade.submitCompleteInboundMessage(data, phoneNumbers, application);
|
|
}
|
|
|
|
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,
|
|
) {
|
|
if (!application) {
|
|
throw new BadRequestException('CMPP account is invalid');
|
|
}
|
|
const persisted = requestedGroupMessageId
|
|
? await this.prisma.smsMessageRecord.findMany({
|
|
where: {
|
|
cmppSubmitGroupMessageId: requestedGroupMessageId,
|
|
phoneNumber: { in: phoneNumbers },
|
|
},
|
|
select: {
|
|
id: true,
|
|
tenantId: true,
|
|
applicationId: true,
|
|
batchTaskId: true,
|
|
messageId: true,
|
|
phoneNumber: true,
|
|
status: true,
|
|
errorCode: true,
|
|
},
|
|
})
|
|
: [];
|
|
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
|
|
const 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)
|
|
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
|
|
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
|
|
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
|
|
}));
|
|
const results: GatewayInboundSingleSubmitResult[] = [];
|
|
const concurrency = 10;
|
|
for (let offset = 0; offset < submissions.length; offset += concurrency) {
|
|
const batch = submissions.slice(offset, offset + concurrency);
|
|
results.push(...await Promise.all(batch.map((submission) => submission.persisted
|
|
? Promise.resolve({
|
|
accepted: submission.persisted.errorCode !== 'DAILY_LIMIT',
|
|
tenantId: submission.persisted.tenantId ?? application.tenantId,
|
|
applicationId: submission.persisted.applicationId ?? application.id,
|
|
taskId: submission.persisted.batchTaskId ?? '',
|
|
messageId: submission.persisted.messageId,
|
|
messageRecordId: submission.persisted.id,
|
|
status: submission.persisted.status,
|
|
})
|
|
: this.facade.submitInboundSingleMessage({
|
|
...data,
|
|
phoneNumber: submission.phoneNumber,
|
|
phoneNumbers: undefined,
|
|
}, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection))));
|
|
}
|
|
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,
|
|
synchronousRejection?: { code: string; reason: string },
|
|
receiptRejection?: { code: string; reason: string },
|
|
) {
|
|
const application = await this.facade.findInboundApplication(data.account);
|
|
if (!application) {
|
|
throw new BadRequestException('CMPP account 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');
|
|
}
|
|
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
|
|
const template = await 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 task = await this.prisma.smsBatchTask.create({
|
|
data: {
|
|
tenantId: application.tenantId,
|
|
applicationId: application.id,
|
|
templateId: template?.id,
|
|
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
sourceType: 'cmpp',
|
|
content: data.content,
|
|
phoneTotal: 1,
|
|
status: synchronousRejection ? 'rejected' : 'validating',
|
|
auditStatus: synchronousRejection ? 'rejected' : undefined,
|
|
rejectReason: synchronousRejection?.reason,
|
|
progressTotal: 1,
|
|
},
|
|
});
|
|
await this.prisma.smsApiRequest.create({
|
|
data: {
|
|
tenantId: application.tenantId,
|
|
batchTaskId: task.id,
|
|
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
sourceIp: data.remoteIp,
|
|
userAgent: 'cmpp-gateway',
|
|
payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account },
|
|
status: synchronousRejection ? 'rejected' : 'accepted',
|
|
},
|
|
});
|
|
const drainageDetection = await detectDrainageContent(this.prisma, data.content);
|
|
const message = await this.prisma.smsMessageRecord.create({
|
|
data: {
|
|
tenantId: application.tenantId,
|
|
batchTaskId: task.id,
|
|
applicationId: application.id,
|
|
templateId: template?.id,
|
|
messageId,
|
|
phoneNumber: data.phoneNumber,
|
|
content: data.content,
|
|
...drainageDetection,
|
|
billingUnits: billing.billingUnitsPerMessage,
|
|
unitPrice: receiptRejection ? 0 : billing.unitPrice,
|
|
amountCents: receiptRejection ? 0 : billing.amountCents,
|
|
queuePriority,
|
|
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
|
cmppSubmitGroupMessageId: submitGroupMessageId,
|
|
cmppRegisteredDelivery: data.registeredDelivery !== 0,
|
|
clientSrcId,
|
|
applicationExtension: application.cmppApplicationExtension,
|
|
status: synchronousRejection ? 'rejected' : 'validating',
|
|
errorCode: synchronousRejection?.code,
|
|
errorMessage: synchronousRejection?.reason,
|
|
},
|
|
});
|
|
|
|
if (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 drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content);
|
|
const drainageInfoId = drainage?.id;
|
|
const risk = 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',
|
|
});
|
|
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.billing.checkAccount({
|
|
tenantId: application.tenantId,
|
|
amountCents: billing.amountCents,
|
|
});
|
|
if (!accountCheck.canSend) {
|
|
await reject('BALANCE', '企业账户余额不足');
|
|
return;
|
|
}
|
|
if (billing.amountCents > 0) {
|
|
await this.billing.freeze({
|
|
tenantId: application.tenantId,
|
|
amountCents: billing.amountCents,
|
|
relatedType: 'sms_batch_task',
|
|
relatedId: task.id,
|
|
remark: 'CMPP 入站短信冻结',
|
|
});
|
|
}
|
|
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);
|
|
};
|
|
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',
|
|
});
|
|
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 模板不匹配待审核短信冻结',
|
|
});
|
|
}
|
|
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';
|
|
}) {
|
|
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,
|
|
);
|
|
const rejection = frequencyRejections.get(input.phoneNumber);
|
|
return rejection
|
|
? { ...risk, status: 'rejected' as const, reason: rejection.reason }
|
|
: risk;
|
|
}
|
|
|
|
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 } });
|
|
}
|
|
}
|