Gateway Token 一致性与登录绑定.md 26 KB

Gateway Token 一致性与登录绑定

本文引用的文件

  • LoginRequestFilter.java
  • StickyRouteFilter.java
  • LoginResponseFilter.java
  • TokenResolver.java
  • UserRouteService.java
  • UserRouteRepository.java
  • NodeSelector.java
  • GatewayAttributes.java
  • StickyUnavailableException.java
  • GatewayErrorHandler.java
  • gateway.sql

目录

  1. 引言
  2. 项目结构与入口
  3. 核心组件与职责
  4. 系统架构总览
  5. 关键流程详解
  6. 依赖关系分析
  7. 性能与一致性特征
  8. 常见失败场景与排查
  9. 结论

引言

本文聚焦 ai-gateway 中三个网关过滤器与相关支撑组件,解释以下问题:

  • 客户端、Gateway、ai-server 之间 token 为什么必须保持一致;
  • LoginRequestFilter 如何从登录请求中提取 username;
  • StickyRouteFilter 如何根据 userId 或用户名选择 ai-server 节点并重写目标 URL;
  • LoginResponseFilter 如何在登录成功后把用户与服务节点绑定写入数据库;
  • 当用户绑定的节点 DOWN 时,为什么返回 503 而不是自动切换到其他节点;
  • 登录流程时序图、常见失败场景及排查步骤。

本说明面向既需要理解代码实现,又希望把握整体设计意图的读者。

项目结构与入口

Gateway 层围绕 Spring Cloud Gateway 的全局过滤器机制构建。本次文档涉及的核心 Java 类位于 com.zsjz.gateway.route 包和 com.zsjz.gateway.support 包,数据模型定义在 SQL 脚本中。

graph TB
    Client["客户端"] --> GW["Spring Cloud Gateway"]
    GW --> LRF["LoginRequestFilter<br/>提取 username"]
    GW --> SRF["StickyRouteFilter<br/>按 userId/username 选节点"]
    GW --> LRSF["LoginResponseFilter<br/>登录后写绑定与 token 缓存"]
    SRF --> TR["TokenResolver<br/>x-token → userId"]
    SRF --> URS["UserRouteService<br/>Redis + PostgreSQL 路由缓存"]
    URS --> UR["UserRouteRepository<br/>gateway_user_route"]
    SRF --> NS["NodeSelector<br/>健康节点轮询"]
    GW --> ERR["GatewayErrorHandler<br/>统一错误码"]
    GW --> AI["ai-server 节点"]
    TR --> Redis["Redis"]
    URS --> Redis

图表来源

  • LoginRequestFilter.java:1-120
  • StickyRouteFilter.java:1-180
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • UserRouteService.java:1-117
  • UserRouteRepository.java:1-227
  • NodeSelector.java:1-45
  • GatewayErrorHandler.java:1-98

章节来源

  • LoginRequestFilter.java:1-120
  • StickyRouteFilter.java:1-180
  • LoginResponseFilter.java:1-176

核心组件与职责

组件 主要职责 关键行为
LoginRequestFilter 识别并预处理登录请求 解析请求体中的 username;改写 Accept-Encoding;转发原请求体
StickyRouteFilter 粘性路由决策与目标 URL 重写 登录请求用 username 查绑定或轮询候选;普通请求用 x-token 查 userId;校验节点可用性并设置 GATEWAY_REQUEST_URL_ATTR
LoginResponseFilter 登录响应后处理 解析成功登录响应;调用 UserRouteService 绑定用户到节点;回填 token 到 Redis
TokenResolver x-token 到 userId 的解析与缓存 优先读 gateway:token 键;未命中则调用 sa-token;登录成功后由响应过滤器回填
UserRouteService 用户路由两级缓存 Redis 热路径缓存;PostgreSQL gateway_user_route 为事实来源;提供 bindIfAbsent
UserRouteRepository 用户路由数据库访问 查询绑定;幂等插入;并发冲突由部分唯一索引裁决
NodeSelector 匿名或新用户健康节点轮询 仅对 enabled 且 isUp 的节点轮询;已绑定用户不改变其节点
StickyUnavailableException 粘性不可用异常 封装 HTTP 503 语义
GatewayErrorHandler 统一错误处理器 将 StickyUnavailableException 转为 503 JSON,连接失败转 502,其余转 500

章节来源

  • GatewayAttributes.java:1-31
  • StickyUnavailableException.java:1-25
  • GatewayErrorHandler.java:1-98

系统架构总览

