课程设计总结 | 前后端分离 | Spring Boot 2.5.6 + Vue 2 + Element UI + MySQL
高校图书馆的日常业务围绕"图书"和"借阅"展开:图书信息需要按类别归档、读者需要能自助查询、借还书需要留下可追溯的记录。传统的人工登记方式在图书数量增长后会出现两个问题:一是查询效率低,管理员很难快速定位某位读者当前借了哪些书;二是状态不同步,书架上的书被借走后,台账上往往不能及时更新,导致"显示在馆、实际已借出"的情况。
本系统正是为了解决这两个问题而设计的。系统面向管理员和读者两类角色:管理员负责图书、图书类型、用户和借阅记录的维护;读者可以查询图书、自助借还、查看自己的借阅记录。系统采用前后端分离架构,后端以 RESTful 接口对外提供服务,前端通过 Axios 调用接口渲染页面,二者可以独立开发、独立部署。
本项目采用 Windows 10 操作系统和 IntelliJ IDEA 2021.2.1(Ultimate Edition) 进行系统开发,具体环境如下表所示。
表 1-1 开发环境
名称 | 版本 / 说明 |
|---|---|
操作系统 | Windows 10 home |
开发工具 | IntelliJ IDEA 2021.2.1(Ultimate Edition) |
Java 版本 | JDK 1.8.0 |
构建工具 | Maven 3.3.9 |
Web 服务器 | Tomcat 8.5(Spring Boot 内嵌) |
数据库 | MySQL 5.7 |
缓存 | Redis(存放登录 Token) |
接口测试 | Postman / curl |
表 1-2 技术栈选型
层次 | 技术 |
|---|---|
前端框架 | Vue 2 + Vue Router + Vuex |
前端 UI 库 | Element UI |
前端网络库 | Axios |
后端框架 | Spring Boot 2.5.6 |
持久层 | MyBatis(XML 映射 + 动态 SQL) |
数据库 | MySQL 5.7(InnoDB) |
缓存 | Redis(Token 存储,有效期 1 小时) |
构建 | Maven |
表 1-3 角色分工
姓名 | 职位 | 负责范围 |
|---|---|---|
liwo | 项目经理 | 进度把控、任务拆分、需求确认 |
liwo | 系统分析师 | 需求分析、功能模块划分、接口约定 |
liwo | 设计师 | 页面原型、交互设计、UI 风格统一 |
liwo | 前端开发 | 页面开发、Axios 接口调用、路由与状态管理 |
liwo | 后端开发 | 接口开发、数据库设计、业务逻辑实现 |
liwo | 测试工程师 | 编写测试用例、接口测试、缺陷跟踪 |
liwo | 运维工程师 | 环境搭建、项目部署、备份与恢复 |
通过对图书馆日常业务的梳理,系统需要满足以下需求:
系统功能可归纳为四大模块:
系统整体分为管理员模块与读者模块两大部分,两者共用同一套后端接口,通过登录时选择的身份(isAdmin 字段)区分权限。
系统功能结构如图 3-1 所示。
图 3-1 系统结构图

本系统为前后端分离架构,前端为单页应用(SPA),使用 Vue Router 的 hash 模式组织页面跳转,主要路由如下表。
表 3-1 前端路由表
路由 | 对应页面 | 可访问角色 |
|---|---|---|
/login | 登录页 | 全部 |
/register | 注册页 | 全部 |
/dashboard | 信息看板 | 全部 |
/bookmanage/bookinfo | 图书信息管理 | 全部(读者只读 + 借阅) |
/bookmanage/booktype | 图书类型管理 | 管理员 |
/bookmanage/borrow | 借阅信息管理 | 全部(读者仅本人记录) |
/other/user | 用户管理 | 管理员 |
/other/password | 修改密码 | 全部 |
登录页面如图 3-2 所示,包含用户名、密码、身份选择三个输入项和登录、注册两个按钮。身份选择为下拉框,可选"管理员"或"读者",系统会将该值一并提交到登录接口,与数据库中的 isAdmin 字段比对,从而保证"同一个用户名不会被错误地以另一种身份登录"。
图 3-2 登录页面

