Spring Boot在线图书管理系统毕设:从选题到答辩的完整实践
2026/9/9 5:53:56 网站建设 项目流程

每年开题季,都会有人拿类似的问题来问我:“Spring Boot 在线图书管理系统做毕业设计是不是太老套了,能不能换个新鲜的?”我的回答一般都挺直接:如果你目标是安稳毕业、能现场演示、能扛住答辩追问,这套题反而是我见过“性价比”最高的选择。图书管理系统表面看着像普通增删改查,但把图书库存、读者借书、预约、归还、超期处理、统计报表一路做下来,它其实覆盖了一个管理系统的所有典型问题——权限、状态流转、事务、并发控制、数据统计,一样都不缺。

这篇文章把我从选题、技术栈选择、数据库设计、核心功能实现,到打包部署和答辩准备的全过程拆开写,适合正在做 Java 毕业设计、或者想拿 Spring Boot 完整练一个项目的同学参考。我不会只给成品代码,也不会只讲“怎么写”,而是把代码背后的“为什么这么做”和踩过的坑全部讲出来。

1. 定题目先别动手,画清楚功能边界再开始编码

很多同学拿到题目后的第一反应是打开 IDEA 直接建工程,写到一半发现功能越加越多,代码越来越乱,最后连自己都圆不回来。我先劝一句:图书管理系统虽然业务直观,但“图书管理”四个字可以无限外延,你得先给自己划定一条清晰的交付线。

1.1 图书管理系统为什么是毕设里的“标准模板”

先说本质。图书管理系统并不是只能管图书馆,它其实是一套标准的“主数据 + 状态流转 + 权限控制”系统,放到企业里你把它叫“订单管理系统”“设备管理系统”“预约服务管理系统”都行。图书、读者、借阅记录只是具体的业务对象,系统骨架是完全一致的。

这套系统对毕业生友好,是因为领域规则人人熟悉:图书有库存、读者可以借书、借了要还、逾期要处理。你不需要花精力去理解复杂的行业术语,可以把精力集中在怎么用 Spring Boot 把业务稳定地实现出来。而它又不像“员工管理系统”那样单纯到只做一张表的 CRUD,借阅过程涉及多种状态变化和并发问题,这些是毕业论文里很好的“创新点”和“难点”。

如果答辩老师问一句“你这个系统有什么复杂的地方”,你可以理直气壮地说:我处理了借阅状态机、库存扣减的并发一致性和超期自动检测。这个答案足以让老师意识到你做的不是玩具项目。

1.2 用优先级矩阵把功能砍到能交付的范围

我先把我当时列出的完整功能清单摆出来:

  • 前台读者端:注册登录、图书检索、图书详情、借书、还书、续借、预约、个人借阅记录
  • 后台管理端:图书信息管理、图书分类管理、出版社管理、读者账号管理、借阅记录管理、超期管理、数据统计看板
  • 公共功能:验证码登录、密码加密、接口参数校验、操作日志、接口文档
  • 加分项:热门图书 Redis 缓存、定时任务扫描逾期、Excel 导出借阅明细

如果把这些全部做完,一个熟练开发者可能也需要三四周。毕设周期通常是两三个月,但中间你还得写论文、画图、准备 PPT,所以一定要分优先级。

我建议用最基本的 P0 / P1 / P2 三级来管理需求:

优先级功能范围理由
P0 必做用户登录注册、图书 CRUD、读者管理、借书还书续借、基础统计缺了它,系统就不叫图书管理系统
P1 尽量做图书分类、出版社、预约、超期罚金、定时任务体现业务完整性,论文里可以单开一节
P2 选做Redis 缓存热门图书、操作日志、Excel 导出、公告写“展望与扩展”时提一句即可,实现锦上添花

我当时给自己定了原则:P0 必须稳定跑通,演示不能翻车;P1 至少实现预约和超期两个点,因为这两个点最能体现“系统设计思维”;P2 看时间,能做到哪个算哪个。这样的分配保证了我的论文有充足的章节素材,又不会把自己拖进需求无底洞。

1.3 不管有没有前端,请先把后端工程按这个包结构拆好

很多自学教程会为了演示方便把所有类丢在几个包里,但答辩老师打开项目一眼就能看出工程化能力。我建议创建工程时就直接按下面的包结构组织代码:

