目录
最终效果展示
QQ2025318-205420-HD
代码 Gitee 地址
1. 引言
在之前 Spring MVC 阶段的案例练习中, 我们只使用了 MVC 的知识来和前端进行交互, 没有对数据进行持久化的处理, 当重启服务器后, 所有的数据都会丢失.
之前练习的案例在这篇博客中:
而目前, 基于 MyBatis 知识的学习, 再来对之前的练习进行一下改造, 将数据持久化的保存到数据库中.
注意: 由于这些案例的部分接口已经在之前的博客中约定好了, 并且已经完成了前端代码, 因此在本篇博客中就不再赘述.
2. 留言板 [热身小练习]
在之前的代码中, 用户每发送一条留言, 前端会将这些留言追加到页面显示给用户, 并且我们的后端是会数据存储到 List 中, 当用户刷新页面时, 前端调用后端的接口, 将 List 中的数据返回给前端, 前端再将数据展示到页面上, 以到达用户刷新页面时, 之前发布的留言不会丢失的目的.
虽然之前的代码, 能够保证用户刷新页面时数据不丢失, 但是由于 List 是保存在内存中的, 服务器重启后, List 中的数据依旧会丢失.
要实现数据的持久化处理, 需要将数据保存到数据库中.
2.1 准备工作 - 配置相关
首先, 引入 MyBatis 和 MySQL 驱动的相关依赖.
接着, 进行数据库连接和其他相关配置.
spring:
application:
name: springboot-demo
# 数据库配置
datasource:
url: jdbc:mysql://127.0.0.1:3306/mybatis_test?characterEncoding=utf8&useSSL=false
username: root
password: 111111
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
# 配置 mybatis xml 的⽂件路径,在 resources/mapper 创建所有表的 xml ⽂件
mapper-locations: classpath:mapper/*Mapper.xml
configuration:
# 配置打印 MyBatis⽇志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 配置驼峰自动转换
map-underscore-to-camel-case: true
# 将默认的日志级别修改为 info
logging:
level:
root: info
2.2 创建留言表
将留言信息保存到数据库中, 首先需要创建一个留言表(message_info):
DROP TABLE IF EXISTS message_info;
CREATE TABLE `message_info` (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
`from` VARCHAR ( 127 ) NOT NULL,
`to` VARCHAR ( 127 ) NOT NULL,
`message` VARCHAR ( 256 ) NOT NULL,
`delete_flag` TINYINT ( 4 ) DEFAULT 0 COMMENT '0-正常, 1-删除',
`create_time` DATETIME DEFAULT now(),
`update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY ( `id` )
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;
注意: delete_flag 字段是为了实现逻辑删除而设定的.
- 逻辑删除: 不使用 delete 进行删除, 而是使用额外的字段(如: delete_flag)对记录进行标记, 表示不再使用该记录(不是真正的删除)
- 物理删除: 使用 delete 删除记录(真正的删除)
在实际工作中, 要尽量避免使用 delete, 以免带来不必要的损失.
2.3 创建 Java 类
创建完数据库表后, 需要创建一个 Java 实体类来和表中字段相映射.
@Data
public class MessageInfo {
private int id;
private String from;
private String to;
private String message;
private int deleteFlag;
private Date createTime;
private Date updateTime;
}
2.4 定义 Mapper 接口
在这个练习中, 涉及到以下两个操作:
- 存储留言信息 => 将留言 insert 到 message_info 表中
- 查询留言信息 => 从 message_info 表中 select 数据
因此, 需要在 Mapper 接口中定义两个方法(这里使用注解完成数据库相关操作):
@Mapper
public interface MessageMapper {
@Select("select * from message_info where delete_flag = 0")
List<MessageInfo> selectAll();
// 注意: from 和 to 是关键字, 要使用 ` 引起来
@Insert("insert into message_info (`from`, `to`, message) values (#{from}, #{to}, #{message})")
Integer insert(MessageInfo messageInfo);
}
注意: 表中的 from 字段和 to 字段是 MySQL 的关键字, 因此若指定这两个字段进行 sql 操作时, 需要使用反引号(`)引起来.
2.5 controller
controller 层接收前端传来的参数, 对参数进行简单校验后, 将参数传递给 service 层, service 层返回结果后, 再将结果返回给前端.
@RestController
@RequestMapping("/message")
public class MessageController {
// 保存留言板信息
// List<MessageInfo> list = new ArrayList<>();
@Resource
private MessageService messageService;
// 接口一: 用户发表留言
@PostMapping(value = "/publish", produces = "application/json")
public String publish(@RequestBody MessageInfo messageInfo) {
if(!StringUtils.hasLength(messageInfo.getFrom())
|| !StringUtils.hasLength(messageInfo.getTo())
|| !StringUtils.hasLength(messageInfo.getMessage())) {
return "{\"ok\": 0}";
}
// list.add(messageInfo);
int affectedRows = messageService.insert(messageInfo);
return "{\"ok\": 1}";
}
// 接口二: 获取留言信息
@GetMapping("/getList")
public List<MessageInfo> getList() {
return messageService.selectAll();
}
}
2.6 service
service 层接收 controller 传来的数据, 调用 mapper 层完成数据库操作, 并将结果返回给 controller 层:
@Service
public class MessageService {
@Resource
MessageMapper messageMapper;
public List<MessageInfo> selectAll() {
return messageMapper.selectAll();
}
public Integer insert(MessageInfo messageInfo) {
return messageMapper.insert(messageInfo);
}
}
controller, service, mapper 层的编写顺序并无要求, 根据个人习惯编写即可.
完成以上操作, 就对数据进行了持久化处理, 将数据保存到数据库中了. 即使重启服务器, 数据也不会丢失.
3. 图书管理系统
3.1 准备工作 - 配置相关
首先, 依旧需要引入 MyBatis 和 MySQL 驱动的相关依赖.
接着, 进行数据库连接和其他相关配置.
spring:
application:
name: springboot-demo
# 数据库配置
datasource:
url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8&useSSL=false
username: root
password: 111111
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
# 配置 mybatis xml 的⽂件路径,在 resources/mapper 创建所有表的 xml ⽂件
mapper-locations: classpath:mapper/*Mapper.xml
configuration:
# 配置打印 MyBatis⽇志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 配置驼峰自动转换
map-underscore-to-camel-case: true
# 将默认的日志级别修改为 info
logging:
level:
root: info
3.2 创建数据库表
3.2.1 创建用户表 + 图书表
-- 创建数据库
DROP DATABASE IF EXISTS book_test;
CREATE DATABASE book_test DEFAULT CHARACTER SET utf8mb4;
USE book_test;
-- 用户表
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 = '用户表';
-- 图书表
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 user_info ( user_name, PASSWORD ) VALUES ( "admin", "admin" );
INSERT INTO user_info ( user_name, PASSWORD ) VALUES ( "zhangsan", "123456" );
-- 初始化图书数据
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('活着', '余华', 29, 22.00, '北京文艺出版社');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('平凡的世界', '路遥', 5, 98.56, '北京十月文艺出版社');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('三体', '刘慈欣', 9, 102.67, '重庆出版社');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('金字塔原理', '麦肯锡', 16, 178.00, '民主与建设出版社');
3.3 创建 Java 类
为图书表和用户表创建相映射的 Java 实体类.
@Data
public class UserInfo {
private int id;
private String username;
private String password;
private int delete_flag; // 0-正常 1-删除
private Date createTime;
private Date updateTime;
}
@Data
public class BookInfo {
private Integer id;
private String bookName;
private String author;
private Integer count;
private BigDecimal price;
private String publish;
// 状态信息, 习惯上使用数字
private Integer status; // 0-删除, 1-正常, 2-不允许借阅
// 图书状态的中文表示
// 开发中, 一般交给前端处理. 由于学习, 在后端这里直接就处理了
private String statusCN;
private String createTime;
private String updateTime;
}
3.4 校验用户登录接口
用户登录时, 输入账号密码, 前端接收数据并通过 Ajax 请求将参数传递给后端接口, 后端 controller 层接收参数, 并传递给 service 层, service 调用 mapper 层查询数据库数据, 校验账号密码是否正确, 最终由 controller 层将校验结果返回给前端, 前端再进行相关处理将结果展示给用户.
@RequestMapping("/user")
@RestController
public class UserController {
@Resource
UserService userService;
// 登录验证接口
@RequestMapping("/login")
public boolean login(String name, String password, HttpSession session) {
if(!StringUtils.hasLength(name) || !StringUtils.hasLength(password)) {
return false;
}
UserInfo userInfo = userService.selectUserInfoByName(name);
if(userInfo != null && userInfo.getPassword().equals(password)) {
// 登录成功, 将用户信息保存在 Session 中
// 保存之前. 隐藏用户密码(可选)
userInfo.setPassword("****");
session.setAttribute("user", userInfo);
return true;
}
return false;
}
}
@Service
public class UserService {
@Resource
UserMapper userMapper;
public UserInfo selectUserInfoByName(String name) {
return userMapper.selectUserInfoByName(name);
}
}
在 Mapper 接口中, 对于校验用户登录, 需要定义一个根据用户名查询用户信息的方法:
@Mapper
public interface UserMapper {
/**
* 校验用户登录 : 根据用户名查询用户信息
* @param name
* @return
*/
@Select("select * from user_info where user_name = #{name}")
UserInfo selectUserInfoByName(String name);
}
3.5 添加图书
3.5.1 约定前后端交互接口
3.5.2 后端接口
添加图书, 就是将新图书的信息插入到图书表中.
前端收到用户所添加图书的图书信息后, 调用后端接口并传递图书信息, 后端接口在 controller service 层对图书信息进行校验, 最终在 Mapper 层将新图书信息插入到图书表中.
注意: 后端添加图书的接口, 使用的是一个 bookInfo 对象来接收的, 但是这并不意味着前端传来的就是 JSON 数据, 当前端传递的是多个参数的时, 后端也可以使用对象来接收:
- 前端传递多个参数, 放到 queryString 中或者以 form 表单的形式传递 => 后端对象接收(不使用注解)
- queryString 和 form 表单的数据传输格式都为: key1=value1&key2=value2&.... 但 queryString 位于 URL 中(GET 请求), form 表单数据位于 body 中(POST 请求)
- 前端传递 JSON 数据, 放到 body 中进行传递(POST 请求) => 后端对象接收(使用 @RequestBody)
3.5.3 前端代码
由于我们主攻后端, 这里就讲解一下前端代码中的核心部分:
这里使用了 JQuery 的 serialize 函数(序列化), 自动将选中的 form 中的数据导入到了 data 属性中.
图书添加成功后, 就会跳转到图书列表.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>添加图书</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/add.css">
</head>
<body>
<div class="container">
<div class="form-inline">
<h2 style="text-align: left; margin-left: 10px;"><svg xmlns="http://www.w3.org/2000/svg" width="40"
fill="#17a2b8" class="bi bi-book-half" viewBox="0 0 16 16">
<path
d="M8.5 2.687c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z" />
</svg>
<span>添加图书</span>
</h2>
</div>
<form id="addBook">
<div class="form-group">
<label for="bookName">图书名称:</label>
<input type="text" class="form-control" placeholder="请输入图书名称" id="bookName" name="bookName">
</div>
<div class="form-group">
<label for="bookAuthor">图书作者</label>
<input type="text" class="form-control" placeholder="请输入图书作者" id="bookAuthor" name="author" />
</div>
<div class="form-group">
<label for="bookStock">图书库存</label>
<input type="text" class="form-control" placeholder="请输入图书库存" id="bookStock" name="count"/>
</div>
<div class="form-group">
<label for="bookPrice">图书定价:</label>
<input type="number" class="form-control" placeholder="请输入价格" id="bookPrice" name="price">
</div>
<div class="form-group">
<label for="bookPublisher">出版社</label>
<input type="text" id="bookPublisher" class="form-control" placeholder="请输入图书出版社" name="publish" />
</div>
<div class="form-group">
<label for="bookStatus">图书状态</label>
<select class="custom-select" id="bookStatus" name="status">
<option value="1" selected>可借阅</option>
<option value="2">不可借阅</option>
</select>
</div>
<div class="form-group" style="text-align: right">
<button type="button" class="btn btn-info btn-lg" onclick="add()">确定</button>
<button type="button" class="btn btn-secondary btn-lg" onclick="javascript:history.back()">返回</button>
</div>
</form>
</div>
<script type="text/javascript" src="js/jquery.min.js"></script>
<!-- 实现前后端交互 -->
<script>
function add() {
// 此时, 前端应进行参数校验, 此处省略
// 前端向后端接口发送 Ajax 请求
$.ajax({
type: "post",
url: "/book/addBook",
data: $("#addBook").serialize(),
success: function(body) {
if(body == "") {
alert("添加成功");
location.assign("book_list.html");
}else {
alert(body);
}
}
});
}
</script>
</body>
</html>
3.6 展示图书列表(分页展示)
当用户登录成功后, 就会来到图书列表界面.
图书系统中可能存储着大量的书籍, 在一个网页中是展示不完的, 因此需要对图书列表进行分页:
3.6.1 约定前后端交互接口
3.6.2 后端接口
首先, 先来回顾下分页查询的 sql 语句:
MySQL 中, 使用 LIMIT 关键字进行分页查询, 后面跟两个参数:
- 第一个参数为 offset, 表示偏移量(从第几个记录开始往后进行查询, 不包含 offset 本身)
- 第二个参数为 limit, 表示从 offset 后, 要查询的个数(每页中数据的个数)
并且, 可以根据页数和每页个数计算得出 offset. 偏移量 = (当前页数 - 1) * (每页个数)
3.6.2.1 准备工作 - 参数接收和响应返回
后端接口必定需要接收 页码(currentPage) 以及每页的记录数(pageSize) 这两个参数, 因为只有知道了这两个参数, 才能计算得出 offset, 才能编写 sql 进行分页查询.
因此, 可以新建一个类专门用来接收请求中的参数:
@Data
public class RequestPage {
// 当前端没有传值时, 默认当前页是 1, 默认一页的大小是 10 条记录
// 查询哪一页
private int currentPage = 1;
// 每页中有多少条记录
private int pageSize = 10;
// 计算得到偏移量
private int offset;
// 根据当前页和每页的个数, 计算 offset
public int getOffset() {
return this.offset = (this.currentPage - 1) * this.pageSize;
}
}
注意: 如果 Mapper 方法参数是一个对象, 那么 #{} 是根据 get 方法获取对象属性值的, 因此我们在 offset 的 get 方法中, 计算得到 offset 返回即可.
注意: offset 的值必须通过 get 方法得到, 不能在构造方法中计算 offset 的值, 因为构造方法只能执行一次, currentPage 和 pageSize 已经有了默认值, 那么在构造对象时, offset 就会在构造方法中根据 currentPage 和 pageSize 的默认值被计算定型(offset 就会始终为 (1 - 1) * 10 = 0!!), 即使后续前端对 currentPage 和 pageSize 值进行了传递更改, offset 的值仍然不会改变!! 而通过 get 方法获取 offset, 每次获取到的都是最新值!!
此外, 根据接口文档, 响应结果包含了 total(表中记录总数) 和 List<BookIfo>(当前页中的图书信息) 两个属性, 因此, 可以新建一个类, 返回该类的对象作为响应结果:
@AllArgsConstructor
@NoArgsConstructor
@Data
public class ResponseResult<T> {
// 表中记录的总数
private int total;
// 当前页中的图书信息
private List<T> records;
// 把请求内容放到响应中返回, 以便前端后续查询
private RequestPage requestPage;
}
3.6.2.2 编写 Mapper 层方法
有了以上准备后, 就可以编写 Mapper 层了:
3.6.2.3 controller + service
接收到前端传来的 currentPage 和 pageSize 后, 我们直接在 service 层调用 Mapper 方法, 进行 count 计数和分页查询, 并将结果打包到 ResponseBody 对象中返回即可.
此外, 在 service 中, 还需要对分页查询得到的图书的 statusCN 属性依据 status 的值进行处理(这里通过枚举类):
枚举类:
3.6.3 前端代码
前端代码中, 这里使用了一个分页组件: https://jqpaginator.keenwon.com/
这里仍然只讲一下前端代码中的核心逻辑:
组件相关:
location.search 可以获取 URL 中 queryString 的信息(包括 ? ):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图书列表展示</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/list.css">
<script type="text/javascript" src="js/jquery.min.js"></script>
<script type="text/javascript" src="js/bootstrap.min.js"></script>
<script src="js/jq-paginator.js"></script>
</head>
<body>
<div class="bookContainer">
<h2>图书列表展示</h2>
<div class="navbar-justify-between">
<div>
<button class="btn btn-outline-info" type="button" onclick="location.href='book_add.html'">添加图书</button>
<button class="btn btn-outline-info" type="button" onclick="batchDelete()">批量删除</button>
</div>
</div>
<table>
<thead>
<tr>
<td>选择</td>
<td class="width100">图书ID</td>
<td>书名</td>
<td>作者</td>
<td>数量</td>
<td>定价</td>
<td>出版社</td>
<td>状态</td>
<td class="width200">操作</td>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div class="demo">
<ul id="pageContainer" class="pagination justify-content-center"></ul>
</div>
<script>
getBookList();
function getBookList() {
$.ajax({
url: "/book/getListByPage" + location.search,
type: "get",
success: function(res) {
if(res == null || res.records == null) {
return;
}
var books = res.records;
var newHtml = '';
for(var book of books) {
newHtml += '<tr>';
newHtml += '<td><input type="checkbox"name="selectBook" value="' + book.id + '" id="selectBook" class="book-select"></td>';
newHtml += '<td>' + book.id + '</td>';
newHtml += '<td>' + book.bookName + '</td>';
newHtml += '<td>' + book.author + '</td>';
newHtml += '<td>' + book.count + '</td>';
newHtml += '<td>' + book.price + '</td>';
newHtml += '<td>' + book.publish + '</td>';
newHtml += '<td>' + book.statusCN + '</td>';
newHtml += '<td><div class="op">';
newHtml += '<a href="book_update.html?id=' + book.id + '">修改</a>';
newHtml += '<a href="javascript:void(0)" onclick="deleteBook(' + book.id + ')">删除</a>';
newHtml += '</div></td></tr>';
}
// .html => 置换 tbody 标签里面的内容
$("tbody").html(newHtml);
//翻页信息
$("#pageContainer").jqPaginator({
totalCounts: res.total, //总记录数
pageSize: 10, //每页的个数
visiblePages: 5, //可视页数
currentPage: res.requestPage.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 == "change") {
location.assign("book_list.html?currentPage=" + page);
}
}
});
}
});
}
function deleteBook(id) {
var isDelete = confirm("确认删除?");
if (isDelete) {
$.ajax({
url: "/book/deleteBookById?id=" + id,
type: "post",
success: function(result) {
if(result == "") {
//删除图书
alert("删除成功!!");
location.assign("book_list.html");
}else {
alert("删除失败!! " + result);
}
}
});
}
}
function batchDelete() {
var isDelete = confirm("确认批量删除?");
if (isDelete) {
//获取复选框的id
var ids = [];
$("input:checkbox[name='selectBook']:checked").each(function () {
ids.push($(this).val());
});
console.log(ids);
alert("批量删除成功");
}
}
</script>
</div>
</body>
</html>
3.7 更新图书信息
3.7.1 约定前后端交互接口
进入更新图书信息的页面时, 需要先展示原来的图书信息, 然后用户选择性的对图书信息进行更新.
因此我们后端需要提供两个接口:
- 根据 id 查询图书信息接口
- 更新图书信息接口
3.7.2 后端接口
3.7.2.1 Mapper 层
根据 id 查询图书信息的接口, 不必多说.
但是更新图书信息的接口, 由于用户是选择性的更新图书信息, 因此需要编写动态 sql 来完成:
3.7.2.2 controller + service
3.7.3 前段代码
- 更新图书信息的 html 文件中, 首先需要调用后端 selectById 接口, 通过 id 查询图书信息, 将原本的图书信息展示在页面中
- 用户填写要修改内容, 前端将这些数据发送给后端, 后端接口进行 update 操作.
核心框架如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>修改图书</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/add.css">
</head>
<body>
<div class="container">
<div class="form-inline">
<h2 style="text-align: left; margin-left: 10px;"><svg xmlns="http://www.w3.org/2000/svg" width="40"
fill="#17a2b8" class="bi bi-book-half" viewBox="0 0 16 16">
<path
d="M8.5 2.687c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z" />
</svg>
<span>修改图书</span>
</h2>
</div>
<form id="updateBook">
<input type="hidden" class="form-control" id="bookId" name="id">
<div class="form-group">
<label for="bookName">图书名称:</label>
<input type="text" class="form-control" id="bookName" name="bookName">
</div>
<div class="form-group">
<label for="bookAuthor">图书作者</label>
<input type="text" class="form-control" id="bookAuthor" name="author"/>
</div>
<div class="form-group">
<label for="bookStock">图书库存</label>
<input type="text" class="form-control" id="bookStock" name="count"/>
</div>
<div class="form-group">
<label for="bookPrice">图书定价:</label>
<input type="number" class="form-control" id="bookPrice" name="price">
</div>
<div class="form-group">
<label for="bookPublisher">出版社</label>
<input type="text" id="bookPublisher" class="form-control" name="publish"/>
</div>
<div class="form-group">
<label for="bookStatus">图书状态</label>
<select class="custom-select" id="bookStatus" name="status">
<option value="1" selected>可借阅</option>
<option value="2">不可借阅</option>
</select>
</div>
<div class="form-group" style="text-align: right">
<button type="button" class="btn btn-info btn-lg" onclick="update()">确定</button>
<button type="button" class="btn btn-secondary btn-lg" onclick="javascript:history.back()">返回</button>
</div>
</form>
</div>
<script type="text/javascript" src="js/jquery.min.js"></script>
<script>
getBookInfo();
function getBookInfo() {
// 进入修改页面后, 先展示该图书原来的信息
$.ajax({
// 从路径中获取要修改的图书的 id(从 book_list.html 的 "修改" 超链接跳转过来的)
url: "/book/selectById" + location.search,
success: function(result) {
if(result == null) {
return;
}
// 将要修改的图书 id, 记录在隐藏标签中, 以便后续根据 id 进行 update 操作
$("#bookId").val(result.id),
$("#bookName").val(result.bookName),
$("#bookAuthor").val(result.author),
$("#bookStock").val(result.count),
$("#bookPrice").val(result.price),
$("#bookPublisher").val(result.publish),
$("#bookStatus").val(result.status)
}
});
}
// 将用户做出的修改, 发送给后端接口
function update() {
// 此处, 应对用户的输入进行校验, 这里暂且忽略.
$.ajax({
url: "/book/updateBook",
type: "post",
data: $("#updateBook").serialize(),
success: function(result) {
if(result == "") {
alert("更新成功!!");
location.assign("book_list.html");
}else {
alert("更新失败!!");
location.assign("book_list.html");
}
}
});
}
</script>
</body>
</html>
3.8 删除图书信息
3.8.1 约定前后端交互接口
3.8.2 后端接口
删除图信息, 本质是上就将图书对象中的 status 属性修改为 0.
可以和更新图书信息操作共用一个 Mapper 接口, 仅对 status 属性进行修改即可.
因此, Mapper 层可以不做修改, 只需封装一个 controller 和 service 即可.
3.8.3 前端代码
function deleteBook(id) {
var isDelete = confirm("确认删除?");
if (isDelete) {
$.ajax({
url: "/book/deleteBookById?id=" + id,
type: "post",
success: function(result) {
if(result == "") {
//删除图书
alert("删除成功!!");
location.assign("book_list.html");
}else {
alert("删除失败!! " + result);
}
}
});
}
}
3.9 批量删除图书信息
3.9.1 约定前后端交互接口
3.9.2 后端接口
批量删除图书和删除图书, 本质上都是 update 操作, 都是将图书表中对应图书的 status 字段设为 0.
不同的是, 批量删除图书需要根据用户选择的图书, 批量的进行删除, 也就需要编写动态 SQL.
3.9.3 前端代码
function batchDelete() {
var isDelete = confirm("确认批量删除?");
if (isDelete) {
//获取复选框的id
var ids = [];
$("input:checkbox[name='selectBook']:checked").each(function () {
ids.push($(this).val());
});
console.log(ids);
$.ajax({
type: "post",
url: "/book/batchDelete?ids=" + ids,
success: function(result) {
if(result) {
location.assign("book_list.html");
}else {
alert("删除失败!!");
}
}
});
}
}
3.10 强制登录机制
到目前为止, 图书管理系统的所有功能已经完成了, 但是有一个明显的 bug --- 即使用户没有登录, 也可以通过 book_list.html 路径或者后端接口路径直接访问图书管理系统:
这是一个非常严重的安全问题, 我们需要实现对用户进行强制登录的功能.
3.10.1 后端接口
解决以上安全问题, 就需要借助 Cookie-Session 机制.
3.10.1.1 封装常量
其实我们在校验用户登录接口中, 已经将用户信息存储到了 Session 中:
但是之前存储用户 Session 时, 我们是直接将 key 设置为一个字符串("user"), 这个方法是不友好的, 因为后续接口是也通过 key 来获取 Session 的, 当这个字符串(key)改变时, 那接口中 getAttribute 中的 key 也得做出相应的改变, 因此, 我们可以将这个常量 key(当然不限于此)封装到一个类中, 实现 key 值对代码的解耦:
3.10.1.2 封装响应结果
我们可以借助服务端的 Session 和客户端的 Sessionid, 完成强制登录操作.
当用户后续向后端服务器发送请求时, 请求中都会携带 Sessionid, 那我们就可以在后端接口中, 根据用户的 Sessionid 进行用户校验操作, 即根据 Sessionid 查找对应的 Session, 如果 Session 存在, 那么就说明该用户登录过, 后端就给予正常响应; 否则, 对用户进行强制登录操作.
当无法从 Session 获取到用户信息时(上图第3步), 说明用户未登录, 此时我们应该返回一些错误提示信息, 告诉前端用户还未登录, 应将页面跳转到登录页面, 对用户进行强制登录操作.
当然, 响应结果不是只有用户未登录这一个结果, 当然还有后端接口内部错误, 以及用户已登录并操作成功(如添加图书成功)等等...
因此, 我们可以对返回结果再次进行封装:
code: 业务状态码, 不同的值代表不同状态
errMsg: 错误信息描述, 告诉前端错误原因是什么
data: 真实的业务数据(上文的 ResponseBody)
假设本例(图书系统)中的业务状态码含义如下:
code = 200: 结果正常/操作成功
code = 0: 用户未登录
code = -1: 后端内部错误
因此, 我们可以将 code 使用枚举类进行封装:
此时, 后端接口返回响应时, 返回一个 Result 对象即可.
但是每个接口返回时, 都需要 new 一个 Result 对象, 这样观感上会觉得代码冗余. 因此为了简化代码, 我们可以将不同的响应结果都封装到 Result 类中(不同结果对应一个 Result 方法), 接口返回响应时, 直接调用 Result 中的方法即可, 不需在接口中 new Result 再返回:
这里只展示查询图书列表接口的强制登录代码, 剩下接口的代码就不一一展示了.
到这里, 我们就完成了强制用户登陆的后端代码, 我们可以通过 postman/浏览器 在还未登录的情况下, 访问后端查询图书列表的接口, 观察结果(提示用户未登录):
然后, 我们进行登录操作, 再次访问该后端接口:
登录完毕后, 服务器就创建了 Session 并存储了用户信息, 并通过 set-cookie 向客户端发送了 Sessionid, 用户再次发起请求时, 请求中就会携带 Sessionid, 服务器通过 Sessionid 找到对应的 Session , 就能识别到用户已登录, 就能正常响应结果了.
3.10.2 前端代码
由于我们对后端响应结果进行了封装, 因此前端接收的结果也就发生了改变, 我们需要对前端代码进行简单调整, 并对未登录且越权访问的用户跳转到登录界面实施强转登录操作:
到这里, 图书管理系统大功告成!!
END