557 lines
23 KiB
TypeScript
557 lines
23 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 { detectDrainageContent } from './drainage-content-detection';
|
|
import type { 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 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: {
|
|
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) {
|
|
await this.prisma.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,
|
|
messageId: `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 (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) {
|
|
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 },
|
|
});
|
|
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || 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) {
|
|
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
|
|
throw new BadRequestException('发送号码数量必须为正整数');
|
|
}
|
|
const usageDate = shanghaiDateKey();
|
|
const reservationId = randomUUID();
|
|
const rows = await this.prisma.$queryRaw<Array<{ dailyLimit: number; usedCount: number | null }>>(Prisma.sql`
|
|
WITH application_limit AS (
|
|
SELECT id, 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."dailyLimit", reservation."usedCount"
|
|
FROM application_limit
|
|
LEFT JOIN reservation ON TRUE
|
|
`);
|
|
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,
|
|
};
|
|
}
|
|
}
|