Java Web电商实战:JSP+Servlet+MySQL完整闭环
2026/9/16 14:51:18 网站建设 项目流程

简介:本资源是一套完整的Java Web开发实战项目——基于JSP的网上体育商城系统,面向高校计算机专业本科生及Java初学者,用于毕业设计、课程设计或Web全栈能力进阶训练。项目覆盖用户管理、商品浏览、购物车、订单处理与支付集成等电商核心功能,深度融合JSP、Servlet、MVC架构、MySQL数据库、Session/Cookie会话控制及基础安全防护实践。压缩包共98.56MB,内含项目报告、答辩PPT、可运行源代码、SQL建库脚本、系统功能截图及Tomcat部署实操视频,各类文件协同支撑从理论理解到环境落地的全流程学习。目前已有377人学习下载,读者可直接导入IDE调试运行,参考部署视频完成本地环境搭建,结合报告与PPT深入理解需求分析、模块设计、测试验证与答辩逻辑,是少有的集教学性、完整性与可复现性于一体的Java Web综合实践案例。

1. 这不是又一个“登录注册+增删改查”的JSP模板,而是一套可跑通的体育电商最小闭环

你打开这个压缩包,第一眼看到的是“毕业设计”四个字,但别急着划走——它里面藏着一个真实跑得通的 Java Web 电商骨架:用户能注册、能加购物车、能下单、能跳转模拟支付页、后台能查订单、数据库表之间有外键约束、连部署视频里都演示了 Tomcat 启动后访问 localhost:8080/sports 的完整路径。这不是用 JSP 写了个静态页面再套个 login.jsp 就交差的项目,而是把 Servlet 做控制器、JavaBean 做模型、JSP 做视图的 MVC 链路真正串起来了。尤其对刚学完《Java Web 编程基础》但还没在真实项目里写过request.getSession().setAttribute("user", user)的人来说,它提供了一条从理论到可调试代码的窄通道:所有业务逻辑都在src/com/sports/下分包清晰,数据库脚本直接执行就能建库建表,连中文乱码、JSP 页面编码、Tomcat context-path 配置这些新手卡点,都在部署视频第 3 分钟和项目报告第 12 页给了截图和参数值。它不追求微服务或 Spring Boot,但把 Java Web 最原始、最扎实的那层底子,压实在了 MySQL + Tomcat + JSP + Servlet 这四块砖上。

2. JSP + Servlet 如何协同完成一次用户登录验证:从请求流转到 Session 绑定

2.1 登录流程的三层拆解:前端表单 → Servlet 接收 → JSP 跳转

整个登录动作始于login.jsp中的 HTML 表单:

<form action="LoginServlet" method="post"> <input type="text" name="username" placeholder="用户名" required> <input type="password" name="password" placeholder="密码" required> <input type="submit" value="登录"> </form>

注意action="LoginServlet"并未带.do/servlet/LoginServlet,这是关键。它依赖web.xml中的<servlet-mapping>配置将路径映射到具体类:

<servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.sports.servlet.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/LoginServlet</url-pattern> </servlet-mapping>

提示:若部署后点击登录无响应,第一件事是检查web.xml是否存在该 mapping,且<servlet-class>的包路径与实际.java文件物理路径完全一致(如com/sports/servlet/LoginServlet.java)。

当表单提交,Tomcat 将请求交给LoginServletdoPost()方法。该方法核心逻辑是:获取参数 → 查询数据库 → 验证密码 → 设置 Session → 转发页面:

public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); // 1. 调用 DAO 层查询用户(此处简化为硬编码验证,实际应调用 UserDAO) User user = new UserDAO().findUserByUsername(username); if (user != null && user.getPassword().equals(password)) { // 2. 成功则将用户对象存入 Session,实现会话保持 request.getSession().setAttribute("user", user); // 3. 转发至主页,避免重复提交 request.getRequestDispatcher("index.jsp").forward(request, response); } else { // 4. 失败则重定向回登录页,并携带错误信息 request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } } }
2.1.1request.getSession()的隐式创建与生命周期控制

