简介:基于JSP+Servlet+JavaBean的超市进销存管理系统源码,专为Java Web学习者、高校课程设计及毕业设计准备,覆盖超市商品档案、用户权限、进货、销售、库存统计等核心业务,采用经典的JSP+Servlet+JavaBean分层设计,便于理解请求处理、业务逻辑与数据访问的分离。压缩包内共134个文件,包含9个JSP页面、14个Java源文件及对应class文件,并通过js/css完成前端交互,另有图片、字体图标、jar依赖库、XML配置等辅助资源,包体仅6.94MB,结构清晰、可直接部署运行。目前已吸引114人学习,内容预览涉及控制器、实体封装、数据库操作与Excel表格导出等核心环节,读者可借此掌握从界面到数据库的完整实现思路,也可在源码基础上针对具体业务二次开发。
1. 一个老技术组合,为什么超市进销存还在用它
你拿到的这份“超市进销存管理系统源码.zip”不是一堆过时代码的堆砌,它几乎是 JavaWeb 从入门到实战之间最标准的一个“中间件”:从建表、写 DAO、配置 Servlet 映射,到 JSP 页面循环渲染库存列表,正好覆盖传统行业系统最常见的完整链路。超市进销存解决的核心问题只有三件:进货、销售、库存台账,而这个标题里的 JSP、Servlet、JavaBean 三个技术词,恰好对应 MVC 里的 View、Controller、Model:JSP 把库存数字变成表格,Servlet 接收表单请求并控制页面跳转,JavaBean 承载商品、单据、供应商对象的属性与数据访问。适合谁看?刚学完 JSP/Servlet 要做课程设计的人、要接手维护这类老系统的工程师,以及好奇“为什么今天还有人敢用 JSP 交项目”的开发者。这套代码不会变成微服务,但能让你把它的来龙去脉、跑通步骤和改造边界一次讲清楚。
2. 先立架构:JSP、Servlet、JavaBean 在进销存里各管哪一段
2.1 为什么要用“老三样”而不是直接上 Spring Boot
早期 JavaWeb 有两种组织方式:Model1 把数据库查询、业务判断和 HTML 全塞进 JSP 页面,写起来快,但改一个字段要翻几百行脚本;Model2 才是标题里这套组合的标准用法,JSP 只负责展示,Servlet 只做请求分发,JavaBean 只做数据和业务,三者职责分离。下表直接对应到进销存项目里的具体位置。
| 层次 | 技术 | 在进销存中的职责 | 典型代码位置 |
|---|---|---|---|
| 视图层 | JSP | 商品列表、进货单填写、库存预警页面 | /product/list.jsp |
| 控制层 | Servlet | 接收“添加商品”“提交进货单”请求,跳转结果页 | AddProductServlet |
| 模型层 | JavaBean | 商品实体、供应商实体、DAO 访问、库存事务 | Product.java、SupplierDao.java |
不直接用 Spring Boot 的原因很实际:这套系统跑在 Tomcat 上就能独立运行,不需要引入依赖注入、自动配置和一堆 starter。新手看 Spring Boot 的启动类org.springframework.boot.web.servlet.support.SpringBootServletInitializer经常会和传统 Servlet 项目混淆,而传统项目压根不碰它。维护老系统时,Tomcat 8.5/9 + JDK 8 的部署矩阵远比 Spring Boot 版本兼容矩阵简单,这才是这类源码包仍然活跃的根本原因。
2.2 数据模型怎么建:商品、库存、进货单、销售单的关系
进销存系统的表结构有固定套路:商品表管静态属性,库存表管当前数量,进货和销售分别拆成“单据主表 + 单据明细表”两张表的组合。主表记“这一单跟谁做的、总金额多少”,明细表记“这一单里每个商品进了多少、进价多少”,这是 ERP 类系统的通用范式,超市进销存管理系统源码包大多遵循这个设计。
CREATE TABLE supplier ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '供应商名称', contact VARCHAR(50) COMMENT '联系人', phone VARCHAR(20) COMMENT '联系电话' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) UNIQUE COMMENT '商品条码', name VARCHAR(100) NOT NULL COMMENT '商品名称', spec VARCHAR(100) COMMENT '规格,如 500ml/瓶', price DECIMAL(10,2) NOT NULL COMMENT '零售价', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock ( product_id INT PRIMARY KEY COMMENT '商品ID,与商品表一对一', qty INT NOT NULL DEFAULT 0 COMMENT '当前库存数', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_stock_product FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE purchase_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE COMMENT '单据号', supplier_id INT NOT NULL, total_amount DECIMAL(12,2) COMMENT '总金额', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (supplier_id) REFERENCES supplier(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE purchase_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT '进货单价', qty INT NOT NULL COMMENT '进货数量', FOREIGN KEY (order_id) REFERENCES purchase_order(id), FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;销售单sale_order/sale_item与进货单结构同构,这里不重复建。这套设计里有两个关键点值得专门说。第一,库存单独成表而不是在product表里加qty字段,因为库存是“当前状态”,单据表记录的是“历史流水”,把状态和流水混在同一张表里,月底要对账时无从下手。第二,价格一律用DECIMAL(10,2),不要贪方便用FLOAT或DOUBLE,二进制浮点算总价时会出现 0.1+0.2 不等于 0.3 这类问题,超市结算这种场景对金额精度是零容忍的。
3. JavaBean 层:实体类、DAO 与 JDBC 调用规范
3.1 实体类不该只是 getter/setter 容器
很多人把 JavaBean 理解成“一个带 getter/setter 的类”,这没错,但在进销存系统里,模型层应该细化为三块:实体 Bean 对应数据表的行,DAO Bean 封装 JDBC 访问,Service Bean 处理库存扣减这类跨表事务。课程设计源码最常见的毛病是把所有表字段堆进一个“超级 Bean”里,再在 Servlet 里直接写 SQL,等于把 Model 层做成了摆设。
package com.example.entity; import java.math.BigDecimal; public class Product { private Integer id; // 商品ID,数据库自增 private String code; // 商品条码,唯一索引 private String name; // 商品名称 private String spec; // 规格描述,如 500ml/瓶 private BigDecimal price; // 零售价,用 BigDecimal 避免浮点误差 public Product() { // JavaBean 规范要求保留无参构造 } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getSpec() { return spec; } public void setSpec(String spec) { this.spec = spec; } public BigDecimal getPrice() { return price; } public void setPrice(BigDecimal price) { this.price = price; } }这里有个细节:spec虽然允许为空,但在新增商品页面里最好也做一次非空处理,否则列表页会出现“名称对得上、规格空白”的显示瑕疵。另一个 JavaBean 的隐藏坑是boolean类型字段的 getter 命名,规范要求写成isXxx(),但很多序列化组件只认getXxx(),如果你后面要把商品对象转 JSON 输出给前端,建议统一用包装类型Boolean并生成getXxx()。
3.2 JDBC 连接与预编译:字符串拼接是进销存源码里第一宗罪
老源码里最容易翻车的就是数据库连接方式。早期教材喜欢Class.forName("com.mysql.jdbc.Driver"),那是 MySQL Connector/J 5.x 的写法,换成 8.x 驱动后类名变成com.mysql.cj.jdbc.Driver,直接用旧驱动类会抛ClassNotFoundException。这里给一个兼容两种环境的工具类。
package com.example.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { // 5.x 驱动类名是 com.mysql.jdbc.Driver,8.x 是 com.mysql.cj.jdbc.Driver private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/supermarket" + "?useUnicode=true&characterEncoding=UTF-8" + "&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动加载失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }JDBC URL 里那三个参数一个都不能少:useUnicode=true和characterEncoding=UTF-8保证中文商品名不乱码,serverTimezone=Asia/Shanghai是 MySQL 8.x 的硬性要求,缺了会报时区错误。密码写死在常量里对课设没问题,生产环境要改成从配置文件读取,这个后面会提。接下来是商品查询的 DAO 方法,注意 SQL 拼接和参数绑定的差别。
public List<Product> findByKeyword(String keyword) { String sql = "SELECT id, code, name, spec, price FROM product " + "WHERE name LIKE ? OR code LIKE ?"; List<Product> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + keyword + "%"; ps.setString(1, like); ps.setString(2, like); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Product p = new Product(); p.setId(rs.getInt("id")); p.setCode(rs.getString("code")); p.setName(rs.getString("name")); p.setSpec(rs.getString("spec")); p.setPrice(rs.getBigDecimal("price")); list.add(p); } } } catch (SQLException e) { throw new RuntimeException("按关键字查询商品失败", e); } return list; }PreparedStatement的价值一是预编译,数据库可以复用执行计划;二是参数绑定,?占位符不会被拼进 SQL 结构里,天然防注入。如果你在源码里看到Statement加"SELECT * FROM product WHERE name = '" + keyword + "'"这种写法,务必把它当成典型案例改掉,超市管理系统里的商品搜索框就是黑客最爱的注入入口。try-with-resources会自动关闭Connection、PreparedStatement和ResultSet,顺序是反着来的,不用手动在finally里写一大段关闭逻辑。
3.3 Servlet 控制器:一次添加商品的完整请求链路
控制器是进销存里最容易写乱的一层。标准流程应该是“接收参数 -> 参数校验 -> 调用 DAO -> 决定跳转”,绝对不要在 Servlet 里拼 HTML。下面这段AddProductServlet展示了完整的请求处理方式,同时兼容注解配置和web.xml配置。
package com.example.web; import com.example.dao.ProductDao; import com.example.entity.Product; import java.io.IOException; import java.math.BigDecimal; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @WebServlet("/product/add") public class AddProductServlet extends HttpServlet { private ProductDao productDao = new ProductDao(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 先设置编码,再取参数 request.setCharacterEncoding("UTF-8"); String code = request.getParameter("code"); String name = request.getParameter("name"); String priceStr = request.getParameter("price"); // 三道基础校验:非空、格式、业务 if (code == null || code.isEmpty() || name == null || name.isEmpty() || priceStr == null || priceStr.isEmpty()) { request.setAttribute("error", "商品编码、名称和价格不能为空"); request.getRequestDispatcher("/product/add.jsp") .forward(request, response); return; } Product product = new Product(); product.setCode(code.trim()); product.setName(name.trim()); product.setPrice(new BigDecimal(priceStr.trim())); productDao.add(product); // 添加成功后重定向到列表,避免刷新重复提交 response.sendRedirect(request.getContextPath() + "/product/list"); } }request.setCharacterEncoding("UTF-8")必须在第一个getParameter之前调用,否则 POST 中文参数已经按容器默认编码解析完了,再设置也无效。校验失败用forward转发回表单页,request域里的error属性可以在 JSP 里用${error}显示;添加成功用sendRedirect跳转列表页,这是“重定向派”和“转发派”的分水岭——表单提交成功后必须重定向,否则用户按 F5 刷新就会重复插入一条商品。@WebServlet("/product/add")是 Servlet 3.0 起的注解写法,老项目里等效的web.xml配置如下:
<servlet> <servlet-name>addProduct</servlet-name> <servlet-class>com.example.web.AddProductServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>addProduct</servlet-name> <url-pattern>/product/add</url-pattern> </servlet-mapping>两种配置方式作用相同,但注意一个项目里不要同时用两套,否则排查请求路径时很容易精神分裂。另外,Servlet 是单实例多线程的,private ProductDao productDao这种成员变量只能放无状态对象,绝对不能把Connection或SimpleDateFormat放进去,并发高的收银场景会直接踩线程安全的地雷。
4. 业务串联:进货入库、销售扣库存与页面联动
4.1 事务边界:进货单要一次写三张表
进销存的核心业务不是单表增删改查,而是“一张进货单同时影响单据主表、单据明细表和库存表”的跨表操作。如果三条 SQL 不在同一个事务里,就会出现“进货单录入了、库存没涨”这类对不上账的问题。下面这段入库逻辑把所有写操作包在同一个事务里。
public void importStock(Integer supplierId, List<PurchaseItem> items) { Connection conn = null; PreparedStatement psOrder = null; PreparedStatement psItem = null; PreparedStatement psStock = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 psOrder = conn.prepareStatement( "INSERT INTO purchase_order(order_no, supplier_id, total_amount) " + "VALUES(?, ?, ?)"); psOrder.setString(1, generateOrderNo()); psOrder.setInt(2, supplierId); psOrder.setBigDecimal(3, calculateTotal(items)); psOrder.executeUpdate(); int orderId = getGeneratedId(psOrder); // 从插入结果中取自增主键 psItem = conn.prepareStatement( "INSERT INTO purchase_item(order_id, product_id, price, qty) " + "VALUES(?, ?, ?, ?)"); psStock = conn.prepareStatement( "UPDATE stock SET qty = qty + ?, update_time = NOW() " + "WHERE product_id = ?"); for (PurchaseItem item : items) { psItem.setInt(1, orderId); psItem.setInt(2, item.getProductId()); psItem.setBigDecimal(3, item.getPrice()); psItem.setInt(4, item.getQty()); psItem.executeUpdate(); // 库存累加 psStock.setInt(1, item.getQty()); psStock.setInt(2, item.getProductId()); psStock.executeUpdate(); } conn.commit(); } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { // 回滚失败需要记录日志,这里仅示意 } } throw new RuntimeException("入库失败,事务已回滚", e); } finally { // 按 psStock、psItem、psOrder、conn 的逆序逐个关闭 } }setAutoCommit(false)之后,所有 SQL 都在同一个数据库连接里等到commit()才真正落盘;任何一个executeUpdate抛异常,rollback()会把前面已执行的两条 SQL 全部撤销。这里的库存更新用的是“读改写”之外的直接累加,MySQL 的UPDATE默认会锁定命中的行,所以同一商品并发入库时不会丢更新。真正要注意的是getGeneratedId的时机,必须在同一条PreparedStatement上、executeUpdate之后立即调用,中间不能穿插其他 SQL 操作。
4.2 销售出库:库存不足不是查出来,是写进去
无脑判断库存的写法是“先SELECT qty FROM stock,再在 Java 里判断够不够,够了再 UPDATE”。但收银台两台 POS 同时卖同一瓶水时,两个请求都查到了库存 1,两个都判断“够卖”,最后库存变成 -1。正确的做法是把判断条件直接写进 UPDATE 语句。
public boolean sale(Integer productId, int qty) { String sql = "UPDATE stock SET qty = qty - ?, update_time = NOW() " + "WHERE product_id = ? AND qty >= ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, qty); ps.setInt(2, productId); ps.setInt(3, qty); // 条件里的库存下限 int rows = ps.executeUpdate(); return rows == 1; // 返回 0 说明库存不足,条件没命中 } catch (SQLException e) { throw new RuntimeException("扣减库存失败", e); } }WHERE qty >= ?这个条件让数据库在原子操作层面完成“检查库存并扣减”,executeUpdate返回 0 就代表库存不够,连回滚都不用做。当然,它还顺带解决了并发超卖问题,因为同一行数据的 UPDATE 在 InnoDB 里是串行的。销售出库和进货入库一样要套事务,只是把qty + ?换成qty - ?,判断用sale()的返回值决定提示“库存不足”还是继续生成sale_item明细。
库存列表页面是超市店长每天要看的东西,配合 JSP 的 JSTL 标签可以很干净地渲染出来,注意不要用<% %>脚本片段强行循环,下面这一小段是标准示范:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table border="1"> <tr> <th>条码</th> <th>名称</th> <th>库存</th> <th>零售价</th> <th>出库</th> </tr> <c:forEach items="${stockList}" var="row"> <tr> <td>${row.code}</td> <td>${row.name}</td> <td><c:if test="${row.stockQty <= 5}">低库存!</c:if>${row.stockQty}</td> <td>${row.price}</td> <td> <form method="post" action="${pageContext.request.contextPath}/sale"> <input type="hidden" name="productId" value="${row.id}"/> <input type="number" name="qty" min="1" value="1"/> <button type="submit">出库</button> </form> </td> </tr> </c:forEach> </table>${row.stockQty}是 EL 表达式,底层调用的是 JavaBean 的getStockQty()方法;<c:if>在这里做低库存预警。stockList是从 Servlet 里request.setAttribute("stockList", list)塞进去的,由 DAO 用LEFT JOIN stock把商品表和库存表拼起来查询。这个页面对应一个分页问题,别一次性把全表商品查出来,常见做法是 MySQL 的LIMIT ?, ?,第一页查LIMIT 0, 20,第二页LIMIT 20, 20,总页数从COUNT(*)算。
4.3 页面上的业务逻辑越少越好
JSP 页面里出现 500 行<% %>脚本是老系统的通病,IDE 里默认生成的 JSP 模板也常带脚本片段,但工程上的正确姿势是:Servlet 把计算结果放到 request 域,JSP 只做取值和遍历。你可能会在源码里看到out.println("<td>" + list.get(i).getName() + "</td>")这种写法,它在小系统里能跑,但每次改页面样式都要重新编译 JSP,而且 JSP 编译后的 class 文件会留在 Tomcat 的work/Catalina/localhost/项目名/org/apache/jsp目录下,改完不重启时经常出现“改了没生效”的假象,其实就是旧 class 没被替换。把脚本迁移到 JSTL/EL 之后,页面和后端逻辑彻底解耦,改 HTML 结构不再碰 Java 代码,这才是这套三件套架构本来该有的样子。
5. 源码跑通的部署细节与两个值得做的改造
5.1 三个最容易卡住的运行环境问题
拿到源码包后,先别急着 import,按下面这张表核对运行环境,能避开大部分启动失败:
| 检查项 | 常见坑 | 正确取值 |
|---|---|---|
| Tomcat 版本 | 用了 Tomcat 10,JSP 报 404 或编译失败 | Tomcat 8.5 / 9.x,配 JDK 8 |
| JSTL 依赖 | 页面报NoClassDefFoundError: javax/servlet/jsp/jstl/core | 把jstl-1.2.jar放进WEB-INF/lib |
| JDBC URL | 中文乱码、时区异常 | 带上useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai |
Tomcat 10 把javax.servlet换成了jakarta.servlet,老源码的 Servlet、JSP 全部会编译报错,这是版本不兼容,不是代码问题。另一个常见误操作是在 IDEA 里把普通 Web 项目配成了 Spring Boot 启动方式,启动时报错误: 找不到或无法加载主类 org.jeecg.JeecgSystemApplication,后面跟NoClassDefFoundError: org/springframework/boot/web/servlet/support/SpringBootServletInitializer,这才意识到项目根本没引入 Spring Boot;这类报错只要回到 Edit Configurations 里改用 Tomcat 启动就不会出现。确认 JSP 是否更新时,直接看 Tomcat 工作目录下对应项目的org/apache/jsp文件夹,如果编译时间没变,说明容器缓存没刷新,Clean 一下项目再重启即可。
5.2 两个适用性最广的改造:连接池与库存盘点
第一处改造是把DBUtil里的DriverManager换成连接池。超市收银高峰时几十个并发请求同时getConnection(),直接连 MySQL 会反复创建物理连接,数据库默认连接数很快被打满。用 Druid 或 HikariCP 后,连接是池里复用的,初始化连接数设 5、最大连接数设 20,够一家小型超市用了。替换方式很简单:把DBUtil.getConnection()内部改成从DataSource取连接,DAO 层的try-with-resources不用动,连接池遵循同样的Connection接口语义。
第二处改造是补一张库存盘点表,并让盘点调整走和进货、销售一样的事务逻辑。老系统只靠进货和销售的加减库存,但漏录单据、退货未处理、商品损坏都会让账面和实物对不上。加一张stock_adjustment表,记录盘点时间、商品、差异数量和调整原因,UPDATE stock和INSERT adjustment放在同一个事务里,就能把库存账和流水账彻底对上。这样当你接到“给店长做一张每日销售汇总报表”的需求时,新增一个按天聚合的 DAO 查询,复用现成的连接池和事务边界,前面这几章打下的骨架就都派上用场了。
本文还有配套的精品资源,点击获取