☰
MySQL图书管理系统设计与实现:从表结构到Spring Boot避坑指南
2026/10/9 13:15:58 网站建设 项目流程

简介:面向数据库课程期末大作业场景,这是一款基于 MySQL 的图书管理系统资料包,主要服务高校学生与数据库入门者,用于完成图书借还、信息查询与权限管理方向的课设实践;系统覆盖图书、读者、管理员、借阅、逾期处罚等核心数据表,功能上包含借还书流程、模糊查询以及用户权限设置,可作为同类项目的设计蓝本。压缩包共19个文件,整体仅467KB,其中以 .frm 表结构定义文件为主,搭配 .trg 触发器、.TRN 及 ibdata1 等数据库对象与存储文件,另有 .sql 脚本和 .doc 课设报告,便于直接导入数据库并对照文档理解实现思路。目前已有11219人学习,热度较高。文件内既包含完整的课程设计报告,也提供可直接运行的 MySQL 源文件与 SQL 代码,读者可参考其中的表关系设计、借还书事务逻辑、模糊查询语句和权限控制写法,快速迁移到自己的期末项目或用于数据库复习巩固。

1. mysql图书管理系统:别把它当成只会增删改查的入门作业

mysql图书管理系统,听起来像程序员段子里那个“谁没做过一个图书管理系统”的梗。但真把它做成一个能答辩、能写进简历的作品,没那么容易。很多人以为这个项目的核心是 Java 代码,踩过坑之后我才明白——它的质量分和难点几乎全在 MySQL 那几张表上。表设计立住了,后面的代码是顺水推舟;表设计糊弄过去,后续每一个功能都在给结构打补丁。这篇笔记我从表结构、Spring Boot 实现、连接配置到排错清单,把一套可靠方案和踩坑记录一次性讲清楚。适合正在做课设、毕设,或者想拿它练手但不想只写 CRUD 的开发者。

2. 先立表再写代码:图书管理系统的 MySQL 数据模型

2.1 为什么表结构是这个项目真正的主心骨

图书管理系统最典型的业务流程就是借书、还书、查书、管读者。表面看功能不多,但“借书”这个动作一旦落到数据库,就牵扯到库存扣减、借阅记录新增、读者在借数量校验、逾期状态判定。这一连串操作的前提是表关系设计得够清楚,否则代码层再怎么补救都别扭。

我一般会先用一张简图画实体关系,再动手建库。这个项目里有四个核心实体:图书、读者、管理员、借阅记录,外加一个图书分类。借阅记录是中间表,把图书和读者关联起来,同时记录管理员是谁办理的。这个关系理清之后,建表就是按图索骥。

设计时优先满足三范式的常规要求:不存重复数据、不搞冗余字段。比如图书的出版社、作者、ISBN 都只存在 books 表里,借阅记录里只存 book_id,不冗余书名。查询时靠 JOIN 把书名带出来,虽然多写一条关联,但保证了数据修改时只需要改一处,不会出现两本书名不一致的脏数据。

2.2 六张核心表的建表 SQL 与字段取舍

直接给一套可复制的建表 SQL。字符集用 utf8mb4,排序规则用 utf8mb4_general_ci。注意 MySQL 8.0 的 utf8mb4 是默认字符集,但老项目里经常遇到 utf8 存不下生僻字和 emoji 的情况,所以建库时显式声明最保险。

CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE library_db; -- 图书分类表 CREATE TABLE categories ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID', name VARCHAR(50) NOT NULL COMMENT '分类名称', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', PRIMARY KEY (id), UNIQUE KEY uk_name (name) ) ENGINE=InnoDB COMMENT='图书分类表'; -- 图书表 CREATE TABLE books ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '图书ID', isbn VARCHAR(20) NOT NULL COMMENT 'ISBN编号', title VARCHAR(200) NOT NULL COMMENT '书名', author VARCHAR(100) NOT NULL COMMENT '作者', category_id INT UNSIGNED NOT NULL COMMENT '分类ID,逻辑外键', publisher VARCHAR(100) DEFAULT NULL COMMENT '出版社', publish_date DATE DEFAULT NULL COMMENT '出版日期', total_stock INT NOT NULL DEFAULT 1 COMMENT '馆藏总数', available_stock INT NOT NULL DEFAULT 1 COMMENT '当前可借库存', location VARCHAR(50) DEFAULT NULL COMMENT '所在书架位置', status TINYINT NOT NULL DEFAULT 1 COMMENT '1在架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_isbn (isbn), KEY idx_title (title), KEY idx_category (category_id) ) ENGINE=InnoDB COMMENT='图书表'; -- 读者表 CREATE TABLE readers ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '读者ID', reader_no VARCHAR(20) NOT NULL COMMENT '读者证号', name VARCHAR(50) NOT NULL COMMENT '读者姓名', phone VARCHAR(11) DEFAULT NULL COMMENT '手机号', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0挂失/停用', max_borrow INT NOT NULL DEFAULT 5 COMMENT '最大可借数量', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_reader_no (reader_no) ) ENGINE=InnoDB COMMENT='读者表'; -- 管理员表 CREATE TABLE admins ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '管理员ID', username VARCHAR(50) NOT NULL COMMENT '登录用户名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt哈希后的密码', role VARCHAR(20) NOT NULL DEFAULT 'ADMIN' COMMENT '角色', last_login_time DATETIME DEFAULT NULL COMMENT '最后登录时间', PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB COMMENT='管理员表'; -- 借阅记录表 CREATE TABLE borrow_records ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '借阅记录ID', book_id BIGINT UNSIGNED NOT NULL COMMENT '图书ID', reader_id BIGINT UNSIGNED NOT NULL COMMENT '读者ID', borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '借出时间', due_time DATETIME NOT NULL COMMENT '应还时间', return_time DATETIME DEFAULT NULL COMMENT '实际归还时间', status TINYINT NOT NULL DEFAULT 1 COMMENT '1借出 2已还 3逾期', operator_id INT UNSIGNED NOT NULL COMMENT '办理借阅的管理员ID', PRIMARY KEY (id), KEY idx_reader_status (reader_id, status), KEY idx_book_status (book_id, status), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES books (id), CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES readers (id) ) ENGINE=InnoDB COMMENT='借阅记录表';

字段取舍上有几个点值得细说。books 表里我同时放了 total_stock 和 available_stock,前者是馆藏总量,后者是当前可借数量。每次借出扣 available_stock,归还时加回来,total_stock 永远不变。这样统计“馆藏多少本”和“现在能借多少本”都只需要查一条记录,不用去 borrow_records 里数数。

borrow_records 的联合索引 idx_reader_status 是我比较坚持的做法。这个表最常见的查询就是“某个读者当前借了哪几本没还”和“某本书现在在谁手里”,两个查询都躲不开 reader_id + status 或 book_id + status 的过滤条件。有了联合索引,这两个查询走索引就很快。等数据量到了几万条,有没有这个索引的查询耗时差一个数量级。

物理外键我特意保留在了借阅记录上。虽然很多生产环境为了高并发会禁用物理外键,但这个项目里借阅记录就是核心明细表,物理外键能保证你永远删不掉一条被借阅记录引用的图书或读者,数据完整性从数据库层面就锁死了。后面第 5 章我会专门讲它带来的“麻烦”。

2.3 三个字段设计决策:逾期、罚款、软删除

每届做图书管理系统的人几乎都会在答辩桌上被问到同一个问题:“逾期怎么办?”很多同学的表里压根没有逾期这个概念,只靠页面上的日期比较硬算。我的做法是直接在借阅记录表的 status 里加一个 3 表示逾期,然后每天跑一个定时任务,把 due_time 小于当前时间且 status 仍为 1 的记录翻成 3。

UPDATE borrow_records SET status = 3 WHERE status = 1 AND due_time < NOW();

这条 SQL 简单到不需要解释,但它的意义在于:逾期变成了一个可查询、可统计的状态,而不是每次查询时在业务代码里临时判断。列表页直接WHERE status = 3就是逾期列表,统计逾期率也一条 SQL 搞定。

罚款字段我建议先预留,不用单独建表。在 readers 表上加一个debt DECIMAL(10,2) NOT NULL DEFAULT 0.00,还书时按逾期天数计算欠款累加上去。如果后面决定不收罚款,这个字段空着也不影响任何功能。预留字段这件事,在课设阶段没人会主动做,但验收时老师问“逾期怎么办”,你能顺势把罚款逻辑讲出来,印象分完全不同。

软删除是第三个值得做的设计。图书下架不执行DELETE FROM books,而是把 status 置为 0。原因是下架一本书时,历史借阅记录还指向这本书,物理删除会让那些记录变成悬空引用,统计历史数据时全部断裂。常见做法是查询列表默认带上status = 1过滤,管理员界面提供“查看已下架图书”的入口。这个习惯养成了,以后做任何带明细的业务系统都不会犯硬删主数据的错。

