ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

可运行JavaWeb图书馆管理系统源码:MySQL增删改查与借阅事务

可运行JavaWeb图书馆管理系统源码:MySQL增删改查与借阅事务

简介:面向高校课程设计、毕业设计与Java Web入门者的图书馆管理系统完整源码包,基于Servlet、JSP、JavaBean与MVC分层实现,后端使用MySQL管理图书、读者、借阅和管理员数据,可支撑图书信息维护、借阅归还、登录鉴权与权限区分等典型业务的学习和二次开发。压缩包共209个文件、约1.91MB,主体为51个JSP页面、32个Java源文件和32个class文件,并配有MySQL数据文件、SQL建库脚本、jar依赖、properties配置、tld标签库以及gif、jpg、js、css等页面资源,覆盖视图展示、请求控制、DAO访问到数据库部署的完整链路。已有359人学习。读者可结合图书、读者、借阅、管理员等DAO模块理解增删改查实现,进一步掌握JSTL与EL、session状态保持、请求转发与重定向、异常处理及Tomcat部署;系统按实体划分数据表,借阅记录关联图书与读者,也有助于理解关系型数据库设计与外键使用,适合作为课程作业、毕设原型和Java Web综合练习的参考底稿。

1. 图书馆管理系统源码能干什么:一套能跑通的 JavaWeb 增删改查骨架

接手一个馆藏后台或者课程设计,最耗时间的往往不是写业务,而是把登录、列表、增删改查这套底子搭起来。这套 JavaWeb 图书馆管理系统源码 MySQL 版本,给的就是一副能直接跑起来的底子:图书的增删改查、读者管理、借阅归还、分类维护、按书名作者模糊检索,配上 JSP 页面和一整套 JDBC 数据访问代码,导入数据库、改一下连接配置就能登录进后台点。它适合两类人:一是刚学完 Servlet 和 JSP,想找一份结构规整、能逐行读懂的完整项目对照练手;二是手上有个小系统要交付,需要一套分层清楚、好往上加功能的起手代码,省掉搭框架的两三天。不适合的也很明确——它没有做高并发设计,也没有微服务和缓存那一套,拿它当互联网级借阅平台用会很难受。把它当成一副骨架,改造成自己要的东西,才是最顺的用法。

2. 技术栈与目录结构:从 web.xml 一路摸到 DAO 层

拿到一套陌生的 JavaWeb 源码,我的习惯是先不看业务,先看两样东西:依赖包里有什么,目录怎么分层。这两样决定了后面改代码时手往哪儿伸。这套图书馆管理系统的组织方式很典型,原生 Servlet 做控制器,JSP 做视图,JavaBean 做数据载体,JDBC 直连 MySQL,中间靠一层手写的 DBUtil 管连接。没有 Spring 那套注解和容器,所有对象的创建、依赖的传递都是显式 new 出来的,看着啰嗦,但对刚入门的人反而是好事——调用链摆在明面上,不存在“注解一加、神秘生效”的黑匣子。

2.1 运行环境与技术选型盘点

这套代码的技术组合偏传统,好处是资料多、报错好搜。下表是我实际跑这类项目时用的环境,不是项目硬性声明,按这个配基本不会遇到版本层面的玄学问题:

层次技术我一般用的版本说明
语言Java(JDK)8 或 11源码用 8 的语法写,11 也能编译
Web 容器Tomcat8.5 / 9不要用 10,包名从 javax 变了,会全线报错
数据库MySQL5.7 / 8.08.0 要注意驱动类和时区参数
数据访问原生 JDBC—手写 SQL、手写结果集映射
连接管理连接池(Druid / C3P0)随源码也有版本是每次 DriverManager 直连
视图JSP + JSTL—页面里混着 EL 表达式和 scriptlet
前端Bootstrap + jQuery—表格、弹窗、表单校验都在这一层

一个容易忽略的点:Tomcat 10 之后 Servlet API 的包名从javax.servlet换成了jakarta.servlet,源码里的 import 一行都编译不过。这不是代码写错了,是容器选错了,换回 8.5 或 9 立刻就好。类似的还有 MySQL 8 的驱动类,老代码写com.mysql.jdbc.Driver,在新驱动里虽然还能用但会打警告,正确写法是com.mysql.cj.jdbc.Driver。

