资讯详情

SpringBoot3+Vue3超市库存管理系统实战:从数据库设计到部署排坑

📅 2026/10/7 10:47:07 | 华诺云谱 👁 阅读
SpringBoot3+Vue3超市库存管理系统实战:从数据库设计到部署排坑
去年接了个毕业设计的活儿要求做一个“超市库存管理系统”技术栈指定要用 SpringBoot3 Vue3。前后折腾了一个多月从数据库设计到前后端联调再到部署答辩踩了不少典型的坑也总结出一套比较顺手的做法。这篇就把完整的实现思路、核心代码和排坑经验都放出来给正在做类似毕设或者想练手全栈项目的朋友一个可以直接参考的样例。做毕设最怕的不是技术不会而是方向太散。超市库存管理系统这个题目好就好在业务足够典型、模块边界清晰既有增删改查又有事务、权限、报表这些能体现“工作量”的地方特别适合用来展示全栈开发能力。它不像“图书管理系统”那么简单到答辩时没话说也不像“电商秒杀系统”那样复杂度失控。只要把商品、供应商、入库、出库、盘点、统计这几条主线理顺再把前端 Vue3 的交互做得顺手一点评审老师一般都会觉得完整度和工程化意识都到位了。1. 项目定位与实际需求拆解1.1 选题背景为什么是“超市库存管理系统”超市库存管理的核心矛盾是“货多、类杂、进出频繁”。一个小超市的商品种类可能就有几百上千种每一种都有库存上下限、供应商、进货价、零售价。人工用 Excel 管经常出现货架上有货但系统里没数量或者账面上有库存实际已经丢损的情况。做管理系统本质上就是解决“信息同步”的问题。在毕业设计里这个题目能覆盖的技能点非常丰富数据库设计要处理商品与分类、商品与供应商、流水与库存之间的关系后端要写分页查询、批量删除、事务性入出库、登录鉴权前端要处理表格渲染、表单校验、弹窗交互、统计图表。这些内容刚好对应一个合格后端/全栈开发者的基本功。我建议做的时候不要只盯着“库存增减”这个点一定要把“流水”的概念做进去。库存是结果流水是原因只改库存不记流水做完之后自己都说不清楚数据怎么来的答辩时一问就露馅。1.2 技术栈为什么这么选SpringBoot3 Vue3SpringBoot3 相比 SpringBoot2 最大的变化是底层基于 Spring Framework 6默认要求 JDK17Servlet 相关 API 迁移到了jakarta命名空间下。这意味着很多老教程里的代码直接复制会报javax包不存在的错误。Vue3 则全面拥抱 Composition API 和 Vite 构建工具配合 Element Plus 做后台管理界面非常顺滑。在毕设场景下这套组合的优点是技术新答辩时有话题社区教程多遇到问题搜得到前后端分离更贴近企业开发模式。缺点也很明显——你如果之前只学过 SSH 或者 SSM 的老套路刚切换到 SpringBoot3 Vue3 会有一个明显的适应成本。网上很多 2022 年之前的教程默认还是 JDK8 SpringBoot2 Vue2照抄一遍会发现各种依赖冲突。我个人的建议是既然选择了 SpringBoot3 Vue3就老老实实把 JDK17 环境和新版依赖一次配好不要退回到老版本去“求稳”。新版框架的坑绝大多数是版本不匹配造成的后面我会把版本搭配表列出来。2. 系统架构与数据库设计2.1 前后端分离架构设计整个系统我采用的是标准的前后端分离结构前端Vue3 Vite Vue Router Pinia Axios Element Plus后端SpringBoot3 MyBatis-Plus MySQL8 JWT部署前端构建后由 Nginx 托管后端打成 Jar 包单独运行后端按经典三层分包controller 层负责接收请求和参数校验service 层处理业务逻辑mapper 层通过 MyBatis-Plus 操作数据库entity 实体与数据库表字段一一对应。另外加了一个common包放统一的返回结果Result、全局异常处理器、JWT 工具类这样代码结构更干净写起来也省事。前端这边页面文件放在src/views下接口请求统一封装在src/api下公共状态比如当前登录用户、token交给 Pinia 管理。路由使用createWebHistory通过前置守卫判断是否登录未登录直接跳转登录页。这个壳子搭好之后后面加功能就是单纯地“写页面”和“写接口”。2.2 核心表结构设计数据库我设计了下面这几张核心表不需要太花哨但一定要把业务闭环支撑起来表名说明关键字段sys_user系统用户表id, username, password, real_name, roleproduct_category商品分类表id, name, parent_idsupplier供应商表id, name, contact, phone, addressproduct商品信息表id, name, category_id, supplier_id, price, stock, min_stock, statusstock_in入库单表id, supplier_id, user_id, total_amount, create_timestock_in_item入库明细表id, stock_in_id, product_id, quantity, purchase_pricestock_out出库单表id, user_id, remark, create_timestock_out_item出库明细表id, stock_out_id, product_id, quantitystock_check盘点记录表id, product_id, before_stock, after_stock, diff, user_id, create_time商品和分类、供应商都是多对一关系入库单和入库明细是一对多关系。库存字段我直接冗余在了product表里这种设计适合毕设也适合大多数中小型管理系统——实时查询很快不需要每次都用SUM汇总流水。但冗余的关键在于任何入库、出库、盘点操作都必须放在同一个数据库事务里同时更新流水表和库存字段否则两边一不一致就说不清楚了。2.3 库存字段与流水表的关系很多同学纠结“库存到底是存字段还是算出来”。我的答案是存字段同时保留流水表作为依据。以一次入库为例前端提交一个入库单后端要往stock_in插一条主表记录再往stock_in_item插 N 条明细最后把每条明细对应的商品库存加上数量。这三件事必须是一个事务有一件失败就要全部回滚。流水表解决的是“可追溯”问题你说现在库存是 97凭什么因为之前入过 100出过 3。如果没有流水表只有库存字段那系统就是个“高级计数器”没有一点管理价值。答辩的时候把“库存余量 原始入库 - 出库”这个公式讲清楚同时说明为什么要在业务代码里维护这个结果字段就已经比很多流水账式的毕设高一个层次了。3. 后端核心功能实现3.1 SpringBoot3 项目搭建与依赖配置项目直接用 IDEA 的 Spring Initializr 创建JDK 选择 17。这里一个最容易踩的坑就是 MyBatis-Plus 的 starter 包名跟 SpringBoot2 时代不一样了。早期版本用mybatis-plus-boot-starterSpringBoot3 必须用mybatis-plus-spring-boot3-starter。第一次跑起来报ClassNotFoundException的十有八九是包没选对。核心依赖配置大概长这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency /dependenciesapplication.yml里除了常规的数据源配置记得加 MyBatis-Plus 的逻辑删除和驼峰映射配置。逻辑删除强烈建议加虽然毕设场景里不一定真的会删除数据但面试或答辩时被问到“删除商品后历史流水怎么办”有逻辑删除字段能直接答上。3.2 统一返回体与异常处理前后端分离项目中最忌讳 controller 直接返回裸数据或者随便抛异常。我习惯封装一个ResultT结构如下Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }配合RestControllerAdvice做全局异常处理比如参数校验失败、库存不足、数据不存在等都返回统一格式的 JSON。这样前端 axios 响应拦截器只需要判断code是否为 200不需要在每一个接口里重复处理错误分支。全局异常里有一个细节必须注意不要直接返回e.getMessage()给前端尤其在生产环境。有些异常信息会泄露 SQL 细节或内部路径毕设虽然不用那么严谨但养成好习惯总没错。可以在异常处理器里按类型区分BusinessException返回自定义提示其他Exception统一返回“系统繁忙请稍后重试”。3.3 入库、出库事务逻辑与并发控制入库出库是最核心的业务接口也是整个项目最值得展开讲的部分。先看入库接口的 service 实现思路Transactional(rollbackFor Exception.class) public void stockIn(StockInDTO dto) { // 1. 创建入库单主表 StockIn stockIn new StockIn(); stockIn.setSupplierId(dto.getSupplierId()); stockIn.setUserId(LoginUserHolder.get()); stockIn.setTotalAmount(dto.getItems().stream() .map(item - item.getQuantity() * item.getPurchasePrice()) .reduce(BigDecimal.ZERO, BigDecimal::add)); stockInMapper.insert(stockIn); // 2. 插入明细并逐条更新商品库存 for (StockInItemDTO item : dto.getItems()) { StockInItem detail new StockInItem(); detail.setStockInId(stockIn.getId()); detail.setProductId(item.getProductId()); detail.setQuantity(item.getQuantity()); detail.setPurchasePrice(item.getPurchasePrice()); stockInItemMapper.insert(detail); // 3. 增加库存同时更新更新时间 productMapper.increaseStock(item.getProductId(), item.getQuantity()); } }这里有两个很关键的细节。第一Transactional必须加上并且最好显式指定rollbackFor Exception.class。默认情况下 RuntimeException 才会回滚如果不指定某些受检异常抛出后事务不能正确回滚库存就会出现“单子建了但库存没变”的脏数据。第二更新库存不能用“先查出来加数量再 update”的三步写法。在高并发下两个请求同时读到库存 10各自加 5后提交的会覆盖前一个最终库存只有 15 而不是 20。正确做法是直接执行update product set stock stock 5 where id ?让数据库的行锁去处理并发。这个点我在导项目时特意作为亮点给同学讲过答辩时老师也专门追问了。出库逻辑稍微复杂一点因为要先检查库存是否充足。我的做法是查询商品当前库存如果小于出库数量直接抛业务异常然后仍然用stock stock - 数量的 SQL 更新。就算极端情况下两个请求同时出库数据库行锁也会保证最终不会把库存扣成负数安全边界就有了。3.4 登录鉴权与拦截器登录设计我直接用了 JWT依赖加的是 jjwt 0.11.5。逻辑很简单用户输入用户名密码校验通过后生成一个带 userId 和 username 的 token 返回给前端。前端后续请求在Authorization头带上 token后端通过拦截器解析并放行。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !JwtUtil.validate(token)) { response.setStatus(401); response.getWriter().write(未登录或登录已过期); return false; } String userId JwtUtil.parseId(token); LoginUserHolder.set(userId); return true; } }拦截器注册时要注意放行登录接口、静态资源、错误路径否则前端第一次调登录接口就 401很容易误判是跨域问题。我用了一个LoginUserHolder本质是 ThreadLocal来保存当前登录用户这样 service 层不需要每个方法都传 userId在创建入库单、出库单时直接取当前操作人就行。这种做法在企业项目里也很常见。如果是想省事一点可以直接用 Sa-Token 或者 Spring Security JWT但作为毕业设计手写一个 JWT 工具类反而更能体现你对认证机制的理解。时间不够的话用 Sa-Token 也不丢人关键是答辩时能把“token 存在哪”“拦截器做了什么”说清楚。4. 前端 Vue3 实现与交互细节4.1 前端工程初始化与基础配置前端我用 Vite 创建项目执行npm create vitelatest supermarket-web -- --template vue后再安装依赖npm install vue-router4 pinia axios element-plus element-plus/icons-vueVite 创建的项目里跨域问题是毕设新手最容易卡住的地方。后端接口跑在http://localhost:8080前端跑在http://localhost:5173浏览器直接发请求会被 CORS 策略拦下来。解决办法有两个后端写全局 CORS 配置或者前端配开发代理。我推荐前端配代理更贴近真实部署环境。在vite.config.js里加export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/product/list会被转发到http://localhost:8080/api/product/list浏览器里看不到跨域后端也不用额外开 CORS。注意后端的 Controller 前缀要统一为/api两边约定好后面联调会省很多事。4.2 登录态管理与路由守卫登录状态这一块我用 Pinia 做了一个 user store保存 token 和用户名。token 同时持久化到localStorage刷新页面后还能保持登录态。axios 请求拦截器里统一加上Authorization头request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config })路由守卫是用来防“手输地址直接进后台”的。在router/index.js里加一个前置守卫判断目标路由是否需要登录需要的话检查 localStorage 里有没有 token没有就跳登录页。但这里注意路由表的 meta 信息一定要配好不要把所有路由都加上requiresAuth登录页自己会被自己拦住出现“死循环重定向”的诡异问题我就犯过这个错。4.3 商品管理页与库存联动商品列表页是典型的“表格 搜索 分页 弹窗”结构。用 Element Plus 的el-table、el-pagination、el-dialog组合。每次操作完调用fetchList()重新拉取数据这是最简单也最不容易出错的刷新方式不要试图在前端手动改表格数据容易把状态搞乱。Vue3 里写这种页面推荐直接用script setup语法。下面是一个简化示例能看出 Composition API 的思路script setup import { ref, onMounted } from vue import { getProductPage, deleteProduct } from /api/product import ProductForm from /components/ProductForm.vue import { ElMessage, ElMessageBox } from element-plus const loading ref(false) const list ref([]) const total ref(0) const queryParams ref({ pageNum: 1, pageSize: 10, keyword: }) const dialogVisible ref(false) const currentId ref(null) async function fetchList() { loading.value true try { const res await getProductPage(queryParams.value) list.value res.data.records total.value res.data.total } finally { loading.value false } } function handleEdit(row) { currentId.value row.id dialogVisible.value true } async function handleDelete(row) { await ElMessageBox.confirm(确定删除该商品吗, 提示, { type: warning }) await deleteProduct(row.id) ElMessage.success(删除成功) fetchList() } onMounted(fetchList) /script这里有个 Vue3 响应式的小坑要提醒一下如果用reactive定义list在把res.data.records直接赋值给list时有可能整个对象被替换模板里的引用反而失效。用ref就没有这种问题.value赋值是重新触发依赖的标准方式。新手建议统一用ref到底遇到复杂嵌套对象再用reactive。入库、出库弹窗里的表单校验也很关键。我给数量字段加了一个自定义校验必须大于 0且出库时不能超过当前库存。超过库存时后端也会拦截但前端先校验一遍用户不需要等接口报错就能看到提示体验感和专业度都会好很多。4.4 库存流水与统计报表库存流水页面我选择直接把入库单和出库单的所有明细放在一张表里展示用“类型”字段区分是入库还是出库。前端通过 tab 切换或下拉筛选展示单号、商品名、数量、方向、时间、操作人。这种设计比“入库明细”和“出库明细”两个页面分开更直观代码量也少一半。统计报表用了 ECharts。后端提供一个汇总接口前端基于接口返回值渲染柱状图和饼图。柱状图展示最近 7 天出入库趋势饼图展示分类库存占比。ECharts 在 Vue3 里的用法很简单npm install echarts后在onMounted里初始化图表数据返回来之后用setOption更新。记得在onBeforeUnmount里调用chart.dispose()销毁实例不然页面多切换几次会有内存泄漏的警告。5. 环境部署与问题排查实录5.1 打包部署本地能跑不算完要能发给别人跑毕设项目最忌讳“在我电脑上能跑”。我习惯把整套环境整理成一份部署清单方便演示时换电脑也能快速启动。后端打包很简单在项目根目录执行mvn clean package -DskipTests生成的target/supermarket-0.0.1-SNAPSHOT.jar直接用java -jar启动。前端先执行npm run build产物在dist/目录把这个目录交给 Nginx 托管。一个常见的部署姿势是Nginx 监听 80 端口location /指向前端静态文件location /api/反代到后端服务。配置大致如下server { listen 80; server_name localhost; root /opt/supermarket/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }要特别注意的是try_files $uri $uri/ /index.html;这一段。Vue Router 用的是 history 模式直接刷新/product/list这个地址时 Nginx 会先去磁盘找这个文件找不到就 404加上回退到 index.html 之后才能正常渲染。不知道这个小点的话很多人前脚配置完后脚刷新页面就白屏还以为是前端代码写错了。5.2 常见问题速查表我把这次开发过程中最常出现的几类问题整理成一个速查表你直接对着排查就行。现象可能原因解决办法启动报ClassNotFoundException: javax.servlet...SpringBoot3 还在用旧版依赖检查依赖坐标换jakarta版或升级到兼容 SpringBoot3 的版本前端页面白屏/无法访问接口未配置代理或后端地址不对检查 vite.config.js 的 proxy确认 target 端口登录后刷新页面又回到登录页路由守卫拿不到 token确认登录时已把 token 写入 localStorage守卫读取的 key 要一致数据库插入中文报错数据库字符集不是 utf8mb4建库时指定DEFAULT CHARSETutf8mb4连接串加characterEncodingutf8商品删除后历史流水查询异常外键约束/逻辑删除配置缺失product 表加逻辑删除字段查询流水时基于商品 ID 而非删除状态出库后库存变负数并发请求导致超卖service 层用库存检查 行级更新 SQL事务开启前端表格数据修改后不刷新直接改了原始数组而没有重新请求操作完成后重新调用 fetchList / 调用接口后替换list.value这里面最值得单独说的还是“库存变负数”的问题。毕设演示时通常是一个人点来点去很难触发并发但答辩老师如果问“多个收银员同时给同一款商品出库怎么办”你如果回答不上来就很减分。解决办法在 3.3 节已经写过就是用数据库行锁来保证更新原子性。把这个思路背熟属于必答题。5.3 答辩准备与后续扩展方向答辩时的自我介绍不要背一遍“我做了个库存管理系统”而是按“业务痛点 → 技术方案 → 核心实现 → 自我评价”四条线来讲。业务痛点就是人工管库存容易出错、信息不同步。技术方案就是前后端分离、JWT 鉴权、事务性库存流水。核心实现就讲入库出库的事务处理和报表统计。老师们高频追问的几个问题提前准备一下“商品库存字段放在 product 表怎么保证和流水一致”——事务内同时更新流水和库存异常回滚。“删除分类时分类下还有商品怎么办”——前端做级联校验后端也做数量检查有商品时禁止删除。“查询列表很慢怎么优化”——分页 索引字段上建立普通索引避免全表扫描。“为什么选 MySQL 而不是 Oracle”——开源免费、学习成本低、和 SpringBoot 生态匹配项目规模下性能完全够用。这套系统后续还能往很多方向扩展加一个销售收银模块就是小型进销存加一个采购单审核流程就是协同办公接上扫码枪就是真正的超市端应用。作为毕设先把核心链路跑通再挑一两个扩展点做出来完整度就很高了。最后说点个人的体会这种体量的项目真正花时间的不是写代码而是处理环境问题。我建议你拿到源码之后不要急着看业务代码先把 JDK17、Maven、MySQL、Node 这几样环境版本对齐把项目启动起来看到登录页了再逐模块去理解。源码演示之前一定自己完整跑一遍登录、入库、出库、盘点的流程确认数据库里的库存数字对得上。好几回我帮同学看问题最后发现只是数据库脚本只执行了一半或者 Nginx 配置文件里的端口写错了。把这套基础工作做扎实后面的路会顺畅很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