cc 2dd5c1e4f0 feat(gateway): 新增 ai-gateway 按用户粘性路由的转发网关模块 4 gün önce
..
src 2dd5c1e4f0 feat(gateway): 新增 ai-gateway 按用户粘性路由的转发网关模块 4 gün önce
README.md 2dd5c1e4f0 feat(gateway): 新增 ai-gateway 按用户粘性路由的转发网关模块 4 gün önce
pom.xml 2dd5c1e4f0 feat(gateway): 新增 ai-gateway 按用户粘性路由的转发网关模块 4 gün önce

README.md

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. 运行与配置

# 开发(连 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。

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