拿到的这个题目,是典型的JavaWeb课程设计或者毕业设计选题。JSP+Servlet+JDBC+MySQL这套组合,放在今天看确实不算新潮,但几乎所有计算机专业的学生都绕不开它。玩具商城这个业务场景,涵盖了电商系统最核心的用户、商品、购物车、订单这几大模块,业务复杂度适中,既能体现技术深度,又不至于把精力全耗在需求分析上。
我前前后后做过不少类似的项目,也帮同学改过很多版本,JSP+Servlet这套架构最大的价值在于:框架少、原理透,你能真切看到每一个HTTP请求是怎么被处理、每一行数据是怎么流到页面上的。这篇文章就把我从数据库设计到后台管理的完整实现思路展开聊聊,包括踩过的坑和排查方法,适合正在做课程设计,或者想把手写JavaWeb项目打通的同学参考。
1. 项目整体设计与技术选型思路
1.1 核心需求拆解:玩具商城到底要做哪些功能
玩具商城听起来是个小项目,但仔细拆下来,功能点一点都不少。站在用户侧,至少需要注册登录、商品分类浏览、商品详情查看、关键词搜索、加入购物车、修改购物车数量、提交订单。站在管理员侧,需要商品管理、分类管理、订单状态处理和简单的销售统计。
我见过很多同学一上来就写代码,写到最后发现表格对不上、功能链断了。正确的做法是先画用例图或者列功能清单。玩具商城的核心链路就一条:用户浏览商品,把商品加入购物车,结算生成订单,管理员处理订单。所有表结构和代码逻辑都应该围绕这条主链路展开。
1.2 技术选型:为什么选用JSP+Servlet+JDBC而不是Spring Boot
这道题的限定技术栈是JSP+Servlet+JDBC+MySQL,很多同学觉得这套组合太老了。但说句实在话,做课程设计恰恰适合用这套组合,原因有三。
第一,框架黑盒少。Spring Boot自动配置帮你把DispatcherServlet、视图解析器、数据库连接池都配好了,出了问题你连排查方向都没有。但JSP+Servlet这套,一个请求从浏览器发出,经过Servlet处理,再到JSP渲染,每一条链路都清清楚楚。任何报错都能在Tomcat控制台里找到对应代码位置。
第二,契合学习曲线。如果你马上要找实习或者工作,Spring Boot是必须掌握的。但如果你还在课程阶段,直接上Spring Boot容易绕开Servlet、JSP、会话管理这些底层知识,面试时一问细节就露馅。先做几个JSP+Servlet项目打底,再过渡到框架,跨度会小很多。
第三,部署和演示方便。一个打包好的WAR文件扔进Tomcat的webapps目录就能跑,不需要考虑复杂的环境依赖,答辩演示的时候出问题的概率也低。
1.3 三层架构划分与项目结构规划
项目虽然没用Spring,但分层决不能省。我是按经典的三层架构来组织的:JSP负责展示,Servlet负责接收请求和控制跳转,DAO层负责数据库操作。中间加了一层Service,虽然题目里没明确提,但实际开发时业务逻辑放在Servlet里会越来越臃肿,而且事务控制放在Service层才有意义。
项目包结构建议这样命名:
src ├── com.toy.entity // 实体类:User、Product、Category、CartItem、Order ├── com.toy.dao // DAO接口和实现类:UserDao、ProductDao、OrderDao ├── com.toy.service // 业务逻辑层:CartService、OrderService ├── com.toy.servlet // Servlet控制器:LoginServlet、RegisterServlet、ProductServlet ├── com.toy.filter // 过滤器:EncodingFilter、LoginFilter ├── com.toy.util // 工具类:DBUtil、MD5Util ├── web │ ├── index.jsp // 商品列表首页 │ ├── user // 用户相关页面:login.jsp、register.jsp │ ├── cart // 购物车页面 │ ├── admin // 后台管理页面 │ ├── uploads // 商品图片上传目录 │ └── WEB-INF │ ├── web.xml // 如果不全部用注解配置 │ └── lib // MySQL驱动包等实体类直接对应数据库表字段,属性命名建议和数据库字段保持一致,最好都用驼峰式,避免使用getSimpleName之类的反射操作时出现映射问题。Service层的核心价值在于处理事务性的操作,比如下单时涉及订单表、订单明细表、商品表和购物车表多表操作,这些必须在Service里用同一个Connection包起来,事务边界清晰了,代码才不会乱。
2. 数据库设计:玩具商城的六张核心表
2.1 表结构设计思路
数据库是这类项目的地基。表设计不合理,后面写DAO的时候会非常痛苦。玩具商城我建议用六张表:用户表t_user、分类表t_category、商品表t_product、购物车表t_cart、订单表t_order、订单明细表t_order_item。
为什么购物车要单独建表而不是直接存Session?说实话,如果只是应付课程设计,购物车放Session完全可以,用户关闭浏览器后重新访问购物车就空了,体验不太好。用数据库表存购物车的好处是用户换设备也能看到自己的购物车,而且下单结算时可以直接用SQL里的数据做校验,避免在Session里存一份、数据库里又查一遍的尴尬。考虑到工作量,我这里采用数据库存储方案。
商品表设计时需要注意,库存字段stock和销量字段sales都要用整型。图片字段image存的是相对路径,比如upload/product1.jpg,而不是存图片的二进制内容,更不要用Base64字符串直接入库。数据库里存路径,文件放磁盘,这是正规项目的做法。
2.2 核心建表SQL参考
直接看SQL更直观。下面这些都是我用过验证过的结构,字段名可以按自己习惯调整,但关键约束别删。
CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), phone VARCHAR(20), address VARCHAR(200), role TINYINT DEFAULT 0 COMMENT '0普通用户 1管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, description VARCHAR(200) ); CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, sales INT NOT NULL DEFAULT 0, image VARCHAR(200), status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES t_category(id) ); CREATE TABLE t_cart ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, add_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_product (user_id, product_id), FOREIGN KEY (user_id) REFERENCES t_user(id), FOREIGN KEY (product_id) REFERENCES t_product(id) ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3已完成 4已取消', receiver_name VARCHAR(50), receiver_phone VARCHAR(20), receiver_address VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES t_user(id) ); CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100), product_image VARCHAR(200), price DECIMAL(10,2), quantity INT, subtotal DECIMAL(10,2), FOREIGN KEY (order_id) REFERENCES t_order(id) );2.3 设计要点与避坑建议
这里有几个非常容易踩的坑,提前写出来。
第一,商品价格用DECIMAL(10,2),不要用FLOAT。浮点数在数据库里存的是近似值,0.1+0.2会出现0.30000000000000004这种问题。曾经有同学用FLOAT存商品价格,某次演示的时候计算购物车总价差了0.01元,看起来很尴尬。
第二,t_order_item表里冗余了product_name、product_image、price字段。这不是设计缺陷,是故意的。因为商品可能会改名、改价、下架甚至删除,如果订单明细只关联product_id,那历史订单展示的时候就会抓瞎。订单表要保留下单时刻的快照信息,这是电商系统的惯例。
第三,t_cart表加上了UNIQUE KEY uk_user_product (user_id, product_id)。这么做是为了插入购物车数据时使用ON DUPLICATE KEY UPDATE直接完成“存在就加数量,不存在就插入”的逻辑。
INSERT INTO t_cart (user_id, product_id, quantity) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity);第四,关于外键。很多生产环境不用物理外键,因为会影响插入效率、不方便分库分表。但课程设计阶段完全可以用,它能让表关系在可视化工具里一目了然,答辩时也好讲清楚。唯一要注意的是删除商品或分类时需要处理关联数据,或者干脆用状态字段做逻辑删除,不要物理删除。
3. JDBC层:连接管理、基础封装与SQL注入防护
3.1 数据库连接工具类的标准写法
JDBC层做的事情说起来很基础:获取连接、执行SQL、释放资源。但写得不规范会引发很多莫名其妙的问题,比如连接没有关闭导致数据库连接耗尽,项目跑几十分钟就全部卡死。
写一个DBUtil工具类是第一步。需要注意MySQL的驱动类名和URL在不同的驱动版本下不一样,我遇到过最多的问题就是这里。
package com.toy.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/toy_mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; private static final String USERNAME = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }MySQL 5.x和8.x版本驱动类名不同,5.x用com.mysql.jdbc.Driver,8.x用com.mysql.cj.jdbc.Driver。你本地的版本最好先确认一下。URL里的serverTimezone=Asia/Shanghai必须加,尤其是MySQL 8.x版本,不加这个经常会报日期时区相关的异常。allowPublicKeyRetrieval=true也是MySQL 8.x连接时可能遇到的问题之一,不加会报public key retrieval not allowed。
3.2 BaseDao的简单封装思路
每个实体都写一套完整DAO会重复大量代码。封装一个BaseDao,把通用的查询、更新操作提炼出来。这里不推荐用复杂的反射泛型框架,课程设计阶段用重载方法即可。
比如一个查询商品列表的方法:
public List<Product> listProducts(String keyword, int categoryId) { List<Product> list = new ArrayList<>(); String sql = "SELECT * FROM t_product WHERE status = 1"; List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sql += " AND name LIKE ?"; params.add("%" + keyword.trim() + "%"); } if (categoryId > 0) { sql += " AND category_id = ?"; params.add(categoryId); } sql += " ORDER BY create_time DESC"; // 执行查询并封装对象... }需要强调一个核心原则:SQL语句里一律用占位符。很多同学的第一个版本都是直接拼接字符串,比如:
安全的方式是这样的:
Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); String sql = "SELECT * FROM t_user WHERE username = ? AND password = ?"; ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password); rs = ps.executeQuery(); if (rs.next()) { // 登录成功 } } finally { DBUtil.close(rs, ps, conn); }PreparedStatement不只是防注入,它还能预编译SQL提升执行效率,而且参数类型明确,代码可读性也更好。写课程设计的时候答辩老师很可能会问SQL注入的防范,把PreparedStatement答出来,印象分会好很多。
3.3 查询结果封装成实体对象
ResultSet到实体对象这件事,很多同学在DAO里手动写getter。字段少的时候还行,字段多了很容易漏。
Product p = new Product(); p.setId(rs.getInt("id")); p.setName(rs.getString("name")); p.setPrice(rs.getBigDecimal("price")); p.setStock(rs.getInt("stock"));注意BigDecimal要用getBigDecimal而不是getDouble,否则精度又丢了。如果字段特别多,可以写一个RowMapper统一处理,或者用反射把列名映射到属性名,但反射在那个阶段可以先不折腾,手写一遍反而能帮你把每个字段都记牢。
3.4 密码存储的规范处理
用户密码用MD5存储是底线,但直接存MD5仍然不够安全,因为有彩虹表。建议加盐:
public class MD5Util { public static String md5(String source) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] bytes = md.digest(source.getBytes("UTF-8")); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) { sb.append('0'); } sb.append(hex); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(e); } } public static String md5WithSalt(String password, String salt) { return md5(password + salt); } }注册时生成随机盐,把md5(password+salt)和salt一起存到表里。登录时从库中取出盐,再计算一次比对。这样做即使数据库泄露,也无法直接反向破解密码。
4. Servlet控制层:登录会话、请求分发与编码过滤
4.1 Servlet的注解配置与路径映射
Servlet 3.0以上版本支持@WebServlet注解,在类上直接标注映射路径,就不用在web.xml里逐条配置了。路径命名建议规范一些,比如用户模块统一定义为/user下的二级路径。
@WebServlet("/register") public class RegisterServlet extends HttpServlet { // 也可以写成 @WebServlet({"/register", "/user/register"}) }Servlet常见的错误是路径匹配问题。表单里的action写的是register,Servlet映射的却是/Register,大小写不一致都会404。Tomcat路径匹配是区分大小写的,建议全部小写。
4.2 注册登录的业务逻辑实现
注册逻辑比较直接,先校验用户名是否已存在,再插入数据。但需要把一个关键点处理好:校验用户名存在和插入数据这两个操作之间存在竞态。严格来说应该给username加UNIQUE约束,插入的时候捕获SQLIntegrityConstraintViolationException,判断是唯一键冲突。这样即使两个人同时注册同一个用户名,数据库层面也能兜底,不会出现两条重复数据。
登录的实现重点是Session。用户登录成功后:
User user = userService.login(username, password); if (user != null) { HttpSession session = request.getSession(); session.setAttribute("loginUser", user); // 用户角色决定跳转到首页还是后台 if (user.isAdmin()) { response.sendRedirect(request.getContextPath() + "/admin/index"); } else { response.sendRedirect(request.getContextPath() + "/index.jsp"); } } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); }这里两个关键点。第一,跳转用重定向,如果用forward那么刷新页面会重复提交表单。第二,登录失败用forward,配合JSP里的<%@ page isErrorPage="true" %>或者request.getAttribute("errorMsg")回显错误信息。重定向到登录页会把错误信息丢掉,除非用URL参数传过去。
4.3 LoginFilter实现权限控制
很多页面必须是登录状态才能访问,比如购物车、结算页面、个人中心。手动在每个Servlet里判断很枯燥,写一个Filter统一拦截更干净。
@WebFilter("/*") public class LoginFilter implements Filter { 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.contains("/login") || uri.contains("/register") || uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".jpg") || uri.endsWith(".png") || uri.endsWith("/index.jsp") || uri.endsWith("/product/list") || uri.endsWith("/product/detail")) { chain.doFilter(req, resp); return; } HttpSession session = request.getSession(false); User user = (session != null) ? (User) session.getAttribute("loginUser") : null; if (user != null) { // 管理员访问用户页面可以放行,用户访问后台要拦截 if (uri.contains("/admin") && !user.isAdmin()) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } } }需要注意,获取Session时用的是request.getSession(false),这样不会为每一个静态请求都创建一个新的Session对象。否则不放行静态资源的话,页面上一堆图片和CSS都会触发Session创建,浪费内存。Filter里做字符串contains判断虽然有点粗糙,但对课程设计来说完全够用且容易理解。
4.4 中文乱码处理的正确姿势
乱码问题是JavaWeb项目里出现频率最高的,需要分三种情况处理。
POST请求乱码,在Servlet里加一行:
request.setCharacterEncoding("UTF-8");这行代码必须在读取任何参数之前调用。很多同学把它放在中间某个位置,结果发现还是乱码。
响应乱码,在获取Writer之前设置:
response.setContentType("text/html;charset=UTF-8");或者用response.setCharacterEncoding("UTF-8")。两者都可以,setContentType更严谨一些,顺便指定了MIME类型。
GET请求的乱码要复杂一些,因为GET参数在URL里,Tomcat 8以上版本默认已经使用UTF-8解码了,如果你的Tomcat是7或者更老的版本,需要在server.xml里的Connector标签上加上URIEncoding="UTF-8"。简单来说,只要Tomcat 8以上,处理的顺序是“先设置setCharacterEncoding,再读取数据,再输出数据,最后在JSP页面顶部也声明charset=UTF-8”,基本不会再有乱码。
JSP页面顶部加上这行:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>统一的思路就是请求、响应、数据库连接、页面文件编码全部统一成UTF-8,乱码问题自然消失。
5. JSP视图层:EL表达式、JSTL标签与数据回显
5.1 JSP最佳实践:页面里尽量不写Java代码
现在写JSP有个重要原则:视图层尽量只做展示,不能出现大段的Java代码。用EL表达式替代<%= %>输出,用JSTL标签替代if和for循环。
页面里不要出现这样的代码:
<% List<Product> list = (List<Product>)request.getAttribute("productList"); for (Product p : list) { %>而是这样写:
<c:forEach items="${productList}" var="p"> <div class="product-card"> <h3>${p.name}</h3> <p>价格:¥${p.price}</p> </div> </c:forEach>5.2 JSTL标签库的引入与常用标签
引入JSTL标签库,必须先保证WEB-INF/lib目录下有jstl.jar和standard.jar。很多同学下载了JSTL依赖,结果页面一直报错“The absolute uri cannot be resolved”,多半是jar包没放对位置。
页面顶部引入:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>常用的标签一要掌握c:forEach、c:if、c:choose、c:when、c:otherwise。商品列表展示、空购物车提示、登录状态不同显示不同导航,都用得上。
空购物车判断可以这样写:
<c:choose> <c:when test="${empty cartList}"> <p>购物车还是空的,快去挑选玩具吧</p> </c:when> <c:otherwise> <c:forEach items="${cartList}" var="item"> ... </c:forEach> </c:otherwise> </c:choose>EL表达式还有一种用法值得推荐:格式化价格。价格是BigDecimal对象,直接输出可能显示成12.00,想显示成12.00元,可以使用fmt标签:
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <fmt:formatNumber value="${p.price}" pattern="#.##" />5.3 商品列表展示与分页功能
商品列表页是最重要的页面之一。分页逻辑可以用一个PageBean封装:
public class PageBean<T> { private int pageNo; // 当前页码 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private int totalPage; // 总页数 private List<T> list; // 当前页的数据 }Servlet里计算分页参数的逻辑:
int pageNo = 1; int pageSize = 12; String pageNoStr = request.getParameter("pageNo"); if (pageNoStr != null && !pageNoStr.isEmpty()) { pageNo = Integer.parseInt(pageNoStr); } int totalCount = productDao.countAll(); int totalPage = (int) Math.ceil((double) totalCount / pageSize); if (pageNo < 1) pageNo = 1; if (pageNo > totalPage) pageNo = totalPage; int offset = (pageNo - 1) * pageSize; List<Product> list = productDao.listPage(offset, pageSize);分页SQL用LIMIT:
SELECT * FROM t_product ORDER BY create_time DESC LIMIT ?, ?JSP里用c:forEach生成分页导航:
<c:forEach begin="1" end="${pageBean.totalPage}" var="i"> <a href="${pageContext.request.contextPath}/product/list?pageNo=${i}">${i}</a> </c:forEach>5.4 页面复用与静态资源路径
JSP最常见的重复代码是导航栏和页脚。用include指令可以解决:
<%@ include file="header.jsp" %>这种方式是静态包含,把header内容原样贴进来。也可以用jsp:include动态加载,两种各有适用场景,课程设计阶段用静态include就够了。
一个很容易踩的坑是静态资源路径。如果项目用了相对路径,在二级路径页面里CSS样式全部失效。解决办法有两个,一是全部用绝对路径,二是在页面顶部用JSP的内置变量拼出基础路径:
<% String basePath = request.getContextPath() + "/"; %>然后在引用资源时都拼上basePath:
<link rel="stylesheet" href="<%=basePath%>css/common.css"> <img src="<%=basePath%>${product.image}">在JSP中使用<%= %>输出basePath这种做法虽然不算优雅,但对课程项目来说直观易懂。另一种更JSP风格的写法是用EL表达式配合${pageContext.request.contextPath},比如:
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/common.css">推荐使用EL表达式版本,这样页面里少一段Java代码。
6. 购物车与订单:事务处理和并发控制的实战现场
6.1 购物车表交互逻辑设计
既然购物车用数据库存,那么购物车的表结构我们在前面已经建好了。添加购物车的核心SQL就是那句ON DUPLICATE KEY UPDATE。
具体到Servlet的处理流程:
- 从Session中取当前登录用户
- 从请求参数中取出商品id和数量
- 调用CartService.addToCart(userId, productId, quantity)
- 返回购物车数量给页面,或者直接跳转到购物车列表页
购物车CRUD的DAO层实现比较直接,但有一个地方需要注意:查询购物车列表时,如果把商品信息和购物车数据分开查询会造成N+1问题。每查出一条购物车记录,就要再查一次商品表,商品数量多的时候效率很低。应该用一条JOIN查询:
SELECT c.id AS cart_id, c.quantity, p.* FROM t_cart c JOIN t_product p ON c.product_id = p.id WHERE c.user_id = ? ORDER BY c.add_time DESC这样一次查询就把购物车的所有数据带出来了。Product实体类里可以加一个quantity字段,或者单独建一个CartItemVO类,用于购物车列表展示。
6.2 修改数量和删除项:表单提交还是Ajax
购物车页面通常需要修改数量、删除条目。课程设计阶段可以用form表单的post提交,每次修改数量后传productId和新的数量给CartServlet,更新完再重定向回购物车页面。这种方案简单但每改一次就刷新一次页面,体验一般。
更好的方案是用jQuery发起Ajax请求,局部更新总价。但要注意后端跨请求传参的约定,建议写一个统一的接口规范。比如:
POST /cart/update 参数:productId=1&quantity=2 响应:{"code":0, "totalPrice": 99.00}如果不想引入JSON解析库,也可以在Servlet里直接向前端输出纯文本,然后前端拿到文本后用parseFloat转换。Gson这个库很轻量,导入一个jar包就能用。
6.3 下单核心流程:事务必须到位
下单是整个项目里最考验逻辑的部分。一次下单操作涉及四张表,任何一步失败都不能留下脏数据。
下单流程:
- 校验购物车是否有商品
- 查询商品最新价格,重新计算总价,不能用前端传来的总价
- 创建订单主表记录,拿到订单id
- 遍历购物车明细,逐条写入t_order_item
- 扣减商品库存,增加销量
- 清空该用户的购物车
- 提交事务
TransactionService里这一步必须开启事务:
public Order createOrder(int userId, String receiverName, String receiverPhone, String receiverAddress) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 查询购物车明细 List<CartItem> cartItems = cartDao.findCartItemsByUserId(conn, userId); // 2. 计算总价,校验库存 BigDecimal totalPrice = BigDecimal.ZERO; for (CartItem item : cartItems) { Product product = productDao.findById(conn, item.getProductId()); if (product.getStock() < item.getQuantity()) { throw new BusinessException("商品[" + product.getName() + "]库存不足"); } totalPrice = totalPrice.add(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 3. 生成订单号 String orderNo = generateOrderNo(); // 4. 插入订单主表 // 5. 插入订单明细 // 6. 扣减库存、增加销量 for (CartItem item : cartItems) { Product product = productDao.findById(conn, item.getProductId()); int rows = productDao.deductStock(conn, product.getId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("商品[" + product.getName() + "]库存不足"); } } // 7. 清空购物车 cartDao.clearCart(conn, userId); conn.commit(); return order; } catch (Exception e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } throw new RuntimeException(e); } finally { try { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException ex) { ex.printStackTrace(); } } }这里我把Connection作为参数传入DAO方法,是事务能生效的关键。如果每个DAO方法内部自己DBUtil.getConnection(),那各操作就各用各的连接,commit和rollback根本无法统一控制。
6.4 库存防止超卖:UPDATE语句加上库存判断
订单并发问题听起来很高级,但对工具商城来说其实可以很简单地处理。核心思路是用一条SQL完成“检查库存并扣减”两个操作:
UPDATE t_product SET stock = stock - ?, sales = sales + ? WHERE id = ? AND stock >= ?注意最后的条件stock >= ?,这条UPDATE语句执行后返回受影响行数。如果行数是0,说明库存不足,需要回滚并提示用户重新下单。这就是一种乐观锁的思路,在高并发场景下依然能防止超卖。
课程设计的答辩环节,老师如果问“多人同时下单怎么办”,可以把这条SQL抛出来,比解释一堆复杂的理论更有说服力。
7. 后台管理模块:商品管理、订单处理和统计
7.1 管理员角色与登录入口
后台管理功能需要一个管理员账户。可以在数据库初始化时直接插入一条角色为1的用户数据:
INSERT INTO t_user (username, password, nickname, role) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', '管理员', 1);这个密码值是123456的MD5。正式演示的时候建议换成别的密码,因为默认密码太常见了。初始化管理员时顺便加盐处理更好。
后台页面的URL都加上admin前缀,配合之前的LoginFilter做权限控制。普通用户访问后台直接跳转登录页。
7.2 商品CRUD与图片上传
商品后台管理要支持新增、编辑、下架、上架、删除。图片上传是容易出问题的环节。在Servlet 3.0及以上版本,可以直接用request.getPart("image")处理上传文件,不需要额外导入FileUpload组件。
@MultipartConfig(maxFileSize = 5 * 1024 * 1024, maxRequestSize = 10 * 1024 * 1024) @WebServlet("/admin/product/add") public class ProductAddServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Part part = request.getPart("image"); String fileName = getFileName(part); if (fileName != null && !fileName.isEmpty()) { // 使用UUID作为文件名,避免重名 String ext = fileName.substring(fileName.lastIndexOf(".")); String newFileName = UUID.randomUUID().toString().replace("-", "") + ext; String uploadPath = request.getServletContext().getRealPath("/uploads"); File dir = new File(uploadPath); if (!dir.exists()) dir.mkdirs(); part.write(uploadPath + File.separator + newFileName); // 将"uploads/文件名"存入数据库 } } private String getFileName(Part part) { String contentDisposition = part.getHeader("content-disposition"); for (String token : contentDisposition.split(";")) { if (token.trim().startsWith("filename")) { return token.substring(token.indexOf("=") + 1).trim().replace("\"", ""); } } return null; } }文件写入磁盘后,数据库只保存upload/xxx.jpg这样的相对路径。JSP页面渲染图片时,用basePath拼接一下就能访问。
7.3 订单状态管理
管理员查看订单列表,处理订单状态。订单状态字段用整型存储,页面展示时建议用JSP的c:choose做映射:
<c:choose> <c:when test="${o.status == 0}">待付款</c:when> <c:when test="${o.status == 1}">已付款</c:when> <c:when test="${o.status == 2}">已发货</c:when> <c:when test="${o.status == 3}">已完成</c:when> <c:when test="${o.status == 4}">已取消</c:when> </c:choose>管理员订单处理就是点击按钮,把状态从1改成2,从2改成3。写一个通用的状态更新接口即可。对状态迁移,简单判断一下当前状态是否允许变更,比如已取消的订单不能再发货。
7.4 简单的销售统计
后台首页可以放几个统计卡片:累计销售额、待处理订单数、上架商品数、注册用户数。统计SQL用聚合函数就能完成:
-- 累计销售额(排除已取消订单) SELECT COALESCE(SUM(total_price), 0) FROM t_order WHERE status != 4; -- 待处理订单数 SELECT COUNT(*) FROM t_order WHERE status = 1; -- 分类销量排行 SELECT c.name, SUM(oi.quantity) AS total_count FROM t_order_item oi JOIN t_product p ON oi.product_id = p.id JOIN t_category c ON p.category_id = c.id GROUP BY c.id ORDER BY total_count DESC;把统计结果封装成一个Map或者一个DashboardVO传到后台首页JSP,用EL表达式直接展示。答辩时能讲清楚这几个统计数字背后的SQL逻辑,基本就展示了你的SQL功底。
8. 高频问题和排查经验速查
8.1 数据库连接失败
报错信息常见的有以下几种:
| 报错特征 | 原因 | 解决 |
|---|---|---|
| ClassNotFoundException: com.mysql.jdbc.Driver | 驱动包没导入或版本不匹配 | 确认mysql-connector-java.jar在WEB-INF/lib下;MySQL 8用com.mysql.cj.jdbc.Driver |
| UnknownHostException / Access denied for user | URL里的地址写错或用户名密码不对 | 检查jdbc:mysql://后的host和端口,检查root密码 |
| Communications link failure | MySQL服务没启动或端口不对 | 启动MySQL服务,确认3306端口没被占用 |
| Public Key Retrieval is not allowed | MySQL 8默认认证插件问题 | URL上加上allowPublicKeyRetrieval=true |
8.2 页面报404和500
404表示请求路径找不到,排查思路比较清晰:
- 访问的URL和@WebServlet中的路径是否完全一致,包括大小写和项目名
- 表单action有没有写contextPath,比如${pageContext.request.contextPath}/login
- web.xml里有没有配置welcome-file,首页是否命名index.jsp
- 项目有没有成功部署到Tomcat,控制台有没有报部署异常
500就复杂一些,通常会在Tomcat控制台或页面堆栈信息里看到具体的报错位置。常见的几个固定场景:
- NullPointerException:多半是某个对象没从request或Session中正确取出。比如Session里没有loginUser,直接在JSP里用${loginUser.nickname}
- NumberFormatException:前端传来的字符串参数转Integer时没做非空和格式校验。比如pageNo传成了pageNo=
- 权限错误:Filter拦截后跳转页面时,路径使用request.getRequestDispatcher("admin/index.jsp")没有加contextPath,跳转失败
8.3 Tomcat端口被占用
启动时出现Port 8080 required by Tomcat v9.0 Server is already in use,说明8080端口被别的进程占用了。Windows下用命令查一下占用进程:
netstat -ano | findstr 8080 taskkill /f /pid 进程号或者直接修改Tomcat的server.xml端口。网络端口占用是小事,别动不动重启电脑。
8.4 商品图片不显示
图片不显示,优先按这个顺序排查。第一,浏览器F12打开网络面板,看图片请求返回什么状态。第二,确认图片文件的物理路径是磁盘上项目部署的真实路径,而不是Eclipse里workspace的路径。getRealPath返回的是Tomcat容器实际运行的路径。第三,确认数据库里存的路径和实际路径大小写一致。Linux服务器上路径大小写敏感,Windows上不敏感,开发环境没问题不代表部署到Linux上没问题。
8.5 Session失效和登录状态丢失
部署后重启Tomcat,Session就没了,重新登录是正常现象。但项目运行过程中经常掉登录状态,就要检查Filter是否误拦截了首页和相关请求。另外JSP页面里request.getSession()和request.getSession(true)都会创建新Session,有时无法登录是因为登录Servlet里存的Session和Filter里取的Session不是同一个,检查一下Filter中是否用request.getSession()强制创建了新会话。
8.6 数据库驱动版本冲突
如果项目里既在Java代码里引用了驱动jar包,又在Tomcat的lib目录下放了另一个版本的驱动jar包,可能会报奇怪的错误。建议驱动包只放在项目WEB-INF/lib里,Tomcat全局lib目录不要放。否则两个类加载器加载同一份类,偶发异常排查起来非常痛苦。
8.7 代码修改不生效
JSP修改后不需要重启Tomcat,刷新页面就能看到。Servlet和DAO类的修改需要重启。如果重启后还是旧代码,检查Tomcat是不是没有清理原来的部署目录,或者Eclipse里的自动编译没有触发。手动执行Project -> Clean,让项目重新编译生成class文件,再重启Tomcat,大部分能解决。
8.8 排序的优先级问题
商品展示列表如果不加ORDER BY,数据库返回的结果顺序是不确定的,执行效率也会受影响。建议所有列表查询都显式加上排序字段,比如商品按创建时间倒序,后台订单按状态和时间综合排序:
SELECT * FROM t_order ORDER BY CASE WHEN status IN (0, 1) THEN 0 ELSE 1 END, create_time DESC;这样能保证未处理的订单排在前面,已完成的自动沉底。
8.9 防御性编程:参数校验不要漏
任何从前端接收的参数都要在Servlet层校验,不能信任用户输入。比如数量既然是int类型,如果前端传了abc,Integer.parseInt会直接抛异常。简单封装一个判断,非法输入给个友好提示:
String quantityStr = request.getParameter("quantity"); if (quantityStr == null || !quantityStr.matches("\\d+")) { request.setAttribute("errorMsg", "数量参数无效"); request.getRequestDispatcher("/cart/list").forward(request, response); return; }这样页面至少不会白屏,只会提示用户操作出错。
9. 最后说点实操体会
做完这个项目,我不敢说JSP+Servlet是最先进的技术,但它确实能帮人把JavaWeb的基础打扎实。我实际带过不少同学调试类似的代码,绝大多数问题不是技术难点不会,而是细节没有处理好。比如数据库连接没关闭导致假死,Filter拦截范围过大把所有请求都吞掉,事务只开了一半忘记提交,这些坑踩过一次就会长记性,将来用框架时再遇到类似问题,你能直接想到框架底层是怎么处理的,排查起来就能直击要害。
如果时间充裕,我建议在这个基础上做两个小扩展。第一个是注册时发送简单的通知,不用真的接短信,控制台打一行日志就好,体验一下异步处理流程。第二个是把分页改成携带分类筛选和搜索关键词的状态保持,这样点击第二页的时候不会丢了筛选条件。这两个点虽然不大,但做完之后你会对请求流转和数据状态的理解更上一个台阶。
最后再分享一个我自己的习惯:写代码之前先把所有Servlet的URL清单列出来,写一张表放在项目根目录的README里。比如GET /product/list是商品列表,POST /cart/add是添加购物车,PUT /admin/order/status是修改状态。开发时对照着这张表检查每个页面跳转,能省下特别多找路径的时间。