2.2 目录结构与各层职责

源码解压后是一个标准的 Eclipse 动态 Web 工程结构(有些版本是 Maven 的 src/main/java,逻辑一样)。核心目录大致如下:

library-system/ ├── src/ │ ├── com/library/bean/ # 实体类:Book、Reader、BorrowRecord、Category │ ├── com/library/dao/ # 数据访问层:每个实体一个 DAO,只写 SQL │ ├── com/library/service/ # 业务层:借还书这类要跨表的逻辑放这里 │ ├── com/library/servlet/ # 控制器:收参数、调 service、转发 JSP │ ├── com/library/filter/ # 登录拦截、字符编码过滤 │ └── com/library/util/ # DBUtil、分页封装、日期工具 ├── WebContent/ # 或 web/、WebRoot/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet 与 Filter 的注册入口 │ │ └── lib/ # mysql-connector、jstl、连接池 jar │ ├── jsp/ # book_list.jsp、reader_list.jsp 等页面 │ ├── css/ js/ images/ # 静态资源 │ └── login.jsp └── sql/ └── library.sql # 建库建表 + 初始数据

分层的边界在代码里是要守住的:Servlet 只做三件事——取参数、调 Service、决定跳哪个页面,不写 SQL;DAO 只碰数据库,不做业务判断,方法签名里出现“够不够借”这种判断就说明写串了;Service 是把多个 DAO 操作拼成一个完整动作的地方,事务也开在这一层。血泪经验是,很多下载来的源码在 Servlet 里直接new BookDao().update(...),一开始跑得通,等你加预约、加罚款就会发现同一段 SQL 散在五六个 Servlet 里,改一次漏三处。

2.3 数据库表结构与初始化脚本

整套系统的地基是四张表:图书、读者、分类、借阅记录。借阅记录表是关键,它决定了“谁在什么时候借了哪本书、还没还”这件事能不能查清楚。下面是核心两张表的建法,字段名按大多数版本的命名习惯来:

