feat: aggregate cmpp template mismatch reviews
This commit is contained in:
@@ -331,9 +331,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.getBatchTask(task.id);
|
||||
}
|
||||
|
||||
async listBatchTasks(tenantId?: string, status?: string) {
|
||||
async listBatchTasks(tenantId?: string, status?: string, sourceType = 'client') {
|
||||
const tasks = await this.prisma.smsBatchTask.findMany({
|
||||
where: { tenantId, status },
|
||||
where: { tenantId, status, sourceType },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
@@ -355,11 +355,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}));
|
||||
}
|
||||
|
||||
getBatchTask(taskId: string) {
|
||||
return this.prisma.smsBatchTask.findUnique({
|
||||
where: { id: taskId },
|
||||
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 listClientTaskMessages(taskId: string, tenantId: string) {
|
||||
await this.getBatchTask(taskId, tenantId, 'client');
|
||||
return this.listMessages({ tenantId, taskId });
|
||||
}
|
||||
|
||||
listMessages(query: {
|
||||
@@ -506,8 +515,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return { taskId, enqueued: messages.length };
|
||||
}
|
||||
|
||||
async cancelBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
async cancelBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
|
||||
const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, tenantId, sourceType } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
@@ -524,6 +533,50 @@ 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) {
|
||||
return { reviewTaskId, decision, affected: 0 };
|
||||
}
|
||||
const batchTaskIds = new Set<string>();
|
||||
for (const message of reviewTask.messageRecords) {
|
||||
if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue;
|
||||
if (decision === 'approved') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'queued', errorCode: null, errorMessage: null },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: message.batchTaskId },
|
||||
data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null },
|
||||
});
|
||||
batchTaskIds.add(message.batchTaskId);
|
||||
} else {
|
||||
await this.releaseMessageReservation(
|
||||
message as typeof message & { tenantId: string; batchTaskId: string },
|
||||
'模板不匹配人工审核驳回释放冻结',
|
||||
);
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: message.batchTaskId },
|
||||
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
|
||||
});
|
||||
await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason);
|
||||
}
|
||||
}
|
||||
for (const batchTaskId of batchTaskIds) {
|
||||
await this.enqueueBatchTask(batchTaskId);
|
||||
}
|
||||
return { reviewTaskId, decision, affected: reviewTask.messageRecords.length };
|
||||
}
|
||||
|
||||
async terminateBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
@@ -611,7 +664,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
async processSendJob(job: SendJob) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id: job.messageRecordId },
|
||||
include: { batchTask: true, template: { include: { signature: true } } },
|
||||
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
|
||||
});
|
||||
if (!message || message.status !== 'queued') {
|
||||
return { skipped: true };
|
||||
@@ -1513,6 +1566,60 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
|
||||
} else if (application.tenant.certificationStatus !== 'approved') {
|
||||
await reject('CERT', '企业认证未通过');
|
||||
} else if (!template && application.templateMismatchMode === 'manual_review') {
|
||||
const signature = await this.resolveInboundSignatureCandidate(application.id, data.content);
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核且已报备的签名');
|
||||
} else {
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
content: data.content,
|
||||
phones: [data.phoneNumber],
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
} else {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
|
||||
} else {
|
||||
if (billing.amountCents + billing.totalBillingUnits > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 模板不匹配待审核短信冻结',
|
||||
});
|
||||
}
|
||||
const reviewTask = risk.status === 'pending_review' && risk.task
|
||||
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id)
|
||||
: await this.riskReview.aggregateTemplateMismatch({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
messageRecordId: message.id,
|
||||
signatureId: signature.id,
|
||||
content: data.content,
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: 'pending_review',
|
||||
riskTaskId: reviewTask?.id,
|
||||
auditStatus: 'pending',
|
||||
reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!template) {
|
||||
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
|
||||
} else if (template.auditStatus !== 'approved') {
|
||||
@@ -1616,6 +1723,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
routed: RoutedChannel,
|
||||
attempt: number,
|
||||
@@ -1668,7 +1776,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
queuePriority: normalizeQueuePriority(message.queuePriority),
|
||||
phoneNumber: message.phoneNumber,
|
||||
content: message.content,
|
||||
signature: message.template?.signature?.name ?? 'SMS',
|
||||
signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS',
|
||||
templateId: message.templateId ?? 'unknown',
|
||||
billingUnits: message.billingUnits,
|
||||
route: {
|
||||
@@ -1897,6 +2005,28 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
const match = content.match(/^【([^】]+)】/);
|
||||
if (!match?.[1]) return null;
|
||||
return this.prisma.smsSignature.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
name: match[1],
|
||||
auditStatus: 'approved',
|
||||
reportStatus: 'approved',
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: messageRecordId },
|
||||
data: { reviewTaskId, signatureId, status: 'pending_review' },
|
||||
});
|
||||
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
|
||||
}
|
||||
|
||||
private async recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
@@ -2102,10 +2232,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
id: string;
|
||||
templateId?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
channelId: string,
|
||||
) {
|
||||
let signatureId = message.template?.signature?.id ?? null;
|
||||
let signatureId = message.template?.signature?.id ?? message.signature?.id ?? null;
|
||||
if (!signatureId && message.templateId) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: message.templateId },
|
||||
|
||||
Reference in New Issue
Block a user