Gateway 不是业务逻辑中心,而是认证上下文、路由粘性与后端可达性的协调者。真实登录校验、token 签发由 ai-server 完成;Gateway 只负责“把请求送到正确的节点”,并在登录成功后维护“用户→节点”的映射。

sequenceDiagram
    participant C as "客户端"
    participant G as "Gateway"
    participant A as "ai-server 节点"
    participant DB as "PostgreSQL"
    participant R as "Redis"

    Note over C,A: 首次登录
    C->>G: POST /服务前缀/登录路径<br/>请求体含 username
    G->>G: LoginRequestFilter 解析 username
    G->>G: StickyRouteFilter 决定节点
    alt 已有绑定
        G->>DB: 查询 user_id/username 对应 node_id
    else 新用户
        G->>G: NodeSelector 轮询健康节点
    end
    G->>A: 转发登录请求
    A-->>G: 登录成功 Result{code=200,data:{token,userId,username}}
    G->>G: LoginResponseFilter 解析响应
    G->>DB: bindIfAbsent(serviceId,userId,username,nodeId)
    G->>R: cacheToken(serviceId,token,userId)
    G-->>C: 原样透传登录响应

图表来源

  • LoginRequestFilter.java:1-120
  • StickyRouteFilter.java:1-180
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • UserRouteService.java:1-117
  • UserRouteRepository.java:1-227
  • NodeSelector.java:1-45

关键流程详解

LoginRequestFilter:从登录请求中提取 username

LoginRequestFilter 是一个优先级较高的全局过滤器。它只对“已注册服务的完整登录路径”生效,并且只处理 HTTP POST 请求。

处理要点:

  1. 通过 GatewayRegistry 查找当前请求是否命中某个服务;
  2. 如果是该服务的 loginFullPath,则读取整个请求体;
  3. 使用 Jackson 解析 JSON,取出 username 字段;
  4. 将 LOGIN_REQUEST 和 LOGIN_USERNAME 写入 ServerWebExchange 属性;
  5. 把 Accept-Encoding 改为 identity,避免后端 gzip 压缩导致响应难以解析;
  6. 重建请求并继续转发,不修改业务内容。

如果请求体为空、超过 64KB、非 JSON 或没有 username,它会记录日志并按匿名请求继续处理,由后端自行判断登录结果。

章节来源

  • LoginRequestFilter.java:1-120
  • GatewayAttributes.java:1-31

StickyRouteFilter:按 userId 选择 ai-server 节点并重写目标 URL

StickyRouteFilter 是粘性路由的核心,优先级为 10001,在 Spring Cloud Gateway 内部 RouteToRequestUrlFilter(10000)之后执行。它会根据“是否是登录请求”走两条分支。

登录请求分支

  • 先从 Exchange 属性中取 LOGIN_USERNAME;
  • 通过 UserRouteService 查询 serviceId + username 对应的 nodeId;
  • 若已绑定,直接使用该节点;
  • 若未绑定,则由 NodeSelector 从健康节点中轮询一个候选节点;
  • 注意:此时不会立即写库,绑定推迟到 LoginResponseFilter。

普通请求分支

  • 从请求头读取 x-token;
  • 若为空,按匿名请求轮询健康节点;
  • 若存在,通过 TokenResolver 解析出 userId;
  • 再用 UserRouteService 查询 userId 对应的 nodeId;
  • 若没找到绑定,且服务配置了 defaultNodeId,则尝试 bindIfAbsent 兜底绑定;
  • 若仍无绑定,抛出 StickyUnavailableException,最终返回 503。

节点校验与 URL 重写

