☰
应急物资管理系统实战:SpringBoot+Vue前后端分离与部署
2026/10/2 4:56:08 网站建设 项目流程

做应急物资管理系统,最早源于一件小事:朋友所在的基层应急仓库,台账用的是Excel,物资出入库靠手写领用单,每年盘点要对上一两周。他们最怕的不是工作量大,而是账实不符——没有批次概念、没有效期提醒,过期物资和合格物资混在一起,真要出库时没人敢拍板。后来我把这套流程搬到线上,技术栈就是标题里这套:SpringBoot+Vue+MyBatis+MySQL,前后端分离。从后端表结构、Vue前端路由到最终的部署,整个项目跑通之后,我觉得它特别适合作为"前后端分离全流程"的学习样本:业务不复杂但五脏俱全,技术栈通用,踩坑点也非常典型。这篇文章就把完整源码思路和部署过程拆开讲,包括我在实际开发中遇到并解决过的具体问题。适合想上手全套系统的开发者,也适合拿这个题目做毕业设计、需要一份可复现方案的朋友。

1. 应急物资管理系统为什么适合前后端分离:项目定位与功能边界

1.1 这套系统要解决什么实际问题

应急物资和普通进销存有一个关键区别:它不只是"记一笔账",而是要保证在突发情况下,物资能快速找到、快速出库、责任清晰。所以我做需求时没有照搬电商后台,而是围绕物资全生命周期来设计。

系统要解决的实际问题可以拆成四块:

  • 物资档案混乱:同一种口罩,不同批次、不同厂家、不同规格,如果没有档案管理,后面盘点根本无从下手。
  • 出入库无流程:随便一个人都能领物资,领了不说话,账就乱了。必须要有入库单、出库单、审批人,每一笔变动都能追溯到操作人和时间。
  • 库存预警缺失:库存积压或者严重不足,靠人肉看表格不现实,需要系统根据最低库存、最高库存、效期自动生成预警。
  • 调拨和统计麻烦:仓库之间余缺调剂,要有一个独立的调拨流程,同时给管理者按月、按类别看趋势报表。

功能模块方面,我做的是常规版本:用户登录、角色权限、基础资料(供应商、物资分类、物资档案、仓库)、入库管理、出库管理、调拨管理、库存台账、预警记录、统计报表、操作日志。表大约十二张左右,没有做非常复杂的审批流,但对于一个可用系统来说,这个边界已经足够了。

1.2 前后端分离的选型判断依据

有人会问:这种规模的系统,用 JSP 或者 Thymeleaf 服务端渲染不就行了吗?确实能行,但我还是选了前后端分离。

理由很实际。第一,这个系统页面交互不浅:库存台账要支持多条件组合查询、分页、批量操作,入库单要动态加载明细行,预警中心要有红点提醒,这类交互用服务端模板写起来非常别扭。第二,后端只暴露 API 之后,前端想怎么改界面都行,不必重新部署后端。第三,如果后面要接移动端或者小程序,API 是现成的,直接复用。第四,从团队分工看,前后端分离让一个人负责接口、一个人负责页面,工作边界非常清楚。

当然,前后端分离也有代价:部署链路变长、跨域和处理路由刷新的问题多了一些。但这些代价是可以通过规范和部署手段稳定解决的,比起框架绑定带来的限制,我更愿意承担前者。这套系统的定位决定了它非常适合采用 SpringBoot 提供接口、Vue 消费接口的方式,也刚好能让你在一套完整代码里跑通从开发到上线的全流程。

2. SpringBoot+MyBatis后端骨架:表结构设计与Mapper层搭建

2.1 核心表设计:字段怎么定才能撑起物流闭环

为了让系统走通"入库→库存→出库→调拨"的闭环,我建了下面这些核心表:

表名作用关键点
material_category物资分类层级用 parent_id,不要只做一个字段
material物资档案编码唯一,带规格、单位、保质期、库存阈值
warehouse仓库独立成表,一个物资可以存放在多个仓库
stock库存表material_id + warehouse_id 唯一,带乐观锁版本号
stock_in_order / stock_in_item入库单及明细明细记录批次号和有效期
stock_out_order / stock_out_item出库单及明细明细关联入库批次,支持先进先出
allocate_order / allocate_item调拨单及明细状态字段标识调拨进度
stock_warning_record预警记录记录预警类型和是否已处理
sys_user / sys_role / sys_menu用户角色菜单多对多关联权限

