feat: complete cmpp gateway delivery recovery workflows
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
-- Add customer-side downstream delivery persistence and uplink matching fields.
|
||||
|
||||
ALTER TABLE "SmsUplinkMessage"
|
||||
ADD COLUMN "applicationId" TEXT,
|
||||
ADD COLUMN "messageRecordId" TEXT,
|
||||
ADD COLUMN "matchStatus" TEXT NOT NULL DEFAULT 'unmatched',
|
||||
ADD COLUMN "matchReason" TEXT;
|
||||
|
||||
ALTER TABLE "SmsUplinkMessage"
|
||||
ADD CONSTRAINT "SmsUplinkMessage_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SmsUplinkMessage"
|
||||
ADD CONSTRAINT "SmsUplinkMessage_messageRecordId_fkey"
|
||||
FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "SmsUplinkMessage_applicationId_receivedAt_idx" ON "SmsUplinkMessage"("applicationId", "receivedAt");
|
||||
CREATE INDEX "SmsUplinkMessage_messageRecordId_idx" ON "SmsUplinkMessage"("messageRecordId");
|
||||
CREATE INDEX "SmsUplinkMessage_matchStatus_idx" ON "SmsUplinkMessage"("matchStatus");
|
||||
|
||||
CREATE TABLE "CmppDownstreamDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"messageRecordId" TEXT,
|
||||
"messageId" TEXT,
|
||||
"deliveryType" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"payload" JSONB NOT NULL,
|
||||
"retryCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextRetryAt" TIMESTAMP(3),
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CmppDownstreamDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "CmppDownstreamDelivery"
|
||||
ADD CONSTRAINT "CmppDownstreamDelivery_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CmppDownstreamDelivery"
|
||||
ADD CONSTRAINT "CmppDownstreamDelivery_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CmppDownstreamDelivery"
|
||||
ADD CONSTRAINT "CmppDownstreamDelivery_messageRecordId_fkey"
|
||||
FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "CmppDownstreamDelivery_tenantId_status_createdAt_idx" ON "CmppDownstreamDelivery"("tenantId", "status", "createdAt");
|
||||
CREATE INDEX "CmppDownstreamDelivery_applicationId_status_createdAt_idx" ON "CmppDownstreamDelivery"("applicationId", "status", "createdAt");
|
||||
CREATE INDEX "CmppDownstreamDelivery_messageId_idx" ON "CmppDownstreamDelivery"("messageId");
|
||||
CREATE INDEX "CmppDownstreamDelivery_messageRecordId_idx" ON "CmppDownstreamDelivery"("messageRecordId");
|
||||
CREATE INDEX "CmppDownstreamDelivery_deliveryType_status_idx" ON "CmppDownstreamDelivery"("deliveryType", "status");
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "SmsApplication"
|
||||
ADD COLUMN "cmppMaxConnections" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "cmppWindowSize" INTEGER NOT NULL DEFAULT 16;
|
||||
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE "GatewaySubmitDeadLetter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"streamMessageId" TEXT NOT NULL,
|
||||
"tenantId" TEXT,
|
||||
"applicationId" TEXT,
|
||||
"channelId" TEXT,
|
||||
"traceId" TEXT,
|
||||
"messageId" TEXT,
|
||||
"submitId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"failureCode" TEXT NOT NULL,
|
||||
"failureMessage" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||
"maxAttempts" INTEGER NOT NULL DEFAULT 3,
|
||||
"commandPayload" JSONB,
|
||||
"rawPayload" TEXT,
|
||||
"manualRetryCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastRetryStreamId" TEXT,
|
||||
"lastRetriedAt" TIMESTAMP(3),
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"resolvedStatus" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GatewaySubmitDeadLetter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "GatewaySubmitDeadLetter_streamMessageId_key" ON "GatewaySubmitDeadLetter"("streamMessageId");
|
||||
CREATE INDEX "GatewaySubmitDeadLetter_status_createdAt_idx" ON "GatewaySubmitDeadLetter"("status", "createdAt");
|
||||
CREATE INDEX "GatewaySubmitDeadLetter_tenantId_status_createdAt_idx" ON "GatewaySubmitDeadLetter"("tenantId", "status", "createdAt");
|
||||
CREATE INDEX "GatewaySubmitDeadLetter_applicationId_status_createdAt_idx" ON "GatewaySubmitDeadLetter"("applicationId", "status", "createdAt");
|
||||
CREATE INDEX "GatewaySubmitDeadLetter_channelId_status_createdAt_idx" ON "GatewaySubmitDeadLetter"("channelId", "status", "createdAt");
|
||||
CREATE INDEX "GatewaySubmitDeadLetter_messageId_idx" ON "GatewaySubmitDeadLetter"("messageId");
|
||||
CREATE INDEX "GatewaySubmitDeadLetter_submitId_idx" ON "GatewaySubmitDeadLetter"("submitId");
|
||||
|
||||
ALTER TABLE "GatewaySubmitDeadLetter" ADD CONSTRAINT "GatewaySubmitDeadLetter_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "GatewaySubmitDeadLetter" ADD CONSTRAINT "GatewaySubmitDeadLetter_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "GatewaySubmitDeadLetter" ADD CONSTRAINT "GatewaySubmitDeadLetter_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE "GatewayDownstreamRecoveryStatus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"account" TEXT NOT NULL,
|
||||
"tenantId" TEXT,
|
||||
"applicationId" TEXT,
|
||||
"gatewayInstanceId" TEXT,
|
||||
"state" TEXT NOT NULL,
|
||||
"lastAttemptAt" TIMESTAMP(3),
|
||||
"lastSuccessAt" TIMESTAMP(3),
|
||||
"lastFailureAt" TIMESTAMP(3),
|
||||
"nextRetryAt" TIMESTAMP(3),
|
||||
"attemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
"lastSkipReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GatewayDownstreamRecoveryStatus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "GatewayDownstreamRecoveryStatus_account_key" ON "GatewayDownstreamRecoveryStatus"("account");
|
||||
CREATE INDEX "GatewayDownstreamRecoveryStatus_tenantId_state_updatedAt_idx" ON "GatewayDownstreamRecoveryStatus"("tenantId", "state", "updatedAt");
|
||||
CREATE INDEX "GatewayDownstreamRecoveryStatus_applicationId_state_updatedAt_idx" ON "GatewayDownstreamRecoveryStatus"("applicationId", "state", "updatedAt");
|
||||
CREATE INDEX "GatewayDownstreamRecoveryStatus_state_updatedAt_idx" ON "GatewayDownstreamRecoveryStatus"("state", "updatedAt");
|
||||
CREATE INDEX "GatewayDownstreamRecoveryStatus_nextRetryAt_idx" ON "GatewayDownstreamRecoveryStatus"("nextRetryAt");
|
||||
|
||||
ALTER TABLE "GatewayDownstreamRecoveryStatus"
|
||||
ADD CONSTRAINT "GatewayDownstreamRecoveryStatus_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "GatewayDownstreamRecoveryStatus"
|
||||
ADD CONSTRAINT "GatewayDownstreamRecoveryStatus_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "GatewayDownstreamRecoveryStatus"
|
||||
ADD COLUMN "failureCategory" TEXT;
|
||||
|
||||
CREATE INDEX "GatewayDownstreamRecoveryStatus_failureCategory_updatedAt_idx"
|
||||
ON "GatewayDownstreamRecoveryStatus"("failureCategory", "updatedAt");
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "GatewayDownstreamRecoveryStatus"
|
||||
ADD COLUMN "lockOwner" TEXT,
|
||||
ADD COLUMN "lockExpiresAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "GatewayDownstreamRecoveryStatus_lockOwner_lockExpiresAt_idx"
|
||||
ON "GatewayDownstreamRecoveryStatus"("lockOwner", "lockExpiresAt");
|
||||
@@ -0,0 +1,64 @@
|
||||
CREATE TABLE "SmsMessageSegmentAudit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"batchTaskId" TEXT,
|
||||
"messageRecordId" TEXT NOT NULL,
|
||||
"submitRecordId" TEXT,
|
||||
"channelId" TEXT,
|
||||
"submitId" TEXT NOT NULL,
|
||||
"attempt" INTEGER NOT NULL DEFAULT 0,
|
||||
"segmentTotal" INTEGER NOT NULL DEFAULT 1,
|
||||
"segmentIndex" INTEGER NOT NULL DEFAULT 1,
|
||||
"sequenceId" INTEGER,
|
||||
"gatewayMessageId" TEXT,
|
||||
"submitStatus" TEXT NOT NULL DEFAULT 'queued',
|
||||
"receiptStatus" TEXT,
|
||||
"rawStatus" TEXT,
|
||||
"compensationType" TEXT,
|
||||
"errorCode" TEXT,
|
||||
"errorMessage" TEXT,
|
||||
"submittedAt" TIMESTAMP(3),
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SmsMessageSegmentAudit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SmsMessageSegmentAudit_messageRecordId_submitId_segmentIndex_key"
|
||||
ON "SmsMessageSegmentAudit"("messageRecordId", "submitId", "segmentIndex");
|
||||
|
||||
CREATE INDEX "SmsMessageSegmentAudit_tenantId_createdAt_idx"
|
||||
ON "SmsMessageSegmentAudit"("tenantId", "createdAt");
|
||||
|
||||
CREATE INDEX "SmsMessageSegmentAudit_messageRecordId_segmentIndex_idx"
|
||||
ON "SmsMessageSegmentAudit"("messageRecordId", "segmentIndex");
|
||||
|
||||
CREATE INDEX "SmsMessageSegmentAudit_submitRecordId_idx"
|
||||
ON "SmsMessageSegmentAudit"("submitRecordId");
|
||||
|
||||
CREATE INDEX "SmsMessageSegmentAudit_gatewayMessageId_idx"
|
||||
ON "SmsMessageSegmentAudit"("gatewayMessageId");
|
||||
|
||||
CREATE INDEX "SmsMessageSegmentAudit_submitStatus_receiptStatus_idx"
|
||||
ON "SmsMessageSegmentAudit"("submitStatus", "receiptStatus");
|
||||
|
||||
ALTER TABLE "SmsMessageSegmentAudit"
|
||||
ADD CONSTRAINT "SmsMessageSegmentAudit_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SmsMessageSegmentAudit"
|
||||
ADD CONSTRAINT "SmsMessageSegmentAudit_batchTaskId_fkey"
|
||||
FOREIGN KEY ("batchTaskId") REFERENCES "SmsBatchTask"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SmsMessageSegmentAudit"
|
||||
ADD CONSTRAINT "SmsMessageSegmentAudit_messageRecordId_fkey"
|
||||
FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SmsMessageSegmentAudit"
|
||||
ADD CONSTRAINT "SmsMessageSegmentAudit_submitRecordId_fkey"
|
||||
FOREIGN KEY ("submitRecordId") REFERENCES "SmsSubmitRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SmsMessageSegmentAudit"
|
||||
ADD CONSTRAINT "SmsMessageSegmentAudit_channelId_fkey"
|
||||
FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SmsUplinkMatchCandidate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"uplinkMessageId" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"messageRecordId" TEXT,
|
||||
"matchSource" TEXT NOT NULL,
|
||||
"confidence" INTEGER NOT NULL DEFAULT 50,
|
||||
"reason" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"claimedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SmsUplinkMatchCandidate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SmsUplinkMatchCandidate_uplinkMessageId_applicationId_messageRecordId_key" ON "SmsUplinkMatchCandidate"("uplinkMessageId", "applicationId", "messageRecordId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsUplinkMatchCandidate_uplinkMessageId_status_idx" ON "SmsUplinkMatchCandidate"("uplinkMessageId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsUplinkMatchCandidate_tenantId_status_createdAt_idx" ON "SmsUplinkMatchCandidate"("tenantId", "status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsUplinkMatchCandidate_applicationId_status_createdAt_idx" ON "SmsUplinkMatchCandidate"("applicationId", "status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsUplinkMatchCandidate_messageRecordId_idx" ON "SmsUplinkMatchCandidate"("messageRecordId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SmsUplinkMatchCandidate" ADD CONSTRAINT "SmsUplinkMatchCandidate_uplinkMessageId_fkey" FOREIGN KEY ("uplinkMessageId") REFERENCES "SmsUplinkMessage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SmsUplinkMatchCandidate" ADD CONSTRAINT "SmsUplinkMatchCandidate_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SmsUplinkMatchCandidate" ADD CONSTRAINT "SmsUplinkMatchCandidate_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SmsUplinkMatchCandidate" ADD CONSTRAINT "SmsUplinkMatchCandidate_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+201
-12
@@ -36,8 +36,13 @@ model Tenant {
|
||||
smsApiRequests SmsApiRequest[]
|
||||
smsSubmitRecords SmsSubmitRecord[]
|
||||
smsReceiptRecords SmsReceiptRecord[]
|
||||
smsMessageSegmentAudits SmsMessageSegmentAudit[]
|
||||
smsUplinkMessages SmsUplinkMessage[]
|
||||
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||
cmppDownstreamDeliveries CmppDownstreamDelivery[]
|
||||
cmppConnectionStates CmppConnectionState[]
|
||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
|
||||
}
|
||||
|
||||
model EnterpriseCertification {
|
||||
@@ -338,6 +343,8 @@ model SmsApplication {
|
||||
callbackUrl String?
|
||||
cmppAccount String @unique
|
||||
secretHash String
|
||||
cmppMaxConnections Int @default(1)
|
||||
cmppWindowSize Int @default(16)
|
||||
dailyLimit Int?
|
||||
customerUnitPrice Int @default(0)
|
||||
queuePriority String @default("normal")
|
||||
@@ -354,7 +361,12 @@ model SmsApplication {
|
||||
sendTasks SmsSendTask[]
|
||||
batchTasks SmsBatchTask[]
|
||||
messageRecords SmsMessageRecord[]
|
||||
uplinkMessages SmsUplinkMessage[]
|
||||
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||
downstreamDeliveries CmppDownstreamDelivery[]
|
||||
connectionStates CmppConnectionState[]
|
||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
}
|
||||
@@ -496,8 +508,10 @@ model SmsChannel {
|
||||
submitSessions CmppSubmitSession[]
|
||||
submitRecords SmsSubmitRecord[]
|
||||
receiptRecords SmsReceiptRecord[]
|
||||
segmentAudits SmsMessageSegmentAudit[]
|
||||
uplinkMessages SmsUplinkMessage[]
|
||||
connectionStates CmppConnectionState[]
|
||||
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
@@ -818,6 +832,7 @@ model SmsBatchTask {
|
||||
messages SmsMessageRecord[]
|
||||
submitRecords SmsSubmitRecord[]
|
||||
receiptRecords SmsReceiptRecord[]
|
||||
segmentAudits SmsMessageSegmentAudit[]
|
||||
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([status, scheduledAt])
|
||||
@@ -876,6 +891,10 @@ model SmsMessageRecord {
|
||||
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||
submitRecords SmsSubmitRecord[]
|
||||
receiptRecords SmsReceiptRecord[]
|
||||
segmentAudits SmsMessageSegmentAudit[]
|
||||
matchedUplinks SmsUplinkMessage[] @relation("SmsUplinkMatchedMessage")
|
||||
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||
downstreamDeliveries CmppDownstreamDelivery[]
|
||||
|
||||
@@index([tenantId, status, queuedAt])
|
||||
@@index([batchTaskId, status])
|
||||
@@ -922,12 +941,51 @@ model SmsSubmitRecord {
|
||||
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id], onDelete: Cascade)
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
session CmppSubmitSession? @relation(fields: [sessionId], references: [id])
|
||||
segmentAudits SmsMessageSegmentAudit[]
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([messageRecordId])
|
||||
@@index([gatewayMessageId])
|
||||
}
|
||||
|
||||
model SmsMessageSegmentAudit {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
batchTaskId String?
|
||||
messageRecordId String
|
||||
submitRecordId String?
|
||||
channelId String?
|
||||
submitId String
|
||||
attempt Int @default(0)
|
||||
segmentTotal Int @default(1)
|
||||
segmentIndex Int @default(1)
|
||||
sequenceId Int?
|
||||
gatewayMessageId String?
|
||||
submitStatus String @default("queued")
|
||||
receiptStatus String?
|
||||
rawStatus String?
|
||||
compensationType String?
|
||||
errorCode String?
|
||||
errorMessage String?
|
||||
submittedAt DateTime?
|
||||
deliveredAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id])
|
||||
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id], onDelete: Cascade)
|
||||
submitRecord SmsSubmitRecord? @relation(fields: [submitRecordId], references: [id])
|
||||
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@unique([messageRecordId, submitId, segmentIndex])
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([messageRecordId, segmentIndex])
|
||||
@@index([submitRecordId])
|
||||
@@index([gatewayMessageId])
|
||||
@@index([submitStatus, receiptStatus])
|
||||
}
|
||||
|
||||
model SmsReceiptRecord {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
@@ -954,21 +1012,152 @@ model SmsReceiptRecord {
|
||||
}
|
||||
|
||||
model SmsUplinkMessage {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
channelId String
|
||||
messageId String?
|
||||
sequenceId Int?
|
||||
phoneNumber String
|
||||
destId String
|
||||
content String
|
||||
receivedAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
channelId String
|
||||
messageRecordId String?
|
||||
messageId String?
|
||||
sequenceId Int?
|
||||
phoneNumber String
|
||||
destId String
|
||||
content String
|
||||
matchStatus String @default("unmatched")
|
||||
matchReason String?
|
||||
receivedAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||
messageRecord SmsMessageRecord? @relation("SmsUplinkMatchedMessage", fields: [messageRecordId], references: [id])
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
matchCandidates SmsUplinkMatchCandidate[]
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([applicationId, receivedAt])
|
||||
@@index([channelId, receivedAt])
|
||||
@@index([messageRecordId])
|
||||
@@index([matchStatus])
|
||||
@@index([phoneNumber])
|
||||
}
|
||||
|
||||
model SmsUplinkMatchCandidate {
|
||||
id String @id @default(cuid())
|
||||
uplinkMessageId String
|
||||
tenantId String
|
||||
applicationId String
|
||||
messageRecordId String?
|
||||
matchSource String
|
||||
confidence Int @default(50)
|
||||
reason String?
|
||||
status String @default("pending")
|
||||
claimedAt DateTime?
|
||||
claimedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
uplinkMessage SmsUplinkMessage @relation(fields: [uplinkMessageId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||
|
||||
@@unique([uplinkMessageId, applicationId, messageRecordId])
|
||||
@@index([uplinkMessageId, status])
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([applicationId, status, createdAt])
|
||||
@@index([messageRecordId])
|
||||
}
|
||||
|
||||
model CmppDownstreamDelivery {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String
|
||||
messageRecordId String?
|
||||
messageId String?
|
||||
deliveryType String
|
||||
status String @default("pending")
|
||||
payload Json
|
||||
retryCount Int @default(0)
|
||||
nextRetryAt DateTime?
|
||||
deliveredAt DateTime?
|
||||
lastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([applicationId, status, createdAt])
|
||||
@@index([messageId])
|
||||
@@index([messageRecordId])
|
||||
@@index([deliveryType, status])
|
||||
}
|
||||
|
||||
model GatewaySubmitDeadLetter {
|
||||
id String @id @default(cuid())
|
||||
streamMessageId String @unique
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
channelId String?
|
||||
traceId String?
|
||||
messageId String?
|
||||
submitId String?
|
||||
status String @default("pending")
|
||||
failureCode String
|
||||
failureMessage String
|
||||
attempts Int @default(1)
|
||||
maxAttempts Int @default(3)
|
||||
commandPayload Json?
|
||||
rawPayload String?
|
||||
manualRetryCount Int @default(0)
|
||||
lastRetryStreamId String?
|
||||
lastRetriedAt DateTime?
|
||||
resolvedAt DateTime?
|
||||
resolvedStatus String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([applicationId, status, createdAt])
|
||||
@@index([channelId, status, createdAt])
|
||||
@@index([messageId])
|
||||
@@index([submitId])
|
||||
}
|
||||
|
||||
model GatewayDownstreamRecoveryStatus {
|
||||
id String @id @default(cuid())
|
||||
account String @unique
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
gatewayInstanceId String?
|
||||
state String
|
||||
lockOwner String?
|
||||
lockExpiresAt DateTime?
|
||||
lastAttemptAt DateTime?
|
||||
lastSuccessAt DateTime?
|
||||
lastFailureAt DateTime?
|
||||
nextRetryAt DateTime?
|
||||
attemptCount Int @default(0)
|
||||
failureCategory String?
|
||||
lastError String?
|
||||
lastSkipReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||
|
||||
@@index([tenantId, state, updatedAt])
|
||||
@@index([applicationId, state, updatedAt])
|
||||
@@index([failureCategory, updatedAt])
|
||||
@@index([lockOwner, lockExpiresAt])
|
||||
@@index([state, updatedAt])
|
||||
@@index([nextRetryAt])
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ describe('ChannelsService', () => {
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
desiredConnections: 2,
|
||||
windowSize: 32,
|
||||
});
|
||||
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
|
||||
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
|
||||
@@ -166,6 +168,7 @@ describe('ChannelsService', () => {
|
||||
rateLimitPerSecond: 100,
|
||||
sendRegion: '全国',
|
||||
status: 'active',
|
||||
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32 }),
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
@@ -173,7 +176,7 @@ describe('ChannelsService', () => {
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
desiredConnections: 1,
|
||||
desiredConnections: 2,
|
||||
currentConnections: 0,
|
||||
}),
|
||||
});
|
||||
@@ -215,6 +218,8 @@ describe('ChannelsService', () => {
|
||||
sendRegion: '全国',
|
||||
account: 'sp-new',
|
||||
srcId: '10690001',
|
||||
desiredConnections: 3,
|
||||
windowSize: 64,
|
||||
unitPrice: 4,
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
id: 'channel-1',
|
||||
@@ -230,6 +235,7 @@ describe('ChannelsService', () => {
|
||||
gatewayPort: 27890,
|
||||
carrier: 'all',
|
||||
passwordCipher: undefined,
|
||||
config: expect.objectContaining({ desiredConnections: 3, windowSize: 64 }),
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface CreateChannelDto {
|
||||
rateLimitPerSecond?: number;
|
||||
unitPrice?: number;
|
||||
status?: string;
|
||||
desiredConnections?: number;
|
||||
windowSize?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -196,6 +198,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const config = normalizeChannelRuntimeConfig(data.config, data.desiredConnections, data.windowSize);
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
@@ -213,7 +216,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
rateLimitPerSecond: data.rateLimitPerSecond ?? 100,
|
||||
unitPrice: data.unitPrice ?? 0,
|
||||
status: data.status ?? 'active',
|
||||
config: data.config as Prisma.InputJsonValue | undefined,
|
||||
config: config as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (channel.status === 'active') {
|
||||
@@ -231,6 +234,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined
|
||||
? normalizeChannelRuntimeConfig(channel.config, data.desiredConnections, data.windowSize)
|
||||
: undefined;
|
||||
const updated = await this.prisma.smsChannel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
@@ -249,7 +255,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
rateLimitPerSecond: data.rateLimitPerSecond,
|
||||
unitPrice: data.unitPrice,
|
||||
status: data.status,
|
||||
config: data.config as Prisma.InputJsonValue | undefined,
|
||||
config: config as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
@@ -1105,6 +1111,26 @@ function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function normalizeChannelRuntimeConfig(config?: Prisma.JsonValue | Record<string, unknown> | null, desiredConnections?: number, windowSize?: number) {
|
||||
const base = config && typeof config === 'object' && !Array.isArray(config)
|
||||
? { ...(config as Record<string, unknown>) }
|
||||
: {};
|
||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
||||
return base;
|
||||
}
|
||||
|
||||
function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized <= 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { SendChainService } from '../send-chain/send-chain.service';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
type DownloadResponse = {
|
||||
setHeader(name: string, value: number | string): void;
|
||||
send(content: string | Buffer): void;
|
||||
};
|
||||
|
||||
@ApiTags('operations')
|
||||
@Controller('admin/operations')
|
||||
export class AdminOperationsController {
|
||||
constructor(private readonly operations: OperationsService) {}
|
||||
constructor(
|
||||
private readonly operations: OperationsService,
|
||||
private readonly sendChain: SendChainService,
|
||||
) {}
|
||||
|
||||
@Get('monitor')
|
||||
monitor(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
@@ -30,11 +39,27 @@ export class AdminOperationsController {
|
||||
return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, messageId, phoneNumber, status });
|
||||
}
|
||||
|
||||
@Get('message-segment-audits')
|
||||
messageSegmentAudits(
|
||||
@Query('messageId') messageId?: string,
|
||||
@Query('messageRecordId') messageRecordId?: string,
|
||||
) {
|
||||
return this.operations.listMessageSegmentAudits({ messageId, messageRecordId });
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId });
|
||||
}
|
||||
|
||||
@Post('uplink-messages/:id/claim')
|
||||
claimUplinkMatchCandidate(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { candidateId?: string; operatorId?: string },
|
||||
) {
|
||||
return this.sendChain.claimUplinkMatchCandidate(id, String(body.candidateId ?? ''), body.operatorId);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(@Query('tenantId') tenantId?: string) {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
@@ -76,6 +101,123 @@ export class AdminOperationsController {
|
||||
reconciliation(@Query('tenantId') tenantId?: string, @Query('taskId') taskId?: string) {
|
||||
return this.operations.reconciliation({ tenantId, taskId });
|
||||
}
|
||||
|
||||
@Get('gateway-submit-dead-letters')
|
||||
gatewaySubmitDeadLetters(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('channelId') channelId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.listGatewaySubmitDeadLetters({
|
||||
tenantId,
|
||||
applicationId,
|
||||
channelId,
|
||||
status,
|
||||
keyword,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('gateway-submit-dead-letters/:id/requeue')
|
||||
requeueGatewaySubmitDeadLetter(@Param('id') id: string) {
|
||||
return this.sendChain.requeueGatewaySubmitDeadLetter(id);
|
||||
}
|
||||
|
||||
@Get('downstream-deliveries')
|
||||
downstreamDeliveries(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('deliveryType') deliveryType?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.listDownstreamDeliveries({
|
||||
tenantId,
|
||||
applicationId,
|
||||
deliveryType,
|
||||
status,
|
||||
keyword,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('downstream-deliveries/dashboard')
|
||||
downstreamDeliveryDashboard(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('deliveryType') deliveryType?: string,
|
||||
) {
|
||||
return this.operations.downstreamDeliveryDashboard({
|
||||
tenantId,
|
||||
applicationId,
|
||||
deliveryType,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('downstream-recovery-statuses')
|
||||
downstreamRecoveryStatuses(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('state') state?: string,
|
||||
@Query('failureCategory') failureCategory?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.listDownstreamRecoveryStatuses({
|
||||
tenantId,
|
||||
applicationId,
|
||||
state,
|
||||
failureCategory,
|
||||
keyword,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('downstream-recovery-statuses/export')
|
||||
async exportDownstreamRecoveryStatuses(
|
||||
@Query('tenantId') tenantId: string | undefined,
|
||||
@Query('applicationId') applicationId: string | undefined,
|
||||
@Query('state') state: string | undefined,
|
||||
@Query('failureCategory') failureCategory: string | undefined,
|
||||
@Query('keyword') keyword: string | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
const exported = await this.operations.exportDownstreamRecoveryStatuses({
|
||||
tenantId,
|
||||
applicationId,
|
||||
state,
|
||||
failureCategory,
|
||||
keyword,
|
||||
});
|
||||
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
|
||||
response.send(`\uFEFF${exported.content}`);
|
||||
}
|
||||
|
||||
@Get('downstream-recovery-statuses/:id')
|
||||
downstreamRecoveryStatusDetail(@Param('id') id: string) {
|
||||
return this.operations.getDownstreamRecoveryStatus(id);
|
||||
}
|
||||
|
||||
@Post('downstream-deliveries/:id/requeue')
|
||||
requeueDownstreamDelivery(@Param('id') id: string) {
|
||||
return this.sendChain.requeueDownstreamDelivery(id);
|
||||
}
|
||||
|
||||
@Post('downstream-deliveries/requeue')
|
||||
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
|
||||
return this.sendChain.batchRequeueDownstreamDeliveries(body.ids ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('admin-system-logs')
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SendChainModule } from '../send-chain/send-chain.module';
|
||||
import { AdminOperationsController, AdminSystemLogsController } from './admin-operations.controller';
|
||||
import { ClientOperationsController } from './client-operations.controller';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, SendChainModule],
|
||||
controllers: [AdminOperationsController, AdminSystemLogsController, ClientOperationsController],
|
||||
providers: [OperationsService],
|
||||
exports: [OperationsService],
|
||||
|
||||
@@ -40,6 +40,12 @@ function createPrismaMock() {
|
||||
enterpriseCertification: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
smsApplication: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'app-1', name: '应用A' },
|
||||
{ id: 'app-2', name: '应用B' },
|
||||
]),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||
},
|
||||
@@ -58,6 +64,101 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]),
|
||||
},
|
||||
gatewaySubmitDeadLetter: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'dead-1',
|
||||
streamMessageId: '1710000000000-0',
|
||||
status: 'pending',
|
||||
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
||||
failureMessage: 'network down',
|
||||
tenant: { name: '租户A' },
|
||||
application: { name: '应用A' },
|
||||
channel: { code: 'CMPP-A' },
|
||||
}]),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'recover-1',
|
||||
account: '100001',
|
||||
state: 'waiting_connection',
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
||||
attemptCount: 2,
|
||||
failureCategory: 'client_disconnected',
|
||||
nextRetryAt: new Date('2026-07-08T12:10:00.000Z'),
|
||||
lastError: 'downstream client is not connected',
|
||||
tenant: { name: '租户A' },
|
||||
application: { name: '应用A' },
|
||||
}]),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'recover-1',
|
||||
account: '100001',
|
||||
state: 'waiting_connection',
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
||||
attemptCount: 2,
|
||||
failureCategory: 'client_disconnected',
|
||||
nextRetryAt: new Date('2026-07-08T12:10:00.000Z'),
|
||||
lastAttemptAt: new Date('2026-07-08T12:00:00.000Z'),
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: new Date('2026-07-08T11:58:00.000Z'),
|
||||
lastError: 'downstream client is not connected',
|
||||
lastSkipReason: 'waiting for reconnect',
|
||||
gatewayInstanceId: 'gw-01',
|
||||
createdAt: new Date('2026-07-08T11:50:00.000Z'),
|
||||
updatedAt: new Date('2026-07-08T12:00:00.000Z'),
|
||||
tenant: { name: '租户A' },
|
||||
application: { name: '应用A' },
|
||||
}),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
groupBy: jest.fn().mockResolvedValue([
|
||||
{ failureCategory: 'client_disconnected', _count: { _all: 1 } },
|
||||
]),
|
||||
},
|
||||
smsMessageSegmentAudit: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'segment-1',
|
||||
messageRecordId: 'record-1',
|
||||
submitId: 'SUB-1',
|
||||
segmentTotal: 2,
|
||||
segmentIndex: 1,
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-1-A',
|
||||
submitStatus: 'accepted',
|
||||
receiptStatus: 'delivered',
|
||||
channel: { name: '通道A' },
|
||||
}]),
|
||||
},
|
||||
cmppDownstreamDelivery: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'failed',
|
||||
payload: { account: '100001', phoneNumber: '13800000001' },
|
||||
retryCount: 10,
|
||||
lastError: 'client offline',
|
||||
tenant: { name: '租户A' },
|
||||
application: { name: '应用A' },
|
||||
messageRecord: { messageId: 'MSG-1' },
|
||||
}]),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
groupBy: jest.fn().mockResolvedValue([
|
||||
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
|
||||
{ deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } },
|
||||
{ deliveryType: 'receipt', status: 'delivered', _count: { _all: 6 } },
|
||||
{ deliveryType: 'uplink', status: 'pending', _count: { _all: 1 } },
|
||||
{ deliveryType: 'uplink', status: 'delivered', _count: { _all: 2 } },
|
||||
{ applicationId: 'app-1', status: 'pending', _count: { _all: 2 } },
|
||||
{ applicationId: 'app-1', status: 'failed', _count: { _all: 1 } },
|
||||
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } },
|
||||
{ applicationId: 'app-2', status: 'pending', _count: { _all: 1 } },
|
||||
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } },
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +201,20 @@ describe('OperationsService', () => {
|
||||
|
||||
expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', channelId: 'channel-1' },
|
||||
include: { tenant: true, channel: true },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
messageRecord: { include: { application: true } },
|
||||
matchCandidates: {
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
@@ -108,6 +222,12 @@ describe('OperationsService', () => {
|
||||
|
||||
it('builds dashboard and statistics aggregates', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(8)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(2);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.dashboard({ tenantId: 'tenant-1' })).resolves.toEqual(
|
||||
@@ -116,6 +236,14 @@ describe('OperationsService', () => {
|
||||
uplinkCount: 1,
|
||||
pendingAuditCount: 6,
|
||||
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
downstreamDeliverySummary: expect.objectContaining({
|
||||
pending: 3,
|
||||
failed: 2,
|
||||
delivered: 8,
|
||||
stalledPending: 1,
|
||||
recentFailed: 2,
|
||||
alertCount: 3,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
||||
@@ -169,4 +297,221 @@ describe('OperationsService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns paginated gateway submit dead letters', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.listGatewaySubmitDeadLetters({
|
||||
tenantId: 'tenant-1',
|
||||
status: 'pending',
|
||||
keyword: 'SUBMIT',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})).resolves.toEqual({
|
||||
items: [expect.objectContaining({ id: 'dead-1', status: 'pending' })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
expect(prisma.gatewaySubmitDeadLetter.findMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
status: 'pending',
|
||||
}),
|
||||
include: { tenant: true, application: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns paginated downstream deliveries', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.listDownstreamDeliveries({
|
||||
tenantId: 'tenant-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'failed',
|
||||
keyword: '1380',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})).resolves.toEqual({
|
||||
items: [expect.objectContaining({ id: 'delivery-1', status: 'failed' })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'failed',
|
||||
}),
|
||||
include: { tenant: true, application: true, messageRecord: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds downstream delivery dashboard aggregates', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
||||
.mockResolvedValueOnce(12)
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(8)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(0);
|
||||
prisma.cmppDownstreamDelivery.groupBy = jest.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
|
||||
{ deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } },
|
||||
{ deliveryType: 'receipt', status: 'delivered', _count: { _all: 6 } },
|
||||
{ deliveryType: 'uplink', status: 'pending', _count: { _all: 1 } },
|
||||
{ deliveryType: 'uplink', status: 'delivered', _count: { _all: 2 } },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ applicationId: 'app-1', status: 'pending', _count: { _all: 2 } },
|
||||
{ applicationId: 'app-1', status: 'failed', _count: { _all: 1 } },
|
||||
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } },
|
||||
{ applicationId: 'app-2', status: 'pending', _count: { _all: 1 } },
|
||||
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } },
|
||||
]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.downstreamDeliveryDashboard({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
deliveryType: 'all',
|
||||
})).resolves.toEqual({
|
||||
summary: {
|
||||
total: 12,
|
||||
pending: 3,
|
||||
delivered: 8,
|
||||
failed: 1,
|
||||
stalledPending: 1,
|
||||
recentFailed: 1,
|
||||
alertCount: 2,
|
||||
},
|
||||
typeBreakdown: [
|
||||
{ deliveryType: 'receipt', total: 9, pending: 2, delivered: 6, failed: 1 },
|
||||
{ deliveryType: 'uplink', total: 3, pending: 1, delivered: 2, failed: 0 },
|
||||
],
|
||||
retryBuckets: [
|
||||
{ label: '0次', count: 2 },
|
||||
{ label: '1-3次', count: 1 },
|
||||
{ label: '4次及以上', count: 0 },
|
||||
],
|
||||
topApplications: [
|
||||
{ applicationId: 'app-1', name: '应用A', pending: 2, failed: 1, delivered: 5, alertCount: 3 },
|
||||
{ applicationId: 'app-2', name: '应用B', pending: 1, failed: 0, delivered: 3, alertCount: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns paginated downstream recovery statuses', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.gatewayDownstreamRecoveryStatus.count = jest.fn()
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.listDownstreamRecoveryStatuses({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
state: 'waiting_connection',
|
||||
failureCategory: 'client_disconnected',
|
||||
keyword: '100001',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})).resolves.toEqual({
|
||||
items: [expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection' })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
summary: {
|
||||
total: 1,
|
||||
running: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
waitingConnection: 1,
|
||||
backoff: 1,
|
||||
failureCategories: [
|
||||
{ category: 'client_disconnected', count: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns downstream recovery status detail', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.getDownstreamRecoveryStatus('recover-1')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'recover-1',
|
||||
account: '100001',
|
||||
gatewayInstanceId: 'gw-01',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prisma.gatewayDownstreamRecoveryStatus.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'recover-1' },
|
||||
include: { tenant: true, application: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('exports downstream recovery statuses as csv rows', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.exportDownstreamRecoveryStatuses({
|
||||
tenantId: 'tenant-1',
|
||||
state: 'waiting_connection',
|
||||
keyword: '100001',
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
total: 1,
|
||||
fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/),
|
||||
content: expect.stringContaining('100001'),
|
||||
}));
|
||||
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
failureCategory: undefined,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns message segment audit rows', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.listMessageSegmentAudits({ messageRecordId: 'record-1' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'segment-1',
|
||||
segmentIndex: 1,
|
||||
gatewayMessageId: 'GW-1-A',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(prisma.smsMessageSegmentAudit.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
messageRecordId: 'record-1',
|
||||
messageRecord: undefined,
|
||||
},
|
||||
include: { channel: true, submitRecord: true },
|
||||
orderBy: [{ submitId: 'asc' }, { segmentIndex: 'asc' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -27,6 +27,47 @@ export interface OperationLogQuery {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitDeadLetterQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface DownstreamDeliveryQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface DownstreamDeliveryDashboardQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
export interface DownstreamRecoveryStatusQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
state?: string;
|
||||
failureCategory?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MessageSegmentAuditQuery {
|
||||
messageId?: string;
|
||||
messageRecordId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OperationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -52,7 +93,20 @@ export class OperationsService {
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string }) {
|
||||
return this.prisma.smsUplinkMessage.findMany({
|
||||
where: { tenantId: query.tenantId, channelId: query.channelId },
|
||||
include: { tenant: true, channel: true },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
messageRecord: { include: { application: true } },
|
||||
matchCandidates: {
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
@@ -99,6 +153,11 @@ export class OperationsService {
|
||||
tenantAccounts,
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
downstreamPendingCount,
|
||||
downstreamFailedCount,
|
||||
downstreamDeliveredCount,
|
||||
downstreamStalledPendingCount,
|
||||
downstreamRecentFailedCount,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
@@ -152,8 +211,32 @@ export class OperationsService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'pending' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'failed' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'delivered' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: 'pending',
|
||||
createdAt: { lte: new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000) },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: 'failed',
|
||||
updatedAt: { gte: new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamRecentFailedCount;
|
||||
return {
|
||||
taskCount,
|
||||
messageStatus: messageGroups,
|
||||
@@ -171,6 +254,14 @@ export class OperationsService {
|
||||
transactions: transactionAggregate,
|
||||
gatewayConnections: connectionGroups,
|
||||
pendingAuditCount,
|
||||
downstreamDeliverySummary: {
|
||||
pending: downstreamPendingCount,
|
||||
failed: downstreamFailedCount,
|
||||
delivered: downstreamDeliveredCount,
|
||||
stalledPending: downstreamStalledPendingCount,
|
||||
recentFailed: downstreamRecentFailedCount,
|
||||
alertCount: downstreamAlertCount,
|
||||
},
|
||||
accounts: tenantAccounts,
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
@@ -256,6 +347,303 @@ export class OperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ streamMessageId: { contains: query.keyword } },
|
||||
{ traceId: { contains: query.keyword } },
|
||||
{ messageId: { contains: query.keyword } },
|
||||
{ submitId: { contains: query.keyword } },
|
||||
{ failureCode: { contains: query.keyword } },
|
||||
{ failureMessage: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.gatewaySubmitDeadLetter.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async listDownstreamDeliveries(query: DownstreamDeliveryQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where: Prisma.CmppDownstreamDeliveryWhereInput = {
|
||||
...downstreamDeliveryScopedWhere(query),
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ messageId: { contains: query.keyword } },
|
||||
{ payload: { path: ['account'], string_contains: query.keyword } },
|
||||
{ payload: { path: ['phoneNumber'], string_contains: query.keyword } },
|
||||
{ lastError: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true, messageRecord: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) {
|
||||
const scopedWhere = downstreamDeliveryScopedWhere(query);
|
||||
const stalledPendingAt = new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000);
|
||||
const recentFailedAt = new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000);
|
||||
const [total, pending, delivered, failed, stalledPending, recentFailed, typeGroups, applicationGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: 'pending',
|
||||
createdAt: { lte: stalledPendingAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: 'failed',
|
||||
updatedAt: { gte: recentFailedAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['deliveryType', 'status'],
|
||||
where: scopedWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['applicationId', 'status'],
|
||||
where: scopedWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed'] },
|
||||
retryCount: 0,
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed'] },
|
||||
retryCount: { gte: 1, lte: 3 },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed'] },
|
||||
retryCount: { gte: 4 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const applicationIds = [...new Set(applicationGroups.map((item) => item.applicationId).filter((value): value is string => Boolean(value)))];
|
||||
const applications: Array<{ id: string; name: string }> = applicationIds.length > 0
|
||||
? await this.prisma.smsApplication.findMany({
|
||||
where: { id: { in: applicationIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const applicationMap = new Map<string, string>(applications.map((item) => [item.id, item.name]));
|
||||
const groupedByType = groupDownstreamByType(typeGroups);
|
||||
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total,
|
||||
pending,
|
||||
delivered,
|
||||
failed,
|
||||
stalledPending,
|
||||
recentFailed,
|
||||
alertCount: stalledPending + recentFailed,
|
||||
},
|
||||
typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({
|
||||
deliveryType,
|
||||
total: groupedByType[deliveryType]?.total ?? 0,
|
||||
pending: groupedByType[deliveryType]?.pending ?? 0,
|
||||
delivered: groupedByType[deliveryType]?.delivered ?? 0,
|
||||
failed: groupedByType[deliveryType]?.failed ?? 0,
|
||||
})),
|
||||
retryBuckets: [
|
||||
{ label: '0次', count: retryZero },
|
||||
{ label: '1-3次', count: retryLow },
|
||||
{ label: '4次及以上', count: retryHigh },
|
||||
],
|
||||
topApplications: groupedByApplication
|
||||
.sort((left, right) => (
|
||||
right.alertCount - left.alertCount
|
||||
|| right.failed - left.failed
|
||||
|| right.pending - left.pending
|
||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||
))
|
||||
.slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
async listDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where = downstreamRecoveryStatusWhere(query);
|
||||
const now = new Date();
|
||||
const [items, total, runningCount, successCount, failedCount, waitingConnectionCount, backoffCount, categoryGroups] = await Promise.all([
|
||||
recoveryStatuses.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
recoveryStatuses.count({ where }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'running' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'success' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'failed' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'waiting_connection' } }),
|
||||
recoveryStatuses.count({
|
||||
where: {
|
||||
...where,
|
||||
nextRetryAt: { gt: now },
|
||||
},
|
||||
}),
|
||||
recoveryStatuses.groupBy({
|
||||
by: ['failureCategory'],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
total,
|
||||
running: runningCount,
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
waitingConnection: waitingConnectionCount,
|
||||
backoff: backoffCount,
|
||||
failureCategories: categoryGroups
|
||||
.filter((item) => item.failureCategory)
|
||||
.map((item) => ({
|
||||
category: String(item.failureCategory),
|
||||
count: item._count?._all ?? 0,
|
||||
}))
|
||||
.sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listMessageSegmentAudits(query: MessageSegmentAuditQuery) {
|
||||
const segmentAudits = (this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
if (!query.messageId && !query.messageRecordId) {
|
||||
return [];
|
||||
}
|
||||
return segmentAudits.findMany({
|
||||
where: {
|
||||
messageRecordId: query.messageRecordId,
|
||||
messageRecord: query.messageId ? { messageId: query.messageId } : undefined,
|
||||
},
|
||||
include: { channel: true, submitRecord: true },
|
||||
orderBy: [{ submitId: 'asc' }, { segmentIndex: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async getDownstreamRecoveryStatus(id: string) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const item = await recoveryStatuses.findUnique({
|
||||
where: { id },
|
||||
include: { tenant: true, application: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException('Recovery status not found');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async exportDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const where = downstreamRecoveryStatusWhere(query);
|
||||
const items = await recoveryStatuses.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
|
||||
take: 5000,
|
||||
});
|
||||
const rows = [
|
||||
[
|
||||
'账号',
|
||||
'企业',
|
||||
'应用',
|
||||
'Gateway实例',
|
||||
'恢复状态',
|
||||
'锁持有实例',
|
||||
'锁过期时间',
|
||||
'失败分类',
|
||||
'尝试次数',
|
||||
'最后尝试时间',
|
||||
'恢复成功时间',
|
||||
'恢复失败时间',
|
||||
'下次恢复时间',
|
||||
'最后错误',
|
||||
'最后跳过原因',
|
||||
'创建时间',
|
||||
'更新时间',
|
||||
],
|
||||
...items.map((item) => [
|
||||
item.account ?? '',
|
||||
item.tenant?.name ?? '',
|
||||
item.application?.name ?? '',
|
||||
item.gatewayInstanceId ?? '',
|
||||
item.state ?? '',
|
||||
(item as { lockOwner?: string | null }).lockOwner ?? '',
|
||||
formatCsvDate((item as { lockExpiresAt?: Date | string | null }).lockExpiresAt),
|
||||
(item as { failureCategory?: string | null }).failureCategory ?? '',
|
||||
String(item.attemptCount ?? 0),
|
||||
formatCsvDate(item.lastAttemptAt),
|
||||
formatCsvDate(item.lastSuccessAt),
|
||||
formatCsvDate(item.lastFailureAt),
|
||||
formatCsvDate(item.nextRetryAt),
|
||||
item.lastError ?? '',
|
||||
item.lastSkipReason ?? '',
|
||||
formatCsvDate(item.createdAt),
|
||||
formatCsvDate(item.updatedAt),
|
||||
]),
|
||||
];
|
||||
|
||||
return {
|
||||
fileName: `gateway-downstream-recovery-statuses-${formatExportTimestamp(new Date())}.csv`,
|
||||
content: rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n'),
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
auditSummary(query: { tenantId?: string }) {
|
||||
return this.prisma.operationLog.groupBy({
|
||||
by: ['action', 'resource'],
|
||||
@@ -346,6 +734,17 @@ export class OperationsService {
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
]).then((counts) => counts.reduce((sum, value) => sum + value, 0));
|
||||
}
|
||||
|
||||
private gatewayDownstreamRecoveryStatusDelegate() {
|
||||
return (this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
count: (args: Record<string, unknown>) => Promise<number>;
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
groupBy: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).gatewayDownstreamRecoveryStatus;
|
||||
}
|
||||
}
|
||||
|
||||
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
@@ -390,6 +789,68 @@ function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
|
||||
return { gte: date };
|
||||
}
|
||||
|
||||
function downstreamAlertPendingMinutes() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : 10;
|
||||
}
|
||||
|
||||
function downstreamAlertRecentFailedHours() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1);
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
|
||||
function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
state: query.state && query.state !== 'all' ? query.state : undefined,
|
||||
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ account: { contains: query.keyword } },
|
||||
{ gatewayInstanceId: { contains: query.keyword } },
|
||||
{ lastError: { contains: query.keyword } },
|
||||
{ lastSkipReason: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: string) {
|
||||
const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) {
|
||||
return `"${normalized.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatCsvDate(value?: Date | string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function formatExportTimestamp(date: Date) {
|
||||
const parts = [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
String(date.getHours()).padStart(2, '0'),
|
||||
String(date.getMinutes()).padStart(2, '0'),
|
||||
String(date.getSeconds()).padStart(2, '0'),
|
||||
];
|
||||
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
|
||||
}
|
||||
|
||||
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | null; billingUnits: number | null } }>) {
|
||||
return groups.reduce(
|
||||
(summary, group) => {
|
||||
@@ -410,6 +871,51 @@ function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all:
|
||||
);
|
||||
}
|
||||
|
||||
function groupDownstreamByType(
|
||||
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
|
||||
) {
|
||||
return groups.reduce<Record<string, { total: number; pending: number; delivered: number; failed: number }>>((accumulator, item) => {
|
||||
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, delivered: 0, failed: 0 };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
}
|
||||
accumulator[item.deliveryType] = current;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function groupDownstreamByApplication(
|
||||
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
|
||||
applicationMap: Map<string, string>,
|
||||
) {
|
||||
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; failed: number; delivered: number; alertCount: number }>();
|
||||
groups.forEach((item) => {
|
||||
const current = summaryMap.get(item.applicationId) ?? {
|
||||
applicationId: item.applicationId,
|
||||
name: applicationMap.get(item.applicationId) ?? item.applicationId,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
delivered: 0,
|
||||
alertCount: 0,
|
||||
};
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
}
|
||||
current.alertCount = current.pending + current.failed;
|
||||
summaryMap.set(item.applicationId, current);
|
||||
});
|
||||
return [...summaryMap.values()];
|
||||
}
|
||||
|
||||
function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
|
||||
const detail = (log.detail ?? {}) as Record<string, unknown>;
|
||||
const result = String(detail.result ?? detail.status ?? '');
|
||||
|
||||
@@ -3,7 +3,10 @@ import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
GatewayInboundAuthDto,
|
||||
GatewayInboundSubmitDto,
|
||||
GatewayPendingDeliveryQueryDto,
|
||||
GatewayDownstreamRecoveryStatusDto,
|
||||
GatewayReceiptEventDto,
|
||||
GatewaySubmitDeadLetterDto,
|
||||
GatewaySubmitResultDto,
|
||||
GatewayUplinkEventDto,
|
||||
SendChainService,
|
||||
@@ -29,6 +32,11 @@ export class GatewayEventsController {
|
||||
return this.sendChain.handleUplink(body);
|
||||
}
|
||||
|
||||
@Post('dead-letter')
|
||||
deadLetter(@Body() body: GatewaySubmitDeadLetterDto) {
|
||||
return this.sendChain.recordGatewaySubmitDeadLetter(body);
|
||||
}
|
||||
|
||||
@Post('inbound/authenticate')
|
||||
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||
return this.sendChain.authenticateInboundApplication(body);
|
||||
@@ -38,4 +46,24 @@ export class GatewayEventsController {
|
||||
submitInbound(@Body() body: GatewayInboundSubmitDto) {
|
||||
return this.sendChain.submitInboundMessage(body);
|
||||
}
|
||||
|
||||
@Post('downstream/pending')
|
||||
pendingDownstream(@Body() body: GatewayPendingDeliveryQueryDto) {
|
||||
return this.sendChain.listPendingDownstreamDeliveries(body);
|
||||
}
|
||||
|
||||
@Post('downstream/delivered')
|
||||
downstreamDelivered(@Body() body: { id: string }) {
|
||||
return this.sendChain.markDownstreamDeliveryDelivered(body.id);
|
||||
}
|
||||
|
||||
@Post('downstream/failed')
|
||||
downstreamFailed(@Body() body: { id: string; errorMessage?: string }) {
|
||||
return this.sendChain.markDownstreamDeliveryFailed(body.id, body.errorMessage);
|
||||
}
|
||||
|
||||
@Post('downstream/recovery-status')
|
||||
downstreamRecoveryStatus(@Body() body: GatewayDownstreamRecoveryStatusDto) {
|
||||
return this.sendChain.recordGatewayDownstreamRecoveryStatus(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ function createPrismaMock() {
|
||||
status: 'active',
|
||||
carrier: 'mobile',
|
||||
sendRegion: '全国',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
passwordCipher: 'secret',
|
||||
cmppVersion: '3.0',
|
||||
config: { serviceId: 'SMS' },
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
};
|
||||
@@ -60,6 +64,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }]),
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
@@ -109,6 +114,7 @@ function createPrismaMock() {
|
||||
},
|
||||
channelRouteRule: {
|
||||
findFirst: jest.fn().mockResolvedValue(route),
|
||||
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', applicationId: 'app-1' }]),
|
||||
},
|
||||
phoneCarrierRule: {
|
||||
findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]),
|
||||
@@ -126,8 +132,16 @@ function createPrismaMock() {
|
||||
smsSubmitRecord: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted' }),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
smsMessageSegmentAudit: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'segment-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
|
||||
},
|
||||
@@ -138,6 +152,103 @@ function createPrismaMock() {
|
||||
smsUplinkMessage: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'uplink-1', matchStatus: 'matched', matchCandidates: [] }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
||||
},
|
||||
smsUplinkMatchCandidate: {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'candidate-1',
|
||||
uplinkMessageId: 'uplink-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageRecordId: 'record-1',
|
||||
matchSource: 'phone_window',
|
||||
confidence: 55,
|
||||
reason: '手机号 72 小时窗口候选下发 MSG-1',
|
||||
status: 'pending',
|
||||
application: { id: 'app-1', name: '应用A', cmppAccount: '100001' },
|
||||
messageRecord: { id: 'record-1', messageId: 'MSG-1', content: 'hello' },
|
||||
uplinkMessage: {
|
||||
id: 'uplink-1',
|
||||
tenantId: null,
|
||||
applicationId: null,
|
||||
messageRecordId: null,
|
||||
messageId: null,
|
||||
channelId: 'channel-1',
|
||||
phoneNumber: '13800000001',
|
||||
destId: '10690000',
|
||||
content: '回复TD',
|
||||
matchStatus: 'ambiguous',
|
||||
receivedAt: new Date('2026-07-08T12:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
|
||||
},
|
||||
cmppDownstreamDelivery: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', ...data, createdAt: new Date(), updatedAt: new Date() })),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
retryCount: 0,
|
||||
lastError: null,
|
||||
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
||||
application: { cmppAccount: '100001' },
|
||||
}),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', ...data })),
|
||||
},
|
||||
gatewaySubmitDeadLetter: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'dead-1' }),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'dead-1',
|
||||
tenantId: 'tenant-1',
|
||||
streamMessageId: '1710000000000-0',
|
||||
submitId: 'SUB-1',
|
||||
messageId: 'MSG-1',
|
||||
commandPayload: {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
traceId: 'trace-1',
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
createdAt: '2026-07-08T12:00:00.000Z',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
submitId: 'SUB-1',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
signature: '签名',
|
||||
templateId: 'tpl-1',
|
||||
billingUnits: 1,
|
||||
queuePriority: 'normal',
|
||||
route: { channelCode: 'CMPP-A', cmppAccountCode: 'account-a', priority: 0 },
|
||||
cmpp: { serviceId: 'SMS', srcId: '10690000', registeredDelivery: 1, msgFmt: 8 },
|
||||
upstream: { gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'account-a', passwordCipher: 'secret', cmppVersion: '3.0' },
|
||||
retry: { attempt: 0, maxAttempts: 1 },
|
||||
},
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
upsert: jest.fn().mockResolvedValue({
|
||||
id: 'recover-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
account: '100001',
|
||||
state: 'waiting_connection',
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
||||
attemptCount: 2,
|
||||
failureCategory: 'client_disconnected',
|
||||
nextRetryAt: new Date('2026-07-08T12:10:00.000Z'),
|
||||
lastError: 'downstream client is not connected',
|
||||
}),
|
||||
},
|
||||
smsBillingRecord: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
@@ -148,12 +259,16 @@ function createPrismaMock() {
|
||||
accountTransaction: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
|
||||
},
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
globalBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,7 +293,10 @@ function createService(prisma = createPrismaMock()) {
|
||||
task: { id: 'risk-task-1' },
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
return { service: new SendChainService(prisma as never, billing, riskReview), prisma, billing, riskReview };
|
||||
const service = new SendChainService(prisma as never, billing, riskReview);
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
||||
return { service, prisma, billing, riskReview };
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
@@ -371,8 +489,11 @@ describe('SendChainService', () => {
|
||||
phoneNumber: '13800000001',
|
||||
route: expect.objectContaining({ channelCode: 'CMPP-A', rateLimitPerSecond: 100 }),
|
||||
cmpp: expect.objectContaining({ serviceId: 'SMS', srcId: '10690000' }),
|
||||
upstream: expect.objectContaining({ gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'cmpp-account' }),
|
||||
}),
|
||||
);
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ messageId: 'MSG-1' }));
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
|
||||
});
|
||||
|
||||
it('updates submit result status, charges billing, and task progress', async () => {
|
||||
@@ -386,6 +507,24 @@ describe('SendChainService', () => {
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'accepted',
|
||||
submittedAt: '2026-07-01T10:00:00.000Z',
|
||||
segments: [
|
||||
{
|
||||
segmentTotal: 2,
|
||||
segmentIndex: 1,
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-1-A',
|
||||
submitStatus: 'accepted',
|
||||
submittedAt: '2026-07-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
segmentTotal: 2,
|
||||
segmentIndex: 2,
|
||||
sequenceId: 8,
|
||||
gatewayMessageId: 'GW-1-B',
|
||||
submitStatus: 'accepted',
|
||||
submittedAt: '2026-07-01T10:00:01.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
||||
@@ -401,6 +540,18 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }),
|
||||
});
|
||||
expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsMessageSegmentAudit.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { messageRecordId_submitId_segmentIndex: { messageRecordId: 'record-1', submitId: 'SUB-1', segmentIndex: 1 } },
|
||||
create: expect.objectContaining({ segmentTotal: 2, segmentIndex: 1, gatewayMessageId: 'GW-1-A', submitStatus: 'accepted' }),
|
||||
}));
|
||||
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: { in: ['pending', 'requeued'] },
|
||||
OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }],
|
||||
},
|
||||
data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
||||
@@ -520,6 +671,88 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'submit-timeout-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: null,
|
||||
submitStatus: 'timeout',
|
||||
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
|
||||
messageRecord: {
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: null,
|
||||
status: 'timeout',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'receipt-123456789',
|
||||
channelId: 'channel-1',
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-RECOVERED-1',
|
||||
phoneNumber: '13800000001',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
deliveredAt: '2026-07-01T10:01:00.000Z',
|
||||
});
|
||||
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'submit-timeout-1',
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: 'GW-RECOVERED-1',
|
||||
sequenceId: 7,
|
||||
},
|
||||
});
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
messageRecordId: 'record-1',
|
||||
messageId: 'MSG-1',
|
||||
gatewayMessageId: 'GW-RECOVERED-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue(null);
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'submit-timeout-1',
|
||||
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
|
||||
},
|
||||
{
|
||||
id: 'submit-timeout-2',
|
||||
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.handleReceipt({
|
||||
messageId: 'receipt-ambiguous',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-AMBIGUOUS',
|
||||
phoneNumber: '13800000001',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
}),
|
||||
).rejects.toThrow('SMS message record not found');
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks submit when signature is not approved on the selected channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -635,6 +868,301 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records ambiguous uplink match candidates for shared access numbers', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([
|
||||
{ applicationId: 'app-1' },
|
||||
{ applicationId: 'app-2' },
|
||||
]);
|
||||
prisma.smsApplication.findMany.mockResolvedValue([
|
||||
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
|
||||
{ id: 'app-2', tenantId: 'tenant-2', name: '应用B' },
|
||||
]);
|
||||
|
||||
await service.handleUplink({
|
||||
channelId: 'channel-1',
|
||||
sequenceId: 8,
|
||||
phoneNumber: '13800000001',
|
||||
destId: '10690000',
|
||||
content: '回复TD',
|
||||
receivedAt: '2026-07-01T10:02:00.000Z',
|
||||
});
|
||||
|
||||
expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: undefined,
|
||||
applicationId: undefined,
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: '接入号匹配多个应用',
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', matchSource: 'access_number', confidence: 70 }),
|
||||
expect.objectContaining({ tenantId: 'tenant-2', applicationId: 'app-2', matchSource: 'access_number', confidence: 70 }),
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('claims an ambiguous uplink candidate and queues downstream uplink delivery', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
|
||||
await service.claimUplinkMatchCandidate('uplink-1', 'candidate-1', 'admin-1');
|
||||
|
||||
expect(prisma.smsUplinkMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'uplink-1' },
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageRecordId: 'record-1',
|
||||
messageId: 'MSG-1',
|
||||
matchStatus: 'matched',
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsUplinkMatchCandidate.updateMany).toHaveBeenCalledWith({
|
||||
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
|
||||
data: { status: 'rejected' },
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageRecordId: 'record-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'uplink',
|
||||
status: 'pending',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('records gateway submit dead letters and allows manual requeue', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue('1710000001000-0');
|
||||
|
||||
await service.recordGatewaySubmitDeadLetter({
|
||||
streamMessageId: '1710000000000-0',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
traceId: 'trace-1',
|
||||
messageId: 'MSG-1',
|
||||
submitId: 'SUB-1',
|
||||
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
||||
failureMessage: 'network down',
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
commandPayload: { messageType: 'SubmitCommand', submitId: 'SUB-1' },
|
||||
rawPayload: '{"messageType":"SubmitCommand"}',
|
||||
deadLetteredAt: '2026-07-08T12:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith({
|
||||
where: { streamMessageId: '1710000000000-0' },
|
||||
update: expect.objectContaining({
|
||||
submitId: 'SUB-1',
|
||||
failureCode: 'SUBMIT_PROCESSING_FAILED',
|
||||
attempts: 3,
|
||||
}),
|
||||
create: expect.objectContaining({
|
||||
streamMessageId: '1710000000000-0',
|
||||
failureMessage: 'network down',
|
||||
}),
|
||||
});
|
||||
|
||||
await service.requeueGatewaySubmitDeadLetter('dead-1');
|
||||
|
||||
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' }));
|
||||
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dead-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: '1710000001000-0',
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'gateway.submit_dead_letter_requeue',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: 'dead-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('records gateway downstream recovery statuses', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
await expect(service.recordGatewayDownstreamRecoveryStatus({
|
||||
account: '100001',
|
||||
gatewayInstanceId: 'gateway-a',
|
||||
state: 'waiting_connection',
|
||||
lastAttemptAt: '2026-07-08T12:00:00.000Z',
|
||||
nextRetryAt: '2026-07-08T12:10:00.000Z',
|
||||
attemptCount: 2,
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: '2026-07-08T12:00:30.000Z',
|
||||
lastError: 'downstream client is not connected',
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
id: 'recover-1',
|
||||
account: '100001',
|
||||
state: 'waiting_connection',
|
||||
}));
|
||||
|
||||
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { account: '100001' },
|
||||
update: expect.objectContaining({
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
||||
failureCategory: 'client_disconnected',
|
||||
}),
|
||||
create: expect.objectContaining({
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
||||
failureCategory: 'client_disconnected',
|
||||
}),
|
||||
}));
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'gateway.downstream_recovery_status_sync',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('marks downstream delivery as failed after reaching retry limit', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
|
||||
process.env.CMPP_DOWNSTREAM_MAX_RETRIES = '2';
|
||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
retryCount: 1,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.markDownstreamDeliveryFailed('delivery-1', 'client offline');
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
|
||||
} else {
|
||||
process.env.CMPP_DOWNSTREAM_MAX_RETRIES = previous;
|
||||
}
|
||||
}
|
||||
|
||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({
|
||||
where: { id: 'delivery-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
retryCount: 2,
|
||||
nextRetryAt: null,
|
||||
lastError: 'client offline',
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'gateway.downstream_delivery_failed',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: 'delivery-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses exponential backoff for downstream delivery retries before final failure', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const previousBase = process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS;
|
||||
const previousMax = process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
||||
const now = Date.UTC(2026, 6, 8, 12, 0, 0);
|
||||
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = '60000';
|
||||
process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = '600000';
|
||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
retryCount: 2,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.markDownstreamDeliveryFailed('delivery-1', 'temporary network jitter');
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
if (previousBase === undefined) {
|
||||
delete process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS;
|
||||
} else {
|
||||
process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS = previousBase;
|
||||
}
|
||||
if (previousMax === undefined) {
|
||||
delete process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
||||
} else {
|
||||
process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS = previousMax;
|
||||
}
|
||||
}
|
||||
|
||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith({
|
||||
where: { id: 'delivery-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
retryCount: 3,
|
||||
nextRetryAt: new Date(now + 240_000),
|
||||
lastError: 'temporary network jitter',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('requeues downstream delivery through real gateway control path', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
|
||||
await service.requeueDownstreamDelivery('delivery-1');
|
||||
|
||||
expect(service['postGatewayControl']).toHaveBeenCalledWith(
|
||||
'/downstream/receipt',
|
||||
expect.objectContaining({
|
||||
deliveryId: 'delivery-1',
|
||||
account: '100001',
|
||||
messageId: 'MSG-1',
|
||||
}),
|
||||
);
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: 'delivery-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('supports batch requeue of downstream deliveries', async () => {
|
||||
const { service } = createService();
|
||||
service.requeueDownstreamDelivery = jest.fn()
|
||||
.mockResolvedValueOnce({ id: 'delivery-1' })
|
||||
.mockRejectedValueOnce(new Error('Gateway control delivery failed'));
|
||||
|
||||
await expect(service.batchRequeueDownstreamDeliveries(['delivery-1', 'delivery-2'])).resolves.toEqual({
|
||||
total: 2,
|
||||
successCount: 1,
|
||||
failedCount: 1,
|
||||
results: [
|
||||
{ id: 'delivery-1', status: 'success' },
|
||||
{ id: 'delivery-2', status: 'failed', errorMessage: 'Gateway control delivery failed' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty downstream batch requeue selection', async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.batchRequeueDownstreamDeliveries([])).rejects.toThrow('请选择至少一条下游投递记录');
|
||||
});
|
||||
|
||||
it('marks 72 hour unknown receipts as timeout', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ function createPrismaMock() {
|
||||
name: '应用A',
|
||||
status: 'active',
|
||||
cmppAccount: '100001',
|
||||
cmppMaxConnections: 2,
|
||||
cmppWindowSize: 32,
|
||||
queuePriority: 'normal',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
|
||||
@@ -19,6 +21,8 @@ function createPrismaMock() {
|
||||
name: '应用A',
|
||||
status: 'active',
|
||||
cmppAccount: '100001',
|
||||
cmppMaxConnections: 2,
|
||||
cmppWindowSize: 32,
|
||||
queuePriority: 'normal',
|
||||
secretHash: 'secret-hash',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
@@ -170,6 +174,7 @@ describe('SmsConfigService', () => {
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
maxConnections: 2,
|
||||
windowSize: 32,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -181,6 +186,9 @@ describe('SmsConfigService', () => {
|
||||
await expect(service.createApplication({
|
||||
tenantId: 'tenant-1',
|
||||
name: '优先应用',
|
||||
cmppAccount: '123456',
|
||||
cmppMaxConnections: 3,
|
||||
cmppWindowSize: 32,
|
||||
queuePriority: 'priority',
|
||||
ipAllowlist: ['10.0.0.1/32'],
|
||||
})).resolves.toEqual(expect.objectContaining({ id: 'app-new' }));
|
||||
@@ -189,7 +197,9 @@ describe('SmsConfigService', () => {
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
name: '优先应用',
|
||||
cmppAccount: expect.stringMatching(/^\d{6}$/),
|
||||
cmppAccount: '123456',
|
||||
cmppMaxConnections: 3,
|
||||
cmppWindowSize: 32,
|
||||
queuePriority: 'priority',
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
}),
|
||||
@@ -209,6 +219,23 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.smsApplication.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects duplicate or invalid CMPP application accounts', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createApplication({
|
||||
tenantId: 'tenant-1',
|
||||
name: '异常应用',
|
||||
cmppAccount: 'abc',
|
||||
})).rejects.toThrow('cmppAccount must be a 6-digit number');
|
||||
|
||||
await expect(service.createApplication({
|
||||
tenantId: 'tenant-1',
|
||||
name: '重复应用',
|
||||
cmppAccount: '100001',
|
||||
})).rejects.toThrow('cmppAccount already exists');
|
||||
});
|
||||
|
||||
it('updates enterprise application profile and allowlist through a transaction', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface CreateSmsApplicationDto {
|
||||
name: string;
|
||||
scene?: string;
|
||||
callbackUrl?: string;
|
||||
cmppAccount?: string;
|
||||
cmppMaxConnections?: number;
|
||||
cmppWindowSize?: number;
|
||||
dailyLimit?: number;
|
||||
customerUnitPrice?: number;
|
||||
queuePriority?: string;
|
||||
@@ -153,7 +156,7 @@ export class SmsConfigService {
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
const secret = randomBytes(24).toString('hex');
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const cmppAccount = await this.generateCmppAccount();
|
||||
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
|
||||
return this.prisma.smsApplication.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -162,6 +165,8 @@ export class SmsConfigService {
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
secretHash: hashSecret(secret),
|
||||
cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: data.dailyLimit,
|
||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||
queuePriority,
|
||||
@@ -183,6 +188,9 @@ export class SmsConfigService {
|
||||
const queuePriority = data.queuePriority === undefined
|
||||
? undefined
|
||||
: normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const cmppAccount = data.cmppAccount === undefined
|
||||
? undefined
|
||||
: await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (data.ipAllowlist) {
|
||||
@@ -194,6 +202,9 @@ export class SmsConfigService {
|
||||
name: data.name,
|
||||
scene: data.scene,
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: data.dailyLimit,
|
||||
customerUnitPrice: data.customerUnitPrice,
|
||||
queuePriority,
|
||||
@@ -350,13 +361,24 @@ export class SmsConfigService {
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
srcId: channel?.srcId ?? '',
|
||||
maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
heartbeatSeconds: 30,
|
||||
windowSize: 16,
|
||||
windowSize: application.cmppWindowSize,
|
||||
protocolVersion: channel?.cmppVersion ?? '3.0',
|
||||
};
|
||||
}
|
||||
|
||||
private async validateAndReserveCmppAccount(cmppAccount: string, currentApplicationId?: string) {
|
||||
if (!/^\d{6}$/.test(cmppAccount)) {
|
||||
throw new BadRequestException('cmppAccount must be a 6-digit number');
|
||||
}
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } });
|
||||
if (exists && exists.id !== currentApplicationId) {
|
||||
throw new BadRequestException('cmppAccount already exists');
|
||||
}
|
||||
return cmppAccount;
|
||||
}
|
||||
|
||||
private async generateCmppAccount() {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const cmppAccount = String(randomInt(100000, 1000000));
|
||||
@@ -769,6 +791,17 @@ function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePrio
|
||||
return queuePriority as ApplicationQueuePriority;
|
||||
}
|
||||
|
||||
function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized <= 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
|
||||
if (applicationStatus !== 'active') {
|
||||
return 'inactive';
|
||||
|
||||
@@ -54,6 +54,13 @@ function createSubmitCommand(index) {
|
||||
feeCode: '0',
|
||||
feeType: '01',
|
||||
},
|
||||
upstream: {
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
account: 'cmpp-account-spike',
|
||||
passwordCipher: 'secret-spike',
|
||||
cmppVersion: '3.0',
|
||||
},
|
||||
retry: {
|
||||
attempt: 0,
|
||||
maxAttempts: 3,
|
||||
|
||||
Reference in New Issue
Block a user