做物资管理系统,我前后写过好几版,从最早的 JSP 单体应用,到后来用 SpringBoot + Vue 做前后端分离,最大的感受是:技术本身并不复杂,难的是把业务边界想清楚,再把部署链路里的坑一个个填平。最近整理了一套前后端分离的物资管理平台,技术栈选的是 SpringBoot + Vue + MyBatis + MySQL,正好有朋友在问完整源码和部署过程,这篇就把设计思路、核心实现、部署步骤一次讲透,适合正在做课设、接私活,或者公司内部要快速搭一个后台管理系统的开发者参考。
这个系统表面上是"新冠物资管理",但剥开业务外壳,本质就是一个典型的库存 + 出入库 + 预警 + 统计管理系统。这类系统在政务、医院、企业物资管理里都很常见,业务逻辑不复杂,但对数据的准确性、权限的严谨性、部署的傻瓜化有一定要求。下面我会从选型逻辑开始,一步步讲到数据库设计、后端模块、前端联调,最后给出完整的部署流程和上线后遇到的典型问题。
1. 为什么最终选了 SpringBoot + Vue 这个组合,而不是继续用单体
1.1 单体时代留下的痛
早年的管理类系统很多是 JSP + Servlet + JDBC 一套走到底,页面里嵌 Java 代码,业务逻辑和页面展示焊死在一起。当时做物资管理,最麻烦的就是前端改个按钮样式,后端要重新编译打包,稍微复杂一点的条件查询,SQL 拼接能写到怀疑人生。
后来换到 SpringBoot,后端开发效率确实上来了,但如果还是用 Template Engine 渲染页面,前后端依然没法真正解耦。项目大了以后,后端同学要关心页面跳转,前端同学要本地装一套 Java 环境才能跑起来,联调效率低,部署也得整个应用一起发布。所以这个物资管理系统,我一开始就决定用前后端分离的架构,而不是在原有单体上继续堆功能。
1.2 这套技术栈的取舍理由
很多人问,为什么选 Vue 不选 React,为什么用 MyBatis 不用 JPA,为什么数据库不是 PostgreSQL。我的答案很简单:要团队熟悉、上手快、坑有现成答案。
- SpringBoot:配置简化,内嵌 Tomcat,独立 jar 运行,社区极其庞大。做管理类后台,SpringBoot 是目前综合成本最低的选择。
- Vue:中文资料多,模板语法直观,适合中小团队快速迭代。Vue 的双向绑定做表单类页面非常顺手,而且 Element UI / Element Plus 这类组件库把表格、表单、弹窗都封装好了,省掉大量样式工作。
- MyBatis:SQL 是自己写的,可控性强,尤其适合多表关联、动态条件查询多的业务系统。JPA 虽然开发快,但碰到复杂查询和 SQL 优化时,反而不如 MyBatis 直白。
- MySQL:轻量、成熟、运维成本低,对这类中小型管理系统的并发量完全够用。
这套组合不是"最潮流"的,但绝对是最稳的,尤其在需要快速交付、长期有人维护的场景下,招人容易、资料多、出问题能快速搜到答案,这比某个技术点特别炫重要得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从物资流转流程倒推数据库表结构,先把地基打对
2.1 先理业务流程,再动手建表
很多新手一上来就建表,结果做到一半发现缺字段、缺关联,这是项目管理里最忌讳的。我是先梳理了物资管理的主链路,反复画流程图,确认页面长什么样,再开始设计表。
主链路其实很清晰:物资入库 -> 库存增加 -> 物资出库 -> 库存扣减 -> 库存低于阈值触发预警 -> 统计报表。围绕着这条链路,衍生出几个核心问题:物资信息怎么维护?出入库记录怎么留痕?库存扣减怎么避免超发?不同角色能看到什么数据?
把这个流程想明白后,表结构就自然出来了,几乎是顺着业务流一张张捋出来的,而不是拍脑袋凑字段。
2.2 核心表结构设计与几个关键决策
我整理一下这套物资系统的主要表,以及每一张表在设计时的核心考量:
| 表名 | 用途 | 设计要点 |
|---|---|---|
| material_info | 物资基本信息表 | 存储物资名称、规格、单位、分类、预警阈值等;冗余存储分类名称,避免每次关联查询字典表 |
| stock_info | 库存表 | 一个物资对应一条库存记录,包含当前库存量、锁定库存量、更新时间;使用乐观锁版本号字段 |
| stock_in_record | 入库记录表 | 记录每次入库的数量、来源、经手人、入库时间;预留备注字段 |
| stock_out_record | 出库记录表 | 记录每次出库的数量、领用部门、领用人、用途;预留审批状态字段 |
| sys_user | 系统用户表 | 用户名、密码(BCrypt 加密)、状态、部门、电话 |
| sys_role / sys_user_role | 角色表 / 用户角色关联表 | 简化版 RBAC,角色与菜单权限关联 |
| sys_dict | 数据字典表 | 统一管理物资分类、计量单位、来源类型等枚举值 |
有几个细节是实际踩过坑之后才加上的,这里单独说明。
第一,库存表为什么要冗余物资名称和单位字段?因为库存列表页面、出入库记录页面都要显示物资名称,如果每次都 join 物资表,数据量一大,查询就慢,而且 MyBatis 映射多表关联时要写一堆 resultMap。直接在库存表里冗余 name、unit、category_name 这几个字段,查询时单表即可,更新物资信息时同步更新即可,代价很小,收益很明显。
第二,库存数量为什么要用 DECIMAL(15,3) 而不是 FLOAT?之前有个项目用 FLOAT 存库存,结果某次出库 0.3 再入库 0.2,库存变成一堆小数尾差,最后对账对不上。金额和数量这类字段必须用 DECIMAL,Java 侧用 BigDecimal 接收,宁可多写几行转换代码,也不能埋精度隐患。
第三,为什么需要有 "锁定库存" 字段?在多人同时操作时,比如有人正在申请出库,还没最终确认,这时候如果另一个后台人员直接扣减库存,可能造成数据不一致。我在库存表里加了 locked_stock 字段,申请单创建时先锁定库存,审批通过后再真正扣减,这样能有效避免超卖和超发。
2.3 库存扣减的并发安全设计
库存扣减是这个系统里最核心的并发场景,类似电商的"超卖"问题。我第一次上线时只用了简单的 "update stock_info set stock = stock - #{num} where material_id = #{id}",结果压测时发现同一时刻多个请求都读到同一个库存值,最后库存被扣成了负数。
后来改成了乐观锁方案,在 stock_info 表加了一个 version 字段,更新时带上版本号条件:
sql复制UPDATE stock_info
SET stock = stock - #{num},
version = version + 1
WHERE material_id = #{materialId}
AND version = #{version}
AND stock >= #{num}
如果影响行数为 0,说明版本不匹配或库存不足,再重新查询并给前端返回友好提示。这套方案对物资管理这类并发量不高的场景完全够用,不需要引入 Redis 分布式锁,代码逻辑也简单很多。实现时还需要在事务里执行"查库存 -> 扣库存 -> 插入出库记录"三步操作,确保要么全部成功,要么全部回滚。
3. 后端 SpringBoot 工程的关键实现,不只是 CRUD
3.1 包结构划分和统一返回体
很多项目一开始不注重包结构,controller 里直接写业务代码,service 层形同虚设,后面维护成本非常高。这套物资管理系统的后端包结构非常清晰,我是按功能模块划分的:
text复制com.xxx.material
├── common // 统一返回体、异常处理、常量、工具类
├── config // 配置类:跨域、拦截器、MyBatis、异步任务
├── controller // 接收请求,参数校验,返回结果
├── service // 业务逻辑层,接口 + 实现
├── mapper // MyBatis 数据访问层
├── entity // 数据库实体对象
├── dto // 前端交互数据对象,避免实体直接暴露
└── security // 登录鉴权、JWT 拦截器
接口返回格式必须统一。前端拿到数据之后不需要再分类处理成功和失败的情况,直接看 code 就行。这是我在联调阶段吃过亏加上去的,最开始有的接口返回 true/false,有的直接抛异常,前端同事写响应拦截器时需要兼容各种格式,太痛苦了。
统一返回体的核心套路如下:
java复制@Data
public class R {
private Integer code;
private String message;
private Object data;
public static R success(Object data) {
R r = new R();
r.setCode(200);
r.setMessage("success");
r.setData(data);
return r;
}
public static R error(Integer code, String message) {
R r = new R();
r.setCode(code);
r.setMessage(message);
return r;
}
}
配合一个全局异常处理器,用 @RestControllerAdvice 捕获业务异常、参数校验异常和兜底异常,前端永远拿到的是结构完整的 JSON,而不是乱七八糟的错误堆栈。
3.2 MyBatis 动态 SQL 处理复杂查询
这套系统里最典型的一个查询是入库记录列表,筛选条件可能是物资名称、入库时间段、来源类型、经手人,而且这些条件都可选。用 MyBatis 动态 SQL 写起来非常顺,不需要在 Java 代码里拼接 SQL,也不用担心 SQL 注入。
一个简化的例子:
xml复制<select id="selectStockInRecord" resultType="com.xxx.material.dto.StockInRecordDTO">
SELECT
r.id,
m.name AS materialName,
r.in_num,
r.source_type,
r.create_time,
u.username AS createBy
FROM stock_in_record r
LEFT JOIN material_info m ON r.material_id = m.id
LEFT JOIN sys_user u ON r.create_by = u.id
<where>
<if test="materialName != null and materialName != ''">
AND m.name LIKE CONCAT('%', #{materialName}, '%')
</if>
<if test="startTime != null">
AND r.create_time >= #{startTime}
</if>
<if test="endTime != null">
AND r.create_time <= #{endTime}
</if>
<if test="sourceType != null and sourceType != ''">
AND r.source_type = #{sourceType}
</if>
</where>
ORDER BY r.create_time DESC
</select>
这里建议把多表查询的结果 DTO 和数据库实体分开,不要直接用实体接收多表字段。原因是实体类的字段和表结构一一对应,一旦查询结果里有聚合字段、别的表的字段,实体兜不住,代码会变得很难看。我一般会用 dto 包专门放查询结果对象,这个习惯让我在后续加功能时省了很多事。
3.3 JWT 登录鉴权与拦截器
管理后台一定要有权限控制,不然谁都能调接口,后果很严重。这套系统用的是 JWT + 拦截器的方式,没有引入 Spring Security,主要是为了轻量。
登录流程:用户提交用户名密码,后端用 BCrypt 校验密码,校验通过后生成 JWT token,token 里带上用户 id、用户名、角色编码,返回给前端。前端把 token 存在 localStorage 里,每次请求在 header 里带 Authorization: Bearer <token>。
后端配置一个拦截器,拦截所有 /api/** 请求,但放行 /api/auth/login。拦截器里解析 token,解析失败直接返回 401,成功就把用户信息放入 ThreadLocal,后续业务代码里直接可以从上下文拿到当前用户。这样出入库记录里"经手人"字段就能自动填上当前登录用户,不用前端每次传来传去。
要注意的是 JWT 是无状态的,token 一旦签发,在有效期内无法撤销。如果需要做"强制下线"功能,就需要配合 Redis 做 token 黑名单,或者把 token 版本号存到库里。对于物资管理系统,一般来说 JWT 过期时间设置 8 小时就够用,真需要更强的安全控制再引入 Redis 也不迟。
4. 前端 Vue 工程的路由、组件化与接口联调细节
4.1 Vue 工程结构和路由设计
前端我用的是 Vue 2 + Element UI,原因是这套系统启动时间较早,当时 Vue 3 + Element Plus 还不够稳定。如果你现在新起项目,建议直接用 Vue 3 + Vite + Element Plus + Pinia,思路完全一致,组件库换代不影响整体架构。
前端目录结构如下:
text复制src
├── api // 接口请求模块,按业务模块拆分
├── assets // 静态资源
├── components // 公共组件
├── layout // 后台管理布局,侧边栏 + 顶部栏
├── router // 路由配置
├── store // 状态管理
├── utils // 请求封装、工具函数
└── views // 页面视图
路由设计上采用了动态路由的思路:登录成功后,后端返回当前用户的菜单权限列表,前端把菜单映射成路由,动态添加到 Router 实例中。这样不同角色登录后看到的菜单和可访问页面是不同的,而不是把所有路由都写死在代码里。
一个简化版的路由权限判断方法:
javascript复制// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (!token) {
if (to.path === '/login') {
next()
} else {
next('/login')
}
} else {
if (to.path === '/login') {
next('/')
} else {
next()
}
}
})
4.2 axios 封装与跨域处理
前端所有请求都走一个统一的 axios 实例,这样处理携带 token、统一错误提示、响应解构都很方便。封装时几个关键点:
- 请求拦截器:从 localStorage 取 token,放到请求头
- 响应拦截器:如果 HTTP 状态码是 401,说明未登录或 token 过期,直接跳转登录页
- 响应拦截器:如果业务 code 不是 200,统一 message.error 提示,避免每个页面重复写错误处理
开发环境的跨域问题,用 Vue CLI 的 devServer.proxy 解决,前端请求 /api,代理转发到 http://localhost:8080,cookie 跨域问题用 changeOrigin: true 解决。这里有一个常见的误区:开发环境没有跨域问题,不代表线上也没有。线上一般是 Nginx 反向代理,把 /api 路径代理到后端服务,这样前后端域名一致,自然没有跨域问题。
4.3 核心页面的组件化思路
物资管理后台的页面虽然多,但大部分是"列表 + 搜索表单 + 弹窗表单"的组合。为了不重复写代码,我把几个高频场景抽成了公共组件:
- SearchForm:统一封装搜索表单,传入配置数组自动渲染表单项
- ProTable:封装 el-table,加载数据、分页、loading 状态统一处理
- FormDialog:封装弹窗表单,支持新增和编辑两种模式
举个例子,物资列表页、入库记录页、出库记录页都用了 ProTable 和 SearchForm,加起来每个页面业务代码不到 150 行,大大减少了重复劳动。这个思路在维护阶段特别香,后来加了一个"物资盘点"功能,复用这些组件,一天就做完了页面。
5. 从本地到服务器:整套部署流程与配置细节
5.1 环境准备清单
我在部署时踩过的坑有一半来自环境不一致。这里先列一份环境清单,建议本地和服务器尽量保持一致:
| 软件 | 版本建议 | 注意事项 |
|---|---|---|
| JDK | 1.8 或 11 | Spring Boot 2.x 用 JDK8 最稳;JDK 17 以上可能遇到兼容问题 |
| Maven | 3.6+ | 国内建议配置阿里云镜像,否则依赖下载慢到怀疑人生 |
| Node.js | 14+(Vue2)/ 18+(Vue3+Vite) | 版本太高或太低都会导致依赖安装失败 |
| MySQL | 5.7 或 8.0 | 8.0 需要注意驱动和时区配置,详见后文 |
| Nginx | 1.20+ | 用于部署前端静态资源和反向代理 |
5.2 后端打包与启动
SpringBoot 项目打包成可执行 jar 是最简单的部署方式。执行 mvn clean package -Dmaven.test.skip=true,得到 target 目录下的 material-system.jar,然后直接启动:
bash复制nohup java -jar material-system.jar --spring.profiles.active=prod > app.log 2>&1 &
这里建议把不同环境的配置拆开,通过 --spring.profiles.active=prod 切换,比如 application-dev.yml 指向本地数据库,application-prod.yml 指向线上数据库,避免每次上线改配置。
如果你需要容器化部署,我后来也写了一个极简 Dockerfile:
dockerfile复制FROM openjdk:8-jre-alpine
COPY material-system.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=prod"]
然后 docker build -t material-system .,docker run -d -p 8080:8080 --name material material-system。Docker 部署的好处是环境隔离,换服务器直接拉镜像跑,省去装 JDK 和配置环境的步骤。
5.3 前端构建与 Nginx 反向代理
前端部署分两步:先构建静态资源,再配置 Nginx。
bash复制npm install
npm run build
构建完成后,dist 目录就是纯静态文件。我把 dist 里的文件拷贝到服务器的 /var/www/material 目录,然后配置 Nginx:
nginx复制server {
listen 80;
server_name your-domain.com;
# 前端静态资源
root /var/www/material;
index index.html;
# 前端路由 history 模式需要配置,否则刷新页面404
location / {
try_files $uri $uri/ /index.html;
}
# 后端 API 反向代理
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
注意 proxy_pass http://127.0.0.1:8080; 结尾不带 /,这样 /api/login 会代理到 http://127.0.0.1:8080/api/login,后端接口路径无需调整。如果写的 proxy_pass http://127.0.0.1:8080/;,则会变成 http://127.0.0.1:8080/login,导致 404,这个细节很容易踩。
5.4 数据库初始化与线上数据安全
数据库这步,我强烈建议用 Flyway 或 Liquibase 管理迁移脚本,而不是手动往线上库灌 SQL。这套物资系统里我用了 Flyway,启动时自动执行 classpath:db/migration 目录下的 V1__init.sql、V2__add_out_record.sql 等脚本,这样新环境部署时不需要人工一步步执行 SQL,版本统一,不会出现"我本地有字段,线上没有"的鬼故事。
如果不用 Flyway,至少要建一个 sql/ 目录,把所有 DDL、初始化数据脚本按版本号命名维护好,部署时手动执行。另外,上线前必须做一次性初始化脚本的验证,SQL 是不是可重复执行,有没有幂等性,这些都要在测试环境跑一遍,不能到生产环境再试。
6. 部署上线后我踩过的几个典型坑,写出来帮你省时间
6.1 前端刷新就 404
这是因为 Vue Router 用了 history 模式,地址栏直接访问 /material/list,Nginx 找不到对应的物理文件,返回 404。解决方案就是在 Nginx 里加上 try_files $uri $uri/ /index.html;,让所有前端路由都重新指向 index.html,由前端路由接管。如果你不想配置 Nginx,也可以改用 hash 模式,但地址栏会多一个 #,看着不美观。
6.2 MySQL 8.0 的时区问题导致连接报错
JDBC 连接串如果写成 jdbc:mysql://localhost:3306/material,在 MySQL 8.0 下通常会报服务器时区异常。需要加上参数:
yaml复制url: jdbc:mysql://localhost:3306/material?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
allowPublicKeyRetrieval=true 是 MySQL 8.0 使用 caching_sha2_password 认证时需要加的,否则连接时可能报 Public Key Retrieval is not allowed。另外对应驱动类也要改成 com.mysql.cj.jdbc.Driver。
6.3 MyBatis-Plus 批量插入没报错但数据没写进去
这套系统后来有接入同步数据的场景,我用 MyBatis-Plus 的 saveBatch 批量插入,结果接口返回成功,数据库里却一条数据都没有。排查后发现是事务问题:MyBatis-Plus 的批量插入默认走的是 JDBC 批处理,但如果没有加 @Transactional,每个 SqlSession 各自提交,中间一环出问题也不会回滚,日志里也没报错。解决方法是给批量方法加上事务注解,或者手动指定 SqlSessionTemplate 执行批量操作。
这个坑特别隐蔽,建议遇到"接口成功但数据缺失"的场景,第一时间检查事务注解和批处理策略,而不是怀疑数据本身有问题。
6.4 线上跨域问题反而出现在本地
本地开发用 devServer 代理,接口调得飞起。上线后 Nginx 也配置了反向代理,按理说不会有跨域。但实际运行中某些浏览器直接访问后端地址报跨域。原因是我在 SpringBoot 里配置了全局 CORS,Nginx 又做了代理,产生了双重跨域处理,某些情况下反而触发预检请求失败。
解决方案是线上环境让 Nginx 处理跨域,后端不要配置全局 CORS,或者只在本地开发环境启用 CORS,通过 @Profile("dev") 限定配置类只在 dev 环境生效。这样线上完全由 Nginx 代理,请求头统一,跨域问题就不会冒出来。
7. 如果从零重写这个系统,我会怎么做
做这个项目时最深的体会是:代码层面的难度远没有想象中高,真正的精力都花在"业务边界不清晰"和"环境问题"上。如果让我从零重写,我会做几个调整:
第一,引入低代码思维。管理后台的列表页、表单页、搜索条件高度相似,我会用一个 JSON Schema 描述页面结构,统一生成列表和表单,减少重复代码。
第二,权限模型更细粒度。现在的角色-菜单权限已经够用,但真正复杂的系统需要到按钮级权限,比如审批按钮只有特定角色可见,后端接口也要做对应的权限校验,不能只控制菜单。
第三,增加操作审计日志。物资管理涉及出入库、库存调整等敏感操作,必须有完整的审计日志,记录谁在什么时间改了哪些字段,防止事后扯皮。这也是老系统升级时遗漏的模块,后续补的时候成本更高。
第四,考虑引入消息队列做数据异步同步。如果库存变动频繁,可以通过 MQ 异步通知下游系统,避免同步调用导致接口响应变慢。但对物资管理系统来说,绝大多数场景并没有那么高的并发,引入 MQ 反而增加运维复杂度,要权衡。
最后再分享一个我在部署这套系统时的个人经验:不管代码写得多好,一定要先在全新的空环境里完整走一遍部署流程,把所有依赖、配置、初始化脚本都记录下来。我第一次部署时就是因为本地有历史库和历史依赖,跳过了一些步骤,结果在服务器上折腾了一个下午。后来我养成习惯,每次都用一台干净的虚拟机或 Docker 容器模拟部署,确认没问题才写进部署文档,这个习惯让我后续交付项目时少踩了很多坑。
