☰
JSP+Servlet+MySQL校园二手平台实战:事务控制与会话管理
2026/10/3 5:01:27 网站建设 项目流程

简介:本资源是一套面向计算机专业本科生的毕业设计级校园二手交易平台实战源码,采用JSP+Servlet+MySQL经典Java Web技术栈构建,完整覆盖用户管理、商品发布与浏览、交易流程、消息通知及基础安全防护等核心模块,适用于课程设计、毕设开发与Web全栈入门实践。压缩包共5个文件,含数据库脚本(market.sql)用于初始化数据、README.md提供项目说明、LICENSE明确授权协议、毕设说明书(.doc)详述设计思路与实现过程,以及主源码压缩包(market.zip),整体大小7.95MB,结构清晰、开箱即用。已有883人学习下载,资源附带可直接运行的完整工程结构、分层代码组织(MVC模式)、典型SQL注入/XSS防护示例及配套文档,便于读者快速理解前后端协作逻辑、掌握数据库建模与Servlet控制器开发要点,并复现真实业务场景。

1. 这不是又一个“学生练手项目”:JSP+Servlet+MySQL校园二手交易平台,解决的是真实场景下的数据一致性、会话隔离与轻量级事务闭环

很多开发者看到“毕业设计”四个字就下意识划走——但这个基于 JSP+Servlet+MySQL 的校园二手交易平台,恰恰卡在了企业级 Web 开发的“临界点”上:它不用 Spring Boot 自动装配掩盖底层细节,不靠 MyBatis 封装屏蔽 SQL 执行路径,而是用最原始的HttpServlet生命周期、RequestDispatcher转发链、Connection手动管理,把用户登录态绑定、商品状态流转(上架→已售→下架)、订单生成与库存扣减的原子性控制,全部摊开在 JDBC 的try-catch-finally里。它适合两类人:一是刚学完 Java Web 基础、正卡在“为什么 form 提交后页面没跳转”“为什么 session.setAttribute 后另一个 servlet 取不到值”的初学者;二是想回溯 Servlet 容器本质、排查web.xml中<load-on-startup>与ServletContextListener初始化顺序问题的进阶者。它不追求高并发,但每一步操作都对应 HTTP 协议状态码、Servlet 规范中明确规定的线程安全边界和 MySQL 事务隔离级别实测行为。

2. 从零启动:JSP 页面渲染、Servlet 控制流与 MySQL 数据库三者的协同机制

2.1 JSP 不是静态 HTML:理解 pageContext、request、session 三大作用域的实际生命周期

JSP 在运行时被容器编译为 Servlet 类,其本质是HttpServlet的子类。关键在于三个隐式对象的作用域边界:request仅在一次 HTTP 请求内有效(如从login.jsp表单提交到LoginServlet,再forward回index.jsp时仍可取值);session绑定到客户端 Cookie 中的JSESSIONID,只要浏览器未关闭且未超时(默认 30 分钟),跨多个请求持续存在;pageContext仅限当前 JSP 文件内有效,无法被include或forward传递。在本项目中,用户登录成功后,LoginServlet执行:

// LoginServlet.java HttpSession session = request.getSession(true); session.setAttribute("user", user); // user 是 User 对象,含 id、username、role request.setAttribute("message", "登录成功"); RequestDispatcher dispatcher = request.getRequestDispatcher("index.jsp"); dispatcher.forward(request, response);

注意:request.setAttribute()必须在forward()前调用,且目标 JSP(如index.jsp)中需用<%= request.getAttribute("message") %>或 JSTL<c:out value="${requestScope.message}"/>输出。若误用session.setAttribute()存储临时提示信息,会导致多次刷新页面重复显示“登录成功”。

2.2 Servlet 的标准处理链:doGet/doPost 分离、URL 映射与请求参数解析

本项目所有业务逻辑均通过继承HttpServlet实现,严格区分doGet()(用于展示页面、查询数据)和doPost()(用于表单提交、数据修改)。以发布二手商品为例,PublishServlet的核心逻辑如下:

// PublishServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); // 必须在获取参数前设置,否则中文乱码 String title = request.getParameter("title"); // <input name="title"> String description = request.getParameter("description"); String priceStr = request.getParameter("price"); int price = Integer.parseInt(priceStr); // 简单校验,生产环境应加 try-catch 和范围检查 int userId = (int) request.getSession().getAttribute("user_id"); // 构建 SQL 插入语句(此处应使用 PreparedStatement 防注入,源码中已实现) String sql = "INSERT INTO goods (title, description, price, user_id, status, create_time) VALUES (?, ?, ?, ?, 'on_sale', NOW())"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { pstmt.setString(1, title); pstmt.setString(2, description); pstmt.setInt(3, price); pstmt.setInt(4, userId); int affectedRows = pstmt.executeUpdate(); if (affectedRows == 0) { throw new SQLException("创建商品失败"); } // 获取自增主键 try (ResultSet rs = pstmt.getGeneratedKeys()) { if (rs.next()) { int goodsId = rs.getInt(1); request.setAttribute("goodsId", goodsId); request.setAttribute("message", "商品发布成功"); } } } catch (SQLException e) { request.setAttribute("error", "数据库操作异常:" + e.getMessage()); request.getRequestDispatcher("publish.jsp").forward(request, response); return; } request.getRequestDispatcher("my_goods.jsp").forward(request, response); }

提示:request.setCharacterEncoding("UTF-8")必须放在getParameter()之前,否则 POST 提交的中文参数会变成??。DBUtil.getConnection()是项目封装的工具类,内部使用DriverManager.getConnection(),连接字符串形如"jdbc:mysql://localhost:3306/secondhand?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"。

2.3 MySQL 数据库设计:围绕校园场景的 5 张核心表与外键约束实践

本项目数据库共 5 张表,结构精简但覆盖核心业务:users(用户)、goods(商品)、orders(订单)、order_items(订单明细)、messages(站内信)。其中goods表的user_id字段与users.id建立外键,orders.user_id和order_items.goods_id同理。建表语句示例如下:

-- users 表 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, -- 存储 BCrypt 加密后的密文 real_name VARCHAR(30), phone VARCHAR(20), email VARCHAR(100), role ENUM('student', 'admin') DEFAULT 'student', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- goods 表(关键字段含状态枚举) CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, user_id INT NOT NULL, status ENUM('on_sale', 'sold', 'off_shelf') DEFAULT 'on_sale', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );

关键点说明:status字段使用ENUM类型而非VARCHAR,既节省存储空间,又通过数据库层强制约束合法值;ON DELETE CASCADE表明当用户被删除时,其发布的所有商品自动级联删除,避免孤儿记录;update_time的ON UPDATE CURRENT_TIMESTAMP确保每次UPDATE操作自动更新时间戳,无需在 Java 代码中手动赋值。

3. 核心功能落地:用户登录态管理、商品状态机与订单生成的完整事务控制

3.1 登录态与权限拦截:Filter 实现统一访问控制,避免每个 Servlet 重复校验

