
简介一套基于Java Web的图书管理系统完整项目源码采用浏览器/服务器架构与模型-视图-控制器设计模式面向Java初学者、课程设计及毕业设计场景覆盖普通用户与管理员双角色的完整业务闭环。普通用户端支持注册、登录、借书、还书与个人信息修改管理端提供用户管理、图书信息维护、添加用户与管理员等功能包含分页与详细代码注释业务流程清晰。压缩包共157个文件主要包含Java源码、JSP页面、SQL数据库脚本、依赖jar包、运行截图、课程报告文档和演示视频等整包约65.85MB系统使用4张数据库表、约23个JSP页面代码按DAO层、分页工具类、控制层等分层组织便于对照MVC实现与二次开发。运行环境基于JDK1.8、Tomcat和MySQL配套课程报告与视频演示可帮助快速部署和排错。目前已有8130人学习下载适合需要完整可运行案例并想深入理解Java Web项目结构的读者。1. 基于Java web的图书管理系统从课设CRUD到能落地的借阅后台基于Java web的图书管理系统是很多Java工程师从头到尾做过一遍的项目。它看起来只是对图书记录和借阅信息做增删改查可真要把登录角色、借书超期、库存不足这些问题揉进同一套代码你会被迫重新面对事务、锁、数据一致性和Web容器安全。这篇文章我会按一条能完整跑通的路线把系统拆开从数据模型、IDEA创建Web项目、列表查询到借阅事务、权限拦截和上线前检查。适合刚学完Java基础、被课设卡住的同学也适合想转后端开发、需要一个练手项目的初级工程师。先说结论这个项目天花板不低但下限很低关键看你怎么定边界。2. 技术栈选型与数据模型先给系统画出边界我在动手写图书管理系统之前会先花半天把技术栈、数据表和模块边界定下来。常见做法是Spring Boot MyBatis-Plus MySQL Thymeleaf单体应用不引入微服务也不强拆Vue前端。这套组合足够应付课设、个人作品和绝大多数中小型后台这也是企业级web开发里很常见的一种快速落地形态。2.1 为什么用Spring Boot而不是裸Servlet/JSP很多老课设和教材还在用JSP Servlet JDBC不是说不能跑而是维护成本太高。一个图书管理系统要处理登录校验、分页查询、事务回滚、上传适配这些常规需求如果用原生Servlet几乎每个接口都要手写参数解析和转发配置。Spring Boot把内嵌Tomcat容器、自动装配、Starter依赖管理都做好了开发时只需要关注业务代码这也是它在企业级web开发里被大量选择的原因。我一般会在下面三条路线里挑技术路线启动方式前端渲染适合场景JSP Servlet外置Tomcat部署WARJSP标签教学演示、年代较早的项目Spring Boot Thymeleaf内嵌容器直接跑Jar服务端模板课设、中小后台、内部系统Spring Boot Vue前后端分离内嵌容器跑接口浏览器JS渲染团队协作、多端复用、中大型项目图书管理系统这种业务我建议选Spring Boot Thymeleaf。原因有三第一服务端渲染让新手一眼能看到数据从哪里来第二前后端不分离的情况下会话权限用Session或Cookie处理更直观第三部署简单一台普通云服务器就能跑。但要注意如果你打算日后把这个项目包装成求职项目建议把接口层和页面层分开设计Controller里只返回JSON或只返回视图不要混着写这样以后拆Vue前端时不用重写后端。2.2 五张表支撑的图书业务闭环图书管理不只有book一张表。一个能跑通借还闭环的系统最少要有sys_user、book_category、book、reader、borrow_record这五张表。sys_user存放登录账号和管理员类型book_category做图书分类book存放书籍信息和总库存reader登记借阅人borrow_record记录每次借书和还书。我最看重的表是borrow_record它决定了系统能不能回答“这本书被谁借走了”“什么时候该还”“逾期了几天”这些问题。所以每条借阅记录至少要包含reader_id、book_id、borrow_time、due_time、return_time、status几个字段。status我习惯用0表示借出中、1表示已归还、2表示逾期。下面是这套模型的最小建表语句可以直接放进MySQL执行CREATE DATABASE IF NOT EXISTS lib_master DEFAULT CHARACTER SET utf8mb4; USE lib_master; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT BCrypt密文, real_name VARCHAR(32) NOT NULL DEFAULT COMMENT 姓名, role TINYINT NOT NULL DEFAULT 0 COMMENT 0管理员 1操作员, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) ENGINEInnoDB COMMENT系统用户; CREATE TABLE book_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, sort_no INT NOT NULL DEFAULT 0 COMMENT 排序号, create_time DATETIME NOT NULL ) ENGINEInnoDB COMMENT图书分类; CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_no VARCHAR(32) NOT NULL UNIQUE COMMENT 馆藏编号, title VARCHAR(128) NOT NULL COMMENT 书名, author VARCHAR(64) NOT NULL DEFAULT , publisher VARCHAR(128) NOT NULL DEFAULT , isbn VARCHAR(32) NOT NULL DEFAULT , category_id BIGINT NOT NULL, stock INT NOT NULL DEFAULT 0 COMMENT 可借库存, total_stock INT NOT NULL DEFAULT 0 COMMENT 总库存, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, KEY idx_category (category_id), KEY idx_isbn (isbn) ) ENGINEInnoDB COMMENT图书表; CREATE TABLE reader ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reader_no VARCHAR(32) NOT NULL UNIQUE COMMENT 读者证号, name VARCHAR(32) NOT NULL, phone VARCHAR(20) NOT NULL DEFAULT , status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0挂失, create_time DATETIME NOT NULL ) ENGINEInnoDB COMMENT读者表; CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reader_id BIGINT NOT NULL, book_id BIGINT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL COMMENT 应还时间, return_time DATETIME NULL COMMENT 实际归还时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0借出 1已还 2逾期, late_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 逾期费用, KEY idx_reader_status (reader_id, status), KEY idx_due_time (due_time) ) ENGINEInnoDB COMMENT借阅记录表;这里有几个参数值得说明。首先id全部用BIGINT自增不是INT因为图书量可能不大但借阅记录累积很快BIGINT能省掉以后改表结构的麻烦。其次book表里我加了version字段这是为第四章的乐观锁准备的。最后所有表都不建物理外键只用普通索引。原因是物理外键在插入和删除时会触发额外检查MySQL在并发稍微高一点时容易成为瓶颈。业务上的外键关系由Service层保证这也是现代后端开发里常用的做法。2.3 数据字典字段类型与外键约束建表之后要把字段类型和约束记清楚否则Java侧和数据库侧容易对不上。比如Java数据类型里的LocalDateTime对应MySQL的DATETIME或TIMESTAMP价格用BigDecimal对应DECIMAL(10,2)布尔状态用TINYINT不要用Java的boolean直接映射。下面是我常用的一份关键字段清单表字段类型约束说明bookbook_noVARCHAR(32)UNIQUE馆藏编号bookstockINT默认0当前可借库存bookversionINT默认0乐观锁版本borrow_recorddue_timeDATETIME非空应还时间borrow_recordstatusTINYINT默认0借出、还书、逾期sys_userroleTINYINT默认0角色区分关于唯一约束我建议只给真正不允许重复的字段加UNIQUE例如book_no、reader_no、username。isbn不要加唯一因为图书管理系统里经常会遇到同样ISBN的书但版本不同的情况而且旧数据里isbn可能为空。逻辑外键的字段应该建普通索引比如borrow_record的reader_id、book_id、status这样按“某读者当前借了哪些书”查时能走索引不会全表扫描。业务约束方面我习惯在Service层写校验而不是依赖数据库。例如借书时先判断reader状态是否挂失、book是否有库存、是否重复借同一本书。数据库约束只兜底不承担业务判断。3. 用IDEA 2024创建Web项目跑通最小图书列表与新增现在进入可复现的步骤。我用IDEA 2024版本创建web项目配合Spring Initializr一步初始化。如果你没有Spring Initializr也可以直接在IDEA里新建Maven项目再把pom内容替换成下面这套。核心目标是让book表的列表和新增先跑通再看后面的权限和事务。3.1 初始化工程pom、配置和启动类打开IDEA新建项目时选择Spring InitializrGroup填自己的包名例如com.exampleArtifact填library-adminJava版本选8或11然后在依赖里勾选Spring Web和Thymeleaf。IDEA 2024版本创建web项目时生成的骨架很干净但我习惯手动改成下面的pom因为需要加入MyBatis-Plus和MySQL驱动。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdlibrary-admin/artifactId version1.0.0/version namelibrary-admin/name description基于Java web的图书管理系统/description properties java.version1.8/java.version mybatis-plus.version3.5.3.1/mybatis-plus.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project这个pom的关键是Spring Boot Parent帮我们把Web、Thymeleaf、MySQL驱动的版本统一管理住了只有MyBatis-Plus需要自己声明版本。我写的是3.5.3.1这也是目前网上能找到大量资料的稳定版。JDK版本我保留了1.8因为很多课设环境还在用Java 8如果你在2024版本后用的JDK 17把java.version改成17Spring Boot版本换成3.x即可。配置文件放在src/main/resources/application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/lib_master?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto注意连接串里的serverTimezoneAsia/Shanghai。如果漏掉这个参数数据库和JVM时区不一致时会出现时间字段差8小时甚至连接报错。开发和调试阶段把Thymeleaf的cache设为false改页面不用重启。map-underscore-to-camel-case设为true后数据库的book_no会自动映射到Java实体里的bookNo属性。启动类也很简单package com.example.library; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication MapperScan(com.example.library.mapper) public class LibraryApplication { public static void main(String[] args) { SpringApplication.run(LibraryApplication.class, args); } }MapperScan是MyBatis-Plus的扫包注解有了它就不用每个Mapper都加Mapper。这是Java后端工程里常见的规范目的是统一管理Mapper注册避免遗漏。3.2 从实体到Mapper让Book表可以被程序操作数据库表建好后在Java里建对应的实体类和Mapper接口。实体类字段要和数据库列对应用驼峰命名。Lombok可省去getter/setter。package com.example.library.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(book) public class Book { TableId(type IdType.AUTO) private Long id; private String bookNo; private String title; private String author; private String publisher; private String isbn; private Long categoryId; private Integer stock; private Integer totalStock; private Integer status; private Integer version; private LocalDateTime createTime; private LocalDateTime updateTime; }这里每个字段都是一种Java数据类型映射Long对应BIGINTInteger对应INTLocalDateTime对应DATETIME。为什么不用Date而用LocalDateTime因为Java 8的LocalDateTime在格式化、时区计算上更干净且MyBatis-Plus默认支持。Mapper接口只需要继承BaseMapperpackage com.example.library.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.library.entity.Book; public interface BookMapper extends BaseMapperBook { }BaseMapper是MyBatis-Plus内置的通用Mapper已经提供selectById、selectPage、insert、updateById等常用方法不需要手写SQL。对图书管理这种简单查询来说直接用的效率最高。如果后面要做复杂报表或联表查询再在Mapper里写XML。接着是Servicepackage com.example.library.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.library.entity.Book; import com.example.library.mapper.BookMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; Service public class BookService { Autowired private BookMapper bookMapper; public IPageBook findPage(int pageNum, int pageSize) { PageBook page new Page(pageNum, pageSize); return bookMapper.selectPage(page, null); } public void addBook(Book book) { book.setCreateTime(LocalDateTime.now()); book.setUpdateTime(LocalDateTime.now()); book.setStatus(1); book.setStock(book.getTotalStock()); book.setVersion(0); bookMapper.insert(book); } }注意addBook里没有直接让数据库填createTime因为我们没在配置里开启MetaObjectHandler自动填充。最简单的做法就是手动set时间。如果你想让代码更整洁可以写一个MybatisPlus自动填充处理器但我认为小项目手动set更直观不容易出现“设计了自动填充但没启用”的隐性问题。3.3 Controller Thymeleaf页面在浏览器里看到书单列表和新增的Controller用一个就够了package com.example.library.controller; import com.baomidou.mybatisplus.core.metadata.IPage; import com.example.library.entity.Book; import com.example.library.service.BookService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; Controller RequestMapping(/book) public class BookController { Autowired private BookService bookService; GetMapping(/list) public String list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, Model model) { IPageBook page bookService.findPage(pageNum, pageSize); model.addAttribute(page, page); return book/list; } PostMapping(/add) public String add(Book book) { bookService.addBook(book); return redirect:/book/list; } }分页参数pageNum和pageSize都由前端传入默认值写在RequestParam里。Controller返回字符串book/list时Thymeleaf会去templates/book/list.html找模板。!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title图书列表/title /head body h2图书列表/h2 table border1 tr th书号/th th书名/th th作者/th th库存/th th状态/th /tr tr th:eachb : ${page.records} td th:text${b.bookNo}/td td th:text${b.title}/td td th:text${b.author}/td td th:text${b.stock}/td td th:text${b.status 1 ? 上架 : 下架}/td /tr /table form action/book/add methodpost input namebookNo placeholder书号/ input nametitle placeholder书名/ input nameauthor placeholder作者/ input nametotalStock typenumber value1/ input namecategoryId typehidden value1/ button typesubmit新增图书/button /form /body /html这段模板里th:each遍历Page对象里的records集合th:text输出字段。表单提交到Post接口新增完成后重定向到列表页。跑通这一步说明Spring Boot、Thymeleaf、MyBatis-Plus这条链路没问题。用IDEA 2024版本创建web项目时一个常见问题是Thymeleaf命名空间没写或者模板文件放错目录导致页面直接下载而不是渲染。模板必须放在src/main/resources/templates下Controller返回的字符串路径不能带.html后缀。如果页面出现404先检查这两点。4. 登录权限与借阅事务做到角色能控、书不能超借列表和新增跑通后图书管理系统还缺两个核心能力谁能进后台、借书/还书时库存和记录怎么保证一致。这也是面试时最常被追问的点。这一章我会用最直接的方式实现登录拦截、借书事务和逾期提醒。4.1 登录与会话权限用拦截器控制管理入口小系统不一定要上Spring Security。Spring Security功能完整但配置门槛高对刚入门的人来说容易陷入过滤器链的黑箱。我一般会先自定义一个HandlerInterceptor用Session保存登录用户信息十几行代码就能控制后台入口。package com.example.library.interceptor; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(/login); return false; } return true; } }这里只检查Session里有没有loginUser。如果需要区分管理员和操作员可以再判断一个role字段。注册这个拦截器要定义一个配置类package com.example.library.config; import com.example.library.interceptor.LoginInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class WebConfig implements WebMvcConfigurer { Autowired private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/book/**, /borrow/**) .excludePathPatterns(/login, /css/**, /js/**); } }addPathPatterns必须把静态资源路径排除掉否则登录页的CSS会进拦截器死循环。登录接口可以手动往Session里写入用户信息退出时直接session.invalidate()。这个方案的好处是逻辑透明坏处是如果以后要和移动端对接Session方案不如JWT灵活。对于课设和后台管理界面Session足够。行级权限在这个系统里可以这样理解普通操作员只能看到和自己相关的借阅记录管理员能看到全部。实现方式不是加复杂框架而是在查询SQL或Mapper的查询对象里追加一个user_id条件。例如借阅记录表加一个create_by字段操作员查询时强制带上create_by 当前登录用户id管理员不带。这样就不会出现操作员能看到别人借阅数据的问题。4.2 借书还书的Transactional边界库存和记录的原子性借书逻辑看起来简单查库存、减库存、插入借阅记录。但如果不加事务控制减库存成功而插入记录失败就会出现书没了但借阅单没生成。事务必须保证这三个操作同成功或同失败。package com.example.library.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.example.library.entity.Book; import com.example.library.entity.BorrowRecord; import com.example.library.mapper.BookMapper; import com.example.library.mapper.BorrowRecordMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; Service public class BorrowService { Autowired private BookMapper bookMapper; Autowired private BorrowRecordMapper borrowRecordMapper; Transactional(rollbackFor Exception.class) public void borrow(Long readerId, Long bookId, LocalDateTime dueTime) { Book book bookMapper.selectById(bookId); if (book null) { throw new RuntimeException(图书不存在); } int updated bookMapper.update(null, new LambdaUpdateWrapperBook() .setSql(stock stock - 1) .eq(Book::getId, bookId) .gt(Book::getStock, 0) .eq(Book::getVersion, book.getVersion()) .set(Book::getVersion, book.getVersion() 1)); if (updated 0) { throw new RuntimeException(库存不足或版本变更请重试); } BorrowRecord record new BorrowRecord(); record.setReaderId(readerId); record.setBookId(bookId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(dueTime); record.setStatus(0); record.setLateFee(new java.math.BigDecimal(0.00)); borrowRecordMapper.insert(record); } }这里最关键的是update语句里的.gt(Book::getStock, 0)和.eq(Book::getVersion, book.getVersion())。它把“检查库存”和“扣减库存”合并成了一条原子SQL数据库在更新时会自己判断stock是否大于0不需要提前select再加锁。同时version字段参与了更新条件MyBatis-Plus的乐观锁插件会自动把version加进update条件但是手动写LambdaUpdateWrapper更可控出错时排查也容易。为什么不用selectById查完再updateById因为两个请求同时查到最后一件库存时两个线程都认为库存够然后都去扣减最后可能出现负值。条件更新直接让数据库决定成败这是Java保证数据一致性的常见手段。如果更新行数为0就说明要么库存不足要么数据被其他请求改过直接抛业务异常让前端提示用户重试。还书逻辑类似先更新借阅记录的状态和return_time再把book表的stock加回去。同样要包在事务里否则还书记录更新了但库存没加系统会越跑越少。4.3 到期提醒与定时任务用Scheduled补上业务闭环借阅系统不能只有借和还还应该有一个后台任务自动标记逾期。Java定时任务框架有很多Spring自带Scheduled在这个场景就够用不需要引入Quartz。大数据量或分布式部署时再考虑分布式任务调度。package com.example.library.task; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.example.library.entity.BorrowRecord; import com.example.library.mapper.BorrowRecordMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; Component EnableScheduling public class BookOverdueTask { Autowired private BorrowRecordMapper borrowRecordMapper; Scheduled(cron 0 30 1 * * ?) public void markOverdue() { LocalDateTime now LocalDateTime.now(); borrowRecordMapper.update(null, new LambdaUpdateWrapperBorrowRecord() .set(BorrowRecord::getStatus, 2) .eq(BorrowRecord::getStatus, 0) .lt(BorrowRecord::getDueTime, now)); } }这个定时任务每天凌晨1点30分执行一次把status为0且due_time小于当前时间的记录全部置为2。cron表达式0 30 1 * * ?的意思是“每天01:30:00执行”。如果你是单机部署直接用fixedRate 3600000每小时扫一次也可以但凌晨跑更稳妥不会在业务高峰期抢占数据库连接。如果以后系统部署成多个实例相同的定时任务会在每个节点各执行一次会出现重复更新。解决方法是加一个分布式锁比如用Redis的setnx或者在一个公共表里记录上次执行时间保证同一时刻只有一个节点真正执行业务逻辑。做图书管理系统时不需要一步到位但心里要有这个边界。5. 五个容易翻车的细节现象、原因和后悔药这个项目看起来简单但我在带人复现时见过太多同学在同一批问题上翻车。下面这几条都是真实踩过的坑按“现象 → 原因 → 解决”的顺序写命中一条就能少折腾半天。5.1 中文乱码数据库连接和页面显示一起炸现象页面上书名显示成问号或者后台插入的中文到数据库里变成乱码。原因两处没对齐。第一MySQL建库时字符集不是utf8mb4第二JDBC连接串里没有characterEncodingutf8。如果两处只改一处乱码依然存在。解决建库时明确写DEFAULT CHARACTER SET utf8mb4连接串里加useUnicodetruecharacterEncodingutf8。同时保证数据库、表、连接串三层一致。页面meta里加charsetUTF-8只是最后一道保险源头不对时页面怎么设都白搭。5.2 LocalDateTime被Thymeleaf显示成ISO标准串现象图书列表里时间字段显示成2025-01-01T10:30:00T字母看起来很不美观。原因Thymeleaf默认对LocalDateTime没有可读性的自动格式化输出的是ISO-8601格式。解决在模板里用#temporals.format或者在配置文件里统一配置Jackson的时间格式。如果是Thymeleaf模板推荐前者td th:text${#temporals.format(b.createTime, yyyy-MM-dd HH:mm)}/td如果项目做了前后端分离、接口返回JSON就在application.yml里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8记住服务端渲染和JSON接口的时间格式化要分开处理别在数据库里拼字符串Java后端直接用LocalDateTime最安全。5.3 乐观锁更新行数为0被误判为失败现象两个浏览器同时打开同一本图书的编辑页A先保存成功B再保存时提示“更新失败”但实际数据没有被破坏。原因version字段不一致。A更新后version从0变1B的更新条件还是version0所以更新行数为0。解决这是乐观锁的正常机制。业务上应该捕获更新行数为0的情况然后提示“数据已被其他操作更新请刷新后再试”而不是报500。如果希望用户体验更好可以把旧数据重新加载回来覆盖但要在页面明确标注这是覆盖操作。注意service里要区分真正的失败和并发冲突别把异常吞掉。5.4 Thymeleaf缓存导致页面改不动现象改完templates下的HTML刷新浏览器发现还是旧页面重启项目才生效。原因Spring Boot的Thymeleaf默认开启缓存生产环境需要缓存但开发环境会让人崩溃。解决在application.yml里加spring.thymeleaf.cache: false同时IDEA里给静态文件做无损保存。改完模板后在浏览器强刷一次通常就能看到变化。如果还是不行检查IDEA有没有把静态文件编译到target目录有些版本需要手动Rebuild Project。5.5 先查库存再判断照样被并发超借击穿现象模拟两个用户同时借最后一本书两个请求都成功库存变成-1。原因借书逻辑写成“先select stock再if stock0然后update stock stock - 1”。两个请求同时完成select都看到stock1然后都执行update于是超借。解决必须用条件UPDATE也就是把库存判断放进SQL的WHERE条件里直接在update语句里使用stock stock - 1的SQL表达式并带上stock 0条件。我在第四章的borrow方法里写的就是这种方式。如果用了悲观锁SELECT FOR UPDATE也能解决但锁会一直持有到事务结束在高并发场景下容易拉长数据库锁等待。常见做法是优先用乐观条件更新冲突时重试或报错。6. 上线前再走三步接口验证、密码加盐和Web安全基线项目功能做完后离“能交给别人用”还差几步。我习惯在上线前固定一套接口用例把密码改成BCrypt存储再检查一遍Web服务器安全配置。这三件事花的时间不多但能避免后面出大事。6.1 用Postman固化核心接口用例我至少会保留20条Postman请求覆盖登录、图书增删改查、借书、还书、逾期标记。重点不是数量而是每条请求都要有对应的成功和失败断言。例如借书接口要验证库存不足时必须返回业务错误码不能返回500还书后要能查到return_time。这套接口用例以后每次重构都能直接跑等于给项目装了一个后悔药。6.2 把密码从明文换成BCrypt管理员密码一定不能明文入库。Spring Security里带BCryptPasswordEncoder即使不用完整的安全框架也可以单独引入这个工具类。注册时用encoder.encode(password)存密文登录时用encoder.matches(rawPassword, encodedPassword)校验。密文长度会超过32位sys_user表中的password字段建议设成VARCHAR(64)。如果前面已经建表为32记得先改表结构再存密码否则插入密文会被截断导致永远登录不上。6.3 Web服务器安全底线检查Spring Boot内嵌Tomcat默认算比较安全但仍要做几个基础检查。生产环境要关闭Swagger和Actuator的敏感端点设置server.servlet.session.timeout限制登录会话时长。如果不需要文档接口就把springdoc或knife4j依赖从正式包中排除或加访问控制。HTTPS层由反向代理处理应用层至少要开启X-Content-Type-Options、X-Frame-Options这些安全响应头Spring Security或一个简单Filter就能设置。我最早做图书管理系统时只想着把增删改查点通结果演示时两个人同时点借书库存当场变负数现场很尴尬。后来我养成了习惯每一个写接口前都问自己一句这个接口被连续调用两次、被两个用户同时调用会不会出问题。把数据一致性当成功能来做这个项目才真正从课设升到了面试能讲清楚的水平。希望帮到你。本文还有配套的精品资源点击获取