feat: improve operations diagnostics and channel management

This commit is contained in:
hectorzhao
2026-08-09 14:27:19 +08:00
parent 44352aeb2f
commit 4724b9db6a
65 changed files with 1211 additions and 293 deletions
@@ -15,6 +15,7 @@ export class ChannelGroupRoutingService {
listGroups() {
return this.prisma.smsChannelGroup.findMany({
where: { status: { not: 'deleted' } },
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
orderBy: { createdAt: 'desc' },
});
@@ -158,23 +159,78 @@ export class ChannelGroupRoutingService {
});
}
async deleteGroup(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
async getGroupDeletionImpact(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, items: { select: { id: true } } },
});
if (!group) {
throw new NotFoundException('Channel group not found');
}
const boundRoute = await this.prisma.channelRouteRule.findFirst({
where: {
groupId,
status: 'active',
},
select: { id: true },
const routes = await this.prisma.channelRouteRule.findMany({
where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } },
select: { applicationId: true },
});
if (boundRoute) {
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
const applicationIds = [...new Set(routes.flatMap((route) => route.applicationId ? [route.applicationId] : []))];
const [applications, pendingSupplierSubmitCount] = await Promise.all([
this.prisma.smsApplication.findMany({
where: { id: { in: applicationIds } },
select: { id: true, status: true },
}),
this.prisma.smsSubmitRecord.count({
where: { channelGroupId: groupId, submitStatus: 'queued' },
}),
]);
const applicationStatusById = new Map(applications.map((application) => [application.id, application.status]));
const deletedApplicationCount = applicationIds.filter((applicationId) => {
const status = applicationStatusById.get(applicationId);
return status === undefined || status === 'deleted';
}).length;
return {
groupId: group.id,
groupName: group.name,
normalApplicationCount: applicationIds.length - deletedApplicationCount,
deletedApplicationCount,
channelCount: group.items.length,
pendingSupplierSubmitCount,
};
}
async deleteGroup(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
if (!group) {
throw new NotFoundException('Channel group not found');
}
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
if (group.status === 'deleted') {
return group;
}
const impact = await this.getGroupDeletionImpact(groupId);
// Logical deletion keeps group items and route bindings available for historical
// receipts and uplink access-number matching; new submits already require an active group.
return this.prisma.$transaction(async (tx) => {
const deleted = await tx.smsChannelGroup.update({
where: { id: groupId },
data: { status: 'deleted' },
});
await tx.operationLog.create({
data: {
action: 'sms_channel_group.delete',
resource: 'sms_channel_group',
resourceId: groupId,
detail: {
before: channelGroupAuditSnapshot(group),
impact,
deletionMode: 'soft_delete',
} as Prisma.InputJsonValue,
},
});
return deleted;
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
listRouteRules() {
+5
View File
@@ -119,6 +119,11 @@ export class ChannelsController {
return this.channels.updateGroup(groupId, body);
}
@Get('channel-groups/:id/deletion-impact')
getGroupDeletionImpact(@Param('id') groupId: string) {
return this.channels.getGroupDeletionImpact(groupId);
}
@Delete('channel-groups/:id')
@RequireRecentAuthentication()
deleteGroup(@Param('id') groupId: string) {
+5 -11
View File
@@ -1,8 +1,13 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
export function summarizeReportStatuses(statuses: string[]) {
return summarizeCommonReportStatuses(statuses);
}
/** Constants and pure validation/normalization helpers shared by R5 domains. */
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
@@ -654,17 +659,6 @@ export function normalizeReportType(value?: string) {
throw new BadRequestException('reportType must be signature, drainage or both');
}
export function summarizeReportStatuses(statuses: string[]) {
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
const approved = statuses.filter((status) => status === 'approved').length;
let status = 'pending';
if (approved === statuses.length) status = 'approved';
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
return { status, approved, total: statuses.length };
}
export function normalizeLinkEvent(action: string) {
if (action.includes('connect_requested')) {
return '连接请求';
+53 -7
View File
@@ -81,7 +81,7 @@ function createPrismaMock() {
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
},
@@ -137,6 +137,7 @@ function createPrismaMock() {
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
tenant: {
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
@@ -153,6 +154,7 @@ function createPrismaMock() {
},
smsSubmitRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
count: jest.fn().mockResolvedValue(0),
},
cmppConnectionState: {
findMany: jest.fn(),
@@ -866,16 +868,60 @@ describe('ChannelsService', () => {
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
it('deletes channel groups only when no active route rule is bound', async () => {
it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] });
prisma.channelRouteRule.findMany.mockResolvedValueOnce([
{ applicationId: 'app-active' },
{ applicationId: 'app-active' },
{ applicationId: 'app-deleted' },
{ applicationId: 'app-missing' },
]);
prisma.smsApplication.findMany.mockResolvedValueOnce([
{ id: 'app-active', status: 'active' },
{ id: 'app-deleted', status: 'deleted' },
]);
prisma.smsSubmitRecord.count.mockResolvedValueOnce(2);
await service.deleteGroup('group-1');
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({
groupId: 'group-1',
groupName: '移动主通道组',
normalApplicationCount: 1,
deletedApplicationCount: 2,
channelCount: 2,
pendingSupplierSubmitCount: 2,
});
expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({
where: { channelGroupId: 'group-1', submitStatus: 'queued' },
});
});
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
it('logically deletes channel groups without removing application bindings or group items', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' });
const operationLogCreate = jest.fn();
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]);
prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]);
prisma.$transaction.mockImplementationOnce((callback) => callback({
smsChannelGroup: { update: groupUpdate },
operationLog: { create: operationLogCreate },
}));
await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' });
expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } });
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled();
expect(operationLogCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel_group.delete',
detail: expect.objectContaining({
deletionMode: 'soft_delete',
impact: expect.objectContaining({ normalApplicationCount: 1 }),
}),
}),
});
});
it('upserts signature report material per channel field', async () => {
+4
View File
@@ -114,6 +114,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
return this.groups.deleteGroup(groupId);
}
getGroupDeletionImpact(groupId: string) {
return this.groups.getGroupDeletionImpact(groupId);
}
listRouteRules() {
return this.groups.listRouteRules();
}