前面十五篇,我们把 JavaEE 规范、Servlet、Spring 核心原理、数据访问、AOP、事务、自动配置都过了一遍。知识点拆开讲是一回事,把它们组合在一起解决实际问题,是另一回事。
这一篇,我们不再拆解某个组件的原理,而是用 Spring Boot 3.x + MyBatis-Plus 3.5.x + JWT,从零到一构建一个完整的博客系统后端。
这个项目会用到前面讲过的几乎所有知识:IoC 容器管理 Bean、Spring MVC 处理请求、MyBatis-Plus 操作数据库、拦截器做登录校验、JWT 做身份认证、BCrypt 加密密码、全局异常处理统一返回格式。最终你会得到一个可运行、带接口文档、具备完整认证授权功能的后端服务。
学习目标
- 综合运用 Spring Boot + MyBatis-Plus + JWT 完成完整项目
- 掌握前后端分离项目的接口设计规范
- 掌握 JWT 令牌的生成、传递、校验全流程
- 掌握拦截器实现强制登录
- 掌握 BCrypt 加密/加盐保护用户密码
正文
一、项目需求与功能拆解
我们要构建的是一个个人博客系统,包含以下核心功能模块:
| 模块 | 功能 | 接口 |
|---|---|---|
| 用户认证 | 注册、登录、获取当前用户信息 | /api/auth/register、/api/auth/login、/api/auth/me |
| 文章管理 | 发布、编辑、删除、查询列表、查看详情 | /api/posts(CRUD) |
| 分类管理 | 文章分类的增删改查 | /api/categories(CRUD) |
| 评论管理 | 对文章发表评论、查看评论列表 | /api/comments |
接口设计规范:
- 所有接口统一前缀
/api - 返回格式统一为
Result<T>结构:{ code, message, data } - 认证接口(登录/注册)不需要 Token
- 其他接口需要在请求头中携带
Authorization: Bearer <token> - 使用 HTTP 状态码 + 业务 code 双重标识:HTTP 200 表示请求成功,业务 code 为 0 表示成功,非 0 表示业务异常
二、数据库设计
我们一共设计三张核心表:用户表、文章表、分类表。
用户表(t_user) :
sql
CREATE TABLE t_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
password VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)',
email VARCHAR(100) NOT NULL COMMENT '邮箱',
avatar VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
role VARCHAR(20) DEFAULT 'USER' COMMENT '角色',
status TINYINT DEFAULT 1 COMMENT '状态:0禁用 1启用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
文章表(t_post) :
sql
CREATE TABLE t_post (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '文章ID',
title VARCHAR(200) NOT NULL COMMENT '标题',
content LONGTEXT NOT NULL COMMENT '内容',
summary VARCHAR(500) DEFAULT NULL COMMENT '摘要',
category_id BIGINT DEFAULT NULL COMMENT '分类ID',
author_id BIGINT NOT NULL COMMENT '作者ID',
view_count INT DEFAULT 0 COMMENT '浏览次数',
status TINYINT DEFAULT 1 COMMENT '状态:0草稿 1已发布',
is_deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
分类表(t_category) :
sql
CREATE TABLE t_category (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID',
name VARCHAR(50) NOT NULL UNIQUE COMMENT '分类名称',
description VARCHAR(200) DEFAULT NULL COMMENT '分类描述',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分类表';
三、项目骨架搭建
项目结构:
blog-backend/
├── src/main/java/com/example/blog/
│ ├── BlogApplication.java # 启动类
│ ├── common/ # 公共模块
│ │ ├── Result.java # 统一响应
│ │ └── ResultCode.java # 响应码枚举
│ ├── config/ # 配置类
│ │ ├── WebConfig.java # Web 配置(拦截器、CORS)
│ │ ├── MyBatisPlusConfig.java # MyBatis-Plus 配置
│ │ └── SecurityConfig.java # 安全配置(BCrypt)
│ ├── interceptor/ # 拦截器
│ │ └── LoginInterceptor.java # 登录校验
│ ├── entity/ # 实体类
│ │ ├── User.java
│ │ ├── Post.java
│ │ └── Category.java
│ ├── mapper/ # Mapper 接口
│ │ ├── UserMapper.java
│ │ ├── PostMapper.java
│ │ └── CategoryMapper.java
│ ├── service/ # Service 层
│ │ ├── UserService.java
│ │ ├── PostService.java
│ │ └── CategoryService.java
│ ├── controller/ # Controller 层
│ │ ├── AuthController.java
│ │ ├── PostController.java
│ │ └── CategoryController.java
│ └── util/ # 工具类
│ ├── JwtUtil.java # JWT 工具类
│ └── UserContext.java # 线程上下文
└── src/main/resources/
├── application.yml
└── mapper/ # MyBatis XML
依赖(pom.xml 核心部分) :
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web 层 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 数据访问 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- 密码加密 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>6.2.0</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
配置文件(application.yml) :
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/blog_db?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.blog.entity
global-config:
db-config:
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
jwt:
secret: your-256-bit-secret-key-for-jwt-signing
expiration: 604800000 # 7天(毫秒)
server:
port: 8080
四、公共模块编写
统一响应类 Result :
java
package com.example.blog.common;
import lombok.Data;
@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
private Result(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> Result<T> success(T data) {
return new Result<>(0, "success", data);
}
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
}
全局异常处理:
java
package com.example.blog.handler;
import com.example.blog.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public Result<String> handleRuntimeException(RuntimeException e) {
log.error("运行时异常", e);
return Result.error(500, e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return Result.error(400, message);
}
// 自定义业务异常
@ExceptionHandler(BusinessException.class)
public Result<String> handleBusinessException(BusinessException e) {
return Result.error(e.getCode(), e.getMessage());
}
}
java
// 自定义业务异常
package com.example.blog.common;
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private Integer code;
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(String message) {
this(500, message);
}
}
五、JWT 工具类
java
package com.example.blog.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
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;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
/**
* 生成 JWT Token
*/
public String generateToken(Long userId, String username) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.claim("userId", userId)
.claim("username", username)
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
/**
* 解析 JWT Token,获取 Claims
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 从 Token 中获取用户 ID
*/
public Long getUserIdFromToken(String token) {
return parseToken(token).get("userId", Long.class);
}
/**
* 验证 Token 是否有效
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
}
线程上下文 UserContext :
java
package com.example.blog.util;
import com.example.blog.entity.User;
public class UserContext {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
public static void setCurrentUser(User user) {
currentUser.set(user);
}
public static User getCurrentUser() {
return currentUser.get();
}
public static Long getCurrentUserId() {
User user = currentUser.get();
return user != null ? user.getId() : null;
}
public static void clear() {
currentUser.remove();
}
}
六、登录拦截器
java
package com.example.blog.interceptor;
import com.example.blog.common.BusinessException;
import com.example.blog.entity.User;
import com.example.blog.service.UserService;
import com.example.blog.util.JwtUtil;
import com.example.blog.util.UserContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Arrays;
import java.util.List;
@Component
@RequiredArgsConstructor
public class LoginInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
private final UserService userService;
// 不需要登录的接口路径
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/api/auth/login",
"/api/auth/register",
"/api/posts/public"
);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String path = request.getRequestURI();
// 检查是否在白名单中
if (EXCLUDE_PATHS.stream().anyMatch(path::startsWith)) {
return true;
}
// 获取 Token
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new BusinessException(401, "未登录,请先登录");
}
String token = authHeader.substring(7);
// 验证 Token
if (!jwtUtil.validateToken(token)) {
throw new BusinessException(401, "Token 无效或已过期");
}
// 获取用户信息
Long userId = jwtUtil.getUserIdFromToken(token);
User user = userService.getById(userId);
if (user == null) {
throw new BusinessException(401, "用户不存在");
}
// 存入 ThreadLocal
UserContext.setCurrentUser(user);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
UserContext.clear();
}
}
注册拦截器:
java
package com.example.blog.config;
import com.example.blog.interceptor.LoginInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/**");
}
}
七、用户认证模块
实体类 User :
java
package com.example.blog.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("t_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String email;
private String avatar;
private String role;
private Integer status;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
DTO(数据传输对象) :
java
// 登录请求
@Data
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
// 注册请求
@Data
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度为3-20位")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度为6-20位")
private String password;
@Email(message = "邮箱格式不正确")
private String email;
}
// 登录响应
@Data
public class LoginResponse {
private String token;
private UserInfo userInfo;
@Data
public static class UserInfo {
private Long id;
private String username;
private String email;
private String avatar;
}
}
认证 Service:
java
package com.example.blog.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.blog.common.BusinessException;
import com.example.blog.entity.User;
import com.example.blog.mapper.UserMapper;
import com.example.blog.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final JwtUtil jwtUtil;
private final BCryptPasswordEncoder passwordEncoder;
public void register(String username, String password, String email) {
// 检查用户名是否已存在
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
if (userMapper.selectCount(wrapper) > 0) {
throw new BusinessException(400, "用户名已被占用");
}
// 创建新用户
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setEmail(email);
user.setStatus(1);
user.setRole("USER");
userMapper.insert(user);
}
public String login(String username, String password) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
User user = userMapper.selectOne(wrapper);
if (user == null) {
throw new BusinessException(401, "用户名或密码错误");
}
if (user.getStatus() == 0) {
throw new BusinessException(403, "账号已被禁用");
}
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BusinessException(401, "用户名或密码错误");
}
return jwtUtil.generateToken(user.getId(), user.getUsername());
}
public User getCurrentUser(Long userId) {
return userMapper.selectById(userId);
}
}
安全配置(BCrypt 密码加密) :
java
package com.example.blog.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
认证 Controller:
java
package com.example.blog.controller;
import com.example.blog.common.Result;
import com.example.blog.dto.LoginRequest;
import com.example.blog.dto.LoginResponse;
import com.example.blog.dto.RegisterRequest;
import com.example.blog.entity.User;
import com.example.blog.service.AuthService;
import com.example.blog.util.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
public Result<Void> register(@Valid @RequestBody RegisterRequest request) {
authService.register(request.getUsername(), request.getPassword(), request.getEmail());
return Result.success();
}
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
String token = authService.login(request.getUsername(), request.getPassword());
LoginResponse response = new LoginResponse();
response.setToken(token);
User user = authService.getCurrentUser(UserContext.getCurrentUserId());
LoginResponse.UserInfo userInfo = new LoginResponse.UserInfo();
userInfo.setId(user.getId());
userInfo.setUsername(user.getUsername());
userInfo.setEmail(user.getEmail());
userInfo.setAvatar(user.getAvatar());
response.setUserInfo(userInfo);
return Result.success(response);
}
@GetMapping("/me")
public Result<User> getCurrentUser() {
User user = authService.getCurrentUser(UserContext.getCurrentUserId());
return Result.success(user);
}
}
八、文章管理模块
实体类 Post :
java
package com.example.blog.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("t_post")
public class Post {
@TableId(type = IdType.AUTO)
private Long id;
private String title;
private String content;
private String summary;
private Long categoryId;
private Long authorId;
private Integer viewCount;
private Integer status;
@TableLogic
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
文章 Service:
java
package com.example.blog.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.blog.common.BusinessException;
import com.example.blog.entity.Post;
import com.example.blog.mapper.PostMapper;
import com.example.blog.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PostService {
private final PostMapper postMapper;
public void create(Post post) {
post.setAuthorId(UserContext.getCurrentUserId());
post.setStatus(1); // 默认发布
post.setViewCount(0);
postMapper.insert(post);
}
public void update(Post post) {
Post existing = postMapper.selectById(post.getId());
if (existing == null) {
throw new BusinessException(404, "文章不存在");
}
if (!existing.getAuthorId().equals(UserContext.getCurrentUserId())) {
throw new BusinessException(403, "无权修改此文章");
}
postMapper.updateById(post);
}
public void delete(Long id) {
Post existing = postMapper.selectById(id);
if (existing == null) {
throw new BusinessException(404, "文章不存在");
}
if (!existing.getAuthorId().equals(UserContext.getCurrentUserId())) {
throw new BusinessException(403, "无权删除此文章");
}
postMapper.deleteById(id);
}
public Post getDetail(Long id) {
Post post = postMapper.selectById(id);
if (post == null) {
throw new BusinessException(404, "文章不存在");
}
// 浏览次数 +1
post.setViewCount(post.getViewCount() + 1);
postMapper.updateById(post);
return post;
}
public Page<Post> list(int page, int size, String keyword) {
Page<Post> pageParam = new Page<>(page, size);
LambdaQueryWrapper<Post> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Post::getStatus, 1)
.orderByDesc(Post::getCreateTime);
if (keyword != null && !keyword.isEmpty()) {
wrapper.like(Post::getTitle, keyword)
.or()
.like(Post::getContent, keyword);
}
return postMapper.selectPage(pageParam, wrapper);
}
}
文章 Controller:
java
package com.example.blog.controller;
import com.example.blog.common.Result;
import com.example.blog.entity.Post;
import com.example.blog.service.PostService;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@PostMapping
public Result<Void> create(@RequestBody Post post) {
postService.create(post);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody Post post) {
post.setId(id);
postService.update(post);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
postService.delete(id);
return Result.success();
}
@GetMapping("/{id}")
public Result<Post> detail(@PathVariable Long id) {
return Result.success(postService.getDetail(id));
}
@GetMapping
public Result<Page<Post>> list(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String keyword) {
return Result.success(postService.list(page, size, keyword));
}
}
九、MyBatis-Plus 分页配置
java
package com.example.blog.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
十、接口测试
项目启动后,接口测试结果如下:
| 接口 | 方法 | 请求示例 | 响应示例 |
|---|---|---|---|
| 注册 | POST /api/auth/register |
{"username":"test","password":"123456","email":"test@example.com"} |
{"code":0,"message":"success"} |
| 登录 | POST /api/auth/login |
{"username":"test","password":"123456"} |
{"code":0,"data":{"token":"eyJ...","userInfo":{...}}} |
| 创建文章 | POST /api/posts |
{"title":"标题","content":"内容","summary":"摘要"}(需携带 Token) |
{"code":0,"message":"success"} |
| 文章列表 | GET /api/posts?page=1&size=10 |
无需认证 | {"code":0,"data":{"records":[...],"total":100}} |
| 文章详情 | GET /api/posts/1 |
无需认证 | {"code":0,"data":{"id":1,"title":"..."}} |
完整接口清单(共 12 个):
POST /api/auth/register- 用户注册POST /api/auth/login- 用户登录GET /api/auth/me- 获取当前用户信息(需认证)POST /api/posts- 发布文章(需认证)PUT /api/posts/{id}- 更新文章(需认证)DELETE /api/posts/{id}- 删除文章(需认证)GET /api/posts/{id}- 获取文章详情GET /api/posts- 文章列表(支持分页和关键词搜索)
新手错误 vs 正确姿势
| 错误表象 | 根本原因 | 正确姿势 |
|---|---|---|
| JWT 拦截器校验失败,但前端页面没有跳转登录页 | 前端未在 ajax 中统一处理 401 状态码 | 在 axios 拦截器中添加 if (error.response.status === 401) { router.push('/login') } |
| 密码加密后登录总是失败 | 注册时加密和登录时验证使用了不同的算法或盐值策略 | 注册时用 passwordEncoder.encode(raw),登录时用 passwordEncoder.matches(raw, encoded)------BCrypt 的盐值已包含在密文中,不需要单独存储 |
接口返回的日期格式是数组 [2024,1,1,0,0,0] |
Jackson 序列化 LocalDateTime 使用默认格式 | 在 application.yml 中配置 spring.jackson.date-format 和 spring.jackson.time-zone |
文章更新时,createTime 被覆盖为 null |
更新时传入了完整的 Post 对象,但 createTime 为 null |
在 Service 层手动从数据库中查询原有记录,只更新需要修改的字段;或使用 updateById 配合 @TableField(updateStrategy = FieldStrategy.NOT_EMPTY) |
| 分类删除后,文章查询报错 | 文章表有 category_id 外键约束,删除分类时未级联处理 |
使用软删除 或外键的 ON DELETE SET NULL |
疑难深度追问
Q1:为什么不用 Session 而用 JWT?
Session 是有状态 的------用户登录后,服务器存储 Session 数据,后续请求通过 Cookie 中的 SessionId 关联。在集群环境下,Session 共享需要额外的分布式缓存(如 Redis)。JWT 是无状态的------用户信息编码在 Token 中,服务器只需要验证签名,不需要存储任何状态。这使得 JWT 天然支持水平扩展,适合微服务架构。缺点是 Token 无法主动失效(除非配合黑名单机制)。
Q2:密码加盐后,盐值存储在哪里?
BCrypt 的盐值已经包含在加密后的密文中 了。调用 passwordEncoder.encode("123456") 返回的字符串如 $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy,其中 $2a$10$ 是版本标识和轮数,紧接着的 22 个字符就是盐值(N9qo8uLOickgx2ZMRZoMye),后面是加盐后的密文。登录验证时,passwordEncoder.matches("123456", storedHash) 会从密文中提取盐值,用同样的盐值对输入密码重新加密,然后比较结果。不需要单独存储盐值。
Q3:如何在前端处理 JWT 的自动续期?
前端可以在每次请求前检查 Token 的过期时间(JWT 的 exp 字段是 Unix 时间戳),如果即将过期,在请求前先调用刷新接口获取新 Token。或者后端在响应中判断 Token 剩余时间,如果低于阈值(如 5 分钟),在响应头中返回新的 Token,前端拦截器自动更新存储。典型的实现是:后端返回 401 时,前端自动跳转登录,不推荐自动续期------续期逻辑容易引入安全漏洞(刷新 Token 被窃取的风险)。
思考与延伸
-
动手验证:按照本篇的代码结构,完整实现博客系统的全部接口。然后用 Postman 或 Swagger 测试所有接口,从注册 → 登录 → 发布文章 → 查看文章 → 编辑文章 → 删除文章走一遍完整流程。
-
思考题 :如果用户量很大,JWT 的过期时间如何合理设置?太短(如 30 分钟)影响体验,太长(如 30 天)有安全风险。常见做法是用双 Token 机制:Access Token(短时效,如 15 分钟)+ Refresh Token(长时效,如 7 天),Access Token 过期后用 Refresh Token 换取新的 Access Token。
-
延伸阅读:BCrypt 算法的官方文档对加盐和哈希轮数有详细说明。另外,JJWT 的官方仓库提供了完整的 JWT 生成和解析示例。
参考与延伸阅读
- JJWT. JJWT GitHub Repository. github.com/jwtk/jjwt
- Spring Security. BCryptPasswordEncoder. Spring Security Documentation
- Baeldung. Spring Boot Security with JWT. Baeldung, 2024
- 腾讯云. JWT 实现登录认证的完整流程详解. 2025-06
- 阿里云. Spring Boot 整合 JWT 实现登录拦截. 2025-04
- MyBatis-Plus. MyBatis-Plus 逻辑删除. baomidou.com