简介:这是一套基于Java Web的超市管理系统完整项目资料,面向计算机相关专业的在校学生、课程设计或毕业设计需求者,也可供教师教学演示与初学者进阶练习。项目已通过导师指导与答辩评审,获得95分评价,代码经过测试可正常运行,适合直接用于课设、毕设或作为项目初期立项参考。压缩包共125个文件,约3.05MB,以30个Java源文件、20个JSP页面、23个JavaScript脚本、10个XML配置及若干CSS、图片资源为主,涵盖后端业务逻辑、前端交互与页面样式,另含war包与说明文档,结构完整便于二次开发。目前已有40人学习关注。读者可从中获得一套可运行的超市管理业务实现,理解Java Web分层开发思路、数据库表设计与前后端交互流程,并在此基础上修改扩展功能,完成自己的课程设计或毕业设计任务。
1. 从一份超市管理系统课设说起:Java web 和数据库到底怎么串起来
很多同学做数据库课程设计时,第一反应是去搜“java课程设计案例源码”,找到一份超市管理系统的压缩包,解压、导入、跑起来,然后对着屏幕发呆——因为根本不知道这份代码里哪些是 Java web 的部分,哪些是数据库的部分,答辩时老师问一句“你的数据库设计范式到第几层”就卡住了。这份“基于 Java web 的超市管理系统”本质上是一个典型的 CRUD 型信息管理系统,前端用 JSP 或 HTML+Servlet 渲染页面,后端用 Java 处理业务逻辑,底层用 MySQL 做数据持久化。它解决的核心问题是:把超市里商品、库存、订单、用户这些实体,用关系表存起来,再通过 Web 页面做增删改查。适合正在做数据库课程设计、需要一份能跑通且能讲清楚原理的参考项目的学生,也适合想补 Java web 全栈基础的新手。下面我从表结构设计一路讲到部署排错,把这份课设里真正值钱的东西拆开。
2. 超市管理系统的数据库设计:从 ER 图到建表语句的完整落地
2.1 先想清楚实体和关系,别急着写 CREATE TABLE
超市管理系统的数据库设计,核心实体就那么几个:商品(product)、分类(category)、供应商(supplier)、库存(inventory)、订单(orders)、订单明细(order_item)、用户(user)。很多同学一上来就打开 Navicat 或者 dbx 数据库管理工具开始建表,结果建到一半发现订单和商品是多对多关系,中间表没设计,只能删了重来。正确的顺序是先在纸上或 draw.io 里画 ER 图,确定实体属性和联系类型。
商品和分类是多对一:一个分类下有多个商品,一个商品只属于一个分类。商品和供应商也是多对一。订单和商品是多对多:一个订单包含多个商品,一个商品可以出现在多个订单里,所以必须有一张 order_item 中间表,把订单 ID 和商品 ID 作为联合主键,再加上数量、单价这两个属性。库存表可以独立出来,也可以直接作为商品表的一个字段,但独立出来更灵活,因为库存会随着入库、出库、销售不断变化,单独一张 inventory 表记录每次变动,方便追溯。
用户表要区分角色:管理员和普通收银员。加一个 role 字段,用枚举值或者关联角色表都行。课程设计里为了简单,通常直接用一个 varchar 字段存“admin”或“cashier”。
2.2 建表语句与字段类型选择:MySQL 8 下的实操
下面这套建表语句可以直接在 MySQL 8.x 里执行。注意字符集用 utf8mb4,排序规则用 utf8mb4_0900_ai_ci,这是 MySQL 8 的默认值,避免中文乱码。引擎用 InnoDB,支持事务和外键。
-- 创建数据库,字符集必须指定,否则中文商品名会变问号 CREATE DATABASE supermarket_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci; USE supermarket_db; -- 分类表:分类名唯一,避免重复添加 CREATE TABLE category ( category_id INT PRIMARY KEY AUTO_INCREMENT, category_name VARCHAR(50) NOT NULL UNIQUE, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- 供应商表:电话和联系人可能为空,但名称必填 CREATE TABLE supplier ( supplier_id INT PRIMARY KEY AUTO_INCREMENT, supplier_name VARCHAR(100) NOT NULL, contact_person VARCHAR(50), phone VARCHAR(20), address VARCHAR(200) ) ENGINE=InnoDB; -- 商品表:外键关联分类和供应商,价格用 DECIMAL 不用 FLOAT CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(100) NOT NULL, category_id INT, supplier_id INT, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock_quantity INT NOT NULL DEFAULT 0, unit VARCHAR(10) DEFAULT '件', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES category(category_id), FOREIGN KEY (supplier_id) REFERENCES supplier(supplier_id) ) ENGINE=InnoDB; -- 订单表:订单号用唯一索引,状态用 TINYINT 表示 CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, order_status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- 订单明细表:联合主键,记录每个订单里每个商品的数量和成交价 CREATE TABLE order_item ( order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL, PRIMARY KEY (order_id, product_id), FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES product(product_id) ) ENGINE=InnoDB; -- 用户表:密码存哈希,课程设计里至少用 MD5 演示,别存明文 CREATE TABLE user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role VARCHAR(20) DEFAULT 'cashier', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB;这段 SQL 里几个关键点:DECIMAL(10,2)存金额,不要用FLOAT或DOUBLE,否则 0.1+0.2 不等于 0.3 的经典问题会在对账时让你崩溃。order_item的联合主键保证同一个订单里同一个商品不会出现两条记录,数量累加即可。ON DELETE CASCADE让删除订单时自动清理明细,避免脏数据。password字段长度给 64,因为 MD5 是 32 位十六进制,SHA-256 是 64 位,留够空间。
注意:外键约束在批量导入数据时可能拖慢速度,课程设计数据量小无所谓,但如果你用
source命令导入几十万条测试数据,可以临时SET FOREIGN_KEY_CHECKS=0;,导入完再打开。
2.3 插入测试数据与验证约束是否生效
建完表要立刻插几条数据验证外键和唯一约束。下面这段插入语句故意包含一个重复分类名和一个不存在的分类 ID,用来观察 MySQL 的报错信息。
-- 正常插入 INSERT INTO category (category_name) VALUES ('饮料'), ('零食'), ('日用品'); INSERT INTO supplier (supplier_name, contact_person, phone) VALUES ('华东食品', '张经理', '13800001111'); INSERT INTO product (product_name, category_id, supplier_id, price, stock_quantity) VALUES ('可乐 330ml', 1, 1, 3.50, 100); -- 下面这条会报错:分类名重复,违反 UNIQUE 约束 INSERT INTO category (category_name) VALUES ('饮料'); -- 下面这条会报错:category_id=99 不存在,违反外键约束 INSERT INTO product (product_name, category_id, supplier_id, price) VALUES ('测试商品', 99, 1, 1.00);执行后你会看到ERROR 1062 (23000): Duplicate entry '饮料' for key 'category.category_name'和ERROR 1452 (23000): Cannot add or update a child row。这两条报错就是数据库在帮你守门。课程设计答辩时,老师经常问“你怎么保证数据一致性”,把这两个约束讲清楚,比背范式定义管用。
3. Java web 后端实现:Servlet + JDBC 的最小可用闭环
3.1 项目结构与环境准备:IDEA + Tomcat + MySQL 驱动
这份课设的 Java web 部分,常见做法是用 Servlet + JSP,不引入 Spring 框架,因为课程设计要体现“自己写的代码”。项目结构如下:src/main/java下按包名分dao、service、servlet、entity、util;src/main/webapp下放 JSP 页面、CSS、JS 和WEB-INF/web.xml。数据库驱动用mysql-connector-j-8.x.jar,放到WEB-INF/lib下,并在 IDEA 的 Project Structure 里确认已加入 Artifact。
连接池不是必须的,但如果你想让项目看起来“有点东西”,可以用 Druid 或者 HikariCP。课程设计里我一般会写一个简单的DBUtil类,用DriverManager获取连接,虽然性能一般,但代码量少,容易讲清楚。
// DBUtil.java:最简数据库连接工具类 package com.supermarket.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/supermarket_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL 驱动未找到", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r != null) { try { r.close(); } catch (Exception ignored) {} } } } }URL 里的serverTimezone=Asia/Shanghai必须加,否则 MySQL 8 的时区问题会让create_time差 8 小时。useSSL=false在本地开发时关掉,避免控制台一堆警告。characterEncoding=utf8配合数据库的 utf8mb4,保证中文不乱码。
3.2 商品增删改查的 Servlet 实现与参数说明
以商品管理为例,一个ProductServlet用action参数区分操作:list、add、delete、update。下面展示list和add的核心代码。
// ProductServlet.java 片段 package com.supermarket.servlet; import com.supermarket.dao.ProductDao; import com.supermarket.entity.Product; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.util.List; @WebServlet("/product") public class ProductServlet extends HttpServlet { private final ProductDao productDao = new ProductDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("list".equals(action)) { List<Product> products = productDao.findAll(); req.setAttribute("products", products); req.getRequestDispatcher("/product_list.jsp").forward(req, resp); } else if ("delete".equals(action)) { int id = Integer.parseInt(req.getParameter("id")); productDao.deleteById(id); resp.sendRedirect("product?action=list"); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String action = req.getParameter("action"); if ("add".equals(action)) { Product p = new Product(); p.setProductName(req.getParameter("productName")); p.setCategoryId(Integer.parseInt(req.getParameter("categoryId"))); p.setSupplierId(Integer.parseInt(req.getParameter("supplierId"))); p.setPrice(new java.math.BigDecimal(req.getParameter("price"))); p.setStockQuantity(Integer.parseInt(req.getParameter("stockQuantity"))); productDao.insert(p); resp.sendRedirect("product?action=list"); } } }doPost里第一行req.setCharacterEncoding("UTF-8")必须在获取任何参数之前调用,否则中文商品名会变成乱码。price用BigDecimal接收,和数据库的DECIMAL类型对应,避免精度丢失。sendRedirect之后不要继续写forward,否则会报IllegalStateException。
对应的 DAO 层用PreparedStatement防止 SQL 注入:
// ProductDao.java 片段 public void insert(Product p) { String sql = "INSERT INTO product (product_name, category_id, supplier_id, price, stock_quantity) VALUES (?, ?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, p.getProductName()); ps.setInt(2, p.getCategoryId()); ps.setInt(3, p.getSupplierId()); ps.setBigDecimal(4, p.getPrice()); ps.setInt(5, p.getStockQuantity()); ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("插入商品失败", e); } }PreparedStatement的?占位符不仅防注入,还能让 MySQL 缓存执行计划。课程设计里如果老师问“为什么不用 Statement 拼接字符串”,把 SQL 注入的例子讲一遍就够了。
3.3 订单业务的事务处理:库存扣减和订单写入必须原子
订单模块是超市管理系统里唯一需要事务的地方。用户下单时,要同时做两件事:往orders和order_item插入记录,并扣减product表的stock_quantity。如果扣库存成功但订单插入失败,库存就白白少了。所以必须把autoCommit设为false,手动提交。
public boolean createOrder(Order order, List<OrderItem> items) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入订单主表 String orderSql = "INSERT INTO orders (order_no, user_id, total_amount, order_status) VALUES (?, ?, ?, 0)"; PreparedStatement orderPs = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); orderPs.setString(1, order.getOrderNo()); orderPs.setInt(2, order.getUserId()); orderPs.setBigDecimal(3, order.getTotalAmount()); orderPs.executeUpdate(); // 获取自增的 order_id ResultSet rs = orderPs.getGeneratedKeys(); int orderId = 0; if (rs.next()) orderId = rs.getInt(1); // 2. 插入订单明细并扣库存 String itemSql = "INSERT INTO order_item (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)"; String stockSql = "UPDATE product SET stock_quantity = stock_quantity - ? WHERE product_id = ? AND stock_quantity >= ?"; PreparedStatement itemPs = conn.prepareStatement(itemSql); PreparedStatement stockPs = conn.prepareStatement(stockSql); for (OrderItem item : items) { itemPs.setInt(1, orderId); itemPs.setInt(2, item.getProductId()); itemPs.setInt(3, item.getQuantity()); itemPs.setBigDecimal(4, item.getUnitPrice()); itemPs.addBatch(); stockPs.setInt(1, item.getQuantity()); stockPs.setInt(2, item.getProductId()); stockPs.setInt(3, item.getQuantity()); int affected = stockPs.executeUpdate(); if (affected == 0) { throw new SQLException("库存不足,商品ID:" + item.getProductId()); } } itemPs.executeBatch(); conn.commit(); // 全部成功才提交 return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { DBUtil.close(conn); } }stockSql里的AND stock_quantity >= ?是关键,它保证库存不会被扣成负数。如果executeUpdate返回 0,说明库存不够,直接抛异常触发回滚。addBatch和executeBatch减少网络往返,但注意批量插入时如果有一条失败,整个批次的行为取决于驱动配置,课程设计里数据量小,逐条执行更直观。
提示:MySQL 的 InnoDB 默认隔离级别是 REPEATABLE READ,事务里扣库存用
UPDATE ... WHERE stock_quantity >= ?会加行锁,并发下单时不会超卖。但如果你在事务里先SELECT再UPDATE,就可能出现幻读导致超卖,这是数据库课程设计里常考的并发场景。
4. 部署与联调:Tomcat 跑起来之后最容易翻车的几个地方
4.1 从 IDEA 部署到 Tomcat 的完整步骤
在 IDEA 里点 Run 之前,先确认三件事:Artifact 里包含了WEB-INF/lib下的 MySQL 驱动;Tomcat 的conf/server.xml里端口没被占用;数据库服务已经启动。部署步骤:File → Project Structure → Artifacts → 添加 Web Application Exploded → 把src/main/webapp和编译输出都加进去。然后 Run → Edit Configurations → 添加 Tomcat Local → Deployment 里选刚才的 Artifact,Application context 设为/supermarket。
启动后访问http://localhost:8080/supermarket/product?action=list,如果看到商品列表页面,说明 Servlet 和 JSP 都通了。如果报 404,检查web.xml里的url-pattern或者@WebServlet注解的路径。如果报 500 且堆栈里有ClassNotFoundException: com.mysql.cj.jdbc.Driver,说明驱动 jar 没放进WEB-INF/lib,IDEA 里加了依赖但 Artifact 没同步。
4.2 中文乱码的三种场景和对应解法
中文乱码在 Java web 课设里几乎人人都会遇到,但场景不同解法不同。第一种:表单 POST 提交后数据库里是问号。原因是request.setCharacterEncoding("UTF-8")没写或者写在getParameter之后。第二种:GET 请求 URL 里的中文参数乱码。Tomcat 8 之后默认 URI 编码是 UTF-8,但如果你用的 Tomcat 7 或者更早,需要在server.xml的 Connector 里加URIEncoding="UTF-8"。第三种:数据库连接 URL 里没加characterEncoding=utf8,导致 JDBC 驱动用平台默认编码发送数据。三个地方都检查一遍,基本能解决 99% 的乱码。
4.3 数据库连接池配置不当导致的连接泄漏
课程设计里如果用了 Druid 或 HikariCP,最常见的问题是连接用完没关闭,跑几次操作后 Tomcat 控制台报Wait millis 60000, active 20, maxActive 20。原因是 DAO 层里Connection、PreparedStatement、ResultSet没有在finally里关闭。用 try-with-resources 可以自动关闭,但注意Connection如果是从连接池拿的,close()是归还而不是真正关闭,所以必须调用。我一般会在DBUtil.close()里按ResultSet→Statement→Connection的顺序关闭,避免某些驱动报错。
5. 避坑与排查:课设答辩前必须自己先跑一遍的 5 个场景
5.1 现象:插入商品时提示“Cannot add or update a child row”
原因:product表的category_id或supplier_id在对应表里不存在。解决:先查category和supplier表里有哪些 ID,或者在前端下拉框里只渲染数据库里真实存在的分类和供应商。如果确实需要允许空分类,把外键字段设为NULL并在建表时去掉NOT NULL。
5.2 现象:订单提交后库存没变,但订单记录有了
原因:事务没有生效,conn.setAutoCommit(false)写了但conn.commit()没执行,或者扣库存的 SQL 条件写错导致affected=0却没抛异常。解决:在扣库存后立刻判断返回值,不等于 1 就throw new SQLException,并在catch里rollback。另外确认 MySQL 引擎是 InnoDB,MyISAM 不支持事务。
5.3 现象:JSP 页面里用 EL 表达式取不到 Servlet 传的值
原因:req.setAttribute("products", products)之后用了forward,但 JSP 里写的是${productList},名字对不上。解决:检查setAttribute的 key 和 JSP 里 EL 表达式的名字是否完全一致,大小写敏感。另外如果用了 JSTL,确认jstl.jar和standard.jar已导入,并且页面顶部有<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。
5.4 现象:Tomcat 启动报“ClassNotFoundException: com.mysql.cj.jdbc.Driver”
原因:MySQL 驱动 jar 没有放到WEB-INF/lib下,或者 IDEA 的 Artifact 里没有包含lib目录。解决:在 Project Structure → Artifacts → 选中 Artifact → 右侧 Available Elements 里双击mysql-connector-j,让它出现在WEB-INF/lib下。重新部署。
5.5 现象:删除分类时提示“Cannot delete or update a parent row”
原因:product表里有商品引用了这个分类,外键约束阻止删除。解决:要么先删除或转移该分类下的商品,要么在建表时把外键设为ON DELETE SET NULL,但这样商品会变成无分类状态。课程设计里更稳妥的做法是逻辑删除,给category加一个is_deleted字段,删除时只更新标记,不物理删除。
6. 进阶技巧:用一条 SQL 查出“库存低于阈值且最近 30 天没被下单”的商品
课设做完基本功能后,如果想在答辩时多拿几分,可以加一个“滞销商品预警”功能。核心是一条 SQL 同时用到聚合、子查询和日期函数。
SELECT p.product_id, p.product_name, p.stock_quantity, c.category_name FROM product p JOIN category c ON p.category_id = c.category_id WHERE p.stock_quantity < 20 AND p.product_id NOT IN ( SELECT oi.product_id FROM order_item oi JOIN orders o ON oi.order_id = o.order_id WHERE o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND o.order_status = 1 ) ORDER BY p.stock_quantity DESC;这条 SQL 的逻辑是:先找出库存小于 20 的商品,再排除掉那些在最近 30 天内出现在已支付订单里的商品。DATE_SUB(NOW(), INTERVAL 30 DAY)是 MySQL 的日期减法,NOT IN子查询里只取已支付的订单,避免把取消的订单也算进去。ORDER BY p.stock_quantity DESC让库存最多的滞销品排前面,方便优先处理。
在 Java 里调用这条 SQL 时,建议把它放在ProductDao的findSlowMovingProducts(int threshold, int days)方法里,参数化阈值和天数,不要写死。前端用一个单独的 JSP 页面渲染,表格列显示商品名、分类、库存。答辩时老师如果问“你怎么定义滞销”,你就说“库存低于阈值且最近 N 天无有效订单”,比空谈“卖得不好”有说服力。
注意:
NOT IN子查询在数据量大时性能较差,因为 MySQL 可能对外层每一行都执行一次子查询。课程设计数据量小无所谓,但如果想优化,可以改成LEFT JOIN ... WHERE oi.product_id IS NULL的形式。
我自己做课设时踩过最深的坑是:本地 MySQL 是 8.0,但同学电脑上装的是 5.7,utf8mb4_0900_ai_ci这个排序规则在 5.7 里不存在,导入 SQL 直接报错。后来我把建表语句里的排序规则改成utf8mb4_general_ci,兼容性就好了。所以如果你要把这份课设发给别人跑,建表语句里别写死 8.0 独有的特性。希望帮到你。
本文还有配套的精品资源,点击获取