☰
JavaWeb蛋糕商城实战:从Servlet到订单事务的完整开发指南
2026/10/12 13:46:04 网站建设 项目流程

简介:面向JavaWeb学习者,这份蛋糕商城项目源码完整包含客户、商品、订单三大功能模块,适合课程设计、毕业设计或进阶实战。客户管理区分普通用户与管理员:用户可注册、登录、退出以及修改地址和密码;管理员可登录后添加、修改、删除客户信息,并能重置用户密码。商品管理亮点较多,购物车采用cookie-json存储,商品支持添加(包含文件上传)、修改、删除、分类与类目管理、搜索,并可设置条幅、热销、新品三类推荐。订单模块支持用户提交订单,管理员查询订单列表和删除订单。资源包共225个文件,包含52个Java源码、23个JSP页面、16个CSS样式、11个Jar依赖和SQL数据库脚本,另有多张图片素材,总大小约10.97MB,目录结构清晰易检索。已有6784人学习下载,适合想系统掌握JavaWeb典型业务逻辑开发者参考练习。

1. 开篇:JavaWeb 蛋糕商城是什么

JavaWeb 蛋糕商城,是很多 Web 学习者绕不开的一个完整落地项目:它把 Servlet、JSP、MySQL、Maven、BootStrap 这些零散技术串成一条业务线,从用户登录、浏览蛋糕、加购物车、下订单到后台管理,全程自己动手做一遍,比刷一百道 Servlet 面试题都管用。这个项目最大的价值不是“能跑”,而是它逼着你把请求怎么进来、数据怎么存、页面怎么渲染、事务怎么保证这几件事真正想明白,所以它既适合拿来练手,也适合作为课程设计或入职前的综合项目复现。标题里那个“系统”二字不是套路,是提醒你按真实业务流程来做——用户端能买,管理端能管,而不是只堆一个花哨首页。

2. 先拆清楚:蛋糕商城由哪些零件组成,怎么选型不翻车

2.1 技术栈选型:为什么我用 Servlet + JSP + JSTL,而不是一上来就 Spring Boot

做蛋糕商城类似规模的项目,最怕“技术选型用力过猛”。我见过不少同学第一步就架 Spring Boot + MyBatis Plus + Redis,结果数据库表还没建明白,光配置就折腾了两天,最后项目没了主心骨。这里我不反对用 Spring Boot,而是想强调:如果你是在校学生或刚学完 JavaWeb 基础,用 Servlet + JSP + JSTL + JDBC + Tomcat 反而更容易把业务逻辑看通透。

常见做法是这样的:

角色技术选型干的事
前端页面JSP + BootStrap + JQuery列表页、详情页、购物车、结算页、后台管理页
控制层Servlet 或 BaseServlet + 反射接收请求、调 Service、跳转
业务层Service 接口 + 实现校验参数、组装数据、控制事务
数据访问层DAO + JDBC(可用 DBUtils 简化)增删改查,拼 SQL
数据库MySQL 5.7 / 8.x用户、分类、商品、购物车、订单、订单项
构建工具Maven管理 jar 包,打 war 包
服务器Tomcat 8.5 / 9运行项目

一句话总结选型思路:Servlet 虽然写着繁琐,但能看到全链路——前端表单提交、Servlet 接收、Service 处理、DAO 查库、结果回填到 JSP,每一段都是显式的。Spring Boot 把这些藏进注解里,新手反而不知道数据到底怎么流转的。

2.2 数据库设计:六张表怎么建,字段怎么定才够用

蛋糕商城的表结构每家略有不同,但我常用的核心是六张表:用户表 user、分类表 category、商品表 cake、购物车表 cart(可选)、订单表 orders、订单项表 order_item。注意一个经验:购物车表不是必须落库的,纯放 Session 也能跑,但如果你要做“用户重登后购物车还在”,就必须落库。我这里的方案是落库,便于复现完整逻辑。

建表语句大致长这样:

