目录
在学习了 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目录下:
测试前端页面
我们运行程序,访问前端页面:登录http://127.0.0.1:8080/login.html
(其他页面就不再一一展示了,大家自行进行测试)
前端页面正确显示,项目的准备工作完成
后端代码实现
项目可分为 控制层(Controller),服务层(Service),持久层(Mapper),还有实体类和公共层:
我们首先根据需求实现项目公共模块,即 实体类 和 公共层
项目公共模块
实体类
需要创建两个实体:UserInfo类 和 BookInfo类
创建 model 目录,在 model 目录下根据数据表创建 UserInfo 和 BookInfo
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 时,我们跳转到对应分页(若不进行判断,则会在初始化时一直进行跳转)
注意对应保持一致
此时,再次运行程序,访问图书列表展示http://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)