fix: connect operations pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 15:16:34 +08:00
parent ab421cf8a7
commit 321cf716f2
22 changed files with 1255 additions and 429 deletions
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
@@ -8,8 +8,28 @@ export class AdminSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService) {}
@Get('enterprise-applications')
listApplications(@Query('tenantId') tenantId?: string) {
return this.smsConfig.listApplications(tenantId);
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
}
@Get('enterprise-applications/:id/connections')
listApplicationConnections(@Param('id') applicationId: string) {
return this.smsConfig.listApplicationConnections(applicationId);
}
@Get('enterprise-applications/:id/cmpp-params')
getApplicationCmppParams(@Param('id') applicationId: string) {
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')
@@ -2,6 +2,24 @@ import { SmsConfigService } from './sms-config.service';
function createPrismaMock() {
return {
smsApplication: {
findMany: jest.fn().mockResolvedValue([{
id: 'app-1',
tenantId: 'tenant-1',
name: '应用A',
status: 'active',
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
}]),
findUnique: jest.fn().mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
name: '应用A',
status: 'active',
secretHash: 'secret-hash',
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
}),
},
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
update: jest.fn(),
@@ -17,6 +35,27 @@ function createPrismaMock() {
user: {
findUnique: jest.fn().mockResolvedValue(null),
},
cmppConnectionState: {
findMany: jest.fn().mockResolvedValue([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]),
findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
},
smsChannel: {
findFirst: jest.fn().mockResolvedValue({
id: 'channel-1',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
enterpriseCode: 'EC',
account: 'sp',
passwordCipher: 'cipher',
srcId: '10690000',
cmppVersion: '3.0',
config: { maxConnections: 2 },
}),
},
operationLog: {
create: jest.fn(),
},
};
}
@@ -32,4 +71,51 @@ describe('SmsConfigService', () => {
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
});
it('lists enterprise applications with real CMPP connection state', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listApplications({ includeConnections: true })).resolves.toEqual([
expect.objectContaining({
id: 'app-1',
cmppStatus: 'connected',
sentToday: 2,
deliveryRate: 50,
cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })],
}),
]);
});
it('returns CMPP params from persisted application and channel config', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
applicationId: 'app-1',
tenantName: '租户A',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
maxConnections: 2,
}));
});
it('disconnects application CMPP connections and writes operation logs', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'cmpp_connection.disconnected',
resource: 'cmpp_connection',
resourceId: 'channel-1:conn-a',
}),
});
});
});
+145 -3
View File
@@ -57,16 +57,56 @@ export interface TemplateListQuery {
keyword?: string;
}
export interface ApplicationListQuery {
tenantId?: string;
keyword?: string;
includeConnections?: boolean;
}
@Injectable()
export class SmsConfigService {
constructor(private readonly prisma: PrismaService) {}
listApplications(tenantId?: string) {
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
return this.prisma.smsApplication.findMany({
where: tenantId ? { tenantId } : undefined,
include: { ipAllowlist: true },
where: {
tenantId: query.tenantId,
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
] : undefined,
},
include: {
tenant: true,
ipAllowlist: true,
messageRecords: { where: { queuedAt: { gte: startOfToday() } }, take: 1000 },
},
orderBy: { createdAt: 'desc' },
take: 100,
}).then(async (applications) => {
if (!query.includeConnections) {
return applications;
}
const tenantIds = [...new Set(applications.map((application) => application.tenantId))];
const connections = await this.prisma.cmppConnectionState.findMany({
where: { tenantId: { in: tenantIds } },
include: { channel: true },
orderBy: { updatedAt: 'desc' },
take: 500,
});
return applications.map((application) => {
const appConnections = connections.filter((connection) => connection.tenantId === application.tenantId);
const todayTotal = application.messageRecords.length;
const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length;
return {
...application,
cmppConnections: appConnections,
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
sentToday: todayTotal,
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
};
});
});
}
@@ -121,6 +161,89 @@ export class SmsConfigService {
return updated;
}
async listApplicationConnections(applicationId: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: { tenant: true },
});
if (!application) {
throw new NotFoundException('Application not found');
}
const connections = await this.prisma.cmppConnectionState.findMany({
where: { tenantId: application.tenantId },
include: { channel: true },
orderBy: { updatedAt: 'desc' },
take: 100,
});
return {
application,
connections,
summary: {
desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0),
currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0),
status: normalizeApplicationCmppStatus(connections, application.status),
},
};
}
async getApplicationCmppParams(applicationId: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: { tenant: true },
});
if (!application) {
throw new NotFoundException('Application not found');
}
const channel = await this.prisma.smsChannel.findFirst({
where: { status: { not: 'deleted' } },
orderBy: { createdAt: 'desc' },
});
return {
applicationId: application.id,
applicationName: application.name,
tenantId: application.tenantId,
tenantName: application.tenant.name,
appCode: application.id,
gatewayHost: channel?.gatewayHost ?? '',
gatewayPort: channel?.gatewayPort ?? 0,
enterpriseCode: channel?.enterpriseCode ?? application.tenant.code,
account: channel?.account ?? application.tenant.code,
passwordCipher: channel?.passwordCipher ?? application.secretHash,
srcId: channel?.srcId ?? '',
maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1,
heartbeatSeconds: 30,
windowSize: 16,
protocolVersion: channel?.cmppVersion ?? '3.0',
};
}
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: { tenantId: application.tenantId, connectionId },
});
if (!connection) {
throw new NotFoundException('Connection not found');
}
const updated = await this.prisma.cmppConnectionState.update({
where: { channelId_connectionId: { channelId: connection.channelId, connectionId } },
data: {
status: 'disconnected',
currentConnections: 0,
lastDisconnectedAt: new Date(),
lastError: data.reason,
},
});
await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, {
applicationId,
reason: data.reason,
});
return updated;
}
listSignatures(tenantId?: string) {
return this.prisma.smsSignature.findMany({
where: tenantId ? { tenantId } : undefined,
@@ -407,3 +530,22 @@ function inferTemplateVariables(content: string): TemplateVariableInput[] {
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
}
function startOfToday() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
if (applicationStatus !== 'active') {
return 'inactive';
}
if (connections.some((connection) => ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0)) {
return 'connected';
}
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
return 'degraded';
}
return 'disconnected';
}