全栈(Java + Vue + MySQL)开发图书管理系统教程(二)

简介: 教程来源 https://hllft.cn 本节详解图书管理系统后端开发:基于Spring Boot 2.7构建,集成MyBatis-Plus、JWT鉴权与Spring Security;采用BCrypt密码加密、统一Result响应、DTO分层传输,并实现图书借阅/归还、RBAC权限控制及全局异常处理。

第三部分:后端开发

3.1 为什么选择这些技术栈
在开始编码之前,我们先理解为什么选择这些技术栈:
image.png
关于密码加密:为什么不能用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("系统繁忙,请稍后重试");
    }
}
相关文章
|
3月前
|
SQL Java 数据库
【MyBatis-Plus】Spring Boot + MyBatis-Plus 进行各种数据库操作(附完整 CRUD 项目代码示例)
本文详解Spring Boot集成MyBatis-Plus全流程:从依赖引入、数据源配置、Mapper扫描到分页/乐观锁/逻辑删除等核心插件配置;涵盖BaseMapper基础CRUD、LambdaQueryWrapper条件查询、Service层封装、自定义XML多表关联及批量优化实践,附完整可运行示例。
|
2月前
|
数据采集 缓存 运维
IP查询工具如何评估IP负载?云上资源分配的实战方法
我们曾因P99延迟骤升盲目扩容无效,最终靠IP分桶定位到某云厂商ASN段的爬虫流量。IP查询工具不测性能,而是为请求打标签(ASN/代理类型/风险分等),结合监控数据精准识别“谁拖垮了系统”。分四类桶、设三条件、按优先级调度(分流>限流>扩容>封禁),离线缓存+二次验证,避免误伤。
|
2月前
|
机器学习/深度学习 人工智能 缓存
中国AI又赢了!成本砍到前代1/10!DeepSeek V4为什么能这么便宜?
DeepSeek V4以自研CSA+HCA混合稀疏注意力架构,实现百万上下文算力需求降至前代1/10;KV缓存压缩至7%,消费级显卡即可运行;全量开源、免费商用。精度不妥协——MRCR检索准确率83.5%,超越Gemini 3.1 Pro,真正让长文本AI从“奢侈品”变为普惠“水电煤”。(239字)
449 2
|
2月前
|
机器学习/深度学习 人工智能 数据可视化
【AI加持】基于PyQt+YOLO+DeepSeek的口罩佩戴检测系统(详细介绍)
本文介绍了一个基于PyQt+YOLO+DeepSeek的口罩佩戴检测系统。该系统利用YOLOv8实现高效目标检测,结合PyQt5构建可视化界面,并集成DeepSeek模型进行智能分析。支持图片、视频、摄像头等多种数据源输入,可实时检测口罩佩戴情况。系统采用多线程技术保证流畅运行,并使用SQLite3进行数据存储管理。该方案有效解决了公共场所口罩佩戴监测难题,相比人工巡查显著提升了管理效率和准确性,为智慧城市建设和公共卫生安全管理提供了智能化解决方案。
314 34
【AI加持】基于PyQt+YOLO+DeepSeek的口罩佩戴检测系统(详细介绍)
|
2月前
|
弹性计算 数据可视化
阿里云服务器管理控制台(后台)在哪登录?统一阿里云后台链接入口整理,一键直达
阿里云服务器管理控制台是ECS与轻量应用服务器的统一可视化后台,支持重启、远程连接、重装系统等操作。主入口为控制台首页(home.console.aliyun.com),亦可直连ECS官网:https://t.aliyun.com/U/AZBUsA 或轻量官网:https://t.aliyun.com/U/dwftch
630 8
|
1月前
|
人工智能 自然语言处理 算法
"大三考下CAIE一级人工智能认证,我秋招时吃到了红利"
CAIE注册人工智能工程师(一级)是专为大学生设计的AI能力认证,零基础可考、门槛低、贴合秋招需求。覆盖AI基础、应用与工程认知,非算法岗(产品/运营/数据等)同样适用,获电信、腾讯、平安等百家企业认可,助你在简历筛选和面试中脱颖而出。
|
29天前
|
人工智能 运维 开发工具
一篇搞懂 AI Agent 架构选型,避开 80% 落地坑!
AI Agent正加速落地,但架构选型常成绊脚石。本文精析LangChain、LangGraph、AutoGen、CrewAI、OpenAI Agents SDK五大主流框架,从任务复杂度、可控性、开发效率、成本四大维度对比,助企业按需选型、避坑提速,实现智能化升级。
一篇搞懂 AI Agent 架构选型,避开 80% 落地坑!
|
15天前
|
人工智能 弹性计算 数据库
阿里云新用户和老用户十大最新活动参考:云服务器抢购与特惠,域名注册优惠,AI产品特惠,百炼优惠券等
阿里云2026年面向新老用户推出的活动覆盖计算、存储、数据库、AI等全品类。云服务器方面,轻量应用服务器38元/年限量抢购,经济型e实例99元/年、u1实例199元/年续费同价;另有多规格实例低至3折起。组合购套餐覆盖建站、电商等场景,低至38元起。AI领域,Qwen3.7-Max推理服务限时5折,HappyHorse视频模型8折,新用户享7000万免费tokens。此外还有Token Plan多档订阅、百炼"先用后返"返券、160+云产品最长12个月免费试用等权益,构建从基础算力到前沿AI的完整福利矩阵,助力各类用户低成本上云与AI创新。
|
2月前
|
存储 安全 C++
C++智能指针的演进与最佳实践
C++作为一门系统级编程语言,对内存管理的控制是其核心优势之一,但也因此给开发者带来了手动管理动态内存的负担。
177 5
|
2月前
|
监控 网络协议 网络安全
RUM 实战:用数据说话的 Android 网络性能优化
移动端网络性能直接影响用户体验,面临网络多样、设备碎片化、问题难复现、监控粗粒度等挑战。阿里云 RUM Android SDK 通过采集详细的网络资源指标,助力开发者精准定位性能瓶颈。
286 34