Bladeren bron

feat(gateway): 新增 ai-gateway 按用户粘性路由的转发网关模块

- Spring Cloud Gateway(WebFlux) 动态路由 + 首次登录绑定 + x-token→节点 两级路由(Redis 缓存/PG 兜底)
- 节点探活(连续3次失败DOWN/1次成功恢复),绑定节点故障明确 503 不自动切换
- /gateway/admin 管理接口:服务/节点/路由增删改查、存量用户导入、手动探活,管理员鉴权+IP白名单
- gateway_service/gateway_node/gateway_user_route 三表 DDL(sql/gateway.sql, 启动幂等执行+YAML播种)
- 实施修正设计稿 8 处缺陷(CORS 重复头/sa-token 配置镜像/超限不 413/token 失效透传 401/响应过滤器 order -2/绑定 fire-and-forget/启动期循环依赖/URI 二次编码),详见 ai-gateway/README.md §7
- 单测 23 个通过;mock 后端端到端冒烟验证粘性/绑定/503 不切换/恢复
cc 3 dagen geleden
bovenliggende
commit
2dd5c1e4f0
46 gewijzigde bestanden met toevoegingen van 3435 en 4 verwijderingen
  1. 3 2
      AI_AGENT.md
  2. 157 0
      ai-gateway/README.md
  3. 148 0
      ai-gateway/pom.xml
  4. 23 0
      ai-gateway/src/main/java/com/zsjz/gateway/GatewayApplication.java
  5. 30 0
      ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminApiException.java
  6. 90 0
      ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminAuthWebFilter.java
  7. 153 0
      ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminController.java
  8. 290 0
      ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminService.java
  9. 39 0
      ai-gateway/src/main/java/com/zsjz/gateway/config/CorsConfig.java
  10. 81 0
      ai-gateway/src/main/java/com/zsjz/gateway/config/GatewayProperties.java
  11. 26 0
      ai-gateway/src/main/java/com/zsjz/gateway/config/WebClientConfig.java
  12. 174 0
      ai-gateway/src/main/java/com/zsjz/gateway/registry/GatewayConfigRepository.java
  13. 116 0
      ai-gateway/src/main/java/com/zsjz/gateway/registry/GatewayRegistry.java
  14. 62 0
      ai-gateway/src/main/java/com/zsjz/gateway/registry/HealthChecker.java
  15. 68 0
      ai-gateway/src/main/java/com/zsjz/gateway/registry/NodeHealthRegistry.java
  16. 13 0
      ai-gateway/src/main/java/com/zsjz/gateway/registry/NodeInfo.java
  17. 32 0
      ai-gateway/src/main/java/com/zsjz/gateway/registry/ServiceInfo.java
  18. 31 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/GatewayAttributes.java
  19. 66 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/GatewayRouteDefinitionRepository.java
  20. 120 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/LoginRequestFilter.java
  21. 176 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/LoginResponseFilter.java
  22. 45 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/NodeSelector.java
  23. 180 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/StickyRouteFilter.java
  24. 100 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/TokenResolver.java
  25. 227 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/UserRouteRepository.java
  26. 117 0
      ai-gateway/src/main/java/com/zsjz/gateway/route/UserRouteService.java
  27. 37 0
      ai-gateway/src/main/java/com/zsjz/gateway/support/ApiResult.java
  28. 98 0
      ai-gateway/src/main/java/com/zsjz/gateway/support/GatewayErrorHandler.java
  29. 87 0
      ai-gateway/src/main/java/com/zsjz/gateway/support/IpWhitelist.java
  30. 37 0
      ai-gateway/src/main/java/com/zsjz/gateway/support/JsonResponses.java
  31. 34 0
      ai-gateway/src/main/java/com/zsjz/gateway/support/RequestIdFilter.java
  32. 25 0
      ai-gateway/src/main/java/com/zsjz/gateway/support/StickyUnavailableException.java
  33. 14 0
      ai-gateway/src/main/resources/application-dev.yaml
  34. 14 0
      ai-gateway/src/main/resources/application-pro.yaml
  35. 81 0
      ai-gateway/src/main/resources/application.yaml
  36. 76 0
      ai-gateway/src/test/java/com/zsjz/gateway/route/LoginBodyParseTest.java
  37. 62 0
      ai-gateway/src/test/java/com/zsjz/gateway/route/NodeSelectorTest.java
  38. 38 0
      ai-gateway/src/test/java/com/zsjz/gateway/route/StickyRouteFilterUriTest.java
  39. 55 0
      ai-gateway/src/test/java/com/zsjz/gateway/support/IpWhitelistTest.java
  40. 18 0
      ai-gateway/src/test/python/QueryRoutes.java
  41. 18 0
      ai-gateway/src/test/python/RestoreNodes.java
  42. 19 0
      ai-gateway/src/test/python/UpdateNode.java
  43. 45 0
      ai-gateway/src/test/python/mock_backend.py
  44. 2 2
      docs/design/ai-gateway-user-sticky-routing.md
  45. 1 0
      pom.xml
  46. 107 0
      sql/gateway.sql

+ 3 - 2
AI_AGENT.md

@@ -32,10 +32,11 @@
 
 ```
 zsjz-ai/
-├─ pom.xml                    # 父 POM(packaging=pom),Spring Boot 3.5.16,仅聚合 ai-server
+├─ pom.xml                    # 父 POM(packaging=pom),Spring Boot 3.5.16,聚合 ai-server + ai-gateway
 ├─ ai-server/                 # 后端:Spring Boot 3 + MyBatis-Plus + AgentScope
+├─ ai-gateway/                # 网关:Spring Cloud Gateway(WebFlux),按用户粘性路由到多台 ai-server
 ├─ ai-frontend/               # 前端:Vue3 + Vite7 + TS + Ant Design Vue(JeeSite 脚手架血统)
-├─ sql/                       # 建表 / 初始化脚本(*.sql)
+├─ sql/                       # 建表 / 初始化脚本(*.sql,含网关 gateway.sql)
 ├─ QingJian/                  # 运行期工作空间素材(agent 提示词、skills、conf、db、license)
 ├─ cmd/  lib/  pmtiles.exe    # 本机工具(regInfo.exe 授权、mbtiles→pmtiles 转换等)
 └─ enc.md  readme.md

+ 157 - 0
ai-gateway/README.md

@@ -0,0 +1,157 @@
+# ai-gateway · 按用户粘性路由的转发网关
+
+> 设计文档:`docs/design/ai-gateway-user-sticky-routing.md`(本文与其冲突时以本文为准,
+> 实施时对设计做了若干修正,见文末「与设计文档的差异」)。
+
+## 1. 为什么需要它
+
+案件数据源(DuckDB)、上传附件、Agent 记忆/技能、Python 产物都落在**各 ai-server 节点的本地磁盘**;
+`/case/open` 还把案件数据源注册进节点 JVM 内存。因此**不能做普通的无状态负载均衡** ——
+请求落到非所属机器上,即使 token 和 caseId 都能从共享 Redis 恢复,本机也没有对应数据源,
+会直接报「当前案件数据源未打开或已释放」。
+
+网关只做一件事:**同一用户的请求永远转发到同一台 ai-server**。
+
+- 用户**首次登录成功**时自动绑定到一台机器(数据库唯一索引裁决并发);
+- 普通请求 `x-token` → userId → 绑定节点(Redis 缓存 + PostgreSQL 兜底);
+- 绑定节点 DOWN → **503 明确报错,不自动切换**(切了只会把数据写散)。
+
+## 2. 架构
+
+```
+Electron / Web
+      │  http://gateway:8080/js/a/**
+      ▼
+┌──────────────── ai-gateway :8080 ────────────────┐
+│ 登录:username → 已绑定节点;未绑定 → 轮询候选节点 │
+│       (登录成功后 bindIfAbsent 落库,失败不绑定) │
+│ 普通请求:x-token → userId → 绑定节点            │
+│ 无 token / token 失效:健康节点轮询(透传后端 401)│
+└──────────────┬───────────────────┬───────────────┘
+        ai-server node-1     ai-server node-2     (:8980,各自本地数据)
+               └──── 共享 PostgreSQL(gateway_* 配置与路由表)
+               └──── 共享 Redis(sa-token 登录态 + 网关缓存)
+```
+
+技术栈:Spring Boot 3.5 + Spring Cloud Gateway 2025.0.x(WebFlux/Netty),
+sa-token 1.46(只读共享 Redis 解析 token,不做拦截)、PostgreSQL、Redis。
+
+关键类速查:
+
+| 类 | 职责 |
+|---|---|
+| `route/StickyRouteFilter`(order 10001) | 粘性决策、节点健康校验、重写目标 URL、`X-Gateway-Node` 响应头、503 |
+| `route/LoginRequestFilter`(9999) | 登录体缓存(≤64KB)解析 username;`Accept-Encoding: identity` |
+| `route/LoginResponseFilter`(**-2**) | 登录响应捕获(≤256KB)→ 绑定 + token 缓存;响应原样透传 |
+| `route/TokenResolver` | `gateway:token:{svc}:{token}` 缓存 → `StpUtil.getLoginIdByToken` 兜底 |
+| `route/UserRouteService` | userId/username → 节点 两级缓存读写、`bindIfAbsent`、缓存清理 |
+| `registry/GatewayRegistry` | 服务/节点快照(DB 为事实来源),变化发布 `RefreshRoutesEvent` |
+| `registry/HealthChecker` | 每 10s 探活,连续 3 次失败 DOWN,1 次成功恢复 UP |
+| `admin/*` | `/gateway/admin/**` 管理接口(管理员 token + IP 白名单) |
+| `support/GatewayErrorHandler` | 503/502/404/500 统一 `{code,message,data}` JSON |
+
+> `LoginResponseFilter` 的 order 是 `NettyWriteResponseFilter - 1`(-2):
+> 写响应体的 NettyWriteResponseFilter(-1)持有它自己拿到的 exchange,
+> 只有比它更外层 mutate response,装饰器才会被写路径穿过(SCG 官方 ModifyResponseBody 同款排法)。
+> 登录路径判定在该过滤器内独立完成,绑定用的节点属性在 post 阶段从共享 attributes 读取。
+
+## 3. 运行与配置
+
+```bash
+# 开发(连 192.168.0.106 的 PG/Redis,与 ai-server dev 同一套)
+mvn -pl ai-gateway package -DskipTests
+java -jar ai-gateway/target/ai-gateway-1.0.jar --spring.profiles.active=dev
+
+# 生产:用环境变量注入凭证
+PG_URL=... PG_USER=... PG_PASSWORD=... REDIS_HOST=... \
+java -jar ai-gateway-1.0.jar --spring.profiles.active=pro
+```
+
+- 启动时自动执行 `sql/gateway.sql`(幂等建表,源文件在仓库根 `sql/`,打包时复制进 classpath)。
+- `gateway_service` 表为空时按 `gateway.services` YAML 播种;**之后以数据库为准,YAML 不再覆盖**。
+- **sa-token 配置必须与 ai-server 逐项一致**(同一 Redis、同一 `token-name`、同一 database),
+  否则 token 键位对不上、全部解析失败。网关改 sa-token 配置前先对照后端。
+- Redis 路由缓存键:`gateway:token:{svc}:{token}`(TTL 24h)、
+  `gateway:route:user:{svc}:{userId}`、`gateway:route:name:{svc}:{lower(username)}`(无 TTL,DB 为准)。
+
+## 4. 管理 API(`/gateway/admin/**`)
+
+需要管理员 `x-token`(sys_user.is_admin=1 且 status=1),可选 `gateway.admin.ip-whitelist`
+(单 IP / CIDR)。`gateway.admin.enabled=false` 时一律 404。
+
+```text
+GET    /gateway/admin/status                      服务/节点/健康/绑定数总览
+GET    /gateway/admin/services
+POST   /gateway/admin/services                    {id,name,pathPrefix,loginPath,healthPath,defaultNodeId,enabled}
+PUT    /gateway/admin/services/{serviceId}
+DELETE /gateway/admin/services/{serviceId}        (有节点/绑定时 409)
+POST   /gateway/admin/services/{serviceId}/nodes  {id,name,baseUrl,enabled,remark}
+PUT    /gateway/admin/nodes/{nodeId}
+DELETE /gateway/admin/nodes/{nodeId}              (有绑定时 409)
+POST   /gateway/admin/nodes/{nodeId}/check        手动探活
+GET    /gateway/admin/routes?serviceId=&keyword=&page=&size=
+POST   /gateway/admin/routes/bind                 {serviceId,userId|username,nodeId} 预绑定/重绑
+DELETE /gateway/admin/routes/{id}                 解绑(同步清 Redis 缓存)
+POST   /gateway/admin/routes/import-existing      {serviceId,nodeId} 存量用户批量绑定
+POST   /gateway/admin/reload                      强制重载配置
+```
+
+多网关实例:写库实例立即刷新;其余实例每 10s 对账(`gateway.registry-poll-interval`)。
+
+## 5. 部署切换步骤
+
+1. 现有唯一 ai-server 确认工作目录、授权、`QingJian/` 数据目录就绪。
+2. 启动网关:`node-1` 指向现有后端,`default-node-id=node-1`。
+3. `POST /gateway/admin/routes/import-existing` 把存量 `sys_user` 绑到 node-1
+   (未导入的用户也会走 default-node 兜底)。
+4. 前端 `VITE_GLOB_API_URL`(或打包产物 `_app.config.js`)改为 `http://<gateway>:8080`。
+5. 验证:登录、开案、附件上传、SSE 研判全程 `X-Gateway-Node` 一致。
+6. 部署 node-2(独立授权 + 独立工作目录,**不要共享 DuckDB 文件**)。
+7. 防火墙:8980 只对网关开放 —— 直连后端会绕过粘性。
+
+## 6. 排障
+
+| 现象 | 排查 |
+|---|---|
+| 503 用户未绑定节点 | 走了 default-node 兜底仍无绑定?检查 `gateway_service.default_node_id` 与路由表 |
+| 503 绑定节点不可用 | `GET /gateway/admin/status` 看节点 state;节点恢复后自动放行,映射不变 |
+| 502 连接失败 | 节点 base_url / 防火墙 / 后端进程 |
+| 登录后仍不粘 | 看日志「登录成功:用户 …绑定候选节点」;检查响应结构是否仍是 `Result<LoginVO>`(code=200 + data.userId/username) |
+| token 全部解析失败 | 网关与后端 sa-token 配置 / Redis database 不一致 |
+| 改了管理接口没生效 | 本实例立即生效;其他实例等 10s 对账,或 `POST /gateway/admin/reload` |
+
+## 7. 与设计文档的差异(实施修正)
+
+对照设计稿逐条核实后修正的点,均已实现并验证:
+
+1. **网关默认不收口 CORS**:后端已有全局 CorsFilter(`app.cors.*`),网关再配一层会输出重复
+   `Access-Control-Allow-Origin` 被浏览器拒绝。默认透传后端响应;`gateway.cors.enabled=true`
+   可打开网关层 CORS,常备的 `DedupeResponseHeader` 默认过滤器兜底去重。
+2. **sa-token 配置镜像后端**:设计稿写 `is-concurrent: true`,后端实际是 `false`(单端登录);
+   网关已逐项复制后端配置,避免键位/行为差异。
+3. **登录体/响应超限不返回 413/502**:超限只跳过解析与绑定(记日志),请求/响应原样转发 ——
+   绑定是旁路逻辑,不应破坏一次本可成功的登录。
+4. **token 失效 ≠ 未绑定**:token 解析不出(过期/被单端登录顶掉/伪造)按匿名轮询透传,
+   由后端返回 `code=401` 引导重新登录;而不是设计稿笼统落入的 503。
+5. **`LoginResponseFilter` order 为 -2** 而非 10002:`NettyWriteResponseFilter`(-1)持有自己
+   的 exchange,order 10002 的装饰器会被整个绕过(响应捕获不到、绑定永不发生)。
+6. **绑定写库 fire-and-forget**:挂在请求订阅生命周期上会被客户端断连取消传播砍掉。
+7. **首次加载不发 `RefreshRoutesEvent`**:`@PostConstruct` 阶段发事件会在启动期拉起
+   路由基础设施(→ 路由定义仓库 → registry),形成循环依赖导致启动失败。
+8. **URI 重写用字符串拼接**:多参 `URI` 构造器会二次编码已编码的 query(`%E4` → `%25E4`)。
+
+## 8. 已验证行为(冒烟记录,2026-10-25)
+
+用 mock 后端 + 直改库完成端到端验证:
+
+- 匿名请求轮询、`X-Gateway-Node`/`X-Request-Id` 响应头 ✔
+- 登录响应原样透传 + `gateway_user_route` 绑定落库 ✔
+- `x-token` 请求按缓存/路由表粘到绑定节点 ✔
+- 节点连续 3 次探活失败 → DOWN → 该节点用户 503「用户绑定的后端节点不可用」不切换 ✔
+- 节点恢复 → UP → 同一用户继续同一节点 ✔
+- 管理接口未登录 401、无路由 404、无健康节点 503 ✔
+
+单测 23 个(IP 白名单 / 节点轮询 / 登录体解析 / 目标 URI 构造):`mvn -pl ai-gateway test`。
+
+冒烟工具(不入部署包):`src/test/python/mock_backend.py`(模拟后端)、
+`UpdateNode.java` / `QueryRoutes.java` / `RestoreNodes.java`(直改库的辅助脚本)。

+ 148 - 0
ai-gateway/pom.xml

@@ -0,0 +1,148 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.zsjz</groupId>
+        <artifactId>zsjz-ai</artifactId>
+        <version>1.0</version>
+        <!-- 模块在父仓库内,走相对路径 -->
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+    <artifactId>ai-gateway</artifactId>
+    <name>ai-gateway</name>
+    <description>按用户粘性路由的转发网关(Spring Cloud Gateway WebFlux)</description>
+
+    <properties>
+        <java.version>25</java.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <spring-cloud.version>2025.0.1</spring-cloud.version>
+        <sa-token.version>1.46.0</sa-token.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.cloud</groupId>
+                <artifactId>spring-cloud-dependencies</artifactId>
+                <version>${spring-cloud.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <!-- Spring Cloud Gateway(WebFlux/Netty),负责流式转发(含 SSE / multipart / Range 下载) -->
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-webflux</artifactId>
+        </dependency>
+
+        <!-- 网关配置与用户路由持久化(PostgreSQL),阻塞调用统一放 boundedElastic -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-jdbc</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.postgresql</groupId>
+            <artifactId>postgresql</artifactId>
+        </dependency>
+
+        <!-- 网关自身的路由缓存(gateway:token / gateway:route:*) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
+        </dependency>
+
+        <!-- sa-token:只用来解析 token -> loginId(共享后端同一个 Redis)。
+             版本、Redis 地址、sa-token 配置必须与 ai-server 完全一致,
+             否则 token 键位对不上会全部解析失败。 -->
+        <dependency>
+            <groupId>cn.dev33</groupId>
+            <artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
+            <version>${sa-token.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.dev33</groupId>
+            <artifactId>sa-token-redis-jackson</artifactId>
+            <version>${sa-token.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.projectreactor</groupId>
+            <artifactId>reactor-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-resources-plugin</artifactId>
+                <executions>
+                    <!-- DDL 只维护一份:把仓库根的 sql/gateway.sql 复制成 classpath:db/gateway.sql,
+                         供 spring.sql.init 启动时建表(幂等,可重复执行) -->
+                    <execution>
+                        <id>copy-gateway-sql</id>
+                        <phase>generate-resources</phase>
+                        <goals>
+                            <goal>copy-resources</goal>
+                        </goals>
+                        <configuration>
+                            <outputDirectory>${project.build.outputDirectory}</outputDirectory>
+                            <resources>
+                                <resource>
+                                    <directory>${project.parent.basedir}/sql</directory>
+                                    <includes>
+                                        <include>gateway.sql</include>
+                                    </includes>
+                                    <targetPath>db</targetPath>
+                                </resource>
+                            </resources>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <annotationProcessorPaths>
+                        <path>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </path>
+                    </annotationProcessorPaths>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 23 - 0
ai-gateway/src/main/java/com/zsjz/gateway/GatewayApplication.java

@@ -0,0 +1,23 @@
+package com.zsjz.gateway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * 按用户粘性路由的转发网关。
+ *
+ * <p>职责只有一个:把每个用户的请求固定转发到该用户绑定的 ai-server 节点。
+ * 业务鉴权、案件数据源都在后端,网关不复制后端逻辑。
+ * 设计文档:docs/design/ai-gateway-user-sticky-routing.md。
+ */
+@SpringBootApplication
+@EnableScheduling
+@ConfigurationPropertiesScan
+public class GatewayApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(GatewayApplication.class, args);
+    }
+}

