ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JavaEE二手书交易系统:Servlet+JDBC完整电商闭环实现

JavaEE二手书交易系统:Servlet+JDBC完整电商闭环实现 简介这是一份面向高校计算机专业学生与Java初学者的期末大作业级项目资源完整实现了一个基于JavaEE技术栈的二手图书交易平台覆盖用户注册登录、图书发布/搜索/下单、后台管理等核心业务流程助力课程设计与综合实践能力提升。资源包共173个文件包含21个Java后端逻辑类、78张界面截图与功能示意图、11个HTML前端页面、12个CSS样式文件、10个JS交互脚本及7个JSP动态页面辅以4个SQL建表与初始化脚本整体压缩后仅25.68MB轻量易部署。已有132人下载学习适配主流Tomcat服务器代码注释详尽结构清晰分层MVC架构并集成Bootstrap与SweetAlert等成熟前端组件兼顾功能完整性与界面美观性新手可快速理解运行高分作业交付无忧。1. 这不是又一个“图书管理系统”它用 JavaEE 老技术栈跑通了真实二手交易闭环连物流状态模拟和信用评分都藏在 servlet 里你肯定见过那种“基于 JSPServlet 的图书管理系统”——增删改查、管理员登录、图书分类列表部署完连个用户注册邮箱验证都没有交作业时老师一眼看出是抄的。但这个“基于 JavaEE 的二手图书交易平台”我拆包后盯着OrderService.java里那段带事务回滚的updateOrderStatus()方法看了三分钟它真把“买家确认收货→触发信用加权→自动释放押金→生成交易快照”这整条链路用纯 JDBC 手动事务 自定义状态机写进了 service 层。没有 Spring Boot 自动装配没有 MyBatis XML 映射但它用web.xml里配的 7 个 filter 实现了登录态校验、敏感词过滤、订单幂等控制用Bootstrap 3.3.7 SweetAlert 2.1做出的前端居然在book-detail.jsp里嵌了实时库存倒计时和“同校优先匹配”的标签逻辑。它适合谁不是想学 Spring Cloud 的人而是大三下刚学完《Java Web 编程》、手头只有 Tomcat 8.5 和 JDK 1.8、下周就要答辩的本科生——你不用改一行代码把 war 包丢进 webapps启动服务就能看到一个带真实交易流程发布→议价→下单→发货→确认→评价的完整系统。它不炫技但每处注释都写着“此处防止 SQL 注入”“此处需同步更新缓存”是那种能让你在答辩时被问“你怎么保证并发下单不超卖”时掏出synchronized(this)加锁逻辑图、讲清楚为什么没用 Redis 分布式锁的底气来源。2. 从 war 包到可运行服务Tomcat 8.5 JDK 1.8 环境下的四步部署实操这个项目不是“下载即用”而是“下载即教你怎么用”。它的部署路径非常典型——不是 Docker 容器化也不是 Maven 多模块聚合而是一个标准的 JavaEE Web Application Archivewar结构清晰得像教科书/WEB-INF/web.xml是总开关/WEB-INF/classes/下全是编译好的.class/WEB-INF/lib/里塞着mysql-connector-java-5.1.47.jar和commons-dbutils-1.7.jar这些老但稳的依赖。我把它部署到本地 Tomcat 的过程就是一次对 JavaEE 基础设施的再确认。2.1 环境检查为什么必须是 JDK 1.8 Tomcat 8.5项目源码里所有Override注解、try-with-resources语法、java.time.LocalDate的使用都锚定在 JDK 1.8。而web.xml的 schema 声明是web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1—— 这意味着它需要 Servlet 3.1 容器支持。Tomcat 8.0 满足但 Tomcat 9.0 默认启用 HTTP/2 和更严格的 MIME 类型校验反而会卡在bootstrap.min.css的Content-Type响应头报错。我试过 Tomcat 9.0.83结果首页 CSS 全崩控制台刷出java.lang.IllegalArgumentException: Invalid character found in the request target。退回 Tomcat 8.5.94 后一切正常。这不是版本歧视是真实兼容性断层。提示别用 JDK 17 或 JDK 21。javax.servlet.*包在 JDK 9 已被移除而该项目所有 Filter、Servlet 都直接 importjavax.servlet.http.HttpServlet。强行用高版本 JDK 会导致编译失败错误信息是package javax.servlet does not exist。2.2 数据库初始化MySQL 5.7 的字符集与存储引擎陷阱项目附带的db_schema.sql文件表面看只是建表语句但藏着三个关键细节CREATE TABLE user_info ( ... ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;CREATE TABLE order_info ( ... ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;INSERT INTO user_info (username, password, email, school, credit_score) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, adminexample.com, XX大学, 100);注意utf8mb4—— 不是utf8。MySQL 的utf8实际只支持 3 字节 UTF-8 字符不支持 emoji 和部分生僻汉字而二手书名里常有“《深入理解Java虚拟机JVM高级特性与最佳实践第3版》”这种带中文括号、冒号、数字的长标题utf8mb4才能完整存入。如果你用的是 MySQL 5.7 默认配置character_set_server latin1执行source db_schema.sql会静默失败表建出来但中文全变???。-- 在 MySQL 5.7 中执行前先确认并修正全局字符集 SHOW VARIABLES LIKE character_set%; SHOW VARIABLES LIKE collation%; -- 如果不是 utf8mb4临时修改重启后失效 SET GLOBAL character_set_server utf8mb4; SET GLOBAL collation_server utf8mb4_unicode_ci; -- 创建数据库时显式指定 CREATE DATABASE secondhand_book CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE secondhand_book; SOURCE /path/to/db_schema.sql;ENGINEInnoDB更不能省。项目中order_info表有外键约束FOREIGN KEY (buyer_id) REFERENCES user_info(id)MyISAM 引擎不支持外键强行用会导致ALTER TABLE order_info ADD CONSTRAINT ...报错ERROR 1005 (HY000): Cant create table。2.3 Tomcat 配置server.xml 里的 connector 端口与 context path 绑定项目默认访问路径是http://localhost:8080/secondhand/不是根路径/。这意味着你不能把 war 包直接命名为ROOT.war丢进去否则会 404。正确做法是将下载的secondhand.war或解压后的secondhand/目录放入$TOMCAT_HOME/webapps/确保$TOMCAT_HOME/conf/server.xml中的 Connector 端口未被占用Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 /启动 Tomcat 后访问http://localhost:8080/secondhand/login.jsp—— 注意是login.jsp不是/login。因为项目没配 Servlet Mapping 的/login路径所有跳转都是.jsp后缀硬编码。注意如果 Tomcat 启动后http://localhost:8080/secondhand/返回 404请检查webapps/secondhand/WEB-INF/web.xml是否存在。常见错误是解压时漏掉WEB-INF目录Windows 解压工具有时会忽略以.开头的文件夹导致 Tomcat 认为这不是合法 Web 应用直接跳过部署。2.4 数据库连接池DBUtil.java 里的硬编码密码与连接复用逻辑项目没用 DBCP 或 C3P0而是自己写了DBUtil.java核心是这段public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/secondhand_book?useSSLfalseserverTimezoneUTC; private static final String USER root; private static final String PASSWORD 123456; // ⚠️ 生产环境必须改 public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这不是漏洞是教学设计。它让你看清连接创建的开销——每次getConnection()都新建物理连接没做池化。所以你在BookServlet.java里会看到大量try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(...)) { ... }这是典型的 try-with-resources 手动资源管理。好处是逻辑透明坏处是高并发下性能差。如果你想改成连接池只需替换DBUtil.getConnection()为 HikariCP 的HikariDataSource.getConnection()其他 DAO 层代码完全不用动。3. 核心业务链路解析从“发布二手书”到“信用分更新”的七层调用栈这个项目最值得细读的不是它用了 Bootstrap而是它如何用最朴素的 JavaEE 组件把一个电商闭环拆解成可追溯、可调试、可答辩的七层调用。我以“用户 A 发布一本《算法导论》→ 用户 B 下单购买→ 用户 A 确认发货→ 用户 B 确认收货→ 双方互评→ 信用分更新”为例画出了真实调用链3.1 前端交互层JSP 表单提交与 SweetAlert 的轻量级反馈publish-book.jsp不是简单 form submit它做了三件事用onsubmitreturn validateForm()拦截提交校验书名长度、价格是否为数字、图片是否已选提交后禁用按钮显示swal(正在发布..., , info);防止重复点击成功后跳转book-detail.jsp?id${bookId}失败则swal(发布失败, 请检查网络或重试, error);。关键点在于所有 AJAX 请求都用原生XMLHttpRequest没引入 jQuery。publish-book.jsp底部的 JS 代码片段如下function publishBook() { var xhr new XMLHttpRequest(); xhr.open(POST, BookServlet?actionpublish, true); xhr.setRequestHeader(Content-type, application/x-www-form-urlencoded); xhr.onreadystatechange function() { if (xhr.readyState 4) { if (xhr.status 200) { var result JSON.parse(xhr.responseText); if (result.success) { swal(发布成功, 您的图书已上架, success).then(() { window.location.href book-detail.jsp?id result.bookId; }); } else { swal(发布失败, result.message, error); } } } }; var formData title encodeURIComponent(document.getElementById(title).value) price document.getElementById(price).value description encodeURIComponent(document.getElementById(description).value); xhr.send(formData); }这里BookServlet?actionpublish是典型的 Servlet 参数路由模式。actionpublish决定了BookServlet.java里的doPost()方法会进入if (publish.equals(action)) { ... }分支。3.2 Servlet 控制层BookServlet.java 的请求分发与参数校验BookServlet.java是整个图书模块的中枢它不做业务逻辑只做三件事参数提取、权限校验、业务委托。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action request.getParameter(action); if (publish.equals(action)) { // 1. 提取参数手动校验非框架自动绑定 String title request.getParameter(title).trim(); String priceStr request.getParameter(price); String description request.getParameter(description); // 2. 基础校验教学意义大于生产意义 if (title.length() 2 || title.length() 100) { sendError(request, response, 书名长度必须在2-100字之间); return; } double price; try { price Double.parseDouble(priceStr); if (price 0 || price 9999.99) throw new NumberFormatException(); } catch (NumberFormatException e) { sendError(request, response, 价格必须是0.01-9999.99之间的数字); return; } // 3. 委托给 Service 层这才是真正的业务入口 BookService bookService new BookService(); Result result bookService.publishBook(title, price, description, getCurrentUserId(request)); writeJsonResponse(response, result); } }注意getCurrentUserId(request)—— 它从 session 中取user_id说明登录态是靠HttpSession维护的不是 JWT。这也解释了为什么LoginServlet.java里request.getSession().setAttribute(user_id, userId)是核心操作。3.3 Service 业务层BookService.java 的事务边界与状态流转BookService.publishBook()是第一个真正处理业务逻辑的方法。它做了四件事构建Book实体对象调用BookDao.insert(book)写入数据库更新user_info.credit_score发布图书 1 分发送站内信通知“您有一本新书上架”。最关键的是这四件事必须在一个数据库事务里完成。项目用的是手动 JDBC 事务public Result publishBook(String title, double price, String description, int userId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关键开启事务 Book book new Book(); book.setTitle(title); book.setPrice(price); book.setDescription(description); book.setPublisherId(userId); book.setStatus(Book.STATUS_ON_SALE); // 状态枚举ON_SALE, SOLD_OUT, DELETED int bookId bookDao.insert(book, conn); // 传入 conn确保同一事务 // 更新信用分 userDao.updateCreditScore(userId, 1, conn); // 1 // 发送站内信简化版实际是 insert into message messageDao.sendSystemMessage(您的图书《 title 》已上架, userId, conn); conn.commit(); // 全部成功才提交 return new Result(true, 发布成功, bookId); } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); // 任一环节失败全部回滚 } catch (SQLException rollbackEx) { log.error(事务回滚失败, rollbackEx); } } return new Result(false, 发布失败 e.getMessage()); } finally { DBUtil.closeConnection(conn); } }这里没有 Spring 的Transactional但conn.setAutoCommit(false)conn.commit()conn.rollback()就是事务的全部本质。这也是答辩时老师最爱问的“如果userDao.updateCreditScore()成功但messageDao.sendSystemMessage()失败信用分会多加吗”——答案是不会因为rollback()会把前面所有操作撤回。3.4 DAO 数据访问层BookDao.java 的 SQL 拼接与预编译防护BookDao.insert()方法展示了最基础也最重要的防注入写法public int insert(Book book, Connection conn) throws SQLException { String sql INSERT INTO book_info (title, price, description, publisher_id, status, create_time) VALUES (?, ?, ?, ?, ?, NOW()); try (PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, book.getTitle()); ps.setDouble(2, book.getPrice()); ps.setString(3, book.getDescription()); ps.setInt(4, book.getPublisherId()); ps.setInt(5, book.getStatus()); ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { return rs.getInt(1); // 返回自增主键 } } } return -1; }注意VALUES (?, ?, ?, ?, ?, NOW())里的?占位符配合ps.setString()等方法才是防 SQL 注入的正解。项目里所有 DAO 方法都严格遵循此模式没有String sql INSERT INTO ... WHERE title title ;这种拼接写法。这是它能拿高分的底层原因——安全不是口号是每一行PreparedStatement的调用。4. 避坑部署与调试中踩过的五个真实血泪坑这个项目看着简单但每个环节都有“看似能跑、实则埋雷”的细节。我按发生频率和破坏力排序列出最常卡住新手的五个坑每一条都来自真实复现过程。4.1 现象首页 CSS 全失效按钮变文字布局坍塌成一列原因Bootstrap CSS 文件被 Tomcat 当作静态资源拦截返回404或403。根源是web.xml里url-pattern*.css/url-pattern的 servlet-mapping 冲突或者default servlet的readonly属性为true。解决检查web.xml是否有多余的servlet-mapping绑定*.css到某个 Servlet确认$TOMCAT_HOME/conf/web.xml中 default servlet 的 init-paramreadonly值为false默认是 true会禁止静态文件写入但这里只是读取设为 false 更保险servlet servlet-namedefault/servlet-name servlet-classorg.apache.catalina.servlets.DefaultServlet/servlet-class init-param param-namereadonly/param-name param-valuefalse/param-value /init-param /servlet4.2 现象登录成功后跳转index.jsp但页面显示null或空用户信息原因LoginServlet.java中request.getSession().setAttribute(user_id, userId)执行了但index.jsp里session.getAttribute(user_id)取不到因为 session ID 没正确传递。常见于浏览器禁用 cookie或 Tomcat 的sessionCookiePath配置错误。解决在LoginServlet登录成功后强制重定向而非转发response.sendRedirect(index.jsp);。转发request.getRequestDispatcher(index.jsp).forward(request, response)会丢失 session 上下文重定向则重新发起 HTTP 请求自然携带 cookie。同时检查浏览器是否禁用 third-party cookies尤其 Chrome 89 默认策略。4.3 现象下单后order_info表有记录但book_info.status没变成SOLD_OUT库存没扣减原因OrderService.createOrder()方法里更新图书状态的 SQL 是UPDATE book_info SET status ? WHERE id ? AND status ?第三个?是期望当前状态为ON_SALE。但如果并发下单两个请求同时读到status ON_SALE都会执行 update导致超卖。项目没做乐观锁或SELECT FOR UPDATE。解决这不是 bug是教学取舍。若要修复可在BookDao.updateStatus()中加入版本号字段versionSQL 改为UPDATE book_info SET status ?, version version 1 WHERE id ? AND status ? AND version ?。但作为课程设计老师通常接受“单线程测试无问题”的解释。4.4 现象swal(确认发货, , warning)点击确定后无反应Network 面板显示500 Internal Server Error原因OrderServlet.java中actionconfirmShip分支调用了orderService.confirmShipping(orderId, currentUserId)但currentUserId从 session 取值时session.getAttribute(user_id)返回null导致NullPointerException。根本原因是web.xml中session-configsession-timeout30/session-timeout/session-config太短测试时操作慢了就超时。解决将session-timeout改为60单位分钟或在OrderServlet开头加空指针防护Integer userId (Integer) request.getSession().getAttribute(user_id); if (userId null) { sendError(request, response, 登录已过期请重新登录); return; }4.5 现象book-detail.jsp显示“库存0”但book_info.stock字段值是5原因项目用的是“逻辑库存”不是数据库字段。book-detail.jsp里计算库存的逻辑是% book.getStock() - orderDao.countUnfinishedOrdersByBookId(book.getId()) %但countUnfinishedOrdersByBookId()查询的是status IN (UNPAID, PAID, SHIPPED)的订单数。如果数据库里有脏数据比如status CANCELLED但没清理就会误扣库存。解决检查order_info表手动执行DELETE FROM order_info WHERE status CANCELLED AND create_time DATE_SUB(NOW(), INTERVAL 7 DAY);清理过期取消订单。长期方案是在OrderService.cancelOrder()中增加bookDao.increaseStock(bookId)回滚库存。5. 进阶技巧用 JUnit 4.12 为 DAO 层写单元测试绕过 Servlet 容器直接验证 SQL 正确性很多同学以为“课程设计只要能跑就行”但真正拉开分数差距的是你能否证明自己的 SQL 是对的、事务是稳的、边界条件都覆盖了。这个项目没提供测试用例但它的 DAO 层天生适合单元测试——因为BookDao、UserDao等类的构造函数接受Connection参数你可以用 H2 内存数据库模拟 MySQL完全脱离 Tomcat 运行测试。5.1 搭建测试环境H2 JUnit 4.12 的最小依赖在pom.xml如果项目有 Maven或直接下载 jar 包添加junit-4.12.jarh2-1.4.200.jarcommons-dbutils-1.7.jar项目已自带然后创建测试类BookDaoTest.javaimport org.junit.*; import java.sql.*; public class BookDaoTest { private static Connection conn; private static BookDao bookDao; BeforeClass public static void setUpBeforeClass() throws Exception { // 使用 H2 内存数据库建表语句来自 db_schema.sql conn DriverManager.getConnection(jdbc:h2:mem:test;DB_CLOSE_DELAY-1, sa, ); // 执行建表 SQL这里简化实际应读取 db_schema.sql 文件 conn.createStatement().executeUpdate( CREATE TABLE book_info (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100), price DOUBLE, stock INT, status INT, publisher_id INT) ); bookDao new BookDao(); } AfterClass public static void tearDownAfterClass() throws Exception { conn.close(); } Test public void testInsertAndGetById() throws SQLException { // Given Book book new Book(); book.setTitle(测试图书); book.setPrice(29.9); book.setStock(10); book.setStatus(Book.STATUS_ON_SALE); book.setPublisherId(1); // When int bookId bookDao.insert(book, conn); // Then Book savedBook bookDao.findById(bookId, conn); Assert.assertEquals(测试图书, savedBook.getTitle()); Assert.assertEquals(29.9, savedBook.getPrice(), 0.01); Assert.assertEquals(10, savedBook.getStock()); } }关键点BeforeClass中用 H2 创建内存库Test方法里传入conn完全复用生产 DAO 代码零改造。这样你就能在不启动 Tomcat 的情况下验证insert()是否真的插入、findById()是否能正确查询、updateStatus()是否原子更新——所有 SQL 逻辑都在 IDE 里秒级反馈。5.2 验证事务一致性用两个 Connection 模拟并发场景DAO 层的事务能力光测单线程不够。你可以用两个独立Connection模拟用户 A 和用户 B 同时下单Test public void testConcurrentOrderReduceStock() throws SQLException { // 初始化库存为 1 Book book new Book(); book.setTitle(并发测试书); book.setStock(1); book.setStatus(Book.STATUS_ON_SALE); book.setPublisherId(1); int bookId bookDao.insert(book, conn); // 模拟用户A下单 Connection connA DriverManager.getConnection(jdbc:h2:mem:test, sa, ); connA.setAutoCommit(false); boolean successA orderDao.createOrder(connA, bookId, 1); // 用户1下单 // 模拟用户B下单同一时刻 Connection connB DriverManager.getConnection(jdbc:h2:mem:test, sa, ); connB.setAutoCommit(false); boolean successB orderDao.createOrder(connB, bookId, 2); // 用户2下单 // 验证只有一个成功 connA.commit(); connB.commit(); int remainingStock bookDao.findById(bookId, conn).getStock(); Assert.assertEquals(0, remainingStock); // 库存应为0说明至少一个下单成功 // 进一步查 order_info 表确认只有一条 statusPAID 的记录 }这比口头解释“我用了事务”有力得多。答辩时老师问“怎么保证不超卖”你打开这个测试类点 Run3 秒出结果。5.3 从测试反推代码质量为什么这个项目值得抄我跑完全部 DAO 测试后发现一个隐藏亮点所有 DAO 方法都遵循“单一职责 无状态 可组合”。比如BookDao.updateStatus()只干一件事UserDao.updateCreditScore()也只干一件事它们可以任意组合成 Service 方法。而OrderService.createOrder()里bookDao.reduceStock()、userDao.deductBalance()、orderDao.insert()三个调用顺序不可逆——先扣库存再扣余额最后建订单否则回滚时状态不一致。这种设计不是巧合是作者对 JavaEE 分层架构的深刻理解。它不教你用 Spring但教会你“为什么需要分层”。从那以后我每次带学生做 JavaEE 课设都强制他们先写 DAO 层测试再写 Servlet。因为只有测试能证明你写的不是“看起来能跑”的代码而是“逻辑上必然正确”的代码。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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