ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

狂神说超市收银系统拆解:JavaWeb课程设计从表结构到并发扣库存

狂神说超市收银系统拆解:JavaWeb课程设计从表结构到并发扣库存 简介这是一套基于Java与JavaWeb技术的超市收银系统完整设计源码由狂神说团队开发主要面向Java初学者、Web开发学习者及需要快速搭建课设项目的在校生。系统采用MVC分层结构业务逻辑、数据访问与用户界面相互分离覆盖商品管理、收银结账、库存与销售数据统计等核心模块能帮助读者理解JavaWeb中Servlet、JSP、数据库交互和前后端协作的完整流程。压缩包共123个文件包含33个Java源文件、21个JSP页面、23个JavaScript和5个CSS样式文件以及18张PNG图片、9个XML配置、SQL初始化脚本和Maven的pom.xml配置整体约561KB内容紧凑、目录清晰便于直接导入IDE阅读和二次开发。资源内还附有.gitignore和LICENSE便于规范版本管理与开源使用src目录集中存放全部源码.idea目录收录开发配置层层分明。目前已有551人学习下载适合用来巩固JavaWeb编程技能也可作为课程设计或毕业设计的参考方案资源包体量小巧方便下载与存档可反复对照练习。1. 狂神说超市收银系统为什么课程设计里它总是被翻牌期末课程设计答辩前夜或者白天要交 JavaWeb 大作业的早上你大概率会搜到狂神说系列里这套超市收银系统。它是个标准的 Java JavaWeb 项目MySQL 存数据Servlet 处理请求JSP 渲染页面没有 Spring Boot 全家桶逻辑肉眼可见改动哪一行都知道后果。这套资源能直接帮你把“收银、商品管理、订单记录、登录权限”跑通适合课程设计、毕设起步也适合想搞懂 JavaWeb 请求链路的人。一点提醒它重在设计逻辑而非工程框架你要做的是把每一行代码读明白而不是解压就交差。下面我会从表结构、核心流程、部署踩坑到库存优化把这套源码拆开给你看。2. 数据库设计先行四张表把商品、订单、库存的边界划清楚2.1 为什么收银系统不需要第五张表我拆过不少课程设计源码最常见的毛病是表太多、关系太乱。这套系统的表设计走的是“够用且清晰”的路子核心就是四张表用户表、商品表、订单主表、订单明细表。订单主表和订单明细表为什么要拆开这是收银系统的关键设计。一张订单里可能买了三件商品如果只放一张表要么一行存三个商品名违背第一范式要么每件商品一行但把订单号、总价重复写三遍数据冗余。拆成两张表后orders记录“这一次收银”的整体信息——订单号、应收金额、收银员、支付方式order_item记录“这一次收银里具体买了什么”——商品名、单价、数量、小计。一对多关系账目才对得上。表名作用关键字段t_user收银员与登录账户id, username, password, real_namet_product商品档案与库存id, barcode, name, price, stockt_orders订单主表id, order_no, user_id, total_amount, pay_typet_order_item订单明细表id, order_id, product_id, name, price, quantitybarcode字段值得单独说。超市收银的第一动作是扫条码但课程设计里没有扫码枪所以条码实际上承担的是“商品唯一编码”的角色前端输入框里敲条码或者商品名后端走模糊查询。这也决定了后面 DAO 层要写两个查询方法一个按条码精确查一个按名称模糊查。2.2 建表脚本与初始化数据源码里的init.sql一般长这样我按常见写法还原了核心部分CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50) ) ENGINEInnoDB DEFAULT CHARSETutf8; CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8; CREATE TABLE t_orders ( 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, pay_type TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8; CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8;几个设计细节说清楚DECIMAL(10,2)是货币字段的标准选择别用FLOAT浮点算钱会出现 0.1 0.2 0.30000000000000004 这种尴尬password用 64 位是为了兼容 MD5 加盐后的长度虽然课程设计里很多人直接存明文但表结构先留够长度没坏处订单号order_no加了UNIQUE约束后续代码里生成订单号时就要处理重复冲突。初始化数据建议插入至少十条商品覆盖食品、饮料、日用品三个分类条码用纯数字比如6901234567890这种 13 位格式以后测试模糊查询时更有真实感。2.3 表设计的取舍为什么不做库存流水表我在第 2.1 节说了“四张表够用”但你可能想问库存变动不需要记录吗正规超市每笔出库都有流水但课程设计的评分点不在这里。如果加了t_stock_log每个结算动作要多插一条流水事务边界拉长出问题的概率翻倍。这套源码选择把库存扣减放在商品表里的stock字段上用UPDATE t_product SET stock stock - ? WHERE id ?直接改简单直观答辩时能把这条 SQL 讲清楚比堆一张流水表更实在。提示如果你打算在这个项目上做扩展往后加库存流水表没问题但建议先把核心的收银链路跑通再考虑审计需求。唯一要注意的是create_time这样的时间字段。源码里常见的写法是new Date()直接塞给 JDBC 的setTimestampMySQL 端用DATETIME接收。如果你用TIMESTAMP类型会有 2038 年问题和时区转换的潜在坑DATETIME 反而省心。3. 收银核心逻辑购物车、结算与库存扣减的事务边界3.1 购物车为什么放在 Session 里超市收银和电商购物车有个本质区别电商购物车要跨会话保存所以落库超市收银是“顾客拿商品到你面前你一次性扫完”操作窗口就几分钟不需要持久化。这套源码把购物车放在HttpSession里是正确且省事的设计。购物车的存取结构常见做法是这样的// CartItem 实体 public class CartItem { private Integer productId; private String name; private BigDecimal price; private Integer quantity; // 构造器、getter/setter 省略 }会话里存一个MapInteger, CartItemkey 是商品 IDvalue 是购物车条目。为什么用Map而不是List因为同一个商品扫两次条码应该合并数量而不是生成两行。用Map的话加购逻辑就是“先查 Map有则数量加一无则新建条目”代码写起来最顺手。SuppressWarnings(unchecked) MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } CartItem item cart.get(productId); if (item null) { item new CartItem(productId, name, price, 1); cart.put(productId, item); } else { item.setQuantity(item.getQuantity() 1); }这段逻辑的关键是Map的get和put先取出来判断是否存在存在就只改数量不存在才新建。参数productId是商品主键不是条码这里要注意 DAO 查询时把条码对应的商品完整信息查出来再进购物车购物车里就不要放条码了减少实体字段冗余。3.2 结算 Servlet事务从这里开始购物车只存在内存里一旦用户关浏览器就没了所以“结算”这个动作必须落库。源码里核心的CheckoutServlet流程是读购物车 → 算总价 → 生成订单主表 → 逐条插入订单明细 → 扣减库存 → 清空购物车。这里面藏着一个经典问题如果只扣库存订单写一半失败了怎么办比如订单主表插进去了明细表插到第三条时数据库连接断了结果是库存扣了但订单不完整。所以整个结算流程必须包在一个事务里。Servlet 层拿到Connection后先setAutoCommit(false)全部操作成功再commit()任何一步异常就rollback()。Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 生成订单号并插入订单主表 String orderNo generateOrderNo(); BigDecimal total calculateTotal(cart); insertOrder(conn, orderNo, userId, total, payType); // 2. 获取订单主表自增 ID遍历购物车插入明细 int orderId getGeneratedOrderId(conn); for (CartItem item : cart.values()) { insertOrderItem(conn, orderId, item); // 3. 扣减库存注意 WHERE 条件带了 stock ? int rows reduceStock(conn, item.getProductId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(商品库存不足: item.getName()); } } conn.commit(); session.removeAttribute(cart); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new ServletException(结算失败, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }逻辑说明setAutoCommit(false)关闭自动提交后续所有 SQL 都在同一个事务里执行rows 0说明UPDATE影响行数为零也就是库存不够主动抛异常触发回滚finally里把autoCommit恢复成true再关连接是避免连接池复用时把脏状态留给下一个请求。这里的reduceStock方法值得单独看String sql UPDATE t_product SET stock stock - ? WHERE id ? AND stock ?; PreparedStatement ps conn.prepareStatement(sql); ps.setInt(1, quantity); ps.setInt(2, productId); ps.setInt(3, quantity); int rows ps.executeUpdate();WHERE stock ?这个条件就是防止库存扣成负数。先查库存再扣库存的写法在并发下会超卖直接在UPDATE条件里带stock ?数据库层面就把这个问题挡住了。后面第 6 章我会展开讲怎么进一步用乐观锁加固。3.3 订单号生成时间戳加随机数订单号不需要全局唯一到分布式级别但要保证同一时刻两个收银员不会撞号。常见做法是时间戳加随机数public static String generateOrderNo() { SimpleDateFormat sdf new SimpleDateFormat(yyyyMMddHHmmss); String time sdf.format(new Date()); int random (int)((Math.random() * 9 1) * 1000); return time random; }(Math.random() * 9 1) * 1000生成的是 1000 到 9999 之间的四位随机数int强转后拼到时间后面。订单号是UNIQUE索引极端情况下撞号会抛DuplicateKeyException源码里一般直接让事务回滚重新生成。课程设计做到这步已经够了不用上雪花算法。4. 页面交互与订单流程从加购到小票展示的请求链路4.1 收银台的请求-响应链路这套系统的前端是 JSP不复杂但请求链路值得捋清楚答辩时这就是你的架构图。收银台页面cashier.jsp上有一个输入框和一个商品列表用户在输入框敲条码或商品名触发 JavaScript 发起异步请求到SearchProductServletServlet 调 DAO 查t_product表把命中的商品拼成 JSON 返回前端渲染到待结算列表里。整个过程是典型的“浏览器 → Servlet → DAO → MySQL → 回填 JSP”单向链路。没有 Spring MVC 那一层注解驱动每个 Servlet 要在web.xml里配 URL 映射每写一个功能就要动web.xml一次这恰恰是学习 JavaWeb 的好素材——你能看见请求是怎么一步一步被分发到具体类的。4.2 商品搜索接口的前后端配合前端我用原生XMLHttpRequest还原一下兼容性最好不用引 jQueryfunction searchProduct() { var keyword document.getElementById(keyword).value.trim(); if (keyword ) return; var xhr new XMLHttpRequest(); xhr.open(GET, searchProduct?keyword encodeURIComponent(keyword), true); xhr.onreadystatechange function () { if (xhr.readyState 4 xhr.status 200) { var products JSON.parse(xhr.responseText); renderProductList(products); } }; xhr.send(); }注意我加了encodeURIComponent商品名可能是中文不编码的话 GET 请求在部分 Tomcat 版本上会乱码。searchProduct是web.xml里配置的 Servlet URL 映射keyword是请求参数。后端 Servlet 里做的事情很简单拿参数 → 调 DAO 的searchByNameOrBarcode方法 → 把ListProduct转成 JSON 字符串写回响应。DAO 层的 SQL 是模糊匹配的常见写法String sql SELECT id, barcode, name, price, stock FROM t_product WHERE name LIKE ? OR barcode LIKE ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, % keyword %); ps.setString(2, % keyword %);LIKE ?配合%keyword%实现双向模糊。这里有个性能注意点前导通配符%xxx会让索引失效但这套项目的数据量在百级以内不需要纠结。如果以后商品表到了十万行这个查询就该换方案了。4.3 结算请求与订单小票展示点“结算”按钮后前端把购物车数据交给CheckoutServlet后端处理完事务后redirect到订单成功页。这里有个细节结算后用重定向而不是转发。如果用了forward用户按 F5 刷新页面浏览器会再次提交表单同一笔订单被插两次。sendRedirect会让浏览器发起全新的 GET 请求刷新也不怕。// CheckoutServlet 处理完结算后 response.sendRedirect(request.getContextPath() /orderSuccess?orderNo orderNo);重定向的orderNo参数带到订单成功页页面根据订单号查出订单主表和明细表拼出小票样式的表格。小票展示用 JSTL 的c:forEach遍历order_item页面上模拟超市小票的排版商品名左对齐、单价右对齐、数量居中、最底下加粗显示应收总额。c:forEach varitem items${orderItems} tr td${item.name}/td td styletext-align:center;${item.quantity}/td td styletext-align:right;${item.price}/td td styletext-align:right;${item.price * item.quantity}/td /tr /c:forEach tr td colspan3 styletext-align:right;合计/td td styletext-align:right;font-weight:bold;${totalAmount}/td /trJSTL 表达式里${item.price * item.quantity}能不能直接乘取决于 EL 版本。老 Tomcat 的 EL 2.2 之前不支持直接在表达式里做乘法如果页面报错就在后端先把subtotal算好放进实体类前端只做展示。这也是我建议order_item表加subtotal字段的原因——避免把计算逻辑散落在 JSP 里。4.4 登录与权限拦截收银系统不能让人随便打开就结账源码里通常有一个LoginFilter做登录校验。拦截器逻辑不复杂从 Session 里拿loginUser为空就跳转到登录页不放行任何业务请求非空就chain.doFilter继续往下走。public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); }这段代码有个小坑request.getSession(false)的false参数很关键。如果不传参数getSession()默认会创建一个新 Session结果是没登录的用户访问任意页面都会“被创建”一个会话虽然最终还是被拦截跳转但每次请求都白造一个无用 Session浪费内存还容易被面试官追问。getSession(false)表示“有就取没有就返回 null”配合后面的 null 判断逻辑才完整。5. 部署与常见问题排查Tomcat 版本、JDK 环境与 MySQL 8 的五条踩坑记录5.1 环境版本先对齐再谈跑起来拆这套源码之前先把环境版本看清楚。狂神说系列课程录制的年代主流组合是 JDK 8 Tomcat 9 MySQL 5.7/8.0这个组合最稳。如果你机器上装的是 JDK 17、Tomcat 10 或者 MySQL 5.5我会建议你装一个干净的版本组合而不是指望源码兼容你所有的“新环境”。组件推荐版本注意事项JDK8 或 11JDK 17 跑老 Tomcat 会报反射访问错误Tomcat9.0.x 以下Tomcat 10 包名是 jakarta.servlet源码不兼容MySQL5.7 或 8.08.0 驱动类名是 com.mysql.cj.jdbc.DriverIDEA2020导入项目时选 Eclipse 或默认选项都行数据库连接配置一般在db.properties或DBUtil.java里长这样jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/supermarket?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf-8 jdbc.usernameroot jdbc.password123456serverTimezoneAsia/Shanghai是 MySQL 8 必须加的不加会报The server time zone value异常。useSSLfalse是避免本机连接时 MySQL 8 默认启用 SSL 导致警告刷屏。5.2 五条高频踩坑记录坑一Tomcat 10 部署后所有页面 404控制台报 ClassNotFound现象源码在 Tomcat 8/9 上好好的换到 Tomcat 10 直接无法访问日志提示找不到javax.servlet.http.HttpServlet相关的类。原因Tomcat 10 从 Java EE 迁移到了 Jakarta EEServlet API 包名从javax.servlet改成了jakarta.servlet。源码里所有import javax.servlet.*全部失效。解决换回 Tomcat 9.0.x。不要手动改包名源码几十个文件改起来不现实改了可能还有隐形问题。这是最常见也是杀伤力最大的一个坑。坑二JDK 17 启动 Tomcat 时报非法反射访问现象Tomcat 启动到一半抛IllegalAccessError或者InaccessibleObjectException提示Unable to make field accessible。原因Tomcat 9 早期版本内部用了反射操作 JDK 内部类JDK 17 模块化后默认禁止强拆封装。解决把 JDK 降回 8 或 11。课程设计不需要 JDK 17 的新特性降版本是最省时间的路径。如果你非要用高版本 JDKTomcat 9.0.54 之后的版本修复了大部分问题但要重装 Tomcat 版本不如降 JDK 一步到位。坑三数据库连接报时区错误或驱动类找不到现象启动项目后访问任何需要查库的页面报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized或者ClassNotFoundException: com.mysql.jdbc.Driver。原因MySQL 8 的驱动类改名了com.mysql.jdbc.Driver是 5.x 时代的类名8.0 时代要用com.mysql.cj.jdbc.Driver同时 8.0 强制要求连接串里带时区参数。解决驱动类换com.mysql.cj.jdbc.DriverURL 加serverTimezoneAsia/Shanghai。顺带确认一下pom.xml或WEB-INF/lib里的mysql-connector-java版本是 8.x别让代码和依赖版本打架——我见过代码改对了但 jar 包还是 5.1 的折腾半小时才发现。坑四POST 提交中文正常GET 请求中文乱码现象结算后订单里的商品名显示正常但从搜索框用 GET 传中文关键词时后端拿到的是一串乱码。原因request.setCharacterEncoding(utf-8)只对请求体POST 表单生效GET 请求参数在 URL 里编码由 Tomcat 决定。Tomcat 8 以上默认 URI 编码是 UTF-8但如果server.xml里的Connector配置被改过或者你用了老版本 Tomcat默认就是ISO-8859-1。解决检查conf/server.xml里Connector port8080 URIEncodingUTF-8加上URIEncodingUTF-8重启。这是最彻底的修法不依赖前端是否编码。坑五库存扣成负数或者多人同时结算同一商品时库存错乱现象商品只有 10 件两个收银员同时结算各买 6 件最后库存变成 -2或者两个人都提示结算成功。原因代码是“先查库存再判够不够最后更新”三个步骤之间没有锁。A 查到 10 件B 也查到 10 件A 扣到 4 件B 拿着 10 继续扣成 -2。解决用第 3 章里那种UPDATE t_product SET stock stock - ? WHERE id ? AND stock ?的写法原子扣减影响行数为 0 就回滚。这是最轻量的方案后面第 6 章再讲加乐观锁的做法。注意前四个坑是“跑不起来”的问题第五个坑是“跑起来但数据错”的问题。课程设计答辩时第五个坑的解决方案是最容易拿分的点因为它触及了并发和数据一致性面试题也常考。5.3 部署到 Tomcat 的完整步骤源码导入 IDEA 后配置 Tomcat 的步骤应该是这样的先确保Project Structure里把源码目录标记为Sourcesweb目录标记为Web Resources然后在运行配置里新增 Tomcat ServerDeployment标签页点加号选Artifact把 war exploded 包署上Application context填/supermarket或保持根路径启动前手动建好数据库执行一遍init.sql。cd /usr/local/tomcat9/bin ./startup.sh tail -f /usr/local/tomcat9/logs/catalina.outLinux 上部署的同学会用到这三条命令。startup.sh启动后日志输出到catalina.outtail -f实时盯日志任何启动报错都能第一时间看到。Windows 上直接双击startup.bat但窗口关了就停了我会建议用catalina.bat run在前台跑日志直接打在控制台上排查环境问题比后台方式直观得多。6. 进阶给库存扣减加乐观锁把超卖问题堵死在 SQL 层第 5 章坑五里我提到了原子扣减那是悲观思路。这里给你一个更完整的升级方案在t_product表加一个version字段每次更新库存时把版本号带上这是教科书级的乐观锁写法。ALTER TABLE t_product ADD COLUMN version INT NOT NULL DEFAULT 0;更新操作变成两条要点stock扣减version加一但WHERE条件里必须带上旧的版本号。String sql UPDATE t_product SET stock stock - ?, version version 1 WHERE id ? AND stock ? AND version ?; PreparedStatement ps conn.prepareStatement(sql); ps.setInt(1, quantity); ps.setInt(2, productId); ps.setInt(3, quantity); ps.setInt(4, oldVersion); int rows ps.executeUpdate(); if (rows 0) { throw new RuntimeException(库存不足或商品已被修改请刷新后重试); }逻辑说明oldVersion是在事务开始前查出来的版本号比如 3。如果另一个请求已经把版本改成了 4那么带version 3的UPDATE影响行数为 0这个事务直接回滚避免了“拿旧数据覆盖新数据”的问题。stock ?仍然保留两道防线并存。乐观锁和原子扣减不是二选一而是双层保险。原子扣减防超卖乐观锁防丢失更新。把这个方案写进课程设计报告里“如何解决并发下的超卖问题”这一小节基本就是满分答案Java 面试里问超卖、问乐观锁、问 CAS你都能拿这个项目当实际案例讲比背八股文有说服力。我当年做这个项目时也以为“反正单机跑不会有并发”直到同学拿两个浏览器窗口同时结算同一个商品把库存干成了负数数据库里那笔躺着的 -2 看得我头皮发麻。从那以后我每次写库存扣减、余额扣减、优惠券核销这类“先查再改”的逻辑都强制走一遍受影响行数检查哪怕单机课程设计没有并发这个习惯也保留着。这套源码的价值就在于它把 JavaWeb 的主干链路完整地暴露在你面前——表设计、会话状态、事务边界、部署排错全都能对着代码讲清楚。希望帮到你。本文还有配套的精品资源点击获取
返回列表