拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot+Vue+MySQL:生鲜超市销售系统开发全流程详解

简介这是一份基于Java生态的生鲜超市销售系统毕业设计源码包使用VueSpringBootMySQL搭建面向计算机类专业学生、毕业设计开发者以及需要快速搭建权限管理后台的Java工程师。系统涵盖商品档案、进货、销售、供应商、活动管理、消息通知等核心业务模块并集成用户、部门、角色、菜单、日志、数据字典、文件管理、图表展示等基础功能基于角色访问控制权限可精确到按钮级别适合需要自定义权限模型的场景。包体共396个文件其中Java源文件175个、Vue组件122个另有JS、PNG、LESS、SQL等类型涵盖后端业务逻辑、前端页面、样式与数据库脚本压缩包仅1.38MB结构紧凑便于快速部署学习。内容预览显示包含树形表格、添加编辑页面、Controller/Service/Entity等典型分层代码模板能帮助理解SpringBootVue项目从接口到页面的完整实现路径。已有548人学习下载可作为毕业设计参考或企业内部销售系统二次开发的基础工程。1. 生鲜超市销售系统选 VueSpringBootMySQL先看这个组合要解决的业务问题一个订单创建完库存没扣或者库存扣了订单明细没有落库生鲜超市销售系统在验收现场往往撑不过三个追问。这类系统核心业务不大但闭环清楚管理员维护商品与库存前台按分类浏览、加购结算后台查订单、盯临期商品。用 Vue 处理页面交互SpringBoot 提供 HTTP 服务MySQL 负责商品、库存、订单持久化三段职责互相独立又正好把实体映射、接口设计、事务控制这些 Java 方向的主线知识串在一起。这套技术栈能控制项目复杂度也能在有限时间里交付一个演示功能完整、答辩有话讲的系统。下面按我通常做的顺序展开先搭后端骨架再设计表和事务然后接 Vue 页面最后讲联调验证。2. 搭建 SpringBoot 后端骨架Maven 依赖、数据源配置和商品接口后端部分我习惯先分层再写代码。项目虽然不大但不分层到答辩时会很难讲清“事务加在哪一层”这种问题。2.1 后端工程要拆成哪几个包为什么毕业设计也值得分层常见做法是把 controller、service、mapper、entity 四层分开另外放一个 common 包放统一返回结构。目录结构如下fresh-market/ ├── pom.xml └── src/main/java/com/example/freshmarket/ ├── FreshMarketApplication.java ├── controller/ │ ├── CategoryController.java │ ├── ProductController.java │ └── OrderController.java ├── service/ │ ├── ProductService.java │ └── OrderService.java ├── mapper/ │ ├── ProductMapper.java │ └── OrderMapper.java ├── entity/ │ ├── Product.java │ └── SalesOrder.java └── common/ └── Result.java分层不是给代码数量凑数。controller 只负责接收参数和返回结果service 里放事务和业务校验mapper 只管 SQL 读写。这样当“下单后库存不减少”这类问题出现时排查路径很清楚先看 controller 是否把参数传全再看 service 是否加了 Transactional最后看 mapper 的 update 语句影响行数。答辩中“事务加在哪一层”是高频问题能明确答出“service 层”会比含糊解释强很多。实体类放在 entity 包内字段直接对应一张业务表。MyBatis 的 mapper 接口用 Mapper 注解标记或者在启动类上使用 MapperScan两种方式任选其一。我用 MapperScan 方式因为 mapper 接口数量增多时不需要每个接口都重复注解。2.2 pom.xml 和 application.yml 的关键配置项框架版本直接影响兼容性。我一般选用 SpringBoot 2.7 系列它支持 JDK8也支持后续常见的 JDK17 迁移是当前毕业设计环境里最稳妥的选择。如果电脑上装的是新版 JDK或者想体验 SpringBoot 3需要同步切换到 JDK17映射类和配置上会有少量差异别只改版本号就指望项目跑起来。pom.xml 中核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis 与 SpringBoot 的整合包不是 MyBatis 官方单独维护的包 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies依赖选择上mysql-connector-j 是 MySQL 8.x 官方驱动坐标如果项目里还在用老坐标 mysql-connector-java两者功能等价换名而已。mybatis-spring-boot-starter 2.3.1 对 SpringBoot 2.7 适配良好控制台不会出现版本冲突日志。数据源配置写在 application.ymlserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fresh_market?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 hikari: minimum-idle: 2 maximum-pool-size: 10 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.freshmarket.entity configuration: map-underscore-to-camel-case: true logging: level: com.example.freshmarket.mapper: DEBUG几个关键配置项的取值参考配置项推荐值说明characterEncodingutf8中文写入不乱码配合数据库 utf8mb4 使用serverTimezoneAsia/ShanghaiMySQL8 驱动校验时区不改会启动报错allowPublicKeyRetrievaltrue解决 caching_sha2_password 首次连接问题maximum-pool-size10演示场景足够调大浪费数据库连接资源logging.level.mapperDEBUG控制台打印 SQL联调排错必需HikariCP 是 SpringBoot 默认连接池连接数不需要跟着并发数膨胀。mybatis 的 type-aliases-package 配置让 XML 里可以写 resultTypeProduct不需要写全限定名map-underscore-to-camel-case 把 category_id 自动映射为 categoryId少写大量 resultMap。2.3 从 Product 实体到商品列表接口的完整链路先从实体类开始。生鲜商品相比普通电商商品需要额外关注批次和保质期字段设计如下Data public class Product { private Integer id; private String name; private Integer categoryId; private BigDecimal price; private String unit; // 单位份/斤/盒 private Integer stock; // 当前库存数量 private String batchNo; // 进货批次号 private LocalDate produceDate; private LocalDate expireDate; private Integer status; // 1 上架0 下架 private String imageUrl; }unit 字段是我建议保留的。生鲜超市里同一件商品可能按份卖也可能按斤称重单位不同展示文案完全不同如果演示要扩展称重商品可以把 stock 改成 DECIMAL(10,3)用 kg 做基本单位这里先按整数量来做。price 用 BigDecimal 而不是 double后续计算订单总额时不会出现浮点误差。Mapper 接口只写一个方法Mapper public interface ProductMapper { ListProduct selectOnSale(Param(categoryId) Integer categoryId); }对应 XMLselect idselectOnSale resultTypeProduct SELECT id, name, category_id, price, unit, stock, batch_no, produce_date, expire_date, status, image_url FROM product WHERE status 1 AND (#{categoryId} IS NULL OR category_id #{categoryId}) ORDER BY id DESC /select这里用了“#{categoryId} IS NULL OR category_id #{categoryId}”的写法前端不传分类时返回全部上架商品传了分类就按分类过滤避免在 Java 代码里写两套查询逻辑。需要注意product 表此时还没有建立MyBatis 启动后第一次访问这张表会直接报错所以跑通接口前必须先把下一章的建表脚本执行完顺序不要反。Service 层直接调用 mapperController 负责接收查询参数RestController RequestMapping(/api/product) public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService productService; } GetMapping(/list) public ResultListProduct list(RequestParam(required false) Integer categoryId) { ListProduct products productService.listOnSale(categoryId); return Result.success(products); } }统一返回结构 Result 里包含 code、message、data 三个字段code 为 200 表示成功。前端 Axios 拦截器只需判断 code 即可进入成功分支错误提示也能统一处理不用每个接口自己写 try catch。后端的商品接口到这里就能跑通这相当于整条开发链路的基准线此后订单、库存功能都参照这个模式扩展。3. MySQL 表设计生鲜商品字段、库存扣减和订单事务后端骨架通了接下来要把“生鲜”两个字落在表结构上。很多人把生鲜超市系统做成普通商城丢失了业务特征答辩时容易被问倒。3.1 生鲜商品表要单独承载哪些字段普通电商商品关注的是标题、图片、SKU、销量而生鲜商品有另一个时间维度保质期。西红柿放三天可能就变成损耗肉类批次不同进货价也不同。所以商品表里 batch_no、produce_date、expire_date 这三个字段不是装饰它们支撑两个实际功能临期商品提醒和批次管理。另一个特征是损耗与拆零。蔬菜水果在售过程中会出现自然损耗这类系统如果只记录“入多少卖多少”月底盘点对不上账。毕业设计阶段不需要把损耗模块做成完整进销存但至少可以在库存变动表里预留一个变动类型字段区分入库、销售扣减、盘亏调整这样复盘数据时有据可查。下面 DDL 中 stock_change 表就承担这个角色。3.2 商品、订单、库存变动三张核心表的 DDL以 MySQL 8.0 为基准建议字符集使用 utf8mb4因为它能完整支持中文和特殊符号避免 emoji 或生僻字写入报错。CREATE DATABASE IF NOT EXISTS fresh_market DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE fresh_market; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort INT NOT NULL DEFAULT 0 ) ENGINEInnoDB; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, unit VARCHAR(10) NOT NULL DEFAULT 份, stock INT NOT NULL DEFAULT 0, batch_no VARCHAR(40), produce_date DATE, expire_date DATE, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, image_url VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_expire (expire_date) ) ENGINEInnoDB; CREATE TABLE sales_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0已创建 1已支付 2已取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_order (order_id) ) ENGINEInnoDB; CREATE TABLE stock_change ( id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, change_type TINYINT NOT NULL COMMENT 1入库 2销售扣减 3盘亏, quantity INT NOT NULL, remark VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_product (product_id) ) ENGINEInnoDB;DDL 里值得说明的点有三个。price 用 DECIMAL 而不是 FLOAT因为浮点数在金额计算上存在精度误差订单里对不上账会很麻烦。sales_order 表没有 user_id是刻意简化的行为如果系统只面向门店收银场景订单属于门店而不是某个注册用户就不需要会员体系可以让系统更聚焦在商品和库存上如果指导老师要求登录注册再补充 user 表和 user_id 字段。stock_change 是流水表只做累加不修改之前的记录盘点时以流水 trace 库存变化这比直接改商品表的 stock 字段更可信。商品表里 idx_category 和 idx_expire 两个二级索引分别服务分类筛选和临期查询。数量级别到百万行时这两个索引的作用会体现出来在演示数据量下它们能帮助说明“索引是建立在查询模式之上的”答辩时可以顺势讲。created_at 和 updated_at 用数据库默认值维护Java 代码里不需要手动 set 时间避免多台服务器时间不一致的问题。3.3 扣库存用条件 UPDATE别用“先查后改”下单扣库存是销售系统最容易出错的一段。常见误用是先查询剩余库存在 Java 代码里判断数量够不够再执行 UPDATE。单用户操作没问题一旦前端两个请求同时到达两个请求都读到 stock5都判断“够扣”先后执行扣减最后一次写入就把结果覆盖了这就是超卖。两种实现方式对比实现方式并发安全性推荐度原因select 后判断再 update不安全不推荐两端读可能读到相同库存覆盖更新UPDATE 带 stock 数量安全推荐行锁加条件判断一次原子完成SELECT FOR UPDATE 悲观锁安全此场景不推荐增加锁等待处理超卖问题不需要上升到悲观锁正确做法是把校验放进 SQL 条件里update iddeductStock UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity} /update这段 SQL 中 stock #{quantity} 是原子判断数据库行锁保证同一时刻只有一个事务能更新这一行。当库存不足时update 影响行数为 0程序通过受影响行数判断失败并抛出异常。配合 Service 层事务商品表更新失败后前面已插入的订单头和订单明细会一并回滚。完整下单逻辑Transactional(rollbackFor Exception.class) public String createOrder(OrderAddRequest req) { if (req.getItems() null || req.getItems().isEmpty()) { throw new RuntimeException(订单不能为空); } SalesOrder order new SalesOrder(); order.setOrderNo(SO System.currentTimeMillis() String.format(%04d, ThreadLocalRandom.current().nextInt(10000))); orderMapper.insert(order); BigDecimal total BigDecimal.ZERO; for (OrderItemRequest item : req.getItems()) { Product product productMapper.selectById(item.getProductId()); if (product null || product.getStatus() ! 1) { throw new RuntimeException(商品不存在或已下架 item.getProductId()); } int rows productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足 product.getName()); } total total.add(product.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); orderItemMapper.insert(order.getId(), item.getProductId(), product.getName(), product.getPrice(), item.getQuantity()); } order.setTotalAmount(total); orderMapper.updateAmount(order.getId(), total); return order.getOrderNo(); }方法上的 Transactional(rollbackFor Exception.class) 必须写 rollbackFor默认情况下 RuntimeException 才会回滚这里的业务异常都设计成 RuntimeException所以加这个参数保险且含义清晰。流程里先插入订单头拿到自增 id然后逐条扣库存、插明细最后回填总金额。中间任何一条扣减失败事务回滚后订单头和已插明细都不会留在数据库里。事务的隔离级别这里没有单独声明使用 MySQL InnoDB 默认的 REPEATABLE READ。行锁加条件 UPDATE 已经解决了超卖问题不需要上升到悲观锁 SELECT FOR UPDATE更不需要引入 Redis 分布式锁。毕业设计去演示分布式锁反而容易被追问多实例部署的假设条件不如把单库事务讲透。3.4 临期商品预警查询生鲜系统用一个查询就能体现出业务差异查到期前 3 天的上架商品。SELECT id, name, expire_date, DATEDIFF(expire_date, CURDATE()) AS days_left FROM product WHERE status 1 AND expire_date IS NOT NULL AND DATEDIFF(expire_date, CURDATE()) 3 ORDER BY expire_date;DATEDIFF 返回两个日期相差天数days_left 为 0 表示当天到期负数表示已过保。expire_date 有非空判断防止历史数据里大量 NULL 把结果带偏。页面上给 days_left 加红色标签就是一个能写进功能清单的亮点PPT 上截图展示比空讲“库存管理”有说服力。这段 SQL 也可以做成后端接口/api/product/expiringService 里直接返回标记好 daysLeft 的列表前端在管理后台单独做一个临期商品 Tab。注意 DATEDIFF(expire_date, CURDATE()) 在 expire_date 上无法走索引优化数据量大时可以改成expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY)演示阶段用 DATEDIFF 更直观答辩时能接住这个优化问题反而加分。4. Vue 前端页面路由、Axios 封装、购物车和下单后端接口成型后前端主要做三件事把页面和路由组织好、把请求统一封装、把购物车和下单流程跑通。以下使用 Vue3 Vite Pinia 的常见组合如果你拿到的项目模板是 Vue2 Vuex组件的写法会变但状态管理的设计思路不变。4.1 前端目录结构和路由参数设计常规目录结构如下fresh-market-web/ ├── vite.config.js ├── package.json ├── index.html └── src/ ├── main.js ├── App.vue ├── router/index.js ├── api/ │ ├── request.js │ ├── product.js │ └── order.js ├── store/cart.js └── views/ ├── Home.vue ├── CategoryProducts.vue └── OrderConfirm.vue路由主要定义三条路径import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(../views/Home.vue) }, { path: /category/:id, name: category, component: () import(../views/CategoryProducts.vue), props: true }, { path: /order/confirm, name: orderConfirm, component: () import(../views/OrderConfirm.vue) } ] export default createRouter({ history: createWebHistory(), routes })vite 项目里用 createWebHistory 是 HTML5 History 模式URL 干净没有 # 号如果部署到静态服务器需要配置 fallback否则刷新二级页面会 404开发环境 Vite 自带处理不用纠结。component 使用动态 import 做路由级懒加载首屏只加载首页代码商品页和订单页按需请求演示机器配置低时页面打开会快一些。路由传参有几种常见方式场景不同选择不同传参方式适用场景获取方式path query订单成功页回显参数route.query.orderNo路由 params分类页传 idprops.id 或 route.params.idPinia 状态大批量对象传递store 直接读取路径里加入props: true后CategoryProducts 组件直接用 defineProps 接收 id而不是在组件内部读 route.params.id。props 方式类型更明确页面跳转传参也更直观。CategoryProducts 里监听 id 变化重新请求商品列表watch( () props.id, (newId) { loadProducts(newId) }, { immediate: true } )immediate: true 让组件首次创建时就执行一次加载不需要额外在 onMounted 里重复写逻辑这是 Vue3 组合式 API 里比较省事的写法。4.2 Axios 请求封装与后端返回结构约定Axios 封装统一处理 baseURL、超时、响应拦截和错误提示。后端接口的公共前缀 /api 写在这里页面里不会出现具体 URL。import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: http://localhost:8080/api, timeout: 10000 }) request.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { ElMessage.warning(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { if (error.response error.response.status 401) { router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request响应拦截器把外层 { code, message, data } 拆开业务组件里拿到的直接就是 data不用每页都 res.data.data。对于 401 未登录场景拦截器统一跳登录页这样做的前提是后端在需要登录的接口上返回标准 401前后端约定一致。ElMessage 是 Element Plus 的全局提示组件如果项目换用 Ant Design Vue 或 Naive UI替换对应通知组件即可。商品接口模块import request from ./request export function getProductList(categoryId) { return request.get(/product/list, { params: { categoryId } }) }这里注意 params 传 null 时Axios 会忽略该参数正好对应后端接口中 categoryId 可空的设计两种实现是配套的。4.3 购物车状态管理和提交订单购物车数据不需要每个页面都向后端请求放在前端状态里更流畅。Pinia 写法如下import { defineStore } from pinia export const useCartStore defineStore(cart, { state: () ({ items: [] }), getters: { totalCount(state) { return state.items.reduce((sum, item) sum item.quantity, 0) }, totalAmount(state) { return state.items.reduce( (sum, item) sum item.price * item.quantity, 0 ) } }, actions: { addProduct(product) { const found this.items.find((item) item.id product.id) if (found) { found.quantity } else { this.items.push({ ...product, quantity: 1 }) } }, clear() { this.items [] } } })状态里只保存购物车数组getters 承担计算逻辑totalCount 算总件数totalAmount 算总金额。更新商品数量时直接改 found.quantity数组是响应式的页面自动刷新。这里没有把商品价格再封装一层 VO直接复用商品对象因为购物车页需要展示名称、单位、单价这些字段在后端商品列表接口里已经全部返回前端不需要额外请求。提交订单时组装后端需要的结构import { createOrder } from ../api/order import { useCartStore } from ../store/cart const cart useCartStore() const submitting ref(false) async function submitOrder() { if (cart.items.length 0) { return } submitting.value true try { const orderNo await createOrder({ items: cart.items.map((item) ({ productId: item.id, quantity: item.quantity })) }) cart.clear() router.push({ path: /order/success, query: { orderNo } }) } finally { submitting.value false } }提交按钮绑定了 submitting防止用户连点导致重复下单。后端事务保证了同一时刻同一个商品的扣减是串行的即使重复请求到达第二个请求也会因库存不足失败。订单号通过 query 参数传给成功页成功页可以直接展示“订单 SOxxx 创建成功”不需要再调用查询接口减少一次请求往返。5. 从接口自测到答辩看请求日志定位前后端问题5.1 用浏览器开发工具和后端日志定位联调问题前端页面点加购没反应最常见的原因是请求根本没发出去或者返回被拦截器挡掉。打开浏览器开发者工具 Network 面板先看请求 URL 是否正确指向后端地址。如果状态码是 404检查后端 RequestMapping 的 /api 前缀有没有配全如果状态码是 500直接看后端控制台堆栈而不是在前端代码里找原因。后端侧要养成看 MyBatis SQL 日志的习惯。前面 yml 里配置了 mapper 包 DEBUG控制台会打印类似内容 Preparing: UPDATE product SET stock stock - ? WHERE id ? AND stock ? Parameters: 3(Integer), 1(Integer) Updates: 1Updates: 1 表示扣减成功0 表示库存不足。它还能暴露另一个问题如果打印出来的参数位置和预想不一致多半是 Param 没写全检查 mapper 方法参数即可。前后端联调阶段大多数问题都能靠对照 Network 面板和控制台日志定位不需要额外引入链路追踪工具。5.2 答辩时按业务链路讲技术点答辩环节不要按“我用了 SpringBoot、Vue、MySQL”逐个罗列技术栈而是挑一个业务场景顺着链路讲。以“用户下单扣库存”为例前端购物车提交订单Axios 把订单明细 POST 到 /api/order/createSpringBoot controller 接收 JSON交给 service 方法service 上的 Transactional 开启事务先插订单头再逐条执行带库存校验的条件 UPDATE影响行数为 0 就抛异常触发回滚数据库层面的行锁保证了并发请求不会超卖。讲完这条链路事务边界、并发控制、前后端接口约定三个点都覆盖了。数据库部分可以准备一张自己画的 ER 图标注商品、分类、订单、订单明细的关系以及 stock_change 流水表的作用。被问到“为什么订单总价不直接存在明细表”时能回答“总价是订单表的冗余字段方便订单列表查询明细用于追溯单品成交价”就说明你想过数据冗余的代价。演示临期商品查询时把某个商品的 expire_date 改成当天日期刷新页面看到预警标记出现这个交互比念 PPT 更直观也证明了表设计时考虑了保质期维度。把加了红色标记的临期商品页面放在演示最后老师问“你的系统哪里有业务特色”直接切到管理后台的临期列表顺势讲 DATEDIFF 的查询逻辑和索引优化思路即可。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门