diff --git a/go-gost/gost b/go-gost/gost index 012bb42..1a88086 100755 Binary files a/go-gost/gost and b/go-gost/gost differ diff --git a/go-gost/main.go b/go-gost/main.go index b5909f6..0504305 100644 --- a/go-gost/main.go +++ b/go-gost/main.go @@ -119,7 +119,7 @@ func main() { log := xlogger.NewLogger() logger.SetDefault(log) - wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret) + wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, "1.0.2") defer wsReporter.Stop() service.SetHTTPReportURL(config.Addr, config.Secret) diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index 4f8eaee..5285bcf 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -781,10 +781,10 @@ func getMemoryInfo() MemoryInfo { } // StartWebSocketReporterWithConfig 使用配置启动WebSocket报告器 -func StartWebSocketReporterWithConfig(Addr string, Secret string) *WebSocketReporter { +func StartWebSocketReporterWithConfig(Addr string, Secret string, Version string) *WebSocketReporter { // 构建包含本机IP的WebSocket URL - var fullURL = "ws://" + Addr + "/system-info?type=1&secret=" + Secret + var fullURL = "ws://" + Addr + "/system-info?type=1&secret=" + Secret + "&version=" + Version fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL) diff --git a/gost.sql b/gost.sql index f3c5edf..0011d07 100644 --- a/gost.sql +++ b/gost.sql @@ -3,7 +3,7 @@ -- https://www.phpmyadmin.net/ -- -- 主机: localhost --- 生成日期: 2025-06-26 14:11:52 +-- 生成日期: 2025-07-02 11:33:42 -- 服务器版本: 5.7.40-log -- PHP 版本: 7.4.33 @@ -35,8 +35,8 @@ CREATE TABLE `forward` ( `tunnel_id` int(10) NOT NULL, `in_port` int(10) NOT NULL, `out_port` int(10) DEFAULT NULL, - `remote_addr` longtext NOT NULL, - `strategy` varchar(100) NOT NULL DEFAULT 'fifo' COMMENT '负载均衡策略', + `remote_addr` varchar(100) NOT NULL, + `strategy` varchar(100) NOT NULL DEFAULT 'fifo', `in_flow` bigint(20) NOT NULL DEFAULT '0', `out_flow` bigint(20) NOT NULL DEFAULT '0', `created_time` bigint(20) NOT NULL, @@ -54,8 +54,11 @@ CREATE TABLE `node` ( `id` int(10) NOT NULL, `name` varchar(100) NOT NULL, `secret` varchar(100) NOT NULL, - `ip` longtext DEFAULT NULL, + `ip` longtext, `server_ip` varchar(100) NOT NULL, + `port_sta` int(10) NOT NULL, + `port_end` int(10) NOT NULL, + `version` varchar(100) DEFAULT NULL, `created_time` bigint(20) NOT NULL, `updated_time` bigint(20) DEFAULT NULL, `status` int(10) NOT NULL @@ -90,12 +93,8 @@ CREATE TABLE `tunnel` ( `traffic_ratio` decimal(10,1) NOT NULL DEFAULT '1.0', `in_node_id` int(10) NOT NULL, `in_ip` varchar(100) NOT NULL, - `in_port_sta` int(10) NOT NULL, - `in_port_end` int(10) NOT NULL, `out_node_id` int(10) NOT NULL, `out_ip` varchar(100) NOT NULL, - `out_ip_sta` int(10) NOT NULL, - `out_ip_end` int(10) NOT NULL, `type` int(10) NOT NULL, `protocol` varchar(10) NOT NULL DEFAULT 'tls', `flow` int(10) NOT NULL, @@ -133,7 +132,7 @@ CREATE TABLE `user` ( -- INSERT INTO `user` (`id`, `user`, `pwd`, `role_id`, `exp_time`, `flow`, `in_flow`, `out_flow`, `flow_reset_time`, `num`, `created_time`, `updated_time`, `status`) VALUES -(1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 1780480500000, 99999, 0, 0, 1, 99999, 1748914865000, 1750910756282, 1); +(1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 1780480500000, 99999, 0, 0, 1, 99999, 1748914865000, 1751113171541, 1); -- -------------------------------------------------------- @@ -203,37 +202,37 @@ ALTER TABLE `user_tunnel` -- 使用表AUTO_INCREMENT `forward` -- ALTER TABLE `forward` - MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=128; + MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=164; -- -- 使用表AUTO_INCREMENT `node` -- ALTER TABLE `node` - MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=22; + MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=27; -- -- 使用表AUTO_INCREMENT `speed_limit` -- ALTER TABLE `speed_limit` - MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=67; + MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=70; -- -- 使用表AUTO_INCREMENT `tunnel` -- ALTER TABLE `tunnel` - MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=45; + MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=59; -- -- 使用表AUTO_INCREMENT `user` -- ALTER TABLE `user` - MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=31; + MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=38; -- -- 使用表AUTO_INCREMENT `user_tunnel` -- ALTER TABLE `user_tunnel` - MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=48; + MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=56; COMMIT; /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; diff --git a/panel_install.sh b/panel_install.sh index e3f6dbb..7a94867 100755 --- a/panel_install.sh +++ b/panel_install.sh @@ -458,6 +458,134 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- node 表:添加 version 字段(如果不存在) +SET @sql = ( + SELECT IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'node' + AND column_name = 'version' + ), + 'ALTER TABLE \`node\` ADD COLUMN \`version\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;', + 'SELECT "Column \`version\` already exists in \`node\`";' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- node 表:添加 port_sta 字段(如果不存在) +SET @sql = ( + SELECT IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'node' + AND column_name = 'port_sta' + ), + 'ALTER TABLE \`node\` ADD COLUMN \`port_sta\` INT(10) DEFAULT 1000 COMMENT "端口起始范围";', + 'SELECT "Column \`port_sta\` already exists in \`node\`";' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- node 表:添加 port_end 字段(如果不存在) +SET @sql = ( + SELECT IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'node' + AND column_name = 'port_end' + ), + 'ALTER TABLE \`node\` ADD COLUMN \`port_end\` INT(10) DEFAULT 65535 COMMENT "端口结束范围";', + 'SELECT "Column \`port_end\` already exists in \`node\`";' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 为现有节点设置默认端口范围 +UPDATE \`node\` +SET \`port_sta\` = 1000, \`port_end\` = 65535 +WHERE \`port_sta\` IS NULL OR \`port_end\` IS NULL; + +-- tunnel 表:删除废弃字段(如果存在) +SET @sql = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'tunnel' + AND column_name = 'in_port_sta' + ), + 'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_sta\`;', + 'SELECT "Column \`in_port_sta\` not exists in \`tunnel\`";' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'tunnel' + AND column_name = 'in_port_end' + ), + 'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_end\`;', + 'SELECT "Column \`in_port_end\` not exists in \`tunnel\`";' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'tunnel' + AND column_name = 'out_ip_sta' + ), + 'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_sta\`;', + 'SELECT "Column \`out_ip_sta\` not exists in \`tunnel\`";' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'tunnel' + AND column_name = 'out_ip_end' + ), + 'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_end\`;', + 'SELECT "Column \`out_ip_end\` not exists in \`tunnel\`";' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + -- tunnel 表:添加 tcp_listen_addr、udp_listen_addr、protocol(如果不存在) -- tcp_listen_addr diff --git a/springboot-backend/src/main/java/com/admin/common/dto/ForwardWithTunnelDto.java b/springboot-backend/src/main/java/com/admin/common/dto/ForwardWithTunnelDto.java index 8427e17..3ea65f3 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/ForwardWithTunnelDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/ForwardWithTunnelDto.java @@ -85,28 +85,4 @@ public class ForwardWithTunnelDto { private Long outFlow; private String strategy; -// /** -// * 入口端口开始 -// */ -// private Integer inPortSta; -// -// /** -// * 入口端口结束 -// */ -// private Integer inPortEnd; -// -// /** -// * 出口IP -// */ -// private String outIp; -// -// /** -// * 出口端口开始 -// */ -// private Integer outIpSta; -// -// /** -// * 出口端口结束 -// */ -// private Integer outIpEnd; } \ No newline at end of file diff --git a/springboot-backend/src/main/java/com/admin/common/dto/NodeDto.java b/springboot-backend/src/main/java/com/admin/common/dto/NodeDto.java index b55a57f..961c71b 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/NodeDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/NodeDto.java @@ -18,4 +18,14 @@ public class NodeDto { @NotBlank(message = "服务器ip不能为空") private String serverIp; + + @NotNull(message = "起始端口不能为空") + @Min(value = 1, message = "起始端口必须大于0") + @Max(value = 65535, message = "起始端口不能超过65535") + private Integer portSta; + + @NotNull(message = "结束端口不能为空") + @Min(value = 1, message = "结束端口必须大于0") + @Max(value = 65535, message = "结束端口不能超过65535") + private Integer portEnd; } \ No newline at end of file diff --git a/springboot-backend/src/main/java/com/admin/common/dto/NodeUpdateDto.java b/springboot-backend/src/main/java/com/admin/common/dto/NodeUpdateDto.java index 59b8983..767b5a6 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/NodeUpdateDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/NodeUpdateDto.java @@ -4,6 +4,8 @@ import lombok.Data; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; @Data public class NodeUpdateDto { @@ -19,4 +21,14 @@ public class NodeUpdateDto { @NotBlank(message = "服务器ip不能为空") private String serverIp; + + @NotNull(message = "起始端口不能为空") + @Min(value = 1, message = "起始端口必须大于0") + @Max(value = 65535, message = "起始端口不能超过65535") + private Integer portSta; + + @NotNull(message = "结束端口不能为空") + @Min(value = 1, message = "结束端口必须大于0") + @Max(value = 65535, message = "结束端口不能超过65535") + private Integer portEnd; } \ No newline at end of file diff --git a/springboot-backend/src/main/java/com/admin/common/dto/TunnelDto.java b/springboot-backend/src/main/java/com/admin/common/dto/TunnelDto.java index c8c18e3..61972ee 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/TunnelDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/TunnelDto.java @@ -17,30 +17,10 @@ public class TunnelDto { @NotNull(message = "入口节点不能为空") private Long inNodeId; - - @NotNull(message = "入口端口开始不能为空") - @Min(value = 1, message = "入口端口开始必须大于0") - @Max(value = 65535, message = "入口端口开始不能超过65535") - private Integer inPortSta; - - @NotNull(message = "入口端口结束不能为空") - @Min(value = 1, message = "入口端口结束必须大于0") - @Max(value = 65535, message = "入口端口结束不能超过65535") - private Integer inPortEnd; - + // 出口节点ID,当type=1时可以为空,会自动设置为入口节点ID private Long outNodeId; - // 出口端口开始,当type=1时可以为空,会自动设置为入口端口 - @Min(value = 0, message = "出口端口开始必须大于等于1") - @Max(value = 65535, message = "出口端口开始不能超过65535") - private Integer outIpSta; - - // 出口端口结束,当type=1时可以为空,会自动设置为入口端口 - @Min(value = 0, message = "出口端口结束必须大于等于1") - @Max(value = 65535, message = "出口端口结束不能超过65535") - private Integer outIpEnd; - @NotNull(message = "隧道类型不能为空") private Integer type; diff --git a/springboot-backend/src/main/java/com/admin/common/dto/TunnelListDto.java b/springboot-backend/src/main/java/com/admin/common/dto/TunnelListDto.java index af10338..fdf79be 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/TunnelListDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/TunnelListDto.java @@ -15,30 +15,15 @@ public class TunnelListDto { private String ip; /** - * 入口端口范围开始 + * 入口节点端口起始范围 */ - private Integer inPortSta; + private Integer inNodePortSta; /** - * 入口端口范围结束 + * 入口节点端口结束范围 */ - private Integer inPortEnd; - - /** - * 出口IP - */ - private String outIp; - - /** - * 出口端口范围开始 - */ - private Integer outIpSta; - - /** - * 出口端口范围结束 - */ - private Integer outIpEnd; - + private Integer inNodePortEnd; + /** * 隧道类型(1-端口转发,2-隧道转发) */ 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 62e7bd6..e7032bd 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 @@ -25,27 +25,7 @@ public class TunnelUpdateDto { @DecimalMin(value = "0.0", message = "流量倍率不能小于0.0") @DecimalMax(value = "100.0", message = "流量倍率不能大于100.0") private BigDecimal trafficRatio; - - @NotNull(message = "入口端口开始不能为空") - @Min(value = 1, message = "入口端口开始必须大于0") - @Max(value = 65535, message = "入口端口开始不能超过65535") - private Integer inPortSta; - - @NotNull(message = "入口端口结束不能为空") - @Min(value = 1, message = "入口端口结束必须大于0") - @Max(value = 65535, message = "入口端口结束不能超过65535") - private Integer inPortEnd; - - // 出口端口开始(对于隧道转发类型的隧道) - @Min(value = 1, message = "出口端口开始必须大于等于0") - @Max(value = 65535, message = "出口端口开始不能超过65535") - private Integer outIpSta; - - // 出口端口结束(对于隧道转发类型的隧道) - @Min(value = 1, message = "出口端口结束必须大于等于0") - @Max(value = 65535, message = "出口端口结束不能超过65535") - private Integer outIpEnd; - + // TCP监听地址 private String tcpListenAddr; diff --git a/springboot-backend/src/main/java/com/admin/common/dto/UserTunnelWithDetailDto.java b/springboot-backend/src/main/java/com/admin/common/dto/UserTunnelWithDetailDto.java index d6c75bb..7ef8039 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/UserTunnelWithDetailDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/UserTunnelWithDetailDto.java @@ -84,39 +84,5 @@ public class UserTunnelWithDetailDto { private Long outFlow; private Integer status; - -// /** -// * 入口IP -// */ -// private String inIp; -// -// /** -// * 入口端口开始 -// */ -// private Integer inPortSta; -// -// /** -// * 入口端口结束 -// */ -// private Integer inPortEnd; -// -// /** -// * 出口IP -// */ -// private String outIp; -// -// /** -// * 出口端口开始 -// */ -// private Integer outIpSta; -// -// /** -// * 出口端口结束 -// */ -// private Integer outIpEnd; -// -// /** -// * 隧道类型(1-端口转发,2-隧道转发) -// */ -// private Integer type; + } \ No newline at end of file diff --git a/springboot-backend/src/main/java/com/admin/config/WebSocketInterceptor.java b/springboot-backend/src/main/java/com/admin/config/WebSocketInterceptor.java index bb79a0a..be0a59e 100644 --- a/springboot-backend/src/main/java/com/admin/config/WebSocketInterceptor.java +++ b/springboot-backend/src/main/java/com/admin/config/WebSocketInterceptor.java @@ -34,11 +34,13 @@ public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor { ServletServerHttpRequest serverHttpRequest = (ServletServerHttpRequest) request; String secret = serverHttpRequest.getServletRequest().getParameter("secret"); String type = serverHttpRequest.getServletRequest().getParameter("type"); + String version = serverHttpRequest.getServletRequest().getParameter("version"); if (Objects.equals(type, "1")) { Node node = nodeService.getOne(new QueryWrapper().eq("secret", secret)); if (node == null) return false; attributes.put("id", node.getId()); node.setStatus(1); + node.setVersion(version); nodeService.updateById(node); }else { boolean b = JwtUtil.validateToken(secret); diff --git a/springboot-backend/src/main/java/com/admin/entity/Node.java b/springboot-backend/src/main/java/com/admin/entity/Node.java index 702d5dd..a4176d5 100644 --- a/springboot-backend/src/main/java/com/admin/entity/Node.java +++ b/springboot-backend/src/main/java/com/admin/entity/Node.java @@ -26,4 +26,10 @@ public class Node extends BaseEntity { private String serverIp; + private String version; + + private Integer portSta; + + private Integer portEnd; + } diff --git a/springboot-backend/src/main/java/com/admin/entity/Tunnel.java b/springboot-backend/src/main/java/com/admin/entity/Tunnel.java index c24b9c9..b5b7f4f 100644 --- a/springboot-backend/src/main/java/com/admin/entity/Tunnel.java +++ b/springboot-backend/src/main/java/com/admin/entity/Tunnel.java @@ -34,16 +34,6 @@ public class Tunnel extends BaseEntity { */ private String inIp; - /** - * 入口端口开始 - */ - private Integer inPortSta; - - /** - * 入口端口结束 - */ - private Integer inPortEnd; - /** * 出口节点ID */ @@ -54,16 +44,6 @@ public class Tunnel extends BaseEntity { */ private String outIp; - /** - * 出口端口开始 - */ - private Integer outIpSta; - - /** - * 出口端口结束 - */ - private Integer outIpEnd; - /** * 隧道类型(1-端口转发,2-隧道转发) */ diff --git a/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java index 9dd686a..ef85d8b 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java @@ -1,1141 +1,1145 @@ -package com.admin.service.impl; - -import com.admin.common.dto.ForwardDto; -import com.admin.common.dto.ForwardUpdateDto; -import com.admin.common.dto.ForwardWithTunnelDto; -import com.admin.common.dto.GostDto; -import com.admin.common.lang.R; -import com.admin.common.utils.GostUtil; -import com.admin.common.utils.JwtUtil; -import com.admin.entity.*; -import com.admin.mapper.ForwardMapper; -import com.admin.service.*; -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.BeanUtils; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; - -import javax.annotation.Resource; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; - -/** - *

- * 端口转发服务实现类 - *

