目录
项目分析
实现页面
功能描述
页面预览
准备工作
数据准备
创建数据库
用户表
创建项目
导入前端页面
测试前端页面
后端代码实现
项目公共模块
实体类
公共层
统一结果返回
统一异常处理
业务实现
持久层
用户登录
用户注册
密码加密验证
添加图书
图书列表
修改图书
删除图书
批量删除
强制登录
令牌生成
拦截器
在学习了 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)