注册页面如图 3-3 所示,包含用户名、密码、确认密码三个输入框。点击"确认"后调用 /user/register 接口;若用户名已存在,接口返回 0,前端提示注册失败。本系统面向校内使用,注册默认创建读者账号,管理员账号由已有管理员在用户管理中新增。
图 3-3 注册页面

信息看板页面如图 3-4 所示,登录成功后默认跳转至此。页面顶部展示系统欢迎信息,下方是项目开发环境说明。看板数据来自 /user/getCount、/bookInfo/getCount、/bookType/getCount、/borrow/getCount 四个统计接口。
图 3-4 信息看板页面

图书信息管理页面如图 3-5 所示,是管理员使用频率最高的页面。页面顶部是查询区,支持按书名、作者、类型三个条件组合查询,另有"显示全部"按钮用于清空条件;下方是图书列表,列出序号、图书名称、图书作者、图书价格、图书类型名、图书描述、图书借阅状态(未借出为绿色标签、已借出为红色标签)等信息,每行提供"编辑""删除""借阅登记"三个操作按钮。
列表底部是分页控件,支持每页 10 / 20 / 50 条的切换与页码跳转,当前数据共 60 条图书记录,分为 6 页展示。
图 3-5 图书信息管理页面

图书类型管理页面如图 3-6 所示,列出类型编号、类型名称、类型描述,并提供新增、编辑、删除操作。当前系统内置 10 个类型:计算机科学、历史、文学、科幻、小说、外语、数学、经济管理、心理学、艺术设计。由于图书表中保存的是类型 ID,删除类型前需要确认该类型下没有图书,避免出现"孤儿数据"。
图 3-6 图书类型管理页面

借阅信息管理页面如图 3-7 所示,这是系统的核心业务页面。列表展示借阅记录编号、用户名、图书名、借书时间、还书时间,并提供"删除"和"归还图书"操作。
页面支持按用户名和图书名模糊查询。管理员可以看到全部读者的借阅记录,而读者登录后进入同一路由时,前端会自动带上当前用户的 userid 作为查询条件,从而只能看到自己的借阅记录。
图 3-7 借阅信息管理页面

用户管理页面如图 3-8 所示,用于维护读者与管理员账号。列表展示用户编号、用户名、密码、是否为管理员(0 表示读者、1 表示管理员),并提供编辑、删除、批量删除操作。
需要注意的是,超级管理员账号(userId = 1)在服务端做了保护——删除请求会被直接拒绝并返回 0,防止误删导致系统无法登录。
图 3-8 用户管理页面

修改密码页面如图 3-9 所示,包含原密码、新密码、确认新密码三项。提交时会先校验原密码是否正确:若原密码错误,接口返回 0,前端提示"原密码错误";校验通过后写入新密码并返回 1。
图 3-9 修改密码页面

读者登录后进入的图书页面如图 3-10 所示,与管理员的图书管理页面使用同一套列表组件,但隐藏了新增、编辑、删除等管理员操作,仅保留查询与借阅入口。读者可以按书名或作者检索图书,点击"借阅图书"即可完成借书。
图 3-10 读者端图书查询页面

读者端的借阅信息管理页面如图 3-11 所示,列出当前读者本人的借阅历史,包含图书名、借书时间、还书时间,并提供"归还图书"操作。还书时间列为空表示该书尚未归还,这是判断借阅状态最直接的方式。
图 3-11 读者端借阅记录页面