+ 30 - 0
ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminApiException.java

@@ -0,0 +1,30 @@
+package com.zsjz.gateway.admin;
+
+/**
+ * 管理接口业务异常:status 同时作为 HTTP 状态码与返回体 code(400/404/409 …)。
+ */
+public class AdminApiException extends RuntimeException {
+
+    private final int status;
+
+    public AdminApiException(int status, String message) {
+        super(message);
+        this.status = status;
+    }
+
+    public static AdminApiException badRequest(String message) {
+        return new AdminApiException(400, message);
+    }
+
+    public static AdminApiException notFound(String message) {
+        return new AdminApiException(404, message);
+    }
+
+    public static AdminApiException conflict(String message) {
+        return new AdminApiException(409, message);
+    }
+
+    public int getStatus() {
+        return status;
+    }
+}

+ 90 - 0
ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminAuthWebFilter.java

@@ -0,0 +1,90 @@
+package com.zsjz.gateway.admin;
+
+import com.zsjz.gateway.config.GatewayProperties;
+import com.zsjz.gateway.route.GatewayAttributes;
+import com.zsjz.gateway.route.TokenResolver;
+import com.zsjz.gateway.route.UserRouteRepository;
+import com.zsjz.gateway.support.ApiResult;
+import com.zsjz.gateway.support.IpWhitelist;
+import com.zsjz.gateway.support.JsonResponses;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.net.InetSocketAddress;
+
+/**
+ * 管理接口准入(只作用于 {@code /gateway/admin/**}):
+ * <ol>
+ *   <li>{@code gateway.admin.enabled=false} → 一律 404(接口“消失”);</li>
+ *   <li>IP 白名单({@code gateway.admin.ip-whitelist},单 IP / CIDR,空 = 不限);</li>
+ *   <li>{@code x-token} → sa-token 共享 Redis → userId;无效 401;</li>
+ *   <li>{@code sys_user.is_admin=1 && status=1} 才放行,否则 403。</li>
+ * </ol>
+ *
+ * <p>管理员 token 是普通登录 token:管理员先经网关登录任意节点拿到 x-token。
+ */
+@Slf4j
+@Component
+@Order(-50)
+@RequiredArgsConstructor
+public class AdminAuthWebFilter implements WebFilter {
+
+    private final GatewayProperties properties;
+    private final TokenResolver tokenResolver;
+    private final UserRouteRepository userRouteRepository;
+
+    @Override
+    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+        String path = exchange.getRequest().getPath().value();
+        if (!path.startsWith("/gateway/admin")) {
+            return chain.filter(exchange);
+        }
+        if (!properties.getAdmin().isEnabled()) {
+            return JsonResponses.write(exchange, HttpStatus.NOT_FOUND,
+                    ApiResult.error(404, "管理接口未启用"));
+        }
+        if (!IpWhitelist.allows(properties.getAdmin().getIpWhitelist(), remoteHost(exchange.getRequest()))) {
+            log.warn("管理接口拒绝非白名单来源:{}", remoteHost(exchange.getRequest()));
+            return JsonResponses.write(exchange, HttpStatus.FORBIDDEN,
+                    ApiResult.error(403, "来源 IP 不在管理白名单"));
+        }
+        String token = exchange.getRequest().getHeaders().getFirst("x-token");
+        if (token == null || token.isBlank()) {
+            return JsonResponses.write(exchange, HttpStatus.UNAUTHORIZED,
+                    ApiResult.error(401, "未登录或登录已失效"));
+        }
+        return tokenResolver.resolveUserIdDirect(token)
+                .flatMap(userId -> Mono
+                        .fromCallable(() -> userRouteRepository.findSysUserById(userId))
+                        .subscribeOn(Schedulers.boundedElastic())
+                        .flatMap(user -> {
+                            if (user == null) {
+                                return JsonResponses.write(exchange, HttpStatus.UNAUTHORIZED,
+                                        ApiResult.error(401, "未登录或登录已失效"));
+                            }
+                            if (user.isAdmin() != 1 || user.status() != 1) {
+                                log.warn("管理接口拒绝非管理员/停用用户:userId={}", user.id());
+                                return JsonResponses.write(exchange, HttpStatus.FORBIDDEN,
+                                        ApiResult.error(403, "需要管理员权限"));
+                            }
+                            exchange.getAttributes().put(GatewayAttributes.ADMIN_USER_ID, user.id());
+                            return chain.filter(exchange);
+                        }))
+                .switchIfEmpty(Mono.defer(() -> JsonResponses.write(exchange, HttpStatus.UNAUTHORIZED,
+                        ApiResult.error(401, "未登录或登录已失效"))));
+    }
+
+    private String remoteHost(ServerHttpRequest request) {
+        InetSocketAddress remote = request.getRemoteAddress();
+        return remote == null ? null : remote.getHostString();
+    }
+}

+ 153 - 0
ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminController.java

@@ -0,0 +1,153 @@
+package com.zsjz.gateway.admin;
+
+import com.zsjz.gateway.support.ApiResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.support.WebExchangeBindException;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.util.Map;
+
+/**
+ * 网关管理接口(全部需要管理员登录态 + 可选 IP 白名单,见 AdminAuthWebFilter)。
+ *
+ * <p>阻塞 JDBC 操作统一放 boundedElastic,避免占住 Netty 事件循环。
+ */
+@RestController
+@RequestMapping("/gateway/admin")
+@RequiredArgsConstructor
+public class AdminController {
+
+    private final AdminService adminService;
+
+    @GetMapping("/status")
+    public Mono<ApiResult<Object>> status() {
+        return blocking(() -> ApiResult.ok(adminService.status()));
+    }
+
+    @GetMapping("/services")
+    public Mono<ApiResult<Object>> services() {
+        return blocking(() -> ApiResult.ok(adminService.status().get("services")));
+    }
+
+    @PostMapping("/services")
+    public Mono<ApiResult<Object>> createService(@RequestBody AdminService.ServiceUpsert dto) {
+        return blocking(() -> {
+            adminService.createService(dto);
+            return ApiResult.ok();
+        });
+    }
+
+    @PutMapping("/services/{serviceId}")
+    public Mono<ApiResult<Object>> updateService(@PathVariable String serviceId,
+                                                 @RequestBody AdminService.ServiceUpsert dto) {
+        return blocking(() -> {
+            adminService.updateService(serviceId, dto);
+            return ApiResult.ok();
+        });
+    }
+
+    @DeleteMapping("/services/{serviceId}")
+    public Mono<ApiResult<Object>> deleteService(@PathVariable String serviceId) {
+        return blocking(() -> {
+            adminService.deleteService(serviceId);
+            return ApiResult.ok();
+        });
+    }
+
+    @PostMapping("/services/{serviceId}/nodes")
+    public Mono<ApiResult<Object>> createNode(@PathVariable String serviceId,
+                                              @RequestBody AdminService.NodeUpsert dto) {
+        return blocking(() -> {
+            adminService.createNode(serviceId, dto);
+            return ApiResult.ok();
+        });
+    }
+
+    @PutMapping("/nodes/{nodeId}")
+    public Mono<ApiResult<Object>> updateNode(@PathVariable String nodeId,
+                                              @RequestBody AdminService.NodeUpsert dto) {
+        return blocking(() -> {
+            adminService.updateNode(nodeId, dto);
+            return ApiResult.ok();
+        });
+    }
+
+    @DeleteMapping("/nodes/{nodeId}")
+    public Mono<ApiResult<Object>> deleteNode(@PathVariable String nodeId) {
+        return blocking(() -> {
+            adminService.deleteNode(nodeId);
+            return ApiResult.ok();
+        });
+    }
+
+    @PostMapping("/nodes/{nodeId}/check")
+    public Mono<ApiResult<Object>> checkNode(@PathVariable String nodeId) {
+        return adminService.checkNode(nodeId).map(ApiResult::ok);
+    }
+
+    @GetMapping("/routes")
+    public Mono<ApiResult<Object>> routes(@RequestParam String serviceId,
+                                          @RequestParam(required = false) String keyword,
+                                          @RequestParam(defaultValue = "0") int page,
+                                          @RequestParam(defaultValue = "20") int size) {
+        return blocking(() -> ApiResult.ok(adminService.pageRoutes(serviceId, keyword, page, size)));
+    }
+
+    @PostMapping("/routes/bind")
+    public Mono<ApiResult<Object>> bind(@RequestBody AdminService.BindRequest dto) {
+        return blocking(() -> {
+            adminService.bindRoute(dto);
+            return ApiResult.ok();
+        });
+    }
+
+    @DeleteMapping("/routes/{id}")
+    public Mono<ApiResult<Object>> unbind(@PathVariable long id) {
+        return blocking(() -> {
+            adminService.unbind(id);
+            return ApiResult.ok();
+        });
+    }
+
+    @PostMapping("/routes/import-existing")
+    public Mono<ApiResult<Object>> importExisting(@RequestBody Map<String, String> body) {
+        String serviceId = body.get("serviceId");
+        String nodeId = body.get("nodeId");
+        return blocking(() -> ApiResult.ok(adminService.importExisting(serviceId, nodeId)));
+    }
+
+    @PostMapping("/reload")
+    public Mono<ApiResult<Object>> reload() {
+        return blocking(() -> ApiResult.ok(adminService.reload()));
+    }
+
+    // ------------------------------------------------------------------
+
+    private <T> Mono<T> blocking(java.util.function.Supplier<T> supplier) {
+        return Mono.fromSupplier(supplier).subscribeOn(Schedulers.boundedElastic());
+    }
+
+    @ExceptionHandler(AdminApiException.class)
+    public ResponseEntity<ApiResult<Object>> handleAdminApi(AdminApiException ex) {
+        return ResponseEntity.status(ex.getStatus())
+                .body(ApiResult.error(ex.getStatus(), ex.getMessage()));
+    }
+
+    @ExceptionHandler(WebExchangeBindException.class)
+    public ResponseEntity<ApiResult<Object>> handleBind(WebExchangeBindException ex) {
+        return ResponseEntity.badRequest()
+                .body(ApiResult.error(400, "请求参数错误:" + ex.getMessage()));
+    }
+}

+ 290 - 0
ai-gateway/src/main/java/com/zsjz/gateway/admin/AdminService.java