3. 让系统跑起来:Spring Boot + MyBatis-Plus 的分层实现

3.1 项目结构与分层:为什么 Controller、Service、Mapper 不能混着写

数据库的底子打好了,代码层的关键在于别把业务规则散落在 Controller 里。最常见且可靠的做法是拆三层:Controller 只接收参数和返回结果,Service 放业务规则,Mapper 只跟数据库打交道。很多同学嫌类多,把逻辑全写在 Controller,短期内跑得通,但一旦借书规则要加一个“读者欠款超过 50 元不能借”,你就得去翻每一个入口方法,漏改一个接口就会出现规则不一致。

src/main/java/com/example/library ├── controller │ ├── AuthController.java │ ├── BookController.java │ └── BorrowController.java ├── service │ ├── BookService.java │ └── BorrowService.java ├── mapper │ ├── BookMapper.java │ └── BorrowRecordMapper.java ├── entity │ ├── Book.java │ ├── Reader.java │ └── BorrowRecord.java └── config └── WebConfig.java

这个结构对应的依赖方向是 Controller 调 Service,Service 调 Mapper,实体类被各层共用。Service 是核心,借书的库存判断、读者在借数量校验、还书的逾期判定,全部写在这里。Controller 里不应该出现任何 SQL 语句或者业务判断,它只做参数解析和结果包装。

3.2 借书还书的核心代码:事务与原子扣减

借书是整个系统里最值得认真写的功能。它不是一个 insert 就结束的,而是至少两步:扣减图书可借库存、插入借阅记录。这两步必须在一个事务里,任何一步失败都要全部回滚,否则会出现库存扣了但记录没插上的脏数据。