- * - * @author QAQ - * @since 2025-06-03 - */ -@Slf4j -@Service -public class ForwardServiceImpl extends ServiceImpl implements ForwardService { - - // 常量定义 - private static final String GOST_SUCCESS_MSG = "OK"; - private static final String GOST_NOT_FOUND_MSG = "not found"; - private static final int ADMIN_ROLE_ID = 0; - private static final int TUNNEL_TYPE_PORT_FORWARD = 1; - private static final int TUNNEL_TYPE_TUNNEL_FORWARD = 2; - private static final int FORWARD_STATUS_ACTIVE = 1; - private static final int FORWARD_STATUS_PAUSED = 0; - private static final int FORWARD_STATUS_ERROR = -1; - private static final int TUNNEL_STATUS_ACTIVE = 1; - private static final int FLOW_TYPE_UPLOAD_ONLY = 1; - private static final long BYTES_TO_GB = 1024L * 1024L * 1024L; - - @Resource - @Lazy - private TunnelService tunnelService; - - @Resource - UserTunnelService userTunnelService; - - @Resource - UserService userService; - - @Resource - NodeService nodeService; - - - @Override - public R createForward(ForwardDto forwardDto) { - // 1. 获取当前用户信息 - UserInfo currentUser = getCurrentUserInfo(); - - // 2. 检查隧道是否存在和可用 - Tunnel tunnel = validateTunnel(forwardDto.getTunnelId()); - if (tunnel == null) { - return R.err("隧道不存在"); - } - if (tunnel.getStatus() != TUNNEL_STATUS_ACTIVE) { - return R.err("隧道已禁用,无法创建转发"); - } - - // 3. 普通用户权限和限制检查 - UserPermissionResult permissionResult = checkUserPermissions(currentUser, tunnel, null); - if (permissionResult.isHasError()) { - return R.err(permissionResult.getErrorMessage()); - } - - // 4. 分配端口 - PortAllocation portAllocation = allocatePorts(tunnel, forwardDto.getInPort()); - if (portAllocation.isHasError()) { - return R.err(portAllocation.getErrorMessage()); - } - - // 5. 创建并保存Forward对象 - Forward forward = createForwardEntity(forwardDto, currentUser, portAllocation); - if (!this.save(forward)) { - return R.err("端口转发创建失败"); - } - - // 6. 获取所需的节点信息 - NodeInfo nodeInfo = getRequiredNodes(tunnel); - if (nodeInfo.isHasError()) { - this.removeById(forward.getId()); - return R.err(nodeInfo.getErrorMessage()); - } - - // 7. 调用Gost服务创建转发 - R gostResult = createGostServices(forward, tunnel, permissionResult.getLimiter(), - nodeInfo, permissionResult.getUserTunnel()); - - if (gostResult.getCode() != 0) { - this.removeById(forward.getId()); - return gostResult; - } - - return R.ok(); - } - - @Override - public R getAllForwards() { - UserInfo currentUser = getCurrentUserInfo(); - - List forwardList; - if (currentUser.getRoleId() != ADMIN_ROLE_ID) { - forwardList = baseMapper.selectForwardsWithTunnelByUserId(currentUser.getUserId()); - } else { - forwardList = baseMapper.selectAllForwardsWithTunnel(); - } - - return R.ok(forwardList); - } - - @Override - public R updateForward(ForwardUpdateDto forwardUpdateDto) { - // 1. 获取当前用户信息 - UserInfo currentUser = getCurrentUserInfo(); - - // 2. 检查转发是否存在 - Forward existForward = validateForwardExists(forwardUpdateDto.getId(), currentUser); - if (existForward == null) { - return R.err("转发不存在"); - } - - // 3. 检查隧道是否存在和可用 - Tunnel tunnel = validateTunnel(forwardUpdateDto.getTunnelId()); - if (tunnel == null) { - return R.err("隧道不存在"); - } - if (tunnel.getStatus() != TUNNEL_STATUS_ACTIVE) { - return R.err("隧道已禁用,无法更新转发"); - } - - // 4. 检查权限和限制 - UserPermissionResult permissionResult = null; - if (isTunnelChanged(existForward, forwardUpdateDto)) { - permissionResult = checkUserPermissions(currentUser, tunnel, forwardUpdateDto.getId()); - if (permissionResult.isHasError()) { - return R.err(permissionResult.getErrorMessage()); - } - } - - // 5. 获取UserTunnel(即使隧道未变化也需要获取,用于构建服务名称) - UserTunnel userTunnel = null; - if (currentUser.getRoleId() != ADMIN_ROLE_ID) { - userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); - if (userTunnel == null) { - return R.err("你没有该隧道权限"); - } - } else { - // 管理员用户也需要获取UserTunnel(如果存在的话),用于构建正确的服务名称 - // 通过forward记录获取原始的用户ID - userTunnel = getUserTunnel(existForward.getUserId(), tunnel.getId().intValue()); - } - - // 6. 更新Forward对象 - Forward updatedForward = updateForwardEntity(forwardUpdateDto, existForward, tunnel); - - // 7. 获取所需的节点信息 - NodeInfo nodeInfo = getRequiredNodes(tunnel); - if (nodeInfo.isHasError()) { - return R.err(nodeInfo.getErrorMessage()); - } - - // 8. 调用Gost服务更新转发 - R gostResult; - if (isTunnelChanged(existForward, forwardUpdateDto)) { - // 隧道变化时:先删除原配置,再创建新配置 - gostResult = updateGostServicesWithTunnelChange(existForward, updatedForward, tunnel, - permissionResult != null ? permissionResult.getLimiter() : null, - nodeInfo, userTunnel); - } else { - // 隧道未变化时:直接更新配置 - gostResult = updateGostServices(updatedForward, tunnel, - permissionResult != null ? permissionResult.getLimiter() : null, - nodeInfo, userTunnel); - } - - if (gostResult.getCode() != 0) { - return gostResult; - } - updatedForward.setStatus(1); - // 9. 保存更新 - boolean result = this.updateById(updatedForward); - return result ? R.ok("端口转发更新成功") : R.err("端口转发更新失败"); - } - - @Override - public R deleteForward(Long id) { - // 1. 获取当前用户信息 - UserInfo currentUser = getCurrentUserInfo(); - - // 2. 检查转发是否存在 - Forward forward = validateForwardExists(id, currentUser); - if (forward == null) { - return R.err("端口转发不存在"); - } - - // 3. 获取隧道信息 - Tunnel tunnel = validateTunnel(forward.getTunnelId()); - if (tunnel == null) { - return R.err("隧道不存在"); - } - - // 4. 权限检查(仅普通用户需要) - UserTunnel userTunnel = null; - if (currentUser.getRoleId() != ADMIN_ROLE_ID) { - userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); - if (userTunnel == null) { - return R.err("你没有该隧道权限"); - } - } else { - // 管理员删除用户记录时,需要获取对应的UserTunnel用于构建正确的服务名称 - userTunnel = getUserTunnel(forward.getUserId(), tunnel.getId().intValue()); - } - - // 5. 获取所需的节点信息 - NodeInfo nodeInfo = getRequiredNodes(tunnel); - if (nodeInfo.isHasError()) { - return R.err(nodeInfo.getErrorMessage()); - } - - // 6. 调用Gost服务删除转发 - R gostResult = deleteGostServices(forward, tunnel, nodeInfo, userTunnel); - if (gostResult.getCode() != 0) { - return gostResult; - } - - // 7. 删除转发记录 - boolean result = this.removeById(id); - if (result) { - // 归还用户转发条数(普通用户才需要归还) - returnUserForwardQuota(currentUser); - return R.ok("端口转发删除成功"); - } else { - return R.err("端口转发删除失败"); - } - } - - @Override - public R pauseForward(Long id) { - return changeForwardStatus(id, FORWARD_STATUS_PAUSED, "暂停", "PauseService"); - } - - @Override - public R resumeForward(Long id) { - return changeForwardStatus(id, FORWARD_STATUS_ACTIVE, "恢复", "ResumeService"); - } - - @Override - public R forceDeleteForward(Long id) { - // 1. 获取当前用户信息 - UserInfo currentUser = getCurrentUserInfo(); - - // 2. 检查转发是否存在且用户有权限操作 - Forward forward = validateForwardExists(id, currentUser); - if (forward == null) { - return R.err("端口转发不存在"); - } - - // 3. 直接删除转发记录,跳过GOST服务删除 - boolean result = this.removeById(id); - if (result) { - // 归还用户转发条数(普通用户才需要归还) - returnUserForwardQuota(currentUser); - return R.ok("端口转发强制删除成功"); - } else { - return R.err("端口转发强制删除失败"); - } - } - - /** - * 改变转发状态(暂停/恢复) - */ - private R changeForwardStatus(Long id, int targetStatus, String operation, String gostMethod) { - // 1. 获取当前用户信息 - UserInfo currentUser = getCurrentUserInfo(); - - // 2. 检查转发是否存在 - Forward forward = validateForwardExists(id, currentUser); - if (forward == null) { - return R.err("转发不存在"); - } - - // 3. 获取隧道信息 - Tunnel tunnel = validateTunnel(forward.getTunnelId()); - if (tunnel == null) { - return R.err("隧道不存在"); - } - - // 4. 恢复服务时需要额外检查 - UserTunnel userTunnel = null; - if (targetStatus == FORWARD_STATUS_ACTIVE) { - if (tunnel.getStatus() != TUNNEL_STATUS_ACTIVE) { - return R.err("隧道已禁用,无法恢复服务"); - } - - // 普通用户需要检查流量和账户状态 - if (currentUser.getRoleId() != ADMIN_ROLE_ID) { - R flowCheckResult = checkUserFlowLimits(currentUser.getUserId(), tunnel); - if (flowCheckResult.getCode() != 0) { - return flowCheckResult; - } - - userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); - if (userTunnel == null) { - return R.err("你没有该隧道权限"); - } - } - } - - // 5. 权限检查(仅普通用户需要) - if (currentUser.getRoleId() != ADMIN_ROLE_ID && userTunnel == null) { - userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); - if (userTunnel == null) { - return R.err("你没有该隧道权限"); - } - } - - // 6. 确保获取UserTunnel用于构建服务名称(包括管理员用户) - if (userTunnel == null) { - // 通过forward记录获取原始的用户ID来查找UserTunnel - userTunnel = getUserTunnel(forward.getUserId(), tunnel.getId().intValue()); - } - - // 7. 获取所需的节点信息 - NodeInfo nodeInfo = getRequiredNodes(tunnel); - if (nodeInfo.isHasError()) { - return R.err(nodeInfo.getErrorMessage()); - } - - // 8. 调用Gost服务 - String serviceName = buildServiceName(forward.getId(), forward.getUserId(), forward.getTunnelId(), userTunnel); - GostDto gostResult; - - if ("PauseService".equals(gostMethod)) { - gostResult = GostUtil.PauseService(nodeInfo.getInNode().getId(), serviceName); - - // 隧道转发需要同时暂停远端服务 - if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD && nodeInfo.getOutNode() != null) { - GostDto remoteResult = GostUtil.PauseRemoteService(nodeInfo.getOutNode().getId(), serviceName); - if (!isGostOperationSuccess(remoteResult)) { - return R.err(operation + "远端服务失败:" + remoteResult.getMsg()); - } - } - } else { - gostResult = GostUtil.ResumeService(nodeInfo.getInNode().getId(), serviceName); - - // 隧道转发需要同时恢复远端服务 - if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD && nodeInfo.getOutNode() != null) { - GostDto remoteResult = GostUtil.ResumeRemoteService(nodeInfo.getOutNode().getId(), serviceName); - if (!isGostOperationSuccess(remoteResult)) { - return R.err(operation + "远端服务失败:" + remoteResult.getMsg()); - } - } - } - - if (!isGostOperationSuccess(gostResult)) { - return R.err(operation + "服务失败:" + gostResult.getMsg()); - } - - // 9. 更新转发状态 - forward.setStatus(targetStatus); - forward.setUpdatedTime(System.currentTimeMillis()); - boolean result = this.updateById(forward); - - return result ? R.ok("服务已" + operation) : R.err("更新状态失败"); - } - - /** - * 获取当前用户信息 - */ - private UserInfo getCurrentUserInfo() { - Integer userId = JwtUtil.getUserIdFromToken(); - Integer roleId = JwtUtil.getRoleIdFromToken(); - String userName = JwtUtil.getNameFromToken(); - return new UserInfo(userId, roleId, userName); - } - - /** - * 验证隧道是否存在 - */ - private Tunnel validateTunnel(Integer tunnelId) { - return tunnelService.getById(tunnelId); - } - - /** - * 验证转发是否存在且用户有权限访问 - */ - private Forward validateForwardExists(Long forwardId, UserInfo currentUser) { - Forward forward = this.getById(forwardId); - if (forward == null) { - return null; - } - - // 普通用户只能操作自己的转发 - if (currentUser.getRoleId() != ADMIN_ROLE_ID && - !Objects.equals(currentUser.getUserId(), forward.getUserId())) { - return null; - } - - return forward; - } - - /** - * 获取所需的节点信息 - */ - private NodeInfo getRequiredNodes(Tunnel tunnel) { - Node inNode = nodeService.getNodeById(tunnel.getInNodeId()); - if (inNode == null) { - return NodeInfo.error("入口节点不存在"); - } - - Node outNode = null; - if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - outNode = nodeService.getNodeById(tunnel.getOutNodeId()); - if (outNode == null) { - return NodeInfo.error("出口节点不存在"); - } - } - - return NodeInfo.success(inNode, outNode); - } - - /** - * 检查用户权限和限制 - */ - private UserPermissionResult checkUserPermissions(UserInfo currentUser, Tunnel tunnel, Long excludeForwardId) { - if (currentUser.getRoleId() == ADMIN_ROLE_ID) { - return UserPermissionResult.success(null, null); - } - - // 获取用户信息 - User userInfo = userService.getById(currentUser.getUserId()); - if (userInfo.getExpTime() != null && userInfo.getExpTime() <= System.currentTimeMillis()) { - return UserPermissionResult.error("当前账号已到期"); - } - - // 检查用户隧道权限 - UserTunnel userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); - if (userTunnel == null) { - return UserPermissionResult.error("你没有该隧道权限"); - } - - // 检查隧道权限到期时间 - if (userTunnel.getExpTime() != null && userTunnel.getExpTime() <= System.currentTimeMillis()) { - return UserPermissionResult.error("该隧道权限已到期"); - } - - // 流量限制检查 - if (userInfo.getFlow() <= 0) { - return UserPermissionResult.error("用户总流量已用完"); - } - if (userTunnel.getFlow() <= 0) { - return UserPermissionResult.error("该隧道流量已用完"); - } - - // 转发数量限制检查 - R quotaCheckResult = checkForwardQuota(currentUser.getUserId(), tunnel.getId().intValue(), userTunnel, userInfo, excludeForwardId); - if (quotaCheckResult.getCode() != 0) { - return UserPermissionResult.error(quotaCheckResult.getMsg()); - } - - return UserPermissionResult.success(userTunnel.getSpeedId(), userTunnel); - } - - /** - * 检查用户转发数量限制 - */ - private R checkForwardQuota(Integer userId, Integer tunnelId, UserTunnel userTunnel, User userInfo, Long excludeForwardId) { - // 检查用户总转发数量限制 - long userForwardCount = this.count(new QueryWrapper().eq("user_id", userId)); - if (userForwardCount >= userInfo.getNum()) { - return R.err("用户总转发数量已达上限,当前限制:" + userInfo.getNum() + "个"); - } - - // 检查用户在该隧道的转发数量限制 - QueryWrapper tunnelQuery = new QueryWrapper() - .eq("user_id", userId) - .eq("tunnel_id", tunnelId); - - if (excludeForwardId != null) { - tunnelQuery.ne("id", excludeForwardId); - } - - long tunnelForwardCount = this.count(tunnelQuery); - if (tunnelForwardCount >= userTunnel.getNum()) { - return R.err("该隧道转发数量已达上限,当前限制:" + userTunnel.getNum() + "个"); - } - - return R.ok(); - } - - /** - * 检查用户流量限制 - */ - private R checkUserFlowLimits(Integer userId, Tunnel tunnel) { - User userInfo = userService.getById(userId); - if (userInfo.getExpTime() != null && userInfo.getExpTime() <= System.currentTimeMillis()) { - return R.err("当前账号已到期"); - } - - UserTunnel userTunnel = getUserTunnel(userId, tunnel.getId().intValue()); - if (userTunnel == null) { - return R.err("你没有该隧道权限"); - } - - // 检查隧道权限到期时间 - if (userTunnel.getExpTime() != null && userTunnel.getExpTime() <= System.currentTimeMillis()) { - return R.err("该隧道权限已到期,无法恢复服务"); - } - - // 检查用户总流量限制 - if (userInfo.getFlow() * BYTES_TO_GB <= userInfo.getInFlow() + userInfo.getOutFlow()) { - return R.err("用户总流量已用完,无法恢复服务"); - } - - // 检查隧道流量限制 - long tunnelFlow = (tunnel.getFlow() == FLOW_TYPE_UPLOAD_ONLY) ? - userTunnel.getOutFlow() : - userTunnel.getInFlow() + userTunnel.getOutFlow(); - - if (userTunnel.getFlow() * BYTES_TO_GB <= tunnelFlow) { - return R.err("该隧道流量已用完,无法恢复服务"); - } - - return R.ok(); - } - - /** - * 分配端口 - */ - private PortAllocation allocatePorts(Tunnel tunnel, Integer specifiedInPort) { - return allocatePorts(tunnel, specifiedInPort, null); - } - - /** - * 分配端口 - */ - private PortAllocation allocatePorts(Tunnel tunnel, Integer specifiedInPort, Long excludeForwardId) { - Integer inPort; - - if (specifiedInPort != null) { - // 用户指定了入口端口,需要检查是否可用 - if (!isInPortAvailable(tunnel, specifiedInPort, excludeForwardId)) { - return PortAllocation.error("指定的入口端口 " + specifiedInPort + " 已被占用或不在允许范围内"); - } - inPort = specifiedInPort; - } else { - // 用户未指定端口时自动分配 - inPort = allocateInPort(tunnel, excludeForwardId); - if (inPort == null) { - return PortAllocation.error("隧道入口端口已满,无法分配新端口"); - } - } - - Integer outPort = null; - if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - outPort = allocateOutPort(tunnel, excludeForwardId); - if (outPort == null) { - return PortAllocation.error("隧道出口端口已满,无法分配新端口"); - } - } - - return PortAllocation.success(inPort, outPort); - } - - /** - * 创建Forward实体对象 - */ - private Forward createForwardEntity(ForwardDto forwardDto, UserInfo currentUser, PortAllocation portAllocation) { - Forward forward = new Forward(); - // 先复制DTO的属性,再设置其他属性,避免被覆盖 - BeanUtils.copyProperties(forwardDto, forward); - forward.setStatus(FORWARD_STATUS_ACTIVE); - forward.setInPort(portAllocation.getInPort()); - forward.setOutPort(portAllocation.getOutPort()); - forward.setUserId(currentUser.getUserId()); - forward.setUserName(currentUser.getUserName()); - forward.setCreatedTime(System.currentTimeMillis()); - forward.setUpdatedTime(System.currentTimeMillis()); - return forward; - } - - /** - * 更新Forward实体对象 - */ - private Forward updateForwardEntity(ForwardUpdateDto forwardUpdateDto, Forward existForward, Tunnel tunnel) { - Forward forward = new Forward(); - BeanUtils.copyProperties(forwardUpdateDto, forward); - - // 处理端口分配逻辑 - boolean tunnelChanged = !existForward.getTunnelId().equals(forwardUpdateDto.getTunnelId()); - boolean inPortChanged = forwardUpdateDto.getInPort() != null && - !Objects.equals(forwardUpdateDto.getInPort(), existForward.getInPort()); - - if (tunnelChanged || inPortChanged) { - // 隧道变化或入口端口变化时需要重新分配 - Integer specifiedInPort = forwardUpdateDto.getInPort(); - // 如果没有指定新端口但隧道未变化,保持原端口 - if (specifiedInPort == null && !tunnelChanged) { - specifiedInPort = existForward.getInPort(); - } - - PortAllocation portAllocation = allocatePorts(tunnel, specifiedInPort, forwardUpdateDto.getId()); - if (portAllocation.isHasError()) { - throw new RuntimeException(portAllocation.getErrorMessage()); - } - forward.setInPort(portAllocation.getInPort()); - forward.setOutPort(portAllocation.getOutPort()); - } else { - // 隧道和端口都未变化,保持原端口 - forward.setInPort(existForward.getInPort()); - forward.setOutPort(existForward.getOutPort()); - } - - forward.setUpdatedTime(System.currentTimeMillis()); - return forward; - } - - /** - * 创建Gost服务 - */ - private R createGostServices(Forward forward, Tunnel tunnel, Integer limiter, - NodeInfo nodeInfo, UserTunnel userTunnel) { - String serviceName = buildServiceName(forward.getId(), forward.getUserId(), forward.getTunnelId(), userTunnel); - - // 隧道转发需要创建链和远程服务 - if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - R chainResult = createChainService(nodeInfo.getInNode(), serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol()); - if (chainResult.getCode() != 0) { - GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); - return chainResult; - } - - R remoteResult = createRemoteService(nodeInfo.getOutNode(), serviceName, forward, tunnel.getProtocol()); - if (remoteResult.getCode() != 0) { - GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); - GostUtil.DeleteRemoteService(nodeInfo.getOutNode().getId(), serviceName); - return remoteResult; - } - } - - // 创建主服务 - R serviceResult = createMainService(nodeInfo.getInNode(), serviceName, forward, limiter, tunnel.getType(), tunnel, forward.getStrategy()); - if (serviceResult.getCode() != 0) { - GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); - if (nodeInfo.getOutNode() != null) { - GostUtil.DeleteRemoteService(nodeInfo.getOutNode().getId(), serviceName); - } - return serviceResult; - } - return R.ok(); - } - - /** - * 更新Gost服务 - */ - private R updateGostServices(Forward forward, Tunnel tunnel, Integer limiter, - NodeInfo nodeInfo, UserTunnel userTunnel) { - String serviceName = buildServiceName(forward.getId(), forward.getUserId(), forward.getTunnelId(), userTunnel); - - // 隧道转发需要更新链和远程服务 - if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - R chainResult = updateChainService(nodeInfo.getInNode(), serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol()); - if (chainResult.getCode() != 0) { - updateForwardStatusToError(forward); - return chainResult; - } - - R remoteResult = updateRemoteService(nodeInfo.getOutNode(), serviceName, forward, tunnel.getProtocol()); - if (remoteResult.getCode() != 0) { - updateForwardStatusToError(forward); - return remoteResult; - } - } - - // 更新主服务 - R serviceResult = updateMainService(nodeInfo.getInNode(), serviceName, forward, limiter, tunnel.getType(), tunnel, forward.getStrategy()); - if (serviceResult.getCode() != 0) { - updateForwardStatusToError(forward); - return serviceResult; - } - - return R.ok(); - } - - /** - * 隧道变化时更新Gost服务:先删除原配置,再创建新配置 - */ - private R updateGostServicesWithTunnelChange(Forward existForward, Forward updatedForward, Tunnel newTunnel, - Integer limiter, NodeInfo nodeInfo, UserTunnel userTunnel) { - // 1. 获取原隧道信息 - Tunnel oldTunnel = tunnelService.getById(existForward.getTunnelId()); - if (oldTunnel == null) { - return R.err("原隧道不存在,无法删除旧配置"); - } - - // 2. 删除原有的Gost服务配置 - R deleteResult = deleteOldGostServices(existForward, oldTunnel, userTunnel); - if (deleteResult.getCode() != 0) { - // 删除失败时记录日志,但不影响后续创建(可能原配置已不存在) - log.warn("删除原隧道{}的Gost配置失败: {}", oldTunnel.getId(), deleteResult.getMsg()); - } - - // 3. 创建新的Gost服务配置 - R createResult = createGostServices(updatedForward, newTunnel, limiter, nodeInfo, userTunnel); - if (createResult.getCode() != 0) { - updateForwardStatusToError(updatedForward); - return R.err("创建新隧道配置失败: " + createResult.getMsg()); - } - - return R.ok(); - } - - /** - * 删除原有的Gost服务(隧道变化时专用) - */ - private R deleteOldGostServices(Forward forward, Tunnel oldTunnel, UserTunnel userTunnel) { - // 获取原隧道的用户隧道关系 - UserTunnel oldUserTunnel = getUserTunnel(forward.getUserId(), oldTunnel.getId().intValue()); - String serviceName = buildServiceName(forward.getId(), forward.getUserId(), forward.getTunnelId(), oldUserTunnel); - - // 获取原隧道的节点信息 - NodeInfo oldNodeInfo = getRequiredNodes(oldTunnel); - - // 删除主服务(使用原隧道的入口节点) - if (!oldNodeInfo.isHasError() && oldNodeInfo.getInNode() != null) { - GostDto serviceResult = GostUtil.DeleteService(oldNodeInfo.getInNode().getId(), serviceName); - if (!isGostOperationSuccess(serviceResult)) { - log.warn("删除主服务失败: {}", serviceResult.getMsg()); - } - } - - // 如果原隧道是隧道转发类型,需要删除链和远程服务 - if (oldTunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - // 删除链服务 - if (!oldNodeInfo.isHasError() && oldNodeInfo.getInNode() != null) { - GostDto chainResult = GostUtil.DeleteChains(oldNodeInfo.getInNode().getId(), serviceName); - if (!isGostOperationSuccess(chainResult)) { - log.warn("删除链服务失败: {}", chainResult.getMsg()); - } - } - - // 删除远程服务(即使节点信息获取失败,也要尝试删除) - Node outNode = null; - if (!oldNodeInfo.isHasError()) { - outNode = oldNodeInfo.getOutNode(); - } else { - // 即使获取节点信息失败,也尝试直接获取出口节点来删除远程服务 - outNode = nodeService.getNodeById(oldTunnel.getOutNodeId()); - } - - if (outNode != null) { - GostDto remoteResult = GostUtil.DeleteRemoteService(outNode.getId(), serviceName); - if (!isGostOperationSuccess(remoteResult)) { - log.warn("删除远程服务失败: {}", remoteResult.getMsg()); - } - } - } - - return R.ok(); - } - - /** - * 删除Gost服务 - */ - private R deleteGostServices(Forward forward, Tunnel tunnel, NodeInfo nodeInfo, UserTunnel userTunnel) { - String serviceName = buildServiceName(forward.getId(), forward.getUserId(), forward.getTunnelId(), userTunnel); - - // 删除主服务 - GostDto serviceResult = GostUtil.DeleteService(nodeInfo.getInNode().getId(), serviceName); - if (!isGostOperationSuccess(serviceResult)) { - return R.err(serviceResult.getMsg()); - } - - // 隧道转发需要删除链和远程服务 - if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - GostDto chainResult = GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); - if (!isGostOperationSuccess(chainResult)) { - return R.err(chainResult.getMsg()); - } - - if (nodeInfo.getOutNode() != null) { - GostDto remoteResult = GostUtil.DeleteRemoteService(nodeInfo.getOutNode().getId(), serviceName); - if (!isGostOperationSuccess(remoteResult)) { - return R.err(remoteResult.getMsg()); - } - } - } - - return R.ok(); - } - - /** - * 创建链服务 - */ - private R createChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) { - String remoteAddr = outIp + ":" + outPort; - if (outIp.contains(":")) { - remoteAddr = "[" + outIp + "]:" + outPort; - } - GostDto result = GostUtil.AddChains(inNode.getId(), serviceName, remoteAddr, protocol); - return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); - } - - /** - * 创建远程服务 - */ - private R createRemoteService(Node outNode, String serviceName, Forward forward, String protocol) { - GostDto result = GostUtil.AddRemoteService(outNode.getId(), serviceName, forward.getOutPort(), forward.getRemoteAddr(), protocol, forward.getStrategy()); - return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); - } - - /** - * 创建主服务 - */ - private R createMainService(Node inNode, String serviceName, Forward forward, Integer limiter, Integer tunnelType, Tunnel tunnel, String strategy) { - GostDto result = GostUtil.AddService(inNode.getId(), serviceName, forward.getInPort(), limiter, forward.getRemoteAddr(), tunnelType, tunnel, strategy); - return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); - } - - /** - * 更新链服务 - */ - private R updateChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) { - // 创建新链 - String remoteAddr = outIp + ":" + outPort; - if (outIp.contains(":")) { - remoteAddr = "[" + outIp + "]:" + outPort; - } - GostDto createResult = GostUtil.UpdateChains(inNode.getId(), serviceName, remoteAddr, protocol); - if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) { - createResult = GostUtil.AddChains(inNode.getId(), serviceName, remoteAddr, protocol); - } - return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg()); - } - - /** - * 更新远程服务 - */ - private R updateRemoteService(Node outNode, String serviceName, Forward forward, String protocol) { - // 创建新远程服务 - GostDto createResult = GostUtil.UpdateRemoteService(outNode.getId(), - serviceName, forward.getOutPort(), - forward.getRemoteAddr(), protocol, forward.getStrategy()); - if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) { - createResult = GostUtil.AddRemoteService(outNode.getId(), - serviceName, forward.getOutPort(), - forward.getRemoteAddr(),protocol, forward.getStrategy()); - } - return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg()); - } - - /** - * 更新主服务 - */ - private R updateMainService(Node inNode, String serviceName, Forward forward, Integer limiter, Integer tunnelType, Tunnel tunnel, String strategy) { - GostDto result = GostUtil.UpdateService(inNode.getId(), serviceName, forward.getInPort(), limiter, forward.getRemoteAddr(), tunnelType, tunnel, strategy); - - if (result.getMsg().contains(GOST_NOT_FOUND_MSG)) { - result = GostUtil.AddService(inNode.getId(), serviceName, forward.getInPort(), limiter, forward.getRemoteAddr(), tunnelType, tunnel, strategy); - } - - return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); - } - - /** - * 更新转发状态为错误 - */ - private void updateForwardStatusToError(Forward forward) { - forward.setStatus(FORWARD_STATUS_ERROR); - this.updateById(forward); - } - - /** - * 获取用户隧道关系 - */ - private UserTunnel getUserTunnel(Integer userId, Integer tunnelId) { - return userTunnelService.getOne(new QueryWrapper() - .eq("user_id", userId) - .eq("tunnel_id", tunnelId)); - } - - /** - * 检查隧道是否发生变化 - */ - private boolean isTunnelChanged(Forward existForward, ForwardUpdateDto updateDto) { - return !existForward.getTunnelId().equals(updateDto.getTunnelId()); - } - - /** - * 归还用户转发配额 - */ - private void returnUserForwardQuota(UserInfo currentUser) { - if (currentUser.getRoleId() != ADMIN_ROLE_ID) { - User user = userService.getById(currentUser.getUserId()); - if (user != null) { - user.setNum(user.getNum() + 1); - user.setUpdatedTime(System.currentTimeMillis()); - userService.updateById(user); - } - } - } - - /** - * 检查Gost操作是否成功 - */ - private boolean isGostOperationSuccess(GostDto gostResult) { - return Objects.equals(gostResult.getMsg(), GOST_SUCCESS_MSG); - } - - /** - * 检查指定的入口端口是否可用 - */ - private boolean isInPortAvailable(Tunnel tunnel, Integer port) { - return isInPortAvailable(tunnel, port, null); - } - - /** - * 检查指定的入口端口是否可用(可排除指定的转发ID) - */ - private boolean isInPortAvailable(Tunnel tunnel, Integer port, Long excludeForwardId) { - // 检查端口是否在隧道允许的范围内 - if (port < tunnel.getInPortSta() || port > tunnel.getInPortEnd()) { - return false; - } - - // 获取所有使用相同入口节点的隧道 - List tunnelsWithSameInNode = tunnelService.list(new QueryWrapper().eq("in_node_id", tunnel.getInNodeId())); - Set tunnelIds = tunnelsWithSameInNode.stream() - .map(Tunnel::getId) - .collect(Collectors.toSet()); - - // 获取这些隧道的所有转发已使用的入口端口(排除指定的转发ID) - QueryWrapper queryWrapper = new QueryWrapper().in("tunnel_id", tunnelIds); - if (excludeForwardId != null) { - queryWrapper.ne("id", excludeForwardId); - } - - List usedForwards = this.list(queryWrapper); - Set usedInPorts = usedForwards.stream() - .map(Forward::getInPort) - .filter(portNum -> portNum != null) - .collect(Collectors.toSet()); - - // 检查端口是否已被占用 - return !usedInPorts.contains(port); - } - - /** - * 为隧道分配一个可用的入口端口 - */ - private Integer allocateInPort(Tunnel tunnel) { - return allocateInPort(tunnel, null); - } - - /** - * 为隧道分配一个可用的入口端口(可排除指定的转发ID) - */ - private Integer allocateInPort(Tunnel tunnel, Long excludeForwardId) { - // 获取所有使用相同入口节点的隧道 - List tunnelsWithSameInNode = tunnelService.list(new QueryWrapper().eq("in_node_id", tunnel.getInNodeId())); - Set tunnelIds = tunnelsWithSameInNode.stream() - .map(Tunnel::getId) - .collect(Collectors.toSet()); - - // 获取这些隧道的所有转发已使用的入口端口(排除指定的转发ID) - QueryWrapper queryWrapper = new QueryWrapper().in("tunnel_id", tunnelIds); - if (excludeForwardId != null) { - queryWrapper.ne("id", excludeForwardId); - } - - List usedForwards = this.list(queryWrapper); - Set usedInPorts = usedForwards.stream() - .map(Forward::getInPort) - .filter(port -> port != null) - .collect(Collectors.toSet()); - - // 在隧道端口范围内寻找未使用的端口 - for (int port = tunnel.getInPortSta(); port <= tunnel.getInPortEnd(); port++) { - if (!usedInPorts.contains(port)) { - return port; - } - } - return null; - } - - /** - * 为隧道分配一个可用的出口端口 - */ - private Integer allocateOutPort(Tunnel tunnel) { - return allocateOutPort(tunnel, null); - } - - /** - * 为隧道分配一个可用的出口端口(可排除指定的转发ID) - */ - private Integer allocateOutPort(Tunnel tunnel, Long excludeForwardId) { - // 获取所有使用相同出口节点的隧道 - List tunnelsWithSameOutNode = tunnelService.list(new QueryWrapper().eq("out_node_id", tunnel.getOutNodeId())); - Set tunnelIds = tunnelsWithSameOutNode.stream() - .map(Tunnel::getId) - .collect(Collectors.toSet()); - - // 获取这些隧道的所有转发已使用的出口端口(排除指定的转发ID) - QueryWrapper queryWrapper = new QueryWrapper().in("tunnel_id", tunnelIds); - if (excludeForwardId != null) { - queryWrapper.ne("id", excludeForwardId); - } - - List usedForwards = this.list(queryWrapper); - Set usedOutPorts = usedForwards.stream() - .map(Forward::getOutPort) - .filter(port -> port != null) - .collect(Collectors.toSet()); - - // 在隧道出口端口范围内寻找未使用的端口 - for (int port = tunnel.getOutIpSta(); port <= tunnel.getOutIpEnd(); port++) { - if (!usedOutPorts.contains(port)) { - return port; - } - } - return null; - } - - /** - * 构建服务名称,优化后减少重复查询 - */ - private String buildServiceName(Long forwardId, Integer userId, Integer tunnelId, UserTunnel userTunnel) { - int userTunnelId = (userTunnel != null) ? userTunnel.getId() : 0; - return forwardId + "_" + userId + "_" + userTunnelId; - } - - // ========== 内部数据类 ========== - - /** - * 用户信息封装类 - */ - @Data - private static class UserInfo { - private final Integer userId; - private final Integer roleId; - private final String userName; - } - - /** - * 用户权限检查结果 - */ - @Data - private static class UserPermissionResult { - private final boolean hasError; - private final String errorMessage; - private final Integer limiter; - private final UserTunnel userTunnel; - - private UserPermissionResult(boolean hasError, String errorMessage, Integer limiter, UserTunnel userTunnel) { - this.hasError = hasError; - this.errorMessage = errorMessage; - this.limiter = limiter; - this.userTunnel = userTunnel; - } - - public static UserPermissionResult success(Integer limiter, UserTunnel userTunnel) { - return new UserPermissionResult(false, null, limiter, userTunnel); - } - - public static UserPermissionResult error(String errorMessage) { - return new UserPermissionResult(true, errorMessage, null, null); - } - } - - /** - * 端口分配结果 - */ - @Data - private static class PortAllocation { - private final boolean hasError; - private final String errorMessage; - private final Integer inPort; - private final Integer outPort; - - private PortAllocation(boolean hasError, String errorMessage, Integer inPort, Integer outPort) { - this.hasError = hasError; - this.errorMessage = errorMessage; - this.inPort = inPort; - this.outPort = outPort; - } - - public static PortAllocation success(Integer inPort, Integer outPort) { - return new PortAllocation(false, null, inPort, outPort); - } - - public static PortAllocation error(String errorMessage) { - return new PortAllocation(true, errorMessage, null, null); - } - } - - /** - * 节点信息封装类 - */ - @Data - private static class NodeInfo { - private final boolean hasError; - private final String errorMessage; - private final Node inNode; - private final Node outNode; - - private NodeInfo(boolean hasError, String errorMessage, Node inNode, Node outNode) { - this.hasError = hasError; - this.errorMessage = errorMessage; - this.inNode = inNode; - this.outNode = outNode; - } - - public static NodeInfo success(Node inNode, Node outNode) { - return new NodeInfo(false, null, inNode, outNode); - } - - public static NodeInfo error(String errorMessage) { - return new NodeInfo(true, errorMessage, null, null); - } - } -} +package com.admin.service.impl; + +import com.admin.common.dto.ForwardDto; +import com.admin.common.dto.ForwardUpdateDto; +import com.admin.common.dto.ForwardWithTunnelDto; +import com.admin.common.dto.GostDto; +import com.admin.common.lang.R; +import com.admin.common.utils.GostUtil; +import com.admin.common.utils.JwtUtil; +import com.admin.entity.*; +import com.admin.mapper.ForwardMapper; +import com.admin.service.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + *

+ * 端口转发服务实现类 + *

+ * + * @author QAQ + * @since 2025-06-03 + */ +@Slf4j +@Service +public class ForwardServiceImpl extends ServiceImpl implements ForwardService { + + // 常量定义 + private static final String GOST_SUCCESS_MSG = "OK"; + private static final String GOST_NOT_FOUND_MSG = "not found"; + private static final int ADMIN_ROLE_ID = 0; + private static final int TUNNEL_TYPE_PORT_FORWARD = 1; + private static final int TUNNEL_TYPE_TUNNEL_FORWARD = 2; + private static final int FORWARD_STATUS_ACTIVE = 1; + private static final int FORWARD_STATUS_PAUSED = 0; + private static final int FORWARD_STATUS_ERROR = -1; + private static final int TUNNEL_STATUS_ACTIVE = 1; + private static final int FLOW_TYPE_UPLOAD_ONLY = 1; + private static final long BYTES_TO_GB = 1024L * 1024L * 1024L; + + @Resource + @Lazy + private TunnelService tunnelService; + + @Resource + UserTunnelService userTunnelService; + + @Resource + UserService userService; + + @Resource + NodeService nodeService; + + + @Override + public R createForward(ForwardDto forwardDto) { + // 1. 获取当前用户信息 + UserInfo currentUser = getCurrentUserInfo(); + + // 2. 检查隧道是否存在和可用 + Tunnel tunnel = validateTunnel(forwardDto.getTunnelId()); + if (tunnel == null) { + return R.err("隧道不存在"); + } + if (tunnel.getStatus() != TUNNEL_STATUS_ACTIVE) { + return R.err("隧道已禁用,无法创建转发"); + } + + // 3. 普通用户权限和限制检查 + UserPermissionResult permissionResult = checkUserPermissions(currentUser, tunnel, null); + if (permissionResult.isHasError()) { + return R.err(permissionResult.getErrorMessage()); + } + + // 4. 分配端口 + PortAllocation portAllocation = allocatePorts(tunnel, forwardDto.getInPort()); + if (portAllocation.isHasError()) { + return R.err(portAllocation.getErrorMessage()); + } + + // 5. 创建并保存Forward对象 + Forward forward = createForwardEntity(forwardDto, currentUser, portAllocation); + if (!this.save(forward)) { + return R.err("端口转发创建失败"); + } + + // 6. 获取所需的节点信息 + NodeInfo nodeInfo = getRequiredNodes(tunnel); + if (nodeInfo.isHasError()) { + this.removeById(forward.getId()); + return R.err(nodeInfo.getErrorMessage()); + } + + // 7. 调用Gost服务创建转发 + R gostResult = createGostServices(forward, tunnel, permissionResult.getLimiter(), + nodeInfo, permissionResult.getUserTunnel()); + + if (gostResult.getCode() != 0) { + this.removeById(forward.getId()); + return gostResult; + } + + return R.ok(); + } + + @Override + public R getAllForwards() { + UserInfo currentUser = getCurrentUserInfo(); + + List forwardList; + if (currentUser.getRoleId() != ADMIN_ROLE_ID) { + forwardList = baseMapper.selectForwardsWithTunnelByUserId(currentUser.getUserId()); + } else { + forwardList = baseMapper.selectAllForwardsWithTunnel(); + } + + return R.ok(forwardList); + } + + @Override + public R updateForward(ForwardUpdateDto forwardUpdateDto) { + // 1. 获取当前用户信息 + UserInfo currentUser = getCurrentUserInfo(); + + // 2. 检查转发是否存在 + Forward existForward = validateForwardExists(forwardUpdateDto.getId(), currentUser); + if (existForward == null) { + return R.err("转发不存在"); + } + + // 3. 检查隧道是否存在和可用 + Tunnel tunnel = validateTunnel(forwardUpdateDto.getTunnelId()); + if (tunnel == null) { + return R.err("隧道不存在"); + } + if (tunnel.getStatus() != TUNNEL_STATUS_ACTIVE) { + return R.err("隧道已禁用,无法更新转发"); + } + + // 4. 检查权限和限制 + UserPermissionResult permissionResult = null; + if (isTunnelChanged(existForward, forwardUpdateDto)) { + permissionResult = checkUserPermissions(currentUser, tunnel, forwardUpdateDto.getId()); + if (permissionResult.isHasError()) { + return R.err(permissionResult.getErrorMessage()); + } + } + + // 5. 获取UserTunnel(即使隧道未变化也需要获取,用于构建服务名称) + UserTunnel userTunnel = null; + if (currentUser.getRoleId() != ADMIN_ROLE_ID) { + userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); + if (userTunnel == null) { + return R.err("你没有该隧道权限"); + } + } else { + // 管理员用户也需要获取UserTunnel(如果存在的话),用于构建正确的服务名称 + // 通过forward记录获取原始的用户ID + userTunnel = getUserTunnel(existForward.getUserId(), tunnel.getId().intValue()); + } + + // 6. 更新Forward对象 + Forward updatedForward = updateForwardEntity(forwardUpdateDto, existForward, tunnel); + + // 7. 获取所需的节点信息 + NodeInfo nodeInfo = getRequiredNodes(tunnel); + if (nodeInfo.isHasError()) { + return R.err(nodeInfo.getErrorMessage()); + } + + // 8. 调用Gost服务更新转发 + R gostResult; + if (isTunnelChanged(existForward, forwardUpdateDto)) { + // 隧道变化时:先删除原配置,再创建新配置 + gostResult = updateGostServicesWithTunnelChange(existForward, updatedForward, tunnel, + permissionResult != null ? permissionResult.getLimiter() : null, + nodeInfo, userTunnel); + } else { + // 隧道未变化时:直接更新配置 + gostResult = updateGostServices(updatedForward, tunnel, + permissionResult != null ? permissionResult.getLimiter() : null, + nodeInfo, userTunnel); + } + + if (gostResult.getCode() != 0) { + return gostResult; + } + updatedForward.setStatus(1); + // 9. 保存更新 + boolean result = this.updateById(updatedForward); + return result ? R.ok("端口转发更新成功") : R.err("端口转发更新失败"); + } + + @Override + public R deleteForward(Long id) { + // 1. 获取当前用户信息 + UserInfo currentUser = getCurrentUserInfo(); + + // 2. 检查转发是否存在 + Forward forward = validateForwardExists(id, currentUser); + if (forward == null) { + return R.err("端口转发不存在"); + } + + // 3. 获取隧道信息 + Tunnel tunnel = validateTunnel(forward.getTunnelId()); + if (tunnel == null) { + return R.err("隧道不存在"); + } + + // 4. 权限检查(仅普通用户需要) + UserTunnel userTunnel = null; + if (currentUser.getRoleId() != ADMIN_ROLE_ID) { + userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); + if (userTunnel == null) { + return R.err("你没有该隧道权限"); + } + } else { + // 管理员删除用户记录时,需要获取对应的UserTunnel用于构建正确的服务名称 + userTunnel = getUserTunnel(forward.getUserId(), tunnel.getId().intValue()); + } + + // 5. 获取所需的节点信息 + NodeInfo nodeInfo = getRequiredNodes(tunnel); + if (nodeInfo.isHasError()) { + return R.err(nodeInfo.getErrorMessage()); + } + + // 6. 调用Gost服务删除转发 + R gostResult = deleteGostServices(forward, tunnel, nodeInfo, userTunnel); + if (gostResult.getCode() != 0) { + return gostResult; + } + + // 7. 删除转发记录 + boolean result = this.removeById(id); + if (result) { + // 归还用户转发条数(普通用户才需要归还) + returnUserForwardQuota(currentUser); + return R.ok("端口转发删除成功"); + } else { + return R.err("端口转发删除失败"); + } + } + + @Override + public R pauseForward(Long id) { + return changeForwardStatus(id, FORWARD_STATUS_PAUSED, "暂停", "PauseService"); + } + + @Override + public R resumeForward(Long id) { + return changeForwardStatus(id, FORWARD_STATUS_ACTIVE, "恢复", "ResumeService"); + } + + @Override + public R forceDeleteForward(Long id) { + // 1. 获取当前用户信息 + UserInfo currentUser = getCurrentUserInfo(); + + // 2. 检查转发是否存在且用户有权限操作 + Forward forward = validateForwardExists(id, currentUser); + if (forward == null) { + return R.err("端口转发不存在"); + } + + // 3. 直接删除转发记录,跳过GOST服务删除 + boolean result = this.removeById(id); + if (result) { + // 归还用户转发条数(普通用户才需要归还) + returnUserForwardQuota(currentUser); + return R.ok("端口转发强制删除成功"); + } else { + return R.err("端口转发强制删除失败"); + } + } + + /** + * 改变转发状态(暂停/恢复) + */ + private R changeForwardStatus(Long id, int targetStatus, String operation, String gostMethod) { + // 1. 获取当前用户信息 + UserInfo currentUser = getCurrentUserInfo(); + + // 2. 检查转发是否存在 + Forward forward = validateForwardExists(id, currentUser); + if (forward == null) { + return R.err("转发不存在"); + } + + // 3. 获取隧道信息 + Tunnel tunnel = validateTunnel(forward.getTunnelId()); + if (tunnel == null) { + return R.err("隧道不存在"); + } + + // 4. 恢复服务时需要额外检查 + UserTunnel userTunnel = null; + if (targetStatus == FORWARD_STATUS_ACTIVE) { + if (tunnel.getStatus() != TUNNEL_STATUS_ACTIVE) { + return R.err("隧道已禁用,无法恢复服务"); + } + + // 普通用户需要检查流量和账户状态 + if (currentUser.getRoleId() != ADMIN_ROLE_ID) { + R flowCheckResult = checkUserFlowLimits(currentUser.getUserId(), tunnel); + if (flowCheckResult.getCode() != 0) { + return flowCheckResult; + } + + userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); + if (userTunnel == null) { + return R.err("你没有该隧道权限"); + } + } + } + + // 5. 权限检查(仅普通用户需要) + if (currentUser.getRoleId() != ADMIN_ROLE_ID && userTunnel == null) { + userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); + if (userTunnel == null) { + return R.err("你没有该隧道权限"); + } + } + + // 6. 确保获取UserTunnel用于构建服务名称(包括管理员用户) + if (userTunnel == null) { + // 通过forward记录获取原始的用户ID来查找UserTunnel + userTunnel = getUserTunnel(forward.getUserId(), tunnel.getId().intValue()); + } + + // 7. 获取所需的节点信息 + NodeInfo nodeInfo = getRequiredNodes(tunnel); + if (nodeInfo.isHasError()) { + return R.err(nodeInfo.getErrorMessage()); + } + + // 8. 调用Gost服务 + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel); + GostDto gostResult; + + if ("PauseService".equals(gostMethod)) { + gostResult = GostUtil.PauseService(nodeInfo.getInNode().getId(), serviceName); + + // 隧道转发需要同时暂停远端服务 + if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD && nodeInfo.getOutNode() != null) { + GostDto remoteResult = GostUtil.PauseRemoteService(nodeInfo.getOutNode().getId(), serviceName); + if (!isGostOperationSuccess(remoteResult)) { + return R.err(operation + "远端服务失败:" + remoteResult.getMsg()); + } + } + } else { + gostResult = GostUtil.ResumeService(nodeInfo.getInNode().getId(), serviceName); + + // 隧道转发需要同时恢复远端服务 + if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD && nodeInfo.getOutNode() != null) { + GostDto remoteResult = GostUtil.ResumeRemoteService(nodeInfo.getOutNode().getId(), serviceName); + if (!isGostOperationSuccess(remoteResult)) { + return R.err(operation + "远端服务失败:" + remoteResult.getMsg()); + } + } + } + + if (!isGostOperationSuccess(gostResult)) { + return R.err(operation + "服务失败:" + gostResult.getMsg()); + } + + // 9. 更新转发状态 + forward.setStatus(targetStatus); + forward.setUpdatedTime(System.currentTimeMillis()); + boolean result = this.updateById(forward); + + return result ? R.ok("服务已" + operation) : R.err("更新状态失败"); + } + + /** + * 获取当前用户信息 + */ + private UserInfo getCurrentUserInfo() { + Integer userId = JwtUtil.getUserIdFromToken(); + Integer roleId = JwtUtil.getRoleIdFromToken(); + String userName = JwtUtil.getNameFromToken(); + return new UserInfo(userId, roleId, userName); + } + + /** + * 验证隧道是否存在 + */ + private Tunnel validateTunnel(Integer tunnelId) { + return tunnelService.getById(tunnelId); + } + + /** + * 验证转发是否存在且用户有权限访问 + */ + private Forward validateForwardExists(Long forwardId, UserInfo currentUser) { + Forward forward = this.getById(forwardId); + if (forward == null) { + return null; + } + + // 普通用户只能操作自己的转发 + if (currentUser.getRoleId() != ADMIN_ROLE_ID && + !Objects.equals(currentUser.getUserId(), forward.getUserId())) { + return null; + } + + return forward; + } + + /** + * 获取所需的节点信息 + */ + private NodeInfo getRequiredNodes(Tunnel tunnel) { + Node inNode = nodeService.getNodeById(tunnel.getInNodeId()); + if (inNode == null) { + return NodeInfo.error("入口节点不存在"); + } + + Node outNode = null; + if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { + outNode = nodeService.getNodeById(tunnel.getOutNodeId()); + if (outNode == null) { + return NodeInfo.error("出口节点不存在"); + } + } + + return NodeInfo.success(inNode, outNode); + } + + /** + * 检查用户权限和限制 + */ + private UserPermissionResult checkUserPermissions(UserInfo currentUser, Tunnel tunnel, Long excludeForwardId) { + if (currentUser.getRoleId() == ADMIN_ROLE_ID) { + return UserPermissionResult.success(null, null); + } + + // 获取用户信息 + User userInfo = userService.getById(currentUser.getUserId()); + if (userInfo.getExpTime() != null && userInfo.getExpTime() <= System.currentTimeMillis()) { + return UserPermissionResult.error("当前账号已到期"); + } + + // 检查用户隧道权限 + UserTunnel userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); + if (userTunnel == null) { + return UserPermissionResult.error("你没有该隧道权限"); + } + + // 检查隧道权限到期时间 + if (userTunnel.getExpTime() != null && userTunnel.getExpTime() <= System.currentTimeMillis()) { + return UserPermissionResult.error("该隧道权限已到期"); + } + + // 流量限制检查 + if (userInfo.getFlow() <= 0) { + return UserPermissionResult.error("用户总流量已用完"); + } + if (userTunnel.getFlow() <= 0) { + return UserPermissionResult.error("该隧道流量已用完"); + } + + // 转发数量限制检查 + R quotaCheckResult = checkForwardQuota(currentUser.getUserId(), tunnel.getId().intValue(), userTunnel, userInfo, excludeForwardId); + if (quotaCheckResult.getCode() != 0) { + return UserPermissionResult.error(quotaCheckResult.getMsg()); + } + + return UserPermissionResult.success(userTunnel.getSpeedId(), userTunnel); + } + + /** + * 检查用户转发数量限制 + */ + private R checkForwardQuota(Integer userId, Integer tunnelId, UserTunnel userTunnel, User userInfo, Long excludeForwardId) { + // 检查用户总转发数量限制 + long userForwardCount = this.count(new QueryWrapper().eq("user_id", userId)); + if (userForwardCount >= userInfo.getNum()) { + return R.err("用户总转发数量已达上限,当前限制:" + userInfo.getNum() + "个"); + } + + // 检查用户在该隧道的转发数量限制 + QueryWrapper tunnelQuery = new QueryWrapper() + .eq("user_id", userId) + .eq("tunnel_id", tunnelId); + + if (excludeForwardId != null) { + tunnelQuery.ne("id", excludeForwardId); + } + + long tunnelForwardCount = this.count(tunnelQuery); + if (tunnelForwardCount >= userTunnel.getNum()) { + return R.err("该隧道转发数量已达上限,当前限制:" + userTunnel.getNum() + "个"); + } + + return R.ok(); + } + + /** + * 检查用户流量限制 + */ + private R checkUserFlowLimits(Integer userId, Tunnel tunnel) { + User userInfo = userService.getById(userId); + if (userInfo.getExpTime() != null && userInfo.getExpTime() <= System.currentTimeMillis()) { + return R.err("当前账号已到期"); + } + + UserTunnel userTunnel = getUserTunnel(userId, tunnel.getId().intValue()); + if (userTunnel == null) { + return R.err("你没有该隧道权限"); + } + + // 检查隧道权限到期时间 + if (userTunnel.getExpTime() != null && userTunnel.getExpTime() <= System.currentTimeMillis()) { + return R.err("该隧道权限已到期,无法恢复服务"); + } + + // 检查用户总流量限制 + if (userInfo.getFlow() * BYTES_TO_GB <= userInfo.getInFlow() + userInfo.getOutFlow()) { + return R.err("用户总流量已用完,无法恢复服务"); + } + + // 检查隧道流量限制 + long tunnelFlow = (tunnel.getFlow() == FLOW_TYPE_UPLOAD_ONLY) ? + userTunnel.getOutFlow() : + userTunnel.getInFlow() + userTunnel.getOutFlow(); + + if (userTunnel.getFlow() * BYTES_TO_GB <= tunnelFlow) { + return R.err("该隧道流量已用完,无法恢复服务"); + } + + return R.ok(); + } + + /** + * 分配端口 + */ + private PortAllocation allocatePorts(Tunnel tunnel, Integer specifiedInPort) { + return allocatePorts(tunnel, specifiedInPort, null); + } + + /** + * 分配端口 + */ + private PortAllocation allocatePorts(Tunnel tunnel, Integer specifiedInPort, Long excludeForwardId) { + Integer inPort; + + if (specifiedInPort != null) { + // 用户指定了入口端口,需要检查是否可用 + if (!isInPortAvailable(tunnel, specifiedInPort, excludeForwardId)) { + return PortAllocation.error("指定的入口端口 " + specifiedInPort + " 已被占用或不在允许范围内"); + } + inPort = specifiedInPort; + } else { + // 用户未指定端口时自动分配 + inPort = allocateInPort(tunnel, excludeForwardId); + if (inPort == null) { + return PortAllocation.error("隧道入口端口已满,无法分配新端口"); + } + } + + Integer outPort = null; + if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { + outPort = allocateOutPort(tunnel, excludeForwardId); + if (outPort == null) { + return PortAllocation.error("隧道出口端口已满,无法分配新端口"); + } + } + + return PortAllocation.success(inPort, outPort); + } + + /** + * 创建Forward实体对象 + */ + private Forward createForwardEntity(ForwardDto forwardDto, UserInfo currentUser, PortAllocation portAllocation) { + Forward forward = new Forward(); + // 先复制DTO的属性,再设置其他属性,避免被覆盖 + BeanUtils.copyProperties(forwardDto, forward); + forward.setStatus(FORWARD_STATUS_ACTIVE); + forward.setInPort(portAllocation.getInPort()); + forward.setOutPort(portAllocation.getOutPort()); + forward.setUserId(currentUser.getUserId()); + forward.setUserName(currentUser.getUserName()); + forward.setCreatedTime(System.currentTimeMillis()); + forward.setUpdatedTime(System.currentTimeMillis()); + return forward; + } + + /** + * 更新Forward实体对象 + */ + private Forward updateForwardEntity(ForwardUpdateDto forwardUpdateDto, Forward existForward, Tunnel tunnel) { + Forward forward = new Forward(); + BeanUtils.copyProperties(forwardUpdateDto, forward); + + // 处理端口分配逻辑 + boolean tunnelChanged = !existForward.getTunnelId().equals(forwardUpdateDto.getTunnelId()); + boolean inPortChanged = forwardUpdateDto.getInPort() != null && + !Objects.equals(forwardUpdateDto.getInPort(), existForward.getInPort()); + + if (tunnelChanged || inPortChanged) { + // 隧道变化或入口端口变化时需要重新分配 + Integer specifiedInPort = forwardUpdateDto.getInPort(); + // 如果没有指定新端口但隧道未变化,保持原端口 + if (specifiedInPort == null && !tunnelChanged) { + specifiedInPort = existForward.getInPort(); + } + + PortAllocation portAllocation = allocatePorts(tunnel, specifiedInPort, forwardUpdateDto.getId()); + if (portAllocation.isHasError()) { + throw new RuntimeException(portAllocation.getErrorMessage()); + } + forward.setInPort(portAllocation.getInPort()); + forward.setOutPort(portAllocation.getOutPort()); + } else { + // 隧道和端口都未变化,保持原端口 + forward.setInPort(existForward.getInPort()); + forward.setOutPort(existForward.getOutPort()); + } + + forward.setUpdatedTime(System.currentTimeMillis()); + return forward; + } + + /** + * 创建Gost服务 + */ + private R createGostServices(Forward forward, Tunnel tunnel, Integer limiter, + NodeInfo nodeInfo, UserTunnel userTunnel) { + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel); + + // 隧道转发需要创建链和远程服务 + if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { + R chainResult = createChainService(nodeInfo.getInNode(), serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol()); + if (chainResult.getCode() != 0) { + GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); + return chainResult; + } + + R remoteResult = createRemoteService(nodeInfo.getOutNode(), serviceName, forward, tunnel.getProtocol()); + if (remoteResult.getCode() != 0) { + GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); + GostUtil.DeleteRemoteService(nodeInfo.getOutNode().getId(), serviceName); + return remoteResult; + } + } + + // 创建主服务 + R serviceResult = createMainService(nodeInfo.getInNode(), serviceName, forward, limiter, tunnel.getType(), tunnel, forward.getStrategy()); + if (serviceResult.getCode() != 0) { + GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); + if (nodeInfo.getOutNode() != null) { + GostUtil.DeleteRemoteService(nodeInfo.getOutNode().getId(), serviceName); + } + return serviceResult; + } + return R.ok(); + } + + /** + * 更新Gost服务 + */ + private R updateGostServices(Forward forward, Tunnel tunnel, Integer limiter, + NodeInfo nodeInfo, UserTunnel userTunnel) { + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel); + + // 隧道转发需要更新链和远程服务 + if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { + R chainResult = updateChainService(nodeInfo.getInNode(), serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol()); + if (chainResult.getCode() != 0) { + updateForwardStatusToError(forward); + return chainResult; + } + + R remoteResult = updateRemoteService(nodeInfo.getOutNode(), serviceName, forward, tunnel.getProtocol()); + if (remoteResult.getCode() != 0) { + updateForwardStatusToError(forward); + return remoteResult; + } + } + + // 更新主服务 + R serviceResult = updateMainService(nodeInfo.getInNode(), serviceName, forward, limiter, tunnel.getType(), tunnel, forward.getStrategy()); + if (serviceResult.getCode() != 0) { + updateForwardStatusToError(forward); + return serviceResult; + } + + return R.ok(); + } + + /** + * 隧道变化时更新Gost服务:先删除原配置,再创建新配置 + */ + private R updateGostServicesWithTunnelChange(Forward existForward, Forward updatedForward, Tunnel newTunnel, + Integer limiter, NodeInfo nodeInfo, UserTunnel userTunnel) { + // 1. 获取原隧道信息 + Tunnel oldTunnel = tunnelService.getById(existForward.getTunnelId()); + if (oldTunnel == null) { + return R.err("原隧道不存在,无法删除旧配置"); + } + + // 2. 删除原有的Gost服务配置 + R deleteResult = deleteOldGostServices(existForward, oldTunnel); + if (deleteResult.getCode() != 0) { + // 删除失败时记录日志,但不影响后续创建(可能原配置已不存在) + log.warn("删除原隧道{}的Gost配置失败: {}", oldTunnel.getId(), deleteResult.getMsg()); + } + + // 3. 创建新的Gost服务配置 + R createResult = createGostServices(updatedForward, newTunnel, limiter, nodeInfo, userTunnel); + if (createResult.getCode() != 0) { + updateForwardStatusToError(updatedForward); + return R.err("创建新隧道配置失败: " + createResult.getMsg()); + } + + return R.ok(); + } + + /** + * 删除原有的Gost服务(隧道变化时专用) + */ + private R deleteOldGostServices(Forward forward, Tunnel oldTunnel) { + // 获取原隧道的用户隧道关系 + UserTunnel oldUserTunnel = getUserTunnel(forward.getUserId(), oldTunnel.getId().intValue()); + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), oldUserTunnel); + + // 获取原隧道的节点信息 + NodeInfo oldNodeInfo = getRequiredNodes(oldTunnel); + + // 删除主服务(使用原隧道的入口节点) + if (!oldNodeInfo.isHasError() && oldNodeInfo.getInNode() != null) { + GostDto serviceResult = GostUtil.DeleteService(oldNodeInfo.getInNode().getId(), serviceName); + if (!isGostOperationSuccess(serviceResult)) { + log.warn("删除主服务失败: {}", serviceResult.getMsg()); + } + } + + // 如果原隧道是隧道转发类型,需要删除链和远程服务 + if (oldTunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { + // 删除链服务 + if (!oldNodeInfo.isHasError() && oldNodeInfo.getInNode() != null) { + GostDto chainResult = GostUtil.DeleteChains(oldNodeInfo.getInNode().getId(), serviceName); + if (!isGostOperationSuccess(chainResult)) { + log.warn("删除链服务失败: {}", chainResult.getMsg()); + } + } + + // 删除远程服务(即使节点信息获取失败,也要尝试删除) + Node outNode = null; + if (!oldNodeInfo.isHasError()) { + outNode = oldNodeInfo.getOutNode(); + } else { + // 即使获取节点信息失败,也尝试直接获取出口节点来删除远程服务 + outNode = nodeService.getNodeById(oldTunnel.getOutNodeId()); + } + + if (outNode != null) { + GostDto remoteResult = GostUtil.DeleteRemoteService(outNode.getId(), serviceName); + if (!isGostOperationSuccess(remoteResult)) { + log.warn("删除远程服务失败: {}", remoteResult.getMsg()); + } + } + } + + return R.ok(); + } + + /** + * 删除Gost服务 + */ + private R deleteGostServices(Forward forward, Tunnel tunnel, NodeInfo nodeInfo, UserTunnel userTunnel) { + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel); + + // 删除主服务 + GostDto serviceResult = GostUtil.DeleteService(nodeInfo.getInNode().getId(), serviceName); + if (!isGostOperationSuccess(serviceResult)) { + return R.err(serviceResult.getMsg()); + } + + // 隧道转发需要删除链和远程服务 + if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { + GostDto chainResult = GostUtil.DeleteChains(nodeInfo.getInNode().getId(), serviceName); + if (!isGostOperationSuccess(chainResult)) { + return R.err(chainResult.getMsg()); + } + + if (nodeInfo.getOutNode() != null) { + GostDto remoteResult = GostUtil.DeleteRemoteService(nodeInfo.getOutNode().getId(), serviceName); + if (!isGostOperationSuccess(remoteResult)) { + return R.err(remoteResult.getMsg()); + } + } + } + + return R.ok(); + } + + /** + * 创建链服务 + */ + private R createChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) { + String remoteAddr = outIp + ":" + outPort; + if (outIp.contains(":")) { + remoteAddr = "[" + outIp + "]:" + outPort; + } + GostDto result = GostUtil.AddChains(inNode.getId(), serviceName, remoteAddr, protocol); + return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); + } + + /** + * 创建远程服务 + */ + private R createRemoteService(Node outNode, String serviceName, Forward forward, String protocol) { + GostDto result = GostUtil.AddRemoteService(outNode.getId(), serviceName, forward.getOutPort(), forward.getRemoteAddr(), protocol, forward.getStrategy()); + return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); + } + + /** + * 创建主服务 + */ + private R createMainService(Node inNode, String serviceName, Forward forward, Integer limiter, Integer tunnelType, Tunnel tunnel, String strategy) { + GostDto result = GostUtil.AddService(inNode.getId(), serviceName, forward.getInPort(), limiter, forward.getRemoteAddr(), tunnelType, tunnel, strategy); + return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); + } + + /** + * 更新链服务 + */ + private R updateChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) { + // 创建新链 + String remoteAddr = outIp + ":" + outPort; + if (outIp.contains(":")) { + remoteAddr = "[" + outIp + "]:" + outPort; + } + GostDto createResult = GostUtil.UpdateChains(inNode.getId(), serviceName, remoteAddr, protocol); + if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) { + createResult = GostUtil.AddChains(inNode.getId(), serviceName, remoteAddr, protocol); + } + return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg()); + } + + /** + * 更新远程服务 + */ + private R updateRemoteService(Node outNode, String serviceName, Forward forward, String protocol) { + // 创建新远程服务 + GostDto createResult = GostUtil.UpdateRemoteService(outNode.getId(), + serviceName, forward.getOutPort(), + forward.getRemoteAddr(), protocol, forward.getStrategy()); + if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) { + createResult = GostUtil.AddRemoteService(outNode.getId(), + serviceName, forward.getOutPort(), + forward.getRemoteAddr(),protocol, forward.getStrategy()); + } + return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg()); + } + + /** + * 更新主服务 + */ + private R updateMainService(Node inNode, String serviceName, Forward forward, Integer limiter, Integer tunnelType, Tunnel tunnel, String strategy) { + GostDto result = GostUtil.UpdateService(inNode.getId(), serviceName, forward.getInPort(), limiter, forward.getRemoteAddr(), tunnelType, tunnel, strategy); + + if (result.getMsg().contains(GOST_NOT_FOUND_MSG)) { + result = GostUtil.AddService(inNode.getId(), serviceName, forward.getInPort(), limiter, forward.getRemoteAddr(), tunnelType, tunnel, strategy); + } + + return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg()); + } + + /** + * 更新转发状态为错误 + */ + private void updateForwardStatusToError(Forward forward) { + forward.setStatus(FORWARD_STATUS_ERROR); + this.updateById(forward); + } + + /** + * 获取用户隧道关系 + */ + private UserTunnel getUserTunnel(Integer userId, Integer tunnelId) { + return userTunnelService.getOne(new QueryWrapper() + .eq("user_id", userId) + .eq("tunnel_id", tunnelId)); + } + + /** + * 检查隧道是否发生变化 + */ + private boolean isTunnelChanged(Forward existForward, ForwardUpdateDto updateDto) { + return !existForward.getTunnelId().equals(updateDto.getTunnelId()); + } + + /** + * 归还用户转发配额 + */ + private void returnUserForwardQuota(UserInfo currentUser) { + if (currentUser.getRoleId() != ADMIN_ROLE_ID) { + User user = userService.getById(currentUser.getUserId()); + if (user != null) { + user.setNum(user.getNum() + 1); + user.setUpdatedTime(System.currentTimeMillis()); + userService.updateById(user); + } + } + } + + /** + * 检查Gost操作是否成功 + */ + private boolean isGostOperationSuccess(GostDto gostResult) { + return Objects.equals(gostResult.getMsg(), GOST_SUCCESS_MSG); + } + + + /** + * 检查指定的入口端口是否可用(可排除指定的转发ID) + */ + private boolean isInPortAvailable(Tunnel tunnel, Integer port, Long excludeForwardId) { + // 获取入口节点信息 + Node inNode = nodeService.getNodeById(tunnel.getInNodeId()); + if (inNode == null) { + return false; + } + + // 检查端口是否在节点允许的范围内 + if (port < inNode.getPortSta() || port > inNode.getPortEnd()) { + return false; + } + + // 获取该节点上所有已被占用的端口(包括作为入口和出口使用的端口) + Set usedPorts = getAllUsedPortsOnNode(tunnel.getInNodeId(), excludeForwardId); + + // 检查端口是否已被占用(在节点级别检查,考虑入口和出口端口) + return !usedPorts.contains(port); + } + + /** + * 为隧道分配一个可用的入口端口(可排除指定的转发ID) + */ + private Integer allocateInPort(Tunnel tunnel, Long excludeForwardId) { + return allocatePortForNode(tunnel.getInNodeId(), excludeForwardId); + } + + /** + * 为隧道分配一个可用的出口端口(可排除指定的转发ID) + */ + private Integer allocateOutPort(Tunnel tunnel, Long excludeForwardId) { + return allocatePortForNode(tunnel.getOutNodeId(), excludeForwardId); + } + + /** + * 为指定节点分配一个可用端口(通用方法) + * + * @param nodeId 节点ID + * @param excludeForwardId 要排除的转发ID + * @return 可用端口号,如果没有可用端口则返回null + */ + private Integer allocatePortForNode(Long nodeId, Long excludeForwardId) { + // 获取节点信息 + Node node = nodeService.getNodeById(nodeId); + if (node == null) { + return null; + } + + // 获取该节点上所有已被占用的端口(包括作为入口和出口使用的端口) + Set usedPorts = getAllUsedPortsOnNode(nodeId, excludeForwardId); + + // 在节点端口范围内寻找未使用的端口 + for (int port = node.getPortSta(); port <= node.getPortEnd(); port++) { + if (!usedPorts.contains(port)) { + return port; + } + } + return null; + } + + /** + * 获取指定节点上所有已被占用的端口(包括入口和出口端口) + * + * @param nodeId 节点ID + * @param excludeForwardId 要排除的转发ID + * @return 已占用的端口集合 + */ + private Set getAllUsedPortsOnNode(Long nodeId, Long excludeForwardId) { + Set usedPorts = new HashSet<>(); + + // 1. 收集该节点作为入口时占用的端口 + List inTunnels = tunnelService.list(new QueryWrapper().eq("in_node_id", nodeId)); + if (!inTunnels.isEmpty()) { + Set inTunnelIds = inTunnels.stream() + .map(Tunnel::getId) + .collect(Collectors.toSet()); + + QueryWrapper inQueryWrapper = new QueryWrapper().in("tunnel_id", inTunnelIds); + if (excludeForwardId != null) { + inQueryWrapper.ne("id", excludeForwardId); + } + + List inForwards = this.list(inQueryWrapper); + for (Forward forward : inForwards) { + if (forward.getInPort() != null) { + usedPorts.add(forward.getInPort()); + } + } + } + + // 2. 收集该节点作为出口时占用的端口 + List outTunnels = tunnelService.list(new QueryWrapper().eq("out_node_id", nodeId)); + if (!outTunnels.isEmpty()) { + Set outTunnelIds = outTunnels.stream() + .map(Tunnel::getId) + .collect(Collectors.toSet()); + + QueryWrapper outQueryWrapper = new QueryWrapper().in("tunnel_id", outTunnelIds); + if (excludeForwardId != null) { + outQueryWrapper.ne("id", excludeForwardId); + } + + List outForwards = this.list(outQueryWrapper); + for (Forward forward : outForwards) { + if (forward.getOutPort() != null) { + usedPorts.add(forward.getOutPort()); + } + } + } + + return usedPorts; + } + + + /** + * 构建服务名称,优化后减少重复查询 + */ + private String buildServiceName(Long forwardId, Integer userId, UserTunnel userTunnel) { + int userTunnelId = (userTunnel != null) ? userTunnel.getId() : 0; + return forwardId + "_" + userId + "_" + userTunnelId; + } + + // ========== 内部数据类 ========== + + /** + * 用户信息封装类 + */ + @Data + private static class UserInfo { + private final Integer userId; + private final Integer roleId; + private final String userName; + } + + /** + * 用户权限检查结果 + */ + @Data + private static class UserPermissionResult { + private final boolean hasError; + private final String errorMessage; + private final Integer limiter; + private final UserTunnel userTunnel; + + private UserPermissionResult(boolean hasError, String errorMessage, Integer limiter, UserTunnel userTunnel) { + this.hasError = hasError; + this.errorMessage = errorMessage; + this.limiter = limiter; + this.userTunnel = userTunnel; + } + + public static UserPermissionResult success(Integer limiter, UserTunnel userTunnel) { + return new UserPermissionResult(false, null, limiter, userTunnel); + } + + public static UserPermissionResult error(String errorMessage) { + return new UserPermissionResult(true, errorMessage, null, null); + } + } + + /** + * 端口分配结果 + */ + @Data + private static class PortAllocation { + private final boolean hasError; + private final String errorMessage; + private final Integer inPort; + private final Integer outPort; + + private PortAllocation(boolean hasError, String errorMessage, Integer inPort, Integer outPort) { + this.hasError = hasError; + this.errorMessage = errorMessage; + this.inPort = inPort; + this.outPort = outPort; + } + + public static PortAllocation success(Integer inPort, Integer outPort) { + return new PortAllocation(false, null, inPort, outPort); + } + + public static PortAllocation error(String errorMessage) { + return new PortAllocation(true, errorMessage, null, null); + } + } + + /** + * 节点信息封装类 + */ + @Data + private static class NodeInfo { + private final boolean hasError; + private final String errorMessage; + private final Node inNode; + private final Node outNode; + + private NodeInfo(boolean hasError, String errorMessage, Node inNode, Node outNode) { + this.hasError = hasError; + this.errorMessage = errorMessage; + this.inNode = inNode; + this.outNode = outNode; + } + + public static NodeInfo success(Node inNode, Node outNode) { + return new NodeInfo(false, null, inNode, outNode); + } + + public static NodeInfo error(String errorMessage) { + return new NodeInfo(true, errorMessage, null, null); + } + } +} diff --git a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java index 5f59e87..2ac7008 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java @@ -54,6 +54,12 @@ public class NodeServiceImpl extends ServiceImpl implements No /** 隧道使用检查相关消息 */ private static final String ERROR_IN_NODE_IN_USE = "该节点还有 %d 个隧道作为入口节点在使用,请先删除相关隧道"; private static final String ERROR_OUT_NODE_IN_USE = "该节点还有 %d 个隧道作为出口节点在使用,请先删除相关隧道"; + + /** 端口范围验证相关消息 */ + private static final String ERROR_PORT_STA_REQUIRED = "起始端口不能为空"; + private static final String ERROR_PORT_END_REQUIRED = "结束端口不能为空"; + private static final String ERROR_PORT_RANGE_INVALID = "端口必须在1-65535范围内"; + private static final String ERROR_PORT_ORDER_INVALID = "结束端口不能小于起始端口"; // ========== 依赖注入 ========== @@ -188,6 +194,9 @@ public class NodeServiceImpl extends ServiceImpl implements No Node node = new Node(); BeanUtils.copyProperties(nodeDto, node); + // 验证端口范围 + validatePortRange(node.getPortSta(), node.getPortEnd()); + // 设置默认属性 node.setSecret(IdUtil.simpleUUID()); node.setStatus(NODE_STATUS_ACTIVE); @@ -212,6 +221,12 @@ public class NodeServiceImpl extends ServiceImpl implements No node.setName(nodeUpdateDto.getName()); node.setIp(nodeUpdateDto.getIp()); node.setServerIp(nodeUpdateDto.getServerIp()); + node.setPortSta(nodeUpdateDto.getPortSta()); + node.setPortEnd(nodeUpdateDto.getPortEnd()); + + // 验证端口范围 + validatePortRange(node.getPortSta(), node.getPortEnd()); + node.setUpdatedTime(System.currentTimeMillis()); return node; } @@ -386,4 +401,33 @@ public class NodeServiceImpl extends ServiceImpl implements No long colonCount = address.chars().filter(ch -> ch == ':').count(); return colonCount >= 2; } + + /** + * 验证端口范围的有效性 + * + * @param portSta 起始端口 + * @param portEnd 结束端口 + * @throws RuntimeException 当端口范围无效时抛出异常 + */ + private void validatePortRange(Integer portSta, Integer portEnd) { + // 检查起始端口是否为空 + if (portSta == null) { + throw new RuntimeException(ERROR_PORT_STA_REQUIRED); + } + + // 检查结束端口是否为空 + if (portEnd == null) { + throw new RuntimeException(ERROR_PORT_END_REQUIRED); + } + + // 检查端口范围是否在有效区间内 + if (portSta < 1 || portSta > 65535 || portEnd < 1 || portEnd > 65535) { + throw new RuntimeException(ERROR_PORT_RANGE_INVALID); + } + + // 检查端口顺序是否正确 + if (portEnd < portSta) { + throw new RuntimeException(ERROR_PORT_ORDER_INVALID); + } + } } 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 e5a6b64..b8710f7 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 @@ -181,25 +181,11 @@ public class TunnelServiceImpl extends ServiceImpl impleme return nameValidationResult; } - // 3. 验证端口范围 - R portValidationResult = validatePortRanges(tunnelUpdateDto); - if (portValidationResult.getCode() != 0) { - return portValidationResult; - } - // 4. 验证隧道转发类型的出口端口 - if (existingTunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - R tunnelTypeValidationResult = validateTunnelForwardUpdate(tunnelUpdateDto); - if (tunnelTypeValidationResult.getCode() != 0) { - return tunnelTypeValidationResult; - } - } // 5. 更新允许修改的字段 existingTunnel.setName(tunnelUpdateDto.getName()); existingTunnel.setFlow(tunnelUpdateDto.getFlow()); - existingTunnel.setInPortSta(tunnelUpdateDto.getInPortSta()); - existingTunnel.setInPortEnd(tunnelUpdateDto.getInPortEnd()); // 更新流量倍率 if (tunnelUpdateDto.getTrafficRatio() != null) { @@ -213,14 +199,7 @@ public class TunnelServiceImpl extends ServiceImpl impleme if (StrUtil.isNotBlank(tunnelUpdateDto.getUdpListenAddr())) { existingTunnel.setUdpListenAddr(tunnelUpdateDto.getUdpListenAddr()); } - - if (existingTunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) { - existingTunnel.setOutIpSta(tunnelUpdateDto.getOutIpSta()); - existingTunnel.setOutIpEnd(tunnelUpdateDto.getOutIpEnd()); - }else{ - existingTunnel.setOutIpSta(tunnelUpdateDto.getInPortSta()); - existingTunnel.setOutIpEnd(tunnelUpdateDto.getInPortEnd()); - } + // 6. 保存更新 boolean result = this.updateById(existingTunnel); @@ -315,51 +294,11 @@ public class TunnelServiceImpl extends ServiceImpl impleme return R.ok(); } - /** - * 验证端口范围 - * - * @param tunnelUpdateDto 更新数据传输对象 - * @return 验证结果响应 - */ - private R validatePortRanges(TunnelUpdateDto tunnelUpdateDto) { - // 验证入口端口范围 - if (tunnelUpdateDto.getInPortSta() > tunnelUpdateDto.getInPortEnd()) { - return R.err(ERROR_IN_PORT_RANGE_INVALID); - } - - // 验证出口端口范围(如果有的话) - if (tunnelUpdateDto.getOutIpSta() != null && tunnelUpdateDto.getOutIpEnd() != null) { - if (tunnelUpdateDto.getOutIpSta() > tunnelUpdateDto.getOutIpEnd()) { - return R.err(ERROR_OUT_PORT_RANGE_INVALID); - } - } - - return R.ok(); - } - /** - * 验证隧道转发更新时的出口端口 - * - * @param tunnelUpdateDto 更新数据传输对象 - * @return 验证结果响应 - */ - private R validateTunnelForwardUpdate(TunnelUpdateDto tunnelUpdateDto) { - // 验证出口端口参数不能为空 - if (tunnelUpdateDto.getOutIpSta() == null || tunnelUpdateDto.getOutIpEnd() == null) { - return R.err(ERROR_OUT_PORT_REQUIRED); - } - - // 验证出口端口范围 - if (tunnelUpdateDto.getOutIpSta() > tunnelUpdateDto.getOutIpEnd()) { - return R.err(ERROR_OUT_PORT_RANGE_INVALID); - } - - return R.ok(); - } /** * 验证隧道转发创建时的必要参数 - * + * * @param tunnelDto 隧道创建数据传输对象 * @return 验证结果响应 */ @@ -368,17 +307,6 @@ public class TunnelServiceImpl extends ServiceImpl impleme if (tunnelDto.getOutNodeId() == null) { return R.err(ERROR_OUT_NODE_REQUIRED); } - - // 验证出口端口参数不能为空 - if (tunnelDto.getOutIpSta() == null || tunnelDto.getOutIpEnd() == null) { - return R.err(ERROR_OUT_PORT_REQUIRED); - } - - // 验证出口端口范围 - if (tunnelDto.getOutIpSta() > tunnelDto.getOutIpEnd()) { - return R.err(ERROR_OUT_PORT_RANGE_INVALID); - } - return R.ok(); } @@ -400,11 +328,6 @@ public class TunnelServiceImpl extends ServiceImpl impleme return NodeValidationResult.error(ERROR_IN_NODE_OFFLINE); } - // 验证入口端口范围 - if (tunnelDto.getInPortSta() > tunnelDto.getInPortEnd()) { - return NodeValidationResult.error(ERROR_IN_PORT_RANGE_INVALID); - } - return NodeValidationResult.success(inNode); } @@ -479,8 +402,6 @@ public class TunnelServiceImpl extends ServiceImpl impleme private R setupPortForwardOutParameters(Tunnel tunnel, TunnelDto tunnelDto, String server_ip) { tunnel.setOutNodeId(tunnelDto.getInNodeId()); tunnel.setOutIp(server_ip); - tunnel.setOutIpSta(tunnelDto.getInPortSta()); - tunnel.setOutIpEnd(tunnelDto.getInPortEnd()); return R.ok(); } @@ -519,15 +440,6 @@ public class TunnelServiceImpl extends ServiceImpl impleme if (outNode.getStatus() != NODE_STATUS_ONLINE) { return R.err(ERROR_OUT_NODE_OFFLINE); } - - // 验证出口端口参数 - if (tunnelDto.getOutIpSta() == null || tunnelDto.getOutIpEnd() == null) { - return R.err(ERROR_OUT_PORT_REQUIRED); - } - if (tunnelDto.getOutIpSta() > tunnelDto.getOutIpEnd()) { - return R.err(ERROR_OUT_PORT_RANGE_INVALID); - } - // 设置出口参数 tunnel.setOutNodeId(tunnelDto.getOutNodeId()); tunnel.setOutIp(outNode.getServerIp()); @@ -684,13 +596,18 @@ public class TunnelServiceImpl extends ServiceImpl impleme dto.setId(tunnel.getId().intValue()); dto.setName(tunnel.getName()); dto.setIp(tunnel.getInIp()); - dto.setInPortSta(tunnel.getInPortSta()); - dto.setInPortEnd(tunnel.getInPortEnd()); - dto.setOutIp(tunnel.getOutIp()); - dto.setOutIpSta(tunnel.getOutIpSta()); - dto.setOutIpEnd(tunnel.getOutIpEnd()); dto.setType(tunnel.getType()); dto.setProtocol(tunnel.getProtocol()); + + // 获取入口节点的端口范围信息 + if (tunnel.getInNodeId() != null) { + Node inNode = nodeService.getById(tunnel.getInNodeId()); + if (inNode != null) { + dto.setInNodePortSta(inNode.getPortSta()); + dto.setInNodePortEnd(inNode.getPortEnd()); + } + } + return dto; } diff --git a/springboot-backend/src/main/resources/mapper/ForwardMapper.xml b/springboot-backend/src/main/resources/mapper/ForwardMapper.xml index 098d2b9..4192739 100644 --- a/springboot-backend/src/main/resources/mapper/ForwardMapper.xml +++ b/springboot-backend/src/main/resources/mapper/ForwardMapper.xml @@ -21,11 +21,7 @@ f.out_flow AS outFlow, t.name AS tunnelName, t.in_ip AS inIp, - t.in_port_sta AS inPortSta, - t.in_port_end AS inPortEnd, t.out_ip AS outIp, - t.out_ip_sta AS outIpSta, - t.out_ip_end AS outIpEnd, t.type, t.protocol FROM @@ -55,11 +51,7 @@ f.strategy AS strategy, t.name AS tunnelName, t.in_ip AS inIp, - t.in_port_sta AS inPortSta, - t.in_port_end AS inPortEnd, t.out_ip AS outIp, - t.out_ip_sta AS outIpSta, - t.out_ip_end AS outIpEnd, t.type, t.protocol FROM diff --git a/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml b/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml index 598cdfd..7d149ea 100644 --- a/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml +++ b/springboot-backend/src/main/resources/mapper/UserTunnelMapper.xml @@ -19,11 +19,7 @@ t.name as tunnelName, t.flow as tunnelFlow, t.in_ip as inIp, - t.in_port_sta as inPortSta, - t.in_port_end as inPortEnd, t.out_ip as outIp, - t.out_ip_sta as outIpSta, - t.out_ip_end as outIpEnd, t.type, t.protocol, sl.name as speedLimitName, diff --git a/vite-frontend/.env.development b/vite-frontend/.env.development new file mode 100644 index 0000000..f4b3b65 --- /dev/null +++ b/vite-frontend/.env.development @@ -0,0 +1 @@ +VITE_API_BASE=http://localhost:6365 diff --git a/vite-frontend/.env.production b/vite-frontend/.env.production new file mode 100644 index 0000000..a41b3e9 --- /dev/null +++ b/vite-frontend/.env.production @@ -0,0 +1 @@ +VITE_API_BASE= diff --git a/vite-frontend/.gitignore b/vite-frontend/.gitignore new file mode 100644 index 0000000..3d27651 --- /dev/null +++ b/vite-frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +pnpm-lock.yaml +yarn.lock +package-lock.json +bun.lockb \ No newline at end of file diff --git a/vite-frontend/.npmrc b/vite-frontend/.npmrc new file mode 100644 index 0000000..1e54ebc --- /dev/null +++ b/vite-frontend/.npmrc @@ -0,0 +1 @@ +package-lock=true \ No newline at end of file diff --git a/vite-frontend/LICENSE b/vite-frontend/LICENSE new file mode 100644 index 0000000..d3006aa --- /dev/null +++ b/vite-frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Next UI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/vite-frontend/README.md b/vite-frontend/README.md new file mode 100644 index 0000000..30476e8 --- /dev/null +++ b/vite-frontend/README.md @@ -0,0 +1,50 @@ +# Vite & HeroUI Template + +This is a template for creating applications using Vite and HeroUI (v2). + +[Try it on CodeSandbox](https://githubbox.com/frontio-ai/vite-template) + +## Technologies Used + +- [Vite](https://vitejs.dev/guide/) +- [HeroUI](https://heroui.com) +- [Tailwind CSS](https://tailwindcss.com) +- [Tailwind Variants](https://tailwind-variants.org) +- [TypeScript](https://www.typescriptlang.org) +- [Framer Motion](https://www.framer.com/motion) + +## How to Use + +To clone the project, run the following command: + +```bash +git clone https://github.com/frontio-ai/vite-template.git +``` + +### Install dependencies + +You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`: + +```bash +npm install +``` + +### Run the development server + +```bash +npm run dev +``` + +### Setup pnpm (optional) + +If you are using `pnpm`, you need to add the following code to your `.npmrc` file: + +```bash +public-hoist-pattern[]=*@heroui/* +``` + +After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly. + +## License + +Licensed under the [MIT license](https://github.com/frontio-ai/vite-template/blob/main/LICENSE). diff --git a/vite-frontend/eslint.config.mjs b/vite-frontend/eslint.config.mjs new file mode 100644 index 0000000..6b26484 --- /dev/null +++ b/vite-frontend/eslint.config.mjs @@ -0,0 +1,175 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineConfig, globalIgnores } from "eslint/config"; +import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; +import react from "eslint-plugin-react"; +import unusedImports from "eslint-plugin-unused-imports"; +import _import from "eslint-plugin-import"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import jsxA11Y from "eslint-plugin-jsx-a11y"; +import prettier from "eslint-plugin-prettier"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default defineConfig([ + globalIgnores([ + ".now/*", + "**/*.css", + "**/.changeset", + "**/dist", + "esm/*", + "public/*", + "tests/*", + "scripts/*", + "**/*.config.js", + "**/.DS_Store", + "**/node_modules", + "**/coverage", + "**/.next", + "**/build", + "!**/.commitlintrc.cjs", + "!**/.lintstagedrc.cjs", + "!**/jest.config.js", + "!**/plopfile.js", + "!**/react-shim.js", + "!**/tsup.config.ts", + ]), + { + extends: fixupConfigRules( + compat.extends( + "plugin:react/recommended", + "plugin:prettier/recommended", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ), + ), + + plugins: { + react: fixupPluginRules(react), + "unused-imports": unusedImports, + import: fixupPluginRules(_import), + "@typescript-eslint": typescriptEslint, + "jsx-a11y": fixupPluginRules(jsxA11Y), + prettier: fixupPluginRules(prettier), + }, + + languageOptions: { + globals: { + ...Object.fromEntries( + Object.entries(globals.browser).map(([key]) => [key, "off"]), + ), + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 12, + sourceType: "module", + + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + + settings: { + react: { + version: "detect", + }, + }, + + + files: ["**/*.ts", "**/*.tsx"], + + rules: { + "no-console": "warn", + "react/prop-types": "off", + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off", + "react-hooks/exhaustive-deps": "off", + "jsx-a11y/click-events-have-key-events": "warn", + "jsx-a11y/interactive-supports-focus": "warn", + "prettier/prettier": "warn", + "no-unused-vars": "off", + "unused-imports/no-unused-vars": "off", + "unused-imports/no-unused-imports": "warn", + + "@typescript-eslint/no-unused-vars": [ + "warn", + { + args: "after-used", + ignoreRestSiblings: false, + argsIgnorePattern: "^_.*?$", + }, + ], + + "import/order": [ + "warn", + { + groups: [ + "type", + "builtin", + "object", + "external", + "internal", + "parent", + "sibling", + "index", + ], + + pathGroups: [ + { + pattern: "~/**", + group: "external", + position: "after", + }, + ], + + "newlines-between": "always", + }, + ], + + "react/self-closing-comp": "warn", + + "react/jsx-sort-props": [ + "warn", + { + callbacksLast: true, + shorthandFirst: true, + noSortAlphabetically: false, + reservedFirst: true, + }, + ], + + "padding-line-between-statements": [ + "warn", + { + blankLine: "always", + prev: "*", + next: "return", + }, + { + blankLine: "always", + prev: ["const", "let", "var"], + next: "*", + }, + { + blankLine: "any", + prev: ["const", "let", "var"], + next: ["const", "let", "var"], + }, + ], + }, + }, +]); diff --git a/vite-frontend/favicon.ico b/vite-frontend/favicon.ico new file mode 100644 index 0000000..e38d32a Binary files /dev/null and b/vite-frontend/favicon.ico differ diff --git a/vite-frontend/index.html b/vite-frontend/index.html new file mode 100644 index 0000000..273f085 --- /dev/null +++ b/vite-frontend/index.html @@ -0,0 +1,70 @@ + + + + + + + GOST 管理后台 + + + + + + + + + +
+ + + diff --git a/vite-frontend/package.json b/vite-frontend/package.json new file mode 100644 index 0000000..01d66d2 --- /dev/null +++ b/vite-frontend/package.json @@ -0,0 +1,71 @@ +{ + "name": "vite-template", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint --fix", + "preview": "vite preview" + }, + "dependencies": { + "@heroui/alert": "^2.2.22", + "@heroui/avatar": "^2.2.18", + "@heroui/button": "2.2.22", + "@heroui/card": "^2.2.21", + "@heroui/chip": "^2.2.18", + "@heroui/divider": "^2.2.15", + "@heroui/dropdown": "^2.3.22", + "@heroui/input": "2.4.22", + "@heroui/link": "2.2.19", + "@heroui/modal": "^2.2.19", + "@heroui/navbar": "2.2.20", + "@heroui/select": "^2.4.22", + "@heroui/spacer": "^2.2.16", + "@heroui/spinner": "^2.2.19", + "@heroui/switch": "2.2.20", + "@heroui/system": "2.4.18", + "@heroui/table": "^2.2.21", + "@heroui/theme": "2.4.17", + "@heroui/use-theme": "2.1.9", + "@react-aria/visually-hidden": "3.8.25", + "@react-types/shared": "3.30.0", + "axios": "^1.10.0", + "clsx": "2.1.1", + "framer-motion": "11.15.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-hot-toast": "^2.5.2", + "react-router-dom": "6.23.0", + "tailwind-variants": "0.3.0", + "tailwindcss": "3.4.16" + }, + "devDependencies": { + "@eslint/compat": "1.2.8", + "@eslint/eslintrc": "3.3.1", + "@eslint/js": "9.25.1", + "@types/node": "20.5.7", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "@typescript-eslint/eslint-plugin": "8.31.1", + "@typescript-eslint/parser": "8.31.1", + "@vitejs/plugin-react": "4.4.1", + "autoprefixer": "10.4.21", + "eslint": "9.25.1", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-import": "2.31.0", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-node": "11.1.0", + "eslint-plugin-prettier": "5.2.1", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "5.2.0", + "eslint-plugin-unused-imports": "4.1.4", + "globals": "16.0.0", + "postcss": "8.5.3", + "prettier": "3.5.3", + "typescript": "5.6.3", + "vite": "5.2.0", + "vite-tsconfig-paths": "4.3.2" + } +} diff --git a/vite-frontend/postcss.config.js b/vite-frontend/postcss.config.js new file mode 100644 index 0000000..2c02c54 --- /dev/null +++ b/vite-frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + }; \ No newline at end of file diff --git a/vite-frontend/public/vite.svg b/vite-frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/vite-frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx new file mode 100644 index 0000000..564a61e --- /dev/null +++ b/vite-frontend/src/App.tsx @@ -0,0 +1,121 @@ +import { Route, Routes, Navigate, useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; + +import IndexPage from "@/pages/index"; +import ChangePasswordPage from "@/pages/change-password"; +import DashboardPage from "@/pages/dashboard"; +import ForwardPage from "@/pages/forward"; +import LimitPage from "@/pages/limit"; +import PageWrapper from "@/components/page-wrapper"; +import { isLoggedIn } from "@/utils/auth"; + +// 路由保护组件 - 使用useNavigate避免循环渲染 +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const [checking, setChecking] = useState(true); + const [authenticated, setAuthenticated] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + const checkAuth = () => { + const loggedIn = isLoggedIn(); + + if (!loggedIn) { + // 如果未登录,直接导航到登录页 + navigate('/', { replace: true }); + return; + } + + setAuthenticated(true); + setChecking(false); + }; + + checkAuth(); + }, [navigate]); + + if (checking) { + return ( +
+
检查登陆状态...
+
+ ); + } + + return authenticated ? <>{children} : null; +}; + +function App() { + return ( + + : + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + +
+
+ + } + /> + + +
+
+ + } + /> + + +
+
+ + } + /> + + + + } + /> +
+ ); +} + +export default App; diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts new file mode 100644 index 0000000..24b6003 --- /dev/null +++ b/vite-frontend/src/api/index.ts @@ -0,0 +1,66 @@ +import Network from './network'; + +// 登陆相关接口 +export interface LoginData { + username: string; + password: string; +} + +export interface LoginResponse { + token: string; + role_id: number; + name: string; + requirePasswordChange?: boolean; +} + +export const login = (data: LoginData) => Network.post("/user/login", data); + +// 用户CRUD操作 - 全部使用POST请求 +export const createUser = (data: any) => Network.post("/user/create", data); +export const getAllUsers = (pageData: any = {}) => Network.post("/user/list", pageData); +export const updateUser = (data: any) => Network.post("/user/update", data); +export const deleteUser = (id: number) => Network.post("/user/delete", { id }); +export const getUserPackageInfo = () => Network.post("/user/package"); + +// 节点CRUD操作 - 全部使用POST请求 +export const createNode = (data: any) => Network.post("/node/create", data); +export const getNodeList = () => Network.post("/node/list"); +export const updateNode = (data: any) => Network.post("/node/update", data); +export const deleteNode = (id: number) => Network.post("/node/delete", { id }); +export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id }); + +// 隧道CRUD操作 - 全部使用POST请求 +export const createTunnel = (data: any) => Network.post("/tunnel/create", data); +export const getTunnelList = () => Network.post("/tunnel/list"); +export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id }); +export const updateTunnel = (data: any) => Network.post("/tunnel/update", data); +export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); +export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId }); + +// 用户隧道权限管理操作 - 全部使用POST请求 +export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data); +export const getUserTunnelList = (queryData: any = {}) => Network.post("/tunnel/user/list", queryData); +export const removeUserTunnel = (params: any) => Network.post("/tunnel/user/remove", params); +export const updateUserTunnelFlow = (params: any) => Network.post("/tunnel/user/updateFlow", params); +export const updateUserTunnel = (data: any) => Network.post("/tunnel/user/update", data); +export const userTunnel = () => Network.post("/tunnel/user/tunnel"); + +// 转发CRUD操作 - 全部使用POST请求 +export const createForward = (data: any) => Network.post("/forward/create", data); +export const getForwardList = () => Network.post("/forward/list"); +export const updateForward = (data: any) => Network.post("/forward/update", data); +export const deleteForward = (id: number) => Network.post("/forward/delete", { id }); +export const forceDeleteForward = (id: number) => Network.post("/forward/force-delete", { id }); + +// 转发服务控制操作 - 通过Java后端接口 +export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId }); +export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId }); + +// 限速规则CRUD操作 - 全部使用POST请求 +export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data); +export const getSpeedLimitList = () => Network.post("/speed-limit/list"); +export const updateSpeedLimit = (data: any) => Network.post("/speed-limit/update", data); +export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id }); + +// 修改密码接口 +export const updatePassword = (data: any) => Network.post("/user/updatePassword", data); \ No newline at end of file diff --git a/vite-frontend/src/api/network.ts b/vite-frontend/src/api/network.ts new file mode 100644 index 0000000..985edd0 --- /dev/null +++ b/vite-frontend/src/api/network.ts @@ -0,0 +1,106 @@ +import axios, { AxiosResponse } from 'axios'; + +// 配置axios基础URL +const baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/'; +axios.defaults.baseURL = baseURL; + +// 在开发环境下输出API配置信息 +if (import.meta.env.DEV) { + console.log('🌐 API Configuration:'); + console.log(' - VITE_API_BASE:', import.meta.env.VITE_API_BASE || '(undefined)'); + console.log(' - Base URL:', baseURL); + console.log(' - Environment:', import.meta.env.MODE); +} + +interface ApiResponse { + code: number; + msg: string; + data: T; +} + +// 处理token失效的逻辑 +function handleTokenExpired() { + // 清除localStorage中的token + window.localStorage.removeItem('token'); + window.localStorage.removeItem('role_id'); + window.localStorage.removeItem('name'); + + // 跳转到登录页面 + if (window.location.pathname !== '/') { + window.location.href = '/'; + } +} + +// 检查响应是否为token失效 +function isTokenExpired(response: ApiResponse) { + return response && response.code === 401 && + (response.msg === '未登录或token已过期' || + response.msg === '无效的token或token已过期' || + response.msg === '无法获取用户权限信息'); +} + +const Network = { + get: function(path: string = '', data: any = {}): Promise> { + return new Promise(function(resolve) { + axios.get(path, { + params: data, + timeout: 30000, + headers: { + "Authorization": window.localStorage.getItem('token') + } + }) + .then(function(response: AxiosResponse>) { + // 检查是否token失效 + if (isTokenExpired(response.data)) { + handleTokenExpired(); + return; + } + resolve(response.data); + }) + .catch(function(error: any) { + console.error('GET请求错误:', error); + + // 检查是否是401错误(token失效) + if (error.response && error.response.status === 401) { + handleTokenExpired(); + return; + } + + resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T}); + }); + }); + }, + + post: function(path: string = '', data: any = {}): Promise> { + return new Promise(function(resolve) { + axios.post(path, data, { + timeout: 30000, + headers: { + "Authorization": window.localStorage.getItem('token'), + "Content-Type": "application/json" + } + }) + .then(function(response: AxiosResponse>) { + // 检查是否token失效 + if (isTokenExpired(response.data)) { + handleTokenExpired(); + return; + } + resolve(response.data); + }) + .catch(function(error: any) { + console.error('POST请求错误:', error); + + // 检查是否是401错误(token失效) + if (error.response && error.response.status === 401) { + handleTokenExpired(); + return; + } + + resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T}); + }); + }); + } +}; + +export default Network; \ No newline at end of file diff --git a/vite-frontend/src/components/icons.tsx b/vite-frontend/src/components/icons.tsx new file mode 100644 index 0000000..fe0dbfa --- /dev/null +++ b/vite-frontend/src/components/icons.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const Logo: React.FC = ({ + size = 36, + height, + ...props +}) => ( + + + +); + +export const MoonFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const SunFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); diff --git a/vite-frontend/src/components/navbar.tsx b/vite-frontend/src/components/navbar.tsx new file mode 100644 index 0000000..6ef9c2b --- /dev/null +++ b/vite-frontend/src/components/navbar.tsx @@ -0,0 +1,32 @@ +import { Link } from "@heroui/link"; +import { + Navbar as HeroUINavbar, + NavbarBrand, + NavbarContent, +} from "@heroui/navbar"; + +import { ThemeSwitch } from "@/components/theme-switch"; +import { Logo } from "@/components/icons"; + +export const Navbar = () => { + return ( + + + + + +