@@ -0,0 +1,290 @@
+package com.zsjz.gateway.admin;
+
+import com.zsjz.gateway.config.GatewayProperties;
+import com.zsjz.gateway.registry.GatewayConfigRepository;
+import com.zsjz.gateway.registry.GatewayRegistry;
+import com.zsjz.gateway.registry.HealthChecker;
+import com.zsjz.gateway.registry.NodeHealthRegistry;
+import com.zsjz.gateway.registry.NodeInfo;
+import com.zsjz.gateway.registry.ServiceInfo;
+import com.zsjz.gateway.route.UserRouteRepository;
+import com.zsjz.gateway.route.UserRouteService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionTemplate;
+import reactor.core.publisher.Mono;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理接口服务层(阻塞 JDBC,控制器统一放 boundedElastic)。
+ *
+ * <p>写操作完成后调用 {@link GatewayRegistry#reload()}:本实例路由立即刷新;
+ * 其他网关实例靠 registry 的 10 秒对账收敛。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AdminService {
+
+    private final GatewayConfigRepository configRepository;
+    private final UserRouteRepository routeRepository;
+    private final UserRouteService userRouteService;
+    private final GatewayRegistry registry;
+    private final NodeHealthRegistry healthRegistry;
+    private final HealthChecker healthChecker;
+    private final TransactionTemplate transactionTemplate;
+    private final GatewayProperties properties;
+
+    // ------------------------------------------------------------------
+    // 状态
+    // ------------------------------------------------------------------
+
+    public Map<String, Object> status() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> services = new ArrayList<>();
+        for (ServiceInfo service : registry.getServices()) {
+            Map<String, Object> svc = new LinkedHashMap<>();
+            svc.put("id", service.id());
+            svc.put("name", service.name());
+            svc.put("pathPrefix", service.pathPrefix());
+            svc.put("enabled", service.enabled());
+            svc.put("defaultNodeId", service.defaultNodeId());
+            svc.put("routeCount", routeRepository.countByService(service.id()));
+            List<Map<String, Object>> nodes = new ArrayList<>();
+            for (NodeInfo node : service.nodes()) {
+                Map<String, Object> n = new LinkedHashMap<>();
+                n.put("id", node.id());
+                n.put("name", node.name());
+                n.put("baseUrl", node.baseUrl());
+                n.put("enabled", node.enabled());
+                NodeHealthRegistry.Health health = healthRegistry.snapshot().get(node.id());
+                n.put("state", health == null ? "UNKNOWN" : health.state().name());
+                n.put("lastError", health == null ? null : health.lastError());
+                n.put("lastCheckedAt", health == null ? null : health.lastCheckedAt());
+                n.put("routeCount", routeRepository.countByNode(node.id()));
+                nodes.add(n);
+            }
+            svc.put("nodes", nodes);
+            services.add(svc);
+        }
+        result.put("services", services);
+        return result;
+    }
+
+    // ------------------------------------------------------------------
+    // 服务 / 节点
+    // ------------------------------------------------------------------
+
+    public void createService(ServiceUpsert dto) {
+        GatewayConfigRepository.validateServiceId(dto.id);
+        GatewayConfigRepository.validatePathPrefix(dto.pathPrefix);
+        if (configRepository.serviceExists(dto.id)) {
+            throw AdminApiException.badRequest("服务已存在:" + dto.id);
+        }
+        try {
+            configRepository.upsertService(true, dto.id, requireName(dto.name), dto.pathPrefix,
+                    dto.loginPath, dto.healthPath, dto.defaultNodeId, dto.enabled());
+        } catch (DuplicateKeyException ex) {
+            throw AdminApiException.badRequest("path-prefix 已被其他服务占用:" + dto.pathPrefix);
+        }
+        registry.reload();
+    }
+
+    public void updateService(String serviceId, ServiceUpsert dto) {
+        if (!configRepository.serviceExists(serviceId)) {
+            throw AdminApiException.notFound("服务不存在:" + serviceId);
+        }
+        GatewayConfigRepository.validatePathPrefix(dto.pathPrefix);
+        try {
+            configRepository.upsertService(false, serviceId, requireName(dto.name), dto.pathPrefix,
+                    dto.loginPath, dto.healthPath, dto.defaultNodeId, dto.enabled());
+        } catch (DuplicateKeyException ex) {
+            throw AdminApiException.badRequest("path-prefix 已被其他服务占用:" + dto.pathPrefix);
+        }
+        registry.reload();
+    }
+
+    public void deleteService(String serviceId) {
+        if (!configRepository.serviceExists(serviceId)) {
+            throw AdminApiException.notFound("服务不存在:" + serviceId);
+        }
+        if (!configRepository.loadAll().stream().filter(s -> s.id().equals(serviceId))
+                .findFirst().orElseThrow().nodes().isEmpty()) {
+            throw AdminApiException.conflict("服务下仍有节点,请先删除节点");
+        }
+        if (routeRepository.countByService(serviceId) > 0) {
+            throw AdminApiException.conflict("服务下仍有用户绑定,请先解绑用户");
+        }
+        configRepository.deleteService(serviceId);
+        registry.reload();
+    }
+
+    public void createNode(String serviceId, NodeUpsert dto) {
+        if (!configRepository.serviceExists(serviceId)) {
+            throw AdminApiException.notFound("服务不存在:" + serviceId);
+        }
+        GatewayConfigRepository.validateNodeId(dto.id);
+        GatewayConfigRepository.validateBaseUrl(dto.baseUrl);
+        if (configRepository.nodeExists(dto.id)) {
+            throw AdminApiException.badRequest("节点 id 已存在(全局唯一):" + dto.id);
+        }
+        try {
+            configRepository.insertNode(dto.id, serviceId, requireName(dto.name),
+                    dto.baseUrl, dto.enabledOrDefault(), dto.remark);
+        } catch (DuplicateKeyException ex) {
+            throw AdminApiException.badRequest("同一服务下 base-url 已存在:" + dto.baseUrl);
+        }
+        registry.reload();
+    }
+
+    public void updateNode(String nodeId, NodeUpsert dto) {
+        ServiceInfo owner = registry.getServices().stream()
+                .flatMap(s -> s.nodes().stream().filter(n -> n.id().equals(nodeId))
+                        .map(n -> Map.entry(s, n)))
+                .findFirst().map(Map.Entry::getKey).orElse(null);
+        if (owner == null) {
+            throw AdminApiException.notFound("节点不存在:" + nodeId);
+        }
+        GatewayConfigRepository.validateBaseUrl(dto.baseUrl);
+        try {
+            configRepository.updateNode(nodeId, requireName(dto.name), dto.baseUrl,
+                    dto.enabledOrDefault(), dto.remark);
+        } catch (DuplicateKeyException ex) {
+            throw AdminApiException.badRequest("同一服务下 base-url 已存在:" + dto.baseUrl);
+        }
+        registry.reload();
+    }
+
+    public void deleteNode(String nodeId) {
+        if (!configRepository.nodeExists(nodeId)) {
+            throw AdminApiException.notFound("节点不存在:" + nodeId);
+        }
+        if (routeRepository.countByNode(nodeId) > 0) {
+            throw AdminApiException.conflict("节点上仍有用户绑定,请先解绑或重绑用户");
+        }
+        configRepository.deleteNode(nodeId);
+        registry.reload();
+    }
+
+    /** 手动探活(立即执行一次,不等定时任务) */
+    public Mono<Map<String, Object>> checkNode(String nodeId) {
+        for (ServiceInfo service : registry.getServices()) {
+            for (NodeInfo node : service.nodes()) {
+                if (node.id().equals(nodeId)) {
+                    int threshold = properties.getHealthCheck().getFailureThreshold();
+                    return healthChecker.probe(service, node, threshold).map(state -> {
+                        Map<String, Object> r = new LinkedHashMap<>();
+                        r.put("nodeId", nodeId);
+                        r.put("state", state.name());
+                        return r;
+                    });
+                }
+            }
+        }
+        throw AdminApiException.notFound("节点不存在:" + nodeId);
+    }
+
+    // ------------------------------------------------------------------
+    // 用户路由
+    // ------------------------------------------------------------------
+
+    public Object pageRoutes(String serviceId, String keyword, int page, int size) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", routeRepository.countRoutes(serviceId, keyword));
+        result.put("items", routeRepository.pageRoutes(serviceId, keyword,
+                Math.max(page, 0), Math.min(Math.max(size, 1), 200)));
+        return result;
+    }
+
+    /** 预绑定 / 重绑:覆盖式(先删该用户已有路由再插入新绑定) */
+    public void bindRoute(BindRequest dto) {
+        ServiceInfo service = registry.getService(dto.serviceId)
+                .orElseThrow(() -> AdminApiException.notFound("服务不存在:" + dto.serviceId));
+        NodeInfo node = service.findNode(dto.nodeId)
+                .orElseThrow(() -> AdminApiException.badRequest("节点不属于服务 " + dto.serviceId));
+        if (!node.enabled()) {
+            throw AdminApiException.badRequest("节点已停用:" + dto.nodeId);
+        }
+        if (dto.userId == null && (dto.username == null || dto.username.isBlank())) {
+            throw AdminApiException.badRequest("userId 与 username 至少提供一个");
+        }
+        // 以 sys_user 为准补全 userId / username,保证两个唯一索引都能命中
+        UserRouteRepository.SysUserRow user = dto.userId != null
+                ? routeRepository.findSysUserById(dto.userId)
+                : routeRepository.findSysUserByUsername(dto.username);
+        if (user == null) {
+            throw AdminApiException.notFound("用户不存在(sys_user)");
+        }
+        Long userId = user.id();
+        String username = user.username();
+        transactionTemplate.executeWithoutResult(tx -> {
+            routeRepository.deleteRoutesForUser(dto.serviceId, userId, username);
+            routeRepository.insertRoute(dto.serviceId, userId, username, dto.nodeId);
+        });
+        // 缓存清理放事务外:失败只影响缓存时效,不影响正确性
+        userRouteService.evictUserCache(dto.serviceId, userId, username).block();
+        log.info("管理端绑定用户 {}({}) -> 节点 {}", username, userId, dto.nodeId);
+    }
+
+    public void unbind(long routeId) {
+        UserRouteRepository.RouteRow row = routeRepository.findRouteById(routeId);
+        if (row == null) {
+            throw AdminApiException.notFound("路由不存在:" + routeId);
+        }
+        routeRepository.deleteRouteById(routeId);
+        userRouteService.evictUserCache(row.serviceId(), row.userId(), row.username()).block();
+    }
+
+    public Object importExisting(String serviceId, String nodeId) {
+        ServiceInfo service = registry.getService(serviceId)
+                .orElseThrow(() -> AdminApiException.notFound("服务不存在:" + serviceId));
+        if (service.findNode(nodeId).isEmpty()) {
+            throw AdminApiException.badRequest("节点不属于服务 " + serviceId);
+        }
+        int inserted = routeRepository.importExisting(serviceId, nodeId);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("inserted", inserted);
+        result.put("total", routeRepository.countByService(serviceId));
+        return result;
+    }
+
+    public Object reload() {
+        boolean changed = registry.reload();
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("changed", changed);
+        return result;
+    }
+
+    private String requireName(String name) {
+        if (name == null || name.isBlank()) {
+            throw AdminApiException.badRequest("name 不能为空");
+        }
+        return name.trim();
+    }
+
+    // ------------------------------------------------------------------
+    // DTO
+    // ------------------------------------------------------------------
+
+    public record ServiceUpsert(String id, String name, String pathPrefix, String loginPath,
+                                String healthPath, String defaultNodeId, Boolean enabled) {
+        public boolean enabledOrDefault() {
+            return enabled == null || enabled;
+        }
+    }
+
+    public record NodeUpsert(String id, String name, String baseUrl, Boolean enabled, String remark) {
+        public boolean enabledOrDefault() {
+            return enabled == null || enabled;
+        }
+    }
+
+    public record BindRequest(String serviceId, Long userId, String username, String nodeId) {
+    }
+}

+ 39 - 0
ai-gateway/src/main/java/com/zsjz/gateway/config/CorsConfig.java

@@ -0,0 +1,39 @@
+package com.zsjz.gateway.config;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.reactive.CorsWebFilter;
+import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
+
+import java.util.List;
+
+/**
+ * 网关层 CORS(默认关闭)。
+ *
+ * <p><b>为什么默认关</b>:ai-server 已有全局 CorsFilter({@code app.cors.*},
+ * 预检和响应头都由后端应答),网关再配一层会产生两个 {@code Access-Control-Allow-Origin},
+ * 浏览器直接拒绝整个响应。默认透传后端响应头即可;确需网关收口跨域时打开本开关,
+ * application.yaml 里常备的 DedupeResponseHeader 默认过滤器会把重复头去重兜底。
+ */
+@Configuration
+@ConditionalOnProperty(name = "gateway.cors.enabled", havingValue = "true")
+public class CorsConfig {
+
+    @Bean
+    public CorsWebFilter gatewayCorsWebFilter(GatewayProperties properties) {
+        CorsConfiguration config = new CorsConfiguration();
+        config.setAllowedOriginPatterns(properties.getCors().getAllowedOriginPatterns());
+        config.addAllowedHeader(CorsConfiguration.ALL);
+        config.addAllowedMethod(CorsConfiguration.ALL);
+        // x-token 走请求头、不走 Cookie,允许无凭证跨域(与后端 CorsConfig 同语义)
+        config.setAllowCredentials(false);
+        config.setExposedHeaders(List.of("X-Gateway-Node", "Content-Disposition", "X-Request-Id"));
+        config.setMaxAge(3600L);
+
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        source.registerCorsConfiguration("/**", config);
+        return new CorsWebFilter(source);
+    }
+}

+ 81 - 0
ai-gateway/src/main/java/com/zsjz/gateway/config/GatewayProperties.java

@@ -0,0 +1,81 @@
+package com.zsjz.gateway.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@code gateway.*} 配置。
+ *
+ * <p>{@code gateway.services} 只在<b>首次启动</b>(gateway_service 表为空)时播种;
+ * 之后以数据库为准,YAML 不再覆盖,变更走 {@code /gateway/admin/**}。
+ */
+@Data
+@ConfigurationProperties(prefix = "gateway")
+public class GatewayProperties {
+
+    private final Admin admin = new Admin();
+
+    private final HealthCheck healthCheck = new HealthCheck();
+
+    private final Cors cors = new Cors();
+
+    /** 播种用的服务定义(仅表为空时写入) */
+    private List<ServiceDef> services = new ArrayList<>();
+
+    @Data
+    public static class Admin {
+        /** 关闭后 /gateway/admin/** 一律 404 */
+        private boolean enabled = true;
+        /** 单 IP / CIDR;空 = 不限制 */
+        private List<String> ipWhitelist = new ArrayList<>();
+    }
+
+    @Data
+    public static class HealthCheck {
+        /** 探活间隔 */
+        private Duration interval = Duration.ofSeconds(10);
+        /** 单次探活超时 */
+        private Duration timeout = Duration.ofSeconds(2);
+        /** 连续失败 N 次标记 DOWN;1 次成功恢复 UP */
+        private int failureThreshold = 3;
+    }
+
+    @Data
+    public static class Cors {
+        /**
+         * 默认关闭:后端已有全局 CorsFilter,网关再收口会出现重复 CORS 头。
+         * 仅在网关需要独立承担跨域(如后端关闭 CORS)时打开。
+         */
+        private boolean enabled = false;
+        private List<String> allowedOriginPatterns = List.of("*");
+    }
+
+    @Data
+    public static class ServiceDef {
+        /** ^[a-z][a-z0-9-]{1,63}$ */
+        private String id;
+        private String name;
+        /** 以 / 开头、不以 / 结尾、不能是 /gateway,全局唯一 */
+        private String pathPrefix;
+        /** 相对 pathPrefix 的登录路径,如 /auth/login;空 = 不做登录粘性 */
+        private String loginPath;
+        /** 完整健康检查路径,如 /js/a/sys/health */
+        private String healthPath;
+        /** 存量用户兜底节点 */
+        private String defaultNodeId;
+        private List<NodeDef> nodes = new ArrayList<>();
+    }
+
+    @Data
+    public static class NodeDef {
+        /** ^[a-z][a-z0-9-]{1,63}$,全局唯一 */
+        private String id;
+        private String name;
+        /** http(s)://host:port,不含路径 */
+        private String baseUrl;
+    }
+}

+ 26 - 0
ai-gateway/src/main/java/com/zsjz/gateway/config/WebClientConfig.java

@@ -0,0 +1,26 @@
+package com.zsjz.gateway.config;
+
+import io.netty.channel.ChannelOption;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
+
+/**
+ * 节点探活专用 WebClient(业务转发走 Spring Cloud Gateway 自己的 HttpClient,不用这里)。
+ */
+@Configuration
+public class WebClientConfig {
+
+    @Bean
+    public WebClient healthWebClient(GatewayProperties properties) {
+        int timeoutMillis = (int) properties.getHealthCheck().getTimeout().toMillis();
+        HttpClient httpClient = HttpClient.create()
+                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, timeoutMillis)
+                .responseTimeout(properties.getHealthCheck().getTimeout());
+        return WebClient.builder()
+                .clientConnector(new ReactorClientHttpConnector(httpClient))
+                .build();
+    }
+}

+ 174 - 0
ai-gateway/src/main/java/com/zsjz/gateway/registry/GatewayConfigRepository.java

@@ -0,0 +1,174 @@
+package com.zsjz.gateway.registry;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * gateway_service / gateway_node 两张配置表的 JDBC 访问(阻塞,调用方负责放 boundedElastic)。
+ */
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class GatewayConfigRepository {
+
+    public static final Pattern ID_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,63}$");
+    public static final Pattern BASE_URL_PATTERN = Pattern.compile("^https?://[^/\\s]+(:\\d+)?$");
+
+    private final JdbcTemplate jdbc;
+
+    // ------------------------------------------------------------------
+    // 读取
+    // ------------------------------------------------------------------
+
+    public int countServices() {
+        Integer count = jdbc.queryForObject("SELECT count(*) FROM gateway_service", Integer.class);
+        return count == null ? 0 : count;
+    }
+
+    public List<ServiceInfo> loadAll() {
+        Map<String, List<NodeInfo>> nodesByService = new LinkedHashMap<>();
+        jdbc.query("SELECT id, service_id, name, base_url, enabled, remark FROM gateway_node ORDER BY id",
+                rs -> {
+                    NodeInfo node = new NodeInfo(
+                            rs.getString("id"),
+                            rs.getString("service_id"),
+                            rs.getString("name"),
+                            rs.getString("base_url"),
+                            rs.getShort("enabled") == 1,
+                            rs.getString("remark"));
+                    nodesByService.computeIfAbsent(node.serviceId(), k -> new ArrayList<>()).add(node);
+                });
+
+        List<ServiceInfo> services = new ArrayList<>();
+        jdbc.query("""
+                        SELECT id, name, path_prefix, login_path, health_path, default_node_id, enabled
+                        FROM gateway_service
+                        """,
+                rs -> {
+                    String id = rs.getString("id");
+                    services.add(new ServiceInfo(
+                            id,
+                            rs.getString("name"),
+                            rs.getString("path_prefix"),
+                            rs.getString("login_path"),
+                            rs.getString("health_path"),
+                            rs.getString("default_node_id"),
+                            rs.getShort("enabled") == 1,
+                            nodesByService.getOrDefault(id, List.of())));
+                });
+        // 前缀长的优先,保证 /js/a/xxx 不会被更短前缀的服务抢走
+        services.sort((a, b) -> Integer.compare(b.pathPrefix().length(), a.pathPrefix().length()));
+        return services;
+    }
+
+    // ------------------------------------------------------------------
+    // 播种(仅首次启动,表为空时)
+    // ------------------------------------------------------------------
+
+    public void seedService(String id, String name, String pathPrefix, String loginPath,
+                            String healthPath, String defaultNodeId) {
+        jdbc.update("""
+                INSERT INTO gateway_service (id, name, path_prefix, login_path, health_path, default_node_id)
+                VALUES (?, ?, ?, ?, ?, ?)
+                ON CONFLICT (id) DO NOTHING
+                """, id, name, pathPrefix, loginPath, healthPath, defaultNodeId);
+    }
+
+    public void seedNode(String id, String serviceId, String name, String baseUrl) {
+        jdbc.update("""
+                INSERT INTO gateway_node (id, service_id, name, base_url)
+                VALUES (?, ?, ?, ?)
+                ON CONFLICT (id) DO NOTHING
+                """, id, serviceId, name, baseUrl);
+    }
+
+    // ------------------------------------------------------------------
+    // 管理:服务
+    // ------------------------------------------------------------------
+
+    public boolean serviceExists(String id) {
+        Integer count = jdbc.queryForObject(
+                "SELECT count(*) FROM gateway_service WHERE id = ?", Integer.class, id);
+        return count != null && count > 0;
+    }
+
+    public boolean nodeExists(String id) {
+        Integer count = jdbc.queryForObject(
+                "SELECT count(*) FROM gateway_node WHERE id = ?", Integer.class, id);
+        return count != null && count > 0;
+    }
+
+    public int upsertService(boolean create, String id, String name, String pathPrefix, String loginPath,
+                             String healthPath, String defaultNodeId, boolean enabled) {
+        if (create) {
+            return jdbc.update("""
+                    INSERT INTO gateway_service (id, name, path_prefix, login_path, health_path, default_node_id, enabled)
+                    VALUES (?, ?, ?, ?, ?, ?, ?)
+                    """, id, name, pathPrefix, loginPath, healthPath, defaultNodeId, enabled ? 1 : 0);
+        }
+        return jdbc.update("""
+                UPDATE gateway_service
+                SET name = ?, path_prefix = ?, login_path = ?, health_path = ?, default_node_id = ?, enabled = ?, update_time = now()
+                WHERE id = ?
+                """, name, pathPrefix, loginPath, healthPath, defaultNodeId, enabled ? 1 : 0, id);
+    }
+
+    public int deleteService(String id) {
+        return jdbc.update("DELETE FROM gateway_service WHERE id = ?", id);
+    }
+
+    public int insertNode(String id, String serviceId, String name, String baseUrl,
+                          boolean enabled, String remark) {
+        return jdbc.update("""
+                INSERT INTO gateway_node (id, service_id, name, base_url, enabled, remark)
+                VALUES (?, ?, ?, ?, ?, ?)
+                """, id, serviceId, name, baseUrl, enabled ? 1 : 0, remark);
+    }
+
+    public int updateNode(String id, String name, String baseUrl,
+                          boolean enabled, String remark) {
+        return jdbc.update("""
+                UPDATE gateway_node
+                SET name = ?, base_url = ?, enabled = ?, remark = ?, update_time = now()
+                WHERE id = ?
+                """, name, baseUrl, enabled ? 1 : 0, remark, id);
+    }
+
+    public int deleteNode(String id) {
+        return jdbc.update("DELETE FROM gateway_node WHERE id = ?", id);
+    }
+
+    public static void validateServiceId(String id) {
+        if (id == null || !ID_PATTERN.matcher(id).matches()) {
+            throw new IllegalArgumentException("服务 id 必须匹配 ^[a-z][a-z0-9-]{1,63}$:" + id);
+        }
+    }
+
+    public static void validateNodeId(String id) {
+        if (id == null || !ID_PATTERN.matcher(id).matches()) {
+            throw new IllegalArgumentException("节点 id 必须匹配 ^[a-z][a-z0-9-]{1,63}$:" + id);
+        }
+    }
+
+    public static void validatePathPrefix(String pathPrefix) {
+        if (pathPrefix == null || !pathPrefix.startsWith("/") || pathPrefix.endsWith("/")
+                || pathPrefix.equals("/gateway") || pathPrefix.length() < 2) {
+            throw new IllegalArgumentException(
+                    "path-prefix 必须以 / 开头、不以 / 结尾、不能是 /gateway:" + pathPrefix);
+        }
+    }
+
+    public static void validateBaseUrl(String baseUrl) {
+        if (baseUrl == null || !BASE_URL_PATTERN.matcher(baseUrl).matches()) {
+            throw new IllegalArgumentException("base-url 必须是 http(s)://host[:port] 且不含路径:" + baseUrl);
+        }
+    }
+}

