T0.12: GatewayProperties 路由表 + TargetRouter(/{target}/oapi/** 路由判定与 forwardUrl 换主机不换路径计算)

This commit is contained in:
chenxing
2026-09-21 00:25:54 +08:00
parent 6cb0fa1b5b
commit 4777d47499
3 changed files with 198 additions and 0 deletions
@@ -0,0 +1,42 @@
package com.yunhai.platform.gateway.route;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 网关配置(T0.12 起;环境值走平台配置层,jar 内 yml 只留占位)。
* <ul>
* <li>routes:target → base-url(容器网内服务直连 URL,如 http://userbase-service:8080;
* target = F11 七个生产 ctx);</li>
* <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4)。</li>
* </ul>
*/
@Component
@ConfigurationProperties(prefix = "yh.gateway")
public class GatewayProperties {
/** target → base-url(值不含路径;转发 = base-url + 完整原始 URI,换主机不换路径) */
private Map<String, String> routes = new LinkedHashMap<>();
/** 观察模式:true = 全判定只记不拦(P1 只观察,R4) */
private boolean observe = true;
public Map<String, String> getRoutes() {
return routes;
}
public void setRoutes(Map<String, String> routes) {
this.routes = routes;
}
public boolean isObserve() {
return observe;
}
public void setObserve(boolean observe) {
this.observe = observe;
}
}
@@ -0,0 +1,77 @@
package com.yunhai.platform.gateway.route;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 目标路由与转发 URL 计算(T0.12):{@code /{target}/oapi/**} → 提供方 base-url 的映射。
* <p>
* <b>换主机不换路径</b>:forwardUrl = base-url + 完整原始 URI(含 query)。target 首段仅用于
* 路由选择、注册表匹配与 canonical 剥离,不参与转发路径改写——提供方自带 context-path,
* URI 原样送达即天然对齐,签名 canonical 逐字节不变。<br>
* 网关只做 oapi:未知 target / 单段 URI / 第二段非 oapi(如 /{target}/api/x)一律返回 null
* = <b>裸 404</b>({@code /api} 与页面不属于网关职责)。
*/
@Component
public class TargetRouter {
/** 路由产物:判定字段 + 转发 URL */
public static class Routing {
public final String target;
/** 剥掉 target 首段后的路径(如 /oapi/message/list)——RouteRegistry 与签名 canonical 同一坐标系 */
public final String pathAfterStrip;
/** base-url + 完整原始 URI(含 query),逐字符不改写 */
public final String forwardUrl;
Routing(String target, String pathAfterStrip, String forwardUrl) {
this.target = target;
this.pathAfterStrip = pathAfterStrip;
this.forwardUrl = forwardUrl;
}
}
@Autowired
private GatewayProperties properties;
/** 测试/显式装配入口 */
void setProperties(GatewayProperties properties) {
this.properties = properties;
}
/**
* @param requestUri 原始请求 URI(未解码,如 /userbase/oapi/x)
* @param queryString 原始 query(无 '?' 前缀;null = 无)
* @return 路由产物;不匹配(未知 target / 单段 / 非 oapi 第二段)返回 null = 裸 404
*/
public Routing route(String requestUri, String queryString) {
if (requestUri == null || requestUri.isEmpty() || requestUri.equals("/")) {
return null;
}
// 剥首段 target:/userbase/oapi/x → target=userbase, rest=/oapi/x
int secondSlash = requestUri.indexOf('/', 1);
String target;
String rest;
if (secondSlash < 0) {
return null; // 单段 URI(/foo)——无 oapi 第二段
} else {
target = requestUri.substring(1, secondSlash);
rest = requestUri.substring(secondSlash);
}
if (target.isEmpty()) {
return null; // 双斜杠开头等畸形
}
// 网关只做 oapi:第二段必须恰为 oapi(/oapi、/oapi/xxx 均可)
if (!rest.equals("/oapi") && !rest.startsWith("/oapi/")) {
return null;
}
String baseUrl = properties.getRoutes().get(target);
if (baseUrl == null || baseUrl.trim().isEmpty()) {
return null; // 未知 target = 裸 404
}
String normalizedBase = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String url = normalizedBase + requestUri
+ (queryString == null || queryString.isEmpty() ? "" : "?" + queryString);
return new Routing(target, rest, url);
}
}
@@ -0,0 +1,79 @@
package com.yunhai.platform.gateway.route;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* TargetRouter 用例(T0.12 TDD):已知 target forwardUrl 逐字符断言(换主机不换路径)、
* 未知 target / 单段 / 非 oapi 第二段一律裸 404(null)、base-url 尾斜杠归一化。
*/
class TargetRouterTest {
private TargetRouter router;
@BeforeEach
void setUp() {
router = new TargetRouter();
GatewayProperties props = new GatewayProperties();
Map<String, String> routes = new LinkedHashMap<>();
routes.put("userbase", "http://userbase-service:8080");
routes.put("notice", "http://notice-service:8080/");
props.setRoutes(routes);
router.setProperties(props);
}
@Test
void 已知target_forwardUrl逐字符断言() {
TargetRouter.Routing r = router.route("/userbase/oapi/x", "a=1");
assertThat(r).isNotNull();
assertThat(r.forwardUrl).as("换主机不换路径:base-url + 完整原始 URI 含 query,逐字符")
.isEqualTo("http://userbase-service:8080/userbase/oapi/x?a=1");
assertThat(r.target).isEqualTo("userbase");
assertThat(r.pathAfterStrip).as("剥段路径 = 注册表/签名坐标系").isEqualTo("/oapi/x");
}
@Test
void 无query的URI不带问号() {
TargetRouter.Routing r = router.route("/notice/oapi/notice/list", null);
assertThat(r.forwardUrl).isEqualTo("http://notice-service:8080/notice/oapi/notice/list");
assertThat(r.pathAfterStrip).isEqualTo("/oapi/notice/list");
}
@Test
void 未知target_裸404() {
assertThat(router.route("/nobody/oapi/x", null)).as("未配置的 target = 裸 404").isNull();
}
@Test
void 单段URI_裸404() {
assertThat(router.route("/", null)).isNull();
assertThat(router.route("/foo", null)).isNull();
}
@Test
void 非oapi第二段_裸404() {
assertThat(router.route("/userbase/api/x", null)).as("网关只做 oapi,/api 一律 404").isNull();
assertThat(router.route("/userbase/oapix", null)).as("前缀相同不算 oapi 段").isNull();
}
@Test
void 尾斜杠base归一化_无双斜杠() {
TargetRouter.Routing r = router.route("/notice/oapi/x", null);
assertThat(r.forwardUrl).as("base-url 尾斜杠不得产生双斜杠")
.isEqualTo("http://notice-service:8080/notice/oapi/x");
}
@Test
void 裸oapi路径可路由() {
// /{target}/oapi(无尾路径)属 /{target}/oapi/** 匹配范围
TargetRouter.Routing r = router.route("/userbase/oapi", null);
assertThat(r).isNotNull();
assertThat(r.forwardUrl).isEqualTo("http://userbase-service:8080/userbase/oapi");
assertThat(r.pathAfterStrip).isEqualTo("/oapi");
}
}