fix: harden real backend admin workflows and ui

This commit is contained in:
hectorzhao
2026-07-03 19:29:56 +08:00
parent dd09d91c1e
commit 8cca361441
71 changed files with 5111 additions and 4439 deletions
+13 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
ChannelsService,
@@ -14,6 +14,8 @@ import {
CreateReportTaskDto,
CreateRouteRuleDto,
UpsertConnectionStateDto,
UpdateChannelDto,
UpdateChannelGroupDto,
} from './channels.service';
@ApiTags('channels')
@@ -31,6 +33,11 @@ export class ChannelsController {
return this.channels.createChannel(body);
}
@Put('channels/:id')
updateChannel(@Param('id') channelId: string, @Body() body: UpdateChannelDto) {
return this.channels.updateChannel(channelId, body);
}
@Post('channels/:id/test')
testChannel(@Param('id') channelId: string) {
return this.channels.testChannel(channelId);
@@ -86,6 +93,11 @@ export class ChannelsController {
return this.channels.createGroup(body);
}
@Put('channel-groups/:id')
updateGroup(@Param('id') groupId: string, @Body() body: UpdateChannelGroupDto) {
return this.channels.updateGroup(groupId, body);
}
@Post('channel-groups/items')
addGroupItem(@Body() body: CreateChannelGroupItemDto) {
return this.channels.addGroupItem(body);
+120 -3
View File
@@ -27,6 +27,14 @@ function createPrismaMock() {
smsChannel: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
},
smsChannelGroup: {
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }),
},
smsChannelGroupItem: {
deleteMany: jest.fn(),
createMany: jest.fn(),
},
signatureReportMaterial: {
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
createMany: jest.fn(),
@@ -44,10 +52,12 @@ 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' }),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
},
smsChannelGroupItem: {
deleteMany: jest.fn(),
createMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
},
@@ -144,6 +154,52 @@ describe('ChannelsService', () => {
});
});
it('updates CMPP channel configuration without requiring password changes', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.updateChannel('channel-1', {
name: '主通道-编辑',
gatewayHost: '10.0.0.1',
gatewayPort: 27890,
carrier: 'all',
sendRegion: '全国',
account: 'sp-new',
srcId: '10690001',
unitPrice: 4,
})).resolves.toEqual(expect.objectContaining({
id: 'channel-1',
name: '主通道-编辑',
gatewayHost: '10.0.0.1',
}));
expect(prisma.smsChannel.update).toHaveBeenCalledWith({
where: { id: 'channel-1' },
data: expect.objectContaining({
name: '主通道-编辑',
gatewayHost: '10.0.0.1',
gatewayPort: 27890,
carrier: 'all',
passwordCipher: undefined,
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: 'channel-1',
}),
});
});
it('rejects invalid channel update ports', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.updateChannel('channel-1', { gatewayPort: 70000 })).rejects.toThrow('gatewayPort must be an integer between 1 and 65535');
expect(prisma.smsChannel.update).not.toHaveBeenCalled();
});
it('rejects direct single-channel route rules', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -214,6 +270,36 @@ describe('ChannelsService', () => {
.rejects.toThrow('同一通道组内全国通道优先级不能重复');
});
it('updates channel groups and replaces items with backend validation', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannel.findMany.mockResolvedValue([
{ id: 'channel-sd', carrier: 'mobile', sendRegion: '山东' },
{ id: 'channel-national', carrier: 'all', sendRegion: '全国' },
]);
await service.updateGroup('group-1', {
name: '移动组更新',
carrier: 'mobile',
retryEnabled: true,
retryTimeLimitHours: 24,
items: [
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 },
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
],
});
expect(prisma.$transaction).toHaveBeenCalled();
await expect(service.updateGroup('group-1', {
carrier: 'mobile',
items: [
{ channelId: 'channel-sd', carrier: 'mobile', priority: 1 },
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
],
})).rejects.toThrow('同一通道组内全国通道优先级不能重复');
});
it('requires route rule carrier to match the channel group carrier', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -265,11 +351,42 @@ describe('ChannelsService', () => {
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'rejected', reason: 'one rejected' },
data: { status: 'partial', reason: 'one rejected' },
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
data: { reportStatus: 'rejected' },
data: { reportStatus: 'partial' },
});
});
it('parses text receipt imports and derives report task status', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.importReportReceipt('report-task-1', {
fileObjectId: 'file-1',
fileName: 'receipt.csv',
fileContent: 'phone,status\n13800138000,success\n13900139000,failed\n13700137000,通过',
reason: 'carrier receipt',
});
expect(prisma.reportReceiptImport.create).toHaveBeenCalledWith({
data: expect.objectContaining({
fileObjectId: 'file-1',
fileName: 'receipt.csv',
rowCount: 3,
successCount: 2,
failedCount: 1,
result: expect.objectContaining({ hasHeader: true, rows: expect.any(Array) }),
}),
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'partial', reason: 'carrier receipt' },
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
data: { reportStatus: 'partial' },
});
});
+275 -6
View File
@@ -21,6 +21,8 @@ export interface CreateChannelDto {
config?: Record<string, unknown>;
}
export type UpdateChannelDto = Partial<CreateChannelDto>;
export interface CreateChannelGroupDto {
code: string;
name: string;
@@ -42,6 +44,17 @@ export interface CreateChannelGroupItemDto {
rateLimitPerSecond?: number;
}
export interface UpdateChannelGroupDto {
code?: string;
name?: string;
carrier?: string;
description?: string;
status?: string;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
}
export interface CreateRouteRuleDto {
tenantId?: string;
applicationId?: string;
@@ -88,6 +101,8 @@ export interface CreateReportExportDto {
export interface CreateReceiptImportDto {
fileObjectId?: string;
fileName: string;
fileContent?: string;
delimiter?: ',' | '\t';
rowCount?: number;
successCount?: number;
failedCount?: number;
@@ -164,6 +179,61 @@ export class ChannelsService {
});
}
async updateChannel(channelId: string, data: UpdateChannelDto) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort);
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const updated = await this.prisma.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier: data.carrier,
sendRegion: data.sendRegion,
protocol: data.protocol,
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion: data.cmppVersion,
rateLimitPerSecond: data.rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: data.config as Prisma.InputJsonValue | undefined,
},
});
await this.prisma.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: channel.unitPrice,
},
after: data,
} as Prisma.InputJsonValue,
},
});
return updated;
}
async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) {
@@ -381,7 +451,7 @@ export class ChannelsService {
listGroups() {
return this.prisma.smsChannelGroup.findMany({
include: { items: { include: { channel: true } } },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
orderBy: { createdAt: 'desc' },
take: 100,
});
@@ -461,6 +531,57 @@ export class ChannelsService {
});
}
async updateGroup(groupId: string, data: UpdateChannelGroupDto) {
const current = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
if (!current) {
throw new NotFoundException('Channel group not found');
}
const retryTimeLimitHours = data.retryTimeLimitHours ?? current.retryTimeLimitHours;
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
}
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
const items = data.items ?? [];
const channelIds = [...new Set(items.map((item) => item.channelId))];
const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } });
const channelById = new Map(channels.map((channel) => [channel.id, channel]));
validateGroupItems(carrier, items, channelById);
return this.prisma.$transaction(async (tx) => {
await tx.smsChannelGroupItem.deleteMany({ where: { groupId } });
await tx.smsChannelGroup.update({
where: { id: groupId },
data: {
code: data.code ?? current.code,
name: data.name ?? current.name,
carrier,
description: data.description,
status: data.status ?? current.status,
retryEnabled: data.retryEnabled ?? current.retryEnabled,
retryTimeLimitHours,
},
});
if (items.length > 0) {
await tx.smsChannelGroupItem.createMany({
data: items.map((item) => ({
groupId,
channelId: item.channelId,
carrier,
province: item.province,
priority: item.priority ?? 100,
weight: item.weight ?? 1,
isBackup: item.isBackup ?? false,
rateLimitPerSecond: item.rateLimitPerSecond,
})),
});
}
return tx.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
});
}
listRouteRules() {
return this.prisma.channelRouteRule.findMany({
include: { group: true, channel: true },
@@ -600,17 +721,21 @@ export class ChannelsService {
async importReportReceipt(taskId: string, data: CreateReceiptImportDto) {
const task = await this.getReportTaskOrThrow(taskId);
const statusAfter = data.statusAfter ?? (data.failedCount && data.failedCount > 0 ? 'rejected' : 'approved');
const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined;
const rowCount = data.rowCount ?? parsed?.rowCount ?? 0;
const successCount = data.successCount ?? parsed?.successCount ?? 0;
const failedCount = data.failedCount ?? parsed?.failedCount ?? 0;
const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount);
const imported = await this.prisma.reportReceiptImport.create({
data: {
taskId,
fileObjectId: data.fileObjectId,
fileName: data.fileName,
rowCount: data.rowCount ?? 0,
successCount: data.successCount ?? 0,
failedCount: data.failedCount ?? 0,
rowCount,
successCount,
failedCount,
status: 'imported',
result: data.result as Prisma.InputJsonValue | undefined,
result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined,
},
});
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
@@ -690,6 +815,107 @@ function normalizeConnectionAction(status: string) {
return 'updated';
}
function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length === 0) {
throw new BadRequestException('Receipt file is empty');
}
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
const firstCells = splitReceiptLine(lines[0], separator);
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
const header = hasHeader ? firstCells : [];
const rows = hasHeader ? lines.slice(1) : lines;
const statusIndex = findReceiptStatusIndex(header);
let successCount = 0;
let failedCount = 0;
const resultRows = rows.map((line, index) => {
const cells = splitReceiptLine(line, separator);
const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? '';
const normalizedStatus = normalizeReceiptStatus(rawStatus);
if (normalizedStatus === 'success') {
successCount += 1;
} else {
failedCount += 1;
}
return {
rowNumber: (hasHeader ? index + 2 : index + 1),
phone: cells[0] ?? '',
status: normalizedStatus,
rawStatus,
raw: cells,
};
});
return {
rowCount: resultRows.length,
successCount,
failedCount,
result: {
delimiter: separator === '\t' ? 'tab' : 'comma',
hasHeader,
rows: resultRows,
},
};
}
function splitReceiptLine(line: string, delimiter: ',' | '\t') {
if (delimiter === '\t') {
return line.split('\t').map((cell) => stripReceiptCell(cell));
}
const cells: string[] = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
index += 1;
} else if (char === '"') {
quoted = !quoted;
} else if (char === ',' && !quoted) {
cells.push(stripReceiptCell(current));
current = '';
} else {
current += char;
}
}
cells.push(stripReceiptCell(current));
return cells;
}
function stripReceiptCell(value: string) {
return value.trim().replace(/^"|"$/g, '').trim();
}
function findReceiptStatusIndex(header: string[]) {
if (header.length === 0) {
return 1;
}
const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase()));
return index >= 0 ? index : Math.max(0, header.length - 1);
}
function normalizeReceiptStatus(value: string) {
const normalized = value.trim().toLowerCase();
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
return 'success';
}
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
return 'failed';
}
return 'failed';
}
function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) {
if (rowCount <= 0 || successCount <= 0) {
return 'failed';
}
if (failedCount > 0) {
return 'partial';
}
return 'completed';
}
function normalizeBusinessCarrier(carrier?: string | null) {
const normalized = normalizeChannelCarrier(carrier);
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
@@ -720,6 +946,49 @@ function isRegionCompatible(channelRegion: string | null | undefined, itemProvin
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
}
function validateGroupItems(
groupCarrier: string,
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
) {
const channelIds = new Set<string>();
const provinces = new Set<string>();
const nationalPriorities = new Set<number>();
for (const item of items) {
const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier;
if (itemCarrier !== groupCarrier) {
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
}
const channel = channels.get(item.channelId);
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (channelIds.has(item.channelId)) {
throw new BadRequestException('通道组内不能重复配置同一通道');
}
channelIds.add(item.channelId);
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
}
if (item.province) {
const province = normalizeRegion(item.province);
if (provinces.has(province)) {
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
}
provinces.add(province);
if (!isRegionCompatible(channel.sendRegion, item.province)) {
throw new BadRequestException('Province route must use a channel with the same sendRegion');
}
} else {
const priority = item.priority ?? 100;
if (nationalPriorities.has(priority)) {
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
}
nationalPriorities.add(priority);
}
}
}
function normalizeLinkEvent(action: string) {
if (action.includes('connected')) {
return '新建';