# 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://: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`(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`(直改库的辅助脚本)。