项目未使用 Spring Security,而是通过自定义LoginCheckFilter实现登录拦截。该 Filter 配置在web.xml中,对/user/*、/admin/*等路径生效:

<!-- web.xml --> <filter> <filter-name>LoginCheckFilter</filter-name> <filter-class>filter.LoginCheckFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginCheckFilter</filter-name> <url-pattern>/user/*</url-pattern> <url-pattern>/admin/*</url-pattern> </filter-mapping>

LoginCheckFilter的doFilter方法核心逻辑:

// LoginCheckFilter.java public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; HttpSession session = httpRequest.getSession(false); // false 表示不创建新 session // 检查 session 中是否存在 user 对象 if (session == null || session.getAttribute("user") == null) { // 未登录,重定向到登录页,并保存原请求路径用于登录后跳转 String originalUrl = httpRequest.getRequestURI(); if (httpRequest.getQueryString() != null) { originalUrl += "?" + httpRequest.getQueryString(); } session.setAttribute("originalUrl", originalUrl); httpResponse.sendRedirect(httpRequest.getContextPath() + "/login.jsp"); return; } // 已登录,放行 chain.doFilter(request, response); }

注意:session.getAttribute("user")是判断登录的核心依据,而非检查username字符串。originalUrl的保存机制,使得用户从/user/my_goods.jsp被重定向到/login.jsp后,登录成功即可自动跳回原页面,提升体验。

3.2 商品状态机:从“上架”到“已售”的状态流转与并发安全控制

商品状态变更(如买家点击“立即购买”)必须保证原子性,防止超卖。本项目采用 MySQL 的UPDATE ... WHERE status = 'on_sale'语句实现乐观锁:

// BuyServlet.java String updateSql = "UPDATE goods SET status = 'sold', buyer_id = ? WHERE id = ? AND status = 'on_sale'"; try (PreparedStatement pstmt = conn.prepareStatement(updateSql)) { pstmt.setInt(1, buyerId); pstmt.setInt(2, goodsId); int rowsAffected = pstmt.executeUpdate(); if (rowsAffected == 0) { // 更新失败,说明商品已被他人抢购或状态非 on_sale request.setAttribute("error", "商品已被抢购或状态异常,请刷新页面重试"); request.getRequestDispatcher("goods_detail.jsp?id=" + goodsId).forward(request, response); return; } } // 状态更新成功,继续创建订单...

原理说明:WHERE status = 'on_sale'是关键。当两个请求同时尝试购买同一商品时,数据库会按行锁(InnoDB 默认)锁定该记录,第二个请求必须等待第一个事务提交或回滚。若第一个请求已将status改为'sold',第二个请求的UPDATE将匹配 0 行,executeUpdate()返回 0,从而在应用层捕获并发冲突,而非让数据库报错。

3.3 订单生成与库存扣减:跨表插入的事务边界与回滚策略

一笔订单包含主表orders和明细表order_items,必须保证两者同时成功或同时失败。本项目在OrderServlet中显式开启事务:

// OrderServlet.java Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 // 1. 插入 orders 主表 String orderSql = "INSERT INTO orders (user_id, total_amount, status, create_time) VALUES (?, ?, 'pending', NOW())"; try (PreparedStatement orderPstmt = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) { orderPstmt.setInt(1, userId); orderPstmt.setBigDecimal(2, totalAmount); orderPstmt.executeUpdate(); // 获取订单 ID int orderId; try (ResultSet rs = orderPstmt.getGeneratedKeys()) { rs.next(); orderId = rs.getInt(1); } // 2. 插入 order_items 明细(循环插入多件商品) String itemSql = "INSERT INTO order_items (order_id, goods_id, quantity, price) VALUES (?, ?, 1, ?)"; try (PreparedStatement itemPstmt = conn.prepareStatement(itemSql)) { for (GoodsItem item : cartItems) { // cartItems 来自 session 购物车 itemPstmt.setInt(1, orderId); itemPstmt.setInt(2, item.getGoodsId()); itemPstmt.setBigDecimal(3, item.getPrice()); itemPstmt.addBatch(); } itemPstmt.executeBatch(); } } conn.commit(); // 全部成功,提交事务 request.setAttribute("message", "订单创建成功"); } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // 任一环节失败,回滚整个事务 } catch (SQLException rollbackEx) { log.error("事务回滚失败", rollbackEx); } } request.setAttribute("error", "订单创建失败:" + e.getMessage()); } finally { if (conn != null) { try { conn.setAutoCommit(true); // 恢复自动提交 conn.close(); } catch (SQLException e) { log.error("连接关闭失败", e); } } }

关键参数说明:conn.setAutoCommit(false)是事务开始的标志;Statement.RETURN_GENERATED_KEYS用于获取自增主键;executeBatch()批量执行明细插入,比单条executeUpdate()效率更高;finally块中恢复autoCommit并关闭连接,是资源释放的兜底保障。

4. 部署与调试:Tomcat 本地运行、JSP 修改热加载失效排查与 MySQL 连接验证

4.1 Tomcat 8.5+ 环境配置:确认 JDK 版本、CATALINA_HOME 与 webapps 目录结构

本项目要求 JDK 8 或 JDK 11(因 JSP 编译依赖tools.jar,JDK 9+ 已移除,故推荐 JDK 8u202)。Tomcat 版本需 8.5.x 或 9.0.x,避免使用 10.x+(Servlet 4.0 规范对web.xml有更严格校验)。部署步骤:

  1. 解压 Tomcat 到无中文、无空格路径(如D:\apache-tomcat-8.5.93);
  2. 设置系统环境变量CATALINA_HOME=D:\apache-tomcat-8.5.93;
  3. 将项目文件夹(含WEB-INF目录)整体复制到CATALINA_HOME\webapps\secondhand;
  4. 启动CATALINA_HOME\bin\startup.bat(Windows)或startup.sh(Linux/Mac);
  5. 访问http://localhost:8080/secondhand/login.jsp。

注意:若启动后页面 404,首先检查webapps\secondhand\WEB-INF\web.xml是否存在且格式正确(XML 标签闭合、无非法字符);其次确认webapps\secondhand\WEB-INF\lib下是否包含mysql-connector-java-5.1.47.jar(或 8.0.x 版本),缺失则导致Class.forName("com.mysql.jdbc.Driver")报ClassNotFoundException。

4.2 JSP 修改不生效?定位work\Catalina\localhost\secondhand缓存与重新编译机制

JSP 第一次访问时,Tomcat 会将其编译为.java和.class文件,存于CATALINA_HOME\work\Catalina\localhost\secondhand\org\apache\jsp\目录下。修改 JSP 后不生效,常见原因及解决方法:

现象原因解决方案
修改index.jsp后刷新仍是旧内容Tomcat 未检测到文件变化,或web.xml中<init-param>配置了development=false在CATALINA_HOME\conf\web.xml中找到<servlet>标签内的jspservlet,添加或修改<init-param>:
<init-param><param-name>development</param-name><param-value>true</param-value></init-param>
work目录下无对应.java文件JSP 语法错误,编译失败查看CATALINA_HOME\logs\catalina.out,搜索JasperException,定位具体行号和错误类型(如Unterminated &lt;%)
修改后需重启 Tomcat 才生效web.xml中reloadable="false"在CATALINA_HOME\conf\server.xml的<Context>标签中,确保reloadable="true"(仅开发环境启用,生产禁用)

提示:最彻底的清理方式是停止 Tomcat,删除work目录和temp目录,再启动。work目录完全由 Tomcat 管理,删除后首次访问 JSP 会自动重建。

4.3 MySQL 连接验证:用命令行直连、检查用户权限与时区配置

当DBUtil.getConnection()报Communications link failure或Access denied时,需分层验证:

  1. 命令行直连 MySQL:

    mysql -h localhost -P 3306 -u root -p # 输入密码后,执行: SHOW DATABASES; -- 确认 secondhand 库存在 USE secondhand; DESCRIBE users; -- 确认表结构 SELECT * FROM users LIMIT 1; -- 确认有测试数据
  2. 检查用户权限(假设应用使用secondhand_user用户):

    -- 创建用户并授权(MySQL 5.7) CREATE USER 'secondhand_user'@'localhost' IDENTIFIED BY 'password123'; GRANT SELECT, INSERT, UPDATE, DELETE ON secondhand.* TO 'secondhand_user'@'localhost'; FLUSH PRIVILEGES;
  3. 验证时区配置(避免java.sql.SQLException: The server time zone value '...' is unrecognized):

    -- 在 MySQL 命令行执行 SELECT @@global.time_zone, @@session.time_zone; -- 若返回 SYSTEM,需在连接 URL 中显式指定: -- jdbc:mysql://localhost:3306/secondhand?serverTimezone=Asia/Shanghai

关键参数表:web.xml中context-param配置数据库连接信息(部分项目将此抽离为db.properties):

参数名示例值说明
jdbc.drivercom.mysql.cj.jdbc.DriverMySQL 8.0+ 必须用cj包,5.1.x 用com.mysql.jdbc.Driver
jdbc.urljdbc:mysql://localhost:3306/secondhand?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8useSSL=false关闭 SSL(本地开发可省略,但必须显式声明)
jdbc.usernamesecondhand_user数据库用户名
jdbc.passwordpassword123数据库密码

5. 进阶技巧:用 JSTL 替代 Scriptlet、JSON 数据交互与 MySQL 存储过程优化查询

5.1 用 JSTL 标签库重构 JSP:消除<% %>脚本,提升可维护性与安全性

原始 JSP 中大量使用<% %>嵌入 Java 代码(Scriptlet),易出错且难维护。本项目已集成 JSTL 1.2,可在index.jsp中用<c:forEach>替代for循环:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <!-- 原始 Scriptlet 方式(不推荐) --> <% List<Goods> goodsList = (List<Goods>) request.getAttribute("goodsList"); for (Goods g : goodsList) { %> <div class="goods-item"> <h3><%= g.getTitle() %></h3> <p><%= g.getDescription() %></p> <span>¥<%= g.getPrice() %></span> </div> <% } %> <!-- JSTL 方式(推荐) --> <c:forEach items="${goodsList}" var="g"> <div class="goods-item"> <h3>${g.title}</h3> <p>${g.description}</p> <span>¥<fmt:formatNumber value="${g.price}" pattern="#,##0.00"/></span> </div> </c:forEach>

优势说明:${g.title}是 EL 表达式,自动调用getTitle()getter;<fmt:formatNumber>提供千分位和小数精度控制;JSTL 标签在编译期检查语法,避免运行时NullPointerException;更重要的是,它天然防御 XSS,${g.title}会自动 HTML 转义,而<%= g.getTitle() %>若未手动StringEscapeUtils.escapeHtml4()则存在风险。

5.2 Servlet 与前端 JSON 交互:用 Jackson 将 Java 对象转为 JSON 响应

当需要通过 AJAX 加载商品列表(如首页轮播图数据),Servlet 可直接输出 JSON:

// GoodsApiServlet.java protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("application/json;charset=UTF-8"); List<Goods> goodsList = goodsService.findTop4ByStatus("on_sale"); // 使用 Jackson(需引入 jackson-databind-2.13.4.2.jar) ObjectMapper mapper = new ObjectMapper(); String json = mapper.writeValueAsString(goodsList); response.getWriter().write(json); }

前端 JavaScript 调用:

fetch('/secondhand/api/goods/top4') .then(response => response.json()) .then(data => { data.forEach(goods => { const item = document.createElement('div'); item.innerHTML = `<h3>${goods.title}</h3><p>¥${goods.price}</p>`; document.getElementById('carousel').appendChild(item); }); });

注意:response.setContentType("application/json;charset=UTF-8")必须在getWriter()前设置,否则响应头无效;Jackson 的writeValueAsString()会自动处理Date类型(默认转为毫秒时间戳),若需 ISO 格式,需配置mapper.registerModule(new JavaTimeModule())。

5.3 MySQL 存储过程优化高频查询:封装“我的待发货订单”统计逻辑

对于频繁执行的聚合查询(如统计用户待发货订单数),可将 SQL 逻辑下沉至 MySQL 存储过程中,减少网络传输与应用层解析开销:

DELIMITER $$ CREATE PROCEDURE GetUserPendingOrders(IN p_user_id INT, OUT p_count INT) BEGIN SELECT COUNT(*) INTO p_count FROM orders o INNER JOIN order_items oi ON o.id = oi.order_id WHERE o.user_id = p_user_id AND o.status = 'pending' AND oi.status = 'unshipped'; END$$ DELIMITER ;

在OrderService.java中调用:

String sql = "{CALL GetUserPendingOrders(?, ?)}"; try (CallableStatement cstmt = conn.prepareCall(sql)) { cstmt.setInt(1, userId); cstmt.registerOutParameter(2, Types.INTEGER); cstmt.execute(); int pendingCount = cstmt.getInt(2); request.setAttribute("pendingCount", pendingCount); }

适用场景说明:存储过程适合逻辑复杂、多表关联、且被多个应用模块调用的查询。本例中,UserServlet、MyOrdersServlet、首页index.jsp都可能需要该统计值,统一由存储过程提供,避免 SQL 重复编写与 JDBC 参数绑定开销。但需注意,过度使用存储过程会增加数据库负担,且不利于版本控制(SQL 代码散落在数据库中)。

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

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

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

立即咨询