T0.10: RouteRegistry Caffeine 装载/判定(精确优先+/**通配+最严默认)与 /api/internal/routes/refresh
This commit is contained in:
@@ -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>
|
||||
* 条目量 <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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user