|
|
@@ -0,0 +1,560 @@
|
|
|
+# ai-gateway 设计:按用户粘性的多机器转发网关
|
|
|
+
|
|
|
+> 状态:方案设计稿(未实施)
|
|
|
+> 目标:在多台 ai-server 部署环境下,把同一用户的请求固定转发到该用户所属的机器上。
|
|
|
+> 本文只描述方案,不包含代码实施。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 1. 背景与现状约束
|
|
|
+
|
|
|
+### 1.1 需求
|
|
|
+
|
|
|
+1. 能配置多个后端服务地址(多个服务、每个服务多台机器)。
|
|
|
+2. 自动转发用户请求:用户注册/首次登录在哪台机器上,后续请求就转发到哪台机器。
|
|
|
+3. 部署多台机器时,按用户做粘性路由,而不是普通轮询负载均衡。
|
|
|
+
|
|
|
+### 1.2 仓库现状
|
|
|
+
|
|
|
+- 仓库是 **Spring Boot 单体后端 `ai-server` + Electron/Vue 前端**,没有独立网关、Spring Cloud Gateway、Nginx 配置或服务注册中心。
|
|
|
+- 后端统一端口 `8980`,context-path `/js/a`;前端所有请求最终落到 `/js/a/...`。
|
|
|
+- 没有自助注册接口:用户由管理员通过 `POST /user/create` 创建,只写入共享 PostgreSQL 的 `sys_user`,不记录所属机器。
|
|
|
+- 登录使用 **sa-token 1.46**:token 是不透明 UUID,请求头 `x-token`,登录态和 Token-Session 都存 Redis。多台后端只要连同一个 Redis,就能识别同一个 token。
|
|
|
+- 案件、上传文件、聊天附件、Agent 记忆/技能、Python 产物都落在**各机器本地磁盘**;`/case/open` 还会把案件数据源注册到当前 JVM 内存。
|
|
|
+- 每台 ai-server 需要独立授权(机器码绑定)和独立工作目录,不能共享 DuckDB 文件(跨进程文件锁会失败)。
|
|
|
+
|
|
|
+### 1.3 关键结论
|
|
|
+
|
|
|
+不能做普通的无状态负载均衡。请求落到非用户所属机器时,即使 token 和 `caseId` 都能从 Redis 恢复,本机也没有对应的案件数据源,会直接报“当前案件数据源未打开或已释放”。
|
|
|
+
|
|
|
+因此必须:
|
|
|
+
|
|
|
+- 按用户做粘性路由;
|
|
|
+- 用户绑定的节点不可用时明确失败,不自动切到其他机器;
|
|
|
+- 用户数据迁移不在本期范围。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 2. 已定型的决策
|
|
|
+
|
|
|
+| 项 | 决策 |
|
|
|
+|---|---|
|
|
|
+| 工程形态 | 当前仓库新增 `ai-gateway` Maven 模块 |
|
|
|
+| 技术栈 | Spring Boot 3.5.16 + Spring Cloud Gateway 2025.0.x(WebFlux/Netty),独立端口 `8080` |
|
|
|
+| 用户绑定 | 用户**首次登录成功**时自动绑定到一台机器;管理员可通过接口预先绑定 |
|
|
|
+| 用户识别 | `x-token` → sa-token 共享 Redis → userId |
|
|
|
+| 配置 | YAML 首次播种 + PostgreSQL 运行时配置 + `/gateway/admin/**` 管理接口 |
|
|
|
+| 节点故障 | 返回 HTTP 503 + 明确提示,不自动切换 |
|
|
|
+| 前端 | 不改业务代码,只把 API 基地址指向网关 |
|
|
|
+
|
|
|
+> 当前仓库没有注册接口,所以“注册在哪台机器”落地为“首次登录成功在哪台机器”。如果以后新增注册接口,只需在注册成功后调用同一个绑定服务。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 3. 目标架构
|
|
|
+
|
|
|
+```
|
|
|
+Electron / Web
|
|
|
+ │ http://gateway:8080/js/a/**
|
|
|
+ ▼
|
|
|
+┌──────────────────────── ai-gateway :8080 ────────────────────────┐
|
|
|
+│ 登录请求:username → node(首次成功登录后写映射) │
|
|
|
+│ 普通请求:x-token → userId → node(Redis 缓存 + PostgreSQL 兜底)│
|
|
|
+│ 无 token:健康节点轮询;MCP:固定节点 │
|
|
|
+└───────────────┬──────────────────────────┬───────────────────────┘
|
|
|
+ │ │
|
|
|
+ ai-server node-1 :8980 ai-server node-2 :8980
|
|
|
+ (本地 DuckDB/附件/Agent) (本地 DuckDB/附件/Agent)
|
|
|
+ │ │
|
|
|
+ └──────── 共享 PostgreSQL(服务/节点/用户路由)
|
|
|
+ └──────── 共享 Redis(sa-token + 网关路由缓存)
|
|
|
+```
|
|
|
+
|
|
|
+网关只做转发和粘性决策,不复制后端鉴权逻辑;业务接口仍由 ai-server 校验 token。
|
|
|
+
|
|
|
+### 3.1 请求处理总流程
|
|
|
+
|
|
|
+1. 客户端请求进入网关(默认 `8080`)。
|
|
|
+2. 按路径前缀匹配已配置的服务(如 `/js/a/**` → `zsjz-ai`)。
|
|
|
+3. 根据登录用户名或 `x-token` 解析出目标节点。
|
|
|
+4. 校验节点启用状态和健康状态。
|
|
|
+5. 把请求流式转发到目标节点,保留原始路径、查询参数、请求头、请求体。
|
|
|
+6. 响应流式返回客户端,并附加 `X-Gateway-Node` 便于排查。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 4. 路由与绑定算法
|
|
|
+
|
|
|
+### 4.1 登录请求
|
|
|
+
|
|
|
+登录请求没有 token,只能靠请求体里的 `username` 定位用户。
|
|
|
+
|
|
|
+1. `LoginRequestFilter` 只对配置的登录路径(`/js/a/auth/login`)生效:
|
|
|
+ - 缓存 ≤64KB 请求体;
|
|
|
+ - 解析 JSON 中的 `username`;
|
|
|
+ - 把 `Accept-Encoding` 改为 `identity`,避免后端压缩导致无法解析响应;
|
|
|
+ - 原样转发请求体,不改变业务内容。
|
|
|
+2. `StickyRouteFilter` 决定目标节点:
|
|
|
+ - 有 `username → node` 映射:走该节点;节点 DOWN 直接 503。
|
|
|
+ - 无映射:查 `sys_user` 得到 `userId`,再查 `userId → node`;有则使用并回填 username 缓存。
|
|
|
+ - 仍无映射:在所有健康节点中轮询选一个“候选节点”,**此时不写绑定**。
|
|
|
+3. `LoginResponseFilter` 缓存 ≤256KB 登录响应,解析:
|
|
|
+
|
|
|
+ ```json
|
|
|
+ {
|
|
|
+ "code": 200,
|
|
|
+ "message": "",
|
|
|
+ "data": {
|
|
|
+ "token": "...",
|
|
|
+ "userId": 1,
|
|
|
+ "username": "admin",
|
|
|
+ "displayName": "系统管理员",
|
|
|
+ "isAdmin": 1
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ```
|
|
|
+
|
|
|
+ - `code == 200` 且 `userId` 非空时,调用 `bindIfAbsent(userId, username, 候选节点)`。
|
|
|
+ - 并发首次登录由数据库唯一索引裁决:先插入者生效,后到者读取赢家节点;不会来回漂移。
|
|
|
+ - 缓存 `gateway:token:{serviceId}:{token} → userId`,TTL 24h。
|
|
|
+ - 无论绑定成功与否,响应原样写回。
|
|
|
+4. 登录失败(密码错等)不产生绑定,避免无效映射。
|
|
|
+
|
|
|
+### 4.2 普通请求
|
|
|
+
|
|
|
+1. 从 `x-token` 读 token;先查 `gateway:token:*` 得到 userId。
|
|
|
+2. 缓存未命中时用 sa-token `StpUtil.getLoginIdByToken`(共享 Redis)解析,并回填 token 缓存;兼容网关上线前已签发的 token。
|
|
|
+3. 查 `userId → node`(Redis 缓存,未命中查 `gateway_user_route` 并回填)。
|
|
|
+4. 没有该用户的映射时:
|
|
|
+ - 服务配置了 `default-node-id`:绑定到该节点(用于存量用户迁移);
|
|
|
+ - 否则返回 503「用户未绑定节点,请重新登录或联系管理员」。
|
|
|
+5. 绑定节点被禁用或健康检查 DOWN:返回 503,不切换。
|
|
|
+6. 节点健康时把 `GATEWAY_REQUEST_URL_ATTR` 重写为 `节点 origin + 原始 rawPath + rawQuery`,由 Netty 流式转发;响应头加 `X-Gateway-Node` 便于排查。
|
|
|
+
|
|
|
+### 4.3 无 token 与 MCP
|
|
|
+
|
|
|
+- 登录以外的无 token 请求(`/sys/health`、`/sys/map/**` 等)在健康节点间轮询。
|
|
|
+- MCP 路径(`/mcpsse`、`/mcpstreamable`、`/mcp/message`)无 token 且会话有状态,固定转发到服务配置的 `mcp-node-id`;未配置时走首个健康节点。本期不做 MCP 会话级粘性。
|
|
|
+- 其他无 token 请求如果后端需要鉴权,由后端返回现有的 `code=401`,网关不重复鉴权。
|
|
|
+
|
|
|
+### 4.4 绑定写入与并发
|
|
|
+
|
|
|
+`bindIfAbsent` 的语义:
|
|
|
+
|
|
|
+1. 先按 `(serviceId, userId)` 和 `(serviceId, lower(username))` 查已有路由;有则直接返回已有节点,不覆盖。
|
|
|
+2. 没有则插入一行,同时写 `user_id`、`username`、`node_id`。
|
|
|
+3. 使用 `ON CONFLICT DO NOTHING`,再按 userId、username 重新查询,读取并发下的赢家。
|
|
|
+4. 写入成功后更新 Redis 的 `user`、`name` 两个缓存键。
|
|
|
+
|
|
|
+数据库唯一索引是最终裁决,保证同一用户/用户名只存在一条路由。
|
|
|
+
|
|
|
+### 4.5 用户路由的 Redis 键
|
|
|
+
|
|
|
+| 键 | 值 | TTL | 用途 |
|
|
|
+|---|---|---|---|
|
|
|
+| `gateway:token:{serviceId}:{token}` | userId | 24h | 避免每个请求都调 sa-token |
|
|
|
+| `gateway:route:user:{serviceId}:{userId}` | nodeId | 无 | 普通请求热路径 |
|
|
|
+| `gateway:route:name:{serviceId}:{lower(username)}` | nodeId | 无 | 登录请求热路径 |
|
|
|
+
|
|
|
+- PostgreSQL 是事实来源,Redis 只是缓存。
|
|
|
+- Redis 清空后按需求回源数据库,不影响正确性。
|
|
|
+- 管理员重绑/解绑时同时更新数据库和 Redis。
|
|
|
+
|
|
|
+### 4.6 健康检查与故障处理
|
|
|
+
|
|
|
+- 每 10 秒对每个节点 `GET {base-url}{health-path}`,2 秒超时。
|
|
|
+- 连续 3 次失败标记 DOWN;1 次成功恢复 UP。
|
|
|
+- 新用户分配只选健康且启用的节点。
|
|
|
+- 已绑定用户的节点 DOWN 时直接 503,不切换。
|
|
|
+- 背景节点恢复后自动恢复服务,用户映射不变。
|
|
|
+- `/sys/health` 目前只证明进程存活,不检查 PG/Redis/ES;节点级探活够用,深度健康可作为后续增强。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 5. 配置模型
|
|
|
+
|
|
|
+`ai-gateway/src/main/resources/application.yaml`:
|
|
|
+
|
|
|
+```yaml
|
|
|
+server:
|
|
|
+ port: 8080
|
|
|
+
|
|
|
+spring:
|
|
|
+ application:
|
|
|
+ name: ai-gateway
|
|
|
+ datasource:
|
|
|
+ url: jdbc:postgresql://192.168.0.106:5432/zsjz-ai
|
|
|
+ username: postgres
|
|
|
+ password: postgres
|
|
|
+ data:
|
|
|
+ redis:
|
|
|
+ host: 192.168.0.106
|
|
|
+ port: 6379
|
|
|
+ sql:
|
|
|
+ init:
|
|
|
+ mode: always
|
|
|
+ schema-locations: classpath:db/gateway.sql
|
|
|
+
|
|
|
+sa-token:
|
|
|
+ token-name: x-token
|
|
|
+ is-read-header: true
|
|
|
+ is-read-cookie: false
|
|
|
+ is-read-body: false
|
|
|
+ timeout: 2592000
|
|
|
+ is-concurrent: true
|
|
|
+ is-share: false
|
|
|
+ token-style: uuid
|
|
|
+
|
|
|
+gateway:
|
|
|
+ admin:
|
|
|
+ enabled: true
|
|
|
+ ip-whitelist: [] # 支持单 IP / CIDR;空表示不限制
|
|
|
+ health-check:
|
|
|
+ interval: 10s
|
|
|
+ timeout: 2s
|
|
|
+ failure-threshold: 3
|
|
|
+ cors:
|
|
|
+ allowed-origin-patterns: ["*"]
|
|
|
+ services:
|
|
|
+ - id: zsjz-ai
|
|
|
+ name: 智能分析后端
|
|
|
+ path-prefix: /js/a
|
|
|
+ login-path: /auth/login
|
|
|
+ health-path: /js/a/sys/health
|
|
|
+ default-node-id: node-1
|
|
|
+ mcp-node-id: node-1
|
|
|
+ mcp-paths: ["/mcpsse", "/mcpstreamable", "/mcp/message"]
|
|
|
+ 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
|
|
|
+```
|
|
|
+
|
|
|
+### 5.1 配置优先级
|
|
|
+
|
|
|
+- **数据库为准**:首次启动若 `gateway_service` 为空,用 YAML 播种;之后 YAML 不再覆盖数据库。
|
|
|
+- 节点、服务变更通过管理接口完成。
|
|
|
+- 支持多个服务,每个服务一个路径前缀、一组节点;前缀冲突在保存时拒绝。
|
|
|
+- 网关的 Redis/sa-token 配置必须与后端一致(同一 Redis、同一 `token-name`、同一 DB index),否则 token 无法解析。
|
|
|
+
|
|
|
+### 5.2 生产配置建议
|
|
|
+
|
|
|
+- 数据库密码、Redis 密码用环境变量注入,不写进仓库。
|
|
|
+- `gateway.admin.ip-whitelist` 配置为运维网段。
|
|
|
+- 多个网关实例可同时运行,共享 PG/Redis,粘性结果一致。
|
|
|
+- 网关本身无本地状态,可放在 VIP/Nginx 后面。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 6. 数据库模型
|
|
|
+
|
|
|
+新增 `sql/gateway.sql`(`CREATE TABLE IF NOT EXISTS`,可重复执行),Maven 把它复制到 classpath 供 `spring.sql.init` 自动建表。
|
|
|
+
|
|
|
+### 6.1 `gateway_service`
|
|
|
+
|
|
|
+| 字段 | 类型 | 说明 |
|
|
|
+|---|---|---|
|
|
|
+| `id` | varchar(64) PK | 服务标识,如 `zsjz-ai` |
|
|
|
+| `name` | varchar(128) | 服务名称 |
|
|
|
+| `path_prefix` | varchar(128) UNIQUE | 对外路径前缀,如 `/js/a` |
|
|
|
+| `login_path` | varchar(128) | 相对登录路径,如 `/auth/login`,可空 |
|
|
|
+| `health_path` | varchar(255) | 完整健康检查路径,如 `/js/a/sys/health` |
|
|
|
+| `default_node_id` | varchar(64) | 存量用户兜底节点,可空 |
|
|
|
+| `mcp_node_id` | varchar(64) | MCP 固定节点,可空 |
|
|
|
+| `mcp_paths` | varchar(512) | MCP 路径列表,逗号分隔,可空 |
|
|
|
+| `enabled` | smallint | 1 启用 0 停用 |
|
|
|
+| `create_time` / `update_time` | timestamp | 审计 |
|
|
|
+
|
|
|
+### 6.2 `gateway_node`
|
|
|
+
|
|
|
+| 字段 | 类型 | 说明 |
|
|
|
+|---|---|---|
|
|
|
+| `id` | varchar(64) PK | 节点标识,全局唯一,如 `node-1` |
|
|
|
+| `service_id` | varchar(64) FK | 所属服务 |
|
|
|
+| `name` | varchar(128) | 节点名称 |
|
|
|
+| `base_url` | varchar(255) | `http(s)://host:port`,不含路径 |
|
|
|
+| `enabled` | smallint | 1 启用 0 停用 |
|
|
|
+| `remark` | varchar(512) | 备注 |
|
|
|
+| `create_time` / `update_time` | timestamp | 审计 |
|
|
|
+
|
|
|
+`(service_id, base_url)` 唯一。
|
|
|
+
|
|
|
+### 6.3 `gateway_user_route`
|
|
|
+
|
|
|
+| 字段 | 类型 | 说明 |
|
|
|
+|---|---|---|
|
|
|
+| `id` | bigserial PK | 主键 |
|
|
|
+| `service_id` | varchar(64) FK | 服务 |
|
|
|
+| `user_id` | bigint | `sys_user.id`,可空 |
|
|
|
+| `username` | varchar(64) | 登录名,可空 |
|
|
|
+| `node_id` | varchar(64) FK | 目标节点 |
|
|
|
+| `create_time` / `update_time` | timestamp | 审计 |
|
|
|
+
|
|
|
+约束:
|
|
|
+
|
|
|
+- `(service_id, user_id)` 唯一(user_id 非空时);
|
|
|
+- `(service_id, lower(username))` 唯一(username 非空时);
|
|
|
+- `user_id`、`username` 至少一个非空;
|
|
|
+- 所有外键 `ON DELETE RESTRICT`;
|
|
|
+- 删除有绑定用户的节点直接拒绝,避免产生无主用户。
|
|
|
+
|
|
|
+### 6.4 与后端表的关系
|
|
|
+
|
|
|
+- 网关只读 `sys_user` 的 `id/username/is_admin/status`,不修改后端表。
|
|
|
+- 用户路由放独立表,不侵入 `sys_user`,支持同一用户在不同服务绑定不同节点。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 7. 核心实现设计
|
|
|
+
|
|
|
+### 7.1 模块与依赖
|
|
|
+
|
|
|
+- 根 `pom.xml` 增加 `<module>ai-gateway</module>`。
|
|
|
+- `ai-gateway/pom.xml`:
|
|
|
+ - 父 POM `com.zsjz:zsjz-ai:1.0`,JDK 25;
|
|
|
+ - 导入 Spring Cloud 2025.0.x BOM;
|
|
|
+ - 依赖:
|
|
|
+ - `spring-cloud-starter-gateway-server-webflux`
|
|
|
+ - `spring-boot-starter-webflux`
|
|
|
+ - `spring-boot-starter-jdbc`
|
|
|
+ - PostgreSQL 驱动
|
|
|
+ - `spring-boot-starter-data-redis-reactive`
|
|
|
+ - `sa-token-reactor-spring-boot3-starter:1.46.0`
|
|
|
+ - `sa-token-redis-jackson:1.46.0`
|
|
|
+ - `spring-boot-starter-validation`
|
|
|
+ - 测试依赖:Reactor Test、WireMock、Testcontainers(PostgreSQL + Redis 容器)。
|
|
|
+- Maven resources 把 `../sql/gateway.sql` 打进 `classpath:db/gateway.sql`,避免 DDL 两份维护。
|
|
|
+
|
|
|
+### 7.2 动态路由
|
|
|
+
|
|
|
+- `GatewayRouteDefinitionRepository` 实现 `RouteDefinitionRepository`:
|
|
|
+ - 每个服务生成一条路由;
|
|
|
+ - `Path=/js/a/**`;
|
|
|
+ - URI 用占位符 `http://gateway.invalid`;
|
|
|
+ - metadata 携带 `serviceId`;
|
|
|
+ - 按路径前缀长度降序设置 route order,保证更长前缀优先。
|
|
|
+- `GatewayRegistry` 从数据库加载服务/节点快照。
|
|
|
+- 配置变化后发布 `RefreshRoutesEvent`,清理路由缓存。
|
|
|
+- `StickyRouteFilter`(order 10001,排在 `RouteToRequestUrlFilter` 之后)负责把占位 URI 改写成实际节点地址。
|
|
|
+
|
|
|
+### 7.3 过滤器与核心类
|
|
|
+
|
|
|
+| 组件 | 职责 |
|
|
|
+|---|---|
|
|
|
+| `LoginRequestFilter`(order 9999) | 登录路径请求体缓存、username 解析、`Accept-Encoding: identity` |
|
|
|
+| `LoginResponseFilter`(order 10002) | 登录响应缓存解析、首次成功登录绑定、token 缓存 |
|
|
|
+| `StickyRouteFilter`(order 10001) | 粘性节点选择、节点健康校验、URL 重写、`X-Gateway-Node` 响应头、503 错误输出 |
|
|
|
+| `TokenResolver` | Redis token 缓存 → sa-token Redis 兜底,阻塞调用放 `boundedElastic` |
|
|
|
+| `UserRouteService` / `UserRouteRepository` | username/userId 路由查询、`bindIfAbsent`、管理端重绑/解绑、存量用户导入 |
|
|
|
+| `NodeSelector` | 轮询选择健康节点,跳过 disabled/DOWN |
|
|
|
+| `HealthChecker` + `NodeHealthRegistry` | 定时探测、失败阈值、状态变更日志 |
|
|
|
+| `AdminAuthWebFilter` | `x-token` → userId → `sys_user.is_admin=1 && status=1`,叠加 IP 白名单;管理接口关闭时返回 404 |
|
|
|
+| `GatewayErrorHandler` | 连接失败/超时统一返回 502,其他异常 500,响应体沿用 `{code,message,data}` |
|
|
|
+| `CorsConfig` | `allowedOriginPatterns=*`、暴露 `X-Gateway-Node`;`x-token` 不走 Cookie,允许无凭证跨域 |
|
|
|
+| `RequestIdFilter` | 透传/生成 `X-Request-Id`,日志按请求串联 |
|
|
|
+
|
|
|
+### 7.4 流式与请求体
|
|
|
+
|
|
|
+- 除登录外不缓存请求/响应,上传、下载、SSE 全部走 Netty 流式转发。
|
|
|
+- `spring.cloud.gateway.httpclient.connect-timeout=3s`,`response-timeout=0s`(SSE 不设总超时),关闭客户端压缩。
|
|
|
+- 保留 `Range`、`Content-Type`、`x-token`、Cookie 等端到端头;SCG 自动剔除 hop-by-hop 头。
|
|
|
+- 登录体 ≤64KB、登录响应 ≤256KB;超出返回 413/502,避免大响应被缓存。
|
|
|
+- 后端返回的 4xx/5xx 状态码、响应头、响应体原样透传。
|
|
|
+
|
|
|
+### 7.5 错误响应
|
|
|
+
|
|
|
+- 用户绑定节点不可用:HTTP 503 + `{code:503,message:"用户绑定的后端节点不可用:node-2,请稍后重试或联系管理员"}`。
|
|
|
+- 无健康节点:HTTP 503 + 明确提示。
|
|
|
+- 上游连接失败/超时:HTTP 502 + 明确提示。
|
|
|
+- 管理接口未登录:HTTP 401;非管理员:HTTP 403。
|
|
|
+- 不在错误信息里暴露后端完整地址,只暴露节点 ID。
|
|
|
+
|
|
|
+### 7.6 配置热更新
|
|
|
+
|
|
|
+- 管理接口写库后立即刷新本实例路由。
|
|
|
+- 其他网关实例每 10 秒比对 `max(update_time) + count`,有变化则重载服务/节点并刷新路由。
|
|
|
+- 用户路由变更直接更新 PostgreSQL 和 Redis,所有网关实例立即生效。
|
|
|
+- 健康状态是每个网关实例自己的内存状态,不跨实例同步。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 8. 管理 API(`/gateway/admin/**`)
|
|
|
+
|
|
|
+所有接口要求管理员登录态(`x-token`),可选 IP 白名单。
|
|
|
+
|
|
|
+```text
|
|
|
+GET /gateway/admin/status 服务、节点、健康状态、绑定用户数
|
|
|
+GET /gateway/admin/services
|
|
|
+POST /gateway/admin/services
|
|
|
+PUT /gateway/admin/services/{serviceId}
|
|
|
+DELETE /gateway/admin/services/{serviceId} (有节点/路由时拒绝)
|
|
|
+POST /gateway/admin/services/{serviceId}/nodes
|
|
|
+PUT /gateway/admin/nodes/{nodeId} 修改地址/启停
|
|
|
+DELETE /gateway/admin/nodes/{nodeId} 有绑定用户时拒绝
|
|
|
+POST /gateway/admin/nodes/{nodeId}/check 手动探活
|
|
|
+GET /gateway/admin/routes?serviceId=&keyword=&page=&size=
|
|
|
+POST /gateway/admin/routes/bind {serviceId,username|userId,nodeId} 预绑定/重绑
|
|
|
+DELETE /gateway/admin/routes/{id} 解绑并清 Redis 缓存
|
|
|
+POST /gateway/admin/routes/import-existing {serviceId,nodeId} 把 sys_user 存量用户批量绑到该节点
|
|
|
+POST /gateway/admin/reload 强制刷新配置
|
|
|
+```
|
|
|
+
|
|
|
+### 8.1 校验规则
|
|
|
+
|
|
|
+- 服务 id:`^[a-z][a-z0-9-]{1,63}$`。
|
|
|
+- `path-prefix` 必须以 `/` 开头、不能以 `/` 结尾、不能是 `/gateway`,且全局唯一。
|
|
|
+- `base-url` 必须是 `http(s)://host[:port]`,不含 path/query/fragment。
|
|
|
+- 节点 id 全局唯一。
|
|
|
+- 绑定时节点必须属于对应服务且启用。
|
|
|
+- 删除服务/节点前检查关联数据,返回 409。
|
|
|
+
|
|
|
+### 8.2 存量用户导入
|
|
|
+
|
|
|
+`POST /gateway/admin/routes/import-existing` 的核心 SQL:
|
|
|
+
|
|
|
+```sql
|
|
|
+INSERT INTO gateway_user_route (service_id, user_id, username, node_id)
|
|
|
+SELECT :serviceId, u.id, u.username, :nodeId
|
|
|
+FROM sys_user u
|
|
|
+WHERE NOT EXISTS (
|
|
|
+ SELECT 1 FROM gateway_user_route r
|
|
|
+ WHERE r.service_id = :serviceId
|
|
|
+ AND (r.user_id = u.id OR lower(r.username) = lower(u.username))
|
|
|
+)
|
|
|
+ON CONFLICT DO NOTHING;
|
|
|
+```
|
|
|
+
|
|
|
+用于网关首次上线时把现有用户全部归到当前唯一后端,避免存量用户被分配错机器。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 9. 文件清单
|
|
|
+
|
|
|
+```text
|
|
|
+pom.xml + ai-gateway 模块
|
|
|
+sql/gateway.sql 三张表 DDL
|
|
|
+ai-gateway/pom.xml
|
|
|
+ai-gateway/src/main/resources/application.yaml
|
|
|
+ai-gateway/src/main/resources/application-dev.yaml
|
|
|
+ai-gateway/src/main/java/com/zsjz/gateway/
|
|
|
+ GatewayApplication.java
|
|
|
+ config/GatewayProperties.java
|
|
|
+ config/CorsConfig.java
|
|
|
+ config/WebClientConfig.java
|
|
|
+ registry/GatewayRegistry.java
|
|
|
+ registry/GatewayConfigRepository.java
|
|
|
+ registry/HealthChecker.java
|
|
|
+ registry/NodeHealthRegistry.java
|
|
|
+ route/GatewayRouteDefinitionRepository.java
|
|
|
+ route/LoginRequestFilter.java
|
|
|
+ route/LoginResponseFilter.java
|
|
|
+ route/StickyRouteFilter.java
|
|
|
+ route/TokenResolver.java
|
|
|
+ route/UserRouteService.java
|
|
|
+ route/UserRouteRepository.java
|
|
|
+ route/NodeSelector.java
|
|
|
+ route/GatewayAttributes.java
|
|
|
+ admin/AdminController.java
|
|
|
+ admin/AdminService.java
|
|
|
+ admin/AdminAuthWebFilter.java
|
|
|
+ support/ApiResult.java
|
|
|
+ support/GatewayErrorHandler.java
|
|
|
+ support/RequestIdFilter.java
|
|
|
+ support/IpWhitelist.java
|
|
|
+ai-gateway/src/test/java/com/zsjz/gateway/... 单元测试 + 集成测试
|
|
|
+ai-gateway/README.md 架构、配置、接口、部署、排障
|
|
|
+AI_AGENT.md 仓库结构补充 ai-gateway
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 10. 部署与切换步骤
|
|
|
+
|
|
|
+1. 在现有唯一的 ai-server 上确认工作目录、授权和 `QingJian/` 数据目录。
|
|
|
+2. 启动网关,配置 `node-1` 指向现有后端,`default-node-id=node-1`。
|
|
|
+3. 调用 `POST /gateway/admin/routes/import-existing {serviceId:"zsjz-ai",nodeId:"node-1"}`,把现有 `sys_user` 全部绑定到 node-1;未导入的用户也会走 default-node 兜底。
|
|
|
+4. 把前端 `VITE_GLOB_API_URL`(或打包后的 `_app.config.js`)改为 `http://<gateway>:8080`;开发环境把 `VITE_PROXY` 指向网关。
|
|
|
+5. 验证登录、案件打开、附件上传、SSE 聊天都走同一节点。
|
|
|
+6. 再部署 node-2,新用户由轮询分配,或管理员预绑定。
|
|
|
+7. 防火墙只允许客户端访问网关端口,禁止直接访问 8980;否则用户可以绕过粘性。
|
|
|
+8. 每台 ai-server 仍需独立授权和独立数据目录;不要共享 DuckDB 文件。
|
|
|
+
|
|
|
+后端不需要改代码;可选加一行 `server.forward-headers-strategy: framework`,让后端正确识别 `X-Forwarded-*`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 11. 测试与验收
|
|
|
+
|
|
|
+### 11.1 单元测试
|
|
|
+
|
|
|
+- 登录体解析(正常/空/非法 JSON/超大)。
|
|
|
+- 首次登录绑定、并发 `bindIfAbsent` 唯一性、登录失败不绑定。
|
|
|
+- token 缓存命中/未命中、sa-token 兜底。
|
|
|
+- 节点轮询跳过 DOWN/disabled、绑定节点 DOWN 返回 503。
|
|
|
+- 管理接口权限(非管理员 403、停用用户 403、IP 白名单)。
|
|
|
+
|
|
|
+### 11.2 集成测试
|
|
|
+
|
|
|
+使用 Testcontainers PostgreSQL + Redis + WireMock 两个后端:
|
|
|
+
|
|
|
+- 用户 A 首次登录 → node-1;后续带 token 请求始终 node-1;用户 B → node-2。
|
|
|
+- node-1 停止探活后,A 的请求返回 503;恢复后继续 node-1,且不切 node-2。
|
|
|
+- 管理接口新增/禁用节点后路由即时生效;重绑用户后新请求切到新节点。
|
|
|
+- SSE 分块响应不被缓冲;multipart 上传和 Range 下载原样透传。
|
|
|
+- 清空 Redis 路由缓存后能从 PostgreSQL 重新加载。
|
|
|
+- 存量用户导入后,已有 token 的请求能正确路由。
|
|
|
+
|
|
|
+### 11.3 人工验收
|
|
|
+
|
|
|
+- 两台真实 ai-server(各自独立工作目录和授权)分别登录两个用户。
|
|
|
+- 打开案件、上传附件、发起 SSE 研判,确认 `X-Gateway-Node` 始终一致。
|
|
|
+- 停掉一台后端,确认 503 提示可读且没有跨节点写入。
|
|
|
+- 直接访问后端 8980 被防火墙拒绝,只能经过网关。
|
|
|
+
|
|
|
+### 11.4 验收标准
|
|
|
+
|
|
|
+1. 配置两个后端节点后,`/js/a/**` 的所有请求自动转发,无需逐接口配置。
|
|
|
+2. 同一用户首次登录后,后续所有请求(含 SSE、上传、下载)固定到同一节点。
|
|
|
+3. 不同用户可分配到不同节点。
|
|
|
+4. 绑定节点故障返回明确 503,不自动切换。
|
|
|
+5. 管理接口可在不重启网关的情况下增删/启停节点、查看和重绑用户。
|
|
|
+6. 现有用户可通过导入接口一次性绑定到当前节点,旧 token 继续可用。
|
|
|
+7. 单元测试和集成测试通过。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 12. 不在本期范围
|
|
|
+
|
|
|
+- 用户数据在两台机器之间的自动迁移。
|
|
|
+- 节点故障自动切换/共享存储。
|
|
|
+- MCP 会话级粘性(本期固定节点)。
|
|
|
+- 管理 UI(本期只提供 REST 接口)。
|
|
|
+- 限流、TLS 终止、深度健康检查。
|
|
|
+- 自助注册页面(当前仓库没有;如有需要,未来在注册成功后调用绑定服务)。
|
|
|
+- 按案件粘性(本期按用户;当前案件仅创建者可见,用户级粘性已覆盖)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 13. 风险与注意事项
|
|
|
+
|
|
|
+| 风险 | 说明 | 应对 |
|
|
|
+|---|---|---|
|
|
|
+| 网关只读耦合 `sys_user` | 绑定用户和管理员校验需要读 `sys_user.id/username/is_admin/status` | 字段变更时同步网关;只读不写 |
|
|
|
+| 健康检查较浅 | `/sys/health` 只证明进程存活,不检查 PG/Redis/ES | 节点级探活够用;后续可加深度健康接口 |
|
|
|
+| 登录响应结构变化 | 首次绑定依赖 `Result + LoginVO` 的 `code/data.userId/data.username` | 结构变化时调整解析,或改用管理端预绑定 |
|
|
|
+| 多服务鉴权差异 | 网关共用一套 sa-token Redis/token-name | 本期所有服务复用同一套鉴权;未来扩展为按服务配置 |
|
|
|
+| 绕过网关 | 用户直连 8980 会落到非绑定节点 | 防火墙限制后端端口只对网关开放 |
|
|
|
+| 节点 DOWN 即不可用 | 按设计不自动切换,用户会看到 503 | 运维尽快恢复节点;提供管理端重绑(需人工确认数据位置) |
|
|
|
+| MCP 无鉴权 | 现有 `/mcpsse` 等端点本身无 token | 网关固定节点 + 防火墙限制来源 |
|
|
|
+| 每机授权 | 多台 ai-server 需要各自机器码授权 | 部署前逐台申请/导入授权 |
|
|
|
+| Redis 清空 | 路由缓存和 sa-token 都会丢 | 用户重新登录;路由从 PostgreSQL 恢复 |
|
|
|
+| 并发首次登录 | 两个网关同时为同一用户选不同节点 | 数据库唯一索引裁决,后到者读取赢家 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 14. 一句话总结
|
|
|
+
|
|
|
+新增 `ai-gateway`(Spring Cloud Gateway WebFlux),用“首次登录成功绑定 + `x-token`→userId→node 的 Redis/PG 两级路由”把每个用户固定到一台 ai-server;配置由 YAML 播种、PostgreSQL 持久化、管理接口热更新;节点故障明确返回 503,不做破坏本地数据一致性的自动切换。
|