CREATE TABLE user ( uid INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码,加盐后存储', nickname VARCHAR(32), phone VARCHAR(16), address VARCHAR(255) COMMENT '默认收货地址', created_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE category ( cid INT PRIMARY KEY AUTO_INCREMENT, cname VARCHAR(32) NOT NULL ); CREATE TABLE cake ( cid INT PRIMARY KEY AUTO_INCREMENT, cname VARCHAR(64) NOT NULL, category_id INT NOT NULL COMMENT '所属分类', price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0 COMMENT '库存', image VARCHAR(255) COMMENT '图片相对路径', detail TEXT COMMENT '商品描述', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', sales INT DEFAULT 0 COMMENT '销量,可做排序' ); CREATE TABLE cart_item ( cart_id INT PRIMARY KEY AUTO_INCREMENT, uid INT NOT NULL COMMENT '所属用户', cake_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_cake (uid, cake_id) ); CREATE TABLE orders ( oid VARCHAR(32) PRIMARY KEY COMMENT '订单号,前端生成', uid INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, receiver VARCHAR(32) COMMENT '收货人', phone VARCHAR(16), address VARCHAR(255), status TINYINT DEFAULT 0 COMMENT '0未付款 1已付款 2已发货 3已完成 4已取消', created_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, oid VARCHAR(32) NOT NULL, cake_id INT NOT NULL, price DECIMAL(10,2) COMMENT '下单时的单价', quantity INT NOT NULL );

几个字段设计的重点:

  • 订单表用 varchar 做主键,订单号在 Service 层生成,比如yyyyMMddHHmmss加一个随机后几位,避免数据库自增主键暴露订单量,也方便业务上追溯。
  • cake 表里存sales字段,是为了后期做“销量排序”或“热销推荐”,这比每次 count 订单项快得多。
  • 订单项里存price,这是下单那一刻的成交价,不直接 Join 商品表。因为蛋糕可能改价,历史订单应该保留旧价格。这一点很多新手容易忽略。
  • 密码不能明文存,起码要加盐哈希。课程设计阶段用老一代的 MD5 加盐也能接受,能解释明白也行;有时间建议了解并优先使用 BCrypt 这样的更可信的算法。

2.3 项目目录结构:用 Maven 布局,把每一层放对位置

我见过不少学生项目把手写类全堆在 src 里,Tomcat 也能跑,但改一个功能要找半天。我更推荐用 Maven 的标准目录结构,至少分清楚webapp和java:

cake-shop ├── pom.xml └── src/main ├── java/com/cake │ ├── controller // Servlet,如 UserServlet, CakeServlet, OrderServlet, CartServlet │ ├── service // 接口 + impl │ ├── dao // JdbcUtils + UserDao + CakeDao + OrderDao │ ├── entity // User, Cake, Category, Order, OrderItem, CartItem │ ├── util // DBUtils, MD5Util, OrderIdUtil │ └── filter // EncodingFilter, 登录拦截(可选) └── webapp ├── index.jsp ├── user / login.jsp, register.jsp ├── cake / list.jsp, detail.jsp ├── cart / cart.jsp ├── order / confirm.jsp, pay.jsp, success.jsp └── admin / 后台管理页面

目录能说明问题的本质。看到controller里有CakeServlet,说明是“按资源建 Servlet”,每个业务资源一个控制器;如果是 Spring Boot,你会写CakeController,结构意思差不多。先习惯这种分层,后面再去学框架,会发现很多概念只是换了壳。

pom.xml 里的依赖不用多,最精简是这样:

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.31</version> </dependency> <dependency> <groupId>commons-dbutils</groupId> <artifactId>commons-dbutils</artifactId> <version>1.7</version> </dependency> <dependency> <groupId>com.mchange</groupId> <artifactId>c3p0</artifactId> <version>0.9.5.5</version> </dependency> </dependencies>

依赖说明:provided表示 Servlet 容器里已有,打包时别重复打进 war;JSTL 是 JSP 页面里用<c:forEach>的必需品;DBUtils 帮我们省去手写ResultSet转对象的体力活;c3p0 是连接池。连接池一定要上,不然高并发下单时数据库连接反复创建会非常吃力。

