SpringBoot+Vue3物资管理系统实践:从库存设计到前后端分离部署

发布时间:2026/10/2 10:00:50
SpringBoot+Vue3物资管理系统实践:从库存设计到前后端分离部署 在开发资源管理系统这个领域摸爬滚打了几年Java SpringBootVue3MyBatisMySQL这套前后端分离技术栈是我在实际项目中用得最顺手的一套组合。物资综合管理系统听起来就是标准的增删改查但真正落地时你会发现难点从来不在于CRUD本身而是库存台账的准确性、出入库流程的严谨性、以及多角色并发操作时的数据一致性。这篇博文以我完整开发过的一版物资综合管理系统为例把从数据库设计、后端接口开发到前端页面联调的整条链路拆开来讲包含可以直接参考的表结构、核心代码和踩坑记录适合正在做毕业设计、公司内部管理系统的开发者也适合想系统理解前后端分离项目完整流程的初学者。1. 项目整体设计与技术选型思路1.1 为什么选前后端分离而不是服务端渲染物资管理系统这类企业内部应用早期的做法是Thymeleaf或JSP做服务端渲染后端返回整个页面。但实际做过一次多人协作的项目之后我坚决转向了前后端分离。原因很实际前端工程师和后端工程师可以并行开发只要提前约定好接口文档前端用Mock数据先跑起来后端专注业务逻辑开发周期能缩短30%以上。另一个核心原因是部署和扩展的灵活性。后端只需要打一个Jar包扔到服务器上前端构建成静态资源用Nginx托管两边可以独立伸缩。比如库存报表模块并发高可以给后端单独加实例前端资源走CDN加速互不干扰。这套架构下后端只需通过JSON与前端通信前后端各自只关注自己那一层。看下这套系统的顶层架构示意浏览器 - Nginx静态资源 (Vue3构建产物) - Ajax/JSON - SpringBoot后端API - MyBatis - MySQL数据库1.2 技术栈选型的背后逻辑技术选型不是越新越好而是要看团队熟悉度和生态成熟度。我当时的选型逻辑是这样的技术组件选择方案核心理由后端框架SpringBoot 2.7.xstarter机制简化配置内嵌Tomcat快速启动持久层MyBatisSQL可控性最强复杂报表SQL能精细调优前端框架Vue3 ViteComposition API更适合复杂业务逻辑复用Vite构建快UI组件库Element Plus后台管理系统生态最完善表格、表单开箱即用状态管理PiniaVue3官方推荐去掉了Vuex的Mutation样板代码数据库MySQL 8.x成熟稳定InnoDB支持事务社区资料多认证方案JWT 拦截器无状态认证适合前后端分离扩展方便这里重点说一下MyBatis的选择。Spring Data JPA虽然开发效率高但在物资管理系统这种存在大量多表关联查询、动态条件筛选、报表统计的场景下JPA的自动SQL很难写出高效的查询语句。MyBatis的Provider和动态SQL能让SQL完全掌握在开发者手中比如库存台账的按月汇总、供应商供货统计这类复杂SQL我可以一条一条手动调优这是JPA很难做到的。2. 数据库设计与核心表结构拆解2.1 物资管理核心模块的业务边界先梳理一下物资管理系统到底要管什么。在我做的版本里核心用户角色分为三类仓库管理员负责入库、出库、盘点部门员工发起领用申请管理员负责系统配置和审批。围绕这三类角色系统拆成了下面几个核心模块基础档案物资分类、物资信息、供应商档案、仓库档案、计量单位入库管理采购入库、退货入库、生产入库每笔入库产生库存流水出库管理领用出库、销售出库同样需要严格的审批流库存管理实时库存查询、库存上下限预警、周期盘点报表中心库存台账、收发存汇总表、供应商供货统计这些模块看起来独立但数据流向是环环相扣的基础档案是业务的起点入库增加库存出库减少库存盘点调整库存差异报表从流水表聚合出经营数据。所以在建表之前我花了一周时间梳理数据流而不是急着写建表语句。2.2 核心表设计库存台账与流水分离库存管理最忌讳一张库存表到处更新直接把出入库数量写在库存表上出问题根本没得查。我的设计思路是库存台账只存当前结余流水表记录每一笔变动两者通过事务保证一致性。库存表设计要点CREATE TABLE inventory ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, material_id bigint NOT NULL COMMENT 物资ID, warehouse_id bigint NOT NULL COMMENT 仓库ID, quantity decimal(12,2) NOT NULL DEFAULT 0 COMMENT 当前库存数量, locked_quantity decimal(12,2) NOT NULL DEFAULT 0 COMMENT 锁定数量, min_stock decimal(12,2) DEFAULT NULL COMMENT 库存下限, max_stock decimal(12,2) DEFAULT NULL COMMENT 库存上限, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_material_warehouse (material_id, warehouse_id) ) ENGINEInnoDB COMMENT库存台账表;这里的关键是那个唯一索引uk_material_warehouse它保证同一物资在同一个仓库只有一条库存记录这是防止并发插入出现重复库存的底线。locked_quantity字段是给销售出库场景用的——客户下单先锁定库存出库时再真正扣减避免超卖。流水表设计CREATE TABLE stock_flow ( id bigint NOT NULL AUTO_INCREMENT, material_id bigint NOT NULL COMMENT 物资ID, warehouse_id bigint NOT NULL COMMENT 仓库ID, flow_type tinyint NOT NULL COMMENT 流水类型 1-入库 2-出库 3-盘点调整, quantity decimal(12,2) NOT NULL COMMENT 变动数量正数入负数出, before_quantity decimal(12,2) NOT NULL COMMENT 变动前库存, after_quantity decimal(12,2) NOT NULL COMMENT 变动后库存, biz_order_no varchar(64) NOT NULL COMMENT 业务单号如入库单号, create_user varchar(32) DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_material_time (material_id, create_time), KEY idx_biz_order_no (biz_order_no) ) ENGINEInnoDB COMMENT库存流水表;流水表的before_quantity和after_quantity是查账神器出现库存对不上时可以精确回溯每一笔变动前后的值。biz_order_no字段把流水和具体的出入库单据关联起来前端点开一笔流水直接跳到对应的单据详情。2.3 入库与出库单的双层结构业务单据我采用的是主从表结构也就是“主表 明细表”这是标准的ERP设计套路。主表存单号、单据类型、供应商、经办人、状态、审批人明细表存该单据下的具体物资条目、数量、单价。为什么要拆两张表因为一张入库单可能包含十几条物资如果全塞在一张表里字段冗余严重而且查询单次单据时需要把明细逐条拆出来数据结构也难受。入库单主表和明细表不在这里放完整SQL了核心就是外键order_id关联主表明细表通过order_id索引关联查询。这里给一句经验单号字段一定要使用业务上有意义的编码比如RK202501150001含义是“入库单 2025年1月15日第1号”不要用自增ID直接当单号拿给用户看客户报单号时说“第245条记录”是很反人类的。3. 后端核心实现SpringBootMyBatis实战3.1 工程分层与模块化设计后端工程我用了经典的四层结构Controller层接收请求、Service层业务逻辑、Mapper层数据访问、Entity/DTO层数据载体。SpringBoot的包结构这样划分com.company.mms ├── controller # REST接口层 │ ├── StockInController.java │ ├── StockOutController.java │ └── InventoryController.java ├── service # 业务层 │ ├── StockInService.java │ ├── StockInServiceImpl.java │ └── InventoryService.java ├── mapper # MyBatis接口 XML │ ├── StockInMapper.java │ └── mapper/xml/StockInMapper.xml ├── entity # 数据库实体 ├── dto # 前端请求响应对象 ├── common # 统一响应封装、异常处理 ├── config # 配置类CORS、拦截器、事务 └── utils # 工具类JWT、Excel导出这样分层的核心收益是职责清晰。Controller只做参数校验和结果包装不碰业务Service只做业务编排不写SQL真正的SQL全在Mapper的XML里。遇到问题时能快速定位是哪个环节出了错而不是在一个几百行的类里大海捞针。3.2 入库与出库的事务一致性物资系统的灵魂在于出入库操作而这里的核心难点是多条SQL必须在一个事务里完成哪一步失败都不能留下脏数据。我以前犯过的错误是先插入入库单主表再插入入库明细最后更新库存中间没有加事务结果更新库存时抛了异常单据数据已经写进去了库存却没变账实不符的烂账就是这么来的。后来我在入库操作上用了Spring的Transactional注解并且指定了回滚策略Override Transactional(rollbackFor Exception.class) public void createStockIn(StockInDTO dto) { // 1. 生成入库单号 String orderNo generateOrderNo(RK); // 2. 插入入库主表 stockInMapper.insertOrder(orderNo, dto.getSupplierId(), dto.getOperator()); // 3. 循环插入入库明细同时更新库存 for (StockInItemDTO item : dto.getItems()) { stockInMapper.insertOrderDetail(orderNo, item.getMaterialId(), item.getQuantity()); inventoryMapper.increaseStock(item.getMaterialId(), dto.getWarehouseId(), item.getQuantity()); stockFlowMapper.insertFlow(...); } }这里有两个细节值得注意。第一rollbackFor Exception.class必须显式声明因为Spring的声明式事务默认只对RuntimeException回滚受检异常Exception默认是不回滚的这个坑我踩过一次线上出现了半个订单入账、半个订单没入账的情况。第二循环里逐条更新库存的方式在数据量小时没问题如果要优化性能可以在循环外先算好各物资的总增减量再批量更新。3.3 MyBatis动态SQL与复杂报表查询物资管理系统的查询条件变化多端比如库存查询可能要求“按物资名称模糊搜索 按分类筛选 按库存区间过滤 按仓库筛选”这时候如果每个组合条件写一条SQL代码量直接爆炸。MyBatis的动态SQL就是解决这个问题的select idselectInventoryPage resultTypecom.company.mms.dto.InventoryVO SELECT i.material_id, m.material_name, m.specification, m.unit, i.warehouse_id, w.warehouse_name, i.quantity, i.min_stock FROM inventory i LEFT JOIN material_info m ON i.material_id m.id LEFT JOIN warehouse w ON i.warehouse_id w.id where if testmaterialName ! null and materialName ! AND m.material_name LIKE CONCAT(%, #{materialName}, %) /if if testcategoryId ! null AND m.category_id #{categoryId} /if if testwarehouseId ! null AND i.warehouse_id #{warehouseId} /if if testminQuantity ! null AND i.quantity gt; #{minQuantity} /if /where ORDER BY i.update_time DESC /selectwhere标签会自动处理前面多余的AND比手写WHERE 11干净得多。报表模块的月收发存汇总SQL则是利用CASE WHEN把流水表按类型归集这里的核心技巧是优先在SQL层面做聚合而不是把流水捞到内存再用Java循环MySQL对聚合计算的效率远超Java循环尤其流水量上了十万条之后差别非常明显。3.4 JWT认证与权限控制物资系统的操作权限要细粒度控制仓库管理员不能审单部门员工不能维护物资档案。我用的方案是JWT生成Token 拦截器校验 注解鉴权三层结构。登录成功时后端签发Token里面包含用户ID、用户名、角色代码String token Jwts.builder() .setSubject(user.getUsername()) .claim(userId, user.getId()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();前端把Token存在localStorage中每次请求在Axios拦截器里带上Authorization: Bearer xxx。后端拦截器负责解析Token如果过期则返回401前端捕获到401后跳转登录页。细粒度的权限判断放在接口方法上用自定义注解RequirePermission(stock_in:create)配合AOP实现方法级别的权限校验。这个方案在单一后端服务下完全够用不需要引入Spring Security全家桶学习成本低排查也直观。4. 前端核心实现Vue3Element Plus实战4.1 前端工程化搭建与请求封装Vue3项目我使用的是Vite搭建相比WebpackVite的开发服务器启动速度真的是肉眼可见的快改代码热更新基本是毫秒级响应。工程结构上我按“视图 - 组件 - 状态 - 请求”四个维度组织src ├── api # 接口请求模块按业务划分 │ ├── stockIn.js │ ├── stockOut.js │ └── inventory.js ├── views # 页面级组件 │ ├── stockIn/ │ ├── stockOut/ │ └── inventory/ ├── components # 复用组件物资选择器、分类树 ├── stores # Pinia状态 │ └── user.js ├── router # 路由配置含动态路由和守卫 └── utils └── request.js # Axios实例封装Axios封装是个必做的环节不封装的后果是每个页面都要写一遍axios.get(url, { headers: { Authorization: ... }})代码重复不说后端换接口路径时改动量巨大。我的一致做法是// utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动附加Token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误码 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { ElMessage.warning(登录已过期请重新登录) router.push(/login) } else { ElMessage.error(网络异常请稍后重试) } return Promise.reject(error) } )4.2 核心页面实现出入库单的双表联动出入库单的页面是系统中最复杂的交互场景要求是顶部是单据信息表单中间是物资明细的表格底部是合计信息用户在明细表格里连续添加物资时主表的总金额需要实时汇总。这里我用Vue3的组合式API处理得非常顺手。一个管理多行明细的关键代码是给每行明细维护响应式引用而不是用普通对象数组import { ref, computed } from vue const detailRows ref([]) function addRow() { detailRows.value.push({ materialId: null, materialName: , quantity: 1, price: 0.00, amount: 0.00 }) } const totalAmount computed(() { return detailRows.value.reduce((sum, row) sum row.quantity * row.price, 0) })物资选择器的联动是个容易忽视的细节选了物资之后系统需要自动带出规格型号、计量单位、默认仓库的可用库存这些数据都来自后端接口。我设计了一个getMaterialInfo接口选择器回填之后再去查库存并在表格里显示“当前可用库存”列方便仓库管理员在录单时一眼判断库存是否充足。4.3 动态生成表单与前端校验不同业务的单据字段差异很大采购入库需要供应商字段领用出库需要领用人字段报废出库需要备注字段。我用的方案是后端根据业务类型返回表单配置JSON前端用动态渲染组件的方式生成表单。这个方案虽然前期开发多花了一些时间但新增业务类型时前端完全不用改代码灵活性非常好。校验部分用Element Plus的Form规则其中库存数量的校验可以自定义const rules { quantity: [ { required: true, message: 请输入数量, trigger: blur }, { validator: (rule, value, callback) { if (value 0) { callback(new Error(数量必须大于0)) } else if (value currentStock.value) { callback(new Error(超出当前库存${currentStock.value})) } else { callback() } }, trigger: blur } ] }这里有个经验凡是需要从后端拿数据参与校验的逻辑不能单纯依赖前端校验后端接口必须做同样的校验兜底否则用户绕过前端直接调接口就能把库存扣成负数。5. 常见问题与排查技巧实录5.1 开发与部署中的典型问题问题一跨域请求报CORS错误。前后端分离项目最常见的问题前端请求后端接口时被浏览器拦截报Access-Control-Allow-Origin相关错误。解决方式是在SpringBoot中配置跨域过滤器Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }但要注意生产环境用Nginx反向代理时通常同源就不需要CORS配置了。我的建议是后端保留CORS配置用于开发环境生产环境通过Nginx配置/api指向后端服务这样前端请求同源路径不会触发跨域。问题二MySQL连接时报Public Key Retrieval is not allowed。这个报错我用MySQL 8.0.X时经常遇到原因是默认认证插件是caching_sha2_password而客户端连接时不允许自动获取公钥。解决办法是在JDBC连接串上加参数allowPublicKeyRetrievaltrueuseSSLfalse。问题三MyBatis驼峰映射无效查询结果字段全是null。这是新手必踩的坑。数据库字段是下划线风格material_nameJava属性是驼峰materialName如果没有开启驼峰映射查询结果就是null。需要在application.yml中配置mybatis: configuration: map-underscore-to-camel-case: true5.2 数据准确性排查实战生产环境出现过一次库存对不上的情况总结了一套查账方法论。第一步先查流水表找到这笔物资最近一笔变动确认是在哪个业务单据发生的第二步打开对应的出入库单看明细数量和操作人第三步检查是不是存在“未提交但库存已经变动”的半截数据这种情况通常是代码里事务没控制好或者接口被重复调用。排查时有用的一条SQL是查某物资在某时间段的收发存SELECT material_id, SUM(CASE WHEN flow_type 1 THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN flow_type 2 THEN quantity ELSE 0 END) AS total_out, SUM(CASE WHEN flow_type 1 THEN quantity WHEN flow_type 2 THEN -quantity ELSE 0 END) AS net_change FROM stock_flow WHERE material_id #{materialId} AND create_time BETWEEN #{startTime} AND #{endTime} GROUP BY material_id如果这条SQL算出的结余和库存表对不上问题基本就锁定在库存更新环节了。发现账实不符时另一个关键点是检查有没有并发的两个事务同时对库存表做更新。MyBatis的乐观锁插件可以解决这个问题给库存表加version字段更新时带上WHERE version #{oldVersion}更新成功后再把版本号加一这样就能避免同时扣两次库存的问题。5.3 性能优化与前端体验提升最后一个值得说的点权限路由。Vue3的router可以配置动态路由根据用户角色动态生成菜单。基础思路是路由meta中标记角色权限router.beforeEach守卫中判断当前用户是否有权限继续访问。我用了一张权限表来管理菜单和多级按钮权限页面只渲染用户有权限的按钮避免用户看到“删除”、“审批”按钮点了却提示没权限的尴尬。在表格性能方面1万条以上的数据直接渲染DOM会卡顿我的做法是后端分页 前端用表格的懒加载只在滚动到底部时请求下一页数据。Element Plus的表格在做大数据量展示的时候还可以开启虚拟滚动但受限于浏览器性能我更推荐后端分页方案既省前端资源又能实现快速搜索定位。6. 上线部署与后续扩展建议6.1 从Jar包到Nginx的完整部署流程后端打包部署的核心命令# 跳过测试打包 mvn clean package -DskipTests # 后台启动指定生产环境配置 nohup java -jar mms-server-1.0.0.jar --spring.profiles.activeprod mms.log 21 前端构建npm run build构建产物在dist目录把该目录上传到Nginx的web目录下并做三个关键配置server { listen 80; server_name your-domain.com; # 前端静态资源 root /usr/share/nginx/html; index index.html; # 解决刷新404SPA路由需要回退回index.html location / { try_files $uri $uri/ /index.html; } # 接口反向代理避免跨域 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files这个配置是SPA部署的关键Vue3用的是History模式路由如果Nginx没有这一行规则刷新页面时会出现404很多人部署完页面访问正常一刷新就白屏就是漏了这个配置。6.2 系统扩展的可演进方向物资管理系统做完了基础版本后续扩展有几个方向非常值得做。第一个是对接扫码枪出入库操作通过扫描条形码自动识别物资编号效率提升非常明显这是仓库现场落地时最常提的需求。第二个是Excel批量导入导出前端用SheetJS或直接后端用EasyExcel实现管理人员只需要维护Excel就可以完成大批量的期初库存导入。第三个是消息通知当库存低于预警值后往钉钉或企业微信发机器人消息我实际开发中用的SpringBoot整合Webhook就能实现逻辑不复杂但业务价值很高。这些扩展都是在现有架构上做加法SpringBoot的模块化优势和Vue3的组件复用能力让每次扩展都能控制在很短的周期内这也是当初选这套技术栈的最核心原因。最后再分享一个我的个人操作习惯物资系统的开发环境、测试环境、生产环境三套配置一定要分开通过application-{profile}.yml管理。我见过有的团队在开发环境把数据库连接直接改成线上地址然后跑了一条测试数据到生产库这种事故才是真正让人头痛的。配置分离之外建议给SpringBoot开启Actuator的健康检查端点运维监控系统可以直接探测系统状态出了问题先看健康指标再查日志效率会高很多。