☰
SpringBoot+Vue+MyBatis+MySQL物资管理系统实战详解
2026/10/6 13:03:33 网站建设 项目流程

做物资管理系统,说实话这需求我已经接过好几次了。每次版本要求都不一样,但核心都绕不开那一套:入库、出库、库存台账、分类管理、供应商信息。这次用SpringBoot+Vue+MyBatis+MySQL这套经典组合重新搭一遍,顺便把实现过程中一些容易踩坑的细节记录下来。这套系统不是那种只有增删改查的玩具项目,而是把库存流水、事务控制、动态条件查询这些实际业务问题都考虑进去的完整方案。

1. 为什么选SpringBoot+Vue+MyBatis+MySQL这套组合来搭物资管理系统

1.1 一个典型物资管理系统的真实需求清单

很多人在做这类系统时容易犯一个毛病——拿到需求就开始写代码,结果数据库表设计得一塌糊涂,联调阶段才发现逻辑对不上。我一般在动手之前,会先把自己代入到实际使用场景里,把用户真正要做的事情列清楚。物资管理系统的核心使用场景其实就几类:

  • 物资管理员需要把新采购的物资录入系统,录入时要知道这是哪个分类、放哪个仓库、入库数量是多少、供应商是哪家、采购单价是多少。
  • 领用人需要申请领用物资,管理员审批后库存减少,系统要能查得到这笔领用记录。
  • 库存不够的时候要能快速知道哪些物资低于库存预警下限,方便及时补货。
  • 财务或领导需要看某个时间段内物资的入库、出库统计,知道每个月大概采买了多少钱的物资。

把这些场景翻译成系统功能,基本就是:物资分类管理、供应商管理、物资信息管理、入库管理、出库管理、库存台账、预警管理、统计报表、系统用户管理这几大模块。

这套需求和仓库管理系统(WMS)不一样,不需要管库位、批次、序列号那些复杂的仓储概念,重点在"账目清晰、流程可追溯、库存准确"这三件事上。所以技术选型不需要太重,SpringBoot+Vue+MyBatis+MySQL这套组合刚好能稳稳接住。

1.2 这套技术栈的分工逻辑:谁负责什么,为什么这么分

技术选型不是看哪个框架热门就无脑上,而是看这套组合是否让开发效率、维护成本和业务匹配度达到平衡。

后端SpringBoot负责的是"业务规则和接口生命周期"。它自带Tomcat内嵌容器、自动配置、健康检查、参数校验一堆现成能力,写好一个启动类就能跑起来,对于中小型管理系统的开发效率提升非常明显。尤其是SpringBoot对事务管理、依赖注入、AOP切面的支持非常成熟,后面处理"入库必须同时更新库存和写流水"这种原子性操作时,一个@Transactional注解就能搞定,不用自己写一堆事务模板代码。

MyBatis负责的是"SQL的精细控制"。物资管理系统的统计查询会涉及多表关联、条件动态拼接、聚合分组,MyBatis的XML动态SQL在这种场景下非常好用。比如"根据分类、名称、仓库、供应商等多个条件联合筛选物资列表",用<if>标签动态拼接条件,比JPA那种先查出来再内存过滤的方式性能上高了一个量级,而且SQL完全自己掌控,出现性能问题可以直接优化SQL本身。

Vue负责的是"页面交互和数据驱动的渲染"。物资管理系统的表单、表格、弹窗、统计图表这类页面,Vue的双向绑定和组件化开发模式天然契合。数据一变页面跟着变,那种"库存数量变化后表格自动刷新"的体验,用Vue很自然就实现了。

MySQL负责的是"稳定的数据持久化"。绝大多数管理系统是读多写少,MySQL在这种场景下配合InnoDB引擎、合理的索引设计,完全够用。而且MySQL的安装和维护成本很低,部署环境随处可用。

这套组合的配合逻辑简单说就是:用户操作Vue页面,Vue通过Axios调用SpringBoot接口,SpringBoot通过MyBatis操作MySQL,数据再一步步返回给前端渲染。链路清晰,出了问题也容易定位。

2. 数据库设计:这个系统的命根子全在表和表关系上

2.1 从业务故事反推核心数据表结构