-- 图书表:total 是馆藏总数,stock 是当前可借数,两者分开才能记录借出状态 CREATE TABLE `book` ( `book_id` int NOT NULL AUTO_INCREMENT COMMENT '图书主键', `isbn` varchar(20) NOT NULL COMMENT '国际标准书号,业务上唯一', `title` varchar(100) NOT NULL COMMENT '书名,列表页模糊查询字段', `author` varchar(50) DEFAULT NULL COMMENT '作者', `category_id` int DEFAULT NULL COMMENT '所属分类,关联 category 表', `total` int NOT NULL DEFAULT 0 COMMENT '馆藏总数', `stock` int NOT NULL DEFAULT 0 COMMENT '可借数量,借出减一归还加一', `publish` varchar(80) DEFAULT NULL COMMENT '出版社', PRIMARY KEY (`book_id`), UNIQUE KEY `uk_isbn` (`isbn`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书信息'; -- 借阅记录表:归还时只更新 return_date,不删记录,历史可追溯 CREATE TABLE `borrow_record` ( `record_id` bigint NOT NULL AUTO_INCREMENT, `book_id` int NOT NULL COMMENT '借的哪本书', `reader_id` int NOT NULL COMMENT '谁借的', `borrow_date` datetime NOT NULL COMMENT '借出时间', `due_date` datetime NOT NULL COMMENT '应还时间,借出时按天数算好', `return_date` datetime DEFAULT NULL COMMENT '实际归还时间,NULL 表示未还', PRIMARY KEY (`record_id`), KEY `idx_book` (`book_id`), KEY `idx_reader` (`reader_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录';

这段建表语句里有三个决策值得说。total和stock分开存,是为了在图书列表页直接展示“总共 5 本,可借 2 本”,而不用每次去 count 借阅记录;uk_isbn加了唯一索引,防止有人重复录入同一本书,导入初始数据时如果 SQL 报 Duplicate entry,说明脚本里本身就有重复行;return_date允许为 NULL,用它来区分在借和已还,比额外加一个 status 字段更省事,查询在借记录就是WHERE return_date IS NULL。字符集统一用 utf8mb4,别用 utf8,后者在 MySQL 里其实是三字节的残缺实现,存书名里的特殊符号会报错。

3. 核心业务链路:借一本书从点击到入库经历了什么

页面上的一个“借阅”按钮,背后是过滤器放行、Servlet 取参、Service 开事务、DAO 执行两条 UPDATE 加一条 INSERT 的一串动作。这条链路跑通了,整套代码你也就摸透了七八成,剩下的增删改查都是同一个套路的复制。这一章按请求经过的顺序拆三段:拦截、查询、写操作。

3.1 登录校验与权限过滤器

登录拦截用 Filter 实现,注册在 web.xml 里,拦所有路径,但要把登录页和静态资源放出去,否则会出现“访问登录页被拦去登录页”的死循环。这是新手最常踩的第一坑:

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; String uri = request.getRequestURI(); // 白名单:登录页、登录接口、静态资源必须放行,不然永远进不去 if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.contains("/css/") || uri.contains("/js/") || uri.contains("/images/")) { 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); // 已登录,交给后面的 Servlet } }

getRequestURI()拿到的是带上下文路径的完整路径,所以判断用endsWith比equals稳,项目部署成/library还是/都不影响。getContextPath()返回项目根路径,重定向时拼上它,否则在非根路径部署时会跳到容器根目录去。session 里存的 key 必须和登录 Servlet 里setAttribute的 key 完全一致,大小写都算,这一点经常因为复制粘贴改漏而翻车。放行名单里如果漏了/js/,页面能打开但样式全丢,看着像 CSS 路径写错,其实是静态资源被 302 了。

3.2 图书列表的模糊查询与分页

列表页是使用频率最高的页面,也是最容易写出性能问题的地方。合格写法是 SQL 层分页,不要一次SELECT *全查出来再在 Java 里 subList。DAO 里两个方法配合:一个查当前页数据,一个查总数:

// 分页查询:keyword 为空时走全量,非空时按书名或作者模糊匹配 public List<Book> page(String keyword, int pageNo, int pageSize) { List<Book> list = new ArrayList<>(); String sql = "SELECT * FROM book WHERE title LIKE ? OR author LIKE ? " + "ORDER BY book_id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + (keyword == null ? "" : keyword.trim()) + "%"; ps.setString(1, like); ps.setString(2, like); ps.setInt(3, (pageNo - 1) * pageSize); // 偏移量从 0 开始,第 1 页是 0 ps.setInt(4, pageSize); // 每页条数,一般 10 或 15 try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(toBook(rs)); // 结果集转实体,字段名要和 SQL 别名对得上 } } } catch (SQLException e) { throw new RuntimeException("图书分页查询失败", e); } return list; } // 总数查询:给分页条算总页数用,条件必须和上面完全一致 public int count(String keyword) { String sql = "SELECT COUNT(*) FROM book WHERE title LIKE ? OR author LIKE ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + (keyword == null ? "" : keyword.trim()) + "%"; ps.setString(1, like); ps.setString(2, like); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt(1) : 0; } } catch (SQLException e) { throw new RuntimeException("图书总数查询失败", e); } }

LIMIT ?, ?的两个参数,第一个是偏移量,公式(pageNo - 1) * pageSize,第二个是每页条数,顺序写反了会出现“第一页只有 1 条、第二页从第 10 条开始”的诡异现象。count方法和page方法的 WHERE 条件必须逐字一致,否则会出现总数 30 条、翻到第 2 页却是空的情况。参数全部走PreparedStatement占位符,既防住了单引号引起的 SQL 报错,也顺手挡了注入;如果看到源码里用字符串拼"WHERE title LIKE '%" + keyword + "%'",那一段建议直接重写。

3.3 借阅与归还:一个必须包在事务里的操作

借一本书要动两张表:borrow_record插一条记录,book的stock减一。这两步必须同生共死,否则会出现“记录有了但库存没减”或者“库存减了但没记录”的脏数据,对账时能把人对到怀疑人生。事务开在 Service 层,用 JDBC 的手动提交:

public boolean borrow(int bookId, int readerId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关掉自动提交,从这里开始一个事务 // 第一步:扣库存。stock > 0 是并发下的兜底,避免扣成负数 String updateSql = "UPDATE book SET stock = stock - 1 WHERE book_id = ? AND stock > 0"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, bookId); if (ps.executeUpdate() == 0) { // 影响 0 行说明库存已空或书不存在 conn.rollback(); return false; } } // 第二步:写借阅记录,应还时间默认借出后 30 天 String insertSql = "INSERT INTO borrow_record(book_id, reader_id, borrow_date, due_date) " + "VALUES (?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY))"; try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, bookId); ps.setInt(2, readerId); ps.executeUpdate(); } conn.commit(); // 两步都成功才提交 return true; } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ignore) { } throw new RuntimeException("借阅失败", e); } finally { try { if (conn != null) conn.setAutoCommit(true); } catch (SQLException ignore) { } DBUtil.close(conn); // 归还连接池,不是真关闭 } }

关键点有三个。setAutoCommit(false)之后,所有的 SQL 都在同一个连接上,所以 DAO 不能自己再去DBUtil.getConnection()拿新连接,否则事务就断了——这是改造这类代码时最容易犯的错,一定要把 Connection 当参数往下传,或者用 ThreadLocal 绑定。update ... where stock > 0这种写法叫乐观校验,两个管理员同时点借阅,只有一个人能成功,另一个拿到影响 0 行,直接提示“库存不足”,比先查再判断稳得多。finally里把 autoCommit 还原再 close,是因为连接池里的连接是复用的,不还原会污染下一次使用。归还的逻辑正好反过来:更新return_date加库存加一,事务结构一模一样,只是 SQL 换掉。

4. 本地跑通:从建库到登录首页的操作步骤

代码读得再明白,跑不起来也白搭。这一章按顺序给出一条最短路径,每一步都标清楚失败时该看哪里。整套流程大概二十分钟,前提是环境别选错版本。

4.1 环境与依赖确认

先确认三件事:JDK 装好并且java -version能输出版本号;Tomcat 解压后bin/startup.bat(或.sh)能起来,浏览器访问localhost:8080能看到默认页;MySQL 服务在跑,能用一个账号登进去。这三样少一样,后面所有报错都会指向错误的方向。

依赖包放在WEB-INF/lib下,一般包括mysql-connector-java-x.x.x.jar、jstl.jar、standard.jar,以及连接池的 jar(Druid 或 C3P0)。如果解压后这个目录是空的,说明源码没带依赖,需要自己从本地仓库或可靠镜像补齐。少 jar 的典型表现是启动时抛ClassNotFoundException,类名一看就知道缺哪个包。

提示:Eclipse 导入后如果项目上有红叉但代码没错,先看 Build Path 里的 JRE 是不是指向了 Tomcat 自带的 JDK,换成自己的 JDK 8 一般就好。

4.2 建库导数据与改配置

先建库再导表。命令行里执行两句就行:

# 登录 MySQL,创建数据库并指定字符集 mysql -u root -p -e "CREATE DATABASE library DEFAULT CHARSET utf8mb4;" # 导入源码 sql 目录下的脚本,注意路径换成自己解压后的实际位置 mysql -u root -p library < ./sql/library.sql

导完用SHOW TABLES;确认四张表都在。如果脚本里带了CREATE DATABASE和USE,第一句可以省掉,直接导就行;如果报Unknown database,说明脚本里没有建库语句,得先手动建。

接下来改配置文件。老一点的版本把连接参数写在src/jdbc.properties,新一点的直接写死在 DBUtil 里,两种都见过:

# 放在 src 根目录,编译后会自动进 WEB-INF/classes jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/library?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=换成你自己的密码

逐项说一下:com.mysql.cj.jdbc.Driver是 MySQL 8 驱动的类名,用 5.x 驱动的话是com.mysql.jdbc.Driver;characterEncoding=utf8解决中文写入变问号;serverTimezone=Asia/Shanghai必须加,不然 MySQL 8 会在启动时报时区错误,日志里那句The server time zone value ... is unrecognized就是它;useSSL=false避免本地连接时的一堆警告;allowPublicKeyRetrieval=true是 8.0 默认认证方式下必须加的,不加会报公钥检索失败。

如果源码里没有 properties 文件,而是有个DBUtil类,那就直接改里面的常量:

public class DBUtil { private static final String URL = "jdbc:mysql://127.0.0.1:3306/library" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false"; private static final String USER = "root"; private static final String PWD = "换成你自己的密码"; // 每次调用取一个连接,连接池实现会在这里做复用 public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // 空实现也要有,方便以后换连接池时上层代码不用动 public static void close(Connection conn) { if (conn != null) { try { conn.close(); } catch (SQLException ignore) { } } } }

URL里的库名必须和上一步建的库一致,改完记得重新编译,WEB-INF/classes下的 class 不更新等于没改。如果源码用的是连接池,配置项名字会变成url、username、password、initialSize这种,语义一样,只是前缀不同。

4.3 部署运行与首次登录

Eclipse 里右键项目 Run on Server,或者手动把整个工程目录拷到 Tomcat 的webapps下,然后启动容器。访问http://localhost:8080/项目名/login.jsp,出现登录框就算成功了一半。

web.xml 里 Filter 的注册长这样,路径和类名要和代码对上:

<filter> <filter-name>loginFilter</filter-name> <filter-class>com.library.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <!-- /* 拦截所有请求,具体放行逻辑在 Java 代码里判断 --> <url-pattern>/*</url-pattern> </filter-mapping>

url-pattern写/*是对的,因为白名单在 Filter 内部处理;如果写成/jsp/*,那直接访问根目录下的页面就绕过了校验。首次登录的账号密码一般在library.sql的reader或admin表的 INSERT 语句里,明文或者 MD5 值都有,明文直接输,MD5 就得查一下对应的原文,别硬猜。登进去之后第一件事是把图书列表、新增图书、借一本书、还一本书这四个动作各点一遍,链路通了再谈改功能——这四步里任何一步报错,都比后面加需求时才发现问题好修得多。

5. 排查清单:这套代码最容易翻车的五个地方

下面五条是我在不同版本的 JavaWeb 图书管理源码上反复遇到的,基本覆盖了八成启动期和运行期故障。按“现象 → 原因 → 解决”记,遇到时对着看就行。

5.1 页面中文全变问号或乱码

现象:登录进去后书名、作者显示成???或者一堆方块,控制台打印的 SQL 参数也是乱码。

原因:三处编码没统一。JSP 页面头部没写pageEncoding="UTF-8";Servlet 取参前没调request.setCharacterEncoding("UTF-8");数据库连接的 URL 里没带characterEncoding=utf8。三者缺一个就会在链路的某一环掉链子。

解决:JSP 顶部补<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,在过滤器里统一加一句request.setCharacterEncoding("UTF-8")(要放在所有getParameter之前),数据库连接串补上编码参数。改完重启容器,别只刷新页面,class 不重编不生效。

5.2 启动就 404,日志里没有明显异常

现象:Tomcat 起来了,访问项目路径返回 404,或者访问具体 Servlet 报“请求的资源不可用”。

原因:多半是访问路径和@WebServlet或 web.xml 里的url-pattern对不上。比如代码里写的是/book/list,你访问的是/bookList;或者项目部署名不是你以为的那个,实际路径是/library-system而你访问了/library。

解决:先看 Tomcat 启动日志里那行Deployment of web application archive ... has finished,里面会打出真实上下文路径。再核对 Servlet 的映射,@WebServlet("/book/list")对应的请求就是项目名/book/list。路径里多一层少一层斜杠也会 404,别嫌麻烦,逐字对。

5.3 数据库连接池报连接超时或连接已关闭

现象:点了几个页面之后开始报Communications link failure或者No operations allowed after connection closed,重启容器又能撑一会儿。

原因:要么连接池的maxActive太小、连接借出去没还;要么代码里在 DAO 里conn.close()之后又拿去用;要么 MySQL 的wait_timeout比连接池的空闲检测短,连接被服务端先掐了。

解决:确认每个getConnection都有对应的 close,最好用 try-with-resources;连接池配置里加上空闲检测(testWhileIdle=true、validationQuery=SELECT 1)和minEvictableIdleTimeMillis;实在拿不准就先把连接池换回 DriverManager 直连,排除池的问题再逐步加回来。

5.4 借完书库存没变,或者能借成负数

现象:借阅记录插进去了,但图书列表里可借数量还是原值;或者连着点两次借阅,库存变成 -1。

原因:扣库存的 UPDATE 和插记录的 INSERT 不在同一个事务里,中间抛了异常但前面已经提交;或者 UPDATE 语句没加stock > 0条件,并发下两个请求都判断通过。

解决:按 3.3 的写法把两条 SQL 放进同一个 Connection 的手动事务,setAutoCommit(false)之后统一 commit;UPDATE 的 WHERE 补上stock > 0,用影响行数判断成功与否;另外检查图书表里stock和total的初始值,有些脚本只填了total,stock默认 0,那就一本都借不出去。

5.5 日期字段插入报错或显示成 1970

现象:新增图书或借阅时抛Incorrect datetime value,或者列表页显示的借出时间全是 1970-01-01。

原因:日期格式对不上。前端传的是2024-05-01这种字符串,后端用new Date()直接塞进datetime字段;或者 JDBC 用的是java.sql.Date但只保留了日期部分,时间被截断;再或者数据库时区和 JVM 时区不一致,差 8 小时。

解决:统一用java.sql.Timestamp或LocalDateTime承载,Servlet 里用SimpleDateFormat("yyyy-MM-dd")显式解析,解析失败给默认值别抛空指针;连接串里加serverTimezone=Asia/Shanghai;页面展示时用 JSTL 的<fmt:formatDate>统一格式,别在 JSP 里手写substring。

6. 二次开发:加一个“预约图书”功能要动哪几个文件

拿到骨架之后,多数人要做的第一件事就是加功能。拿“预约图书”举例,这个需求比借书轻——不扣库存、不改借阅记录,只记一条“某人想借某本暂时借完的书”。改动面不大,但正好能练一遍从建表到页面的完整链路。

先梳理要动的地方:

改动点文件说明
建表sql/library.sql 追加reservation表,记书、人、预约时间、状态
实体bean/Reservation.java四个字段加 getter/setter,照 Book 抄
数据访问dao/ReservationDao.javainsert、按读者查、按状态查三个方法
业务service/ReservationService.java判断是否已预约过、库存是否真的为 0
控制器servlet/ReservationServlet.java收参数、调 service、返回 JSON 或转发
页面jsp/book_list.jsp每行加一个“预约”按钮,库存大于 0 时置灰
注册web.xml 或注解新 Servlet 的映射

控制器这一层保持薄,只做参数收转:

@WebServlet("/reservation/add") public class ReservationServlet extends HttpServlet { private final ReservationService service = new ReservationService(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 读者身份从 session 取,不要从请求参数取,否则谁都能替别人预约 Integer readerId = (Integer) request.getSession().getAttribute("readerId"); String bookIdStr = request.getParameter("bookId"); response.setContentType("application/json;charset=UTF-8"); if (readerId == null || bookIdStr == null) { response.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}"); return; } try { int bookId = Integer.parseInt(bookIdStr); // 非数字会抛异常,统一兜住 boolean ok = service.reserve(bookId, readerId); response.getWriter().write(ok ? "{\"code\":0,\"msg\":\"预约成功\"}" : "{\"code\":1,\"msg\":\"库存充足或已预约过\"}"); } catch (NumberFormatException e) { response.getWriter().write("{\"code\":400,\"msg\":\"参数不合法\"}"); } } }

业务层的判断顺序建议是:先查该读者对该书是否有未完成的预约(有就直接返回,别重复插),再查库存是否真的大于 0(大于 0 说明能直接借,不该走预约),两条都过了才 insert。这个顺序反了会出现“书明明有货还被预约”的怪状态。

验证方法很土但有效:把某本书的stock手动改成 0,然后在页面上点预约,看数据库里有没有多一条状态为待处理的记录;再把stock改回 1,点预约应该被拒;最后用同一个人点两次,第二次应该提示已预约。三个用例全过,这条链路就算接进去了。页面上的按钮建议按库存做禁用,前端拦一道只是体验,后端那两道判断才是真正的保险。

从那以后我每接一套陌生的 JavaWeb 源码,都强制自己先把登录链路和一条写操作完整走通,再动任何一行业务代码——跳过这步直接改功能,后面花在排查环境上的时间会成倍还回来。这套图书馆管理系统的价值也在这里:它的分层足够朴素,朴素到你可以在上面放心地加任何东西。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进