fix: connect operations pages to real backend
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user