系统后端采用 RESTful 风格,统一以 /BookManager 作为上下文根,端口为 8092。所有列表类接口返回统一的 JSON 结构,所有写操作返回受影响的行数。
表 3-2 主要接口清单
模块 | 请求方式 | 接口路径 | 说明 |
|---|---|---|---|
用户 | POST | /user/login | 登录,返回 Token |
用户 | GET | /user/info | 按 Token 获取当前用户 |
用户 | POST | /user/logout | 退出登录,清除 Token |
用户 | POST | /user/register | 注册读者账号 |
用户 | POST | /user/alterPassword | 修改密码 |
用户 | GET | /user/queryUsersByPage | 分页查询用户 |
用户 | POST / DELETE / PUT | /user/addUser、/user/deleteUser、/user/deleteUsers、/user/updateUser | 用户增删改 |
图书 | GET | /bookInfo/queryBookInfosByPage | 分页 + 条件查询图书 |
图书 | GET | /bookInfo/getCount | 图书总数 |
图书 | POST / DELETE / PUT | /bookInfo/addBookInfo、/bookInfo/deleteBookInfo、/bookInfo/deleteBookInfos、/bookInfo/updateBookInfo | 图书增删改 |
类型 | GET | /bookType/queryBookTypes | 查询全部类型 |
类型 | GET | /bookType/queryBookTypesByPage | 分页查询类型 |
借阅 | POST | /borrow/borrowBook | 借书(含状态校验与事务) |
借阅 | POST | /borrow/returnBook | 还书(含状态校验与事务) |
借阅 | GET | /borrow/queryBorrowsByPage | 分页 + 条件查询借阅记录 |
借阅 | GET | /borrow/getCount | 借阅总数 |
上传 | POST | /update/updateImg | 上传图书封面 |
系统共设计 4 张数据表:user(用户表)、book_info(图书信息表)、book_type(图书类型表)、borrow(借阅记录表)。表之间的关系如图 3-12 所示。
图 3-12 数据库表关系图

