简介:这份资源是一套基于JSP与Servlet的JavaWeb蛋糕店售卖网站完整项目源码,面向正在学习JavaWeb开发、需要课程设计或毕业设计参考的初学者与进阶学生。项目在IDEA 2017.3.5下开发,部署于Tomcat 8.5.35,数据库采用MySQL 5.7,并引入c3p0连接池与DBUtil工具类,技术栈贴近企业常见实践。压缩包共290个文件,约19.11MB,其中58个jsp页面承载前后台视图,55个java文件实现业务逻辑,另有20个xml配置、14个js与12个css负责交互与样式,并附带1个sql脚本用于快速建库。前台覆盖商品推荐与分类展示、详情浏览、购物车增改、登录注册、个人信息维护、下单付款、订单查询及关键字搜索;后台支持订单状态流转、用户管理、商品类目与商品信息的增删改查。目前已有127人学习,适合对照源码理解MVC分层、连接池配置与前后台权限控制的实现思路。
1. 蛋糕店售卖网站:为什么 JSP + Servlet 这套“老技术栈”还值得动手做一遍
打开招聘软件搜 Java 后端,满屏 Spring Boot、微服务、分布式,但真到课程设计、毕业设计或者公司内部一个轻量级展示型站点,JSP + Servlet 这套组合反而经常被翻出来用。蛋糕店售卖网站就是典型场景:商品列表、详情、购物车、下单、后台管理,功能边界清晰,数据量不大,用最原始的 JavaWeb 三层结构就能跑通,不需要引入一堆中间件。很多人觉得这套东西过时了,但如果你连 Servlet 的生命周期、请求转发和重定向的区别、JSP 九大内置对象都说不清楚,直接上框架也只是在抄配置。这个标题背后真正要解决的问题是:用最少的依赖,把一套完整的电商前台加后台从零搭起来,同时把 JavaWeb 的核心机制吃透。适合在校学生做课设、转行的人补基础、以及需要快速交付一个内部小站点的开发者。下面按实际动手顺序拆开讲,从环境配置到数据库设计再到部署排错,每一步都给出可复现的做法。
2. 环境与项目骨架:IDEA 里跑通第一个 Servlet 需要几步
2.1 JDK、Tomcat、IDEA 的版本搭配与踩坑点
这套技术栈对版本比较敏感,不是随便装一个就能跑。JDK 建议用 8 或 11,Tomcat 用 8.5 或 9.0,IDEA 用 2021 之后的社区版就够。注意 Tomcat 10 开始把 javax.servlet 包名改成了 jakarta.servlet,如果你照着老教程写 import javax.servlet.*,在 Tomcat 10 上直接编译报错。我一般会先确认三件事:java -version输出是不是 1.8 或 11,Tomcat 的 lib 目录下有没有 servlet-api.jar,IDEA 的 Project Structure 里 SDK 和 Language Level 是否一致。常见翻车现场是 JDK 装了 17,Tomcat 用 9,编译时提示“无效的目标发行版”,其实就是 Language Level 没降下来。
配置环境变量时,JAVA_HOME 指向 JDK 根目录,CATALINA_HOME 指向 Tomcat 根目录,Path 里加上 %JAVA_HOME%\bin 和 %CATALINA_HOME%\bin。验证方式是命令行敲startup.bat,浏览器访问 localhost:8080 能看到 Tomcat 默认页,说明 Tomcat 本身没问题。这一步不过,后面全是白搭。
2.2 用 IDEA 建 Web 项目并写第一个能返回 JSON 的 Servlet
新建项目选 Java Enterprise,勾选 Web Application,IDEA 会自动生成 web 目录和 WEB-INF/web.xml。如果没有这个模板,就建普通 Java 项目,手动在项目根下建 web 文件夹,再在 Project Structure 的 Facets 里添加 Web,把 web.xml 的路径指到 WEB-INF 下。
下面是一个最小可用的 Servlet,访问后返回一段 JSON,用来验证环境是否通了:
package com.cake.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; // 用注解方式注册,不用再改 web.xml @WebServlet("/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置响应类型和编码,防止中文乱码 resp.setContentType("application/json;charset=UTF-8"); PrintWriter out = resp.getWriter(); out.write("{\"code\":200,\"msg\":\"蛋糕店服务已启动\"}"); out.flush(); out.close(); } }逻辑说明:@WebServlet("/hello")把访问路径映射到 /hello,浏览器请求http://localhost:8080/项目名/hello就会进 doGet。setContentType里的 charset 必须写,否则返回的中文在浏览器里是问号。参数方面,如果你把注解换成 web.xml 配置,就要写 servlet 和 servlet-mapping 两组标签,路径要带斜杠。写完代码后,在 IDEA 右上角 Run/Debug Configurations 里添加 Tomcat Server,Deployment 选项卡里把 artifact 加进去,Application context 设成 /cake,启动后访问对应地址即可。这一步跑通,说明整个链路没有阻塞,可以开始设计数据库了。
3. 数据库与实体设计:蛋糕店售卖网站的表结构和连接池怎么定
3.1 五张核心表:用户、商品、分类、订单、订单明细
蛋糕店售卖网站的数据模型不复杂,但字段要一次想清楚,不然后面改表很痛苦。我一般会建五张表:user 存用户信息,category 存蛋糕分类,product 存商品,orders 存订单主表,order_item 存订单明细。注意 orders 不要写成 order,那是 SQL 关键字,会报语法错误,这是血泪经验。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id, username, password, phone, address | 密码存 MD5,不存明文 |
| category | id, name, sort_order | 分类排序用整数,越小越靠前 |
| product | id, name, price, image, stock, category_id, description | price 用 decimal(10,2) |
| orders | id, user_id, total_price, status, create_time | status 用 0/1/2 表示待付款/已付款/已发货 |
| order_item | id, order_id, product_id, quantity, price | price 记录下单时的单价 |
建表时字符集统一用 utf8mb4,排序规则 utf8mb4_general_ci,否则 emoji 和部分生僻字存不进去。主键用 int 自增,外键在逻辑上关联即可,物理外键看情况加,小项目加了反而影响插入效率。
3.2 JDBC 工具类与数据库连接配置
不要在每个 Servlet 里都写 DriverManager.getConnection,那样代码重复且连接无法复用。常见做法是抽一个 DBUtil 类,用静态代码块加载驱动,提供 getConnection 和 close 方法。如果想让连接池更规范,可以引入 Druid 或 C3P0,但纯 JSP + Servlet 项目用简单的连接池就够了。
package com.cake.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 URL = "jdbc:mysql://localhost:3306/cake_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; static { try { // MySQL 8 用 com.mysql.cj.jdbc.Driver,5.x 用 com.mysql.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("数据库驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (stmt != null) stmt.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }逻辑说明:静态块只执行一次,驱动加载失败直接抛运行时异常,避免后面报错时找不到根因。URL 里的 serverTimezone 必须加,MySQL 8 不加会报时区错误。参数方面,useUnicode 和 characterEncoding 保证中文正常,如果你用的是 MySQL 5.7,驱动类名换成 com.mysql.jdbc.Driver。连接关闭顺序是先 ResultSet 再 Statement 再 Connection,反了会报错。写完这个类,先写一个 main 方法测试能否拿到连接,通了再往下做。
4. 前台与后台功能落地:商品展示、购物车和订单提交怎么写
4.1 商品列表与详情页:JSP 里怎么用 JSTL 遍历数据
Servlet 负责查数据,JSP 负责展示,这是 MVC 的基本分工。商品列表的 Servlet 从数据库查出 List ,塞进 request 域,转发到 product_list.jsp。JSP 页面用 JSTL 的 c:forEach 遍历,不要用脚本片段写 Java 代码,那样后期没法维护。
// ProductListServlet.java 核心片段 List<Product> list = productService.findAll(); req.setAttribute("products", list); req.getRequestDispatcher("/product_list.jsp").forward(req, resp);<%-- product_list.jsp 核心片段 --%> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <c:forEach items="${products}" var="p"> <div class="cake-item"> <img src="${pageContext.request.contextPath}/upload/${p.image}" alt="${p.name}"> <h3>${p.name}</h3> <p>¥${p.price}</p> <a href="${pageContext.request.contextPath}/product/detail?id=${p.id}">查看详情</a> </div> </c:forEach>逻辑说明:${pageContext.request.contextPath}拿到项目上下文路径,避免硬编码 /cake 导致换项目名就 404。参数方面,items 里的 products 必须和 Servlet 里 setAttribute 的 key 一致,var 是循环变量名。图片路径存在数据库里只存文件名,展示时拼上下文路径和 upload 目录。如果页面显示空白,先看 Servlet 有没有转发成功,再看 JSTL 的 jar 包有没有放进 WEB-INF/lib。
4.2 购物车用 Session 还是数据库:两种方案的取舍
购物车有两种做法:未登录时放 Session,登录后合并到数据库。小项目为了简单,可以全程用 Session 存一个 Map<Integer, CartItem>,key 是商品 id,value 是数量和单价。好处是不用建表,坏处是换浏览器就丢。如果要做登录后持久化,就建 cart 表,用户登录时把 Session 里的数据合并进去。
// 加入购物车 Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } int productId = Integer.parseInt(req.getParameter("id")); CartItem item = cart.get(productId); if (item == null) { item = new CartItem(productId, 1, productService.findById(productId).getPrice()); cart.put(productId, item); } else { item.setQuantity(item.getQuantity() + 1); } resp.sendRedirect(req.getContextPath() + "/cart/list");逻辑说明:session 里存 Map,key 用商品 id 保证同一商品只累加数量。参数方面,quantity 增加时要判断库存,超过库存就提示。重定向用 sendRedirect,不要用 forward,否则刷新页面会重复提交。这里容易踩的坑是 Session 超时时间,默认 30 分钟,可以在 web.xml 里配 session-config 调整。
4.3 订单提交与事务处理:保证订单主表和明细同时写入
下单是最容易出问题的地方。用户点提交后,要往 orders 插一条,再往 order_item 插多条,还要扣库存。这三步必须在一个事务里,任何一步失败都要回滚。JDBC 默认自动提交,要手动 setAutoCommit(false)。
Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 插入订单主表 String orderSql = "INSERT INTO orders(user_id,total_price,status,create_time) VALUES(?,?,?,NOW())"; PreparedStatement ps = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); ps.setInt(1, userId); ps.setBigDecimal(2, totalPrice); ps.setInt(3, 0); ps.executeUpdate(); ResultSet rs = ps.getGeneratedKeys(); int orderId = 0; if (rs.next()) orderId = rs.getInt(1); // 2. 插入订单明细并扣库存 for (CartItem item : cart.values()) { String itemSql = "INSERT INTO order_item(order_id,product_id,quantity,price) VALUES(?,?,?,?)"; PreparedStatement ps2 = conn.prepareStatement(itemSql); ps2.setInt(1, orderId); ps2.setInt(2, item.getProductId()); ps2.setInt(3, item.getQuantity()); ps2.setBigDecimal(4, item.getPrice()); ps2.executeUpdate(); String stockSql = "UPDATE product SET stock=stock-? WHERE id=? AND stock>=?"; PreparedStatement ps3 = conn.prepareStatement(stockSql); ps3.setInt(1, item.getQuantity()); ps3.setInt(2, item.getProductId()); ps3.setInt(3, item.getQuantity()); if (ps3.executeUpdate() == 0) { throw new RuntimeException("库存不足"); } } conn.commit(); } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { DBUtil.close(conn, null, null); }逻辑说明:RETURN_GENERATED_KEYS拿到刚插入的订单 id,用于关联明细。扣库存的 SQL 带了stock>=?条件,防止超卖,如果影响行数为 0 说明库存不够,直接抛异常触发回滚。参数方面,totalPrice 用 BigDecimal 不要用 double,避免精度丢失。事务里所有操作共用同一个 Connection,不能从连接池里拿多个连接。这段代码跑通后,整个下单流程就闭环了。
5. 避坑与排查:JSP + Servlet 项目最容易翻车的五个地方
5.1 中文乱码:请求参数和响应输出各管各的
现象:表单提交中文,后台收到的是乱码;或者后台返回中文,页面显示问号。原因:GET 请求的编码由 Tomcat 的 server.xml 决定,POST 请求要手动 setCharacterEncoding,响应输出要 setContentType。解决:在 Servlet 里第一行写req.setCharacterEncoding("UTF-8"),响应写resp.setContentType("text/html;charset=UTF-8")。Tomcat 8 之后 GET 默认 UTF-8,但 POST 不设一定乱。数据库连接 URL 也要带 characterEncoding=utf8。
5.2 404:路径对不上,九成是上下文路径没拼
现象:浏览器访问某个 Servlet 报 404。原因:要么 @WebServlet 的路径写错,要么 JSP 里的链接没加${pageContext.request.contextPath}。解决:先看 Tomcat 启动日志有没有映射信息,再检查访问地址是否带了项目名。IDEA 里 Application context 设成 /cake,访问就必须是 localhost:8080/cake/hello,少一段都不行。
5.3 500:空指针和驱动未加载是常客
现象:页面报 500,日志里一堆堆栈。原因:最常见的是 DBUtil 里驱动类名写错,或者 ResultSet 关闭后还在用。解决:看日志第一行 Caused by,如果是 ClassNotFoundException 就检查 mysql-connector-java 的 jar 有没有放进 WEB-INF/lib;如果是 NullPointerException 就检查 request.getParameter 有没有做空判断。注意 Tomcat 的 lib 目录和项目 lib 目录不要混,驱动放项目里更稳妥。
5.4 Session 丢失:浏览器禁用 Cookie 或超时
现象:登录后跳转其他页面又变成未登录。原因:Session 依赖 JSESSIONID 这个 Cookie,浏览器禁用 Cookie 就丢;或者 Session 超时。解决:检查浏览器 Cookie 设置,或者在 URL 里用response.encodeURL()重写。超时时间在 web.xml 里配<session-timeout>60</session-timeout>,单位是分钟。
5.5 图片上传后不显示:路径和权限两个坑
现象:上传成功但页面图片裂开。原因:一是存到了项目编译输出目录,重启就没了;二是路径拼错。解决:存到项目外的固定目录,比如 D:/cake_upload,数据库只存文件名,展示时用虚拟路径映射或者直接读文件流。如果存项目内,要存到 webapp/upload 下,且 IDEA 里要配置热部署,否则新文件不生效。
6. 进阶技巧:用过滤器统一编码和登录校验,少写重复代码
到这一步,网站基本能跑了,但你会发现每个 Servlet 都要写 setCharacterEncoding,每个后台页面都要判断 session 里有没有用户。这种重复代码用 Filter 解决最合适。Filter 是 Servlet 规范里的组件,在请求到达 Servlet 之前拦截,可以统一处理编码、登录校验、日志记录。
先写一个编码过滤器:
package com.cake.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; @WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); // 放行,交给下一个过滤器或 Servlet } }逻辑说明:@WebFilter("/*")拦截所有请求,chain.doFilter是放行,不写这行请求就断了。参数方面,如果你只想拦截特定路径,把 /* 改成 /admin/* 即可。注意 Filter 的执行顺序按类名字母序,多个过滤器时要注意。
再写一个登录校验过滤器,只拦后台路径:
@WebFilter("/admin/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }逻辑说明:从 Session 里取 loginUser,为空就重定向到登录页,不为空才放行。参数方面,/admin/*表示只拦后台,前台商品页不受影响。这里有个细节:登录页本身不能拦,否则死循环,所以登录 Servlet 的路径不要放在 /admin 下。
验证过滤器是否生效,可以故意清掉 Session 再访问后台,看是否跳登录页。我一般会在过滤器里加一行日志,输出请求路径和拦截结果,排查时一目了然。这套东西加上之后,代码量能少三分之一,后期加权限控制也方便。
最后说个习惯:每次改完代码,先 clean 再 rebuild,再重启 Tomcat,不要依赖热部署,JSP + Servlet 的热部署经常玄学失效。数据库操作前先备份,改表结构之前想清楚字段类型。这套技术栈虽然老,但把每个环节都动手写一遍,对 HTTP 协议、Servlet 容器、MVC 分层的理解会比直接上框架扎实得多。希望帮到你。
本文还有配套的精品资源,点击获取