feat: harden platform workflows and UI governance
This commit is contained in:
@@ -255,6 +255,12 @@ const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72;
|
||||
const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
|
||||
const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
||||
const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000;
|
||||
const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000;
|
||||
const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
|
||||
const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000;
|
||||
const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||
priority: 1,
|
||||
normal: 100,
|
||||
@@ -270,6 +276,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private receiptTimeoutInitialTimer?: ReturnType<typeof setTimeout>;
|
||||
private receiptTimeoutIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
private receiptTimeoutScanRunning = false;
|
||||
private scheduledDispatchInitialTimer?: ReturnType<typeof setTimeout>;
|
||||
private scheduledDispatchIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
private scheduledDispatchScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -291,11 +300,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
this.receiptTimeoutIntervalTimer.unref?.();
|
||||
}
|
||||
if (process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED !== 'false') {
|
||||
this.scheduledDispatchInitialTimer = setTimeout(
|
||||
() => void this.runScheduledDispatchScan(),
|
||||
SCHEDULED_DISPATCH_INITIAL_DELAY_MS,
|
||||
);
|
||||
this.scheduledDispatchInitialTimer.unref?.();
|
||||
this.scheduledDispatchIntervalTimer = setInterval(
|
||||
() => void this.runScheduledDispatchScan(),
|
||||
positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS),
|
||||
);
|
||||
this.scheduledDispatchIntervalTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
|
||||
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
|
||||
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
|
||||
if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer);
|
||||
await this.worker?.close();
|
||||
await this.sendQueue?.close();
|
||||
await this.gatewayQueue?.close();
|
||||
@@ -307,21 +330,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const schedule = parseSchedule(data);
|
||||
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
|
||||
this.resolveTemplateMessageClassification(data.templateId, data.content),
|
||||
this.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
|
||||
this.resolveUnitPrice(data.tenantId, data.applicationId),
|
||||
this.resolveQueuePriority(data.tenantId, data.applicationId),
|
||||
this.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
|
||||
]);
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
phones,
|
||||
variables: data.variables,
|
||||
createdById: data.createdById,
|
||||
});
|
||||
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,
|
||||
});
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
@@ -703,33 +728,64 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.SMS_SCHEDULED_DISPATCH_STALE_MS,
|
||||
DEFAULT_SCHEDULED_DISPATCH_STALE_MS,
|
||||
));
|
||||
const tasks = await this.prisma.smsBatchTask.findMany({
|
||||
where: { status: 'scheduled', scheduledAt: { lte: now } },
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'scheduled', scheduledAt: { lte: now } },
|
||||
{ status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } },
|
||||
],
|
||||
},
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
});
|
||||
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
|
||||
for (const task of tasks) {
|
||||
const candidateStatus = task.status || 'scheduled';
|
||||
const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching';
|
||||
const claimed = await this.prisma.smsBatchTask.updateMany({
|
||||
where: {
|
||||
id: task.id,
|
||||
status: candidateStatus,
|
||||
...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }),
|
||||
},
|
||||
data: { status: claimedStatus },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
let reservationEstablished = false;
|
||||
let dispatchPrepared = false;
|
||||
try {
|
||||
await this.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
|
||||
select: { id: true, amountCents: true, billingUnits: true },
|
||||
take: 100000,
|
||||
});
|
||||
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: task.tenantId,
|
||||
amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '定时任务到点冻结',
|
||||
});
|
||||
const existingReservation = await this.prisma.accountTransaction.findFirst({
|
||||
where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id },
|
||||
select: { id: true },
|
||||
});
|
||||
reservationEstablished = Boolean(existingReservation);
|
||||
if (!reservationEstablished) {
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: task.tenantId,
|
||||
amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '定时任务到点冻结',
|
||||
});
|
||||
reservationEstablished = true;
|
||||
}
|
||||
}
|
||||
dispatchPrepared = true;
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'queued' },
|
||||
@@ -738,6 +794,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '定时任务到点执行失败';
|
||||
if (reservationEstablished || dispatchPrepared) {
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` },
|
||||
});
|
||||
results.push({ taskId: task.id, status: 'retrying', reason });
|
||||
continue;
|
||||
}
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'rejected', errorMessage: reason },
|
||||
@@ -752,6 +816,18 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return { dispatched: results.filter((result) => result.status === 'queued').length, results };
|
||||
}
|
||||
|
||||
private async runScheduledDispatchScan() {
|
||||
if (this.scheduledDispatchScanRunning) return;
|
||||
this.scheduledDispatchScanRunning = true;
|
||||
try {
|
||||
await this.dispatchDueScheduledTasks();
|
||||
} catch (error) {
|
||||
this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
this.scheduledDispatchScanRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.worker) {
|
||||
return { status: 'already_started' };
|
||||
@@ -852,7 +928,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: {
|
||||
status: { in: ['pending', 'requeued'] },
|
||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||
OR: [
|
||||
data.submitId ? { submitId: data.submitId } : undefined,
|
||||
data.messageId ? { messageId: data.messageId } : undefined,
|
||||
@@ -1189,15 +1265,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
traceId: data.traceId,
|
||||
messageId: data.messageId,
|
||||
submitId: data.submitId,
|
||||
status: 'pending',
|
||||
failureCode: data.failureCode,
|
||||
failureMessage: data.failureMessage,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||
rawPayload: data.rawPayload,
|
||||
resolvedAt: null,
|
||||
resolvedStatus: null,
|
||||
},
|
||||
create: {
|
||||
streamMessageId: data.streamMessageId,
|
||||
@@ -1369,19 +1442,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
let retryStreamMessageId: string;
|
||||
try {
|
||||
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
|
||||
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!publishedStreamMessageId) {
|
||||
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
|
||||
}
|
||||
retryStreamMessageId = publishedStreamMessageId;
|
||||
} catch (error) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.update({ where: { id }, data: { status: 'pending' } });
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const updated = await this.prisma.gatewaySubmitDeadLetter.update({
|
||||
where: { id },
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
@@ -1389,6 +1466,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (finalized.count !== 1 && updated.status !== 'resolved') {
|
||||
throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
@@ -1409,6 +1493,69 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
));
|
||||
const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where: { status: 'requeueing', updatedAt: { lt: staleCutoff } },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
let recovered = 0;
|
||||
let failed = 0;
|
||||
for (const deadLetter of stale) {
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'requeue_recovering' },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
try {
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
const retryStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号');
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: retryStreamMessageId,
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (finalized.count === 1) {
|
||||
recovered += 1;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: deadLetter.tenantId ?? undefined,
|
||||
action: 'gateway.submit_dead_letter_requeue_recovered',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: deadLetter.id,
|
||||
detail: { retryStreamMessageId, requeueKey },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return { recovered, failed };
|
||||
}
|
||||
|
||||
async requeueDownstreamDelivery(id: string) {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { id },
|
||||
@@ -1440,10 +1587,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
...payload,
|
||||
};
|
||||
const retriedAt = new Date();
|
||||
const requeued = await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: {
|
||||
id: delivery.id,
|
||||
status: delivery.status,
|
||||
updatedAt: delivery.updatedAt,
|
||||
},
|
||||
data: {
|
||||
status: 'pending',
|
||||
status: 'manual_requeueing',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: retriedAt,
|
||||
@@ -1459,6 +1610,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
@@ -1471,7 +1625,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
messageId: delivery.messageId,
|
||||
previousStatus: delivery.status,
|
||||
previousRetryCount: delivery.retryCount,
|
||||
manualRetryCount: requeued.manualRetryCount,
|
||||
manualRetryCount: (delivery.manualRetryCount ?? 0) + 1,
|
||||
lastRetriedAt: retriedAt,
|
||||
},
|
||||
},
|
||||
@@ -1494,6 +1648,32 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async recoverStaleDownstreamManualRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
));
|
||||
const stale = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
|
||||
select: { id: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 500,
|
||||
});
|
||||
let recovered = 0;
|
||||
for (const delivery of stale) {
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt },
|
||||
data: {
|
||||
status: 'pending',
|
||||
nextRetryAt: null,
|
||||
lastError: '人工重投进程中断,已恢复为待投递',
|
||||
},
|
||||
});
|
||||
recovered += updated.count;
|
||||
}
|
||||
return { recovered };
|
||||
}
|
||||
|
||||
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||
const uniqueIds = [...new Set(ids.filter(Boolean))];
|
||||
if (uniqueIds.length === 0) {
|
||||
@@ -1949,7 +2129,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.recordCmppFailureReceipt(message, code, reason);
|
||||
};
|
||||
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
|
||||
const drainageInfoId = await this.resolveDrainageInfoId(options.signatureId, data.content);
|
||||
const drainage = await this.resolveDrainageInfoMatch(options.signatureId, data.content);
|
||||
const drainageInfoId = drainage?.id;
|
||||
const drainageReason = drainageRejectionReason(drainage);
|
||||
if (drainageReason) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { drainageInfoId, signatureId: options.signatureId },
|
||||
});
|
||||
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
|
||||
return;
|
||||
}
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
@@ -2011,6 +2201,24 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||
} else {
|
||||
const drainage = await this.resolveDrainageInfoMatch(signature.id, data.content);
|
||||
const drainageReason = drainageRejectionReason(drainage);
|
||||
if (drainageReason) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { drainageInfoId: drainage?.id, signatureId: signature.id },
|
||||
});
|
||||
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
messageId,
|
||||
messageRecordId: message.id,
|
||||
taskId: task.id,
|
||||
status: 'rejected',
|
||||
};
|
||||
}
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
@@ -2037,7 +2245,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
const reviewTask = risk.status === 'pending_review' && risk.task
|
||||
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, await this.resolveDrainageInfoId(signature.id, data.content))
|
||||
? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id)
|
||||
: await this.riskReview.aggregateTemplateMismatch({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
@@ -2143,12 +2351,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (this.receiptTimeoutScanRunning) return;
|
||||
this.receiptTimeoutScanRunning = true;
|
||||
try {
|
||||
const [receiptResult, downstreamResult] = await Promise.all([
|
||||
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
|
||||
this.markUnknownTimeout({}),
|
||||
this.markExpiredDownstreamDeliveries(),
|
||||
this.recoverStaleGatewaySubmitRequeues(),
|
||||
this.recoverStaleDownstreamManualRequeues(),
|
||||
]);
|
||||
if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
|
||||
if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
|
||||
if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
|
||||
if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
|
||||
} catch (error) {
|
||||
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
@@ -2510,21 +2722,67 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveTemplateMessageClassification(templateId: string | undefined, content: string) {
|
||||
if (!templateId) return { signatureId: undefined, drainageInfoId: undefined };
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
select: { signatureId: true },
|
||||
});
|
||||
const signatureId = template?.signatureId ?? undefined;
|
||||
return { signatureId, drainageInfoId: await this.resolveDrainageInfoId(signatureId, content) };
|
||||
private 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.resolveDrainageInfoMatch(template.signatureId, content);
|
||||
return {
|
||||
signatureId: template.signatureId,
|
||||
drainageInfoId: drainage?.id,
|
||||
variables,
|
||||
rejectionReason: drainageRejectionReason(drainage),
|
||||
};
|
||||
}
|
||||
|
||||
if (!applicationId) {
|
||||
throw new BadRequestException('自由内容短信必须关联企业应用');
|
||||
}
|
||||
const [application, signature] = await Promise.all([
|
||||
this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, templateMismatchMode: true },
|
||||
}),
|
||||
this.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.resolveDrainageInfoMatch(signature.id, content);
|
||||
return {
|
||||
signatureId: signature.id,
|
||||
drainageInfoId: drainage?.id,
|
||||
variables: undefined,
|
||||
rejectionReason: drainageRejectionReason(drainage),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveDrainageInfoId(signatureId: string | undefined, content: string) {
|
||||
private async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
|
||||
if (!signatureId) return undefined;
|
||||
const candidates = await this.prisma.smsDrainageInfo.findMany({
|
||||
where: { signatureId, auditStatus: 'approved' },
|
||||
select: { id: true, url: true, updatedAt: true },
|
||||
where: { signatureId, auditStatus: { not: 'deleted' } },
|
||||
select: { id: true, url: true, auditStatus: true, updatedAt: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const matches = candidates
|
||||
@@ -2534,7 +2792,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (matches.length === 0) return undefined;
|
||||
const longestLength = matches[0].normalizedUrl.length;
|
||||
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
|
||||
return longestMatches.length === 1 ? longestMatches[0].id : undefined;
|
||||
if (longestMatches.length !== 1) {
|
||||
throw new BadRequestException({
|
||||
code: 'DRAINAGE_MATCH_AMBIGUOUS',
|
||||
message: '短信内容同时匹配多条等长引流地址,无法确定报备资料',
|
||||
drainageInfoIds: longestMatches.map((item) => item.id),
|
||||
});
|
||||
}
|
||||
const matched = longestMatches[0];
|
||||
return { id: matched.id, auditStatus: matched.auditStatus };
|
||||
}
|
||||
|
||||
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
|
||||
@@ -3143,19 +3409,39 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return response.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
private async publishGatewaySubmitCommand(command: unknown) {
|
||||
return this.getRedis().xadd(
|
||||
process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM,
|
||||
'*',
|
||||
'messageType',
|
||||
'SubmitCommand',
|
||||
'data',
|
||||
JSON.stringify(command),
|
||||
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||
const redis = this.getRedis();
|
||||
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
|
||||
const payload = JSON.stringify(command);
|
||||
if (!idempotencyKey) {
|
||||
return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload);
|
||||
}
|
||||
const result = await redis.eval(
|
||||
`local existing = redis.call('GET', KEYS[2])
|
||||
if existing then return existing end
|
||||
local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1])
|
||||
redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2])
|
||||
return streamId`,
|
||||
2,
|
||||
stream,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS),
|
||||
);
|
||||
return typeof result === 'string' ? result : String(result ?? '');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
|
||||
return `gateway:submit:requeue:${deadLetterId}:${attempt}`;
|
||||
}
|
||||
|
||||
function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) {
|
||||
if (!drainage || drainage.auditStatus === 'approved') return undefined;
|
||||
return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`;
|
||||
}
|
||||
|
||||
function statusFromRisk(status: string, scheduled: boolean) {
|
||||
if (status === 'rejected') {
|
||||
return 'rejected';
|
||||
|
||||
Reference in New Issue
Block a user