Java超市管理系统:Servlet+DAO+MySQL构建进销存与事务实战

发布时间:2026/9/15 4:49:02
Java超市管理系统:Servlet+DAO+MySQL构建进销存与事务实战 简介基于Eclipse与MySQL开发的Java超市管理系统面向Java学习者、毕业设计者及零售信息化入门人员覆盖商品进销存、销售管理、采购跟踪、数据统计与管理员/收银员双角色权限控制等核心业务可帮助读者理解企业级JavaWeb项目的基本分层与数据库设计思路。压缩包共378个文件约10.37MB除了Java源码、JSP页面、class编译文件外还提供SQL/MySQL数据库脚本及jar依赖、XML配置并包含PNG图片、CSS/LESS样式和JavaScript脚本便于直接导入Eclipse运行和二次开发。资源内附有文档和目录结构能快速梳理从登录鉴权到结算下单的完整流程。目前已有2383人学习下载适合用作课程设计、毕业设计参考或小型超市管理系统的改造蓝本借助其中的销售排行、库存预警、毛利分析等统计模块也可深入了解MySQL多表关联查询和报表生成方法。1. 从收银台晚高峰说起这套 Java MySQL 超市系统到底在管什么晚高峰的收银台最怕的不是扫码慢而是结完账发现库存没扣减、会员积分没到账、晚上对账时销售流水和实际收款对不上。这套基于 Eclipse 与 MySQL 的 Java 超市管理系统把商品进销存、会员管理、供应商采购、双角色权限这几块容易出乱子的业务统一放进了数据库再用 Servlet 把操作串成完整链路。技术栈并不新——JDK Servlet JSP MySQL但业务闭环是完整的管理员进后台管供应商和采购、看统计报表收银员在另一个界面专注扫码结账。对于想理解 Java Web 业务系统代码怎么组织、表结构怎么设计才不怕对账的人来说这套源码是一个可以直接改动的活样本。2. Eclipse 下的分层骨架Servlet 控制层与 DAO 数据层的协作方式在源码里翻一圈最先看到的就是 UserDaoImpl、ProductDaoImpl、VipDaoImpl、ProviderDaoImpl 和 CheckoutServlet 这一组类。类名里的 Impl 后缀本身就是一种设计信号面向接口编程接口负责定义契约实现类负责写 SQL。Servlet 关心的是「请求进来做什么、做完跳去哪一页」而 SQL 怎么写、连接怎么打开和关闭全被压进 DAO 层。这套规矩一旦立住后面加功能就不会把 JSP 页面变成 SQL 泥潭。2.1 为什么是 Servlet DAO 而不是 JSP 直连数据库早年的 JSP 项目里经常看到把 JDBC 代码直接写在 JSP 里的写法% Connection conn ... %嵌套在 HTML 标签中间看起来直接但销售记录一多、查询条件一复杂页面就变成谁也改不动的大泥球。这套系统选择 Servlet DAO 分层核心是让职责边界清晰JSP 只负责渲染数据Servlet 负责接收参数和分发请求DAO 层独占数据访问。好处很实际——将来要把 MySQL 换成别的数据库或者把 JDBC 换成 MyBatis改动范围被限制在 DAO 层页面完全不用碰。有人会把 DAO 简单理解成「写 SQL 的地方」这其实低估了接口的价值。DAO 的粒度应该对应完整的业务数据动作而不是 SQL 语句。比如findByBarcode(String barcode)是收银台扫码的精确查询findLowStock(int threshold)是后台库存预警的列表查询两个方法都叫 ProductDao 的成员但服务的业务场景完全不同。接口把这两类动作固化下来实现类怎么改都影响不到上层调用。2.2 DAO 接口与实现的拆分逻辑先看一个典型的商品 DAO 接口定义public interface ProductDao { // 新增商品返回受影响行数 int insert(Product product); // 根据条形码精确查询收银台扫码结算时用 Product findByBarcode(String barcode); // 分页查询商品列表后台管理列表页用 ListProduct findByPage(int offset, int limit); // 更新库存delta 为正表示入库为负表示销售出库 int updateStock(int productId, int delta); // 查询库存低于阈值的商品用于库存预警 ListProduct findLowStock(int threshold); }ProductDaoImpl 实现类的工作就是把每个接口方法翻译成 JDBC 操作。有个设计细节值得留意updateStock接收的是增量 delta而不是最终的库存绝对值。收银台和后台有多个入口会改动库存传增量配合 SQL 里的stock stock ?原子更新天然避免了并发覆盖。两个收银员同时结账时如果各自传「当前库存减一」后的绝对值后提交的会覆盖先提交的结果账就对不上了。Eclipse 里查看这套代码时有两个快捷键比较实用CtrlShiftT输入类名直接跳转F4看类型层次结构。调试 ProductDaoImpl 时在 SQL 执行行打上断点Variables 窗口能直接看到 PreparedStatement 的绑定参数比打日志快得多。如果断点根本没进实现类多半是 DAO 对象用 new 直接创建的而某个工厂类用了反射——后者在 Tomcat 热部署后偶尔会抛 ClassNotFoundException。2.3 Servlet 在中间层如何承接请求与分发Servlet 扮演的是翻译官角色。管理员在列表页点「编辑」按钮浏览器发出/product/edit?id12请求ProductEditServlet 的 doGet 里就三步从 request 取参数、调 ProductDao.findById(12) 拿到对象、setAttribute 放进 request 再 forward 到 product_edit.jsp。整个过程不写 SQL页面里也不拼 HTML。这套系统里每个 Servlet 的职责都挺收敛没有出现一个 Servlet 包揽所有业务的情况。CheckoutServlet 是比较有代表性的一个因为结算动作涉及多表联动查询商品、校验库存、写销售记录、扣减库存、更新会员积分。这五步串在一个 Servlet 方法里配合数据库事务才能保证最终一致。如果把每一步拆到不同 Servlet中间任何一步挂了都会留下「钱收了但库存没扣」的脏数据。下面的表列了这套系统中主要 Servlet 与 DAO 的对应关系拿到源码后可以按图索骥Servlet / 功能点对应 DAO主要业务动作LoginServletUserDaoImpl用户名密码校验、Session 写入CheckoutServletProductDaoImpl / VipDaoImpl扫码结算、库存扣减、会员积分ProductManageServletProductDaoImpl商品增删改查、分页列表ProviderManageServletProviderDaoImpl供应商信息维护、采购记录VipManageServletVipDaoImpl会员开卡、积分查询与调整有一点想提醒CheckoutServlet 用单个 Servlet 扛整个结算链路代码组织上集中但高并发下会成瓶颈。后续改造时优先把扣库存抽成独立方法配合SELECT ... FOR UPDATE或乐观锁版本号控并发而不是在 Servlet 方法上加 synchronized——Tomcat 集群下 synchronized 只对当前 JVM 生效多节点部署时不解决问题。3. MySQL 表设计与进销存核心数据流这套系统的数据量级体量再大的超市单日也就几万条流水MySQL 完全扛得住。难点在于表之间的关联要能支撑三种查询方向按商品看「卖了多少、还剩多少」、按订单看「这一单收了多少钱、利润多少」、按时间看「什么时候该补货」。这三类问题对应着主数据表、订单表和流水表的分工理解清楚这个看源码里的 SQL 就不会懵。3.1 商品、供应商、会员三类主数据的关系建模商品表是这个系统的核心主数据。条形码 barcode 加了唯一约束对应收银台的扫码动作。价格和成本价分开存卖价用 price成本用 cost_price毛利润分析就是靠这两个字段的差值算出来的。供应商通过 provider_id 和供应商表关联这里用外键约束但实际项目里很多人习惯不加物理外键只保留逻辑关联——原因后面再说。商品表核心字段如下CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NOT NULL UNIQUE, -- 条形码扫码结算的唯一依据 name VARCHAR(64) NOT NULL, category VARCHAR(32), price DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 零售价 cost_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 进价算毛利用 stock INT NOT NULL DEFAULT 0, low_stock_threshold INT NOT NULL DEFAULT 10, -- 低于这个值就预警 provider_id INT, -- 逻辑关联 providers.id created_at DATETIME DEFAULT CURRENT_TIMESTAMP );DECIMAL(10,2) 而不是 FLOAT是这套代码里值得夸的一个选择。浮点数的二进制表示导致 0.1 0.2 不等于 0.3钱在这种类型上会出大问题。DECIMAL 是定点数按十进制存储计算金额用它才靠谱。price 和 cost_price 分开两列销售排行按 subtotal 聚合毛利润报表按(price - cost_price) * quantity算两条统计路径都不必回查供应商表。会员表 Vip 和用户表 User 是两个容易混淆的概念。User 是能登录系统的人管理员或收银员Vip 是超市的持卡会员。登录认证走 User 表结算时按手机号或卡号查 Vip 表两者的生命周期完全不同。这套系统的 VipDaoImpl 里有条findByCardNo方法是收银台查询会员的老入口。3.2 销售与采购订单主表加明细表的结构商品卖出去光知道「哪个商品少了一件」不够还得知道「这一单是什么时候卖的、谁收的钱、会员有没有积分」。所以销售数据拆成订单主表和订单明细表两级。主表存一次结算的汇总信息明细表存这一单里的每件商品。这张表可以直接在 MySQL 里试跑CREATE TABLE sale_orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, -- 订单号打印在小票上 cashier_id INT NOT NULL, -- 收银员ID关联 users.id vip_id INT NULL, -- 会员ID非会员结算为 NULL total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 折扣前总额 discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 折扣金额 final_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 实收金额 sale_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sale_order_items ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, -- 关联 sale_orders.id product_id INT NOT NULL, -- 关联 products.id quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL, -- 成交单价防止商品改价后历史记录失真 subtotal DECIMAL(10,2) NOT NULL -- quantity * unit_price );这里最容易被忽视的是 sale_order_items 里的 unit_price。如果结算时只记录 product_id 和 quantity不存当时的单价那商品调价之后再看历史订单毛利统计就全错了。把单价冗余进明细表查询时不需要回商品表报表性能也更好。单表数据量上来之后这类订单表通常按月或按年分区但那是后话。3.3 用 JDBC 实现 ProductDaoImpl 的典型查询ProductDaoImpl 里会看到两类查询风格。列表页查询适合用普通 PreparedStatement跑findByPage时用数据库分页public ListProduct findByPage(int offset, int limit) { String sql SELECT id, barcode, name, price, cost_price, stock FROM products ORDER BY id DESC LIMIT ?, ?; ListProduct list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, offset); ps.setInt(2, limit); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Product p new Product(); p.setId(rs.getInt(id)); p.setBarcode(rs.getString(barcode)); p.setName(rs.getString(name)); p.setPrice(rs.getBigDecimal(price)); p.setCostPrice(rs.getBigDecimal(cost_price)); p.setStock(rs.getInt(stock)); list.add(p); } } } catch (SQLException e) { // 实际项目中这里应记录日志并转成业务异常 e.printStackTrace(); } return list; }代码里 LIMIT ?, ? 两个占位符MySQL 的 PreparedStatement 支持直接把 int 参数绑进去执行。如果是在 Oracle 上写同样的分页语法就变成OFFSET ? ROWS FETCH NEXT ? ROWS ONLY这也是为什么 SQL 都收敛在 DAO 层——换数据库只改实现类。第二个细节是 try-with-resources 自动关闭 Connection、PreparedStatement 和 ResultSet这是 JDBC 7 以后的标准写法省掉的坑包括连接泄漏和 ResultSet 未关闭导致的游标耗尽。库存扣减的 SQL 在进销存系统里讲究原子性用一条 UPDATE 完成检查和修改UPDATE products SET stock stock - ? WHERE id ? AND stock ?这条语句返回的受影响行数是 1 才说明扣减成功返回 0 说明库存不足业务层拿到这个结果直接终止结算。很多人习惯先 SELECT 查库存判断够了再 UPDATE这中间隔着的几毫秒就是并发穿墙的窗口。把条件写进 UPDATE 的 WHERE 子句数据库层面一次锁住检查和修改这个思路在整个系统的存货变动代码里反复出现。4. 收银员与管理员双角色会话控制与页面隔离权限设计是这套系统区分「能看报表的人」和「只能收银的人」的关键。收银员页面只有扫码、选会员、收款几个入口管理员页面才有采购、统计、员工管理。这种隔离不能只靠「把链接藏起来」更要在 Servlet 入口处设一道闸放行或不放行由会话状态决定。4.1 LoginServlet 的登录校验与 Session 标记登录逻辑在 LoginServlet 里核心是三步取参数、查 UserDao、写 Session。注意密码比较没有用明文拼接 SQL而是走 PreparedStatement——不只是防 SQL 注入还避免用户名里带单引号时直接语法报错。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); UserDao userDao new UserDaoImpl(); User user userDao.findByUsernameAndPassword(username, password); if (user ! null) { // 登录成功后把用户和角色放进 Session后续请求靠它识别身份 HttpSession session request.getSession(); session.setAttribute(loginUser, user); session.setAttribute(role, user.getRole()); // 1 管理员2 收银员 if (1.equals(user.getRole())) { response.sendRedirect(request.getContextPath() /admin/index.jsp); } else { response.sendRedirect(request.getContextPath() /cashier/index.jsp); } } else { request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(login.jsp).forward(request, response); } }这里有个细节值得注意登录失败用的是 forward 而不是 sendRedirect。forward 是服务器内部跳转浏览器的 URL 不会变登录页还能通过 request 域显示 errorMsg。如果改用 sendRedirect消息带不过去还得塞进 Session 再清掉绕一圈没必要。Session 里的 role 是字符串而不是布尔值是为了以后扩展角色时不必改数据结构。4.2 用过滤器实现角色隔离Servlet 规范里过滤器Filter就是为这类横切逻辑准备的。在 web.xml 里把/admin/*路径映射到一个角色校验过滤器拦截发生在 Servlet 执行之前。它的判断逻辑很简单Session 里没有 loginUser或者 role 不是管理员直接重定向到登录页根本不给进入后台的机会。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); String uri request.getRequestURI(); String ctx request.getContextPath(); // 放行登录页和静态资源其余 /admin/ 请求必须管理员身份 if (uri.equals(ctx /login.jsp) || uri.startsWith(ctx /css/)) { chain.doFilter(req, resp); return; } if (user null || !1.equals(user.getRole())) { response.sendRedirect(ctx /login.jsp); return; } chain.doFilter(req, resp); }通过这套过滤器的代码可以理解为什么权限校验不该写在 JSP 页面里。页面级的% %判断是请求到达 JSP 之后才执行的但很多 JSP 本身包含了业务数据的初始化代码比如从数据库加载店铺信息这些代码会在判断之前就执行。更重要的是页面判断散落在每个 JSP 里改一条权限规则要动几十个文件过滤器只改一处就能覆盖整个目录。4.3 CheckoutServlet 结算流程的事务边界结算链路涉及多张表写入最怕写到一半断掉。查商品、扣库存、写订单、加积分每一步之间都有依赖关系。把这五步放进同一个数据库事务里任何一步失败整体回滚才能保证不会产生「库存扣了但订单没生成」这类脏数据。典型的写法是拿到一个 Connection手动关闭自动提交public void checkout(CheckoutRequest req) throws Exception { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 开启手动事务 // 1. 查商品并锁定该行防止并发同时操作同一商品 Product product productDao.findByBarcodeForUpdate(req.getBarcode(), conn); if (product.getStock() req.getQuantity()) { throw new BusinessException(库存不足当前剩余 product.getStock()); } // 2. 插入销售主单拿到自增主键 SaleOrder order new SaleOrder(); order.setCashierId(req.getCashierId()); order.setFinalAmount(req.getFinalAmount()); int orderId saleOrderDao.insertOrder(order, conn); // 3. 插入销售明细 saleOrderDao.insertOrderItem(orderId, product.getId(), req.getQuantity(), product.getPrice(), conn); // 4. 条件更新扣减库存返回 0 说明被并发抢了 int updated productDao.updateStock(product.getId(), -req.getQuantity(), conn); if (updated 0) { throw new BusinessException(库存扣减失败请重试); } // 5. 更新会员积分 if (req.getVipId() ! null) { vipDao.addPoints(req.getVipId(), req.getFinalAmount().intValue(), conn); } conn.commit(); // 全部成功才提交 } catch (Exception e) { conn.rollback(); // 任何一步失败本次操作全部撤销 throw e; } finally { conn.setAutoCommit(true); conn.close(); } }findByBarcodeForUpdate是这套代码里值得注意的方法它执行的是SELECT ... FOR UPDATE在事务内锁定商品行。两个收银员几乎同时扫同一件商品时第二个请求会等第一个事务提交或回滚之后才读到新库存。updateStock返回受影响行数为 0 的情况代表行锁等待结束后库存已被别的线程扣光业务层直接抛异常停止本单。事务的粒度在这段代码里是「一次完整结算」不是单个 SQL这点容易在改代码时被破坏。5. Eclipse Tomcat 部署联调中的三个典型问题把项目从压缩包里的源码变成能在浏览器访问的系统这一路比想象中容易出问题。这里列三个我在装配这套环境时最常遇到的坎每个都给出排查路径。5.1 找不到或无法加载主类 org.apache.catalina.startup.Bootstrap这个报错在 Eclipse 里最常见的原因是服务器运行时Server Runtime没配好但还有一个很多人都没注意过的角度——项目使用的 JDK 编译版本与 Tomcat 运行的 JRE 版本不一致。Tomcat 8.5 默认要求 Java 7 以上如果你用 JDK 17 编译出的 class 文件放到了以 Java 8 运行的 Tomcat 里Bootstrap 会直接抛出 UnsupportedClassVersionError。排查时先确认三处版本对齐java -version javac -version # Eclipse 里 Window Preferences Java Compiler 配置 catalina.bat version # Tomcat bin 目录下执行Windows 环境第二个常见路径是 Tomcat 的 lib 目录下的 servlet-api.jar 与项目里塞进的同名 jar 产生冲突。Eclipse 的 Java Build Path 里如果手动加了 servlet-api.jar部署时它会出现在 WEB-INF/lib 下与 Tomcat 自带的一份重复加载导致类加载器判定失败。标准做法是把这个 jar 的 scope 设为 provided 或直接从构建路径移除只在 Server Runtime 里引用。5.2 MySQL 8 驱动版本与经典连接串参数这套系统如果连的是 MySQL 5.x驱动类名是com.mysql.jdbc.Driver到 MySQL 8 后换成com.mysql.cj.jdbc.Driver同时必须带上时区参数否则连接直接报The server time zone value错误。一个兼容多版本 MySQL 的连接串写法如下jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/supermarket?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.password123456useSSLfalse的作用是关闭 SSL 加密连接本地开发时省去证书配置生产环境应该去掉或开启。characterEncodingutf8必须放在 url 参数里而不是 driver 属性里保证插入的商品名称中文不乱码。如果你的 MySQL 是 8.0.4 以上且没用 utf8mb4遇到表情符号写入报错就检查数据库端建表字符集连接串上的 utf8 无法覆盖。5.3 用 SQL 验证结算后库存和数据统计的准确性界面操作完别急着说「跑通了」用三条 SQL 对账比点十遍页面都管用。假设收银员刚才完成一笔购买 2 件商品的结算先查结算前后库存变化-- 对比结算前后的 products.stock再和销售明细核对件数 SELECT p.barcode, p.name, p.stock, oi.quantity FROM sale_order_items oi JOIN products p ON oi.product_id p.id WHERE oi.order_id ( SELECT MAX(id) FROM sale_orders -- 最近一笔订单 ); -- 验证订单金额实收 明细求和 - 折扣 SELECT o.id, o.final_amount, SUM(oi.subtotal) AS items_sum, o.final_amount - SUM(oi.subtotal) AS diff FROM sale_orders o JOIN sale_order_items oi ON o.id oi.order_id WHERE o.id (SELECT MAX(id) FROM sale_orders) GROUP BY o.id, o.final_amount;第二条 SQL 里 diff 是 0.00 才说明金额链路没有断。如果 diff 不为 0多半是 CheckoutServlet 里组装订单金额时用了前端传回的参数而不是服务端重新按明细求和——这是一个经典的接口信任边界问题。最后验证统计报表口径SELECT p.name, SUM(oi.quantity) AS qty, SUM(oi.subtotal) AS turnover, SUM((p.price - p.cost_price) * oi.quantity) AS gross_profit FROM sale_order_items oi JOIN products p ON oi.product_id p.id JOIN sale_orders o ON oi.order_id o.id WHERE o.sale_time CURDATE() - INTERVAL 7 DAY GROUP BY p.id, p.name ORDER BY qty DESC LIMIT 10;gross_profit里的p.cost_price取的是当下商品的成本价如果商品中途进过货且成本变了严格做法是采购时就把成本冗余进库存流水表报表按当时成本算。这套系统里是用商品表当前值近似小规模超市够用但知道这个边界在哪里更重要。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询