request.getSession()默认行为是:若当前请求无有效 Session,则自动创建一个新的;若已存在,则返回已有 Session。这正是setAttribute("user", user)能跨页面生效的基础。Session ID 由 Tomcat 自动生成并写入 Cookie(名为JSESSIONID),后续请求自动携带该 Cookie,服务器据此关联用户状态。

但需注意:若浏览器禁用 Cookie,Session 将失效。此时需启用 URL 重写(response.encodeURL())作为备选方案,本项目未启用,故部署时务必确认测试环境允许 Cookie。

2.1.2forward()sendRedirect()的本质区别
  • request.getRequestDispatcher("index.jsp").forward(request, response)服务器端内部跳转,URL 不变,requestresponse对象被复用,request.setAttribute()设置的数据在目标 JSP 中仍可读取(如request.getAttribute("error"))。
  • response.sendRedirect("login.jsp")客户端重定向,浏览器发起新请求,原request对象丢失,setAttribute()数据不可见,URL 变为新地址。

本项目登录成功用forward,失败用forward(保留错误提示),符合典型 MVC 流程。若误将成功逻辑写成sendRedirect("index.jsp"),则index.jsp中无法通过session.getAttribute("user")获取登录用户,导致首页显示“未登录”。

2.2 JSP 视图层如何安全读取 Session 并渲染用户信息

index.jsp开头即进行 Session 校验:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <% // 传统 scriptlet 方式(不推荐但项目中存在) Object userObj = session.getAttribute("user"); if (userObj == null) { response.sendRedirect("login.jsp"); return; } %>

更现代的做法是使用 JSTL 判断:

<c:if test="${empty sessionScope.user}"> <c:redirect url="login.jsp"/> </c:if>

用户信息展示部分直接从 Session 读取:

欢迎回来,<strong>${sessionScope.user.username}</strong>! <a href="LogoutServlet">退出登录</a>

这里${sessionScope.user.username}等价于((User)session.getAttribute("user")).getUsername(),JSP EL 表达式自动解包并调用 getter 方法。

注意:sessionScope是 JSP 的内置作用域对象,明确限定数据来源为 Session,避免与requestScopepageScope混淆。若直接写${user.username},EL 会按 page → request → session → application 顺序查找,可能引发意外覆盖。

2.3 登录态校验的边界场景与防御性处理

项目中LogoutServlet的实现极为简洁:

public class LogoutServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); // false 表示不创建新 Session if (session != null) { session.invalidate(); // 彻底销毁 Session,清除所有属性 } response.sendRedirect("login.jsp"); } }

关键点在于getSession(false):若用户未登录就直接访问/LogoutServletsessionnull,避免NullPointerExceptioninvalidate()不仅清空属性,还使当前 Session ID 失效,防止 Session Fixation 攻击。

此外,项目报告第 15 页明确指出:“所有需登录访问的 JSP 页面(如cart.jsp,order.jsp)均在页面顶部添加 Session 校验逻辑”。这意味着,即使绕过导航栏直接输入 URL,系统也会强制跳转至登录页——这是基于 Java Web 原生机制的轻量级权限控制,虽不如 Spring Security 精细,但对毕业设计而言足够健壮。

3. MySQL 数据库设计与 JSP 动态商品列表实现:从 ER 图到 ResultSet 映射

3.1 核心三张表的字段设计与外键约束解析

项目提供的sports.sql脚本创建了以下关键表(截取主干):

CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, -- 实际应为加密后字符串 email VARCHAR(100), phone VARCHAR(20) ); CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, category VARCHAR(50), -- 如 '篮球', '跑步', '健身' description TEXT ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_date DATETIME DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) DEFAULT '待支付', total_amount DECIMAL(10,2) NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );
3.1.1 为什么products表不设外键关联category

项目中category字段为VARCHAR类型,而非指向独立categories表的INT外键。这是一种去规范化(Denormalization)的权衡:

  • ✅ 优势:查询商品列表时无需JOINSELECT * FROM products WHERE category='篮球'直接命中,性能更高;
  • ❌ 劣势:若需修改“篮球”分类名称,必须UPDATE products SET category='球类运动' WHERE category='篮球',存在数据一致性风险。

