fix: track live downstream cmpp connections
This commit is contained in:
@@ -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