ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JSP+Servlet+JavaBean实战:超市进销存系统架构与部署指南

JSP+Servlet+JavaBean实战:超市进销存系统架构与部署指南 简介基于JSPServletJavaBean的超市进销存管理系统源码专为Java Web学习者、高校课程设计及毕业设计准备覆盖超市商品档案、用户权限、进货、销售、库存统计等核心业务采用经典的JSPServletJavaBean分层设计便于理解请求处理、业务逻辑与数据访问的分离。压缩包内共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、ModelJSP 把库存数字变成表格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 联系电话 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;销售单sale_order/sale_item与进货单结构同构这里不重复建。这套设计里有两个关键点值得专门说。第一库存单独成表而不是在product表里加qty字段因为库存是“当前状态”单据表记录的是“历史流水”把状态和流水混在同一张表里月底要对账时无从下手。第二价格一律用DECIMAL(10,2)不要贪方便用FLOAT或DOUBLE二进制浮点算总价时会出现 0.10.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.Driver8.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 ?useUnicodetruecharacterEncodingUTF-8 serverTimezoneAsia/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 里那三个参数一个都不能少useUnicodetrue和characterEncodingUTF-8保证中文商品名不乱码serverTimezoneAsia/Shanghai是 MySQL 8.x 的硬性要求缺了会报时区错误。密码写死在常量里对课设没问题生产环境要改成从配置文件读取这个后面会提。接下来是商品查询的 DAO 方法注意 SQL 拼接和参数绑定的差别。public ListProduct findByKeyword(String keyword) { String sql SELECT id, code, name, spec, price FROM product WHERE name LIKE ? OR code LIKE ?; ListProduct 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-nameaddProduct/servlet-name servlet-classcom.example.web.AddProductServlet/servlet-class /servlet servlet-mapping servlet-nameaddProduct/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, ListPurchaseItem 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 contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table border1 tr th条码/th th名称/th th库存/th th零售价/th th出库/th /tr c:forEach items${stockList} varrow tr td${row.code}/td td${row.name}/td tdc:if test${row.stockQty 5}低库存!/c:if${row.stockQty}/td td${row.price}/td td form methodpost action${pageContext.request.contextPath}/sale input typehidden nameproductId value${row.id}/ input typenumber nameqty min1 value1/ button typesubmit出库/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 10JSP 报 404 或编译失败Tomcat 8.5 / 9.x配 JDK 8JSTL 依赖页面报NoClassDefFoundError: javax/servlet/jsp/jstl/core把jstl-1.2.jar放进WEB-INF/libJDBC URL中文乱码、时区异常带上useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiTomcat 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 查询复用现成的连接池和事务边界前面这几章打下的骨架就都派上用场了。本文还有配套的精品资源点击获取
返回列表