跳转到内容
123xiao | 无名键客

《Java Web开发实战:基于Spring Boot与JWT实现权限认证、接口防刷与统一异常处理》

字数: 0 阅读时长: 1 分钟

Java Web开发实战:基于Spring Boot与JWT实现权限认证、接口防刷与统一异常处理

在很多 Java Web 项目里,登录认证、接口防刷、异常处理,往往不是“不会做”,而是“做得不完整”。

最常见的情况是这样的:

  • 登录接口能发 token,但校验逻辑散落在各处
  • 接口被恶意高频调用后,系统才想起要加限流
  • 代码里 try-catch 满天飞,前端拿到的错误结构五花八门
  • 明明是业务异常,却最终返回成 500,排查起来很痛苦

这篇文章我想用一个可运行的 Spring Boot 小项目,带你把这三件事串起来:

  1. JWT 权限认证
  2. 接口防刷(限流)
  3. 统一异常处理

重点不是堆概念,而是做成一个中级开发在实际项目里能直接迁移的方案。


背景与问题

先说为什么这三个点适合一起设计。

在真实 Web 服务中,请求进入系统后,通常会经过这样一条链路:

  1. 用户携带 token 发起请求
  2. 服务端校验身份与权限
  3. 判断接口调用频率是否合法
  4. 执行业务逻辑
  5. 如果出错,统一返回可读、可追踪的错误信息

如果这几个环节分散处理,就很容易出现:

  • 认证逻辑与业务代码耦合
  • 限流逻辑写在 Controller 里,不可复用
  • 异常返回风格不统一,前后端协作困难
  • 错误日志没有上下文,定位慢

所以更合理的做法是:
把“横切逻辑”抽出来,放到过滤器、拦截器、AOP、统一异常处理器这类机制中。


前置知识与环境准备

本文示例环境:

  • JDK 17
  • Spring Boot 3.x
  • Maven 3.9+
  • Redis(用于接口防刷)
  • jjwt 0.11.x

项目结构大致如下:

src/main/java/com/example/demo
├── DemoApplication.java
├── common
│   ├── ApiResponse.java
│   ├── ErrorCode.java
│   └── BizException.java
├── config
│   └── RedisConfig.java
├── controller
│   ├── AuthController.java
│   └── UserController.java
├── exception
│   └── GlobalExceptionHandler.java
├── interceptor
│   └── RateLimitInterceptor.java
├── security
│   ├── JwtAuthenticationFilter.java
│   ├── JwtUtil.java
│   └── UserContext.java
├── annotation
│   └── RateLimit.java
└── model
    ├── LoginRequest.java
    └── UserPrincipal.java

核心原理

先别急着上代码,我们先把这套方案的运行机制捋顺。

1. JWT 认证的本质

JWT(JSON Web Token)本质上是一个自包含的身份凭证
服务端在用户登录成功后签发 token,后续请求只要带上 token,服务端就能解析出用户信息并判断是否合法。

典型过程:

  • 登录成功后生成 JWT
  • JWT 中放入 userIdusernameroles
  • 每次请求带上 Authorization: Bearer xxx
  • 服务端过滤器解析并校验签名、过期时间
  • 校验通过后,把用户信息放到上下文中供后续使用

JWT 认证流程图

sequenceDiagram
    participant C as Client
    participant A as AuthController
    participant J as JwtUtil
    participant F as JwtFilter
    participant B as Business API

    C->>A: POST /auth/login 用户名密码
    A->>J: 生成 JWT
    J-->>A: token
    A-->>C: 返回 token

    C->>F: 携带 Authorization: Bearer token
    F->>J: 校验 token
    J-->>F: 解析用户信息
    F->>B: 放行请求并携带用户上下文
    B-->>C: 返回业务数据

2. 接口防刷的本质

接口防刷不一定等于“复杂风控”。
在中小型系统里,很多时候只需要一个简单有效的限流策略,就能挡掉绝大多数恶意刷接口和误操作。

