# HZHub-Gateway 迁移方案 ## 一、现状分析 ### 当前架构(去中心化) ``` 前端门户 → Nginx/Vite代理 → 直连后端服务 admin → localhost:6039 (hzhub-ai) employee → localhost:6039 (hzhub-ai) + localhost:8082 (hzhub-erp) dealer → localhost:6039 (hzhub-ai) + localhost:8082 (hzhub-erp) ``` hzhub-gateway 已存在但未部署,仅有基础路由配置(3条路由 + CORS)。 ### hzhub-ai 中可迁移的网关级功能 | 功能 | 位置 | 是否适合迁移 | |------|------|:---:| | Sa-Token JWT 验证 | `SaTokenConfig` + `SecurityConfig` | ✅ 是 — 全局统一鉴权 | | XSS 过滤 | `XssFilter` (javax.servlet Filter) | ✅ 是 — WebFilter 可实现 | | 请求/响应加密 | `CryptoFilter` (RSA 加解密) | ⚠️ 部分 — 需适配 WebFlux | | 接口限流 | `@RateLimiter` + Redisson | ✅ 是 — 可用 Redis + RequestRateLimiter | | 幂等性校验 | `@Idempotent` | ❌ 否 — 业务级,保留后端 | | SSE 流式响应 | ChatController | ❌ 否 — 业务级,保留后端 | | WebSocket 握手鉴权 | `PlusWebSocketInterceptor` | ❌ 否 — 协议不同,保留后端 | | 数据权限拦截 | MyBatis 拦截器 | ❌ 否 — ORM 层,保留后端 | ### 核心挑战 1. **Servlet vs WebFlux**:Gateway 基于 Spring WebFlux(响应式),hzhub-ai 的 Filter 是 Servlet 规范,不能直接复用,需改写为 `WebFilter` 2. **JWT 解析**:Gateway 只需验证 JWT 合法性并透传用户信息,无需完整 Sa-Token 登录态 3. **加密适配**:CryptoFilter 依赖 `ServletInputStream`,需改写为 `ServerHttpRequestDecorator` --- ## 二、目标架构 ``` 前端门户 → Gateway(:8080) → 后端服务 /ai/** → hzhub-ai(:8081) /erp/** → hzhub-erp(:8082) /system/** → hzhub-ai(:8081) ``` Gateway 承担: - **统一鉴权**:验证 JWT Token,解析用户信息注入请求头 - **XSS 防护**:对 POST/PUT 请求体进行 XSS 清洗 - **接口限流**:基于 Redis 的令牌桶限流 - **请求/响应加密**:按需解密请求、加密响应 - **路由转发**:按路径分发到对应服务 - **跨域处理**:全局 CORS 后端服务(hzhub-ai / hzhub-erp)承担: - **业务逻辑**:CRUD、AI 对话、工作流等 - **数据权限**:MyBatis 层的多租户数据隔离 - **幂等性**:防重复提交 - **SSE/WebSocket**:流式推送 ### 服务间信任模型 ``` 外部请求 → Gateway → 后端服务 │ │ 验证JWT 信任Gateway 透传的用户头 ``` - Gateway 验证 JWT 通过后,将用户信息注入请求头:`X-User-Id`、`X-User-Name`、`X-Client-Id` - 后端服务在 `SecurityConfig` 中配置:**来自 Gateway 的请求跳过 JWT 验证** - 判断依据:检查请求头 `X-Gateway-Verified: true`(或共享内网 IP 白名单) - 开发环境:保留直连后端的鉴权能力(双重模式) --- ## 三、实施步骤 ### Phase 1: Gateway 基础增强(认证 + 路由) **目标**:让 Gateway 能正常转发请求并验证 JWT #### 3.1 修改 pom.xml ```xml org.springframework.boot spring-boot-starter-data-redis-reactive cn.dev33 sa-token-reactor-spring-boot3-starter 1.39.0 cn.dev33 sa-token-jwt 1.39.0 ``` #### 3.2 JWT 认证全局过滤器 ```java // src/main/java/org/hzhub/gateway/filter/AuthGlobalFilter.java @Component @RequiredArgsConstructor public class AuthGlobalFilter implements GlobalFilter, Ordered { private final SaTokenConfig saTokenConfig; // 放行路径 private static final List WHITE_LIST = List.of( "/erp/test/", "/erp/customer/", "/actuator/" ); @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); // 白名单放行 if (isWhiteList(path)) { return chain.filter(exchange); } // 从 Header 获取 Token String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { return unauthorized(exchange, "未登录或登录已过期"); } // 验证 JWT(使用 sa-token-jwt) String jwtToken = token.substring(7); try { SaTokenInfo info = SaManager.getTokenInfo(jwtToken); if (info == null || !info.isLogin()) { return unauthorized(exchange, "登录已过期"); } // 透传用户信息到后端 ServerHttpRequest mutated = exchange.getRequest().mutate() .header("X-User-Id", info.getLoginId()) .header("X-Client-Id", exchange.getRequest().getHeaders().getFirst("ClientID")) .header("X-Gateway-Verified", "true") .build(); return chain.filter(exchange.mutate().request(mutated).build()); } catch (Exception e) { return unauthorized(exchange, "Token 无效"); } } private boolean isWhiteList(String path) { return WHITE_LIST.stream().anyMatch(path::startsWith); } private Mono unauthorized(ServerWebExchange exchange, String msg) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] bytes = ("{\"code\":401,\"msg\":\"" + msg + "\"}").getBytes(StandardCharsets.UTF_8); DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes); return exchange.getResponse().writeWith(Mono.just(buffer)); } @Override public int getOrder() { return -100; } // 最高优先级 } ``` #### 3.3 更新 application.yml ```yaml server: port: 8080 spring: application: name: hzhub-gateway # Redis 配置(限流 + Token 验证共享) data: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} cloud: gateway: routes: - id: hzhub-ai uri: http://${AI_HOST:localhost}:${AI_PORT:8081} predicates: - Path=/ai/**,/system/** filters: - StripPrefix=1 - id: hzhub-erp uri: http://${ERP_HOST:localhost}:${ERP_PORT:8082} predicates: - Path=/erp/** filters: - StripPrefix=1 globalcors: cors-configurations: '[/**]': allowedOriginPatterns: "*" allowedMethods: "*" allowedHeaders: "*" allowCredentials: true maxAge: 3600 # Sa-Token(JWT 验证用,与后端服务共享 secret) sa-token: jwt-secret-key: ${JWT_SECRET:abcdefghijklmnopqrstuvwxyz} token-name: Authorization # 日志 logging: level: org.hzhub.gateway: debug ``` --- ### Phase 2: XSS 过滤 + 限流 #### 3.4 XSS 全局过滤器(WebFlux 版) ```java // src/main/java/org/hzhub/gateway/filter/XssGlobalFilter.java @Component public class XssGlobalFilter implements GlobalFilter, Ordered { private static final List EXCLUDE_PATHS = List.of("/ai/upload"); @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); HttpMethod method = exchange.getRequest().getMethod(); // GET/DELETE 不处理;排除白名单 if (method == HttpMethod.GET || method == HttpMethod.DELETE || EXCLUDE_PATHS.stream().anyMatch(path::startsWith)) { return chain.filter(exchange); } // 包装请求,对 Body 进行 XSS 清洗 ServerHttpRequest decorated = new ServerHttpRequestDecorator(exchange.getRequest()) { @Override public Flux getBody() { return super.getBody().map(buffer -> { byte[] bytes = new byte[buffer.readableByteCount()]; buffer.read(bytes); String body = new String(bytes, StandardCharsets.UTF_8); String cleaned = cleanXss(body); byte[] cleanedBytes = cleaned.getBytes(StandardCharsets.UTF_8); DataBufferFactory factory = buffer.factory(); DataBuffer newBuffer = factory.allocateBuffer(cleanedBytes.length); newBuffer.write(cleanedBytes); return newBuffer; }); } }; return chain.filter(exchange.mutate().request(decorated).build()); } private String cleanXss(String value) { if (value == null) return null; return value .replaceAll("", "</script>") .replaceAll("javascript:", "") .replaceAll("on\\w+\\s*=", ""); } @Override public int getOrder() { return -50; } } ``` #### 3.5 限流配置(基于 Redis) ```yaml # 在 application.yml 的 gateway 配置中添加 spring.cloud.gateway.default-filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 # 每秒补充令牌数 redis-rate-limiter.burstCapacity: 20 # 令牌桶容量 key-resolver: "#{@ipKeyResolver}" # 按 IP 限流 ``` ```java // src/main/java/org/hzhub/gateway/config/RateLimiterConfig.java @Configuration public class RateLimiterConfig { /** * 按 IP 地址限流 */ @Bean public KeyResolver ipKeyResolver() { return exchange -> { String ip = exchange.getRequest().getRemoteAddress() != null ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() : "unknown"; return Mono.just("gateway:ratelimit:" + ip); }; } /** * 按路径+IP 限流(更细粒度) */ @Bean public KeyResolver pathIpKeyResolver() { return exchange -> { String path = exchange.getRequest().getURI().getPath(); String ip = exchange.getRequest().getRemoteAddress() != null ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() : "unknown"; return Mono.just("gateway:ratelimit:" + path + ":" + ip); }; } } ``` --- ### Phase 3: 后端服务适配 #### 3.6 hzhub-ai 的 SecurityConfig 改造 在 hzhub-ai 的 `SecurityConfig` 中新增:当请求来自 Gateway 时,跳过 JWT 验证。 ```java // hzhub-ai 的 SecurityConfig.java 中修改 private boolean isFromGateway(ServerHttpRequest request) { String verified = request.getHeaders().getFirst("X-Gateway-Verified"); return "true".equals(verified); } ``` 或者更简单:在 Sa-Token 拦截器中添加检查: ```java // 如果 Gateway 已验证,直接放行 if ("true".equals(request.getHeader("X-Gateway-Verified"))) { return true; } ``` #### 3.7 hzhub-erp 同理 在 hzhub-erp 的 `SecurityConfig.java` 中添加同样的 Gateway 信任逻辑。 --- ### Phase 4: 前端路由切换 #### 3.8 hzhub-admin(管理后台) 修改 `.env.development`: ```env VITE_GLOB_API_URL=http://localhost:8080 ``` 修改 `vite.config.ts` 代理: ```typescript '/api': { target: 'http://localhost:8080', // 改为 Gateway changeOrigin: true, rewrite: path => path.replace(/^\/api/, ''), } ``` #### 3.9 hzhub-portal-employee(员工门户) 修改 `.env.development`: ```env VITE_API_URL=http://localhost:8080 ``` `request.ts` 中已自动使用此 baseURL,无需改动。 注意:原来直连 ERP 的请求(`/erp/customer/**`)也需经过 Gateway,Gateway 路由已覆盖 `/erp/**`。 #### 3.10 hzhub-portal-dealer(经销商门户) 同 employee,统一指向 Gateway。 --- ### Phase 5: Docker Compose 集成 #### 3.11 新增 hzhub-gateway 服务 ```yaml # docker-compose.yml 新增 hzhub-gateway: build: context: ../hzhub-gateway dockerfile: Dockerfile container_name: hzhub-gateway ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: prod REDIS_HOST: hzhub-redis REDIS_PORT: 6379 AI_HOST: hzhub-ai AI_PORT: 6039 ERP_HOST: hzhub-erp ERP_PORT: 8082 JWT_SECRET: ${JWT_SECRET:-abcdefghijklmnopqrstuvwxyz} depends_on: redis: condition: service_healthy hzhub-ai: condition: service_started hzhub-erp: condition: service_started networks: - hzhub-network ``` #### 3.12 前端 Nginx 代理改为 Gateway ```nginx # hzhub-portal-employee 的 Nginx 配置 # 原来: proxy_pass http://hzhub-ai:6039; # 改为: location /api/ { proxy_pass http://hzhub-gateway:8080; } location /erp/ { proxy_pass http://hzhub-gateway:8080; } ``` #### 3.13 后端服务关闭外部端口暴露 ```yaml # docker-compose.yml 修改 hzhub-ai: # ports: # - "6039:6039" # 不再对外暴露,仅内网访问 hzhub-erp: # ports: # - "8082:8082" # 不再对外暴露,仅内网访问 ``` --- ## 四、文件变更清单 ### 新增文件 | 文件 | 说明 | |------|------| | `hzhub-gateway/src/main/java/org/hzhub/gateway/filter/AuthGlobalFilter.java` | JWT 认证过滤器 | | `hzhub-gateway/src/main/java/org/hzhub/gateway/filter/XssGlobalFilter.java` | XSS 过滤 | | `hzhub-gateway/src/main/java/org/hzhub/gateway/config/RateLimiterConfig.java` | 限流 Key 解析器 | | `hzhub-gateway/Dockerfile` | Gateway 容器构建 | ### 修改文件 | 文件 | 变更 | |------|------| | `hzhub-gateway/pom.xml` | 新增 sa-token-reactor、redis-reactive 依赖 | | `hzhub-gateway/src/main/resources/application.yml` | 重写路由、Redis、Sa-Token 配置 | | `hzhub-gateway/src/main/java/org/hzhub/HzhubGatewayApplication.java` | 无需改动 | | `hzhub-ai/.../SecurityConfig.java` | 添加 Gateway 信任逻辑 | | `hzhub-erp/.../SecurityConfig.java` | 添加 Gateway 信任逻辑 | | `hzhub-admin/apps/web-antd/.env.development` | API 地址改为 Gateway | | `hzhub-admin/apps/web-antd/vite.config.ts` | 代理目标改为 Gateway | | `hzhub-portal-employee/.env.development` | VITE_API_URL 指向 Gateway | | `hzhub-portal-dealer/.env.development` | 新增 API URL | | `hzhub-deploy/docker-compose.yml` | 新增 Gateway 服务,调整网络 | --- ## 五、分阶段验证 ### Phase 1 验证 ```bash # 1. 启动 Gateway cd hzhub-gateway && mvn spring-boot:run # 2. 测试路由转发 curl http://localhost:8080/erp/test/connection # 应返回 SQL Server 连接信息 # 3. 测试鉴权(未登录) curl http://localhost:8080/ai/chat/message # 应返回 401 # 4. 测试鉴权(带 Token) curl -H "Authorization: Bearer " http://localhost:8080/ai/chat/message # 应正常返回结果 ``` ### Phase 2 验证 ```bash # 测试 XSS 过滤 curl -X POST http://localhost:8080/ai/xxx \ -H "Content-Type: application/json" \ -d '{"content": ""}' # 请求体应被清洗 # 测试限流(快速连续请求) for i in {1..30}; do curl -s http://localhost:8080/ai/xxx; done # 超过阈值应返回 429 ``` ### Phase 3 验证 ```bash # 前端登录后,所有请求走 Gateway # 确认 AI 接口、ERP 接口均正常工作 # 确认 Token 过期后自动跳转登录页 ``` --- ## 六、风险与注意事项 | 风险 | 应对 | |------|------| | Sa-Token JWT 验证方式与后端不一致 | 统一使用 `StpLogicJwtForSimple`,共享 `jwt-secret-key` | | Gateway 成为单点故障 | 后续可部署多实例 + Nginx 负载均衡 | | SSE 流式响应经过 Gateway 可能超时 | 需在 Gateway 配置路由超时时间(默认无超时) | | CORS 配置冲突 | Gateway 统一处理 CORS,后端服务关闭 CORS | | 开发环境直连 vs 生产走 Gateway | 后端服务保留双重模式:有 `X-Gateway-Verified` 跳过验证,否则自行验证 | --- ## 七、后续优化方向 1. **服务注册与发现**:引入 Nacos,替代硬编码的服务地址 2. **熔断降级**:引入 Resilience4j,对下游服务做熔断保护 3. **链路追踪**:引入 SkyWalking 或 Micrometer Trace 4. **灰度发布**:基于 Header 的蓝绿部署路由 5. **API 文档聚合**:Gateway 聚合 Swagger/Knife4j 文档