feat: 增强下游重投与签名质量检测

This commit is contained in:
hectorzhao
2026-08-12 17:05:53 +08:00
parent 1d8d6701a6
commit 4994841709
23 changed files with 960 additions and 67 deletions
@@ -0,0 +1,57 @@
CREATE TABLE "DownstreamRequeueTask" (
"id" TEXT NOT NULL,
"taskNo" TEXT NOT NULL,
"tenantId" TEXT,
"applicationId" TEXT,
"status" TEXT NOT NULL DEFAULT 'queued',
"filterSnapshot" JSONB NOT NULL,
"snapshotAt" TIMESTAMP(3) NOT NULL,
"reason" TEXT NOT NULL,
"ratePerSecond" INTEGER NOT NULL DEFAULT 10,
"consecutiveFailureLimit" INTEGER NOT NULL DEFAULT 10,
"totalCount" INTEGER NOT NULL DEFAULT 0,
"successCount" INTEGER NOT NULL DEFAULT 0,
"failedCount" INTEGER NOT NULL DEFAULT 0,
"skippedCount" INTEGER NOT NULL DEFAULT 0,
"waitingCount" INTEGER NOT NULL DEFAULT 0,
"consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"createdById" TEXT,
"startedAt" TIMESTAMP(3),
"pausedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DownstreamRequeueTask_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "DownstreamRequeueTaskItem" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"deliveryId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'queued',
"previousStatus" TEXT NOT NULL,
"skipReason" TEXT,
"errorMessage" TEXT,
"claimedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DownstreamRequeueTaskItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DownstreamRequeueTask_taskNo_key" ON "DownstreamRequeueTask"("taskNo");
CREATE INDEX "DownstreamRequeueTask_status_createdAt_idx" ON "DownstreamRequeueTask"("status", "createdAt");
CREATE INDEX "DownstreamRequeueTask_applicationId_status_createdAt_idx" ON "DownstreamRequeueTask"("applicationId", "status", "createdAt");
CREATE INDEX "DownstreamRequeueTask_tenantId_createdAt_idx" ON "DownstreamRequeueTask"("tenantId", "createdAt");
CREATE UNIQUE INDEX "DownstreamRequeueTaskItem_taskId_deliveryId_key" ON "DownstreamRequeueTaskItem"("taskId", "deliveryId");
CREATE INDEX "DownstreamRequeueTaskItem_taskId_status_createdAt_idx" ON "DownstreamRequeueTaskItem"("taskId", "status", "createdAt");
CREATE INDEX "DownstreamRequeueTaskItem_applicationId_status_createdAt_idx" ON "DownstreamRequeueTaskItem"("applicationId", "status", "createdAt");
CREATE INDEX "DownstreamRequeueTaskItem_deliveryId_status_idx" ON "DownstreamRequeueTaskItem"("deliveryId", "status");
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "DownstreamRequeueTaskItem" ADD CONSTRAINT "DownstreamRequeueTaskItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "DownstreamRequeueTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DownstreamRequeueTaskItem" ADD CONSTRAINT "DownstreamRequeueTaskItem_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "CmppDownstreamDelivery"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+84 -22
View File
@@ -43,6 +43,7 @@ model Tenant {
smsUplinkMessages SmsUplinkMessage[]
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
cmppDownstreamDeliveries CmppDownstreamDelivery[]
downstreamRequeueTasks DownstreamRequeueTask[]
cmppDownstreamConnections CmppDownstreamConnection[]
cmppConnectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
@@ -98,6 +99,7 @@ model User {
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
createdDownstreamRequeueTasks DownstreamRequeueTask[] @relation("DownstreamRequeueTaskCreator")
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
@@ -451,6 +453,7 @@ model SmsApplication {
uplinkMessages SmsUplinkMessage[]
uplinkMatchCandidates SmsUplinkMatchCandidate[]
downstreamDeliveries CmppDownstreamDelivery[]
downstreamRequeueTasks DownstreamRequeueTask[]
downstreamConnections CmppDownstreamConnection[]
connectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
@@ -1056,20 +1059,20 @@ model DrainageReportMaterial {
}
model ChannelSignatureReportTask {
id String @id @default(cuid())
id String @id @default(cuid())
tenantId String
signatureId String
channelId String
carrier String?
approvedAt DateTime?
approvalScope String @default("legacy_channel")
reportType String @default("signature")
approvalScope String @default("legacy_channel")
reportType String @default("signature")
drainageItemId String?
status String @default("pending")
status String @default("pending")
reason String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signature SmsSignature @relation(fields: [signatureId], references: [id])
channel SmsChannel @relation(fields: [channelId], references: [id])
@@ -1088,22 +1091,22 @@ model ChannelSignatureReportTask {
}
model SignatureRetirementRule {
id String @id @default(cuid())
ruleType String
targetId String?
targetKey String @default("")
enabled Boolean @default(true)
mobileWindowDays Int @default(30)
mobileThreshold Int @default(1)
unicomWindowDays Int @default(30)
unicomThreshold Int @default(1)
telecomWindowDays Int @default(30)
telecomThreshold Int @default(1)
messageTemplate String?
version Int @default(1)
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
ruleType String
targetId String?
targetKey String @default("")
enabled Boolean @default(true)
mobileWindowDays Int @default(30)
mobileThreshold Int @default(1)
unicomWindowDays Int @default(30)
unicomThreshold Int @default(1)
telecomWindowDays Int @default(30)
telecomThreshold Int @default(1)
messageTemplate String?
version Int @default(1)
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ruleType, targetKey])
@@index([ruleType, enabled])
@@ -2107,6 +2110,7 @@ model CmppDownstreamDelivery {
application SmsApplication @relation(fields: [applicationId], references: [id])
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
attempts CmppDownstreamDeliveryAttempt[]
requeueItems DownstreamRequeueTaskItem[]
@@index([tenantId, status, createdAt])
@@index([applicationId, status, createdAt])
@@ -2116,6 +2120,64 @@ model CmppDownstreamDelivery {
@@index([status, ackDeadlineAt])
}
model DownstreamRequeueTask {
id String @id @default(cuid())
taskNo String @unique
tenantId String?
applicationId String?
status String @default("queued")
filterSnapshot Json
snapshotAt DateTime
reason String
ratePerSecond Int @default(10)
consecutiveFailureLimit Int @default(10)
totalCount Int @default(0)
successCount Int @default(0)
failedCount Int @default(0)
skippedCount Int @default(0)
waitingCount Int @default(0)
consecutiveFailures Int @default(0)
lastError String?
createdById String?
startedAt DateTime?
pausedAt DateTime?
finishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id])
createdBy User? @relation("DownstreamRequeueTaskCreator", fields: [createdById], references: [id])
items DownstreamRequeueTaskItem[]
@@index([status, createdAt])
@@index([applicationId, status, createdAt])
@@index([tenantId, createdAt])
}
model DownstreamRequeueTaskItem {
id String @id @default(cuid())
taskId String
deliveryId String
applicationId String
status String @default("queued")
previousStatus String
skipReason String?
errorMessage String?
claimedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task DownstreamRequeueTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
delivery CmppDownstreamDelivery @relation(fields: [deliveryId], references: [id], onDelete: Restrict)
@@unique([taskId, deliveryId])
@@index([taskId, status, createdAt])
@@index([applicationId, status, createdAt])
@@index([deliveryId, status])
}
model CmppDownstreamDeliveryAttempt {
id String @id @default(cuid())
deliveryId String
@@ -225,6 +225,14 @@ export class AdminOperationsController {
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
}
@Post('gateway-submit-dead-letters/:id/resolve')
resolveGatewaySubmitDeadLetter(
@Param('id') id: string,
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
}
@Get('receipt-anomalies')
receiptAnomalies(
@Query('tenantId') tenantId?: string,
@@ -354,6 +362,44 @@ export class AdminOperationsController {
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
return this.sendChain.batchRequeueDownstreamDeliveries(body.ids ?? []);
}
@Post('downstream-requeue-tasks/preview')
previewDownstreamRequeueTask(@Body() body: { filter?: Record<string, string | undefined> }) {
return this.sendChain.previewDownstreamRequeueTask(body.filter ?? {});
}
@Post('downstream-requeue-tasks')
createDownstreamRequeueTask(
@Body() body: { filter?: Record<string, string | undefined>; snapshotAt?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.createDownstreamRequeueTask({
filter: body.filter ?? {},
snapshotAt: body.snapshotAt ?? '',
reason: body.reason ?? '',
ratePerSecond: body.ratePerSecond,
consecutiveFailureLimit: body.consecutiveFailureLimit,
}, operatorId);
}
@Get('downstream-requeue-tasks')
listDownstreamRequeueTasks(@Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) });
}
@Get('downstream-requeue-tasks/:id')
getDownstreamRequeueTask(@Param('id') id: string) {
return this.sendChain.getDownstreamRequeueTask(id);
}
@Post('downstream-requeue-tasks/:id/:action')
changeDownstreamRequeueTaskStatus(
@Param('id') id: string,
@Param('action') action: 'pause' | 'resume' | 'terminate',
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.changeDownstreamRequeueTaskStatus(id, action, operatorId);
}
}
@ApiTags('admin-system-logs')
@@ -853,12 +853,14 @@ describe('SendChainService', () => {
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
const { service } = createService();
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(1_000);
expect(dispatch).toHaveBeenCalledTimes(1);
@@ -870,6 +872,8 @@ describe('SendChainService', () => {
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
jest.useRealTimers();
}
});
@@ -3376,6 +3380,41 @@ describe('SendChainService', () => {
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('marks a pending gateway submit exception as resolved without requeueing it', async () => {
const { service, prisma } = createService();
prisma.gatewaySubmitDeadLetter.findUnique
.mockResolvedValueOnce({
id: 'dead-1',
tenantId: 'tenant-1',
status: 'pending',
messageId: 'MSG-1',
submitId: 'SUB-1',
})
.mockResolvedValueOnce({ id: 'dead-1', status: 'resolved', resolvedStatus: 'manually_resolved' });
prisma.gatewaySubmitDeadLetter.updateMany.mockResolvedValueOnce({ count: 1 });
await expect(service.resolveGatewaySubmitDeadLetter('dead-1', 'user-1')).resolves.toEqual(
expect.objectContaining({ status: 'resolved', resolvedStatus: 'manually_resolved' }),
);
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'pending' },
data: expect.objectContaining({
status: 'resolved',
resolvedAt: expect.any(Date),
resolvedStatus: 'manually_resolved',
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 'user-1',
action: 'gateway.submit_dead_letter_resolved',
resourceId: 'dead-1',
}),
});
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
const { service, prisma } = createService();
@@ -4208,12 +4247,14 @@ describe('SendChainService', () => {
jest.useFakeTimers();
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
const { service } = createService();
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(60_000);
expect(scan).toHaveBeenCalledWith({});
@@ -4224,6 +4265,8 @@ describe('SendChainService', () => {
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
jest.useRealTimers();
}
});
+36
View File
@@ -19,6 +19,7 @@ import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEv
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
import { SendDownstreamRequeueTaskService, type DownstreamRequeueFilter } from './send-downstream-requeue-task.service';
@Injectable()
export class SendChainService implements OnModuleInit, OnModuleDestroy {
@@ -37,8 +38,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
private downstreamRequeueTaskIntervalTimer?: ReturnType<typeof setInterval>;
private readonly submission: SendSubmissionService;
private readonly completion: SendCompletionService;
private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService;
constructor(
private readonly prisma: PrismaService,
@@ -68,6 +71,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
openApi,
this as unknown as SendCompletionFacade,
);
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
}
onModuleInit() {
@@ -129,6 +133,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
);
this.upstreamReceiptInboxIntervalTimer.unref?.();
}
if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') {
this.downstreamRequeueTaskIntervalTimer = setInterval(
() => void this.downstreamRequeueTasks.runScan().catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000),
);
this.downstreamRequeueTaskIntervalTimer.unref?.();
}
}
async onModuleDestroy() {
@@ -140,6 +151,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer);
if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer);
if (this.downstreamRequeueTaskIntervalTimer) clearInterval(this.downstreamRequeueTaskIntervalTimer);
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
@@ -481,6 +493,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.completion.requeueGatewaySubmitDeadLetter(id, data);
}
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
return this.completion.resolveGatewaySubmitDeadLetter(id, operatorId);
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
return this.completion.recoverStaleGatewaySubmitRequeues(now);
}
@@ -497,6 +513,26 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.completion.batchRequeueDownstreamDeliveries(ids);
}
previewDownstreamRequeueTask(filter: DownstreamRequeueFilter) {
return this.downstreamRequeueTasks.preview(filter);
}
createDownstreamRequeueTask(data: { filter: DownstreamRequeueFilter; snapshotAt: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string) {
return this.downstreamRequeueTasks.create(data, operatorId);
}
listDownstreamRequeueTasks(query: { status?: string; page?: number; pageSize?: number }) {
return this.downstreamRequeueTasks.list(query);
}
getDownstreamRequeueTask(id: string) {
return this.downstreamRequeueTasks.get(id);
}
changeDownstreamRequeueTaskStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) {
return this.downstreamRequeueTasks.changeStatus(id, action, operatorId);
}
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
}
@@ -158,6 +158,10 @@ export class SendCompletionService {
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
}
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
return this.retry.resolveGatewaySubmitDeadLetter(id, operatorId);
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
return this.retry.recoverStaleGatewaySubmitRequeues(now);
}
@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import { SendDownstreamRequeueTaskService } from './send-downstream-requeue-task.service';
function prismaMock(): Record<string, any> {
const result: Record<string, any> = {
cmppDownstreamDelivery: {
count: jest.fn(), groupBy: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), findUnique: jest.fn(),
},
downstreamRequeueTask: {
findFirst: jest.fn(), findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(),
},
downstreamRequeueTaskItem: {
createMany: jest.fn(), groupBy: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(),
},
operationLog: { create: jest.fn() },
};
result.$transaction = jest.fn(async (callback: (tx: unknown) => unknown) => callback(result));
return result;
}
let mock: Record<string, any>;
describe('SendDownstreamRequeueTaskService', () => {
beforeEach(() => { mock = prismaMock(); });
it('previews all matches separately from replayable records', async () => {
mock.cmppDownstreamDelivery.count.mockResolvedValueOnce(12).mockResolvedValueOnce(8);
mock.cmppDownstreamDelivery.groupBy
.mockResolvedValueOnce([{ status: 'pending', _count: { _all: 8 } }, { status: 'delivered', _count: { _all: 4 } }])
.mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 12 } }]);
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date('2026-08-11T00:00:00Z') });
const service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: jest.fn() });
const result = await service.preview({ status: 'all' });
expect(result).toEqual(expect.objectContaining({ matchedCount: 12, replayableCount: 8, skippedCount: 4, applicationCount: 1 }));
});
it('rejects delivered filters and short reasons', async () => {
const service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: jest.fn() });
await expect(service.create({ filter: { status: 'delivered' }, snapshotAt: new Date().toISOString(), reason: '事故恢复' })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.create({ filter: { status: 'pending' }, snapshotAt: new Date().toISOString(), reason: '短' })).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a new task when the same application scope already has an unfinished task', async () => {
mock.downstreamRequeueTask.findFirst.mockResolvedValue({ taskNo: 'DRT-EXISTING' });
const service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: jest.fn() });
await expect(service.create({ filter: { applicationId: 'app-1', status: 'pending' }, snapshotAt: new Date().toISOString(), reason: '处理历史回执积压' })).rejects.toThrow('DRT-EXISTING');
});
it('skips a delivery that automatic recovery already confirmed before task execution', async () => {
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
mock.downstreamRequeueTask.findUnique
.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'queued', startedAt: null, ratePerSecond: 10, consecutiveFailures: 0, consecutiveFailureLimit: 10 })
.mockResolvedValueOnce({ status: 'running' })
.mockResolvedValue({ status: 'running' });
mock.downstreamRequeueTaskItem.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'item-1', deliveryId: 'delivery-1' }])
.mockResolvedValueOnce([]);
mock.downstreamRequeueTaskItem.updateMany.mockResolvedValue({ count: 1 });
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ status: 'delivered', payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([{ status: 'skipped', _count: { _all: 1 } }]);
const requeue = jest.fn();
const service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
await service.runScan();
expect(requeue).not.toHaveBeenCalled();
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'skipped', skipReason: '已被客户确认' }) }));
});
});
@@ -0,0 +1,235 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { parseDateBoundary } from '../operations/operations.helpers';
export type DownstreamRequeueFilter = {
tenantId?: string;
applicationId?: string;
deliveryType?: string;
status?: string;
keyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
};
type RequeueFacade = { requeueDownstreamDelivery(id: string): Promise<unknown> };
const REPLAYABLE_STATUSES = ['pending', 'failed', 'unconfirmed', 'rejected'];
function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayableByDefault = true): Prisma.CmppDownstreamDeliveryWhereInput {
const from = parseDateBoundary(filter.createdAtFrom, false);
const to = parseDateBoundary(filter.createdAtTo, true);
return {
tenantId: filter.tenantId && filter.tenantId !== 'all' ? filter.tenantId : undefined,
applicationId: filter.applicationId && filter.applicationId !== 'all' ? filter.applicationId : undefined,
deliveryType: filter.deliveryType && filter.deliveryType !== 'all' ? filter.deliveryType : undefined,
status: filter.status && filter.status !== 'all' ? filter.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined,
createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt },
OR: filter.keyword ? [
{ messageId: { contains: filter.keyword } },
{ payload: { path: ['account'], string_contains: filter.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: filter.keyword } },
{ lastError: { contains: filter.keyword } },
{ tenant: { name: { contains: filter.keyword } } },
{ application: { name: { contains: filter.keyword } } },
] : undefined,
};
}
export class SendDownstreamRequeueTaskService {
constructor(private readonly prisma: PrismaService, private readonly facade: RequeueFacade) {}
async preview(filter: DownstreamRequeueFilter) {
const snapshotAt = new Date();
const base = taskWhere({ ...filter, status: 'all' }, snapshotAt, false);
const where = taskWhere(filter, snapshotAt);
const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([
this.prisma.cmppDownstreamDelivery.count({ where: base }),
this.prisma.cmppDownstreamDelivery.count({ where: { AND: [where, { status: { in: REPLAYABLE_STATUSES } }] } }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.findFirst({ where: base, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
]);
return {
snapshotAt,
matchedCount,
replayableCount,
skippedCount: matchedCount - replayableCount,
applicationCount: appGroups.length,
oldestCreatedAt: oldest?.createdAt ?? null,
statusCounts: Object.fromEntries(statusGroups.map((item) => [item.status, item._count._all])),
filter: { ...filter, status: filter.status ?? 'all' },
};
}
async create(data: { filter: DownstreamRequeueFilter; snapshotAt: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, createdById?: string) {
const reason = data.reason?.trim();
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
const snapshotAt = new Date(data.snapshotAt);
if (Number.isNaN(snapshotAt.getTime()) || snapshotAt.getTime() > Date.now() + 10_000) throw new BadRequestException('预检快照时间无效');
if (data.filter.status === 'delivered' || data.filter.status === 'awaiting_ack') throw new BadRequestException('第一版后台任务不支持已确认或等待ACK记录');
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: {
status: { in: ['queued', 'running', 'paused'] },
...(data.filter.applicationId && data.filter.applicationId !== 'all'
? { OR: [{ applicationId: data.filter.applicationId }, { applicationId: null }] }
: {}),
}, select: { taskNo: true } });
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
const where = { AND: [taskWhere(data.filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput;
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ where, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: 100001, select: { id: true, tenantId: true, applicationId: true, status: true } });
if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录');
if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10)));
const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10)));
const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`;
const task = await this.prisma.$transaction(async (tx) => {
const created = await tx.downstreamRequeueTask.create({ data: {
taskNo,
tenantId: data.filter.tenantId && data.filter.tenantId !== 'all' ? data.filter.tenantId : null,
applicationId: data.filter.applicationId && data.filter.applicationId !== 'all' ? data.filter.applicationId : null,
filterSnapshot: data.filter as Prisma.InputJsonValue,
snapshotAt, reason, ratePerSecond, consecutiveFailureLimit: failureLimit,
totalCount: deliveries.length, createdById,
} });
await tx.downstreamRequeueTaskItem.createMany({ data: deliveries.map((item) => ({ taskId: created.id, deliveryId: item.id, applicationId: item.applicationId, previousStatus: item.status })) });
await tx.operationLog.create({ data: { userId: createdById, action: 'gateway.downstream_requeue_task_created', resource: 'downstream_requeue_task', resourceId: created.id, detail: { taskNo, reason, totalCount: deliveries.length, snapshotAt, filter: data.filter, ratePerSecond } } });
return created;
});
return this.get(task.id);
}
async list(query: { status?: string; page?: number; pageSize?: number }) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTask.findMany({ where, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
this.prisma.downstreamRequeueTask.count({ where }),
]);
return { items, total, page, pageSize };
}
async get(id: string) {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } } });
if (!task) throw new NotFoundException('后台重投任务不存在');
const [itemGroups, recentItems] = await Promise.all([
this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId: id }, _count: { _all: true } }),
this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId: id }, include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } }, orderBy: { updatedAt: 'desc' }, take: 50 }),
]);
return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])), recentItems };
}
async changeStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) {
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
if (!task) throw new NotFoundException('后台重投任务不存在');
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作');
const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated';
const updated = await this.prisma.downstreamRequeueTask.update({ where: { id }, data: { status, pausedAt: status === 'paused' ? new Date() : null, finishedAt: status === 'terminated' ? new Date() : undefined } });
if (status === 'terminated') await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId: id, status: 'queued' }, data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() } });
await this.prisma.operationLog.create({ data: { userId: operatorId, action: `gateway.downstream_requeue_task_${action}`, resource: 'downstream_requeue_task', resourceId: id, detail: { taskNo: task.taskNo, previousStatus: task.status, status } } });
return updated;
}
async runScan() {
const tasks = await this.prisma.downstreamRequeueTask.findMany({ where: { status: { in: ['queued', 'running'] } }, orderBy: { createdAt: 'asc' }, take: 3 });
for (const task of tasks) await this.processTask(task.id);
}
private async processTask(taskId: string) {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId } });
if (!task || !['queued', 'running'].includes(task.status)) return;
await this.reconcileWaiting(taskId);
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status: 'running', startedAt: task.startedAt ?? new Date() } });
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'queued' }, orderBy: { createdAt: 'asc' }, take: Math.min(10, task.ratePerSecond), select: { id: true, deliveryId: true } });
let consecutiveFailures = task.consecutiveFailures;
for (const item of items) {
const latestTask = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } });
if (latestTask?.status === 'paused' || latestTask?.status === 'terminated') break;
const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'queued' }, data: { status: 'processing', claimedAt: new Date() } });
if (!claimed.count) continue;
try {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { id: item.deliveryId },
include: { application: { select: { status: true, interfaceEnabled: true } } },
});
if (!delivery) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'skipped', skipReason: '投递记录已不存在', completedAt: new Date() } });
continue;
}
if (!REPLAYABLE_STATUSES.includes(delivery.status)) {
if (delivery.status === 'awaiting_ack') {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'waiting_external_ack', skipReason: null } });
} else {
const skipReason = delivery.status === 'delivered' ? '已被客户确认' : '执行前状态已变化';
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'skipped', skipReason, completedAt: new Date() } });
}
continue;
}
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'skipped', skipReason: '应用或投递能力已停用', completedAt: new Date() } });
continue;
}
if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType)) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'skipped', skipReason: '投递数据不完整', completedAt: new Date() } });
continue;
}
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({ where: { deliveryId: item.deliveryId, id: { not: item.id }, status: { in: ['processing', 'waiting_ack', 'success'] } }, select: { id: true } });
if (activeOther) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'skipped', skipReason: '已被其他任务处理', completedAt: new Date() } });
continue;
}
const result = await this.facade.requeueDownstreamDelivery(item.deliveryId) as { status?: string; lastError?: string | null };
if (result?.status === 'awaiting_ack' || result?.status === 'delivered') {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: result.status === 'delivered' ? 'success' : 'waiting_ack', completedAt: result.status === 'delivered' ? new Date() : null } });
consecutiveFailures = 0;
} else {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'failed', errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态', completedAt: new Date() } });
consecutiveFailures += 1;
}
} catch (error) {
const message = error instanceof Error ? error.message : '后台重投失败';
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化'
: /payload|投递类型/.test(message) ? '投递数据不完整'
: /Submit|Msg_Id|Sequence/.test(message) ? '缺少原Submit映射,无法安全重投'
: null;
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: skipReason ? 'skipped' : 'failed', skipReason, errorMessage: skipReason ? null : message, completedAt: new Date() } });
if (!skipReason) consecutiveFailures += 1;
}
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { consecutiveFailures } });
if (consecutiveFailures >= task.consecutiveFailureLimit) {
// Stop before claiming another delivery: a customer or Gateway outage must not become a retry flood.
const pausedAt = new Date();
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status: 'paused', pausedAt, lastError: `连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停` } });
await this.prisma.operationLog.create({ data: { action: 'gateway.downstream_requeue_task_auto_paused', resource: 'downstream_requeue_task', resourceId: taskId, detail: { taskNo: task.taskNo, consecutiveFailures, failureLimit: task.consecutiveFailureLimit } } });
break;
}
}
await this.reconcileWaiting(taskId);
await this.refreshTask(taskId);
}
private async reconcileWaiting(taskId: string) {
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } }, include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } }, take: 100 });
const now = new Date();
for (const item of items) {
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: item.status === 'waiting_external_ack' ? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now } : { status: 'success', completedAt: now } });
} else if (['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) || (item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: item.status === 'waiting_external_ack' ? { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null } : { status: 'failed', errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时', completedAt: now } });
}
}
}
private async refreshTask(taskId: string) {
const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId }, _count: { _all: true } });
const counts = new Map(groups.map((item) => [item.status, item._count._all]));
const queued = counts.get('queued') ?? 0;
const active = (counts.get('processing') ?? 0) + (counts.get('waiting_ack') ?? 0) + (counts.get('waiting_external_ack') ?? 0);
const failed = counts.get('failed') ?? 0;
const current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } });
const status = current?.status === 'paused' || current?.status === 'terminated' ? current.status : queued + active === 0 ? (failed > 0 ? 'partial_completed' : 'completed') : 'running';
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status, successCount: counts.get('success') ?? 0, failedCount: failed, skippedCount: counts.get('skipped') ?? 0, waitingCount: active, ...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}) } });
}
}
+41
View File
@@ -166,6 +166,47 @@ export class SendRetryService {
return updated;
}
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
if (!deadLetter) {
throw new NotFoundException('Gateway提交异常记录不存在');
}
if (deadLetter.status === 'resolved') {
return deadLetter;
}
if (deadLetter.status !== 'pending') {
throw new BadRequestException('只有待处理的提交异常可以标记为已处理');
}
const resolvedAt = new Date();
const resolved = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id, status: 'pending' },
data: {
status: 'resolved',
resolvedAt,
resolvedStatus: 'manually_resolved',
},
});
if (resolved.count !== 1) {
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
}
await this.prisma.operationLog.create({
data: {
tenantId: deadLetter.tenantId ?? undefined,
userId: operatorId,
action: 'gateway.submit_dead_letter_resolved',
resource: 'gateway_submit_dead_letter',
resourceId: deadLetter.id,
detail: {
previousStatus: deadLetter.status,
resolvedStatus: 'manually_resolved',
messageId: deadLetter.messageId,
submitId: deadLetter.submitId,
},
},
});
return this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
@@ -112,6 +112,13 @@ describe('SignatureRetirementService dimensions', () => {
page: 2,
pageSize: 10,
});
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
const sql = query.strings?.join('?') ?? '';
expect(sql).toContain("SUBSTRING(message.content FROM '^【[^【】]+】')");
expect(sql).toContain('message."signatureId" IS NULL');
expect(sql).toContain('FROM "SmsSignature" signature');
expect(sql).toContain('signature."applicationId" = extracted.application_id');
expect(sql).not.toContain('FROM "ChannelSignatureReportTask" report');
});
it('returns a filtered historical message page with application metadata', async () => {
@@ -270,49 +270,44 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
messageCount: number;
rowCount: number;
}>>(Prisma.sql`
WITH unreported AS (
WITH extracted AS (
SELECT
signature.id AS signature_id,
signature.name AS signature_name,
message."tenantId" AS tenant_id,
message."applicationId" AS application_id,
SUBSTRING(message.content FROM '^【[^【】]+】') AS signature_name
FROM "SmsMessageRecord" message
WHERE message."queuedAt" >= ${shanghaiStart(date)}
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
AND message."signatureId" IS NULL
), unreported AS (
SELECT
CONCAT('unregistered:', MD5(extracted.tenant_id || ':' || extracted.application_id || ':' || extracted.signature_name)) AS signature_id,
extracted.signature_name,
tenant.id AS tenant_id,
tenant.name AS tenant_name,
application.id AS application_id,
application.name AS application_name,
COUNT(*)::integer AS message_count
FROM "SmsMessageRecord" message
JOIN "SmsSignature" signature ON signature.id = message."signatureId"
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"
WHERE message."queuedAt" >= ${shanghaiStart(date)}
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
FROM extracted
JOIN "Tenant" tenant ON tenant.id = extracted.tenant_id
JOIN "SmsApplication" application ON application.id = extracted.application_id
WHERE extracted.signature_name IS NOT NULL
-- 未报备签名指系统签名库中不存在,而不是已有签名缺少某个通道的运营商报备。
AND NOT EXISTS (
SELECT 1
FROM "ChannelSignatureReportTask" report
JOIN "SmsChannel" channel ON channel.id = report."channelId"
WHERE report."signatureId" = message."signatureId"
AND report."reportType" = 'signature'
AND report.status = 'approved'
AND channel.status <> 'deleted'
AND (
report."approvalScope" = 'legacy_channel'
OR (
report."approvalScope" = 'carrier_specific'
AND report.carrier = CASE
WHEN LOWER(COALESCE(message.carrier, '')) IN ('mobile', 'cmcc', '移动', '中国移动') THEN 'mobile'
WHEN LOWER(COALESCE(message.carrier, '')) IN ('unicom', 'cucc', '联通', '中国联通') THEN 'unicom'
WHEN LOWER(COALESCE(message.carrier, '')) IN ('telecom', 'ctcc', '电信', '中国电信') THEN 'telecom'
ELSE '__unknown__'
END
)
)
FROM "SmsSignature" signature
WHERE signature."tenantId" = extracted.tenant_id
AND signature."applicationId" = extracted.application_id
AND signature.name = extracted.signature_name
AND signature."auditStatus" <> 'deleted'
)
AND (
${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern}
OR extracted.signature_name ILIKE ${keywordPattern}
OR tenant.name ILIKE ${keywordPattern}
OR application.name ILIKE ${keywordPattern}
)
GROUP BY signature.id, signature.name, tenant.id, tenant.name, application.id, application.name
GROUP BY extracted.signature_name, tenant.id, tenant.name, application.id, application.name
)
SELECT
signature_id AS "signatureId",