常见维度:

  • 按 IP 限流
  • 按用户 ID 限流
  • 按接口路径限流
  • 按“用户 + 接口”限流

本文用 Redis 实现一个简单的固定窗口限流

  • 在一个时间窗口内,比如 60 秒
  • 同一用户访问某个接口最多 5 次
  • 超过后直接拒绝请求

这不是最精细的算法,但足够实用,而且非常容易落地。

3. 统一异常处理的本质

统一异常处理的目标不是“把所有异常吞掉”,而是:

  • 业务异常:给出明确提示
  • 鉴权异常:返回 401/403
  • 限流异常:返回 429
  • 系统异常:返回 500,同时记录日志

这样前端处理起来简单,后端排查也更高效。


整体架构设计

我们先看一下请求进入系统后的整体链路。

flowchart LR
    A[客户端请求] --> B[JWT认证过滤器]
    B -->|通过| C[接口限流拦截器]
    B -->|失败| X[统一异常返回]
    C -->|通过| D[Controller]
    C -->|限流触发| X
    D --> E[Service/业务逻辑]
    E -->|正常| F[统一响应体]
    E -->|抛异常| X

这个顺序很关键:

  • 认证先于限流:因为很多限流要依赖用户身份
  • 限流先于业务:防止业务代码被无意义消耗
  • 异常统一收口:避免每层自己拼返回结果

实战代码(可运行)

下面我们直接搭一个最小可运行版本。

1. Maven 依赖

<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 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
    </parent>

    <properties>
        <java.version>17</java.version>
        <jjwt.version>0.11.5</jjwt.version>
    </properties>

    <dependencies>
        <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>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</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>
    </dependencies>
</project>

2. application.yml

server:
  port: 8080

spring:
  data:
    redis:
      host: localhost
      port: 6379

app:
  jwt:
    secret: 12345678901234567890123456789012
    expire-seconds: 3600

这里的 secret 只是演示用。生产环境一定不要这么写死,后面会讲最佳实践。


3. 统一返回结构

ApiResponse.java

package com.example.demo.common;

public class ApiResponse<T> {

    private int code;
    private String message;
    private T data;

    public ApiResponse() {
    }

    public ApiResponse(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(0, "success", data);
    }

    public static <T> ApiResponse<T> fail(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

ErrorCode.java

package com.example.demo.common;

public enum ErrorCode {

    PARAM_ERROR(400, "参数错误"),
    UNAUTHORIZED(401, "未登录或token无效"),
    FORBIDDEN(403, "无权限访问"),
    TOO_MANY_REQUESTS(429, "请求过于频繁,请稍后再试"),
    BIZ_ERROR(1001, "业务异常"),
    SYSTEM_ERROR(500, "系统繁忙,请稍后重试");

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int code() {
        return code;
    }

    public String message() {
        return message;
    }
}

BizException.java

package com.example.demo.common;

public class BizException extends RuntimeException {

    private final int code;

    public BizException(ErrorCode errorCode) {
        super(errorCode.message());
        this.code = errorCode.code();
    }

    public BizException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}

4. JWT 工具类

UserPrincipal.java

package com.example.demo.model;

import java.util.List;

public class UserPrincipal {

    private Long userId;
    private String username;
    private List<String> roles;

    public UserPrincipal() {
    }

    public UserPrincipal(Long userId, String username, List<String> roles) {
        this.userId = userId;
        this.username = username;
        this.roles = roles;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<String> getRoles() {
        return roles;
    }

    public void setRoles(List<String> roles) {
        this.roles = roles;
    }
}

UserContext.java

package com.example.demo.security;

import com.example.demo.model.UserPrincipal;

public class UserContext {

    private static final ThreadLocal<UserPrincipal> HOLDER = new ThreadLocal<>();

    public static void set(UserPrincipal user) {
        HOLDER.set(user);
    }

    public static UserPrincipal get() {
        return HOLDER.get();
    }

    public static void clear() {
        HOLDER.remove();
    }
}

JwtUtil.java

package com.example.demo.security;

import com.example.demo.model.UserPrincipal;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;

@Component
public class JwtUtil {

    private final SecretKey secretKey;
    private final long expireSeconds;

    public JwtUtil(@Value("${app.jwt.secret}") String secret,
                   @Value("${app.jwt.expire-seconds}") long expireSeconds) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expireSeconds = expireSeconds;
    }

    public String generateToken(UserPrincipal user) {
        Date now = new Date();
        Date expire = new Date(now.getTime() + expireSeconds * 1000);

        return Jwts.builder()
                .setSubject(String.valueOf(user.getUserId()))
                .claim("username", user.getUsername())
                .claim("roles", user.getRoles())
                .setIssuedAt(now)
                .setExpiration(expire)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public UserPrincipal parseToken(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Long userId = Long.valueOf(claims.getSubject());
        String username = claims.get("username", String.class);
        List<String> roles = claims.get("roles", List.class);

        return new UserPrincipal(userId, username, roles);
    }
}

5. JWT 认证过滤器

这里我们用 OncePerRequestFilter,保证每次请求只执行一次。

JwtAuthenticationFilter.java

package com.example.demo.security;

import com.example.demo.common.BizException;
import com.example.demo.common.ErrorCode;
import com.example.demo.model.UserPrincipal;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return request.getRequestURI().startsWith("/auth/");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new BizException(ErrorCode.UNAUTHORIZED);
        }

        String token = authHeader.substring(7);
        try {
            UserPrincipal user = jwtUtil.parseToken(token);
            UserContext.set(user);
            filterChain.doFilter(request, response);
        } catch (JwtException | IllegalArgumentException e) {
            throw new BizException(ErrorCode.UNAUTHORIZED);
        } finally {
            UserContext.clear();
        }
    }
}

