最近把一个超市仓库进销存管理系统完整做了一遍,项目前端用 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_user:id、用户名、密码(BCrypt 加密)、真实姓名、角色、状态、创建时间。 - 商品分类表
product_category:id、分类名称、上级分类 id、排序。超市商品分类一般是两级,比如“食品饮料”下再分“饮料”、“休闲零食”。 - 商品表
product:id、分类 id、商品名称、条码、规格、单位、进价、售价、库存上限、库存下限、保质期天数、状态、备注。 - 供应商表
supplier:id、名称、联系人、联系电话、地址、状态。 - 客户表
customer:id、名称、联系人、联系电话、地址、状态,销售单可能关联客户。 - 采购入库单表
purchase_order:id、单号、供应商 id、入库仓库、备注、操作人、创建时间。 - 采购入库单明细表
purchase_order_item:id、入库单 id、商品 id、数量、进价、金额。 - 销售出库单表
sale_order:id、单号、客户 id、出库仓库、备注、操作人、创建时间。 - 销售出库单明细表
sale_order_item:id、出库单 id、商品 id、数量、售价、金额。 - 库存表
stock:id、商品 id、仓库 id、当前数量、版本号。 - 库存流水表
stock_transaction:id、商品 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Initializr,Java 版本选 8,因为要兼容一些老环境。依赖这边,最常用的几个必须加上:spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、lombok、jjwt(做 JWT 用)。
pom.xml里这几个关键依赖可以这样写:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> </dependency>application.yml里面的配置我习惯分成三块来写:数据源信息、MyBatis-Plus 配置、自定义 JWT 配置。数据源这一块注意加上时区参数,否则容易出现数据库时间和 Java 时间差 8 小时的问题。
spring: datasource: url: jdbc:mysql://localhost:3306/supermarket_stock?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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 Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("成功"); r.setData(data); return r; } public static <T> Result<T> fail(Integer code, String message) { Result<T> 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;charset=UTF-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") List<SalesDailyVO> 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 vite@latest 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.html:
location / { 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.active=prod指定。数据库密码不要明文放在代码库里,可以用环境变量注入。
部署后我遇到过一个隐蔽问题:后端部分代码里写了本地路径,比如文件上传目录,结果 Linux 目录不存在导致上传失败。解决方法是把文件存储路径做成配置项,启动时自动创建目录。如果项目里用到 Redis,别忘了确认 Redis 服务地址和密码是否可用,否则登录功能可能报无法连接。
6. 这套系统后续还能怎么扩展
项目做到这一步,基础功能已经完整:登录鉴权、商品管理、采购入库、销售出库、库存查询、报表统计、预警提示都有。但作为一套真实的超市进销存系统,还是有很多可以继续加的点。
首先是批次和保质期管理。目前库存表只存了商品总数量,如果超市卖食品饮料,同一种商品可能有多个批次,不同批次进价不同、过期时间不同,销售时还得先进先出。要做到这一点,库存表要升级成“商品 + 批次 + 仓库”维度,入库时记录每批的到期日期和数量,出库时按到期日期排序,优先扣最早批次。这个改动会影响出入库逻辑,但业务价值非常高。
其次是采购订单和审批流。现在采购直接生成入库单,加入采购订单后,采购员可以先下订单,供应商送货后仓库按订单验收,多一道流程,防止未授权的采购行为。再配合简单的审批流,比如超过一定金额要经理审批,系统的权限和业务流程就更完整了。
再次是数据导出。后台系统几乎都逃不过“导出 Excel”这个需求。加一个 EasyExcel 依赖,把商品列表、出入库流水、销售报表按模板导出,工作量不大但效果很明显。导出的时候注意大数据量不要一次性查出来放内存,可以分批写。
最后是库存定时盘点提醒。可以写一个定时任务,每天扫一遍商品表,找出最近一段时间没有动过的商品或者库存数量异常的品项,生成待盘点清单推给仓管。这个功能没有太多技术难度,用 Spring Boot 的@Scheduled注解就能实现。
从我个人的实际体会来说,这种管理系统最难的不是某个技术点,而是把业务理顺、数据留底。你只要把每次库存变动的因果关系记录清楚,系统和流程就是可靠的。后面加任何新功能,也都是在这个可靠数据基础上做扩展。踩过几次坑之后我最深的感受是:宁可前期多花两天设计表和流程,也不要后期靠加班去填数据不一致的洞。