多用户跨学科交流系统(4)参数校验+分页搜索全流程的实现

目录

  • 🌸参数校验
    • [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深挖)
    • 一、分页与性能
    • [二、搜索逻辑与 SQL 优化](#二、搜索逻辑与 SQL 优化)
    • [三、MyBatis 深度题](#三、MyBatis 深度题)
    • [四、Service / Controller 设计](#四、Service / Controller 设计)
    • 五、项目实际场景

本篇博客基于之前三篇继续做模块完善,适用于初学者。

🍿摘要:

  1. 本文介绍了Spring Boot项目中参数校验与JWT登录保护的实现方案。
  2. 参数校验通过在实体类和DTO上添加注解(如@NotBlank、@NotNull),配合@Valid注解和全局异常处理实现统一校验。
  3. 特别强调Service层应专注业务逻辑,避免参数校验代码。
  4. 针对JWT登录保护,优化了Security配置和Filter实现,解决原方案中的跨域、Token解析等问题,实现了基于Token的身份认证机制。主要包括:放行登录注册接口、自动过滤OPTIONS请求、解析Bearer Token并验证有效性、将认证信息存入SecurityContext等核心功能。

🌸参数校验

总体原则:

  1. 校验卸载实体类 or DTO上
  2. Controller 参数前加 @Valid
  3. 异常由全局异常处理统一输出
  4. Service层不写 if 去判断空,只专注业务

加依赖

java 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

1. Comment 参数校验

  1. 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;
}
  1. CommentController加@Valid
java 复制代码
@PostMapping
    public Result<String> addComment(@Valid @RequestBody Comment comment){
        commentService.addComment(comment);
        return Result.success("评论发布成功");
    }

2. Post模块参数校验

  1. 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;
}
  1. PostController中的addPost函数里的参数前面加@Valid

3. User模块参数校验

不要直接用User实体类做校验。 因为User里有太多字段,比如role、加密密码、id,这些不该让前端传。

所以我们写两个DTO。

DTO = Data Transfer Object

(专门用于 Controller 接收参数的类)

比如注册接口:

  • 只需要 username 和 password
  • 你就写一个只包含这两个字段的类

这样更安全、更干净、更好维护。

  1. 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;
}
  1. 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;
}
  1. 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");

    }
  1. 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登录保护体系修改

修改目标:

  1. 让用户在登录后拿着 Token 才能访问文章、评论等接口,实现基础的身份认证机制。
  2. 登录、注册接口放行,其他所有接口全部需要 Token。SecurityFilterChain 调整为符合 Spring Security 最新写法。
  3. 修复JwtFilter的核心问题

原来的JwtFilter 存在以下问题:

  1. 没有交给 Spring 管理
  2. 不能处理 Bearer token 格式
  3. 不支持跨域 OPTIONS 请求(会导致前端 403)
  4. 多次解析 token、性能低
  5. 直接输出字符串响应,不规范
  6. 没有异常捕获(token 过期会报 500)
  1. 实现了一个真正可用的 JWT 校验流程

功能包括:

  1. 放行 /login、/register
  2. 自动过滤 OPTIONS
  3. 从 Authorization 中读取 Bearer token
  4. 解析 token,验证合法性和有效期
  5. 将认证信息写入 Spring SecurityContext
  6. 从而实现 Spring Security 能识别当前用户身份。
  1. 代码修改:
  • 删除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

  1. 将之前entity下的Result类也移到dto下。
  2. 新建类
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 是啥?
    1. 它是 MySQL(和 PostgreSQL)里用来做"取第几页数据"的语法。
      LIMIT = 每页多少条
      OFFSET = 跳过多少条
    2. 另一个写法(等价):LIMIT offset, size
    3. 想要分页,必须加!

4. Service层

  1. PostService 接口中新增:
java 复制代码
    PageResult<Post> searchPosts(String keyword, int page, int size);
  1. 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 会发生什么性能问题?有没有证明?如何优化?

考察点:数据库内部执行机制

思考方向:

  1. OFFSET 会扫描前 N 行再丢掉 → 本质是跳页,并不是"直接从中间开始查"

  2. explain 可以看到 "Using where; Using filesort" 或 "Using index"

  3. 优化方式:

    基于索引的游标分页(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

  • 热词缓存

  • 降级策略(关键字为空直接不查)

相关推荐
小池先生2 小时前
Gradle vs Maven 详细对比
java·maven
q***23922 小时前
基于SpringBoot和PostGIS的云南与缅甸的千里边境线实战
java·spring boot·spring
q***78783 小时前
Spring Boot的项目结构
java·spring boot·后端
百***17073 小时前
Spring Boot spring.factories文件详细说明
spring boot·后端·spring
q***96583 小时前
Spring Data JDBC 详解
java·数据库·spring
Kuo-Teng3 小时前
LeetCode 118: Pascal‘s Triangle
java·算法·leetcode·职场和发展·动态规划
倚肆3 小时前
HttpServletResponse 与 ResponseEntity 详解
java·后端·spring
Q_Q5110082853 小时前
python+django/flask的宠物用品系统vue
spring boot·python·django·flask·node.js·php
悟能不能悟3 小时前
java List怎么转换为Vector
java·windows·list