+ 116 - 0
ai-gateway/src/main/java/com/zsjz/gateway/registry/GatewayRegistry.java

@@ -0,0 +1,116 @@
+package com.zsjz.gateway.registry;
+
+import com.zsjz.gateway.config.GatewayProperties;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 服务/节点配置的内存快照(事实来源是 PostgreSQL,Redis 与内存都只是缓存/快照)。
+ *
+ * <ul>
+ *   <li>启动时:gateway_service 为空则用 YAML 播种,然后加载快照;</li>
+ *   <li>运行中:本实例管理接口写库后立即 {@link #reload()};其他实例每 10 秒对账一次,
+ *       快照变化时发布 {@link RefreshRoutesEvent} 刷新路由缓存。</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class GatewayRegistry {
+
+    private final GatewayConfigRepository repository;
+    private final GatewayProperties properties;
+    private final ApplicationEventPublisher eventPublisher;
+
+    private volatile List<ServiceInfo> services = List.of();
+
+    /**
+     * 首次加载(@PostConstruct 阶段)不能发布 RefreshRoutesEvent:启动期事件监听会
+     * 拉起路由基础设施(CachingRouteLocator -> 路由定义仓库 -> 本类),形成循环依赖。
+     * 初始路由定义由 CachingRouteLocator 自己创建时拉取,不需要事件;后续变更才需要。
+     */
+    private volatile boolean initialized = false;
+
+    @PostConstruct
+    void init() {
+        seedFromYamlIfNeeded();
+        reload();
+    }
+
+    /** 首次启动播种:表为空时把 gateway.services 写入数据库;之后 YAML 不再覆盖 */
+    void seedFromYamlIfNeeded() {
+        if (repository.countServices() > 0) {
+            return;
+        }
+        List<GatewayProperties.ServiceDef> defs = properties.getServices();
+        if (defs == null || defs.isEmpty()) {
+            log.warn("gateway_service 表为空且 gateway.services 未配置,网关没有任何可转发服务");
+            return;
+        }
+        for (GatewayProperties.ServiceDef def : defs) {
+            GatewayConfigRepository.validateServiceId(def.getId());
+            GatewayConfigRepository.validatePathPrefix(def.getPathPrefix());
+            repository.seedService(def.getId(), def.getName(), def.getPathPrefix(),
+                    def.getLoginPath(), def.getHealthPath(), def.getDefaultNodeId());
+            if (def.getNodes() != null) {
+                for (GatewayProperties.NodeDef node : def.getNodes()) {
+                    GatewayConfigRepository.validateNodeId(node.getId());
+                    GatewayConfigRepository.validateBaseUrl(node.getBaseUrl());
+                    repository.seedNode(node.getId(), def.getId(), node.getName(), node.getBaseUrl());
+                }
+            }
+        }
+        log.info("已按 YAML 播种 {} 个网关服务", defs.size());
+    }
+
+    /**
+     * 从数据库重载快照;有变化时发布路由刷新事件。
+     *
+     * @return true 表示快照发生了变化
+     */
+    public synchronized boolean reload() {
+        List<ServiceInfo> fresh = repository.loadAll();
+        boolean changed = !fresh.equals(services);
+        if (changed) {
+            services = fresh;
+            if (initialized) {
+                eventPublisher.publishEvent(new RefreshRoutesEvent(this));
+            }
+            log.info("网关配置已重载:{} 个服务 {}", fresh.size(),
+                    fresh.stream().map(ServiceInfo::id).toList());
+        }
+        initialized = true;
+        return changed;
+    }
+
+    /** 其他网关实例写库后的对账(多实例共享 PG,写侧只刷新自己,读侧靠这里收敛) */
+    @Scheduled(fixedDelayString = "${gateway.registry-poll-interval:10s}")
+    void pollForChanges() {
+        try {
+            reload();
+        } catch (Exception ex) {
+            log.warn("网关配置对账失败,下一轮重试:{}", ex.toString());
+        }
+    }
+
+    public List<ServiceInfo> getServices() {
+        return services;
+    }
+
+    public Optional<ServiceInfo> getService(String serviceId) {
+        return services.stream().filter(s -> s.id().equals(serviceId)).findFirst();
+    }
+
+    /** 按路径前缀找服务(快照已按前缀长度降序,第一个命中的即最长前缀) */
+    public Optional<ServiceInfo> findServiceByPath(String rawPath) {
+        return services.stream().filter(s -> s.matchesPath(rawPath)).findFirst();
+    }
+}

+ 62 - 0
ai-gateway/src/main/java/com/zsjz/gateway/registry/HealthChecker.java

@@ -0,0 +1,62 @@
+package com.zsjz.gateway.registry;
+
+import com.zsjz.gateway.config.GatewayProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+/**
+ * 节点探活:每 {@code gateway.health-check.interval} 对所有启用节点
+ * {@code GET {base-url}{health-path}},连续失败达到阈值标记 DOWN,1 次成功恢复 UP。
+ *
+ * <p>{@code /js/a/sys/health} 只证明进程存活(不检查 PG/Redis/ES),节点级探活够用;
+ * 新用户分配与匿名轮询只选健康节点;<b>已绑定用户的节点 DOWN 时网关 503,不切换</b>。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HealthChecker {
+
+    private final WebClient healthWebClient;
+    private final GatewayRegistry registry;
+    private final NodeHealthRegistry healthRegistry;
+    private final GatewayProperties properties;
+
+    @Scheduled(initialDelayString = "5s",
+            fixedDelayString = "${gateway.health-check.interval:10s}")
+    public void checkAll() {
+        int threshold = properties.getHealthCheck().getFailureThreshold();
+        for (ServiceInfo service : registry.getServices()) {
+            for (NodeInfo node : service.nodes()) {
+                if (!node.enabled()) {
+                    continue;
+                }
+                probe(service, node, threshold).subscribe();
+            }
+        }
+    }
+
+    /**
+     * 探活一个节点(定时任务与管理端手动触发共用),返回探测后的状态。
+     * 返回值为空字符串表示成功,否则为失败原因(HTTP 状态码或异常摘要)。
+     */
+    public Mono<NodeHealthRegistry.State> probe(ServiceInfo service, NodeInfo node, int threshold) {
+        String url = node.baseUrl() + service.healthPath();
+        return healthWebClient.get().uri(url)
+                .exchangeToMono(resp -> Mono.just(resp.statusCode().is2xxSuccessful()
+                        ? "" : "HTTP " + resp.statusCode().value()))
+                .onErrorResume(ex -> Mono.just(ex.getClass().getSimpleName()))
+                .flatMap(reason -> {
+                    if (reason.isEmpty()) {
+                        healthRegistry.recordSuccess(node.id());
+                    } else {
+                        healthRegistry.recordFailure(node.id(), reason, threshold);
+                    }
+                    NodeHealthRegistry.Health h = healthRegistry.snapshot().get(node.id());
+                    return Mono.just(h == null ? NodeHealthRegistry.State.UNKNOWN : h.state());
+                });
+    }
+}

+ 68 - 0
ai-gateway/src/main/java/com/zsjz/gateway/registry/NodeHealthRegistry.java

@@ -0,0 +1,68 @@
+package com.zsjz.gateway.registry;
+
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+/**
+ * 节点健康状态(每个网关实例自己的内存状态,不跨实例同步)。
+ *
+ * <p>UNKNOWN(启动后尚未探活)按可用处理,避免刚启动的 10 秒内所有匿名请求 503;
+ * 连续失败达到阈值才标记 DOWN,1 次成功立即恢复 UP。
+ */
+@Component
+public class NodeHealthRegistry {
+
+    public enum State { UP, DOWN, UNKNOWN }
+
+    public record Health(State state, String lastError, Instant lastCheckedAt, int consecutiveFailures) {
+    }
+
+    private final ConcurrentHashMap<String, Health> health = new ConcurrentHashMap<>();
+
+    /** DOWN 视为不可用;UNKNOWN(尚未探活)乐观放行 */
+    public boolean isUp(String nodeId) {
+        Health h = health.get(nodeId);
+        return h == null || h.state() != State.DOWN;
+    }
+
+    public void recordSuccess(String nodeId) {
+        Health prev = health.get(nodeId);
+        if (prev == null || prev.state() != State.UP) {
+            // 只在状态翻转时输出,避免每 10 秒刷屏
+            HealthLogger.log(nodeId, State.UP, null);
+        }
+        health.put(nodeId, new Health(State.UP, null, Instant.now(), 0));
+    }
+
+    public void recordFailure(String nodeId, String error, int failureThreshold) {
+        Health prev = health.getOrDefault(nodeId,
+                new Health(State.UNKNOWN, null, null, 0));
+        int failures = prev.consecutiveFailures() + 1;
+        // 已 DOWN 的节点在阈值内继续失败保持 DOWN;未达阈值且之前可用则仍按可用处理
+        State next = (failures >= failureThreshold || prev.state() == State.DOWN) ? State.DOWN : State.UP;
+        if (next == State.DOWN && prev.state() != State.DOWN) {
+            HealthLogger.log(nodeId, State.DOWN, error);
+        }
+        health.put(nodeId, new Health(next, error, Instant.now(), failures));
+    }
+
+    public Map<String, Health> snapshot() {
+        return health.entrySet().stream()
+                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+    }
+
+    /** 隔离日志调用,避免 recordFailure 里持有锁/嵌套 */
+    static final class HealthLogger {
+        private HealthLogger() {
+        }
+
+        static void log(String nodeId, State state, String error) {
+            org.slf4j.LoggerFactory.getLogger(NodeHealthRegistry.class)
+                    .info("节点 {} 健康状态 -> {}{}", nodeId, state, error == null ? "" : "(" + error + ")");
+        }
+    }
+}

+ 13 - 0
ai-gateway/src/main/java/com/zsjz/gateway/registry/NodeInfo.java

@@ -0,0 +1,13 @@
+package com.zsjz.gateway.registry;
+
+/**
+ * 后端节点(一台 ai-server 实例)的运行时快照。
+ */
+public record NodeInfo(
+        String id,
+        String serviceId,
+        String name,
+        String baseUrl,
+        boolean enabled,
+        String remark) {
+}

+ 32 - 0
ai-gateway/src/main/java/com/zsjz/gateway/registry/ServiceInfo.java

@@ -0,0 +1,32 @@
+package com.zsjz.gateway.registry;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 网关服务(一个对外路径前缀 + 一组节点)的运行时快照,由 {@link GatewayRegistry} 从数据库加载。
+ */
+public record ServiceInfo(
+        String id,
+        String name,
+        String pathPrefix,
+        String loginPath,
+        String healthPath,
+        String defaultNodeId,
+        boolean enabled,
+        List<NodeInfo> nodes) {
+
+    /** 完整登录路径(pathPrefix + loginPath);未配置登录路径时为 null */
+    public String loginFullPath() {
+        return (loginPath == null || loginPath.isBlank()) ? null : pathPrefix + loginPath;
+    }
+
+    /** 原始路径是否落在本服务的路径前缀下(含前缀本身) */
+    public boolean matchesPath(String rawPath) {
+        return rawPath.equals(pathPrefix) || rawPath.startsWith(pathPrefix + "/");
+    }
+
+    public Optional<NodeInfo> findNode(String nodeId) {
+        return nodes.stream().filter(n -> n.id().equals(nodeId)).findFirst();
+    }
+}

+ 31 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/GatewayAttributes.java

@@ -0,0 +1,31 @@
+package com.zsjz.gateway.route;
+
+/**
+ * 网关在 {@code ServerWebExchange.getAttributes()} 里传递的内部属性键。
+ */
+public final class GatewayAttributes {
+
+    private GatewayAttributes() {
+    }
+
+    /** X-Request-Id(见 support/RequestIdFilter) */
+    public static final String REQUEST_ID = "gateway.requestId";
+
+    /** 本请求是登录请求(LoginRequestFilter 判定并缓存请求体后置位) */
+    public static final String LOGIN_REQUEST = "gateway.loginRequest";
+
+    /** 登录请求体里解析出的 username;可能为 null(体缺失/非法/超限) */
+    public static final String LOGIN_USERNAME = "gateway.loginUsername";
+
+    /** 登录请求选定的节点(已绑定的用绑定节点,新用户用轮询候选节点);登录成功后绑定到它 */
+    public static final String LOGIN_CHOSEN_NODE_ID = "gateway.loginChosenNodeId";
+
+    /** 本请求最终转发的目标节点 id(排障用,与 X-Gateway-Node 响应头一致) */
+    public static final String TARGET_NODE_ID = "gateway.targetNodeId";
+
+    /** 命中的网关服务 id */
+    public static final String SERVICE_ID = "gateway.serviceId";
+
+    /** 管理接口已认证的管理员 userId(见 admin/AdminAuthWebFilter) */
+    public static final String ADMIN_USER_ID = "gateway.adminUserId";
+}

+ 66 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/GatewayRouteDefinitionRepository.java

