多用户跨学科交流系统(4)参数校验+分页搜索全流程的实现
目录
- 🌸参数校验
- 1. Comment 参数校验
- 2. Post模块参数校验
- 3. User模块参数校验
- 4. 全局异常处理补充参数校验异常
- 🌸JWT登录保护体系修改
- 🌸分页+搜索
- 1. 分页DTO
- 2. Mapper接口
- 3. Mapper XML
- 4. Service层
- 5. Controller层(带 @RequestParam)
- 🌸面试问题模拟(分页+搜索+MyBatis深挖)
- 一、分页与性能
- 二、搜索逻辑与 SQL 优化
- 三、MyBatis 深度题
- 四、Service / Controller 设计
- 五、项目实际场景
本篇博客基于之前三篇继续做模块完善,适用于初学者。
🍿摘要:
- 本文介绍了Spring Boot项目中参数校验与JWT登录保护的实现方案。
- 参数校验通过在实体类和DTO上添加注解(如@NotBlank、@NotNull),配合@Valid注解和全局异常处理实现统一校验。
- 特别强调Service层应专注业务逻辑,避免参数校验代码。
- 针对JWT登录保护,优化了Security配置和Filter实现,解决原方案中的跨域、Token解析等问题,实现了基于Token的身份认证机制。主要包括:放行登录注册接口、自动过滤OPTIONS请求、解析Bearer Token并验证有效性、将认证信息存入SecurityContext等核心功能。
🌸参数校验
总体原则:
- 校验卸载实体类 or DTO上
- Controller 参数前加 @Valid
- 异常由全局异常处理统一输出
- Service层不写 if 去判断空,只专注业务
加依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId>
</dependency>
1. Comment 参数校验
- Comment 实体类
package com.example.blog.entity;import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;@Datapublic 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
@PostMappingpublic Result<String> addComment(@Valid @RequestBody Comment comment){commentService.addComment(comment);return Result.success("评论发布成功");}
2. Post模块参数校验
- Post实体类加校验
@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
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
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
@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());// 登录成功,签发TokenString token = JwtUtil.generateToken(dbUser.getUsername());return Result.success("token");}
- UserService(配合DTO)
UserService.register() 里面的逻辑完全不用管 “密码是否为空”“用户名是否太短”这类低级问题。
所有脏东西在 Controller 直接被拦掉了 ✨
仅修改以下部分:
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 加:
@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
- 进行以下修改:
// config/SecurityConfigpackage 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 {@Autowiredprivate JwtFilter jwtFilter;@Beanpublic 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();}
}
// filter/JwtFilterpackage 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 {@Overridepublic 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 取 tokenString 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的格式为:
Bearer(空格)"token"
以删除评论接口为例

🌸分页+搜索
流程:
Controller ——接收分页参数、关键字
↓
Service ——计算 offset、调用 Mapper
↓
Mapper ——执行 SQL(search + count)
↓
数据库 ——返回数据列表 + 总条数
↓
Service ——组装 PageResult
↓
Controller ——返回给前端 / Apifox
为啥分页?
如果你一次性查出来500条数据返回给前端:
数据库压力大、网络传输慢、前端渲染卡顿!
如果分页:
第一页:10条
第2页:10条
……
1. 分页DTO
- 将之前entity下的Result类也移到dto下。
- 新建类
// 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中新增:
// 分页+搜索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 中新增
<select id="searchPosts" resultType="Post">SELECT * FROM postWHERE title LIKE CONCAT('%', #{keyword}, '%')OR content LIKE CONCAT('%', #{keyword}, '%')LIMIT #{size} OFFSET #{offset}</select><select id="countSearchPosts" resultType="int">SELECT COUNT(*) FROM postWHERE 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 接口中新增:
PageResult<Post> searchPosts(String keyword, int page, int size);
- PostServiceImpl中新增:
@Overridepublic 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)
@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
热词缓存
降级策略(关键字为空直接不查)
