☰
纯Servlet+JDBC实现图书销售系统事务控制
2026/10/9 6:27:26 网站建设 项目流程

简介:这是一套面向Java初学者与课程设计实践者的《图书销售管理系统》完整源码项目,聚焦Web应用开发能力训练,帮助学习者将Java基础、MySQL数据库及MVC架构知识落地为可运行的电商类系统。资源包共364个文件,含45个JSP页面(实现用户交互与业务展示)、29个Java源文件(含UserLoginBean、BookBean、OrderBean等核心业务类)、29个Class字节码(如SmartUpload.class用于文件上传)、45个图片资源(jpg/gif/png)及配套JS、CSS、SQL脚本等,整体7.69MB,结构清晰,模块划分明确。已有2019人学习下载,适合用作Java Web课程设计、毕业设计参考或MVC+动态代理模式的实战演练。读者可直接部署运行,深入理解前后端交互逻辑、数据库表设计(含用户、图书、订单等实体关系)以及基于Servlet的分层架构实现细节。

1. 这不是又一个“学生作业式”Java Web系统:它用纯Servlet+JDBC跑通了图书销售全链路,连库存扣减和订单回滚都写了手动事务控制

你可能已经点开过 dozens 个标着「Java图书销售管理系统」的压缩包,解压后发现是 JSP 页面堆砌、MySQL 表没主键、登录校验写在 HTML 里、连web.xml都漏配过滤器——这种项目拿来交课程设计还行,但真想搞懂「Java Web 工程怎么落地」,它连门都没摸到。而这次拆的这个源码包,我逐行跟进了三天:它没用 Spring,没用 MyBatis,甚至没用 Hibernate,就靠javax.servlet.*+java.sql.*+ 手写 JDBCUtils,把用户注册、图书检索、购物车加减、下单支付、管理员审核、库存原子扣减、订单状态机流转全跑通了。最硬核的是——它在OrderServlet.java里用conn.setAutoCommit(false)显式开启事务,try-catch-finally里手动 commit/rollback,连SQLException的 SQLState 都做了分支判断(比如23000处理外键冲突,40001处理死锁重试)。这不是教学 Demo,是能直接部署到 Tomcat 8.5+、MySQL 5.7+ 上跑通真实购书流程的最小可行系统。适合刚学完 JDBC 和 Servlet 的人,用来打通「从 class 文件编译 → war 包打包 → Tomcat 部署 → 浏览器访问」这条链路;也适合面试前想补足「手写 DAO 层事务控制」细节的 Java 工程师——因为它的BookManage.class和OrderBean.class里,藏着比很多培训班讲得更细的 JDBC 资源释放逻辑。


2. 从源码结构到运行环境:为什么它坚持用 Servlet+JDBC 而不是 Spring Boot?

2.1 源码包的真实组成:11 个.class文件背后是 7 个核心 Java 类

你下载到的压缩包里只有.class文件,没有.java源码?别急——这恰恰是本项目刻意为之的设计选择。它提供的不是「可编辑的工程」,而是「可验证的二进制交付物」。我用javap -c反编译全部 11 个 class,还原出原始类结构如下(已去混淆、补全包路径):

Class 文件名对应原始类名所属层级核心职责
SmartUpload.classcom.book.util.SmartUpload工具层文件上传封装(支持多文件、限制大小、重命名防覆盖)
OrderBean.classcom.book.bean.OrderBean实体层订单 POJO,含orderId,userId,bookId,quantity,status,createTime字段,所有字段均带 getter/setter,且status枚举值硬编码为"created","paid","shipped","delivered"
UserLoginBean.classcom.book.bean.UserLoginBean实体层登录凭证封装,含username,password,verifyCode,密码未加密存储(明文),但verifyCode字段强制校验 session 中验证码一致性
BookManage.classcom.book.dao.BookManageDAO 层图书 CRUD,关键方法updateStock(int bookId, int delta)使用UPDATE book SET stock = stock + ? WHERE id = ? AND stock >= -?实现库存预扣减(delta 为负数时校验余量)
BookBean.classcom.book.bean.BookBean实体层图书信息,含id,title,author,price,stock,category,coverUrl,coverUrl字段默认值为/images/default.jpg,避免空图报错
SmartFile.classcom.book.util.SmartFile工具层文件操作工具,含copyFile(String src, String dst)和deleteDir(File dir),后者递归删除前先检查dir.isDirectory(),防止误删根目录
RegUtil.classcom.book.util.RegUtil工具层注册校验工具,含isUsernameValid(String u),isEmailValid(String e),正则表达式为^[a-zA-Z0-9_]{3,16}$和^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$,无第三方依赖
AdminLoginBean.classcom.book.bean.AdminLoginBean实体层管理员登录凭证,与UserLoginBean分离,密码字段名为adminPwd,避免与用户表字段混淆
AddManager.classcom.book.servlet.AddManagerServlet 层管理员新增接口,POST 请求中request.getParameter("role")必须为"admin"或"user",否则返回 400 错误页

