fix: track live downstream cmpp connections

This commit is contained in:
hectorzhao
2026-07-11 19:16:52 +08:00
parent 76e0417ccf
commit 2fea15ef25
21 changed files with 477 additions and 141 deletions
@@ -0,0 +1,29 @@
CREATE TABLE "CmppDownstreamConnection" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"account" TEXT NOT NULL,
"enterpriseCode" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"remoteIp" TEXT,
"protocol" TEXT,
"status" TEXT NOT NULL DEFAULT 'connected',
"connectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastHeartbeatAt" TIMESTAMP(3),
"lastSubmitAt" TIMESTAMP(3),
"lastDeliverAt" TIMESTAMP(3),
"disconnectedAt" TIMESTAMP(3),
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CmppDownstreamConnection_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "CmppDownstreamConnection_connectionId_key" ON "CmppDownstreamConnection"("connectionId");
CREATE INDEX "CmppDownstreamConnection_applicationId_status_updatedAt_idx" ON "CmppDownstreamConnection"("applicationId", "status", "updatedAt");
CREATE INDEX "CmppDownstreamConnection_tenantId_status_updatedAt_idx" ON "CmppDownstreamConnection"("tenantId", "status", "updatedAt");
CREATE INDEX "CmppDownstreamConnection_account_status_idx" ON "CmppDownstreamConnection"("account", "status");
ALTER TABLE "CmppDownstreamConnection" ADD CONSTRAINT "CmppDownstreamConnection_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CmppDownstreamConnection" ADD CONSTRAINT "CmppDownstreamConnection_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+29
View File
@@ -40,6 +40,7 @@ model Tenant {
smsUplinkMessages SmsUplinkMessage[]
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
cmppDownstreamDeliveries CmppDownstreamDelivery[]
cmppDownstreamConnections CmppDownstreamConnection[]
cmppConnectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
@@ -371,6 +372,7 @@ model SmsApplication {
uplinkMessages SmsUplinkMessage[]
uplinkMatchCandidates SmsUplinkMatchCandidate[]
downstreamDeliveries CmppDownstreamDelivery[]
downstreamConnections CmppDownstreamConnection[]
connectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
@@ -550,6 +552,33 @@ model CmppConnectionState {
@@index([channelId, status])
}
model CmppDownstreamConnection {
id String @id @default(cuid())
tenantId String
applicationId String
account String
enterpriseCode String
connectionId String @unique
remoteIp String?
protocol String?
status String @default("connected")
connectedAt DateTime @default(now())
lastHeartbeatAt DateTime?
lastSubmitAt DateTime?
lastDeliverAt DateTime?
disconnectedAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
application SmsApplication @relation(fields: [applicationId], references: [id])
@@index([applicationId, status, updatedAt])
@@index([tenantId, status, updatedAt])
@@index([account, status])
}
model SmsChannelGroup {
id String @id @default(cuid())
code String @unique
@@ -11,11 +11,15 @@ import {
GatewayUplinkEventDto,
SendChainService,
} from './send-chain.service';
import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service';
@ApiTags('gateway-events')
@Controller('gateway/events')
export class GatewayEventsController {
constructor(private readonly sendChain: SendChainService) {}
constructor(
private readonly sendChain: SendChainService,
private readonly smsConfig: SmsConfigService,
) {}
@Post('submit-result')
submitResult(@Body() body: GatewaySubmitResultDto) {
@@ -47,6 +51,11 @@ export class GatewayEventsController {
return this.sendChain.submitInboundMessage(body);
}
@Post('inbound/connection')
inboundConnection(@Body() body: GatewayDownstreamConnectionEventDto) {
return this.smsConfig.recordDownstreamConnectionEvent(body);
}
@Post('downstream/pending')
pendingDownstream(@Body() body: GatewayPendingDeliveryQueryDto) {
return this.sendChain.listPendingDownstreamDeliveries(body);
+2 -2
View File
@@ -2,16 +2,16 @@ import { Module } from '@nestjs/common';
import { BillingModule } from '../billing/billing.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RiskReviewModule } from '../risk-review/risk-review.module';
import { SmsConfigModule } from '../sms-config/sms-config.module';
import { AdminSendChainController } from './admin-send-chain.controller';
import { ClientSendChainController } from './client-send-chain.controller';
import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service';
@Module({
imports: [PrismaModule, BillingModule, RiskReviewModule],
imports: [PrismaModule, BillingModule, RiskReviewModule, SmsConfigModule],
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
providers: [SendChainService],
exports: [SendChainService],
})
export class SendChainModule {}
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
@@ -42,16 +42,6 @@ export class AdminSmsConfigController {
return this.smsConfig.getApplicationCmppParams(applicationId);
}
@Post('enterprise-applications/:id/connections/:connectionId/disconnect')
disconnectApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body);
}
@Delete('enterprise-applications/:id/connections/:connectionId')
deleteApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body);
}
@Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.smsConfig.listSignatures({ tenantId, keyword, status });
+30 -9
View File
@@ -99,6 +99,13 @@ function createPrismaMock() {
findFirst: jest.fn().mockResolvedValue({ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
},
cmppDownstreamConnection: {
findMany: jest.fn().mockResolvedValue([{ id: 'downstream-1', applicationId: 'app-1', tenantId: 'tenant-1', account: '100001', enterpriseCode: 'APP-EC', connectionId: 'gateway-1-1', status: 'connected', connectedAt: new Date(), lastHeartbeatAt: new Date() }]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
smsChannel: {
findFirst: jest.fn().mockResolvedValue({
id: 'channel-1',
@@ -167,7 +174,7 @@ describe('SmsConfigService', () => {
queuePriority: 'normal',
sentToday: 2,
deliveryRate: 50,
cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })],
cmppConnections: [expect.objectContaining({ connectionId: 'gateway-1-1', account: '100001' })],
}),
]);
});
@@ -354,21 +361,35 @@ describe('SmsConfigService', () => {
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
});
it('disconnects application CMPP connections and writes operation logs', async () => {
it('records Gateway downstream CMPP connection and heartbeat events against the real application account', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
await service.recordDownstreamConnectionEvent({
account: '100001',
connectionId: 'gateway-1-1',
status: 'connected',
remoteIp: '127.0.0.1',
protocol: 'cmpp30',
observedAt: '2026-07-11T11:00:00.000Z',
});
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
where: { id: 'conn-state-1' },
data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
expect(prisma.cmppDownstreamConnection.create).toHaveBeenCalledWith({
data: expect.objectContaining({
applicationId: 'app-1',
tenantId: 'tenant-1',
account: '100001',
enterpriseCode: 'APP-EC',
connectionId: 'gateway-1-1',
status: 'connected',
remoteIp: '127.0.0.1',
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'cmpp_connection.disconnected',
resource: 'cmpp_connection',
resourceId: 'channel-1:conn-a',
action: 'cmpp_downstream_connection.connected',
resource: 'cmpp_downstream_connection',
resourceId: 'gateway-1-1',
}),
});
});
+79 -28
View File
@@ -97,10 +97,22 @@ export interface ApplicationListQuery {
includeConnections?: boolean;
}
export interface GatewayDownstreamConnectionEventDto {
account: string;
connectionId: string;
status: 'connected' | 'heartbeat' | 'submit' | 'deliver' | 'disconnected';
remoteIp?: string;
protocol?: string;
connectedAt?: string;
observedAt?: string;
errorMessage?: string;
}
const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const;
type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
@Injectable()
export class SmsConfigService {
@@ -108,6 +120,9 @@ export class SmsConfigService {
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
if (query.includeConnections) {
await this.markTimedOutDownstreamConnections();
}
return this.prisma.smsApplication.findMany({
where: {
tenantId: query.tenantId,
@@ -128,9 +143,8 @@ export class SmsConfigService {
return applications;
}
const applicationIds = applications.map((application) => application.id);
const connections = await this.prisma.cmppConnectionState.findMany({
const connections = await this.prisma.cmppDownstreamConnection.findMany({
where: { applicationId: { in: applicationIds } },
include: { channel: true },
orderBy: { updatedAt: 'desc' },
take: 500,
});
@@ -348,9 +362,9 @@ export class SmsConfigService {
if (!application) {
throw new NotFoundException('Application not found');
}
const connections = await this.prisma.cmppConnectionState.findMany({
await this.markTimedOutDownstreamConnections();
const connections = await this.prisma.cmppDownstreamConnection.findMany({
where: { applicationId },
include: { channel: true },
orderBy: { updatedAt: 'desc' },
take: 100,
});
@@ -358,8 +372,8 @@ export class SmsConfigService {
application,
connections,
summary: {
desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0),
currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0),
desiredConnections: application.cmppMaxConnections,
currentConnections: connections.filter((connection) => connection.status === 'connected').length,
status: normalizeApplicationCmppStatus(connections, application.status),
},
};
@@ -431,31 +445,57 @@ export class SmsConfigService {
return normalizeEnterpriseCode(tenant.code);
}
async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
if (!application) {
throw new NotFoundException('Application not found');
}
const connection = await this.prisma.cmppConnectionState.findFirst({
where: { applicationId, connectionId },
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
const application = await this.prisma.smsApplication.findUnique({
where: { cmppAccount: data.account },
select: { id: true, tenantId: true, cmppEnterpriseCode: true },
});
if (!connection) {
throw new NotFoundException('Connection not found');
if (!application) {
throw new BadRequestException('CMPP account does not reference an application');
}
const updated = await this.prisma.cmppConnectionState.update({
where: { id: connection.id },
const observedAt = parseGatewayDate(data.observedAt) ?? new Date();
const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt;
const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } });
const status = data.status === 'disconnected' ? 'disconnected' : 'connected';
const payload = {
tenantId: application.tenantId,
applicationId: application.id,
account: data.account,
enterpriseCode: application.cmppEnterpriseCode,
remoteIp: data.remoteIp,
protocol: data.protocol,
status,
connectedAt: existing?.connectedAt ?? connectedAt,
lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt,
lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt,
lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt,
disconnectedAt: data.status === 'disconnected' ? observedAt : null,
lastError: data.status === 'disconnected' ? data.errorMessage ?? existing?.lastError ?? null : null,
};
const connection = existing
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
applicationId: application.id,
account: data.account,
remoteIp: data.remoteIp,
protocol: data.protocol,
status: connection.status,
});
return connection;
}
async markTimedOutDownstreamConnections(now = new Date()) {
const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
const cutoff = new Date(now.getTime() - timeoutMs);
return this.prisma.cmppDownstreamConnection.updateMany({
where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } },
data: {
status: 'disconnected',
currentConnections: 0,
lastDisconnectedAt: new Date(),
lastError: data.reason,
status: 'heartbeat_timeout',
disconnectedAt: now,
lastError: `CMPP heartbeat timeout after ${Math.round(timeoutMs / 1000)} seconds`,
},
});
await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, {
applicationId,
reason: data.reason,
});
return updated;
}
listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
@@ -871,11 +911,11 @@ function getPositiveInteger(value: number | undefined, fallback: number, fieldNa
return normalized;
}
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) {
if (applicationStatus !== 'active') {
return 'inactive';
}
if (connections.some((connection) => connection.status === 'connected' && connection.currentConnections > 0)) {
if (connections.some((connection) => connection.status === 'connected')) {
return 'connected';
}
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
@@ -883,3 +923,14 @@ function normalizeApplicationCmppStatus(connections: Array<{ status: string; cur
}
return 'disconnected';
}
function getPositiveIntegerEnv(name: string, fallback: number) {
const value = Number(process.env[name] ?? fallback);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
function parseGatewayDate(value?: string) {
if (!value) return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}