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
@@ -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;
}