6. 注册过滤器

DemoApplication.java

package com.example.demo;

import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtFilterRegistration(JwtAuthenticationFilter filter) {
        FilterRegistrationBean<JwtAuthenticationFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(filter);
        bean.addUrlPatterns("/*");
        bean.setOrder(1);
        return bean;
    }
}

7. 登录接口

LoginRequest.java

package com.example.demo.model;

import jakarta.validation.constraints.NotBlank;

public class LoginRequest {

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

    @NotBlank(message = "密码不能为空")
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

AuthController.java

package com.example.demo.controller;

import com.example.demo.common.ApiResponse;
import com.example.demo.common.BizException;
import com.example.demo.common.ErrorCode;
import com.example.demo.model.LoginRequest;
import com.example.demo.model.UserPrincipal;
import com.example.demo.security.JwtUtil;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

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

    private final JwtUtil jwtUtil;

    public AuthController(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/login")
    public ApiResponse<Map<String, String>> login(@Valid @RequestBody LoginRequest request) {
        if (!"admin".equals(request.getUsername()) || !"123456".equals(request.getPassword())) {
            throw new BizException(ErrorCode.BIZ_ERROR.code(), "用户名或密码错误");
        }

        UserPrincipal user = new UserPrincipal(1L, "admin", List.of("ADMIN"));
        String token = jwtUtil.generateToken(user);

        return ApiResponse.success(Map.of("token", token));
    }
}

8. 自定义限流注解

RateLimit.java

package com.example.demo.annotation;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

    int limit();

    int windowSeconds() default 60;
}

9. 限流拦截器

这里为了简单直观,我选择拦截器而不是 AOP。
因为接口级限流天然适合在 HandlerInterceptor 中做,而且可以拿到方法注解。

RateLimitInterceptor.java

package com.example.demo.interceptor;

