1 概述
1.1 项目背景
高校图书馆的日常业务围绕"图书"和"借阅"展开:图书信息需要按类别归档、读者需要能自助查询、借还书需要留下可追溯的记录。传统的人工登记方式在图书数量增长后会出现两个问题:一是查询效率低 ,管理员很难快速定位某位读者当前借了哪些书;二是状态不同步,书架上的书被借走后,台账上往往不能及时更新,导致"显示在馆、实际已借出"的情况。
本系统正是为了解决这两个问题而设计的。系统面向管理员 和读者两类角色:管理员负责图书、图书类型、用户和借阅记录的维护;读者可以查询图书、自助借还、查看自己的借阅记录。系统采用前后端分离架构,后端以 RESTful 接口对外提供服务,前端通过 Axios 调用接口渲染页面,二者可以独立开发、独立部署。
1.2 开发工具与环境
本项目采用 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.3 技术栈
表 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.4 项目成员
表 1-3 角色分工
| 姓名 | 职位 | 负责范围 |
|---|---|---|
| liwo | 项目经理 | 进度把控、任务拆分、需求确认 |
| liwo | 系统分析师 | 需求分析、功能模块划分、接口约定 |
| liwo | 设计师 | 页面原型、交互设计、UI 风格统一 |
| liwo | 前端开发 | 页面开发、Axios 接口调用、路由与状态管理 |
| liwo | 后端开发 | 接口开发、数据库设计、业务逻辑实现 |
| liwo | 测试工程师 | 编写测试用例、接口测试、缺陷跟踪 |
| liwo | 运维工程师 | 环境搭建、项目部署、备份与恢复 |
2 系统分析
2.1 需求分析
通过对图书馆日常业务的梳理,系统需要满足以下需求:
- 身份与权限:系统存在管理员和读者两种角色,登录时需要选择身份,不同角色进入不同的功能界面,读者不能访问管理员的功能入口。
- 图书信息管理:图书包含编号、名称、作者、价格、类型、描述、封面、借阅状态等属性,需要支持增加、修改、删除、按条件搜索。
- 图书类型管理:图书按类型归档,类型可以被新增与修改,图书与类型之间是多对一关系。
- 借阅管理:读者借书后,图书的借阅状态需要立即变为"已借出",同时生成一条借阅记录;还书后状态回到"未借",记录补上归还时间。
- 用户管理:管理员可以维护读者账号,可以重置密码,可以删除账号。
- 数据统计:需要一个信息看板,展示用户数量、图书数量、借阅数量等关键指标。
2.2 可行性分析
- 技术可行性:Spring Boot 与 Vue 均为成熟框架,社区资料充足;MyBatis 的动态 SQL 足以应对本系统的条件查询需求;系统数据量小(图书量级为百条),单库单表即可支撑。
- 经济可行性:全部采用开源技术栈,无商业授权成本;开发阶段使用本机 MySQL 与 Redis,无需额外服务器投入。
- 操作可行性:界面采用 Element UI 标准组件,操作方式与常见的后台管理系统一致,管理员无需专门培训即可上手。
2.3 功能分析
系统功能可归纳为四大模块:
- 账号管理:包含普通用户(读者)账号管理与管理员账号管理。读者可以自助注册;管理员可以登录后查看全部账号、修改用户信息、重置密码、删除账号。
- 图书管理 :包含图书信息管理与图书类型管理。支持添加、删除、修改图书;查询分为单独查询 (按书名、按作者、按类型)和批量查询(显示全部)两类;图书封面的上传由独立的上传接口完成。
- 借阅管理:包含读者借书、管理员代借书、还书、续借(修改借阅时间)、删除借阅记录。借书时系统会校验图书是否已被借出,重复借阅会被拒绝。
- 信息看板:展示用户数量、图书数量、借阅数量、访问次数等统计数据,便于管理员快速了解系统使用情况。
3 系统设计
3.1 功能概述
系统整体分为管理员模块 与读者模块 两大部分,两者共用同一套后端接口,通过登录时选择的身份(isAdmin 字段)区分权限。
- 管理员模块:注册、登录、图书管理(增删改查)、图书类型管理、用户管理、借阅管理、修改个人信息、修改密码。
- 读者模块:注册、登录、查询图书信息、借阅图书、归还图书、查看个人借阅记录、修改个人信息、修改密码。
系统功能结构如图 3-1 所示。
图 3-1 系统结构图

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

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

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

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

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

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

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

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

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

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

