
简介一份基于 Java 的超市购物系统完整项目资源包面向 Java 初学者、课程设计或毕业设计开发者涵盖从业务编码、数据库设计到项目文档撰写的完整闭环。压缩包体积仅 2.48MB共包含 165 个文件31 个 java 源文件与对应 class 文件便于对照学习jar 依赖库保障环境搭建mdf/ldf 数据库文件可直接附加使用doc/xls/txt 文档及 png 界面截图则用于理解系统结构。内容覆盖商品管理、订单处理、库存出入库、结算统计等核心模块并配有需求分析、设计说明、数据库表结构、API 说明、用户手册及测试记录等多类文档能帮助读者快速理清 MVC 分层、Java 与数据库交互的实现思路。已有 349 人学习下载适合需要完整范例快速上手超市类管理系统的开发者。通过源码、数据库与文档三者的配合可大幅缩短项目开发与文档编写时间尤其适合作为毕业设计或期末项目的参考蓝本。1. java超市购物系统不只是课设/毕设还是一次完整的数据库与JavaCRUD实战在搜索引擎里输入“java超市购物系统”这个完整标题的人我猜多半是正处于课设阶段或者马上要交毕设初稿。这个项目名看起来很像典型的“增删改查练习”但它真正考验你的不只是写几个 Java 接口而是三件事数据库表怎么设计、下单扣库存这类事务怎么保证不出错、以及“详细的文档”能不能从数据库字段一直讲清楚到接口调用。把这三件事做扎实答辩时你腰杆是硬的只把页面跑出来遇到“库存为什么超卖”“购物车数据存哪”这类问题就会卡壳。这篇我会按我自己复现这类项目时常用的方案从技术选型讲到建表、写代码、排坑最后给你一套能拿得出手的验证方法。适合正在做课设、毕设或者想拿一个完整 Java 全栈项目练手的从业者。2. 技术选型和项目骨架Spring Boot MySQL 的组合为什么是首选2.1 为什么不用 JSPServlet而选 Spring BootMyBatis不少教材和课程设计样例还在用 JSP Servlet JDBC 三件套代码写在 JSP 页面里数据库连接用 DriverManager 手工管理。那种写法不是不能运行而是把“业务逻辑、数据访问、页面展示”揉在一起后续每加一个功能都要改一堆文件。现在答辩老师看项目普遍更认可 Spring Boot 前后端分离这种结构。Spring Boot 内嵌 Tomcat不需要单独部署 Web 容器一个 java -jar 就能起来配合 MyBatisSQL 写在哪、Java 方法写在哪都很清楚MySQL 则是成本最低、资料最多的开源关系型数据库网上随便搜“mysql数据库常用命令”基本都能找到答案。我一般会把依赖选成 Spring Boot 2.7.x MyBatis-Plus MySQL 8.0。MyBatis-Plus 比纯 MyBatis 多了一个好处单表的增删改查不用手写 SQLBaseMapper 里自带 insert、updateById、selectPage。超市购物系统的用户表、购物车表、订单表大部分操作都是单表增删改查能省下大量写 XML 的时间把精力放在订单事务这种真正的难点上。2.2 一份能直接跑起来的目录结构一个前后端分离的超市购物系统后端目录长这样supermarket-shopping/ ├── pom.xml └── src/main/ ├── java/com/example/supermarket/ │ ├── SupermarketApplication.java # 启动类 │ ├── config/ │ │ ├── CorsConfig.java # 跨域配置 │ │ └── MybatisPlusConfig.java # 分页插件 │ ├── controller/ # 接口层登录、商品、购物车、订单 │ ├── service/ # 业务层事务都放在这层 │ ├── mapper/ # MyBatis-Plus 的 Mapper 接口 │ ├── entity/ # 对应数据库表的实体类 │ └── common/ # 统一返回结果、异常处理、JWT工具类 └── resources/ ├── application.yml # 数据源、端口、日志配置 └── mapper/ # 复杂 SQL 的 XML 文件前端我通常用 Vue 2 Element UI因为课设/毕设要求不高Element UI 的表格和表单组件能让你一天之内把页面搭出来。相比 ReactVue 对中文资料和组件生态更友好遇到问题搜起来快。这个目录结构不是死的但有个原则controller 里不写业务逻辑service 里不直接操作数据库细节mapper 只管数据访问。答辩时老师问你“某段业务在哪”你能直接说出路径这个印象分很重要。2.3 启动流程与环境配置从 JDK 到 Maven 镜像拉下来项目第一件事不是看代码而是先把环境打通。JDK 版本必须和 pom.xml 里配置一致我见过太多因为 JDK 8 和 JDK 17 混用导致编译失败的情况。如果你还在装环境直接搜“java环境变量配置详细教程”把 JAVA_HOME 和 PATH 配好。Maven 建议在 settings.xml 里配阿里云镜像否则 spring-boot-starter-web 这一家老小的依赖能下载半小时。这是实操里最挫败的一步实话说没什么技术含量就是先配好。# 配置好环境后进入项目根目录 mvn clean package -DskipTests # 启动后端服务 java -jar target/supermarket-0.0.1-SNAPSHOT.jar参数说明clean会把旧的 target 目录清掉避免打进去过期的 class 文件package把项目打成可执行 jar-DskipTests跳过测试课设项目里测试不完善强行跑测试反而会因环境问题失败。启动后看到Started SupermarketApplication日志再访问http://localhost:8080验证。如果端口被占用改 application.yml 里的server.port或者把占用进程结束掉这类问题在第 5 章我会细讲。在 application.yml 里数据库连接这块是最容易出错的直接决定能不能连上数据库spring: datasource: url: jdbc:mysql://localhost:3306/supermarket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driverurl 里characterEncodingutf8解决中文乱码serverTimezoneAsia/Shanghai解决 MySQL 8 和 Java 的时区问题。这两个参数不加项目能跑起来但存中文会乱码、时间字段会差 8 小时都属于“看起来不是问题但迟早踩中”的坑。密码字段我建议从环境变量读取至少不要硬编码真实密码提交到 GitHub。3. 数据库设计是“包含数据库”的核心五张表与三处边界3.1 用户表与商品表字段类型和索引的增删改查边角“包含数据库”这四个字在很多课设/毕设里被理解成了“有个 .sql 文件能导入”。实际上数据库设计的合理性比有没有 SQL 文件更重要。超市购物系统的核心是用户、商品、购物车、订单、订单明细这五张表。先看最基础的两张CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL COMMENT 登录名, password varchar(128) NOT NULL COMMENT BCrypt加密后的密码, role varchar(20) NOT NULL DEFAULT customer COMMENT admin/customer, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, name varchar(128) NOT NULL COMMENT 商品名称, price decimal(10,2) NOT NULL COMMENT 售价, stock int NOT NULL DEFAULT 0 COMMENT 库存, category varchar(32) DEFAULT NULL COMMENT 分类, image_url varchar(255) DEFAULT NULL COMMENT 图片路径, PRIMARY KEY (id), KEY idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表;有两个容易被忽略的细节。第一价格字段必须用decimal(10,2)不能用 float 或 double。二进制浮点数存金额会有精度误差比如 0.1 0.2 在计算机里不等于 0.3 这个基础问题金额一旦出现这种误差对账会很难看。第二username加唯一索引因为用户名不能重复这比在 Java 代码里先查一遍再插入更可靠——并发注册时代码判断可能漏掉数据库的唯一索引是最后一道防线。“数据库增删改查”这个词在这里就有了落点单表操作靠 MyBatis-Plus 的 BaseMapper你只写一个extends BaseMapperUser的接口就能获得 insert、deleteById、selectById、updateById。字段多、SQL 复杂的时候再落到 XML 里手写。3.2 购物车表数据库存还是 Redis 存先想清楚这个问题购物车有两种存法存数据库表或者存 Redis。很多课设项目直接砍掉购物车表把状态塞在订单里这其实是偷懒。标准做法是一张独立的购物车表CREATE TABLE cart_item ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, product_id bigint NOT NULL COMMENT 商品ID, quantity int NOT NULL DEFAULT 1 COMMENT 数量, checked tinyint(1) NOT NULL DEFAULT 1 COMMENT 是否选中, UNIQUE KEY uk_user_product (user_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT购物车条目表;uk_user_product这个联合唯一索引很关键同一个用户对同一个商品购物车里只允许有一行。用户反复点击“加入购物车”Java 代码先按 user_id 和 product_id 查有就 update quantity 加一没有就 insert。这个逻辑属于典型的“先查再改”并发下可能有重复插入的隐患但购物车场景并发极低课程设计阶段用一个唯一索引兜底已经足够。不做 Redis 的理由也很简单Redis 的购物车数据是内存态重启就丢你还得额外维护另一套持久化方案。超市购物系统这种体量一张 MySQL 表完全扛得住。等你有 10 万用户再把购物车迁到 Redis 缓存那属于架构演进的话题了。3.3 订单表与订单明细表价格为什么必须冗余下单是超市购物系统最核心的动作涉及两张表订单主表和订单明细表。订单主表存一次购买行为的总价、状态、下单时间订单明细表存这个订单里每个商品的信息。两张表设计成什么样直接决定你的下单代码好不好写。CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号全局唯一, user_id bigint NOT NULL, total_amount decimal(10,2) NOT NULL COMMENT 订单总价, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; CREATE TABLE order_detail ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, product_id bigint NOT NULL, product_name varchar(128) NOT NULL COMMENT 商品名称快照, price decimal(10,2) NOT NULL COMMENT 下单时单价快照, quantity int NOT NULL COMMENT 购买数量, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;我特意在 order_detail 里加了product_name和price两个“快照”字段。原因是商品表里的价格和名称是随时可变的——今天搞促销改价明天商品改叫“新版薯片”如果订单明细只存 product_id到时候你查历史订单价格和商品名都对不上。这种冗余在数据库设计里不是脏设计而是业务需要。在写文档时把这两行注释写清楚答辩老师会一眼看出你理解“订单是状态快照”这件事。订单号也不要直接用自增 id。自增 id 容易暴露销量而且多表操作时订单号不像业务号。常见的做法是年月日 时间戳 用户id后四位或者直接用 UUID我这里用order_no字符串加唯一索引就是为了防止并发生成重复号。下一章我会给出 Java 生成订单号的代码。4. Java 与 MyBatis 落地业务登录鉴权、分页查询、下单扣库存4.1 登录鉴权JWT 的思路和最小实现前后端分离的项目Session 方案会在跨域和移动端场景下遇到一堆坑Session 存在服务器内存里前端下次请求必须带上同样的 Cookie这在浏览器里还能自动带但在小程序或 App 里未必配合。JWT 的思路是把一份带签名信息的 token 发给前端前端每次请求把它放在 header 的 Authorization 字段里后端验签即可不需要存储登录态。这个方案对超市购物系统这种项目最合适。// JwtUtil.java 核心方法生成 token 和解析 token public class JwtUtil { private static final String SECRET supermarket-demo-secret; public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setExpiration(new Date(System.currentTimeMillis() 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }参数说明setSubject把用户 id 放进 token 里后续任何接口想拿当前用户直接从 token 里解析claim用来附带非敏感信息我一般只放 usernamesetExpiration设置 24 小时过期课设场景这个时长合适太短会导致用户频繁重新登录。这个类的依赖是 jjwtpom.xml 里加jjwt-api和jjwt-impl两个依赖。SECRET 在真实项目里不能写死在代码中课程设计不涉及生产安全但文档里可以强调一声。登录接口本身很直白先按用户名查出用户再用 BCrypt 比对密码不要把密码用明文比对。BCrypt 是 Spring Security 里自带的类每次加密结果不同但matches方法能正确校验。这样做即使数据库泄露密码拿到的也是哈希串。4.2 商品分页与关键字检索MyBatis-Plus 的 Page 对象怎么用超市购物系统的商品列表必然要分页否则几百条商品全部返回前端渲染会卡数据流量也浪费。MyBatis-Plus 的分页要配置一个分页插件才能生效普普通通直接传 Page 对象是查不出 limit 的——这是新手最容易忽略的一步。// MybatisPlusConfig.java Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }// ProductService.java 商品分页查询 public IPageProduct pageProducts(int pageNum, int pageSize, String keyword) { PageProduct page new Page(pageNum, pageSize); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.like(Product::getName, keyword); } wrapper.orderByDesc(Product::getId); return productMapper.selectPage(page, wrapper); }逻辑说明Page对象的第一第二参数是页码和每页大小LambdaQueryWrapper是 MyBatis-Plus 的条件构造器like会生成WHERE name LIKE %keyword%orderByDesc生成按 id 倒序。用 Lambda 表达式写字段名编译期就能检查字段是否存在比字符串name更安全。selectPage执行完后返回的 IPage 对象里带了 total 总数、pages 总页数和当前页 records 列表前端表格分页条直接喂这些字段就行。分页查询有几个常见翻车点。第一pageNum 从 1 开始但 MySQL 的 offset 是(pageNum - 1) * pageSizeMyBatis-Plus 内部做了处理你不用管但自己写 SQL 时很容易算错。第二关键字搜索要加StringUtils.hasText判断否则前端参数为空时like %%会导致全表扫描。第三分页插件没配置时 selectPage 不报错只会把全表数据返回然后内存截断数据量小的时候你根本发现不了问题。4.3 下单事务把扣库存、写订单、写明细放进同一个事务下单逻辑是真正考察“会不会写业务”的地方也是我在答辩时最常被问到的代码。一个正常的下单流程至少包含四步生成订单号、扣减商品库存、插入订单主表、插入订单明细表。这四步任何一步失败前面成功的步骤都必须回滚否则就会出现“订单生成了库存没扣”或者“库存扣了钱没收”这种严重对不上的情况。// OrderService.java 下单核心逻辑 Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, ListCartItemVO cartItems) { // 1. 生成订单号时间戳 随机数 String orderNo ORD System.currentTimeMillis() RandomUtil.randomNumbers(4); Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setStatus(0); order.setCreateTime(LocalDateTime.now()); ordersMapper.insert(order); double totalAmount 0.0; for (CartItemVO item : cartItems) { // 2. 条件更新扣库存只允许库存充足时扣减成功 int rows productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(商品[ item.getProductId() ]库存不足); } // 3. 查询商品最新价格写入订单明细快照 Product product productMapper.selectById(item.getProductId()); OrderDetail detail new OrderDetail(); detail.setOrderId(order.getId()); detail.setProductId(product.getId()); detail.setProductName(product.getName()); detail.setPrice(product.getPrice()); detail.setQuantity(item.getQuantity()); orderDetailMapper.insert(detail); totalAmount totalAmount product.getPrice() * item.getQuantity(); } order.setTotalAmount(totalAmount); ordersMapper.updateById(order); return order.getId(); }对应的扣库存 SQL 写在 ProductMapper.xml 里update iddeductStock UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity} /update这段代码有三个关键点。第一Transactional(rollbackFor Exception.class)Spring 默认只在 RuntimeException 时回滚如果业务代码里抛的是自定义 checked exception不加 rollbackFor 事务不会回滚这是一个非常隐蔽的坑。第二扣库存用的是“条件更新”而不是“先查再更新”WHERE stock #{quantity}由数据库原子地保证不会扣成负数。如果先selectById查到库存为 5再在 Java 里判断然后 update 减 6两个请求并发时会同时通过检查最后库存变成 -1这就是典型的“超卖”。第三订单明细里的价格是查询时从商品表现取的写入的是快照字段后续商品改价不影响历史订单。Redis 里扣库存如果对标 Redis 操作也有类似的条件扣减思路但 MySQL 这一套足以覆盖课设场景。以后再接触 Redis 时你会发现核心都是同一个问题如何让“检查库存”和“扣减库存”这两个动作不可分割。5. 避坑清单从环境配置到并发场景的五个常见问题5.1 中文乱码页面全显示问号现象前端录入的商品名“苹果”保存到数据库后变成“”。原因MySQL 连接串里没有指定characterEncodingutf8数据库客户端连接时用了默认字符集而默认字符集通常不是 utf8mb4。另一个可能原因是建表时用了CHARSETutf8而不是utf8mb4utf8 在 MySQL 里存不了 emoji 和生僻字。解决应用层连接串改成jdbc:mysql://localhost:3306/supermarket?useUnicodetruecharacterEncodingutf8mb4同时确认建表 SQL 里统一写CHARSETutf8mb4。修改后要重启应用并且确认表结构已经通过ALTER TABLE改过否则仍然乱码。5.2 库存扣成负数多线程并发下单的“超卖”现象商品库存只有 10 件两个用户同时下单 6 件最后库存变成 -2。原因代码里先selectById查库存Java 判断库存量充足后执行update。两次请求同时在“查”这一步读到 10同时在“改”这一步各扣 6最后剩 -2。解决把“检查库存”和“扣减库存”合并成一条条件更新语句UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}影响行数为 0 就说明库存不足。这个方案不用锁、不用事务隔离级别也能扛住并发。这是我最想让你记住的一件事能交给数据库原子的操作不要拆成两步在 Java 里做。5.3 HikariPool 连接超时数据库连接池被打满现象项目运行一段时间后控制台频繁报HikariPool-1 - Connection is not available, request timed out after 30000ms。原因最常见的是代码里用完连接没释放。比如在 service 里自己写了 JDBC 的Connection没有close或者 MyBatis 查询超时后连接被占住。MyBatis 的默认配置会把连接归还连接池但如果你手工混用了原生 JDBC漏掉关闭就必然泄露。解决检查代码里所有原生 JDBC 操作用 try-with-resources 包裹连接对象。同时在 application.yml 里给连接池留出余量spring.datasource.hikari.maximum-pool-size: 20connection-timeout: 30000。连接池大小不是越大越好默认 10 到 20 对课设项目足够设置过大反而增加数据库负担。5.4 前后端跨域导致登录失效每次请求都是未登录现象前端用 Vue 起在 8081 端口后端在 8080 端口登录接口可以通但登录后再请求商品列表后端还是提示未登录。原因前后端分离的项目跨域请求默认不带 CookieSession 方案取不到登录态。另一个原因是浏览器跨域请求时发了OPTIONS预检请求后端没处理这个预检就直接返回前端认为是请求被拒绝。解决两步同时做。后端加一个 CorsConfig 放行指定来源allowedOrigins不要用*加allowCredentialstrue组合这样前端带凭证会被浏览器拦截用具体地址如http://localhost:8081。登录态方案建议换成 JWTtoken 放在请求头里不依赖 Cookie跨域问题天然消失。这比调 CORS 参数省心得多。5.5 文档与代码不一致数据库文档纯粹抄建表语句现象交付的“详细文档”里列了五张表但代码里实际有七张表一个字段注释还是英文代码里已经改了用途。原因写文档的人脱离代码或者建表之后又改了字段但没回头更新数据库文档。答辩老师往往先翻文档再翻代码对不上就会对你的完成度打问号。解决我一般会在文档里放一个“数据字典”章节直接查information_schema库生成表结构清单而不是手敲表格。下面这条 SQL 可以输出全库所有表名和注释SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA supermarket;再配合SHOW FULL COLUMNS FROM product;查看字段注释。文档和代码要在同一个 commit 里更新每次改表结构顺手把文档里的字段表改掉成本很低收益是答辩时不露怯。6. 交稿前的验证手段与一个提升档次的进阶技巧项目写完不等于能交我建议你花半天时间做两件事一是验证事务回滚二是看到真实的并发行为。先说验证事务你可以临时写一个测试接口在createOrder方法里制造异常——比如故意让成交价为 0 不通过——然后看数据库里有没有残留的半截订单和扣掉的库存。正常情况是订单表、订单明细表、商品库存都保持原样。这一步能证明你的Transactional真的生效了。第二件事打开两个终端窗口同时对一个商品执行下单请求然后看库存是否出现负数。没有线程工具也能测最简单的做法是 curl 命令在循环里发请求# 模拟 20 个并发请求 for i in $(seq 1 20); do curl -X POST http://localhost:8080/api/orders/create \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {productId:1,quantity:1} done wait这个脚本不是生产级压测工具但足以暴露问题了如果你的扣库存逻辑是“先查后改”这 20 个并发请求大概率会把库存打穿。跑通这个测试你能亲眼看到 MySQL 条件更新的价值。如果时间允许我建议把一个热点商品的数据加一层 Redis 缓存。不是让你把购物车迁过去而是商品详情这种读多写少的接口用 Spring Cache 注解缓存起来。这一步对课设来说已经算超纲亮点答辦时可以说“我使用了 Redis 缓存把热点商品查询的响应时间从几十毫秒降到了个位数”。实现起来只需要在 service 方法上加Cacheable(cacheNames product, key #id)然后配置 RedisTemplate 即可。我这几年做过的项目凡是下单扣库存这种资金相关逻辑都坚持同一个习惯先扣库存再写订单最后算金额更新订单。顺序背后是风险大小的取舍——库存扣错了可以补偿用户订单生成了却扣不了库存客服就要处理退款。把最危险的操作放在事务最前面是我被线上事故教育后养成的习惯。做超市购物系统同理数据库设计多花一小时后面写代码能省一整天。希望这篇能帮你把这个项目做得比平均水平高出一截答辩顺利。本文还有配套的精品资源点击获取