毕业设计场景下,分类数量少且极少变更,此设计合理。若扩展为大型商城,则应拆分为categories表并建立外键。

3.1.2orders表的ON DELETE CASCADE含义

当某用户被删除(DELETE FROM users WHERE id=123),其所有订单将自动级联删除。这避免了孤儿订单(orders.user_id指向不存在的users.id)。但需谨慎:若业务要求保留历史订单(即使用户注销),则应移除此约束,并将user_id改为VARCHAR存储用户名快照。

3.2 商品列表 JSP 页面的动态渲染与分页雏形

product_list.jsp是典型的 MVC View 层,其核心是遍历request中传递的productList

<c:forEach var="product" items="${requestScope.productList}" varStatus="status"> <div class="product-item"> <h3>${product.name}</h3> <p>价格:<span class="price">¥${product.price}</span></p> <p>库存:<span class="stock">${product.stock}</span> 件</p> <p>分类:<span class="category">${product.category}</span></p> <a href="add_to_cart.jsp?productId=${product.id}" class="btn-add">加入购物车</a> </div> <c:if test="${status.count % 4 == 0}"> <div style="clear:both;"></div> </c:if> </c:forEach>
3.2.1productList如何从 Servlet 传递到 JSP?

ProductListServlet执行查询并封装为 List:

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { List<Product> productList = new ProductDAO().findAllProducts(); request.setAttribute("productList", productList); // 关键:绑定到 request 作用域 request.getRequestDispatcher("product_list.jsp").forward(request, response); }

Product是标准 JavaBean,含id,name,price,stock,category,description字段及对应 getter/setter。DAO 层使用 JDBCResultSet映射:

public List<Product> findAllProducts() { List<Product> list = new ArrayList<>(); String sql = "SELECT id, name, price, stock, category, description FROM products"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Product p = new Product(); p.setId(rs.getInt("id")); p.setName(rs.getString("name")); p.setPrice(rs.getBigDecimal("price")); p.setStock(rs.getInt("stock")); p.setCategory(rs.getString("category")); p.setDescription(rs.getString("description")); list.add(p); } } catch (SQLException e) { e.printStackTrace(); } return list; }
3.2.2 分页功能的简易实现(项目中已预留接口)

虽然当前product_list.jsp未实现分页,但ProductDAO中已存在findProductsByPage(int pageNum, int pageSize)方法签名,且DBUtil工具类支持 MySQL 的LIMIT ? OFFSET ?语法。若需启用,只需在 Servlet 中传入页码参数:

int pageNum = Integer.parseInt(request.getParameter("page")); int pageSize = 10; List<Product> productList = new ProductDAO().findProductsByPage(pageNum, pageSize); request.setAttribute("productList", productList); // 同时查询总记录数用于计算总页数 int totalCount = new ProductDAO().getTotalCount(); int totalPages = (int) Math.ceil((double) totalCount / pageSize); request.setAttribute("totalPages", totalPages); request.setAttribute("currentPage", pageNum);

JSP 中即可用<c:forEach>遍历,并添加分页导航:

<c:forEach begin="1" end="${totalPages}" var="i"> <a href="ProductListServlet?page=${i}">${i}</a> </c:forEach>

4. 购物车与订单生成的事务控制:从 Session 临时存储到数据库持久化

4.1 基于 Session 的轻量级购物车实现原理

本项目未使用独立购物车表,而是将购物车数据暂存于HttpSession中,结构为Map<Integer, Integer>productId → quantity):

// AddToCartServlet.java HttpSession session = request.getSession(); Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } int productId = Integer.parseInt(request.getParameter("productId")); int quantity = cart.getOrDefault(productId, 0) + 1; cart.put(productId, quantity); response.sendRedirect("cart.jsp");
4.1.1 为何选择 Session 而非数据库存储购物车?
  • 性能考量:每次添加/修改购物车都触发数据库写操作,对高并发场景不友好;
  • 用户体验:未登录用户也能加购,Session 支持匿名会话;
  • 毕业设计复杂度平衡:避免引入购物车表、库存扣减事务、并发更新锁等高级话题。