3.3 接口设计
系统后端采用 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 |
上传图书封面 |
3.4 数据库设计
系统共设计 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 | 可空 | 还书时间,为空表示未归还 |
4 实现与测试
4.1 关键技术实现
4.1.1 统一返回结构 MyResult
前后端分离项目中,接口返回结构的统一是最先要解决的问题。项目通过 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 计算总页数,无需在前端做额外转换。
4.1.2 分页与条件查询:BookInfoController
图书查询是本系统最典型的"分页 + 多条件"场景。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}, '%') 则实现了书名与作者名的模糊查询。类型名称不冗余存储在图书表中,而是通过子查询实时获取,保证了类型改名后图书列表能同步更新。
4.1.3 借书业务与事务控制:BorrowController
借书是本系统中最需要保证数据一致性 的操作。它同时要改两张表:把 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、为借阅记录写入归还时间。
4.1.4 登录与 Token 机制:UserController
登录接口是整个系统的入口,其实现思路是"登录成功即发一个 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);
}
}
4.1.5 图书封面上传:UpdateController
图书封面需要上传到服务器。上传接口接收 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> 渲染封面。
4.2 测试
4.2.1 测试环境
- 后端服务:Spring Boot 内嵌 Tomcat,监听
8092端口,上下文/BookManager - 数据库:MySQL 5.7,库名
book_manager - 测试数据:用户 21 条、图书 60 条、图书类型 10 条、借阅记录 99 条(其中 23 条尚未归还)
- 测试方式:接口层使用 curl / Postman 直接调用,界面层在 Chrome 浏览器中按角色走完整流程
4.2.2 接口测试
表 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 条) | 与预期一致 | 通过 |
4.2.3 功能测试结论
- 角色权限有效:以读者身份登录后,界面不出现"用户管理""图书类型管理"等管理员入口,且查询借阅记录时被限定为本人数据。
- 借还流程闭环:借书后图书状态立即变为"已借出"并生成记录;还书后状态复原、归还时间写入。重复借阅与重复还书均被正确拦截。
- 查询与分页正确:单条件、组合条件与模糊查询均返回预期结果;分页越界时返回空列表而不报错,前端分页组件正常显示总页数。
- 异常处理完整:密码错误、图书不存在、图书已被借出等异常场景均返回友好提示,未出现 500 错误页面。
- 遗留问题:原始版本使用了第三方对象存储保存图书封面,该地址现已不可访问(返回 403),测试时使用本地占位封面替代,不影响借阅主流程。
5 部署与运行
5.1 数据库准备
- 安装 MySQL 5.7,创建数据库
book_manager(字符集建议utf8)。 - 导入数据库脚本
book_manager_full.sql:脚本会依次建库、建表并写入业务数据,一次性完成 10 个图书类型、60 本图书、21 个用户、99 条借阅记录的初始化。其中 23 本图书处于"已借出"状态,与借阅记录中未归还的 23 条数据严格对应,可用于直接演示借还流程。 - 脚本使用无外键的物理表设计,导入前会执行
DROP TABLE IF EXISTS,可反复导入而不会产生重复数据(生产环境使用前请先备份)。
5.2 后端启动
-
修改
src/main/resources/application.properties:server.port=8092
server.servlet.context-path=/BookManagerspring.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密码 -
启动 Redis 服务(登录 Token 存储依赖 Redis,未启动会导致登录接口报错)。
-
用 IDEA 打开项目,等待 Maven 依赖下载完成后运行
BookManagerApplication。 -
浏览器访问
http://localhost:8092/BookManager/,使用初始账号登录: - 管理员:admin/admin- 读者:liwo/123456
5.3 部署常见问题
表 5-1 常见问题与解决办法
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 启动报数据库连接失败 | 账号密码占位符未替换 | 检查 application.properties 中的 ? 占位 |
| 登录接口 500 | Redis 未启动 | 先启动 Redis,或确认 spring.redis.* 配置正确 |
| 页面样式错乱 | 静态资源路径带上下文前缀 | 前端打包时 publicPath 需配置为 /BookManager/ |
| 页面 404 | 路由为 hash 模式 | 访问地址需带 #,如 /#/bookmanage/bookinfo |
| 中文乱码 | 数据库或连接字符集不一致 | 建库使用 utf8,JDBC URL 增加字符集参数 |
6 总结与不足
6.1 完成的工作
本系统实现了图书管理业务的完整闭环:用户可注册登录并按角色进入不同界面,管理员可以维护图书、图书类型、用户和借阅记录,读者可以自助查询图书、借还图书并查看个人借阅历史。系统采用前后端分离架构,后端提供 RESTful 接口,前端通过 Axios 调用,二者解耦,便于后续扩展。
6.2 主要收获
- 掌握了 Spring Boot + MyBatis 的分层开发模式:Controller 负责参数与返回、Service 负责业务、Mapper 负责持久化,职责边界清晰。
- 理解了事务在业务中的实际意义 :借书这种"跨两张表的写操作"必须保证原子性,
@Transactional加手动回滚是可靠的做法。 - 学会了分页查询的完整实现链路:前端翻页参数 → 服务端换算 begin/size → SQL limit → 统一列表结构返回。
- 体会到接口约定先行的价值:先定好统一返回结构,前后端联调时几乎不需要额外沟通。
6.3 不足与改进方向
- 密码明文存储 :
userPassword直接以明文保存,存在安全隐患,后续应改为 BCrypt 加盐哈希。 - 权限控制偏弱:项目中的登录拦截器目前被注释掉了,实际靠前端隐藏入口控制,服务端应当重新启用拦截器或引入 Spring Security,做到接口级鉴权。
- 图书库存模型过简 :
isBorrowed只有 0/1 两态,同一本书无法多副本借阅,可扩展为库存数量字段。 - 缺少续借与逾期提醒:目前续借只支持手工修改借阅时间,可以增加自动续借次数限制与逾期罚金计算。
- 检索能力有限:仅支持书名与作者模糊匹配,可以引入全文检索或 Elasticsearch 提升体验。
编码不易,有偿分享代码和原文档,有意私聊