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