com.example.library ├── common # 统一返回结果、全局异常、常量 ├── config # 跨域、拦截器、MyBatis-Plus、Swagger 配置 ├── controller # 接口层 ├── service # 业务逻辑层(接口 + impl) ├── mapper # MyBatis-Plus Mapper 层 ├── entity # 数据库实体对应类 ├── dto # 入参对象 ├── vo # 返回视图对象 ├── interceptor # 登录/角色权限拦截器 ├── task # 定时任务 └── utils # JWT、日期等工具

这里有一个容易被忽视的点:不要把数据库实体直接丢给前端。比如图书实体里有逻辑删除标记 deleted、创建时间 create_time,这些不该让前端看到。我习惯在 dto/vo 包里单独定义入参和返回对象,这样前后端接口通过 VO 交互,能避免很多字段泄露和不一致问题。

先搭包结构再写代码,还有一个好处:论文里的“系统总体架构”章节可以直接参考这个结构来画层次图,写起来省事很多。

2. 技术栈选型与项目基础配置

技术选型在毕业设计里其实非常讲究。选太新,出了问题网上没有配套答案;选太旧,论文查重和老师印象分都会受影响。我在这部分给出一个稳妥的组合,并解释清楚每一个选择的意图。

2.1 锁死 Spring Boot 版本,别让新特性拖慢开发

我推荐使用 Spring Boot 2.7.18 配合 JDK 8。理由很现实:大部分毕设同学的电脑里安装的是 JDK 8,学校机房也普遍用 JDK 8;这个组合下所有教程、依赖、报错信息都能在论坛上找到对应解决方案。

如果你电脑已经装了 JDK 17,也可以用 Spring Boot 2.7.x,只要在 pom 里明确指定 java.version 并让 IDEA 的 Project Structure 与 Maven 设置保持一致。但我不建议直接上 Spring Boot 3.x,因为从 3.0 开始,原来的javax包名换成了jakarta,很多老教程里的代码 import 路径直接失效,MyBatis-Plus、JWT 这些框架也要用相应的高版本,对新手来说排查成本会明显上升。

毕业设计追求的是“稳定完成并且你能把每个点讲清楚”,不是展示最新版本号。这一点请务必记住。等我有一次帮人排查项目,看到 Spring Boot 3 配了一个 1.18.20 的旧版 Lombok,启动就报错,那种问题纯属自找麻烦。

2.2 核心依赖清单与说明

下面是我整理后的 pom.xml 核心依赖,都是一个成熟项目里真正用得上的。注意这里特意去掉了 Spring Security,原因后面我会详细说。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> <mybatis-plus.version>3.5.3.2</mybatis-plus.version> </properties> <dependencies> <!-- Web 核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- 简化实体类代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- JWT --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <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> <version>0.11.5</version> <scope>runtime</scope> </dependency> <!-- Redis 可选,做缓存用 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 接口文档 --> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-boot-starter</artifactId> <version>3.0.0</version> </dependency> </dependencies>

有几个版本相关的问题必须提醒你。Springfox 3.0.0 和 Spring Boot 2.6 以上版本存在兼容问题,启动后访问/swagger-ui/会报错或者页面空白,解决办法是给 springfox 提供基础路径匹配策略,我在后面“常见问题”章节里会给你具体配置。另一个是 Lombok 版本和 JDK 版本强相关,如果本机是 JDK 17,需要把 Lombok 升到 1.18.30 以上,否则 IDE 可能直接不识别注解。

MyBatis-Plus 的版本不要用太老的 3.1 之类,3.5.x 对 Spring Boot 2.7 支持得比较好,分页插件配置方式也稳定。这套依赖基本不会出现“某个工具依赖的 jar 冲突导致项目起不来”的尴尬局面。

2.3 基础配置文件和统一返回结构要在第一天就搭好

工程建好后第一件事是配置 application.yml,不要等代码写了一大堆再补。我当时把端口、数据库连接、MyBatis-Plus 逻辑删除、JWT 密钥都写在一个文件里,为了方便本地和服务器切换,还可以把配置分成application-dev.ymlapplication-prod.yml,通过spring.profiles.active切换。

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 redis: host: localhost port: 6379 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key-must-be-long-enough expire: 86400000