但此设计有明显局限:Session 过期(默认 30 分钟)或服务器重启,购物车即丢失。生产环境必须结合 Redis 或数据库持久化。

4.2 订单生成的原子性保障:手动事务管理与库存校验

CreateOrderServlet是事务关键点,必须确保“扣库存”与“写订单”同时成功或同时失败:

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 1. 从 Session 获取购物车 Map<Integer, Integer> cart = (Map<Integer, Integer>) request.getSession().getAttribute("cart"); if (cart == null || cart.isEmpty()) { throw new RuntimeException("购物车为空"); } // 2. 遍历购物车,逐条检查库存并扣减 for (Map.Entry<Integer, Integer> entry : cart.entrySet()) { int productId = entry.getKey(); int quantity = entry.getValue(); // 查询当前库存 String checkSql = "SELECT stock FROM products WHERE id = ?"; PreparedStatement checkPs = conn.prepareStatement(checkSql); checkPs.setInt(1, productId); ResultSet rs = checkPs.executeQuery(); if (!rs.next() || rs.getInt("stock") < quantity) { throw new RuntimeException("商品 " + productId + " 库存不足"); } rs.close(); checkPs.close(); // 扣减库存 String updateSql = "UPDATE products SET stock = stock - ? WHERE id = ?"; PreparedStatement updatePs = conn.prepareStatement(updateSql); updatePs.setInt(1, quantity); updatePs.setInt(2, productId); updatePs.executeUpdate(); updatePs.close(); } // 3. 插入订单主表 int userId = ((User) request.getSession().getAttribute("user")).getId(); String insertOrderSql = "INSERT INTO orders (user_id, total_amount, status) VALUES (?, ?, ?)"; PreparedStatement insertOrderPs = conn.prepareStatement(insertOrderSql, Statement.RETURN_GENERATED_KEYS); insertOrderPs.setInt(1, userId); insertOrderPs.setBigDecimal(2, calculateTotalAmount(cart)); // 计算总价 insertOrderPs.setString(3, "待支付"); insertOrderPs.executeUpdate(); // 4. 获取生成的订单 ID,插入订单明细表(orders_items) ResultSet keyRs = insertOrderPs.getGeneratedKeys(); if (keyRs.next()) { int orderId = keyRs.getInt(1); insertOrderItems(conn, orderId, cart); } keyRs.close(); insertOrderPs.close(); conn.commit(); // 全部成功,提交事务 request.getSession().removeAttribute("cart"); // 清空购物车 request.setAttribute("message", "订单创建成功!"); request.getRequestDispatcher("order_success.jsp").forward(request, response); } catch (Exception e) { if (conn != null) { try { conn.rollback(); // 任一环节失败,回滚所有操作 } catch (SQLException ex) { ex.printStackTrace(); } } request.setAttribute("error", "订单创建失败:" + e.getMessage()); request.getRequestDispatcher("cart.jsp").forward(request, response); } finally { DBUtil.closeConnection(conn); } }
4.2.1conn.setAutoCommit(false)的必要性与风险
  • 若不手动控制事务,每个 SQL 执行后立即提交,一旦库存扣减成功但订单插入失败,将导致库存虚减(货没了但没下单);
  • rollback()必须在catch块中显式调用,否则异常抛出后连接关闭,未提交的更改可能处于不确定状态;
  • DBUtil.closeConnection(conn)finally中确保连接释放,避免连接泄漏。

4.3 订单状态机的简化实现与支付模拟

项目中支付环节为模拟:order_success.jsp显示订单号后,提供“前往支付”按钮,链接至payment.jsp。该页面仅展示静态信息(订单号、金额、支付方式选择),无真实 API 调用。但其设计预留了状态更新入口:

<!-- payment.jsp --> <form action="UpdateOrderStatusServlet" method="post"> <input type="hidden" name="orderId" value="${param.orderId}"> <select name="status"> <option value="已支付">支付宝支付</option> <option value="已支付">微信支付</option> </select> <button type="submit">确认支付</button> </form>

UpdateOrderStatusServlet仅执行一条 SQL:

String sql = "UPDATE orders SET status = ? WHERE id = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, request.getParameter("status")); ps.setInt(2, Integer.parseInt(request.getParameter("orderId"))); ps.executeUpdate();

