feat: complete cmpp gateway delivery recovery workflows

This commit is contained in:
hectorzhao
2026-07-08 16:30:06 +08:00
parent cc628d0214
commit 8144f08652
60 changed files with 8901 additions and 94 deletions
@@ -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;
@@ -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
View File
@@ -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])
}
+7 -1
View File
@@ -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({
+28 -2
View File
@@ -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')
+2 -1
View File
@@ -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],
+346 -1
View File
@@ -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' }],
});
});
});
+508 -2
View File
@@ -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);
}
}
+529 -1
View File
@@ -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
+28 -1
View File
@@ -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 = {
+36 -3
View File
@@ -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';
+7
View File
@@ -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,