多用户跨学科交流系统(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

  • 热词缓存

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

相关推荐
咩咩啃树皮10 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚10 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
大模型码小白12 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程
腾渊信息科技公司12 小时前
Spring Boot对接MES实战:视觉检测数据自动同步方案
java·人工智能·spring boot·后端·计算机视觉·ai·软件需求
爱笑的源码基地13 小时前
高并发 Redis 缓存门诊HIS系统源码,含财务统计药房进销存
java·程序·门诊系统·诊所系统·云诊所源码
wuqingshun31415913 小时前
TCP超时重传机制是为了解决什么问题?
java
莫逸风16 小时前
【AgentScope 2.0】 0. 学习指南
java·llm·agent·agentscope
隔窗听雨眠16 小时前
Spring Boot在云原生时代的编程范式革新研究
spring boot·后端·云原生
z1234567898616 小时前
2026最新两款AI编程工具深度对比实测
java·数据库·ai编程
yaoxin52112317 小时前
470. Java 反射 - Member 接口与 AccessFlag
java·开发语言·python