GOST

+ +
+
+ + + + +
+ ); +}; diff --git a/vite-frontend/src/components/page-wrapper.tsx b/vite-frontend/src/components/page-wrapper.tsx new file mode 100644 index 0000000..16b131b --- /dev/null +++ b/vite-frontend/src/components/page-wrapper.tsx @@ -0,0 +1,56 @@ +import React, { useState, useEffect } from 'react'; +import AdminLayout from '@/layouts/admin'; + +interface PageWrapperProps { + children: React.ReactNode; + title: string; + description?: string; + className?: string; +} + +export default function PageWrapper({ + children, + title, + description, + className = "container mx-auto max-w-7xl px-3 lg:px-6 py-8" +}: PageWrapperProps) { + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + // 使用短暂的延迟确保组件完全加载,避免闪烁 + const timer = setTimeout(() => { + setIsReady(true); + }, 50); + + return () => clearTimeout(timer); + }, []); + + if (!isReady) { + return ( + +
+
+
+
+ 正在加载页面... +
+
+
+
+ ); + } + + return ( + +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/vite-frontend/src/components/primitives.ts b/vite-frontend/src/components/primitives.ts new file mode 100644 index 0000000..5344b5f --- /dev/null +++ b/vite-frontend/src/components/primitives.ts @@ -0,0 +1,41 @@ +import { tv } from "tailwind-variants"; + +export const title = tv({ + base: "tracking-tight inline font-semibold", + variants: { + color: { + violet: "from-[#FF1CF7] to-[#b249f8]", + yellow: "from-[#FF705B] to-[#FFB457]", + blue: "from-[#5EA2EF] to-[#0072F5]", + cyan: "from-[#00b7fa] to-[#01cfea]", + green: "from-[#6FEE8D] to-[#17c964]", + pink: "from-[#FF72E1] to-[#F54C7A]", + foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]", + }, + size: { + sm: "text-3xl lg:text-4xl", + md: "text-[2.3rem] lg:text-5xl leading-9", + lg: "text-4xl lg:text-6xl", + }, + fullWidth: { + true: "w-full block", + }, + }, + defaultVariants: { + size: "md", + }, + compoundVariants: [ + { + color: [ + "violet", + "yellow", + "blue", + "cyan", + "green", + "pink", + "foreground", + ], + class: "bg-clip-text text-transparent bg-gradient-to-b", + }, + ], +}); diff --git a/vite-frontend/src/components/theme-provider.tsx b/vite-frontend/src/components/theme-provider.tsx new file mode 100644 index 0000000..b9a409c --- /dev/null +++ b/vite-frontend/src/components/theme-provider.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react'; +import { useTheme } from '@heroui/use-theme'; + +interface ThemeProviderProps { + children: React.ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const { theme, setTheme } = useTheme(); + + useEffect(() => { + // 确保主题与HTML class同步 + const updateThemeClass = (currentTheme: string) => { + if (currentTheme === 'dark') { + document.documentElement.classList.add('dark'); + document.documentElement.style.colorScheme = 'dark'; + } else { + document.documentElement.classList.remove('dark'); + document.documentElement.style.colorScheme = 'light'; + } + // 保存到localStorage + localStorage.setItem('heroui-theme', currentTheme); + }; + + // 初始化时检查localStorage + const savedTheme = localStorage.getItem('heroui-theme'); + if (savedTheme && savedTheme !== theme) { + setTheme(savedTheme); + } + + // 监听主题变化 + updateThemeClass(theme); + }, [theme, setTheme]); + + return <>{children}; +}; \ No newline at end of file diff --git a/vite-frontend/src/components/theme-switch.tsx b/vite-frontend/src/components/theme-switch.tsx new file mode 100644 index 0000000..e38858e --- /dev/null +++ b/vite-frontend/src/components/theme-switch.tsx @@ -0,0 +1,82 @@ +import { FC, useState, useEffect } from "react"; +import { VisuallyHidden } from "@react-aria/visually-hidden"; +import { SwitchProps, useSwitch } from "@heroui/switch"; +import clsx from "clsx"; +import { useTheme } from "@heroui/use-theme"; + +import { SunFilledIcon, MoonFilledIcon } from "@/components/icons"; + +export interface ThemeSwitchProps { + className?: string; + classNames?: SwitchProps["classNames"]; +} + +export const ThemeSwitch: FC = ({ + className, + classNames, +}) => { + const [isMounted, setIsMounted] = useState(false); + + const { theme, setTheme } = useTheme(); + + const { + Component, + slots, + isSelected, + getBaseProps, + getInputProps, + getWrapperProps, + } = useSwitch({ + isSelected: theme === "light", + onChange: () => setTheme(theme === "light" ? "dark" : "light"), + }); + + useEffect(() => { + setIsMounted(true); + }, [isMounted]); + + // Prevent Hydration Mismatch + if (!isMounted) return
; + + return ( + + + + +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts new file mode 100644 index 0000000..4c5f32b --- /dev/null +++ b/vite-frontend/src/config/site.ts @@ -0,0 +1,15 @@ +export type SiteConfig = typeof siteConfig; + +export const siteConfig = { + name: "GOST", + description: "GOST 代理管理系统", + navItems: [], + navMenuItems: [], + links: { + github: "https://github.com/go-gost/gost", + twitter: "", + docs: "", + discord: "", + sponsor: "", + }, +}; diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx new file mode 100644 index 0000000..6aa2d7c --- /dev/null +++ b/vite-frontend/src/layouts/admin.tsx @@ -0,0 +1,434 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { Button } from "@heroui/button"; +import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from "@heroui/dropdown"; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal"; +import { Input } from "@heroui/input"; +import { toast } from 'react-hot-toast'; + +import { Logo } from '@/components/icons'; +import { ThemeSwitch } from '@/components/theme-switch'; +import { updatePassword } from '@/api'; +import { safeLogout } from '@/utils/logout'; + +interface MenuItem { + path: string; + label: string; + icon: React.ReactNode; + adminOnly?: boolean; +} + +interface PasswordForm { + newUsername: string; + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const navigate = useNavigate(); + const location = useLocation(); + const { isOpen, onOpen, onOpenChange } = useDisclosure(); + + const [isMobile, setIsMobile] = useState(false); + const [mobileMenuVisible, setMobileMenuVisible] = useState(false); + const [username, setUsername] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordForm, setPasswordForm] = useState({ + newUsername: '', + currentPassword: '', + newPassword: '', + confirmPassword: '' + }); + + // 菜单项配置 + const menuItems: MenuItem[] = [ + { + path: '/dashboard', + label: '仪表板', + icon: ( + + + + ) + }, + { + path: '/forward', + label: '转发管理', + icon: ( + + + + ) + }, + { + path: '/tunnel', + label: '隧道管理', + icon: ( + + + + ), + adminOnly: true + }, + { + path: '/node', + label: '节点监控', + icon: ( + + + + ), + adminOnly: true + }, + { + path: '/user', + label: '用户管理', + icon: ( + + + + ), + adminOnly: true + }, + { + path: '/limit', + label: '限速管理', + icon: ( + + + + ), + adminOnly: true + } + ]; + + // 检查移动端 + const checkMobile = () => { + setIsMobile(window.innerWidth <= 768); + if (window.innerWidth > 768) { + setMobileMenuVisible(false); + } + }; + + useEffect(() => { + // 获取用户信息 + const name = localStorage.getItem('name') || 'Admin'; + + // 兼容处理:如果没有admin字段,根据role_id判断(0为管理员) + let adminFlag = localStorage.getItem('admin') === 'true'; + if (localStorage.getItem('admin') === null) { + const roleId = parseInt(localStorage.getItem('role_id') || '1', 10); + adminFlag = roleId === 0; + // 补充设置admin字段,避免下次再次判断 + localStorage.setItem('admin', adminFlag.toString()); + } + + setUsername(name); + setIsAdmin(adminFlag); + + // 响应式检查 + checkMobile(); + window.addEventListener('resize', checkMobile); + + return () => { + window.removeEventListener('resize', checkMobile); + }; + }, []); + + // 退出登录 + const handleLogout = () => { + safeLogout(); + navigate('/'); + }; + + // 切换移动端菜单 + const toggleMobileMenu = () => { + setMobileMenuVisible(!mobileMenuVisible); + }; + + // 隐藏移动端菜单 + const hideMobileMenu = () => { + setMobileMenuVisible(false); + }; + + // 菜单点击处理 + const handleMenuClick = (path: string) => { + navigate(path); + if (isMobile) { + hideMobileMenu(); + } + }; + + // 密码表单验证 + const validatePasswordForm = (): boolean => { + if (!passwordForm.newUsername.trim()) { + toast.error('请输入新用户名'); + return false; + } + if (passwordForm.newUsername.length < 3) { + toast.error('用户名长度至少3位'); + return false; + } + if (!passwordForm.currentPassword) { + toast.error('请输入当前密码'); + return false; + } + if (!passwordForm.newPassword) { + toast.error('请输入新密码'); + return false; + } + if (passwordForm.newPassword.length < 6) { + toast.error('新密码长度不能少于6位'); + return false; + } + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + toast.error('两次输入密码不一致'); + return false; + } + return true; + }; + + // 提交密码修改 + const handlePasswordSubmit = async () => { + if (!validatePasswordForm()) return; + + setPasswordLoading(true); + try { + const response = await updatePassword(passwordForm); + if (response.code === 0) { + toast.success('密码修改成功,请重新登录'); + onOpenChange(); + handleLogout(); + } else { + toast.error(response.msg || '密码修改失败'); + } + } catch (error) { + toast.error('修改密码时发生错误'); + console.error('修改密码错误:', error); + } finally { + setPasswordLoading(false); + } + }; + + // 重置密码表单 + const resetPasswordForm = () => { + setPasswordForm({ + newUsername: '', + currentPassword: '', + newPassword: '', + confirmPassword: '' + }); + }; + + // 过滤菜单项(根据权限) + const filteredMenuItems = menuItems.filter(item => + !item.adminOnly || isAdmin + ); + + return ( +
+ {/* 移动端遮罩层 */} + {isMobile && mobileMenuVisible && ( +
+ )} + + {/* 左侧菜单栏 */} + + + {/* 主内容区域 */} +
+ {/* 顶部导航栏 */} +
+
+ {/* 移动端菜单按钮 */} + {isMobile && ( + + )} +
+ +
+ + + {/* 用户菜单 */} + + + + + + + + + } + onPress={onOpen} + > + 修改密码 + + + + + } + className="text-danger" + color="danger" + onPress={handleLogout} + > + 退出登录 + + + +
+
+ + {/* 主内容 */} +
+ {children} +
+
+ + {/* 修改密码弹窗 */} + { + onOpenChange(); + resetPasswordForm(); + }} + size={isMobile ? 'full' : 'md'} + scrollBehavior="inside" + > + + {(onClose: () => void) => ( + <> + 修改密码 + +
+ ) => setPasswordForm(prev => ({ ...prev, newUsername: e.target.value }))} + variant="bordered" + /> + ) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))} + variant="bordered" + /> + ) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))} + variant="bordered" + /> + ) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))} + variant="bordered" + /> +
+
+ + + + + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/vite-frontend/src/layouts/default.tsx b/vite-frontend/src/layouts/default.tsx new file mode 100644 index 0000000..e07ddf3 --- /dev/null +++ b/vite-frontend/src/layouts/default.tsx @@ -0,0 +1,27 @@ +import { Link } from "@heroui/link"; + +import { Navbar } from "@/components/navbar"; + +export default function DefaultLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
+ {children} +
+
+ + +
+
+ ); +} diff --git a/vite-frontend/src/main.tsx b/vite-frontend/src/main.tsx new file mode 100644 index 0000000..7bbc79d --- /dev/null +++ b/vite-frontend/src/main.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; + +import App from "./App.tsx"; +import { Provider } from "./provider.tsx"; +import "@/styles/globals.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/vite-frontend/src/pages/change-password.tsx b/vite-frontend/src/pages/change-password.tsx new file mode 100644 index 0000000..da0ca90 --- /dev/null +++ b/vite-frontend/src/pages/change-password.tsx @@ -0,0 +1,200 @@ +import { Button } from "@heroui/button"; +import { Input } from "@heroui/input"; +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import toast from 'react-hot-toast'; + +import { title } from "@/components/primitives"; +import { updatePassword } from "@/api"; +import DefaultLayout from "@/layouts/default"; +import { safeLogout } from "@/utils/logout"; + +interface PasswordForm { + newUsername: string; + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export default function ChangePasswordPage() { + const [form, setForm] = useState({ + newUsername: '', + currentPassword: '', + newPassword: '', + confirmPassword: '' + }); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + const navigate = useNavigate(); + + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!form.newUsername.trim()) { + newErrors.newUsername = '请输入新用户名'; + } else if (form.newUsername.length < 3) { + newErrors.newUsername = '用户名长度至少3位'; + } else if (form.newUsername.length > 20) { + newErrors.newUsername = '用户名长度不能超过20位'; + } + + if (!form.currentPassword.trim()) { + newErrors.currentPassword = '请输入当前密码'; + } + + if (!form.newPassword.trim()) { + newErrors.newPassword = '请输入新密码'; + } else if (form.newPassword.length < 6) { + newErrors.newPassword = '新密码长度不能少于6位'; + } else if (form.newPassword.length > 20) { + newErrors.newPassword = '新密码长度不能超过20位'; + } + + if (!form.confirmPassword.trim()) { + newErrors.confirmPassword = '请再次输入新密码'; + } else if (form.confirmPassword !== form.newPassword) { + newErrors.confirmPassword = '两次输入密码不一致'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleInputChange = (field: keyof PasswordForm, value: string) => { + setForm(prev => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + const handleSubmit = async () => { + if (!validateForm()) return; + + setLoading(true); + try { + const response = await updatePassword(form); + + if (response.code === 0) { + toast.success(response.msg || '账号密码修改成功'); + + // 使用 toast 确认对话框的替代方案 + setTimeout(() => { + toast.success('即将跳转到登陆页面,请重新登录'); + setTimeout(() => { + logout(); + }, 1000); + }, 1000); + } else { + toast.error(response.msg || '账号密码修改失败'); + } + } catch (error) { + console.error('修改账号密码错误:', error); + toast.error('修改账号密码时发生错误'); + } finally { + setLoading(false); + } + }; + + const logout = () => { + safeLogout(); + navigate('/'); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !loading) { + handleSubmit(); + } + }; + + return ( + +
+
+ + +
+ + + +
+ +