import com.example.demo.annotation.RateLimit;
import com.example.demo.common.BizException;
import com.example.demo.common.ErrorCode;
import com.example.demo.model.UserPrincipal;
import com.example.demo.security.UserContext;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Objects;

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final StringRedisTemplate redisTemplate;

    public RateLimitInterceptor(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!(handler instanceof HandlerMethod handlerMethod)) {
            return true;
        }

        RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
        if (rateLimit == null) {
            return true;
        }

        UserPrincipal user = UserContext.get();
        String keyPart = user != null ? "user:" + user.getUserId() : "ip:" + request.getRemoteAddr();
        String key = "rate_limit:" + request.getRequestURI() + ":" + keyPart;

        Long count = redisTemplate.opsForValue().increment(key);
        if (Objects.equals(count, 1L)) {
            redisTemplate.expire(key, Duration.ofSeconds(rateLimit.windowSeconds()));
        }

        if (count != null && count > rateLimit.limit()) {
            throw new BizException(ErrorCode.TOO_MANY_REQUESTS);
        }

        return true;
    }
}

10. 注册拦截器

WebMvcConfig.java

package com.example.demo.config;

import com.example.demo.interceptor.RateLimitInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final RateLimitInterceptor rateLimitInterceptor;

    public WebMvcConfig(RateLimitInterceptor rateLimitInterceptor) {
        this.rateLimitInterceptor = rateLimitInterceptor;
    }

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

11. 业务接口

UserController.java

package com.example.demo.controller;

import com.example.demo.annotation.RateLimit;
import com.example.demo.common.ApiResponse;
import com.example.demo.model.UserPrincipal;
import com.example.demo.security.UserContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.Map;

@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/profile")
    @RateLimit(limit = 5, windowSeconds = 60)
    public ApiResponse<Map<String, Object>> profile() {
        UserPrincipal user = UserContext.get();

        return ApiResponse.success(Map.of(
                "userId", user.getUserId(),
                "username", user.getUsername(),
                "roles", user.getRoles(),
                "time", LocalDateTime.now().toString()
        ));
    }
}

12. 统一异常处理

这一部分非常关键。
如果没有它,前面抛出的各种异常就会直接变成默认错误页或杂乱 JSON。

GlobalExceptionHandler.java

package com.example.demo.exception;

import com.example.demo.common.ApiResponse;
import com.example.demo.common.BizException;
import com.example.demo.common.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public ApiResponse<Void> handleBizException(BizException e, HttpServletRequest request) {
        return ApiResponse.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResponse<Void> handleValidException(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldError() != null
                ? e.getBindingResult().getFieldError().getDefaultMessage()
                : ErrorCode.PARAM_ERROR.message();
        return ApiResponse.fail(ErrorCode.PARAM_ERROR.code(), msg);
    }

    @ExceptionHandler(BindException.class)
    public ApiResponse<Void> handleBindException(BindException e) {
        String msg = e.getBindingResult().getFieldError() != null
                ? e.getBindingResult().getFieldError().getDefaultMessage()
                : ErrorCode.PARAM_ERROR.message();
        return ApiResponse.fail(ErrorCode.PARAM_ERROR.code(), msg);
    }

    @ExceptionHandler({
            ConstraintViolationException.class,
            HttpMessageNotReadableException.class,
            IllegalArgumentException.class
    })
    public ApiResponse<Void> handleParamException(Exception e) {
        return ApiResponse.fail(ErrorCode.PARAM_ERROR.code(), ErrorCode.PARAM_ERROR.message());
    }

    @ExceptionHandler(Exception.class)
    public ApiResponse<Void> handleException(Exception e) {
        e.printStackTrace();
        return ApiResponse.fail(ErrorCode.SYSTEM_ERROR.code(), ErrorCode.SYSTEM_ERROR.message());
    }
}

逐步验证清单

到这里项目已经能跑起来了。下面按步骤验证。

1. 登录获取 token

curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{
  "username": "admin",
  "password": "123456"
}'

返回示例:

{
  "code": 0,
  "message": "success",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9..."
  }
}

2. 携带 token 访问受保护接口

curl -X GET 'http://localhost:8080/user/profile' \
-H 'Authorization: Bearer 你的token'

返回示例:

{
  "code": 0,
  "message": "success",
  "data": {
    "username": "admin",
    "roles": ["ADMIN"],
    "time": "2026-01-26T12:00:00",
    "userId": 1
  }
}

