T0.10: RouteRegistry Caffeine 装载/判定(精确优先+/**通配+最严默认)与 /api/internal/routes/refresh

This commit is contained in:
chenxing
2026-09-21 00:21:28 +08:00
parent 06a02750b0
commit d4bfe8a3b4
4 changed files with 272 additions and 0 deletions
@@ -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<String, Object> refresh() {
int loaded = routeRegistry.reload();
Map<String, Object> body = new LinkedHashMap<>();
body.put("loaded", loaded);
return body;
}
}
@@ -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) 不查库,变更后手动刷新即生效。
* <p>
* 条目量 &lt;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<String, List<Entry>> cache = Caffeine.newBuilder().build();
@PostConstruct
public void init() {
reload();
}
/** 全量重 load(@PostConstruct 与 refresh 端点共用);返回加载数 */
public int reload() {
Map<String, List<Entry>> 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<Entry> 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 + "/");
}
}
@@ -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;
}
}
@@ -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<OpenApiRoute> 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);
}
}