diff --git a/src/main/java/com/yunhai/platform/gateway/filter/AccessLogFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/AccessLogFilter.java new file mode 100644 index 0000000..94270b3 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/AccessLogFilter.java @@ -0,0 +1,116 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.log.AccessLogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.AsyncEvent; +import javax.servlet.AsyncListener; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 台账采集(T1.6 链序第 0 环,最外层):只记 oapi 请求(网关自身 {@code /api/internal/**} 不记), + * 出口状态取网关真实响应码,costMs 取端到端耗时。 + *
+ * 转发是异步的(T1.3 {@code AsyncContext})——请求线程提前返回时响应尚未完成, + * 此时挂 {@link AsyncListener} 在 complete 时记录,保证台账 status 不是"半途值"; + * 记录动作本身在 {@link AccessLogService} 内只入队,绝不拖慢请求。 + */ +@Component +@Order(0) +public class AccessLogFilter implements Filter { + + private static final String ATTR_START_NANOS = "yh.gateway.start.nanos"; + + @Autowired + private AccessLogService accessLogService; + + /** 测试/显式装配入口 */ + void setAccessLogService(AccessLogService accessLogService) { + this.accessLogService = accessLogService; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (RoutingFilter.isGatewayInternal(RoutingFilter.sanitizedUri(req))) { + chain.doFilter(request, response); // 网关自身端点不进 open_api 台账 + return; + } + req.setAttribute(ATTR_START_NANOS, System.nanoTime()); + try { + chain.doFilter(request, response); + } finally { + if (req.isAsyncStarted()) { + req.getAsyncContext().addListener(new LedgerListener(req, res)); + } else { + record(req, res); + } + } + } + + private void record(HttpServletRequest req, HttpServletResponse res) { + Object start = req.getAttribute(ATTR_START_NANOS); + int costMs = start instanceof Long ? (int) ((System.nanoTime() - (Long) start) / 1_000_000L) : 0; + // backendCode:流式转发零缓冲(T1.3),本期不解析提供方信封 code → null + accessLogService.recordRequest(req, res.getStatus(), null, costMs); + } + + /** 异步收尾(complete/timeout/error)时记一次,重复触发由 claimed 挡住 */ + private class LedgerListener implements AsyncListener { + private final HttpServletRequest req; + private final HttpServletResponse res; + private final java.util.concurrent.atomic.AtomicBoolean claimed = + new java.util.concurrent.atomic.AtomicBoolean(); + + LedgerListener(HttpServletRequest req, HttpServletResponse res) { + this.req = req; + this.res = res; + } + + @Override + public void onComplete(AsyncEvent event) { + recordOnce(); + } + + @Override + public void onTimeout(AsyncEvent event) { + recordOnce(); + } + + @Override + public void onError(AsyncEvent event) { + recordOnce(); + } + + @Override + public void onStartAsync(AsyncEvent event) { + // 网关不嵌套异步:忽略 + } + + private void recordOnce() { + if (claimed.compareAndSet(false, true)) { + try { + record(req, res); + } catch (RuntimeException e) { + // 台账故障绝不影响请求收尾 + } + } + } + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java new file mode 100644 index 0000000..eb4a2eb --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java @@ -0,0 +1,61 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.envelope.EnvelopeWriter; +import com.yunhai.platform.gateway.forward.StreamingForwarder; +import com.yunhai.platform.gateway.route.TargetRouter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 链尾转发(T1.6 链序第 5 环):oapi 请求到此一律交给 {@link StreamingForwarder} + * 流式送提供方并原样回传,链在此终结(不再回到网关的 DispatcherServlet)。 + *
+ * 身份注入(链序第 4 环)在转发器内完成(T1.4 {@code CallerHeader}),所以本环只需带上 + * 请求属性。缺路由 attribute(未经 RoutingFilter 的异常路径)按 404 收口,绝不裸转。 + * 超时/体积暂无 per-route 覆盖(RouteSpec 不含该列),用转发器默认值。 + */ +@Component +@Order(5) +public class DispatchFilter implements Filter { + + @Autowired + private StreamingForwarder forwarder; + + /** 测试/显式装配入口 */ + void setForwarder(StreamingForwarder forwarder) { + this.forwarder = forwarder; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + String uri = RoutingFilter.sanitizedUri(req); + if (RoutingFilter.isGatewayInternal(uri)) { + chain.doFilter(request, response); // 自身端点:交给 controller,不经提供方 + return; + } + Object routing = req.getAttribute(RoutingFilter.ATTR_ROUTING); + if (!(routing instanceof TargetRouter.Routing)) { + EnvelopeWriter.writeBare(res, 404); + return; + } + forwarder.forward(req, res, ((TargetRouter.Routing) routing).forwardUrl, + StreamingForwarder.DEFAULT_READ_TIMEOUT_MS, StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java new file mode 100644 index 0000000..7049b00 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java @@ -0,0 +1,89 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.envelope.EnvelopeWriter; +import com.yunhai.platform.gateway.route.RouteRegistry; +import com.yunhai.platform.gateway.route.RouteSpec; +import com.yunhai.platform.gateway.route.TargetRouter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 路由判定(T1.6 链序第 2 环):Sanitize 之后、验签之前,把 URI 定到 target 与提供方 + * ({@link TargetRouter},未知 target / 非 oapi 第二段 = 裸 404,协议层错误永不放行), + * 并取注册表最严策略({@link RouteRegistry})存入 attribute 供后续环节消费。 + *
+ * {@code /api/internal/**} 属网关自身路径(如 T0.10 刷新端点),不进 oapi 链、不验签、不转发。
+ */
+@Component
+@Order(2)
+public class RoutingFilter implements Filter {
+
+ /** 网关自身管理端点前缀:绕开 oapi 判定链(T0.10 /api/internal/routes/refresh) */
+ public static final String INTERNAL_PREFIX = "/api/internal";
+
+ /** 路由产物 attribute({@link TargetRouter.Routing}) */
+ public static final String ATTR_ROUTING = "yh.gateway.routing";
+ /** 注册表策略 attribute({@link RouteSpec}) */
+ public static final String ATTR_ROUTE_SPEC = "yh.gateway.route.spec";
+
+ @Autowired
+ private TargetRouter targetRouter;
+
+ @Autowired
+ private RouteRegistry routeRegistry;
+
+ /** 测试/显式装配入口 */
+ void setTargetRouter(TargetRouter targetRouter) {
+ this.targetRouter = targetRouter;
+ }
+
+ void setRouteRegistry(RouteRegistry routeRegistry) {
+ this.routeRegistry = routeRegistry;
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
+ chain.doFilter(request, response);
+ return;
+ }
+ HttpServletRequest req = (HttpServletRequest) request;
+ HttpServletResponse res = (HttpServletResponse) response;
+ String uri = sanitizedUri(req);
+ if (isGatewayInternal(uri)) {
+ chain.doFilter(request, response); // 自身端点:直达 controller
+ return;
+ }
+ TargetRouter.Routing routing = targetRouter.route(uri, req.getQueryString());
+ if (routing == null) {
+ EnvelopeWriter.writeBare(res, 404); // 404 属协议层,与 observe 无关
+ return;
+ }
+ req.setAttribute(ATTR_ROUTING, routing);
+ req.setAttribute(ATTR_ROUTE_SPEC,
+ routeRegistry.resolve(routing.target, routing.pathAfterStrip, req.getMethod()));
+ chain.doFilter(request, response);
+ }
+
+ /** 一律消费 Sanitize 归一化后的原始 URI;缺 attribute(未经 Sanitize)时退回 getRequestURI */
+ static String sanitizedUri(HttpServletRequest req) {
+ Object attr = req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI);
+ return attr instanceof String ? (String) attr : req.getRequestURI();
+ }
+
+ static boolean isGatewayInternal(String uri) {
+ return uri != null
+ && (uri.equals(INTERNAL_PREFIX) || uri.startsWith(INTERNAL_PREFIX + "/"));
+ }
+}
diff --git a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java
index d6c8360..dbd5aa9 100644
--- a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java
+++ b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java
@@ -75,6 +75,10 @@ public class SignVerifyFilter implements Filter {
}
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
+ if (RoutingFilter.isGatewayInternal(RoutingFilter.sanitizedUri(req))) {
+ chain.doFilter(request, response); // 网关自身端点不经 oapi 验签
+ return;
+ }
boolean pass = false;
String appKey = req.getHeader("X-App-Key");
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 94d30d4..40f53cf 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -22,9 +22,10 @@ spring:
batch_size: 200
order_inserts: true
redis:
- # 密钥链(sign:app:{appKey})走共享 Redis db0——由平台配置层注入
- host: #
- port: #
+ # 密钥链(sign:app:{appKey})走共享 Redis db0——host/port 由平台配置层注入。
+ # 注意:jar 内只留注释,不留 `host:` 空键(空值绑不到 int 会让 context 起不来)
+ # host: <平台配置层>
+ # port: <平台配置层>
main:
allow-bean-definition-overriding: true
@@ -39,20 +40,22 @@ management:
enabled: true
yh:
- app:
- auth:
- # starter 验签配置(T0.11 起 GatewayKeyService 复用 SignKeyResolver)
- # include/exclude 路径策略由平台配置层下发;本文件不配任何真实值
- enabled: # 默认随 starter
+ # starter 的服务端验签过滤器(AppAuthFilter,注册在 HIGHEST_PRECEDENCE+10)必须关掉:
+ # 网关自身的 SignVerifyFilter(T1.2) 是唯一验签面,否则它会抢在链首把合法流量按
+ # "完整 URI 含 target 段"验签 → 全部 401。注意前缀是 yh.app-auth(不是 yh.app.auth)。
+ # 密钥链 SignKeyRedisAutoConfiguration 独立装配,不受此开关影响(GatewayKeyService 仍可用)。
+ app-auth:
+ enabled: false
gateway:
# 路由表(T0.12):target -> base-url,值 = 容器网内服务直连 URL,走平台配置层
- routes: #
- # userbase: http://userbase-service:8080
- # userclass: http://userclass-service:8080
- # cms: http://cms-service:8080
- # svc-hall: http://svc-hall-service:8080
- # flow-center: http://flow-center-service:8080
- # usermsg: http://usermsg-service:8080
- # notice: http://notice-service:8080
+ # (只留注释不留空键:空值会让 Binder 把 Map 绑成 null)
+ # routes:
+ # userbase: http://userbase-service:8080
+ # userclass: http://userclass-service:8080
+ # cms: http://cms-service:8080
+ # svc-hall: http://svc-hall-service:8080
+ # flow-center: http://flow-center-service:8080
+ # usermsg: http://usermsg-service:8080
+ # notice: http://notice-service:8080
# 观察模式(T1.6):true = 全判定只记不拦(R4:P1 只观察);生产初期保持 true
observe: true
diff --git a/src/test/java/com/yunhai/platform/gateway/chain/GatewayChainTest.java b/src/test/java/com/yunhai/platform/gateway/chain/GatewayChainTest.java
new file mode 100644
index 0000000..d67c230
--- /dev/null
+++ b/src/test/java/com/yunhai/platform/gateway/chain/GatewayChainTest.java
@@ -0,0 +1,182 @@
+package com.yunhai.platform.gateway.chain;
+
+import com.sun.net.httpserver.HttpServer;
+import com.yunhai.platform.gateway.auth.GatewayKeyService;
+import com.yunhai.platform.gateway.entity.OpenApiLog;
+import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
+import com.yunhai.platform.utils.crypto.SignUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * T1.6 链序集成测试(observe=true 默认):Sanitize(0/1) → Routing(2) → SignVerify(3) →
+ * 身份注入(4,转发器内) → Dispatch(5) 全链走通——假后端收到注入的 X-Caller-App-Key、
+ * 伪造值被剥、响应原样透传、台账异步入库;observe 语义边界:坏签名放行、
+ * 但协议层错误(400/404)不受 observe 影响永远拦;/api/internal/** 自身端点不被链拦截。
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+class GatewayChainTest {
+
+ private static final String SECRET = "sec-chain-0123456789abcdef";
+ private static final HttpServer BACKEND;
+ private static final int BACKEND_PORT;
+ static final AtomicReference