3. 不带 token 访问

curl -X GET 'http://localhost:8080/user/profile'

返回:

{
  "code": 401,
  "message": "未登录或token无效",
  "data": null
}

4. 连续调用超过限流阈值

同一分钟内连续调用超过 5 次,返回:

{
  "code": 429,
  "message": "请求过于频繁,请稍后再试",
  "data": null
}

类之间的关系

为了更清楚地理解职责划分,可以看下这个简化类图。

classDiagram
    class JwtUtil {
        +generateToken(user)
        +parseToken(token)
    }

    class JwtAuthenticationFilter {
        +doFilterInternal(request, response, chain)
    }

    class UserContext {
        +set(user)
        +get()
        +clear()
    }

    class RateLimitInterceptor {
        +preHandle(request, response, handler)
    }

    class GlobalExceptionHandler {
        +handleBizException(e)
        +handleException(e)
    }

    class RateLimit {
        <<Annotation>>
        +limit()
        +windowSeconds()
    }

    JwtAuthenticationFilter --> JwtUtil
    JwtAuthenticationFilter --> UserContext
    RateLimitInterceptor --> UserContext
    RateLimitInterceptor --> RateLimit

常见坑与排查

这部分很重要,很多人不是不会写,而是卡在“为什么不生效”。

1. 统一异常处理抓不到过滤器里的异常

这是 Spring Boot 新手到中级最常踩的坑之一。

@RestControllerAdviceController 层及之后 的异常处理很友好,但对 Filter 中直接抛出的异常,有时并不会按你预期那样被全局异常处理器接住。

如果你发现过滤器抛出异常后,响应不是统一 JSON,而是容器默认错误页,说明这里出问题了。

解决思路

更稳妥的方式有两种:

  1. 在过滤器中自己写回响应
  2. 把认证逻辑迁移到 Spring Security 体系

本文为了演示简单直接,使用了抛异常的写法。
但如果你在线上项目里要求绝对稳定,我更建议在过滤器中直接返回 JSON。

例如:

response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未登录或token无效\",\"data\":null}");
return;

2. ThreadLocal 没清理导致串数据

UserContext 使用了 ThreadLocal
如果不在 finallyclear(),线程复用时就可能拿到上一个请求的用户信息,这个问题很隐蔽,也很危险。

这一点我以前线上排查过,表面像是“偶发权限错乱”,本质就是上下文泄漏。

结论:只要用 ThreadLocal,就必须配套 clear。

3. Redis 限流不够精确

当前示例使用的是:

  • increment
  • 第一次访问时设置过期时间

这会有一个边界问题:
如果并发特别高,incrementexpire 不是严格原子操作,理论上可能出现少量误差。

更稳妥的方式

生产环境建议:

  • 用 Lua 脚本保证原子性
  • 或者使用 Redisson / Sentinel / Bucket4j 等成熟方案

4. JWT 中不要塞太多信息

很多人喜欢把用户所有资料都放进 token,包括手机号、部门、菜单、组织树……
这会导致:

  • token 变大
  • 网络传输开销增加
  • 用户信息更新后 token 不一致
  • 敏感数据泄露风险变高

建议只放身份识别和最小权限信息

  • userId
  • username
  • roleCodes
  • jti(可选)

5. 过期时间设置不合理

过短会导致用户频繁掉线,过长会增加被盗用风险。

常见实践:

  • access token:15 分钟 ~ 2 小时
  • refresh token:7 天 ~ 30 天

本文为了演示只用了单 token 模式。
如果是正式后台系统,建议做成 access token + refresh token


安全/性能最佳实践

写到这里,功能已经完成了,但距离“可上线”还有一段距离。下面是我更建议你落地时一起考虑的点。

1. 优先接入 Spring Security

如果项目只是 Demo,用自定义 Filter 足够。
但如果你准备长期维护,建议尽早接入 Spring Security,因为它天然提供:

  • 认证过滤链
  • 授权控制
  • 异常处理入口
  • 上下文管理
  • 与方法级权限注解整合