再强调一个很多教程根本不会提的细节:MySQL 连接串里的serverTimezone=Asia/Shanghai必须写。如果不写,系统时间跟你本地差 8 个小时,后面“逾期判断”这种对日期敏感的功能会在演示时出现灵异 bug,明明是今天借的书,系统却说已经超期了。

统一返回结构是我强烈建议第一天就写好的代码。你要保证所有接口返回格式一致:状态码、提示消息、数据体。后来帮别人改项目,看见有的接口直接返回实体,有的返回 Map,有的报错直接往页面抛异常白屏,这种项目现场演示基本属于“翻车预定”。

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("操作成功"); r.setData(data); return r; } public static <T> Result<T> error(Integer code, String msg) { Result<T> r = new Result<>(); r.setCode(code); r.setMsg(msg); return r; } }

统一返回结构再配合一个全局异常处理器,业务层只需要专注抛业务异常,前端拿到的永远是一个结构清晰的 JSON。这个设计是代码规范性的直接体现,也是论文里“系统设计原则”章节很能写的一笔。

3. 数据库设计:影响后面开发速度的关键环节

我见过很多项目写代码非常快,但改需求时痛不欲生,原因基本都出在数据库表结构设计得太随意。图书管理系统的表不算多,但如果字段和关系处理不好,后续每个查询都会变得别扭。

3.1 核心表从用户故事里来,不要直接抄网上模板

我没有直接抄网上的表结构,而是先梳理使用者操作流程,再决定需要哪些表。围绕“读者可以查书、借书、还书、续借、预约,管理员可以管理图书和处理借阅”这个流程,核心表最少需要:

  • user:系统用户表,包含管理员和读者,用 role 字段区分
  • book:图书表,放图书元数据和库存
  • category:图书分类表,简单的一对多关系
  • borrow_record:借阅记录表,核心业务表
  • reservation:预约表,图书被借出时可以预约

我给 borrow_record 表一个比较完整的字段设计,这是整个系统的核心。注意它不只存“谁借了哪本书”,还要记录借出时间、应还时间、实际归还时间、状态、续借次数、操作人等信息。

字段名类型说明
idbigint主键自增
user_idbigint读者 ID
book_idbigint图书 ID
borrow_timedatetime借书时间
due_timedatetime应还时间,根据借阅规则自动计算
return_timedatetime实际归还时间,允许为空
statustinyint1-借出中 2-已归还 3-已逾期 4-已续借
renew_countint续借次数
operator_idbigint管理员操作人 ID
deletedtinyint逻辑删除

字段设计的原则是:每个状态变化都要能在表里留痕。借书时 insert 一条 borrow_record,状态为“借出中”;还书时 update 这条记录的 return_time 和 status,而不是删除记录。这样后续要统计“某本书借过几次”“某个读者历史借阅”都有一手数据支撑。

3.2 状态、时间、冗余字段的设计细节

图书表需要特别注意库存字段。很多网上模板只放一个stock总数,借书时减 1,还书时加 1,看起来没问题,但图书可能因为破损、丢失等原因不可借。我建议设计成stock(总库存)、borrowed_count(当前借出数量)、available(可借数量)三个字段,或者至少保留一个“在库数”字段,配合一个status表示上架还是下架。

更好的做法是不要保存“可借数量”这种可以被计算出来的冗余字段。但是借阅场景下,图书列表页要频繁显示“剩余几本”,每次都去 count 借阅记录会随数据量上升越来越慢。所以实践中可以用stock - borrowed_count实时算,也可以定期同步一个冗余字段。我最终选择的是保留总库存和当前借出量,可借量由业务层计算,而不是单独存一列,这样避免了数据不一致的问题。

还有两个细节值得专门写出来。第一,时间字段一律用 datetime,并且应用层统一使用LocalDateTime,不要用java.util.Date配合字符串拼 SQL,否则时区问题和格式问题会让你排查到崩溃。第二,逻辑删除字段deleted建议全局统一叫这个名字,配合 MyBatis-Plus 的全局配置,这样每个实体都少写很多重复代码。

3.3 “看起来像真实系统”的造数思路

