做应急物资管理系统最早源于一件小事朋友所在的基层应急仓库台账用的是Excel物资出入库靠手写领用单每年盘点要对上一两周。他们最怕的不是工作量大而是账实不符——没有批次概念、没有效期提醒过期物资和合格物资混在一起真要出库时没人敢拍板。后来我把这套流程搬到线上技术栈就是标题里这套SpringBootVueMyBatisMySQL前后端分离。从后端表结构、Vue前端路由到最终的部署整个项目跑通之后我觉得它特别适合作为前后端分离全流程的学习样本业务不复杂但五脏俱全技术栈通用踩坑点也非常典型。这篇文章就把完整源码思路和部署过程拆开讲包括我在实际开发中遇到并解决过的具体问题。适合想上手全套系统的开发者也适合拿这个题目做毕业设计、需要一份可复现方案的朋友。1. 应急物资管理系统为什么适合前后端分离项目定位与功能边界1.1 这套系统要解决什么实际问题应急物资和普通进销存有一个关键区别它不只是记一笔账而是要保证在突发情况下物资能快速找到、快速出库、责任清晰。所以我做需求时没有照搬电商后台而是围绕物资全生命周期来设计。系统要解决的实际问题可以拆成四块物资档案混乱同一种口罩不同批次、不同厂家、不同规格如果没有档案管理后面盘点根本无从下手。出入库无流程随便一个人都能领物资领了不说话账就乱了。必须要有入库单、出库单、审批人每一笔变动都能追溯到操作人和时间。库存预警缺失库存积压或者严重不足靠人肉看表格不现实需要系统根据最低库存、最高库存、效期自动生成预警。调拨和统计麻烦仓库之间余缺调剂要有一个独立的调拨流程同时给管理者按月、按类别看趋势报表。功能模块方面我做的是常规版本用户登录、角色权限、基础资料供应商、物资分类、物资档案、仓库、入库管理、出库管理、调拨管理、库存台账、预警记录、统计报表、操作日志。表大约十二张左右没有做非常复杂的审批流但对于一个可用系统来说这个边界已经足够了。1.2 前后端分离的选型判断依据有人会问这种规模的系统用 JSP 或者 Thymeleaf 服务端渲染不就行了吗确实能行但我还是选了前后端分离。理由很实际。第一这个系统页面交互不浅库存台账要支持多条件组合查询、分页、批量操作入库单要动态加载明细行预警中心要有红点提醒这类交互用服务端模板写起来非常别扭。第二后端只暴露 API 之后前端想怎么改界面都行不必重新部署后端。第三如果后面要接移动端或者小程序API 是现成的直接复用。第四从团队分工看前后端分离让一个人负责接口、一个人负责页面工作边界非常清楚。当然前后端分离也有代价部署链路变长、跨域和处理路由刷新的问题多了一些。但这些代价是可以通过规范和部署手段稳定解决的比起框架绑定带来的限制我更愿意承担前者。这套系统的定位决定了它非常适合采用 SpringBoot 提供接口、Vue 消费接口的方式也刚好能让你在一套完整代码里跑通从开发到上线的全流程。2. SpringBootMyBatis后端骨架表结构设计与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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 不拼 SQLmapper 只做数据访问职责边界清楚。项目再小也建议保持这个分层习惯后面加功能会快很多。Maven 依赖核心就这几块dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency这里有个版本选型问题我特别想说一下。网上大量教程和课程代码都是基于 Spring Boot 2.x 写的我建议如果不是团队强制要求先把 Spring Boot 版本锁在 2.7.x对应 JDK 8 或者 11。Spring Boot 3.x 虽然新但很多老项目和教程代码里的 javax.servlet 要改成 jakarta.servletMyBatis 相关 starter 也要换新版本对新手来说第一个报错就能坑半天。这个项目里我用的就是 Spring Boot 2.7.18稳稳当当所有教程代码都能直接落地。2.3 MyBatis的集成细节XML配置、日志打印、TypeHandler与二级缓存MyBatis 在这一套里的配置我写在 application.ymlmybatis: 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.20npm 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 再 updateUPDATE 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() { ListStockWarningData 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 就完成不需要单独部署前端。具体步骤前端执行npm run build生成 dist 目录。把 dist 目录下的 index.html 和静态资源复制到后端的 src/main/resources/static。后端执行mvn clean package得到可执行 jar。服务器上执行java -jar ems.jar。但这里有个经典坑Vue Router 用了 history 模式后直接访问 http://ip:8080/stock/list 会 404因为后端根本没有这个路由。解决方法是加一个视图控制器把所有不带点后缀的路径都转发到 index.htmlConfiguration 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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: ems_user password: xxxx driver-class-name: com.mysql.cj.jdbc.DriveruseSSLfalse 是关闭 SSL 校验内网环境没必要走 SSLallowPublicKeyRetrievaltrue 是解决 MySQL 8 默认的 caching_sha2_password 认证插件在第一次连接时要求获取公钥的问题。这两个参数加上绝大多数连接报错就消失了。数据库初始化我用的是脚本方式先建好数据库再导入表结构和基础数据脚本。导入后新建一个业务专用账号只给这个库的增删改查权限不要直接用 root 连接业务服务。MySQL 安装本身不复杂Linux 下用 rpm 或 yum 装 8.0Windows 下官网下载 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/listdevServer 如果没配 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 这类工程化问题。如果你想自己完整做一遍我的建议是先把部署跑通再逐个加业务模块——不要一上来就怼权限和报表否则很容易在联调阶段被一堆问题打断。这套系统的完整工程结构和关键接口实现我已经整理进可复现的部署方案里后面有需要我再单独写一篇从表结构到接口的逐个拆解。