borrow 表是整个数据模型的关键:它是 user 与 book_info 之间的关联表,一条借阅记录同时关联"一个读者"和"一本图书",returnTime 字段为空表示尚未归还。这样设计既保留了完整的历史记录,又避免在图书表上重复存储借阅人信息。
表 3-3 user(用户表)
字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
userId | int(11) | 主键、自增 | 用户编号 |
userName | varchar(20) | 非空 | 用户名 |
userPassword | varchar(20) | 非空 | 密码 |
isAdmin | tinyint(4) | 非空 | 1 为管理员,0 为读者 |
表 3-4 book_type(图书类型表)
字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
bookTypeId | int(11) | 主键、自增 | 类型编号 |
bookTypeName | varchar(20) | 非空 | 类型名称 |
bookTypeDesc | varchar(255) | 非空 | 类型描述 |
表 3-5 book_info(图书信息表)
字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
bookId | int(11) | 主键、自增 | 图书编号 |
bookName | varchar(50) | 非空 | 图书名称 |
bookAuthor | varchar(50) | 非空 | 图书作者 |
bookPrice | decimal(10,2) | 非空 | 图书价格 |
bookTypeId | int(11) | 非空 | 所属类型(对应 book_type) |
bookDesc | varchar(255) | 非空 | 书籍描述 |
isBorrowed | tinyint(4) | 非空 | 1 表示借出,0 表示已还 |
bookImg | varchar(255) | 可空 | 书籍图片地址 |
表 3-6 borrow(借阅记录表)
字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
borrowId | int(11) | 主键、自增 | 借阅记录编号 |
userId | int(11) | 非空 | 借阅人(对应 user) |
bookId | int(11) | 非空 | 被借图书(对应 book_info) |
borrowTime | datetime | 非空 | 借书时间 |
returnTime | datetime | 可空 | 还书时间,为空表示未归还 |
前后端分离项目中,接口返回结构的统一是最先要解决的问题。项目通过 MyResult 工具类提供三种标准返回:普通结果(含 status、message、timestamp)、带数据的结果(额外含 data)、列表结果(含 code、count、data,供分页表格直接使用)。
package com.liwo.bms.utils;
import java.util.HashMap;
public class MyResult {
public static HashMap<String, Object> getResultMap(Integer status, String message) {
return new HashMap<String, Object>() {
{
put("status", status);
put("message", message);
put("timestamp", System.currentTimeMillis());
}
};
}
public static HashMap<String, Object> getResultMap(Integer status, String message, Object data) {
return new HashMap<String, Object>() {
{
put("status", status);
put("message", message);
put("data", data);
put("timestamp", System.currentTimeMillis());
}
};
}
public static HashMap<String, Object> getListResultMap(Integer status, String message, Integer count, Object data) {
return new HashMap<String, Object>() {
{
put("code", status);
put("message", message);
put("count", count);
put("data", data);
}
};
}
}其中列表结果采用 code 而不是 status,是为了对齐 Element UI 表格分页组件的默认字段约定——组件会直接读取 data 渲染表格行、读取 count 计算总页数,无需在前端做额外转换。
图书查询是本系统最典型的"分页 + 多条件"场景。Controller 层只做参数准备与结果组装,具体的分页参数换算由 MyUtils.parsePageParams 完成——它把前端的 page(第几页)和 limit(每页条数)换算成 SQL 的 begin 与 size。
package com.liwo.bms.web;
import com.liwo.bms.model.BookInfo;
import com.liwo.bms.service.BookInfoService;
import com.liwo.bms.utils.MyResult;
import com.liwo.bms.utils.MyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping(value = "/bookInfo")
public class BookInfoController {
@Autowired
BookInfoService bookInfoService;
// 获取图书数量
@GetMapping(value = "/getCount")
public Integer getCount(){
return bookInfoService.getCount();
}
// 查询所有图书信息
@GetMapping(value = "/queryBookInfos")
public List<BookInfo> queryBookInfos(){
return bookInfoService.queryBookInfos();
}
// 分页搜索查询图书信息 params: {page, limit, bookname, bookauthor, booktypeid}
@GetMapping(value = "/queryBookInfosByPage")
public Map<String, Object> queryBookInfosByPage(@RequestParam Map<String, Object> params){
MyUtils.parsePageParams(params);
int count = bookInfoService.getSearchCount(params); // 获得总数
List<BookInfo> bookInfos = bookInfoService.searchBookInfosByPage(params); // 分页查询
return MyResult.getListResultMap(0, "success", count, bookInfos);
}
// 添加图书信息
@PostMapping(value = "/addBookInfo")
public Integer addBookInfo(@RequestBody BookInfo bookInfo){
return bookInfoService.addBookInfo(bookInfo);
}
// 删除图书信息
@DeleteMapping(value = "/deleteBookInfo")
public Integer deleteBookInfo(@RequestBody BookInfo bookInfo){
return bookInfoService.deleteBookInfo(bookInfo);
}
// 删除一些图书信息
@DeleteMapping(value = "/deleteBookInfos")
public Integer deleteBookInfos(@RequestBody List<BookInfo> bookInfos){
return bookInfoService.deleteBookInfos(bookInfos);
}
// 更新图书信息
@PutMapping(value = "/updateBookInfo")
public Integer updateBookInfo(@RequestBody BookInfo bookInfo){
return bookInfoService.updateBookInfo(bookInfo);
}
}对应的 Mapper 使用 MyBatis 的动态 SQL 实现"条件为空则不加该条件"的语义,并在图书列表查询中用子查询带出类型名称:
<select id="selectBySearch" resultMap="BaseResultMap">
select bookId, bookName, bookAuthor, bookPrice, bookTypeId, bookDesc, isBorrowed, bookImg,
(select bookTypeName from book_type where book_type.bookTypeId = book_info.bookTypeId) as bookTypeName
from book_info
<where>
<if test="bookname != null and bookname != '' ">
and bookName like concat('%',#{bookname},'%')
</if>
<if test="bookauthor != null and bookauthor != '' ">
and bookAuthor like concat('%',#{bookauthor},'%')
</if>
<if test="booktypeid != null and booktypeid != '' ">
and bookTypeId = #{booktypeid}
</if>
</where>
limit #{begin}, #{size}
</select>这里的 <where> 标签会自动处理第一个 and,避免出现 where and bookName like ... 的语法错误;concat('%', #{bookname}, '%') 则实现了书名与作者名的模糊查询。类型名称不冗余存储在图书表中,而是通过子查询实时获取,保证了类型改名后图书列表能同步更新。
借书是本系统中最需要保证数据一致性的操作。它同时要改两张表:把 book_info 的 isBorrowed 置为 1,并向 borrow 表插入一条记录。如果只成功了一半,就会出现"书显示已借出但查不到借阅记录"的脏数据。项目通过 Spring 的 @Transactional 注解配合手动回滚解决这一问题。
// 借书
@RequestMapping(value = {"/borrowBook", "/reader/borrowBook"})
@Transactional
public Integer borrowBook(Integer userid, Integer bookid){
try{
// 查询该书的情况
BookInfo theBook = bookInfoService.queryBookInfoById(bookid);
if(theBook == null) { // 图书不存在
throw new NullPointerException("图书" + bookid + "不存在");
} else if(theBook.getIsborrowed() == 1) { // 已经被借
throw new NotEnoughException("图书" + bookid + "库存不足(已经被借走)");
}
// 更新图书表的isBorrowed
BookInfo bookInfo = new BookInfo();
bookInfo.setBookid(bookid);
bookInfo.setIsborrowed((byte) 1);
Integer res2 = bookInfoService.updateBookInfo(bookInfo);
if(res2 == 0) throw new OperationFailureException("图书" + bookid + "更新被借信息失败");
// 添加一条记录到borrow表
Borrow borrow = new Borrow();
borrow.setUserid(userid);
borrow.setBookid(bookid);
borrow.setBorrowtime(new Date(System.currentTimeMillis()));
Integer res1 = borrowService.addBorrow2(borrow);
if(res1 == 0) throw new OperationFailureException("图书" + bookid + "添加借阅记录失败");
} catch (Exception e) {
System.out.println("发生异常,进行手动回滚");
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
e.printStackTrace();
return 0;
}
return 1;
}这段代码有三个设计要点:
updateBookInfo 与 addBorrow2 都会返回受影响的行数,一旦为 0 说明写库没有生效,立即抛出异常中断流程。setRollbackOnly() 显式标记事务回滚,最后统一返回 0 表示失败,前端据此提示用户。同一 Controller 中的 returnBook 采用相同的模式,只是把方向反过来:校验借阅记录存在且 returnTime 为空(防止重复还书),然后把图书状态置回 0、为借阅记录写入归还时间。
登录接口是整个系统的入口,其实现思路是"登录成功即发一个 Token,后续请求凭 Token 换用户信息",Token 存放在 Redis 中并设置 1 小时过期。
// 登录
@RequestMapping(value = "/login")
public Map<String, Object> login(@RequestBody User user) {
// 登录
User userObj = userService.login(user);
if(userObj == null) { // 账号或密码错误
// 返回结果对象
return MyResult.getResultMap(420, "账号或密码错误");
} else { // 账号密码正确
// 创建token
String token = TokenProcessor.getInstance().makeToken();
// 保存到Redis
userService.saveUser(token, userObj);
// 返回结果对象
return MyResult.getResultMap(200, "登录成功",
new HashMap<String, String>(){{ put("token", token); }});
}
}其中 Token 由时间戳加随机数经 MD5 摘要再 Base64 编码生成,保证不可预测:
public String makeToken() {
String token = (System.currentTimeMillis() + new Random().nextInt(999999999)) + "";
try {
MessageDigest md = MessageDigest.getInstance("md5");
byte md5[] = md.digest(token.getBytes());
BASE64Encoder encoder = new BASE64Encoder();
return encoder.encode(md5);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return null;
}登录时的身份校验是"三条件匹配":用户名、密码、isAdmin 必须同时命中数据库中的同一条记录。这样做的好处是读者无法用正确的账号密码从管理员入口登录,登录页的身份选择不再只是界面装饰,而是参与了一次真实的权限判定。
用户信息则通过 Token 反查:
// 查看用户信息
@RequestMapping(value = "/info")
public Map<String, Object> info(String token) {
// 从redis中取用户
User user = userService.getUser(token);
if(user == null) { // 获取失败
return MyResult.getResultMap(420, "获取用户信息失败");
} else { // 获取成功
return MyResult.getResultMap(200, "获取用户信息成功", user);
}
}图书封面需要上传到服务器。上传接口接收 multipart 请求,把文件写入 classes/static/pictures 目录,文件名加上时间戳防止重名,最后返回可访问的网络路径。
@RequestMapping("/updateImg")
@ResponseBody
public Map<String,Object> updateImg(HttpServletRequest req){
String resPath = myUpdate(req, "pictures");
Map<String,Object> res = new HashMap<>();
res.put("code",0);
res.put("data", resPath);
return res;
}前端拿到返回的路径后,把它写入图书记录的 bookImg 字段,列表页即可直接以 <img src> 渲染封面。
8092 端口,上下文 /BookManagerbook_manager表 4-1 接口测试用例与结果
编号 | 测试用例 | 输入 | 预期结果 | 实际结果 | 结论 |
|---|---|---|---|---|---|
1 | 管理员正常登录 | admin / admin / 管理员 | status=200,返回 token | status=200,返回 token | 通过 |
2 | 密码错误登录 | admin / wrong / 管理员 | status=420,提示账号或密码错误 | status=420,账号或密码错误 | 通过 |
3 | 借书 | 读者 14 借图书 3 | 返回 1 | 返回 1 | 通过 |
4 | 借书后状态联动 | 查询图书 3 | isBorrowed 变为 1 | isBorrowed=1 | 通过 |
5 | 重复借阅同一本书 | 读者 14 再借图书 3 | 返回 0(拒绝) | 返回 0 | 通过 |
6 | 还书 | 归还记录 100 | 返回 1 | 返回 1 | 通过 |
7 | 重复还书 | 再次归还记录 100 | 返回 0(拒绝) | 返回 0 | 通过 |
8 | 书名模糊查询 | bookname=Java | 命中 1 条 | count=1,返回 1 条 | 通过 |
9 | 分页越界 | page=99 | 总数为 60,当前页为空 | count=60,data 为 0 条 | 通过 |
10 | 统计接口 | 无 | 用户 21 / 图书 60 / 类型 10 / 借阅 100(含用例 3 新增的 1 条) | 与预期一致 | 通过 |
book_manager(字符集建议 utf8)。book_manager_full.sql:脚本会依次建库、建表并写入业务数据,一次性完成 10 个图书类型、60 本图书、21 个用户、99 条借阅记录的初始化。其中 23 本图书处于"已借出"状态,与借阅记录中未归还的 23 条数据严格对应,可用于直接演示借还流程。DROP TABLE IF EXISTS,可反复导入而不会产生重复数据(生产环境使用前请先备份)。src/main/resources/application.properties:server.port=8092
server.servlet.context-path=/BookManager
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/book_manager?useAffectedRows=true
spring.datasource.username=你的数据库账号
spring.datasource.password=你的数据库密码
spring.redis.host=你的Redis地址
spring.redis.port=6379
spring.redis.password=你的Redis密码BookManagerApplication。http://localhost:8092/BookManager/,使用初始账号登录: - 管理员:admin / admin - 读者:liwo / 123456表 5-1 常见问题与解决办法
现象 | 原因 | 解决办法 |
|---|---|---|
启动报数据库连接失败 | 账号密码占位符未替换 | 检查 application.properties 中的 ? 占位 |
登录接口 500 | Redis 未启动 | 先启动 Redis,或确认 spring.redis.* 配置正确 |
页面样式错乱 | 静态资源路径带上下文前缀 | 前端打包时 publicPath 需配置为 /BookManager/ |
页面 404 | 路由为 hash 模式 | 访问地址需带 #,如 /#/bookmanage/bookinfo |
中文乱码 | 数据库或连接字符集不一致 | 建库使用 utf8,JDBC URL 增加字符集参数 |
本系统实现了图书管理业务的完整闭环:用户可注册登录并按角色进入不同界面,管理员可以维护图书、图书类型、用户和借阅记录,读者可以自助查询图书、借还图书并查看个人借阅历史。系统采用前后端分离架构,后端提供 RESTful 接口,前端通过 Axios 调用,二者解耦,便于后续扩展。
@Transactional 加手动回滚是可靠的做法。userPassword 直接以明文保存,存在安全隐患,后续应改为 BCrypt 加盐哈希。isBorrowed 只有 0/1 两态,同一本书无法多副本借阅,可扩展为库存数量字段。原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。