Files
lislgosms/api/src/send-chain/send-batch-entry.service.ts
T
2026-09-14 12:48:10 +08:00

698 lines
27 KiB
TypeScript

import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
import {
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
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,
ImportPreviewDto,
ConfirmImportDto,
QueuePriority,
} from './send-chain.contracts';
import {
statusFromRisk,
parseSchedule,
parseImportRows,
normalizeQueuePriority,
matchTemplateContent,
shanghaiDateKey,
} from './send-chain.helpers';
import { detectDrainageContent } from './drainage-content-detection';
import type {
SendResourceValidationOptions,
SendSubmissionCallbacks,
SendSubmissionService,
} from './send-submission.service';
/**
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
*/
export class SendBatchEntryService {
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;
},
errorCode: string,
reason: string,
) {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async createBatchTask(data: CreateBatchTaskDto) {
const httpRequest = data[HTTP_REQUEST_CONTEXT];
const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data);
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
this.facade.resolveTemplateMessageClassification(
data.tenantId,
data.applicationId,
data.templateId,
data.content,
),
this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
detectDrainageContent(this.prisma, data.content),
]);
const risk = messageClassification.rejectionReason
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
: await this.riskReview.evaluateTask({
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
content: data.content,
category: data.category,
phones,
variables: messageClassification.variables ?? data.variables,
createdById: data.createdById,
sourceType: data.sourceType ?? 'client',
});
let frequencyRejectedAll = false;
let frequencyBatchReason: string | undefined;
if (risk.status !== 'rejected' && sendablePhones.length > 0) {
const frequencyRejections = await this.phoneFrequency.reserve(
data.tenantId,
data.applicationId,
sendablePhones,
data.sourceType ?? 'client',
);
for (const [phone, rejection] of frequencyRejections) {
phoneRejections.set(phone, rejection);
}
sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone));
frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0;
frequencyBatchReason = frequencyRejectedAll ? [...frequencyRejections.values()][0]?.reason : undefined;
}
if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) {
await this.prisma.smsSendTask.update({
where: { id: risk.task.id },
data: {
status: 'rejected',
riskDecision: 'block',
reviewReason: null,
rejectReason: frequencyBatchReason,
},
});
}
const billing = this.billing.estimateSmsCost({
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: risk.task?.id,
content: data.content,
phoneCount: sendablePhones.length,
unitPrice,
});
const batchStatus = frequencyRejectedAll
? 'rejected'
: risk.status === 'approved' && sendablePhones.length === 0
? 'failed'
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
const shouldReserveBalance = batchStatus === 'ready';
if (risk.status === 'approved') {
const accountCheck = await this.billing.checkAccount({
tenantId: data.tenantId,
amountCents: billing.amountCents,
});
if (!accountCheck.canSend) {
throw new BadRequestException('企业账户余额不足');
}
}
if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) {
await this.facade.reserveDailySendQuota(data.applicationId, sendablePhones.length);
}
const task = await this.prisma.smsBatchTask.create({
data: {
...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceType: data.sourceType ?? 'client',
content: data.content,
category: data.category,
phoneTotal: phones.length,
status: batchStatus,
riskTaskId: risk.task?.id,
auditStatus:
frequencyRejectedAll || risk.status === 'rejected'
? 'rejected'
: risk.status === 'pending_review'
? 'pending'
: 'approved',
reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null,
rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null,
progressTotal: phones.length,
scheduledAt: schedule.scheduledAt,
createdById: data.createdById,
},
});
if (shouldReserveBalance && billing.amountCents > 0) {
await this.billing.freeze({
tenantId: data.tenantId,
amountCents: billing.amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '发送任务创建冻结',
});
}
await this.prisma.smsApiRequest.create({
data: {
tenantId: data.tenantId,
batchTaskId: task.id,
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceIp: data.sourceIp,
userAgent: data.userAgent,
payloadSummary: {
phoneTotal: phones.length,
contentLength: [...data.content].length,
category: data.category,
sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: schedule.scheduledAt?.toISOString(),
},
status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted',
},
});
if (phones.length > 0) {
const persistMessages = async (tx: Prisma.TransactionClient) => {
await tx.smsMessageRecord.createMany({
data: phones.map((phone) => {
const rejection = phoneRejections.get(phone);
const status = rejection
? 'submit_failed'
: batchStatus === 'ready'
? 'queued'
: batchStatus === 'scheduled'
? 'scheduled'
: batchStatus;
return {
tenantId: data.tenantId,
batchTaskId: task.id,
applicationId: data.applicationId,
templateId: data.templateId,
signatureId: messageClassification.signatureId,
drainageInfoId: messageClassification.drainageInfoId,
reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
messageId: httpRequest ? `MSG-http-${httpRequest.id}` : `MSG-${randomUUID()}`,
clientMessageId: data.clientMessageId,
phoneNumber: phone,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: rejection ? 0 : billing.unitPrice,
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
queuePriority,
clientSrcId: accessNumber.clientSrcId,
applicationExtension: accessNumber.applicationExtension,
status,
submitStatus: rejection ? 'rejected' : undefined,
errorCode: rejection?.code,
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? (risk.reason ?? undefined) : undefined),
};
}),
});
if (httpRequest) {
const rejected = batchStatus === 'rejected';
const responseBody = rejected
? { code: 'SEND_REJECTED', message: task.rejectReason ?? '短信未通过业务校验' }
: {
code: 'ACCEPTED',
requestId: httpRequest.requestId,
messageId: 'MSG-http-' + httpRequest.id,
clientMessageId: data.clientMessageId ?? null,
status: phoneRejections.has(phones[0])
? 'submit_failed'
: batchStatus === 'ready'
? 'queued'
: batchStatus,
acceptedAt: new Date().toISOString(),
};
const frozen = await tx.openApiRequest.updateMany({
where: { id: httpRequest.id, status: 'processing' },
data: {
status: rejected ? 'failed' : 'completed',
httpStatus: rejected ? 422 : 202,
businessCode: responseBody.code,
responseBody,
messageRecordId: 'http-' + httpRequest.id,
completedAt: new Date(),
},
});
if (frozen.count !== 1) throw new ConflictException('HTTP request is no longer processing');
if (batchStatus === 'ready' && sendablePhones.length > 0)
await tx.openApiDispatchOutbox.create({ data: { requestId: httpRequest.id, batchTaskId: task.id } });
}
};
if (httpRequest) await this.prisma.$transaction(persistMessages);
else await persistMessages(this.prisma);
}
if (!httpRequest && batchStatus === 'ready' && sendablePhones.length > 0) {
await this.facade.enqueueBatchTask(task.id);
} else if (batchStatus === 'failed') {
await this.facade.refreshTaskProgress(task.id);
}
return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
}
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
if (!data.applicationId) {
throw new BadRequestException('公开 HTTP 发送必须关联企业应用');
}
const template = await this.facade.resolveInboundTemplateCandidate(data.applicationId, data.content);
if (!template || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
throw new BadRequestException('短信内容未匹配当前应用已审核通过的签名和模板');
}
const variables = matchTemplateContent(template.content, data.content);
if (variables === null) {
throw new BadRequestException('短信内容与已审核模板不匹配');
}
return this.facade.createBatchTask({
...data,
templateId: template.id,
variables,
sourceType: 'api',
});
}
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
const task = await this.prisma.smsBatchTask.findFirst({
where: { id: taskId, tenantId, sourceType },
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
});
if (!task) {
throw new NotFoundException('SMS batch task not found');
}
return task;
}
async previewImport(data: ImportPreviewDto) {
const sizeBytes = Buffer.byteLength(data.content, 'utf8');
if (sizeBytes > 20 * 1024 * 1024) {
throw new BadRequestException('导入文件不能超过 20MB');
}
const rows = parseImportRows(data.content, data.delimiter);
const phones: string[] = [];
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
const requiredVariables = data.requiredVariables ?? [];
const enterpriseBlacklist = data.applicationId
? await this.prisma.enterpriseBlacklist.findMany({
where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
select: { phoneNumber: true },
})
: [];
const globalBlacklist = await this.prisma.globalBlacklist.findMany({
where: { status: 'active' },
select: { phoneNumber: true },
});
const blacklist = new Set([...enterpriseBlacklist, ...globalBlacklist].map((item) => item.phoneNumber));
const seen = new Set<string>();
for (const row of rows) {
if (!row.phoneNumber) {
errors.push({ rowNumber: row.rowNumber, reason: '缺少手机号' });
continue;
}
if (!/^1[3-9]\d{9}$/.test(row.phoneNumber)) {
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '手机号格式非法' });
continue;
}
if (seen.has(row.phoneNumber)) {
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '重复号码' });
continue;
}
if (blacklist.has(row.phoneNumber)) {
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '命中黑名单' });
continue;
}
const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
if (missingVariables.length > 0) {
errors.push({
rowNumber: row.rowNumber,
phoneNumber: row.phoneNumber,
reason: `变量列缺失:${missingVariables.join(',')}`,
});
continue;
}
seen.add(row.phoneNumber);
phones.push(row.phoneNumber);
}
return {
fileName: data.fileName,
encoding: data.encoding ?? 'utf8',
totalRows: rows.length,
validCount: phones.length,
errorCount: errors.length,
phones,
errors,
};
}
async confirmImport(data: ConfirmImportDto) {
const preview = await this.facade.previewImport({
tenantId: data.tenantId,
applicationId: data.applicationId,
content: data.importContent,
requiredVariables: data.requiredVariables,
});
if (preview.validCount === 0) {
throw new BadRequestException('导入文件没有可发送号码');
}
return this.facade.createBatchTask({ ...data, phones: preview.phones });
}
async resolveUnitPrice(tenantId: string, applicationId?: string) {
if (!applicationId) {
return 0;
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true, customerUnitPrice: true },
});
if (!application || application.tenantId !== tenantId) {
return 0;
}
return moneyToNumber(application.customerUnitPrice);
}
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
if (!applicationId) {
return 'normal';
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true, queuePriority: true },
});
if (!application || application.tenantId !== tenantId) {
return 'normal';
}
return normalizeQueuePriority(application.queuePriority);
}
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
if (!applicationId) {
return { clientSrcId: null, applicationExtension: null };
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true },
});
if (!application || application.tenantId !== tenantId) {
return { clientSrcId: null, applicationExtension: null };
}
return {
clientSrcId: application.cmppClientSrcId,
applicationExtension: application.cmppApplicationExtension,
};
}
async resolveTemplateMessageClassification(
tenantId: string,
applicationId: string | undefined,
templateId: string | undefined,
content: string,
) {
if (templateId) {
const template = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
include: { signature: true },
});
if (
!template ||
template.tenantId !== tenantId ||
template.applicationId !== applicationId ||
template.auditStatus !== 'approved' ||
template.signature?.auditStatus !== 'approved'
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
const variables = matchTemplateContent(template.content, content);
if (variables === null) {
throw new BadRequestException('短信内容与选定的审核模板不匹配');
}
const drainage = await this.facade.resolveDrainageInfoMatch(template.signatureId, content);
return {
signatureId: template.signatureId,
drainageInfoId: drainage?.id,
variables,
// 引流资料只做关联与监控,报备审核状态不参与本期发送决策。
rejectionReason: undefined,
};
}
if (!applicationId) {
throw new BadRequestException('自由内容短信必须关联企业应用');
}
const [application, signature] = await Promise.all([
this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true, templateMismatchMode: true },
}),
this.facade.resolveInboundSignatureCandidate(applicationId, content),
]);
if (!application || application.tenantId !== tenantId) {
throw new BadRequestException('短信应用不存在或不属于当前企业');
}
if (!signature) {
throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头');
}
if (application.templateMismatchMode !== 'direct_send') {
throw new BadRequestException('当前应用未允许无模板自由内容直接发送');
}
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, content);
return {
signatureId: signature.id,
drainageInfoId: drainage?.id,
variables: undefined,
rejectionReason: undefined,
};
}
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
const rejected = new Map<string, { code: string; reason: string }>();
for (const phone of phones) {
if (!/^1\d{10}$/.test(phone)) {
rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' });
}
}
const validPhones = phones.filter((phone) => !rejected.has(phone));
if (validPhones.length === 0) {
return rejected;
}
const [globalHits, enterpriseHits] = await Promise.all([
this.prisma.globalBlacklist.findMany({
where: { phoneNumber: { in: validPhones }, status: 'active' },
select: { phoneNumber: true, reason: true },
}),
applicationId
? this.prisma.enterpriseBlacklist.findMany({
where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' },
select: { phoneNumber: true, reason: true },
})
: Promise.resolve([]),
]);
for (const hit of globalHits) {
rejected.set(hit.phoneNumber, {
code: 'GLOBAL_BLACKLIST',
reason: hit.reason?.trim() || '号码命中平台黑名单',
});
}
for (const hit of enterpriseHits) {
rejected.set(hit.phoneNumber, {
code: 'ENTERPRISE_BLACKLIST',
reason: hit.reason?.trim() || '号码命中企业应用黑名单',
});
}
return rejected;
}
async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
options: SendResourceValidationOptions = {},
) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant || tenant.status !== 'active') {
throw new BadRequestException('企业客户不存在或已停用');
}
if (tenant.certificationStatus !== 'approved') {
throw new BadRequestException('企业认证未通过,不能发送短信');
}
if (!applicationId) {
return;
}
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
throw new BadRequestException('短信应用不存在或已停用');
}
if (!application.interfaceEnabled) {
throw new BadRequestException('短信应用接口未开通,不能发送短信');
}
if (!templateId) {
return;
}
const template = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
include: { signature: true },
});
const templateBelongsToApplication =
template && template.tenantId === tenantId && template.applicationId === applicationId;
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
if (
!templateBelongsToApplication ||
(!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
if (!template.signature || template.signature.auditStatus !== 'approved') {
throw new BadRequestException('短信签名未审核通过');
}
}
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount);
if (!result.reserved) {
throw new HttpException(
{
code: 'DAILY_SEND_LIMIT_EXCEEDED',
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
dailyLimit: result.dailyLimit,
requestedCount,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
return result;
}
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
throw new BadRequestException('发送号码数量必须为正整数');
}
const usageDate = shanghaiDateKey();
const usageDateValue = new Date(`${usageDate}T00:00:00.000Z`);
const reserve = (client: Pick<Prisma.TransactionClient, '$queryRaw'>) => {
const reservationId = randomUUID();
return client.$queryRaw<Array<{ tenantId: string; dailyLimit: number; usedCount: number | null }>>(Prisma.sql`
WITH application_limit AS (
SELECT id, "tenantId", COALESCE("dailyLimit", 100000)::integer AS "dailyLimit"
FROM "SmsApplication"
WHERE id = ${applicationId}
), reservation AS (
INSERT INTO "SmsApplicationDailyUsage" (
id, "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt"
)
SELECT ${reservationId}, id, ${usageDate}::date, ${requestedCount}, NOW(), NOW()
FROM application_limit
WHERE ${requestedCount} <= "dailyLimit"
ON CONFLICT ("applicationId", "usageDate") DO UPDATE
SET "usedCount" = "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount",
"updatedAt" = NOW()
WHERE "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount"
<= (SELECT "dailyLimit" FROM application_limit)
RETURNING "usedCount"
)
SELECT application_limit."tenantId", application_limit."dailyLimit", reservation."usedCount"
FROM application_limit
LEFT JOIN reservation ON TRUE
`);
};
const normalizedReservationKey = reservationKey?.trim();
const rows = normalizedReservationKey
? await this.prisma.$transaction(async (tx) => {
// The quota increment and its idempotency record share one short transaction. A worker
// crash can therefore neither lose a successful reservation nor increment it twice.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'daily-quota:' + normalizedReservationKey}, 0))`;
const existing = await tx.smsApplicationDailyReservation.findUnique({
where: { reservationKey: normalizedReservationKey },
});
if (existing) {
if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
throw new ConflictException('日发送配额幂等键已用于另一笔预留');
}
return [
{
tenantId: existing.tenantId,
dailyLimit: existing.dailyLimit,
usedCount: existing.usedCount,
},
];
}
const reservedRows = await reserve(tx);
if (reservedRows.length > 0) {
const row = reservedRows[0];
await tx.smsApplicationDailyReservation.create({
data: {
reservationKey: normalizedReservationKey,
tenantId: row.tenantId,
applicationId,
usageDate: usageDateValue,
requestedCount,
dailyLimit: Number(row.dailyLimit),
usedCount: row.usedCount == null ? null : Number(row.usedCount),
reserved: row.usedCount != null,
},
});
}
return reservedRows;
})
: await reserve(this.prisma);
if (rows.length === 0) {
throw new NotFoundException('短信应用不存在');
}
return {
dailyLimit: Number(rows[0].dailyLimit),
usedCount: rows[0].usedCount == null ? null : Number(rows[0].usedCount),
reserved: rows[0].usedCount != null,
};
}
}