@Service public class BorrowService { private final BookMapper bookMapper; private final BorrowRecordMapper borrowRecordMapper; private final ReaderMapper readerMapper; public BorrowService(BookMapper bookMapper, BorrowRecordMapper borrowRecordMapper, ReaderMapper readerMapper) { this.bookMapper = bookMapper; this.borrowRecordMapper = borrowRecordMapper; this.readerMapper = readerMapper; } @Transactional(rollbackFor = Exception.class) public void borrow(Long bookId, Long readerId, Long operatorId) { // 先校验读者状态和未还数量 Reader reader = readerMapper.selectById(readerId); if (reader == null || reader.getStatus() != 1) { throw new IllegalStateException("读者不存在或状态异常"); } Long outstanding = borrowRecordMapper.selectCount( new LambdaQueryWrapper<BorrowRecord>() .eq(BorrowRecord::getReaderId, readerId) .eq(BorrowRecord::getStatus, 1)); if (outstanding >= reader.getMaxBorrow()) { throw new IllegalStateException("该读者借阅数量已达上限"); } // 原子扣减库存:只有库存大于0才会更新成功 int updated = bookMapper.decreaseStock(bookId); if (updated == 0) { throw new IllegalStateException("图书库存不足或已下架"); } BorrowRecord record = new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); record.setStatus(1); record.setOperatorId(operatorId); borrowRecordMapper.insert(record); } }

对应的 BookMapper 里,扣库存不是先查再改,而是用一条 SQL 原子完成:

@Mapper public interface BookMapper extends BaseMapper<Book> { @Update("UPDATE books SET available_stock = available_stock - 1 " + "WHERE id = #{bookId} AND available_stock > 0 AND status = 1") int decreaseStock(@Param("bookId") Long bookId); @Update("UPDATE books SET available_stock = available_stock + 1 " + "WHERE id = #{bookId}") int increaseStock(@Param("bookId") Long bookId); }

这里有两个关键设计值得在答辩或者写简历时专门提出来。第一,库存扣减为什么不用“先 select 再 updateById”?因为两个请求同时借最后一本书时,双方都查到了库存为 1,都认为可以借,最后都执行更新,库存变成 -1。把判断和扣减合并进一条 UPDATE,数据库的行锁会保证同一时刻只有一个请求能更新成功,另一个拿到影响行数为 0,从而被拒绝。这个就是经典的并发超卖问题在图书系统里的变体。

第二,@Transactional 保证扣库存和插记录要么都成功、要么都失败。比如在 insert 借阅记录时数据库突然报错,事务回滚会把刚才那条 UPDATE 的库存扣减也一起撤销。没有这个注解,恢复数据只能靠手写补偿 SQL,那种人肉恢复的操作谁都经历过一次就不想再来第二次。

还书逻辑对称,只是方向相反:先查借阅记录,校验状态,然后把库存加回去:

@Transactional(rollbackFor = Exception.class) public void returnBook(Long recordId) { BorrowRecord record = borrowRecordMapper.selectById(recordId); if (record == null || record.getStatus() != 1) { throw new IllegalStateException("借阅记录不存在或已归还"); } int newStatus = record.getDueTime().isBefore(LocalDateTime.now()) ? 3 : 2; record.setStatus(newStatus); record.setReturnTime(LocalDateTime.now()); borrowRecordMapper.updateById(record); bookMapper.increaseStock(record.getBookId()); }

逾期判断放在还书时做,而不是依赖定时任务,是为了保证任何时刻还书都能得到准确的最终状态。定时任务负责提前把超期的记录翻成状态 3,让列表页和统计页能看到;而还书时再次判断是为了兜底,防止定时任务没跑或时间差导致状态失真。

3.3 实体与表映射:驼峰规则和 BaseMapper 帮你省掉大半代码

建表时字段名是下划线风格,Java 里是驼峰风格,这中间的映射是 MyBatis-Plus 默认处理好的。publish_date自动映射到publishDate,available_stock映射到availableStock,不需要手写任何映射配置。这个能力来自map-underscore-to-camel-case配置,Spring Boot 集成 MyBatis-Plus 后默认开启。

@TableName("books") public class Book { @TableId(type = IdType.AUTO) private Long id; private String isbn; private String title; private String author; private LocalDate publishDate; private Integer totalStock; private Integer availableStock; private Integer status; // getter/setter 省略 }

实体类继承 BaseMapper 之后,单表的增删改查几乎不再需要手写 SQL。selectById、insert、updateById、selectCount这些方法开箱即用。这个项目里只有库存扣减这种需要原子操作的场景才值得自己写 @Update 注解。剩下的查询用 LambdaQueryWrapper 构造条件即可,代码可读性比拼 SQL 字符串好太多。

常见做法是把实体和表字段保持严格对齐,但也要注意数据库的 update_time 字段用数据库自动更新即可,实体里不需要每次 set。MyBatis-Plus 的自动填充功能可以做这件事,但课设阶段用不上,靠DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP已经足够。

4. 连接配置与登录安全:给系统装上安全带

4.1 数据库连接串上的每个参数都是血泪的产物

很多同学本地跑项目时连数据库这关就过不去,报错从 “Public Key Retrieval is not allowed” 到 “Server returns invalid timezone”,随便一个都能卡半天。其实问题几乎都集中在连接串上。我固定使用的配置如下:

spring: datasource: url: jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 2 maximum-pool-size: 10

连接串上这几个参数的用途,对照着记就不会再踩坑:

参数作用备注
useUnicode=true使用 Unicode 字符集传输中文乱码的第一道保障
characterEncoding=utf8指定客户端编码和库表 utf8mb4 配套
useSSL=false关闭 SSL 握手本地开发不需要加密连接
serverTimezone=Asia/Shanghai指定服务器时区MySQL 8.0 不写会直接报时区错误
allowPublicKeyRetrieval=true允许获取服务端公钥MySQL 8.0 默认认证插件需要

driver-class-name 这里用的是com.mysql.cj.jdbc.Driver,这是 MySQL 8.0 的驱动类。老项目里常见的com.mysql.jdbc.Driver属于 5.x 驱动,在 MySQL 8.0 驱动里已经移除了。如果你的项目报 “ClassNotFoundException”,先检查是不是用了旧包旧类名。

HikariCP 连接池是 Spring Boot 默认集成的,minimum-idle=2表示最少保持 2 个空闲连接,maximum-pool-size=10是最大连接数。课设这种单机并发场景,10 个连接绰绰有余。曾经见过有人把 maximum-pool-size 调到 200,结果 MySQL 端max_connections默认只有 151,直接连接数耗尽。连接池大小要和数据库端的配置匹配,不是越大越好。

4.2 密码别存明文:用 BCrypt 替换 MD5

图书管理系统有管理员表,就有登录功能。登录功能做得好不好,第一个评价点是密码怎么存。明文存储是绝对红线,一旦数据库泄露,所有账号密码直接暴露。MD5 也是老黄历了,彩虹表早已让 MD5 形同虚设。我一般直接用 BCrypt,Spring Security 里现成的BCryptPasswordEncoder就可以,不需要把整个 Spring Security 引进来。

@Component public class PasswordService { private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); public String encode(String rawPassword) { return encoder.encode(rawPassword); } public boolean matches(String rawPassword, String encodedPassword) { return encoder.matches(rawPassword, encodedPassword); } }

BCrypt 的特点是为同一个明文密码生成不同的哈希结果,因为每次加密都会混入随机盐。所以数据库里两个人的密码即使明文相同,哈希值也不一样,这样攻击者连“哪些人使用了相同密码”都看不出来。校验时调用 matches 方法,它会自己从哈希里取出盐来重新计算比对。

初始化管理员账号时,做法是先跑一个一次性接口,或者在启动时往 admins 表插入一条 records:

admin.setPassword(passwordService.encode("admin123"));

注意admin123这种初始密码一定要提醒使用者登录后修改。我见过不少项目把初始密码写死在代码里,上线半年都不改,等于给系统开了后门。哪怕只是课设,养成这个习惯也不亏。

4.3 登录拦截与 SQL 注入:两个不写也能跑但早晚出事的点

系统只要有登录,就必须有访问控制。不写拦截器的后果是:任何人都可以直接访问/books/list、/borrow/add等接口,登录形同虚设。Spring Boot 里加一个 HandlerInterceptor 是最轻量的方案。

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect("/login"); return false; } return true; } }

