fix: track live downstream cmpp connections
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user