简介:一套基于JSP和Servlet的JavaWeb蛋糕店售卖网站源码,搭配MySQL数据库,适合JavaWeb学习者、课程设计或毕业设计参考,也能作为电商项目二次开发的起点。项目开发环境为IDEA 2017、Tomcat 8.5和MySQL 5.7,采用C3P0连接池与DBUtil工具进行数据操作。网站分为前台和后台两部分:前台支持推荐商品、新品热销、分类浏览、商品详情、购物车管理、用户注册登录、个人信息维护、订单支付查询、关键字搜索;后台则覆盖订单状态管理、用户管理、商品类目维护、商品增删改查等完整流程。压缩包共290个文件,包括58个JSP页面(呈现页面结构)、55个Java类(处理业务逻辑与数据访问)、20个XML配置(维护运行参数)、14个JS文件与12个CSS样式表(完善交互与样式),并携带SQL数据库脚本及图片素材,整体体积仅19.11MB,结构清晰、便于导入和调试。目前已有126人学习使用,能帮助快速掌握传统JavaWeb开发模式。
1. 为什么 2025 年还要用 JSP + Servlet 写蛋糕店商城
接过一个用 IDEA 2017.3.5、Tomcat 8.5.35、MySQL 5.7 搭建的 JSP + Servlet 商城项目,第一反应通常是「这年头谁还写 JSP」。但真把十几张表、前台购物车和后台订单管理梳理完,会发现这套老技术恰恰是最容易讲清楚 JavaWeb 请求生命周期、连接池管理和 Session 状态的载体。这个蛋糕店售卖网站就是这样一个完整案例:前台有商品条幅推荐、热销新品、类型列表、商品详情、购物车数量修改、用户注册登录、收货信息维护,后台有按状态查订单、改发货状态、管理用户、商品类目和商品信息。它不像 Spring Boot 那样自动封装,所有 Servlet 映射、数据库连接、JSP 取值都能一眼看透,适合刚学完 JavaWeb 想练全流程的人,也适合需要给老项目做维护或二次开发的人。下面就从工程结构和数据层开始拆。
2. 工程结构与数据层设计:从 C3P0 到 DBUtil 的封装思路
2.1 一个典型 JSP + Servlet 项目的目录划分
拿到项目源码,先别急着跑,把目录结构过一遍。这类项目一般分成 src 主目录和 web 应用目录两大部分。src 下会看到bean/entity、dao、service、servlet/controller、util、filter这几个包;web 目录下则有admin、css、js、images、WEB-INF等。这个蛋糕店项目的前台页面直接放在根目录或WEB-INF之外的page下,后台管理页放在admin下,静态资源里出现了 bootstrap.css、style.css、layer.css,说明前端用了 Bootstrap 弹层和自定义样式。
不同模块的职责可以用一张表说清楚:
| 目录/包 | 主要文件 | 职责 |
|---|---|---|
| bean | User.java / Product.java / CartItem.java | 数据库表结构对应的 JavaBean,存字段和 getter/setter |
| dao | UserDao / ProductDao / OrderDao / CategoryDao | 直接执行 SQL,返回 JavaBean 或 List |
| servlet | UserServlet / ProductServlet / CartServlet / OrderServlet | 接收请求,调用 dao,转发或重定向到 JSP |
| filter | AdminFilter.java | 拦截后台管理地址,校验管理员登录状态 |
| web/WEB-INF/lib | c3p0 jar、mysql 驱动 | 数据库连接池与驱动依赖 |
| web/admin | list.jsp / order_list.jsp / category_list.jsp | 后台管理页面 |
| web/css | bootstrap.css / style.css / layer.css | 页面样式与弹层组件 |
注意,老项目经常把service层省略,Servlet 直接调 DAO。这个蛋糕店项目虽然叫 JavaWeb,但登录、注册、订单状态变更这类逻辑不复杂,所以直接写 DAO 也说得过去。如果你要二次开发,建议补一层 service,否则后面加事务会非常痛苦。
2.2 C3P0 配置与连接池初始化
项目配置里写的是 C3P0,这是早期 JavaWeb 项目最常见的连接池。它的配置一般放在src/c3p0-config.xml,核心目的是在项目启动时预创建一批数据库连接,避免每次请求都走一次完整的三次握手。下面是一份典型配置:
<?xml version="1.0" encoding="UTF-8"?> <c3p0-config> <named-config name="cake"> <property name="driverClass">com.mysql.jdbc.Driver</property> <property name="jdbcUrl">jdbc:mysql://localhost:3306/cake?useUnicode=true&characterEncoding=utf-8</property> <property name="user">root</property> <property name="password">123456</property> <property name="initialPoolSize">5</property> <property name="maxPoolSize">20</property> <property name="minPoolSize">5</property> <property name="checkoutTimeout">3000</property> </named-config> </c3p0-config>这里最容易踩的坑有两个。一个是com.mysql.jdbc.Driver只适用于 MySQL 5.x,如果你本地用了 MySQL 8+,要换成com.mysql.cj.jdbc.Driver。另一个是characterEncoding=utf-8必须跟着useUnicode=true一起写,否则中文商品名入库会变成问号。c3p0 通过DataSource接口暴露连接对象,只要项目依赖了 c3p0 包,就能在工具类里通过new ComboPooledDataSource("cake")拿到数据源。
2.3 DBUtil 封装:从 Connection 到 QueryRunner
这个项目里提到的 DBUtil,实际上是对 JDBC 操作的二次封装。它一般提供三块能力:从 C3P0 数据源取连接、释放资源、执行通用增删改查。虽然 Apache DbUtils 也有现成的QueryRunner,但手写一个最小的 DBUtil 反而更符合老项目风格,也好理解底层原理。看一下常见的写法:
public class DBUtil { private static DataSource ds = new ComboPooledDataSource("cake"); public static Connection getConnection() throws SQLException { return ds.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }注意conn.close()在这里不是真正断开连接,而是把连接归还给 c3p0 连接池。所以每次用完后必须调用close,否则连接池耗尽,系统会卡在checkoutTimeout后报超时异常。另外,写 DAO 时要使用 PreparedStatement 而不是 Statement,因为蛋糕名称、用户收货地址这种字符串,一旦包含单引号或中文标点,直接拼 SQL 轻则报错,重则被注入。
2.4 DAO 层以商品为例
商品表是这个项目的核心,前台展示、搜索、后台管理都围绕它。一个 ProductDao 至少包含以下方法:根据推荐位查询、按类型查询、按关键字搜索、分页查询、插入、更新、删除。这里给出两个典型查询方法:
public List<Product> findByRecommend(int recType) throws SQLException { String sql = "SELECT * FROM product WHERE rec_type = ? AND status = 1 ORDER BY sale_count DESC"; return query(sql, recType); } public List<Product> search(String keyword) throws SQLException { String sql = "SELECT * FROM product WHERE name LIKE ? AND status = 1"; return query(sql, "%" + keyword + "%"); }recType通常用 0、1、2 分别表示条幅推荐、热销推荐、新品推荐;status = 1控制商品是否上架。这里用了LIKE ?而不是直接拼接'%"+keyword+"%',本质是为了让 PreparedStatement 帮你处理特殊字符。查询方法内部就是获取连接、PreparedStatement 绑定参数、执行查询、把 ResultSet 映射成 Product 对象的重复动作,DBUtil 的意义就是把这段重复代码收拢到一个地方。
3. 前台业务流:商品展示、购物车数量修改与关键字搜索
3.1 首页推荐位的数据源与 JSP 取值
蛋糕店网站的首页一般分三块:顶部条幅推荐,中间热销商品,底部新品推荐。Controller 里常见做法是在一个IndexServlet中调用三次 findByRecommend,把结果放进 request,再forward到index.jsp。JSP 里用 JSTL 或最原始的<%= %>取数据。以热销推荐为例:
<ul class="hot-list"> <% List<Product> hotList = (List<Product>) request.getAttribute("hotList"); for (Product p : hotList) { %> <li> <a href="<%= request.getContextPath() %>/product/detail?id=<%= p.getId() %>"> <img src="<%= p.getImagePath() %>" alt="<%= p.getName() %>"/> </a> <p class="price">¥<%= p.getPrice() %></p> </li> <% } %> </ul>这里有一个容易被忽略的细节:商品图片、价格、详情都是在ProductBean 中直接给到页面的,但如果页面要展示商品分类名称而不是分类 ID,就需要在查询 Product 时把category_id转换成category_name。常见做法是 SQL 联表查询,把category表也 join 进来,而不是在 JSP 里再去查一次数据库。
3.2 购物车设计:Map 还是独立表
购物车是这个项目里最需要想清楚的模块。常见做法有两种:一是用数据库购物车表,适合用户要求跨设备保存的场景;二是用 Session 中的Map<Integer, CartItem>,适合这种结构简单的 JavaWeb 项目。本项目的需求是「修改购物车内商品信息,例如数量」,如果每次增减数量都要把整个购物车对象重新存一遍,Session 存 Map 会更合理。
定义一个 CartItem 结构:
public class CartItem { private Product product; private int count; private double subtotal; public double getSubtotal() { return product.getPrice() * count; } }然后在CartServlet里维护购物车:
HttpSession session = request.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<Integer, CartItem>(); session.setAttribute("cart", cart); }为什么要用Map<Integer, CartItem>?因为加入购物车时,只需要判断商品 ID 是否已存在,存在就直接把 count 加 1,不存在就 new 一个 CartItem 放进去。如果用的是 List,每次添加都要遍历整张列表,数量多了以后性能会变差。但要注意,Session 里的Map默认是HashMap,无法保证顺序,如果页面想按添加时间倒序显示,需要在显示时对cart.values()排序。
3.3 数量增减与价格重算的 Servlet 接口
购物车数量的修改通常是一个 Ajax 请求,前端点击加号或减号,把商品 ID 和新的 count 发给后端。一个比较简洁的 Servlet 接口长这样:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int productId = Integer.parseInt(request.getParameter("productId")); int count = Integer.parseInt(request.getParameter("count")); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) request.getSession() .getAttribute("cart"); if (cart != null) { CartItem item = cart.get(productId); if (item != null) { item.setCount(count); if (count <= 0) { cart.remove(productId); } } } response.getWriter().write("ok"); }这个接口没有直接返回价格,而是返回固定字符串,前端再重新刷新购物车列表。这种做法好处是简单,坏处是页面会闪动。进阶一点可以在响应里返回新的小计和总价,比如{"subtotal": 99.0, "total": 199.0},前端用 JS 直接更新。价格计算时不要用double做累加,多件商品数量大时会有精度问题,建议用BigDecimal,或者至少保证所有价格单位统一用分,显示时再转换成元。
3.4 关键字搜索的 LIKE 注入与参数绑定
搜索功能在前台是个小点,但特别容易写脏。商品名的搜索 SQL 十有八九是WHERE name LIKE '%蛋糕%',但很多新手喜欢用字符串拼接:
String sql = "SELECT * FROM product WHERE name LIKE '%" + keyword + "%'";这个写法问题很大。比如用户输入一个%,相当于把全表商品都匹配出来;输入'; DROP TABLE product; --,轻则报错,重则数据被删。注意,这个项目用的 MySQL 5.7 + PreparedStatement 完全可以避免这个问题。正确写法是先写占位符,再绑定参数。搜索页面还要注意编码,GET请求中的中文在 Tomcat 8.5 默认是 UTF-8,但在某些老版本下需要单独配置URIEncoding。
前台搜索框通常带一个下拉选择「按名称或按分类」,实现时可以在ProductDao.search里加一个搜索类型参数,如果是名称搜索就WHERE name LIKE ?,如果是分类搜索就WHERE category_id = ?,而不是把所有条件用 OR 拼到一条 SQL 里。这样索引利用率更高,中文模糊搜索时也不至于全表扫描太久。
3.5 JSP 个人信息展示页面的常见坑
热搜词里经常出现「jsp个人信息展示页面」,这个功能在蛋糕店项目里就是「我的账户」页面。用户登录后,在 Session 里存的是 User 对象;用户修改了收货地址,如果只更新了数据库,没有更新 Session 中的 User,页面上展示的仍然是旧地址。正确做法是在用户信息更新成功后,重新把当前用户查出并写回 Session:
User updatedUser = userDao.findById(user.getId()); request.getSession().setAttribute("loginUser", updatedUser);另一个坑是 JSP 页面直接用${user.receiveAddress}取值,但如果字段名与 JavaBean 属性不对应,EL 表达式会静默输出空字符串,不报错。排查时可以先用<%= ((User)session.getAttribute("loginUser")).getReceiveAddress() %>验证一遍,确认数据已经在 Session 里,再回头改 EL。
4. 后台权限与管理的落地:订单状态机、类目级联与用户增删改查
4.1 管理员入口与 Servlet 过滤器
后台功能是通过「管理员用户登录后显示后台管理按钮」进入的。这里需要做的不仅是按钮显示,更重要的是 URL 层面的拦截。因为即使页面上不显示后台入口,用户直接访问/admin/order_list.jsp照样能看到数据。常见做法是写一个AdminFilter,拦截所有/admin/*路径:
@WebFilter("/admin/*") public class AdminFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; User user = (User) request.getSession().getAttribute("loginUser"); if (user == null || !"admin".equals(user.getRole())) { request.getRequestDispatcher("/login.jsp").forward(request, response); return; } chain.doFilter(request, response); } }注意:角色判断不能只靠前端传入的参数。有些老项目会把role存在 Session 里,这没问题;但千万不要在 JSP 里用隐藏域<input type="hidden" name="role" value="admin">,否则用户改一下就变成管理员了。所有后台操作必须经过这个过滤器,包括 Ajax 请求。chain.doFilter之后才允许访问 JSP 或 Servlet。
4.2 订单按状态查询与状态机流转
后台订单操作包括按状态查询、修改状态(发货、完成、删除)。订单状态不是随便改的,需要定义清晰的状态流转。常见状态码如下:
| 状态码 | 含义 | 可执行操作 | 触发源 |
|---|---|---|---|
| 0 | 未付款 | 取消 / 付款 | 用户 |
| 1 | 已付款未发货 | 发货 | 管理员 |
| 2 | 已发货 | 完成 | 管理员 |
| 3 | 已完成 | 删除 | 管理员 |
| 4 | 已删除 | 无 | 系统 |
订单查询接口可以接收一个state参数,为空时查全部,不为空时精确查某一种状态。修改状态的 SQL 一般写成一个状态机:
UPDATE orders SET status = ? WHERE id = ? AND status = ?第三个status是当前状态,用来防止并发下重复发货。比如管理员A和管理员B同时打开同一个订单,A先点了发货,B再点发货时,因为当前状态已经不是「未发货」,更新影响行数为 0,B 就能感知到操作无效。Servlet 里判断int rows = orderDao.updateStatus(targetState, orderId, currentState),如果 rows 为 0,就提示「订单状态已被修改,请刷新」。这种乐观锁思路比直接加synchronized更轻量。
4.3 商品类目删除的级联保护
后台有一个「删除商品类目」的功能。如果类目下已经存在商品,直接删除会报外键约束异常,或者在页面上看到商品变成了「无分类」。常见做法是删除前先统计该类目下商品数量:
public int countByCategory(int categoryId) { String sql = "SELECT COUNT(*) FROM product WHERE category_id = ?"; ... }如果数量大于 0,则在 Servlet 里返回错误提示:「该类目下还有商品,请先删除或转移商品」。这一步虽然简单,但很多项目会漏掉。注意,这里的「删除」也可以改成逻辑删除,给 category 表加一个deleted字段,商品查询时同时过滤掉已删除的类目,这样历史订单里商品名称还正常。但老项目为了简洁,经常直接物理删除,代价是历史关联数据会丢失。
4.4 用户新增与密码加密的取舍
后台用户管理包括新增用户、修改密码、修改信息和删除用户。新增用户时最常见的错误是明文保存密码:
INSERT INTO user(username, password, phone, address) VALUES (?, ?, ?, ?)这个蛋糕店项目没有在摘要中明确说加密方式,作为接手的人,我建议至少做到加盐 MD5。Java 原生 MD5 不够安全,但比明文好很多:
String salt = UUID.randomUUID().toString().substring(0, 6); String encrypted = md5(md5(password) + salt);数据库存salt和encrypted两列。登录时用相同规则重新算一遍,再与数据库比对。修改密码时也要重新生成盐,不要复用旧盐。后台新增用户时,管理员输入初始密码,系统加密后入库。
删除用户的处理也需要谨慎,如果用户已经下过订单,物理删除会让订单关联不到用户,前台查询订单时就容易出现空指针。比较稳妥的做法是给 user 表加status字段,删除时置为禁用,而不是真删。如果项目结构已经固定、没法加字段,那至少要确保删除前检查该用户是否有未完成订单。
5. 部署到 Tomcat 8.5 + MySQL 5.7:高频异常与验证清单
5.1 启动与数据库连接验证
Tomcat 8.5.35 对应的是 Servlet 3.1 规范,所以项目里用@WebServlet注解是没有问题的。如果改成 Tomcat 9 或 10,javax.servlet 会变成 jakarta.servlet,老代码要全部替换包名,这里不建议升级。启动前先确认 MySQL 5.7 的 root 密码和 c3p0-config.xml 里一致,然后单独用下面的 JDBC 测试类验证连通性:
public static void main(String[] args) { Connection conn = DBUtil.getConnection(); System.out.println(conn != null ? "连接成功" : "连接失败"); DBUtil.close(conn, null, null); }如果报Access denied for user,多半是密码或账号问题;报Communications link failure,先检查 MySQL 服务是否启动,再检查端口是否被改过。数据库创建字符集建议用utf8mb4,不然 emoji 表情符号无法入库。
5.2 三个高频异常:404、500、ClassNotFoundException
部署完总会遇到几个典型问题。404 大多数是项目上下文路径不对,访问地址应该包含请求根路径/项目名,比如http://localhost:8080/cake_war/,而不是http://localhost:8080/cake_war_exploded/。IDEA 2017.3 下 Artifact 输出目录名经常和项目名不一致,需要在 Run Configuration 的 Deployment 里检查 Application context。
500 错误要分两层看。页面报 500 且 Tomcat 日志里是NullPointerException,最可能是把request.getParameter("id")直接Integer.parseInt,但前端传来的 id 为空。建议先判空再转换。另一种情况是 JSP 中使用${product.price},而getPrice()返回的是double,显示没问题;但如果返回null,EL 表达式输出空字符串也不会报错,这时候就不好查。
ClassNotFoundException 最常见的原因是驱动包和 c3p0 包没有打进 WEB-INF/lib。IDEA 中 Artifact 需要把依赖添加到WEB-INF/lib下,而不是只在本地 classpath 里。如果项目是直接拷到 Tomcat webapps 下部署,必须确认 lib 目录里有 mysql-connector-java 和 c3p0 两个 jar,否则启动时不会报错,第一次访问数据库才会闪断。
5.3 验证技巧:按模块写一个快速回归脚本
老项目改完搜索、购物车或订单状态后,最好按一套固定请求走一遍,避免漏改。可以自己写一个简单的 Servlet 或直接用 curl 模拟关键链路:
curl "http://localhost:8080/cake/user/login?username=admin&password=123456" curl "http://localhost:8080/cake/product/search?keyword=芝士" curl "http://localhost:8080/cake/cart/change?productId=1&count=2"建议每个请求返回的内容都是可控的纯文本或 JSON,而不是一个完整的 HTML 页面。HTML 对交互验证有意义,但自动化脚本里解析成本高。把登录、查商品、加购物车、改数量、下订单、后台发货这六个动作做成一个测试脚本,之后每次改动后执行一遍,就能在 3 分钟内确认核心业务没有回归。这个习惯对后续维护老项目特别有用,尤其是像蛋糕店这类前后台功能都齐全的中型项目,手动点页面太容易漏掉细节。
本文还有配套的精品资源,点击获取