From 50f9948b49ad54a9b0405a1f3817eeef167570e8 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 22 Sep 2026 10:14:46 +0800 Subject: [PATCH] =?UTF-8?q?E11:=20open=5Fapi=5Ftarget=20=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=20DB>yml=20=E5=9C=B0=E5=9D=80=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit target base-url 由 open_api_target 快照优先解析,无行/禁用行/空白地址行回落 yml yh.gateway.routes;/api/internal/routes/refresh 同时重载两表。 --- docs/sql/03_target.sql | 18 +++++ .../gateway/entity/OpenApiTarget.java | 66 +++++++++++++++++ .../repository/OpenApiTargetRepository.java | 11 +++ .../gateway/route/RouteRefreshController.java | 6 +- .../platform/gateway/route/RouteRegistry.java | 35 ++++++++++ .../platform/gateway/route/TargetRouter.java | 18 ++++- .../gateway/route/RouteRegistryTest.java | 25 +++++++ .../gateway/route/TargetRouterTest.java | 70 +++++++++++++++++++ src/test/resources/application-test.yml | 6 +- 9 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 docs/sql/03_target.sql create mode 100644 src/main/java/com/yunhai/platform/gateway/entity/OpenApiTarget.java create mode 100644 src/main/java/com/yunhai/platform/gateway/repository/OpenApiTargetRepository.java diff --git a/docs/sql/03_target.sql b/docs/sql/03_target.sql new file mode 100644 index 0000000..aff4cbb --- /dev/null +++ b/docs/sql/03_target.sql @@ -0,0 +1,18 @@ +-- ===================================================================== +-- 开放接口中心 target 地址表(E11,T-D1a) +-- 语义:target_code → 提供方 base-url(容器网内直连)。地址解析优先级 DB > yml(yh.gateway.routes); +-- DB 无行 / enabled=0 / base_url 空白 三者一律回落 yml,故本表可整表为空、可逐行灰度。 +-- 写入方:projman Apply 派生(E12)+ 存量回填(E13);网关运行时只读, +-- 改行后 POST /api/internal/routes/refresh 重载即生效(不重启)。 +-- 可重复执行:CREATE TABLE IF NOT EXISTS(不 DROP——生产回填数据不可丢) +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS open_api_target ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + target_code VARCHAR(32) NOT NULL COMMENT '目标应用 ctx(剥掉首斜杠,如 userbase/notice/flowcenter)', + base_url VARCHAR(256) NOT NULL COMMENT '提供方 base-url(如 http://notice-service:8080,不含路径)', + enabled TINYINT NOT NULL DEFAULT 1, + gmt_create DATETIME NOT NULL, + gmt_modified DATETIME NOT NULL, + UNIQUE KEY uk_target (target_code) +); diff --git a/src/main/java/com/yunhai/platform/gateway/entity/OpenApiTarget.java b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiTarget.java new file mode 100644 index 0000000..6b51cd3 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiTarget.java @@ -0,0 +1,66 @@ +package com.yunhai.platform.gateway.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * target 地址表(E11 T-D1a;DDL 见 docs/sql/03_target.sql,字段与列逐一对齐)。 + *

