feat: wire admin workflows to real APIs

This commit is contained in:
hectorzhao
2026-07-02 13:50:09 +08:00
parent f8c9b78c21
commit ab421cf8a7
42 changed files with 2596 additions and 250 deletions
+17 -1
View File
@@ -1,8 +1,9 @@
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 {
ChannelsService,
ChangeChannelStatusDto,
CopyChannelDto,
CreateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
@@ -40,11 +41,26 @@ export class ChannelsController {
return this.channels.changeChannelStatus(channelId, body);
}
@Post('channels/:id/copy')
copyChannel(@Param('id') channelId: string, @Body() body: CopyChannelDto) {
return this.channels.copyChannel(channelId, body);
}
@Delete('channels/:id')
deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
return this.channels.deleteChannel(channelId, body);
}
@Get('channels/:id/metrics')
listChannelMetrics(@Param('id') channelId: string) {
return this.channels.listChannelMetrics(channelId);
}
@Get('channels/:id/link-logs')
listChannelLinkLogs(@Param('id') channelId: string) {
return this.channels.listChannelLinkLogs(channelId);
}
@Get('channels/:id/connections')
listChannelConnections(@Param('id') channelId: string) {
return this.channels.listChannelConnections(channelId);
+63 -2
View File
@@ -2,11 +2,42 @@ import { ChannelsService } from './channels.service';
function createPrismaMock() {
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
const channel = {
id: 'channel-1',
code: 'CMPP-A',
name: '主通道',
carrier: 'mobile',
protocol: 'CMPP',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
enterpriseCode: 'EC',
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
cmppVersion: '3.0',
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
config: { serviceId: 'SMS' },
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
};
return {
$transaction: jest.fn((callback) => callback({
smsChannel: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
},
signatureReportMaterial: {
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
createMany: jest.fn(),
},
operationLog: {
create: jest.fn(),
},
})),
smsChannel: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', status: 'active' }),
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
},
channelHealthMetric: { findMany: jest.fn() },
@@ -26,7 +57,8 @@ function createPrismaMock() {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
},
signatureReportMaterial: {
findMany: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
createMany: jest.fn(),
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })),
},
channelSignatureReportTask: {
@@ -54,6 +86,7 @@ function createPrismaMock() {
},
operationLog: {
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ id: 'log-1', action: 'cmpp_connection.heartbeat', resourceId: 'channel-1:conn-a', detail: {}, createdAt: new Date() }]),
},
};
}
@@ -161,6 +194,25 @@ describe('ChannelsService', () => {
});
});
it('copies channels with report field configuration and report materials', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const copied = await service.copyChannel('channel-1', { operatorId: 'admin-1' });
expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本' }));
expect(prisma.$transaction).toHaveBeenCalled();
});
it('soft deletes channels through status change', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.deleteChannel('channel-1', { operatorId: 'admin-1', status: 'deleted' });
expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'deleted' } });
});
it('upserts and lists CMPP connection states', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -175,6 +227,7 @@ describe('ChannelsService', () => {
});
await service.listChannelConnections('channel-1');
await service.listTenantConnections('tenant-1');
await service.listChannelLinkLogs('channel-1');
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
@@ -192,5 +245,13 @@ describe('ChannelsService', () => {
orderBy: { updatedAt: 'desc' },
take: 100,
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'cmpp_connection.connected',
resource: 'cmpp_connection',
resourceId: 'channel-1:conn-a',
}),
});
expect(prisma.operationLog.findMany).toHaveBeenCalled();
});
});
+183 -2
View File
@@ -112,6 +112,12 @@ export interface ChangeChannelStatusDto {
reason?: string;
}
export interface CopyChannelDto {
name?: string;
code?: string;
operatorId?: string;
}
@Injectable()
export class ChannelsService {
constructor(private readonly prisma: PrismaService) {}
@@ -164,6 +170,91 @@ export class ChannelsService {
return updated;
}
async copyChannel(channelId: string, data: CopyChannelDto = {}) {
const source = await this.prisma.smsChannel.findUnique({
where: { id: channelId },
include: { reportFields: true },
});
if (!source) {
throw new NotFoundException('Channel not found');
}
const suffix = Date.now().toString(36).toUpperCase();
const nextName = data.name ?? `${source.name}副本`;
const nextCode = data.code ?? `${source.code}-COPY-${suffix}`;
const copied = await this.prisma.$transaction(async (tx) => {
const nextChannel = await tx.smsChannel.create({
data: {
code: nextCode,
name: nextName,
carrier: source.carrier,
protocol: source.protocol,
gatewayHost: source.gatewayHost,
gatewayPort: source.gatewayPort,
enterpriseCode: source.enterpriseCode,
account: source.account,
passwordCipher: source.passwordCipher,
srcId: source.srcId,
cmppVersion: source.cmppVersion,
rateLimitPerSecond: source.rateLimitPerSecond,
unitPrice: source.unitPrice,
status: source.status,
config: source.config as Prisma.InputJsonValue | undefined,
reportFields: {
create: source.reportFields.map((field) => ({
code: field.code,
name: field.name,
fieldType: field.fieldType,
required: field.required,
description: field.description,
sortOrder: field.sortOrder,
status: field.status,
})),
},
},
include: { reportFields: true },
});
const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } });
if (reportMaterials.length > 0) {
await tx.signatureReportMaterial.createMany({
data: reportMaterials.map((material) => ({
signatureId: material.signatureId,
channelId: nextChannel.id,
fieldCode: material.fieldCode,
fieldValue: material.fieldValue,
fileObjectId: material.fileObjectId,
})),
skipDuplicates: true,
});
}
await tx.operationLog.create({
data: {
userId: data.operatorId,
action: 'sms_channel.copy',
resource: 'sms_channel',
resourceId: nextChannel.id,
detail: {
sourceChannelId: source.id,
sourceCode: source.code,
copiedReportFields: source.reportFields.length,
copiedReportMaterials: reportMaterials.length,
} as Prisma.InputJsonValue,
},
});
return nextChannel;
});
return copied;
}
async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) {
return this.changeChannelStatus(channelId, { ...data, status: 'deleted' });
}
testChannel(channelId: string) {
return {
channelId,
@@ -188,6 +279,42 @@ export class ChannelsService {
});
}
async listChannelLinkLogs(channelId: string) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
const [connectionStates, logs] = await Promise.all([
this.prisma.cmppConnectionState.findMany({
where: { channelId },
orderBy: { updatedAt: 'desc' },
take: 50,
}),
this.prisma.operationLog.findMany({
where: {
OR: [
{ resource: 'sms_channel', resourceId: channelId },
{ resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } },
],
},
orderBy: { createdAt: 'desc' },
take: 100,
}),
]);
return {
channelId,
connectionStates,
logs: logs.map((log) => ({
id: log.id,
time: log.createdAt,
event: normalizeLinkEvent(log.action),
action: log.action,
resourceId: log.resourceId,
detail: log.detail,
})),
};
}
listTenantConnections(tenantId: string) {
return this.prisma.cmppConnectionState.findMany({
where: { tenantId },
@@ -197,7 +324,7 @@ export class ChannelsService {
});
}
upsertConnectionState(data: UpsertConnectionStateDto) {
async upsertConnectionState(data: UpsertConnectionStateDto) {
const payload = {
tenantId: data.tenantId,
status: data.status,
@@ -209,7 +336,7 @@ export class ChannelsService {
reconnectCount: data.reconnectCount ?? 0,
lastError: data.lastError,
};
return this.prisma.cmppConnectionState.upsert({
const state = await this.prisma.cmppConnectionState.upsert({
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } },
update: payload,
create: {
@@ -218,6 +345,21 @@ export class ChannelsService {
...payload,
},
});
await this.prisma.operationLog.create({
data: {
tenantId: data.tenantId,
action: `cmpp_connection.${normalizeConnectionAction(data.status)}`,
resource: 'cmpp_connection',
resourceId: `${data.channelId}:${data.connectionId}`,
detail: {
status: data.status,
desiredConnections: state.desiredConnections,
currentConnections: state.currentConnections,
lastError: state.lastError,
} as Prisma.InputJsonValue,
},
});
return state;
}
listGroups() {
@@ -445,3 +587,42 @@ export class ChannelsService {
});
}
}
function normalizeConnectionAction(status: string) {
const normalized = status.toLowerCase();
if (['online', 'connected', 'open'].includes(normalized)) {
return 'connected';
}
if (['heartbeat', 'active_test'].includes(normalized)) {
return 'heartbeat';
}
if (['reconnecting', 'reconnect'].includes(normalized)) {
return 'reconnecting';
}
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
return 'disconnected';
}
return 'updated';
}
function normalizeLinkEvent(action: string) {
if (action.includes('connected')) {
return '新建';
}
if (action.includes('heartbeat')) {
return '心跳';
}
if (action.includes('reconnecting')) {
return '重连';
}
if (action.includes('disconnected')) {
return '断开';
}
if (action.includes('copy')) {
return '复制';
}
if (action.includes('deleted')) {
return '删除';
}
return '更新';
}