无论登录还是普通请求,StickyRouteFilter 都会:

  1. 检查选出的 nodeId 是否存在于 ServiceInfo 且启用;
  2. 检查 healthRegistry.isUp(nodeId);
  3. 把 SERVICE_ID、TARGET_NODE_ID 写入 Exchange 属性;
  4. 计算 target URI = baseUrl + 原始 rawPath + 原始 rawQuery;
  5. 设置 GATEWAY_REQUEST_URL_ATTR,使后续 NettyRoutingFilter 流式转发;
  6. 在响应头添加 X-Gateway-Node 便于排障。

    flowchart TD
    Start(["进入 StickyRouteFilter"]) --> HasRoute{"是否命中路由"}
    HasRoute -->|否| Next["跳过粘性逻辑"]
    HasRoute -->|是| IsLogin{"是否登录请求"}
    IsLogin -->|是| GetUser["取 LOGIN_USERNAME"]
    GetUser --> BoundCheck{"是否有绑定节点"}
    BoundCheck -->|有| UseBound["使用绑定节点"]
    BoundCheck -->|无| PickCandidate["NodeSelector 轮询健康节点"]
    IsLogin -->|否| GetToken["读取 x-token"]
    GetToken --> TokenValid{"token 可解析?"}
    TokenValid -->|否| Anonymous["匿名轮询"]
    TokenValid -->|是| ResolveUserId["TokenResolver 解析 userId"]
    ResolveUserId --> LookupBind{"是否绑定节点"}
    LookupBind -->|是| UseBound
    LookupBind -->|否| DefaultNode{"是否配置默认节点"}
    DefaultNode -->|是| BindDefault["bindIfAbsent 到默认节点"]
    DefaultNode -->|否| Error503["抛出粘性不可用异常"]
    UseBound --> ValidateNode["校验节点启用与健康状态"]
    PickCandidate --> ValidateNode
    Anonymous --> ValidateNode
    BindDefault --> ValidateNode
    ValidateNode --> Rewrite["重写 GATEWAY_REQUEST_URL_ATTR"]
    Rewrite --> End(["交给下游过滤器转发"])
    Error503 --> End
    

图表来源

  • StickyRouteFilter.java:1-180
  • NodeSelector.java:1-45
  • TokenResolver.java:1-100
  • UserRouteService.java:1-117

章节来源

  • StickyRouteFilter.java:1-180

LoginResponseFilter:登录成功后绑定用户与服务节点

LoginResponseFilter 的优先级为 NettyWriteResponseFilter - 1,保证在响应写出之前捕获响应体。

处理流程:

  1. 再次判断当前请求是否命中某个服务的 loginFullPath;
  2. 装饰响应,收集最多 256KB 的响应体;
  3. 等待链式过滤完成后,解析登录成功结构:code=200,data 中存在 token、userId、username;
  4. 若满足条件,调用 UserRouteService.bindIfAbsent(serviceId, userId, username, chosenNodeId);
  5. 同时调用 TokenResolver.cacheToken(serviceId, token, userId),写入 Redis;
  6. 无论绑定成功与否,都不修改原始登录响应。

这里的关键是 chosenNodeId 来自 StickyRouteFilter 在请求阶段设置的 LOGIN_CHOSEN_NODE_ID。因此,新用户第一次登录会绑定到当时轮询到的健康节点;并发首次登录时,数据库部分唯一索引保证只有一个绑定生效。

章节来源

  • LoginResponseFilter.java:1-176
  • GatewayAttributes.java:1-31

用户路由与 token 解析的数据模型

数据库表结构定义了 Gateway 的用户粘性路由基础:

  • gateway_service:服务定义,包含 path_prefix、login_path、health_path、default_node_id;
  • gateway_node:ai-server 节点,base_url 不含路径;
  • gateway_user_route:用户到节点的绑定,支持按 user_id 或 username 唯一。

