feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 12:48:10 +08:00
parent d13ca0713a
commit f0e843436c
33 changed files with 3332 additions and 452 deletions
+217 -127
View File
@@ -1,20 +1,41 @@
import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
import {
BadRequestException,
ConflictException,
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 { randomUUID } from 'node:crypto';
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 {
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';
import type {
SendResourceValidationOptions,
SendSubmissionCallbacks,
SendSubmissionService,
} from './send-submission.service';
/**
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
@@ -33,7 +54,13 @@ export class SendBatchEntryService {
) {}
private releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.callbacks.releaseMessageReservation(message, remark);
@@ -56,15 +83,20 @@ export class SendBatchEntryService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async createBatchTask(data: CreateBatchTaskDto) {
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.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),
@@ -73,16 +105,16 @@ async createBatchTask(data: CreateBatchTaskDto) {
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',
});
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) {
@@ -97,9 +129,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
}
sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone));
frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0;
frequencyBatchReason = frequencyRejectedAll
? [...frequencyRejections.values()][0]?.reason
: undefined;
frequencyBatchReason = frequencyRejectedAll ? [...frequencyRejections.values()][0]?.reason : undefined;
}
if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) {
await this.prisma.smsSendTask.update({
@@ -124,7 +154,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
? 'rejected'
: risk.status === 'approved' && sendablePhones.length === 0
? 'failed'
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
const shouldReserveBalance = batchStatus === 'ready';
if (risk.status === 'approved') {
const accountCheck = await this.billing.checkAccount({
@@ -140,6 +170,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
}
const task = await this.prisma.smsBatchTask.create({
data: {
...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
tenantId: data.tenantId,
applicationId: data.applicationId,
templateId: data.templateId,
@@ -150,7 +181,12 @@ async createBatchTask(data: CreateBatchTaskDto) {
phoneTotal: phones.length,
status: batchStatus,
riskTaskId: risk.task?.id,
auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved',
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,
@@ -185,44 +221,80 @@ async createBatchTask(data: CreateBatchTaskDto) {
},
});
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),
};
}),
});
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 (batchStatus === 'ready' && sendablePhones.length > 0) {
if (!httpRequest && batchStatus === 'ready' && sendablePhones.length > 0) {
await this.facade.enqueueBatchTask(task.id);
} else if (batchStatus === 'failed') {
await this.facade.refreshTaskProgress(task.id);
@@ -230,7 +302,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
}
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
if (!data.applicationId) {
throw new BadRequestException('公开 HTTP 发送必须关联企业应用');
}
@@ -250,7 +322,7 @@ async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
});
}
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
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' } } },
@@ -261,7 +333,7 @@ async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
return task;
}
async previewImport(data: ImportPreviewDto) {
async previewImport(data: ImportPreviewDto) {
const sizeBytes = Buffer.byteLength(data.content, 'utf8');
if (sizeBytes > 20 * 1024 * 1024) {
throw new BadRequestException('导入文件不能超过 20MB');
@@ -270,10 +342,12 @@ async previewImport(data: ImportPreviewDto) {
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 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 },
@@ -299,7 +373,11 @@ async previewImport(data: ImportPreviewDto) {
}
const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
if (missingVariables.length > 0) {
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` });
errors.push({
rowNumber: row.rowNumber,
phoneNumber: row.phoneNumber,
reason: `变量列缺失:${missingVariables.join(',')}`,
});
continue;
}
seen.add(row.phoneNumber);
@@ -316,7 +394,7 @@ async previewImport(data: ImportPreviewDto) {
};
}
async confirmImport(data: ConfirmImportDto) {
async confirmImport(data: ConfirmImportDto) {
const preview = await this.facade.previewImport({
tenantId: data.tenantId,
applicationId: data.applicationId,
@@ -329,7 +407,7 @@ async confirmImport(data: ConfirmImportDto) {
return this.facade.createBatchTask({ ...data, phones: preview.phones });
}
async resolveUnitPrice(tenantId: string, applicationId?: string) {
async resolveUnitPrice(tenantId: string, applicationId?: string) {
if (!applicationId) {
return 0;
}
@@ -343,7 +421,7 @@ async resolveUnitPrice(tenantId: string, applicationId?: string) {
return moneyToNumber(application.customerUnitPrice);
}
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
if (!applicationId) {
return 'normal';
}
@@ -357,7 +435,7 @@ async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<Qu
return normalizeQueuePriority(application.queuePriority);
}
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
if (!applicationId) {
return { clientSrcId: null, applicationExtension: null };
}
@@ -374,7 +452,7 @@ async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
};
}
async resolveTemplateMessageClassification(
async resolveTemplateMessageClassification(
tenantId: string,
applicationId: string | undefined,
templateId: string | undefined,
@@ -385,8 +463,13 @@ async resolveTemplateMessageClassification(
where: { id: templateId },
include: { signature: true },
});
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId
|| template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
if (
!template ||
template.tenantId !== tenantId ||
template.applicationId !== applicationId ||
template.auditStatus !== 'approved' ||
template.signature?.auditStatus !== 'approved'
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
const variables = matchTemplateContent(template.content, content);
@@ -431,7 +514,7 @@ async resolveTemplateMessageClassification(
};
}
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
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)) {
@@ -469,7 +552,7 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
return rejected;
}
async validateSendResources(
async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
@@ -499,12 +582,14 @@ async validateSendResources(
where: { id: templateId },
include: { signature: true },
});
const templateBelongsToApplication = template
&& template.tenantId === tenantId
&& template.applicationId === applicationId;
const templateBelongsToApplication =
template && template.tenantId === tenantId && template.applicationId === applicationId;
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) {
if (
!templateBelongsToApplication ||
(!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
if (!template.signature || template.signature.auditStatus !== 'approved') {
@@ -512,20 +597,23 @@ async validateSendResources(
}
}
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
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);
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) {
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
throw new BadRequestException('发送号码数量必须为正整数');
}
@@ -560,40 +648,42 @@ async tryReserveDailySendQuota(applicationId: string, requestedCount: number, re
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,
},
// 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 },
});
}
return reservedRows;
})
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('短信应用不存在');