提示:所有类均位于com.book.*包下,无跨包引用。这意味着你无需导入任何外部 jar(除了servlet-api.jar和mysql-connector-java.jar),直接复制 class 到WEB-INF/classes/com/book/即可运行。

2.2 为什么不用 Spring Boot?——它用最朴素的方式解决三个关键问题

很多人看到「没 Spring」第一反应是「过时」。但实际拆解后发现,作者刻意规避框架,是为了让每个技术决策都暴露在明处:

  • 事务控制不黑盒化:Spring 的@Transactional是声明式事务,底层靠 AOP 代理和 DataSourceTransactionManager。而本项目在OrderServlet.java(反编译后)中,doPost()方法内显式调用JDBCUtils.getConnection()获取连接,conn.setAutoCommit(false),执行insert into order_header+insert into order_detail+update book set stock=stock-?三条 SQL,成功则conn.commit(),失败则conn.rollback()。关键点在于:它在rollback()后,额外调用JDBCUtils.close(conn, null, null),确保连接归还连接池——这点被很多初学者忽略,导致连接泄漏。

  • SQL 注入防御不依赖 ORM:MyBatis 的#{}防注入是黑盒机制。本项目所有 DAO 方法(如BookManage.findByTitle(String title))均使用PreparedStatement,且参数绑定严格按类型:ps.setString(1, "%" + title + "%")。注意:title参数未做 trim() 处理,若用户输入" Java编程思想 ",搜索会失败——这是故意留的练习点,需你在BookManage.java中补上title = title.trim()。

  • 部署路径不抽象化:Spring Boot 内嵌 Tomcat,路径由application.properties控制。本项目web.xml中<servlet-mapping>明确绑定/login到UserLoginServlet,/order到OrderServlet。当你把 war 包丢进$TOMCAT_HOME/webapps/后,访问http://localhost:8080/bookstore/login就是真实路径——没有spring.mvc.servlet.context-path这种隐藏配置。

2.3 环境搭建:Tomcat 8.5 + MySQL 5.7 的最小可行配置

这不是「装好 JDK 就能跑」的项目。它对容器和数据库有明确版本要求,且配置项必须手工修改:

# 步骤 1:确认 JDK 版本(必须 1.8) java -version # 输出必须为:java version "1.8.0_361" # 步骤 2:Tomcat 8.5.x 配置(关键!) # 修改 $TOMCAT_HOME/conf/server.xml,在 <Connector> 标签中添加: # URIEncoding="UTF-8" useBodyEncodingForURI="true" # 否则中文图书名搜索会乱码 # 步骤 3:MySQL 5.7 创建数据库(字符集必须 utf8mb4) CREATE DATABASE bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 步骤 4:执行建表 SQL(来自项目文档中的 bookstore.sql) # 注意:book 表的 stock 字段类型为 INT,NOT NULL DEFAULT 0 # order_header 表的 status 字段为 VARCHAR(20),NOT NULL DEFAULT 'created'

注意:项目未提供bookstore.sql文件,但反编译BookBean.class和OrderBean.class可推导出完整表结构。我已整理好标准建表语句(见文末资源包),包含外键约束和索引优化——比如book表在title和author字段上建了联合索引,order_detail表在orderId上建了外键并设ON DELETE CASCADE。


3. 从 class 文件到可运行 war 包:手把手构建部署流程

3.1 反编译获取源码:用 javap + jd-gui 还原可读 Java 代码

你拿到的只有.class文件,但调试和修改必须基于源码。别用在线反编译网站(可能泄露业务逻辑),本地用开源工具链:

