JavaWeb图书管理系统实战:从建表到借还书事务的完整落地

发布时间:2026/10/13 3:30:24
JavaWeb图书管理系统实战:从建表到借还书事务的完整落地
简介这是一套面向JavaWeb初学者与课程设计学习者的图书管理系统完整项目包围绕HTML、CSS、JavaScript、JSP、Servlet、JavaBean、JDBC与DAO等技术栈展开可用于课程设计、毕业设计参考或自学练手帮助理解前后端交互与MVC架构的实际落地方式。压缩包共3个文件包含1个docx设计报告、1个zip项目源码和1个sql数据库脚本整体约4.52MB分别对应需求分析与系统设计文档、可运行的工程代码以及图书信息表、用户信息表等建表数据便于快速导入数据库并对照文档梳理模块结构。目前已有19488人学习下载说明该案例在同类教学资源中具有较高的参考价值。通过这份资源读者可以完整走通从数据库设计、DAO数据访问到Servlet控制与JSP页面展示的开发流程掌握图书信息增删查改的实现思路并借助设计报告理解需求分析、架构设计与功能模块划分适合作为JavaWeb综合实践的入门范例。1. JavaWeb图书管理系统从课程设计到能扛住答辩的完整落地路径很多同学做 JavaWeb图书管理系统最后交出来的东西自己都不敢点第二遍借书按钮点下去没反应还书之后库存对不上管理员和读者登录进的是同一个页面。问题不在 Java 本身而在于大多数人把它当成“把 CRUD 拼起来”的作业而不是一个需要设计状态流转、权限边界和数据一致性的小型业务系统。这个标题背后真正要解决的是三件事图书、读者、借阅记录三张核心表怎么建模借书还书这类会改库存的操作怎么保证不出错不同角色进来看到的东西怎么隔离开。它适合正在做课程设计、毕业设计或者想用一个完整项目把 Servlet、JSP、JDBC、会话管理串起来的人。下面按我实际带人做这个系统的顺序把选型、建表、编码、排错和验收一条线讲清楚你照着能跑通也能在答辩时说出每一处为什么这么写。2. 先把技术栈和数据库定死别在选型上反复横跳2.1 为什么这个系统用 Servlet JSP JDBC 反而最稳现在一提 JavaWeb很多人第一反应是上 Spring Boot MyBatis Vue。方向没错但对图书管理系统这个体量前后端分离会额外引入跨域、Token 传递、接口联调三块工作量而这三块恰恰是新手最容易卡住的地方。课程设计和入门实战的目标是“把请求怎么进来、数据怎么出去”这条链路走通Servlet JSP JDBC 把这条链路暴露得最彻底一个 HTTP 请求打到哪个 Servlet、Servlet 怎么拿参数、怎么查库、怎么转发到 JSP全程没有框架帮你藏。我一般会这样定视图层用 JSP JSTL控制层用 Servlet数据访问层用原生 JDBC 加一个薄薄的 DAO 封装数据库用 MySQL 8。这个组合的依赖极少一个 Tomcat 加一个 MySQL 驱动就能跑出问题也好定位——报错栈直接指到你的 Servlet 行号不会像框架那样在十几层拦截器里绕。需要说清楚的是这不是说框架不好而是这个阶段用框架会掩盖你对请求生命周期的理解。等你把原生这套写顺了再换 Spring Boot你会发现它只是帮你把 new 对象和配置这件事自动化了底层逻辑你早就懂了。2.2 三张核心表怎么建字段、类型和约束一次到位图书管理系统的数据模型核心就三张表图书表、读者表、借阅记录表。很多人翻车在借阅记录表上——只记了“谁借了哪本书”没记借出时间和归还时间导致后面算超期、算在借数量全都没法做。下面是我常用的建表语句字段类型和约束都标了理由-- 图书表库存用两个字段分开管别用一个字段硬扛 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, -- 唯一防止同一本书重复录入 title VARCHAR(100) NOT NULL, author VARCHAR(50) NOT NULL, total_count INT NOT NULL DEFAULT 1, -- 馆藏总数 stock INT NOT NULL DEFAULT 1, -- 当前可借数量 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 读者表role 区分管理员和普通读者 CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, -- 存哈希不存明文 real_name VARCHAR(30), role TINYINT NOT NULL DEFAULT 0, -- 0读者 1管理员 status TINYINT NOT NULL DEFAULT 1 -- 1正常 0禁用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 借阅记录表归还时间允许为空为空即“在借” CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, return_time DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0在借 1已还 INDEX idx_reader (reader_id), INDEX idx_book (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明图书表用 total_count 和 stock 两个字段是因为“馆藏总数”和“当前可借数”是两个概念借出去一本 stock 减一total_count 不变还回来 stock 加一。如果只用一个字段你没法区分“这本书一共几本”和“现在还剩几本”。借阅记录表用 return_time 是否为空来判断在借状态比单独维护一个状态字段更不容易出现数据不一致——状态字段可能忘了改但归还时间写了就是写了。参数说明字符集统一 utf8mb4避免书名里出现特殊字符或生僻字时乱码。reader 表的 password 字段给到 64 位是为了放 SHA-256 哈希后的结果明文密码绝对不能入库。两个索引加在 borrow_record 的外键字段上因为“查某个读者的借阅历史”和“查某本书的借阅情况”是最高频的两个查询。2.3 项目目录结构按职责分层别全堆在 src 根目录目录结构乱后期改一个功能要翻半天。我一般按下面这样分src/ com.demo.book/ dao/ -- 数据访问一个表一个 DAO 类 entity/ -- 实体类和表字段一一对应 servlet/ -- 控制层按业务分BookServlet、BorrowServlet、UserServlet util/ -- DBUtil 连接管理、密码工具 filter/ -- 登录校验、编码过滤 WebContent/ jsp/ -- 页面 css/ js/ WEB-INF/web.xml这个分法的好处是你要改查询逻辑只动 dao要改页面跳转只动 servlet要加一个全局登录拦截只加一个 filter。每层职责单一答辩时老师问“你这个功能改哪里”你能一句话答上来。3. 借书还书这条主线怎么写成能跑通的代码3.1 数据库连接与 DAO 基类把重复代码抽干净每个 DAO 都写一遍加载驱动、拿连接、关连接代码会又臭又长。先写一个工具类public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/library?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PWD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL驱动加载失败, e); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } public static void close(Connection c, PreparedStatement p, ResultSet r) { try { if (r ! null) r.close(); } catch (SQLException ignored) {} try { if (p ! null) p.close(); } catch (SQLException ignored) {} try { if (c ! null) c.close(); } catch (SQLException ignored) {} } }逻辑说明静态代码块只执行一次驱动加载放这里最合适。URL 里三个参数必须带useSSLfalse 避免本地连接时的 SSL 警告serverTimezone 不设会导致时间差 8 小时characterEncoding 不设中文会乱码。close 方法按 ResultSet、Statement、Connection 的逆序关闭每个都单独 try-catch保证一个关失败不影响后面。参数说明serverTimezone 的值按你数据库实际时区填国内一般用 Asia/Shanghai。如果你的 MySQL 是 5.7 版本驱动类名是 com.mysql.jdbc.Driver8.0 才是 com.mysql.cj.jdbc.Driver这个写错会直接报 ClassNotFound。3.2 借书操作库存扣减和记录插入必须在一个事务里借书这个动作要同时做两件事图书表 stock 减一借阅记录表插一条。这两件事必须一起成功或一起失败否则就会出现“库存扣了但没记录”或者“有记录但库存没扣”的脏数据。public boolean borrowBook(int bookId, int readerId) { Connection conn null; PreparedStatement ps1 null, ps2 null; try { conn DBUtil.getConn(); conn.setAutoCommit(false); // 开启事务 // 1. 扣库存条件是 stock 0防止超借 String sql1 UPDATE book SET stock stock - 1 WHERE id ? AND stock 0; ps1 conn.prepareStatement(sql1); ps1.setInt(1, bookId); int rows ps1.executeUpdate(); if (rows 0) { conn.rollback(); return false; // 库存不足借书失败 } // 2. 插入借阅记录 String sql2 INSERT INTO borrow_record(book_id, reader_id) VALUES(?, ?); ps2 conn.prepareStatement(sql2); ps2.setInt(1, bookId); ps2.setInt(2, readerId); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ignored) {} e.printStackTrace(); return false; } finally { try { if (conn ! null) conn.setAutoCommit(true); } catch (SQLException ignored) {} DBUtil.close(conn, ps1, null); try { if (ps2 ! null) ps2.close(); } catch (SQLException ignored) {} } }逻辑说明关键在 UPDATE 语句的 WHERE 条件带了 stock 0。这一句同时完成了“判断库存”和“扣减库存”是原子的不会出现两个线程同时判断库存够、然后都扣成负数的情况。如果 executeUpdate 返回 0说明没有行被更新也就是库存不足直接回滚返回失败。插入记录和扣库存都在同一个 Connection 上commit 之前任何一步失败都 rollback。参数说明setAutoCommit(false) 是事务的起点执行完必须恢复成 true否则这个连接被放回连接池后会影响下一个使用者。rollback 放在 catch 里保证异常时数据回退。这里没有用连接池如果并发量上来每次新建连接开销大可以换成 Druid 或 HikariCP但逻辑不变。3.3 还书操作更新记录状态和回补库存还书是借书的逆操作但要注意只能还对“在借”的记录已经还过的不能再还一次。public boolean returnBook(int recordId) { Connection conn null; PreparedStatement ps1 null, ps2 null; try { conn DBUtil.getConn(); conn.setAutoCommit(false); // 1. 更新借阅记录只更新 status0 的记录防止重复还书 String sql1 UPDATE borrow_record SET return_time NOW(), status 1 WHERE id ? AND status 0; ps1 conn.prepareStatement(sql1); ps1.setInt(1, recordId); if (ps1.executeUpdate() 0) { conn.rollback(); return false; // 记录不存在或已归还 } // 2. 回补库存从记录里查出 book_id 再更新 String sql2 UPDATE book b JOIN borrow_record r ON b.id r.book_id SET b.stock b.stock 1 WHERE r.id ?; ps2 conn.prepareStatement(sql2); ps2.setInt(1, recordId); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ignored) {} e.printStackTrace(); return false; } finally { try { if (conn ! null) conn.setAutoCommit(true); } catch (SQLException ignored) {} DBUtil.close(conn, ps1, null); try { if (ps2 ! null) ps2.close(); } catch (SQLException ignored) {} } }逻辑说明还书的第一步 UPDATE 带了 status 0 条件如果这条记录已经还过status1executeUpdate 返回 0直接回滚避免同一本书被还两次导致库存多加。第二步用 JOIN 更新直接从借阅记录里关联出 book_id不用先查再更新少一次数据库往返。参数说明NOW() 是 MySQL 函数取当前时间比在 Java 里 new Date() 再传参更省事也避免时区转换问题。JOIN 更新的写法在 MySQL 里是支持的如果换其他数据库要改成子查询。3.4 登录与权限过滤一个 Filter 挡住所有未登录请求登录功能本身简单难的是“登录之后每个页面都要校验”。如果每个 Servlet 里都写一遍 session 判断代码重复且容易漏。用一个 Filter 统一处理WebFilter(/*) public class AuthFilter implements Filter { // 不需要登录就能访问的路径 private static final String[] WHITE_LIST {/login.jsp, /login, /css/, /js/}; public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); String path uri.substring(request.getContextPath().length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(req, resp); return; } } // 校验 session Object user request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }逻辑说明Filter 拦截所有路径先判断是否在白名单里是就直接放行。白名单必须包含登录页、登录接口和静态资源否则登录页自己都被拦了会陷入死循环。session 里存的 loginUser 对象在登录成功时写入这里只判断是否为 null。参数说明WebFilter(/*) 是 Servlet 3.0 的注解方式不用在 web.xml 里配。如果你的项目用的是老版本 web.xml 配置把这段改成 filter 和 filter-mapping 标签即可。白名单里的路径要和实际项目路径一致比如你的登录 Servlet 映射是 /login就写 /login。4. 那些让系统“看起来能跑但一用就崩”的坑4.1 中文乱码POST 和 GET 要分开治现象表单提交的书名在数据库里变成问号或者页面显示乱码。原因POST 请求的编码由 request.setCharacterEncoding 决定GET 请求的编码由 Tomcat 的 server.xml 里 Connector 的 URIEncoding 决定两者不是一回事。解决POST 在 Filter 里统一设 request.setCharacterEncoding(UTF-8) 和 response.setContentType(text/html;charsetUTF-8)。GET 请求要么改 Tomcat 配置要么在代码里手动 new String(name.getBytes(ISO-8859-1), UTF-8)。最省事的做法是全部用 POST 提交从源头绕开 GET 编码问题。4.2 库存扣成负数并发下的经典翻车现象两个人同时借同一本只剩一本的书结果两个人都借成功了库存变成 -1。原因先 SELECT 查库存再判断再 UPDATE这三步之间有时间窗口两个线程可能都查到库存为 1。解决把判断和扣减合并成一条 UPDATE ... WHERE stock 0靠数据库的行锁保证原子性就是 3.2 里写的那种做法。不要用“先查后改”的两步写法那是并发问题的标准反面教材。4.3 还书之后库存对不上忘了处理重复还书现象同一本书还了两次库存比馆藏总数还多。原因还书接口没有校验记录状态已经还过的记录又被更新了一次库存又加了一次。解决还书的 UPDATE 语句必须带 status 0 条件更新行数为 0 就说明已经还过直接返回失败。这个校验放在 SQL 的 WHERE 里比放在 Java 的 if 里更可靠因为 SQL 层面是原子的。4.4 登录后直接访问后台页面权限只做了登录没做角色现象普通读者登录后手动在地址栏输入管理员页面的 URL居然能打开。原因Filter 只校验了“有没有登录”没校验“是什么角色”。解决在 Filter 里拿到 loginUser 后判断它的 role 字段。如果访问的是 /admin/ 开头的路径而 role 不是管理员直接跳转到无权限提示页。角色判断和登录判断放在同一个 Filter 里不要分散到各个 Servlet。4.5 数据库连接用完不关跑一会儿就报 Too many connections现象系统用一段时间后所有数据库操作都报连接数超限。原因某个 DAO 方法里 Connection 没有在 finally 里关闭异常时连接泄漏。解决所有数据库操作统一用 try-finally 结构finally 里调 DBUtil.close。更彻底的做法是引入连接池由池子管理连接的借出和归还但前提是你的代码必须保证 finally 里归还连接否则池子也会被耗尽。5. 让这个系统在答辩时站得住的两个进阶技巧5.1 用分页查询把“图书列表”做成能演示的功能图书列表如果一次性全查出来数据一多页面就卡而且演示时看不出技术含量。分页是性价比最高的加分项。核心 SQL 就一句SELECT * FROM book ORDER BY id DESC LIMIT ?, ?;第一个问号是偏移量等于 (页码 - 1) * 每页条数第二个问号是每页条数。比如第 2 页、每页 10 条就是 LIMIT 10, 10。在 Servlet 里接收 page 参数默认第 1 页算出偏移量传给 DAO。同时再写一个 COUNT 查询算总条数用来生成页码导航。这里有个容易忽略的点页码参数要做边界校验。如果用户手动把 page 改成 -1 或者 99999偏移量会变成负数或超出范围MySQL 会报错或返回空。我一般会在 Servlet 里做一次 clamppage 小于 1 就取 1大于总页数就取总页数。这个细节答辩时老师如果问“用户乱输页码怎么办”你能答上来就是加分。5.2 借阅超期计算一个能体现业务理解的小功能借书的时候记录 borrow_time还书的时候记录 return_time。超期判断就是比较这两个时间的差值有没有超过规定天数。但要注意如果书还没还return_time 是空的这时候要用当前时间和 borrow_time 比。-- 查询所有超期未还的记录假设借期30天 SELECT r.id, b.title, rd.real_name, DATEDIFF(NOW(), r.borrow_time) AS overdue_days FROM borrow_record r JOIN book b ON r.book_id b.id JOIN reader rd ON r.reader_id rd.id WHERE r.status 0 AND DATEDIFF(NOW(), r.borrow_time) 30;DATEDIFF 返回两个日期相差的天数NOW() 是当前时间。这个查询直接给出超期天数和借阅人信息管理员页面拿来就能展示。如果你想让系统更完整可以在还书时判断是否超期超期就计算罚金比如每天 0.1 元存到记录表的一个额外字段里。这个功能代码量不大但能让你的系统从“增删改查”变成“有业务规则”答辩时讲起来有内容。我自己的习惯是每加一个业务规则就同步想清楚它的边界借期从借出当天算还是第二天算超期天数含不含当天这些规则没有标准答案但你得在代码里写死一个并说得出理由。最怕的是规则模糊代码里东一处西一处最后自己都说不清到底怎么算的。把上面这些串起来一个能登录、能借还、能分页、能查超期的图书管理系统就成型了。它不花哨但每一行代码你都能解释为什么这么写每一个坑你都提前踩过并填上了。这比堆一堆框架然后说不清原理要踏实得多。希望帮到你。本文还有配套的精品资源点击获取