数据库设计我习惯先从"一笔完整业务要在系统里留下哪些痕迹"出发,而不是照着网上的表结构模板抄。拿"物资入库"来说,这一笔业务至少要在系统里留下这些痕迹:单据信息(单号、入库时间、入库人、供应商)、明细信息(入了哪些物资、每种物资的数量单价)、库存变化(对应物资的库存数量增加)、流水记录(什么时间、谁、因为什么单据、让哪个物资库存变化了多少)。缺了其中任何一环,后续查账都会对不上。

基于这个思路,我的核心表设计如下:

表名用途关键字段说明
category物资分类表分类名称、父级ID(支持两级分类)、排序号
supplier供应商表供应商名称、联系人、联系电话、地址、状态
material物资信息表物资编码(唯一)、物资名称、规格型号、计量单位、分类ID、库存上限、库存下限、状态
stock库存台账表物资ID(唯一)、当前库存数量、最近入库时间、最近出库时间
in_stock入库单主表入库单号、供应商ID、入库总金额、入库时间、入库人、备注
in_stock_item入库单明细表入库单ID、物资ID、入库数量、入库单价、小计金额
out_stock出库单主表出库单号、领用人、出库时间、出库总金额、备注
out_stock_item出库单明细表出库单ID、物资ID、出库数量、小计金额
stock_record库存流水表物资ID、流水类型(入库/出库)、关联单号、变化数量、变化后库存、操作时间、操作人
sys_user系统用户表用户名、密码(BCrypt加密)、姓名、角色、状态
role/menu角色权限相关表基础RBAC模型,用户-角色-菜单关联

这里有个设计细节:material(物资信息)和stock(现有库存)必须拆成两张表。我第一次做的时候以为一张表能搞定,"物资信息+当前库存"放一起多省事。但实际运作起来问题就来了:物资信息是基本档案,变更频率很低;库存是高频变化的动态数据,每次出入库都要update。如果放一起,并发高的时候update会锁行,影响其他读物资信息的操作。而且从查询角度看,"查所有物资信息"和"查库存低于预警线的物资"是完全不同的两个维度,拆开以后SQL写起来更清晰。

2.2 为什么必须单独建一张库存流水表

库存流水表(stock_record)是我做这个系统始终坚持的表,哪怕早期版本业务简单我也没省过。很多初学做管理系统的人会觉得有入库单和出库单就够了,要看记录直接查单据就好,流水表是多余的。

实际用过就知道为什么必须建这张表。假如某个物资今天入库50个,明天出库30个,后来又被领用20个,你查入库单只能看到"入过50个",查领用单只能看到"每次领了多少"。但如果要把这个物资的生命周期陈列在一条时间线上,显示"什么时间、因为哪张单子、库存从多少变成了多少",没有流水表就只能靠多张单据表join之后按时间排序,还要处理单据之间的时间交叉,极其麻烦。而有了流水表,这个"库存变化时间线"的查询就是一次简单的SELECT * FROM stock_record WHERE material_id = ? ORDER BY create_time。

而且流水表还有一个作用——对账。系统维护中经常出现"页面库存数和实际库存对不上"的问题,排查思路就是"从上次盘点确认的库存数开始,把流水一条条加或减,看差异出在哪一步"。没有流水表,这种排查是没法做的。我遇到过好几次,有流水表的情况下,基本几分钟就能定位到是哪个操作员的哪笔单据录错了数。这就是流水表的真正价值——它不是业务必须的,但它是运维和信任度必须的。

2.3 索引设计:就这几个字段,索引设计对了一切都顺

MySQL在数据量小的时候感觉不到索引存在的必要,但物资管理系统跑个两三年,流水表几十万条记录非常正常。这个时候索引没建好,查询性能会肉眼可见地慢。

我的索引设计原则简单粗暴:高频查询条件的字段建索引,联合查询的依据建复合索引。实践下来这几个索引收益最大:

  • stock_record表上建(material_id, create_time)复合索引。查某个物资的历史流水是最高频操作之一,这个索引能让查询直接走覆盖索引提前过滤。
  • in_stock_item和out_stock_item表的material_id单列索引。根据物资反查哪些单据出现过这个物资,这个是统计报表的常用查询路径。
  • in_stock、out_stock表上的create_time索引。按时间段统计出入库数量是报表标配,没有这个索引,时间范围查询就是全表扫描。