注意:真实支付需对接第三方 SDK,处理异步通知(如支付宝的notify_url)、幂等性校验、交易流水号生成等。本项目聚焦 Java Web 基础链路,将支付抽象为状态变更,符合毕业设计深度要求。

5. 部署调试高频问题排查:从 JSP 修改不生效到中文乱码根因定位

5.1 “JSP 修改不生效”问题的五层诊断法

当你修改了index.jsp但刷新页面仍是旧内容,按以下顺序排查:

5.1.1 检查 Tomcat 的 work 目录缓存

Tomcat 将 JSP 编译为 Servlet 的.class文件存于work/Catalina/localhost/[context]/org/apache/jsp/。若修改 JSP 后未重启 Tomcat,旧.class文件仍被加载。

✅ 解决方案:

  • 删除work目录下对应项目的整个文件夹(如work/Catalina/localhost/sports/);
  • 或在conf/context.xml中添加<Context reloadable="true"/>(仅开发环境启用,影响性能)。
5.1.2 确认 JSP 页面的 pageEncoding 与 contentType

<%@ page contentType="text/html;charset=UTF-8" %>必须存在,且charset与文件实际编码一致。若用 Notepad++ 编辑,需将编码设为 UTF-8 无 BOM。

❌ 常见错误:

  • 文件保存为 ANSI 编码,但 JSP 声明charset=UTF-8→ 浏览器解析乱码;
  • 文件为 UTF-8,但声明charset=GBK→ 中文显示为方块。

✅ 验证方法:用浏览器开发者工具 → Network → 点击 HTML 请求 → 查看 Response Headers 中Content-Type是否为text/html;charset=UTF-8

5.1.3 检查 web.xml 中的 welcome-file-list

若访问http://localhost:8080/sports/期望进入index.jsp,但实际跳转到其他页面,检查web.xml

<welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list>

若缺失或拼写错误(如index.html),Tomcat 将按默认规则查找。

5.2 数据库中文乱码的终极解决方案

即使 JSP 页面编码正确,MySQL 中存入的中文仍显示为??,根源在数据库连接层。

5.2.1 四步全链路配置
层级配置项正确值验证命令
MySQL 服务端character_set_serverutf8mb4SHOW VARIABLES LIKE 'character_set_server';
数据库/表DEFAULT CHARSETutf8mb4SHOW CREATE DATABASE sports;
JDBC URLuseUnicode=true&characterEncoding=utf8mb4必须包含jdbc:mysql://localhost:3306/sports?useUnicode=true&characterEncoding=utf8mb4
Tomcat 连接池connectionPropertiesuseUnicode=true;characterEncoding=utf8mb4若用 DBCP,在context.xml中配置
5.2.2utf8mb4为何比utf8更可靠?

MySQL 的utf8实际是utf8mb3,最多支持 3 字节字符(无法存储 emoji 或某些生僻汉字);utf8mb4才是真正的 UTF-8,支持 4 字节字符。项目虽未涉及 emoji,但utf8mb4是当前最佳实践。

✅ 强制转换现有数据库:

ALTER DATABASE sports CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

5.3 部署视频中未明说但至关重要的两个参数

项目部署视频演示了将 WAR 包放入webapps目录,但未强调以下两点:

5.3.1 Tomcat 的server.xmlURIEncoding

若 URL 中含中文参数(如?category=篮球),需在conf/server.xmlConnector标签中添加:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" /> <!-- 关键! -->

否则request.getParameter("category")将返回乱码。

5.3.2 项目context.xmldocBase路径规范

若以目录形式部署(非 WAR 包),需在conf/Catalina/localhost/sports.xml中指定:

<Context docBase="D:/workspace/sports" reloadable="true" />

docBase必须为绝对路径,且路径中不能含空格或中文(如D:\我的项目\sports会导致启动失败)。建议路径使用纯英文、无空格,如D:/projects/sports

最后提醒:所有配置修改后,必须完全停止 Tomcat 进程(任务管理器中确认java.exe无残留),再重新启动,否则更改不生效。

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

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

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

立即咨询