状态:方案设计稿(未实施) 目标:在多台 ai-server 部署环境下,把同一用户的请求固定转发到该用户所属的机器上。 本文只描述方案,不包含代码实施。
ai-server + Electron/Vue 前端,没有独立网关、Spring Cloud Gateway、Nginx 配置或服务注册中心。8980,context-path /js/a;前端所有请求最终落到 /js/a/...。POST /user/create 创建,只写入共享 PostgreSQL 的 sys_user,不记录所属机器。x-token,登录态和 Token-Session 都存 Redis。多台后端只要连同一个 Redis,就能识别同一个 token。/case/open 还会把案件数据源注册到当前 JVM 内存。不能做普通的无状态负载均衡。请求落到非用户所属机器时,即使 token 和 caseId 都能从 Redis 恢复,本机也没有对应的案件数据源,会直接报“当前案件数据源未打开或已释放”。
因此必须:
| 项 | 决策 |
|---|---|
| 工程形态 | 当前仓库新增 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 基地址指向网关 |
当前仓库没有注册接口,所以“注册在哪台机器”落地为“首次登录成功在哪台机器”。如果以后新增注册接口,只需在注册成功后调用同一个绑定服务。
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。
8080)。/js/a/** → zsjz-ai)。x-token 解析出目标节点。X-Gateway-Node 便于排查。登录请求没有 token,只能靠请求体里的 username 定位用户。
LoginRequestFilter 只对配置的登录路径(/js/a/auth/login)生效:
username;Accept-Encoding 改为 identity,避免后端压缩导致无法解析响应;StickyRouteFilter 决定目标节点:
username → node 映射:走该节点;节点 DOWN 直接 503。sys_user 得到 userId,再查 userId → node;有则使用并回填 username 缓存。LoginResponseFilter 缓存 ≤256KB 登录响应,解析:
{
"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。登录失败(密码错等)不产生绑定,避免无效映射。
x-token 读 token;先查 gateway:token:* 得到 userId。StpUtil.getLoginIdByToken(共享 Redis)解析,并回填 token 缓存;兼容网关上线前已签发的 token。userId → node(Redis 缓存,未命中查 gateway_user_route 并回填)。default-node-id:绑定到该节点(用于存量用户迁移);GATEWAY_REQUEST_URL_ATTR 重写为 节点 origin + 原始 rawPath + rawQuery,由 Netty 流式转发;响应头加 X-Gateway-Node 便于排查。/sys/health、/sys/map/** 等)在健康节点间轮询。/mcpsse、/mcpstreamable、/mcp/message)无 token 且会话有状态,固定转发到服务配置的 mcp-node-id;未配置时走首个健康节点。本期不做 MCP 会话级粘性。code=401,网关不重复鉴权。bindIfAbsent 的语义:
(serviceId, userId) 和 (serviceId, lower(username)) 查已有路由;有则直接返回已有节点,不覆盖。user_id、username、node_id。ON CONFLICT DO NOTHING,再按 userId、username 重新查询,读取并发下的赢家。user、name 两个缓存键。数据库唯一索引是最终裁决,保证同一用户/用户名只存在一条路由。
| 键 | 值 | TTL | 用途 |
|---|---|---|---|
gateway:token:{serviceId}:{token} |
userId | 24h | 避免每个请求都调 sa-token |
gateway:route:user:{serviceId}:{userId} |
nodeId | 无 | 普通请求热路径 |
gateway:route:name:{serviceId}:{lower(username)} |
nodeId | 无 | 登录请求热路径 |
GET {base-url}{health-path},2 秒超时。/sys/health 目前只证明进程存活,不检查 PG/Redis/ES;节点级探活够用,深度健康可作为后续增强。ai-gateway/src/main/resources/application.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
gateway_service 为空,用 YAML 播种;之后 YAML 不再覆盖数据库。token-name、同一 DB index),否则 token 无法解析。gateway.admin.ip-whitelist 配置为运维网段。新增 sql/gateway.sql(CREATE TABLE IF NOT EXISTS,可重复执行),Maven 把它复制到 classpath 供 spring.sql.init 自动建表。
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 | 审计 |
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) 唯一。
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;sys_user 的 id/username/is_admin/status,不修改后端表。sys_user,支持同一用户在不同服务绑定不同节点。pom.xml 增加 <module>ai-gateway</module>。ai-gateway/pom.xml:
com.zsjz:zsjz-ai:1.0,JDK 25;spring-cloud-starter-gateway-server-webfluxspring-boot-starter-webfluxspring-boot-starter-jdbcspring-boot-starter-data-redis-reactivesa-token-reactor-spring-boot3-starter:1.46.0sa-token-redis-jackson:1.46.0spring-boot-starter-validation../sql/gateway.sql 打进 classpath:db/gateway.sql,避免 DDL 两份维护。GatewayRouteDefinitionRepository 实现 RouteDefinitionRepository:
Path=/js/a/**;http://gateway.invalid;serviceId;GatewayRegistry 从数据库加载服务/节点快照。RefreshRoutesEvent,清理路由缓存。StickyRouteFilter(order 10001,排在 RouteToRequestUrlFilter 之后)负责把占位 URI 改写成实际节点地址。| 组件 | 职责 |
|---|---|
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,日志按请求串联 |
spring.cloud.gateway.httpclient.connect-timeout=3s,response-timeout=0s(SSE 不设总超时),关闭客户端压缩。Range、Content-Type、x-token、Cookie 等端到端头;SCG 自动剔除 hop-by-hop 头。{code:503,message:"用户绑定的后端节点不可用:node-2,请稍后重试或联系管理员"}。max(update_time) + count,有变化则重载服务/节点并刷新路由。/gateway/admin/**)所有接口要求管理员登录态(x-token),可选 IP 白名单。
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 强制刷新配置
^[a-z][a-z0-9-]{1,63}$。path-prefix 必须以 / 开头、不能以 / 结尾、不能是 /gateway,且全局唯一。base-url 必须是 http(s)://host[:port],不含 path/query/fragment。POST /gateway/admin/routes/import-existing 的核心 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;
用于网关首次上线时把现有用户全部归到当前唯一后端,避免存量用户被分配错机器。
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
QingJian/ 数据目录。node-1 指向现有后端,default-node-id=node-1。POST /gateway/admin/routes/import-existing {serviceId:"zsjz-ai",nodeId:"node-1"},把现有 sys_user 全部绑定到 node-1;未导入的用户也会走 default-node 兜底。VITE_GLOB_API_URL(或打包后的 _app.config.js)改为 http://<gateway>:8080;开发环境把 VITE_PROXY 指向网关。后端不需要改代码;可选加一行 server.forward-headers-strategy: framework,让后端正确识别 X-Forwarded-*。
bindIfAbsent 唯一性、登录失败不绑定。使用 Testcontainers PostgreSQL + Redis + WireMock 两个后端:
X-Gateway-Node 始终一致。/js/a/** 的所有请求自动转发,无需逐接口配置。| 风险 | 说明 | 应对 |
|---|---|---|
网关只读耦合 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 恢复 |
| 并发首次登录 | 两个网关同时为同一用户选不同节点 | 数据库唯一索引裁决,后到者读取赢家 |
新增 ai-gateway(Spring Cloud Gateway WebFlux),用“首次登录成功绑定 + x-token→userId→node 的 Redis/PG 两级路由”把每个用户固定到一台 ai-server;配置由 YAML 播种、PostgreSQL 持久化、管理接口热更新;节点故障明确返回 503,不做破坏本地数据一致性的自动切换。