图书管理系统(详解版 附源码)

news2024/12/23 16:51:25

目录

项目分析

实现页面

功能描述

页面预览

准备工作

数据准备

创建数据库

用户表

创建项目

导入前端页面

测试前端页面

后端代码实现

项目公共模块

实体类

公共层

统一结果返回

统一异常处理

业务实现

持久层

用户登录

用户注册

密码加密验证

添加图书

图书列表

修改图书

删除图书

批量删除

强制登录

令牌生成

拦截器


在学习了 Spring 框架 和 MyBatis 相关知识后,我们来尝试实现一个简单的图书管理系统,完成图书管理系统项目的后端开发

项目分析

使用SSM框架(Spring、Spring MVC、Mybaits)实现一个简单的图书管理系统

实现页面

1. 用户登录

2. 用户注册

2. 图书列表页

3. 添加图书页

4. 修改图书页

功能描述

用户进行登录,若是未注册账号则点击注册,注册成功后,返回登录页面进行登录,成功登录后,进入图书列表页,可对图书进行增、删、查、改等操作(未登录前不能访问图书相关页面)

页面预览

用户登录:

用户注册:


图书列表页:

添加图书页:

修改图书页:

准备工作

数据准备

创建数据库

-- 创建数据库
DROP DATABASE IF EXISTS book_test;
CREATE DATABASE book_test DEFAULT CHARACTER SET utf8mb4;

在图书管理系统中,涉及两张表