安全提醒

+

检测到您使用的是默认账号密码,为了您的账户安全,请立即修改

+
+ + +
+ handleInputChange('newUsername', e.target.value)} + onKeyDown={handleKeyPress} + variant="bordered" + isDisabled={loading} + isInvalid={!!errors.newUsername} + errorMessage={errors.newUsername} + /> + + handleInputChange('currentPassword', e.target.value)} + onKeyDown={handleKeyPress} + variant="bordered" + isDisabled={loading} + isInvalid={!!errors.currentPassword} + errorMessage={errors.currentPassword} + /> + + handleInputChange('newPassword', e.target.value)} + onKeyDown={handleKeyPress} + variant="bordered" + isDisabled={loading} + isInvalid={!!errors.newPassword} + errorMessage={errors.newPassword} + /> + + handleInputChange('confirmPassword', e.target.value)} + onKeyDown={handleKeyPress} + variant="bordered" + isDisabled={loading} + isInvalid={!!errors.confirmPassword} + errorMessage={errors.confirmPassword} + /> + + + +
+ ⚠️ 注意:修改账号密码后需要重新登录 +
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx new file mode 100644 index 0000000..2280e6a --- /dev/null +++ b/vite-frontend/src/pages/dashboard.tsx @@ -0,0 +1,842 @@ +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Button } from "@heroui/button"; +import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal"; +import { useState, useEffect } from "react"; +import toast from 'react-hot-toast'; + +import AdminLayout from "@/layouts/admin"; +import { getUserPackageInfo } from "@/api"; + +interface UserInfo { + flow: number; + inFlow: number; + outFlow: number; + num: number; + expTime?: string; + flowResetTime?: number; +} + +interface UserTunnel { + id: number; + tunnelId: number; + tunnelName: string; + flow: number; + inFlow: number; + outFlow: number; + num: number; + expTime?: string; + flowResetTime?: number; + tunnelFlow: number; +} + +interface Forward { + id: number; + name: string; + tunnelId: number; + tunnelName: string; + inIp: string; + inPort: number; + remoteAddr: string; + inFlow: number; + outFlow: number; +} + +interface AddressItem { + id: number; + ip: string; + address: string; + copying: boolean; +} + +export default function DashboardPage() { + const [loading, setLoading] = useState(true); + const [userInfo, setUserInfo] = useState({} as UserInfo); + const [userTunnels, setUserTunnels] = useState([]); + const [forwardList, setForwardList] = useState([]); + + const [addressModalOpen, setAddressModalOpen] = useState(false); + const [addressModalTitle, setAddressModalTitle] = useState(''); + const [addressList, setAddressList] = useState([]); + + // 检查有效期通知 + const checkExpirationNotifications = (userInfo: UserInfo, tunnels: UserTunnel[]) => { + // 避免重复通知,检查是否已经显示过 + const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map(t => t.expTime).join(',')}`; + const lastNotified = localStorage.getItem('lastNotified'); + + if (lastNotified === notificationKey) { + return; // 已经通知过,不重复显示 + } + + let hasNotification = false; + + // 检查主账户有效期 + if (userInfo.expTime) { + const expDate = new Date(userInfo.expTime); + const now = new Date(); + + if (!isNaN(expDate.getTime()) && expDate > now) { + const diffTime = expDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays <= 7 && diffDays > 0) { + hasNotification = true; + if (diffDays === 1) { + toast('账户将于明天过期,请及时续费', { + icon: '⚠️', + duration: 6000, + style: { background: '#f59e0b', color: '#fff' } + }); + } else { + toast(`账户将于${diffDays}天后过期,请及时续费`, { + icon: '⚠️', + duration: 6000, + style: { background: '#f59e0b', color: '#fff' } + }); + } + } else if (diffDays <= 0) { + hasNotification = true; + toast('账户已过期,请立即续费', { + icon: '⚠️', + duration: 8000, + style: { background: '#ef4444', color: '#fff' } + }); + } + } + } + + // 检查隧道有效期 + tunnels.forEach(tunnel => { + if (tunnel.expTime) { + const expDate = new Date(tunnel.expTime); + const now = new Date(); + + if (!isNaN(expDate.getTime()) && expDate > now) { + const diffTime = expDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays <= 7 && diffDays > 0) { + hasNotification = true; + if (diffDays === 1) { + toast(`隧道"${tunnel.tunnelName}"将于明天过期`, { + icon: '⚠️', + duration: 5000, + style: { background: '#f59e0b', color: '#fff' } + }); + } else { + toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, { + icon: '⚠️', + duration: 5000, + style: { background: '#f59e0b', color: '#fff' } + }); + } + } else if (diffDays <= 0) { + hasNotification = true; + toast(`隧道"${tunnel.tunnelName}"已过期`, { + icon: '⚠️', + duration: 6000, + style: { background: '#ef4444', color: '#fff' } + }); + } + } + } + }); + + // 如果显示了通知,记录防止重复 + if (hasNotification) { + localStorage.setItem('lastNotified', notificationKey); + } + }; + + useEffect(() => { + // 重置状态并加载数据,防止页面切换时显示旧数据 + setLoading(true); + setUserInfo({} as UserInfo); + setUserTunnels([]); + setForwardList([]); + + loadPackageData(); + localStorage.setItem('e', '/dashboard'); + }, []); + + const loadPackageData = async () => { + setLoading(true); + try { + const res = await getUserPackageInfo(); + if (res.code === 0) { + const data = res.data; + setUserInfo(data.userInfo || {}); + setUserTunnels(data.tunnelPermissions || []); + setForwardList(data.forwards || []); + + // 检查有效期并显示通知 + checkExpirationNotifications(data.userInfo, data.tunnelPermissions || []); + } else { + toast.error(res.msg || '获取套餐信息失败'); + } + } catch (error) { + console.error('获取套餐信息失败:', error); + toast.error('获取套餐信息失败'); + } finally { + setLoading(false); + } + }; + + const formatFlow = (value: number, unit: string = 'bytes'): string => { + // 99999 表示无限制 + if (value === 99999) { + return '无限制'; + } + + if (unit === 'gb') { + return value + ' GB'; + } else { + if (value === 0) return '0 B'; + if (value < 1024) return value + ' B'; + if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB'; + if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB'; + return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + } + }; + + const formatNumber = (value: number): string => { + // 99999 表示无限制 + if (value === 99999) { + return '无限制'; + } + return value.toString(); + }; + + const getExpStatus = (expTime?: string) => { + if (!expTime) return { + color: 'text-green-600 dark:text-green-400', + bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20', + text: '永久' + }; + + const now = new Date(); + const expDate = new Date(expTime); + + if (isNaN(expDate.getTime())) { + return { + color: 'text-gray-600 dark:text-gray-400', + bg: 'bg-gray-50 dark:bg-black/10 border-gray-200 dark:border-gray-500/20', + text: '无效' + }; + } + + if (expDate < now) { + return { + color: 'text-red-600 dark:text-red-400', + bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20', + text: '已过期' + }; + } + + const diffTime = expDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays <= 7) { + return { + color: 'text-red-600 dark:text-red-400', + bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20', + text: `${diffDays}天后过期` + }; + } else if (diffDays <= 30) { + return { + color: 'text-orange-600 dark:text-orange-400', + bg: 'bg-orange-50 dark:bg-orange-500/10 border-orange-200 dark:border-orange-500/20', + text: `${diffDays}天后过期` + }; + } else { + return { + color: 'text-green-600 dark:text-green-400', + bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20', + text: `${diffDays}天后过期` + }; + } + }; + + const calculateUserTotalUsedFlow = (): number => { + return (userInfo.inFlow || 0) + (userInfo.outFlow || 0); + }; + + const calculateUsagePercentage = (type: 'flow' | 'forwards'): number => { + if (type === 'flow') { + const totalUsed = calculateUserTotalUsedFlow(); + const totalLimit = (userInfo.flow || 0) * 1024 * 1024 * 1024; + // 无限制时返回0% + if (userInfo.flow === 99999) return 0; + return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0; + } else if (type === 'forwards') { + const totalUsed = forwardList.length; + const totalLimit = userInfo.num || 0; + // 无限制时返回0% + if (userInfo.num === 99999) return 0; + return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0; + } + return 0; + }; + + const getUsageColor = (percentage: number) => { + if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'; + if (percentage >= 70) return 'bg-orange-500 dark:bg-orange-600'; + return 'bg-blue-500 dark:bg-blue-600'; + }; + + const renderProgressBar = (percentage: number, size: 'sm' | 'md' = 'md', isUnlimited: boolean = false) => { + const height = size === 'sm' ? 'h-1.5' : 'h-2'; + + if (isUnlimited) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+
+
+
+
+ ); + }; + + const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => { + if (!tunnel) return 0; + const inFlow = tunnel.inFlow || 0; + const outFlow = tunnel.outFlow || 0; + if (tunnel.tunnelFlow === 1) { + return outFlow; + } else { + return inFlow + outFlow; + } + }; + + const calculateTunnelFlowPercentage = (tunnel: UserTunnel): number => { + const totalUsed = calculateTunnelUsedFlow(tunnel); + const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024; + // 无限制时返回0% + if (tunnel.flow === 99999) return 0; + return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0; + }; + + const getTunnelUsedForwards = (tunnelId: number): number => { + return forwardList.filter(forward => forward.tunnelId === tunnelId).length; + }; + + const calculateTunnelForwardPercentage = (tunnel: UserTunnel): number => { + const totalUsed = getTunnelUsedForwards(tunnel.tunnelId); + const totalLimit = tunnel.num || 0; + // 无限制时返回0% + if (tunnel.num === 99999) return 0; + return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0; + }; + + const formatResetTime = (resetDay?: number): string => { + if (!resetDay) return ''; + + const now = new Date(); + const currentDay = now.getDate(); + + let daysUntilReset; + if (resetDay > currentDay) { + daysUntilReset = resetDay - currentDay; + } else if (resetDay < currentDay) { + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, resetDay); + const diffTime = nextMonth.getTime() - now.getTime(); + daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + } else { + daysUntilReset = 0; + } + + if (daysUntilReset === 0) { + return '今日重置'; + } else if (daysUntilReset === 1) { + return '明日重置'; + } else { + return `${daysUntilReset}天后重置`; + } + }; + + const groupedForwards = () => { + const groups: { [key: string]: { tunnelName: string; forwards: Forward[] } } = {}; + forwardList.forEach(forward => { + const tunnelName = forward.tunnelName || '未知隧道'; + if (!groups[tunnelName]) { + groups[tunnelName] = { + tunnelName, + forwards: [] + }; + } + groups[tunnelName].forwards.push(forward); + }); + return Object.values(groups); + }; + + const formatInAddress = (ipString: string, port: number): string => { + if (!ipString || !port) return ''; + + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + + if (ips.length === 0) return ''; + + if (ips.length === 1) { + const ip = ips[0]; + if (ip.includes(':') && !ip.startsWith('[')) { + return `[${ip}]:${port}`; + } else { + return `${ip}:${port}`; + } + } + + const firstIp = ips[0]; + let formattedFirstIp; + + if (firstIp.includes(':') && !firstIp.startsWith('[')) { + formattedFirstIp = `[${firstIp}]`; + } else { + formattedFirstIp = firstIp; + } + + return `${formattedFirstIp}:${port} (+${ips.length - 1})`; + }; + + const formatRemoteAddress = (remoteAddr: string): string => { + if (!remoteAddr) return ''; + + const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr); + + if (addresses.length === 0) return ''; + + if (addresses.length === 1) { + return addresses[0]; + } + + return `${addresses[0]} (+${addresses.length - 1})`; + }; + + const hasMultipleIps = (ipString: string): boolean => { + if (!ipString) return false; + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + return ips.length > 1; + }; + + const hasMultipleRemoteAddresses = (remoteAddr: string): boolean => { + if (!remoteAddr) return false; + const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr); + return addresses.length > 1; + }; + + const showAddressModal = (ipString: string, port: number, title: string) => { + if (!ipString || !port) return; + + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + + if (ips.length <= 1) { + copyToClipboard(formatInAddress(ipString, port), title); + return; + } + + const formattedList = ips.map((ip, index) => { + let formattedAddress; + if (ip.includes(':') && !ip.startsWith('[')) { + formattedAddress = `[${ip}]:${port}`; + } else { + formattedAddress = `${ip}:${port}`; + } + return { + id: index, + ip: ip, + address: formattedAddress, + copying: false + }; + }); + + setAddressList(formattedList); + setAddressModalTitle(`${title} (${ips.length}个)`); + setAddressModalOpen(true); + }; + + const showRemoteAddressModal = (remoteAddr: string, title: string) => { + if (!remoteAddr) return; + + const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr); + + if (addresses.length <= 1) { + copyToClipboard(remoteAddr, title); + return; + } + + const formattedList = addresses.map((address, index) => { + return { + id: index, + ip: address, + address: address, + copying: false + }; + }); + + setAddressList(formattedList); + setAddressModalTitle(`${title} (${addresses.length}个)`); + setAddressModalOpen(true); + }; + + const copyToClipboard = async (text: string, label: string = '内容') => { + try { + await navigator.clipboard.writeText(text); + toast.success(`已复制`); + } catch (error) { + toast.error('复制失败'); + } + }; + + const copyAddress = async (addressItem: AddressItem) => { + try { + setAddressList(prev => prev.map(item => + item.id === addressItem.id ? { ...item, copying: true } : item + )); + await copyToClipboard(addressItem.address, '地址'); + } catch (error) { + toast.error('复制失败'); + } finally { + setAddressList(prev => prev.map(item => + item.id === addressItem.id ? { ...item, copying: false } : item + )); + } + }; + + const copyAllAddresses = async () => { + if (addressList.length === 0) return; + const allAddresses = addressList.map(item => item.address).join('\n'); + await copyToClipboard(allAddresses, '所有地址'); + }; + + const calculateForwardBillingFlow = (forward: Forward): number => { + if (!forward) return 0; + + const tunnel = userTunnels.find(t => t.tunnelId === forward.tunnelId); + if (!tunnel) { + return (forward.inFlow || 0) + (forward.outFlow || 0); + } + + const inFlow = forward.inFlow || 0; + const outFlow = forward.outFlow || 0; + + if (tunnel.tunnelFlow === 1) { + return outFlow; + } else { + return inFlow + outFlow; + } + }; + + if (loading) { + return ( + +
+
+
+
+ 正在加载数据... +
+
+
+
+ ); + } + + return ( + +
+ + {/* 响应式统计卡片 */} +
+ + +
+
+

总流量

+
+ + + +
+
+

{formatFlow(userInfo.flow, 'gb')}

+
+
+
+ + + +
+
+

已用流量

+
+ + + +
+
+

{formatFlow(calculateUserTotalUsedFlow())}

+
+ {renderProgressBar(calculateUsagePercentage('flow'), 'sm', userInfo.flow === 99999)} +
+

+ {userInfo.flow === 99999 ? '无限制' : `${calculateUsagePercentage('flow').toFixed(1)}%`} +

+ {userInfo.flowResetTime && ( +
+ + + + {formatResetTime(userInfo.flowResetTime)} +
+ )} +
+
+
+
+
+ + + +
+
+

转发配额

+
+ + + +
+
+

{formatNumber(userInfo.num || 0)}

+
+
+
+ + + +
+
+

已用转发

+
+ + + +
+
+

{forwardList.length}

+
+ {renderProgressBar(calculateUsagePercentage('forwards'), 'sm', userInfo.num === 99999)} +

+ {userInfo.num === 99999 ? '无限制' : `${calculateUsagePercentage('forwards').toFixed(1)}%`} +

+
+
+
+
+
+ + {/* 隧道权限 */} + + +
+ + + +

隧道权限

+ + {userTunnels.length} + +
+
+ + {userTunnels.length === 0 ? ( +
+ + + +

暂无隧道权限

+
+ ) : ( +
+ {userTunnels.map((tunnel) => { + const tunnelExpStatus = getExpStatus(tunnel.expTime); + return ( +
+
+
+

{tunnel.tunnelName}

+
+ + {tunnel.tunnelFlow === 1 ? '单向计费' : '双向计费'} + + + {tunnelExpStatus.text} + + {tunnel.flowResetTime && ( + + {formatResetTime(tunnel.flowResetTime)} + + )} +
+
+
+ +
+
+

流量配额

+

{formatFlow(tunnel.flow, 'gb')}

+
+
+

已用流量

+

{formatFlow(calculateTunnelUsedFlow(tunnel))}

+
+ {renderProgressBar(calculateTunnelFlowPercentage(tunnel), 'sm', tunnel.flow === 99999)} +
+
+
+

转发配额

+

{formatNumber(tunnel.num)}

+
+
+

已用转发

+

{getTunnelUsedForwards(tunnel.tunnelId)}

+
+ {renderProgressBar(calculateTunnelForwardPercentage(tunnel), 'sm', tunnel.num === 99999)} +
+
+
+
+ ); + })} +
+ )} +
+
+ + {/* 转发配置 */} + + +
+ + + +

转发配置

+ + {forwardList.length} + +
+
+ + {groupedForwards().length === 0 ? ( +
+ + + +

暂无转发配置

+
+ ) : ( +
+ {groupedForwards().map((group) => ( +
+
+

{group.tunnelName}

+ + {group.forwards.length} 个转发 + +
+ +
+ {group.forwards.map((forward) => ( +
+
+
+

{forward.name}

+
+ hasMultipleIps(forward.inIp) && showAddressModal(forward.inIp, forward.inPort, '入口地址')} + title={formatInAddress(forward.inIp, forward.inPort)} + > + {formatInAddress(forward.inIp, forward.inPort)} + +
↓
+ hasMultipleRemoteAddresses(forward.remoteAddr) && showRemoteAddressModal(forward.remoteAddr, '出口地址')} + title={formatRemoteAddress(forward.remoteAddr)} + > + {formatRemoteAddress(forward.remoteAddr)} + +
+
+ +
+
+
+
入站
+
{formatFlow(forward.inFlow || 0)}
+
+
+
出站
+
{formatFlow(forward.outFlow || 0)}
+
+
+
计费
+
{formatFlow(calculateForwardBillingFlow(forward))}
+
+
+
+
+
+ ))} +
+
+ ))} +
+ )} +
+
+ + {/* 地址列表弹窗 */} + setAddressModalOpen(false)} size="lg"> + + {addressModalTitle} + +
+ +
+ +
+ {addressList.map((item) => ( +
+ {item.address} + +
+ ))} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx new file mode 100644 index 0000000..5469234 --- /dev/null +++ b/vite-frontend/src/pages/forward.tsx @@ -0,0 +1,863 @@ +import { useState, useEffect } from "react"; +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Button } from "@heroui/button"; +import { Input } from "@heroui/input"; +import { Select, SelectItem } from "@heroui/select"; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal"; +import { Chip } from "@heroui/chip"; +import { Spinner } from "@heroui/spinner"; +import { Switch } from "@heroui/switch"; +import toast from 'react-hot-toast'; + +import AdminLayout from "@/layouts/admin"; +import { + createForward, + getForwardList, + updateForward, + deleteForward, + forceDeleteForward, + userTunnel, + pauseForwardService, + resumeForwardService +} from "@/api"; + +interface Forward { + id: number; + name: string; + tunnelId: number; + tunnelName: string; + inIp: string; + inPort: number; + remoteAddr: string; + strategy: string; + status: number; + inFlow: number; + outFlow: number; + serviceRunning: boolean; + createdTime: string; + userName?: string; +} + +interface Tunnel { + id: number; + name: string; + inNodePortSta?: number; + inNodePortEnd?: number; +} + +interface ForwardForm { + id?: number; + name: string; + tunnelId: number | null; + inPort: number | null; + remoteAddr: string; + strategy: string; +} + +interface AddressItem { + id: number; + address: string; + copying: boolean; +} + +export default function ForwardPage() { + const [loading, setLoading] = useState(true); + const [forwards, setForwards] = useState([]); + const [tunnels, setTunnels] = useState([]); + + // 模态框状态 + const [modalOpen, setModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [addressModalOpen, setAddressModalOpen] = useState(false); + const [isEdit, setIsEdit] = useState(false); + const [submitLoading, setSubmitLoading] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [forwardToDelete, setForwardToDelete] = useState(null); + const [addressModalTitle, setAddressModalTitle] = useState(''); + const [addressList, setAddressList] = useState([]); + + // 表单状态 + const [form, setForm] = useState({ + name: '', + tunnelId: null, + inPort: null, + remoteAddr: '', + strategy: 'fifo' + }); + + // 表单验证错误 + const [errors, setErrors] = useState<{[key: string]: string}>({}); + const [selectedTunnel, setSelectedTunnel] = useState(null); + + useEffect(() => { + loadData(); + }, []); + + // 加载所有数据 + const loadData = async () => { + setLoading(true); + try { + const [forwardsRes, tunnelsRes] = await Promise.all([ + getForwardList(), + userTunnel() + ]); + + if (forwardsRes.code === 0) { + setForwards(forwardsRes.data?.map((forward: any) => ({ + ...forward, + serviceRunning: forward.status === 1 + })) || []); + } else { + toast.error(forwardsRes.msg || '获取转发列表失败'); + } + + if (tunnelsRes.code === 0) { + setTunnels(tunnelsRes.data || []); + } else { + console.warn('获取隧道列表失败:', tunnelsRes.msg); + } + } catch (error) { + console.error('加载数据失败:', error); + toast.error('加载数据失败'); + } finally { + setLoading(false); + } + }; + + // 表单验证 + const validateForm = (): boolean => { + const newErrors: {[key: string]: string} = {}; + + if (!form.name.trim()) { + newErrors.name = '请输入转发名称'; + } else if (form.name.length < 2 || form.name.length > 50) { + newErrors.name = '转发名称长度应在2-50个字符之间'; + } + + if (!form.tunnelId) { + newErrors.tunnelId = '请选择关联隧道'; + } + + if (!form.remoteAddr.trim()) { + newErrors.remoteAddr = '请输入远程地址'; + } else { + // 验证地址格式 + const addresses = form.remoteAddr.split('\n').map(addr => addr.trim()).filter(addr => addr); + const ipv4Pattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):\d+$/; + const ipv6FullPattern = /^\[((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))\]:\d+$/; + const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*:\d+$/; + + for (let i = 0; i < addresses.length; i++) { + const addr = addresses[i]; + if (!ipv4Pattern.test(addr) && !ipv6FullPattern.test(addr) && !domainPattern.test(addr)) { + newErrors.remoteAddr = `第${i + 1}行地址格式错误`; + break; + } + } + } + + if (form.inPort !== null && (form.inPort < 1 || form.inPort > 65535)) { + newErrors.inPort = '端口号必须在1-65535之间'; + } + + if (selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd && form.inPort) { + if (form.inPort < selectedTunnel.inNodePortSta || form.inPort > selectedTunnel.inNodePortEnd) { + newErrors.inPort = `端口号必须在${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}范围内`; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // 新增转发 + const handleAdd = () => { + setIsEdit(false); + setForm({ + name: '', + tunnelId: null, + inPort: null, + remoteAddr: '', + strategy: 'fifo' + }); + setSelectedTunnel(null); + setErrors({}); + setModalOpen(true); + }; + + // 编辑转发 + const handleEdit = (forward: Forward) => { + setIsEdit(true); + setForm({ + id: forward.id, + name: forward.name, + tunnelId: forward.tunnelId, + inPort: forward.inPort, + remoteAddr: forward.remoteAddr.split(',').join('\n'), + strategy: forward.strategy || 'fifo' + }); + const tunnel = tunnels.find(t => t.id === forward.tunnelId); + setSelectedTunnel(tunnel || null); + setErrors({}); + setModalOpen(true); + }; + + // 显示删除确认 + const handleDelete = (forward: Forward) => { + setForwardToDelete(forward); + setDeleteModalOpen(true); + }; + + // 确认删除转发 + const confirmDelete = async () => { + if (!forwardToDelete) return; + + setDeleteLoading(true); + try { + const res = await deleteForward(forwardToDelete.id); + if (res.code === 0) { + toast.success('删除成功'); + setDeleteModalOpen(false); + loadData(); + } else { + // 删除失败,询问是否强制删除 + const confirmed = window.confirm(`常规删除失败:${res.msg || '删除失败'}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`); + if (confirmed) { + const forceRes = await forceDeleteForward(forwardToDelete.id); + if (forceRes.code === 0) { + toast.success('强制删除成功'); + setDeleteModalOpen(false); + loadData(); + } else { + toast.error(forceRes.msg || '强制删除失败'); + } + } + } + } catch (error) { + console.error('删除失败:', error); + toast.error('删除失败'); + } finally { + setDeleteLoading(false); + } + }; + + // 处理隧道选择变化 + const handleTunnelChange = (tunnelId: string) => { + const tunnel = tunnels.find(t => t.id === parseInt(tunnelId)); + setSelectedTunnel(tunnel || null); + setForm(prev => ({ ...prev, tunnelId: parseInt(tunnelId) })); + }; + + // 提交表单 + const handleSubmit = async () => { + if (!validateForm()) return; + + setSubmitLoading(true); + try { + const processedRemoteAddr = form.remoteAddr + .split('\n') + .map(addr => addr.trim()) + .filter(addr => addr) + .join(','); + + const addressCount = processedRemoteAddr.split(',').length; + + let res; + if (isEdit) { + res = await updateForward({ + ...form, + remoteAddr: processedRemoteAddr, + strategy: addressCount > 1 ? form.strategy : 'fifo' + }); + } else { + const { id, ...createData } = form; + res = await createForward({ + ...createData, + remoteAddr: processedRemoteAddr, + strategy: addressCount > 1 ? form.strategy : 'fifo' + }); + } + + if (res.code === 0) { + toast.success(isEdit ? '修改成功' : '创建成功'); + setModalOpen(false); + loadData(); + } else { + toast.error(res.msg || '操作失败'); + } + } catch (error) { + console.error('提交失败:', error); + toast.error('操作失败'); + } finally { + setSubmitLoading(false); + } + }; + + // 处理服务开关 + const handleServiceToggle = async (forward: Forward) => { + if (forward.status !== 1 && forward.status !== 0) { + toast.error('转发状态异常,无法操作'); + return; + } + + const targetState = !forward.serviceRunning; + + try { + // 乐观更新UI + setForwards(prev => prev.map(f => + f.id === forward.id + ? { ...f, serviceRunning: targetState } + : f + )); + + let res; + if (targetState) { + res = await resumeForwardService(forward.id); + } else { + res = await pauseForwardService(forward.id); + } + + if (res.code === 0) { + toast.success(targetState ? '服务已启动' : '服务已暂停'); + // 更新转发状态 + setForwards(prev => prev.map(f => + f.id === forward.id + ? { ...f, status: targetState ? 1 : 0 } + : f + )); + } else { + // 操作失败,恢复UI状态 + setForwards(prev => prev.map(f => + f.id === forward.id + ? { ...f, serviceRunning: !targetState } + : f + )); + toast.error(res.msg || '操作失败'); + } + } catch (error) { + // 操作失败,恢复UI状态 + setForwards(prev => prev.map(f => + f.id === forward.id + ? { ...f, serviceRunning: !targetState } + : f + )); + console.error('服务开关操作失败:', error); + toast.error('网络错误,操作失败'); + } + }; + + // 格式化流量 + const formatFlow = (value: number): string => { + if (value === 0) return '0 B'; + if (value < 1024) return value + ' B'; + if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB'; + if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB'; + return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + }; + + // 格式化入口地址 + const formatInAddress = (ipString: string, port: number): string => { + if (!ipString || !port) return ''; + + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + if (ips.length === 0) return ''; + + if (ips.length === 1) { + const ip = ips[0]; + if (ip.includes(':') && !ip.startsWith('[')) { + return `[${ip}]:${port}`; + } else { + return `${ip}:${port}`; + } + } + + const firstIp = ips[0]; + let formattedFirstIp; + if (firstIp.includes(':') && !firstIp.startsWith('[')) { + formattedFirstIp = `[${firstIp}]`; + } else { + formattedFirstIp = firstIp; + } + + return `${formattedFirstIp}:${port} (+${ips.length - 1})`; + }; + + // 格式化远程地址 + const formatRemoteAddress = (addressString: string): string => { + if (!addressString) return ''; + + const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr); + if (addresses.length === 0) return ''; + if (addresses.length === 1) return addresses[0]; + + return `${addresses[0]} (+${addresses.length - 1})`; + }; + + // 检查是否有多个地址 + const hasMultipleAddresses = (addressString: string): boolean => { + if (!addressString) return false; + const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr); + return addresses.length > 1; + }; + + // 显示地址列表弹窗 + const showAddressModal = (addressString: string, port: number | null, title: string) => { + if (!addressString) return; + + let addresses: string[]; + if (port !== null) { + // 入口地址处理 + const ips = addressString.split(',').map(ip => ip.trim()).filter(ip => ip); + if (ips.length <= 1) { + copyToClipboard(formatInAddress(addressString, port), title); + return; + } + addresses = ips.map(ip => { + if (ip.includes(':') && !ip.startsWith('[')) { + return `[${ip}]:${port}`; + } else { + return `${ip}:${port}`; + } + }); + } else { + // 远程地址处理 + addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr); + if (addresses.length <= 1) { + copyToClipboard(addressString, title); + return; + } + } + + setAddressList(addresses.map((address, index) => ({ + id: index, + address, + copying: false + }))); + setAddressModalTitle(`${title} (${addresses.length}个)`); + setAddressModalOpen(true); + }; + + // 复制到剪贴板 + const copyToClipboard = async (text: string, label: string = '内容') => { + try { + await navigator.clipboard.writeText(text); + toast.success(`已复制${label}`); + } catch (error) { + toast.error('复制失败'); + } + }; + + // 复制地址 + const copyAddress = async (addressItem: AddressItem) => { + try { + setAddressList(prev => prev.map(item => + item.id === addressItem.id ? { ...item, copying: true } : item + )); + await copyToClipboard(addressItem.address, '地址'); + } catch (error) { + toast.error('复制失败'); + } finally { + setAddressList(prev => prev.map(item => + item.id === addressItem.id ? { ...item, copying: false } : item + )); + } + }; + + // 复制所有地址 + const copyAllAddresses = async () => { + if (addressList.length === 0) return; + const allAddresses = addressList.map(item => item.address).join('\n'); + await copyToClipboard(allAddresses, '所有地址'); + }; + + // 获取状态显示 + const getStatusDisplay = (status: number) => { + switch (status) { + case 1: + return { color: 'success', text: '正常' }; + case 0: + return { color: 'warning', text: '暂停' }; + case -1: + return { color: 'danger', text: '异常' }; + default: + return { color: 'default', text: '未知' }; + } + }; + + // 获取策略显示 + const getStrategyDisplay = (strategy: string) => { + switch (strategy) { + case 'fifo': + return { color: 'primary', text: '主备' }; + case 'round': + return { color: 'success', text: '轮询' }; + case 'rand': + return { color: 'warning', text: '随机' }; + default: + return { color: 'default', text: '未知' }; + } + }; + + // 获取地址数量 + const getAddressCount = (addressString: string): number => { + if (!addressString) return 0; + const addresses = addressString.split('\n').map(addr => addr.trim()).filter(addr => addr); + return addresses.length; + }; + + if (loading) { + return ( + +
+
+ + 正在加载... +
+
+
+ ); + } + + return ( + +
+ {/* 页面头部 */} +
+

转发管理

+ +
+ + {/* 转发卡片网格 */} + {forwards.length > 0 ? ( +
+ {forwards.map((forward) => { + const statusDisplay = getStatusDisplay(forward.status); + const strategyDisplay = getStrategyDisplay(forward.strategy); + + return ( + + +
+
+

{forward.name}

+

{forward.tunnelName}

+
+
+ handleServiceToggle(forward)} + isDisabled={forward.status !== 1 && forward.status !== 0} + /> + + {statusDisplay.text} + +
+
+
+ +
+ {/* 地址信息 */} +
+
showAddressModal(forward.inIp, forward.inPort, '入口地址')} + title={formatInAddress(forward.inIp, forward.inPort)} + > +
入口地址
+ + {formatInAddress(forward.inIp, forward.inPort)} + +
+ +
↓
+ +
showAddressModal(forward.remoteAddr, null, '目标地址')} + title={formatRemoteAddress(forward.remoteAddr)} + > +
目标地址
+ + {formatRemoteAddress(forward.remoteAddr)} + +
+
+ + {/* 策略和流量 */} +
+
+
策略
+ + {strategyDisplay.text} + +
+
+
总流量
+
+ {formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))} +
+
+
+
+ +
+ + +
+
+
+ ); + })} +
+ ) : ( + /* 空状态 */ + + +
+
+ + + +
+
+

暂无转发配置

+

还没有创建任何转发配置,点击上方按钮开始创建

+
+
+
+
+ )} + + {/* 新增/编辑模态框 */} + + + {(onClose) => ( + <> + +

+ {isEdit ? '编辑转发' : '新增转发'} +

+

+ {isEdit ? '修改现有转发配置的信息' : '创建新的转发配置'} +

+
+ +
+ setForm(prev => ({ ...prev, name: e.target.value }))} + isInvalid={!!errors.name} + errorMessage={errors.name} + variant="bordered" + /> + + + + setForm(prev => ({ + ...prev, + inPort: e.target.value ? parseInt(e.target.value) : null + }))} + isInvalid={!!errors.inPort} + errorMessage={errors.inPort} + variant="bordered" + description={ + selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd + ? `允许范围: ${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}` + : '留空将自动分配可用端口' + } + /> + + setForm(prev => ({ ...prev, remoteAddr: e.target.value }))} + isInvalid={!!errors.remoteAddr} + errorMessage={errors.remoteAddr} + variant="bordered" + description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)" + /> + + {getAddressCount(form.remoteAddr) > 1 && ( + + )} +
+
+ + + + + + )} +
+
+ + {/* 删除确认模态框 */} + + + {(onClose) => ( + <> + +

确认删除

+
+ +

+ 确定要删除转发 "{forwardToDelete?.name}" 吗? +

+

+ 此操作无法撤销,删除后该转发将永久消失。 +

+
+ + + + + + )} +
+
+ + {/* 地址列表弹窗 */} + setAddressModalOpen(false)} size="lg"> + + {addressModalTitle} + +
+ +
+ +
+ {addressList.map((item) => ( +
+ {item.address} + +
+ ))} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/vite-frontend/src/pages/index.tsx b/vite-frontend/src/pages/index.tsx new file mode 100644 index 0000000..251ee8c --- /dev/null +++ b/vite-frontend/src/pages/index.tsx @@ -0,0 +1,162 @@ +import { Button } from "@heroui/button"; +import { Input } from "@heroui/input"; +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import toast from 'react-hot-toast'; + +import { title } from "@/components/primitives"; +import DefaultLayout from "@/layouts/default"; +import { login, LoginData } from "@/api"; + +interface LoginForm { + username: string; + password: string; +} + +export default function IndexPage() { + const [form, setForm] = useState({ + username: "", + password: "" + }); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + const navigate = useNavigate(); + + // 验证表单 + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!form.username.trim()) { + newErrors.username = '请输入用户名'; + } + + if (!form.password.trim()) { + newErrors.password = '请输入密码'; + } else if (form.password.length < 6) { + newErrors.password = '密码长度至少6位'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // 处理输入变化 + const handleInputChange = (field: keyof LoginForm, value: string) => { + setForm(prev => ({ ...prev, [field]: value })); + // 清除该字段的错误 + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + const handleLogin = async () => { + if (!validateForm()) return; + + setLoading(true); + + try { + const loginData: LoginData = { + username: form.username.trim(), + password: form.password + }; + + const response = await login(loginData); + + if (response.code !== 0) { + toast.error(response.msg || "登陆失败"); + return; + } + + // 检查是否需要强制修改密码 + if (response.data.requirePasswordChange) { + localStorage.setItem('token', response.data.token); + localStorage.setItem("role_id", response.data.role_id.toString()); + localStorage.setItem("name", response.data.name); + // 设置admin字段 - role_id为0的是管理员 + localStorage.setItem("admin", (response.data.role_id === 0).toString()); + toast.success('检测到默认密码,即将跳转到修改密码页面'); + // 直接跳转到修改密码页面 + navigate("/change-password"); + return; + } + + // 保存登陆信息 + localStorage.setItem('token', response.data.token); + localStorage.setItem("role_id", response.data.role_id.toString()); + localStorage.setItem("name", response.data.name); + // 设置admin字段 - role_id为0的是管理员 + localStorage.setItem("admin", (response.data.role_id === 0).toString()); + + // 登陆成功 + toast.success('登陆成功'); + navigate("/dashboard"); + + } catch (error) { + console.error('登陆错误:', error); + toast.error("网络错误,请稍后重试"); + } finally { + setLoading(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !loading) { + handleLogin(); + } + }; + + return ( + +
+
+ + +

登陆

+

请输入您的账号信息

+
+ +
+ handleInputChange('username', e.target.value)} + onKeyDown={handleKeyPress} + variant="bordered" + isDisabled={loading} + isInvalid={!!errors.username} + errorMessage={errors.username} + /> + + handleInputChange('password', e.target.value)} + onKeyDown={handleKeyPress} + variant="bordered" + isDisabled={loading} + isInvalid={!!errors.password} + errorMessage={errors.password} + /> + + +
+
+
+
+
+
+ ); +} diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx new file mode 100644 index 0000000..770d14f --- /dev/null +++ b/vite-frontend/src/pages/limit.tsx @@ -0,0 +1,470 @@ +import { useState, useEffect } from "react"; +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Button } from "@heroui/button"; +import { Input } from "@heroui/input"; +import { Select, SelectItem } from "@heroui/select"; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal"; +import { Chip } from "@heroui/chip"; +import { Spinner } from "@heroui/spinner"; +import toast from 'react-hot-toast'; + +import AdminLayout from "@/layouts/admin"; +import { + createSpeedLimit, + getSpeedLimitList, + updateSpeedLimit, + deleteSpeedLimit, + getTunnelList +} from "@/api"; + +interface SpeedLimitRule { + id: number; + name: string; + speed: number; + status: number; + tunnelId: number; + tunnelName: string; + createdTime: string; + updatedTime: string; +} + +interface Tunnel { + id: number; + name: string; +} + +interface SpeedLimitForm { + id?: number; + name: string; + speed: number; + tunnelId: number | null; + tunnelName: string; + status: number; +} + +export default function LimitPage() { + const [loading, setLoading] = useState(true); + const [rules, setRules] = useState([]); + const [tunnels, setTunnels] = useState([]); + + // 模态框状态 + const [modalOpen, setModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [isEdit, setIsEdit] = useState(false); + const [submitLoading, setSubmitLoading] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [ruleToDelete, setRuleToDelete] = useState(null); + + // 表单状态 + const [form, setForm] = useState({ + name: '', + speed: 100, + tunnelId: null, + tunnelName: '', + status: 1 + }); + + // 表单验证错误 + const [errors, setErrors] = useState<{[key: string]: string}>({}); + + useEffect(() => { + loadData(); + }, []); + + // 加载所有数据 + const loadData = async () => { + setLoading(true); + try { + const [rulesRes, tunnelsRes] = await Promise.all([ + getSpeedLimitList(), + getTunnelList() + ]); + + if (rulesRes.code === 0) { + setRules(rulesRes.data || []); + } else { + toast.error(rulesRes.msg || '获取限速规则失败'); + } + + if (tunnelsRes.code === 0) { + setTunnels(tunnelsRes.data || []); + } else { + console.warn('获取隧道列表失败:', tunnelsRes.msg); + } + } catch (error) { + console.error('加载数据失败:', error); + toast.error('加载数据失败'); + } finally { + setLoading(false); + } + }; + + // 表单验证 + const validateForm = (): boolean => { + const newErrors: {[key: string]: string} = {}; + + if (!form.name.trim()) { + newErrors.name = '请输入规则名称'; + } else if (form.name.length < 2 || form.name.length > 50) { + newErrors.name = '规则名称长度应在2-50个字符之间'; + } + + if (!form.speed || form.speed < 1) { + newErrors.speed = '请输入有效的速度限制(≥1 Mbps)'; + } + + if (!form.tunnelId) { + newErrors.tunnelId = '请选择要绑定的隧道'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // 新增规则 + const handleAdd = () => { + setIsEdit(false); + setForm({ + name: '', + speed: 100, + tunnelId: null, + tunnelName: '', + status: 1 + }); + setErrors({}); + setModalOpen(true); + }; + + // 编辑规则 + const handleEdit = (rule: SpeedLimitRule) => { + setIsEdit(true); + setForm({ + id: rule.id, + name: rule.name, + speed: rule.speed, + tunnelId: rule.tunnelId, + tunnelName: rule.tunnelName, + status: rule.status + }); + setErrors({}); + setModalOpen(true); + }; + + // 显示删除确认 + const handleDelete = (rule: SpeedLimitRule) => { + setRuleToDelete(rule); + setDeleteModalOpen(true); + }; + + // 确认删除规则 + const confirmDelete = async () => { + if (!ruleToDelete) return; + + setDeleteLoading(true); + try { + const res = await deleteSpeedLimit(ruleToDelete.id); + if (res.code === 0) { + toast.success('删除成功'); + setDeleteModalOpen(false); + loadData(); + } else { + toast.error(res.msg || '删除失败'); + } + } catch (error) { + console.error('删除失败:', error); + toast.error('删除失败'); + } finally { + setDeleteLoading(false); + } + }; + + // 提交表单 + const handleSubmit = async () => { + if (!validateForm()) return; + + setSubmitLoading(true); + try { + let res; + if (isEdit) { + res = await updateSpeedLimit(form); + } else { + const { id, ...createData } = form; + res = await createSpeedLimit(createData); + } + + if (res.code === 0) { + toast.success(isEdit ? '修改成功' : '创建成功'); + setModalOpen(false); + loadData(); + } else { + toast.error(res.msg || '操作失败'); + } + } catch (error) { + console.error('提交失败:', error); + toast.error('操作失败'); + } finally { + setSubmitLoading(false); + } + }; + + if (loading) { + return ( + +
+
+ + 正在加载... +
+
+
+ ); + } + + return ( + +
+ {/* 页面头部 */} +
+

限速管理

+ +
+ + {/* 统一卡片网格 */} + {rules.length > 0 ? ( +
+ {rules.map((rule) => ( + + +
+
+

{rule.name}

+
+ + {rule.status === 1 ? '运行' : '异常'} + +
+
+ +
+
+ 速度限制 + + {rule.speed} Mbps + +
+
+ 绑定隧道 + {rule.tunnelName ? ( + + {rule.tunnelName} + + ) : ( + 未绑定 + )} +
+
+ +
+ + +
+
+
+ ))} +
+ ) : ( + /* 空状态 */ + + +
+
+ + + +
+
+

暂无限速规则

+

还没有创建任何限速规则,点击上方按钮开始创建

+
+
+
+
+ )} + + {/* 新增/编辑模态框 */} + + + {(onClose) => ( + <> + +

+ {isEdit ? '编辑限速规则' : '新增限速规则'} +

+

+ {isEdit ? '修改现有限速规则的配置信息' : '创建新的限速规则并绑定到隧道'} +

+
+ +
+ setForm(prev => ({ ...prev, name: e.target.value }))} + isInvalid={!!errors.name} + errorMessage={errors.name} + variant="bordered" + /> + + setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))} + isInvalid={!!errors.speed} + errorMessage={errors.speed} + variant="bordered" + endContent={ +
+ Mbps +
+ } + /> + + +
+
+ + + + + + )} +
+
+ + {/* 删除确认模态框 */} + + + {(onClose) => ( + <> + +

确认删除

+
+ +

+ 确定要删除限速规则 "{ruleToDelete?.name}" 吗? +

+

+ 此操作无法撤销,删除后该规则将永久消失。 +

+
+ + + + + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/vite-frontend/src/provider.tsx b/vite-frontend/src/provider.tsx new file mode 100644 index 0000000..73855bd --- /dev/null +++ b/vite-frontend/src/provider.tsx @@ -0,0 +1,53 @@ +import type { NavigateOptions } from "react-router-dom"; +import * as React from "react"; + +import { HeroUIProvider } from "@heroui/system"; +import { useHref, useNavigate } from "react-router-dom"; +import { Toaster } from 'react-hot-toast'; +import { ThemeProvider } from '@/components/theme-provider'; + +declare module "@react-types/shared" { + interface RouterConfig { + routerOptions: NavigateOptions; + } +} + +export interface ProvidersProps { + children: React.ReactNode; +} + +export function Provider({ children }: ProvidersProps) { + const navigate = useNavigate(); + + return ( + + + {children} + + + + ); +} diff --git a/vite-frontend/src/styles/globals.css b/vite-frontend/src/styles/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/vite-frontend/src/styles/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/vite-frontend/src/types/index.ts b/vite-frontend/src/types/index.ts new file mode 100644 index 0000000..cece4a4 --- /dev/null +++ b/vite-frontend/src/types/index.ts @@ -0,0 +1,5 @@ +import { SVGProps } from "react"; + +export type IconSvgProps = SVGProps & { + size?: number; +}; diff --git a/vite-frontend/src/utils/auth.ts b/vite-frontend/src/utils/auth.ts new file mode 100644 index 0000000..b2b69e8 --- /dev/null +++ b/vite-frontend/src/utils/auth.ts @@ -0,0 +1,72 @@ +import { getRoleIdFromToken, isTokenValid } from './jwt'; + +/** + * 权限工具类 + */ + +/** + * 获取当前用户的token + * @returns token + */ +export function getToken(): string | null { + return localStorage.getItem('token'); +} + +/** + * 获取当前用户的角色ID + * @returns 角色ID + */ +export function getCurrentUserRoleId(): number | null { + const token = getToken(); + if (!token || !isTokenValid(token)) { + return null; + } + return getRoleIdFromToken(token); +} + +/** + * 判断当前用户是否是管理员 + * @returns 是否是管理员 + */ +export function isAdmin(): boolean { + const roleId = getCurrentUserRoleId(); + return roleId === 0; +} + +/** + * 判断当前用户是否有指定角色 + * @param targetRoleId 目标角色ID + * @returns 是否有指定角色 + */ +export function hasRole(targetRoleId: number): boolean { + const roleId = getCurrentUserRoleId(); + return roleId === targetRoleId; +} + +/** + * 判断当前用户是否已登录且token有效 + * @returns 是否已登录 + */ +export function isLoggedIn(): boolean { + const token = getToken(); + return token ? isTokenValid(token) : false; +} + +/** + * 权限检查装饰器函数 + * @param fn 要执行的函数 + * @param errorMsg 权限不足时的错误提示 + * @returns 包装后的函数 + */ +export function requireAdmin any>( + fn: T, + errorMsg: string = '权限不足,仅管理员可操作' +): T { + return ((...args: Parameters) => { + if (!isAdmin()) { + console.warn(errorMsg); + return false; + } + return fn(...args); + }) as T; +} \ No newline at end of file diff --git a/vite-frontend/src/utils/jwt.ts b/vite-frontend/src/utils/jwt.ts new file mode 100644 index 0000000..18f293d --- /dev/null +++ b/vite-frontend/src/utils/jwt.ts @@ -0,0 +1,113 @@ +/** + * JWT工具类 - TypeScript版本 + */ + +interface JWTPayload { + sub: string; + role_id: number; + user: string; + exp: number; + iat: number; +} + +/** + * 从JWT Token中获取payload + * @param token JWT Token + * @returns payload数据 + */ +function getPayloadFromToken(token: string): JWTPayload | null { + try { + if (!token) return null; + + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const encodedPayload = parts[1]; + const decodedPayload = atob(encodedPayload); + return JSON.parse(decodedPayload) as JWTPayload; + } catch (error) { + return null; + } +} + +/** + * 从JWT Token中获取用户ID + * @param token JWT Token + * @returns 用户ID + */ +export function getUserIdFromToken(token: string): number | null { + const payload = getPayloadFromToken(token); + return payload ? parseInt(payload.sub) : null; +} + +/** + * 从JWT Token中获取用户角色ID + * @param token JWT Token + * @returns 角色ID + */ +export function getRoleIdFromToken(token: string): number | null { + const payload = getPayloadFromToken(token); + return payload ? payload.role_id : null; +} + +/** + * 从JWT Token中获取用户名 + * @param token JWT Token + * @returns 用户名 + */ +export function getUsernameFromToken(token: string): string | null { + const payload = getPayloadFromToken(token); + return payload ? payload.user : null; +} + +/** + * 验证token是否过期 + * @param token JWT Token + * @returns 是否有效 + */ +export function isTokenValid(token: string): boolean { + const payload = getPayloadFromToken(token); + if (!payload) return false; + + const now = Math.floor(Date.now() / 1000); + return payload.exp > now; +} + +// JwtUtil对象,提供便捷的静态方法调用 +export const JwtUtil = { + /** + * 从localStorage获取token并解析用户ID + * @returns 用户ID + */ + getUserIdFromToken(): number | null { + const token = localStorage.getItem('token'); + return token ? getUserIdFromToken(token) : null; + }, + + /** + * 从localStorage获取token并解析角色ID + * @returns 角色ID + */ + getRoleIdFromToken(): number | null { + const token = localStorage.getItem('token'); + return token ? getRoleIdFromToken(token) : null; + }, + + /** + * 从localStorage获取token并解析用户名 + * @returns 用户名 + */ + getUsernameFromToken(): string | null { + const token = localStorage.getItem('token'); + return token ? getUsernameFromToken(token) : null; + }, + + /** + * 验证localStorage中的token是否有效 + * @returns 是否有效 + */ + isTokenValid(): boolean { + const token = localStorage.getItem('token'); + return token ? isTokenValid(token) : false; + } +}; \ No newline at end of file diff --git a/vite-frontend/src/utils/logout.ts b/vite-frontend/src/utils/logout.ts new file mode 100644 index 0000000..de9885e --- /dev/null +++ b/vite-frontend/src/utils/logout.ts @@ -0,0 +1,24 @@ +/** + * 安全退出登录函数 + * 清除登录相关数据,但保留用户偏好设置(如主题) + */ +export const safeLogout = () => { + // 需要保留的用户偏好设置 + const preservedSettings = { + theme: localStorage.getItem('heroui-theme'), + // 可以在这里添加其他需要保留的设置 + }; + + // 清除所有localStorage数据 + localStorage.clear(); + + // 恢复保留的设置 + Object.entries(preservedSettings).forEach(([key, value]) => { + if (value) { + if (key === 'theme') { + localStorage.setItem('heroui-theme', value); + } + // 可以在这里添加其他设置的恢复逻辑 + } + }); +}; \ No newline at end of file diff --git a/vite-frontend/src/vite-env.d.ts b/vite-frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/vite-frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vite-frontend/tailwind.config.js b/vite-frontend/tailwind.config.js new file mode 100644 index 0000000..db470c4 --- /dev/null +++ b/vite-frontend/tailwind.config.js @@ -0,0 +1,17 @@ +import {heroui} from "@heroui/theme" + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + './src/layouts/**/*.{js,ts,jsx,tsx,mdx}', + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + darkMode: "class", + plugins: [heroui()], +} diff --git a/vite-frontend/tsconfig.json b/vite-frontend/tsconfig.json new file mode 100644 index 0000000..456359a --- /dev/null +++ b/vite-frontend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/vite-frontend/tsconfig.node.json b/vite-frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/vite-frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite-frontend/vercel.json b/vite-frontend/vercel.json new file mode 100644 index 0000000..1db5d80 --- /dev/null +++ b/vite-frontend/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/" } + ] +} \ No newline at end of file diff --git a/vite-frontend/vite.config.ts b/vite-frontend/vite.config.ts new file mode 100644 index 0000000..624375e --- /dev/null +++ b/vite-frontend/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 3000, + host: '0.0.0.0' + }, + build: { + outDir: 'dist', + sourcemap: false + } +}); diff --git a/vue-frontend/src/views/Forward.vue b/vue-frontend/src/views/Forward.vue index b54789d..76217e7 100644 --- a/vue-frontend/src/views/Forward.vue +++ b/vue-frontend/src/views/Forward.vue @@ -241,8 +241,11 @@ > -
- 允许范围: {{ selectedTunnel.inPortSta }}-{{ selectedTunnel.inPortEnd }},留空将自动分配可用端口 +
+ 允许范围: {{ selectedTunnel.inNodePortSta }}-{{ selectedTunnel.inNodePortEnd }},留空将自动分配可用端口 +
+
+ 已选择隧道: {{ selectedTunnel.name }},留空将自动分配可用端口
请先选择隧道以查看端口范围,留空将自动分配可用端口 @@ -430,10 +433,10 @@ export default { return; } - // 检查是否在隧道允许范围内 - if (this.selectedTunnel) { - if (value < this.selectedTunnel.inPortSta || value > this.selectedTunnel.inPortEnd) { - callback(new Error(`端口号必须在${this.selectedTunnel.inPortSta}-${this.selectedTunnel.inPortEnd}范围内`)); + // 检查是否在节点允许范围内 + if (this.selectedTunnel && this.selectedTunnel.inNodePortSta && this.selectedTunnel.inNodePortEnd) { + if (value < this.selectedTunnel.inNodePortSta || value > this.selectedTunnel.inNodePortEnd) { + callback(new Error(`端口号必须在${this.selectedTunnel.inNodePortSta}-${this.selectedTunnel.inNodePortEnd}范围内`)); return; } } @@ -582,6 +585,8 @@ export default { async handleAdd() { this.isEdit = false; this.dialogTitle = '新增转发'; + // 重置表单状态,确保selectedTunnel被清空 + this.resetForm(); try { // 只在没有隧道数据时才加载 diff --git a/vue-frontend/src/views/Home.vue b/vue-frontend/src/views/Home.vue index 76895a9..491b27f 100644 --- a/vue-frontend/src/views/Home.vue +++ b/vue-frontend/src/views/Home.vue @@ -9,7 +9,7 @@ @click="toggleMobileMenu" type="text"> - 管理后台1.0.1 + 管理后台1.0.2
diff --git a/vue-frontend/src/views/Tunnel.vue b/vue-frontend/src/views/Tunnel.vue index 4baf503..1eacd3b 100644 --- a/vue-frontend/src/views/Tunnel.vue +++ b/vue-frontend/src/views/Tunnel.vue @@ -208,24 +208,7 @@ - - - - - - + - - - - - - { if (valid) { - // 端口范围验证 - if (this.tunnelForm.inPortSta > this.tunnelForm.inPortEnd) { - this.$message.error('入口起始端口不能大于结束端口'); - return; - } - // 隧道转发时验证入口和出口节点不能相同 if (this.tunnelForm.type === 2 && this.tunnelForm.inNodeId === this.tunnelForm.outNodeId) { @@ -762,13 +695,6 @@ export default { return; } - if (this.tunnelForm.type === 2 && - this.tunnelForm.outIpSta && this.tunnelForm.outIpEnd && - this.tunnelForm.outIpSta > this.tunnelForm.outIpEnd) { - this.$message.error('出口起始端口不能大于结束端口'); - return; - } - this.submitLoading = true; const data = { ...this.tunnelForm }; @@ -780,10 +706,6 @@ export default { name: data.name, flow: data.flow, trafficRatio: data.trafficRatio, - inPortSta: data.inPortSta, - inPortEnd: data.inPortEnd, - outIpSta: data.outIpSta, - outIpEnd: data.outIpEnd, tcpListenAddr: data.tcpListenAddr, udpListenAddr: data.udpListenAddr }; @@ -829,11 +751,7 @@ export default { id: null, name: '', inNodeId: null, - inPortSta: 1, - inPortEnd: 65535, outNodeId: null, - outIpSta: 1, - outIpEnd: 65535, type: 1, flow: 1, // 默认单向计算 status: 1, // 默认启用 diff --git a/vue-frontend/src/views/node.vue b/vue-frontend/src/views/node.vue index eb7da20..515b16b 100644 --- a/vue-frontend/src/views/node.vue +++ b/vue-frontend/src/views/node.vue @@ -37,6 +37,11 @@ {{ node.serverIp }}
+ +
+ + 版本: {{ node.version || '未知' }} +
@@ -179,95 +184,47 @@ > - -
- -
- - - - - -
-
- - 支持IPv4、IPv6地址和域名格式 -
-
- - 已添加 {{ nodeForm.ipList.length }} / 10 个IP -
-
-
- - -
-
- - - 已配置的入口IP - - - - 清空全部 - -
- -
-
- - - {{ ip }} - -
-
-
- - -
- -

请添加至少一个入口IP地址

-
- 示例: - 192.168.1.100 - example.com -
-
+ + + +
+ 格式: IPv4、IPv6地址或域名
+ 支持多个IP,每行一个地址,例如:
+ 192.168.1.100
+ 2001:db8::1
+ example.com
+ + 端口配置 + + + + + + + + + + { - if (!value || value.length === 0) { - callback(new Error('请至少添加一个入口IP地址')); + if (value) { + // 按行分割IP地址 + const ips = value.split('\n').map(ip => ip.trim()).filter(ip => ip); + + if (ips.length === 0) { + callback(new Error('请输入至少一个有效IP地址')); + return; + } + + // 验证每个IP地址 + for (let i = 0; i < ips.length; i++) { + const ip = ips[i]; + if (!this.validateIp(ip)) { + callback(new Error(`第${i + 1}行IP地址格式错误: ${ip}\n请使用正确格式,如: 192.168.1.100 或 2001:db8::1 或 example.com`)); + return; + } + } + + callback(); } else { callback(); } }, - trigger: 'change' + trigger: 'blur' } ], serverIp: [ @@ -377,6 +360,24 @@ export default { }, trigger: 'blur' } + ], + portSta: [ + { required: true, message: '请输入起始端口', trigger: 'blur' }, + { type: 'number', min: 1, max: 65535, message: '端口范围必须在1-65535之间', trigger: 'blur' } + ], + portEnd: [ + { required: true, message: '请输入结束端口', trigger: 'blur' }, + { type: 'number', min: 1, max: 65535, message: '端口范围必须在1-65535之间', trigger: 'blur' }, + { + validator: (rule, value, callback) => { + if (value && this.nodeForm.portSta && value < this.nodeForm.portSta) { + callback(new Error('结束端口不能小于起始端口')); + } else { + callback(); + } + }, + trigger: 'blur' + } ] }, websocket: null, @@ -465,8 +466,10 @@ export default { this.nodeForm = { id: node.id, name: node.name, - ipList: node.ip ? (typeof node.ip === 'string' ? node.ip.split(',').filter(ip => ip.trim()) : []) : [], - serverIp: node.serverIp || '' + ipString: node.ip ? (typeof node.ip === 'string' ? node.ip.split(',').map(ip => ip.trim()).join('\n') : '') : '', + serverIp: node.serverIp || '', + portSta: node.portSta, + portEnd: node.portEnd }; this.dialogVisible = true; }, @@ -524,15 +527,23 @@ export default { this.submitLoading = true; const apiCall = this.isEdit ? updateNode : createNode; + const ipString = this.nodeForm.ipString + .split('\n') + .map(ip => ip.trim()) + .filter(ip => ip) + .join(','); + const submitData = { ...this.nodeForm, - ip: this.nodeForm.ipList.join(',') // 将IP数组转换为逗号分隔的字符串 + ip: ipString // 将多行IP字符串转换为逗号分隔的字符串 }; - delete submitData.ipList; // 移除ipList字段 + delete submitData.ipString; // 移除ipString字段 const data = this.isEdit ? submitData : { name: this.nodeForm.name, - ip: this.nodeForm.ipList.join(','), - serverIp: this.nodeForm.serverIp + ip: ipString, + serverIp: this.nodeForm.serverIp, + portSta: this.nodeForm.portSta, + portEnd: this.nodeForm.portEnd }; apiCall(data).then(res => { @@ -546,8 +557,10 @@ export default { const existingNode = this.nodeList.find(n => n.id === this.nodeForm.id); if (existingNode) { existingNode.name = this.nodeForm.name; - existingNode.ip = this.nodeForm.ipList.join(','); // 正确设置IP字段 + existingNode.ip = ipString; // 正确设置IP字段 existingNode.serverIp = this.nodeForm.serverIp; // 更新服务器IP + existingNode.portSta = this.nodeForm.portSta; // 更新起始端口 + existingNode.portEnd = this.nodeForm.portEnd; // 更新结束端口 existingNode.updatedTime = Date.now(); // 更新时间戳 } } else { @@ -601,10 +614,11 @@ export default { this.nodeForm = { id: null, name: '', - ipList: [], - serverIp: '' + ipString: '', + serverIp: '', + portSta: 1000, + portEnd: 65535 }; - this.newIpInput = ''; if (this.$refs.nodeForm) { this.$refs.nodeForm.clearValidate(); } @@ -872,146 +886,7 @@ export default { return false; }, - // 添加IP地址 - addIp() { - if (!this.newIpInput || !this.newIpInput.trim()) return; - - const ip = this.newIpInput.trim(); - - // 检查数量限制 - if (this.nodeForm.ipList.length >= 10) { - this.$message({ - message: '最多只能添加10个IP地址', - type: 'warning', - duration: 3000, - showClose: true - }); - return; - } - - // 验证IP格式 - if (!this.validateIp(ip)) { - this.$message({ - message: '请输入有效的IP地址或域名(支持IPv4、IPv6和域名格式)', - type: 'error', - duration: 3000, - showClose: true - }); - return; - } - - // 检查是否已存在 - if (this.nodeForm.ipList.includes(ip)) { - this.$message({ - message: '该IP地址已存在', - type: 'warning', - duration: 3000, - showClose: true - }); - return; - } - - // 添加到列表 - this.nodeForm.ipList.push(ip); - this.newIpInput = ''; - - // 触发表单验证 - this.$nextTick(() => { - if (this.$refs.nodeForm) { - this.$refs.nodeForm.validateField('ipList'); - } - }); - - }, - - // 移除IP地址 - removeIp(index) { - const removedIp = this.nodeForm.ipList[index]; - this.nodeForm.ipList.splice(index, 1); - - // 触发表单验证 - this.$nextTick(() => { - if (this.$refs.nodeForm) { - this.$refs.nodeForm.validateField('ipList'); - } - }); - - - }, - - // 清空所有IP地址 - clearAllIps() { - this.$confirm('确定要清空所有IP地址吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'warning' - }).then(() => { - const count = this.nodeForm.ipList.length; - this.nodeForm.ipList = []; - - // 触发表单验证 - this.$nextTick(() => { - if (this.$refs.nodeForm) { - this.$refs.nodeForm.validateField('ipList'); - } - }); - - this.$message({ - message: `已清空 ${count} 个IP地址`, - type: 'success', - duration: 2000 - }); - }).catch(() => { - // 用户取消 - }); - }, - - // 填充示例IP - fillExample(exampleIp) { - if (this.nodeForm.ipList.includes(exampleIp)) { - this.$message({ - message: '该示例IP已存在', - type: 'warning', - duration: 2000 - }); - return; - } - - if (this.nodeForm.ipList.length >= 10) { - this.$message({ - message: '最多只能添加10个IP地址', - type: 'warning', - duration: 2000 - }); - return; - } - - this.newIpInput = exampleIp; - // 自动聚焦到输入框 - this.$nextTick(() => { - const input = this.$el.querySelector('.ip-input input'); - if (input) { - input.focus(); - } - }); - }, - - // 获取IP类型图标 - getIpIcon(ip) { - // IPv4格式验证 - const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - - // IPv6格式验证 - const ipv6Regex = /^((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/; - - if (ipv4Regex.test(ip)) { - return 'el-icon-monitor'; // IPv4 - } else if (ipv6Regex.test(ip)) { - return 'el-icon-cpu'; // IPv6 - } else { - return 'el-icon-link'; // 域名 - } - }, + // 验证单个IP地址(用于服务器IP验证) validateSingleIp(ip) { @@ -1077,22 +952,25 @@ export default { } }, - // 获取IP标签类型(根据IP类型设置不同颜色) - getIpTagType(ip) { - // IPv4格式验证 - const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - - // IPv6格式验证 - const ipv6Regex = /^((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/; - - if (ipv4Regex.test(ip)) { - return 'primary'; // IPv4 - 蓝色 - } else if (ipv6Regex.test(ip)) { - return 'success'; // IPv6 - 绿色 - } else { - return 'warning'; // 域名 - 橙色 - } - } + // 端口起始值变化处理 + handlePortStartChange() { + this.$nextTick(() => { + if (this.$refs.nodeForm) { + this.$refs.nodeForm.validateField('portEnd'); + } + }); + }, + + // 端口结束值变化处理 + handlePortEndChange() { + this.$nextTick(() => { + if (this.$refs.nodeForm) { + this.$refs.nodeForm.validateField('portEnd'); + } + }); + }, + + } }; @@ -1213,6 +1091,20 @@ export default { font-size: 12px; } +.version-info { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: #909399; + margin-top: 4px; +} + +.version-info i { + color: #67c23a; + font-size: 12px; +} + /* 指标网格 */ .metrics-grid { display: grid; @@ -1378,6 +1270,10 @@ export default { font-size: 11px; } + .version-info { + font-size: 11px; + } + .metrics-grid { gap: 6px; margin-bottom: 10px; @@ -1425,239 +1321,12 @@ export default { -/* IP输入组件样式 */ -.ip-input-container { - width: 100%; -} - -/* IP输入区域 */ -.ip-input-section { - margin-bottom: 16px; -} - -.ip-input { - width: 100%; - margin-bottom: 8px; -} - -.ip-input-tips { - display: flex; - justify-content: space-between; - align-items: center; +/* 表单提示样式 */ +.form-hint { font-size: 12px; color: #909399; - margin-top: 6px; -} - -.tip-item { - display: flex; - align-items: center; - gap: 4px; -} - -.tip-item i { - font-size: 14px; -} - -.count-tip { - color: #606266; - font-weight: 500; -} - -/* IP列表区域 */ -.ip-list-section { - background: #fafbfc; - border: 1px solid #e4e7ed; - border-radius: 8px; - padding: 16px; - margin-bottom: 12px; -} - -.ip-list-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; - padding-bottom: 8px; - border-bottom: 1px solid #e4e7ed; -} - -.list-title { - font-size: 14px; - font-weight: 600; - color: #303133; - display: flex; - align-items: center; - gap: 6px; -} - -.list-title i { - color: #409eff; -} - -.clear-all-btn { - color: #f56c6c; - padding: 4px 8px; -} - -.clear-all-btn:hover { - color: #f02d2d; - background: rgba(245, 108, 108, 0.1); -} - -.ip-tags-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 8px; -} - -.ip-tag-item { - display: flex; - align-items: center; -} - -.ip-tag { - font-family: monospace; - font-size: 12px; - width: 100%; - display: flex; - align-items: center; - gap: 6px; - justify-content: space-between; - padding: 6px 12px; - border-radius: 6px; - transition: all 0.3s ease; -} - -.ip-tag:hover { - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.ip-icon { - font-size: 14px; - margin-right: 4px; -} - -/* 空状态样式 */ -.ip-empty-state { - text-align: center; - padding: 40px 20px; - background: #fafbfc; - border: 2px dashed #d9d9d9; - border-radius: 8px; - color: #999; -} - -.ip-empty-state i { - font-size: 48px; - margin-bottom: 12px; - color: #d9d9d9; -} - -.ip-empty-state p { - font-size: 14px; - margin-bottom: 16px; - color: #666; -} - -.example-ips { - display: flex; - justify-content: center; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.example-label { - font-size: 12px; - color: #909399; - margin-right: 4px; -} - -.example-tag { - cursor: pointer; - transition: all 0.3s ease; - font-family: monospace; -} - -.example-tag:hover { - transform: scale(1.05); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -/* 响应式样式 */ -@media (max-width: 768px) { - .ip-input-tips { - flex-direction: column; - align-items: flex-start; - gap: 4px; - } - - .ip-list-section { - padding: 12px; - } - - .ip-list-header { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .clear-all-btn { - align-self: flex-end; - } - - .ip-tags-grid { - grid-template-columns: 1fr; - gap: 6px; - } - - .ip-tag { - font-size: 11px; - padding: 5px 10px; - } - - .ip-empty-state { - padding: 30px 15px; - } - - .ip-empty-state i { - font-size: 36px; - } - - .example-ips { - flex-direction: column; - gap: 6px; - } - - .example-label { - margin-right: 0; - margin-bottom: 4px; - } -} - -@media (max-width: 480px) { - .ip-input-section { - margin-bottom: 12px; - } - - .ip-list-section { - padding: 10px; - } - - .ip-tags-grid { - gap: 4px; - } - - .ip-tag { - font-size: 10px; - padding: 4px 8px; - } - - .ip-empty-state { - padding: 20px 10px; - } + margin-top: 5px; + line-height: 1.4; } /* 复制对话框样式 */