目录
- 🌸参数校验
-
- [1. Comment 参数校验](#1. Comment 参数校验)
- [2. Post模块参数校验](#2. Post模块参数校验)
- [3. User模块参数校验](#3. User模块参数校验)
- [4. 全局异常处理补充参数校验异常](#4. 全局异常处理补充参数校验异常)
- 🌸JWT登录保护体系修改
- 🌸分页+搜索
-
- [1. 分页DTO](#1. 分页DTO)
- [2. Mapper接口](#2. Mapper接口)
- [3. Mapper XML](#3. Mapper XML)
- [4. Service层](#4. Service层)
- [5. Controller层(带 @RequestParam)](#5. Controller层(带 @RequestParam))
- 🌸面试问题模拟(分页+搜索+MyBatis深挖)
本篇博客基于之前三篇继续做模块完善,适用于初学者。
🍿摘要:
- 本文介绍了Spring Boot项目中参数校验与JWT登录保护的实现方案。
- 参数校验通过在实体类和DTO上添加注解(如@NotBlank、@NotNull),配合@Valid注解和全局异常处理实现统一校验。
- 特别强调Service层应专注业务逻辑,避免参数校验代码。
- 针对JWT登录保护,优化了Security配置和Filter实现,解决原方案中的跨域、Token解析等问题,实现了基于Token的身份认证机制。主要包括:放行登录注册接口、自动过滤OPTIONS请求、解析Bearer Token并验证有效性、将认证信息存入SecurityContext等核心功能。
🌸参数校验
总体原则:
- 校验卸载实体类 or DTO上
- Controller 参数前加 @Valid
- 异常由全局异常处理统一输出
- Service层不写 if 去判断空,只专注业务
加依赖
java
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
1. Comment 参数校验
- Comment 实体类
java
package com.example.blog.entity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Comment {
private Long id;
@NotNull(message = "post_id 不能为空")
private Long post_id;
@NotNull(message = "user_id 不能为空")
private Long user_id;
private Long parent_id;
@NotBlank(message = "评论内不能为空")
private String content;
private LocalDateTime created_at;
}
- CommentController加@Valid
java
@PostMapping
public Result<String> addComment(@Valid @RequestBody Comment comment){
commentService.addComment(comment);
return Result.success("评论发布成功");
}
2. Post模块参数校验
- Post实体类加校验
java
@Data
public class Post {
private Long id;
@NotBlank(message = "标题不能为空")
private String title;
@NotBlank(message = "内容不能为空")
private String content;
private String status;
@NotNull(message = "topicId 不能为空")
private Long topicId;
@NotNull(message = "userId 不能为空")
private Long userId;
private LocalDateTime created_at;
private LocalDateTime updated_at;
}
- PostController中的
addPost函数里的参数前面加@Valid
3. User模块参数校验
不要直接用User实体类做校验。 因为User里有太多字段,比如role、加密密码、id,这些不该让前端传。
所以我们写两个DTO。
DTO = Data Transfer Object
(专门用于 Controller 接收参数的类)
比如注册接口:
- 只需要 username 和 password
- 你就写一个只包含这两个字段的类
这样更安全、更干净、更好维护。
- RegisterDTO
java
package com.example.blog.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RegisterDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 4, max = 16)
private String password;
}
- LoginDTO
java
package com.example.blog.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
- UserController
加@Valid
java
@PostMapping("/register")
public Result<?> register(@Valid @RequestBody RegisterDTO dto) {
userService.register(dto);
return Result.success("注册成功");
}
// 💡 登录接口
@PostMapping("/login")
public Result<?> login(@Valid @RequestBody LoginDTO dto) {
User dbUser = userService.login(dto.getUsername(), dto.getPassword());
// 登录成功,签发Token
String token = JwtUtil.generateToken(dbUser.getUsername());
return Result.success("token");
}
- UserService(配合DTO)
UserService.register() 里面的逻辑完全不用管 "密码是否为空""用户名是否太短"这类低级问题。
所有脏东西在 Controller 直接被拦掉了 ✨
仅修改以下部分:
java
public void register(RegisterDTO dto) {
//用户名重复
if(userMapper.findByUsername(dto.getUsername())!=null) {
throw new BusinessException(1001,"用户名已存在");
}
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(encoder.encode(dto.getPassword()));
user.setRole("user");
int rows = userMapper.insert(user);
if(rows!=1) {
throw new BusinessException(1002,"注册失败,请稍后再试");
}
}
4. 全局异常处理补充参数校验异常
校验失败 → 自动抛出 MethodArgumentNotValidException
GlobalExceptionHandler 加:
java
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<?> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldError().getDefaultMessage();
return Result.error(400, msg);
}
现在所有校验错误都会自动返回统一格式。
🌸JWT登录保护体系修改
修改目标:
- 让用户在登录后拿着 Token 才能访问文章、评论等接口,实现基础的身份认证机制。
- 登录、注册接口放行,其他所有接口全部需要 Token。SecurityFilterChain 调整为符合 Spring Security 最新写法。
- 修复JwtFilter的核心问题
原来的JwtFilter 存在以下问题:
- 没有交给 Spring 管理
- 不能处理 Bearer token 格式
- 不支持跨域 OPTIONS 请求(会导致前端 403)
- 多次解析 token、性能低
- 直接输出字符串响应,不规范
- 没有异常捕获(token 过期会报 500)
- 实现了一个真正可用的 JWT 校验流程
功能包括:
- 放行 /login、/register
- 自动过滤 OPTIONS
- 从 Authorization 中读取 Bearer token
- 解析 token,验证合法性和有效期
- 将认证信息写入 Spring SecurityContext
- 从而实现 Spring Security 能识别当前用户身份。
- 代码修改:
- 删除config包下的FilterConfig
- 进行以下修改:
java
// config/SecurityConfig
package com.example.blog.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.example.blog.filter.JwtFilter;
@Configuration
public class SecurityConfig {
@Autowired
private JwtFilter jwtFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/user/register", "/user/login").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable());
return http.build();
}
}
java
// filter/JwtFilter
package com.example.blog.filter;
import com.example.blog.utils.JwtUtil;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.io.IOException;
import java.util.List;
@Component
public class JwtFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
// 放行 OPTIONS(跨域预检)
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
chain.doFilter(request, response);
return;
}
String path = req.getRequestURI();
// 登录 & 注册放行
if (path.contains("/login") || path.contains("/register")) {
chain.doFilter(request, response);
return;
}
// 从 header 取 token
String authHeader = req.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.getWriter().write("Missing or invalid Authorization header");
return;
}
String token = authHeader.substring(7); // 去掉 "Bearer "
String username;
try {
username = JwtUtil.parseToken(token);
} catch (Exception e) {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.getWriter().write("Token invalid or expired");
return;
}
// 设置用户身份(告诉 Spring Security 当前是谁)
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
username, null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
- 现在除了注册登录接口,其他接口测试带token的格式为:
java
Bearer(空格)"token"
以删除评论接口为例

🌸分页+搜索
流程:
Controller ------接收分页参数、关键字
↓
Service ------计算 offset、调用 Mapper
↓
Mapper ------执行 SQL(search + count)
↓
数据库 ------返回数据列表 + 总条数
↓
Service ------组装 PageResult
↓
Controller ------返回给前端 / Apifox
为啥分页?
如果你一次性查出来500条数据返回给前端:
数据库压力大、网络传输慢、前端渲染卡顿!
如果分页:
第一页:10条
第2页:10条
......
1. 分页DTO
- 将之前entity下的Result类也移到dto下。
- 新建类
java
// dto/PageResult
package com.example.blog.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
@Data
@AllArgsConstructor
public class PageResult<T> {
private List<T> content; // 当前页数据
private long totalElements; // 总条数
private int totalPages; // 总页数
}
如果你只写了 @Data 而没有 @AllArgsConstructor,Lombok 默认只生成无参构造器和 getter/setter。
2. Mapper接口
PostMapper中新增:
java
// 分页+搜索
List<Post> searchPosts(
@Param("keyword") String keyword,
@Param("offset") int offset,
@Param("size") int size
);
int countSearchPosts(@Param("keyword") String keyword);
多参数必须要用 @Param
3. Mapper XML
src/main/resources/Mapper/PostMapper.xml 中新增
java
<select id="searchPosts" resultType="Post">
SELECT * FROM post
WHERE title LIKE CONCAT('%', #{keyword}, '%')
OR content LIKE CONCAT('%', #{keyword}, '%')
LIMIT #{size} OFFSET #{offset}
</select>
<select id="countSearchPosts" resultType="int">
SELECT COUNT(*) FROM post
WHERE title LIKE CONCAT('%', #{keyword}, '%')
OR content LIKE CONCAT('%', #{keyword}, '%')
</select>
SQL占位符必须与 Param 名称一致。
- LIMIT + OFFSET 是啥?
- 它是 MySQL(和 PostgreSQL)里用来做"取第几页数据"的语法。
LIMIT= 每页多少条
OFFSET= 跳过多少条 - 另一个写法(等价):LIMIT offset, size
- 想要分页,必须加!
- 它是 MySQL(和 PostgreSQL)里用来做"取第几页数据"的语法。
4. Service层
- PostService 接口中新增:
java
PageResult<Post> searchPosts(String keyword, int page, int size);
- PostServiceImpl中新增:
java
@Override
public PageResult<Post> searchPosts(String keyword, int page, int size) {
int offset = (page-1) * size;
List<Post> content = postMapper.searchPosts(keyword, offset, size);
int total = postMapper.countSearchPosts(keyword);
int totalPages = (total + size - 1) / size; // 向上取整
return new PageResult<>(content, total, totalPages);
}
注意:计算offset时,前端page从1开始
5. Controller层(带 @RequestParam)
java
@GetMapping("/posts/search")
public PageResult<Post> searchPosts(
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
return postService.searchPosts(keyword, page, size);
}
- @Param 和 @RequestParam 完全不同
| 注解 | 用途 |
|---|---|
| @Param | MyBatis 注解 ------ 给 SQL 参数命名 |
| @RequestParam | Spring MVC 注解 ------ 接收 URL 里的 query 参数 |
🌸面试问题模拟(分页+搜索+MyBatis深挖)
一、分页与性能
1、如果数据量非常大(百万级),LIMIT + OFFSET 会发生什么性能问题?有没有证明?如何优化?
考察点:数据库内部执行机制
思考方向:
OFFSET 会扫描前 N 行再丢掉 → 本质是跳页,并不是"直接从中间开始查"
explain 可以看到 "Using where; Using filesort" 或 "Using index"
优化方式:
基于索引的游标分页(id > last_id)
覆盖索引 + limit
反向分页(倒序)
或者 ES(分词搜索)
2、如果分页时 page = -1 或 size 特别大,你的接口如何防止被恶意调用导致数据库被打爆?
考察点:接口防护思维
思考方向:
- 参数合法性校验
- size 上限(例如最大 100)
- 限流(网关或接口级)
二、搜索逻辑与 SQL 优化
3、LIKE '%keyword%' 为什么非常慢?如何分析是否走索引?
考察点:SQL 优化、索引规则
思考方向:
- 前置 % 会导致索引失效
- explain 看 type 是否为 ALL(全表扫描)
- 优化方案:
- 使用全文索引(MySQL FULLTEXT)
- 分词搜索(ES / Sphinx)
- 业务端分词后多词匹配
4、如果 keyword 为空,你的 SQL 是 WHERE title LIKE '%%',这种写法在大表下有什么隐患?
思考:
会导致全表扫描
若表很大,会成为慢查询
可以使用动态 SQL:keyword 空时不加 WHERE,走普通分页
5、WHERE title LIKE CONCAT('%', #{kw}, '%') OR content LIKE ... 这种写法有什么隐患?
考察点:
OR 会严重影响性能
索引失效
可能会触发 filesort、temporary
优化方向:
分开两个 LIKE 查询结果 union
或者使用两列全文索引
三、MyBatis 深度题
6、你写过多参数传递导致 TypeException,能说说 MyBatis 多参数到底是怎么封装的吗?
考察点:源码理解(实习面很加分)
思考方向:
多参数时,MyBatis 使用 ParamNameResolver
最终封成一个 Map
param1, param2...
或 @Param 指定名字
XML 里用 #{keyword} 时,需要对上 Map 的 key
如果 XML 里写 #{keyword} 但 Map 只有 param1 → 报错
7、为什么单参数时 #{keyword} 可以用,但多个参数时就不行?
考察点:细节理解
思考:
单参数时 MyBatis 会自动把参数名替换为 param1 和原名
多参数时没有真实参数名,只用 param1/param2
8、在 MyBatis 里,#{keyword} 和 ${keyword} 有什么底层差别?分别适合什么场景?
考察点:SQL 注入 + 预编译机制
思路:
#{} → 预编译 → 安全
${} → 字符串拼接 → 有风险
我们这里 LIKE 拼接必须用 #{},不能 ${},否则可能注入
9、如果你现在需要动态拼接搜索条件(标题、内容、作者都可选),你如何写 MyBatis XML?写法能否兼容多个条件?
考察点:动态 SQL 的熟练度
思路:
<where>、<if>、<trim>重点:自动去掉多余 AND
10、Mapper 层写 LIMIT #{size} OFFSET #{offset} 和 LIMIT #{offset}, #{size} 有啥区别?哪种更标准?
考察点:细节
方向:
MySQL 支持两种,标准语法是:LIMIT offset, size
PostgreSQL 是 LIMIT size OFFSET offset
你的写法跟数据库兼容性相关
四、Service / Controller 设计
11、分页 offset 在 Controller 算还是 Service 算?为什么?
考察点:分层思想
思路:
业务逻辑不应写在 Controller
Controller 就是参数解析 + 调用 Service
Service 计算 offset 更合理
Mapper 尽量只承接简单 SQL,不做业务转换
12、搜索接口如何设计才能让前端分页时既能展示总条数,又不影响效率?
考察点:实际业务经验
方向:
查询记录
查询 count
有必要拆成两个查询,不要 count + data 一起查
count 单独查更快
13、你设计 PageResult 这个类时考虑了哪些字段?为什么这样设计?
考察点:对象封装习惯
方向:
content(返回列表)
totalElements(总数)
totalPages
currentPage
size
是否有下一页
五、项目实际场景
14、如果搜索关键字很长(比如 200 字),你的 LIKE 会怎么样?如何限制?
考点:安全 + 性能
方向:
长 LIKE 会导致 Range Scan 放大
甚至拖垮索引缓存
限制最大长度(例如 50)
过长直接返回空或报错
15、怎么缓存分页结果?会有什么坑?你会缓存吗?
考点:缓存策略理解
方向:
缓存分页结果困难:数据更新时所有页缓存都失效
多维度(page + keyword)导致 key 巨多
不推荐对动态搜索分页使用缓存
可缓存热门搜索词
16、如果前端频繁调你的搜索接口(用户疯狂输入字符),你如何防止数据库被压垮?
考点:防抖 + 限流
方向:
前端输入框防抖(300ms)
后端限流:Redis + Token Bucket
热词缓存
降级策略(关键字为空直接不查)