698 lines
27 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|