@@ -0,0 +1,66 @@
+package com.zsjz.gateway.route;
+
+import com.zsjz.gateway.registry.GatewayRegistry;
+import com.zsjz.gateway.registry.ServiceInfo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.handler.predicate.PredicateDefinition;
+import org.springframework.cloud.gateway.route.RouteDefinition;
+import org.springframework.cloud.gateway.route.RouteDefinitionRepository;
+import org.springframework.stereotype.Repository;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 从 {@link GatewayRegistry} 快照生成路由定义(动态路由)。
+ *
+ * <p>每个启用的服务一条路由:{@code Path=<prefix>/**},URI 用占位符
+ * {@code http://gateway.invalid}(真实节点地址由 {@link StickyRouteFilter} 按用户粘性重写)。
+ * registry 快照变化时发布的 RefreshRoutesEvent 会让 SCG 重新拉取本仓库。
+ */
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class GatewayRouteDefinitionRepository implements RouteDefinitionRepository {
+
+    public static final String METADATA_SERVICE_ID = "serviceId";
+
+    private final GatewayRegistry registry;
+
+    @Override
+    public Flux<RouteDefinition> getRouteDefinitions() {
+        List<RouteDefinition> definitions = registry.getServices().stream()
+                .filter(ServiceInfo::enabled)
+                .map(this::toDefinition)
+                .toList();
+        return Flux.fromIterable(definitions);
+    }
+
+    private RouteDefinition toDefinition(ServiceInfo service) {
+        RouteDefinition definition = new RouteDefinition();
+        definition.setId("svc-" + service.id());
+        definition.setUri(URI.create("http://gateway.invalid"));
+        // Path 谓词同时匹配前缀本身与子路径(/js/a、/js/a/**)
+        definition.setPredicates(List.of(
+                new PredicateDefinition("Path=" + service.pathPrefix() + "/**," + service.pathPrefix())));
+        // 前缀越长 order 越小(越优先),保证长前缀不被短前缀抢走
+        definition.setOrder(-service.pathPrefix().length());
+        definition.setMetadata(Map.of(METADATA_SERVICE_ID, service.id()));
+        return definition;
+    }
+
+    /** 服务/节点变更统一走管理接口写库 + registry.reload(),不接受 SCG 原生 save/delete */
+    @Override
+    public Mono<Void> save(Mono<RouteDefinition> route) {
+        return Mono.error(new UnsupportedOperationException("路由由数据库配置驱动,请使用 /gateway/admin/**"));
+    }
+
+    @Override
+    public Mono<Void> delete(Mono<String> routeId) {
+        return Mono.error(new UnsupportedOperationException("路由由数据库配置驱动,请使用 /gateway/admin/**"));
+    }
+}

+ 120 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/LoginRequestFilter.java

@@ -0,0 +1,120 @@
+package com.zsjz.gateway.route;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zsjz.gateway.registry.GatewayRegistry;
+import com.zsjz.gateway.registry.ServiceInfo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 登录请求预处理(只对配置的登录路径生效):
+ * <ol>
+ *   <li>缓存 ≤64KB 请求体并解析 {@code username}(供 StickyRouteFilter 定位绑定节点);</li>
+ *   <li>把 {@code Accept-Encoding} 改为 {@code identity},避免后端 gzip 登录响应、
+ *       LoginResponseFilter 解析不了;</li>
+ *   <li>原样转发请求体,不改变业务内容。</li>
+ * </ol>
+ *
+ * <p>体超限 / 非 JSON / 解析失败<b>不阻断请求</b>:username 为空时按匿名轮询,
+ * 由后端自行校验登录;绑定等下次可解析的登录再补。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class LoginRequestFilter implements GlobalFilter, Ordered {
+
+    static final int MAX_LOGIN_BODY_BYTES = 64 * 1024;
+
+    /** 供单测使用;登录体按 Result/LoginDTO 的实际结构只取 username 字段 */
+    static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private final GatewayRegistry registry;
+
+    @Override
+    public int getOrder() {
+        return 9999;
+    }
+
+    @Override
+    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+        ServiceInfo service = resolveService(exchange);
+        String loginFullPath = service == null ? null : service.loginFullPath();
+        if (loginFullPath == null
+                || exchange.getRequest().getMethod() != HttpMethod.POST
+                || !exchange.getRequest().getURI().getRawPath().equals(loginFullPath)) {
+            return chain.filter(exchange);
+        }
+
+        return DataBufferUtils.join(exchange.getRequest().getBody())
+                .map(buffer -> {
+                    byte[] bytes = new byte[buffer.readableByteCount()];
+                    buffer.read(bytes);
+                    DataBufferUtils.release(buffer);
+                    return bytes;
+                })
+                .defaultIfEmpty(new byte[0])
+                .flatMap(bytes -> {
+                    String username = parseUsername(bytes);
+                    exchange.getAttributes().put(GatewayAttributes.LOGIN_REQUEST, Boolean.TRUE);
+                    exchange.getAttributes().put(GatewayAttributes.LOGIN_USERNAME, username);
+                    if (username == null) {
+                        log.info("登录请求 username 解析失败(体 {} 字节),本次按匿名轮询", bytes.length);
+                    }
+                    byte[] forwardBytes = bytes;
+                    var bufferFactory = exchange.getResponse().bufferFactory();
+                    ServerHttpRequest rebuilt = exchange.getRequest().mutate()
+                            .headers(headers -> headers.set(HttpHeaders.ACCEPT_ENCODING, "identity"))
+                            .build();
+                    ServerHttpRequest decorated = new ServerHttpRequestDecorator(rebuilt) {
+                        @Override
+                        public Flux<DataBuffer> getBody() {
+                            if (forwardBytes.length == 0) {
+                                return Flux.empty();
+                            }
+                            return Flux.defer(() -> Flux.just(bufferFactory.wrap(forwardBytes)));
+                        }
+                    };
+                    return chain.filter(exchange.mutate().request(decorated).build());
+                });
+    }
+
+    private ServiceInfo resolveService(ServerWebExchange exchange) {
+        String rawPath = exchange.getRequest().getURI().getRawPath();
+        return registry.findServiceByPath(rawPath).orElse(null);
+    }
+
+    /**
+     * 解析登录体里的 username;空体 / 超 64KB / 非 JSON / 无 username 字段返回 null。
+     */
+    static String parseUsername(byte[] body) {
+        if (body == null || body.length == 0 || body.length > MAX_LOGIN_BODY_BYTES) {
+            return null;
+        }
+        try {
+            JsonNode root = MAPPER.readTree(new String(body, StandardCharsets.UTF_8));
+            JsonNode username = root.get("username");
+            if (username != null && username.isTextual() && !username.asText().isBlank()) {
+                return username.asText();
+            }
+            return null;
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+}

+ 176 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/LoginResponseFilter.java

@@ -0,0 +1,176 @@
+package com.zsjz.gateway.route;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zsjz.gateway.registry.GatewayRegistry;
+import com.zsjz.gateway.registry.ServiceInfo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
+import org.springframework.core.Ordered;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 登录响应后处理:解析 {@code Result<LoginVO>}(code=200 且 data.userId 非空)时,
+ * 把用户绑定到 StickyRouteFilter 选定的节点({@code bindIfAbsent},并发由数据库唯一索引裁决),
+ * 并回填 token 缓存({@code gateway:token:{serviceId}:{token} → userId},24h)。
+ *
+ * <p><b>order 必须是 {@code NettyWriteResponseFilter - 1}</b>:写响应体的是
+ * NettyWriteResponseFilter(order -1),它持有<b>自己拿到的 exchange</b> 的 response;
+ * 只有比它更早执行(更外层)地 mutate response,装饰器才会被写路径穿过 —— SCG 自带的
+ * ModifyResponseBody 也是这么排的。登录路径的判定在本过滤器里独立完成,不能依赖
+ * LoginRequestFilter 的属性(它在 9999 才置位);绑定用的节点属性由更深的
+ * StickyRouteFilter(10001)写入,post 阶段读取时已就位(attributes 全链共享)。
+ *
+ * <p>响应原样写回客户端 —— <b>无论绑定成功与否都不改变登录响应</b>。
+ * 体超 256KB / 非 JSON / 解析失败只记日志:绑定等下次登录补上,登录本身不受影响。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class LoginResponseFilter implements GlobalFilter, Ordered {
+
+    static final int MAX_LOGIN_RESPONSE_BYTES = 256 * 1024;
+
+    static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private final UserRouteService userRouteService;
+    private final TokenResolver tokenResolver;
+    private final GatewayRegistry registry;
+
+    @Override
+    public int getOrder() {
+        return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
+    }
+
+    @Override
+    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+        ServiceInfo service = registry.findServiceByPath(
+                exchange.getRequest().getURI().getRawPath()).orElse(null);
+        String loginFullPath = service == null ? null : service.loginFullPath();
+        if (loginFullPath == null
+                || exchange.getRequest().getMethod() != HttpMethod.POST
+                || !exchange.getRequest().getURI().getRawPath().equals(loginFullPath)) {
+            return chain.filter(exchange);
+        }
+
+        ByteArrayOutputStream collector = new ByteArrayOutputStream();
+        boolean[] overflow = {false};
+
+        ServerHttpResponseDecorator decorated = new ServerHttpResponseDecorator(exchange.getResponse()) {
+            @Override
+            public Mono<Void> writeWith(org.reactivestreams.Publisher<? extends DataBuffer> body) {
+                return super.writeWith(Flux.from(body).doOnNext(buffer -> {
+                    int readable = buffer.readableByteCount();
+                    if (collector.size() + readable <= MAX_LOGIN_RESPONSE_BYTES) {
+                        // slice 只读视图,不推进原 buffer 的读指针(下游还要写它)
+                        DataBuffer view = buffer.slice(0, readable);
+                        byte[] chunk = new byte[readable];
+                        view.read(chunk);
+                        collector.write(chunk, 0, chunk.length);
+                    } else if (!overflow[0]) {
+                        overflow[0] = true;
+                        log.warn("登录响应超过 {}KB,跳过绑定解析(响应原样透传)",
+                                MAX_LOGIN_RESPONSE_BYTES / 1024);
+                    }
+                }));
+            }
+        };
+
+        return chain.filter(exchange.mutate().response(decorated).build())
+                .then(Mono.fromRunnable(() -> {
+                    if (overflow[0]) {
+                        return;
+                    }
+                    // StickyRouteFilter(10001)在请求阶段写入,此刻已就位
+                    String serviceId = (String) exchange.getAttributes().get(GatewayAttributes.SERVICE_ID);
+                    String chosenNodeId = (String) exchange.getAttributes()
+                            .get(GatewayAttributes.LOGIN_CHOSEN_NODE_ID);
+                    if (serviceId == null || chosenNodeId == null) {
+                        log.info("登录响应后处理跳过:captured={}B, serviceId={}, chosenNodeId={}",
+                                collector.size(), serviceId, chosenNodeId);
+                        return;
+                    }
+                    byte[] body = collector.toByteArray();
+                    if (body.length == 0) {
+                        return;
+                    }
+                    // fire-and-forget:不挂在请求订阅生命周期上,避免客户端断开连接时
+                    // 取消传播把数据库绑定写砍掉(内部已兜底错误处理)
+                    parseAndBind(serviceId, chosenNodeId, body).subscribe();
+                }));
+    }
+
+    private Mono<Void> parseAndBind(String serviceId, String chosenNodeId, byte[] body) {
+        return Mono.fromCallable(() -> parseLogin(body))
+                .subscribeOn(Schedulers.boundedElastic())
+                .flatMap(login -> {
+                    if (login == null) {
+                        // 登录失败(密码错等)不产生绑定,属正常分支
+                        log.info("登录响应未命中成功结构({} 字节),不绑定", body.length);
+                        return Mono.empty();
+                    }
+                    log.info("登录成功:用户 {}({}) 绑定候选节点 {}", login.username(), login.userId(), chosenNodeId);
+                    Mono<Void> bind = userRouteService
+                            .bindIfAbsent(serviceId, login.userId(), login.username(), chosenNodeId)
+                            .doOnNext(effective -> {
+                                if (!chosenNodeId.equals(effective)) {
+                                    log.info("并发首次登录:用户 {} 实际绑定到已有节点 {}",
+                                            login.username(), effective);
+                                }
+                            })
+                            .then();
+                    Mono<Void> cache = tokenResolver
+                            .cacheToken(serviceId, login.token(), login.userId())
+                            .then();
+                    return bind.then(cache);
+                })
+                .onErrorResume(ex -> {
+                    log.warn("登录响应后处理失败(不影响本次登录):{}", ex.toString());
+                    return Mono.empty();
+                });
+    }
+
+    /**
+     * 登录成功结构:{@code {code:200, data:{token, userId, username}}}(Result + LoginVO)。
+     *
+     * @return null 表示非成功登录或不满足绑定条件
+     */
+    static LoginSuccess parseLogin(byte[] body) {
+        try {
+            JsonNode root = MAPPER.readTree(new String(body, StandardCharsets.UTF_8));
+            if (root.path("code").asInt(-1) != 200) {
+                return null;
+            }
+            JsonNode data = root.get("data");
+            if (data == null || !data.isObject()) {
+                return null;
+            }
+            JsonNode token = data.get("token");
+            JsonNode userId = data.get("userId");
+            JsonNode username = data.get("username");
+            if (userId == null || username == null
+                    || !userId.isNumber() || !username.isTextual() || username.asText().isBlank()) {
+                return null;
+            }
+            return new LoginSuccess(token == null ? null : token.asText(), userId.asLong(), username.asText());
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    record LoginSuccess(String token, long userId, String username) {
+    }
+}

+ 45 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/NodeSelector.java

@@ -0,0 +1,45 @@
+package com.zsjz.gateway.route;
+
+import com.zsjz.gateway.registry.NodeHealthRegistry;
+import com.zsjz.gateway.registry.NodeInfo;
+import com.zsjz.gateway.registry.ServiceInfo;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 匿名请求 / 新用户的轮询选择器:只在「启用 + 健康」的节点间轮询。
+ *
+ * <p>注意:这只影响<b>无绑定</b>的请求;已绑定用户的节点由路由表决定,
+ * 即使 DOWN 也不会被换掉(明确 503,见设计文档 §4.6)。
+ */
+@Component
+public class NodeSelector {
+
+    private final NodeHealthRegistry healthRegistry;
+
+    private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();
+
+    public NodeSelector(NodeHealthRegistry healthRegistry) {
+        this.healthRegistry = healthRegistry;
+    }
+
+    /** 没有可用节点时返回 null(由调用方决定 503) */
+    public NodeInfo select(ServiceInfo service) {
+        List<NodeInfo> candidates = service.nodes().stream()
+                .filter(node -> node.enabled() && healthRegistry.isUp(node.id()))
+                .toList();
+        if (candidates.isEmpty()) {
+            return null;
+        }
+        if (candidates.size() == 1) {
+            return candidates.get(0);
+        }
+        int index = counters
+                .computeIfAbsent(service.id(), k -> new AtomicInteger())
+                .getAndIncrement();
+        return candidates.get(Math.floorMod(index, candidates.size()));
+    }
+}

+ 180 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/StickyRouteFilter.java

@@ -0,0 +1,180 @@
+package com.zsjz.gateway.route;
+
+import com.zsjz.gateway.registry.GatewayRegistry;
+import com.zsjz.gateway.registry.NodeHealthRegistry;
+import com.zsjz.gateway.registry.NodeInfo;
+import com.zsjz.gateway.registry.ServiceInfo;
+import com.zsjz.gateway.support.StickyUnavailableException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.cloud.gateway.route.Route;
+import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
+import org.springframework.core.Ordered;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.net.URI;
+
+/**
+ * 粘性路由核心:按「登录用户名 / x-token → userId」选节点并重写目标 URL。
+ *
+ * <p>规则(设计 §4):
+ * <ul>
+ *   <li>登录请求:username 已绑定 → 绑定节点;未绑定 → 健康节点轮询出<b>候选节点</b>
+ *       (此时不写绑定,登录成功后由 LoginResponseFilter 落库);</li>
+ *   <li>普通请求:token → userId → 绑定节点;userId 有效但无绑定 → default-node 兜底绑定,
+ *       否则 503「用户未绑定节点」;</li>
+ *   <li>token 解析不出(过期/被顶掉/伪造)→ 按匿名轮询,透传给后端返回 code=401;</li>
+ *   <li>绑定节点停用/DOWN → 503,<b>不自动切换</b>(数据在该节点的本地磁盘上);</li>
+ *   <li>匿名请求(无 token)→ 健康节点轮询。</li>
+ * </ul>
+ *
+ * <p>order 10001:在 RouteToRequestUrlFilter(10000,算出占位 URL)之后,
+ * 把 {@code GATEWAY_REQUEST_URL_ATTR} 重写为「节点 origin + 原始 rawPath + rawQuery」,
+ * 再由 NettyRoutingFilter 流式转发。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StickyRouteFilter implements GlobalFilter, Ordered {
+
+    public static final String HEADER_NODE = "X-Gateway-Node";
+
+    private final GatewayRegistry registry;
+    private final NodeHealthRegistry healthRegistry;
+    private final NodeSelector nodeSelector;
+    private final TokenResolver tokenResolver;
+    private final UserRouteService userRouteService;
+
+    @Override
+    public int getOrder() {
+        return 10001;
+    }
+
+    @Override
+    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+        Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
+        if (route == null) {
+            return chain.filter(exchange);
+        }
+        Object serviceIdAttr = route.getMetadata()
+                .get(GatewayRouteDefinitionRepository.METADATA_SERVICE_ID);
+        if (serviceIdAttr == null) {
+            return chain.filter(exchange);
+        }
+        ServiceInfo service = registry.getService(String.valueOf(serviceIdAttr)).orElse(null);
+        if (service == null || !service.enabled()) {
+            throw StickyUnavailableException.serviceUnavailable(
+                    "服务不存在或已停用:" + serviceIdAttr + "(配置刚变更请稍后重试)");
+        }
+
+        boolean isLogin = Boolean.TRUE.equals(
+                exchange.getAttributes().get(GatewayAttributes.LOGIN_REQUEST));
+        Mono<String> nodeIdMono = isLogin
+                ? decideForLogin(exchange, service)
+                : decideForNormal(exchange, service);
+
+        return nodeIdMono.flatMap(nodeId -> route(exchange, chain, service, nodeId));
+    }
+
+    // ------------------------------------------------------------------
+    // 登录请求:username → 绑定节点;未绑定 → 轮询候选(不写库)
+    // ------------------------------------------------------------------
+
+    private Mono<String> decideForLogin(ServerWebExchange exchange, ServiceInfo service) {
+        String username = (String) exchange.getAttributes().get(GatewayAttributes.LOGIN_USERNAME);
+        Mono<String> bound = userRouteService.findNodeIdByUsername(service.id(), username);
+        return bound.flatMap(nodeId -> {
+            exchange.getAttributes().put(GatewayAttributes.LOGIN_CHOSEN_NODE_ID, nodeId);
+            return Mono.just(nodeId);
+        }).switchIfEmpty(Mono.defer(() -> {
+            NodeInfo candidate = nodeSelector.select(service);
+            if (candidate == null) {
+                return Mono.error(StickyUnavailableException.serviceUnavailable(
+                        "服务 " + service.id() + " 当前没有可用节点,无法处理登录"));
+            }
+            exchange.getAttributes().put(GatewayAttributes.LOGIN_CHOSEN_NODE_ID, candidate.id());
+            return Mono.just(candidate.id());
+        }));
+    }
+
+    // ------------------------------------------------------------------
+    // 普通请求:x-token → userId → 绑定节点;匿名轮询
+    // ------------------------------------------------------------------
+
+    private Mono<String> decideForNormal(ServerWebExchange exchange, ServiceInfo service) {
+        String token = exchange.getRequest().getHeaders().getFirst("x-token");
+        if (token == null || token.isBlank()) {
+            return anonymousNode(service);
+        }
+        return tokenResolver.resolveUserId(service.id(), token)
+                .flatMap(userId -> userRouteService.findNodeIdByUserId(service.id(), userId)
+                        .switchIfEmpty(Mono.defer(() -> bindToDefaultNode(service, userId))))
+                // token 解析不出:按匿名处理,后端会返回 code=401 引导重新登录
+                .switchIfEmpty(anonymousNode(service));
+    }
+
+    private Mono<String> bindToDefaultNode(ServiceInfo service, long userId) {
+        if (service.defaultNodeId() == null || service.defaultNodeId().isBlank()) {
+            return Mono.error(StickyUnavailableException.serviceUnavailable(
+                    "用户未绑定节点,请重新登录或联系管理员"));
+        }
+        String defaultNodeId = service.defaultNodeId();
+        return userRouteService
+                .bindIfAbsent(service.id(), userId, null, defaultNodeId)
+                .thenReturn(defaultNodeId);
+    }
+
+    private Mono<String> anonymousNode(ServiceInfo service) {
+        NodeInfo node = nodeSelector.select(service);
+        if (node == null) {
+            return Mono.error(StickyUnavailableException.serviceUnavailable(
+                    "服务 " + service.id() + " 暂无可用节点,请稍后重试"));
+        }
+        return Mono.just(node.id());
+    }
+
+    // ------------------------------------------------------------------
+    // 校验节点 + 重写目标 URL
+    // ------------------------------------------------------------------
+
+    private Mono<Void> route(ServerWebExchange exchange, GatewayFilterChain chain,
+                             ServiceInfo service, String nodeId) {
+        NodeInfo node = service.findNode(nodeId).orElse(null);
+        if (node == null || !node.enabled()) {
+            throw StickyUnavailableException.serviceUnavailable(
+                    "用户绑定的节点不存在或已停用:" + nodeId + ",请联系管理员");
+        }
+        if (!healthRegistry.isUp(nodeId)) {
+            throw StickyUnavailableException.serviceUnavailable(
+                    "用户绑定的后端节点不可用:" + nodeId + ",请稍后重试或联系管理员");
+        }
+
+        ServerHttpRequest request = exchange.getRequest();
+        exchange.getAttributes().put(GatewayAttributes.SERVICE_ID, service.id());
+        exchange.getAttributes().put(GatewayAttributes.TARGET_NODE_ID, nodeId);
+        exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR,
+                buildTargetUri(node.baseUrl(), request.getURI()));
+        exchange.getResponse().getHeaders().add(HEADER_NODE, nodeId);
+        return chain.filter(exchange);
+    }
+
+    /**
+     * 节点 origin + 原始 rawPath + rawQuery(后端 context-path 由原路径携带,节点地址不含路径)。
+     *
+     * <p>必须用字符串拼接 + {@link URI#create}:多参 {@code URI} 构造器会再编码一次,
+     * 把已编码的查询串(如 {@code %E4%B8%AD})变成 {@code %25E4%25B8%25AD}。
+     */
+    static URI buildTargetUri(String baseUrl, URI requestUri) {
+        StringBuilder target = new StringBuilder(baseUrl);
+        target.append(requestUri.getRawPath());
+        if (requestUri.getRawQuery() != null) {
+            target.append('?').append(requestUri.getRawQuery());
+        }
+        return URI.create(target.toString());
+    }
+}

+ 100 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/TokenResolver.java

@@ -0,0 +1,100 @@
+package com.zsjz.gateway.route;
+
+import cn.dev33.satoken.stp.StpUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.time.Duration;
+
+/**
+ * {@code x-token → userId} 解析:网关缓存 → sa-token 共享 Redis 兜底。
+ *
+ * <p>缓存键 {@code gateway:token:{serviceId}:{token} → userId},TTL 24h,
+ * 避免每个请求都走 sa-token;缓存未命中时用 {@code StpUtil.getLoginIdByToken}
+ * (ai-server 登录时 {@code StpUtil.login(user.getId())},loginId 即 userId),
+ * 兼容网关上线前已签发的 token。
+ *
+ * <p>解析不出(过期 / 被单端登录顶掉 / 伪造)返回空 —— 由调用方按匿名处理,
+ * 透传给后端走它自己的 {@code code=401} 逻辑,引导用户重新登录。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class TokenResolver {
+
+    static final Duration TOKEN_CACHE_TTL = Duration.ofHours(24);
+
+    private final ReactiveStringRedisTemplate redis;
+
+    /**
+     * @return userId;token 无效/解析失败返回空 Mono
+     */
+    public Mono<Long> resolveUserId(String serviceId, String token) {
+        if (token == null || token.isBlank()) {
+            return Mono.empty();
+        }
+        String key = tokenKey(serviceId, token);
+        Mono<String> fromCache = redis.opsForValue().get(key)
+                .onErrorResume(ex -> {
+                    log.warn("token 缓存读取失败,直查 sa-token:{}", ex.toString());
+                    return Mono.empty();
+                });
+        return fromCache
+                .map(Long::valueOf)
+                .onErrorResume(ex -> Mono.empty())
+                .switchIfEmpty(Mono.defer(() -> resolveFromSaToken(token)
+                        .flatMap(userId -> redis.opsForValue().set(key, String.valueOf(userId), TOKEN_CACHE_TTL)
+                                .onErrorResume(ex -> {
+                                    log.warn("token 缓存写入失败(不影响正确性):{}", ex.toString());
+                                    return Mono.just(false);
+                                })
+                                .thenReturn(userId))));
+    }
+
+    /** 登录成功后由 LoginResponseFilter 回填缓存 */
+    public Mono<Boolean> cacheToken(String serviceId, String token, long userId) {
+        if (token == null || token.isBlank()) {
+            return Mono.just(false);
+        }
+        return redis.opsForValue().set(tokenKey(serviceId, token), String.valueOf(userId), TOKEN_CACHE_TTL)
+                .onErrorResume(ex -> {
+                    log.warn("token 缓存写入失败(不影响正确性):{}", ex.toString());
+                    return Mono.just(false);
+                });
+    }
+
+    /**
+     * 管理端认证用(低频,不走缓存):直接查 sa-token。
+     */
+    public Mono<Long> resolveUserIdDirect(String token) {
+        if (token == null || token.isBlank()) {
+            return Mono.empty();
+        }
+        return resolveFromSaToken(token);
+    }
+
+    private Mono<Long> resolveFromSaToken(String token) {
+        return Mono.fromCallable(() -> StpUtil.getLoginIdByToken(token))
+                .subscribeOn(Schedulers.boundedElastic())
+                .flatMap(loginId -> {
+                    try {
+                        return Mono.just(Long.parseLong(String.valueOf(loginId)));
+                    } catch (NumberFormatException ex) {
+                        log.warn("token 解析出的 loginId 不是数字(预期 sys_user.id):{}", loginId);
+                        return Mono.<Long>empty();
+                    }
+                })
+                .onErrorResume(ex -> {
+                    log.warn("sa-token 解析 token 失败:{}", ex.toString());
+                    return Mono.empty();
+                });
+    }
+
+    static String tokenKey(String serviceId, String token) {
+        return "gateway:token:" + serviceId + ":" + token;
+    }
+}

+ 227 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/UserRouteRepository.java

@@ -0,0 +1,227 @@
+package com.zsjz.gateway.route;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Timestamp;
+import java.util.List;
+
+/**
+ * gateway_user_route / sys_user(只读)的 JDBC 访问(阻塞,调用方负责放 boundedElastic)。
+ *
+ * <p>唯一性由两个部分唯一索引裁决(见 sql/gateway.sql):
+ * {@code (service_id, user_id)} 与 {@code (service_id, lower(username))};
+ * {@code bindIfAbsent} 的并发安全 = {@code ON CONFLICT DO NOTHING} + 重新读取赢家。
+ */
+@Repository
+@RequiredArgsConstructor
+public class UserRouteRepository {
+
+    private final JdbcTemplate jdbc;
+
+    public record RouteRow(long id, String serviceId, Long userId, String username,
+                           String nodeId, Timestamp createTime) {
+    }
+
+    public record SysUserRow(long id, String username, String displayName, int isAdmin, int status) {
+    }
+
+    // ------------------------------------------------------------------
+    // 查询
+    // ------------------------------------------------------------------
+
+    public String findNodeIdByUserId(String serviceId, long userId) {
+        List<String> rows = jdbc.queryForList(
+                "SELECT node_id FROM gateway_user_route WHERE service_id = ? AND user_id = ? LIMIT 1",
+                String.class, serviceId, userId);
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    public String findNodeIdByUsername(String serviceId, String username) {
+        List<String> rows = jdbc.queryForList(
+                "SELECT node_id FROM gateway_user_route WHERE service_id = ? AND lower(username) = lower(?) LIMIT 1",
+                String.class, serviceId, username);
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    public RouteRow findRouteById(long id) {
+        List<RouteRow> rows = jdbc.query(
+                "SELECT id, service_id, user_id, username, node_id, create_time "
+                        + "FROM gateway_user_route WHERE id = ?",
+                (rs, i) -> new RouteRow(rs.getLong("id"), rs.getString("service_id"),
+                        rs.getObject("user_id") == null ? null : rs.getLong("user_id"),
+                        rs.getString("username"), rs.getString("node_id"), rs.getTimestamp("create_time")),
+                id);
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    public long countByService(String serviceId) {
+        Long count = jdbc.queryForObject(
+                "SELECT count(*) FROM gateway_user_route WHERE service_id = ?", Long.class, serviceId);
+        return count == null ? 0 : count;
+    }
+
+    public long countByNode(String nodeId) {
+        Long count = jdbc.queryForObject(
+                "SELECT count(*) FROM gateway_user_route WHERE node_id = ?", Long.class, nodeId);
+        return count == null ? 0 : count;
+    }
+
+    // ------------------------------------------------------------------
+    // 绑定
+    // ------------------------------------------------------------------
+
+    /**
+     * 幂等绑定:已有路由直接返回既有节点;没有则插入,冲突时读回并发下的赢家。
+     *
+     * @return 生效的目标节点 id(userId/username 都为 null 时返回 null)
+     */
+    public String bindIfAbsent(String serviceId, Long userId, String username, String nodeId) {
+        if (userId == null && (username == null || username.isBlank())) {
+            return null;
+        }
+        String existing = lookupExisting(serviceId, userId, username);
+        if (existing != null) {
+            return existing;
+        }
+        jdbc.update("""
+                INSERT INTO gateway_user_route (service_id, user_id, username, node_id)
+                VALUES (?, ?, ?, ?)
+                ON CONFLICT DO NOTHING
+                """, serviceId, userId, username, nodeId);
+        // 并发首次登录:两个网关可能同时插入,唯一索引裁决,这里读回赢家
+        String winner = lookupExisting(serviceId, userId, username);
+        return winner != null ? winner : nodeId;
+    }
+
+    private String lookupExisting(String serviceId, Long userId, String username) {
+        if (userId != null) {
+            String byUser = findNodeIdByUserId(serviceId, userId);
+            if (byUser != null) {
+                return byUser;
+            }
+        }
+        if (username != null && !username.isBlank()) {
+            return findNodeIdByUsername(serviceId, username);
+        }
+        return null;
+    }
+
+    /** 管理端重绑:先删该用户全部路由再插入(调用方包事务) */
+    public int deleteRoutesForUser(String serviceId, Long userId, String username) {
+        if (userId != null) {
+            jdbc.update("DELETE FROM gateway_user_route WHERE service_id = ? AND user_id = ?",
+                    serviceId, userId);
+        }
+        if (username != null && !username.isBlank()) {
+            jdbc.update("DELETE FROM gateway_user_route WHERE service_id = ? AND lower(username) = lower(?)",
+                    serviceId, username);
+        }
+        return 0;
+    }
+
+    public int insertRoute(String serviceId, Long userId, String username, String nodeId) {
+        return jdbc.update("""
+                INSERT INTO gateway_user_route (service_id, user_id, username, node_id)
+                VALUES (?, ?, ?, ?)
+                """, serviceId, userId, username, nodeId);
+    }
+
+    public int deleteRouteById(long id) {
+        return jdbc.update("DELETE FROM gateway_user_route WHERE id = ?", id);
+    }
+
+    // ------------------------------------------------------------------
+    // 存量导入 / 分页
+    // ------------------------------------------------------------------
+
+    /** 把 sys_user 里尚未绑定的用户批量绑定到 nodeId;返回本次插入行数 */
+    public int importExisting(String serviceId, String nodeId) {
+        return jdbc.update("""
+                INSERT INTO gateway_user_route (service_id, user_id, username, node_id)
+                SELECT ?, u.id, u.username, ?
+                FROM sys_user u
+                WHERE NOT EXISTS (
+                    SELECT 1 FROM gateway_user_route r
+                    WHERE r.service_id = ?
+                      AND (r.user_id = u.id OR lower(r.username) = lower(u.username))
+                )
+                ON CONFLICT DO NOTHING
+                """, serviceId, nodeId, serviceId);
+    }
+
+    public record PagedRoute(long id, String serviceId, Long userId, String username,
+                             String nodeId, Timestamp createTime, String displayName) {
+    }
+
+    public List<PagedRoute> pageRoutes(String serviceId, String keyword, int page, int size) {
+        String where = "WHERE r.service_id = ?";
+        Object[] args;
+        String like = null;
+        if (keyword != null && !keyword.isBlank()) {
+            like = "%" + keyword.trim()
+                    .replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%";
+        }
+        if (like != null) {
+            where += " AND (r.username ILIKE ? OR COALESCE(u.display_name, '') ILIKE ?)";
+            args = new Object[]{serviceId, like, like, size, page * size};
+        } else {
+            args = new Object[]{serviceId, size, page * size};
+        }
+        String sql = """
+                SELECT r.id, r.service_id, r.user_id, r.username, r.node_id, r.create_time, u.display_name
+                FROM gateway_user_route r
+                LEFT JOIN sys_user u ON u.id = r.user_id
+                """ + where + " ORDER BY r.id DESC LIMIT ? OFFSET ?";
+        return jdbc.query(sql, (rs, i) -> new PagedRoute(
+                        rs.getLong("id"), rs.getString("service_id"),
+                        rs.getObject("user_id") == null ? null : rs.getLong("user_id"),
+                        rs.getString("username"), rs.getString("node_id"),
+                        rs.getTimestamp("create_time"), rs.getString("display_name")),
+                args);
+    }
+
+    public long countRoutes(String serviceId, String keyword) {
+        String like = null;
+        if (keyword != null && !keyword.isBlank()) {
+            like = "%" + keyword.trim()
+                    .replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%";
+        }
+        if (like != null) {
+            Long count = jdbc.queryForObject("""
+                            SELECT count(*) FROM gateway_user_route r
+                            LEFT JOIN sys_user u ON u.id = r.user_id
+                            WHERE r.service_id = ?
+                              AND (r.username ILIKE ? OR COALESCE(u.display_name, '') ILIKE ?)
+                            """,
+                    Long.class, serviceId, like, like);
+            return count == null ? 0 : count;
+        }
+        Long count = jdbc.queryForObject(
+                "SELECT count(*) FROM gateway_user_route WHERE service_id = ?", Long.class, serviceId);
+        return count == null ? 0 : count;
+    }
+
+    // ------------------------------------------------------------------
+    // sys_user(只读)
+    // ------------------------------------------------------------------
+
+    public SysUserRow findSysUserById(long userId) {
+        List<SysUserRow> rows = jdbc.query(
+                "SELECT id, username, display_name, is_admin, status FROM sys_user WHERE id = ?",
+                (rs, i) -> new SysUserRow(rs.getLong("id"), rs.getString("username"),
+                        rs.getString("display_name"), rs.getShort("is_admin"), rs.getShort("status")),
+                userId);
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    public SysUserRow findSysUserByUsername(String username) {
+        List<SysUserRow> rows = jdbc.query(
+                "SELECT id, username, display_name, is_admin, status FROM sys_user WHERE lower(username) = lower(?)",
+                (rs, i) -> new SysUserRow(rs.getLong("id"), rs.getString("username"),
+                        rs.getString("display_name"), rs.getShort("is_admin"), rs.getShort("status")),
+                username);
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+}

+ 117 - 0
ai-gateway/src/main/java/com/zsjz/gateway/route/UserRouteService.java

@@ -0,0 +1,117 @@
+package com.zsjz.gateway.route;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.time.Duration;
+
+/**
+ * 用户路由的读写 + Redis 两级缓存(PostgreSQL 是事实来源,Redis 只是热路径缓存)。
+ *
+ * <p>键(见设计 §4.5):
+ * <ul>
+ *   <li>{@code gateway:route:user:{serviceId}:{userId} → nodeId}(无 TTL)</li>
+ *   <li>{@code gateway:route:name:{serviceId}:{lower(username)} → nodeId}(无 TTL)</li>
+ * </ul>
+ *
+ * <p>Redis 读失败降级为直查数据库;缓存写失败只记日志(下次读会回源)。
+ * 管理端重绑/解绑在写库后同步清缓存。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UserRouteService {
+
+    private final ReactiveStringRedisTemplate redis;
+    private final UserRouteRepository repository;
+
+    public Mono<String> findNodeIdByUserId(String serviceId, long userId) {
+        String key = userKey(serviceId, userId);
+        return getWithCache(key,
+                () -> repository.findNodeIdByUserId(serviceId, userId));
+    }
+
+    public Mono<String> findNodeIdByUsername(String serviceId, String username) {
+        if (username == null || username.isBlank()) {
+            return Mono.empty();
+        }
+        String key = nameKey(serviceId, username);
+        return getWithCache(key,
+                () -> repository.findNodeIdByUsername(serviceId, username));
+    }
+
+    private Mono<String> getWithCache(String key, java.util.function.Supplier<String> dbLookup) {
+        Mono<String> fromCache = redis.opsForValue().get(key)
+                .onErrorResume(ex -> {
+                    log.warn("路由缓存读取失败,回源数据库:{}", ex.toString());
+                    return Mono.empty();
+                });
+        return fromCache.switchIfEmpty(Mono.defer(() ->
+                        Mono.fromCallable(dbLookup::get)
+                                .subscribeOn(Schedulers.boundedElastic()))
+                .flatMap(nodeId -> redis.opsForValue().set(key, nodeId)
+                        .onErrorResume(ex -> {
+                            log.warn("路由缓存写入失败(不影响正确性):{}", ex.toString());
+                            return Mono.empty();
+                        })
+                        .thenReturn(nodeId)));
+    }
+
+    /**
+     * 首次登录成功 / 存量用户兜底绑定:数据库唯一索引裁决并发,返回生效节点。
+     */
+    public Mono<String> bindIfAbsent(String serviceId, Long userId, String username, String nodeId) {
+        return Mono.fromCallable(() -> repository.bindIfAbsent(serviceId, userId, username, nodeId))
+                .subscribeOn(Schedulers.boundedElastic())
+                .flatMap(effective -> cacheUser(serviceId, userId, username, effective).thenReturn(effective));
+    }
+
+    /** 管理端重绑/解绑后清缓存(userId / username 任意一个非空即清对应键) */
+    public Mono<Void> evictUserCache(String serviceId, Long userId, String username) {
+        return redis.delete(cacheKeys(serviceId, userId, username).toArray(new String[0]))
+                .onErrorResume(ex -> {
+                    log.warn("路由缓存清理失败:{}", ex.toString());
+                    return Mono.just(0L);
+                })
+                .then();
+    }
+
+    private Mono<Void> cacheUser(String serviceId, Long userId, String username, String nodeId) {
+        if (nodeId == null) {
+            return Mono.empty();
+        }
+        Mono<Boolean> writes = Mono.just(true);
+        for (String key : cacheKeys(serviceId, userId, username)) {
+            writes = writes.flatMap(ignored -> redis.opsForValue().set(key, nodeId));
+        }
+        return writes
+                .onErrorResume(ex -> {
+                    log.warn("路由缓存写入失败(不影响正确性):{}", ex.toString());
+                    return Mono.just(false);
+                })
+                .then();
+    }
+
+    private java.util.List<String> cacheKeys(String serviceId, Long userId, String username) {
+        java.util.List<String> keys = new java.util.ArrayList<>(2);
+        if (userId != null) {
+            keys.add(userKey(serviceId, userId));
+        }
+        if (username != null && !username.isBlank()) {
+            keys.add(nameKey(serviceId, username));
+        }
+        return keys;
+    }
+
+    static String userKey(String serviceId, long userId) {
+        return "gateway:route:user:" + serviceId + ":" + userId;
+    }
+
+    static String nameKey(String serviceId, String username) {
+        return "gateway:route:name:" + serviceId + ":" + username.toLowerCase();
+    }
+}

+ 37 - 0
ai-gateway/src/main/java/com/zsjz/gateway/support/ApiResult.java

@@ -0,0 +1,37 @@
+package com.zsjz.gateway.support;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 网关自身接口(管理端、错误响应)的统一返回体。
+ *
+ * <p>结构对齐 ai-server 的 {@code com.zsjz.ai.common.config.Result}
+ * ({@code code/message/data},成功 code=200),便于前端/脚本复用同一套解析。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiResult<T> {
+
+    public static final int SUCCEED_CODE = 200;
+
+    private int code;
+
+    private String message;
+
+    private T data;
+
+    public static <T> ApiResult<T> ok() {
+        return new ApiResult<>(SUCCEED_CODE, "", null);
+    }
+
+    public static <T> ApiResult<T> ok(T data) {
+        return new ApiResult<>(SUCCEED_CODE, "", data);
+    }
+
+    public static <T> ApiResult<T> error(int code, String message) {
+        return new ApiResult<>(code, message, null);
+    }
+}

+ 98 - 0
ai-gateway/src/main/java/com/zsjz/gateway/support/GatewayErrorHandler.java

@@ -0,0 +1,98 @@
+package com.zsjz.gateway.support;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
+import org.springframework.core.annotation.Order;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.net.ConnectException;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * 网关错误统一出口:{@code {code,message,data}} JSON 体 + 明确的 HTTP 状态码。
+ *
+ * <ul>
+ *   <li>粘性路由不可用(StickyUnavailableException)→ 503,不切换节点;</li>
+ *   <li>无匹配路由 / 资源不存在 → 404;</li>
+ *   <li>上游连接失败 / 超时 → 502;</li>
+ *   <li>其余 → 500。</li>
+ * </ul>
+ *
+ * <p>错误信息只暴露节点 ID,不暴露后端完整地址。响应已提交(如 SSE 中途出错)时
+ * 不再改写状态码,只记日志,由 Netty 按既有连接语义断开。
+ */
+@Slf4j
+@Component
+@Order(-2)
+public class GatewayErrorHandler implements ErrorWebExceptionHandler {
+
+    @Override
+    public Mono<Void> handle(ServerWebExchange exchange, Throwable error) {
+        if (exchange.getResponse().isCommitted()) {
+            return Mono.error(error);
+        }
+        String requestId = String.valueOf(exchange.getAttributes().get("gateway.requestId"));
+        HttpStatus status;
+        String message;
+        if (error instanceof StickyUnavailableException sue) {
+            status = HttpStatus.resolve(sue.getHttpStatus());
+            status = status == null ? HttpStatus.SERVICE_UNAVAILABLE : status;
+            message = sue.getMessage();
+            log.warn("[{}] sticky route unavailable: {}", requestId, message);
+        } else if (error instanceof ResponseStatusException rse) {
+            status = HttpStatus.resolve(rse.getStatusCode().value());
+            status = status == null ? HttpStatus.INTERNAL_SERVER_ERROR : status;
+            message = rse.getReason() != null ? rse.getReason() : status.getReasonPhrase();
+            if (status.is5xxServerError()) {
+                log.warn("[{}] {}", requestId, message, rse);
+            }
+        } else if (isUpstreamConnectError(error)) {
+            status = HttpStatus.BAD_GATEWAY;
+            message = "后端节点连接失败或超时,请稍后重试";
+            log.warn("[{}] upstream connect failed: {}", requestId, error.toString());
+        } else if (error instanceof TimeoutException) {
+            status = HttpStatus.BAD_GATEWAY;
+            message = "后端节点响应超时,请稍后重试";
+            log.warn("[{}] upstream timeout", requestId);
+        } else {
+            status = HttpStatus.INTERNAL_SERVER_ERROR;
+            message = "网关内部错误:" + (error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
+            log.error("[{}] gateway internal error", requestId, error);
+        }
+
+        exchange.getResponse().setStatusCode(status);
+        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
+        String body = "{\"code\":" + status.value() + ",\"message\":\"" + escape(message) + "\",\"data\":null}";
+        DataBuffer buffer = exchange.getResponse().bufferFactory()
+                .wrap(body.getBytes(StandardCharsets.UTF_8));
+        return exchange.getResponse().writeWith(Mono.just(buffer));
+    }
+
+    private boolean isUpstreamConnectError(Throwable error) {
+        Throwable t = error;
+        int depth = 0;
+        while (t != null && depth++ < 8) {
+            if (t instanceof ConnectException || t instanceof SocketTimeoutException
+                    || t instanceof io.netty.channel.ConnectTimeoutException) {
+                return true;
+            }
+            t = t.getCause();
+        }
+        return false;
+    }
+
+    private String escape(String message) {
+        if (message == null) {
+            return "";
+        }
+        return message.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ").replace("\r", " ");
+    }
+}

+ 87 - 0
ai-gateway/src/main/java/com/zsjz/gateway/support/IpWhitelist.java

@@ -0,0 +1,87 @@
+package com.zsjz.gateway.support;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.List;
+
+/**
+ * 管理接口 IP 白名单:支持单 IP(IPv4/IPv6)与 CIDR(如 192.168.0.0/24)。
+ *
+ * <p>空列表 = 不限制。白名单基于 {@code exchange.getRequest().getRemoteAddress()};
+ * 网关前面还有反代时该地址是反代地址,须谨慎配置(或让反代只转发受信来源)。
+ */
+public final class IpWhitelist {
+
+    private IpWhitelist() {
+    }
+
+    /**
+     * @return 白名单为空返回 true(不限制);地址无法解析按不匹配处理
+     */
+    public static boolean allows(List<String> whitelist, String remoteHost) {
+        if (whitelist == null || whitelist.isEmpty()) {
+            return true;
+        }
+        if (remoteHost == null || remoteHost.isBlank()) {
+            return false;
+        }
+        byte[] addr = toBytes(remoteHost);
+        for (String entry : whitelist) {
+            if (entry == null || entry.isBlank()) {
+                continue;
+            }
+            String e = entry.trim();
+            if (matchesEntry(e, remoteHost, addr)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean matchesEntry(String entry, String remoteHost, byte[] addr) {
+        int slash = entry.indexOf('/');
+        if (slash < 0) {
+            if (entry.equalsIgnoreCase(remoteHost)) {
+                return true;
+            }
+            byte[] entryBytes = toBytes(entry);
+            return entryBytes != null && addr != null && java.util.Arrays.equals(entryBytes, addr);
+        }
+        // CIDR
+        byte[] network = toBytes(entry.substring(0, slash));
+        int prefix;
+        try {
+            prefix = Integer.parseInt(entry.substring(slash + 1).trim());
+        } catch (NumberFormatException ex) {
+            return false;
+        }
+        if (network == null || addr == null || network.length != addr.length) {
+            return false;
+        }
+        if (prefix < 0 || prefix > network.length * 8) {
+            return false;
+        }
+        int fullBytes = prefix / 8;
+        int remBits = prefix % 8;
+        for (int i = 0; i < fullBytes; i++) {
+            if (network[i] != addr[i]) {
+                return false;
+            }
+        }
+        if (remBits > 0) {
+            int mask = 0xFF << (8 - remBits);
+            if ((network[fullBytes] & mask) != (addr[fullBytes] & mask)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static byte[] toBytes(String host) {
+        try {
+            return InetAddress.getByName(host.trim()).getAddress();
+        } catch (UnknownHostException ex) {
+            return null;
+        }
+    }
+}

+ 37 - 0
ai-gateway/src/main/java/com/zsjz/gateway/support/JsonResponses.java

@@ -0,0 +1,37 @@
+package com.zsjz.gateway.support;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 直接向客户端写 JSON 响应的小工具(管理端过滤器的 401/403/404、错误处理器共用)。
+ */
+public final class JsonResponses {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private JsonResponses() {
+    }
+
+    public static <T> Mono<Void> write(ServerWebExchange exchange, HttpStatus status, ApiResult<T> result) {
+        ServerHttpResponse response = exchange.getResponse();
+        response.setStatusCode(status);
+        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
+        byte[] bytes;
+        try {
+            bytes = MAPPER.writeValueAsBytes(result);
+        } catch (Exception ex) {
+            bytes = ("{\"code\":500,\"message\":\"response serialize failed\",\"data\":null}")
+                    .getBytes(StandardCharsets.UTF_8);
+        }
+        DataBuffer buffer = response.bufferFactory().wrap(bytes);
+        return response.writeWith(Mono.just(buffer));
+    }
+}

+ 34 - 0
ai-gateway/src/main/java/com/zsjz/gateway/support/RequestIdFilter.java

@@ -0,0 +1,34 @@
+package com.zsjz.gateway.support;
+
+import com.zsjz.gateway.route.GatewayAttributes;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+/**
+ * 透传或生成 {@code X-Request-Id}:客户端带了自己透传,没带就补一个。
+ *
+ * <p>排查多网关实例 / 多节点转发时用它串联日志;管理端与业务转发共用。
+ */
+@Component
+@Order(-100)
+public class RequestIdFilter implements WebFilter {
+
+    public static final String HEADER = "X-Request-Id";
+
+    @Override
+    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+        String requestId = exchange.getRequest().getHeaders().getFirst(HEADER);
+        if (requestId == null || requestId.isBlank()) {
+            requestId = UUID.randomUUID().toString();
+        }
+        exchange.getAttributes().put(GatewayAttributes.REQUEST_ID, requestId);
+        exchange.getResponse().getHeaders().set(HEADER, requestId);
+        return chain.filter(exchange);
+    }
+}

+ 25 - 0
ai-gateway/src/main/java/com/zsjz/gateway/support/StickyUnavailableException.java

@@ -0,0 +1,25 @@
+package com.zsjz.gateway.support;
+
+import lombok.Getter;
+
+/**
+ * 粘性路由不可用(用户绑定节点停用 / DOWN、无健康节点、用户未绑定等)。
+ *
+ * <p>设计决策:绑定节点不可用时<b>明确失败、不自动切换</b> —— 案件数据、附件、
+ * Agent 记忆都在各节点本地磁盘,切到别的机器只会把请求路由到没有该用户数据的地方。
+ * 由 {@link GatewayErrorHandler} 统一转成 HTTP 503 + JSON 错误体。
+ */
+@Getter
+public class StickyUnavailableException extends RuntimeException {
+
+    private final int httpStatus;
+
+    public StickyUnavailableException(int httpStatus, String message) {
+        super(message);
+        this.httpStatus = httpStatus;
+    }
+
+    public static StickyUnavailableException serviceUnavailable(String message) {
+        return new StickyUnavailableException(503, message);
+    }
+}

+ 14 - 0
ai-gateway/src/main/resources/application-dev.yaml

@@ -0,0 +1,14 @@
+spring:
+  # 与 ai-server 的 application-dev.yaml 保持同库同 Redis:
+  # sa-token 的登录态与 gateway_* 配置表都在这里
+  datasource:
+    url: jdbc:postgresql://192.168.0.106:5432/zsjz-ai
+    username: postgres
+    password: postgres
+    driver-class-name: org.postgresql.Driver
+  data:
+    redis:
+      host: 192.168.0.106
+      port: 6379
+      # 后端未显式配置 database(默认 0);网关必须一致
+      database: 0

+ 14 - 0
ai-gateway/src/main/resources/application-pro.yaml

@@ -0,0 +1,14 @@
+spring:
+  # 生产:凭证用环境变量注入(PG_URL/PG_USER/PG_PASSWORD/REDIS_HOST/REDIS_PASSWORD),
+  # 与 ai-server 的 application-pro.yaml 指向同一套 PostgreSQL / Redis
+  datasource:
+    url: ${PG_URL:jdbc:postgresql://localhost:5432/zsjz-ai}
+    username: ${PG_USER:postgres}
+    password: ${PG_PASSWORD:postgres}
+    driver-class-name: org.postgresql.Driver
+  data:
+    redis:
+      host: ${REDIS_HOST:localhost}
+      port: 6379
+      database: 0
+      #password: ${REDIS_PASSWORD:}

+ 81 - 0
ai-gateway/src/main/resources/application.yaml

@@ -0,0 +1,81 @@
+server:
+  port: 8080
+
+spring:
+  application:
+    name: ai-gateway
+  profiles:
+    active: dev
+  # 建表 DDL:从根仓库 sql/gateway.sql 复制而来(见 pom 的 copy-gateway-sql),
+  # CREATE TABLE / INDEX IF NOT EXISTS 幂等,可重复执行。服务/节点数据由代码按 YAML 播种。
+  sql:
+    init:
+      mode: always
+      schema-locations: classpath:db/gateway.sql
+  cloud:
+    gateway:
+      server:
+        webflux:
+          httpclient:
+            # 连接后端的超时(毫秒)。不设全局响应超时 —— SSE 研判会长时间保持连接
+            connect-timeout: 3000
+          # 后端已有全局 CorsFilter,网关默认不收口跨域(gateway.cors.enabled=false)。
+          # 该去重过滤器无条件生效:只有一份头时是 no-op,误开网关跨域时也不会出现重复头
+          default-filters:
+            - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_UNIQUE
+
+# ============================================================
+# Sa-Token:配置必须与 ai-server 的 application.yaml 逐项一致。
+# 网关只用 StpUtil.getLoginIdByToken(token)(读共享 Redis 的 token->loginId),
+# 不做登录、不做校验拦截;但键名由这些配置参与拼接,不一致就解析不到。
+# ============================================================
+sa-token:
+  token-name: x-token
+  timeout: 2592000
+  active-timeout: -1
+  is-concurrent: false
+  is-share: false
+  token-style: uuid
+  is-log: false
+  is-print: false
+  is-read-header: true
+  is-read-cookie: false
+  is-read-body: false
+  is-write-header: false
+  is-lasting-cookie: false
+  auto-renew: true
+  replaced-login-exit-mode: OLD_DEVICE
+  replaced-range: ALL_DEVICE_TYPE
+  max-login-count: -1
+
+gateway:
+  admin:
+    enabled: true
+    # 管理 IP 白名单:支持单 IP / CIDR(如 192.168.0.0/24);空 = 不限制
+    ip-whitelist: []
+  health-check:
+    interval: 10s
+    timeout: 2s
+    failure-threshold: 3
+  # 跨域默认关闭:后端已有全局 CorsFilter(app.cors.*),网关再加一层会导致
+  # Access-Control-Allow-Origin 重复、浏览器拒绝。仅在需要网关独立收口跨域时打开,
+  # 打开后配合 default-filters 的 DedupeResponseHeader 去重。
+  cors:
+    enabled: false
+    allowed-origin-patterns: "*"
+  # 服务播种:仅首次启动 gateway_service 表为空时写入;之后以数据库为准
+  services:
+    - id: zsjz-ai
+      name: 智能分析后端
+      path-prefix: /js/a
+      login-path: /auth/login
+      health-path: /js/a/sys/health
+      default-node-id: node-1
+      nodes:
+        - id: node-1
+          name: 节点1
+          base-url: http://192.168.0.101:8980
+        - id: node-2
+          name: 节点2
+          base-url: http://192.168.0.102:8980
+

+ 76 - 0
ai-gateway/src/test/java/com/zsjz/gateway/route/LoginBodyParseTest.java

@@ -0,0 +1,76 @@
+package com.zsjz.gateway.route;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class LoginBodyParseTest {
+
+    private static byte[] json(String s) {
+        return s.getBytes(StandardCharsets.UTF_8);
+    }
+
+    @Test
+    void parsesUsername() {
+        assertEquals("admin", LoginRequestFilter.parseUsername(
+                json("{\"username\":\"admin\",\"password\":\"x\"}")));
+    }
+
+    @Test
+    void emptyOrNullBody() {
+        assertNull(LoginRequestFilter.parseUsername(new byte[0]));
+        assertNull(LoginRequestFilter.parseUsername(null));
+    }
+
+    @Test
+    void invalidJson() {
+        assertNull(LoginRequestFilter.parseUsername(json("not-json")));
+        assertNull(LoginRequestFilter.parseUsername(json("{\"broken")));
+    }
+
+    @Test
+    void missingOrBlankUsername() {
+        assertNull(LoginRequestFilter.parseUsername(json("{\"password\":\"x\"}")));
+        assertNull(LoginRequestFilter.parseUsername(json("{\"username\":\"\"}")));
+        assertNull(LoginRequestFilter.parseUsername(json("{\"username\":\" \"}")));
+        assertNull(LoginRequestFilter.parseUsername(json("{\"username\":123}")));
+    }
+
+    @Test
+    void oversizedBodySkipsParsing() {
+        byte[] big = new byte[LoginRequestFilter.MAX_LOGIN_BODY_BYTES + 1];
+        assertNull(LoginRequestFilter.parseUsername(big));
+    }
+
+    // ------------------------------------------------------------------
+
+    @Test
+    void parsesSuccessfulLoginResponse() {
+        String body = "{\"code\":200,\"message\":\"\",\"data\":{"
+                + "\"token\":\"abc\",\"userId\":1,\"username\":\"admin\","
+                + "\"displayName\":\"系统管理员\",\"isAdmin\":1}}";
+        LoginResponseFilter.LoginSuccess login = LoginResponseFilter.parseLogin(json(body));
+        assertEquals("abc", login.token());
+        assertEquals(1L, login.userId());
+        assertEquals("admin", login.username());
+    }
+
+    @Test
+    void failedLoginReturnsNull() {
+        assertNull(LoginResponseFilter.parseLogin(
+                json("{\"code\":400,\"message\":\"密码错误\",\"data\":null}")));
+    }
+
+    @Test
+    void malformedOrEmptyResponseReturnsNull() {
+        assertNull(LoginResponseFilter.parseLogin(new byte[0]));
+        assertNull(LoginResponseFilter.parseLogin(json("<<<")));
+        assertNull(LoginResponseFilter.parseLogin(
+                json("{\"code\":200,\"data\":{\"username\":\"nouserid\"}}")));
+        assertNull(LoginResponseFilter.parseLogin(
+                json("{\"code\":200,\"data\":{}}")));
+    }
+}

+ 62 - 0
ai-gateway/src/test/java/com/zsjz/gateway/route/NodeSelectorTest.java

@@ -0,0 +1,62 @@
+package com.zsjz.gateway.route;
+
+import com.zsjz.gateway.registry.NodeHealthRegistry;
+import com.zsjz.gateway.registry.NodeInfo;
+import com.zsjz.gateway.registry.ServiceInfo;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class NodeSelectorTest {
+
+    private final NodeHealthRegistry health = new NodeHealthRegistry();
+    private final NodeSelector selector = new NodeSelector(health);
+
+    private ServiceInfo service(NodeInfo... nodes) {
+        return new ServiceInfo("zsjz-ai", "智能分析", "/js/a", "/auth/login",
+                "/js/a/sys/health", null, true, List.of(nodes));
+    }
+
+    private NodeInfo node(String id, boolean enabled) {
+        return new NodeInfo(id, "zsjz-ai", id, "http://" + id + ":8980", enabled, null);
+    }
+
+    @Test
+    void skipsDisabledNodes() {
+        ServiceInfo svc = service(node("node-1", false), node("node-2", true));
+        assertEquals("node-2", selector.select(svc).id());
+    }
+
+    @Test
+    void skipsDownNodes() {
+        health.recordFailure("node-1", "timeout", 1);
+        ServiceInfo svc = service(node("node-1", true), node("node-2", true));
+        assertEquals("node-2", selector.select(svc).id());
+    }
+
+    @Test
+    void downNodeRecoversAfterSuccess() {
+        health.recordFailure("node-1", "timeout", 1);
+        health.recordSuccess("node-1");
+        ServiceInfo svc = service(node("node-1", true));
+        assertEquals("node-1", selector.select(svc).id());
+    }
+
+    @Test
+    void returnsNullWhenNoCandidate() {
+        ServiceInfo svc = service(node("node-1", false));
+        assertNull(selector.select(svc));
+    }
+
+    @Test
+    void roundRobinDistributes() {
+        ServiceInfo svc = service(node("node-1", true), node("node-2", true));
+        int first = selector.select(svc).id().equals("node-1") ? 1 : 2;
+        int second = selector.select(svc).id().equals("node-1") ? 1 : 2;
+        assertTrue(first != second, "两次选择应轮询到不同节点");
+    }
+}

+ 38 - 0
ai-gateway/src/test/java/com/zsjz/gateway/route/StickyRouteFilterUriTest.java

@@ -0,0 +1,38 @@
+package com.zsjz.gateway.route;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class StickyRouteFilterUriTest {
+
+    @Test
+    void keepsRawPathAndQuery() {
+        URI request = URI.create("http://gw:8080/js/a/case/open?caseId=7&x=%E4%B8%AD");
+        URI target = StickyRouteFilter.buildTargetUri("http://192.168.0.101:8980", request);
+        assertEquals("http", target.getScheme());
+        assertEquals("192.168.0.101", target.getHost());
+        assertEquals(8980, target.getPort());
+        assertEquals("/js/a/case/open", target.getRawPath());
+        assertEquals("caseId=7&x=%E4%B8%AD", target.getRawQuery());
+    }
+
+    @Test
+    void noQueryGivesNullQuery() {
+        URI request = URI.create("http://gw:8080/js/a/auth/login");
+        URI target = StickyRouteFilter.buildTargetUri("http://10.0.0.2:8980", request);
+        assertEquals("/js/a/auth/login", target.getRawPath());
+        assertEquals(null, target.getRawQuery());
+    }
+
+    @Test
+    void httpsNode() {
+        URI request = URI.create("http://gw:8080/js/a/sys/health");
+        URI target = StickyRouteFilter.buildTargetUri("https://node.example.com", request);
+        assertEquals("https", target.getScheme());
+        assertEquals(-1, target.getPort());
+        assertEquals("/js/a/sys/health", target.getRawPath());
+    }
+}

+ 55 - 0
ai-gateway/src/test/java/com/zsjz/gateway/support/IpWhitelistTest.java

@@ -0,0 +1,55 @@
+package com.zsjz.gateway.support;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class IpWhitelistTest {
+
+    @Test
+    void emptyWhitelistAllowsAll() {
+        assertTrue(IpWhitelist.allows(List.of(), "192.168.0.1"));
+        assertTrue(IpWhitelist.allows(null, "192.168.0.1"));
+    }
+
+    @Test
+    void exactIpMatch() {
+        assertTrue(IpWhitelist.allows(List.of("192.168.0.5"), "192.168.0.5"));
+        assertFalse(IpWhitelist.allows(List.of("192.168.0.5"), "192.168.0.6"));
+    }
+
+    @Test
+    void ipv6ExactMatch() {
+        assertTrue(IpWhitelist.allows(List.of("::1"), "::1"));
+        assertFalse(IpWhitelist.allows(List.of("::1"), "127.0.0.1"));
+    }
+
+    @Test
+    void cidrMatch() {
+        assertTrue(IpWhitelist.allows(List.of("192.168.0.0/24"), "192.168.0.200"));
+        assertFalse(IpWhitelist.allows(List.of("192.168.0.0/24"), "192.168.1.1"));
+    }
+
+    @Test
+    void cidrNonByteAlignedPrefix() {
+        // /28:192.168.0.16 - 192.168.0.31
+        assertTrue(IpWhitelist.allows(List.of("192.168.0.16/28"), "192.168.0.31"));
+        assertFalse(IpWhitelist.allows(List.of("192.168.0.16/28"), "192.168.0.15"));
+    }
+
+    @Test
+    void fullPrefixMatchesWholeNetwork() {
+        assertTrue(IpWhitelist.allows(List.of("10.0.0.0/32"), "10.0.0.0"));
+        assertFalse(IpWhitelist.allows(List.of("10.0.0.0/32"), "10.0.0.1"));
+    }
+
+    @Test
+    void invalidEntryOrAddressDoesNotMatch() {
+        assertFalse(IpWhitelist.allows(List.of("not-an-ip"), "192.168.0.1"));
+        assertFalse(IpWhitelist.allows(List.of("192.168.0.0/24"), null));
+        assertFalse(IpWhitelist.allows(List.of("192.168.0.0/24"), "unknown"));
+    }
+}

+ 18 - 0
ai-gateway/src/test/python/QueryRoutes.java

@@ -0,0 +1,18 @@
+// 查询网关用户路由表
+import java.sql.*;
+
+public class QueryRoutes {
+    public static void main(String[] args) throws Exception {
+        try (Connection c = DriverManager.getConnection(
+                "jdbc:postgresql://192.168.0.106:5432/zsjz-ai", "postgres", "postgres")) {
+            ResultSet rs = c.createStatement().executeQuery(
+                    "SELECT id, service_id, user_id, username, node_id, create_time FROM gateway_user_route ORDER BY id");
+            System.out.println("id | service | user_id | username | node | created");
+            while (rs.next()) {
+                System.out.printf("%d | %s | %s | %s | %s | %s%n",
+                        rs.getLong(1), rs.getString(2), rs.getString(3),
+                        rs.getString(4), rs.getString(5), rs.getTimestamp(6));
+            }
+        }
+    }
+}

+ 18 - 0
ai-gateway/src/test/python/RestoreNodes.java

@@ -0,0 +1,18 @@
+// 冒烟测试后恢复:node-1/node-2 还原为 yaml 示例地址,清掉测试绑定
+import java.sql.Connection;
+import java.sql.DriverManager;
+
+public class RestoreNodes {
+    public static void main(String[] args) throws Exception {
+        try (Connection c = DriverManager.getConnection(
+                "jdbc:postgresql://192.168.0.106:5432/zsjz-ai", "postgres", "postgres")) {
+            c.createStatement().executeUpdate(
+                    "DELETE FROM gateway_user_route");
+            c.createStatement().executeUpdate(
+                    "UPDATE gateway_node SET base_url='http://192.168.0.101:8980', enabled=1, update_time=now() WHERE id='node-1'");
+            c.createStatement().executeUpdate(
+                    "UPDATE gateway_node SET base_url='http://192.168.0.102:8980', enabled=1, update_time=now() WHERE id='node-2'");
+            System.out.println("restored");
+        }
+    }
+}

+ 19 - 0
ai-gateway/src/test/python/UpdateNode.java

@@ -0,0 +1,19 @@
+// 单文件运行:把 node-1 指向本机 mock 后端,删除 node-2(避免轮询打到假节点)
+// java -cp postgresql.jar UpdateNode.java
+import java.sql.Connection;
+import java.sql.DriverManager;
+
+public class UpdateNode {
+    public static void main(String[] args) throws Exception {
+        try (Connection c = DriverManager.getConnection(
+                "jdbc:postgresql://192.168.0.106:5432/zsjz-ai", "postgres", "postgres")) {
+            int n1 = c.createStatement().executeUpdate(
+                    "UPDATE gateway_node SET base_url='http://127.0.0.1:18980', enabled=1, update_time=now() WHERE id='node-1'");
+            int d2 = c.createStatement().executeUpdate(
+                    "DELETE FROM gateway_user_route");
+            int n2 = c.createStatement().executeUpdate(
+                    "UPDATE gateway_node SET enabled=0, update_time=now() WHERE id='node-2'");
+            System.out.println("node-1 updated=" + n1 + ", node-2 disabled=" + n2);
+        }
+    }
+}

+ 45 - 0
ai-gateway/src/test/python/mock_backend.py

@@ -0,0 +1,45 @@
+"""Mock ai-server backend for gateway smoke testing (port 18980).
+
+- GET  /js/a/sys/health        -> 200 {"code":200}
+- POST /js/a/auth/login        -> login success (mockuser / anything)
+- any other path               -> 200 with echo of method+path+x-token
+"""
+import json
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+class Handler(BaseHTTPRequestHandler):
+    def _send(self, obj, status=200):
+        body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+        self.send_response(status)
+        self.send_header("Content-Type", "application/json;charset=UTF-8")
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    def do_GET(self):
+        if self.path == "/js/a/sys/health":
+            self._send({"code": 200, "message": "", "data": None})
+        else:
+            self._send({"code": 200, "data": {
+                "method": "GET", "path": self.path,
+                "token": self.headers.get("x-token")}})
+
+    def do_POST(self):
+        length = int(self.headers.get("Content-Length") or 0)
+        self.rfile.read(length)
+        if self.path == "/js/a/auth/login":
+            self._send({"code": 200, "message": "", "data": {
+                "token": "mock-token-123", "userId": 42, "username": "mockuser",
+                "displayName": "模拟用户", "isAdmin": 0}})
+        else:
+            self._send({"code": 200, "data": {
+                "method": "POST", "path": self.path,
+                "token": self.headers.get("x-token")}})
+
+    def log_message(self, fmt, *args):
+        pass
+
+
+if __name__ == "__main__":
+    ThreadingHTTPServer(("127.0.0.1", 18980), Handler).serve_forever()

+ 2 - 2
docs/design/ai-gateway-user-sticky-routing.md

@@ -1,8 +1,8 @@
 # ai-gateway 设计:按用户粘性的多机器转发网关
 
-> 状态:方案设计稿(未实施)
+> 状态:**已实施**(`ai-gateway/` 模块,2026-10-25。实施时修正了 8 处设计缺陷,
+> 见 `ai-gateway/README.md` §7「与设计文档的差异」;两边冲突时以 README 为准)。
 > 目标:在多台 ai-server 部署环境下,把同一用户的请求固定转发到该用户所属的机器上。
-> 本文只描述方案,不包含代码实施。
 
 ---
 

+ 1 - 0
pom.xml

@@ -17,6 +17,7 @@
     <packaging>pom</packaging>
     <modules>
         <module>ai-server</module>
+        <module>ai-gateway</module>
     </modules>
 
 </project>

+ 107 - 0
sql/gateway.sql

@@ -0,0 +1,107 @@
+-- ============================================================================
+-- ai-gateway 网关表(按用户粘性路由,见 docs/design/ai-gateway-user-sticky-routing.md)
+-- 目标库:PostgreSQL(与 ai-server 共享同一个库,网关表独立于业务表)
+-- 执行方式:ai-gateway 启动时由 spring.sql.init 自动执行(本脚本可重复执行)
+--
+-- 注意:同 user.sql,这是**平台库**表,不登记到 table_info / table_field。
+-- ============================================================================
+
+-- ---------------------------------------------------------------------------
+-- 1. 服务:一组对外路径前缀 + 一组后端节点
+--    首次启动若表为空,用 ai-gateway application.yaml 的 gateway.services 播种;
+--    之后以数据库为准,YAML 不再覆盖(变更走 /gateway/admin/** 管理接口)。
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS gateway_service (
+    id              VARCHAR(64)  PRIMARY KEY,
+    name            VARCHAR(128) NOT NULL,
+    -- 对外路径前缀,如 /js/a;全局唯一,不能以 / 结尾、不能是 /gateway
+    path_prefix     VARCHAR(128) NOT NULL,
+    -- 相对登录路径(相对 path_prefix),如 /auth/login;可空 = 无登录粘性
+    login_path      VARCHAR(128),
+    -- 完整健康检查路径,如 /js/a/sys/health
+    health_path     VARCHAR(255) NOT NULL,
+    -- 存量用户兜底节点;网关上线前已存在的用户若未绑定路由则落到该节点
+    default_node_id VARCHAR(64),
+    enabled         SMALLINT     NOT NULL DEFAULT 1,
+    create_time     TIMESTAMP    NOT NULL DEFAULT now(),
+    update_time     TIMESTAMP    NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_gateway_service_path_prefix
+    ON gateway_service (path_prefix);
+
+COMMENT ON TABLE  gateway_service IS '网关服务定义(路径前缀 -> 节点组)';
+COMMENT ON COLUMN gateway_service.login_path IS '相对路径前缀的登录路径,如 /auth/login;空 = 不做登录粘性';
+COMMENT ON COLUMN gateway_service.default_node_id IS '存量用户兜底节点 id;无绑定的已登录用户会被绑定到该节点';
+
+-- ---------------------------------------------------------------------------
+-- 2. 节点:一台 ai-server 实例
+--    base_url 不含路径(context-path 由转发的原始路径携带);同一服务内地址唯一。
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS gateway_node (
+    id          VARCHAR(64)  PRIMARY KEY,
+    service_id  VARCHAR(64)  NOT NULL,
+    name        VARCHAR(128) NOT NULL,
+    base_url    VARCHAR(255) NOT NULL,
+    enabled     SMALLINT     NOT NULL DEFAULT 1,
+    remark      VARCHAR(512),
+    create_time TIMESTAMP    NOT NULL DEFAULT now(),
+    update_time TIMESTAMP    NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_gateway_node_service_base_url
+    ON gateway_node (service_id, base_url);
+
+COMMENT ON TABLE  gateway_node IS '网关后端节点(一台 ai-server)';
+COMMENT ON COLUMN gateway_node.base_url IS 'http(s)://host:port,不含路径';
+
+ALTER TABLE gateway_node
+    DROP CONSTRAINT IF EXISTS fk_gateway_node_service;
+ALTER TABLE gateway_node
+    ADD CONSTRAINT fk_gateway_node_service
+    FOREIGN KEY (service_id) REFERENCES gateway_service (id) ON DELETE RESTRICT;
+
+-- ---------------------------------------------------------------------------
+-- 3. 用户路由:用户 -> 节点 的粘性绑定
+--    user_id / username 至少一个非空;唯一性用「部分唯一索引」保证:
+--      - (service_id, user_id)             user_id 非空时唯一
+--      - (service_id, lower(username))     username 非空时唯一(用户名不区分大小写,
+--                                          与 sys_user 的 uk_sys_user_username 对齐)
+--    bindIfAbsent 用不带冲突目标的 ON CONFLICT DO NOTHING,由上面两个索引裁决并发。
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS gateway_user_route (
+    id          BIGSERIAL    PRIMARY KEY,
+    service_id  VARCHAR(64)  NOT NULL,
+    -- sys_user.id;可空(允许只按用户名预绑定)
+    user_id     BIGINT,
+    username    VARCHAR(64),
+    node_id     VARCHAR(64)  NOT NULL,
+    create_time TIMESTAMP    NOT NULL DEFAULT now(),
+    update_time TIMESTAMP    NOT NULL DEFAULT now(),
+    -- user_id、username 至少一个非空
+    CONSTRAINT ck_gateway_user_route_owner CHECK (user_id IS NOT NULL OR username IS NOT NULL)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_gateway_user_route_user
+    ON gateway_user_route (service_id, user_id) WHERE user_id IS NOT NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_gateway_user_route_name
+    ON gateway_user_route (service_id, lower(username)) WHERE username IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_gateway_user_route_node
+    ON gateway_user_route (node_id);
+
+COMMENT ON TABLE  gateway_user_route IS '网关用户粘性路由(用户 -> 节点)';
+COMMENT ON COLUMN gateway_user_route.node_id IS '绑定的目标节点;节点不可用时网关返回 503,不自动切换';
+
+ALTER TABLE gateway_user_route
+    DROP CONSTRAINT IF EXISTS fk_gateway_user_route_service;
+ALTER TABLE gateway_user_route
+    ADD CONSTRAINT fk_gateway_user_route_service
+    FOREIGN KEY (service_id) REFERENCES gateway_service (id) ON DELETE RESTRICT;
+
+ALTER TABLE gateway_user_route
+    DROP CONSTRAINT IF EXISTS fk_gateway_user_route_node;
+ALTER TABLE gateway_user_route
+    ADD CONSTRAINT fk_gateway_user_route_node
+    FOREIGN KEY (node_id) REFERENCES gateway_node (id) ON DELETE RESTRICT;