顺便说一句,索引不是越多越好。每个索引在写入的时候都要维护B+树,索引建太多会让insert和update变慢。我的原则是:"查询需求明确到能背出来的时候,才建对应的索引;模棱两可的字段宁可不建"。

3. 后端接口实现:SpringBoot+MyBatis落地过程中的核心细节

3.1 项目基础结构与统一返回体设计

后端工程我习惯用标准的三层结构:Controller层负责接收参数和返回结果,Service层写业务逻辑,Mapper层通过MyBatis操作数据库。实体类放在entity包,DTO(数据传输对象)放dto包,VO(视图对象)放vo包。

项目初始搭建没啥新鲜的,Spring Initializr创建工程,勾选Web、MyBatis、MySQL Driver、Lombok这些依赖。需要注意的一点是SpringBoot版本和MyBatis-Spring-Boot-Starter版本要匹配,SpringBoot 2.x对应mybatis-spring-boot-starter2.x版本,SpringBoot 3.x就要用3.x版本了,不然启动时会报各种奇怪的ClassNotFound。

统一返回体这块,虽然看着是个小细节,但我强烈建议在做任何接口之前先定下来。所有接口返回Result<T>结构,包含code、msg、data三个字段:

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.setCode(500); result.setMsg(msg); return result; } }

没有统一返回体的时候,每个Controller都自己拼Map返回,前端对接时心态真的会崩。这个设计能保证前端Axios拦截器里面一次处理成功和失败的判断逻辑,全局生效。

3.2 MyBatis动态SQL:库存列表的多条件查询是动态SQL的经典场景

物资列表页通常有搜索栏,按物资名称模糊搜索、按分类下拉选择、按库存状态(正常/缺货/超储)筛选、按供应商下拉筛选。这里的"用户填了几个条件就按几个条件查,一个都没填就查全部",必须用MyBatis的动态SQL来实现,不能用Java代码拼SQL字符串。

我在编写多条件查询时,Mapper XML中的实现大致是这样:

