Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权

文章目录

  • [Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权](#Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权)
    • 1、本章目标
    • 2、为什么需要统一鉴权
      • [2.1 当前项目缺少什么能力](#2.1 当前项目缺少什么能力)
      • [2.2 认证与鉴权有什么区别](#2.2 认证与鉴权有什么区别)
      • [2.3 为什么放在 Gateway 统一处理](#2.3 为什么放在 Gateway 统一处理)
    • 3、JWT
      • [3.1 JWT 是什么](#3.1 JWT 是什么)
      • [3.2 JWT 和 Session 的区别](#3.2 JWT 和 Session 的区别)
    • 4、本章整体项目结构
    • [5、第一阶段:cloud-auth 登录认证与 JWT 签发](#5、第一阶段:cloud-auth 登录认证与 JWT 签发)
      • [5.1 为什么单独创建 t_auth_account](#5.1 为什么单独创建 t_auth_account)
      • [5.2 创建认证账号表](#5.2 创建认证账号表)
      • [5.3 BCrypt 是什么](#5.3 BCrypt 是什么)
      • [5.4 生成 BCrypt 哈希](#5.4 生成 BCrypt 哈希)
      • [5.5 父工程管理 JJWT 版本](#5.5 父工程管理 JJWT 版本)
      • [5.6 cloud-auth/pom.xml](#5.6 cloud-auth/pom.xml)
      • [5.7 cloud-auth/bootstrap.yml](#5.7 cloud-auth/bootstrap.yml)
      • [5.8 cloud-auth-dev.yaml](#5.8 cloud-auth-dev.yaml)
      • [5.9 cloud-auth 关键代码](#5.9 cloud-auth 关键代码)
        • [5.9.1 启动类: CloudAuthApplication](#5.9.1 启动类: CloudAuthApplication)
        • [5.9.2 配置密码哈希器:PasswordConfig](#5.9.2 配置密码哈希器:PasswordConfig)
        • [5.9.3 AuthAccount.java](#5.9.3 AuthAccount.java)
        • [5.9.4 AuthAccountMapper.java](#5.9.4 AuthAccountMapper.java)
        • [5.9.5 LoginRequest.java](#5.9.5 LoginRequest.java)
        • [5.9.6 LoginResponse.java](#5.9.6 LoginResponse.java)
        • [5.9.7 Auth JwtProperties.java](#5.9.7 Auth JwtProperties.java)
        • [5.9.8 Auth JwtUtil.java](#5.9.8 Auth JwtUtil.java)
        • [5.9.9 AuthService.java](#5.9.9 AuthService.java)
        • [5.9.10 AuthServiceImpl.java](#5.9.10 AuthServiceImpl.java)
        • [5.9.11 AuthController.java](#5.9.11 AuthController.java)
      • [5.10 Gateway 增加 auth 路由](#5.10 Gateway 增加 auth 路由)
      • [5.11 创建 auth.http](#5.11 创建 auth.http)
      • [5.12 启动顺序](#5.12 启动顺序)
      • [5.13 本轮预期结果](#5.13 本轮预期结果)
        • [5.13.1 正确登录](#5.13.1 正确登录)
        • [5.13.2 密码错误](#5.13.2 密码错误)
        • [5.13.3 用户名不存在](#5.13.3 用户名不存在)
      • [5.14 本轮需要理解的链路](#5.14 本轮需要理解的链路)
    • [6、第二阶段:Gateway GlobalFilter 统一校验 JWT](#6、第二阶段:Gateway GlobalFilter 统一校验 JWT)
      • [6.1 GlobalFilter 是什么](#6.1 GlobalFilter 是什么)
      • [6.2 Gateway 为什么不直接依赖 cloud-auth](#6.2 Gateway 为什么不直接依赖 cloud-auth)
      • [6.3 cloud-gateway 增加依赖](#6.3 cloud-gateway 增加依赖)
      • [6.4 修改 cloud-gateway-dev.yaml](#6.4 修改 cloud-gateway-dev.yaml)
      • [6.5 登录接口为什么必须在白名单中](#6.5 登录接口为什么必须在白名单中)
      • [6.6 OPTIONS 为什么不能校验 Token](#6.6 OPTIONS 为什么不能校验 Token)
      • [6.7 Gateway JwtProperties.java](#6.7 Gateway JwtProperties.java)
      • [6.8 GatewayAuthProperties.java](#6.8 GatewayAuthProperties.java)
      • [6.9 Gateway JwtUtil.java](#6.9 Gateway JwtUtil.java)
      • [6.10 JwtAuthGlobalFilter.java](#6.10 JwtAuthGlobalFilter.java)
      • [6.11 为什么必须覆盖 X-User-Id](#6.11 为什么必须覆盖 X-User-Id)
      • [6.12 为什么 Gateway 中不直接返回 Result<T>](#6.12 为什么 Gateway 中不直接返回 Result)
      • [6.13 `getOrder() = -100` 有什么作用?](#6.13 getOrder() = -100 有什么作用?)
      • [6.14 重启 Gateway](#6.14 重启 Gateway)
      • [6.15 更新 gateway.http](#6.15 更新 gateway.http)
      • [6.16 本轮预期结果](#6.16 本轮预期结果)
        • [6.16.1 登录接口无需 Token](#6.16.1 登录接口无需 Token)
        • [6.16.2 业务接口缺少 Token](#6.16.2 业务接口缺少 Token)
        • [6.16.3 携带合法 Token](#6.16.3 携带合法 Token)
        • [6.16.4 篡改 Token](#6.16.4 篡改 Token)
        • [6.16.5 Token 格式错误](#6.16.5 Token 格式错误)
        • [6.16.6 OPTIONS 预检无需 Token](#6.16.6 OPTIONS 预检无需 Token)
      • [6.17 本轮边界:Gateway 校验不等于彻底安全](#6.17 本轮边界:Gateway 校验不等于彻底安全)
    • [7、第三阶段:下游用户上下文与 Feign 身份透传](#7、第三阶段:下游用户上下文与 Feign 身份透传)
      • [7.1 为什么不能继续从请求体读取 userId](#7.1 为什么不能继续从请求体读取 userId)
      • [7.2 ThreadLocal 是什么](#7.2 ThreadLocal 是什么)
      • [7.3 为什么必须 clear](#7.3 为什么必须 clear)
      • [7.4 保存当前请求用户身份:UserContext.java](#7.4 保存当前请求用户身份:UserContext.java)
      • [7.5 读取 Gateway 透传的用户身份:UserContextInterceptor.java](#7.5 读取 Gateway 透传的用户身份:UserContextInterceptor.java)
      • [7.6 注册用户上下文拦截器: UserContextWebMvcConfig.java](#7.6 注册用户上下文拦截器: UserContextWebMvcConfig.java)
      • [7.7 ContextController.java](#7.7 ContextController.java)
      • [7.8 修改 CreateOrderRequest.java](#7.8 修改 CreateOrderRequest.java)
      • [7.9 修改 OrderServiceImpl.java](#7.9 修改 OrderServiceImpl.java)
      • [7.10 先验证 Gateway 到订单服务的身份链路](#7.10 先验证 Gateway 到订单服务的身份链路)
      • [7.11 修改商品服务日志](#7.11 修改商品服务日志)
      • [7.12 第一次创建订单:观察身份丢失](#7.12 第一次创建订单:观察身份丢失)
      • [7.13 Feign RequestInterceptor 是什么](#7.13 Feign RequestInterceptor 是什么)
      • [7.14 让 Feign 继续透传用户身份:UserContextFeignInterceptor.java](#7.14 让 Feign 继续透传用户身份:UserContextFeignInterceptor.java)
      • [7.12 第二次创建订单:验证 Feign 身份透传](#7.12 第二次创建订单:验证 Feign 身份透传)
    • [8、第四阶段:CircuitBreaker 线程切换导致身份丢失](#8、第四阶段:CircuitBreaker 线程切换导致身份丢失)
      • [8.1 为什么 RequestInterceptor 读取到 null](#8.1 为什么 RequestInterceptor 读取到 null)
      • [8.2 对照实验:关闭 CircuitBreaker](#8.2 对照实验:关闭 CircuitBreaker)
      • [8.3 在 CircuitBreaker 线程池中传播用户上下文:UserContextAwareExecutorService.java](#8.3 在 CircuitBreaker 线程池中传播用户上下文:UserContextAwareExecutorService.java)
      • [8.4 将上下文感知线程池交给 CircuitBreaker:Resilience4jExecutorConfig.java](#8.4 将上下文感知线程池交给 CircuitBreaker:Resilience4jExecutorConfig.java)
      • [8.5 恢复 CircuitBreaker 后验证](#8.5 恢复 CircuitBreaker 后验证)
      • [8.6 直接访问订单服务会发生什么](#8.6 直接访问订单服务会发生什么)
      • [8.7 为什么不用 InheritableThreadLocal](#8.7 为什么不用 InheritableThreadLocal)
    • [9、第五阶段:@Async 异步线程身份丢失与 TaskDecorator 修复](#9、第五阶段:@Async 异步线程身份丢失与 TaskDecorator 修复)
      • [9.1 @Async 是什么](#9.1 @Async 是什么)
      • [9.2 OrderAsyncConfig.java 初始版本](#9.2 OrderAsyncConfig.java 初始版本)
      • [9.3 AsyncUserContextService.java](#9.3 AsyncUserContextService.java)
      • [9.4 为什么 @Async 方法要放在单独 Bean 中](#9.4 为什么 @Async 方法要放在单独 Bean 中)
      • [9.5 第一次测试:异步线程身份丢失](#9.5 第一次测试:异步线程身份丢失)
      • [9.6 TaskDecorator 是什么](#9.6 TaskDecorator 是什么)
      • [9.7 UserContextTaskDecorator.java](#9.7 UserContextTaskDecorator.java)
      • [9.8 OrderAsyncConfig.java 修复版本](#9.8 OrderAsyncConfig.java 修复版本)
      • [9.9 第二次测试:异步线程身份恢复](#9.9 第二次测试:异步线程身份恢复)
    • 10、本章关键概念对比
      • [10.1 JWT 与 Session](#10.1 JWT 与 Session)
      • [10.2 Gateway 鉴权与下游拦截器](#10.2 Gateway 鉴权与下游拦截器)
      • [10.3 Feign RequestInterceptor 与 TaskDecorator](#10.3 Feign RequestInterceptor 与 TaskDecorator)
    • 11、本章结论
    • [12、下一章预告:Sentinel 限流、熔断与降级](#12、下一章预告:Sentinel 限流、熔断与降级)

Spring Cloud 学习与实践(9):Gateway + JWT 统一鉴权

本章目标:实现登录认证、JWT 签发、Gateway 统一鉴权、用户身份透传、下游用户上下文、Feign 身份继续透传,以及 ThreadLocal 在线程切换时丢失的两类真实故障修复。

本章不是直接上完整 Spring Security OAuth2 体系,而是先通过手写 JWT 鉴权链路理解底层原理。掌握这条主线后,再学习 Spring Security、OAuth2 Resource Server 或 Spring Authorization Server 会更容易。


1、本章目标

第 8 章已经完成了 Gateway 统一入口:

text 复制代码
客户端
    ↓
cloud-gateway:9000
    ↓
cloud-user / cloud-product / cloud-order

但是现在所有业务接口只要知道地址就能访问,例如:

http 复制代码
GET http://localhost:9000/api/product/products/1
POST http://localhost:9000/api/order/orders

这显然不符合真实项目要求。

本章要完成的目标是:

text 复制代码
1. cloud-auth 实现登录接口
2. 使用 BCrypt 校验密码哈希
3. 登录成功后签发 JWT
4. Gateway 通过 GlobalFilter 统一校验 JWT
5. 白名单放行登录接口和 OPTIONS 预检请求
6. Gateway 将 JWT 中的 userId 写入 X-User-Id 请求头
7. 下游服务通过 MVC Interceptor 读取 X-User-Id
8. 使用 ThreadLocal 保存当前请求用户身份
9. 订单服务不再信任客户端传入的 userId
10. Feign 调用继续透传 X-User-Id
11. 复现 CircuitBreaker 线程切换导致身份丢失
12. 使用上下文感知 ExecutorService 修复
13. 复现 @Async 异步线程身份丢失
14. 使用 TaskDecorator 修复

本章最终请求链路如下:

text 复制代码
用户登录
    ↓
cloud-auth 校验账号密码
    ↓
签发 JWT
    ↓
客户端携带 Authorization: Bearer <token>
    ↓
Gateway 统一校验 Token
    ↓
Gateway 写入 X-User-Id
    ↓
cloud-order 读取当前用户身份
    ↓
cloud-order 创建订单
    ↓
Feign 调用 cloud-product
    ↓
cloud-product 继续拿到当前用户身份

2、为什么需要统一鉴权

2.1 当前项目缺少什么能力

在没有统一鉴权之前,业务接口存在几个问题。

第一,任何人都能访问业务接口。

text 复制代码
不登录也能查询商品
不登录也能创建订单
不登录也能访问订单接口

第二,订单服务仍然信任客户端传入的 userId

json 复制代码
{
  "userId": 999999,
  "productId": 1,
  "quantity": 1
}

这非常危险。客户端传什么用户 ID,服务端就用什么用户 ID,相当于把身份决定权交给了客户端。

第三,每个服务如果都自己写鉴权逻辑,会导致重复代码。

text 复制代码
cloud-user 校验一次 Token
cloud-product 校验一次 Token
cloud-order 校验一次 Token

这样会带来:

text 复制代码
重复解析 Token
重复处理异常
规则不一致
遗漏接口风险
维护成本高

因此,更合理的做法是:

text 复制代码
外部请求统一经过 Gateway
        ↓
Gateway 统一鉴权
        ↓
下游服务只读取可信用户身份

2.2 认证与鉴权有什么区别

这两个概念经常被混用,但含义不同。

text 复制代码
认证 Authentication:
你是谁?

鉴权 Authorization:
你能做什么?

举例:

text 复制代码
输入用户名和密码登录:
认证

普通用户不能删除商品,管理员可以删除商品:
鉴权

本章主要完成:

text 复制代码
登录认证
Token 校验
用户身份透传

还没有深入做:

text 复制代码
角色权限
菜单权限
按钮权限
接口级权限
方法级权限

这些可以后续在 Spring Security 或权限模型扩展章节中继续学习。


2.3 为什么放在 Gateway 统一处理

Gateway 是外部请求入口。

text 复制代码
客户端
    ↓
Gateway
    ↓
业务服务

因此它天然适合做:

text 复制代码
统一鉴权
统一跨域
统一日志
统一限流
统一请求头处理

在本项目中,职责划分如下:

模块 职责
cloud-auth 负责登录、校验账号密码、签发 JWT
cloud-gateway 负责校验 JWT、白名单、身份透传
cloud-user 只处理用户业务
cloud-product 只处理商品业务
cloud-order 只处理订单业务

一句话总结:

text 复制代码
登录认证由 cloud-auth 做;
统一 Token 校验由 Gateway 做;
业务服务只接收 Gateway 透传后的可信身份。

3、JWT

3.1 JWT 是什么

JWT 全称:

text 复制代码
JSON Web Token

它是一种紧凑的 Token 格式,常用于在客户端和服务端之间传递声明信息。

一个 JWT 通常长这样:

text 复制代码
xxxxx.yyyyy.zzzzz

由三部分组成:

text 复制代码
Header.Payload.Signature
部分 作用
Header 描述 Token 类型和签名算法
Payload 保存声明信息,例如 userId、username、过期时间
Signature 防止 Token 被篡改

本章使用的是签名 JWT,不是加密 JWT。

需要特别注意:

text 复制代码
JWT 的 Payload 通常只是 Base64URL 编码,不是加密。
不要把密码、身份证号、银行卡号等敏感信息放进去。

本项目中,JWT 中只保存:

text 复制代码
sub:用户 ID
username:用户名
iat:签发时间
exp:过期时间

3.2 JWT 和 Session 的区别

传统 Session 登录大致是:

text 复制代码
用户登录
    ↓
服务端创建 Session
    ↓
浏览器保存 Cookie:JSESSIONID
    ↓
后续请求携带 Cookie
    ↓
服务端根据 SessionId 查询登录状态

JWT 登录大致是:

text 复制代码
用户登录
    ↓
服务端生成 JWT
    ↓
客户端保存 Token
    ↓
后续请求携带 Authorization
    ↓
服务端校验 Token 签名和过期时间

简单对比:

对比项 Session JWT
登录状态保存位置 服务端 客户端保存 Token
服务端是否需要存储登录态 通常需要 通常不需要
微服务横向扩展 需要共享 Session 或粘性会话 更容易无状态扩展
主动失效 相对容易 需要黑名单、版本号或短有效期
Token 泄露风险 Cookie 泄露有风险 JWT 泄露同样有风险

JWT 的优势是适合无状态认证,但它不是万能方案。

生产项目仍然需要考虑:

text 复制代码
Token 有效期
Refresh Token
Token 黑名单
密钥轮换
HTTPS
客户端安全存储


4、本章整体项目结构

本章涉及的模块:

text 复制代码
cloud-demo
├── cloud-common
│   └── src/main/java/com/example/cloud/common
│       ├── context
│       │   └── UserContext.java
│       └── web
│           ├── UserContextInterceptor.java
│           └── UserContextWebMvcConfig.java
├── cloud-auth
│   └── src/main/java/com/example/cloud/auth
│       ├── CloudAuthApplication.java
│       ├── config/PasswordConfig.java
│       ├── controller/AuthController.java
│       ├── dto/LoginRequest.java
│       ├── dto/LoginResponse.java
│       ├── entity/AuthAccount.java
│       ├── mapper/AuthAccountMapper.java
│       ├── properties/JwtProperties.java
│       ├── service/AuthService.java
│       ├── service/impl/AuthServiceImpl.java
│       └── util/JwtUtil.java
├── cloud-gateway
│   └── src/main/java/com/example/cloud/gateway/auth
│       ├── filter/JwtAuthGlobalFilter.java
│       ├── properties/GatewayAuthProperties.java
│       ├── properties/JwtProperties.java
│       └── util/JwtUtil.java
├── cloud-order
│   └── src/main/java/com/example/cloud/order
│       ├── client/interceptor/UserContextFeignInterceptor.java
│       ├── config/OrderAsyncConfig.java
│       ├── config/Resilience4jExecutorConfig.java
│       ├── config/UserContextAwareExecutorService.java
│       ├── config/UserContextTaskDecorator.java
│       ├── controller/ContextController.java
│       ├── dto/CreateOrderRequest.java
│       ├── service/AsyncUserContextService.java
│       └── service/impl/OrderServiceImpl.java
└── cloud-product
    └── ProductServiceImpl 中增加用户上下文日志

5、第一阶段:cloud-auth 登录认证与 JWT 签发

5.1 为什么单独创建 t_auth_account

项目中已经有:

text 复制代码
t_user

它表示用户业务信息,例如昵称、手机号、状态等。

本章新增:

text 复制代码
t_auth_account

它专门保存认证相关信息:

text 复制代码
登录用户名
密码哈希
关联 userId
账号状态

这样职责更清晰:

text 复制代码
t_user:
用户业务资料

t_auth_account:
认证凭据

不要把明文密码写入数据库。


5.2 创建认证账号表

cloud_demo 数据库中执行:

sql 复制代码
DROP TABLE IF EXISTS t_auth_account;

CREATE TABLE t_auth_account (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '认证账号ID',
    user_id BIGINT NOT NULL COMMENT '关联用户ID',
    username VARCHAR(64) NOT NULL COMMENT '登录用户名',
    password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt 密码哈希',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_user_id (user_id),
    UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认证账号表';

5.3 BCrypt 是什么

BCrypt 是一种常用的密码哈希算法。

它的特点是:

text 复制代码
不是简单加密
不能反向解密出原始密码
每次生成结果不同
内部包含随机 Salt
可以通过 matches() 验证原始密码是否匹配哈希

本项目使用:

java 复制代码
BCryptPasswordEncoder

用于:

text 复制代码
注册或初始化账号:
明文密码 -> BCrypt 哈希 -> 写入数据库

登录时:
用户输入密码 + 数据库哈希 -> matches() 校验

5.4 生成 BCrypt 哈希

cloud-auth 中临时创建:

text 复制代码
src/test/java/com/example/cloud/auth/PasswordHashGenerator.java

代码:

java 复制代码
package com.example.cloud.auth;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class PasswordHashGenerator {

    public static void main(String[] args) {
        BCryptPasswordEncoder encoder =
                new BCryptPasswordEncoder();

        System.out.println(
                encoder.encode("123456")
        );
    }
}

运行后得到类似:

text 复制代码
$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

然后插入数据库:

sql 复制代码
INSERT INTO t_auth_account (
    user_id,
    username,
    password_hash,
    status
) VALUES (
    1,
    'zhangsan',
    '粘贴刚才生成的 BCrypt 哈希',
    1
);

5.5 父工程管理 JJWT 版本

打开:

text 复制代码
cloud-demo/pom.xml

<properties> 中确认存在:

xml 复制代码
<jjwt.version>0.11.5</jjwt.version>

本章 cloud-authcloud-gateway 都会使用同一个版本。


5.6 cloud-auth/pom.xml

完整配置:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example.cloud</groupId>
        <artifactId>cloud-demo</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>cloud-auth</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>com.example.cloud</groupId>
            <artifactId>cloud-common</artifactId>
            <version>${project.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-crypto</artifactId>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-bootstrap</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

5.7 cloud-auth/bootstrap.yml

位置:

text 复制代码
cloud-auth/src/main/resources/bootstrap.yml

内容:

yaml 复制代码
spring:
  application:
    name: cloud-auth

  profiles:
    active: dev

  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml
        group: DEFAULT_GROUP
        namespace: 你的-dev-Namespace-ID

5.8 cloud-auth-dev.yaml

Nacos 中创建:

text 复制代码
Data ID:cloud-auth-dev.yaml
Group:DEFAULT_GROUP
Namespace:dev

内容:

yaml 复制代码
server:
  port: 9100

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/cloud_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: 你的数据库密码

  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

  global-config:
    db-config:
      id-type: auto

jwt:
  secret: VhxU/uOVwrgavYRbnWfzAYTUDjY8jC/rYOyDkoAFyig=
  expire-minutes: 120

5.9 cloud-auth 关键代码

5.9.1 启动类: CloudAuthApplication
java 复制代码
package com.example.cloud.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@EnableDiscoveryClient
@SpringBootApplication(scanBasePackages = "com.example.cloud")
public class CloudAuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(
                CloudAuthApplication.class,
                args
        );
    }
}
5.9.2 配置密码哈希器:PasswordConfig
java 复制代码
package com.example.cloud.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
public class PasswordConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
5.9.3 AuthAccount.java
java 复制代码
package com.example.cloud.auth.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@TableName("t_auth_account")
public class AuthAccount {

    @TableId(type = IdType.AUTO)
    private Long id;

    private Long userId;

    private String username;

    private String passwordHash;

    private Integer status;

    private LocalDateTime createTime;
}
5.9.4 AuthAccountMapper.java
java 复制代码
package com.example.cloud.auth.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.auth.entity.AuthAccount;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface AuthAccountMapper
        extends BaseMapper<AuthAccount> {
}
5.9.5 LoginRequest.java
java 复制代码
package com.example.cloud.auth.dto;

import lombok.Data;

import javax.validation.constraints.NotBlank;

@Data
public class LoginRequest {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    private String password;
}
5.9.6 LoginResponse.java
java 复制代码
package com.example.cloud.auth.dto;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class LoginResponse {

    private String token;

    private String tokenType;

    private Long expiresIn;

    private Long userId;

    private String username;
}
5.9.7 Auth JwtProperties.java
java 复制代码
package com.example.cloud.auth.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

    private String secret;

    private Long expireMinutes = 120L;
}
5.9.8 Auth JwtUtil.java
java 复制代码
package com.example.cloud.auth.util;

import com.example.cloud.auth.properties.JwtProperties;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
@RequiredArgsConstructor
public class JwtUtil {

    private final JwtProperties jwtProperties;

    public String generateToken(
            Long userId,
            String username
    ) {
        Date issuedAt = new Date();

        Date expiration = new Date(
                issuedAt.getTime()
                        + jwtProperties.getExpireMinutes()
                        * 60
                        * 1000
        );

        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("username", username)
                .setIssuedAt(issuedAt)
                .setExpiration(expiration)
                .signWith(
                        getSigningKey(),
                        SignatureAlgorithm.HS256
                )
                .compact();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(
                jwtProperties.getSecret()
        );

        return Keys.hmacShaKeyFor(keyBytes);
    }
}
5.9.9 AuthService.java
java 复制代码
package com.example.cloud.auth.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.entity.AuthAccount;

public interface AuthService
        extends IService<AuthAccount> {

    LoginResponse login(LoginRequest request);
}
5.9.10 AuthServiceImpl.java
java 复制代码
package com.example.cloud.auth.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.entity.AuthAccount;
import com.example.cloud.auth.mapper.AuthAccountMapper;
import com.example.cloud.auth.properties.JwtProperties;
import com.example.cloud.auth.service.AuthService;
import com.example.cloud.auth.util.JwtUtil;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthServiceImpl
        extends ServiceImpl<AuthAccountMapper, AuthAccount>
        implements AuthService {

    private final BCryptPasswordEncoder passwordEncoder;

    private final JwtUtil jwtUtil;

    private final JwtProperties jwtProperties;

    @Override
    public LoginResponse login(LoginRequest request) {
        AuthAccount account = lambdaQuery()
                .eq(
                        AuthAccount::getUsername,
                        request.getUsername()
                )
                .one();

        if (account == null
                || !passwordEncoder.matches(
                        request.getPassword(),
                        account.getPasswordHash()
                )) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "用户名或密码错误"
            );
        }

        if (account.getStatus() == null
                || account.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "账号已禁用"
            );
        }

        String token = jwtUtil.generateToken(
                account.getUserId(),
                account.getUsername()
        );

        return LoginResponse.builder()
                .token(token)
                .tokenType("Bearer")
                .expiresIn(
                        jwtProperties.getExpireMinutes()
                                * 60
                )
                .userId(account.getUserId())
                .username(account.getUsername())
                .build();
    }
}
5.9.11 AuthController.java
java 复制代码
package com.example.cloud.auth.controller;

import com.example.cloud.auth.dto.LoginRequest;
import com.example.cloud.auth.dto.LoginResponse;
import com.example.cloud.auth.service.AuthService;
import com.example.cloud.common.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Validated
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public Result<LoginResponse> login(
            @Valid @RequestBody LoginRequest request
    ) {
        return Result.success(
                authService.login(request)
        );
    }
}

5.10 Gateway 增加 auth 路由

修改 Nacos 中的:

text 复制代码
cloud-gateway-dev.yaml

routes 中加入:

yaml 复制代码
        - id: auth-route
          uri: lb://cloud-auth
          predicates:
            - Path=/api/auth/**
          filters:
            - StripPrefix=1

外部请求:

text 复制代码
/api/auth/login

经过 StripPrefix=1 后,下游收到:

text 复制代码
/auth/login

正好匹配:

java 复制代码
@RequestMapping("/auth")
@PostMapping("/login")

5.11 创建 auth.http

为了验证 cloud-auth 登录接口和 Gateway 转发是否正常,创建接口测试文件:

text 复制代码
cloud-auth
└── src/test/http
    └── auth.http

内容如下:

http 复制代码
### 直接访问认证服务:登录成功
POST http://localhost:9100/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

### 通过 Gateway 登录成功
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

### 密码错误
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "wrong-password"
}

### 用户名不存在
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "not-exists",
  "password": "123456"
}

5.12 启动顺序

启动顺序如下:

text 复制代码
1. Nacos Server
2. CloudAuthApplication
3. CloudUserApplication
4. CloudProductApplication
5. CloudOrderApplication
6. CloudGatewayApplication

启动完成后,进入 Nacos 服务列表,应能看到新增服务:

text 复制代码
cloud-auth

端口为:

text 复制代码
9100

这说明认证服务已经成功注册到 Nacos,Gateway 后续才能通过:

yaml 复制代码
uri: lb://cloud-auth

按服务名找到认证服务实例。


5.13 本轮预期结果

5.13.1 正确登录

请求:

http 复制代码
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

预期返回类似:

json 复制代码
{
  "code": 0,
  "message": "success",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9...",
    "tokenType": "Bearer",
    "expiresIn": 7200,
    "userId": 1,
    "username": "zhangsan"
  }
}

其中:

text 复制代码
token:
登录成功后签发的 JWT

tokenType:
后续请求头使用 Bearer 类型

expiresIn:
Token 有效秒数

userId:
当前登录用户 ID

username:
当前登录用户名

后续访问业务接口时,客户端需要携带:

http 复制代码
Authorization: Bearer <token>

5.13.2 密码错误

请求:

http 复制代码
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "wrong-password"
}

预期返回:

json 复制代码
{
  "code": 40100,
  "message": "用户名或密码错误",
  "data": null
}

具体 code 以当前项目中 ErrorCode.UNAUTHORIZED 的实际数值为准。


5.13.3 用户名不存在

请求:

http 复制代码
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "not-exists",
  "password": "123456"
}

预期同样返回:

text 复制代码
用户名或密码错误

不要返回:

text 复制代码
用户不存在

原因是:

text 复制代码
如果明确提示"用户不存在",
攻击者就可以通过登录接口枚举哪些账号真实存在。

因此本章统一返回:

text 复制代码
用户名或密码错误

这样不会向外部暴露账号是否存在。


5.14 本轮需要理解的链路

当前阶段完整链路如下:

text 复制代码
客户端提交用户名和密码
        ↓
Gateway 根据 auth-route 转发
        ↓
cloud-auth 查询 t_auth_account
        ↓
BCrypt 校验密码哈希
        ↓
JwtUtil 使用密钥签名
        ↓
生成 JWT
        ↓
返回给客户端

此时需要注意:

text 复制代码
当前 Token 只是签发成功。

下一阶段才会完成:

text 复制代码
客户端携带 Token
        ↓
Gateway GlobalFilter 校验 Token
        ↓
提取 userId
        ↓
写入 X-User-Id
        ↓
转发到业务服务

也就是说,第一阶段只解决:

text 复制代码
登录后如何拿到 Token

还没有解决:

text 复制代码
业务接口如何校验 Token

Gateway 支持全局 Filter。后续 JwtAuthGlobalFilter 会在请求进入下游服务之前执行,用于统一处理 Token 校验、白名单放行和用户身份透传。


6、第二阶段:Gateway GlobalFilter 统一校验 JWT

6.1 GlobalFilter 是什么

第 8 章使用过配置式 Filter:

yaml 复制代码
filters:
  - StripPrefix=2

这种 Filter 属于某条路由。

本章使用:

java 复制代码
GlobalFilter

它是全局过滤器,适合处理:

text 复制代码
统一鉴权
统一日志
链路追踪
请求头加工
全局审计

本章的 JwtAuthGlobalFilter 负责:

text 复制代码
放行 OPTIONS
放行登录白名单
检查 Authorization
校验 JWT
提取 userId
写入 X-User-Id
过滤器类型 作用范围 核心底层机制 经典配置/代码示例 黄金应用场景 默认执行顺序权重
Route Filter (路由过滤器) 某条特定路由 只对绑定的单个服务生效 继承 GatewayFilterFactory 在特定路由的 filters 属性下配置 StripPrefix=2 AddRequestHeader=X-User, Admin 针对单个微服务的路径重写、特定接口的请求头增强。 居中 (同等 Order 下先于 GlobalFilter 执行)
Default Filter (默认过滤器) 所有路由 无差别应用到配置文件里的全部服务 同样继承 GatewayFilterFactory 但在网关的 default-filters 节点下全局配置 DedupeResponseHeader AddResponseHeader=X-Platform, Web 全局跨域响应头去重、全局添加统一响应标识、全局基础限流。 最高 (同等 Order 下最先被加载执行)
GlobalFilter (全局过滤器) 所有路由 通过 Java 代码硬编码,全量拦截 实现 GlobalFilterOrdered 接口 作为 Bean 注入到 Spring 容器 JWT 权限校验 全局接口耗时统计 黑名单统一拦截 统一身份安全认证(Token 校验)、全局日志审计、系统级核心防刷。 最末 (同等 Order 下最后执行,但通常手动指定高优先级)

6.2 Gateway 为什么不直接依赖 cloud-auth

cloud-auth 里已经有 JwtUtil

但是不推荐让:

text 复制代码
cloud-gateway

直接依赖:

text 复制代码
cloud-auth

因为:

text 复制代码
cloud-auth 是认证服务
cloud-gateway 是网关服务

如果 Gateway 依赖认证服务模块,会导致:

text 复制代码
模块边界模糊
依赖传递过重
Gateway 被动引入 MVC、MyBatis-Plus、MySQL 等依赖

当前阶段在 Gateway 中创建轻量级 JWT 解析工具。

后续如果复用需求变多,可以抽取:

text 复制代码
cloud-security-core

6.3 cloud-gateway 增加依赖

打开:

text 复制代码
cloud-gateway/pom.xml

增加:

xml 复制代码
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>${jjwt.version}</version>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>

<!-- Lombok:JwtProperties、GatewayAuthProperties 使用 @Data -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

注意:

text 复制代码
只加 JJWT 不够。
只要新增代码使用 @Data、@Slf4j、@RequiredArgsConstructor,
就要检查当前模块是否已经引入 Lombok。

6.4 修改 cloud-gateway-dev.yaml

在 Nacos 中的:

text 复制代码
cloud-gateway-dev.yaml

增加:

yaml 复制代码
jwt:
  secret: VhxU/uOVwrgavYRbnWfzAYTUDjY8jC/rYOyDkoAFyig=

gateway:
  auth:
    white-paths:
      - /api/auth/login

6.5 登录接口为什么必须在白名单中

如果登录接口也要求 Token:

text 复制代码
登录需要 Token
        ↓
Token 又必须登录后才有

这会造成死循环。

因此:

text 复制代码
/api/auth/login

必须匿名放行。


6.6 OPTIONS 为什么不能校验 Token

浏览器跨域复杂请求会先发送:

http 复制代码
OPTIONS

预检请求通常不会携带业务 Token。

如果 OPTIONS 被 Gateway 返回 401:

text 复制代码
浏览器认为跨域失败
        ↓
正式请求根本不会发送

因此 JwtAuthGlobalFilter 必须放行:

text 复制代码
OPTIONS

6.7 Gateway JwtProperties.java

java 复制代码
package com.example.cloud.gateway.auth.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

    private String secret;
}

6.8 GatewayAuthProperties.java

java 复制代码
package com.example.cloud.gateway.auth.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Data
@Component
@ConfigurationProperties(prefix = "gateway.auth")
public class GatewayAuthProperties {

    private List<String> whitePaths = new ArrayList<>();
}

6.9 Gateway JwtUtil.java

java 复制代码
package com.example.cloud.gateway.auth.util;

import com.example.cloud.gateway.auth.properties.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;

@Component
@RequiredArgsConstructor
public class JwtUtil {

    private final JwtProperties jwtProperties;

    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(
                jwtProperties.getSecret()
        );

        return Keys.hmacShaKeyFor(keyBytes);
    }
}

6.10 JwtAuthGlobalFilter.java

java 复制代码
package com.example.cloud.gateway.auth.filter;

import com.example.cloud.gateway.auth.properties.GatewayAuthProperties;
import com.example.cloud.gateway.auth.util.JwtUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Gateway JWT 全局鉴权 Filter。
 *
 * 职责:
 * 1. 放行 OPTIONS 预检请求
 * 2. 放行白名单接口
 * 3. 读取 Authorization: Bearer <token>
 * 4. 校验 JWT
 * 5. 从 JWT 中提取 userId
 * 6. 覆盖写入可信请求头 X-User-Id
 * 7. 将请求继续转发给下游服务
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthGlobalFilter
        implements GlobalFilter, Ordered {

    private static final String BEARER_PREFIX = "Bearer ";

    /**
     * Gateway 写给下游服务的可信用户身份请求头。
     */
    public static final String USER_ID_HEADER = "X-User-Id";

    /**
     * 与 cloud-common 中 Result 的返回结构保持一致。
     *
     * Gateway 基于 WebFlux,
     * 当前不直接依赖 cloud-common 的 MVC 相关实现。
     */
    private static final int UNAUTHORIZED_CODE = 40100;

    private final JwtUtil jwtUtil;

    private final GatewayAuthProperties authProperties;

    private final ObjectMapper objectMapper;

    private final AntPathMatcher pathMatcher = new AntPathMatcher();

    @Override
    public Mono<Void> filter(
            ServerWebExchange exchange,
            GatewayFilterChain chain
    ) {
        ServerHttpRequest request = exchange.getRequest();

        /*
         * 1. 放行 OPTIONS 预检请求。
         *
         * 浏览器跨域请求可能先发送 OPTIONS。
         * 如果 OPTIONS 也要求 JWT,
         * 浏览器将无法发出正式请求。
         */
        if (HttpMethod.OPTIONS.equals(request.getMethod())) {
            return chain.filter(exchange);
        }

        String path = request.getURI().getPath();

        /*
         * 2. 放行白名单。
         *
         * 登录接口必须匿名访问,
         * 否则客户端无法获得 Token。
         */
        if (isWhitePath(path)) {
            return chain.filter(exchange);
        }

        /*
         * 3. 读取 Authorization。
         */
        String authorization = request.getHeaders()
                .getFirst(HttpHeaders.AUTHORIZATION);

        if (!StringUtils.hasText(authorization)
                || !authorization.startsWith(BEARER_PREFIX)) {
            return unauthorized(
                    exchange,
                    "未登录或 Token 缺失"
            );
        }

        String token = authorization
                .substring(BEARER_PREFIX.length())
                .trim();

        if (!StringUtils.hasText(token)) {
            return unauthorized(
                    exchange,
                    "未登录或 Token 缺失"
            );
        }

        try {
            /*
             * 4. 校验 Token。
             */
            Claims claims = jwtUtil.parseToken(token);

            /*
             * 5. sub 中保存 userId。
             */
            String userId = claims.getSubject();

            if (!StringUtils.hasText(userId)) {
                return unauthorized(
                        exchange,
                        "Token 中缺少用户身份"
                );
            }

            /*
             * 6. 覆盖写入可信 X-User-Id。
             *
             * 必须覆盖,而不是直接信任客户端传入值。
             *
             * 否则攻击者可以自己构造:
             * X-User-Id: 999999
             */
            ServerHttpRequest trustedRequest = request
                    .mutate()
                    .headers(headers -> {
                        headers.remove(USER_ID_HEADER);
                        headers.set(USER_ID_HEADER, userId);
                    })
                    .build();

            /*
             * 7. 使用加工后的请求继续执行 Filter Chain。
             */
            return chain.filter(
                    exchange.mutate()
                            .request(trustedRequest)
                            .build()
            );
        } catch (ExpiredJwtException e) {
            log.warn("JWT 已过期:{}", e.getMessage());

            return unauthorized(
                    exchange,
                    "Token 已过期,请重新登录"
            );
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("JWT 校验失败:{}", e.getMessage());

            return unauthorized(
                    exchange,
                    "Token 无效,请重新登录"
            );
        }
    }

    /**
     * 判断路径是否属于匿名白名单。
     */
    private boolean isWhitePath(String path) {
        return authProperties.getWhitePaths()
                .stream()
                .anyMatch(pattern -> pathMatcher.match(pattern, path));
    }

    /**
     * 直接在 Gateway 返回 401 JSON。
     *
     * Gateway 基于 WebFlux,
     * 返回类型是 Mono<Void>。
     */
    private Mono<Void> unauthorized(
            ServerWebExchange exchange,
            String message
    ) {
        ServerHttpResponse response = exchange.getResponse();

        response.setStatusCode(HttpStatus.UNAUTHORIZED);

        response.getHeaders()
                .setContentType(MediaType.APPLICATION_JSON);

        byte[] bytes = serializeError(message);

        return response.writeWith(
                Mono.just(
                        response.bufferFactory().wrap(bytes)
                )
        );
    }

    /**
     * 统一错误响应:
     *
     * {
     *   "code": 40100,
     *   "message": "...",
     *   "data": null
     * }
     */
    private byte[] serializeError(String message) {
        Map<String, Object> body = new LinkedHashMap<>();

        body.put("code", UNAUTHORIZED_CODE);
        body.put("message", message);
        body.put("data", null);

        try {
            return objectMapper.writeValueAsBytes(body);
        } catch (JsonProcessingException e) {
            log.error("序列化 Gateway 鉴权失败响应异常", e);

            return (
                    "{\"code\":40100,"
                            + "\"message\":\"Unauthorized\","
                            + "\"data\":null}"
            ).getBytes(StandardCharsets.UTF_8);
        }
    }

    /**
     * 数值越小,前置逻辑越早执行。
     */
    @Override
    public int getOrder() {
        return -100;
    }
}

6.11 为什么必须覆盖 X-User-Id

客户端可能主动伪造:

http 复制代码
X-User-Id: 999999

如果下游服务直接信任这个请求头,就会产生身份伪造风险。

正确做法是:

text 复制代码
Gateway 从 JWT 提取真实 userId
        ↓
删除客户端传来的 X-User-Id
        ↓
重新写入可信 X-User-Id

所以:

text 复制代码
X-User-Id 只能由 Gateway 写入。

6.12 为什么 Gateway 中不直接返回 Result

在普通业务服务中,接口通常是 Spring MVC Controller,例如:

java 复制代码
@GetMapping("/users/{id}")
public Result<UserDTO> getById(@PathVariable Long id) {
    return Result.success(userService.getById(id));
}

这种场景中,Controller 可以直接返回:

java 复制代码
Result<T>

但 Gateway 不一样。

Gateway 使用的是:

text 复制代码
Spring WebFlux
Mono<Void>
ServerWebExchange

它不是传统 Spring MVC Controller。

当前鉴权失败时,JwtAuthGlobalFilter 是直接向响应对象写入 JSON 字节流:

text 复制代码
ServerHttpResponse
        ↓
DataBuffer
        ↓
Mono<Void>

也就是说,Gateway 中的返回方式不是:

text 复制代码
return Result.fail(...)

而是:

text 复制代码
response.writeWith(...)

不过响应结构仍然和业务服务保持一致:

json 复制代码
{
  "code": 40100,
  "message": "...",
  "data": null
}

这样做的好处是:

text 复制代码
实现方式符合 Gateway 的 WebFlux 模型;
响应格式又能和业务服务保持统一。

一句话总结:

text 复制代码
Gateway 不直接返回 Result<T>,
但可以手动写出同样结构的 JSON。

6.13 getOrder() = -100 有什么作用?

JwtAuthGlobalFilter 实现了:

java 复制代码
Ordered

并重写:

java 复制代码
@Override
public int getOrder() {
    return -100;
}

这个值决定当前 Filter 在 Gateway Filter Chain 中的执行顺序。

Gateway 会将:

text 复制代码
GlobalFilter
Route Filter

合并到同一条 Filter Chain 中。

然后根据:

java 复制代码
Ordered#getOrder()

排序。

数值越小:

text 复制代码
前置逻辑越早执行

鉴权属于非常靠前的逻辑。

因为如果请求没有 Token,或者 Token 无效,就没有必要继续执行后续路由、负载均衡、转发等逻辑。

所以这里设置为:

text 复制代码
-100

表示让 JWT 鉴权尽量提前执行。

Gateway 官方文档说明,GlobalFilterGatewayFilter 会合并为一条过滤器链,并通过 Ordered 接口排序;优先级最高的 Filter 会最先执行 pre 阶段,并在 post 阶段最后执行。

可以这样理解:

text 复制代码
请求进入 Gateway
        ↓
优先执行 JWT 鉴权
        ↓
鉴权失败:直接返回 401
        ↓
鉴权成功:继续路由匹配和转发

一句话总结:

text 复制代码
鉴权 Filter 越早执行,越能避免无效请求继续进入后续链路。

6.14 重启 Gateway

发布 Nacos 配置后,需要刷新 Maven,并重新启动:

text 复制代码
CloudGatewayApplication

观察控制台是否正常启动。

如果启动失败,优先检查:

text 复制代码
1. cloud-gateway 是否已经添加 JJWT 依赖
2. cloud-gateway 是否已经添加 Lombok 依赖
3. jwt.secret 是否存在
4. gateway.auth.white-paths 是否配置正确
5. JwtAuthGlobalFilter 是否有导包错误
6. Gateway 中是否误引入 spring-boot-starter-web

特别注意:

text 复制代码
cloud-gateway 使用 Spring WebFlux;
不要引入 spring-boot-starter-web。

6.15 更新 gateway.http

打开:

text 复制代码
cloud-gateway/src/test/http/gateway.http

追加:

http 复制代码
### --------------------------------------------------
### JWT 鉴权测试
### --------------------------------------------------

### 登录:无需 Token
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

### 将上方登录返回的 token 粘贴到这里
@token = 粘贴你的JWT

### 业务接口:缺少 Token,预期返回 401
GET http://localhost:9000/api/product/products/1

### 业务接口:携带合法 Token,预期正常返回
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

### 创建订单:携带合法 Token,预期成功
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "userId": 1,
  "productId": 1,
  "quantity": 1
}

### Token 被篡改,预期返回 401
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}abc

### Token 格式错误,预期返回 401
GET http://localhost:9000/api/product/products/1
Authorization: invalid-token

### OPTIONS 预检:无需 Token,预期仍然成功
OPTIONS http://localhost:9000/api/order/orders
Origin: http://localhost:5500
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

这里仍然保留:

json 复制代码
{
  "userId": 1,
  "productId": 1,
  "quantity": 1
}

是因为当前还处于第二阶段。

此时只验证:

text 复制代码
Gateway 能不能校验 Token

下一阶段才会改造订单接口,让订单服务不再信任请求体中的 userId


6.16 本轮预期结果

6.16.1 登录接口无需 Token

请求:

http 复制代码
POST /api/auth/login

预期:

text 复制代码
正常返回 JWT。

原因:

text 复制代码
/api/auth/login 在白名单中,
不需要携带 Authorization。

6.16.2 业务接口缺少 Token

请求:

http 复制代码
GET /api/product/products/1

不携带:

http 复制代码
Authorization

预期 HTTP 状态码:

text 复制代码
401 Unauthorized

响应:

json 复制代码
{
  "code": 40100,
  "message": "未登录或 Token 缺失",
  "data": null
}

说明 Gateway 已经开始保护业务接口。


6.16.3 携带合法 Token

请求头:

http 复制代码
Authorization: Bearer <token>

预期:

text 复制代码
商品接口正常返回。

说明:

text 复制代码
Token 签名正确;
Token 没有过期;
Gateway 可以从 JWT 中提取 userId;
请求可以继续转发到下游服务。

6.16.4 篡改 Token

在合法 Token 末尾增加:

text 复制代码
abc

请求:

http 复制代码
GET /api/product/products/1
Authorization: Bearer {{token}}abc

预期返回:

json 复制代码
{
  "code": 40100,
  "message": "Token 无效,请重新登录",
  "data": null
}

原因是:

text 复制代码
JWT 内容被篡改后,
签名校验无法通过。

签名校验失败的 Token 不应该被信任。


6.16.5 Token 格式错误

请求:

http 复制代码
GET /api/product/products/1
Authorization: invalid-token

预期返回:

json 复制代码
{
  "code": 40100,
  "message": "未登录或 Token 缺失",
  "data": null
}

原因是当前 Filter 要求请求头必须符合:

text 复制代码
Authorization: Bearer <token>

如果没有 Bearer 前缀,就认为格式不合法。


6.16.6 OPTIONS 预检无需 Token

请求:

http 复制代码
OPTIONS http://localhost:9000/api/order/orders
Origin: http://localhost:5500
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

预期仍然返回允许跨域的响应头,例如:

http 复制代码
Access-Control-Allow-Origin: http://localhost:5500
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS

原因是:

text 复制代码
浏览器跨域预检请求通常不会携带业务 Token。

如果 OPTIONS 被 JWT Filter 拦截,浏览器会认为跨域失败,正式请求不会继续发送。


6.17 本轮边界:Gateway 校验不等于彻底安全

当前外部入口:

text 复制代码
http://localhost:9000

已经受 Gateway JWT Filter 保护。

也就是说,通过 Gateway 访问业务接口时:

text 复制代码
无 Token:
返回 401

Token 无效:
返回 401

Token 合法:
允许转发

但本地学习环境中,仍然可以直接访问业务服务端口:

text 复制代码
http://localhost:9200
http://localhost:9300
http://localhost:9400

这会绕过 Gateway。

这是本地学习环境的正常现象。

因为我们在本地直接启动了多个 Spring Boot 服务,每个服务端口都暴露在本机。

真实部署中,还需要通过:

text 复制代码
安全组
防火墙
Kubernetes Service
内网隔离
反向代理

限制业务服务端口不对公网开放。

一句话总结:

text 复制代码
代码层由 Gateway 统一鉴权;
网络层还要限制下游服务绕过 Gateway 被直接访问。

否则攻击者如果能直接访问:

text 复制代码
cloud-order:9400

就可能绕开:

text 复制代码
cloud-gateway:9000

所以真实项目中,Gateway 鉴权和网络隔离必须配合使用。

7、第三阶段:下游用户上下文与 Feign 身份透传

7.1 为什么不能继续从请求体读取 userId

前面虽然 Gateway 已经验证了 Token,但订单接口仍然接收:

json 复制代码
{
  "userId": 1,
  "productId": 1,
  "quantity": 1
}

这不合理。

因为客户端可以伪造:

json 复制代码
{
  "userId": 999999,
  "productId": 1,
  "quantity": 1
}

既然 Gateway 已经从 JWT 中提取出可信 userId,订单服务就不应该再信任请求体里的 userId

因此改成:

json 复制代码
{
  "productId": 1,
  "quantity": 1
}

用户身份只来自:

text 复制代码
JWT
    ↓
Gateway
    ↓
X-User-Id
    ↓
UserContext

7.2 ThreadLocal 是什么

ThreadLocal 可以理解为:

text 复制代码
线程自己的变量盒子

同一个变量名,不同线程看到的是自己的值。

text 复制代码
线程 A:userId = 1
线程 B:userId = 2
线程 C:userId = null

在一个普通 HTTP 请求中,Controller、Service、Mapper 通常运行在同一个 Tomcat 工作线程里。

因此可以在请求进入时:

text 复制代码
读取 X-User-Id
        ↓
写入 ThreadLocal

业务代码中再通过:

java 复制代码
UserContext.getUserId()

获取当前用户身份。


7.3 为什么必须 clear

Tomcat 使用线程池。

线程处理完一个请求后,不会销毁,而是继续处理下一个请求。

如果不清理 ThreadLocal,可能出现:

text 复制代码
请求 A:userId = 1
        ↓
线程放回线程池
        ↓
请求 B:没有身份头
        ↓
复用同一个线程
        ↓
错误读到 userId = 1

所以必须形成完整生命周期:

text 复制代码
请求进入:set
请求处理:get
请求结束:remove

7.4 保存当前请求用户身份:UserContext.java

前面 Gateway 已经完成了两件事:

  1. 校验 JWT 是否合法
  2. 从 JWT 中提取 userId,并写入 X-User-Id 请求头

但请求进入 cloud-order、cloud-product 等业务服务后,业务代码不能每一层都手动传递:

userId

否则 Controller、Service、Mapper、Feign 调用中都会出现大量重复参数。

因此需要一个统一的当前用户上下文:

UserContext

它的作用是:

在同一个请求线程中保存当前用户 ID,

让业务代码可以随时获取当前登录用户。

java 复制代码
package com.example.cloud.common.context;

import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;

/**
 * 当前请求用户上下文。
 *
 * 作用:
 * 在同一个请求线程内保存当前用户 ID。
 *
 * 生命周期:
 * 请求进入时 set
 * 业务处理时 get
 * 请求结束后 clear
 */
public final class UserContext {

    /**
     * Gateway 写给下游服务的可信请求头。
     */
    public static final String USER_ID_HEADER = "X-User-Id";

    /**
     * 每个线程拥有独立的用户 ID 副本。
     */
    private static final ThreadLocal<Long> USER_ID_HOLDER =
            new ThreadLocal<>();

    private UserContext() {
    }

    /**
     * 保存当前线程用户 ID。
     */
    public static void setUserId(Long userId) {
        USER_ID_HOLDER.set(userId);
    }

    /**
     * 获取当前线程用户 ID。
     *
     * 允许返回 null:
     * 某些公开接口或内部任务可能没有用户身份。
     */
    public static Long getUserId() {
        return USER_ID_HOLDER.get();
    }

    /**
     * 获取必须存在的用户 ID。
     *
     * 适用于创建订单等必须登录的业务。
     */
    public static Long requireUserId() {
        Long userId = getUserId();

        if (userId == null) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "用户身份缺失"
            );
        }

        return userId;
    }

    /**
     * 清理当前线程上下文。
     *
     * Tomcat 使用线程池。
     * 请求结束后必须 remove,
     * 防止线程复用导致用户身份串线。
     */
    public static void clear() {
        USER_ID_HOLDER.remove();
    }
}

7.5 读取 Gateway 透传的用户身份:UserContextInterceptor.java

UserContext 只是一个保存用户 ID 的工具类。

但它本身不会自动知道:

X-User-Id 是多少

因此还需要一个 MVC 拦截器,在请求进入业务服务时读取请求头。

这个拦截器的职责是:

  1. 请求进入 Controller 之前,读取 X-User-Id
  2. 如果请求头中存在用户 ID,就写入 UserContext
  3. 如果请求头不存在,就允许继续放行
  4. 请求结束后,清理 UserContext

为什么请求头不存在也放行?

因为不是所有接口都必须有用户身份。

例如后续可能会有:

公开接口

健康检查接口

内部任务接口

测试接口

所以拦截器只负责:

有身份就读取并写入上下文。

至于某个业务接口是否必须登录,由业务代码自己决定。

例如创建订单时调用:

UserContext.requireUserId()

如果没有用户身份,就抛出:

用户身份缺失

这里还要注意一个安全边界:

UserContextInterceptor 不是完整的安全认证逻辑。

真正的 JWT 校验已经在 Gateway 中完成。

当前拦截器只负责读取 Gateway 透传后的可信请求头,并写入当前线程上下文。

也就是说:

Gateway:

负责校验 Token

UserContextInterceptor:

负责读取 X-User-Id,并保存到 ThreadLocal

下面创建 UserContextInterceptor.java。

java 复制代码
package com.example.cloud.common.web;

import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * MVC 请求用户上下文拦截器。
 *
 * 注意:
 * 它不是完整安全边界。
 *
 * 真正的 JWT 校验在 Gateway 中完成。
 * 当前拦截器只负责:
 * 1. 读取 Gateway 写入的 X-User-Id
 * 2. 保存到 ThreadLocal
 * 3. 请求结束后清理 ThreadLocal
 */
@Component
@Slf4j
public class UserContextInterceptor
        implements HandlerInterceptor {

    /**
     * Controller 执行前调用。
     */
    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler
    ) {
        /*
         * 防御性清理:
         * 避免当前线程残留上一次请求的数据。
         */
        UserContext.clear();

        String userId = request.getHeader(
                UserContext.USER_ID_HEADER
        );

        log.info(
                "接收请求,uri={},线程={},X-User-Id={}",
                request.getRequestURI(),
                Thread.currentThread().getName(),
                userId
        );

        /*
         * 公开接口或内部接口可能没有用户身份。
         * 当前只在有值时写入。
         *
         * 具体业务是否必须登录,
         * 由业务方法调用 requireUserId() 决定。
         */
        if (!StringUtils.hasText(userId)) {
            return true;
        }

        try {
            UserContext.setUserId(
                    Long.valueOf(userId)
            );

            return true;
        } catch (NumberFormatException e) {
            throw new BizException(
                    ErrorCode.UNAUTHORIZED,
                    "用户身份格式错误"
            );
        }
    }

    /**
     * 请求完成后调用。
     *
     * 无论请求成功还是异常,
     * 都必须清理 ThreadLocal。
     */
    @Override
    public void afterCompletion(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex
    ) {
        UserContext.clear();
    }
}

7.6 注册用户上下文拦截器: UserContextWebMvcConfig.java

创建了 UserContextInterceptor 之后,还需要把它注册到 Spring MVC 的拦截器链中。

否则这个类虽然被 Spring 扫描到了,但不会自动拦截请求。

因此需要创建:

java 复制代码
package com.example.cloud.common.web;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class UserContextWebMvcConfig
        implements WebMvcConfigurer {

    private final UserContextInterceptor
            userContextInterceptor;

    @Override
    public void addInterceptors(
            InterceptorRegistry registry
    ) {
        registry.addInterceptor(userContextInterceptor)
                .addPathPatterns("/**");
    }
}

7.7 ContextController.java

java 复制代码
package com.example.cloud.order.controller;

import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.service.AsyncUserContextService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/context")
@RequiredArgsConstructor
public class ContextController {

    private final AsyncUserContextService
            asyncUserContextService;

    @GetMapping("/user-id")
    public Result<Long> currentUserId() {
        return Result.success(
                UserContext.requireUserId()
        );
    }

    @GetMapping("/async-user-id")
    public Result<String> asyncUserId() {
        return Result.success(
                asyncUserContextService
                        .currentUserId()
                        .join()
        );
    }
}

通过 Gateway 访问:

http 复制代码
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer <token>

执行过程:

bash 复制代码
Gateway:
JWT sub = 1
        ↓
写入 X-User-Id: 1
        ↓
cloud-order:
UserContextInterceptor 读取请求头
        ↓
UserContext.setUserId(1)
        ↓
ContextController 读取 ThreadLocal
        ↓
返回 1

7.8 修改 CreateOrderRequest.java

java 复制代码
package com.example.cloud.order.dto;

import lombok.Data;

@Data
public class CreateOrderRequest {

    private Long productId;

    private Integer quantity;
}

7.9 修改 OrderServiceImpl.java

核心变化:

text 复制代码
不再使用 request.getUserId()
改为 UserContext.requireUserId()

关键代码:

java 复制代码
Long userId = UserContext.requireUserId();

完整代码:

java 复制代码
package com.example.cloud.order.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.api.product.dto.ProductDTO;
import com.example.cloud.api.user.dto.UserDTO;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.client.ProductClient;
import com.example.cloud.order.client.UserClient;
import com.example.cloud.order.dto.CreateOrderRequest;
import com.example.cloud.order.entity.Order;
import com.example.cloud.order.mapper.OrderMapper;
import com.example.cloud.order.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

/**
 * 订单业务实现类。
 */
@Service
@RequiredArgsConstructor
public class OrderServiceImpl
        extends ServiceImpl<OrderMapper, Order>
        implements OrderService {

    private final UserClient userClient;

    private final ProductClient productClient;

    /**
     * 创建订单。
     *
     * 当前用户 ID 不再由客户端请求体传入。
     * 只从 UserContext 获取。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Order createOrder(CreateOrderRequest request) {
        if (request == null) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "订单参数不能为空"
            );
        }

        if (request.getProductId() == null) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "商品 ID 不能为空"
            );
        }

        if (request.getQuantity() == null
                || request.getQuantity() <= 0) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "购买数量必须大于 0"
            );
        }

        /*
         * 1. 从当前请求上下文获取可信用户 ID。
         *
         * 不再信任客户端请求体中的 userId。
         */
        Long userId = UserContext.requireUserId();

        /*
         * 2. 远程校验用户。
         */
        UserDTO user = requireData(
                userClient.getById(userId),
                "用户不存在或用户服务调用失败"
        );

        if (user.getStatus() == null
                || user.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "用户已禁用"
            );
        }

        /*
         * 3. 远程查询商品。
         */
        ProductDTO product = requireData(
                productClient.getById(
                        request.getProductId()
                ),
                "商品不存在或商品服务调用失败"
        );

        if (product.getStatus() == null
                || product.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "商品未上架"
            );
        }

        /*
         * 4. 远程扣减库存。
         */
        ensureSuccess(
                productClient.deductStock(
                        request.getProductId(),
                        request.getQuantity()
                ),
                "库存扣减失败"
        );

        /*
         * 5. 使用可信用户身份和真实商品信息落库。
         */
        Order order = new Order();

        order.setUserId(userId);
        order.setProductId(request.getProductId());
        order.setProductName(product.getName());
        order.setQuantity(request.getQuantity());

        BigDecimal amount = product.getPrice()
                .multiply(
                        BigDecimal.valueOf(
                                request.getQuantity()
                        )
                );

        order.setAmount(amount);
        order.setStatus(0);

        boolean success = save(order);

        if (!success) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "创建订单失败"
            );
        }

        return order;
    }

    private <T> T requireData(
            Result<T> result,
            String defaultMessage
    ) {
        if (result == null
                || result.getCode() != 0
                || result.getData() == null) {
            String message = result == null
                    ? defaultMessage
                    : result.getMessage();

            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    message
            );
        }

        return result.getData();
    }

    private void ensureSuccess(
            Result<?> result,
            String defaultMessage
    ) {
        if (result == null
                || result.getCode() != 0) {
            String message = result == null
                    ? defaultMessage
                    : result.getMessage();

            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    message
            );
        }
    }
}

完整创建订单流程变为:

text 复制代码
校验请求参数
        ↓
从 UserContext 获取可信 userId
        ↓
远程查询用户
        ↓
远程查询商品
        ↓
远程扣减库存
        ↓
使用可信 userId 落库

7.10 先验证 Gateway 到订单服务的身份链路

完成 UserContextUserContextInterceptorUserContextWebMvcConfigContextControllerOrderServiceImpl 改造后,开始验证。

这一轮只验证:

text 复制代码
Gateway
    ↓
cloud-order
    ↓
MVC Interceptor
    ↓
ThreadLocal

也就是先确认订单服务本身能不能读取到 Gateway 透传的用户身份。

重新启动:

text 复制代码
CloudOrderApplication
CloudProductApplication
CloudUserApplication

登录获取 Token:

http 复制代码
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

将登录返回的 Token 写入 HTTP Client 变量:

http 复制代码
@token = 粘贴你的JWT

执行:

http 复制代码
### Gateway → cloud-order → ThreadLocal
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer {{token}}

预期返回:

json 复制代码
{
  "code": 0,
  "message": "success",
  "data": 1
}

这证明下面这条链路已经跑通:

text 复制代码
JWT
    ↓
Gateway
    ↓
X-User-Id
    ↓
MVC Interceptor
    ↓
ThreadLocal

也就是说:

text 复制代码
Gateway 已经能从 JWT 中提取 userId;
Gateway 已经能把 userId 写入 X-User-Id;
cloud-order 已经能读取 X-User-Id;
UserContext 已经能在当前请求线程中保存 userId。

但这还不能证明服务间调用时身份也能继续传递。

因为:

text 复制代码
cloud-order 调用 cloud-product

会发起一次新的 HTTP 请求。

这个问题需要下一步继续验证。


7.11 修改商品服务日志

为了观察用户身份是否能继续跨服务传播,需要在商品服务扣库存逻辑中打印当前用户 ID。

打开:

text 复制代码
cloud-product
└── src/main/java
    └── com.example.cloud.product.service.impl
        └── ProductServiceImpl.java

如果类上还没有:

java 复制代码
@Slf4j

则加入导入:

java 复制代码
import lombok.extern.slf4j.Slf4j;

并在类上增加:

java 复制代码
@Slf4j
@Service
public class ProductServiceImpl {
    // 原有代码
}

然后在扣库存方法开头加入日志:

java 复制代码
log.info(
        "扣减库存,当前用户 ID:{}",
        UserContext.getUserId()
);

并加入导入:

java 复制代码
import com.example.cloud.common.context.UserContext;

注意:

text 复制代码
这里只增加日志,
不要修改原有库存扣减逻辑。

本轮的目的不是改变商品业务,而是观察:

text 复制代码
cloud-product 能不能读取到当前用户身份。

7.12 第一次创建订单:观察身份丢失

通过 Gateway 创建订单:

http 复制代码
### 创建订单:请求体不再传 userId
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "productId": 1,
  "quantity": 1
}

预期:

text 复制代码
订单应当仍然可以创建成功。

原因是:

text 复制代码
cloud-order 已经可以从 UserContext 中获取 userId;
创建订单不再依赖请求体中的 userId。

但是观察 cloud-product 日志,会看到类似:

text 复制代码
扣减库存,当前用户 ID:null

这就是一个非常重要的现象。

它说明:

text 复制代码
cloud-order 中有 UserContext
        ↓
但 ProductClient 发起的是新的 HTTP 请求
        ↓
ThreadLocal 只属于当前 JVM 当前线程
        ↓
不会自动跨网络传播
        ↓
cloud-product 中 userId = null

也就是说,当前只解决了:

text 复制代码
Gateway → cloud-order

还没有解决:

text 复制代码
cloud-order → cloud-product

的身份继续传递问题。

因此下一步需要引入:

text 复制代码
Feign RequestInterceptor

cloud-order 发起 Feign 请求时,自动把当前用户身份继续写入请求头:

http 复制代码
X-User-Id: 1

这样 cloud-product 才能继续通过 UserContextInterceptor 读取到当前用户身份。

7.13 Feign RequestInterceptor 是什么

现在 cloud-order 已经能拿到用户身份。

cloud-order 还会通过 Feign 调用:

text 复制代码
cloud-user
cloud-product

此时会发起新的 HTTP 请求。

ThreadLocal 不会自动跨网络传递。

所以需要在 Feign 发请求前,把当前用户 ID 放到请求头中。

这就是:

java 复制代码
RequestInterceptor

它适合统一添加:

text 复制代码
X-User-Id
X-Trace-Id
租户 ID
语言
内部调用标识

7.14 让 Feign 继续透传用户身份:UserContextFeignInterceptor.java

java 复制代码
package com.example.cloud.order.client.interceptor;

import com.example.cloud.common.context.UserContext;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class UserContextFeignInterceptor
        implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        Long userId = UserContext.getUserId();

        log.info(
                "Feign 准备发送请求,线程={},url={},当前用户 ID={}",
                Thread.currentThread().getName(),
                template.url(),
                userId
        );

        if (userId == null) {
            return;
        }

        template.header(
                UserContext.USER_ID_HEADER,
                String.valueOf(userId)
        );
    }
}

7.12 第二次创建订单:验证 Feign 身份透传

再来尝试创建订单。

日志显示:

text 复制代码
cloud-order:
Feign 准备发送请求,线程=pool-3-thread-3,
url=/products/1/deduct-stock?quantity=1,
当前用户 ID=null

cloud-product:
接收请求,uri=/products/1/deduct-stock,
线程=http-nio-9300-exec-4,
X-User-Id=null

ProductServiceImpl:
扣减库存,当前用户 ID:null

这说明:

text 复制代码
UserContextFeignInterceptor 生效了
但它执行时 UserContext 已经是 null

8、第四阶段:CircuitBreaker 线程切换导致身份丢失

8.1 为什么 RequestInterceptor 读取到 null

第 7 章中为了 Feign 降级,开启了:

yaml 复制代码
feign:
  circuitbreaker:
    enabled: true

开启后,Feign 调用会被 CircuitBreaker 包装。

实际日志显示:

text 复制代码
线程=pool-3-thread-3

而不是:

text 复制代码
线程=http-nio-9400-exec-1

这说明 Feign 调用已经切换到了 CircuitBreaker 使用的线程池中。

普通 ThreadLocal 不能跨线程传递。

所以:

text 复制代码
Tomcat 请求线程:
UserContext = 1

CircuitBreaker 工作线程:
UserContext = null

8.2 对照实验:关闭 CircuitBreaker

临时修改 Nacos:

yaml 复制代码
feign:
  circuitbreaker:
    enabled: false

重启 cloud-order 后再次创建订单。

日志显示:

text 复制代码
cloud-order:
Feign 准备发送请求,线程=http-nio-9400-exec-1,
url=/products/1/deduct-stock?quantity=1,
当前用户 ID=1

cloud-product:
接收请求,uri=/products/1/deduct-stock,
线程=http-nio-9300-exec-1,
X-User-Id=1

ProductServiceImpl:
扣减库存,当前用户 ID:1

这证明:

text 复制代码
template.header(...) 没问题
UserContextFeignInterceptor 没问题
问题来自 CircuitBreaker 线程切换

但不能为了身份透传成功,就放弃 CircuitBreaker。


8.3 在 CircuitBreaker 线程池中传播用户上下文:UserContextAwareExecutorService.java

前面已经做过对照实验。

当关闭 CircuitBreaker 时:

yaml 复制代码
feign:
  circuitbreaker:
    enabled: false

Feign 调用日志显示:

bash 复制代码
线程=http-nio-9400-exec-1
当前用户 ID=1

说明 Feign 调用仍然在原来的 Tomcat 请求线程中执行,所以 UserContext 没有丢失。

但当开启 CircuitBreaker 时:

yaml 复制代码
feign:
  circuitbreaker:
    enabled: true

Feign 调用日志显示:

bash 复制代码
线程=pool-3-thread-3
当前用户 ID=null

这说明 Feign 调用被 CircuitBreaker 包装后,实际执行线程已经发生了变化。

原来的请求线程中:

bash 复制代码
UserContext = 1

但 CircuitBreaker 的工作线程中:

bash 复制代码
UserContext = null

根本原因是:

bash 复制代码
ThreadLocal 只属于当前线程,
不会自动复制到线程池中的另一个线程。

所以,仅仅有 UserContextFeignInterceptor 还不够。

因为它执行时已经处于 CircuitBreaker 的工作线程中,此时 UserContext.getUserId() 已经变成了 null。

修复思路是:

bash 复制代码
任务提交时:
捕获父线程中的 UserContext

任务执行前:
把捕获到的 userId 恢复到工作线程

任务执行后:
清理或恢复工作线程原有上下文

这正是 UserContextAwareExecutorService 要做的事情。

它不是一个新的业务线程池,而是对已有 ExecutorService 的包装:

原始 ExecutorService
        ↓
UserContextAwareExecutorService 包装
        ↓
提交任务时捕获 userId
        ↓
执行任务前恢复 userId
        ↓
任务结束后清理 userId

为什么任务结束后必须清理?

因为线程池会复用线程。

如果不清理,可能出现:

用户 A 的任务:
userId = 1
        ↓
线程回到线程池
        ↓
用户 B 的任务复用同一线程
        ↓
错误读到 userId = 1

这就是用户身份串线,属于非常严重的问题。

所以本类的核心原则是:

捕获
恢复
执行
清理

下面创建 UserContextAwareExecutorService.java

java 复制代码
package com.example.cloud.order.config;

import com.example.cloud.common.context.UserContext;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * 用户上下文感知线程池包装器。
 *
 * 作用:
 * 将提交任务线程中的 UserContext,
 * 安全传递到真正执行任务的线程。
 *
 * 当前主要用于:
 * Resilience4j CircuitBreaker / TimeLimiter。
 */
public class UserContextAwareExecutorService
        extends AbstractExecutorService {

    private final ExecutorService delegate;

    public UserContextAwareExecutorService(
            ExecutorService delegate
    ) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        Objects.requireNonNull(command, "command");

        /*
         * 提交任务时仍然位于父线程。
         *
         * 此时捕获父线程中的用户身份。
         */
        Long capturedUserId = UserContext.getUserId();

        delegate.execute(() -> {
            /*
             * 保存工作线程原有值。
             *
             * 线程池会复用线程,
             * 因此任务结束后必须恢复或清理。
             */
            Long previousUserId = UserContext.getUserId();

            try {
                if (capturedUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(capturedUserId);
                }

                command.run();
            } finally {
                if (previousUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(previousUserId);
                }
            }
        });
    }

    @Override
    public void shutdown() {
        delegate.shutdown();
    }

    @Override
    public List<Runnable> shutdownNow() {
        return delegate.shutdownNow();
    }

    @Override
    public boolean isShutdown() {
        return delegate.isShutdown();
    }

    @Override
    public boolean isTerminated() {
        return delegate.isTerminated();
    }

    @Override
    public boolean awaitTermination(
            long timeout,
            TimeUnit unit
    ) throws InterruptedException {
        return delegate.awaitTermination(timeout, unit);
    }
}

8.4 将上下文感知线程池交给 CircuitBreaker:Resilience4jExecutorConfig.java

UserContextAwareExecutorService 只是定义了一个"能传播 UserContext 的线程池包装器"。

但如果不把它交给 Resilience4j CircuitBreaker 使用,Feign 调用仍然会继续使用默认线程池。

因此还需要创建配置类:

bash 复制代码
Resilience4jExecutorConfig
java 复制代码
package com.example.cloud.order.config;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.client.circuitbreaker.Customizer;
import org.springframework.cloud.circuitbreaker.resilience4j
        .Resilience4JCircuitBreakerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Resilience4j CircuitBreaker 线程池配置。
 *
 * 为什么需要自定义?
 *
 * Feign 开启 CircuitBreaker 后,
 * 远程调用可能切换到线程池中执行。
 *
 * 普通 ThreadLocal 不会自动跨线程传递,
 * 因此需要包装 ExecutorService。
 */
@Configuration
public class Resilience4jExecutorConfig {

    /**
     * 创建用户上下文感知线程池。
     *
     * destroyMethod = "shutdown":
     * Spring 容器关闭时,自动关闭线程池。
     */
    @Bean(
            name = "userContextAwareCircuitBreakerExecutor",
            destroyMethod = "shutdown"
    )
    public ExecutorService userContextAwareCircuitBreakerExecutor() {
        AtomicInteger index = new AtomicInteger();

        ThreadFactory threadFactory = task -> {
            Thread thread = new Thread(task);

            thread.setName(
                    "resilience4j-context-"
                            + index.incrementAndGet()
            );

            /*
             * 学习项目中设置为守护线程。
             *
             * 正式项目还需要结合:
             * 核心线程数
             * 最大线程数
             * 队列容量
             * 拒绝策略
             * 监控指标
             */
            thread.setDaemon(true);

            return thread;
        };

        ExecutorService delegate =
                Executors.newFixedThreadPool(
                        10,
                        threadFactory
                );

        return new UserContextAwareExecutorService(
                delegate
        );
    }

    /**
     * 将自定义线程池交给 Resilience4j CircuitBreaker。
     */
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory>
            resilience4jExecutorCustomizer(
                    @Qualifier(
                            "userContextAwareCircuitBreakerExecutor"
                    )
                    ExecutorService executorService
            ) {
        return factory ->
                factory.configureExecutorService(
                        executorService
                );
    }
}

8.5 恢复 CircuitBreaker 后验证

恢复 Nacos:

yaml 复制代码
feign:
  circuitbreaker:
    enabled: true

重启 cloud-order 后再次创建订单。

实际验证结果:

text 复制代码
cloud-order:
Feign 拦截器日志中 userId=1

cloud-product:
X-User-Id=1

ProductServiceImpl:
扣减库存,当前用户 ID:1

说明:

text 复制代码
CircuitBreaker 开启
        ↓
线程发生切换
        ↓
上下文感知 ExecutorService 捕获并恢复 UserContext
        ↓
Feign RequestInterceptor 成功写入 X-User-Id
        ↓
cloud-product 成功读取用户身份

8.6 直接访问订单服务会发生什么

cloud-gateway/src/test/http/gateway.http追加:

http 复制代码
### 绕过 Gateway 直接访问 cloud-order
### 预期:用户身份缺失
POST http://localhost:9400/orders
Content-Type: application/json

{
  "productId": 1,
  "quantity": 1
}

预期:

json 复制代码
{
  "code": 40100,
  "message": "用户身份缺失",
  "data": null
}

执行直接访问↓

原因:

bash 复制代码
请求绕过 Gateway
        ↓
没有 X-User-Id
        ↓
UserContext.requireUserId() 失败

这说明订单服务已经不再信任请求体中的 userId。

但需要注意,攻击者如果能够直接访问内部端口,仍然可能手工伪造:

bash 复制代码
X-User-Id: 999999

因此真实部署还必须限制:

bash 复制代码
业务服务端口不对公网开放
只允许 Gateway 或可信内网访问

8.7 为什么不用 InheritableThreadLocal

InheritableThreadLocal 只适合:

text 复制代码
父线程临时创建新子线程

但线程池中的线程通常早已存在,并且会反复复用。

在这种场景中,它容易失效或造成身份串线。

因此更推荐:

text 复制代码
任务提交时捕获上下文
任务执行前恢复上下文
任务结束后清理上下文

9、第五阶段:@Async 异步线程身份丢失与 TaskDecorator 修复

9.1 @Async 是什么

@Async 表示异步执行。

普通方法调用:

text 复制代码
调用方线程
    ↓
执行方法
    ↓
等待方法执行完成

@Async 方法调用:

text 复制代码
调用方线程
    ↓
提交任务到线程池
    ↓
异步线程执行方法

常见用途:

text 复制代码
发送通知
记录审计日志
导出文件
异步计算
无需阻塞主流程的附加任务

但它会带来一个问题:

text 复制代码
线程切换后 ThreadLocal 丢失

9.2 OrderAsyncConfig.java 初始版本

先创建不带 TaskDecorator 的版本,用于复现故障:

java 复制代码
package com.example.cloud.order.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class OrderAsyncConfig {

    @Bean("orderAsyncExecutor")
    public Executor orderAsyncExecutor() {
        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("order-async-");

        executor.initialize();

        return executor;
    }
}

9.3 AsyncUserContextService.java

java 复制代码
package com.example.cloud.order.service;

import com.example.cloud.common.context.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Slf4j
@Service
public class AsyncUserContextService {

    @Async("orderAsyncExecutor")
    public CompletableFuture<String> currentUserId() {
        Long userId = UserContext.getUserId();

        log.info(
                "异步任务执行,线程={},当前用户 ID={}",
                Thread.currentThread().getName(),
                userId
        );

        String result = userId == null
                ? "null"
                : String.valueOf(userId);

        return CompletableFuture.completedFuture(result);
    }
}

9.4 为什么 @Async 方法要放在单独 Bean 中

不要在同一个类里自己调用自己的 @Async 方法。

错误示例:

java 复制代码
public Result<String> asyncUserId() {
    return Result.success(
            currentUserId().join()
    );
}

@Async
public CompletableFuture<String> currentUserId() {
    ...
}

这是同一个对象内部调用,不会经过 Spring 代理。

结果是:

text 复制代码
@Async 不生效
方法仍然在原线程执行

所以这里单独创建:

text 复制代码
AsyncUserContextService

由 Controller 注入后调用。


9.5 第一次测试:异步线程身份丢失

请求同步接口:

http 复制代码
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer {{token}}

返回:

json 复制代码
{
  "code": 0,
  "message": "success",
  "data": 1
}

请求异步接口:

http 复制代码
GET http://localhost:9000/api/order/context/async-user-id
Authorization: Bearer {{token}}

返回:

json 复制代码
{
  "code": 0,
  "message": "success",
  "data": "null"
}

日志:

text 复制代码
异步任务执行,
线程=order-async-1,
当前用户 ID=null

说明:

text 复制代码
请求线程 UserContext=1
        ↓
@Async 切换到 order-async-1
        ↓
普通 ThreadLocal 无法跨线程
        ↓
异步线程 UserContext=null

9.6 TaskDecorator 是什么

TaskDecorator 可以理解为:

text 复制代码
异步任务包装器

任务提交到线程池之前,它可以把原始 Runnable 包装一层。

text 复制代码
原始 Runnable
        ↓
TaskDecorator 包装
        ↓
包装后的 Runnable
        ↓
线程池执行

适合处理:

text 复制代码
用户身份
TraceId
MDC 日志上下文
租户 ID
语言环境
监控埋点

9.7 UserContextTaskDecorator.java

java 复制代码
package com.example.cloud.order.config;

import com.example.cloud.common.context.UserContext;
import org.springframework.core.task.TaskDecorator;

public class UserContextTaskDecorator
        implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Long capturedUserId = UserContext.getUserId();

        return () -> {
            Long previousUserId = UserContext.getUserId();

            try {
                if (capturedUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(
                            capturedUserId
                    );
                }

                runnable.run();
            } finally {
                if (previousUserId == null) {
                    UserContext.clear();
                } else {
                    UserContext.setUserId(
                            previousUserId
                    );
                }
            }
        };
    }
}

9.8 OrderAsyncConfig.java 修复版本

java 复制代码
package com.example.cloud.order.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class OrderAsyncConfig {

    @Bean("orderAsyncExecutor")
    public Executor orderAsyncExecutor() {
        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("order-async-");

        executor.setTaskDecorator(
                new UserContextTaskDecorator()
        );

        executor.initialize();

        return executor;
    }
}

9.9 第二次测试:异步线程身份恢复

再次请求:

http 复制代码
GET http://localhost:9000/api/order/context/async-user-id
Authorization: Bearer {{token}}

返回:

json 复制代码
{
  "code": 0,
  "message": "success",
  "data": "1"
}

日志:

text 复制代码
异步任务执行,
线程=order-async-1,
当前用户 ID=1

说明:

text 复制代码
Gateway 写入 X-User-Id
        ↓
cloud-order 请求线程 UserContext = 1
        ↓
调用 @Async 方法
        ↓
TaskDecorator 捕获 userId = 1
        ↓
任务进入 order-async-1
        ↓
TaskDecorator 恢复 userId = 1
        ↓
异步任务读取 UserContext = 1
        ↓
finally 清理或恢复线程上下文

10、本章关键概念对比

10.1 JWT 与 Session

对比项 JWT Session
状态保存 客户端保存 Token 服务端保存 Session
服务端扩展 更适合无状态扩展 需要共享 Session
主动失效 相对麻烦 相对容易
微服务网关场景 常用 也能用,但要额外设计

10.2 Gateway 鉴权与下游拦截器

位置 职责
Gateway GlobalFilter 校验 JWT,决定请求能否进入系统
MVC Interceptor 读取可信请求头,写入 UserContext
UserContext 保存当前线程用户身份
Feign RequestInterceptor 发起远程调用时继续透传身份

10.3 Feign RequestInterceptor 与 TaskDecorator

技术 解决问题
Feign RequestInterceptor HTTP 远程调用时添加请求头
UserContextAwareExecutorService CircuitBreaker 线程池中传播上下文
TaskDecorator @Async 线程池中传播上下文

11、本章结论

本章完成了完整的统一鉴权主线:

text 复制代码
cloud-auth 登录
BCrypt 密码校验
JWT 签发
Gateway GlobalFilter
白名单
Authorization: Bearer
JWT 校验
X-User-Id 透传
UserContext
ThreadLocal
MVC Interceptor
Feign RequestInterceptor
CircuitBreaker 线程切换故障
UserContextAwareExecutorService
@Async 身份丢失故障
TaskDecorator

到目前为止架构如图↓


12、下一章预告:Sentinel 限流、熔断与降级

到目前为止,项目的主链路已经打通:

text 复制代码
客户端
    ↓
Gateway 统一入口
    ↓
JWT 统一鉴权
    ↓
cloud-order 创建订单
    ↓
OpenFeign 调用 cloud-user 校验用户
    ↓
OpenFeign 调用 cloud-product 查询商品、扣减库存
    ↓
订单创建完成

但是主链路打通之后,还会面临新的问题。

例如:

text 复制代码
如果短时间内大量请求同时访问商品接口,会不会把商品服务打垮?

如果订单服务频繁调用商品服务,而商品服务响应很慢,订单服务会不会被拖垮?

如果下游服务异常,Feign fallback 应该怎么处理?

如果库存不足这种业务异常也进入 fallback,会不会掩盖真实业务错误?

这些问题已经不属于"服务能不能调用成功",而属于:

text 复制代码
服务稳定性
流量治理
异常隔离
熔断降级

所以第 10 章将进入:

text 复制代码
Sentinel 限流、熔断与降级

Sentinel 是 Alibaba 开源的流量治理组件,它以"流量"为切入点,提供流量控制、并发限制、熔断降级、系统自适应保护等能力,用于提升微服务系统可靠性。

下一章主要学习:

text 复制代码
1. Sentinel 是什么
2. Sentinel 和 Hystrix 有什么区别
3. 如何接入 Sentinel Dashboard
4. 如何给商品接口配置 QPS 限流
5. 如何观察接口被限流后的返回结果
6. 如何让 Feign 调用配合 Sentinel fallback
7. 如何复现"降级掩盖真实业务异常"的问题
8. 如何区分业务异常和系统异常
相关推荐
山东点狮信息科技有限公司2 小时前
企业级 MES 制造执行系统架构设计与实践
spring cloud·性能优化·系统架构·策略模式·点狮
MartinYeung52 小时前
[论文学习]DP2Unlearning:高效且具保证的大型语言模型遗忘框架(基于差分隐私的 LLM Unlearning 方法)
学习·算法·语言模型
solicitous3 小时前
学习了解充电桩协议OCPP——J规范
学习
H__Rick4 小时前
C51单片机学习-DAY3
单片机·学习·mongodb
yoothey5 小时前
异常学习笔记:为什么自定义异常后还要 throw?
笔记·学习
WangN26 小时前
【通识】宇树G1_29DOF速度跟踪训练—逐章学习手册
人工智能·python·学习·机器人·具身智能
lazy H7 小时前
Spring Boot 项目如何连接 Redis?新手入门配置和常见错误总结
ide·spring boot·redis·后端·学习·intellij-idea
雾沉川7 小时前
Flutter 入门开发环境完整搭建教程
学习·flutter
星夜夏空998 小时前
STM32单片机学习(37) —— PWR和BKP
stm32·单片机·学习