简介这是一套面向Java Web初学者的完整水果超市管理系统实战项目基于Servlet技术栈实现适用于高校Web开发课程实践、自学入门与毕业设计参考。项目采用标准Java Web分层结构涵盖用户注册登录、水果商品增删改查等核心业务并集成Tomcat部署、MySQL数据库管理适配Navicat操作及前后端基础交互帮助学习者系统掌握Servlet生命周期、HTTP请求处理、JSP页面渲染与MVC雏形构建。资源共85个文件含15个Java源码位于src/cn/cst、30个编译后class文件、14个JSP页面如add.jsp、list.jsp、register.jsp、6个XML配置含web.xml、以及CSS/JS前端资源和图片素材整体压缩包仅2.1MB轻量易导入IDEA工程。目前已有1288人学习下载项目目录结构规范含.idea、web、WEB-INF、out等标准模块附带可直接运行的war exploded部署配置开箱即用是理解Java Web基础开发流程的优质练手案例。1. 水果超市 JavaWeb 项目一个能跑通、能改、能交作业的真实业务闭环这不是一个“Hello World”式的 Servlet 练习也不是只画几个 JSP 页面就收工的空壳 demo。水果超市 JavaWeb 项目是高校课程设计、培训班结业考核、甚至中小公司内部管理工具的真实落地方向——它必须完成「用户浏览商品 → 登录下单 → 库存扣减 → 订单查询」这一完整链路且每个环节都得经得起本地调试、Tomcat 部署、MySQL 数据一致性校验。我带过三届实训班80% 的学员卡在「明明代码抄对了但登录跳转 404」「添加商品后数据库没更新」「订单状态改了但页面不刷新」这三类问题上。根本原因不是不会写代码而是没理清 JavaWeb 技术栈各层之间的责任边界Servlet 做什么、JSP 怎么传参、DAO 层怎么防 SQL 注入、web.xml 和注解到底谁优先、MySQL 表字段类型和 Java 实体类属性怎么一一对应。本文不讲抽象概念只拆解一个真实可运行的水果超市项目从 IDEA 新建工程开始到 Tomcat 启动后能用浏览器下单成功再到你亲手改掉“苹果”价格、新增“车厘子”品类、修复库存超卖漏洞——每一步命令、每个配置项、每一处容易翻车的细节我都压着自己当年踩过的坑写清楚。2. 用 IDEA 搭建水果超市 JavaWeb 工程从空目录到可部署 WAR 包2.1 创建标准 JavaWeb 模块结构非 Maven新手友好很多教程一上来就拉 Maven 依赖结果学员连pom.xml里scopeprovided/scope是干啥的都不知道反而被报错淹没。水果超市这种中等复杂度项目我建议先用原生 JavaWeb 结构起步把路径、编译、部署逻辑摸透再迁移到 Maven。在 IDEA 中新建 Project → 选择Empty Project不要选 Java 或 Maven→ Next → Finish。然后右键 Project →New → Module→ 选择Java Enterprise→ 勾选Web Application→ 确保Version选4.0对应 Servlet 4.0兼容 Tomcat 9→ 勾选Create web.xml关键新手必须保留这个文件否则注解路由容易失效→ Finish。提示不要勾选 “Add sample code”它生成的 index.jsp 会引入错误的 JSTL 标签库路径后续排查 500 错误时浪费 2 小时。生成后你会看到标准目录fruit-shop/ ├── src/ # Java 源码Servlet、DAO、Bean ├── web/ # Web 根目录含 WEB-INF │ ├── WEB-INF/ │ │ ├── web.xml # 路由核心配置文件 │ │ └── lib/ # 手动放 jar 包mysql-connector-java-8.0.33.jar 等 │ └── index.jsp # 入口页面 └── out/ # IDEA 编译输出目录自动映射到 Tomcat webapps2.2 配置 Tomcat Server 并设置 Artifact让 IDEA 知道怎么打包IDEA 默认不配 Tomcat必须手动绑定。打开File → Project Structure → Artifacts→ 点击 → Web Application: Archive → For fruit-shop→ Name 填fruit-shop.war→ Output Directory 选out/artifacts/。接着点OK再点Apply。然后配置 TomcatRun → Edit Configurations → → Tomcat Server → Local→ 在Deployment标签页点击 → Artifact → fruit-shop:war exploded注意选 exploded不是 war方便热更新→ Application context 填/fruit即访问地址为http://localhost:8080/fruit→ 点击OK。参数说明exploded模式意味着 IDEA 把 class 文件、JSP、静态资源实时同步到 Tomcat 的webapps/fruit目录下改完 Java 类 CtrlF9 重新编译就能生效而war模式每次都要重新打包适合最终交付。2.3 引入 MySQL 驱动与 JSTL 支持两个 jar 包决定成败水果超市必须连数据库且页面要循环展示商品列表、判断用户是否登录——这就绕不开 JDBC 驱动和 JSTL 标签库。去 MySQL 官网 下载mysql-connector-java-8.0.33.jar版本别选太高8.0.x 最稳复制到web/WEB-INF/lib/下。JSTL 不能只放jstl-1.2.jar必须配套standard-1.1.2.jar或新版javax.servlet.jsp.jstl-api-1.2.2.jarorg.glassfish.web.jakarta.servlet.jsp.jstl-2.0.0.jar。我推荐直接用 Maven 仓库下载的jstl-1.2.jarstandard-1.1.2.jar组合放进lib/后右键 →Add as Library。验证是否生效在index.jsp顶部加一行% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %如果 IDEA 不报红、且页面能用c:if test${not empty user}欢迎 ${user.name}/c:if说明 JSTL 成功。3. 数据库建模与 Java 实体类映射水果超市的 5 张核心表设计3.1 MySQL 表结构定义含外键约束与中文注释水果超市业务看似简单但库存、订单、用户三者关系必须靠外键兜底否则后期查数据全是脏读。我按生产环境习惯建了 5 张表不是网上常见的 3 张全部带COMMENT注释方便你后续加字段-- 用户表含角色字段支持管理员/普通用户 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密密码, role ENUM(admin,user) DEFAULT user COMMENT 角色, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 商品表重点price 是 DECIMAL(10,2)不是 FLOAT CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 商品名称, price DECIMAL(10,2) NOT NULL COMMENT 单价精确到分, stock INT NOT NULL DEFAULT 0 COMMENT 库存数量, image_url VARCHAR(255) COMMENT 图片路径, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 订单主表status 字段用 tinyint便于扩展更多状态 CREATE TABLE order_master ( id VARCHAR(32) PRIMARY KEY COMMENT 订单号UUID, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表一对多一个订单多个商品 CREATE TABLE order_detail ( id INT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(32) NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL COMMENT 购买数量, price DECIMAL(10,2) NOT NULL COMMENT 下单时快照价格, FOREIGN KEY (order_id) REFERENCES order_master(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 购物车临时表用户未登录时用 session_id登录后转 user_id CREATE TABLE cart ( id INT PRIMARY KEY AUTO_INCREMENT, session_id VARCHAR(100) COMMENT 游客 session ID, user_id INT COMMENT 登录用户 ID, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_session (session_id), INDEX idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意price必须用DECIMAL(10,2)这是金融级精度要求。用FLOAT或DOUBLE会导致 0.1 0.2 ≠ 0.3下单金额计算出错order_id用VARCHAR(32)存 UUID比自增 ID 更安全避免暴露订单量。3.2 Java 实体类与 DAO 层编写手写 JDBC拒绝框架黑匣子新手一上来就学 MyBatis结果连PreparedStatement的?占位符怎么填都不知道。我们手写 JDBC把每一步暴露出来// src/com/fruit/entity/Product.java public class Product { private int id; private String name; private BigDecimal price; // 对应 DECIMAL用 BigDecimal 避免浮点误差 private int stock; private String imageUrl; // getter/setter 省略IDEA AltInsert 自动生成 }// src/com/fruit/dao/ProductDao.java public class ProductDao { private static final String URL jdbc:mysql://localhost:3306/fruit_db?useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; public ListProduct findAll() throws SQLException { ListProduct list new ArrayList(); String sql SELECT id, name, price, stock, image_url FROM product WHERE stock 0; try (Connection conn DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Product p new Product(); p.setId(rs.getInt(id)); p.setName(rs.getString(name)); p.setPrice(rs.getBigDecimal(price)); // 关键用 getBigDecimal 接 DECIMAL p.setStock(rs.getInt(stock)); p.setImageUrl(rs.getString(image_url)); list.add(p); } } return list; } // 更新库存必须用事务否则并发下单会超卖 public boolean updateStock(int productId, int quantity) throws SQLException { String sql UPDATE product SET stock stock - ? WHERE id ? AND stock ?; try (Connection conn DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, quantity); ps.setInt(2, productId); ps.setInt(3, quantity); // WHERE stock quantity确保库存足够 return ps.executeUpdate() 1; // 返回影响行数1 表示扣减成功 } } }逻辑说明updateStock方法里的WHERE stock ?是防超卖的核心。如果库存只剩 5用户下单 10 件这条 SQL 影响行数为 0executeUpdate()返回 0我们就知道扣减失败可以抛异常或返回提示。这是比“先查再更新”更可靠的方案避免并发场景下的时间差漏洞。4. Servlet 路由与 JSP 页面联动从登录到下单的 7 个关键请求链4.1 web.xml 中定义 4 个核心 Servlet 映射拒绝全注解新手看得见虽然 Servlet 3.0 支持WebServlet但新手常因包路径写错、类名拼错导致 404。我们用web.xml明确声明路径一目了然!-- web/WEB-INF/web.xml -- servlet servlet-nameLoginServlet/servlet-name servlet-classcom.fruit.servlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping servlet servlet-nameProductListServlet/servlet-name servlet-classcom.fruit.servlet.ProductListServlet/servlet-class /servlet servlet-mapping servlet-nameProductListServlet/servlet-name url-pattern/product/list/url-pattern /servlet-mapping servlet servlet-nameOrderCreateServlet/servlet-name servlet-classcom.fruit.servlet.OrderCreateServlet/servlet-class /servlet servlet-mapping servlet-nameOrderCreateServlet/servlet-name url-pattern/order/create/url-pattern /servlet-mapping servlet servlet-nameLogoutServlet/servlet-name servlet-classcom.fruit.servlet.LogoutServlet/servlet-class /servlet servlet-mapping servlet-nameLogoutServlet/servlet-name url-pattern/logout/url-pattern /servlet-mapping参数说明url-pattern决定了浏览器访问路径。比如访问http://localhost:8080/fruit/product/list就会触发ProductListServlet的doGet()方法。所有路径以/开头表示相对于应用上下文即/fruit。4.2 ProductListServlet一次请求完成查商品 判登录 传数据到 JSP这是最典型的 JavaWeb 请求处理模式查数据 → 封装到 request → 转发到 JSP// src/com/fruit/servlet/ProductListServlet.java public class ProductListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 判断用户是否登录从 session 取 user 对象 User user (User) req.getSession().getAttribute(user); if (user null) { req.setAttribute(msg, 请先登录); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } // 2. 查询商品列表 ProductDao dao new ProductDao(); try { ListProduct productList dao.findAll(); req.setAttribute(productList, productList); // 绑定到 request 作用域 req.setAttribute(user, user); // 顺便把 user 也传过去JSP 可显示欢迎语 req.getRequestDispatcher(/product/list.jsp).forward(req, resp); } catch (SQLException e) { e.printStackTrace(); req.setAttribute(msg, 商品加载失败 e.getMessage()); req.getRequestDispatcher(/error.jsp).forward(req, resp); } } }逻辑说明req.getRequestDispatcher(...).forward()是服务器端跳转URL 不变且 request 里的属性能传递到目标 JSP而resp.sendRedirect(...)是客户端重定向URL 改变request 属性丢失。这里必须用forward否则productList在 JSP 里取不到。4.3 OrderCreateServlet事务控制 库存校验 订单生成三步原子操作下单是最容易出错的环节。必须在一个数据库事务里完成扣库存 → 插订单主表 → 插订单明细 → 提交事务。任何一步失败全部回滚// src/com/fruit/servlet/OrderCreateServlet.java public class OrderCreateServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取参数前端 form 提交的 productIds 和 quantities String[] productIds req.getParameterValues(productId); String[] quantities req.getParameterValues(quantity); // 2. 从 session 获取当前用户 User user (User) req.getSession().getAttribute(user); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } Connection conn null; try { conn DriverManager.getConnection( jdbc:mysql://localhost:3306/fruit_db?useSSLfalseserverTimezoneAsia/Shanghai, root, 123456); conn.setAutoCommit(false); // 关键关闭自动提交开启事务 // 3. 遍历购物车商品逐个扣库存 BigDecimal totalAmount BigDecimal.ZERO; for (int i 0; i productIds.length; i) { int productId Integer.parseInt(productIds[i]); int quantity Integer.parseInt(quantities[i]); // 先查商品价格快照 Product product new ProductDao().findById(productId); if (product null || product.getStock() quantity) { throw new RuntimeException(商品库存不足 product.getName()); } // 扣库存带 WHERE stock quantity 校验 boolean stockOk new ProductDao().updateStockWithConn(conn, productId, quantity); if (!stockOk) { throw new RuntimeException(库存扣减失败可能已被抢光); } totalAmount totalAmount.add(product.getPrice().multiply(BigDecimal.valueOf(quantity))); } // 4. 生成订单主表 String orderId UUID.randomUUID().toString().replace(-, ); String insertMasterSql INSERT INTO order_master(id, user_id, total_amount, status) VALUES (?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(insertMasterSql)) { ps.setString(1, orderId); ps.setInt(2, user.getId()); ps.setBigDecimal(3, totalAmount); ps.setInt(4, 0); // 待支付 ps.executeUpdate(); } // 5. 生成订单明细省略循环插入逻辑同上 // ... insert into order_detail ... conn.commit(); // 全部成功提交事务 req.setAttribute(msg, 下单成功订单号 orderId); req.getRequestDispatcher(/order/success.jsp).forward(req, resp); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); // 任何异常回滚事务 } catch (SQLException ex) { ex.printStackTrace(); } } req.setAttribute(msg, 下单失败 e.getMessage()); req.getRequestDispatcher(/error.jsp).forward(req, resp); } finally { if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } } }关键点conn.setAutoCommit(false)和conn.commit()/conn.rollback()是事务控制的骨架。updateStockWithConn()方法需接收Connection参数确保所有 SQL 在同一个连接上执行否则事务无效。5. 避坑指南水果超市 JavaWeb 项目上线前必查的 4 类血泪问题5.1 现象访问/fruit/product/list报 404原因web.xml中servlet-class写错了包名比如写成com.fruit.ProductListServlet漏了.servletProductListServlet.java文件实际放在src/com/fruit/servlet/下但 IDEA 编译后 class 文件没生成到out/production/fruit-shop/com/fruit/servlet/目录Tomcat 的conf/server.xml里appBase被改过导致webapps目录指向错误。解决检查web.xml的servlet-class是否与 Java 文件的实际 package 完全一致查看out/production/fruit-shop/目录下是否存在com/fruit/servlet/ProductListServlet.class在 IDEA 的Run → Edit Configurations → Tomcat → Deployment中确认Application context是/fruit且Artifact选的是fruit-shop:war exploded。5.2 现象登录后跳转到首页但右上角不显示用户名原因LoginServlet中req.getSession().setAttribute(user, user)执行了但index.jsp里用% session.getAttribute(user) %直接取没判空导致NullPointerException更常见的是index.jsp用了c:out value${user.name}/但user是 nullEL 表达式默认输出空字符串看起来像“没显示”其实是user根本没存进 session。解决在LoginServlet登录成功后加一行日志System.out.println(Session ID: req.getSession().getId() , user: user);确认 session 写入在index.jsp开头加c:if test${empty user}请登录/c:if明确判断检查LoginServlet的doPost方法末尾是否写了resp.sendRedirect(req.getContextPath() /index.jsp)—— 这会导致 session 属性丢失必须用req.getRequestDispatcher(/index.jsp).forward(req, resp)。5.3 现象添加商品到购物车刷新页面后消失原因购物车数据存在HttpSession里但web.xml中没配session-configsession-timeout30/session-timeout/session-configTomcat 默认超时 30 分钟测试时等太久 session 过期更隐蔽的是前端 form 的action写成了绝对路径/cart/add而没加上下文路径导致请求发到http://localhost:8080/cart/add404而不是http://localhost:8080/fruit/cart/add。解决在web.xml的web-app标签下加session-config session-timeout60/session-timeout /session-config所有 form 的action改为相对路径或使用 ELaction${pageContext.request.contextPath}/cart/add购物车存 session 仅限开发阶段上线必须存数据库用session_id字段关联。5.4 现象MySQL 中price字段存的是19.999999999999999不是20.00原因Java 代码里用了float price 20.0f;或double price 20.0;然后ps.setDouble(1, price)插入数据库DECIMAL类型在 MySQL 里存储的是精确值但float/double是二进制浮点数无法精确表示十进制小数。解决实体类中price字段必须声明为BigDecimal从数据库读取时用rs.getBigDecimal(price)不是rs.getDouble(price)前端传价格参数时在 Servlet 中用new BigDecimal(req.getParameter(price))构造而非Double.parseDouble()。6. 进阶技巧用 Filter 实现统一登录拦截与字符编码过滤6.1 写一个 LoginFilter拦截未登录用户访问后台页面与其在每个 Servlet 里写if (user null) { redirect to login }不如用 Filter 统一处理。创建src/com/fruit/filter/LoginFilter.javapublic class LoginFilter implements Filter { // 定义不需要登录就能访问的路径白名单 private static final String[] EXCLUDE_PATHS { /login.jsp, /login, /register.jsp, /register, /css/, /js/, /images/, /favicon.ico }; Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String requestURI request.getRequestURI(); // 检查是否在白名单中 boolean excluded false; for (String path : EXCLUDE_PATHS) { if (requestURI.startsWith(request.getContextPath() path)) { excluded true; break; } } if (excluded) { chain.doFilter(req, resp); return; } // 检查 session 中是否有 user User user (User) request.getSession().getAttribute(user); if (user null) { request.setAttribute(msg, 请先登录); request.getRequestDispatcher(/login.jsp).forward(request, response); } else { chain.doFilter(req, resp); } } Override public void init(FilterConfig config) throws ServletException {} Override public void destroy() {} }然后在web.xml中配置filter filter-nameLoginFilter/filter-name filter-classcom.fruit.filter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/*/url-pattern /filter-mapping效果所有请求除白名单外都会先经过此 Filter。如果没登录直接跳转到login.jsp无需每个 Servlet 重复判断。这是 JavaWeb 项目走向工程化的第一步。6.2 CharacterEncodingFilter解决中文乱码的终极方案GET 请求乱码URL 参数、POST 请求乱码form 提交、JSP 页面乱码% page contentTypetext/html;charsetUTF-8 %——三个地方都要设 UTF-8。用 Filter 统一搞定public class CharacterEncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // POST 请求参数乱码 request.setCharacterEncoding(UTF-8); // 响应内容编码 response.setContentType(text/html;charsetUTF-8); // GET 请求乱码Tomcat 8.0 默认 UTF-8但低版本需额外处理 // 此处不处理 GET因为 GET 乱码需改 Tomcat conf/server.xml 的 Connector chain.doFilter(req, resp); } }web.xml配置必须放在LoginFilter之前保证编码最先处理filter filter-nameCharacterEncodingFilter/filter-name filter-classcom.fruit.filter.CharacterEncodingFilter/filter-class /filter filter-mapping filter-nameCharacterEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping6.3 Tomcat Connector 配置治本 GET 乱码上面的 Filter 解决了 POST 和响应乱码但 GET 请求如?name苹果仍可能乱码。根本原因是 Tomcat 的 HTTP Connector 默认用 ISO-8859-1 解析 URL。打开conf/server.xml找到Connector port8080 ... /这一行在末尾加上URIEncodingUTF-8Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /重启 TomcatGET 参数中文就彻底正常了。我带过的学员里90% 的乱码问题都卡在这三步没配全Filter 没写、web.xml没注册、server.xml没改 URIEncoding。少一步中文就变问号。现在你手里有完整的解决方案下次遇到乱码直接按这个顺序查5 分钟定位。希望帮到你。本文还有配套的精品资源点击获取