Java Web开发实战:基于Spring Boot与JWT实现权限认证、接口防刷与统一异常处理
在很多 Java Web 项目里,登录认证、接口防刷、异常处理,往往不是“不会做”,而是“做得不完整”。
最常见的情况是这样的:
- 登录接口能发 token,但校验逻辑散落在各处
- 接口被恶意高频调用后,系统才想起要加限流
- 代码里
try-catch满天飞,前端拿到的错误结构五花八门 - 明明是业务异常,却最终返回成 500,排查起来很痛苦
这篇文章我想用一个可运行的 Spring Boot 小项目,带你把这三件事串起来:
- JWT 权限认证
- 接口防刷(限流)
- 统一异常处理
重点不是堆概念,而是做成一个中级开发在实际项目里能直接迁移的方案。
背景与问题
先说为什么这三个点适合一起设计。
在真实 Web 服务中,请求进入系统后,通常会经过这样一条链路:
- 用户携带 token 发起请求
- 服务端校验身份与权限
- 判断接口调用频率是否合法
- 执行业务逻辑
- 如果出错,统一返回可读、可追踪的错误信息
如果这几个环节分散处理,就很容易出现:
- 认证逻辑与业务代码耦合
- 限流逻辑写在 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 中放入
userId、username、roles - 每次请求带上
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 新手到中级最常踩的坑之一。
@RestControllerAdvice 对 Controller 层及之后 的异常处理很友好,但对 Filter 中直接抛出的异常,有时并不会按你预期那样被全局异常处理器接住。
如果你发现过滤器抛出异常后,响应不是统一 JSON,而是容器默认错误页,说明这里出问题了。
解决思路
更稳妥的方式有两种:
- 在过滤器中自己写回响应
- 把认证逻辑迁移到 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。
如果不在 finally 中 clear(),线程复用时就可能拿到上一个请求的用户信息,这个问题很隐蔽,也很危险。
这一点我以前线上排查过,表面像是“偶发权限错乱”,本质就是上下文泄漏。
结论:只要用 ThreadLocal,就必须配套 clear。
3. Redis 限流不够精确
当前示例使用的是:
increment- 第一次访问时设置过期时间
这会有一个边界问题:
如果并发特别高,increment 与 expire 不是严格原子操作,理论上可能出现少量误差。
更稳妥的方式
生产环境建议:
- 用 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实现统一异常处理 - 用统一响应结构让前后端协作更顺畅
如果你准备把它真正放进项目里,我的建议是:
- Demo 阶段:先按本文方式跑通全链路
- 正式项目:迁移到 Spring Security
- 线上场景:限流改为 Lua 或成熟组件,补齐日志、告警、traceId
- 安全要求高:接入 refresh token、黑名单、密钥托管
最后给一个边界判断:
- 如果你的系统是内部管理后台、用户量不大,本文方案已经足够实用
- 如果你的系统是对公网开放的 API 平台,就要进一步加强认证、限流和审计设计
先把基础设施搭稳,再谈复杂业务,开发体验会好很多。很多线上故障,归根到底不是业务难,而是这些底层能力没提前补齐。