简介这是一份面向Java Web初学者与课程设计需求者的图书管理系统实战项目源自大二上学期期末大作业适合作为SSM/MyBatis入门练手与课程设计参考。项目围绕书籍、学生、管理员、留言四类实体展开涵盖借还书日志记录、书籍状态流转未借出、已借出、续借、逾期、丢失、损毁等、信誉等级与借阅数量控制等业务逻辑并完整实现了学生注册、学生登录、管理员登录三条链路注册与登录均采用Ajax异步校验、JSON提交、正则验证、Cookie加密记住账号、Session保存用户信息等常见Web开发要点。资源包共59个文件包含26个Java源码、13个HTML页面、7个XML配置、3个JSP、2个JS脚本及SQL建表脚本、图片与说明文档压缩包约879KB结构清晰便于按模块阅读。目前已有1915人学习下载可帮助读者快速理解Java Web分层开发流程、数据库设计与前后端交互细节适合作为课程设计模板或二次开发起点。1. 图书管理系统大二期末大作业为什么值得用 JavaWeb 认真做一遍大二上学期期末很多学校的 JavaWeb 课程会甩出一个大作业图书管理系统。刚拿到题目的时候大部分人的第一反应是“这不就是个增删改查吗”然后去网上找一份现成源码改改页面、换个数据库名字就交了。但如果你真的想把这个练手项目做出价值它其实是一次把 JSP、Servlet、JDBC、Session、过滤器、MVC 分层这些零散知识点串成一条线的机会。图书管理系统这个题目的好处在于业务边界清晰图书、读者、借阅记录三张核心表就能跑起来但又能自然引出分页查询、多条件搜索、借阅状态流转、权限区分这些真实系统里一定会遇到的问题。它适合刚学完 JavaWeb 基础、想找一个完整案例把知识落地的大二学生也适合已经工作但想回头补一补传统 JavaWeb 项目完整案例的开发者。下面我按一个能真正跑起来、能讲清楚原理、也能避开常见翻车点的路径把这个项目从选型到实现到排错讲一遍。2. 技术选型与项目骨架为什么大二期末作业用 JSPServlet 比直接上 SpringBoot 更合适2.1 先想清楚这个作业到底在练什么很多同学一上来就问“能不能用 SpringBoot 做”当然可以但你要先问自己这个作业的目的是交差还是练手。如果是练手JSPServletJDBC 这套“老技术栈”反而更合适因为它把 HTTP 请求怎么进来、参数怎么取、数据库怎么连、页面怎么渲染这些底层环节全部暴露给你。用 SpringBoot 的话很多配置被自动装配藏起来了你写完了可能还是不知道一个请求从浏览器到数据库再回来到底经过了哪些步骤。大二上学期这个阶段你需要的是把黑匣子打开看一遍而不是再套一层框架。常见做法是视图层用 JSP控制层用 Servlet模型层用 JavaBean数据访问层用 JDBC 手写 DAO。这套结构就是最经典的 MVC虽然写起来代码量大但每一行你都知道它在干什么。选型确定之后项目骨架要提前规划好不要写到哪算哪。我一般会按下面这个目录结构来建后面加功能的时候直接往对应包里塞就行不会乱BookManage/ ├── src/ │ └── com/ │ └── book/ │ ├── bean/ # 实体类Book、Reader、Borrow │ ├── dao/ # 数据访问BookDao、ReaderDao、BorrowDao │ ├── dao/impl/ # DAO 实现类 │ ├── service/ # 业务逻辑可选大二作业可合并到 Servlet │ ├── servlet/ # 控制器BookServlet、ReaderServlet、BorrowServlet │ ├── filter/ # 编码过滤器、登录过滤器 │ └── util/ # DBUtil 数据库连接工具类 ├── WebContent/ │ ├── css/ │ ├── js/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── lib/ # mysql-connector-java.jar 等 │ ├── login.jsp │ ├── book_list.jsp │ ├── book_add.jsp │ └── ... └── database/ └── book_manage.sql # 建库建表脚本这个结构里bean放实体类字段和数据库表一一对应dao定义接口dao/impl写具体实现servlet负责接收请求、调 DAO、转发页面filter处理中文乱码和登录拦截util里的DBUtil统一管理数据库连接。把骨架搭好之后后面每加一个功能你只需要在对应的包里加文件不会出现“这个类到底该放哪”的纠结。2.2 数据库设计三张表撑起整个图书管理系统图书管理系统的核心表其实就三张图书表、读者表、借阅记录表。但很多同学在设计阶段偷懒后面写查询的时候就会各种别扭。下面是我建议的建表语句字段类型和约束都按实际会用到的方式写-- 图书表 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(100), price DECIMAL(10,2), total INT DEFAULT 1, -- 总库存 stock INT DEFAULT 1, -- 当前可借库存 status TINYINT DEFAULT 1 -- 1 可借 0 下架 ); -- 读者表 CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(20), max_borrow INT DEFAULT 5, -- 最大可借数 status TINYINT DEFAULT 1 -- 1 正常 0 冻结 ); -- 借阅记录表 CREATE TABLE borrow ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE, status TINYINT DEFAULT 0, -- 0 借出 1 已还 2 逾期 FOREIGN KEY (book_id) REFERENCES book(id), FOREIGN KEY (reader_id) REFERENCES reader(id) );这里有几个参数需要特别说明。book表里的total和stock要分开因为总库存和当前可借库存不是一回事借出去一本stock减一还回来加一但total不变。reader表里的max_borrow是给借阅上限用的后面在借书逻辑里要判断当前借阅数是否超过这个值。borrow表里的status用三个状态区分借出、已还、逾期比只用一个return_date是否为空要清晰得多因为逾期需要单独判断。外键约束建议加上虽然有些教程说影响性能但在大二作业这个数据量下外键能帮你避免脏数据比如借阅记录里出现一个不存在的图书 ID。2.3 数据库连接工具类一个 DBUtil 解决所有 JDBC 重复代码JDBC 最烦的就是每次都要写加载驱动、获取连接、关闭资源那一套。写一个DBUtil工具类把连接获取和资源释放封装起来后面所有 DAO 都调它。下面这个版本是我在多个练手项目里用过的稳定且够用package com.book.util; import java.sql.*; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/book_manage?useSSLfalseserverTimezoneUTCcharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (stmt ! null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn ! null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }这段代码的关键点有三个。第一Class.forName放在static块里类加载时只执行一次不用每次获取连接都加载驱动。第二URL 里的serverTimezoneUTC和characterEncodingutf8一定要加否则 MySQL 8 以上版本会报时区错误或者中文乱码。第三close方法按ResultSet、Statement、Connection的顺序关闭每个都单独 try-catch避免一个关闭失败影响后面的。参数方面URL里的数据库名、USER和PASSWORD改成你本地的配置就行。如果你用的是 MySQL 5.x驱动类名换成com.mysql.jdbc.DriverURL 里不需要serverTimezone。3. 核心功能实现从登录拦截到借阅状态流转的完整代码路径3.1 登录与权限过滤一个 Filter 挡住所有未登录请求图书管理系统一般分管理员和读者两种角色管理员能管图书和读者读者只能查书和借还。不管哪种角色第一步都是登录。登录本身不难难的是怎么让未登录的用户访问不了其他页面。常见做法是写一个LoginFilter拦截所有请求检查 Session 里有没有用户信息没有就跳回登录页。下面这个过滤器同时处理了中文编码和登录校验package com.book.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 统一设置编码解决 POST 中文乱码 request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String uri request.getRequestURI(); // 放行登录页、登录请求、静态资源 if (uri.endsWith(login.jsp) || uri.endsWith(login) || uri.contains(/css/) || uri.contains(/js/)) { chain.doFilter(req, resp); return; } HttpSession session request.getSession(); Object user session.getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }这个过滤器的逻辑说明先设置请求和响应的编码这一步放在最前面保证后面所有 Servlet 拿到的参数都是 UTF-8。然后判断当前请求的 URI如果是登录页、登录请求或者静态资源就直接放行不检查登录状态。其他请求从 Session 里取user对象取不到就重定向到登录页。参数方面WebFilter(/*)表示拦截所有路径如果你只想拦截特定路径可以改成WebFilter(/admin/*)这种形式。注意login这个判断要和你登录 Servlet 的映射路径一致比如你的登录 Servlet 映射是/login这里就写uri.endsWith(login)。3.2 图书分页查询用 LIMIT 和 COUNT 两条 SQL 搞定图书列表页如果一次性把所有书都查出来数据量大了页面会卡所以分页是必须的。分页的核心就两条 SQL一条查总数一条查当前页的数据。下面这个BookDao里的分页方法把这两个查询封装在一起public ListBook findByPage(int page, int pageSize, String keyword) { ListBook list new ArrayList(); 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, % keyword %); ps.setString(2, % keyword %); ps.setInt(3, (page - 1) * pageSize); ps.setInt(4, pageSize); 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 (SQLException e) { e.printStackTrace(); } return list; } public int count(String keyword) { String sql SELECT COUNT(*) FROM book WHERE name LIKE ? OR author LIKE ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setString(2, % keyword %); ResultSet rs ps.executeQuery(); if (rs.next()) return rs.getInt(1); } catch (SQLException e) { e.printStackTrace(); } return 0; }逻辑说明findByPage接收当前页码page、每页条数pageSize和搜索关键词keyword。SQL 里的LIMIT ?, ?第一个问号是偏移量计算公式是(page - 1) * pageSize第二个问号是每页条数。count方法用同样的条件查总数用来算总页数。参数方面pageSize一般设 10 或 15看页面布局。keyword如果为空字符串LIKE %%会匹配所有记录所以不需要额外判断。注意LIMIT的两个参数在 MySQL 里是“偏移量, 条数”不是“页码, 条数”这是新手最容易搞错的地方。在 Servlet 里调这两个方法把结果和分页信息一起塞进 request转发到 JSPint page Integer.parseInt(request.getParameter(page) null ? 1 : request.getParameter(page)); int pageSize 10; String keyword request.getParameter(keyword) null ? : request.getParameter(keyword); ListBook books bookDao.findByPage(page, pageSize, keyword); int total bookDao.count(keyword); int totalPages (total pageSize - 1) / pageSize; request.setAttribute(books, books); request.setAttribute(currentPage, page); request.setAttribute(totalPages, totalPages); request.setAttribute(keyword, keyword); request.getRequestDispatcher(/book_list.jsp).forward(request, response);这段 Servlet 代码里totalPages的计算用了(total pageSize - 1) / pageSize这是向上取整的常用写法避免用Math.ceil还要转 double。keyword回传到页面是为了搜索框里保留用户输入的内容。3.3 借书与还书库存扣减和状态流转要放在一个事务里借书这个操作看起来简单实际上涉及两个更新借阅记录表插入一条图书表库存减一。如果这两步不放在一个事务里可能出现库存扣了但借阅记录没插入的情况或者反过来。下面这个BorrowService里的借书方法用 JDBC 的手动事务来控制public boolean borrowBook(int bookId, int readerId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 检查库存 String checkSql SELECT stock FROM book WHERE id ? FOR UPDATE; PreparedStatement checkPs conn.prepareStatement(checkSql); checkPs.setInt(1, bookId); ResultSet rs checkPs.executeQuery(); if (!rs.next() || rs.getInt(stock) 0) { conn.rollback(); return false; } // 2. 扣减库存 String updateSql UPDATE book SET stock stock - 1 WHERE id ? AND stock 0; PreparedStatement updatePs conn.prepareStatement(updateSql); updatePs.setInt(1, bookId); int rows updatePs.executeUpdate(); if (rows 0) { conn.rollback(); return false; } // 3. 插入借阅记录 String insertSql INSERT INTO borrow(book_id, reader_id, borrow_date, due_date, status) VALUES(?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 0); PreparedStatement insertPs conn.prepareStatement(insertSql); insertPs.setInt(1, bookId); insertPs.setInt(2, readerId); insertPs.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { try { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } }这段代码的关键点第一conn.setAutoCommit(false)开启手动事务后面所有操作都在同一个连接里。第二检查库存的 SQL 用了FOR UPDATE这是行级锁防止两个读者同时借同一本书时都查到有库存然后都扣减。第三扣减库存的UPDATE带了AND stock 0条件即使前面检查通过这里再兜底一次。第四借阅记录的due_date用DATE_ADD(CURDATE(), INTERVAL 30 DAY)算出 30 天后的日期这个 30 天可以按学校规定改。还书逻辑类似把stock 1借阅记录的status改成 1return_date设为当前日期同样要放在事务里。4. 避坑与排查图书管理系统从能跑到能交的 5 个血泪教训4.1 中文乱码POST 和 GET 要分开处理现象表单提交的书名在数据库里变成问号或者页面显示乱码。原因POST 请求的乱码要靠request.setCharacterEncoding(UTF-8)解决但 GET 请求的参数在 URL 里Tomcat 默认用 ISO-8859-1 解码设置 request 编码没用。解决POST 在过滤器里统一设置GET 要么在 Tomcat 的server.xml里给 Connector 加URIEncodingUTF-8要么在代码里手动转码new String(name.getBytes(ISO-8859-1), UTF-8)。我一般直接改server.xml一劳永逸。4.2 数据库连接泄漏DAO 里忘了关资源现象项目跑一会儿就报Too many connections或者页面越来越慢。原因每次查询都getConnection()但没在finally里关闭连接池很快耗尽。解决所有 DAO 方法用 try-with-resources 或者在finally里调DBUtil.close()。上面DBUtil里的close方法就是干这个的每个 DAO 方法结束前必须调一次。检查方法在 MySQL 里执行SHOW PROCESSLIST如果看到大量 Sleep 状态的连接基本就是泄漏了。4.3 借阅记录重复插入没做唯一性校验现象同一个读者对同一本书点了两次借阅借阅记录表里出现两条未归还的记录。原因借书逻辑只检查了库存没检查这个读者是否已经借了这本书且未还。解决在借书前加一条查询SELECT COUNT(*) FROM borrow WHERE book_id ? AND reader_id ? AND status 0如果大于 0 就提示“您已借阅此书请先归还”。这个校验要放在事务里和库存检查一起做。4.4 JSP 页面取不到 request 属性转发和重定向搞混了现象Servlet 里request.setAttribute(books, list)之后JSP 里用${books}取不到值。原因用了response.sendRedirect()重定向重定向是两次请求第一次请求里的 attribute 在第二次请求里不存在。解决需要传数据到页面时用request.getRequestDispatcher(/book_list.jsp).forward(request, response)转发转发是一次请求attribute 能带到 JSP。只有不需要传数据的场景才用重定向比如登录成功后跳转到列表页。4.5 MySQL 8 驱动配置时区和 SSL 警告现象控制台一直报WARN: Establishing SSL connection without servers identity verification is not recommended或者报时区错误The server time zone value ???ú±ê×??±?? is unrecognized。原因MySQL 8 的驱动默认要求 SSL 和明确的时区。解决JDBC URL 里加上useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8。useSSLfalse关掉 SSL 警告serverTimezoneAsia/Shanghai指定东八区characterEncodingutf8保证中文正常。驱动类名用com.mysql.cj.jdbc.Driver不要用旧的com.mysql.jdbc.Driver。5. 进阶技巧用一条 SQL 查出借阅超期名单并验证数据一致性项目基本功能跑通之后如果你想在答辩或者验收时多拿点分可以加一个“超期提醒”功能。这个功能不需要额外建表直接用借阅记录表和图书、读者表关联查询就能实现。下面这条 SQL 查出所有已借出且超过应还日期的记录同时带出书名和读者名SELECT b.name AS book_name, r.name AS reader_name, r.phone, br.borrow_date, br.due_date, DATEDIFF(CURDATE(), br.due_date) AS overdue_days FROM borrow br JOIN book b ON br.book_id b.id JOIN reader r ON br.reader_id r.id WHERE br.status 0 AND br.due_date CURDATE() ORDER BY overdue_days DESC;这条 SQL 里DATEDIFF(CURDATE(), br.due_date)算出超期天数WHERE条件里status 0表示还没还due_date CURDATE()表示应还日期已经过了。ORDER BY overdue_days DESC让超期最久的排在最前面。在 Servlet 里调这个查询把结果放到 request 里转发到 JSP页面上用c:forEach遍历显示就行。除了超期查询还有一个验证数据一致性的技巧写一个对账 SQL检查图书表的stock和借阅记录表里未归还的数量是否匹配。正常情况下book.total - book.stock应该等于这本书status 0的借阅记录数。如果对不上说明某次借还操作事务出了问题。对账 SQL 如下SELECT b.id, b.name, b.total, b.stock, b.total - b.stock AS borrowed, (SELECT COUNT(*) FROM borrow br WHERE br.book_id b.id AND br.status 0) AS record_count FROM book b HAVING borrowed ! record_count;这条 SQL 用子查询统计每本书未归还的借阅记录数然后和total - stock对比HAVING过滤出不一致的记录。如果查出来有数据说明库存和借阅记录不同步需要手动修数据或者检查借还代码的事务逻辑。这个技巧在答辩时展示出来能说明你不只是把功能堆上去还考虑了数据质量。最后说一个我自己的习惯每次改完 DAO 或者 Service 里的借还逻辑不要只点页面看能不能跑一定要去数据库里执行上面那条对账 SQL 看一眼。页面能跑不代表数据是对的库存扣错了页面照样显示正常但对账 SQL 会立刻暴露问题。这个习惯帮我省了很多答辩前夜改数据的后悔药。希望帮到你。本文还有配套的精品资源点击获取