|
@@ -0,0 +1,290 @@
|
|
|
|
|
+package com.zsjz.gateway.admin;
|
|
|
|
|
+
|
|
|
|
|
+import com.zsjz.gateway.config.GatewayProperties;
|
|
|
|
|
+import com.zsjz.gateway.registry.GatewayConfigRepository;
|
|
|
|
|
+import com.zsjz.gateway.registry.GatewayRegistry;
|
|
|
|
|
+import com.zsjz.gateway.registry.HealthChecker;
|
|
|
|
|
+import com.zsjz.gateway.registry.NodeHealthRegistry;
|
|
|
|
|
+import com.zsjz.gateway.registry.NodeInfo;
|
|
|
|
|
+import com.zsjz.gateway.registry.ServiceInfo;
|
|
|
|
|
+import com.zsjz.gateway.route.UserRouteRepository;
|
|
|
|
|
+import com.zsjz.gateway.route.UserRouteService;
|
|
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.springframework.dao.DuplicateKeyException;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+import org.springframework.transaction.support.TransactionTemplate;
|
|
|
|
|
+import reactor.core.publisher.Mono;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 管理接口服务层(阻塞 JDBC,控制器统一放 boundedElastic)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>写操作完成后调用 {@link GatewayRegistry#reload()}:本实例路由立即刷新;
|
|
|
|
|
+ * 其他网关实例靠 registry 的 10 秒对账收敛。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+@RequiredArgsConstructor
|
|
|
|
|
+public class AdminService {
|
|
|
|
|
+
|
|
|
|
|
+ private final GatewayConfigRepository configRepository;
|
|
|
|
|
+ private final UserRouteRepository routeRepository;
|
|
|
|
|
+ private final UserRouteService userRouteService;
|
|
|
|
|
+ private final GatewayRegistry registry;
|
|
|
|
|
+ private final NodeHealthRegistry healthRegistry;
|
|
|
|
|
+ private final HealthChecker healthChecker;
|
|
|
|
|
+ private final TransactionTemplate transactionTemplate;
|
|
|
|
|
+ private final GatewayProperties properties;
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 状态
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ public Map<String, Object> status() {
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ List<Map<String, Object>> services = new ArrayList<>();
|
|
|
|
|
+ for (ServiceInfo service : registry.getServices()) {
|
|
|
|
|
+ Map<String, Object> svc = new LinkedHashMap<>();
|
|
|
|
|
+ svc.put("id", service.id());
|
|
|
|
|
+ svc.put("name", service.name());
|
|
|
|
|
+ svc.put("pathPrefix", service.pathPrefix());
|
|
|
|
|
+ svc.put("enabled", service.enabled());
|
|
|
|
|
+ svc.put("defaultNodeId", service.defaultNodeId());
|
|
|
|
|
+ svc.put("routeCount", routeRepository.countByService(service.id()));
|
|
|
|
|
+ List<Map<String, Object>> nodes = new ArrayList<>();
|
|
|
|
|
+ for (NodeInfo node : service.nodes()) {
|
|
|
|
|
+ Map<String, Object> n = new LinkedHashMap<>();
|
|
|
|
|
+ n.put("id", node.id());
|
|
|
|
|
+ n.put("name", node.name());
|
|
|
|
|
+ n.put("baseUrl", node.baseUrl());
|
|
|
|
|
+ n.put("enabled", node.enabled());
|
|
|
|
|
+ NodeHealthRegistry.Health health = healthRegistry.snapshot().get(node.id());
|
|
|
|
|
+ n.put("state", health == null ? "UNKNOWN" : health.state().name());
|
|
|
|
|
+ n.put("lastError", health == null ? null : health.lastError());
|
|
|
|
|
+ n.put("lastCheckedAt", health == null ? null : health.lastCheckedAt());
|
|
|
|
|
+ n.put("routeCount", routeRepository.countByNode(node.id()));
|
|
|
|
|
+ nodes.add(n);
|
|
|
|
|
+ }
|
|
|
|
|
+ svc.put("nodes", nodes);
|
|
|
|
|
+ services.add(svc);
|
|
|
|
|
+ }
|
|
|
|
|
+ result.put("services", services);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 服务 / 节点
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ public void createService(ServiceUpsert dto) {
|
|
|
|
|
+ GatewayConfigRepository.validateServiceId(dto.id);
|
|
|
|
|
+ GatewayConfigRepository.validatePathPrefix(dto.pathPrefix);
|
|
|
|
|
+ if (configRepository.serviceExists(dto.id)) {
|
|
|
|
|
+ throw AdminApiException.badRequest("服务已存在:" + dto.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ configRepository.upsertService(true, dto.id, requireName(dto.name), dto.pathPrefix,
|
|
|
|
|
+ dto.loginPath, dto.healthPath, dto.defaultNodeId, dto.enabled());
|
|
|
|
|
+ } catch (DuplicateKeyException ex) {
|
|
|
|
|
+ throw AdminApiException.badRequest("path-prefix 已被其他服务占用:" + dto.pathPrefix);
|
|
|
|
|
+ }
|
|
|
|
|
+ registry.reload();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void updateService(String serviceId, ServiceUpsert dto) {
|
|
|
|
|
+ if (!configRepository.serviceExists(serviceId)) {
|
|
|
|
|
+ throw AdminApiException.notFound("服务不存在:" + serviceId);
|
|
|
|
|
+ }
|
|
|
|
|
+ GatewayConfigRepository.validatePathPrefix(dto.pathPrefix);
|
|
|
|
|
+ try {
|
|
|
|
|
+ configRepository.upsertService(false, serviceId, requireName(dto.name), dto.pathPrefix,
|
|
|
|
|
+ dto.loginPath, dto.healthPath, dto.defaultNodeId, dto.enabled());
|
|
|
|
|
+ } catch (DuplicateKeyException ex) {
|
|
|
|
|
+ throw AdminApiException.badRequest("path-prefix 已被其他服务占用:" + dto.pathPrefix);
|
|
|
|
|
+ }
|
|
|
|
|
+ registry.reload();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void deleteService(String serviceId) {
|
|
|
|
|
+ if (!configRepository.serviceExists(serviceId)) {
|
|
|
|
|
+ throw AdminApiException.notFound("服务不存在:" + serviceId);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!configRepository.loadAll().stream().filter(s -> s.id().equals(serviceId))
|
|
|
|
|
+ .findFirst().orElseThrow().nodes().isEmpty()) {
|
|
|
|
|
+ throw AdminApiException.conflict("服务下仍有节点,请先删除节点");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (routeRepository.countByService(serviceId) > 0) {
|
|
|
|
|
+ throw AdminApiException.conflict("服务下仍有用户绑定,请先解绑用户");
|
|
|
|
|
+ }
|
|
|
|
|
+ configRepository.deleteService(serviceId);
|
|
|
|
|
+ registry.reload();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void createNode(String serviceId, NodeUpsert dto) {
|
|
|
|
|
+ if (!configRepository.serviceExists(serviceId)) {
|
|
|
|
|
+ throw AdminApiException.notFound("服务不存在:" + serviceId);
|
|
|
|
|
+ }
|
|
|
|
|
+ GatewayConfigRepository.validateNodeId(dto.id);
|
|
|
|
|
+ GatewayConfigRepository.validateBaseUrl(dto.baseUrl);
|
|
|
|
|
+ if (configRepository.nodeExists(dto.id)) {
|
|
|
|
|
+ throw AdminApiException.badRequest("节点 id 已存在(全局唯一):" + dto.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ configRepository.insertNode(dto.id, serviceId, requireName(dto.name),
|
|
|
|
|
+ dto.baseUrl, dto.enabledOrDefault(), dto.remark);
|
|
|
|
|
+ } catch (DuplicateKeyException ex) {
|
|
|
|
|
+ throw AdminApiException.badRequest("同一服务下 base-url 已存在:" + dto.baseUrl);
|
|
|
|
|
+ }
|
|
|
|
|
+ registry.reload();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void updateNode(String nodeId, NodeUpsert dto) {
|
|
|
|
|
+ ServiceInfo owner = registry.getServices().stream()
|
|
|
|
|
+ .flatMap(s -> s.nodes().stream().filter(n -> n.id().equals(nodeId))
|
|
|
|
|
+ .map(n -> Map.entry(s, n)))
|
|
|
|
|
+ .findFirst().map(Map.Entry::getKey).orElse(null);
|
|
|
|
|
+ if (owner == null) {
|
|
|
|
|
+ throw AdminApiException.notFound("节点不存在:" + nodeId);
|
|
|
|
|
+ }
|
|
|
|
|
+ GatewayConfigRepository.validateBaseUrl(dto.baseUrl);
|
|
|
|
|
+ try {
|
|
|
|
|
+ configRepository.updateNode(nodeId, requireName(dto.name), dto.baseUrl,
|
|
|
|
|
+ dto.enabledOrDefault(), dto.remark);
|
|
|
|
|
+ } catch (DuplicateKeyException ex) {
|
|
|
|
|
+ throw AdminApiException.badRequest("同一服务下 base-url 已存在:" + dto.baseUrl);
|
|
|
|
|
+ }
|
|
|
|
|
+ registry.reload();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void deleteNode(String nodeId) {
|
|
|
|
|
+ if (!configRepository.nodeExists(nodeId)) {
|
|
|
|
|
+ throw AdminApiException.notFound("节点不存在:" + nodeId);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (routeRepository.countByNode(nodeId) > 0) {
|
|
|
|
|
+ throw AdminApiException.conflict("节点上仍有用户绑定,请先解绑或重绑用户");
|
|
|
|
|
+ }
|
|
|
|
|
+ configRepository.deleteNode(nodeId);
|
|
|
|
|
+ registry.reload();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 手动探活(立即执行一次,不等定时任务) */
|
|
|
|
|
+ public Mono<Map<String, Object>> checkNode(String nodeId) {
|
|
|
|
|
+ for (ServiceInfo service : registry.getServices()) {
|
|
|
|
|
+ for (NodeInfo node : service.nodes()) {
|
|
|
|
|
+ if (node.id().equals(nodeId)) {
|
|
|
|
|
+ int threshold = properties.getHealthCheck().getFailureThreshold();
|
|
|
|
|
+ return healthChecker.probe(service, node, threshold).map(state -> {
|
|
|
|
|
+ Map<String, Object> r = new LinkedHashMap<>();
|
|
|
|
|
+ r.put("nodeId", nodeId);
|
|
|
|
|
+ r.put("state", state.name());
|
|
|
|
|
+ return r;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw AdminApiException.notFound("节点不存在:" + nodeId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 用户路由
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ public Object pageRoutes(String serviceId, String keyword, int page, int size) {
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("total", routeRepository.countRoutes(serviceId, keyword));
|
|
|
|
|
+ result.put("items", routeRepository.pageRoutes(serviceId, keyword,
|
|
|
|
|
+ Math.max(page, 0), Math.min(Math.max(size, 1), 200)));
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 预绑定 / 重绑:覆盖式(先删该用户已有路由再插入新绑定) */
|
|
|
|
|
+ public void bindRoute(BindRequest dto) {
|
|
|
|
|
+ ServiceInfo service = registry.getService(dto.serviceId)
|
|
|
|
|
+ .orElseThrow(() -> AdminApiException.notFound("服务不存在:" + dto.serviceId));
|
|
|
|
|
+ NodeInfo node = service.findNode(dto.nodeId)
|
|
|
|
|
+ .orElseThrow(() -> AdminApiException.badRequest("节点不属于服务 " + dto.serviceId));
|
|
|
|
|
+ if (!node.enabled()) {
|
|
|
|
|
+ throw AdminApiException.badRequest("节点已停用:" + dto.nodeId);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dto.userId == null && (dto.username == null || dto.username.isBlank())) {
|
|
|
|
|
+ throw AdminApiException.badRequest("userId 与 username 至少提供一个");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 以 sys_user 为准补全 userId / username,保证两个唯一索引都能命中
|
|
|
|
|
+ UserRouteRepository.SysUserRow user = dto.userId != null
|
|
|
|
|
+ ? routeRepository.findSysUserById(dto.userId)
|
|
|
|
|
+ : routeRepository.findSysUserByUsername(dto.username);
|
|
|
|
|
+ if (user == null) {
|
|
|
|
|
+ throw AdminApiException.notFound("用户不存在(sys_user)");
|
|
|
|
|
+ }
|
|
|
|
|
+ Long userId = user.id();
|
|
|
|
|
+ String username = user.username();
|
|
|
|
|
+ transactionTemplate.executeWithoutResult(tx -> {
|
|
|
|
|
+ routeRepository.deleteRoutesForUser(dto.serviceId, userId, username);
|
|
|
|
|
+ routeRepository.insertRoute(dto.serviceId, userId, username, dto.nodeId);
|
|
|
|
|
+ });
|
|
|
|
|
+ // 缓存清理放事务外:失败只影响缓存时效,不影响正确性
|
|
|
|
|
+ userRouteService.evictUserCache(dto.serviceId, userId, username).block();
|
|
|
|
|
+ log.info("管理端绑定用户 {}({}) -> 节点 {}", username, userId, dto.nodeId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void unbind(long routeId) {
|
|
|
|
|
+ UserRouteRepository.RouteRow row = routeRepository.findRouteById(routeId);
|
|
|
|
|
+ if (row == null) {
|
|
|
|
|
+ throw AdminApiException.notFound("路由不存在:" + routeId);
|
|
|
|
|
+ }
|
|
|
|
|
+ routeRepository.deleteRouteById(routeId);
|
|
|
|
|
+ userRouteService.evictUserCache(row.serviceId(), row.userId(), row.username()).block();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Object importExisting(String serviceId, String nodeId) {
|
|
|
|
|
+ ServiceInfo service = registry.getService(serviceId)
|
|
|
|
|
+ .orElseThrow(() -> AdminApiException.notFound("服务不存在:" + serviceId));
|
|
|
|
|
+ if (service.findNode(nodeId).isEmpty()) {
|
|
|
|
|
+ throw AdminApiException.badRequest("节点不属于服务 " + serviceId);
|
|
|
|
|
+ }
|
|
|
|
|
+ int inserted = routeRepository.importExisting(serviceId, nodeId);
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("inserted", inserted);
|
|
|
|
|
+ result.put("total", routeRepository.countByService(serviceId));
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Object reload() {
|
|
|
|
|
+ boolean changed = registry.reload();
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("changed", changed);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String requireName(String name) {
|
|
|
|
|
+ if (name == null || name.isBlank()) {
|
|
|
|
|
+ throw AdminApiException.badRequest("name 不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ return name.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // DTO
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ public record ServiceUpsert(String id, String name, String pathPrefix, String loginPath,
|
|
|
|
|
+ String healthPath, String defaultNodeId, Boolean enabled) {
|
|
|
|
|
+ public boolean enabledOrDefault() {
|
|
|
|
|
+ return enabled == null || enabled;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public record NodeUpsert(String id, String name, String baseUrl, Boolean enabled, String remark) {
|
|
|
|
|
+ public boolean enabledOrDefault() {
|
|
|
|
|
+ return enabled == null || enabled;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public record BindRequest(String serviceId, Long userId, String username, String nodeId) {
|
|
|
|
|
+ }
|
|
|
|
|
+}
|