feat: strengthen risk controls and review workflows
This commit is contained in:
@@ -402,6 +402,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const phones = [...new Set(data.phones ?? [])];
|
||||
const schedule = parseSchedule(data);
|
||||
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||
const phoneRejections = await this.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
|
||||
const sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
|
||||
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
|
||||
this.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
|
||||
this.resolveUnitPrice(data.tenantId, data.applicationId),
|
||||
@@ -419,16 +421,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
phones,
|
||||
variables: messageClassification.variables ?? data.variables,
|
||||
createdById: data.createdById,
|
||||
sourceType: data.sourceType ?? 'client',
|
||||
});
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
taskId: risk.task?.id,
|
||||
content: data.content,
|
||||
phoneCount: phones.length,
|
||||
phoneCount: sendablePhones.length,
|
||||
unitPrice,
|
||||
});
|
||||
const batchStatus = statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
|
||||
const batchStatus = 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({
|
||||
@@ -439,8 +444,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException('企业账户余额不足');
|
||||
}
|
||||
}
|
||||
if (data.applicationId && risk.status !== 'rejected') {
|
||||
await this.reserveDailySendQuota(data.applicationId, phones.length);
|
||||
if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) {
|
||||
await this.reserveDailySendQuota(data.applicationId, sendablePhones.length);
|
||||
}
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
@@ -485,35 +490,50 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: schedule.scheduledAt?.toISOString(),
|
||||
},
|
||||
status: batchStatus === 'rejected' ? 'rejected' : 'accepted',
|
||||
status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted',
|
||||
},
|
||||
});
|
||||
if (phones.length > 0) {
|
||||
await this.prisma.smsMessageRecord.createMany({
|
||||
data: phones.map((phone) => ({
|
||||
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,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: billing.unitPrice,
|
||||
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
|
||||
unitPrice: rejection ? 0 : billing.unitPrice,
|
||||
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
|
||||
queuePriority,
|
||||
clientSrcId: accessNumber.clientSrcId,
|
||||
applicationExtension: accessNumber.applicationExtension,
|
||||
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
|
||||
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
|
||||
})),
|
||||
status,
|
||||
submitStatus: rejection ? 'rejected' : undefined,
|
||||
errorCode: rejection?.code,
|
||||
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (batchStatus === 'ready') {
|
||||
if (batchStatus === 'ready' && sendablePhones.length > 0) {
|
||||
await this.enqueueBatchTask(task.id);
|
||||
} else if (batchStatus === 'failed') {
|
||||
await this.refreshTaskProgress(task.id);
|
||||
}
|
||||
return this.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
|
||||
}
|
||||
@@ -743,18 +763,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
|
||||
const reviewTask = await this.prisma.smsSendTask.findUnique({
|
||||
where: { id: reviewTaskId },
|
||||
include: {
|
||||
messageRecords: {
|
||||
where: { status: 'pending_review' },
|
||||
include: { batchTask: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!reviewTask || reviewTask.messageRecords.length === 0) {
|
||||
if (!reviewTask) {
|
||||
return { reviewTaskId, decision, affected: 0 };
|
||||
}
|
||||
const messageRecords = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
status: 'pending_review',
|
||||
OR: [
|
||||
{ reviewTaskId },
|
||||
{ batchTask: { riskTaskId: reviewTaskId } },
|
||||
],
|
||||
},
|
||||
include: { batchTask: true },
|
||||
});
|
||||
if (messageRecords.length === 0) {
|
||||
return { reviewTaskId, decision, affected: 0 };
|
||||
}
|
||||
const batchTaskIds = new Set<string>();
|
||||
for (const message of reviewTask.messageRecords) {
|
||||
for (const message of messageRecords) {
|
||||
if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue;
|
||||
if (decision === 'approved') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
@@ -781,7 +808,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
for (const batchTaskId of batchTaskIds) {
|
||||
await this.enqueueBatchTask(batchTaskId);
|
||||
}
|
||||
return { reviewTaskId, decision, affected: reviewTask.messageRecords.length };
|
||||
return { reviewTaskId, decision, affected: messageRecords.length };
|
||||
}
|
||||
|
||||
async terminateBatchTask(taskId: string) {
|
||||
@@ -1440,8 +1467,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
|
||||
const application = await this.findInboundApplication(data.account);
|
||||
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
||||
@@ -2244,23 +2271,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
select: {
|
||||
cmppAccount: true,
|
||||
interfaceEnabled: true,
|
||||
status: true,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
|
||||
eventType: data.deliveryType,
|
||||
payload: data.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
|
||||
if (deliveryAllowed) {
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
|
||||
eventType: data.deliveryType,
|
||||
payload: data.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
if (application?.interfaceEnabled !== true) {
|
||||
return null;
|
||||
@@ -2274,12 +2305,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
messageId: data.messageId,
|
||||
deliveryType: data.deliveryType,
|
||||
payload,
|
||||
retryEnabled: data.deliveryType === 'uplink'
|
||||
retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink'
|
||||
? application?.downstreamUplinkRetryEnabled ?? true
|
||||
: application?.downstreamReceiptRetryEnabled ?? true,
|
||||
status: 'pending',
|
||||
: application?.downstreamReceiptRetryEnabled ?? true),
|
||||
status: deliveryAllowed ? 'pending' : 'abandoned',
|
||||
lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
|
||||
},
|
||||
});
|
||||
if (!deliveryAllowed) {
|
||||
return delivery;
|
||||
}
|
||||
try {
|
||||
const result = await this.postGatewayControl(
|
||||
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
|
||||
@@ -2413,7 +2448,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
const application = await this.findInboundApplication(data.account);
|
||||
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
@@ -2445,7 +2480,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
: data.phoneNumber
|
||||
? [data.phoneNumber.trim()]
|
||||
: [];
|
||||
if (phoneNumbers.length === 0 || phoneNumbers.some((phoneNumber) => !/^1[3-9]\d{9}$/.test(phoneNumber))) {
|
||||
if (phoneNumbers.length === 0) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
|
||||
@@ -2453,6 +2488,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
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');
|
||||
@@ -2586,7 +2624,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
})
|
||||
: [];
|
||||
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
|
||||
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => !persistedByPhone.has(phoneNumber)).length;
|
||||
const phoneRejections = await this.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers);
|
||||
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => (
|
||||
!persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber)
|
||||
)).length;
|
||||
const dailyQuota = missingPhoneCount > 0
|
||||
? await this.tryReserveDailySendQuota(application.id, missingPhoneCount)
|
||||
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
|
||||
@@ -2601,6 +2642,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
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()}`),
|
||||
}));
|
||||
@@ -2622,7 +2664,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
...data,
|
||||
phoneNumber: submission.phoneNumber,
|
||||
phoneNumbers: undefined,
|
||||
}, submission.messageId, submitGroupMessageId, dailyLimitRejection))));
|
||||
}, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection))));
|
||||
}
|
||||
const first = results[0];
|
||||
return {
|
||||
@@ -2811,6 +2853,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
synchronousRejection?: { code: string; reason: string },
|
||||
receiptRejection?: { code: string; reason: string },
|
||||
) {
|
||||
const application = await this.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
@@ -2819,9 +2862,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
|
||||
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
@@ -2870,8 +2910,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
phoneNumber: data.phoneNumber,
|
||||
content: data.content,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: billing.unitPrice,
|
||||
amountCents: billing.amountCents,
|
||||
unitPrice: receiptRejection ? 0 : billing.unitPrice,
|
||||
amountCents: receiptRejection ? 0 : billing.amountCents,
|
||||
queuePriority,
|
||||
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
cmppSubmitGroupMessageId: submitGroupMessageId,
|
||||
@@ -2921,6 +2961,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
content: data.content,
|
||||
variables: options.templateId ? templateVariables : undefined,
|
||||
phones: [data.phoneNumber],
|
||||
sourceType: 'cmpp',
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
@@ -2929,7 +2970,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (risk.status === 'pending_review') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'pending_review', signatureId: options.signatureId, drainageInfoId },
|
||||
data: {
|
||||
status: 'pending_review',
|
||||
reviewTaskId: risk.task?.id,
|
||||
signatureId: options.signatureId,
|
||||
drainageInfoId,
|
||||
},
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
@@ -2964,7 +3010,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
await this.enqueueBatchTask(task.id);
|
||||
};
|
||||
if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
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 接口已停用');
|
||||
@@ -2998,6 +3046,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
applicationId: application.id,
|
||||
content: data.content,
|
||||
phones: [data.phoneNumber],
|
||||
sourceType: 'cmpp',
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
@@ -3651,6 +3700,44 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant || tenant.status !== 'active') {
|
||||
|
||||
Reference in New Issue
Block a user