From d4bfe8a3b4106976427af65efa407c7f3c4d88d9 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 21 Sep 2026 00:21:28 +0800 Subject: [PATCH] =?UTF-8?q?T0.10:=20RouteRegistry=20Caffeine=20=E8=A3=85?= =?UTF-8?q?=E8=BD=BD/=E5=88=A4=E5=AE=9A=EF=BC=88=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E4=BC=98=E5=85=88+/**=E9=80=9A=E9=85=8D+=E6=9C=80=E4=B8=A5?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=EF=BC=89=E4=B8=8E=20/api/internal/routes/ref?= =?UTF-8?q?resh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/route/RouteRefreshController.java | 30 +++++ .../platform/gateway/route/RouteRegistry.java | 104 ++++++++++++++++ .../platform/gateway/route/RouteSpec.java | 26 ++++ .../gateway/route/RouteRegistryTest.java | 112 ++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java create mode 100644 src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java create mode 100644 src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java create mode 100644 src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java new file mode 100644 index 0000000..97f150a --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java @@ -0,0 +1,30 @@ +package com.yunhai.platform.gateway.route; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达, + * 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。 + */ +@RestController +@RequestMapping("/api/internal") +public class RouteRefreshController { + + @Autowired + private RouteRegistry routeRegistry; + + /** 清缓存重 load,返回加载数 */ + @PostMapping("/routes/refresh") + public Map refresh() { + int loaded = routeRegistry.reload(); + Map body = new LinkedHashMap<>(); + body.put("loaded", loaded); + return body; + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java new file mode 100644 index 0000000..2159b44 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java @@ -0,0 +1,104 @@ +package com.yunhai.platform.gateway.route; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.yunhai.platform.gateway.entity.OpenApiRoute; +import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 路由注册表 Caffeine 装载与判定(T0.10):路由判定 O(1) 不查库,变更后手动刷新即生效。 + *

+ * 条目量 <100,不给 Caffeine 设 expire、不引 Redis——全量重 load 即同步。 + * 匹配坐标系:pathAfterStrip = 剥掉 target 首段后的路径(如 /oapi/message/list), + * 与注册表 path_pattern 同源。先精确匹配,再 {@code /**} 前缀通配;未命中返回 + * {@link RouteSpec#DEFAULT}(最严默认,忘配不裸奔)。 + */ +@Component +public class RouteRegistry { + + private static class Entry { + final String pattern; + final String httpMethod; + final RouteSpec spec; + + Entry(OpenApiRoute r) { + this.pattern = r.getPathPattern(); + this.httpMethod = r.getHttpMethod(); + this.spec = new RouteSpec(r.getVisibility(), r.getAuthMode(), r.getCacheTtlSeconds(), + r.getCacheScope(), r.getRateLimitQps()); + } + } + + @Autowired + private OpenApiRouteRepository routeRepo; + + /** key = targetCode;无 expire(条目 <100,靠手动刷新) */ + private final Cache> cache = Caffeine.newBuilder().build(); + + @PostConstruct + public void init() { + reload(); + } + + /** 全量重 load(@PostConstruct 与 refresh 端点共用);返回加载数 */ + public int reload() { + Map> fresh = new HashMap<>(); + int count = 0; + for (OpenApiRoute r : routeRepo.findByEnabledTrue()) { + if (!r.isEnabled()) { + continue; // 防御双保险:repo 已按 enabled=true 过滤,注册表自身再滤一层 + } + fresh.computeIfAbsent(r.getTargetCode(), k -> new ArrayList<>()).add(new Entry(r)); + count++; + } + cache.invalidateAll(); + cache.asMap().putAll(fresh); + return count; + } + + /** + * 路由判定。method 参与匹配(条目 * 或精确相等);精确 pattern 优先于 /** 通配。 + */ + public RouteSpec resolve(String targetCode, String pathAfterStrip, String method) { + List entries = cache.asMap().get(targetCode); + if (entries != null) { + Entry wildcardHit = null; + for (Entry e : entries) { + if (!methodMatches(e.httpMethod, method)) { + continue; + } + if (e.pattern.equals(pathAfterStrip)) { + return e.spec; // 精确命中,立即返回(优先于通配) + } + if (wildcardHit == null && isWildcardHit(e.pattern, pathAfterStrip)) { + wildcardHit = e; // 先记录,精确命中仍在后续条目时让位 + } + } + if (wildcardHit != null) { + return wildcardHit.spec; + } + } + return RouteSpec.DEFAULT; + } + + private static boolean methodMatches(String patternMethod, String method) { + return "*".equals(patternMethod) || patternMethod.equalsIgnoreCase(method); + } + + /** 仅支持「prefix/**」形态的 Ant 前缀通配(本阶段注册表唯一通配形态) */ + private static boolean isWildcardHit(String pattern, String path) { + if (!pattern.endsWith("/**")) { + return false; + } + String prefix = pattern.substring(0, pattern.length() - 3); + return path.equals(prefix) || path.startsWith(prefix + "/"); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java b/src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java new file mode 100644 index 0000000..9063e29 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java @@ -0,0 +1,26 @@ +package com.yunhai.platform.gateway.route; + +/** + * 路由判定结果(T0.10):注册表条目中参与网关判定的五字段提炼。 + * 未命中时返回 {@link #DEFAULT}——默认值安全:忘配 = 最严判定(INTERNAL+SIGNED+零缓存+不限流), + * 不是裸奔。 + */ +public final class RouteSpec { + + public static final RouteSpec DEFAULT = new RouteSpec("INTERNAL", "SIGNED", 0, "CALLER", 0); + + public final String visibility; + public final String authMode; + public final int cacheTtlSeconds; + public final String cacheScope; + public final int rateLimitQps; + + public RouteSpec(String visibility, String authMode, int cacheTtlSeconds, + String cacheScope, int rateLimitQps) { + this.visibility = visibility; + this.authMode = authMode; + this.cacheTtlSeconds = cacheTtlSeconds; + this.cacheScope = cacheScope; + this.rateLimitQps = rateLimitQps; + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java b/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java new file mode 100644 index 0000000..88a465c --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java @@ -0,0 +1,112 @@ +package com.yunhai.platform.gateway.route; + +import com.yunhai.platform.gateway.entity.OpenApiRoute; +import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * RouteRegistry 五用例(T0.10 TDD):精确命中 / 前缀通配命中 / 未注册取默认(五字段全断言)/ + * enabled=0 不生效 / refresh 后新增条目可见。纯单测,mock repository。 + */ +class RouteRegistryTest { + + private OpenApiRouteRepository routeRepo; + private RouteRegistry registry; + + @BeforeEach + void setUp() { + routeRepo = Mockito.mock(OpenApiRouteRepository.class); + registry = new RouteRegistry(); + ReflectionTestUtils.setField(registry, "routeRepo", routeRepo); + when(routeRepo.findByEnabledTrue()).thenReturn(new ArrayList<>(Arrays.asList( + route("usermsg", "/oapi/message/list", "*", "ANON", 30), + route("usermsg", "/oapi/message/**", "*", "SIGNED", 0)))); + registry.reload(); + } + + private static OpenApiRoute route(String target, String pattern, String method, + String authMode, int ttl) { + OpenApiRoute r = new OpenApiRoute(); + r.setTargetCode(target); + r.setPathPattern(pattern); + r.setHttpMethod(method); + r.setVisibility("INTERNAL"); + r.setAuthMode(authMode); + r.setCacheTtlSeconds(ttl); + r.setCacheScope("CALLER"); + r.setRateLimitQps(0); + r.setEnabled(true); + return r; + } + + @Test + void 精确命中_优先于通配() { + // /oapi/message/list 同时命中精确条目(ANON)与 /oapi/message/**(SIGNED)——精确优先 + RouteSpec spec = registry.resolve("usermsg", "/oapi/message/list", "GET"); + assertThat(spec.authMode).isEqualTo("ANON"); + assertThat(spec.cacheTtlSeconds).isEqualTo(30); + assertThat(spec.visibility).isEqualTo("INTERNAL"); + assertThat(spec.cacheScope).isEqualTo("CALLER"); + assertThat(spec.rateLimitQps).isZero(); + } + + @Test + void 前缀通配命中() { + RouteSpec spec = registry.resolve("usermsg", "/oapi/message/detail", "POST"); + assertThat(spec.authMode).isEqualTo("SIGNED"); + assertThat(spec.cacheTtlSeconds).isZero(); + } + + @Test + void 未注册路径取最严默认_五字段全断言() { + RouteSpec spec = registry.resolve("usermsg", "/oapi/unknown/path", "GET"); + assertThat(spec.visibility).as("visibility").isEqualTo("INTERNAL"); + assertThat(spec.authMode).as("authMode").isEqualTo("SIGNED"); + assertThat(spec.cacheTtlSeconds).as("cacheTtl").isZero(); + assertThat(spec.cacheScope).as("cacheScope").isEqualTo("CALLER"); + assertThat(spec.rateLimitQps).as("qps").isZero(); + } + + @Test + void 未注册target同样取默认() { + RouteSpec spec = registry.resolve("nobody", "/oapi/anything", "GET"); + assertThat(spec.authMode).isEqualTo("SIGNED"); + } + + @Test + void disabled条目不生效() { + // repo 契约按 enabled=true 过滤;注册表自身再滤一层(防御双保险)——disabled 条目即使漏进结果集也不参与判定 + OpenApiRoute disabled = route("usermsg", "/oapi/message/disabled", "*", "ANON", 60); + disabled.setEnabled(false); + when(routeRepo.findByEnabledTrue()).thenReturn(Arrays.asList( + route("usermsg", "/oapi/message/**", "*", "SIGNED", 0), disabled)); + registry.reload(); + assertThat(registry.resolve("usermsg", "/oapi/message/disabled", "GET").authMode) + .as("disabled 条目不生效,走通配 SIGNED").isEqualTo("SIGNED"); + } + + @Test + void refresh后新增条目可见() { + assertThat(registry.resolve("notice", "/oapi/notice/list", "GET").authMode) + .as("刷新前 notice 未注册,取默认").isEqualTo("SIGNED"); + List withNotice = new ArrayList<>(Arrays.asList( + route("usermsg", "/oapi/message/**", "*", "SIGNED", 0), + route("notice", "/oapi/notice/list", "GET", "ANON", 120))); + when(routeRepo.findByEnabledTrue()).thenReturn(withNotice); + int loaded = registry.reload(); + assertThat(loaded).isEqualTo(2); + RouteSpec spec = registry.resolve("notice", "/oapi/notice/list", "GET"); + assertThat(spec.authMode).as("refresh 后新增条目生效").isEqualTo("ANON"); + assertThat(spec.cacheTtlSeconds).isEqualTo(120); + } +}