到这里,技术栈和数据库已经立住了。下一章开始写用户端的一条主线:登录注册、看商品、加购物车。

3. 用户端核心流程:登录、商品列表、购物车用什么姿势写最稳

3.1 注册与登录:给密码加盐,用 Session 管理登录态

用户模块最常见的翻车点是两个:密码明文入库,登录判断写在 JSP 页面里。前者是安全问题,后者是逻辑混乱。先看注册的 Service 层怎么写:

public boolean register(User user) { // 1. 校验用户名是否存在 User exist = userDao.findByUsername(user.getUsername()); if (exist != null) { return false; // 用户名已占用 } // 2. 生成随机盐 String salt = UUID.randomUUID().toString().substring(0, 8); String saltedPwd = MD5Util.md5(user.getPassword() + salt); user.setSalt(salt); user.setPassword(saltedPwd); // 3. 插入新用户 return userDao.insert(user) == 1; }

登录时不再直接select * from user where username='...' and password='...',而是先查出用户,再用同样的规则拼接盐哈希比对:

public User login(String username, String rawPassword) { User user = userDao.findByUsername(username); if (user == null) { return null; } String saltedPwd = MD5Util.md5(rawPassword + user.getSalt()); if (!saltedPwd.equals(user.getPassword())) { return null; } return user; // 密码匹配,返回完整用户对象 }

参数说明:盐存在 user 表里多一个字段salt,之前建表语句里我没写全,实际要补上salt VARCHAR(8)。MD5Util.md5是常见的工具方法,内部用MessageDigest做哈希,再转十六进制字符串。为什么加盐而不是直接 MD5 两次?直接 MD5 可以跑彩虹表碰撞,加盐后每个用户的盐不同,同样的密码散列值也不同,安全性明显更强。

登录态管理我在 Servlet 里这样做:登录成功后session.setAttribute("user", user);需要判断登录的地方,比如购物车、结算页,用 Filter 统一拦:

public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; HttpSession session = request.getSession(false); String uri = request.getRequestURI(); if (uri.endsWith("login.jsp") || uri.endsWith("register.jsp")) { chain.doFilter(req, res); return; } if (session == null || session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/user/login.jsp"); return; } chain.doFilter(req, res); } }

这是“白名单放行”思路,除了登录注册页,其他请求都要求已登录。新手容易写成“黑名单拦截”,每加一个页面都要想起改配置,很快就漏了。白名单相反——默认全拦,只放行公开页,新加的敏感页面天然受保护,这是不容易出问题的做法。

在web.xml里注册过滤器时注意,把静态资源、index.jsp也放行,否则都进不去。一个比较省心的方法是配置两个 Filter,一个编码过滤器EncodingFilter管request.setCharacterEncoding("UTF-8"),一个登录过滤器管权限,顺序别搞反。

3.2 商品列表与详情:分类展示、分页、图片路径的前后端约定

商品列表页是用户进商城看到的第一屏,这个页面如果卡顿或者图片裂掉,体验就直接崩盘。查询逻辑不复杂,关键在于分页和图片路径。

我的分页做法是传pageNumber和pageSize,DAO 里用LIMIT ?,?查当前页数据,另查一个总数算总页数:

public PageBean<Cake> findCakesByCategory(int cid, int pageNumber, int pageSize) { PageBean<Cake> pb = new PageBean<>(); pb.setPageNumber(pageNumber); pb.setPageSize(pageSize); String countSql = "SELECT COUNT(*) FROM cake WHERE category_id = ?"; long totalCount = queryRunner.query(countSql, new ScalarHandler<Long>(), cid); pb.setTotalCount(totalCount); pb.setTotalPages((int) Math.ceil(totalCount * 1.0 / pageSize)); String dataSql = "SELECT * FROM cake WHERE category_id = ? ORDER BY sales DESC LIMIT ?,?"; List<Cake> list = queryRunner.query(dataSql, new BeanListHandler<>(Cake.class), cid, (pageNumber - 1) * pageSize, pageSize); pb.setList(list); return pb; }

参数说明:LIMIT后的偏移量是(pageNumber - 1) * pageSize,这个公式值得记住——所有基于 MySQL 的分页都逃不开它;ScalarHandler<Long>是 DBUtils 里用于取单行单列查询结果的处理器;BeanListHandler<Cake>要求字段名和 Cake 类的属性名保持一致,否则查出来全是 null,这一步是新手高发区。

图片路径我建议数据库只存相对路径,比如images/cake/chocolate_small.png,JSP 里统一加项目上下文:

<img src="${pageContext.request.contextPath}/${cake.image}" class="img-responsive" alt="${cake.cname}">

${pageContext.request.contextPath}拿到的是当前 Web 应用的根路径,Tomcat 部署后可能是http://localhost:8080/cake-shop/这种带上下文路径的地址。如果只写/images/...,部署到带上下文的环境时就容易 404。这个看似细节的东西,我见过不少同学在课上演示时翻车,因为全班都在根路径访问没问题,一到自己的机器上就图片全裂。养成用 contextPath 的习惯百利无害。

3.3 购物车:加车、改数量、清空,作用域里放什么数据结构

购物车实现方案有几种:Session 存 Map、Session 存 List、数据库存表。我建议课程设计和练手阶段用 Session 存 Map<Integer(Integer代表cake_id), CartItem>,理由是购物车是高频操作,落库会引入很多并发更新问题,而 Session 方案的业务模型足够支撑你把流程讲清楚。

加入购物车的核心代码片段如下:

public void addToCart(HttpSession session, Cake cake, int quantity) { // 从 session 里取购物车,首次访问时创建 Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } if (cart.containsKey(cake.getCid())) { CartItem item = cart.get(cake.getCid()); item.setQuantity(item.getQuantity() + quantity); } else { CartItem item = new CartItem(); item.setCake(cake); item.setQuantity(quantity); cart.put(cake.getCid(), item); } }

这里我特意用的HashMap而非List。为什么?因为购物车里按蛋糕 id 查是一件高频操作,Map 按键查询是常数时间,如果用 List 每次都要线性遍历。另一个考虑是合并相同商品:同一个蛋糕加两次,应该累加数量而不是出现两行重复,Map 结构天然方便处理这个逻辑。CartItem 里我存了 Cake 对象整体,这样 JSP 页面上要展示蛋糕名、单价、图片时非常顺手,不用每次再查库。

改数量同样先取 cart,再get(cakeId)修改 quantity;清空购物车最简单,直接session.removeAttribute("cart")。有一个通用原则:购物车里始终持有的是“用户操作时的快照”,所以结算时不要拿购物车里的价格当最终价,还要回表再查商品当前价格做校验。这个一步在结算逻辑里是必做的,不然商品改价后,购物车里的旧价格可能导致卖出异常价格。

4. 订单与事务:下单扣库存,高并发下怎么不超卖

4.1 下单流程:从生成订单号到扣库存,中间要经过哪几道闸

下单是最能体现一个商城项目“有没有骨架”的场景。如果把下单做成“直接 insert 一条订单记录”,那这个项目基本不具备参考价值。我的下单逻辑拆成以下步骤,每步都必须通过校验再继续:

  1. 从 Session 里拿当前用户,没有就跳登录。
  2. 取购物车,为空或数量为 0 直接打回。
  3. 遍历购物车,逐个回查数据库里的商品价格和库存,核对最新价格、判断库存是否足够。
  4. 生成订单号,计算订单总价。
  5. 插入订单主表(状态为未付款)。
  6. 批量插入订单项表,返回自增 key。
  7. 逐商品扣减库存。
  8. 清空购物车。

这里如果你照抄步骤直接写,会发现一个恼人的问题:如果第 5 步订单主表插入成功,但第 7 步扣库存时某个商品库存不足抛异常,数据就会不一致——订单在、库存没扣,或者库存扣了但订单没建成。这就是为什么需要事务。经典实现是在 Service 层用同一个 Connection 包住所有操作:

public boolean createOrder(HttpSession session) throws Exception { User user = (User) session.getAttribute("user"); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null || cart.isEmpty()) { return false; } Connection conn = null; try { conn = JdbcUtils.getConnection(); // 关键:关闭自动提交,手动控制事务范围 conn.setAutoCommit(false); OrderDao orderDao = new OrderDao(); OrderItemDao orderItemDao = new OrderItemDao(); CakeDao cakeDao = new CakeDao(); String oid = OrderIdUtil.generate(uid); // 时间戳+随机数 BigDecimal total = BigDecimal.ZERO; // 第一遍:查库校验并计算总价,不直接操作 for (CartItem item : cart.values()) { Cake cake = cakeDao.findById(item.getCake().getCid(), conn); if (cake == null || cake.getStatus() == 0) { throw new RuntimeException("商品已下架: " + item.getCake().getCname()); } if (cake.getStock() < item.getQuantity()) { throw new RuntimeException("库存不足: " + cake.getCname()); } // 以数据库当前价格为准,累加总价 total = total.add(cake.getPrice().multiply( BigDecimal.valueOf(item.getQuantity()))); } // 插入订单主表 Order order = new Order(); order.setOid(oid); order.setUid(uid); order.setTotalPrice(total); order.setStatus(0); orderDao.insert(conn, order); // 插入订单项,并扣减库存 for (CartItem item : cart.values()) { orderItemDao.insert(conn, oid, item.getCake().getCid(), item.getCake().getPrice(), item.getQuantity()); cakeDao.decreaseStock(conn, item.getCake().getCid(), item.getQuantity()); } // 全部成功才提交 conn.commit(); session.removeAttribute("cart"); // 把新订单号交给页面作后续跳转 session.setAttribute("lastOrderId", oid); return true; } catch (Exception e) { // 任何一步失败,回滚到开始之前 if (conn != null) { conn.rollback(); } throw e; } finally { if (conn != null) { conn.setAutoCommit(true); // 恢复自动提交,还给连接池 conn.close(); } } }

这段代码值得细看。conn.setAutoCommit(false)之后,所有的insert、update都进入一个未提交的“事务环境”;只有当所有操作都没抛异常时conn.commit()才把这些修改持久化;任何一个环节抛出异常,conn.rollback()把前面已执行的修改全部撤销——这样就不会出现“订单存在但库存没扣”的脏状态。

注意我这里所有 DAO 方法都多传了一个conn参数,这是事务能够生效的关键。如果你在 Service 里开事务,但 DAO 里又各自JdbcUtils.getConnection(),那么你开的自动提交关闭只对 Service 那个连接有效,DAO 里的连接各行其是,事务形同虚设。这一点是新手最容易踩的大坑,后面避坑章节还会单独说。

4.2 扣库存的 SQL 写法:先查再改和一条 Update 差在哪

扣库存的常规写法是“先 select stock,判断足够后 update”,但这样在多并发场景下有超卖风险。比如库存 10,两个请求同时查到 10,都认为够,各自减掉 5,最后库存变成 0,看起来没问题;如果是两个请求同时查到 1,都认为够,各减 1,最后库存变成 -1,这不合理。

更稳的做法是让数据库在 Update 时做原子判断:

public int decreaseStock(Connection conn, int cakeId, int quantity) throws SQLException { String sql = "UPDATE cake SET stock = stock - ? WHERE cid = ? AND stock >= ?"; return queryRunner.update(conn, sql, quantity, cakeId, quantity); }

这个写法把“扣减并且校验库存”合并成一条 SQL。数据库执行UPDATE时会在行上加锁,第二个事务要等第一个提交或回滚后再操作,所以不会出现两个事务都基于旧的库存做判断。如果影响行数是 0,说明库存不足,下单流程就要整体回滚。

这里再补充下 FOR UPDATE 的用处:如果你确实需要先查到数据再作复杂判断,可以在SELECT ... FOR UPDATE显式锁行,防止其他事务在这期间修改这条库存记录。对于蛋糕商城这种学习项目,单条UPDATE ... WHERE stock >= ?就是简便且够用的方案;上了生产环境才需要根据隔离级别和业务形态再做调整。

4.3 订单状态机:从未付款到已取消,每次状态变更都要记录什么

订单不是一张静态表,它要经历“未付款 -> 已付款 -> 已发货 -> 已完成”的生命周期,用户还有“取消订单”的权利。我做这个项目时,把状态流转画成一条单向路径:

  • 状态 0 未付款:用户可在付款页选择“模拟支付”或直接取消订单;取消后要回补库存。
  • 状态 1 已付款:等待商家发货;后台管理员看到后可点发货。
  • 状态 2 已发货:用户可在订单列表里点击“确认收货”。
  • 状态 3 已完成:订单结束,不可再修改。
  • 状态 4 已取消:订单关闭,库存已回补。

状态变更的代码位置不要太散。我建议做一个OrderStatus的常量类,或者用枚举管理状态码,避免在 Servlet 里到处写魔法数字if(status == 1)。以下是取消订单的一个简化版实现:

public void cancelOrder(String oid, int uid) throws Exception { Connection conn = null; try { conn = JdbcUtils.getConnection(); conn.setAutoCommit(false); OrderDao orderDao = new OrderDao(); Order order = orderDao.findById(oid, conn); if (order == null) { throw new RuntimeException("订单不存在"); } if (!order.getUid().equals(uid)) { throw new RuntimeException("无权操作该订单"); } if (order.getStatus() != 0) { throw new RuntimeException("当前状态不可取消"); } // 回补库存 List<OrderItem> items = orderItemDao.findByOid(oid, conn); for (OrderItem item : items) { cakeDao.increaseStock(conn, item.getCakeId(), item.getQuantity()); } // 更新状态 orderDao.updateStatus(conn, oid, 4); conn.commit(); } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }

取消订单回补库存与下单扣库存,正好构成一个对偶操作。细心的读者会发现:回补库存只增加了stock,没有校验任何条件——因为取消订单只会把之前扣掉的数加回来,不涉及超卖判断,这是安全的。但要注意,如果商品已经发货(状态进入 2),就不能再走“取消”了,应当提示走售后流程,这是业务边界。

5. 避坑指南:从编码到部署最容易翻车的 12 个细节

写这一章前我先声明:这些都是做这类 JavaWeb 项目时反复出现的报错和迷之行为,有的浪费了我整整一个晚上,现在直接列给你,省得你再趟一遍。

现象 1:数据库存中文正常,但从页面上查出来全是 ???。

原因:MySQL 连接串没指定characterEncoding=utf8,或数据库表默认字符集是 latin1。Tomcat 里request.setCharacterEncoding("UTF-8")只对 POST 表单生效,GET 请求的查询串编码取决于 Tomcat 配置和连接串。

解决:

jdbcUrl=jdbc:mysql://localhost:3306/cake_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

同时建表时显式写DEFAULT CHARSET=utf8mb4。如果你用的是 MySQL 8.x,连接串还要加serverTimezone参数,不然日期字段直接编译异常。

现象 2:JSP 页面上<c:forEach>标签没生效,浏览器把 JSTL 标签当文本显示。

原因:缺 JSTL 依赖,或者忘了在 JSP 顶部写<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。

解决:先查 pom.xml 里jstl和taglibs-standard-impl依赖是否完整;再用 Mavendependency:tree看有没有因为依赖传递把它抹掉。部分 Tomcat 版本只提供 EL 支持,不内置 JSTL,所以这个依赖必须显式加上。

现象 3:Session 存了 user,但跳转后取出来是 null。

原因:浏览器没带 Cookie,或者request.getSession(false)返回了旧 Session 但里面没有该属性。常见情况是你用了两个不同的WebApplicationContext或修改了session-timeout导致会话失效。

解决:先确认浏览器开发者工具里 JSESSIONID 是否在变化;再检查 Filter 里是否误用了request.getSession(true)创建新会话并丢失了原来的属性。更稳妥的做法是在登录成功后session.setAttribute("user", user)后不再手动创建或销毁 Session。

现象 4:下单时一整单失败,但部分数据还是写进库了。

原因:我见过的情况是把事务代码写在了 Servlet 里,而 Service 层的 DAO 各自拿到新连接,根本没进入同一个事务。还有人是conn.commit()只是提交了最后一条 SQL,前面几条是别的连接提交的。

解决:事务一定要在 Service 层统一管理,DAO 方法必须接收外部的Connection,Service 开头setAutoCommit(false),结束统一commit/rollback。写代码前先在纸上画“哪个方法拿连接、哪个方法释放连接”,确定只有一个连接入口。

现象 5:图片上传后找不到文件,部署服务器后图片全裂。

原因:本地 IDE 项目的目录和 Tomcat 部署后的webapps目录不是同一份。你用绝对路径写到项目/webapp/images/...,重启后可能被重部署清掉。

解决:课程设计阶段建议这样——把图片统一放在服务器外部目录,比如/data/cake-images/,页面通过 Servlet 映射/images/*去读外部目录文件。还有一种简便可行的办法:把图片放数据库仅存 base64,但会拖慢列表页,不推荐;更稳妥的是直接用 Tomcat context.xml 配置外部资源映射,或者老老实实把图片目录设置在 webapp 下并且不做 clean。

现象 6:后台管理页面没登录就能直接访问。

原因:Filter 白名单把/admin/*漏放了,或者后台多数 Servlet 跟前台共用同一个 LoginFilter,而你没有为后台单独写权限校验。

解决:后台管理应该单独一个 AdminFilter,检验用户名和 admin 角色。如果表里没有角色字段,至少要加role TINYINT,否则任何一个注册用户都能访问管理端,这是很明显的安全漏洞。

现象 7:分页点击第二页后,筛选条件丢了。

原因:分页链接只传了pageNumber,没传categoryId或keyword。后台翻页时回传全部查询参数是基本功。

解决:生成分页链接时把当前查询条件全部带上,我一般会写一个pageUrl工具方法,参数统一拼接,避免在 JSP 里手动拼串漏参数。代码片段:

<a href="cake?action=list&categoryId=${param.categoryId}&pageNumber=${page.pages-1}">上一页</a>

现象 8:订单号用new Date().getTime()生成,测试时发现两个订单号一样。

原因:同一毫秒内并发生成,时间戳相同。

解决:订单号用时间戳 + 用户id后四位 + 随机数三位,或者干脆用UUID去中划线。注意不要太长,否则订单表主键索引空间变大。我常用的格式是yyyyMMddHHmmss + 三位随机数 + userId后三位,重复概率很低。

现象 9:数据库连接池配置了但没生效,Tomcat 启动报 Too many connections。

原因:最常见的是每次JdbcUtils.getConnection()都走 DriverManager 新建连接,而不是从池里拿;或者池的maxPoolSize被设得太小。

解决:确认你的 JdbcUtils 用的是ComboPooledDataSource,并且调用getConnection()而不是自己Class.forName("com.mysql.jdbc.Driver")后新建;定期重启或监控连接数。连接池不是加上依赖就生效,必须所有获取连接的入口都走它。

现象 10:JSP 页面里大量<% %>语法,一改参数就要重新编译整个 JSP,维护成本极高。

原因:没有把业务逻辑放进 Servlet / Service,把 JSP 当代码页用了。

解决:JSP 里只保留 JSTL 和 EL,不要写 Java 代码块。Java 逻辑留在 Servlet 里,JSP 只做展示,这也是后来 MVC 模式的基本要求。

现象 11:页面点击“确认收货”,返回 JSON 串而不是跳到成功页。

原因:Servlet 里response.getWriter().print("success")后没有及时return,后续又执行了forward或sendRedirect,导致响应里既有 JSON 又有页面内容。

解决:所有以 Ajax 方式处理的接口,写完响应后立即return;接口和页面跳转分别用不同 action,不要混在一个方法里。类似问题也常出现在“同一 Servlet 里既返回 JSON 又返回 JSP”的写法上。

现象 12:本地开发一切正常,打 war 包部署到服务器后找不到数据库。

解决之前先确认:生产环境的数据库地址、账号密码是否和本地一样?改配置文件的习惯要早点养成。我推荐把数据库配置放到src/main/resources/db.properties,打包时可以用 Maven profile 来区分开发/生产,而不是每次手动改代码。类似的问题还有绝不要把我们真实的数据库密码提交到版本库,这也是业内基本素养。

6. 给它加上搜索与分页:一个能直接放进简历的进阶技巧

蛋糕商城做到订单闭环后,最自然的进阶需求是“搜索”。这里介绍一个轻量但完整的实现思路:搜索页也走CakeServlet,支持关键词、分类、价格区间三个条件组合,再加分页。

访问路径设计为cake?action=search&keyword=慕斯&categoryId=2&minPrice=50&maxPrice=150&pageNumber=1。DAO 方法用动态拼接 SQL,注意用参数化查询防注入:

public PageBean<Cake> search(String keyword, Integer cid, BigDecimal minPrice, BigDecimal maxPrice, int pageNumber) throws SQLException { StringBuilder sql = new StringBuilder("SELECT * FROM cake WHERE status = 1 "); List<Object> params = new ArrayList<>(); if (StringUtils.hasText(keyword)) { sql.append(" AND cname LIKE ? "); params.add("%" + keyword + "%"); } if (cid != null && cid > 0) { sql.append(" AND category_id = ? "); params.add(cid); } if (minPrice != null) { sql.append(" AND price >= ? "); params.add(minPrice); } if (maxPrice != null) { sql.append(" AND price <= ? "); params.add(maxPrice); } sql.append(" ORDER BY sales DESC LIMIT ?,?"); Object[] args = new Object[params.size() + 2]; for (int i = 0; i < params.size(); i++) { args[i] = params.get(i); } args[params.size()] = (pageNumber - 1) * PAGE_SIZE; args[params.size() + 1] = PAGE_SIZE; List<Cake> list = qr.query(sql.toString(), new BeanListHandler<>(Cake.class), args); // 单独 count 查询,逻辑同理,此处省略 return new PageBean<>(list, totalCount, pageNumber, PAGE_SIZE); }

参数说明:动态 SQL 是最容易产生注入漏洞的写代码方式,所以拼接处全部用?占位;LIKE关键字两边拼%是为了模糊匹配,注意这个拼接是加在值上,不是拼在 SQL 文本里,QueryRunner 会正确处理转义。排序字段用sales DESC同时解决热销和搜索两个需求。

实现之后,这个功能的简历写法可以这样概括:支持多条件组合查询与分页的商品检索模块,基于 MySQL 参数化动态 SQL 实现,并考虑搜索性能优化;进一步可引入索引或全文检索。不要写“负责整个项目”这种空话,要具体到模块、方案和结果,面试官一听就知道你动过手。

这个技巧总结下来就是:搜索不要一上来就接 Elasticsearch,蛋糕商城的体量用 MySQL 的LIKE加索引完全够用;真正要练的是动态 SQL 拼接时的正确姿势和参数传递方式。我至今保留着当年第一次动态拼接 SQL 时踩进注入坑的记忆——从那以后,凡是用字符串拼 SQL 的地方,我都会拿着放大镜检查一遍有没有用?占位。平时写完一个模块,我都会把它当自己的代码来审,不急着跑通就收工。

这个方向是不是值得投入,我的答案很直接:如果 JavaWeb 基础还没吃透,蛋糕商城绝对是一个性价比很高的综合练习;如果基础已经很好,我建议把它当作一个载体,重点打磨事务、权限、检索这几个点。认认真真把一个小项目做到有边界、有异常处理、有安全考虑,比跟风抄十个半成品更有用。希望帮到你。

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

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

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

立即咨询