From 4edbb6622463473b81380dd3ae1c1f412812c56e Mon Sep 17 00:00:00 2001 From: sagit Date: Sat, 24 Jan 2026 04:22:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=9A=A7=E9=81=93=E5=85=A5=E5=8F=A3/=E5=87=BA=E5=8F=A3/?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E9=93=BE=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: TunnelUpdateDto 添加 inNodeId, chainNodes, outNodeId 字段 - 后端: TunnelServiceImpl 重写 updateTunnel() 支持节点配置更新 - 后端: 添加自动回滚机制,更新失败时恢复原配置 - 前端: tunnel.tsx 解除编辑模式下的字段禁用限制 - 前端: 保留隧道类型不可修改(端口转发/隧道转发) --- .../com/admin/common/dto/TunnelUpdateDto.java | 9 + .../admin/service/impl/TunnelServiceImpl.java | 343 +++++++++++++++++- vite-frontend/src/pages/tunnel.tsx | 19 +- 3 files changed, 350 insertions(+), 21 deletions(-) diff --git a/springboot-backend/src/main/java/com/admin/common/dto/TunnelUpdateDto.java b/springboot-backend/src/main/java/com/admin/common/dto/TunnelUpdateDto.java index 99181bd..ed5c499 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/TunnelUpdateDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/TunnelUpdateDto.java @@ -30,4 +30,13 @@ public class TunnelUpdateDto { @DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0") @DecimalMax(value = "100.0", message = "流量倍率不能大于100.0") private BigDecimal trafficRatio; + + // 入口节点配置(可选,为空时不更新节点配置) + private List inNodeId; + + // 转发链节点配置(二维数组,每一跳可有多个节点) + private List> chainNodes; + + // 出口节点配置 + private List outNodeId; } \ No newline at end of file diff --git a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java index ca1a7bb..a5eafaa 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java @@ -322,6 +322,104 @@ public class TunnelServiceImpl extends ServiceImpl impleme public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) { Tunnel existingTunnel = this.getById(tunnelUpdateDto.getId()); if (existingTunnel == null) return R.err("隧道不存在"); + + List oldChainTunnels = chainTunnelService.list( + new QueryWrapper().eq("tunnel_id", tunnelUpdateDto.getId()) + ); + + boolean hasNodeChanges = detectNodeChanges(oldChainTunnels, tunnelUpdateDto); + + if (hasNodeChanges && tunnelUpdateDto.getInNodeId() != null) { + List backupChains = deepCopyChainTunnels(oldChainTunnels); + + List nodeIds = new ArrayList<>(); + Map nodes = new HashMap<>(); + + for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) { + nodeIds.add(inNode.getNodeId()); + Node node = nodeService.getById(inNode.getNodeId()); + if (node == null) return R.err("入口节点不存在: " + inNode.getNodeId()); + if (node.getStatus() != 1) return R.err("入口节点不在线: " + node.getName()); + nodes.put(node.getId(), node); + } + + List newChainTunnels = new ArrayList<>(); + for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) { + inNode.setTunnelId(existingTunnel.getId()); + inNode.setChainType(1); + newChainTunnels.add(inNode); + } + + if (existingTunnel.getType() == 2) { + if (tunnelUpdateDto.getOutNodeId() == null || tunnelUpdateDto.getOutNodeId().isEmpty()) { + return R.err("隧道转发类型必须配置出口节点"); + } + + List> chainNodes = tunnelUpdateDto.getChainNodes() == null ? + new ArrayList<>() : tunnelUpdateDto.getChainNodes(); + + int inx = 1; + for (List hop : chainNodes) { + for (ChainTunnel chainNode : hop) { + nodeIds.add(chainNode.getNodeId()); + Node node = nodeService.getById(chainNode.getNodeId()); + if (node == null) return R.err("转发链节点不存在: " + chainNode.getNodeId()); + if (node.getStatus() != 1) return R.err("转发链节点不在线: " + node.getName()); + nodes.put(node.getId(), node); + + Integer port = getNodePort(chainNode.getNodeId()); + chainNode.setPort(port); + chainNode.setInx(inx); + chainNode.setChainType(2); + chainNode.setTunnelId(existingTunnel.getId()); + newChainTunnels.add(chainNode); + } + inx++; + } + + for (ChainTunnel outNode : tunnelUpdateDto.getOutNodeId()) { + nodeIds.add(outNode.getNodeId()); + Node node = nodeService.getById(outNode.getNodeId()); + if (node == null) return R.err("出口节点不存在: " + outNode.getNodeId()); + if (node.getStatus() != 1) return R.err("出口节点不在线: " + node.getName()); + nodes.put(node.getId(), node); + + Integer port = getNodePort(outNode.getNodeId()); + outNode.setPort(port); + outNode.setChainType(3); + outNode.setTunnelId(existingTunnel.getId()); + newChainTunnels.add(outNode); + } + } + + Set nodeIdSet = new HashSet<>(nodeIds); + if (nodeIdSet.size() != nodeIds.size()) { + return R.err("节点配置重复"); + } + + try { + cleanupGostConfig(oldChainTunnels, existingTunnel.getId()); + + chainTunnelService.remove( + new QueryWrapper().eq("tunnel_id", existingTunnel.getId()) + ); + + R applyResult = applyNewGostConfig(tunnelUpdateDto, existingTunnel, nodes); + if (applyResult.getCode() != 0) { + chainTunnelService.saveBatch(backupChains); + rebuildGostConfig(backupChains, existingTunnel); + return R.err("更新失败,已回滚: " + applyResult.getMsg()); + } + + chainTunnelService.saveBatch(newChainTunnels); + + } catch (Exception e) { + chainTunnelService.saveBatch(backupChains); + rebuildGostConfig(backupChains, existingTunnel); + return R.err("更新失败,已回滚: " + e.getMessage()); + } + } + Tunnel tunnel = new Tunnel(); tunnel.setId(tunnelUpdateDto.getId()); tunnel.setName(tunnelUpdateDto.getName()); @@ -329,18 +427,23 @@ public class TunnelServiceImpl extends ServiceImpl impleme tunnel.setTrafficRatio(tunnelUpdateDto.getTrafficRatio()); tunnel.setInIp(tunnelUpdateDto.getInIp()); - if (StringUtils.isEmpty(tunnel.getInIp())){ - StringBuilder in_ip = new StringBuilder(); - List chainTunnels = chainTunnelService.list(new QueryWrapper().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)); + if (StringUtils.isEmpty(tunnel.getInIp())) { + StringBuilder inIp = new StringBuilder(); + List chainTunnels = chainTunnelService.list( + new QueryWrapper().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1) + ); for (ChainTunnel chainTunnel : chainTunnels) { Node node = nodeService.getById(chainTunnel.getNodeId()); - if (node == null)return R.err("隧道节点数据错误,部分节点不存在"); - in_ip.append(node.getServerIp()).append(","); + if (node == null) return R.err("隧道节点数据错误,部分节点不存在"); + inIp.append(node.getServerIp()).append(","); } - in_ip.deleteCharAt(in_ip.length() - 1); - tunnel.setInIp(in_ip.toString()); + if (inIp.length() > 0) { + inIp.deleteCharAt(inIp.length() - 1); + } + tunnel.setInIp(inIp.toString()); } + tunnel.setUpdatedTime(System.currentTimeMillis()); this.updateById(tunnel); return R.ok(); } @@ -706,5 +809,231 @@ public class TunnelServiceImpl extends ServiceImpl impleme } } + private boolean detectNodeChanges(List oldChains, TunnelUpdateDto dto) { + if (dto.getInNodeId() == null) { + return false; + } + + List oldInNodes = oldChains.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 1) + .collect(Collectors.toList()); + List oldChainNodes = oldChains.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 2) + .collect(Collectors.toList()); + List oldOutNodes = oldChains.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 3) + .collect(Collectors.toList()); + + Set oldInNodeIds = oldInNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet()); + Set newInNodeIds = dto.getInNodeId().stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet()); + if (!oldInNodeIds.equals(newInNodeIds)) { + return true; + } + + List flatNewChainNodes = (dto.getChainNodes() == null) ? new ArrayList<>() : + dto.getChainNodes().stream().flatMap(List::stream).collect(Collectors.toList()); + if (oldChainNodes.size() != flatNewChainNodes.size()) { + return true; + } + for (int i = 0; i < oldChainNodes.size(); i++) { + ChainTunnel oldCt = oldChainNodes.get(i); + boolean found = flatNewChainNodes.stream().anyMatch(newCt -> + Objects.equals(oldCt.getNodeId(), newCt.getNodeId()) && + Objects.equals(oldCt.getProtocol(), newCt.getProtocol()) && + Objects.equals(oldCt.getStrategy(), newCt.getStrategy()) && + Objects.equals(oldCt.getInx(), newCt.getInx()) + ); + if (!found) { + return true; + } + } + + List newOutNodes = (dto.getOutNodeId() == null) ? new ArrayList<>() : dto.getOutNodeId(); + if (oldOutNodes.size() != newOutNodes.size()) { + return true; + } + Set oldOutNodeIds = oldOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet()); + Set newOutNodeIds = newOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet()); + if (!oldOutNodeIds.equals(newOutNodeIds)) { + return true; + } + for (ChainTunnel oldOut : oldOutNodes) { + boolean found = newOutNodes.stream().anyMatch(newOut -> + Objects.equals(oldOut.getNodeId(), newOut.getNodeId()) && + Objects.equals(oldOut.getProtocol(), newOut.getProtocol()) && + Objects.equals(oldOut.getStrategy(), newOut.getStrategy()) + ); + if (!found) { + return true; + } + } + + return false; + } + + private void cleanupGostConfig(List chainTunnels, Long tunnelId) { + for (ChainTunnel chainTunnel : chainTunnels) { + if (chainTunnel.getChainType() == 1) { + GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId); + } else if (chainTunnel.getChainType() == 2) { + GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId); + JSONArray services = new JSONArray(); + services.add(tunnelId + "_tls"); + GostUtil.DeleteService(chainTunnel.getNodeId(), services); + } else if (chainTunnel.getChainType() == 3) { + JSONArray services = new JSONArray(); + services.add(tunnelId + "_tls"); + GostUtil.DeleteService(chainTunnel.getNodeId(), services); + } + } + } + + private List deepCopyChainTunnels(List original) { + List copy = new ArrayList<>(); + for (ChainTunnel ct : original) { + ChainTunnel newCt = new ChainTunnel(); + newCt.setId(ct.getId()); + newCt.setTunnelId(ct.getTunnelId()); + newCt.setChainType(ct.getChainType()); + newCt.setNodeId(ct.getNodeId()); + newCt.setPort(ct.getPort()); + newCt.setStrategy(ct.getStrategy()); + newCt.setInx(ct.getInx()); + newCt.setProtocol(ct.getProtocol()); + copy.add(newCt); + } + return copy; + } + + private R applyNewGostConfig(TunnelUpdateDto dto, Tunnel tunnel, Map nodes) { + List chainSuccess = new ArrayList<>(); + List serviceSuccess = new ArrayList<>(); + + if (tunnel.getType() == 2) { + List> chainNodes = dto.getChainNodes() == null ? new ArrayList<>() : dto.getChainNodes(); + + for (ChainTunnel inNode : dto.getInNodeId()) { + GostDto gostDto; + if (chainNodes.isEmpty()) { + gostDto = GostUtil.AddChains(inNode.getNodeId(), dto.getOutNodeId(), nodes); + } else { + gostDto = GostUtil.AddChains(inNode.getNodeId(), chainNodes.get(0), nodes); + } + if (!Objects.equals(gostDto.getMsg(), "OK")) { + rollbackGostChanges(chainSuccess, serviceSuccess); + return R.err("创建入口Chain失败: " + gostDto.getMsg()); + } + JSONObject data = new JSONObject(); + data.put("node_id", inNode.getNodeId()); + data.put("name", "chains_" + tunnel.getId()); + chainSuccess.add(data); + } + + for (int i = 0; i < chainNodes.size(); i++) { + List currentHop = chainNodes.get(i); + for (ChainTunnel chainTunnel : currentHop) { + GostDto gostDto; + if (i + 1 >= chainNodes.size()) { + gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), dto.getOutNodeId(), nodes); + } else { + gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), chainNodes.get(i + 1), nodes); + } + if (!Objects.equals(gostDto.getMsg(), "OK")) { + rollbackGostChanges(chainSuccess, serviceSuccess); + return R.err("创建转发链Chain失败: " + gostDto.getMsg()); + } + JSONObject chainData = new JSONObject(); + chainData.put("node_id", chainTunnel.getNodeId()); + chainData.put("name", "chains_" + tunnel.getId()); + chainSuccess.add(chainData); + + GostDto serviceResult = GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes); + if (!Objects.equals(serviceResult.getMsg(), "OK")) { + rollbackGostChanges(chainSuccess, serviceSuccess); + return R.err("创建转发链Service失败: " + serviceResult.getMsg()); + } + JSONObject serviceData = new JSONObject(); + serviceData.put("node_id", chainTunnel.getNodeId()); + serviceData.put("name", tunnel.getId() + "_tls"); + serviceSuccess.add(serviceData); + } + } + + for (ChainTunnel outNode : dto.getOutNodeId()) { + GostDto gostDto = GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes); + if (!Objects.equals(gostDto.getMsg(), "OK")) { + rollbackGostChanges(chainSuccess, serviceSuccess); + return R.err("创建出口Service失败: " + gostDto.getMsg()); + } + JSONObject serviceData = new JSONObject(); + serviceData.put("node_id", outNode.getNodeId()); + serviceData.put("name", tunnel.getId() + "_tls"); + serviceSuccess.add(serviceData); + } + } + + return R.ok(); + } + + private void rollbackGostChanges(List chainSuccess, List serviceSuccess) { + for (JSONObject chain : chainSuccess) { + GostUtil.DeleteChains(chain.getLong("node_id"), chain.getString("name")); + } + for (JSONObject service : serviceSuccess) { + JSONArray services = new JSONArray(); + services.add(service.getString("name")); + GostUtil.DeleteService(service.getLong("node_id"), services); + } + } + + private void rebuildGostConfig(List chainTunnels, Tunnel tunnel) { + Map nodes = new HashMap<>(); + for (ChainTunnel ct : chainTunnels) { + Node node = nodeService.getById(ct.getNodeId()); + if (node != null) { + nodes.put(node.getId(), node); + } + } + + List inNodes = chainTunnels.stream() + .filter(ct -> ct.getChainType() == 1) + .collect(Collectors.toList()); + Map> chainNodesMap = chainTunnels.stream() + .filter(ct -> ct.getChainType() == 2) + .collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0)); + List> chainNodesList = chainNodesMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + List outNodes = chainTunnels.stream() + .filter(ct -> ct.getChainType() == 3) + .collect(Collectors.toList()); + + if (tunnel.getType() == 2) { + for (ChainTunnel inNode : inNodes) { + if (chainNodesList.isEmpty()) { + GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes); + } else { + GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes); + } + } + + for (int i = 0; i < chainNodesList.size(); i++) { + for (ChainTunnel chainTunnel : chainNodesList.get(i)) { + if (i + 1 >= chainNodesList.size()) { + GostUtil.AddChains(chainTunnel.getNodeId(), outNodes, nodes); + } else { + GostUtil.AddChains(chainTunnel.getNodeId(), chainNodesList.get(i + 1), nodes); + } + GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes); + } + } + + for (ChainTunnel outNode : outNodes) { + GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes); + } + } + } + } diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index ca43caf..12dac79 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -693,7 +693,7 @@ export default function TunnelPage() { {isEdit ? '编辑隧道' : '新增隧道'}

- {isEdit ? '编辑时只能修改隧道名称、流量计算和流量倍率' : '创建新的隧道配置'} + {isEdit ? '修改节点配置会中断现有连接,隧道类型不可修改' : '创建新的隧道配置'}

@@ -803,11 +803,10 @@ export default function TunnelPage() { }); setForm(prev => ({ ...prev, inNodeId: newInNodeId })); }} - isInvalid={!!errors.inNodeId} - errorMessage={errors.inNodeId} - variant="bordered" - isDisabled={isEdit} - > + isInvalid={!!errors.inNodeId} + errorMessage={errors.inNodeId} + variant="bordered" + > {nodes.map((node) => ( @@ -889,7 +887,6 @@ export default function TunnelPage() { variant="light" isIconOnly onPress={() => removeChainNode(groupIndex)} - isDisabled={isEdit} > @@ -932,7 +929,6 @@ export default function TunnelPage() { }} variant="bordered" size="sm" - isDisabled={isEdit} classNames={{ label: "text-xs", value: "text-sm" @@ -991,7 +987,6 @@ export default function TunnelPage() { }} variant="bordered" size="sm" - isDisabled={isEdit} classNames={{ label: "text-xs", value: "text-sm" @@ -1018,7 +1013,6 @@ export default function TunnelPage() { }} variant="bordered" size="sm" - isDisabled={isEdit} classNames={{ label: "text-xs", value: "text-sm" @@ -1083,7 +1077,6 @@ export default function TunnelPage() { isInvalid={!!errors.outNodeId} errorMessage={errors.outNodeId} variant="bordered" - isDisabled={isEdit} classNames={{ label: "text-xs", value: "text-sm" @@ -1154,7 +1147,6 @@ export default function TunnelPage() { isInvalid={!!errors.protocol} errorMessage={errors.protocol} variant="bordered" - isDisabled={isEdit} classNames={{ label: "text-xs", value: "text-sm" @@ -1197,7 +1189,6 @@ export default function TunnelPage() { } }} variant="bordered" - isDisabled={isEdit} classNames={{ label: "text-xs", value: "text-sm"