
第一次拿到这门课的第五次作业时我盯着题目看了好一会儿——就一句话“做一个图书管理系统”没有需求文档没有原型图也没有指定技术栈。很多人觉得“作业嘛交上去就完事”但恰恰是这种开放性的东西最容易拉开差距。今天这篇文章我想完整复盘自己做这份第五次作业的过程从需求梳理、技术选型、数据库设计到代码实现、部署排错全部讲透。如果你也在做 Java Web 方向的课程设计或者刚学完 Spring Boot 想找一个小项目练手这份复盘可以直接“抄作业”。这份作业虽然名义上叫“第五次作业”但实际上已经不是一个单纯的语法练习题了它要求你把前面学的 Servlet、JDBC、JSP、MySQL 这些知识点全部串起来完成一个“从数据库到浏览器”的完整闭环。我做完之后最大的感受是作业的价值不在于功能多炫而在于你走完一遍完整的开发流程——先设计再编码最后踩坑排错这个过程才是真正的收获。1. 第五次作业的定位与整体设计思路1.1 课程作业背后的“隐藏考点”前四次作业基本都是单点练习第一次写 HTML 表单第二次写 JDBC 增删改查第三次写 Servlet 接收请求第四次写 JSP 渲染数据。到了第五次作业老师默认你已经掌握了这些零散技能开始要求你把它们组装成一个完整的应用。所以这道题真正的考点不是某个技术点而是“串联能力”。你得自己想清楚页面请求怎么发给后端后端怎么访问数据库查询结果怎么再渲染回页面用户登录状态怎么保持借书和还书时库存和借阅记录怎么保持一致这些问题没有哪个是单独的“难题”但放在一起很容易让第一次做完整项目的人手忙脚乱。我建议拿到作业后别急着写代码第一件事是画一张简单的请求流程图哪怕用纸笔也行。我当时画了三分钟思路立刻就清楚了浏览器发出 HTTP 请求 → Spring Boot 的 Controller 接收 → Service 处理业务逻辑 → Mapper 操作数据库 → 结果返回给 Thymeleaf 模板渲染 → 浏览器展示。整个作业就围绕着这一条链路展开。1.2 需求拆解先列功能清单再动手作业题目只有“图书管理系统”六个字具体做哪些功能需要自己定义。我参考了网上不少课程设计题目结合“系统完整性”和“工作量可控”两个原则最终把需求收敛成六个核心功能用户注册、用户登录、图书列表分页展示、图书新增/编辑/删除、借阅图书、归还图书。这六个功能覆盖了一个 Web 系统最常见的操作类型表单提交、数据列表、删除修改、状态变更、权限控制。再多就没有必要了比如什么图书封面上传、评论功能、排行榜对一次作业来说纯属给自己找麻烦。为了让自己不跑偏我还列了一张“功能验收清单”每做完一项就打勾最后提交前再对照检查一遍。模块功能点验收标准用户模块注册用户名重复时提示错误密码加密存库用户模块登录登录后 Session 保存用户信息未登录不能访问功能页面图书模块分页列表每页显示固定条数展示总页数和当前页图书模块新增/编辑表单校验必填项ISBN 不允许重复图书模块删除删除前确认已借出的书不允许删除借阅模块借书库存大于 0 才能借同时扣减库存、生成借阅记录借阅模块还书更新记录状态回补库存借阅模块我的借阅只显示当前登录用户的借阅记录有了这张表后面开发过程中心里特别有底每完成一项就少一个不确定性。这也是我想强调的拿到模糊需求时先不要纠结技术把“做什么”锁死再想“怎么做”。1.3 技术栈与项目结构怎么定技术栈的选择我纠结了一会儿。老师课上讲的是 Servlet JSP但我之前自学过一点 Spring Boot 的基础知道它比原生 Servlet 省事得多。权衡之后我决定采用 Spring Boot 2.7 Thymeleaf MyBatis MySQL 8.0 Bootstrap 5 的组合。选择 Spring Boot 的核心原因是“少写配置、内嵌 Tomcat”开发时不用再配置一堆 web.xml 和 server.xml启动一个 main 方法就能跑起来。MyBatis 负责数据库操作比纯 JDBC 写出来的代码简洁不少而且 SQL 写在 XML 文件里调整语句不用重新编译。模板引擎选了 Thymeleaf 而不是 JSP因为 Spring Boot 对 Thymeleaf 的支持更自然页面直接就是 HTML看着不膈应。项目结构上我严格按照三层架构来组织controller 包放请求入口service 包放业务逻辑mapper 包放数据库操作entity 包放实体类。config 包放拦截器等配置common 包放 MD5 加密之类的工具类。这样分包的好处是哪一层出问题就直接进哪个包排查不用在几百行代码里大海捞针。2. 技术选型解析为什么选择 Spring Boot Thymeleaf2.1 几种常见方案对比第五次作业这种场景下可选的技术方案其实不少。我把课程作业里最常见的四种方案放在一起对比过各有各的适用场景。方案优点缺点适合场景Servlet JSP贴合课程内容老师挑不出毛病配置繁琐JSP 调试体验差课程要求必须用Spring Boot JSP开发快JSP 熟手上手容易JSP 在 Spring Boot 中支持受限需额外依赖熟悉 JSP 但想省配置Spring Boot Thymeleaf前后端结合自然模板即 HTML模板语法需要重新学本次作业采用Spring Boot Vue 前后端分离贴近企业真实开发需要处理跨域、接口文档、构建打包时间充裕的进阶选择我最后选了第三种。坦白说前后端分离确实更接近生产环境但对课程作业来说相当于把工作量翻倍要额外写接口文档、处理跨域、管理打包构建一个环节出错就卡半天。而 Thymeleaf 这种服务端渲染方案后端直接把数据塞进模板浏览器刷新就能看效果最适合一个人完成的小型系统。2.2 Spring Boot 把“配置地狱”变成了“自动装配”如果用过原生 Spring 写 Web 项目你一定记得那种痛苦web.xml 里配 DispatcherServletSpring 配置文件里配组件扫描还要处理各种 jar 包版本冲突。Spring Boot 最大的贡献是把这个过程变成了“约定大于配置”。比如内嵌的 Tomcat之前部署一个 Servlet 项目要下载 Tomcat、配置 server.xml、把 war 包丢进 webapps 目录。现在 Spring Boot 的 main 方法一执行Tomcat 就在内嵌环境里启动了端口默认 8080改配置只需要在 application.yml 里加一行server.port。MyBatis 的数据源也一样在 yml 里写好数据库连接信息Spring Boot 的自动配置机制会帮你创建好 SqlSessionFactory不用再写一长串 Bean。实际开发中我明显感觉到Spring Boot 把“环境搭建”的时间和心智成本压缩到了极低让我能把精力全部集中在业务代码上。这也是我给所有做作业的人的第一个建议别在环境上死磕能用框架自动搞定的事就别手动配。2.3 Thymeleaf从 JSP 思维切换到模板引擎思维第一次用 Thymeleaf 时我最大的困惑是它到底是前端还是后端的东西理解了之后才明白它可以理解为“带后端逻辑的 HTML”——页面本身是完整的 HTML 标签但使用th:前缀的属性来动态渲染内容。几个最常用的语法我在这里列一下做作业足够了${...}用于输出变量值比如th:text${book.name}就是把书籍名称输出到标签内容里。th:each用于遍历集合类似 JSP 里的c:forEach。th:if做条件判断比如列表为空时显示一行提示。{...}用于拼接上下文路径比如表单提交地址th:action{/user/login}。一开始不太适应这种写法写多了就发现它比 JSP 清爽很多。页面直接用浏览器打开也基本能看到静态效果不会像 JSP 那样一堆标签密密麻麻。3. 数据库设计三张表串起整个业务3.1 表结构设计与建表 SQL数据库设计是整个系统的基础很多同学上来就写代码结果写到最后发现表结构缺字段、关系对不上又推倒重来。我这次先花了一个晚上把表结构定好核心就是三张表用户表、图书表、借阅记录表。用户表存登录信息图书表存书的基本信息和库存借阅记录表存“谁在什么时候借了哪本书、还了没有”。三张表的关系也不复杂用户和借阅记录是一对多图书和借阅记录也是一对多。借阅记录相当于一个中间关系表把用户和图书关联起来。CREATE DATABASE IF NOT EXISTS library_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE library_system; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, salt VARCHAR(16) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, author VARCHAR(100) DEFAULT , publisher VARCHAR(200) DEFAULT , total_count INT NOT NULL DEFAULT 0, remaining_count INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, return_time DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-借出 1-已还, KEY idx_user_id (user_id), KEY idx_book_id (book_id), CONSTRAINT fk_record_user FOREIGN KEY (user_id) REFERENCES user (id), CONSTRAINT fk_record_book FOREIGN KEY (book_id) REFERENCES book (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我把数据库和表都显式指定了utf8mb4字符集这一步特别关键。很多同学的乱码问题根源就是建表时用了默认字符集插入中文后就变成问号。utf8mb4是 UTF-8 的超集能存表情符号更保险。3.2 唯一索引、外键和字段约束表结构里我加了几处容易忽略但很重要的细节。第一user.username和book.isbn都加了UNIQUE唯一约束这样在数据库层面就挡住了重复数据代码里再校验一次双保险。第二book.remaining_count表示剩余库存初始等于total_count每次借书减一、还书加一这个字段是业务核心必须保证非负。第三borrow_record.status用TINYINT类型存 0 或 1而不是字符串“借出”“已还”这样存储更节省空间查询也更快。关于外键这里要说一下。很多在线课程讲数据库设计时都强烈推荐外键但企业开发中其实很多团队会刻意不用物理外键而是靠业务代码来维护数据一致性。原因很简单物理外键会在每次插入、更新时额外做检查高并发场景下影响性能而且后续分库分表时外键完全成为障碍。课程作业这个场景我建议保留外键因为数据量小、并发低更重要的是老师看到完整的外键关系会觉得你的设计更规范评分时也有话可说。3.3 一个容易被忽略的问题密码不能明文存用户注册登录功能是很多人的第一个“完整”功能但也是最容易出安全笑话的地方。有些同学的数据库表里password 字段直接存的就是明文密码注册的时候怎么填数据库里就是什么。这在课程作业里可能没人追究但它养成的坏习惯很危险。我在这份作业里做了两层处理第一层密码使用“MD5 盐”的方式存储。算法也不复杂就是生成一个 8 位随机字符串作为盐把密码和盐拼接后再做 MD5 哈希最后把哈希值和盐一起存到数据库。第二层数据库中的 password 字段长度设置的是 128不为将来更换哈希算法留坑。校验登录时用同样的盐把用户输入的密码做一次哈希和库里存的比对一致才算通过。public class MD5Util { public static String md5(String source) { try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(source.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(MD5 加密失败, e); } } }如果以后想更规范可以直接用 BCrypt 这类专门为密码设计的哈希算法自带盐值且计算速度慢能有效抵抗暴力破解。但课程作业中用 MD5盐已经能体现你的安全意识了写进报告里也是一个可圈可点的亮点。4. 核心功能实现与代码解析4.1 项目目录结构与分层约定很多同学写作业时喜欢把代码全堆在 Controller 里面数据库查询也写在 Controller 里页面跳转也写在 Controller 里。两三百行代码塞一个类看着能跑但维护起来特别痛苦。我这次坚持了标准的三层结构每个类只负责一件事。src/main/java/com/example/library ├── LibraryApplication.java ├── common │ └── MD5Util.java ├── config │ └── LoginInterceptorConfig.java ├── controller │ ├── UserController.java │ ├── BookController.java │ └── BorrowController.java ├── entity │ ├── User.java │ ├── Book.java │ └── BorrowRecord.java ├── mapper │ ├── UserMapper.java │ ├── BookMapper.java │ └── BorrowRecordMapper.java ├── interceptor │ └── LoginInterceptor.java └── service ├── UserService.java ├── UserServiceImpl.java ├── BookService.java ├── BookServiceImpl.java ├── BorrowService.java └── BorrowServiceImpl.javaController 只接收请求参数、调用 Service、返回页面视图Service 处理业务规则比如借书时校验库存、扣减库存、写入记录这些一定要放在 Service 层Mapper 只负责和数据库打交道。这样做的好处非常直接——如果借书功能报错我都不用看别的代码直接去 BorrowServiceImpl 里面找问题就够了。4.2 注册登录Session 会话与登录校验注册登录是系统的入口。我先实现了 UserControllerController RequestMapping(/user) public class UserController { Autowired private UserService userService; GetMapping(/register) public String registerPage() { return register; } PostMapping(/register) public String register(User user, Model model) { String error userService.register(user); if (error ! null) { model.addAttribute(error, error); return register; } return redirect:/login; } GetMapping(/login) public String loginPage() { return login; } PostMapping(/login) public String login(String username, String password, HttpSession session, Model model) { User loginUser userService.login(username, password); if (loginUser null) { model.addAttribute(error, 用户名或密码错误); return login; } session.setAttribute(loginUser, loginUser); return redirect:/book/list; } GetMapping(/logout) public String logout(HttpSession session) { session.invalidate(); return redirect:/login; } }Service 层的注册逻辑要注意两点一是查重用户名已存在就返回错误信息而不继续插入二是密码加密必须在 Service 层做Controller 层不应该关心加密细节。Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public String register(User user) { if (userMapper.findByUsername(user.getUsername()) ! null) { return 用户名已存在; } String salt UUID.randomUUID().toString().substring(0, 8); user.setSalt(salt); user.setPassword(MD5Util.md5(user.getPassword() salt)); userMapper.insert(user); return null; } Override public User login(String username, String password) { User user userMapper.findByUsername(username); if (user ! null user.getPassword().equals(MD5Util.md5(password user.getSalt()))) { return user; } return null; } }登录成功之后session.setAttribute(loginUser, loginUser)这一行是整个登录功能的关键。Session 对象保存在服务器端每个浏览器对应一个独立的 Session登录之后把用户对象塞进去后续的请求就能通过session.getAttribute(loginUser)判断当前是谁在操作。但光是存 Session 还不够还要防止未登录用户直接访问功能页面。这就是拦截器要做的事。我写了一个LoginInterceptor在请求到达 Controller 之前检查 Sessionpublic class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(/login); return false; } return true; } }然后再写配置类把拦截器注册上同时放行登录、注册相关的路径和静态资源Configuration public class LoginInterceptorConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /user/login, /user/register, /css/**, /js/**); } }有了这层拦截器整个系统才算真正“封闭”起来。没登录的用户即使手动在地址栏输入/book/list也会被强制弹回登录页。4.3 图书管理分页查询与增删改图书列表是系统的核心页面数据量一多就一定要分页。作业场景不推荐引入 PageHelper 这种插件手写一个简单的分页逻辑反而更能展示基本功。我的思路是前端传入两个参数pageNum和pageSize默认 pageNum1、pageSize5。后端先查总数count再根据(pageNum - 1) * pageSize计算偏移量用LIMIT offset, pageSize查出当前页的数据。然后把总页数、当前页、数据列表一起封装到一个 PageResult 对象里返回给前端。public class PageResultT { private ListT list; private int pageNum; private int pageSize; private long total; private int totalPages; public PageResult(ListT list, long total, int pageNum, int pageSize) { this.list list; this.total total; this.pageNum pageNum; this.pageSize pageSize; this.totalPages (int) Math.ceil((double) total / pageSize); } }BookController 里只负责接收参数、调用 Service、把结果放进 ModelController RequestMapping(/book) public class BookController { Autowired private BookService bookService; GetMapping(/list) public String list(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 5) int pageSize, Model model) { PageResultBook page bookService.findPage(pageNum, pageSize); model.addAttribute(page, page); return book_list; } GetMapping(/add) public String addPage() { return book_add; } PostMapping(/add) public String add(Book book, Model model) { String error bookService.addBook(book); if (error ! null) { model.addAttribute(error, error); return book_add; } return redirect:/book/list; } GetMapping(/delete/{id}) public String delete(PathVariable Integer id) { bookService.deleteBook(id); return redirect:/book/list; } }MyBatis 的 SQL 我写在BookMapper.xml里分页和查询条件写在同一个 SQL 中select idfindPage resultTypecom.example.library.entity.Book SELECT id, isbn, name, author, publisher, total_count, remaining_count, create_time FROM book ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select select idcount resultTypelong SELECT COUNT(*) FROM book /select这里有一个新手容易踩的坑offset必须在pageSize之前计算好不能直接在 SQL 里写LIMIT (#{pageNum} - 1) * #{pageSize}, #{pageSize}因为 MyBatis 的#{}占位符只适合传值不适合传 SQL 片段预编译时#{}会被当成一个参数占位符不能包含表达式计算。正确的做法是在 Java 里先把 offset 算出来再传进去。4.4 借阅归还事务操作的正确姿势借阅功能是整个系统里最考验逻辑的部分。借一本书表面上只是往borrow_record表插一条记录但实际上必须同时完成三个操作检查剩余库存是否大于 0、扣减book.remaining_count、插入借阅记录。这三个操作任何一个失败都不能让另外两个生效否则就会出现“记录有了但库存没减”或者“库存减了但找不到借阅人”的数据不一致问题。处理这类问题靠的是数据库事务。Spring Boot 中使用Transactional注解就能搞定加在 Service 方法上方法内任一步抛异常整个事务都会回滚数据库回到调用前的状态。Service public class BorrowServiceImpl implements BorrowService { Autowired private BookMapper bookMapper; Autowired private BorrowRecordMapper borrowRecordMapper; Override Transactional(rollbackFor Exception.class) public void borrowBook(Integer userId, Integer bookId) { Book book bookMapper.findById(bookId); if (book null) { throw new RuntimeException(图书不存在); } if (book.getRemainingCount() 0) { throw new RuntimeException(库存不足); } bookMapper.decreaseRemaining(bookId); BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setStatus(0); borrowRecordMapper.insert(record); } Override Transactional(rollbackFor Exception.class) public void returnBook(Integer recordId) { BorrowRecord record borrowRecordMapper.findById(recordId); if (record null || record.getStatus() ! 0) { throw new RuntimeException(借阅记录不存在或已归还); } record.setStatus(1); record.setReturnTime(new Date()); borrowRecordMapper.update(record); bookMapper.increaseRemaining(record.getBookId()); } }需要注意Transactional的默认行为只有遇到运行时异常RuntimeException才会回滚如果方法抛出的是受检异常比如 IOException默认是不会回滚的。所以最稳妥的写法是加上rollbackFor Exception.class明确告诉 Spring“只要是异常就回滚”。还有一点事务只对通过 Spring 容器调用的方法生效。如果你在同一个类里一个方法直接调用另一个带Transactional的方法事务会失效因为这是内部调用没有走 Spring 的代理。我当时就是因为没注意这个排查了整整一个下午。解决办法是把事务方法放在单独的 Service 类里或者通过注入自身来调用。5. 部署运行与前后端联调5.1 环境准备与 application.yml 配置开发环境我用的组合是JDK 8、Maven 3.8、IDEA 2022、MySQL 8.0。为什么特意强调版本因为 Spring Boot 2.7 对 JDK 8 的支持是最好的而 MySQL 8 和 MySQL 5.7 的驱动类名不一样很多同学照搬网上的旧配置报错后完全找不到头绪。在src/main/resources/application.yml里我做的配置是这样的server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/library_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entity这里三个配置项都很关键。第一characterEncodingutf8保证中文传输不乱码第二serverTimezoneAsia/Shanghai解决 MySQL 8 的时区报错第三thymeleaf.cachefalse关闭模板缓存这样修改页面后刷新浏览器就能看到效果不用重启应用。5.2 前端页面与后端接口的“握手”页面我用 Bootstrap 5 搭了个简单的后台风格没有花太多精力在视觉上毕竟作业考察的主要是功能完整性。真正需要搞清楚的是前端表单和后端接口之间怎么对应。Thymeleaf 模板里表单提交的地址用th:action指定form th:action{/user/login} methodpost classneeds-validation div classmb-3 label classform-label用户名/label input typetext nameusername classform-control required /div div classmb-3 label classform-label密码/label input typepassword namepassword classform-control required /div button typesubmit classbtn btn-primary w-100登录/button /form后端 Controller 方法的参数String username, String password会自动和表单里nameusername、namepassword的输入框对应上这是 Spring MVC 的自动绑定机制。如果表单里是一个对象比如注册时的 User只要字段名一致Spring 也会自动封装成对象不用手动一个个取。列表页展示数据时用 Thymeleaf 的th:each遍历后端传入的集合table classtable table-striped thead tr thID/th thISBN/th th书名/th th作者/th th出版社/th th剩余库存/th th操作/th /tr /thead tbody tr th:eachbook : ${page.list} td th:text${book.id}1/td td th:text${book.isbn}ISBN/td td th:text${book.name}书名/td td th:text${book.author}作者/td td th:text${book.publisher}出版社/td td th:text${book.remainingCount}库存/td td a classbtn btn-sm btn-danger th:href{/book/delete/{id}(id${book.id})} onclickreturn confirm(确定删除吗)删除/a /td /tr /tbody /table注意到删除链接里的写法{/book/delete/{id}(id${book.id})}这是 Thymeleaf 的路径变量语法最终会生成类似/book/delete/3的 URL对应后端GetMapping(/delete/{id})接收的路径参数。这个语法很容易写错最常见的问题是把括号写在花括号外面导致路径生成不对。5.3 启动、验证与整体效果全部代码写完后我并没有急着启动项目而是先检查了一遍 Maven 依赖是否有报红然后确认 MySQL 服务已经启动、数据库已经创建、表已经建好。确认无误后启动LibraryApplication的 main 方法控制台出现“Started LibraryApplication”日志说明启动成功。接下来在浏览器里输入http://localhost:8080/login按照功能验收清单一项项测试注册一个测试用户、登录跳转到图书列表、新增两本测试书籍、尝试借一本库存为 0 的书看是否被拦截、正常借书后再看库存是否减一、归还后库存是否回补。全部通过后我只花了半小时写实验报告因为该验证的都已经验证过了报告里的测试结果都是真实数据。6. 常见问题与排查技巧实录6.1 高频报错速查表做完这个项目我整理了一份报错速查表基本覆盖了做同类作业时最容易遇到的问题。报错提示原因解决方案The server time zone value йʱ is unrecognizedMySQL 8 时区问题JDBC URL 加serverTimezoneAsia/ShanghaiAccess denied for user rootlocalhost数据库密码不对检查 application.yml 的 username/password页面中文乱码数据库或连接字符集不是 utf8建库用 utf8mb4URL 加characterEncodingutf8Port 8080 was already in use8080 端口被占用命令行netstat -ano查 pid关掉进程或换端口Failed to bind properties under spring.datasource配置项拼写错误检查 yml 缩进确保spring.datasource层级正确Error resolving template [book_list]Thymeleaf 模板不存在或路径不对检查 templates 目录下是否有 book_list.htmlnested exception is java.lang.NullPointerException调用了为 null 的对象排查注入是否成功、查询结果是否为 nullInvalid bound statement (not found): mapper.UserMapper.findByUsernameMyBatis 映射文件没被扫描到检查 mapper-locations 是否正确、XML 命名空间是否匹配6.2 三个印象深刻的 Bug 排查过程第一个是数据库连接时报时区错误。当时控制台输出的一句话里中文全变成了乱码一看就是编码问题。后来查了才知道MySQL 8 默认使用 UTC 时区而本地是东八区必须在 JDBC URL 后面加上serverTimezoneAsia/Shanghai。顺手也把characterEncodingutf8加上一并解决中文乱码的隐患。第二个是登录后页面一刷新就回到登录页。我刚开始以为是拦截器配置错了后来在浏览器开发者工具里看到请求头中 Session 的 Cookie 没有正常携带。排查下来发现是浏览器 Cookie 的有效期设置问题Session 默认在浏览器关闭后就失效。解决方法是把 Session 的过期时间调长在配置里加一行server: servlet: session: timeout: 30m第三个是借书后库存没有变化。代码逻辑怎么看都没问题最后在 Mapper.xml 里找到了原因decreaseRemaining的 update 语句我写反了条件把remaining_count remaining_count - 1写成了remaining_count #{remainingCount} - 1而传入的remainingCount是旧值等于没减。这类问题最坑的地方在于不报错只是结果不对靠肉眼很难看出来。后来我开了 MyBatis 的 SQL 日志在 yml 里加了一段配置logging: level: com.example.library.mapper: debug控制台把每次执行的 SQL 原样打印出来一眼就看到了问题。6.3 给交作业前的一个小建议所有功能测试通过后我把数据库导出了一份 SQL 文件连同项目源码一起提交。这里建议大家在交作业前做一件事把项目打包成一个完整的说明文档里面写清楚“怎么搭建数据库”“怎么启动项目”“测试账号是什么”。别小看这份说明很多老师评分时就是照着这个文档来跑你的项目跑不通的话代码写得再好也白搭。另外如果你想让作业更有亮点可以在完成后顺手加上一个“图书模糊搜索”功能用LIKE关键字加一个输入框就能实现工作量不大但答辩时能讲的内容会多不少。我这次就在列表页加了搜索框老师当场让我演示了一下印象分直接拉满。做这份第五次作业我最大的体会是课程作业和企业项目虽然规模天差地别但开发的思考方式是相通的——先想清楚做什么再决定怎么做最后动手做。中间一定会遇到各种奇怪的报错别急着百度先看日志、看数据、看配置大部分问题自己都能找到答案。这种“自己动手解决问题”的能力才是这份作业真正想考核的东西。