
简介这套基于Spring Boot实现的图书借阅系统源码面向正在做Java毕业设计或全栈课程设计的同学也适合希望快速搭建完整业务系统的开发者。项目围绕图书借阅场景覆盖学生管理、教师管理、登录权限过滤、图书借还查询等模块后端采用Spring Boot与Servlet前端使用JSP、HTML、CSS和JavaScript并配有MySQL数据库脚本技术栈完整便于理解前后端交互与分层开发。压缩包共234个文件大小约5.78MB核心类型包括46个Java源文件、9个JSP页面、14个HTML页面、23个JavaScript文件、8个CSS样式、1个SQL数据库脚本及6个jar依赖包另有75张GIF操作演示图可直观对照页面流程。源码已经本地编译验证按文档配置好环境即可运行难度适中内容经过助教审定可作为毕业设计原型或全栈进阶的参考素材。目前已有125人浏览学习有需要的同学可放心下载遇到问题也可向博主咨询。1. 为什么 Spring Boot 图书借阅系统是毕设钉子户三张表撑起完整闭环做 Java 后端和毕业设计这几年我拆过不下二十个 Spring Boot 项目图书借阅系统是出现频率最高的一个。原因很现实它的业务闭环完整——管理员管书、学生借书、到期还书三张表就能覆盖用户、权限、库存、状态流转四类问题Spring Boot 又恰好把这套场景的默认配置都做好了。这篇笔记写给两类人一是拿源码改毕设、想快速跑通并看懂每个模块写法的同学二是刚接触 Spring Boot MyBatis、想找个真正全栈项目练手的从业者。按项目结构、核心借阅逻辑、前后端联调、避坑、进阶的顺序拆每一步都给能直接抄的配置和代码。2. 读懂项目骨架目录结构、配置文件与数据库脚本一次性讲透拿到一个 zip 包第一件事不是点运行而是先看目录结构。很多同学把项目导入 IDEA 就急着启动结果报了一堆错其实一半的报错都能从配置文件和建表脚本里提前看出来。2.1 前后端是否分离先看目录结构再决定启动方式这套源码通常有两种组织方式。第一种是纯后端工程前端 Vue 项目单独放在frontend/目录第二种是把 Vue 打包后的dist目录直接放进src/main/resources/static做成一个可单独部署的 jar。两种方式的启动步骤差别很大目录结构大致如下book-manage-system/ ├── src/main/java/com/example/library/ │ ├── controller/ # BookController, UserController, BorrowController │ ├── service/ # 业务逻辑层 │ ├── mapper/ # MyBatis 数据访问层接口 │ ├── entity/ # 数据库实体类 │ ├── config/ # 跨域、拦截器、定时任务配置 │ └── LibraryApplication.java ├── src/main/resources/ │ ├── application.yml # 端口、数据源、MyBatis 配置 │ ├── mapper/ # XML 文件如果用的是 XML 方式 │ └── static/ # 有则说明是前后端一体 ├── frontend/ # 有则说明是前后端分离 ├── sql/ │ └── library.sql # 建表 初始化数据 └── pom.xml拿到项目先确认两件事src/main/resources/static下是否有打包好的前端文件以及根目录是否有独立的frontend工程。如果只有前者直接用 IDEA 运行LibraryApplication主类访问http://localhost:8080就能看到登录页如果存在frontend需要先跑后端再在frontend目录下执行npm run dev起前端开发端口通常用 8081靠跨域配置对接。为什么这套骨架的数据访问层选 MyBatis因为图书借阅的查询条件简单但变更频繁——管理员列表要按书名、作者、分类、ISBN 任意组合过滤MyBatis 的动态 SQL 写这类多条件查询比 Spring Data JPA 的 Specification 直观得多。JPA 在实体关系复杂的系统里有优势但这种三张表的场景 MyBatis 更省心这也是大多数毕设源码选择它的原因。2.2 application.yml 里的三个关键配置Spring Boot 的配置基本围绕端口、数据源、框架开关三个维度展开。这个项目的application.yml核心内容通常长这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entity三个地方要改端口默认 8080本地被占用就换成 8081数据源密码换成自己 MySQL 的密码serverTimezone必须写Asia/Shanghai否则新版 MySQL 驱动会在启动或首次查询时报警时区错误。type-aliases-package的作用是简化 XML 里的实体类引用写resultTypeBook而不是一长串包路径。几个核心配置项的取值和风险点我列了一个自查表配置项我常用的值备注server.port8080 或 8081端口冲突时先改这里spring.datasource.urljdbc:mysql://localhost:3306/library_db时区和字符集参数必须带上spring.datasource.password自己 MySQL 的密码zip 里默认 123456不改必报错mybatis.mapper-locationsclasspath:mapper/*.xmlXML 放错目录会抛 BindingException顺带说一句 MyBatis 的两种写法。有些项目不用 XML直接在 Mapper 接口上写Select注解代码更短但 SQL 和 Java 耦合在一起复杂查询时格式化很难看。毕设和中小项目我更推荐 XML 方式SQL 独立管理、改完不用重新编译排查问题时能直接打开 XML 看语句。前提是application.yml里的mapper-locations路径要和实际目录一致。2.3 数据库脚本核心表初始化和管理员账号sql/library.sql是这个项目最值钱的部分。三张核心表的建表语句如下字段设计基本覆盖了图书馆业务的主要场景-- 用户表 CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, password varchar(100) NOT NULL COMMENT 密码MD5或BCrypt, real_name varchar(50) DEFAULT NULL COMMENT 姓名, role tinyint(1) NOT NULL DEFAULT 2 COMMENT 1管理员 2学生, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 图书表 CREATE TABLE book ( id int(11) NOT NULL AUTO_INCREMENT, isbn varchar(20) DEFAULT NULL, title varchar(200) NOT NULL COMMENT 书名, author varchar(100) DEFAULT NULL, category varchar(50) DEFAULT NULL COMMENT 分类, total int(11) NOT NULL DEFAULT 1 COMMENT 总库存, available int(11) NOT NULL DEFAULT 1 COMMENT 可借数量, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 借阅记录表 CREATE TABLE borrow_record ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, book_id int(11) NOT NULL, borrow_time datetime DEFAULT NULL COMMENT 借书时间, due_time datetime DEFAULT NULL COMMENT 应还时间, return_time datetime DEFAULT NULL COMMENT 实际归还时间, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0在借 1已还, fine decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 滞纳金, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;user在 MySQL 里不算保留字但为了保险很多项目会写成t_user或加反引号。三张表的关系很直白借阅记录通过user_id和book_id关联两边真正的业务逻辑集中在borrow_record的状态字段和book.available的加减上。初始化数据里一般有一个管理员账号admin/admin123和一个学生账号student/123456数据库导入后先拿管理员账号登录验证角色权限是否正常。有个细节值得注意book.available被设计成单独字段而不是每次借阅时去统计未归还记录数。原因是图书列表页需要高频展示可借数量单独字段只需查一行连表统计在数据量大时会影响接口响应。代价是每次借还都要手动维护这个字段所以借阅和归还的 Service 方法必须保证事务一致性。borrow_record.fine字段同理归还时算好滞纳金直接存进去管理员统计超期费用时就不用每行实时计算。3. 把借阅流程写清楚从 Controller 到 Service库存和状态怎么流转图书借阅系统的业务核心不在增删改查而在借阅和归还时的状态机控制。新手最容易在这里翻车借书时只插了一条记录忘了改库存还书时只改了记录状态忘了恢复库存导致系统数据对不上。3.1 借阅接口先查状态、再扣库存、最后写记录先看借阅的 Controller 入口RestController RequestMapping(/api/borrow) public class BorrowController { Autowired private BorrowService borrowService; PostMapping(/add) public Result borrow(RequestBody BorrowDTO dto) { // dto 中只需要 userId 和 bookId 两个参数 return borrowService.borrow(dto.getUserId(), dto.getBookId()); } }业务逻辑在 Service 层完成拆成四步校验用户状态、查询图书库存、扣减可借数量、插入借阅记录。核心代码如下Transactional public Result borrow(Integer userId, Integer bookId) { // 1. 校验用户是否存在且状态正常 User user userMapper.selectById(userId); if (user null || user.getStatus() 0) { return Result.error(用户不存在或已被禁用); } // 2. 查询图书并校验库存 Book book bookMapper.selectById(bookId); if (book null || book.getAvailable() 0) { return Result.error(图书不存在或库存不足); } // 3. 扣减可借数量用受影响行数判断是否成功 int rows bookMapper.decreaseAvailable(bookId); if (rows 0) { return Result.error(该图书已被借完请稍后再试); } // 4. 生成借阅记录默认借期 30 天 BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(new Date()); record.setDueTime(DateUtils.addDays(new Date(), 30)); record.setStatus(0); borrowMapper.insert(record); return Result.success(借阅成功); }这里DateUtils是项目里封装的天数计算工具类等价于new Date()加上 30 天的毫秒数。三个要点值得说清楚。第一Transactional必须加上。第三步扣库存和第四步插记录必须同时成功或同时回滚否则会出现「记录没插上但库存已经扣了」的数据不一致。这里要啰嗦一句Spring Boot 2.x 默认开启 CGLIB 代理Transactional可以放心用在实现类内部不需要额外配置。第二bookMapper.decreaseAvailable(bookId)的 SQL 要写成UPDATE book SET available available - 1 WHERE id ? AND available 0返回受影响行数为 0 就说明库存已经没了。这种写法能避免并发借阅时超借比「先查询再判断再更新」要稳得多不需要引入悲观锁。第三校验顺序有讲究。用户校验走的是主键索引成本最低图书库存是所有借书请求争抢的资源把它放到后面扣减时锁的持有时间就短。把最容易失败的库存校验放在真正修改之前可以避免写库前做耗时操作。3.2 归还接口状态回写和超期费用计算归还逻辑比借阅多一个点超期费用计算。完整方法如下Transactional public Result returnBook(Integer recordId) { // 1. 查询借阅记录状态必须是在借 BorrowRecord record borrowMapper.selectById(recordId); if (record null || record.getStatus() ! 0) { return Result.error(借阅记录不存在或已归还); } // 2. 计算超期费用每天 0.5 元未超期为 0 Date now new Date(); if (now.after(record.getDueTime())) { long days (now.getTime() - record.getDueTime().getTime()) / (1000 * 3600 * 24); record.setFine(new BigDecimal(days).multiply(new BigDecimal(0.5))); } else { record.setFine(BigDecimal.ZERO); } record.setReturnTime(now); record.setStatus(1); borrowMapper.updateById(record); // 3. 恢复图书库存 bookMapper.increaseAvailable(record.getBookId()); return Result.success(归还成功滞纳金 record.getFine()); }超期天数的(long) / (1000 * 3600 * 24)是毫秒转天数的标准写法整数除法会丢掉小数部分所以超期不满 24 小时不收费。默认规则是超过应还日期才开始计费如果你想让当天归还也不收费加一个if (days 1)的判断即可。归还时最容易漏的一步是恢复库存。我见过不少改造版本只更新了borrow_record.status忘了调bookMapper.increaseAvailable结果书明明还了列表里还是「不可借」。如果自己改代码建议把「更新记录」和「恢复库存」两行紧挨着写中间不要插入日志或通知操作。归还流程走完后borrow_record里就有了一条完整时间线借书时间、应还时间、实际归还时间、滞纳金。管理员端的统计报表大多从这张表聚合比如按月统计借阅趋势、按图书统计借阅次数。所以归还时把return_time和fine写对后面做报表就不会返工。3.3 登录认证与权限拦截管理员和学生各走各的路借阅接口不能对所有请求开放。这个项目用的是最基础的拦截器方案而不是 Spring Security——对毕设和中小项目来说拦截器逻辑直观、调试简单没有 Security 那套过滤器链的复杂度。拦截器代码如下Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); if (user null) { response.setStatus(401); return false; } // 管理员专属接口额外校验角色 String uri request.getRequestURI(); if (uri.startsWith(/api/admin/) user.getRole() ! 1) { response.setStatus(403); return false; } return true; } }拦截器注册到 WebMvcConfigurerConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register); } }两行配置决定了谁能访问什么/api/user/login和/api/user/register放行其余/api/**必须登录管理员专属操作通过路径前缀/api/admin/加角色判断。我这里为了表达简洁写了new LoginInterceptor()但如果这个拦截器里注入了 Mapper 或 Service 依赖必须改成构造器注入或标注Component后从容器拿实例否则启动没问题、一到拦截就会空指针。登录接口本身没什么特殊登录成功后在session里放一个loginUser对象后续拦截器读取。注意密码加密一致性注册时用什么算法登录时就必须用什么算法比对这个坑放到第 5 章细说。4. 全栈联调实战Vue 页面怎么对接 Spring Boot 后端很多同学代码能跑起来但页面调不通问题基本集中在跨域、接口地址、静态资源路径三个地方。这一章把三条路都走一遍。4.1 开发期跨域三种方案选一种就行前后端分离开发时前端地址是http://localhost:8081后端是http://localhost:8080浏览器的同源策略会拦截跨域请求。常见三种解决方式后端加CrossOrigin注解、后端加全局 CORS 配置、前端配 Vite/Webpack 代理。我更推荐前端代理因为不动后端代码生产环境不受影响。不过大多数毕设源码里默认用全局 CORS 配置因为最省事、对后端侵入最小。写法如下Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .maxAge(3600); } }两个注意点。第一allowedOriginPatterns(*)不要漏掉如果写成allowedOrigins(*)在 Spring Boot 较新版本上启动会直接抛异常。第二addMapping范围写成/api/**就足够前端页面和后端同源部署时这套配置也不会造成影响。调试时如果浏览器控制台出现CORS error这个配置加上去立刻消失。为什么源码里往往是后端 CORS 而不是前端代理因为大多数毕设开发时是单机环境后端加一个配置就全组通用了前端代理更适合多人协作时一套固定开发环境。单机写毕设选哪个都行上线部署时 CORS 配置并不影响同源留着不删完全没问题。4.2 生产部署把 Vue 打包放进 Spring Boot 的 static 目录联调完成后不可能一直开着两个端口给人演示。最简单的做法是前端构建然后让 Spring Boot 托管静态资源# 在 frontend 目录下执行生成 dist 文件夹 npm run build把dist目录里的内容全部复制到src/main/resources/static/重新打包运行后端浏览器直接访问http://localhost:8080就能同时拿到前后端。注意dist/index.html里静态资源引用路径如果打包后页面空白或控制台 404先看index.html里script标签的src是/js/app.js还是./js/app.jsSpring Boot 托管场景下baseURL或assetsPublicPath配置为/通常最稳。这里有一个经典坑Vue Router 用 history 模式时刷新/books或/admin页面会返回 404因为后端没有对应路由。两个解决办法一是后端加一个转发规则二是 Vue 路由改用 hash 模式。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}).forwardTo(/index.html); } }这段配置的作用是把所有不带文件后缀的路径转发到index.html交给前端路由处理。注意边界带.的路径如.js、.css、.png不会走这个规则静态资源不会被错误转发。hash 模式则简单得多URL 形如/#/books不用动后端。我一般建议毕设项目直接用 hash 模式少一个出错点。4.3 Axios 请求封装路径统一和响应拦截Vue 页面里的请求不能每个组件都写一遍axios.get(http://localhost:8080/api/...)那样后端地址一改就要全局替换。通常在src/utils/request.js里封装一个实例import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) // 统一处理响应与错误 request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { window.location.href /#/login } return Promise.reject(error) } ) export default requestbaseURL写成/api而不是http://localhost:8080/api有讲究生产环境下前后端同源相对路径天然可用开发环境靠前端代理或 CORS 解决不会有写死绝对地址带来的跨域隐患。响应拦截器统一抽出response.data每个页面不需要重复写一层数据解包。这段代码虽然短但没有它后续所有接口联调都会消耗大量时间在路径拼写和错误处理上。5. 避坑与排查图书借阅系统最常见的 5 个翻车现场联调跑通之后我把这类项目里高频出现的问题整理了五条每条按「现象 → 原因 → 解决」来写。5.1 8080 端口占用服务起不来现象启动时日志出现Port 8080 was already in use控制台最后一行显示APPLICATION FAILED TO START服务直接退出。原因本地有别的服务占用了 8080可能是之前跑过一次没关掉的后端进程、Nginx或另一个 IDEA 里的调试实例。解决先在命令行确认占用者。Windows 下执行netstat -ano | findstr :8080拿到 PID再taskkill /F /PID pidLinux/macOS 用lsof -i:8080 -P -n定位进程后 kill。我自己的习惯是直接把application.yml的端口改成 8081并同步前端代理目标这样不用猜是谁占用的。另外注意从 zip 解压后可能之前已经在后台跑过一个实例把多余的 Java 进程一并清掉再启动。5.2 登录一直报「用户名或密码错误」现象前端登录接口返回错误但数据库里查出的密码字段怎么看都是对的。原因注册时用的加密算法和登录时不一致或者直接手动改了数据库里的密码导致格式被破坏。看一眼存储格式就能判断32 位十六进制字符串是 MD5以$2a$10$开头的是 BCrypt。解决如果是 MD5登录时用DigestUtils.md5Hex(rawPassword)加密后再比对如果是 BCrypt用BCryptPasswordEncoder.matches(rawPassword, dbPassword)。两种方式不能混用。手动改数据库密码时也要用相同算法生成新值别直接在 SQL 里写明文。我通常在登录 Service 入口打一行日志把前端传过来的密码打出来看一眼是明文还是已经加密的值十秒定位问题。5.3 借书成功后库存没减现象前端提示借阅成功借阅记录也生成了但图书列表里的可借数量没变化。原因Service 里「先 selectById 判断 available 再 update 减库存」中间没有并发保护或者扣减 SQL 只写了SET available available - 1 WHERE id ?库存已经是 0 时还会继续减成负数。解决把扣库存 SQL 改成条件更新UPDATE book SET available available - 1 WHERE id ? AND available 0再判断rows 0。因为保存借阅记录和扣库存都在Transactional事务里条件更新会在事务内持有行锁直到事务提交才释放。想验证的话开两个浏览器同时点借书两个请求只能有一个成功另一个返回「库存不足」。5.4 Vue 打包后刷新页面 404现象npm run dev开发时一切正常打包放进 Spring Boot 后在/books或/admin这样的页面刷新就 404。原因Vue Router 用 history 模式时路由路径依赖前端 JS 渲染浏览器刷新会把完整地址作为静态资源请求发给后端Spring Boot 找不到对应的 Controller 或静态文件自然返回 404。解决两个方案任选。后端加addViewControllers转发规则见 4.2 的代码把非静态资源路径转发给index.html或者把 Vue Router 改成 hash 模式在路由配置文件里把mode: history改为mode: hashURL 会变成/#/books这种地址不需要后端配合。毕设项目我强烈建议直接上 hash少一个后端配置就少一个故障点。5.5 中文乱码不是换个编码就能解决现象页面上中文正常插入数据库后变成???或者页面本身乱码数据库却是对的。原因两件事要分两个层面看。数据库层面连接 URL 没指定characterEncodingutf8或表结构本身是latin1编码页面层面IDEA 控制台显示编码和项目文件编码不一致或前端页面漏了meta charsetutf-8。解决JDBC URL 里补上useUnicodetruecharacterEncodingutf8建表时统一CHARSETutf8mb4已经建好的表执行ALTER TABLE book CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;。页面乱码就检查 IDEA 的File Encodings把全局编码、项目编码、控制台编码全部改成 UTF-8。改完重启项目插入一条带中文的新数据验证不要只盯着旧数据看。以上五条覆盖了这类项目 80% 的问题。如果遇到别的报错优先看控制台最底部的Caused by那才是真正的错误原因上面一长串at com.xxx...都是调用栈噪音。6. 进阶实战加超期未还提醒和接口冒烟测试项目跑通以后不要急着交差做两个很小的增强能让整套代码的可信度上一个台阶。第一个是超期未还自动提醒。用 Spring Boot 自带的定时任务就能实现每天凌晨检查所有「在借」且超过应还时间的记录Component public class BorrowTask { Autowired private BorrowMapper borrowMapper; // cron 表达式每天凌晨 2 点执行 Scheduled(cron 0 0 2 * * ?) public void checkOverdue() { ListBorrowRecord overdueList borrowMapper.selectOverdueRecords(new Date()); for (BorrowRecord record : overdueList) { // 实际项目中这里发送站内信或邮件通知 log.info(借阅记录 {} 已超期, record.getId()); } } }cron表达式0 0 2 * * ?表示每天凌晨两点执行顺序是秒、分、时、日、月、周、年?表示不指定周几。注意启动类上必须加EnableScheduling注解否则定时任务不会触发主类的注解通常是SpringBootApplication和EnableScheduling一起写。第二个是接口冒烟测试。用MockMvc验证借阅、归还核心链路是否正常SpringBootTest AutoConfigureMockMvc class BorrowFlowTest { Autowired private MockMvc mockMvc; Test void testBorrowAndReturn() throws Exception { // 先借书 mockMvc.perform(post(/api/borrow/add) .contentType(MediaType.APPLICATION_JSON) .content({\userId\: 1, \bookId\: 1})) .andExpect(status().isOk()); // 再归还 mockMvc.perform(post(/api/borrow/return) .contentType(MediaType.APPLICATION_JSON) .content({\recordId\: 1})) .andExpect(status().isOk()); } }注意post(/api/borrow/add)如果被登录拦截器拦住要先做登录态模拟测试前调用一次登录接口把 session 存下来随请求发送。这个项目没引 Spring Security最省事的办法是测试类里先走一遍登录流程再执行借还。这两个功能加起来系统才算真正闭环。从那以后我每次拿到类似源码都会强制走一遍「改端口 → 导数据 → 跑核心借还链路 → 跑一遍冒烟测试」的流程再开始动业务。这套动作能过滤掉绝大部分基础问题希望帮到你。剩下的就是按自己的需求去改给Book实体加个封面字段或给BorrowRecord加个预约状态都可以在这个骨架上直接长出来。本文还有配套的精品资源点击获取