第三部分:后端开发
3.1 为什么选择这些技术栈
在开始编码之前,我们先理解为什么选择这些技术栈:
关于密码加密:为什么不能用MD5?
MD5是哈希算法,不是加密算法,无法解密
相同密码的MD5值相同,容易通过彩虹表破解
BCrypt每次加密结果不同(随机盐值),即使相同密码密文也不一样,且可通过调整cost参数控制计算耗时,增加破解难度
3.2 创建Spring Boot项目
使用Spring Initializr创建项目,选择以下依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.14</version>
<relativePath/>
</parent>
<groupId>com.library</groupId>
<artifactId>library-system</artifactId>
<version>1.0.0</version>
<name>library-system</name>
<description>图书管理系统后端服务</description>
<properties>
<!-- JDK版本,根据实际环境调整 -->
<java.version>11</java.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<jjwt.version>0.9.1</jjwt.version>
</properties>
<dependencies>
<!-- Spring Boot Web:包含Tomcat、Spring MVC、RESTful支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Security:认证和授权框架 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- MyBatis Plus:简化数据库操作 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT:生成和解析JSON Web Token -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<!-- Lombok:自动生成getter/setter/构造器 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 参数校验:@Valid、@NotNull等 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- API文档:Knife4j,Swagger的增强版 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
3.3 配置文件详解
# application.yml
# Spring Boot配置文件,使用YAML格式(比properties更清晰)
server:
port: 8080 # 服务端口
servlet:
context-path: /api # 全局上下文路径,所有接口都以/api开头
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# 连接URL参数详解:
# useSSL=false:不使用SSL加密(开发环境),生产环境应设为true
# serverTimezone=Asia/Shanghai:时区,避免时间错乱
# characterEncoding=utf8:字符编码
url: jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: 123456
# HikariCP连接池配置(Spring Boot默认使用HikariCP,性能最优)
hikari:
maximum-pool-size: 10 # 最大连接数,根据并发量调整
minimum-idle: 5 # 最小空闲连接
connection-timeout: 30000 # 连接超时时间(毫秒)
jackson:
date-format: yyyy-MM-dd HH:mm:ss # 统一日期格式
time-zone: GMT+8 # 时区
# MyBatis Plus配置
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml # XML映射文件位置
type-aliases-package: com.library.entity # 实体类包路径,用于简化XML中的类型引用
global-config:
db-config:
id-type: auto # 主键策略:自增
logic-delete-field: deleted # 逻辑删除字段名
logic-delete-value: 1 # 逻辑删除值
logic-not-delete-value: 0 # 逻辑未删除值
configuration:
map-underscore-to-camel-case: true # 数据库下划线命名自动映射为驼峰命名
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # SQL日志输出到控制台
# JWT配置
jwt:
secret: your-secret-key-for-jwt-token-generation-should-be-long-enough # 签名密钥,生产环境应使用环境变量
expiration: 86400000 # 24小时过期,单位毫秒
# 跨域配置
cors:
allowed-origins: http://localhost:5173 # 允许的前端地址
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: '*'
allow-credentials: true
3.4 实体类(Entity)
实体类对应数据库表的一行记录,使用JPA注解映射字段。
package com.library.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户实体类
* @TableName 指定对应的数据库表名
* @Data Lombok注解,自动生成getter/setter/toString/equals/hashCode
*/
@Data
@TableName("user")
public class User {
/**
* 主键ID
* @TableId 标识主键字段
* IdType.AUTO 表示数据库自增
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户名,唯一
*/
private String username;
/**
* 密码,BCrypt加密存储
*/
private String password;
/**
* 真实姓名
*/
private String realName;
/**
* 邮箱
*/
private String email;
/**
* 手机号
*/
private String phone;
/**
* 角色:admin / user
*/
private String role;
/**
* 状态:0禁用 1启用
*/
private Integer status;
/**
* 头像URL
*/
private String avatar;
/**
* 创建时间
* @TableField(fill = FieldFill.INSERT) 插入时自动填充
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
* @TableField(fill = FieldFill.INSERT_UPDATE) 插入和更新时自动填充
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
@TableField(fill = ...) 的工作原理:配合MetaObjectHandler接口实现自动填充,后面会配置。
package com.library.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 图书实体类
*/
@Data
@TableName("book")
public class Book {
@TableId(type = IdType.AUTO)
private Long id;
/**
* ISBN编号,国际标准书号,唯一标识
*/
private String isbn;
/**
* 图书名称
*/
private String name;
/**
* 作者
*/
private String author;
/**
* 出版社
*/
private String publisher;
/**
* 出版年份
*/
private Integer publishYear;
/**
* 分类
*/
private String category;
/**
* 价格
*/
private BigDecimal price;
/**
* 库存数量
*/
private Integer stock;
/**
* 已借出数量
*/
private Integer borrowed;
/**
* 封面URL
*/
private String coverUrl;
/**
* 图书描述
*/
private String description;
/**
* 状态:0下架 1上架
*/
private Integer status;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
package com.library.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 借阅记录实体类
*/
@Data
@TableName("borrow_record")
public class BorrowRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long bookId;
private LocalDateTime borrowTime;
private LocalDateTime dueTime;
private LocalDateTime returnTime;
private Integer status;
private BigDecimal fine;
}
3.5 DTO(数据传输对象)
DTO用于在不同层之间传输数据,避免直接暴露实体类。
为什么需要DTO而不是直接使用Entity?
安全性:Entity可能包含敏感字段(如password),不应返回给前端
http://oplhc.cn
灵活性:DTO可以组合多个Entity的字段,也可以只暴露部分字段
解耦:数据库结构变化不影响API接口
package com.library.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 登录请求DTO
*/
@Data
public class LoginRequest {
/**
* 用户名
* @NotBlank 不能为null、空字符串、纯空格
*/
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
/**
* 登录响应DTO
*/
@Data
public class LoginResponse {
private Long userId;
private String username;
private String realName;
private String role;
private String token;
private String avatar;
}
/**
* 借阅响应DTO
*/
@Data
public class BorrowDTO {
private String bookName;
private String bookAuthor;
private LocalDateTime borrowTime;
private LocalDateTime dueTime;
private Long recordId;
}
3.6 统一响应结果
package com.library.common;
import lombok.Data;
/**
* 统一响应结果类
* 泛型T表示data字段的类型
*/
@Data
public class Result<T> {
private Integer code; // 状态码:200成功,其他失败
private String message; // 提示信息
private T data; // 响应数据
private Long timestamp; // 时间戳,用于日志追踪和防重放
public Result() {
this.timestamp = System.currentTimeMillis();
}
public Result(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
this.timestamp = System.currentTimeMillis();
}
/**
* 成功响应(无数据)
*/
public static <T> Result<T> success() {
return new Result<>(200, "success", null);
}
/**
* 成功响应(带数据)
*/
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
/**
* 成功响应(自定义消息,一般用于提示操作结果)
*/
public static <T> Result<T> success(String message, T data) {
return new Result<>(200, message, data);
}
/**
* 失败响应(默认状态码500)
*/
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
/**
* 失败响应(自定义状态码)
* 常见状态码:
* 400:参数错误
* 401:未认证/Token失效
* 403:无权限
* 404:资源不存在
* 500:服务器内部错误
*/
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}
}
3.7 JWT工具类
JWT(JSON Web Token)是一种紧凑的、URL安全的、用于在多方之间传递声明的方式。
JWT的结构:
header.payload.signature
- header:算法和令牌类型
- payload:声明(用户信息、过期时间等)
- signature:签名,防篡改
package com.library.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT工具类
* 负责生成和解析JWT令牌
*/
@Component
public class JwtUtils {
/**
* JWT签名密钥
* @Value 从配置文件中读取jwt.secret的值
*/
@Value("${jwt.secret}")
private String secret;
/**
* JWT过期时间(毫秒)
*/
@Value("${jwt.expiration}")
private Long expiration;
/**
* 生成JWT令牌
*
* @param userId 用户ID
* @param username 用户名
* @param role 用户角色
* @return JWT令牌字符串
*/
public String generateToken(Long userId, String username, String role) {
// 存储自定义声明的Map
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("role", role);
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
// 构建JWT
return Jwts.builder()
.setClaims(claims) // 设置自定义声明
.setSubject(username) // 设置主题(通常是用户标识)
.setIssuedAt(now) // 签发时间
.setExpiration(expiryDate) // 过期时间
.signWith(SignatureAlgorithm.HS512, secret) // 签名算法和密钥
.compact();
}
/**
* 从JWT中获取用户ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.get("userId", Long.class);
}
/**
* 从JWT中获取用户名
*/
public String getUsernameFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getSubject();
}
/**
* 从JWT中获取用户角色
*/
public String getRoleFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.get("role", String.class);
}
/**
* 验证JWT是否有效(未过期且签名正确)
*/
public Boolean validateToken(String token) {
try {
Claims claims = getClaimsFromToken(token);
// 检查是否过期
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
// 签名错误、过期、格式错误等都会抛出异常
return false;
}
}
/**
* 解析JWT,获取Claims对象
* 如果签名验证失败会抛出SignatureException
*/
private Claims getClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
}
3.8 Mapper层
MyBatis Plus的Mapper继承BaseMapper即可获得基本的CRUD方法,无需编写SQL。
package com.library.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.library.entity.User;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户Mapper
* @Mapper 让Spring识别这是MyBatis的Mapper接口
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 继承BaseMapper后,自动拥有以下方法:
// insert(), deleteById(), updateById(), selectById(), selectList(), selectPage()等
}
package com.library.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.library.entity.Book;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface BookMapper extends BaseMapper<Book> {
/**
* 增加图书库存
* @Update 直接在注解中写SQL,简单查询推荐使用注解方式
*/
@Update("UPDATE book SET stock = stock + #{count} WHERE id = #{id}")
int increaseStock(@Param("id") Long id, @Param("count") Integer count);
/**
* 减少图书库存
* @Param 用于绑定参数,在SQL中使用#{参数名}引用
*/
@Update("UPDATE book SET stock = stock - #{count}, borrowed = borrowed + #{count} " +
"WHERE id = #{id} AND stock >= #{count}")
int decreaseStock(@Param("id") Long id, @Param("count") Integer count);
/**
* 归还图书:库存+1,借出数-1
*/
@Update("UPDATE book SET stock = stock + 1, borrowed = borrowed - 1 WHERE id = #{id}")
int returnStock(@Param("id") Long id);
}
package com.library.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.library.entity.BorrowRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface BorrowRecordMapper extends BaseMapper<BorrowRecord> {
@Select("SELECT COUNT(*) FROM borrow_record WHERE user_id = #{userId} AND status = 0")
int countBorrowingByUser(@Param("userId") Long userId);
@Select("SELECT COUNT(*) FROM borrow_record WHERE user_id = #{userId} AND book_id = #{bookId} AND status = 0")
int checkUserBorrowed(@Param("userId") Long userId, @Param("bookId") Long bookId);
@Update("UPDATE borrow_record SET status = 1, return_time = NOW() WHERE id = #{id}")
int updateToReturned(@Param("id") Long id);
}
3.9 Service层
package com.library.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.library.common.PageResult;
import com.library.dto.BorrowDTO;
import com.library.entity.Book;
import com.library.entity.BorrowRecord;
import com.library.entity.User;
import com.library.mapper.BookMapper;
import com.library.mapper.BorrowRecordMapper;
import com.library.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 图书服务实现类
* @Service 标识这是一个Spring Bean
* @Slf4j 自动生成log变量,用于日志输出
* @RequiredArgsConstructor 生成final字段的构造器,用于依赖注入
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BookService {
private final BookMapper bookMapper;
private final BorrowRecordMapper borrowRecordMapper;
private final UserMapper userMapper;
/**
* 分页查询图书
*
* @param pageNum 页码,从1开始
* @param pageSize 每页大小
* @param keyword 搜索关键词(书名/作者/ISBN模糊匹配)
* @param category 分类筛选
* @return 分页结果
*/
public PageResult<Book> pageQuery(Integer pageNum, Integer pageSize, String keyword, String category) {
// 创建分页对象
Page<Book> page = new Page<>(pageNum, pageSize);
// LambdaQueryWrapper:类型安全的查询条件构造器
LambdaQueryWrapper<Book> wrapper = new LambdaQueryWrapper<>();
// 只查询上架状态的图书
wrapper.eq(Book::getStatus, 1);
// 关键词搜索(书名、作者、ISBN)
if (StringUtils.hasText(keyword)) {
// and条件组: (name like %keyword% or author like %keyword% or isbn like %keyword%)
wrapper.and(w -> w
.like(Book::getName, keyword)
.or()
.like(Book::getAuthor, keyword)
.or()
.like(Book::getIsbn, keyword)
);
}
// 分类筛选
if (StringUtils.hasText(category)) {
wrapper.eq(Book::getCategory, category);
}
// 按更新时间倒序(最新添加的排在前面)
wrapper.orderByDesc(Book::getUpdateTime);
// 执行分页查询
IPage<Book> result = bookMapper.selectPage(page, wrapper);
// 封装分页结果
return new PageResult<>(
result.getRecords(),
result.getTotal(),
pageNum.longValue(),
pageSize.longValue()
);
}
/**
* 借阅图书
* @Transactional 开启事务,保证多个数据库操作的一致性
* 如果方法中任何一步失败,所有操作都会回滚
*/
@Transactional(rollbackFor = Exception.class)
public BorrowDTO borrowBook(Long userId, Long bookId) {
// 1. 查询图书信息
Book book = bookMapper.selectById(bookId);
if (book == null) {
throw new RuntimeException("图书不存在");
}
// 2. 检查图书库存
if (book.getStock() <= 0) {
throw new RuntimeException("图书库存不足");
}
// 3. 检查用户是否已借阅此书(不能重复借阅同一本书)
int borrowedCount = borrowRecordMapper.checkUserBorrowed(userId, bookId);
if (borrowedCount > 0) {
throw new RuntimeException("您已借阅过此书,请先归还");
}
// 4. 检查用户当前借阅数量(每人最多借5本)
int currentBorrowing = borrowRecordMapper.countBorrowingByUser(userId);
if (currentBorrowing >= 5) {
throw new RuntimeException("借书数量已达上限(5本),请归还后再借");
}
// 5. 扣减库存
int updateStock = bookMapper.decreaseStock(bookId, 1);
if (updateStock == 0) {
throw new RuntimeException("扣减库存失败,请稍后重试");
}
// 6. 创建借阅记录
BorrowRecord record = new BorrowRecord();
record.setUserId(userId);
record.setBookId(bookId);
record.setBorrowTime(LocalDateTime.now());
// 借期30天
record.setDueTime(LocalDateTime.now().plusDays(30));
record.setStatus(0); // 0:借出中
borrowRecordMapper.insert(record);
// 7. 构建返回数据
BorrowDTO borrowDTO = new BorrowDTO();
borrowDTO.setBookName(book.getName());
borrowDTO.setBookAuthor(book.getAuthor());
borrowDTO.setBorrowTime(record.getBorrowTime());
borrowDTO.setDueTime(record.getDueTime());
borrowDTO.setRecordId(record.getId());
log.info("用户{}借阅图书{}成功,借阅记录ID:{}", userId, bookId, record.getId());
return borrowDTO;
}
/**
* 归还图书
*/
@Transactional(rollbackFor = Exception.class)
public void returnBook(Long recordId) {
// 1. 查询借阅记录
BorrowRecord record = borrowRecordMapper.selectById(recordId);
if (record == null) {
throw new RuntimeException("借阅记录不存在");
}
if (record.getStatus() != 0) {
throw new RuntimeException("该书已归还");
}
// 2. 增加图书库存
bookMapper.returnStock(record.getBookId());
// 3. 更新借阅记录状态为已归还
borrowRecordMapper.updateToReturned(recordId);
log.info("借阅记录{}已归还,图书ID:{}", recordId, record.getBookId());
}
/**
* 获取用户借阅记录(关联图书信息)
*/
public List<BorrowRecordVO> getUserBorrowRecords(Long userId) {
// 查询用户的借阅记录
LambdaQueryWrapper<BorrowRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BorrowRecord::getUserId, userId);
wrapper.orderByDesc(BorrowRecord::getBorrowTime);
List<BorrowRecord> records = borrowRecordMapper.selectList(wrapper);
if (records.isEmpty()) {
return List.of();
}
// 批量查询图书信息
List<Long> bookIds = records.stream()
.map(BorrowRecord::getBookId)
.collect(Collectors.toList());
List<Book> books = bookMapper.selectBatchIds(bookIds);
// 转换为Map,便于快速查找
java.util.Map<Long, Book> bookMap = books.stream()
.collect(Collectors.toMap(Book::getId, b -> b));
// 组装VO
return records.stream().map(record -> {
BorrowRecordVO vo = new BorrowRecordVO();
vo.setId(record.getId());
vo.setBookId(record.getBookId());
vo.setBorrowTime(record.getBorrowTime());
vo.setDueTime(record.getDueTime());
vo.setReturnTime(record.getReturnTime());
vo.setStatus(record.getStatus());
Book book = bookMap.get(record.getBookId());
if (book != null) {
vo.setBookName(book.getName());
vo.setBookAuthor(book.getAuthor());
}
return vo;
}).collect(Collectors.toList());
}
/**
* 获取图书详情
*/
public Book getBookDetail(Long id) {
Book book = bookMapper.selectById(id);
if (book == null) {
throw new RuntimeException("图书不存在");
}
return book;
}
/**
* 管理员添加图书
*/
public void addBook(Book book) {
// 检查ISBN是否已存在
LambdaQueryWrapper<Book> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Book::getIsbn, book.getIsbn());
if (bookMapper.selectCount(wrapper) > 0) {
throw new RuntimeException("ISBN已存在");
}
book.setStatus(1); // 默认上架
book.setStock(book.getStock() != null ? book.getStock() : 0);
book.setBorrowed(0);
bookMapper.insert(book);
}
/**
* 管理员更新图书
*/
public void updateBook(Book book) {
// 检查图书是否存在
Book existing = bookMapper.selectById(book.getId());
if (existing == null) {
throw new RuntimeException("图书不存在");
}
bookMapper.updateById(book);
}
/**
* 管理员删除图书(下架)
*/
public void deleteBook(Long id) {
Book book = new Book();
book.setId(id);
book.setStatus(0); // 下架
bookMapper.updateById(book);
}
}
3.10 认证服务(Spring Security集成)
package com.library.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.library.entity.User;
import com.library.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Collections;
/**
* 自定义UserDetailsService
* Spring Security通过这个类加载用户信息进行认证
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
/**
* 根据用户名加载用户信息
* Spring Security会自动调用这个方法进行认证
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
User user = userMapper.selectOne(wrapper);
if (user == null) {
throw new UsernameNotFoundException("用户不存在: " + username);
}
// 构建Spring Security的UserDetails对象
// 第一个参数:用户名
// 第二个参数:密码(加密后的)
// 第三个参数:权限列表(角色需要加上ROLE_前缀)
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole()))
);
}
}
package com.library.service.impl;
import com.library.dto.LoginRequest;
import com.library.dto.LoginResponse;
import com.library.entity.User;
import com.library.mapper.UserMapper;
import com.library.utils.JwtUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
/**
* 认证服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils;
private final AuthenticationManager authenticationManager;
/**
* 用户登录
* 使用Spring Security的AuthenticationManager进行认证
*/
public LoginResponse login(LoginRequest loginRequest) {
// 1. 创建认证Token
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
);
// 2. 执行认证(会自动调用UserDetailsService.loadUserByUsername)
// 认证失败会抛出AuthenticationException
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功,设置SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// 4. 查询用户详细信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, loginRequest.getUsername());
User user = userMapper.selectOne(wrapper);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 5. 检查用户状态
if (user.getStatus() != 1) {
throw new RuntimeException("账户已被禁用");
}
// 6. 生成JWT令牌
String token = jwtUtils.generateToken(user.getId(), user.getUsername(), user.getRole());
// 7. 构建响应
LoginResponse response = new LoginResponse();
response.setUserId(user.getId());
response.setUsername(user.getUsername());
response.setRealName(user.getRealName());
response.setRole(user.getRole());
response.setToken(token);
response.setAvatar(user.getAvatar());
log.info("用户登录成功: {}", loginRequest.getUsername());
return response;
}
}
3.11 Security配置
package com.library.config;
import com.library.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security安全配置
* @EnableWebSecurity 启用Web安全
* @EnableGlobalMethodSecurity(prePostEnabled = true) 启用方法级权限控制(@PreAuthorize)
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* 密码编码器
* BCrypt是自适应的,可以通过参数调整计算复杂度
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 认证管理器
* 用于执行认证操作
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
/**
* Security过滤器链
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF(前后端分离使用JWT,不需要CSRF保护)
.csrf().disable()
// 启用CORS跨域
.cors().configurationSource(corsConfigurationSource())
.and()
// 设置Session为无状态(不使用Session)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 配置请求授权规则
.authorizeRequests()
// 以下路径不需要认证
.antMatchers("/auth/**").permitAll()
.antMatchers("/swagger-ui/**", "/v3/api-docs/**", "/doc.html/**").permitAll()
// 其他请求需要认证
.anyRequest().authenticated()
.and()
// 添加JWT过滤器(在UsernamePasswordAuthenticationFilter之前执行)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* CORS跨域配置
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许的来源(前端地址)
configuration.setAllowedOrigins(Arrays.asList("http://localhost:5173"));
// 允许的HTTP方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// 允许的请求头
configuration.setAllowedHeaders(Arrays.asList("*"));
// 允许携带凭证(Cookie)
configuration.setAllowCredentials(true);
// 预检请求的缓存时间(秒)
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
3.12 JWT认证过滤器
package com.library.filter;
import com.library.utils.JwtUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* JWT认证过滤器
* 拦截每个请求,解析JWT令牌,设置认证上下文
* OncePerRequestFilter确保每个请求只执行一次
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 从请求头中获取JWT令牌
String token = getJwtFromRequest(request);
// 2. 验证令牌有效性
if (StringUtils.hasText(token) && jwtUtils.validateToken(token)) {
// 3. 从令牌中获取用户名
String username = jwtUtils.getUsernameFromToken(token);
// 4. 加载用户信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 5. 创建认证对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 6. 设置认证上下文(标志着用户已登录)
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("用户认证成功: {}", username);
}
// 继续执行后续过滤器
filterChain.doFilter(request, response);
}
/**
* 从请求头中提取JWT令牌
* 格式:Authorization: Bearer <token>
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
3.13 控制器层
package com.library.controller;
import com.library.common.Result;
import com.library.dto.LoginRequest;
import com.library.dto.LoginResponse;
import com.library.dto.BorrowDTO;
import com.library.entity.Book;
import com.library.entity.BorrowRecord;
import com.library.service.impl.AuthService;
import com.library.service.impl.BookService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 认证控制器
*/
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest loginRequest) {
try {
LoginResponse response = authService.login(loginRequest);
return Result.success(response);
} catch (Exception e) {
return Result.error(401, e.getMessage());
}
}
}
/**
* 图书控制器
*/
@RestController
@RequestMapping("/books")
@RequiredArgsConstructor
public class BookController {
private final BookService bookService;
/**
* 分页查询图书
*/
@GetMapping
public Result<com.library.common.PageResult<Book>> pageQuery(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category) {
com.library.common.PageResult<Book> result = bookService.pageQuery(pageNum, pageSize, keyword, category);
return Result.success(result);
}
/**
* 获取图书详情
*/
@GetMapping("/{id}")
public Result<Book> getDetail(@PathVariable Long id) {
Book book = bookService.getBookDetail(id);
return Result.success(book);
}
/**
* 借阅图书
*/
@PostMapping("/borrow")
public Result<BorrowDTO> borrow(@RequestParam Long userId, @RequestParam Long bookId) {
try {
BorrowDTO result = bookService.borrowBook(userId, bookId);
return Result.success(result);
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
/**
* 归还图书
*/
@PutMapping("/return/{recordId}")
public Result<Void> returnBook(@PathVariable Long recordId) {
try {
bookService.returnBook(recordId);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
/**
* 获取用户借阅记录
*/
@GetMapping("/records/{userId}")
public Result<List<BorrowRecordVO>> getUserRecords(@PathVariable Long userId) {
List<BorrowRecordVO> records = bookService.getUserBorrowRecords(userId);
return Result.success(records);
}
/**
* 管理员添加图书
* @PreAuthorize("hasRole('admin')") 只有admin角色可以访问
*/
@PreAuthorize("hasRole('admin')")
@PostMapping
public Result<Void> addBook(@RequestBody Book book) {
try {
bookService.addBook(book);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
/**
* 管理员更新图书
*/
@PreAuthorize("hasRole('admin')")
@PutMapping
public Result<Void> updateBook(@RequestBody Book book) {
try {
bookService.updateBook(book);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
/**
* 管理员删除图书(下架)
*/
@PreAuthorize("hasRole('admin')")
@DeleteMapping("/{id}")
public Result<Void> deleteBook(@PathVariable Long id) {
try {
bookService.deleteBook(id);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
}
3.14 全局异常处理
package com.library.config;
import com.library.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* 全局异常处理器
* @RestControllerAdvice 统一处理Controller层抛出的异常
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理业务异常
*/
@ExceptionHandler(RuntimeException.class)
public Result<Void> handleRuntimeException(RuntimeException e) {
log.error("业务异常: {}", e.getMessage());
return Result.error(e.getMessage());
}
/**
* 处理权限异常(无权限访问)
*/
@ExceptionHandler(AccessDeniedException.class)
public Result<Void> handleAccessDeniedException(AccessDeniedException e) {
log.warn("权限不足: {}", e.getMessage());
return Result.error(403, "权限不足");
}
/**
* 处理参数校验异常
* 当@Valid校验失败时会抛出此异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return Result.error(400, message);
}
/**
* 处理系统异常(兜底)
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常: ", e);
return Result.error("系统繁忙,请稍后重试");
}
}