<select id="selectMaterialPage" resultType="com.example.vo.MaterialStockVO"> SELECT m.id, m.code, m.name, m.spec, m.unit, c.name AS category_name, s.name AS supplier_name, st.current_stock, m.stock_lower_limit, m.stock_upper_limit FROM material m LEFT JOIN category c ON m.category_id = c.id LEFT JOIN supplier s ON m.supplier_id = s.id LEFT JOIN stock st ON m.id = st.material_id <where> <if test="name != null and name != ''"> AND m.name LIKE CONCAT('%', #{name}, '%') </if> <if test="categoryId != null"> AND m.category_id = #{categoryId} </if> <if test="stockStatus != null and stockStatus == 1"> AND st.current_stock &lt; m.stock_lower_limit </if> <if test="stockStatus != null and stockStatus == 2"> AND st.current_stock &gt; m.stock_upper_limit </if> </where> ORDER BY m.create_time DESC </select>

这里有个细节很容易坑到人:<和>在XML里必须转义成&lt;和&gt;,不转义XML解析直接报错。我遇到过很多次有人在XML里直接写<然后报"元素内容必须由格式正确的字符数据或标记组成",就是这个问题。

多条件查询为什么不用MyBatis-Plus的QueryWrapper?这里我要说明一下自己的倾向:简单的单表查询用MyBatis-Plus确实快,但这种多表LEFT JOIN查询,最终还是得写XML。既然统计报表、物资列表这些复杂查询都绕不开XML,那干脆全局统一都用XML写SQL,避免一个项目里两种风格满天飞,后维护的人看着也统一。

3.3 入库和出库的事务处理:为什么"先更新库存再写流水"是正确的顺序

物资入库的后端逻辑看起来不复杂:插入入库单主表、插入入库单明细表、更新库存表数量、插入库存流水表。但这四个操作必须全部成功或者全部失败,绝不能出现"单据保存了库存没涨"这种数据不一致。SpringBoot通过@Transactional注解搞定这件事,这个就不多说了。

我想重点说的是操作顺序的问题。入库时"更新库存"和"写流水"的顺序,有讲究。我的做法是:先更新库存表(UPDATE stock SET current_stock = current_stock + #{count}),再插入流水表。为什么是这个顺序?

假设并发场景下,两个操作同时操作同一个物资的库存,一个是入库加50,一个是出库减30。如果先写流水再更新库存,流水表里两条记录的时间先后可能和库存实际变化不一致——后写流水那条记录在时间线上可能在先,但库存实际是先加了再减的。出现这种问题会导致流水时间线错乱,对账时根本看不清楚。

而先更新库存再写流水,"当前时间点的库存值"是确定的,流水记录的是"这次操作后库存变成了多少",天然地对齐了时间线。代码示例:

@Transactional(rollbackFor = Exception.class) public void inStock(InStockDTO dto) { // 1. 插入入库单主表 InStock inStock = new InStock(); inStock.setStockNo(generateStockNo("IN")); // ...省略其他字段设置 inStockMapper.insert(inStock); // 2. 批量插入入库明细 for (InStockItemDTO item : dto.getItems()) { InStockItem record = new InStockItem(); record.setStockId(inStock.getId()); record.setMaterialId(item.getMaterialId()); record.setQuantity(item.getQuantity()); record.setPrice(item.getPrice()); record.setTotalAmount(item.getQuantity().multiply(item.getPrice())); inStockItemMapper.insert(record); // 3. 更新库存 stockMapper.increaseStock(item.getMaterialId(), item.getQuantity()); // 4. 记录流水 StockRecord recordLog = new StockRecord(); recordLog.setMaterialId(item.getMaterialId()); recordLog.setType("IN"); recordLog.setRelatedNo(inStock.getStockNo()); recordLog.setChangeCount(item.getQuantity()); recordLog.setAfterStock(stockMapper.getStockByMaterialId(item.getMaterialId())); recordLog.setOperator(loginUser.getUsername()); stockRecordMapper.insert(recordLog); } }

上面代码中increaseStock的SQL是UPDATE stock SET current_stock = current_stock + #{count}这种原子更新方式,不是先SELECT查出来再UPDATE写成新的值。两者有什么本质区别?current_stock + #{count}这个操作由MySQL在行锁保护下原子完成,多个并发请求即使同时到达,也会被行锁串行化,各自加自己的数量。而"先查后改"存在经典的并发覆盖问题,两个线程同时读到100,一个加50后写150,另一个减30后写70,最后一次写操作会把前一次的结果覆盖掉——最终库存居然是70,直接数据错乱。这个坑是我在真实项目里踩过的,第一次做库存扣减时用的"先查后改",压测一打就发现问题,后来全部改成原子更新才解决。

3.4 MyBatis缓存和生成编号的实用技巧

MyBatis的缓存机制经常被面试问到,实际开发中的价值需要区分情况。一级缓存(SqlSession级别的缓存)默认开启,同一个SqlSession内重复查询相同SQL会直接返回缓存结果。但在SpringBoot集成环境下,每次Mapper操作通常都是独立的SqlSession,所以一级缓存的作用范围被大幅削弱了——说句实话,基本感知不到它的存在。

二级缓存(Mapper级别的缓存)可以跨SqlSession共享查询结果,听起来很美好,但我强烈建议不要在涉及事务和频繁更新的模块开启二级缓存。原因很简单:物资管理系统的库存、流水这些数据都是实时性要求极高的,二级缓存一旦生效,你入库了一笔库存,另一个请求查询库存信息时可能拿到的还是旧缓存数据。刷新缓存的时机会滞后,这种业务根本承受不起脏读。所以MyBatis二级缓存我在这套系统里是关闭的,只在那些几乎不更新、查询量又极大的数据(比如物资分类列表)上手工做Redis缓存,这样做效果反而好。

还有一个容易被忽略的点——业务单据编号的生成问题。入库单号如果用数据库自增ID拼接,一旦删除过数据ID就会跳号,单据号有断层,财务查账时会追问原因。还要防止并发重复,比如同一秒内两个用户同时入库,如果单号规则是IN + yyyyMMddHHmmss + 随机数,极端情况可能撞号。我的做法是用"日期 + 当天自增序列",序列存在一张独立的sequence表里,生成编号时使用UPDATE ... SET value = value + 1然后SELECT value,原子地拿到当天第几个单号,拼接出类似IN2025011500001这样的编号。既保证了顺序又能从单号看出日期,查账也方便。

4. 前端Vue部分:从页面组件到数据交互的落地过程

4.1 从零搭建Vue项目:哪些配置该改、哪些坑必须先踩

前端这块我用的是Vue 2 + Vue Router + Vuex + Axios + Element UI的组合。这套组合虽然不算最新,但胜在生态成熟、资料多,Element UI的表格、表单、弹窗、分页组件拿来即用,能省下不少写样式的时间。如果从零开始创建项目,我会用Vue CLI来初始化:

vue create material-web cd material-web npm install element-ui axios

创建完项目我做的第一件事不是写代码,而是改目录结构和几个基础配置。目录上我会增加src/api(放接口请求方法)、src/utils(放Axios实例和工具函数)、src/router(放路由配置)、src/store(放Vuex状态管理),页面文件统一放在src/views下,每个模块一个文件夹。

需要特别注意的配置有这几个:

  • 开发环境代理。Vue开发服务器端口默认8080,我后端接口在8081,直接请求会跨域。在vue.config.js里配置代理是最省事的方案:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }

这样前端请求/api/material/list,开发服务器会自动转发到http://localhost:8081/material/list,跨域问题在开发阶段直接消解掉了。

  • 路由模式。默认的history模式刷新页面时如果后端没有做相应的回退处理,会出现404。开发环境配了代理没问题,生产环境我一般直接用hash模式,简单可靠,省去后端配置的麻烦。

  • Element UI按需引入。全量引入虽然省事,但打包出来体积很大。我实际做的时候用了babel-plugin-component做按需加载,首屏加载速度肉眼可见地提升了不少。

4.2 Axios请求封装与前端拦截器的实际作用

前端所有接口请求统一走一个封装好的Axios实例,这样拦截器、错误处理、Token携带这些逻辑写一遍全站生效。我的封装习惯是这样的:

import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL || '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.msg || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.msg)) } return res }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } )

这里有几个细节要说一下。baseURL用环境变量控制,开发环境指向/api(走代理),生产环境可以直接指向实际接口域名,换环境不用改代码。请求拦截器统一把本地存的Token塞进请求头,省得每个接口单独传。响应拦截器统一处理后端返回的Result<T>结构,如果业务码是401就说明登录过期,直接清掉Token并踢回登录页。这样一个封装写下来,页面里调接口就只需要关心成功之后的数据,错误处理和登录失效逻辑全局统一了。

4.3 物资列表、出入库操作和库存预警的页面交互设计

物资列表页是这类系统里最核心的页面,交互上要注意的点比较细。

列表页面用的是el-table,列字段包括物资编码、名称、分类、规格、单位、供应商、当前库存、库存上下限、状态标签。库存状态那一列我用el-tag动态显示:当前库存低于下限显示红色"缺货"标签,高于上限显示橙色"超储",正常显示绿色"正常"。数据来源是后端返回的库存状态字段,前端根据字段值映射成对应的Tag类型,这种做法的好处是后端只管算好状态,前端只负责展示,逻辑分层干净。

分页用的是el-pagination,组件配置需要注意current-page和page-size都要用.sync修饰符绑定,或者用事件更新,否则翻页的时候页码会跳回第一页。总条数从后端返回的total字段拿,我这里统一约定分页接口返回的数据结构是{ records: [], total: 10 },前端拿到后设置给分页组件。

出入库操作的弹窗表单,核心交互是动态明细行。入库时可以一次录入多个物资,所以明细表单要支持动态增删行。我用el-dialog里嵌一个动态表格,每个物资行都有物资下拉选择、数量、单价的输入框,当选择物资后自动带出规格、单位、库存上下限信息,方便录入员核对。明细行删除时如果只剩一行就要拦截,防止误删。

这里有个细节很值得一说:入库单里选了某个物资,前端把整套DTO直接提交给后端,后端在事务内做"插入单据+更新库存+写流水"。前端表单只要有必填校验就行,所有的一致性校验必须交给后端处理。比如"入库数量必须大于0"这种校验,前端校验只是用户体验优化,后端必须再校验一遍。为什么?因为系统用户完全可以用Postman直接调接口绕过前端,如果后端不校验,一个负数入库请求就会把库存改乱。这套系统的安全性依赖后端兜底,不是前端那些漂亮的校验提示。

库存预警页面我用的是卡片+列表混合布局:顶部放几个统计卡片显示"库存短缺N种""超储N种";下方表格直接展示这些异常库存的物资列表,点击"去补货"按钮会带着物资信息跳转到入库页面并自动选中该物资。这个联动操作看着小,实际使用中非常提升效率——从发现缺货到补货完成,原本要经过"查库存→记物资名→去入库页→重新搜物资→填数量"五步,缩短为"看到提示→点补货→填数量→提交"三步。

5. 联调与部署阶段:我踩过的坑和最终的解决方案

5.1 后端接口还没写完,前端怎么并行开发

实际开发中前后端并行是常态,但最怕的就是互相等。后端接口没定义清楚,前端没法写请求;前端页面没起来,后端也没法验证联调效果。我一般用两种方式解决:

第一种是提前定义好接口文档(比如Swagger注解或者独立的YApi/Apifox文档),前后端各写各的,联调阶段再统一对齐。第二种是在前端用Mock数据进行页面开发和调试,等到后端接口就绪后再替换成真实请求。实际操作中,我用Mock的节奏比较合适:对于依赖用户权限、依赖后端计算状态的逻辑(比如库存状态标签),我先Mock一份明确的返回数据把页面画起来;后端好了之后Mock数据直接删掉,改调真实接口。联调阶段可能遇到最多的问题我整理一下:

常见问题原因解决方案
跨域请求被拦截前端域名和接口域名不一致开发环境用Vue代理;生产环境Nginx反向代理或后端配置CORS
时间字段显示成"2025-01-12T08:30:00.000+00:00"后端返回了ISO标准时间格式后端在application.yml配置spring.jackson.date-format和时区;前端用dayjs格式化
表格数据多出很多不需要的字段后端直接返回了实体类定义VO只返回前端需要的字段,避免把数据库字段全暴露
分页后复选框选中状态丢失分页切换后组件重新渲染用row-key属性配合预留选中数组,跨页保存选中状态

5.2 部署时最容易忽略的SpringBoot配置项

系统开发完毕,部署到服务器时有一堆配置细节容易被忽略。我把自己部署时都会检查一遍的配置项列一下:

  • 数据库时区。MySQL连接的JDBC URL上要明确指定时区,比如jdbc:mysql://localhost:3306/material?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai。不指定时区,Ubuntu上安装的MySQL默认时区可能是UTC,导致查询出来的时间和北京时间差8个小时。这个问题排查起来极其隐蔽,因为开发环境(Windows默认本地时区)不指定也没问题,一到Linux服务器上就暴露。

  • 数据库连接池参数。SpringBoot默认使用HikariCP,我一般会显式配置几个参数:

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 max-lifetime: 1800000

max-lifetime如果不配置,默认是1800000毫秒(30分钟),这本身没问题。但有一种情况需要注意:如果MySQL的wait_timeout设置得比连接池的最大存活时间短,空闲连接会被MySQL服务端断开,连接池里的Connection却以为还活着,下次使用时就会报"Connection is not available, request timed out after Xms"。所以我部署时会同时检查MySQL的wait_timeout和Hikari的max-lifetime,保证Hikari的存活时间小于MySQL的空闲超时时间。

  • JVM内存参数。管理系统的后端服务一般几百MB就够,但如果服务器内存比较紧张,启动时可以用java -jar -Xms256m -Xmx512m material.jar限制一下内存上下限,防止系统把整台服务器的内存吃满。-Xms和-Xmx设置为相同值可以避免运行时动态扩容造成的性能抖动。

5.3 Nginx部署前端项目:历史路由刷新404的处理方案

前端项目打包后生成dist目录,我部署时通过Nginx把它作为一个静态站点发布,同时用反向代理把/api请求转发给后端Java服务。

一个经典的Nginx配置片段长这样:

server { listen 80; server_name material.example.com; # 前端静态资源 location / { root /var/www/material/dist; index index.html; try_files $uri $uri/ /index.html; # history模式关键配置 } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

最关键的是try_files $uri $uri/ /index.html这一行。如果用了Vue的history模式,前端路由是/material/list这种路径,浏览器刷新时Nginx拿到这个路径会去服务器找对应的文件,找不到就404。try_files的作用就是告诉Nginx:找不到对应文件就回退到index.html,然后由Vue Router接管路由渲染对应的页面。

如果用了hash模式就不需要这行配置,因为路由变化不发请求到服务器。我在生产环境用hash模式多一些,部署省心、不用额外配置,代价就是URL里多个#符号。对内部管理系统来说,这个代价完全可以接受。

6. 从这套基础版延伸:权限控制、统计报表和后续扩展思路

6.1 RBAC权限模型在这套系统里的实现方式

管理系统基本都逃不开用户权限控制,我在这套系统里用的是经典的RBAC(基于角色的访问控制)模型。三层关系:用户表关联角色表,角色表关联菜单表(权限点表)。用户登录后查询出角色和对应的菜单权限,动态生成前端路由和按钮级别的控制。

后端接口层面实现权限控制,我选择的是Spring Security + JWT这种组合。登录成功后生成JWT Token,前端存到localStorage,每次请求通过拦截器放在请求头里。后端的Security拦截器负责解析Token、校验有效期、判断用户角色是否允许访问某个接口。

有一个具体的场景可以说明这种设计的作用:管理员可以访问用户管理模块,普通仓管员只能访问物资管理和出入库模块。后端通过@PreAuthorize("hasRole('ADMIN')")注解控制接口访问权限,前端根据用户角色动态渲染菜单项,没有权限的菜单直接不显示。如果某个普通用户通过手工拼URL访问了无权限的接口,后端会返回403,前端拦截器统一提示"无权限访问"。

6.2 统计报表里最有用的两张表:月度入库出库统计和物资周转情况

管理层看系统主要不是为了看那些表单,而是看统计数据。我做统计报表时的两个核心查询思路可以分享一下:

月度出入库统计。按月度汇总出库数量和金额,典型SQL如下:

SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(CASE WHEN type = 'IN' THEN change_count ELSE 0 END) AS total_in, SUM(CASE WHEN type = 'OUT' THEN change_count ELSE 0 END) AS total_out FROM stock_record WHERE create_time >= #{startDate} AND create_time < #{endDate} GROUP BY DATE_FORMAT(create_time, '%Y-%m')

前端用ECharts折线图展示,管理人员一眼能看出哪个月出入库量大、是否存在异常波动。这里查询用到的create_time索引,在数据库设计阶段就已经加上了,所以这个GROUP BY查询性能不会有问题。

库存积压分析。查询当前库存数量大、最近出库时间距今很久的物资,这些可能就是积压库存,占用了资金和仓储空间:

SELECT m.name, m.spec, st.current_stock, st.last_out_time, st.current_stock * m.average_price AS stock_amount FROM stock st LEFT JOIN material m ON st.material_id = m.id WHERE st.current_stock > 0 AND (st.last_out_time IS NULL OR st.last_out_time < DATE_SUB(NOW(), INTERVAL 90 DAY)) ORDER BY stock_amount DESC

这种"90天没有出库记录且库存不为零"的查询,能帮管理人员识别出哪些物资长时间不用,考虑调拨或清理。我第一次做类似功能时只做了月度统计,后来做复盘会议时发现管理层其实更关心"钱压在哪些货上",这个积压分析报表的点击率比出入库统计还高。

6.3 如果业务量涨了,这套系统的扩展方向是什么

基础版的物资管理系统可以满足几百人规模的公司使用。如果业务量涨上去,我会按这个顺序逐步演进:

  • 引入Redis做热点数据缓存。物资分类、供应商下拉列表这类读取频繁但更新很少的数据,从数据库查询改为缓存读,减轻数据库压力。库存数据自身的读写压力不大,不建议缓存,保持实时读库更安全。
  • 引入消息队列做异步操作。比如出入库成功后需要给相关人员发通知邮件,发短信提醒库存预警,这些操作不走核心业务链路,可以丢到消息队列异步消费。系统不会因为邮件服务变慢而拖慢核心入库操作。
  • 引入定时任务做自动化盘点报告。每天凌晨生成一份库存日报:昨日出入库数量、当前库存总量、缺货清单、超储清单,推送给管理员邮箱。这个用SpringBoot的@Scheduled就能实现,成本很低,收益却很直接——管理员每天打开邮箱就能掌握全局。

这套系统的整体设计思路,核心就两句话:表结构要能支撑"账实相符"的核对逻辑,后端事务和并发控制要守住"库存数据一致性"这条底线。把这两件事做到了,后面加再多功能模块都只是增量开发。我在做类似管理系统的过程中最深的体会是:这类系统难的不是单个功能,而是模块之间的数据联动——一个入库操作牵动单据、库存、流水三张表,一处漏了,整个账就对不上。写代码之前先把这些数据流转路径想透,开发过程中能少踩一大半的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询