用户表(包含用户id、账号、密码等信息

图书表(包含图书id、图书名、作者等信息

用户表

-- 创建用户表
DROP TABLE IF EXISTS user_info;
CREATE TABLE user_info (
        `id` INT NOT NULL AUTO_INCREMENT,
        `user_name` VARCHAR ( 128 ) NOT NULL,
        `password` VARCHAR ( 128 ) NOT NULL,
        `delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,
        `create_time` DATETIME DEFAULT now(),
        `update_time` DATETIME DEFAULT now() ON UPDATE now(),
        PRIMARY KEY ( `id` ),
UNIQUE INDEX `user_name_UNIQUE` ( `user_name` ASC )) ENGINE = INNODB DEFAULT CHARACTER 
SET = utf8mb4 COMMENT = '用户表';

向用户表中插入一些数据,作为初始化数据:

INSERT INTO user_info ( user_name, PASSWORD ) VALUES ( "admin", "admin" );
INSERT INTO user_info ( user_name, PASSWORD ) VALUES ( "zhangsan", "zhangsan" );

图书表

-- 创建图书表
DROP TABLE IF EXISTS book_info;
CREATE TABLE `book_info` (
        `id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
        `book_name` VARCHAR ( 127 ) NOT NULL,
        `author` VARCHAR ( 127 ) NOT NULL,
        `count` INT ( 11 ) NOT NULL,
        `price` DECIMAL (7,2 ) NOT NULL,
        `publish` VARCHAR ( 256 ) NOT NULL,
        `status` TINYINT ( 4 ) DEFAULT 1 COMMENT '0-无效, 1-正常, 2-不允许借阅',
        `create_time` DATETIME DEFAULT now(),
        `update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;

初始化数据:

INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书1', '作者1', 29, 22.00, '出版社1');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书2', '作者2', 30, 23.40, '出版社2');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书3', '作者3', 59, 26.00, '出版社3');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书4', '作者4', 99, 52.00, '出版社2');

创建项目

创建SpringBoot项目,添加对应依赖

连接数据库:

# 数据库连接配置
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8&useSSL=false
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  configuration:
    map-underscore-to-camel-case: true #配置驼峰自动转换
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句
  mapper-locations: classpath:mapper/**Mapper.xml
# 配置日志文件的文件名
logging:
  file:
    name: logs/spring-book.log

在这里使用的是 application.yml 进行配置(也可以使用 application.properties 进行配置)

导入前端页面

前端页面存放在:

前端代码/图书管理系统 · Echo/project - 码云 - 开源中国 (gitee.com)

将前端页面导入到static目录下:

测试前端页面

我们运行程序,访问前端页面:登录icon-default.png?t=N7T8http://127.0.0.1:8080/login.html

(其他页面就不再一一展示了,大家自行进行测试)

前端页面正确显示,项目的准备工作完成

后端代码实现

项目可分为 控制层(Controller),服务层(Service),持久层(Mapper),还有实体类公共层

我们首先根据需求实现项目公共模块,即 实体类公共层

项目公共模块

实体类

需要创建两个实体:UserInfo类 BookInfo类

创建 model 目录,在 model 目录下根据数据表创建 UserInfoBookInfo

UserInfo:

import lombok.Data;
import java.util.Date;

@Data
public class UserInfo {
    private Integer id;
    private String userName;
    private String password;
    private Integer deleteFlag;
    private Date createTime;
    private Date updateTime;
}

BookInfo:

@Data
public class BookInfo {
    private Integer id;
    private String bookName;
    private String author;
    private Integer count;
    private Double price;
    private String publish;
    private Integer status; // 0:已删除 1:正常 2:不允许借阅
    private String statusCN; // 状态描述信息
    private Date createTime;
    private Date updateTime;
}

公共层

统一结果返回

我们首先创建 统一返回结果实体类 Result

code:业务码(200:业务处理成功,-1:业务处理失败,-2:用户未登录)

errorMessage:业务处理失败时,返回的错误信息

data:业务返回数据

实现业务码时,我们可以定义 final常量

public class Constants {
    public static final int RESULT_CODE_SUCCESS = 200;
    public static final int RESULT_CODE_FAIL = -1;
    public static final int RESULT_CODE_UNLOGIN = -2;
}

也可以使用枚举类型

public enum ResultStatus {
    SUCCESS(200),
    FAIL(-1),
    NOLOGIN(-2)
    ;
    private Integer code;
    ResultStatus(Integer code) {
        this.code = code;
    }
}

在这里,我们选择使用枚举类型,创建enums目录,在目录下创建ResultStatus类

此外,业务返回数据data,我们可以选择使用Object类型,也可以使用泛型,在这里,我们使用泛型

并实现业务处理成功、业务处理失败的方法,由于我们后续会实现强制登录功能,因此,在这里我们也一起实现用户未登录时的处理方法

@Data
public class Result<T> {
    private ResultStatus code;
    private T data;
    private String errorMessage;
    
    // 业务成功处理
    public static <T> Result success(T data) {
        Result result = new Result();
        result.code = ResultStatus.SUCCESS;
        result.data = data;
        result.errorMessage = "";
        return result;
    }
    
    // 用户未登录
    public static <T> Result noLogin() {
        Result result = new Result();
        result.code = ResultStatus.NOLOGIN;
        result.errorMessage = "用户未登录!";
        return result;
    }
    
    // 业务处理失败,返回错误信息
    public static <T> Result fail(String errorMessage) {
        Result result = new Result();
        result.code = ResultStatus.FAIL;
        result.errorMessage = errorMessage;
        return result;
    }
    
    // 业务处理失败,返回错误信息和数据
    public static <T> Result fail(String errorMessage, T data) {
        Result result = new Result();
        result.code = ResultStatus.FAIL;
        result.data = data;
        result.errorMessage = errorMessage;
        return result;
    }
}

统一返回结果:

统一数据返回格式使用 @ControllerAdvice(控制器通知类) 和 ResponseBodyAdvice 实现

添加类 ResponseAdvice,实现 ResponseBodyAdvice 接口,并在类上添加 @ControllerAdvice 注解

@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
    @Autowired
    private ObjectMapper objectMapper;
    
    // supports方法,用于判断是否要执行beforeBodyWrite方法,true为执行,false不执行,
    // 可以通过supports方法选择哪些类或哪些方法的response需要进行处理,哪些不需要进行处理
    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        // 所有方法都进行处理
        return true;
    }

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        // 方法返回的结果已经是Result类型,直接返回Result
        if(body instanceof Result) {
            return body;
        }
        // 返回的结果是String类型,使用SpringBoot内置提供的Jackson来实现信息的序列化
        if(body instanceof String) {
            return objectMapper.writeValueAsString(Result.success(body));
        }
        // 其他情况,调用Result.success方法,返回Result类型数据
        return Result.success(body);
    }
}

使用统一结果返回方便前端接收和解析后端接口返回的数据,也有利于项目统一数据的维护和修改

统一异常处理

统一异常处理使用的是 @ControllerAdvice(控制权通知类)和 @ExceptionHandler(异常处理器),两个结合表示当出现异常时执行某个通知(也就是执行某个方法事件)

@ControllerAdvice
@ResponseBody
@Slf4j
public class ExceptionAdvice {
    @ExceptionHandler
    public Result handlerException(Exception e) {
        log.info("发生异常e:", e);
        return Result.fail("内部错误,请联系管理员!");
    }
}

当代码出现了 Exception 异常(包括 Exception类的子类),就返回一个Result 对象(也可以针对不同的异常,返回不同的结果)

业务实现

持久层

根据需求,先大致计算有哪些 DB 操作,完成持久层初步代码,后续再根据业务需求进行完善

大致需要的DB操作有:

1. 用户登录页

  根据用户名查询用户信息

2. 用户注册页

  根据用户注册信息添加用户信息

3. 图书列表页

   查询所有图书列表

   当点击删除时,根据图书id删除图书信息

4. 添加图书页

   插入新的图书信息

5. 修改图书页

根据图书id修改图书信息 

我们首先实现与 user_info 表相关操作:

1. 根据用户名查询用户信息(由于用户名是唯一的,因此可以通过用户名查询到唯一用户信息

2. 根据用户注册信息添加用户信息

由于操作比较简单,我们直接使用注解的方式实现: 

创建mapper,实现接口UserInfoMapper

@Mapper
public interface UserInfoMapper {
    // 根据用户名查询用户信息
    @Select("select id, user_name, password, delete_flag, create_time, update_time from user_info where user_name = #{userName}")
    UserInfo selectByName(String userName);

    // 根据用户输入信息添加用户信息
    @Insert("insert into user_info (user_name, password) values(#{userName}, #{password})")
    int insertUser(String userName, String password);
}

编写完代码后,我们编写测试用例,简单进行单元测试

@SpringBootTest
class UserInfoMapperTest {
    @Autowired
    private UserInfoMapper userInfoMapper;
    @Test
    void selectByName() {
        userInfoMapper.selectByName("admin");
    }
    @Test
    void insertUser() {
        System.out.println(userInfoMapper.insertUser("lisi", "123456"));
    }
}

测试通过后,我们继续实现与 book_info 表相关操作:

1. 获取所有图书列表

2. 当点击删除时,根据图书 id 删除图书信息

3. 插入新的图书信息

4. 根据图书id修改图书信息

在实现删除图书信息时,我们采用 逻辑删除,即 将 status的值修改为 0,而不是直接将图书信息从表中删除,因此,删除图书信息时,可使用修改图书信息的sql语句 

@Mapper
public interface BookInfoMapper {
    /**
     * 获取图书列表
     */
    @Select("select id, book_name, author, count, price, publish, `status`, delete_flag, create_time, update_time from book_info ")
    List<BookInfo> selectAllBook();

    /**
     * 插入新的图书信息
     */
    @Insert("insert into book_info (book_name, author, count, price, publish, `status`) " +
            "values (#{bookName}, #{author}, #{count}, #{price}, #{publish}, #{status})")
    Integer insertBook(BookInfo bookInfo);

    /**
     * 根据图书id修改图书信息
     */
    Integer updateBook(BookInfo bookInfo);

}

在修改图书信息时,修改的内容是可选的(如:选择只修改bookName或只修改status),因此我们需要使用动态SQL,由于使用注解实现时,是进行的字符串拼接,不易检查出错误,因此在这里我们选择使用 xml 实现(后面实现)

我们先对 获取用户列表、插入新的图书信息 进行单元测试:

@SpringBootTest
class BookInfoMapperTest {
    @Autowired
    private BookInfoMapper bookInfoMapper;
    @Test
    void selectAllBook() {
        bookInfoMapper.selectAllBook();
    }

    @Test
    void insertBook() {
        BookInfo bookInfo = new BookInfo();
        bookInfo.setBookName("图书5");
        bookInfo.setAuthor("作者5");
        bookInfo.setCount(11);
        bookInfo.setPrice(12.5);
        bookInfo.setPublish("出版社5");
        bookInfo.setStatus(1);
        bookInfoMapper.insertBook(bookInfo);
    }
}

测试通过后,我们使用 xml 的方式实现修改图书信息:

由于我们配置的路径为:

因此,在resources目录下添加文件夹 mapper,然后添加文件 bookInfoMapper:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.springbook.mapper.BookInfoMapper">
    <update id="updateBook">
        update book_info
        <set>
            <if test="bookName != null">
                book_name = #{bookName},
            </if>
            <if test="author != null">
                author = #{author},
            </if>
            <if test="count != null">
                count = #{count},
            </if>
            <if test="publish != null">
                publisht = #{publish},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
        </set>
        where id = #{id}
    </update>
</mapper>

然后测试修改图书信息和删除图书信息:

    @Test
    void updateBook() {
        // 修改图书信息
        BookInfo bookInfo = new BookInfo();
        bookInfo.setId(2);
        bookInfo.setBookName("图书222");
        bookInfoMapper.updateBook(bookInfo);
        
        // 删除图书信息
        BookInfo bookInfo1 = new BookInfo();
        bookInfo1.setId(1);
        bookInfo1.setStatus(0);
        bookInfoMapper.updateBook(bookInfo1);
    }

测试成功后,关于持久层的初步代码就实现完毕,若后续以上代码不能满足需求,我们再根据需求进行完善即可

接下来,我们就继续实现控制层和服务层相关代码,并补全前端代码

用户登录

约定前后端交互接口:

[URL]

POST /user/login

[请求参数]

userName=admin&password=admin

[响应]

{

    "code": "SUCCESS",

    "data": "",

    "errorMessage": ""

}

 当登录成功时,返回数据为空字符串 "",登录失败时,返回错误信息(可自行进行定义)

实现服务端代码

创建controller目录,再在目录下创建UserController类

UserController中补充代码:

先进行参数校验,校验通过后查询用户信息

无论前端是否进行了参数校验,后端一律需要进行校验(这是因为后端接口可能会被黑客攻击,不通过前端来访问,若后端不进行校验,就会产生脏数据)

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;

    @RequestMapping("/login")
    public Result<String> login(String userName, String password) {
        log.info("用户登录,获取参数userName:{}, password:{}", userName, password);
        // 参数校验
        if(!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
            return Result.fail("用户名或密码为空!");
        }
        // 根据用户名进行查询
        UserInfo userInfo = userService.selectByName(userName);
        if(userInfo == null) {
            return Result.fail("用户名或密码错误!");
        }
        if(!password.equals(userInfo.getPassword())) {
            return Result.fail("密码错误!");
        }
        return Result.success("");
    }
}

业务层:

创建service目录,再在目录下创建UserService

UserService中补充代码:

@Service
public class UserService {
    @Autowired
    private UserInfoMapper userInfoMapper;

    public UserInfo selectByName(String userName) {
        return userInfoMapper.selectByName(userName);
    }
}

接着我们运行程序,使用浏览器或 postman 对接口进行测试:

分别测试 用户名或密码为空用户名错误密码错误成功登录情况下是否正确响应

修改客户端代码

修改login.html function login()中代码:

在用户点击登录后使用ajax向服务器发送HTTP请求

服务器返回的响应是一个 JSON 格式的数据,根据响应数据构造页面内容

    <script>
        function login() {
            $.ajax({
                url: "/user/login",
                type: "post",
                data: {
                    userName: $("#userName").val(),
                    password: $("#password").val()
                },
                success: function(result) {
                    if(result.code == "SUCCESS" && result.data == "") {
                        location.href = "book_list.html";
                    }else {
                        alert(result.errorMessage);
                    }
                }
            });
        }
    </script>

此时,我们再次运行程序,联动前端一起进行测试:

当输入正确的用户名和密码时,进行跳转;其他异常情况,页面弹窗警告

用户注册

[URL]

POST /user/register

[请求参数]

userName=wangwu&password=wangwu

[响应]

{

    "code": "SUCCESS",

    "data": "",

    "errorMessage": ""

}

 当注册成功时,返回数据为空字符串 "",注册失败时,返回错误信息

实现服务端代码

在 UserController 中补充代码:

先进行参数校验,校验通过后添加用户信息

    /**
     * 用户注册
     */
    @RequestMapping("/register")
    public Result<String> register(String userName, String password) {
        log.info("用户注册");
        // 参数校验
        if(!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
            return Result.fail("用户名或密码为空!");
        }
        // 添加用户信息
        try {
            int result = userService.insertUser(userName, password);
            if(result > 0) {
                return Result.success("添加成功!");
            }
        }catch (Exception e) {
            log.error("添加失败, e", e);
        }
        return Result.fail("用户名已存在!");
    }

业务层:

UserService中补充代码

    public int insertUser(String userName, String password) {
        return userInfoMapper.insertUser(userName, password);
    }

在这里,就不单独对后端代码进行测试了,实现前端代码后一起进行测试(大家可自行使用浏览器或postman进行测试)

修改客户端代码

修改register.html  function register()中代码:

    <script>
        function register() {
            if($("#password").val() != $("#confirmPassword").val()) {
                alert("密码不一致");
                return;
            }
            $.ajax({
                url: "/user/register",
                type: "post",
                data: {
                    userName: $("#userName").val(),
                    password: $("#password").val()
                },
                success: function(result) {
                    if(result.code == "SUCCESS" && result.data == "") {
                        location.href = "login.html";
                    }else {
                        alert(result.errorMessage);
                    }
                }
            });
        }
    </script>

此时再次运行程序,进行测试:

测试成功登录情况下能否正确跳转,密码不一致或用户名已存在情况下是否弹出提示

与用户相关的操作(用户登录注册)我们就实现完毕了

但是,由于我们在数据库中使用明文对用户密码进行存储,非常不安全,因此我们需要对用户密码进行加密,在这里,我们使用 MD5 对密码进行加密

密码加密验证

使用MD5对密码进行加密和验证过程如下图:

创建目录 utils,然后在目录下创建 SecurityUtils 

接下来我们在 SecurityUtils 中实现对密码的加密和验证:

public class SecurityUtils {
    /**
     * 对用户注册密码进行加密
     * @param password password 用户注册密码
     * @return 数据库中存储信息(密文 + 盐值)
     */
    public static String encipher(String password) {
        // 生成随机盐值
        String salt = UUID.randomUUID().toString().replace("-", "");
        // 将 盐值 + 明文进行加密
        String secretPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes());
        // 返回 密文 + 盐值
        return secretPassword + salt;
    }

    /**
     * 验证密码是否正确
     * @param inputPassword 用户登录时输入的密码
     * @param sqlPassword 数据库中存储的密码(密文 + 盐值)
     * @return 密码是否正确
     */
    public static Boolean verity(String inputPassword, String sqlPassword) {
        // 校验用户输入的密码
        if(!StringUtils.hasLength(inputPassword)) {
            return false;
        }
        // 校验数据库中保存的密码
        if(!StringUtils.hasLength(sqlPassword) || sqlPassword.length() != 64) {
            return false;
        }
        // 解析盐值
        String salt = sqlPassword.substring(32, 64);
        // 生成哈希值(盐值 + 明文)
        String secretPassword = DigestUtils.md5DigestAsHex((salt + inputPassword).getBytes());
        // 判断密码是否正确
        return secretPassword.equals(sqlPassword.substring(0, 32));
    }
}

接下来,我们修改 注册登录 相关代码:

注册:生成密钥

登录 :进行验证

重新运行程序,进行测试:

此时进行登录,存储的密码则为密文

我们将之前添加的用户的密码都修改为密文:

class SecurityUtilsTest {
    @Test
    void encipher() {
        System.out.println(SecurityUtils.encipher("admin"));
        System.out.println(SecurityUtils.encipher("zhangsan"));
        System.out.println(SecurityUtils.encipher("123456"));
        System.out.println(SecurityUtils.encipher("wangwu"));
    }
}

 运行,得到加密后的密码:

我们直接修改数据库中的密码 

关于密码加密和验证,可参考之前的文章:http://t.csdnimg.cn/Cf3zo

在实现用户登录注册后,我们继续实现图书相关页面

添加图书

[URL]

POST /book/addBook

[请求参数]

bookName=图书11&author=作者11&count=23&price=12.3&publish=出版社11&status=1

[响应]

{

    "code": "SUCCESS",

    "data": "",

    "errorMessage": ""

}

 当添加成功时,返回数据为空字符串 "",添加失败时,返回错误信息

实现服务端代码

在 controller 目录下创建 BookController

BookController中补充代码

先进行参数校验,校验通过后添加图书信息:

public class BookController {
    @Autowired
    private BookService bookService;

    @RequestMapping("/addBook")
    public Result<String> addBook(BookInfo bookInfo) {
        log.info("添加图书,接收到参数bookInfo:{}", bookInfo);
        // 参数校验
        if(!StringUtils.hasLength(bookInfo.getBookName()) ||
                !StringUtils.hasLength(bookInfo.getAuthor()) ||
                bookInfo.getCount() == null || bookInfo.getCount() < 0 ||
                bookInfo.getPrice() == null || bookInfo.getPrice() < 0 ||
                !StringUtils.hasLength(bookInfo.getPublish()) ||
                bookInfo.getStatus() == null) {
            return Result.fail("输入参数不合法!");
        }
        // 添加图书
        try {
            Integer result = bookService.insertBook(bookInfo);
            if(result > 0) {
                return Result.success("");
            }
        }catch (Exception e) {
            log.error("添加图书失败,e", e);
        }
        return Result.fail("添加失败!");
    }
}

业务层:

在 service 目录下创建BookService

BookService中补充代码:

@Service
public class BookService {
    @Autowired
    private BookInfoMapper bookInfoMapper;
  
    public Integer insertBook(BookInfo bookInfo) {
        return bookInfoMapper.insertBook(bookInfo);
    }
}

同样,我们补全前端代码后一起进行测试

修改客户端代码
    <script>
        function add() {
            $.ajax({
                url: "/book/addBook",
                type: "post",
                data: $("#addBook").serialize(),
                success: function(result) {
                    if(result.code == 'SUCCESS' && result.data == "") {
                        // 添加成功,返回图书列表页
                        location.href = "book_list.html";
                    } else {
                        alert(result.data);
                    }
                }
            })
        }
    </script>

测试:

添加成功:

图书列表

在添加图书后,跳转到图书列表页面,并没有显示刚添加的图书信息,接下来,我们实现图书列表页面

由于图书列表中的数据可能会很多,此时将数据全部展示出来是不现实的,因此我们可以使用分页来解决这个问题,每次只显示一页的数据(一页显示5条数据),若想查看其他的数据,可以通过点击页码进行查看

分页时,数据如何进行显示呢?

第一页:显示 1-5 条数据

第二页:显示 6 - 10 条数据

...

要想实现这个功能,需要从数据库中进行分页查询,使用 LIMIT 关键字,格式为

limit 开始索引(开始索引从0开始), 每页显示的条数

要想显示分页效果,需要更多的数据,因此我们先伪造更多的数据:

INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书1', '作者1', 29, 22.00, '出版社1');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书2', '作者2', 30, 23.40, '出版社2');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书3', '作者3', 59, 26.00, '出版社3');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书4', '作者4', 99, 52.00, '出版社4');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书1', '作者1', 29, 22.00, '出版社1');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书2', '作者2', 30, 23.40, '出版社2');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书3', '作者3', 59, 26.00, '出版社3');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书4', '作者4', 99, 52.00, '出版社4');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书1', '作者1', 29, 22.00, '出版社1');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书2', '作者2', 30, 23.40, '出版社2');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书3', '作者3', 59, 26.00, '出版社3');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书4', '作者4', 99, 52.00, '出版社4');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书1', '作者1', 29, 22.00, '出版社1');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书2', '作者2', 30, 23.40, '出版社2');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书3', '作者3', 59, 26.00, '出版社3');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书4', '作者4', 99, 52.00, '出版社4');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书4', '作者4', 99, 52.00, '出版社4');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书1', '作者1', 29, 22.00, '出版社1');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书2', '作者2', 30, 23.40, '出版社2');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书3', '作者3', 59, 26.00, '出版社3');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('图书4', '作者4', 99, 52.00, '出版社4');

查询第一页的SQL语句为:

select * from book_info limit 0, 5;

查询第二页的SQL语句为:

select * from book_info limit 5, 5;

观察上述SQL语句,我们可以发现:开始索引一直在改变,每页显示的条数是固定的

开始索引 = (当前页面 - 1)* 每页显示的条数

 因此,

前端在发起查询请求时,需要向服务端传递的参数有:

currentPage:当前页码(默认值为1)

pageSize:每页显示的条数(默认值为5)

为了项目更好的扩展性(软件系统具备面对未来需求变化而进行扩展的能力),通常不设置固定值,而是通过参数的形式进行传递,例如,当前需求为一页显示 5 条数据,后期需求为一页显示 10 条数据,此时后端代码不需要进行任何修改

后端在进行响应时,需要响应给前端的数据有:

records:所查询到的数据列表(存储到List集合中)

total:总记录数,用于告诉前端显示多少页

当前显示页数:告诉前端当前显示的页码为 currentPage

对于翻页请求和响应,我们将其封装在两个对象中:

翻页请求对象:

@Data
public class PageRequest {
    private Integer currentPage = 1; // 当前页
    private Integer pageSize = 5; // 每页显示条数
    private int offset; // 索引
    // 计算索引
    public int getOffset() {
        return (currentPage - 1) * pageSize;
    }
}

 翻页响应对象:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult<T> {
    private Integer total; //总记录数
    private List<T> records; // 当前页数据
    private PageRequest pageRequest;
}

currentPage 封装在 PageRequest 中,因此,我们直接将 PageRequest 封装在 PageResult 中

接着,基于上述分析,我们来约定前后端交互接口:

[URL]

GET /book/getListByPage?currentPage=1

[请求参数]

[响应]

{

    "code": "SUCCESS",

    "data": {

        "total": 23,

        "records": [

            {

               "id": 27,

                "bookName": "图书4",

                "author": "作者4",

                "count": 99,

                "price": 52.0,

                "publish": "出版社4",

                "status": 1,

                "statusCN": "可借阅",

                "createTime": "2024-06-17T08:28:22.000+00:00",

                "updateTime": "2024-06-17T08:28:22.000+00:00"

            },

            .....,

        ]

    },

    "errorMessage": ""

当浏览器给服务器发送一个 /book/getListByPage 请求时,通过 currentPage 参数告诉服务器,当前请求为第几页数据,后端根据请求参数返回对应页的数据(第一页可以不传参,currentPage默认值为1

实现服务端代码

完善BookController中代码:

    @RequestMapping("/getListByPage ")
    public Result<PageResult<BookInfo>> getListByPage(PageRequest pageRequest) {
        log.info("获取图书列表, 接收到参数pageRequest:{}", pageRequest);
        PageResult<BookInfo> pageResult = bookService.getListByPage(pageRequest);
        if(pageResult == null) {
            return Result.fail("获取图书列表失败!");
        }
        return Result.success(pageResult);
    }

业务层:

BookService:

    public PageResult<BookInfo> getListByPage(PageRequest pageRequest) {
        // 获取总记录数
        Integer total = bookInfoMapper.count();
        // 获取当前页记录
        List<BookInfo> bookInfoList= bookInfoMapper.selectByPage(pageRequest.getOffset(), pageRequest.getPageSize());
        return new PageResult(total, bookInfoList, pageRequest);
    }

注意:

由于我们需要在列表中显示 图书状态,因此,在返回之前我们需要处理图书的状态描述 statusCN,图书的状态描述与 图书状态(status)有对应关系,在这里我们使用 枚举类型 来表示不同的状态描述,这样,如果后续状态码有变动,我们也只需要修改 BookStatus 中的代码

enums 目录下创建 BookStatus

public enum BookStatus {
    DELETE(0, "删除"),
    NORMAL(1, "可借阅"),
    FORBIDDEN(2, "不可借阅"),
    ;
    BookStatus(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    private Integer code;
    private String desc;
    /**
     * 根据Code, 返回描述信息
     */
    public static BookStatus getDescByCode(Integer code){
        switch (code){
            case 0: return DELETE;
            case 1: return NORMAL;
            case 2:
            default:
                return FORBIDDEN;
        }
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }
}

 在返回结果前处理状态:

    public PageResult<BookInfo> getListByPage(PageRequest pageRequest) {
        // 获取总记录数
        Integer total = bookInfoMapper.count();
        // 获取当前页记录
        List<BookInfo> bookInfoList= bookInfoMapper.selectByPage(pageRequest.getOffset(), pageRequest.getPageSize());
        // 处理状态
        for (BookInfo bookInfo: bookInfoList) {
            bookInfo.setStatusCN(BookStatus.getDescByCode(bookInfo.getStatus()).getDesc());
        }
        return new PageResult(total, bookInfoList, pageRequest);
    }

使用 getDescByCode 方法,通过code获取对应枚举,再使用 getDesc 获取对应的状态描述

翻页信息需要返回图书数据总数列表信息,需要查询两次

由于前面我们在编写持久层代码时,并未实现查询所有图书数量和获取当前页数据,因此我们需要完善持久层代码 

持久层:

    /**
     * 获取当前页图书数据
     */
    @Select("select id, book_name, author, count, price, publish, `status`, create_time, update_time from book_info" +
            " where status != 0" +
            " order by id desc" +
            " limit #{offset}, #{pageSize}")
    List<BookInfo> selectByPage(int offset, Integer pageSize);

    /**
     * 获取未被删除的所有图书数量
     */
    @Select("select count(1) from book_info where status != 0")
    Integer count();

需要注意的是:查询的图书都是未被删除的图书,因此 status 不能为0 

启动服务,访问后端程序:http://127.0.0.1:8080/book/getListByPage?currentPage=1

成功获取记录 1 - 5条记录(按照id进行降序排列,也可以改为升序) 

修改客户端代码

访问第一页图书的前端url为:http://127.0.0.1:8080/book_list.html?pageNum=1

访问第二页图书的前端url为:http://127.0.0.1:8080/book_list.html?pageNum=2

当浏览器访问book_list.html页面时,就请求后端,将后端返回的数据显示在页面上,

调用后端请求: /book/getListByPage?currentPage=1

修改js,将后端请求方法修改为  /book/getListByPage?currentPage=1

// 获取图书列表
            getBookList();
            function getBookList() {
                $.ajax({
                    url: "/book/getListByPage" + location.search,
                    type: "get",
                    success: function(result) {
                        console.log(result)
                        if(result.code == "SUCCESS" && result.data != null && result.data.records != null) {
                            var bookHtml = "";
                            for (var book of result.data.records) {
                                bookHtml += '<tr>';
                                bookHtml += '<td><input type="checkbox" name="selectBook" value="' + book.id + '" id="selectBook" class="book-select"></td>';
                                bookHtml += '<td>' + book.id + '</td>';
                                bookHtml += '<td>' + book.bookName + '</td>';
                                bookHtml += '<td>' + book.author + '</td>';
                                bookHtml += '<td>' + book.count + '</td>';
                                bookHtml += '<td>' + book.price + '</td>';
                                bookHtml += '<td>' + book.publish + '</td>';
                                bookHtml += '<td>' + book.statusCN + '</td>';
                                bookHtml += '<td>';
                                bookHtml += '<div class="op">';
                                bookHtml += '<a href="book_update.html?bookId=' + book.id + '">修改</a>';
                                bookHtml += '<a href="javascript:void(0)" onclick="deleteBook(' + book.id + ')">删除</a>';
                                bookHtml += '</div></td></tr>';
                            }
                            $("tbody").html(bookHtml);
                        }
                    }
                })
            }

url中的 currentPage 参数,我们直接使用 location.search(查询url的查询字符串,包含问号) 从url中获取参数信息即可

接下来,我们实现分页

在这里,我们使用了分页插件:jqPaginator分页组件 (keenwon.com)

我们按照 使用说明 文档实现分页

因此,我们继续修改前端代码:

            // 获取图书列表
            getBookList();
            function getBookList() {
                $.ajax({
                    url: "/book/getListByPage" + location.search,
                    type: "get",
                    success: function(result) {
                        console.log(result)
                        console.log(location.search)
                        if(result.code == "SUCCESS" && result.data != null && result.data.records != null) {
                            var bookHtml = "";
                            for (var book of result.data.records) {
                                bookHtml += '<tr>';
                                bookHtml += '<td><input type="checkbox" name="selectBook" value="' + book.id + '" id="selectBook" class="book-select"></td>';
                                bookHtml += '<td>' + book.id + '</td>';
                                bookHtml += '<td>' + book.bookName + '</td>';
                                bookHtml += '<td>' + book.author + '</td>';
                                bookHtml += '<td>' + book.count + '</td>';
                                bookHtml += '<td>' + book.price + '</td>';
                                bookHtml += '<td>' + book.publish + '</td>';
                                bookHtml += '<td>' + book.statusCN + '</td>';
                                bookHtml += '<td>';
                                bookHtml += '<div class="op">';
                                bookHtml += '<a href="book_update.html?bookId=' + book.id + '">修改</a>';
                                bookHtml += '<a href="javascript:void(0)" onclick="deleteBook(' + book.id + ')">删除</a>';
                                bookHtml += '</div></td></tr>';
                            }
                            $("tbody").html(bookHtml);

                            var data = result.data;
                            $("#pageContainer").jqPaginator ({
                                totalCounts: data.total,  // 总记录数
                                pageSize: 5,  // 每页记录数
                                visiblePages: 5,  // 可视页数
                                currentPage: data.pageRequest.currentPage,  // 当前页码
                                first: '<li class="page-item"><a class="page-link">首页</a></li>',
                                prev: '<li class="page-item"><a class="page-link" href="javascript:void(0);">上一页<\/a><\/li>',
                                next: '<li class="page-item"><a class="page-link" href="javascript:void(0);">下一页<\/a><\/li>',
                                last: '<li class="page-item"><a class="page-link" href="javascript:void(0);">最后一页<\/a><\/li>',
                                page: '<li class="page-item"><a class="page-link" href="javascript:void(0);">{{page}}<\/a><\/li>',
                                //页面初始化和页码点击时都会执行
                                 onPageChange: function (page, type) {
                                    if(type != 'init'){
                                        location.href = "book_list.html?currentPage=" + page;
                                    }
                                }
                            });
                        }
                    }
                })
            }

 当加载图书列表信息时,同步加载分页信息:

其中分页组件需要:

totalCounts:总记录数

pageSize:每页记录数

visiblePages:可视页数

currentPage:当前页码

在这些信息中,pageSize 和 visiblePages 由前端直接设置,totalCounts、currentPage 直接从后端返回结果中获取(currentPage 也可以从参数中获取,但比较复杂,因此我们使用后端返回的)

 其中,onPageChange:回调函数,当触发换页时(包括初始化第一页),会传入两个参数:

page:目标页的页码,Number类型

type:触发类型,可为 init(初始化),change(点击分页)

 当触发类型不为 init 时,我们跳转到对应分页(若不进行判断,则会在初始化时一直进行跳转)

注意对应保持一致

此时,再次运行程序,访问图书列表展示icon-default.png?t=N7T8http://127.0.0.1:8080/book_list.html

页码正确显示

点击页码,进行跳转:

 

成功跳转 

修改图书

在进入修改页面时,需要显示当前图书信息:

根据图书id,获取当前图书信息

[URL]

GET /book/queryById?bookId=10

[请求参数]

[响应]

{

    "code": "SUCCESS",

    "data": {

        "id": 10,

        "bookName": "图书4",

        "author": "作者4",

        "count": 99,

        "price": 52.0,

        "publish": "出版社4",

        "status": 1,

        "createTime": "2024-06-17T08:28:22.000+00:00",

        "updateTime": "2024-06-17T08:28:22.000+00:00"

    },

    "errorMessage": ""

}

获取成功,返回获取图书信息;获取失败,返回错误信息

点击修改,修改图书信息

[URL]

POST /book/updateBook

[请求参数]

id=10&bookName=图书222

[响应]

{

    "code": "SUCCESS",

    "data": "",

    "errorMessage": ""

}

修改成功,返回空字符串"";修改失败,返回错误信息 

实现服务端代码

BookController:

    /**
     * 根据图书id获取图书信息
     */
    @RequestMapping("/queryById")
    public Result<BookInfo> queryById(Integer bookId) {
        log.info("根据图书id获取图书信息, 接收参数id:{}", bookId);
        // 参数校验
        if(bookId == null || bookId <= 0) {
            return Result.fail("参数错误!");
        }
        try {
            BookInfo bookInfo = bookService.selectById(bookId);
            if(bookInfo != null) {
                return Result.success(bookInfo);
            }else {
                return Result.fail("获取图书信息失败!");
            }
        }catch (Exception e) {
            log.info("获取图书信息失败, e", e);
        }
        return Result.fail("获取图书信息失败!");
    }
    /**
     * 修改图书信息
     */
    @RequestMapping("/updateBook")
    public Result<String> updateBook(BookInfo bookInfo) {
        log.info("修改图书信息, 获取参数bookInfo:{}", bookInfo);
        // 参数校验
        if(bookInfo.getId() == null || bookInfo.getId() < 0) {
            return Result.fail("图书id有误!");
        }
        // 修改图书
        int result = bookService.updateById(bookInfo);
        if(result > 0) {
            return Result.success("");
        }else {
            return Result.fail("修改失败!");
        }
    }

BookService:

    public BookInfo selectById(Integer id) {
        return bookInfoMapper.selectById(id);
    }

    public int updateById(BookInfo bookInfo) {
        return bookInfoMapper.updateBook(bookInfo);
    }

由于前面我们在编写持久层代码时,并未实现根据图书id查询图书信息,因此我们需要完善持久层代码

    @Select("select id, book_name, author, count, price, publish, `status`, create_time, update_time from book_info" +
            " where id = #{id} and status != 0")
    BookInfo selectById(Integer id);
修改客户端代码
    <script>
        // 获取图书信息
         $.ajax({
            url: "/book/queryById" + location.search,
            type: "get",
            success: function(result) {
                if(result.code == "SUCCESS" && result.data != null) {
                    var book = result.data;
                    $("#bookId").val(book.id);
                    $("#bookName").val(book.bookName);
                    $("#bookAuthor").val(book.author);
                    $("#bookStock").val(book.count);
                    $("#bookPrice").val(book.price);
                    $("#bookPublisher").val(book.publish);
                    $("#bookStatus").val(book.status);
                }

            }
        });

        function update() {
           $.ajax({
            url: "/book/updateBook",
            type: "post",
            data: $("#updateBook").serialize(),
            success: function (result) {
                console.log(result)
                if(result.code == "SUCCESS" && result.data == "") {
                    location.href = "book_list.html";
                } else {
                    alert(result.data);
                }
            }
           });
        }
    </script>

我们需要根据图书id来对图书信息进行修改,因此前端需要传递图书id

获取图书id有两种方式:

1. 获取url中参数的值(需要拆分url)

2. 在form表单中,添加一个隐藏输入框,存储图书id,就可以使用 $("#updateBook").serialize() 将图书id与其他信息一起提交给后端

在这里,我们选择第二种方式,即在 form 表单中添加一个隐藏输入框 

重新运行程序, 我们修改id = 27的图书信息:

点击修改后,原图书信息正确显示:

进行修改:

修改成功:

删除图书

删除分为 逻辑删除 物理删除

逻辑删除(软删除,假删除,Soft Delete):即不真正删除数据,而在某行数据上增加类型is_deleted的删除标识,一般使用update语句

物理删除(硬删除):从数据库表中删除一行或一集合数据,一般使用delete语句

因此,删除图书有两种实现方式:

逻辑删除:

update book_info set status = 0 where id = 10

物理删除:

delete from book_info where id = 10

 通常情况下,我们采用逻辑删除的方式,也可以采用 物理删除+归档 的方式

在这里,我们采用 逻辑删除 的方式

因此,此时依旧是更新逻辑,我们可以直接使用修改图书中的代码

[URL]

POST /book/deleteBook

[请求参数]

id=10

[响应]

{

    "code": "SUCCESS",

    "data": "",

    "errorMessage": ""

}

删除成功,返回空字符串"",删除失败,返回错误信息 

实现服务器端代码

BookController:

    /**
     * 删除图书信息
     */
    @RequestMapping("/deleteBook")
    public Result<String> deleteBook(BookInfo bookInfo) {
        log.info("删除图书信息, 获取参数bookInfo:{}", bookInfo);
        return this.updateBook(bookInfo);
    }

直接调用 updateBook 方法实现删除

修改客户端代码
            function deleteBook(id) {
                var isDelete = confirm("确认删除?");
                if (isDelete) {
                    //删除图书
                    $.ajax({
                        url: "/book/deleteBook",
                        type: "post",
                        data:  {
                            id : id,
                            status: 0 
                        },
                        success: function(result) {
                            if(result.code == "SUCCESS" && result.data == "") {
                                location.href = "book_list.html";
                            }else {
                                alert("删除失败,请联系管理员");
                            }
                        }
                    })
                }
            }

当删除成功时,返回图书列表页,删除失败时,弹出提示框 

测试: 

我们删除 id = 27 的图书信息:

删除成功 

批量删除

批量删除,也就是批量修改数据

约定前后端交互接口:

[URL]

POST /book/deleteBook

[请求参数]

id=25&id=26

[响应]

{

    "code": "SUCCESS",

    "data": "",

    "errorMessage": ""

}

当点击 批量删除 按钮时,只需要将复选框选中的图书id发送到后端即可

此时有多个id,因此我们使用List的形式来传递参数

实现服务端代码

BookController:

    /**
     * 批量删除图书信息
     */
    @RequestMapping(value = "/batchDeleteBook", produces = "application/json")
    public Boolean batchDeleteBook(@RequestParam List<Integer> ids) {
        log.info("批量删除图书, ids:{}", ids);
        try {
            int result = bookService.batchDeleteBook(ids);
        }catch (Exception e) {
            log.error("批量删除图书异常, e", e);
            return false;
        }
        return true;
    }

在接收集合时,需要使用 @RequestParam 绑定参数关系 

 业务层代码:

BookService:

    public int batchDeleteBook(List<Integer> ids) {
        return bookInfoMapper.batchDeleteBook(ids);
    }

由于删除的id是可选的,因此我们使用xml的方式实现

    <update id="batchDeleteBook">
        update book_info set status = 0
        where id in
        <foreach collection="ids" open="(" item="id" close=")" separator=",">
            #{id}
        </foreach>
    </update>
修改客户端代码
            function batchDelete() {
                var isDelete = confirm("确认批量删除?");
                if (isDelete) {
                    //获取复选框的id
                    var ids = [];
                    $("input:checkbox[name='selectBook']:checked").each(function () {
                        ids.push($(this).val());
                    });

                    $.ajax({
                        url: "/book/batchDeleteBook?ids=" + ids,
                        type: "post",
                        success: function(result) {
                            if(result.code == "SUCCESS" && result.data == true) {
                                location.href = "book_list.html";
                            }else {
                                alert("批量删除失败,请联系管理员!");
                            }
                        }
                    })
                }
            }

重新运行程序,进行测试: 

强制登录

当用户未登录时,不能访问图书相关页面,因此我们使用拦截器拦截前端发来的请求,判断用户是否进行登录,若已登录,则放行;若未登录,则进行拦截,自动跳转到登录页面

在判断用户是否进行登录时,我们可以使用 cookie 和 session ,也可以使用前面学习的JWT令牌:http://t.csdnimg.cn/5toZg,

在这里,我们使用JWT令牌

令牌生成

我们首先引入 JWT令牌的依赖:

		<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-api</artifactId>
			<version>0.11.5</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-impl</artifactId>
			<version>0.11.5</version>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
			<version>0.11.5</version>
			<scope>runtime</scope>
		</dependency>

utils 目录下创建 JwtUtils 类:

JwtUtils 中实现令牌的生成和校验:

我们首先实现密钥(密钥是进行签名计算的关键)生成:

我们在 test 目录下实现密钥的生成:

@SpringBootTest
public class JwtUtils {
    // 生成随机密钥
    @Test
    void genKey() {
        SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
        String secretStr = Encoders.BASE64.encode(secretKey.getEncoded());
        System.out.println(secretStr);
    }
}

运行,得到密钥:

PNYvhIto8tbYt+RWiWHGusQeb8AO5TdCl9zRlqcJToo=

以运行结果作为密钥:

@Slf4j
public class JwtUtils {
    // 设置令牌过期时间为1h
    private static final long JWT_EXPIRATION = 60 * 60 * 1000;
    // 密钥
    private static final String secretStr = "PNYvhIto8tbYt+RWiWHGusQeb8AO5TdCl9zRlqcJToo=";
    // 生成密钥
    private static final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretStr));

    /**
     * 生成令牌
     */
    public static String genJwt(Map<String, Object> claim) {
        // 生成令牌
        String token = Jwts.builder()
                .setClaims(claim) // 自定义信息
                .setExpiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION)) // 过期时间
                .signWith(key)
                .compact();
        return token;
    }
}

测试:

    @Test
    void genJwt() {
        Map<String, Object> claim = new HashMap<>();
        claim.put("id", 1);
        claim.put("userName", "zhangsan");
        System.out.println(JwtUtils.genJwt(claim));
    }

运行结果:

eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlck5hbWUiOiJ6aGFuZ3NhbiIsImV4cCI6MTcxODY3NzgxMn0.6dz5aMSxXMu_yi9izmVxDgzphPwV3a_a2_7aJCi8qNk

将运行结果复制到官网进行解析:

校验通过

接下来,我们实现令牌的校验:

    /**
     * 令牌校验
     */
    public static Claims parseToken(String token) {
        JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
        Claims claims = null;
        try {
            claims = build.parseClaimsJws(token).getBody();
        }catch (Exception e) {
            log.error("解析token失败, e", e);
            return null;
        }
        return claims;
    }

 我们进行测试,解析刚才生成的令牌:

    @Test
    void parseToken() {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlck5hbWUiOiJ6aGFuZ3NhbiIsImV4cCI6MTcxODY3ODYxNH0.af6Jcqp8PjZUpaA6jn2wX12XACTu7eLvp1sZDNZa3CQ";
        Claims claims = JwtUtils.parseToken(token);
        System.out.println(claims);
    }

运行结果:

与我们在官网解析的结果一致

实现了令牌的生成和校验后,我们就可以实现拦截器了

拦截器

添加拦截器

创建 interceptor 目录,在 interceptor目录下创建 LoginInterceptor

从 header中获取token,并校验token是否合法

@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("LoginInterceptor preHandle...");
        // 获取token
        String token = request.getHeader(Constants.REQUEST_HEADER_TOKEN);
        log.info("从header中获取token:{}", token);
        // 校验token, 判断是否放行
        Claims claims = JwtUtils.parseToken(token);
        if(claims == null) {
            // 校验失败
            response.setStatus(401);
            return false;
        }
        // 校验成功,放行
        return true;
    }
}

我们将 getHeader 中的字符串作为常量,放到Constans中,若后续修改字符串,我们就只需修改 Constans中的字符串

创建 constant 目录,在目录下创建 Constants:

public class Constants {
    public static final String REQUEST_HEADER_TOKEN = "user_token_header";

}

配置拦截器:

config 目录下创建 WebConfig 类:

并配置拦截路径:

@Component
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private LoginInterceptor loginInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/user/login")
                .excludePathPatterns("/user/register")
                .excludePathPatterns("/css/**")
                .excludePathPatterns("/js/**")
                .excludePathPatterns("/pic/**")
                .excludePathPatterns("/**/*.html");
    }
}

在用户登录时发放令牌:

我们在令牌中存放 用户id 和 用户名,同样,我们将这两个 key值存放到 Constants 中:

public class Constants {
    public static final String REQUEST_HEADER_TOKEN = "user_token_header";
    public static final String TOKEN_ID = "id";
    public static final String TOKEN_USERNAME = "userName";
}

修改登录代码: 

    /**
     * 用户登录
     */
    @RequestMapping("/login")
    public Result<String> login(String userName, String password) {
        log.info("用户登录,获取参数userName:{}, password:{}", userName, password);
        // 参数校验
        if(!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
            return Result.fail("用户名或密码为空!");
        }
        // 根据用户名进行查询
        UserInfo userInfo = userService.selectByName(userName);
        if(userInfo == null) {
            return Result.fail("用户名或密码错误!");
        }
        if(!SecurityUtils.verity(password, userInfo.getPassword())) {
            return Result.fail("密码错误!");
        }
        // 密码正确,返回token
        Map<String, Object> claim = new HashMap<>();
        claim.put(Constants.TOKEN_ID, userInfo.getId());
        claim.put(Constants.TOKEN_USERNAME, userName);
        String token = JwtUtils.genJwt(claim);
        log.info("UserController 返回token:{}", token);
        return Result.success(token);
    }

进行测试:

接下来,我们修改前端代码:

修改 login.html,完善登录方法,前端收到 token 后,将其保存在 localstorage

   <script>
        function login() {
            $.ajax({
                url: "/user/login",
                type: "post",
                data: {
                    userName: $("#userName").val(),
                    password: $("#password").val()
                },
                success: function(result) {
                    if(result.code == "SUCCESS" && result.data != null) {
                        localStorage.setItem("user_token", result.data);
                        location.href = "book_list.html";
                    }else {
                        alert(result.errorMessage);
                    }
                }
            });
        }
    </script>

由于我们访问图书列表页、添加图书页、修改图书页都需要获取浏览器保存的令牌,因此,我们将代码提取到 common.js 中,

js 目录下创建 common.js,在 common.js 中添加 ajaxSend() 方法

ajaxSend()方法是在ajax请求开始时执行的函数,其中

e:包含 event 对象

xhr:包含 XMLHttpRequest 对象

opt:包含 ajax 请求中使用的选项

$(document).ajaxSend(function(e, xhr, opt){
    var token = localStorage.getItem("user_token");
    xhr.setRequestHeader("user_token_header", token);
});

然后在对应页面(book_list.html、book_add.html、book_update.html)引入 common.js

<script src="js/common.js"></script>

 修改book_add.html,添加失败处理代码,使用 location.href 进行页面跳转

    <script type="text/javascript" src="js/jquery.min.js"></script>
    <script src="js/common.js"></script>
    <script>
        function add() {
            $.ajax({
                url: "/book/addBook",
                type: "post",
                data: $("#addBook").serialize(),
                success: function(result) {
                    if(result.code == 'SUCCESS' && result.data == "") {
                        // 添加成功,返回图书列表页
                        location.href = "book_list.html";
                    } else {
                        alert(result.data);
                    }
                }, error: function (error) {
                    if(error != null && error.status == 401) {
                        location.href = "login.html";
                    }
                }
            })
        }
    </script>

book_list.html、book_update.html 页面也是相同修改方式

修改完成后,我们再次运行程序,进行测试: 

我们尝试直接访问图书列表页:127.0.0.1:8080/book_list.html

此时由于未登录,因此跳转到登录页面:

进行登录,此时成功跳转,前端存储token:

关于 图书管理系统,本篇文章到此为止,关于更多的功能(搜索图书、退出登录等),大家可以自行继续实现

完整代码存放在:

项目完整代码/图书管理系统/spring-book · Echo/project - 码云 - 开源中国 (gitee.com)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1859932.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

串口小工具(来源网络,源码修改)

从CSDN 中的一位博主的分享做了一些修改 QtSerial 的配和更稳定些 信号和槽 … … 更不容易崩 # This Python file uses the following encoding: utf-8 import sys import timefrom PySide6.QtGui import QIcon, QTextCursor from PySide6.QtWidgets import QApplication, QWi…

【PyQt5】一文向您详细介绍 layout.addWidget() 的作用

【PyQt5】一文向您详细介绍 layout.addWidget() 的作用 下滑即可查看博客内容 &#x1f308; 欢迎莅临我的个人主页 &#x1f448;这里是我静心耕耘深度学习领域、真诚分享知识与智慧的小天地&#xff01;&#x1f387; &#x1f393; 博主简介&#xff1a;985高校的普通本…

el-dialog el-select适配移动端

一、el-dialog 2024.06.20今天我学习了如何对el-dialog弹窗适配移动端展示&#xff0c;效果如下&#xff1a; 代码如下&#xff1a; media screen and (min-width: 220px) and (max-width: 600px) {::v-deep .el-dialog {width: 95% !important;} } 二、el-select 代码如下…

技术革命背后的新功能发布:探索Facebook创新

随着技术的飞速发展和社交媒体的普及&#xff0c;Facebook作为全球最大的社交平台之一&#xff0c;不断推出新的功能和服务&#xff0c;以满足用户的需求和提升用户体验。这些新功能不仅仅是技术进步的体现&#xff0c;更是Facebook在竞争激烈的数字化时代中保持领先地位的关键…

CANFD每秒最多可以发送多少帧报文?CAN FD结构详解

我们知道CANFD比CAN拥有更长的数据长度&#xff08;最长64字节&#xff09;&#xff0c;更高的波特率&#xff08;8Mbps甚至更高&#xff09;。那么波特率更高&#xff0c;数据更长的CANFD&#xff0c;一秒钟最高可以发送多少帧CANFD报文呢&#xff1f; 想知道问题的答案&#…

C++结构体内存对齐规则

背景介绍 最近在使用Java语言写一个Java客户端&#xff0c;对接一个C/C语言编写的Server时&#xff0c;采用TCP协议进行通信&#xff0c;在将C结构体序列化的输出流转换为Java结构体时&#xff0c;需要按照结构体每个字段对应的字节长度截取字节流转换为Java类型&#xff0c;遇…

2024国际数字能源展,推动全球能源产业转型升级和可持续发展

随着全球对能源安全和可持续发展的日益关注&#xff0c;数字能源技术作为推动能源革命的重要力量&#xff0c;正逐步成为国际能源领域的新热点。2023年6月29日至7月2日&#xff0c;深圳会展中心成功举办了全球首个以数字能源为主题的2023国际数字能源展&#xff0c;这一盛会的成…

ET实现游戏中邮件系统逻辑思路(服务端)

ET是一个游戏框架&#xff0c;用的编程语言是C#&#xff0c;游戏引擎是Unity&#xff0c;框架作者&#xff1a;熊猫 ET社区 在游戏中我们通常都会看到有邮件系统&#xff0c;邮件系统的作用有给玩家通知、发放奖励等 下面小编使用ET框架带大家看一下邮件系统的一种实现方…

新型基坑气膜:施工开挖的得力干将—轻空间

随着城市建设的加速推进&#xff0c;施工过程中的环境问题日益受到关注。新型基坑气膜以其卓越的防尘、降噪、节能和防火功能&#xff0c;成为施工开挖领域中的得力干将&#xff0c;极大地提升了绿色施工的水平。 基坑气膜的作用 基坑气膜在施工现场形成了一个完全封闭的作业空…

数据模型(models)

自学python如何成为大佬(目录):https://blog.csdn.net/weixin_67859959/article/details/139049996?spm1001.2014.3001.5501 &#xff08;1&#xff09;在App中添加数据模型 在app1的models.py中添加如下代码&#xff1a; from django.db import models # 引入django.…

打造智能家居:用ESP32轻松实现无线控制与环境监测

ESP32是一款集成了Wi-Fi和蓝牙功能的微控制器&#xff0c;广泛应用于物联网项目。它由Espressif Systems公司开发&#xff0c;具有强大的处理能力和丰富的外设接口。下面我们将详细介绍ESP32的基础功能和引脚功能&#xff0c;并通过具体的实例项目展示其应用。 主要功能 双核处…

YOLOv10(6):YOLOv10基于TensorRT的部署(基于INetworkDefinition)

1. 写在前面 我们在前面已经讲过很多关于YOLOv10的一些知识点&#xff0c;也简单理了一下如何训练自己的数据。 现在本篇文章主要是讲解一下如何在TensorRT中部署YOLOv10&#xff0c;相信经过这一步&#xff0c;各位小伙伴已经能够无限的接近于将YOLOv10产品化了。 另一个需要说…

qt creator在windows上配置调试器

从微软下载winsdk&#xff1a;https://developer.microsoft.com/zh-cn/windows/downloads/sdk-archive/ 运行winsdksetup.exe 选择下载 选择debugging tools for windows 选择安装x64版本debug工具 重新打开qt&#xff0c;自动选择安装的编译器

[汇总] Docker容器详解 Macvlan 创建不同容器独立跑仿真(持续更新中)

一、概念介绍 1.1 什么是macvlan macvlan是一种网卡虚拟化技术&#xff0c;能够将一张网卡&#xff08;Network Interface Card, NIC&#xff09;虚拟出多张网卡&#xff0c;这意味着每个虚拟网卡都能拥有独立的MAC地址和IP地址&#xff0c;从而在系统层面表现为完全独立的网络…

下一代广域网技术-Segment Routing(SR)

1.SR MPLS 1.1.广域网VPN技术的演进 特殊的EVPN&#xff1a;从L2VPN发展的EVPN 以VPLS为例的L2VPN存在多种问题&#xff0c;例如业务部署复杂、网络规模受限、不支持双归等&#xff0c;IETF提出EVPN来解决这些问题EVPN最初被设计为一个基于BGP扩展的L2VPN技术&#xff0c;但…

DataWhale - 吃瓜教程学习笔记(三)

学习视频&#xff1a;第3章-对数几率回归_哔哩哔哩_bilibili 西瓜书对应章节&#xff1a; 3.3 对数几率回归 sigmoid函数 极大似然估计建模 信息论 以概率论、随机过程为基本研究工具&#xff0c;研究广义通信系统的整个过程 - 信息熵 &#xff08;信息期望&#xff09; 度…

上海亚商投顾:三大指数均跌超1% 全市场下跌个股近5000只

上海亚商投顾前言&#xff1a;无惧大盘涨跌&#xff0c;解密龙虎榜资金&#xff0c;跟踪一线游资和机构资金动向&#xff0c;识别短期热点和强势个股。 一.市场情绪 三大指数昨日震荡调整&#xff0c;尾盘集体跌超1%&#xff0c;微盘股指数跌逾4%&#xff0c;黄白二线分化明显…

【Hadoop集群搭建】实验3:JDK安装及配置、Hadoop本地模式部署及测试

1. 安装 SSH 工具 SSH Secure Shell Client 传输软件 FinalShell(推荐使用) 1.1使用SSH工具将JDK安装包上传至虚拟主机hadoop01, hadoop02, hadoop03&#xff0c;sogou500w 数据上传至 hadoop01。 a. 在虚拟主机/usr 目录下创建文件夹 java&#xff0c;JDK 上传至此目录&…

昇思25天学习打卡营第1天|初学教程

文章目录 背景创建环境熟悉环境打卡记录学习总结展望未来 背景 参加了昇思的25天学习记录&#xff0c;这里给自己记录一下所学内容笔记。 创建环境 首先在平台注册账号&#xff0c;然后登录&#xff0c;按下图操作&#xff0c;创建环境即可 创建好环境后进入即可&#xff0…

死锁的条件、预防、避免、检测

死锁的条件、预防、避免、检测 死锁的必要条件 互斥条件&#xff1a;一个资源一次只能给一个进程使用&#xff0c;非共享请求与保持条件&#xff1a;保持自己资源的同时请求其他资源&#xff0c;该资源可能被占有非剥夺条件&#xff1a;不能强行夺走资源&#xff0c;只能等别的…