也就是说,本文这套思路没有问题,但正式项目可以把它迁移到更标准的安全框架里。

2. JWT 密钥必须安全管理

不要把密钥写死在仓库里。建议:

  • 放到环境变量
  • 放到配置中心
  • 更高要求下使用 KMS / Vault

同时注意:

  • 密钥长度要足够
  • 定期轮换
  • 区分测试环境与生产环境

3. 限流要分层设计

不要把“接口防刷”只理解成 Controller 层限流。
更完整的设计通常包括:

  • 网关限流
  • 应用层限流
  • 业务级幂等
  • 异常流量告警

比如:

  • 登录接口按 IP 限流
  • 短信接口按手机号 + IP 双限流
  • 下单接口要配合幂等 Token
  • 热点接口可以加本地缓存或布隆过滤器

4. 错误码体系要稳定

统一异常处理不是简单地返回字符串,而是要建立一套可维护的错误码体系。

建议:

  • 参数错误:4xx 语义
  • 认证失败:401
  • 权限不足:403
  • 限流触发:429
  • 业务错误:自定义业务码段,如 1000~1999
  • 系统错误:500

这样前端和调用方才知道该怎么处理,而不是只会弹一个“系统异常”。

5. 日志要记录关键信息,但不要泄露敏感数据

建议日志中记录:

  • traceId
  • userId
  • requestURI
  • clientIp
  • errorCode
  • 耗时

不要直接打印:

  • 明文密码
  • 完整 token
  • 身份证号、银行卡号等敏感字段

JWT 最多打印前后几位做排查即可。

6. 高并发场景下选更合适的限流算法

本文用了固定窗口,优点是简单。
但在流量更复杂的场景下,可以考虑:

  • 滑动窗口
  • 漏桶
  • 令牌桶

简单对比

  • 固定窗口:实现简单,但边界突刺明显
  • 滑动窗口:更平滑,但实现复杂一些
  • 令牌桶:很适合控制平均速率,工程中很常见

如果你只是后台管理系统,固定窗口通常够用。
如果是开放 API 或高并发业务,建议令牌桶优先。


可以继续扩展的方向

在当前方案上,通常还会继续加这些能力:

  • RBAC 权限模型:角色、菜单、按钮权限
  • Refresh Token:无感续期
  • 黑名单机制:注销后让 token 失效
  • 多端登录控制:单端登录 / 多端共存
  • 操作审计日志:谁在什么时候做了什么
  • traceId 透传:方便链路追踪

这也是中级开发走向高级时很关键的一步:
不只是“接口能用”,而是能从认证、稳定性、安全性、可观测性几个维度一起设计。


总结

这篇文章我们做了一套完整但不臃肿的 Spring Boot Web 实战方案:

  • JWT 完成无状态认证
  • Redis + 拦截器 实现接口防刷
  • @RestControllerAdvice 实现统一异常处理
  • 用统一响应结构让前后端协作更顺畅

如果你准备把它真正放进项目里,我的建议是:

  1. Demo 阶段:先按本文方式跑通全链路
  2. 正式项目:迁移到 Spring Security
  3. 线上场景:限流改为 Lua 或成熟组件,补齐日志、告警、traceId
  4. 安全要求高:接入 refresh token、黑名单、密钥托管

最后给一个边界判断:

  • 如果你的系统是内部管理后台、用户量不大,本文方案已经足够实用
  • 如果你的系统是对公网开放的 API 平台,就要进一步加强认证、限流和审计设计

先把基础设施搭稳,再谈复杂业务,开发体验会好很多。很多线上故障,归根到底不是业务难,而是这些底层能力没提前补齐。


分享到:

上一篇
《从浏览器指纹到请求签名:Web逆向中前端加密参数定位与复现实战》
下一篇
《Spring Boot 中基于 Actuator + Micrometer + Prometheus 的应用监控体系实战搭建与告警优化》