简介基于JavaWeb构建的图书馆管理系统项目源码适合Java Web初学者、在校学生以及毕业设计开发者参考与二次学习。系统覆盖图书借阅、归还、查询、用户管理等核心业务模块采用Servlet与JSP处理请求和页面展示结合JDBC完成数据库交互整体遵循MVC分层架构便于理解Web应用从请求处理到数据持久化的完整流程。资源包内共199个文件包含64个Java源码、51个JSP页面、32个class编译文件还附带SQL数据库脚本、项目配置文件与使用说明文档压缩包仅766KB结构清晰导入开发环境即可查看运行效果。已有5168人学习浏览。通过该源码可以掌握图书信息增删改查、借阅记录管理、用户权限控制以及前端页面交互等关键实现同时学习数据库表设计、环境配置与项目部署的相关知识是一份贴近实际教学场景、可直接对照练习的完整JavaWeb项目案例。1. 先看结论一台浏览器就能管完整个图书馆的借还台账某高校社团的资料室书从 200 本涨到 2000 本之后Excel 台账彻底失控谁借了哪本书、借了多久、该不该催还全靠管理员记忆。这套基于 JavaWeb 的图书馆管理系统就是为了终结这种混乱而存在的——管理员在浏览器里就能完成图书录入、读者建档、借书还书、条件搜索每本书从入库到出库都有记录可查。技术路线是经典的 Servlet JSP MySQL 三层结构没有 Spring Boot、没有 MyBatis正因为如此请求从浏览器到数据库每一步都暴露在源码里。适合两类人正在做课程设计或毕业设计的学生需要一套能跑、能讲原理的完整案例以及刚学完 Servlet/JSP、想看真实项目怎么组织代码的初级开发者。2. 架构设计三层结构、工程目录与一次请求的完整链路2.1 技术选型为什么是 Servlet JSP而不是 Spring Boot先给结论这套系统选 Servlet JSP不是因为它做不了新项目而是因为它的目标场景对可理解性的要求高于对开发效率的要求。课程设计、毕设答辩、或者刚入门的开发者都需要在一个请求的完整链路里看到“浏览器发了什么 → Tomcat 收到后交给谁 → Servlet 怎么取参数 → DAO 怎么查库 → 结果怎么渲染回页面”的全过程。Spring Boot 把内嵌容器、自动配置、约定大于配置这些都压成黑匣子跑起来很快但想看清底层就得多翻一层源码。而在这里一个登录请求的起点就是 web.xml 里的一个servlet-mapping标签。下面这张表把两种方案的差异放在一起看对比维度Servlet JSPSpring Boot依赖管理jar 手动放 lib 或用 Maven起步依赖自动传递省心启动方式外置 Tomcat 部署 war内嵌容器一键启动请求可见性从 web.xml 到 Servlet 全可见自动配置把细节藏起来调试定位出错直接定位 Servlet/DAO需要层层剥框架栈适用场景教学案例、课程设计、入门项目快速开发、生产系统web.xml 中最核心的配置片段如下web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 filter filter-nameencoding/filter-name filter-classcom.library.filter.EncodingFilter/filter-class /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namelogin/servlet-name servlet-classcom.library.servlet.LoginServlet/servlet-class init-param param-namevalidUser/param-name param-valueadmin/param-value /init-param /servlet servlet-mapping servlet-namelogin/servlet-name url-pattern/login/url-pattern /servlet-mapping /web-app这段配置解释了两件事。第一filter的url-pattern写/*意思是所有请求先进EncodingFilter统一设置字符集避免每个 Servlet 都重复写编码处理。第二/login这个路径被绑定到LoginServlet等用户在登录页点击提交Tomcat 解析出请求路径为/login就会找到这个 servlet 并调用它的doPost方法。参数说明init-param是给 servlet 提供的初始化参数代码里可以通过getInitParameter(validUser)读取url-pattern建议直接用全路径匹配比*.do这种后缀写法更直白减少新手理解成本。2.2 工程目录逐层拆解拿到源码第一件事不是双击打开 IDE而是先看目录。这类源码的依赖管理通常分两种形态Maven 工程用 pom.xml 管理 jar非 Maven 工程会把 jar 直接放在 WEB-INF/lib 下。下面按 Maven 版给出最常见的目录结构library-web/ ├── pom.xml # Maven 依赖声明 ├── src/main/java/com/library/ │ ├── servlet/ # 控制器层接收请求、调服务、跳转页面 │ │ ├── LoginServlet.java │ │ ├── BookServlet.java # 图书增删改查、分页 │ │ ├── ReaderServlet.java # 读者维护 │ │ └── BorrowServlet.java # 借书、还书、续借 │ ├── service/ # 业务层处理借阅规则、事务边界 │ ├── dao/ # 数据访问层只写 JDBC │ ├── entity/ # 实体类对应核心数据表 │ │ ├── Book.java │ │ ├── Reader.java │ │ ├── Admin.java │ │ └── BorrowRecord.java │ ├── filter/ │ │ ├── EncodingFilter.java # 统一字符集 │ │ └── LoginFilter.java # 未登录拦截 │ └── util/ │ ├── DBUtil.java # 获取数据库连接 │ └── PageBean.java # 分页参数封装 ├── src/main/resources/ │ └── db.properties # 数据库连接配置 └── src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── lib/ # 非 Maven 版的 jar 包目录 ├── css/ ├── js/ ├── login.jsp # 登录页 ├── index.jsp # 首页框架 ├── book_list.jsp # 图书列表 分页 └── borrow_list.jsp # 借阅记录每层职责在源码里分得很清楚servlet 包只负责 HTTP 层面的参数接收和页面跳转不直接写 SQLservice 包处理业务规则比如借书前检查库存、还书时更新记录dao 包只做 JDBC 的增删改查返回实体对象util 包提供连接管理和分页参数。JSP 页面统一放在 webapp 目录下其中 WEB-INF 是受保护目录浏览器不能直接访问里面的资源只能通过 Servlet 转发进入。这里有一个区分点值得新手注意JSP 页面分两种打开方式。一种是index.jsp这种直接放在 webapp 根目录下的浏览器可以绕过 Servlet 直接访问另一种是放在 WEB-INF 下的视图页只能通过request.getRequestDispatcher(/WEB-INF/xxx.jsp).forward()转发。这套系统把登录页放在根目录把受保护页面交给 Servlet 转发算是兼顾了简单和安全。2.3 一次登录请求的完整流转链路理解了目录结构就能画出一条完整的请求链。把 login.jsp 看成起点用户在输入框填好用户名密码点击提交表单以 POST 方式发到/login。Tomcat 启动时已经读入 web.xml知道这个路径对应 LoginServlet于是调用它的doPost方法。在进入 doPost 之前请求会先经过EncodingFilterpublic class EncodingFilter implements Filter { private String encoding UTF-8; public void init(FilterConfig cfg) { String e cfg.getInitParameter(encoding); if (e ! null !e.isEmpty()) { this.encoding e; } } public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(encoding); resp.setCharacterEncoding(encoding); resp.setContentType(text/html;charset encoding); chain.doFilter(req, resp); } }这段 Filter 做的事很单纯给请求和响应统一设置 UTF-8 编码然后放行。chain.doFilter(req, resp)这行是关键它把请求继续往后传直到进入 LoginServlet 或 JSP。参数说明init方法里的FilterConfig可以读取 web.xml 中配置的encoding初始参数setContentType设置了响应头浏览器拿到 HTML 时会按 UTF-8 解码中文表单数据在这里处理完后面 Servlet 里取参数就不会乱码。整条链路走完是这样几步用户在 login.jsp 输入用户名密码form 以 POST 提交到/login。Tomcat 读 web.xml 的servlet-mapping把请求交给 LoginServlet 的doPost。doPost里调用 service 层的 login 方法传入用户名和密码。service 层再调 dao 层执行SELECT * FROM admin WHERE username? AND password?。dao 返回 Admin 对象doPost 把它放进 session然后sendRedirect到 index.jsp。用户后续访问 book_list.jsp 等页面时LoginFilter 会检查 session 里有没有 admin没有就拦回 login.jsp。这套链路把“浏览器 → 容器 → Servlet → Service → DAO → 数据库”每个环节都露在明面上排错时顺着走一遍很快能找到问题出在哪一层。3. 核心模块实战登录鉴权、借阅库存与分页检索3.1 管理员登录与 Session 保持登录模块是整个系统的门卫代码量不大但涉及 Servlet 常用的几个 API。先看 LoginServlet 的核心逻辑public class LoginServlet extends HttpServlet { private AdminService adminService new AdminService(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); Admin admin adminService.login(username, password); if (admin ! null) { HttpSession session request.getSession(); session.setAttribute(admin, admin); session.setMaxInactiveInterval(30 * 60); response.sendRedirect(request.getContextPath() /index.jsp); } else { request.setAttribute(error, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } }逻辑说明request.getParameter从表单里取出两个字段交给 service 层做校验。service 返回的 Admin 对象不为空说明账号密码对上了就把对象放进 session并设置 30 分钟超时为空则把错误提示放进 request 域转发回登录页显示。这里要区分转发和重定向forward是服务端内部跳转浏览器 URL 不变request 域里的值还能用sendRedirect是浏览器重新发起一次请求URL 会变所以适合登录成功跳主页的场景。参数说明session.setMaxInactiveInterval(30 * 60)单位是秒表示用户 30 分钟不操作就需要重新登录request.getContextPath()返回应用上下文路径比如/library用它可以避免写死路径。session 为什么能充当登录凭证因为 Tomcat 会给每个浏览器会话发一个 JSESSIONID 的 Cookie服务端用这个 ID 对应一份独立的 session 数据。管理员登录后把 admin 对象塞进 session之后每个请求 Tomcat 都能通过 JSESSIONID 找回这份 session。配合一个 LoginFilter就能拦截未登录的访问public class LoginFilter implements Filter { public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) servletRequest; HttpServletResponse resp (HttpServletResponse) servletResponse; HttpSession session req.getSession(false); if (session null || session.getAttribute(admin) null) { resp.sendRedirect(req.getContextPath() /login.jsp); } else { chain.doFilter(servletRequest, servletResponse); } } }这段代码用getSession(false)获取已有 session注意参数是 false如果没有会话不会新创建一个而是返回 null。这里常见误用是写成getSession()会导致未登录的用户也被强制建了 session拦截逻辑就失效了。Filter 配置在 web.xml 的url-pattern/book_list.jsp/url-pattern这类受保护资源上或者直接对/*配合白名单判断都能起到门卫作用。3.2 图书借阅库存校验与事务控制借书是这个系统里最值得细看的业务它牵涉两张表的联动往借阅记录表插一条数据、把图书表库存减一。这两步必须同时成功或同时失败否则会出现“记录插进去了但库存没减”或“库存减了记录却丢了”。核心 DAO 方法长这样public boolean borrowBook(Connection conn, int bookId, int readerId) throws Exception { // 先锁定图书行防止两个管理员同时借出最后一本 PreparedStatement ps1 conn.prepareStatement( SELECT stock FROM book WHERE id ? FOR UPDATE); ps1.setInt(1, bookId); ResultSet rs ps1.executeQuery(); if (!rs.next()) return false; int stock rs.getInt(stock); if (stock 0) return false; // 写入借阅记录默认借期 30 天 String sql INSERT INTO borrow_record (book_id, reader_id, borrow_date, due_date) VALUES (?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY)); PreparedStatement ps2 conn.prepareStatement(sql); ps2.setInt(1, bookId); ps2.setInt(2, readerId); ps2.executeUpdate(); // 库存减一带条件兜底 PreparedStatement ps3 conn.prepareStatement( UPDATE book SET stock stock - 1 WHERE id ? AND stock 0); ps3.setInt(1, bookId); return ps3.executeUpdate() 1; }逻辑说明第一步用SELECT ... FOR UPDATE给这行记录加锁直到当前事务结束期间其他事务修改会被阻塞。第二步插入借阅记录把当前时间和 30 天后的时间分别写进borrow_date和due_date。第三步更新库存WHERE stock 0是第二道防线就算第一步的锁没挡住这一步也会拒绝把库存减成负数。参数说明DATE_ADD(NOW(), INTERVAL 30 DAY)是 MySQL 专用语法表示在当前时间上加 30 天FOR UPDATE只在事务内生效单独执行这条 SQL 不会锁行需要配合事务使用。调用这个 DAO 时事务边界放在 service 层控制常用骨架如下public boolean borrow(int bookId, int readerId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); boolean ok borrowDao.borrowBook(conn, bookId, readerId); if (ok) { conn.commit(); } else { conn.rollback(); } return ok; } catch (Exception e) { try { if (conn ! null) conn.rollback(); } catch (Exception ignored) {} return false; } finally { try { if (conn ! null) conn.setAutoCommit(true); } catch (Exception ignored) {} DBUtil.close(conn); } }setAutoCommit(false)是事务的开始告诉 JDBC 不要每条 SQL 执行完就自动提交。之后所有操作要么commit()一次性落盘要么rollback()全部撤销。注意 finally 里把 autoCommit 恢复为 true是因为连接如果被连接池复用这个状态会残留给下一次使用。这里的事务代码是这种老项目的标配写法理解之后再看 Spring 的Transactional会豁然开朗。3.3 图书检索与分页图书列表不可能一次性把几千条记录全查出来分页是必须的。BookDao 里的分页查询方法如下public ListBook queryByPage(String keyword, int currentPage, int pageSize) { ListBook list new ArrayList(); String like % (keyword null ? : keyword) %; String sql SELECT * FROM book WHERE name LIKE ? OR author LIKE ? LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, like); ps.setString(2, like); ps.setInt(3, (currentPage - 1) * pageSize); ps.setInt(4, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book book new Book(); book.setId(rs.getInt(id)); book.setName(rs.getString(name)); book.setAuthor(rs.getString(author)); book.setStock(rs.getInt(stock)); list.add(book); } } } catch (Exception e) { e.printStackTrace(); } return list; }参数说明keyword支持按书名或作者模糊匹配like变量两侧拼上%这样“网络”能匹配“计算机网络”、“网络工程”等包含关键词的记录。分页的核心在LIMIT ?, ?第一参数是偏移量(currentPage - 1) * pageSize第二参数是每页条数。页码从 1 开始第一页查到第 0 条开始的 10 条第二页从第 10 条开始依此类推。这里新手容易算错偏移量记住公式跳过的条数 (当前页码 - 1) × 每页条数。分页还需要另一个查询来算总页数SELECT COUNT(*) FROM book WHERE name LIKE ? OR author LIKE ?拿到总数后总页数 (总数 pageSize - 1) / pageSize。这个实现把当前页码、每页条数、总页数封装在 PageBean 里前端 JSP 的“上一页/下一页/第几页”按钮都从 PageBean 取数据逻辑集中改起来顺手。3.4 还书流程恢复库存与记录归还时间还书是借阅的逆操作核心也是两步把借阅记录的归还时间写上把库存加回来。关键点在于定位记录时要按借阅记录 ID而不能靠书名加读者猜public boolean returnBook(int recordId) { String sql1 UPDATE borrow_record SET return_date NOW() WHERE id ? AND return_date IS NULL; String sql2 UPDATE book SET stock stock 1 WHERE id (SELECT book_id FROM borrow_record WHERE id ?); // 在同一事务中执行 sql1、sql2都成功才 commit }WHERE return_date IS NULL这个条件是保护防止同一条记录被重复归还第二次执行时影响行数为 0事务可以回滚。第二条 SQL 用了子查询先从借阅记录里查出对应的 book_id再把这本书的库存加一。这里有个细节SELECT子查询读到的记录和UPDATE更新的是同一本书但如果没有事务包裹两句话之间可能有其他操作插进来所以这一步必须和标记归还时间放在同一个事务里。4. 本地跑起来数据库初始化、连接配置与 Tomcat 部署4.1 前置环境清单开始运行之前先把环境对齐。下表是这套源码最常见的运行组合照着配能省掉很多玄学问题环境推荐版本说明JDK1.8 或 11老项目按 JDK8 编写11 兼容没问题Tomcat8.5 / 9.0较稳先别上 Tomcat 10MySQL5.7 / 8.05.7 最省事8.0 需处理时区和驱动IDEIntelliJ IDEAUltimate 可直接集成 TomcatCommunity 可用插件Maven3.6 及以上仅 Maven 版需要Tomcat 10是目前改动最大的版本包名从javax.servlet.*换成了jakarta.servlet.*这套源码里的import javax.servlet.http.HttpServlet在 Tomcat 10 下会直接编译失败。如果只有 Tomcat 10 可用两条路要么下兼容包tomcat9-juli这类适配方案要么直接把源码里的 import 批量替换成 jakarta但改动面不小新手不建议第一条就跑这个。4.2 初始化数据库与连接配置数据库脚本一般在源码目录的document/或sql/下核心表一共四张。结构大致如下CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ); CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, author VARCHAR(50), isbn VARCHAR(20), stock INT NOT NULL DEFAULT 0 ); CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) ); CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATETIME, due_date DATETIME, return_date DATETIME ); INSERT INTO admin (username, password) VALUES (admin, 123456);字段说明book.stock是可借库存数刚录入一本书时设为总册数borrow_record里return_date为空表示这本书还在读者手上不为空表示已归还。初始管理员账号密码是admin/123456这是这类源码最常见的默认配置项目里一般会写在海报或 README 上。连接配置放在src/main/resources/db.properties里jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456这里最常翻车的就是serverTimezoneAsia/Shanghai。MySQL 8 的驱动默认要求客户端提供时区缺了它连接时直接报错。useUnicodetruecharacterEncodingUTF-8保证存入的中文不是乱码。驱动类名也要对应版本MySQL 5.7 用com.mysql.jdbc.DriverMySQL 8 用com.mysql.cj.jdbc.Driver写错就抛 ClassNotFoundException。DBUtil 的常见写法是类加载时读取 propertiespublic class DBUtil { private static String url; private static String user; private static String password; static { try { Properties props new Properties(); props.load(DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)); url props.getProperty(jdbc.url); user props.getProperty(jdbc.username); password props.getProperty(jdbc.password); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws Exception { Class.forName(com.mysql.cj.jdbc.Driver); return DriverManager.getConnection(url, user, password); } }说明静态代码块在类首次被加载时执行读取配置一次之后所有连接都复用这三个变量。Class.forName这行显式加载驱动类作用是让 DriverManager 注册这个驱动。参数说明getResourceAsStream的参数是 classpath 下的路径db.properties放在 resources 根目录时写文件名即可如果报 null多半是文件没进 classpath检查 Maven 的 resources 配置或目录位置。4.3 部署到 Tomcat 并启动环境齐了部署方式有两条路。用 IDEA 跑本地 Tomcat 是最直观的IDEA 打开项目等待 Maven 下载完依赖。如果用的是非 Maven 版先检查 WEB-INF/lib 下有没有 mysql-connector-java.jar。打开 Run/Debug Configurations新增 Tomcat Server Local点 Configuration 指定本地 Tomcat 路径。切到 Deployment 标签点加号选 Artifact选择 war exploded 格式。Application context 填/library保存后启动。浏览器访问http://localhost:8080/library/会自动跳登录页用admin/123456进入。第二种方式更接近生产在项目根目录执行mvn package得到library.war把这个文件复制到 Tomcat 的webapps/目录启动 Tomcat 会自动解压部署。之后同样访问上面的地址。这种方式的优点是脱离 IDE 也能跑适合把项目交给别人演示时打包发送。启动成功后登录页能看到图书列表入口。如果页面能打开但登录报数据库相关的错优先检查 4.2 里的 db.properties 三项参数url 时区、账号、密码。如果端口被占用启动会直接失败处理方式放在下一章。5. 避坑手记运行期 5 个高频问题与排查思路这一节把运行这套源码时最容易踩的五个坑列出来都是实际跑项目时的血泪经验每条按“现象 → 原因 → 解决”展开。第一个坑页面中文乱码。现象登录后图书列表里书名显示成问号或提交中文作者名字后查询不到。原因拆两层请求参数经过 Filter 时没设 UTF-8Tomcat 拿到的是 ISO-8859-1 字节流响应页面的 contentType 没指定 charset浏览器按本地默认编码解析。解决先确认 web.xml 里 Filter 的url-pattern是/*再检查 JSP 页面头部有% page contentTypetext/html;charsetUTF-8 %。两处都对上乱码基本消失。如果还乱那就是数据库连接 url 缺characterEncodingUTF-8这属于 4.2 的配置问题。第二个坑Tomcat 启动报端口占用。现象启动日志出现Port 8080 was already in useTomcat 起不来。原因本机已经有一个 Tomcat 实例在跑或者别的程序占用 8080。解决Windows 命令行执行netstat -ano | findstr 8080找到占用进程的 PID再用taskkill /F /PID 进程号结束它或者干脆在 IDEA 的 Tomcat 配置里把 HTTP port 改成 8081省事且不影响别人。只要改端口访问地址跟着变就行。第三个坑java.lang.ClassNotFoundException: com.mysql.jdbc.Driver。现象每次操作数据库都抛这个异常页面显示 500。原因驱动 jar 没进 classpath。Maven 工程常见是 pom.xml 里没写 mysql-connector-java 依赖或写了但没刷新非 Maven 工程常见是 jar 没放在 WEB-INF/lib 下。解决Maven 版检查 pom 依赖并点刷新非 Maven 版去 Maven 仓库下载mysql-connector-java-8.0.x.jar放进 WEB-INF/lib。顺带确认 db.properties 里驱动类名和 MySQL 版本对应8.0 用com.mysql.cj.jdbc.Driver。第四个坑数据库连接报时区或 SSL 异常。现象启动后访问登录接口控制台出现The server time zone value ???ú±ê׼ʱ¼ä is unrecognized或者SSLHandshakeException: No appropriate protocol。原因MySQL 8 默认驱动要求显式指定时区同时默认开启 SSL 校验老证书会握手失败。解决数据库连接 url 上加serverTimezoneAsia/ShanghaiuseSSLfalse。这两段参数在 4.2 的配置里已经包含照抄即可但如果你是拿别人现成的 db.properties一定要检查这两项在不在。第五个坑并发借阅导致库存变负数。现象两个管理员同时借同一本只剩一本的书都成功了库存变成 -1。原因经典“查了再改”的竞态。管理员 A 查到库存 1管理员 B 也查到库存 1A 先执行UPDATE库存变 0B 的UPDATE条件里没判断库存、直接减一结果变 -1。解决3.2 里已经示范的两种做法——查询时用SELECT ... FOR UPDATE锁行或者UPDATE语句的WHERE条件加stock 0后者的影响行数为 0 时回滚事务。在单机老项目里这两招足够不用上升到分布式锁。6. 二次开发给系统加上超期还书罚款功能6.1 改造点分析从数据表到 DAO 层基础版只记录借期和归还时间没有罚金。要做超期罚款先给 borrow_record 表加两个字段ALTER TABLE borrow_record ADD COLUMN fine_amount DECIMAL(10,2) DEFAULT 0; ALTER TABLE borrow_record ADD COLUMN fine_status TINYINT DEFAULT 0;fine_amount存罚金金额fine_status存缴纳状态0 表示未缴1 表示已缴。这个方案把罚款信息挂在借阅记录上不需要额外建表适合改造量最小化的场景。如果业务要支持多次补缴再拆成独立罚款记录表那样改动更大起步阶段先不碰。6.2 核心实现还书时计算逾期天数还书时根据due_date和实际归还日期计算天数差。用 Java 8 的LocalDate计算很干净public double calcFine(LocalDate dueDate, LocalDate returnDate) { long days ChronoUnit.DAYS.between(dueDate, returnDate); return days 0 ? days * 0.5 : 0; }参数说明ChronoUnit.DAYS.between计算两个日期之间相差的天数正数表示逾期0 或负数是按期归还。罚金按每天 0.5 元计算实际项目按自己的规则改单价即可。还书 Servlet 里调用这个方法查出这条记录的 due_date再拿到当前日期算出罚金写入 fine_amount 字段最后和恢复库存的操作放在同一个事务里提交。这样还书成功罚金记录也同时落库管理员的还书列表页能看到待缴金额。6.3 验证思路与扩展方向改完先不要急着写页面手动验证逻辑链路把一条 borrow_record 的 due_date 改成昨天再调用还书接口看 fine_amount 是不是 0.5再把 due_date 改成一周后还书后罚金是不是 0。最关键的是测试重复还书——同一记录还两次第二次必须被拦截。验证完再去做前端展示把 fine_amount 显示到借阅记录列表里给管理员一个“未缴罚款”的筛选条件。从那以后我每次拿到一个 JavaWeb 老项目都会先把数据库脚本跑一遍、用默认账号登录一次、走一遍核心流程再开始读代码。改任何表结构之前先备份原始建表语句改完能随时回滚这套流程在好几次改动里救了我希望帮到你。本文还有配套的精品资源点击获取