Redis 提供两级缓存:

  • gateway:route:user:{serviceId}:{userId} → nodeId;
  • gateway:route:name:{serviceId}:{lower(username)} → nodeId;
  • gateway:token:{serviceId}:{token} → userId,TTL 24 小时。

    erDiagram
    GATEWAY_SERVICE {
    varchar id PK
    varchar name
    varchar path_prefix UK
    varchar login_path
    varchar health_path
    varchar default_node_id
    smallint enabled
    timestamp create_time
    timestamp update_time
    }
    
    GATEWAY_NODE {
    varchar id PK
    varchar service_id FK
    varchar name
    varchar base_url
    smallint enabled
    varchar remark
    timestamp create_time
    timestamp update_time
    }
    
    GATEWAY_USER_ROUTE {
    bigint id PK
    varchar service_id FK
    bigint user_id
    varchar username
    varchar node_id FK
    timestamp create_time
    timestamp update_time
    }
    
    GATEWAY_SERVICE ||--o{ GATEWAY_NODE : "包含"
    GATEWAY_SERVICE ||--o{ GATEWAY_USER_ROUTE : "拥有绑定"
    GATEWAY_NODE ||--o{ GATEWAY_USER_ROUTE : "被绑定"
    

图表来源

  • gateway.sql:1-107
  • UserRouteService.java:1-117
  • TokenResolver.java:1-100

章节来源

  • UserRouteRepository.java:1-227
  • UserRouteService.java:1-117
  • gateway.sql:1-107

依赖关系分析

classDiagram
    class LoginRequestFilter {
        +filter(exchange, chain) Mono<Void>
        +parseUsername(body) String
        -resolveService(exchange) ServiceInfo
    }

    class StickyRouteFilter {
        +filter(exchange, chain) Mono<Void>
        -decideForLogin(exchange, service) Mono<String>
        -decideForNormal(exchange, service) Mono<String>
        -route(exchange, chain, service, nodeId) Mono<Void>
        +buildTargetUri(baseUrl, requestUri) URI
    }

    class LoginResponseFilter {
        +filter(exchange, chain) Mono<Void>
        -parseAndBind(serviceId, chosenNodeId, body) Mono<Void>
        +parseLogin(body) LoginSuccess
    }

    class TokenResolver {
        +resolveUserId(serviceId, token) Mono<Long>
        +cacheToken(serviceId, token, userId) Mono<Boolean>
        -resolveFromSaToken(token) Mono<Long>
    }

    class UserRouteService {
        +findNodeIdByUserId(serviceId, userId) Mono<String>
        +findNodeIdByUsername(serviceId, username) Mono<String>
        +bindIfAbsent(serviceId, userId, username, nodeId) Mono<String>
        +evictUserCache(serviceId, userId, username) Mono<Void>
    }

    class UserRouteRepository {
        +findNodeIdByUserId(...)
        +findNodeIdByUsername(...)
        +bindIfAbsent(...)
        +deleteRoutesForUser(...)
    }

    class NodeSelector {
        +select(service) NodeInfo
    }

    class StickyUnavailableException {
        +httpStatus int
        +serviceUnavailable(message) StickyUnavailableException
    }

    class GatewayErrorHandler {
        +handle(exchange, error) Mono<Void>
    }

    LoginRequestFilter --> StickyRouteFilter : "传递 LOGIN_USERNAME"
    StickyRouteFilter --> TokenResolver : "x-token → userId"
    StickyRouteFilter --> UserRouteService : "查询/绑定节点"
    StickyRouteFilter --> NodeSelector : "健康节点轮询"
    LoginResponseFilter --> UserRouteService : "bindIfAbsent"
    LoginResponseFilter --> TokenResolver : "cacheToken"
    UserRouteService --> UserRouteRepository : "JDBC 访问"
    StickyRouteFilter --> StickyUnavailableException : "不可用时抛出"
    StickyUnavailableException --> GatewayErrorHandler : "统一转 503"

图表来源

  • LoginRequestFilter.java:1-120
  • StickyRouteFilter.java:1-180
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • UserRouteService.java:1-117
  • UserRouteRepository.java:1-227
  • NodeSelector.java:1-45
  • StickyUnavailableException.java:1-25
  • GatewayErrorHandler.java:1-98

章节来源

  • StickyRouteFilter.java:1-180
  • UserRouteService.java:1-117

性能与一致性特征

性能特性

  • LoginRequestFilter 最多缓存 64KB 登录请求体,避免重复读取;
  • LoginResponseFilter 最多缓存 256KB 登录响应体,超时或大响应会降级跳过绑定解析;
  • UserRouteService 使用 Redis 作为热路径缓存,Redis 失败回源 PostgreSQL;
  • TokenResolver 使用 Redis 缓存 token 到 userId 的映射,TTL 24 小时;
  • 数据库读写放在 boundedElastic 调度器上,避免阻塞响应线程;
  • 绑定操作使用 ON CONFLICT DO NOTHING 配合部分唯一索引,并发安全但只保证“首次写入者胜出”。

一致性约束

  • token 一致性要求:客户端保存的 token、Gateway 缓存的 token、ai-server 中 sa-token 存储的 token 必须指向同一登录会话;
  • 如果 Gateway 拿到的 token 无法解析出 userId,会被当作匿名请求,由 ai-server 返回 401 引导重新登录;
  • 用户路由以 PostgreSQL gateway_user_route 为事实来源,Redis 只是加速层;
  • 节点绑定一旦写入,不会因为另一个节点可用就自动切换。

章节来源

  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • UserRouteService.java:1-117
  • UserRouteRepository.java:1-227

常见失败场景与排查

未绑定节点

现象:

  • 普通请求携带有效 token,但数据库中不存在该 userId 的绑定;
  • 如果服务未配置 defaultNodeId,Gateway 返回 503;
  • 如果配置了 defaultNodeId,Gateway 会尝试 bindIfAbsent 并绑定到默认节点。

原因:

  • 存量用户从未登录过新 Gateway;
  • 管理端解绑后未重新登录;
  • 历史绑定被清理。

排查步骤:

  1. 查看响应头 X-Gateway-Node 是否为空或异常;
  2. 查询 gateway_user_route 是否缺少该 service_id 与 user_id 的记录;
  3. 检查 gateway_service.default_node_id 是否配置;
  4. 若需迁移存量用户,可使用导入逻辑将 sys_user 中未绑定用户批量绑定到指定节点。

章节来源

  • StickyRouteFilter.java:1-180
  • UserRouteRepository.java:1-227
  • gateway.sql:1-107

token 过期、被顶下线或伪造

现象:

  • 普通请求返回类似 401 的错误;
  • 客户端应引导重新登录;
  • Gateway 本身不会自动刷新 token。

原因:

  • TokenResolver 无法从 Redis 或 sa-token 解析出 userId;
  • Gateway 把这种情况视为匿名请求,透传给 ai-server;
  • ai-server 根据自身认证逻辑返回 401,提示重新登录。

排查步骤:

  1. 检查 Redis 中是否存在 gateway:token:{serviceId}:{token};
  2. 检查 ai-server 中 sa-token 是否仍认为该 token 有效;
  3. 确认客户端是否使用了旧 token;
  4. 登录后确认 LoginResponseFilter 是否成功写入 token 缓存。

章节来源

  • TokenResolver.java:1-100
  • LoginResponseFilter.java:1-176

节点不可用或 DOWN

现象:

  • 用户绑定的节点变为不可用;
  • Gateway 返回 503;
  • 不会自动切换到其他健康节点。

原因:

  • StickyRouteFilter 明确校验 healthRegistry.isUp(nodeId);
  • StickyUnavailableException.serviceUnavailable 固定返回 503;
  • 设计目标是保护本地磁盘上的案件数据、附件和 Agent 记忆;
  • 自动切换可能导致请求落到没有该用户数据的节点。

排查步骤:

  1. 查看 Gateway 日志中是否出现“用户绑定的后端节点不可用”;
  2. 检查 NodeHealthRegistry 中该节点状态;
  3. 检查 gateway_node 中该节点是否启用;
  4. 恢复节点后再重试;如需强制迁移用户,使用管理接口重绑。

章节来源

  • StickyRouteFilter.java:1-180
  • StickyUnavailableException.java:1-25
  • GatewayErrorHandler.java:1-98

登录请求体无法解析

现象:

  • 登录仍可继续,但不会按 username 做粘性绑定;
  • 下次可解析的登录再补绑定。

原因:

  • 请求体为空、超过 64KB、非 JSON 或缺少 username;
  • LoginRequestFilter 故意不阻断请求,退化为匿名轮询。

排查步骤:

  1. 检查前端发送的登录请求体是否为 JSON;
  2. 检查是否包含 username 字段;
  3. 检查请求体大小是否超过 64KB;
  4. 查看 Gateway 日志中“登录请求 username 解析失败”的提示。

章节来源

  • LoginRequestFilter.java:1-120

登录响应过大或绑定失败

现象:

  • 登录仍然成功;
  • 但用户可能没有被绑定到预期节点;
  • 下次登录时会补绑定。

原因:

  • LoginResponseFilter 最大只解析 256KB 响应体;
  • 非成功登录结构不会触发绑定;
  • 绑定或 token 缓存失败只记日志,不影响登录。

排查步骤:

  1. 检查登录响应是否大于 256KB;
  2. 检查响应结构是否包含 code=200、data.token、data.userId、data.username;
  3. 查看 Gateway 日志中“登录响应超过 256KB,跳过绑定解析”;
  4. 确认 Redis 中是否写入 token 缓存。

章节来源

  • LoginResponseFilter.java:1-176

结论

Gateway 的粘性路由设计围绕三个关键点展开:

  1. token 一致性:客户端、Gateway、ai-server 必须使用同一个 token;否则 Gateway 无法将其映射到 userId,只能按匿名请求处理。
  2. 用户粘性绑定:首次登录成功后,Gateway 把 userId 或 username 与选定节点绑定到 PostgreSQL,并通过 Redis 加速后续请求的路由。
  3. 节点不可用不自动切换:因为案件数据、附件和 Agent 记忆可能存储在节点本地磁盘,自动切换会造成数据不一致;因此 Gateway 显式返回 503,要求运维修复节点或手动迁移绑定。

这套机制在性能、一致性和可排障性之间做了平衡:Redis 减少数据库压力,PostgreSQL 提供最终一致性,StickyRouteFilter 保证请求始终落到有用户数据的节点,GatewayErrorHandler 提供清晰的错误语义。