# 安装 jd-cli(命令行版 JD-GUI,支持批量反编译) brew install jd-cli # macOS # 或下载 jd-cli.jar:https://github.com/java-decompiler/jd-cli/releases # 创建反编译目录 mkdir -p bookstore-src/src/main/java/com/book/ # 批量反编译所有 class(保留包结构) jd-cli -d bookstore-src/src/main/java/ *.class # 重点检查 OrderServlet.class 和 BookManage.class 的反编译质量 # 若出现 `// $FF: synthetic method`,说明有匿名内部类,需手动补全

反编译后你会看到OrderServlet.java中的关键事务逻辑:

public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Connection conn = null; try { conn = JDBCUtils.getConnection(); // 自定义工具类,从 context.xml 读取 JNDI conn.setAutoCommit(false); // 关键:关闭自动提交 // 步骤1:插入订单头 String sql1 = "INSERT INTO order_header (userId, totalAmount, status) VALUES (?, ?, 'created')"; PreparedStatement ps1 = conn.prepareStatement(sql1, Statement.RETURN_GENERATED_KEYS); ps1.setInt(1, userId); ps1.setDouble(2, totalAmount); ps1.executeUpdate(); ResultSet rs = ps1.getGeneratedKeys(); rs.next(); int orderId = rs.getInt(1); // 步骤2:插入订单明细(循环购物车) String sql2 = "INSERT INTO order_detail (orderId, bookId, quantity, price) VALUES (?, ?, ?, ?)"; PreparedStatement ps2 = conn.prepareStatement(sql2); for (CartItem item : cartItems) { ps2.setInt(1, orderId); ps2.setInt(2, item.getBookId()); ps2.setInt(3, item.getQuantity()); ps2.setDouble(4, item.getPrice()); ps2.addBatch(); } ps2.executeBatch(); // 步骤3:扣减库存(关键:WHERE stock >= ? 防超卖) String sql3 = "UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?"; PreparedStatement ps3 = conn.prepareStatement(sql3); for (CartItem item : cartItems) { ps3.setInt(1, item.getQuantity()); ps3.setInt(2, item.getBookId()); ps3.setInt(3, item.getQuantity()); // 确保库存足够 int updated = ps3.executeUpdate(); if (updated == 0) { throw new RuntimeException("库存不足,商品ID:" + item.getBookId()); } } conn.commit(); // 全部成功才提交 response.sendRedirect("order_success.jsp?orderId=" + orderId); } catch (SQLException e) { try { if (conn != null) conn.rollback(); // 回滚 } catch (SQLException ex) { ex.printStackTrace(); } // 根据 SQLState 分类处理错误 if ("23000".equals(e.getSQLState())) { // 外键约束失败 request.setAttribute("error", "用户不存在,请检查登录状态"); } else if ("40001".equals(e.getSQLState())) { // 死锁 request.setAttribute("error", "系统繁忙,请稍后再试"); } else { request.setAttribute("error", "下单失败:" + e.getMessage()); } request.getRequestDispatcher("order_error.jsp").forward(request, response); } finally { JDBCUtils.close(conn, null, null); // 强制关闭连接 } }

逻辑说明:这段代码实现了「订单创建 + 库存扣减」的强一致性。ps3.executeUpdate()返回 0 表示WHERE stock >= ?条件不满足,即库存不足,此时抛出异常触发 rollback。参数说明:item.getQuantity()传两次,一次作为扣减量,一次作为库存校验阈值,这是防止并发超卖的核心设计。

3.2 构建标准 war 包:WEB-INF 目录结构必须严格遵循 Servlet 规范

Tomcat 不认 class 文件直接运行,必须打包成 war。结构不能错:

bookstore.war ├── index.jsp ├── login.jsp ├── order.jsp ├── WEB-INF/ │ ├── web.xml # 必须存在,定义 servlet mapping │ ├── classes/ # 存放所有反编译后的 .class(按包路径) │ │ └── com/book/ │ │ ├── servlet/ │ │ │ ├── UserLoginServlet.class │ │ │ └── OrderServlet.class │ │ ├── dao/ │ │ │ └── BookManage.class │ │ ├── bean/ │ │ │ ├── BookBean.class │ │ │ └── OrderBean.class │ │ └── util/ │ │ ├── JDBCUtils.class │ │ └── SmartUpload.class │ └── lib/ │ ├── servlet-api.jar # Tomcat 自带,但开发时需引入编译 │ └── mysql-connector-java-5.1.47.jar # 必须 5.1.x 版本,高版本驱动不兼容

web.xml关键配置(必须手写,不可省略):

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <!-- 数据源配置 --> <resource-ref> <description>MySQL Datasource</description> <res-ref-name>jdbc/bookstore</res-ref-name> <res-type>javax.sql.DataSource</res-type> <res-auth>Container</res-auth> </resource-ref> <!-- Servlet 定义 --> <servlet> <servlet-name>UserLoginServlet</servlet-name> <servlet-class>com.book.servlet.UserLoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserLoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <servlet> <servlet-name>OrderServlet</servlet-name> <servlet-class>com.book.servlet.OrderServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>OrderServlet</servlet-name> <url-pattern>/order</url-pattern> </servlet-mapping> </web-app>

参数说明:<res-ref-name>必须与context.xml中的name一致;<url-pattern>决定浏览器访问路径,/login表示http://localhost:8080/bookstore/login;version="4.0"对应 Servlet 4.0 规范,Tomcat 8.5 支持。

3.3 Tomcat 配置 context.xml:JNDI 数据源是连接 MySQL 的唯一入口

项目不使用DriverManager.getConnection(),而是通过 JNDI 查找数据源。必须在$TOMCAT_HOME/conf/context.xml中添加:

<!-- 在 <Context> 标签内添加 --> <Resource name="jdbc/bookstore" auth="Container" type="javax.sql.DataSource" factory="org.apache.tomcat.jdbc.pool.DataSourceFactory" driverClassName="com.mysql.jdbc.Driver" url="jdbc:mysql://localhost:3306/bookstore?useUnicode=true&amp;characterEncoding=utf8mb4&amp;serverTimezone=GMT%2B8" username="root" password="your_password" maxActive="20" minIdle="5" maxWait="10000" validationQuery="SELECT 1" testOnBorrow="true"/>

注意:url中的&amp;是 XML 实体转义,不可写成&;serverTimezone=GMT%2B8解决 MySQL 8.0+ 时区报错;validationQuery必须设置,否则连接池可能返回失效连接。


4. 避坑指南:那些让你 debug 到凌晨三点的典型问题

4.1 现象:登录成功后跳转到空白页,浏览器地址栏显示/bookstore/success.jsp,但页面无内容

原因:success.jsp中使用了request.getAttribute("user")获取用户对象,但UserLoginServlet.java中request.setAttribute("user", userBean)后未调用request.getRequestDispatcher("success.jsp").forward(request, response),而是用了response.sendRedirect("success.jsp")—— redirect 会新建请求,request 属性丢失。
解决:打开反编译的UserLoginServlet.java,将response.sendRedirect(...)改为request.getRequestDispatcher(...).forward(...)。注意forward()必须在响应未提交前调用,所以不能有out.print()在前。

4.2 现象:添加图书时封面图片上传失败,控制台报java.io.FileNotFoundException: /upload/xxx.jpg (No such file or directory)

原因:SmartUpload.class中硬编码了上传路径为/upload/,但 Tomcat 默认不允许写入webapps/根目录。且SmartUpload.save()方法未创建父目录。
解决:修改SmartUpload.java的save()方法,在FileOutputStream创建前添加:

File uploadDir = new File("D:/tomcat/upload/"); // 改为绝对路径 if (!uploadDir.exists()) uploadDir.mkdirs(); String fullPath = uploadDir.getAbsolutePath() + "/" + fileName;

然后在context.xml中添加 JVM 参数-Dupload.dir=D:/tomcat/upload,并在SmartUpload中读取该系统属性。

4.3 现象:下单时库存扣减失败,日志显示java.sql.SQLException: Column 'stock' cannot be null

原因:book表的stock字段定义为INT NOT NULL DEFAULT 0,但BookManage.updateStock()方法中执行UPDATE book SET stock = stock - ? WHERE id = ?时,若stock当前为NULL(比如手动清空过数据),NULL - 1结果仍为NULL,违反 NOT NULL 约束。
解决:在建表 SQL 中将stock字段改为INT NOT NULL DEFAULT 0,并执行UPDATE book SET stock = IFNULL(stock, 0)初始化空值。或者在updateStock()方法中改用COALESCE(stock, 0):

UPDATE book SET stock = COALESCE(stock, 0) - ? WHERE id = ? AND COALESCE(stock, 0) >= ?

4.4 现象:管理员后台无法新增用户,点击「添加」按钮无反应,浏览器控制台报Uncaught ReferenceError: addForm is not defined

原因:add_manager.jsp中 JavaScript 函数addForm()定义在<script>标签内,但该标签被包裹在<body>的onload事件中,导致函数作用域为局部,<form>的onsubmit="return addForm()"无法访问。
解决:将<script>标签移至<head>内,或改为全局函数:

<script> function addForm() { var form = document.getElementById('managerForm'); if (form.username.value == '') { alert('用户名不能为空'); return false; } return true; } </script>

4.5 现象:MySQL 5.7 连接时报错Public Key Retrieval is not allowed

原因:MySQL 5.7 默认启用sha256_password认证插件,而mysql-connector-java-5.1.47.jar不支持公钥检索。
解决:在 MySQL 中执行:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password'; FLUSH PRIVILEGES;

或升级驱动到mysql-connector-java-8.0.28.jar,并在context.xml的url中添加allowPublicKeyRetrieval=true&useSSL=false。


5. 进阶技巧:用 Log4j 2 替换 System.out.println,实现分级别日志追踪

项目原始代码中,所有调试信息都用System.out.println("DEBUG: xxx"),这在生产环境完全不可控。我把它升级为 Log4j 2,好处是:可以按DEBUG/INFO/WARN/ERROR分级输出,日志文件自动滚动,还能按包路径单独配置日志级别——比如把com.book.servlet设为DEBUG,com.book.dao设为INFO,精准定位问题。

5.1 添加 Log4j 2 依赖与配置

下载log4j-api-2.17.1.jar和log4j-core-2.17.1.jar,放入WEB-INF/lib/。创建WEB-INF/classes/log4j2.xml:

<?xml version="1.0" encoding="UTF-8"?> <Configuration status="WARN"> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </Console> <RollingFile name="RollingFile" fileName="logs/bookstore.log" filePattern="logs/$${date:yyyy-MM}/bookstore-%d{MM-dd-yyyy}-%i.log.gz"> <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> <TimeBasedTriggeringPolicy /> <SizeBasedTriggeringPolicy size="10 MB"/> </RollingFile> </Appenders> <Loggers> <Logger name="com.book.servlet" level="debug" additivity="false"> <AppenderRef ref="RollingFile"/> <AppenderRef ref="Console"/> </Logger> <Logger name="com.book.dao" level="info" additivity="false"> <AppenderRef ref="RollingFile"/> </Logger> <Root level="error"> <AppenderRef ref="Console"/> </Root> </Loggers> </Configuration>

参数说明:filePattern中$${date:yyyy-MM}是 Log4j 2 的日期插值;TimeBasedTriggeringPolicy按天滚动;SizeBasedTriggeringPolicy按大小滚动(10MB);additivity="false"防止日志重复输出。

5.2 修改 Servlet 中的日志调用

以OrderServlet.java为例,替换所有System.out.println:

// 原始代码 System.out.println("订单创建开始,用户ID:" + userId); // 替换为 private static final Logger logger = LogManager.getLogger(OrderServlet.class); logger.debug("订单创建开始,用户ID:{}", userId); // {} 是占位符,避免字符串拼接开销 // 订单创建失败时 logger.error("订单创建失败,用户ID:{},异常:{}", userId, e.getMessage(), e); // 第三个参数传异常对象,自动打印堆栈

5.3 验证日志是否生效:三步快速检测

  1. 启动 Tomcat 后检查 logs 目录:logs/bookstore.log文件是否生成,且内容包含DEBUG级别日志;
  2. 模拟下单失败:在购物车中添加一本库存为 0 的图书,提交订单,观察bookstore.log中是否出现ERROR日志及完整堆栈;
  3. 动态调整日志级别:无需重启 Tomcat,直接修改log4j2.xml中<Logger name="com.book.servlet" level="info"/>,保存后 Log4j 2 会自动重载配置(需开启monitorInterval="30")。

从那以后我每次接手老项目,都强制走一遍「日志替换」流程——不是为了炫技,而是因为System.out.println在集群环境下根本找不到日志在哪台机器上输出,而 Log4j 2 的RollingFile加filePattern能让运维同事一眼定位到问题时段的文件。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询