From 71dae3b38ee431b8be7dfe78e12fb1c60c1dd312 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 26 Jul 2026 19:37:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E7=BB=84=E5=B9=82=E7=AD=89=E4=B8=8E=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../network-management.service.ts | 47 ++----------------- .../network-port-forward-group.controller.ts | 9 ++++ .../network-port-forward-group.dto.ts | 4 +- .../network-port-forward-group.service.ts | 41 +++++++++++++++- .../network-management.service.spec.ts | 31 ++++++++++++ ...work-port-forward-group.controller.spec.ts | 24 ++++++++-- ...network-port-forward-group.service.spec.ts | 22 +++++++++ 7 files changed, 126 insertions(+), 52 deletions(-) diff --git a/src/modules/admin/platform-config/network-management/network-management.service.ts b/src/modules/admin/platform-config/network-management/network-management.service.ts index f8eac23..ee6119a 100644 --- a/src/modules/admin/platform-config/network-management/network-management.service.ts +++ b/src/modules/admin/platform-config/network-management/network-management.service.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; @@ -103,33 +102,15 @@ export class NetworkManagementService { } async enableKeeper(id: string) { - return this.mutate(id, async (mapping) => { - this.assertKeeperCapable(mapping); - mapping.keeperDesiredEnabled = true; - mapping.probeRequestId = randomUUID(); - mapping.syncStatus = 'pending'; - }); + return this.groupService.enableKeeperV1(id); } async disableKeeper(id: string) { - return this.mutate(id, async (mapping) => { - this.assertKeeperCapable(mapping); - mapping.keeperDesiredEnabled = false; - mapping.probeRequestId = null; - mapping.syncStatus = 'pending'; - this.withdrawCurrentEndpoint(mapping); - }); + return this.groupService.disableKeeperV1(id); } async probe(id: string) { - return this.mutate(id, async (mapping) => { - this.assertKeeperCapable(mapping); - if (!mapping.keeperDesiredEnabled) { - throwVbenError('请先启用 UDP Keeper', HttpStatus.BAD_REQUEST); - } - mapping.probeRequestId = randomUUID(); - mapping.syncStatus = 'pending'; - }); + return this.groupService.probeV1(id); } async endpointHistory( @@ -300,28 +281,6 @@ export class NetworkManagementService { mapping.desiredIssuedAt = issuedAt; } - private assertKeeperCapable(mapping: NetworkPortForward): void { - if (mapping.desiredPresence !== 'present') { - throwVbenError('删除中的记录不能操作 Keeper', HttpStatus.CONFLICT); - } - if (mapping.protocol !== 'udp') { - throwVbenError('TCP 仅支持端口转发 CRUD', HttpStatus.BAD_REQUEST); - } - if (mapping.externalPort !== mapping.internalPort) { - throwVbenError( - 'UDP Keeper 要求外部端口与内部端口一致', - HttpStatus.BAD_REQUEST, - ); - } - } - - private withdrawCurrentEndpoint(mapping: NetworkPortForward): void { - mapping.currentPublicIpv4 = null; - mapping.currentPublicPort = null; - mapping.currentObservedAt = null; - mapping.currentValidUntil = null; - } - private serialize(mapping: NetworkPortForward) { const leaseValid = !!mapping.currentPublicIpv4 && diff --git a/src/modules/admin/platform-config/network-management/network-port-forward-group.controller.ts b/src/modules/admin/platform-config/network-management/network-port-forward-group.controller.ts index 955e7ac..3f33e5a 100644 --- a/src/modules/admin/platform-config/network-management/network-port-forward-group.controller.ts +++ b/src/modules/admin/platform-config/network-management/network-port-forward-group.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + HttpException, HttpCode, HttpStatus, Param, @@ -34,6 +35,14 @@ import { NetworkPortForwardGroupService } from './network-port-forward-group.ser @UseGuards(JwtAuthGuard, AdminSuperGuard) @UsePipes( new ValidationPipe({ + exceptionFactory: () => + new HttpException( + { + err: '请求参数不符合接口约束', + msg: '请求参数校验失败', + }, + HttpStatus.BAD_REQUEST, + ), forbidNonWhitelisted: true, transform: true, whitelist: true, diff --git a/src/modules/admin/platform-config/network-management/network-port-forward-group.dto.ts b/src/modules/admin/platform-config/network-management/network-port-forward-group.dto.ts index a4b8bbb..abe2dfd 100644 --- a/src/modules/admin/platform-config/network-management/network-port-forward-group.dto.ts +++ b/src/modules/admin/platform-config/network-management/network-port-forward-group.dto.ts @@ -20,7 +20,7 @@ export class NetworkPortForwardGroupCreateDto { @ApiProperty({ maxLength: 100 }) @IsString() @Length(1, 100) - @Matches(/\S/, { message: 'name must contain a non-whitespace character' }) + @Matches(/\S/, { message: '名称必须包含非空白字符' }) name: string; @ApiPropertyOptional({ maxLength: 500 }) @@ -51,7 +51,7 @@ export class NetworkPortForwardGroupUpdateDto { @ValidateIf(isProvided) @IsString() @Length(1, 100) - @Matches(/\S/, { message: 'name must contain a non-whitespace character' }) + @Matches(/\S/, { message: '名称必须包含非空白字符' }) name?: string; @ApiPropertyOptional({ maxLength: 500, nullable: true }) diff --git a/src/modules/admin/platform-config/network-management/network-port-forward-group.service.ts b/src/modules/admin/platform-config/network-management/network-port-forward-group.service.ts index f208d9a..24d75a0 100644 --- a/src/modules/admin/platform-config/network-management/network-port-forward-group.service.ts +++ b/src/modules/admin/platform-config/network-management/network-port-forward-group.service.ts @@ -268,8 +268,8 @@ export class NetworkPortForwardGroupService { groupId, 'udp', async (_, channel, channels) => { - this.assertKeeperPorts(channel); if (channel.keeperDesiredEnabled) return false; + this.assertKeeperPorts(channel); this.assertMechanismTransitionAllowed(channels); channel.keeperDesiredEnabled = true; channel.probeRequestId = randomUUID(); @@ -285,8 +285,8 @@ export class NetworkPortForwardGroupService { groupId, 'udp', async (_, channel, channels) => { - this.assertKeeperPorts(channel); if (!channel.keeperDesiredEnabled) return false; + this.assertKeeperPorts(channel); this.assertMechanismTransitionAllowed(channels); channel.keeperDesiredEnabled = false; channel.probeRequestId = null; @@ -298,6 +298,24 @@ export class NetworkPortForwardGroupService { ); } + async enableKeeperV1(channelId: string) { + return this.enableKeeper( + await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'), + ); + } + + async disableKeeperV1(channelId: string) { + return this.disableKeeper( + await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'), + ); + } + + async probeV1(channelId: string) { + return this.probe( + await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'), + ); + } + async probe(groupId: string) { return this.mutateChannel( groupId, @@ -622,6 +640,25 @@ export class NetworkPortForwardGroupService { return group; } + private async resolveV1GroupId( + channelId: string, + protocol: PortForwardProtocol, + action: string, + ): Promise { + this.assertId(channelId, '端口转发'); + const channel = await this.mappingRepository.findOne({ + where: { id: channelId, isDeleted: false }, + }); + if (!channel) throwVbenError('端口转发不存在', HttpStatus.NOT_FOUND); + if (channel.protocol !== protocol) { + throwVbenError( + `${channel.protocol.toUpperCase()} 通道不支持 ${action}`, + HttpStatus.BAD_REQUEST, + ); + } + return String(channel.groupId); + } + private async findChannels( manager: EntityManager, groupId: string, diff --git a/test/admin/network-management/network-management.service.spec.ts b/test/admin/network-management/network-management.service.spec.ts index 01bfaaf..7b2f5fe 100644 --- a/test/admin/network-management/network-management.service.spec.ts +++ b/test/admin/network-management/network-management.service.spec.ts @@ -629,12 +629,15 @@ describe('NetworkManagementService', () => { await expect(harness.service.retry('100')).resolves.toMatchObject({ protocol: 'udp', }); + mapping.syncStatus = 'synced'; await expect(harness.service.enableKeeper('100')).resolves.toMatchObject({ keeperDesiredEnabled: true, }); + mapping.syncStatus = 'synced'; await expect(harness.service.probe('100')).resolves.toMatchObject({ keeperDesiredEnabled: true, }); + mapping.syncStatus = 'synced'; await expect(harness.service.disableKeeper('100')).resolves.toMatchObject({ keeperDesiredEnabled: false, }); @@ -643,4 +646,32 @@ describe('NetworkManagementService', () => { desiredPresence: 'absent', }); }); + + it('keeps repeated v1 Keeper switches idempotent while preserving channel-ID semantics', async () => { + const mapping = createMapping(); + const harness = createHarness([mapping], { mode: 'off' }); + + await harness.service.enableKeeper('100'); + const enabledRevision = harness.state.desiredRevision; + const enabledChannelRevision = mapping.desiredRevision; + const probeRequestId = mapping.probeRequestId; + await harness.service.enableKeeper('100'); + expect(mapping.probeRequestId).toBe(probeRequestId); + expect(mapping.desiredRevision).toBe(enabledChannelRevision); + expect(harness.state.desiredRevision).toBe(enabledRevision); + expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(1); + + mapping.syncStatus = 'synced'; + await harness.service.disableKeeper('100'); + const disabledRevision = harness.state.desiredRevision; + const disabledChannelRevision = mapping.desiredRevision; + await harness.service.disableKeeper('100'); + expect(mapping.desiredRevision).toBe(disabledChannelRevision); + expect(harness.state.desiredRevision).toBe(disabledRevision); + expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(2); + + await expect(harness.service.enableKeeper('200')).rejects.toMatchObject({ + status: 404, + }); + }); }); diff --git a/test/admin/network-management/network-port-forward-group.controller.spec.ts b/test/admin/network-management/network-port-forward-group.controller.spec.ts index 8c5f2b1..c184143 100644 --- a/test/admin/network-management/network-port-forward-group.controller.spec.ts +++ b/test/admin/network-management/network-port-forward-group.controller.spec.ts @@ -89,7 +89,7 @@ describe('NetworkPortForwardGroupController', () => { ) .expect(200) .expect('Cache-Control', 'no-store'); - await request(apiUrl) + const invalidProtocol = await request(apiUrl) .post('/system/network/port-forward-group') .send({ externalPort: 9000, @@ -98,7 +98,8 @@ describe('NetworkPortForwardGroupController', () => { protocolMode: 'icmp', }) .expect(400); - await request(apiUrl) + expectChineseVbenValidation(invalidProtocol.body); + const forbiddenField = await request(apiUrl) .post('/system/network/port-forward-group') .send({ externalPort: 9000, @@ -108,6 +109,9 @@ describe('NetworkPortForwardGroupController', () => { routerPassword: 'forbidden', }) .expect(400); + expectChineseVbenValidation(forbiddenField.body); + expect(JSON.stringify(forbiddenField.body)).not.toContain('forbidden'); + expect(JSON.stringify(forbiddenField.body)).not.toContain('routerPassword'); const created = await request(apiUrl) .post('/system/network/port-forward-group') @@ -229,14 +233,26 @@ describe('NetworkPortForwardGroupController', () => { }); it('rejects invalid group IDs and protocols before calling the service', async () => { - await request(apiUrl) + const invalidId = await request(apiUrl) .post( '/system/network/port-forward-group/not-a-number/channels/tcp/retry', ) .expect(400); - await request(apiUrl) + expectChineseVbenValidation(invalidId.body); + const invalidProtocol = await request(apiUrl) .post('/system/network/port-forward-group/200/channels/icmp/retry') .expect(400); + expectChineseVbenValidation(invalidProtocol.body); expect(service.retry).not.toHaveBeenCalled(); }); }); + +function expectChineseVbenValidation(body: unknown): void { + expect(body).toEqual({ + err: expect.any(String), + msg: expect.any(String), + }); + const response = body as { err: string; msg: string }; + expect(response.msg).toMatch(/[\u4e00-\u9fff]/); + expect(response.err).toMatch(/[\u4e00-\u9fff]/); +} diff --git a/test/admin/network-management/network-port-forward-group.service.spec.ts b/test/admin/network-management/network-port-forward-group.service.spec.ts index c00eb19..cdbf190 100644 --- a/test/admin/network-management/network-port-forward-group.service.spec.ts +++ b/test/admin/network-management/network-port-forward-group.service.spec.ts @@ -488,4 +488,26 @@ describe('NetworkPortForwardGroupService', () => { .enableNatmap('200') .catch((error) => expect(errorStatus(error)).toBe(400)); }); + + it('treats an already-disabled asymmetric UDP Keeper switch as a no-op', async () => { + const group = createGroup({ + externalPort: 9000, + internalPort: 9001, + protocolMode: 'udp', + }); + const udp = createMapping({ + externalPort: 9000, + internalPort: 9001, + keeperDesiredEnabled: false, + protocol: 'udp', + }); + const harness = createHarness([group], [udp]); + + await expect(harness.service.disableKeeper('200')).resolves.toMatchObject({ + desiredRevision: '3', + keeperDesiredEnabled: false, + }); + expect(harness.state.desiredRevision).toBe('3'); + expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled(); + }); });