演示阶段最尴尬的事就是“图书列表只有三本书,统计图只有一根柱子”。数据库里一定要有足够多、足够真实的数据。我用的办法是写一个简单的 Java 命令行初始化器,或者直接写一个init_data.sql脚本,包含:

  • 数量超过 10 个的分类:计算机、文学、历史、经济、心理、艺术、外语、科普、哲学、童书
  • 每个分类下 15 到 20 本图书,书名避免用“图书1”“图书2”这样让人一眼看穿的假数据
  • 至少 3 个读者账号(学号/工号)
  • 一个管理员账号:admin / 密码 123456(存库时用 BCrypt 加密)
  • 几条借阅记录,其中一条状态为“借出中”,一条“已逾期”

我当时为了防止“借阅记录只有两条,统计图像开玩笑”,用嵌套循环造了两三百条借阅历史。流程是:写一个测试方法,遍历读者列表,为每个读者随机借几本书,再随机归还一部分。演示时下拉分页、查看趋势图、导出明细都有充足数据可用。

注意:造数时不要直接往线上库插,开发库随便造无所谓,但如果是最终演示库,最好把自增 id 的起始值和统计日期也修得自然一点,避免出现日期全在同一天这种穿帮现象。

4. 核心业务模块:一步一步把流程跑通

接下来是工程量最大的部分。我按照业务模块来拆解,而不是按 controller/service/mapper 分层去写流水账,因为这样更接近你接需求时的真实思路。

4.1 登录鉴权与角色权限:不引入 Spring Security 也能讲清原理

先回答一个很多人的疑问:为什么我不推荐毕业设计里直接用 Spring Security?不是因为它不好,而是因为很多人不会配,配了 Security 之后所有接口都被拦截,写起来复杂,而论文里如果只是简单提一句“使用了 Spring Security”,答辩老师问起过滤器链、UserDetailsService 时又容易答不上来。

我在这个项目里采用的是JWT + HandlerInterceptor + ThreadLocal的方案。流程是:用户登录成功,后端签发一个 token 返回前端;前端后续请求在 Header 里带上Authorization: Bearer token;后端写一个拦截器统一解析 token,并把当前登录用户信息放到 ThreadLocal 里。

public class LoginInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public LoginInterceptor(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } Long userId = jwtUtil.parseToken(token); if (userId == null) { response.setStatus(401); response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录已过期\",\"data\":null}"); return false; } UserContext.setUserId(userId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

登录接口本身有两个点要注意。第一,不能把明文密码存进数据库,我用的 BCrypt 加密,注册时加密存储,登录时用BCryptPasswordEncoder.matches()校验。第二,登录成功后可以顺手把用户角色写进 token(或从数据库重新查询),这样管理员接口可以用角色注解判断权限。

小教训:我最初把简单用户角色也放进了 token,后来管理员改了角色发现旧 token 还没失效,要等过期才能生效。后来改成每次请求从数据库查一次当前用户角色,虽然多一次查询,但对这种内部系统完全可接受,也避免了“改完权限还要等 token 过期”的奇怪体验。

4.2 图书管理模块:CRUD 也有设计空间

图书管理看似只是增删改查,但接口设计直接体现专业度。

列表查询必须是分页 + 多条件组合。图书名模糊查询、分类下拉筛选、状态筛选、价格区间筛选这些都要支持,接口定义大致像下面这样:

@ApiOperation("分页查询图书") @GetMapping("/book/page") public Result<IPage<BookVO>> pageBook( @RequestParam(defaultValue = "1") long current, @RequestParam(defaultValue = "10") long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) Integer status) { return Result.success(bookService.queryBookPage(current, size, keyword, categoryId, status)); }

ServiceImpl 里用 MyBatis-Plus 的 LambdaQueryWrapper 实现相对安全,不会出现字符串拼 SQL 引发的注入问题。

public IPage<BookVO> queryBookPage(long current, long size, String keyword, Long categoryId, Integer status) { Page<Book> page = new Page<>(current, size); LambdaQueryWrapper<Book> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Book::getName, keyword) .eq(categoryId != null, Book::getCategoryId, categoryId) .eq(status != null, Book::getStatus, status) .orderByDesc(Book::getCreateTime); IPage<Book> result = this.page(page, wrapper); // 转换为 VO,补充分类名称等展示字段 return result.convert(book -> converter.toBookVO(book)); }

