基于Vue+Spring Boot的超市仓库进销存系统开发全流程
发布时间:2026/10/1 18:26:52来源:尧图网络
最近把一个超市仓库进销存管理系统完整做了一遍项目前端用 Vue后端用 Spring Boot从需求梳理到数据库设计再到前后端联调、打包部署整个链路都走通了。这个系统放在企业超市仓库场景里核心就是管住三件事商品能进来多少、卖出去多少、现在还剩多少。相比用 Excel 管库存最大的区别是每一笔变动都有单据和流水库存数量可以实时算出来而不是等月底盘点才发现对不上。这套东西适合两类人参考一类是正在做 Vue Spring Boot 毕设或课设的同学另一类是中小公司里需要快速搭建一套内部进销存后台的开发者。超市仓库这个场景非常典型商品多、批次杂、出入库频繁比纯卖货的电商后台更容易暴露库存设计的坑。我下面把从需求梳理到具体实现的过程完整拆开讲包括表结构怎么设计、库存流水和库存表怎么配合、前后端各自怎么落地还有我实际踩过的那些坑应该能帮你少走不少弯路。1. 先把超市仓库的业务流程盘清楚很多人一上来就建项目写代码这是大忌。进销存系统不是简单的增删改查业务模型没理清后面返工成本很高。我建议拿到需求后先做一件事把仓库里每天都在发生的动作列出来再抽象成系统里的几个核心单据。1.1 超市仓库进销存的业务核心超市仓库跟普通贸易公司仓库不一样的地方在于SKU 数量多一般至少几百上千种商品有保质期和批次要求生鲜、食品类尤其严格每天有销售出库也有供应商送货入库还有退货、报损、盘点这些调整动作。进销存系统的核心就三个字进、销、存。进是指采购入库和采购退货销是指销售出库和销售退货存是指库存管理包括实时库存查询、库存盘点、报损调整、上下限预警、保质期预警。围绕这三个核心业务单据可以分成几类我在设计时用的是这样一组单据类型业务动作库存影响说明采购入库单供应商送货入库正库存对应采购订单生成入库流水采购退货单把不合格商品退回供应商负库存减少对应批次库存销售出库单门店或客户提货负库存对应销售订单生成出库流水销售退货单客户退回商品正库存商品按原批次重新入库盘点单实际库存与账面核对修正盘盈补库存盘亏扣库存报损单过期、损坏商品报废负库存单独记录便于统计损耗一开始我把所有库存变动都做成一张流水表单据只存单头信息后来发现不够用。因为盘点、报损和出入库业务字段差别太大硬塞一张表会导致大量空字段。所以我采用了“单据主表 单据明细表 库存流水表”的三段式设计每个业务都有自己的单表和明细表最后统一往库存流水表里写记录。1.2 用户角色和核心流程超市仓库管理系统里角色可以粗分成四类系统管理员、仓库管理员、采购/供应商对接人、销售/门店操作员。管理员负责用户和基础数据维护仓管员负责入库、盘点、库存查询采购员维护供应商和进货销售端做开单出库。我刚开始把权限做得特别细每个按钮都做权限控制后来发现小项目根本用不上且维护成本高。实际项目中角色控制在“页面功能”这一层就够用比如出库单页面只有销售和仓管能进用户管理只有管理员能进商品管理所有操作员都能看但新增修改可以限定给管理员。这一点在下面的后端鉴权部分还会详细说。核心流程我用文字描述一遍这个顺序和系统单据流转是一致的商品基础信息先建档包括名称、条码、规格、保质期、默认进价售价、库存上下限。采购流程创建采购订单 → 供应商按单送货 → 仓库验收 → 生成采购入库单 → 库存增加 → 流水记录入库。销售流程店员开销售单 → 选择商品和数量 → 校验库存充足 → 生成销售出库单 → 库存减少 → 流水记录出库。库存修正流程定期盘点 → 对比账面数量 → 生成盘点单 → 系统按盘盈盘亏调整库存 → 流水记录盘点差异。这套流程跑通之后最直接的效果就是“任何一个时间点查库存结果都是可信的”。如果库存表数据和流水对不上还能通过流水追溯是哪笔单据出了问题。1.3 技术选型为什么是 Vue Spring Boot这个组合在今天已经不算新鲜但胜在稳定、生态成熟、招人容易。Vue 负责前端单页应用表格、弹窗、表单、路由这些都做得非常顺手Spring Boot 负责提供 RESTful 接口内置 Tomcat配置比传统 SSH 那一套简单太多。两个技术栈配在一起前后端分离开发效率非常高我后来交付的项目也都是这套组合。具体到本次项目前端我用的是 Vue 3 Element Plus Vue Router Pinia后端用 Spring Boot 2.7 MyBatis-Plus MySQL 8.0。有人会问为什么不用 Java 的 Spring Cloud 或者更复杂的前后端不分离模板答案是超市仓库这类系统属于典型的中后台管理系统没有高并发、没有微服务诉求核心是业务完整性和数据准确性。用 Vue Spring Boot 能够以最低复杂度覆盖全部需求后期真要扩展Spring Boot 的模块化设计也留了余地。还有一个小建议如果是自己做毕设数据库持久层用 MyBatis-Plus 就好不用手写太多 XML如果是为了深入学习倒是可以手写 MyBatis 的 XML 来理解 SQL 是怎么执行的。2. Vue Spring Boot 的系统架构和数据模型设计架构和数据模型是整棵树的根前期想清楚了后面写业务代码就是往框架里填东西。这个章节我希望重点讲清楚整体架构怎么走数据库有哪些关键表库存表和流水表为什么必须同时存在。2.1 前后端分离的整体架构这个项目是典型的前后端分离架构整个请求路径大致是这样的浏览器 - Nginx静态托管 Vue 打包文件反向代理 /api - Spring Boot 接口 Spring Boot - MyBatis-Plus - MySQL 8.0 Spring Boot - Redis可选用于 token 黑名单和热点数据缓存前端只负责渲染和交互后端只负责业务逻辑和数据持久化。两者通过 JSON 格式的 Restful API 通信前端用 axios 调用接口后端返回统一结构code、message、data。Composing 的规则是成功 code 为 200业务失败 code 为 400 或自定义错误码未登录或 token 过期返回 401。在代码层级上后端我是按传统的三层架构组织的Controller 接收请求、Service 处理业务、Mapper 访问数据库。额外加了 DTO数据传输对象层出入库的请求对象不会直接绑定到数据库实体防止前端传了多余字段污染数据。前端也没有所有代码堆在一个 Component 里而是页面组件 API 模块 状态管理三层。这样分层的好处是如果后续要换数据库或者加消息队列Controller 和 Service 基本不用动如果前端要把 Vue 换成其他框架后端接口也无感知。2.2 数据库表设计数据库设计是整个项目里最花时间的部分我反复改了三次才定稿。核心原因是进销存系统的数据关系比较密集商品、单据、库存、供应商、客户、用户这六个域相互关联一旦设计不完整写代码时会发现这个字段没加、那个表缺索引。我最终的核心表结构如下每张表都标了主要用途用户表sys_userid、用户名、密码BCrypt 加密、真实姓名、角色、状态、创建时间。商品分类表product_categoryid、分类名称、上级分类 id、排序。超市商品分类一般是两级比如“食品饮料”下再分“饮料”、“休闲零食”。商品表productid、分类 id、商品名称、条码、规格、单位、进价、售价、库存上限、库存下限、保质期天数、状态、备注。供应商表supplierid、名称、联系人、联系电话、地址、状态。客户表customerid、名称、联系人、联系电话、地址、状态销售单可能关联客户。采购入库单表purchase_orderid、单号、供应商 id、入库仓库、备注、操作人、创建时间。采购入库单明细表purchase_order_itemid、入库单 id、商品 id、数量、进价、金额。销售出库单表sale_orderid、单号、客户 id、出库仓库、备注、操作人、创建时间。销售出库单明细表sale_order_itemid、出库单 id、商品 id、数量、售价、金额。库存表stockid、商品 id、仓库 id、当前数量、版本号。库存流水表stock_transactionid、商品 id、仓库 id、变动类型入库、出库、盘点、报损、变动前数量、变动数量、变动后数量、业务单号、操作人、创建时间。盘点单表stock_check和盘点明细表stock_check_item存放每次盘点的账面数、实盘数、差异数、处理状态。商品表的条码字段一定要加唯一索引超市商品几乎都有条码收银和入库时直接扫码快速匹配。库存表则要建复合唯一索引(product_id, warehouse_id)避免同一个商品在同一个仓库出现多条库存记录。我提供一下商品表和库存表的简化 DDL可以直接复制参考CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, category_id bigint NOT NULL COMMENT 商品分类id, product_name varchar(128) NOT NULL COMMENT 商品名称, barcode varchar(64) DEFAULT NULL COMMENT 商品条码, specification varchar(64) DEFAULT NULL COMMENT 规格型号, unit varchar(20) DEFAULT NULL COMMENT 计量单位, purchase_price decimal(10,2) DEFAULT NULL COMMENT 进货价, sale_price decimal(10,2) DEFAULT NULL COMMENT 销售价, low_stock int DEFAULT 0 COMMENT 库存下限, high_stock int DEFAULT 0 COMMENT 库存上限, expiry_days int DEFAULT NULL COMMENT 保质期天数, status tinyint DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id), UNIQUE KEY uk_barcode (barcode) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; CREATE TABLE stock ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL COMMENT 商品id, warehouse_id bigint NOT NULL COMMENT 仓库id, quantity int NOT NULL DEFAULT 0 COMMENT 当前库存数量, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_product_warehouse (product_id, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表;所有金额字段全部用decimal(10,2)不要用 float 或 double否则算钱的时候会出精度问题。所有单据号不要直接用数据库自增 id即使表的主键是自增也要额外生成一个业务单号比如PO202405120001这样线下沟通时提单号更方便也更好做流水关联。2.3 库存流水与库存表怎么配合这是进销存系统最核心的设计问题为什么不能直接更新库存数量就完事只更新库存表确实简单但一旦数据不对根本不知道哪笔操作导致的问题。超市每天要处理几十上百笔出入库如果库存数量差了一件没有流水记录就得把所有历史单据翻一遍这非常痛苦。所以我的做法是库存表只保存当前最新数量流水表保存每次变动的明细。每一笔库存变动在同一个数据库事务里做两件事1. 向 stock_transaction 插入一条流水记录变动前数量、变动数量、变动后数量、业务单号 2. 更新 stock 表根据流水方向增加或减少当前数量以一次出库 5 件商品为例流水记录大概是变动前 20变动 -5变动后 15。库存表从 20 更新到 15。这两步要么都成功要么都失败必须用事务包起来。流水表还有一个隐性的好处可以通过流水重算库存。如果哪天库存表和流水不一致我可以写一段 SQL 或者脚本根据流水表按时间累加数量重算每个商品的当前库存再把 stock 表修正回来。这个“自救”能力非常值钱。顺便提一句无论是入库还是出库我都建议在操作前先用SELECT ... FOR UPDATE锁住库存行再来后面的更新。后面并发问题那一节我会再展开讲这里先记住一个原则库存操作必须防超卖不能裸更新。3. Spring Boot 后端从登录鉴权到库存流水后端是系统的中枢这里我从项目搭建开始把登录鉴权、入库出库逻辑、报表统计和库存预警的套路一个一个讲配合代码片段方便你照着改。3.1 项目搭建和基础配置我建 Spring Boot 项目时直接用的 Spring InitializrJava 版本选 8因为要兼容一些老环境。依赖这边最常用的几个必须加上spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、lombok、jjwt做 JWT 用。pom.xml里这几个关键依赖可以这样写dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version /dependencyapplication.yml里面的配置我习惯分成三块来写数据源信息、MyBatis-Plus 配置、自定义 JWT 配置。数据源这一块注意加上时区参数否则容易出现数据库时间和 Java 时间差 8 小时的问题。spring: datasource: url: jdbc:mysql://localhost:3306/supermarket_stock?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key-please-change-in-production expire-hours: 24统一返回结果类Result很简单但很重要。我见过太多项目接口返回格式五花八门前端没法统一解析。我在项目里就写了三行核心字段Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(成功); r.setData(data); return r; } public static T ResultT fail(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }Controller 每个接口都返回Result配合全局异常处理器前端 axios 拦截器里只要判断res.code就可以了。3.2 登录认证与权限控制后端鉴权我这一版没有引入 Spring Security因为项目本身不大引入全套 Spring Security 反而要配一堆过滤器链和配置类。我选择的是 JWT HandlerInterceptor 的组合逻辑直观代码量可控。登录流程是前端传用户名和密码后端查sys_user校验密码用的是 BCrypt。验证通过后生成 JWT里面放用户 id 和角色然后返回给前端前端存在本地后续每个请求都在请求头带上Authorization: Bearer token。生成 token 的代码示意public String generateToken(SysUser user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim(role, user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expireHours * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); }权限控制我用一个自定义拦截器完成校验 token 是否有效然后把用户信息放进 ThreadLocal方便 Service 层拿到当前操作人。角色过滤也放在拦截器里给需要控制角色的接口加上自定义注解即可。有一个容易踩的坑SecretKey的长度不要低于 32 字节否则某些 JWT 版本的 HS256 会报 key 太短异常。第一次我用的字符串太短启动之后生成 token 直接抛异常改成 32 字节以上才正常。拦截器里对登录接口和静态资源直接放行其他/**都进入 token 校验。校验失败时不要直接返回 JSON 错误码 401否则前端 axios 只会收到一个对象不会正常走业务回调。我是在拦截器的preHandle里直接写响应response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSONUtil.toJsonStr(Result.fail(401, 未登录或登录已过期)));前端拿到 401 后统一跳到登录页这个细节后面也还会说。3.3 入库、出库和库存查询的核心逻辑后端最核心的代码都集中在库存变动这部分。入库单和出库单虽然是两个不同的业务但底层逻辑可以抽象成一段通用的库存更新方法。我先说入库。入库单提交的时候前端把整个单据的单头信息和明细列表一起传过来后端 Service 接收后按顺序做这几件事1. 根据单号查重防止同一单据重复提交 2. 遍历明细列表逐条查询商品校验商品状态是否正常 3. 对库存表加锁SELECT FOR UPDATE 4. 插入库存流水记录变动前数量、入库数量、变动后数量 5. 更新库存表如果没有库存记录就初始化一条 6. 保存单据主表和明细表这几个步骤必须全部放在一个Transactional事务里。我画个简化代码版本Transactional(rollbackFor Exception.class) public Long createPurchaseOrder(PurchaseOrderDTO dto) { // 1. 生成单号 String orderNo generateOrderNo(PO); // 2. 保存单头 PurchaseOrder order new PurchaseOrder(); order.setOrderNo(orderNo); order.setSupplierId(dto.getSupplierId()); purchaseOrderMapper.insert(order); // 3. 遍历明细 AtomicBoolean error new AtomicBoolean(false); for (PurchaseOrderItemDTO item : dto.getItems()) { Product product productMapper.selectById(item.getProductId()); if (product null || product.getStatus() ! 1) { throw new BusinessException(商品不存在或已停用); } Stock stock stockMapper.selectByProductIdForUpdate(item.getProductId(), dto.getWarehouseId()); int beforeQty stock null ? 0 : stock.getQuantity(); int afterQty beforeQty item.getQuantity(); // 4. 插入流水 StockTransaction trans new StockTransaction(); trans.setProductId(item.getProductId()); trans.setType(IN); trans.setBeforeQty(beforeQty); trans.setChangeQty(item.getQuantity()); trans.setAfterQty(afterQty); trans.setOrderNo(orderNo); stockTransactionMapper.insert(trans); // 5. 更新库存 if (stock null) { Stock newStock new Stock(); newStock.setProductId(item.getProductId()); newStock.setWarehouseId(dto.getWarehouseId()); newStock.setQuantity(afterQty); stockMapper.insert(newStock); } else { stock.setQuantity(afterQty); stockMapper.updateById(stock); } } return order.getId(); }出库逻辑和入库类似但有一个关键环节判断库存是否充足。我给出的做法是更新库存的 SQL 直接加条件quantity ?否则更新不到行就说明库存不够UPDATE stock SET quantity quantity - #{outQty}, version version 1 WHERE product_id #{productId} AND warehouse_id #{warehouseId} AND quantity #{outQty}这样从数据库层面保证了不会出现负库存。出库流水里的变动数量写负数变动后数量是变动前减去出库数量这样报表计算更有语义。还有一种情况是库存查询接口。因为库存表只保留当前数量所以查询逻辑很直接关联商品表和分类表加上搜索条件。如果商品是停用状态查询时默认过滤掉。分页用 MyBatis-Plus 的Page对象前端传current和size后端返回总条数和列表。3.4 报表统计与库存预警超市仓库管理者最关心的报表是销售了多少钱、哪些商品卖得好、库存什么时候需要补货、哪些商品快过期了。这些统计如果用 Java 内存计算会很低效直接用 SQL 聚合最快。日销售统计接口我写的是这样的Select(SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS date, SUM(total_amount) AS amount FROM sale_order WHERE create_time #{startDate} AND create_time #{endDate} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY date) ListSalesDailyVO selectDailySales(Param(startDate) String startDate, Param(endDate) String endDate);同理销量排行可以按sale_order_item表分组用 SUM 算出每个商品的销量和销额再 JOIN 商品表把名称和条码带出来。库存预警也有两条 SQL 思路一是库存低于下限的预警二是临期商品预警。临期预警需要设定一个阈值天数比如 90 天内到期的商品都显示SELECT * FROM product WHERE expiry_days IS NOT NULL AND DATE_ADD(create_date, INTERVAL expiry_days DAY) BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY)这套预警做出来后前端只需要在库存看板页面调接口把返回的预警列表用红色标出来。虽然只是查了几条 SQL但实际使用中价值非常大客户一看就知道哪类商品该补货、哪批商品该处理。4. Vue 端落地商品、单据和库存看板前端部分如果你在 Vue 里写过几天后台页面其实大部分都是机械工作。但项目初始化、路由守卫、axios 封装、页面组件组织这些环节有一定“套路”照着来效率最高。4.1 项目初始化和路由配置我用 Vite 创建 Vue 3 项目命令是npm create vitelatest supermarket-frontend -- --template vue。装依赖时除了vue-router和pinia还有element-plus和axios。Element Plus 的全局引入方式适合快速开发但项目大了之后建议按需引入打包体积能小不少。目录结构我习惯这么安排简单清楚src/ api/ // 每个业务域一个 API 文件 router/ // 路由配置 store/ // Pinia 状态 views/ // 页面组件 components/ // 通用组件 utils/ // axios 封装、工具函数路由配置里我会把登录页和主布局分成两层。主布局是一个壳里面放侧边栏、顶部栏和内容区所有业务页面都在壳下面。统一用meta.requiresAuth标记哪些页面需要登录。const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layouts/MainLayout.vue), redirect: /dashboard, children: [ { path: dashboard, component: () import(/views/Dashboard.vue), meta: { title: 库存看板 } }, { path: product, component: () import(/views/Product.vue), meta: { title: 商品管理, roles: [ADMIN, STORE] } }, { path: purchase, component: () import(/views/Purchase.vue), meta: { title: 采购入库 } }, { path: sale, component: () import(/views/Sale.vue), meta: { title: 销售出库 } }, { path: stock, component: () import(/views/Stock.vue), meta: { title: 库存查询 } } ] } ]路由守卫的作用是没登录直接跳登录页登录了但角色不匹配就跳无权限页。这个守卫配合后端的权限拦截双保险。4.2 核心页面商品管理、入库单、出库单、库存看板商品管理页面是最典型的 CRUD 页面用el-table展示商品列表el-dialog做新增和编辑表单顶部放搜索条件。这里有一个经验之谈商品的条码输入框最好支持回车自动定位甚至可以做扫码输入。超市仓库场景里扫枪扫一下条码就自动查询商品体验完全不一样。入库单页面稍微复杂一点单头是供应商选择器、仓库选择器、备注单明细是动态表格用户点击“添加行”后选择商品、输入数量、自动带出进价和金额。商品选择我用了el-select加远程搜索输入关键字或条码就能搜到商品然后回填到当前行。全部填写完点提交前端把单头和明细整体提交到后端。出库单页面同理但商品选择时可以加一个“快捷扫码”小功能扫码后自动追加一条明细数量默认 1如果已经在表格里数量加一。这个设计在真实超市收银或仓库拣货场景里非常实用极大提高开单速度。库存看板页面我放了两块内容顶部是几个统计卡片显示商品总数、库存总量、低库存预警数、临期预警数下面是一个多 Tab 表格分别展示低库存商品、临期商品、最近出入库流水。这样管理者打开页面第一眼就能看到最重要的信息不需要再去翻报表。所有列表页我都用了分页组件数据量大时不能一次把几千条记录全查出来。前后端配合时前端传current和size后端返回total和records表格数据绑定时直接用records渲染分页变化时重新请求接口。4.3 接口封装与状态管理axios 封装是我每次都要提醒的重点。如果不封装每个组件里都写axios.get(/api/product/list)token 怎么带、401 怎么处理、错误提示怎么统一都会很乱。我的 axios 实例这样封装const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res } else if (res.code 401) { localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(未登录)) } else { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message || 请求失败)) } }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } else { ElMessage.error(网络异常请稍后重试) } return Promise.reject(error) } )这样封装完之后每个业务 API 文件就很干净。比如商品模块import request from /utils/request export function getProductPage(params) { return request.get(/product/page, { params }) } export function addProduct(data) { return request.post(/product, data) } export function updateProduct(data) { return request.put(/product, data) } export function deleteProduct(id) { return request.delete(/product/${id}) }Pinia 状态管理我用来存用户信息和侧边栏折叠状态没有把商品数据等业务数据放进去因为那些数据从接口拿就够了不需要全局共享。使用状态管理的基本思路是共享的才进 store不共享的留在组件里。如果所有东西都塞进 store反而会让调试变得复杂。5. 那些年踩过的坑联调、并发与部署前端和后端单独跑都没有问题一联调就会冒出一堆“连接”层面的错误。这一节我把最常见的几个问题统一列出来附上排查思路和解决方案。5.1 前后端联调阶段最常出的问题第一件事就是跨域。开发环境下前端地址是http://localhost:5173后端是http://localhost:8080浏览器默认阻止跨域请求。我推荐两种解法要么在后端写一个 WebMvcConfigurer 配置 CORS要么在前端 Vite 里配置 dev server proxy。我自己更习惯用前端代理因为生产环境 Nginx 本来就要做一层反向代理开发环境和生产环境行为能保持一致。// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }后端接口如果是以/api开头前端请求都走/api代理就自动转发到后端。这个方案在开发时不需要动后端非常方便。第二个常踩的坑是 Long 类型精度丢失。MyBatis-Plus 默认主键是雪花算法生成的长整型传到前端之后会被 JavaScript 解析成浮点数超过 16 位后精度丢失。库存单据主键一旦丢精度更新操作拿到的 id 就是错的。这个问题的解法是在实体上给主键字段加注解序列化时转成字符串JsonSerialize(using ToStringSerializer.class) private Long id;第三个坑是日期格式不统一。后端返回LocalDateTime如果没配 jackson 的格式前端拿到是一长串时间戳或者T分隔的格式。我上面已经在application.yml里配置了全局日期格式那边只要不是特殊字段基本都能统一。第四个坑是分页参数和返回字段不统一。MyBatis-Plus 的分页返回对象里默认字段是records、total、size、current前端封装接口时最好直接和后端定好字段名避免前端自己重新映射。如果实在要适配后端可以定义一个统一的 PageVO 来包装而不是把 MyBatis-Plus 的Page对象直接暴露给前端。5.2 库存并发与数据一致性问题库存数据这类写多读多的数据最容易出并发问题。比如两个收银员同时对同一个商品开销售单如果不加锁两个请求都读到库存 10各自扣减 5最后库存可能变成 5 而不是 0甚至变成负数。我在 3.3 里提到的SELECT ... FOR UPDATE是一种解法在同一个事务里先锁住stock行再执行后面的查询和更新。MySQL 默认使用 InnoDB悲观锁在这个场景很合适因为进销存系统的并发量并不算高加锁对性能影响可以忽略。更进一步我把更新库存的 SQL 改成了带条件和乐观锁版本号的双重保护UPDATE stock SET quantity quantity - #{outQty}, version version 1 WHERE product_id #{productId} AND warehouse_id #{warehouseId} AND quantity #{outQty}这样即使之前没有加锁MySQL 这一条语句也能从数据库约束层面阻止负库存。数据一致性另一个坑是事务自调用失效。比如 Service 里的一个方法写完了入库单然后调用同类里的另一个Transactional方法这个内部调用不会被 Spring 的事务代理捕获导致事务没生效。我一开始为了代码好看把库存更新方法抽到了另一个 Service 类里效果正常后来有人重构到同一个类里调用结果出单后库存没变排查半天发现事务回滚也没有生效。解决办法很简单事务方法不要同类自调用要调用就调用另一个 Spring Bean 的方法或者在同一个类里把整个流程放在一个事务方法中。库存不一致还有一个常见原因是定时任务跑批或数据库手动更新时间跨度很大导致流水记录顺序错乱。我给流水表加了create_time索引查询流水时严格按时间和 id 排序避免因为同一毫秒产生的记录顺序不稳定。5.3 部署时需要注意的地方开发完成后要不要部署取决于项目用途。毕设的话一般交给老师演示就行但如果你要交给公司或者做成 demo最好按生产环境标准来。前端先执行npm run build产物是dist目录把它扔到 Nginx 静态目录下面。Nginx 配置里有两个关键点第一个是前端路由刷新。Vue Router 默认是 history 模式直接访问子页面路径时 Nginx 会返回 404。需要加一个try_files规则把所有非静态文件请求全部回退到index.htmllocation / { root /usr/share/nginx/html/dist; index index.html; try_files $uri $uri/ /index.html; }第二个是/api反向代理把前端的 API 请求转发到后端的8080端口location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }后端打包时用mvn clean package -DskipTests生成jar包后直接java -jar运行。生产环境建议把application.yml里的数据库配置做成application-prod.yml启动时用--spring.profiles.activeprod指定。数据库密码不要明文放在代码库里可以用环境变量注入。部署后我遇到过一个隐蔽问题后端部分代码里写了本地路径比如文件上传目录结果 Linux 目录不存在导致上传失败。解决方法是把文件存储路径做成配置项启动时自动创建目录。如果项目里用到 Redis别忘了确认 Redis 服务地址和密码是否可用否则登录功能可能报无法连接。6. 这套系统后续还能怎么扩展项目做到这一步基础功能已经完整登录鉴权、商品管理、采购入库、销售出库、库存查询、报表统计、预警提示都有。但作为一套真实的超市进销存系统还是有很多可以继续加的点。首先是批次和保质期管理。目前库存表只存了商品总数量如果超市卖食品饮料同一种商品可能有多个批次不同批次进价不同、过期时间不同销售时还得先进先出。要做到这一点库存表要升级成“商品 批次 仓库”维度入库时记录每批的到期日期和数量出库时按到期日期排序优先扣最早批次。这个改动会影响出入库逻辑但业务价值非常高。其次是采购订单和审批流。现在采购直接生成入库单加入采购订单后采购员可以先下订单供应商送货后仓库按订单验收多一道流程防止未授权的采购行为。再配合简单的审批流比如超过一定金额要经理审批系统的权限和业务流程就更完整了。再次是数据导出。后台系统几乎都逃不过“导出 Excel”这个需求。加一个 EasyExcel 依赖把商品列表、出入库流水、销售报表按模板导出工作量不大但效果很明显。导出的时候注意大数据量不要一次性查出来放内存可以分批写。最后是库存定时盘点提醒。可以写一个定时任务每天扫一遍商品表找出最近一段时间没有动过的商品或者库存数量异常的品项生成待盘点清单推给仓管。这个功能没有太多技术难度用 Spring Boot 的Scheduled注解就能实现。从我个人的实际体会来说这种管理系统最难的不是某个技术点而是把业务理顺、数据留底。你只要把每次库存变动的因果关系记录清楚系统和流程就是可靠的。后面加任何新功能也都是在这个可靠数据基础上做扩展。踩过几次坑之后我最深的感受是宁可前期多花两天设计表和流程也不要后期靠加班去填数据不一致的洞。
网站建设高端定制企业官网