注册拦截器时,把登录接口、静态资源放行掉:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/css/**", "/js/**", "/api/login"); } }

这段代码本身不复杂,但它补上了“能登录”到“必须登录才能用”的最后一环。拦截器放行的名单要谨慎,写宽了等于没拦,写严了登录接口自己都进不来。

SQL 注入是另一个必须拎出来讲的问题。MyBatis 的#{}预编译机制天然防注入,它会把你传入的值当作纯参数绑定,而不是拼进 SQL 语句。危险的是${},它做的是纯字符串替换。很多注入漏洞就出在开发者图方便,在 ORDER BY 排序字段或者表名这种没法用占位符的地方用了${},又把用户输入直接传了进去。

图书系统里最常用到模糊查询:

List<Book> books = bookMapper.selectList( new LambdaQueryWrapper<Book>() .like(StringUtils.hasText(keyword), Book::getTitle, keyword));

MyBatis-Plus 的 like 方法生成的 SQL 是WHERE title LIKE ?,参数是%keyword%,整个关键字作为参数绑定,不会有注入风险。如果自己写 SQL,也坚持用CONCAT('%', #{keyword}, '%')而不是'%${keyword}%'。这个习惯值两行简历。

5. 图书管理系统避坑清单:五个让我翻过车的现场

5.1 Public Key Retrieval is not allowed:第一次连 MySQL 8.0 都会被吓到

现象:项目启动时数据库连接报错,提示Public Key Retrieval is not allowed,应用直接起不来。

原因:MySQL 8.0 默认的认证插件是caching_sha2_password,客户端第一次连接拿密码去做加密通信时需要服务端公钥。JDBC 驱动出于安全考虑默认不会主动从服务器拉取公钥,于是连接被拒绝。

解决:在连接串上加上allowPublicKeyRetrieval=true,配合useSSL=false,本地开发这样处理最省事。也可以用 SQL 把用户的认证插件改回mysql_native_password,但那样等于降级了 MySQL 8.0 的安全策略。我推荐加连接参数,改动最小、不碰数据库账号配置。

5.2 中文乱码:建库、连接、页面三层都要对齐

现象:页面上输入中文书名,存入数据库后查出来变成 “???”,或者从数据库读出来的中文在接口里变成了乱码。

原因:乱码是老大难,绝大多数情况下不是某一个环节的问题,而是三层编码不一致。建库时没指定 utf8mb4,数据库默认用了 latin1,存进去的字节本身就不是 UTF-8。连接串里没带useUnicode=true&characterEncoding=utf8,客户端传过去的中文在传输过程就丢了。还有一层是 Spring Boot 的请求编码没生效,导致 POST 参数里的中文在进入 Controller 之前就已经乱掉。

解决:三层一起检查。建库语句里显式写DEFAULT CHARACTER SET utf8mb4;连接串带上useUnicode=true&characterEncoding=utf8;Spring Boot 里配server.servlet.encoding.force=true,强制请求和响应都按 UTF-8 处理。做完这三步,乱码基本不会再有。查问题时顺着“页面 → 接口 → 数据库”这条链路逐层排查,哪一层变了编码一眼就能定位。

5.3 删除图书报外键错:借阅记录让你删不动的理由

现象:管理员在后台尝试删除一本图书,数据库抛出外键约束失败,错误信息指向borrow_records表。

原因:这正是我在第 2 章特意保留物理外键带来的“麻烦”。借阅记录表里存在引用这本书的记录,外键约束不允许主表数据被直接删掉。有些同学遇到这个报错第一反应是“把外键去掉”,这等于拆掉了数据完整性的保险。

解决:分情况处理。如果这本书有未归还的借阅记录,本来就不应该删,正确做法是把 books 表的 status 置为 0 下架;如果只是历史记录有引用,但书确实要清理,那就先确认所有记录都已归还,再决定物理删除。这个报错本质是在保护业务数据的一致性,而不是系统出 bug。答辩时能把这个逻辑讲清楚,外键这道题就是送分题。

5.4 接口返回的日期格式:前端看着想打人

现象:后端接口返回的 JSON 里,日期字段要么是2025-06-01T10:30:00这种带 T 的格式,要么是一串数字时间戳,前端拿到之后还得自己写脚本转换。

原因:默认的 Jackson 序列化对LocalDateTime和java.util.Date的处理不一样,而且不配置就按 ISO 格式输出。前端拿到带 T 的字符串,显示在表格里非常突兀。

解决:在 application.yml 里配置全局日期格式:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

实体里的日期字段尽量用LocalDateTime而不是java.util.Date。LocalDateTime 配合这个全局配置可以稳定输出yyyy-MM-dd HH:mm:ss,前端直接展示不需要再格式化。time-zone: GMT+8是因为 MySQL 连接串已经指定了北京时间,Jackson 这里也要对齐,否则差 8 小时的问题会出现在日志里。

5.5 实体字段全是 null:驼峰映射到底开没开

现象:数据库表里有数据,接口也能查出来,但前端看到的 JSON 对象里publishDate、totalStock这些字段全是 null,而isbn、title这些字段有值。

原因:表的列名是publish_date下划线风格,实体属性是publishDate驼峰风格,两者映射不上。Spring Boot 集成 MyBatis-Plus 时通常默认开启驼峰映射,但如果手写 XML 或者项目里配置被改动过,map-underscore-to-camel-case被关掉,所有下划线字段都会匹配不上。

解决:检查两处。一是 application.yml 里有没有被显式关闭,确保没有配置为 false;二是手写 resultMap 时不要偷懒,列名和属性名必须逐个写清楚。还有一个排查技巧:打开 SQL 日志,如果看到查询语句是SELECT id,isbn,title,publish_date,...,而返回对象里下划线字段为 null,基本就是映射配置的问题,不是 SQL 的问题。

6. 从能跑到能加分:三个进阶验证技巧与简历素材

6.1 用 EXPLAIN 验证索引到底有没有生效

写完一套能跑的系统后,别急着交付。打开命令行连上 MySQL,对最常用的查询执行一遍 EXPLAIN,确认索引真的在起作用。比如查某个读者的在借记录:

EXPLAIN SELECT * FROM borrow_records WHERE reader_id = 1 AND status = 1;

看结果里的type和key字段。type为ref、key显示idx_reader_status,说明联合索引生效了。如果type是ALL,说明在走全表扫描,那就要回去检查表结构索引是否建对。这个动作成本极低,但能让你在汇报时说出来“我验证过索引生效”,而不是“我觉得应该有索引”。

6.2 一条 SQL 算出借阅排行榜

图书管理系统最常见的亮点功能就是统计报表。借阅排行榜是最直观也最好实现的一个,一条分组聚合 SQL 就能出结果:

SELECT b.title, COUNT(br.id) AS borrow_count FROM borrow_records br JOIN books b ON br.book_id = b.id WHERE br.borrow_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') GROUP BY br.book_id ORDER BY borrow_count DESC LIMIT 5;

DATE_FORMAT(CURDATE(), '%Y-%m-01')求出当月第一天,这条 SQL 返回的就是本月借阅量最高的五本书。把它接到一个统计接口上,前端用表格或简单图表展示,系统的完整度立刻上一个台阶。

6.3 答辩和简历里值得讲的两个亮点

如果只允许你讲两个技术点,我会选事务和并发安全。借书时的原子扣减库存,用一条 UPDATE 加判空条件解决了“超卖”问题,这个方案在电商秒杀场景里是同款思路。另一个是 BCrypt 密码存储,能讲清楚随机盐和哈希校验的区别,比堆一堆 CRUD 功能更有说服力。我最早做图书系统时也贪快,先写页面再回头改表,结果后面每天都在给表结构打补丁。后来养成了“先建表、写核心 SQL、再写 Java”的习惯,整个开发顺了很多。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询