删除图书时直接用物理删除还是逻辑删除?强烈建议逻辑删除。因为图书一旦被借阅记录引用,物理删除会让历史记录里的 book_id 变成悬空引用,统计历史时就没法展示书名的冗余信息了。MyBatis-Plus 的逻辑删除配置好之后,删除接口调用removeById会变成 update deleted。但是要注意一个副作用:如果给图书的 ISBN 加了唯一索引,逻辑删除后的记录仍然占着索引,再次添加同一本 ISBN 的书时会冲突,这时候要么还原已删除的旧记录,要么唯一索引改成(isbn, deleted)的组合索引。这个问题在资源管理类系统里特别典型。

新增和编辑图书还应该做参数校验。图书编号 ISBN 不是必填?在我的项目里是必填;价格必须大于等于 0;库存不能为负数。这些校验直接写在 DTO 上:

@Data public class BookSaveDTO { @NotBlank(message = "图书名称不能为空") private String name; @NotBlank(message = "ISBN不能为空") private String isbn; @NotNull(message = "分类不能为空") private Long categoryId; @DecimalMin(value = "0.0", message = "价格不能小于0") private BigDecimal price; @Min(value = 0, message = "库存不能小于0") private Integer stock; private String author; private String press; }

配合 Spring@Valid注解,Controller 参数校验这块就有了,真正的用法是“绝不在 Service 里再做字符串空判断”。

4.3 借阅归还状态机:并发与事务是最大得分点

这一块是整个系统难度最高、也最能拿分的点。先说借书流程的正确顺序:

  1. 校验读者存在且未禁用
  2. 校验读者当前借阅数量是否达到上限
  3. 校验该书状态为上架,且可借数量大于 0
  4. 扣减库存(并发安全)
  5. 生成借阅记录
  6. 记录操作日志

很多人会直接按这个流程用代码写一排 if,然后逐个执行。问题是“查询可借数量大于 0”和“扣减库存”之间如果同时有多个请求进来,库存会扣成负数。解决办法之一是在更新语句里带条件:

@Update("UPDATE book SET borrowed_count = borrowed_count + 1 " + "WHERE id = #{bookId} AND status = 1 AND stock > borrowed_count") int increaseBorrowedCount(Long bookId);

如果受影响行数为 0,说明库存不足或图书状态异常,直接抛业务异常。这种原子更新比“先 select 再 update”安全得多,天然避免了并发超借。整段逻辑还要加上@Transactional,保证扣库存和插借阅记录要么都成功,要么都回滚。

还书的流程一样要考虑很细。还书不是简单地“库存 +1”,而是:

  1. 找到该读者该图书处于“借出中/已逾期”的记录
  2. 更新实际归还时间、状态
  3. 图书表的 borrowed_count 减 1
  4. 如果实际归还晚于应还时间,生成逾期罚单或在记录上标记逾期

我用一个status字段而不是每次靠日期差值来判断状态,就是因为一个记录同时可能有多种状态来源。比如状态 3“已逾期”是用定时任务扫描时发现借出中记录的 due_time 已过期,更新出来的。这样借阅列表页只需按状态过滤,SQL 又简单又可解释。

