资讯动态

JSP+Servlet+JDBC+MySQL玩具商城实战:从数据库设计到后台管理

发布时间:2026/10/16 9:58:30 来源:尧图企业网站定制
拿到的这个题目是典型的JavaWeb课程设计或者毕业设计选题。JSPServletJDBCMySQL这套组合放在今天看确实不算新潮但几乎所有计算机专业的学生都绕不开它。玩具商城这个业务场景涵盖了电商系统最核心的用户、商品、购物车、订单这几大模块业务复杂度适中既能体现技术深度又不至于把精力全耗在需求分析上。我前前后后做过不少类似的项目也帮同学改过很多版本JSPServlet这套架构最大的价值在于框架少、原理透你能真切看到每一个HTTP请求是怎么被处理、每一行数据是怎么流到页面上的。这篇文章就把我从数据库设计到后台管理的完整实现思路展开聊聊包括踩过的坑和排查方法适合正在做课程设计或者想把手写JavaWeb项目打通的同学参考。1. 项目整体设计与技术选型思路1.1 核心需求拆解玩具商城到底要做哪些功能玩具商城听起来是个小项目但仔细拆下来功能点一点都不少。站在用户侧至少需要注册登录、商品分类浏览、商品详情查看、关键词搜索、加入购物车、修改购物车数量、提交订单。站在管理员侧需要商品管理、分类管理、订单状态处理和简单的销售统计。我见过很多同学一上来就写代码写到最后发现表格对不上、功能链断了。正确的做法是先画用例图或者列功能清单。玩具商城的核心链路就一条用户浏览商品把商品加入购物车结算生成订单管理员处理订单。所有表结构和代码逻辑都应该围绕这条主链路展开。1.2 技术选型为什么选用JSPServletJDBC而不是Spring Boot这道题的限定技术栈是JSPServletJDBCMySQL很多同学觉得这套组合太老了。但说句实在话做课程设计恰恰适合用这套组合原因有三。第一框架黑盒少。Spring Boot自动配置帮你把DispatcherServlet、视图解析器、数据库连接池都配好了出了问题你连排查方向都没有。但JSPServlet这套一个请求从浏览器发出经过Servlet处理再到JSP渲染每一条链路都清清楚楚。任何报错都能在Tomcat控制台里找到对应代码位置。第二契合学习曲线。如果你马上要找实习或者工作Spring Boot是必须掌握的。但如果你还在课程阶段直接上Spring Boot容易绕开Servlet、JSP、会话管理这些底层知识面试时一问细节就露馅。先做几个JSPServlet项目打底再过渡到框架跨度会小很多。第三部署和演示方便。一个打包好的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.10.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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue; 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.Driver8.x用com.mysql.cj.jdbc.Driver。你本地的版本最好先确认一下。URL里的serverTimezoneAsia/Shanghai必须加尤其是MySQL 8.x版本不加这个经常会报日期时区相关的异常。allowPublicKeyRetrievaltrue也是MySQL 8.x连接时可能遇到的问题之一不加会报public key retrieval not allowed。3.2 BaseDao的简单封装思路每个实体都写一套完整DAO会重复大量代码。封装一个BaseDao把通用的查询、更新操作提炼出来。这里不推荐用复杂的反射泛型框架课程设计阶段用重载方法即可。比如一个查询商品列表的方法public ListProduct listProducts(String keyword, int categoryId) { ListProduct list new ArrayList(); String sql SELECT * FROM t_product WHERE status 1; ListObject 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(passwordsalt)和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写的是registerServlet映射的却是/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 isErrorPagetrue %或者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;charsetUTF-8);或者用response.setCharacterEncoding(UTF-8)。两者都可以setContentType更严谨一些顺便指定了MIME类型。GET请求的乱码要复杂一些因为GET参数在URL里Tomcat 8以上版本默认已经使用UTF-8解码了如果你的Tomcat是7或者更老的版本需要在server.xml里的Connector标签上加上URIEncodingUTF-8。简单来说只要Tomcat 8以上处理的顺序是“先设置setCharacterEncoding再读取数据再输出数据最后在JSP页面顶部也声明charsetUTF-8”基本不会再有乱码。JSP页面顶部加上这行% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%统一的思路就是请求、响应、数据库连接、页面文件编码全部统一成UTF-8乱码问题自然消失。5. JSP视图层EL表达式、JSTL标签与数据回显5.1 JSP最佳实践页面里尽量不写Java代码现在写JSP有个重要原则视图层尽量只做展示不能出现大段的Java代码。用EL表达式替代% %输出用JSTL标签替代if和for循环。页面里不要出现这样的代码% ListProduct list (ListProduct)request.getAttribute(productList); for (Product p : list) { %而是这样写c:forEach items${productList} varp div classproduct-card h3${p.name}/h3 p价格${p.price}/p /div /c:forEach5.2 JSTL标签库的引入与常用标签引入JSTL标签库必须先保证WEB-INF/lib目录下有jstl.jar和standard.jar。很多同学下载了JSTL依赖结果页面一直报错“The absolute uri cannot be resolved”多半是jar包没放对位置。页面顶部引入% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfn urihttp://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} varitem ... /c:forEach /c:otherwise /c:chooseEL表达式还有一种用法值得推荐格式化价格。价格是BigDecimal对象直接输出可能显示成12.00想显示成12.00元可以使用fmt标签% taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % fmt:formatNumber value${p.price} pattern#.## /5.3 商品列表展示与分页功能商品列表页是最重要的页面之一。分页逻辑可以用一个PageBean封装public class PageBeanT { private int pageNo; // 当前页码 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private int totalPage; // 总页数 private ListT 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; ListProduct list productDao.listPage(offset, pageSize);分页SQL用LIMITSELECT * FROM t_product ORDER BY create_time DESC LIMIT ?, ?JSP里用c:forEach生成分页导航c:forEach begin1 end${pageBean.totalPage} vari a href${pageContext.request.contextPath}/product/list?pageNo${i}${i}/a /c:forEach5.4 页面复用与静态资源路径JSP最常见的重复代码是导航栏和页脚。用include指令可以解决% include fileheader.jsp %这种方式是静态包含把header内容原样贴进来。也可以用jsp:include动态加载两种各有适用场景课程设计阶段用静态include就够了。一个很容易踩的坑是静态资源路径。如果项目用了相对路径在二级路径页面里CSS样式全部失效。解决办法有两个一是全部用绝对路径二是在页面顶部用JSP的内置变量拼出基础路径% String basePath request.getContextPath() /; %然后在引用资源时都拼上basePathlink relstylesheet href%basePath%css/common.css img src%basePath%${product.image}在JSP中使用% %输出basePath这种做法虽然不算优雅但对课程项目来说直观易懂。另一种更JSP风格的写法是用EL表达式配合${pageContext.request.contextPath}比如link relstylesheet 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层实现比较直接但有一个地方需要注意查询购物车列表时如果把商品信息和购物车数据分开查询会造成N1问题。每查出一条购物车记录就要再查一次商品表商品数量多的时候效率很低。应该用一条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 参数productId1quantity2 响应{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. 查询购物车明细 ListCartItem 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.DriverUnknownHostException / Access denied for userURL里的地址写错或用户名密码不对检查jdbc:mysql://后的host和端口检查root密码Communications link failureMySQL服务没启动或端口不对启动MySQL服务确认3306端口没被占用Public Key Retrieval is not allowedMySQL 8默认认证插件问题URL上加上allowPublicKeyRetrievaltrue8.2 页面报404和500404表示请求路径找不到排查思路比较清晰访问的URL和WebServlet中的路径是否完全一致包括大小写和项目名表单action有没有写contextPath比如${pageContext.request.contextPath}/loginweb.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失效和登录状态丢失部署后重启TomcatSession就没了重新登录是正常现象。但项目运行过程中经常掉登录状态就要检查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类型如果前端传了abcInteger.parseInt会直接抛异常。简单封装一个判断非法输入给个友好提示String quantityStr request.getParameter(quantity); if (quantityStr null || !quantityStr.matches(\\d)) { request.setAttribute(errorMsg, 数量参数无效); request.getRequestDispatcher(/cart/list).forward(request, response); return; }这样页面至少不会白屏只会提示用户操作出错。9. 最后说点实操体会做完这个项目我不敢说JSPServlet是最先进的技术但它确实能帮人把JavaWeb的基础打扎实。我实际带过不少同学调试类似的代码绝大多数问题不是技术难点不会而是细节没有处理好。比如数据库连接没关闭导致假死Filter拦截范围过大把所有请求都吞掉事务只开了一半忘记提交这些坑踩过一次就会长记性将来用框架时再遇到类似问题你能直接想到框架底层是怎么处理的排查起来就能直击要害。如果时间充裕我建议在这个基础上做两个小扩展。第一个是注册时发送简单的通知不用真的接短信控制台打一行日志就好体验一下异步处理流程。第二个是把分页改成携带分类筛选和搜索关键词的状态保持这样点击第二页的时候不会丢了筛选条件。这两个点虽然不大但做完之后你会对请求流转和数据状态的理解更上一个台阶。最后再分享一个我自己的习惯写代码之前先把所有Servlet的URL清单列出来写一张表放在项目根目录的README里。比如GET /product/list是商品列表POST /cart/add是添加购物车PUT /admin/order/status是修改状态。开发时对照着这张表检查每个页面跳转能省下特别多找路径的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