+ * 与 yml {@code yh.gateway.routes} 同语义(target → base-url),解析优先级 DB > yml: + * 无行 / enabled=0 / base_url 空白一律回落 yml,故派生写入(projman Apply)可逐行灰度。 + * 网关运行时只读,改行后走 {@code /api/internal/routes/refresh} 重载。 + */ +@Entity +@Table(name = "open_api_target", uniqueConstraints = @UniqueConstraint( + name = "uk_target", columnNames = {"target_code"})) +@Getter +@Setter +public class OpenApiTarget { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "target_code", nullable = false, length = 32) + private String targetCode; + + /** 提供方 base-url,不含路径(转发 = base-url + 完整原始 URI,换主机不换路径) */ + @Column(name = "base_url", nullable = false, length = 256) + private String baseUrl; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Column(name = "gmt_create", nullable = false) + private LocalDateTime gmtCreate; + + @Column(name = "gmt_modified", nullable = false) + private LocalDateTime gmtModified; + + @PrePersist + void preCreate() { + LocalDateTime now = LocalDateTime.now(); + if (gmtCreate == null) { + gmtCreate = now; + } + if (gmtModified == null) { + gmtModified = now; + } + } + + @PreUpdate + void preUpdate() { + gmtModified = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/repository/OpenApiTargetRepository.java b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiTargetRepository.java new file mode 100644 index 0000000..be6be95 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiTargetRepository.java @@ -0,0 +1,11 @@ +package com.yunhai.platform.gateway.repository; + +import com.yunhai.platform.gateway.entity.OpenApiTarget; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OpenApiTargetRepository extends JpaRepository { + + List findByEnabledTrue(); +} diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java index 97f150a..a803ad6 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java @@ -19,12 +19,12 @@ public class RouteRefreshController { @Autowired private RouteRegistry routeRegistry; - /** 清缓存重 load,返回加载数 */ + /** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,E11 同端点重载) */ @PostMapping("/routes/refresh") public Map refresh() { - int loaded = routeRegistry.reload(); Map body = new LinkedHashMap<>(); - body.put("loaded", loaded); + body.put("loaded", routeRegistry.reload()); + body.put("targets", routeRegistry.reloadTargets()); 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 index 2159b44..78fcc19 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java @@ -3,13 +3,17 @@ 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.entity.OpenApiTarget; import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; +import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -40,12 +44,22 @@ public class RouteRegistry { @Autowired private OpenApiRouteRepository routeRepo; + @Autowired + private OpenApiTargetRepository targetRepo; + /** key = targetCode;无 expire(条目 <100,靠手动刷新) */ private final Cache> cache = Caffeine.newBuilder().build(); + /** + * target → base-url 的 DB 快照(E11;整体替换,读侧无锁)。 + * 只收 enabled 且地址非空白的行——空白行等同无行,由 {@link TargetRouter} 回落 yml。 + */ + private volatile Map targetBaseUrls = Collections.emptyMap(); + @PostConstruct public void init() { reload(); + reloadTargets(); } /** 全量重 load(@PostConstruct 与 refresh 端点共用);返回加载数 */ @@ -64,6 +78,27 @@ public class RouteRegistry { return count; } + /** + * 全量重 load target 地址表(与 {@link #reload()} 同由 refresh 端点触发);返回装载数。 + * 表为空(E13 回填前的常态)= 全部 target 走 yml 兜底。 + */ + public int reloadTargets() { + Map fresh = new LinkedHashMap<>(); + for (OpenApiTarget t : targetRepo.findByEnabledTrue()) { + if (!t.isEnabled() || t.getBaseUrl() == null || t.getBaseUrl().trim().isEmpty()) { + continue; // 与路由条目同口径双保险:禁用行/空白地址行不覆盖 yml + } + fresh.put(t.getTargetCode(), t.getBaseUrl().trim()); + } + targetBaseUrls = fresh; + return fresh.size(); + } + + /** target 的 DB 地址;无行(含被过滤的禁用/空白行)返回 null = 回落 yml */ + public String targetBaseUrl(String targetCode) { + return targetBaseUrls.get(targetCode); + } + /** * 路由判定。method 参与匹配(条目 * 或精确相等);精确 pattern 优先于 /** 通配。 */ diff --git a/src/main/java/com/yunhai/platform/gateway/route/TargetRouter.java b/src/main/java/com/yunhai/platform/gateway/route/TargetRouter.java index 5730d6f..667b6e9 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/TargetRouter.java +++ b/src/main/java/com/yunhai/platform/gateway/route/TargetRouter.java @@ -10,7 +10,9 @@ import org.springframework.stereotype.Component; * 路由选择、注册表匹配与 canonical 剥离,不参与转发路径改写——提供方自带 context-path, * URI 原样送达即天然对齐,签名 canonical 逐字节不变。
* 网关只做 oapi:未知 target / 单段 URI / 第二段非 oapi(如 /{target}/api/x)一律返回 null - * = 裸 404({@code /api} 与页面不属于网关职责)。 + * = 裸 404({@code /api} 与页面不属于网关职责)。
+ * base-url 解析优先级 DB(open_api_target)> yml(yh.gateway.routes)(E11): + * DB 无行、禁用行、空白地址行都按「无行」处理,回落 yml,故 yml 恒为兜底不被绕过。 */ @Component public class TargetRouter { @@ -33,11 +35,20 @@ public class TargetRouter { @Autowired private GatewayProperties properties; + /** target 地址 DB 快照持有者(E11);解析优先级 DB > yml */ + @Autowired + private RouteRegistry routeRegistry; + /** 测试/显式装配入口 */ void setProperties(GatewayProperties properties) { this.properties = properties; } + /** 测试/显式装配入口 */ + void setRouteRegistry(RouteRegistry routeRegistry) { + this.routeRegistry = routeRegistry; + } + /** * @param requestUri 原始请求 URI(未解码,如 /userbase/oapi/x) * @param queryString 原始 query(无 '?' 前缀;null = 无) @@ -64,7 +75,10 @@ public class TargetRouter { if (!rest.equals("/oapi") && !rest.startsWith("/oapi/")) { return null; } - String baseUrl = properties.getRoutes().get(target); + String baseUrl = routeRegistry.targetBaseUrl(target); + if (baseUrl == null) { + baseUrl = properties.getRoutes().get(target); // DB 无行(含禁用/空白行)→ yml 兜底 + } if (baseUrl == null || baseUrl.trim().isEmpty()) { return null; // 未知 target = 裸 404 } diff --git a/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java b/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java index 88a465c..8fdd957 100644 --- a/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java +++ b/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java @@ -1,7 +1,9 @@ package com.yunhai.platform.gateway.route; import com.yunhai.platform.gateway.entity.OpenApiRoute; +import com.yunhai.platform.gateway.entity.OpenApiTarget; import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; +import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -109,4 +111,27 @@ class RouteRegistryTest { assertThat(spec.authMode).as("refresh 后新增条目生效").isEqualTo("ANON"); assertThat(spec.cacheTtlSeconds).isEqualTo(120); } + + @Test + void reloadTargets_装载数只计有效行_空白地址不入快照() { + // E11:target 地址快照与路由条目同库不同表,装载口径一致(enabled 行 + 非空地址) + OpenApiTargetRepository targetRepo = Mockito.mock(OpenApiTargetRepository.class); + when(targetRepo.findByEnabledTrue()).thenReturn(Arrays.asList( + targetRow("userbase", "http://userbase-service:8080"), + targetRow("notice", " "))); + RouteRegistry reg = new RouteRegistry(); + ReflectionTestUtils.setField(reg, "targetRepo", targetRepo); + assertThat(reg.reloadTargets()).as("空白地址行不装载").isEqualTo(1); + assertThat(reg.targetBaseUrl("userbase")).isEqualTo("http://userbase-service:8080"); + assertThat(reg.targetBaseUrl("notice")).as("空白地址 = 无 DB 行语义,由 TargetRouter 回落 yml").isNull(); + assertThat(reg.targetBaseUrl("nobody")).as("DB 无此 target").isNull(); + } + + private static OpenApiTarget targetRow(String code, String baseUrl) { + OpenApiTarget t = new OpenApiTarget(); + t.setTargetCode(code); + t.setBaseUrl(baseUrl); + t.setEnabled(true); + return t; + } } diff --git a/src/test/java/com/yunhai/platform/gateway/route/TargetRouterTest.java b/src/test/java/com/yunhai/platform/gateway/route/TargetRouterTest.java index cad395f..ce178d1 100644 --- a/src/test/java/com/yunhai/platform/gateway/route/TargetRouterTest.java +++ b/src/test/java/com/yunhai/platform/gateway/route/TargetRouterTest.java @@ -1,20 +1,33 @@ package com.yunhai.platform.gateway.route; +import com.yunhai.platform.gateway.entity.OpenApiTarget; +import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; 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.LinkedHashMap; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; /** * TargetRouter 用例(T0.12 TDD):已知 target forwardUrl 逐字符断言(换主机不换路径)、 * 未知 target / 单段 / 非 oapi 第二段一律裸 404(null)、base-url 尾斜杠归一化。 + *

+ * E11 追加 DB>yml 四例:DB 有行覆盖 yml、DB 无行回落 yml、enabled=0 回落 yml、 + * refresh 后新增行生效。target 快照经真实 {@link RouteRegistry}(mock repository)走, + * 不 stub 解析逻辑本身。 */ class TargetRouterTest { private TargetRouter router; + private RouteRegistry registry; + private List targetRows; @BeforeEach void setUp() { @@ -25,6 +38,26 @@ class TargetRouterTest { routes.put("notice", "http://notice-service:8080/"); props.setRoutes(routes); router.setProperties(props); + + targetRows = new ArrayList<>(); + OpenApiTargetRepository targetRepo = Mockito.mock(OpenApiTargetRepository.class); + when(targetRepo.findByEnabledTrue()).thenReturn(targetRows); + registry = new RouteRegistry(); + ReflectionTestUtils.setField(registry, "targetRepo", targetRepo); + router.setRouteRegistry(registry); + } + + private static OpenApiTarget target(String code, String baseUrl, boolean enabled) { + OpenApiTarget t = new OpenApiTarget(); + t.setTargetCode(code); + t.setBaseUrl(baseUrl); + t.setEnabled(enabled); + return t; + } + + /** 模拟 refresh 端点:重载 target 表快照 */ + private void refreshTargets() { + registry.reloadTargets(); } @Test @@ -76,4 +109,41 @@ class TargetRouterTest { assertThat(r.forwardUrl).isEqualTo("http://userbase-service:8080/userbase/oapi"); assertThat(r.pathAfterStrip).isEqualTo("/oapi"); } + + @Test + void DB有行_用DB地址覆盖yml() { + targetRows.add(target("userbase", "http://10.0.0.9:9999", true)); + refreshTargets(); + TargetRouter.Routing r = router.route("/userbase/oapi/x", "a=1"); + assertThat(r.forwardUrl).as("DB 行优先于 yml 同名 target") + .isEqualTo("http://10.0.0.9:9999/userbase/oapi/x?a=1"); + } + + @Test + void DB无行_回落yml() { + refreshTargets(); + TargetRouter.Routing r = router.route("/userbase/oapi/x", null); + assertThat(r.forwardUrl).isEqualTo("http://userbase-service:8080/userbase/oapi/x"); + } + + @Test + void DB禁用行_回落yml() { + // repo 契约按 enabled=true 过滤;注册表再滤一层(与 RouteRegistry 同口径双保险) + targetRows.add(target("userbase", "http://10.0.0.9:9999", false)); + refreshTargets(); + TargetRouter.Routing r = router.route("/userbase/oapi/x", null); + assertThat(r.forwardUrl).as("enabled=0 行不参与解析").isEqualTo("http://userbase-service:8080/userbase/oapi/x"); + } + + @Test + void DB行只在解析后新增时可见_未refresh不生效() { + refreshTargets(); + targetRows.add(target("flowcenter", "http://flow-center-service:8080", true)); + assertThat(router.route("/flowcenter/oapi/x", null)) + .as("快照未 refresh 前 DB 新行不生效").isNull(); + refreshTargets(); + TargetRouter.Routing r = router.route("/flowcenter/oapi/x", null); + assertThat(r).as("refresh 后 DB 新增 target 生效(yml 无此 target)").isNotNull(); + assertThat(r.forwardUrl).isEqualTo("http://flow-center-service:8080/flowcenter/oapi/x"); + } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 16f0a59..9a316e7 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -6,9 +6,11 @@ spring: password: sql: init: - # 直接跑交付 DDL(docs/sql/01_schema.sql)——round-trip 钉死「DDL ↔ 实体」一致 + # 直接跑交付 DDL(docs/sql/01_schema.sql + 03_target.sql)——round-trip 钉死「DDL ↔ 实体」一致 mode: always - schema-locations: file:docs/sql/01_schema.sql + schema-locations: + - file:docs/sql/01_schema.sql + - file:docs/sql/03_target.sql jpa: hibernate: ddl-auto: none