@Transactional(rollbackFor = Exception.class) public void borrowBook(BorrowDTO dto) { // 1. 校验读者 User user = userService.getById(dto.getUserId()); if (user == null || !UserStatus.ACTIVE.equals(user.getStatus())) { throw new BusinessException("读者不存在或已被禁用"); } Long count = borrowRecordService.lambdaQuery() .eq(BorrowRecord::getUserId, user.getId()) .in(BorrowRecord::getStatus, BorrowStatus.BORROWED.getCode(), BorrowStatus.OVERDUE.getCode()) .count(); if (count >= user.getMaxBorrowCount()) { throw new BusinessException("已达到最大借阅数量"); } // 2. 校验图书并原子扣减库存 Book book = bookService.getById(dto.getBookId()); if (book == null || book.getStatus() == null || book.getStatus() != BookStatus.ON_SHELF.getCode()) { throw new BusinessException("图书不存在或未上架"); } if (!bookService.increaseBorrowedCount(book.getId())) { throw new BusinessException("库存不足"); } // 3. 生成借阅记录 BorrowRecord record = new BorrowRecord(); record.setUserId(user.getId()); record.setBookId(book.getId()); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); record.setStatus(BorrowStatus.BORROWED.getCode()); borrowRecordService.save(record); }

这里千万要注意 Spring 事务“自调用失效”问题。如果你在同一个类里的方法 A 调用了方法 B,且 B 上有@Transactional,事务不会生效,因为通过this调用不会经过代理对象。所以要拆一个BorrowService承担跨表事务,controller 只负责接收参数,不要在 controller 里写事务逻辑。

续借功能也一样是状态流转:只允许状态为“借出中”的记录续借,而且一本图书最多续借一次,续借后新的 due_time 在当前到期时间基础上加 30 天。这个逻辑很简单,但很多教程都漏了“续借次数限制”这个约束,导致用户可以无限续借,这在业务流程上是违和的。你不仅要实现“能”,还要解释“为什么不允许无限续”。

4.4 超期处理与统计报表:给系统收个好尾

超期处理有两种实现路线。一种是不定时任务,每次查询时动态比较 due_time 和当前时间,把 overdue 作为一种动态状态;另一种是每天凌晨跑一个定时任务扫描所有“借出中”且已超过 due_time 的记录,把状态改成“已逾期”。

我在项目里选的是定时任务。理由很直接:借阅记录表的数据量如果大了,每次分页查询都做“状态判断 + 日期比较”会拖慢查询,而且“逾期通知”“逾期罚金累计”这类衍生功能也需要一个单独的触发时机。用 Spring 自带的@Scheduled就能实现,不需要引入 Quartz,代码非常简单。

@Component public class OverdueTask { @Resource private BorrowRecordService borrowRecordService; @Scheduled(cron = "0 0 2 * * ?") @Transactional(rollbackFor = Exception.class) public void markOverdueRecords() { List<BorrowRecord> overdueList = borrowRecordService.lambdaQuery() .in(BorrowRecord::getStatus, BorrowStatus.BORROWED.getCode()) .lt(BorrowRecord::getDueTime, LocalDateTime.now()) .list(); for (BorrowRecord record : overdueList) { record.setStatus(BorrowStatus.OVERDUE.getCode()); } borrowRecordService.updateBatchById(overdueList); } }

测试批处理类功能时,不要真的等凌晨两点,临时把 cron 改成0 * * * * ?(每秒触发的话要小心 MySQL 压力),关注点在于“每次扫描都是全表扫”,所以记得要给 due_time 字段建立索引,否则数据量上来后会全表扫描拖垮数据库。

统计报表部分可以做成管理后台首页的卡片和图表。例如今日借出量、今日归还量、当月新增读者、逾期未还数,以及“借阅排行 Top10”的图书列表。这些统计值用简单的聚合 SQL 就能搞定,不建议在前端把全表数据拉下来再算,那样数据量大后必崩。

统计模块有一个容易掉坑的点:统计“本月”不能只拿month()函数后用当前日期去过滤,更要小心 MySQL 时区与服务器时区不一致,导致凌晨 0 点到 8 点的数据被归到前一天。我当时的处理方式是用 Java 的LocalDate.now()先算出时间和结束时间再传参给 SQL,避免在数据库函数层面直接依赖系统时区。

5. 打包部署与答辩准备

系统写完只是第一步,能现场跑起来才是最终的交付标准。每年都能看到有人答辩的时候现场打不开,那种尴尬会直接把前面所有努力清零。所以我把部署和演示脚本单独拿出来讲。

5.1 从 IDEA 到服务器:Maven 打包与常见环境问题

如果你用的内嵌 Tomcat,打包交付非常简单,在项目根目录执行:

mvn clean package -DskipTests

打完包后,target 目录下会生成一个library-0.0.1-SNAPSHOT.jar。服务器上只要装了对应版本的 JDK,直接:

java -jar library-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

就能启动整个系统。这类部署方式的好处是环境依赖少,安装 JDK、上传 jar、运行三件事就够。纯内嵌方式对演示机和服务器都很友好,是我推荐毕设采用的方式。

如果你学校要求必须部署到外置 Tomcat,思路也简单:把打包方式改成 war,并让 Spring Boot 打成可部署的 war 包。把 pom 里的<packaging>war</packaging>加上,同时把内嵌 Tomcat 的依赖 scope 设为provided。然后继承SpringBootServletInitializer重写 configure 方法。这里有个必须提醒的点:Spring Boot 2.7 的项目用的是javax.servlet命名空间,所以外置 Tomcat 版本要选 9.x,不要选 Tomcat 10,因为 Tomcat 10 把包名换成了jakarta.servlet,直接部署会导致各种 NoClassDefFoundError。

环境变量配置也是一个高频翻车点。如果电脑上之前装过多个 JDK,或最近重装过系统,经常在命令行执行java -version发现版本不对,而在 IDEA 里项目又能跑。解决办法是把JAVA_HOME环境变量和Path里的 Java 路径都清掉,只保留一套稳定版本,然后关掉命令行窗口重新打开再试一次。不要问我为什么强调这个,我见过太多同学开开心心打包,然后在命令行那一关卡一下午。

5.2 答辩高频问题,提前把口径都过一遍

答辩老师不会逐行看你的代码,但会通过提问判断你是不是真做了。提前准备答题口径非常关键。下面是我整理的一套高频问题及参考回答思路。

高频问题回答要点
系统架构是什么前后端分离/后端渲染 + 标准三层架构 Controller-Service-Mapper
为什么用 Spring Boot 而不是 SSM自动配置简化开发、内嵌容器方便部署、生态成熟
JWT 和 Session 有什么区别无状态、适合前后端分离,不占服务端内存但无法主动失效,所以设置了过期时间
如何防止并发超借采用原子更新语句带库存条件判断,而不是先查后改
数据库表之间关系如何设计图书与分类一对多、用户与借阅记录一对多、借览记录与图书多对一
项目有哪些可以改进的地方引入消息队列做归还提醒、增加分布式锁、服务拆分、引入 Redis 缓存高频数据

每个回答都要落地。比如老师问“为什么用 Redis”,光答“用 Redis 做缓存”还不够,最好能具体到“图书详情页浏览量大,把热门图书数据缓存起来,key 是 book:hot,缓存时间 30 分钟”。这样的答案一听就是实践过的,而不是背八股文背出来的。

还有一类问题是围绕“数据一致性”的。比如“借书时如果库存扣减成功了,但插入借阅记录失败了怎么办”,这个问题考察你懂不懂事务。你只要说出@Transactional和回滚原理,就已经能拿分。如果再补充一句“MySQL InnoDB 默认 REPEATABLE READ,行级锁可以保证并发安全”,那就更稳了。

6. 实操中踩过的坑,整理成可以直接查的速查表

最后这部分,我直接整理成“翻车记录速查表”。每一行都是我真实遇到或者帮别人排查时见过的,请把它当排查手册收藏。

6.1 环境依赖类问题

现象常见原因解决办法
IDEA 报“源发行版 17 需要目标发行版 17”项目 java.version 与当前 IDE 编译级别不一致pom 里统一设置<java.version>1.8</java.version>,再让 Maven 重新导入,并检查 Project Structure
启动时报 “Failed to configure a DataSource”数据库没启动或连接串/账号密码不对先确认 MySQL 服务已启动,再用命令行 mysql 客户端测试连接
Springfox 3.0.0 搭配 Spring Boot 2.6+ 后 Swagger 页面空白Spring Boot 默认路径匹配策略与 Springfox 不兼容在配置文件加spring.mvc.pathmatch.matching-strategy=ant_path_matcher
JDK 17 下 Lombok 不生效Lombok 版本太老升级 Lombok 到 1.18.30 及以上
打包后运行报 no main manifest attribute没执行 mvn package 或主类配置错误执行mvn clean package -DskipTests,确认主类被 Spring Boot Maven 插件识别
MySQL 日期差 8 小时时区未指定连接 URL 加serverTimezone=Asia/Shanghai

6.2 业务逻辑与数据问题

现象常见原因解决办法
借书接口并发测试时库存变负数先查询后更新,没有原子性改用update book set borrowed_count = borrowed_count + 1 where id=? and stock > borrowed_count
点击还书后数据没保存@Transactional自调用导致事务失效把跨表事务方法单独放到一个 service,通过 Spring 代理调用
编辑图书后 ISBN 重复报错逻辑删除记录占了唯一索引用带 deleted 的组合唯一索引,或删除时物理清除无引用的旧图书
分页查询没有生效,数据全查出来没配置 MyBatis-Plus 分页插件注入MybatisPlusInterceptor,并添加PaginationInnerInterceptor
修改用户角色后 token 未立刻失效角色写死在 token 里拦截器每次请求从数据库查最新用户状态和角色
统计报表时间段有误MySQL 时区或函数拼接问题在 Java 侧算好开始和结束时间再传入 SQL

6.3 接口调试与联调阶段

接口联调是项目开发

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询