一个经常被新手忽略的点:物资档案表里不要直接放"库存数量",因为同一个物资可能放在A仓和B仓,数量是仓库维度,和物资档案属于两个维度。我把数量放在了 stock 表,通过 material_id 和 warehouse_id 联合唯一。

物资档案的表结构核心字段大概是这样的:

CREATE TABLE `material` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `category_id` BIGINT NOT NULL COMMENT '物资分类ID', `code` VARCHAR(50) NOT NULL COMMENT '物资编码', `name` VARCHAR(100) NOT NULL COMMENT '物资名称', `specification` VARCHAR(200) DEFAULT '' COMMENT '规格型号', `unit` VARCHAR(20) NOT NULL COMMENT '计量单位', `shelf_life_days` INT DEFAULT NULL COMMENT '保质期(天),空表示不限', `min_stock` INT NOT NULL DEFAULT 0 COMMENT '最低库存阈值', `max_stock` INT DEFAULT NULL COMMENT '最高库存阈值', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物资档案表';

库存表加了一个 version 字段,这是为并发扣库存做乐观锁准备的:

CREATE TABLE `stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `material_id` BIGINT NOT NULL, `warehouse_id` BIGINT NOT NULL, `quantity` INT NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY `uk_material_warehouse` (`material_id`, `warehouse_id`), PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';

战役物资里口罩、药品、食品都有保质期,所以入库明细里我加了 batch_no、production_date、expire_date 三个字段。出库时优先按效期升序扣减,这就是常规的先进先出逻辑。没有批次的库存管理系统只能算流水账,有了批次才能说是真正可用的应急物资系统。

2.2 Maven工程如何组织:分层包结构与依赖版本选型

后端工程我按经典分层来组织,包结构如下:

com.example.ems ├── controller # 接口层,只做参数接收和结果封装 ├── service # 业务层,接口+impl ├── mapper # MyBatis Mapper接口 ├── entity # 数据库实体 ├── dto # 请求/响应对象 ├── config # 配置类,比如WebConfig、定时任务配置 └── common # 统一返回结果、异常处理、常量

这样做的好处是:controller 不写业务,service 不拼 SQL,mapper 只做数据访问,职责边界清楚。项目再小,也建议保持这个分层习惯,后面加功能会快很多。

Maven 依赖核心就这几块:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

这里有个版本选型问题,我特别想说一下。网上大量教程和课程代码都是基于 Spring Boot 2.x 写的,我建议如果不是团队强制要求,先把 Spring Boot 版本锁在 2.7.x,对应 JDK 8 或者 11。Spring Boot 3.x 虽然新,但很多老项目和教程代码里的 javax.servlet 要改成 jakarta.servlet,MyBatis 相关 starter 也要换新版本,对新手来说第一个报错就能坑半天。这个项目里我用的就是 Spring Boot 2.7.18,稳稳当当,所有教程代码都能直接落地。

2.3 MyBatis的集成细节:XML配置、日志打印、TypeHandler与二级缓存

MyBatis 在这一套里的配置我写在 application.yml:

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.ems.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

开发阶段开启 StdOutImpl 可以完整看到执行的 SQL、入参和返回结果,排查字段映射和参数绑定问题非常高效。生产环境我会改回 Slf4jImpl 或者直接关掉日志打印,避免敏感数据写进日志。

XML Mapper 里我习惯用 ResultMap 而不是依赖自动映射,尤其遇到关联查询时。比如出库明细需要关联出 material 名称、规格,用一个 ResultMap 带 association 比在 Java 里循环补字段省很多事。配置了 map-underscore-to-camel-case 之后,数据库的下划线字段会自动映射到驼峰属性,但多表查询如果有字段重名,还是要显式指定列别名。

MyBatis 的 TypeHandler 在这个系统里有一个很典型的使用场景:状态字段。比如出库单有一个 status 字段,TINYINT 在 Java 里用 Integer 接没问题,但如果想用枚举或者把用户的完整对象存成 JSON 字符串,就需要自定义 TypeHandler。最朴素的做法是继承 BaseTypeHandler,重写 setNonNullParameter 和 getNullableResult 三个方法,把 Java 对象序列化为 JSON 写入,读出来时再反序列化。只有在字段确实有这个需求时才用,不要为了炫技所有字段都套一个 TypeHandler。

二级缓存这个点我要多说一句:这个项目里我直接关掉了<cache/>。二级缓存最典型的脏数据场景是——materialMapper 和 stockMapper 都有缓存,stock 表更新了库存,但 materialMapper 的缓存记录还在,查出来的关联数据是旧值。尤其涉及多表关联查询时,缓存失效策略很难控制。与其花时间调缓存一致性,不如把数据库索引和连接池参数调好。真正需要缓存的时候,优先选择 Redis 这种集中式方案,而不是 MyBatis 的本地二级缓存。

3. Vue前端工程:路由、请求封装与登录态管理

3.1 环境准备与工程初始化

前端工程初始化前,先确认 Node 环境。我用的是 Node 16.20,npm 8 左右。安装依赖太慢是国内网络环境的常态,我直接换了 npm 镜像源:

node -v npm config get registry npm config set registry https://registry.npmmirror.com

创建工程我用的 Vue CLI,命令是vue create ems-ui。虽然社区已经有很多新工具,但 Vue CLI + element-plus 的组合对这套管理系统来说成熟稳定,文档也多,遇到问题好查。创建时选择 Vue 3、Vue Router、状态管理这几个预设,然后进入项目目录安装依赖即可。

环境配置阶段容易踩的坑是版本不一致:你本地 Node 是 18,但教程用的是 12,依赖树解析出来完全不同,运行报错也千奇百怪。解决办法是锁版本,项目根目录放 .nvmrc 文件写清楚 Node 版本,或者用 package.json 里的 engines 字段声明。这个细节看上去小,实际能帮你省下大量排错时间。

3.2 路由设计与动态菜单

前端路由我分成两块:静态路由和动态路由。静态路由包括 /login、/404、根路径 /,动态路由是所有需要权限的业务页面。为什么不在 router 里把所有页面一次性注册完?因为不同角色的用户看到的菜单不一样,仓库管理员不需要看到用户管理,只做前端按钮隐藏是不够的,路由层面就应该把不可见的页面过滤掉。

动态路由的核心逻辑是:用户登录成功,后端返回 token 和该用户的权限标识列表,前端用这些标识过滤出当前用户可访问的路由,再通过 router.addRoute 动态注册。路由守卫里要判断动态路由是否已经挂载,否则刷新页面之后路由会丢失,这是新手最容易犯的错。

路由示例结构:

const constantRoutes = [ { path: '/login', component: Login }, { path: '/', component: Layout, redirect: '/dashboard' } ] const dynamicRoutes = [ { path: '/stock', component: Layout, meta: { title: '库存管理', roles: ['admin', 'warehouse'] }, children: [ { path: 'list', component: StockList, meta: { title: '库存台账' } }, { path: 'in', component: StockIn, meta: { title: '入库登记' } } ] } ]

路由守卫:

router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') return } if (token && !store.state.dynamicRoutesLoaded) { const accessibleRoutes = filterRoutes(dynamicRoutes, store.state.permissions) accessibleRoutes.forEach(route => router.addRoute(route)) store.commit('SET_DYNAMIC_ROUTES_LOADED', true) next({ ...to, replace: true }) return } next() })

这段代码解决了"登录后菜单为空、刷新后路由丢失"的问题。注意第二次进入守卫时用 replace 重进目标路由,让动态路由完全生效。每次写这种逻辑我都提醒自己:动态路由不是挂一次就完了,刷新场景一定要处理。

3.3 axios请求封装与token处理

前端所有后端请求,我都走一个统一的 axios 实例,而不是在页面里随手 import axios 直接发。统一封装的好处是:token 注入、业务码判断、401 跳转、错误提示,全部收口到一个文件,页面代码干净很多。

核心代码如下:

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API || '/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) { ElMessage.error(res.msg || '系统错误') return Promise.reject(new Error(res.msg)) } return res }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.msg || '网络异常') return Promise.reject(error) } ) export default service

后端接口我统一定义返回结构{ code, msg, data },code 为 200 表示成功。前端拦截器只处理 code,页面里直接拿 data 用,不需要每个接口都写一层 if 判断。

开发阶段的跨域问题,我用 vue.config.js 的 devServer 代理解决:

devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } }

这样前端的 /api 请求会转发到后端 8080,同时把 /api 前缀剥掉,接口层不用感知前缀。之所以推荐代理而不是后端开 CORS,是因为生产环境用同域部署或 Nginx 反代时根本不需要处理跨域,只在开发阶段走代理最省心。

4. 核心业务场景的实现:出入库、库存预警与统计

4.1 入库/出库的事务设计

入库业务不是"insert 一条记录"那么简单。一次入库操作要同时完成:写入入库单主表、写入批量入库明细、更新库存表、写入操作日志。这四步必须在一个事务里,任何一个失败都要全部回滚,否则会出现"单据建了但库存没加"这种账实不符的情况。

Service 层方法用 @Transactional 注解,默认传播行为 REQUIRED 就够:

@Transactional(rollbackFor = Exception.class) public Long createStockIn(StockInDTO dto) { StockInOrder order = buildOrder(dto); stockInOrderMapper.insert(order); for (StockInItem item : dto.getItems()) { item.setOrderId(order.getId()); stockInItemMapper.insert(item); upsertStock(item); } operateLogService.log("入库登记", dto); return order.getId(); }

出库比入库多一个关键动作:扣减库存前必须校验可用数量。我的库存扣减 SQL 是直接条件更新,而不是先 select 再 update:

UPDATE stock SET quantity = quantity - #{quantity}, version = version + 1 WHERE material_id = #{materialId} AND warehouse_id = #{warehouseId} AND quantity >= #{quantity}

这条 SQL 的巧妙之处在于quantity >= #{quantity}本身就是数据库层面的校验,并发环境下也不会扣成负数。执行后的受影响行数如果为 0,说明库存不足或者库存被其他单据先扣了,Service 直接抛业务异常提示用户刷新重试。

单据号我不用自增 ID 直接展示给用户,而是单独生成带业务语义的单号,规则类似 RK20250318001,表示"入库单 2025 年 3 月 18 日第 1 单"。这样做的好处是用户对单子的时候不用抄一串无意义的数字,后期排查问题时按单号直接能定位日期。

4.2 库存预警的定时扫描方案

库存预警我做了两套机制。第一套是在入库、出库事务里实时判断,库存低于最低阈值或高于最高阈值时立即产生一条预警记录;第二套是定时任务兜底,每天凌晨扫描全量库存,防止某天业务代码漏掉判断。

定时任务用 Spring 自带的 @Scheduled 就够了,不需要引入额外框架:

@Component public class StockWarningTask { @Resource private StockMapper stockMapper; @Resource private WarningRecordMapper warningRecordMapper; @Scheduled(cron = "0 0 1 * * ?") public void checkLowStock() { List<StockWarningData> list = stockMapper.selectLowStockList(); for (StockWarningData data : list) { if (!warningRecordMapper.existsToday(data.getMaterialId(), data.getWarehouseId())) { warningRecordMapper.insert(buildWarning(data)); } } } }

cron 表达式 "0 0 1 * * ?" 表示每天凌晨 1 点执行一次。注意我加了 existsToday 判断,避免同一物资同一仓库每天重复生成一堆相同的预警,否则预警中心很快就会被噪音淹没。

临期物资的预警 SQL 思路类似:查入库明细里 expire_date 在 90 天内但尚未出库的批次,按剩余天数排序。这类查询要注意在 expire_date 字段上建索引,否则库存数据过十万后,定时任务会越来越慢。预警结果我写到了站内信表,用户登录后右上角有红点提醒;真正内网环境里对短信、企业微信这类强提醒的需求不强,站内信最不容易出问题。

4.3 报表统计的SQL写法

报表是管理者用得最多的功能,我做了三个基础统计:每月出入库趋势、物资类别占比、库存预警 Top10。

月度趋势的核心 SQL 长这样:

SELECT DATE_FORMAT(in_time, '%Y-%m') AS ym, COUNT(*) AS order_cnt, SUM(total_quantity) AS total_qty FROM stock_in_order WHERE in_time >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) GROUP BY ym ORDER BY ym;

出库趋势写法完全一样,把表换成 stock_out_order。这里有两个常见问题:一是字段类型,in_time 必须用 datetime 而不是 varchar,否则 DATE_FORMAT 的效率极差;二是有条件筛选时,要考虑在 in_time 上建立索引,否则月数据量到几十万级别后 GROUP BY 会明显变慢。

类别占比的查询用到了 material 和 stock 的关联,统计每个类别下的物资总数和库存总金额。这种报表 SQL 我不建议在代码里拼动态 SQL,直接在 Mapper XML 里写清楚,参数只传时间范围,更清晰也更容易调优。

5. 联调与部署:从开发环境到服务器

5.1 开发阶段的跨域配置

前后端分离项目第一个绕不开的问题就是跨域。开发环境后端跑在 8080,前端跑在 8081,两边端口不同,浏览器的同源策略会把所有请求挡下来。

我推荐的做法是前端 devServer 代理,也就是在 vue.config.js 里配置 proxy。代理在爬虫、网络层看来就是同源请求,后端完全不需要开启 CORS,生产环境也不会因为你忘了关跨域配置而暴露额外接口风险。

后端如果确实需要支持跨域,比如有第三方系统要直接调接口,可以在配置类里写一个 CorsFilter 统一处理,允许的源头、方法、请求头都显式配置,不要用*全放开。这里有个矛盾点:如果前端已经走了代理,后端再开 CORS 反而会多一层处理,两边配置不一致时容易出奇怪问题。原则就是开发环境只走一条路:要么前端代理,要么后端 CORS,别两个同时上。

5.2 前端打包放进SpringBoot:一种Tomcat部署姿势

很多人纠结"tomcat 部署前后端分离项目"怎么搞,其实最省事的方式是:前端打包后直接放进 SpringBoot 的静态资源目录,整体打成 jar 运行。这一步做好了,单机部署一个 java -jar 就完成,不需要单独部署前端。

具体步骤:

  1. 前端执行npm run build,生成 dist 目录。
  2. 把 dist 目录下的 index.html 和静态资源复制到后端的 src/main/resources/static。
  3. 后端执行mvn clean package,得到可执行 jar。
  4. 服务器上执行java -jar ems.jar。

但这里有个经典坑:Vue Router 用了 history 模式后,直接访问 http://ip:8080/stock/list 会 404,因为后端根本没有这个路由。解决方法是加一个视图控制器,把所有不带点后缀的路径都转发到 index.html:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}").forwardTo("/index.html"); } }

正则里[^\\.]*保证 js、css、png 这类带点的静态资源不会被转发,只有前端路由路径会走 index.html。如果你要用外部 Tomcat 打 war 包,需要把 packaging 改成 war,主类继承 SpringBootServletInitializer 并重写 configure 方法。但除非公司固定要求外部 Tomcat,否则我真的建议直接用内嵌 Tomcat 的 jar 方式,生命周期更简单,部署回滚也容易。

5.3 Nginx反向代理部署方式

如果不想把前端打进后端 jar,可以用 Nginx 做反向代理,这种方式更符合"前后端分离"的工程标准,前端改版只需替换静态文件,后端升级独立进行,互不干扰。

Nginx 配置如下:

server { listen 80; server_name your.domain.com; root /var/www/ems/dist; index index.html; location / { try_files $uri $uri/ /index.html; } 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; } }

location / 里的 try_files 三件套就是为了解决 history 模式刷新 404 的问题:如果请求的路径不是真实文件,就回退到 index.html,交给前端路由接管。location /api/ 里的 proxy_pass 末尾带了斜杠,这会把请求路径里的 /api 前缀剥掉,再转发给后端;如果后端接口本身就带 /api 上下文,代理时就不要加末尾斜杠。这个细节非常容易忽略,配错了的表现就是登录接口一直 404。

5.4 MySQL初始化与SSL连接错误处理

数据库方面,我建库建表用的是 utf8mb4,而不是 utf8。utf8 在 MySQL 里存不了完整的 emoji 和特殊字符,utf8mb4 才是真正的"全字符集"。

现在新装的 MySQL 基本都是 8.0,第一次用 JDBC 连接时最常见的报错就是 SSL 连接错误和 Public Key Retrieval 异常。解决办法是在连接串里加两个参数:

spring: datasource: url: jdbc:mysql://localhost:3306/ems?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: ems_user password: xxxx driver-class-name: com.mysql.cj.jdbc.Driver

useSSL=false 是关闭 SSL 校验,内网环境没必要走 SSL;allowPublicKeyRetrieval=true 是解决 MySQL 8 默认的 caching_sha2_password 认证插件在第一次连接时要求获取公钥的问题。这两个参数加上,绝大多数连接报错就消失了。

数据库初始化我用的是脚本方式:先建好数据库,再导入表结构和基础数据脚本。导入后新建一个业务专用账号,只给这个库的增删改查权限,不要直接用 root 连接业务服务。MySQL 安装本身不复杂,Linux 下用 rpm 或 yum 装 8.0,Windows 下官网下载 zip 解压后初始化 data 目录、启动服务就行。装好后记得确认防火墙放行端口,不然后端服务永远连接不上。

6. 这套系统最容易踩的坑:版本、缓存与数据一致性问题

6.1 SpringBoot版本过高导致的兼容性问题

我在调试过程中,遇到过按网上的 SpringBoot 2.x 教程写的代码,放到 Spring Boot 3.x 环境下直接编译失败的情况。核心原因就是 Boot 3 之后 Java EE 的 javax.* 命名空间全面切换为 jakarta.*,很多老依赖没有同步升级。如果用的是 MyBatis Spring Boot Starter 2.x,在 Boot 3 下甚至会因为初始化方式变了而找不到数据源。

所以我在项目里一直保留 Spring Boot 2.7.x。它不是最新的,但生态兼容性最稳,教程覆盖最全。团队如果确实要上 3.x,一定要提前确认:JDK 版本是否满足 17 及以上、MyBatis Starter 是否用了 3.x、所有依赖里有没有直接引用 javax.* 的老代码。这些问题在项目初期排查的成本最低,等到联调阶段再发现,改起来就伤筋动骨了。

6.2 MyBatis二级缓存的脏数据问题

MyBatis 二级缓存看起来是提升性能的好东西,但对这类账务型系统是个隐患。最经典的脏数据场景:我查物资和库存时,数据被缓存到 materialMapper 的二级缓存里;另一个用户做了出库操作,stockMapper 更新了数据并清了自己的缓存,但 materialMapper 的缓存还在;此时我再次查询,看到的是更新前的旧库存。这个错误很难追查,因为它不是每次必现,取决于缓存命中和失效的时机,排错成本极高。

我的经验是:涉及库存、金额、状态这类敏感数据的项目,一律关闭 MyBatis 二级缓存,靠 MySQL 自身的性能、合理索引和连接池来支撑。等系统并发真的上来了,再引入 Redis 做集中缓存,并且设计好 key 的失效策略,不要指望 MyBatis 自带的本地缓存解决分布式场景的问题。

6.3 前端路由history模式刷新404的经典坑

这个坑我在开发阶段就踩过,部署阶段又踩了一次。开发阶段,Vue Router 用 history 模式时直接刷新 /stock/list,devServer 如果没配 history fallback,一样会 404。vite 或者 vue-cli 里要加对应的配置:

// vue.config.js devServer: { historyApiFallback: true }

部署阶段的 404 我在上一篇讲过:Nginx 用 try_files 解决,打进 SpringBoot 用 ViewController 转发解决。很多同学第一反应是"是不是后端接口挂了",其实不是,前端路由本身就是浏览器地址栏里的一个假路径,真实服务器上并不存在这个文件或接口。排查这类问题先看网络请求返回的状态码,404 且响应是 index.html 以外的东西,基本就是路由 fallback 没配好。

6.4 库存并发扣减:前端按钮防抖只是表象

前端在出库按钮上防抖、加 loading、禁用按钮,都只是改善用户体验,根本挡不住多个用户从不同电脑同时操作同一批物资。后端必须有能力在数据库层面保证不会超发。

我前面讲到的条件 UPDATE 方案,就是后端最关键的一道闸。实际测试时,我用两个账号同时抢同一批剩余数量只有 5 的物资,各提交出库 3 件,最终只有一单成功,另一单收到"库存不足或库存已变化"的提示,库存始终不会变成负数。这个结果在单机事务隔离和行锁的配合下是确定性的,而不是靠运气。

如果系统未来并发明显上升,可以在 UPDATE 语句的基础上加 version 字段做乐观锁重试,或者进一步评估悲观锁 SELECT FOR UPDATE 的可行性。不过对于应急物资这类低频到中频业务,条件 UPDATE 已经足够稳定,不要过度设计。

做这套系统最大的体会是:所谓"常规系统",技术栈并不难,真正考验人的是把事务边界、并发更新、部署链路想清楚。我踩过的坑,几乎都不是某个 API 不会用,而是版本互相不兼容、开发环境正常上线后路由 404 这类工程化问题。如果你想自己完整做一遍,我的建议是先把部署跑通,再逐个加业务模块——不要一上来就怼权限和报表,否则很容易在联调阶段被一堆问题打断。这套系统的完整工程结构和关键接口实现,我已经整理进可复现的部署方案里,后面有需要我再单独写一篇从表结构到接口的逐个拆解。

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

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

立即咨询