☰
SpringBoot+Vue+MyBatis实战:构建可追溯的物资捐赠分配系统
2026/10/10 2:11:31 网站建设 项目流程

开头部分:

我在做这类管理系统的过程中,最大的体会是:急性事件下的物资管理,难点从来不是“录入数据”,而是“让每一件物资的来龙去脉都经得起核对”。疫情物资捐赠和分配系统,正是围绕这个痛点立项的——用一套基于SpringBoot+Vue+MyBatis架构、MySQL数据库支撑的前后端分离系统,把社会捐赠、入库验收、仓储管理、需求申报、分配出库、签收反馈全部串成一条可追溯的责任链。下面这篇内容,是我从项目需求梳理到部署上线的完整总结,既聊技术选型和数据库设计,也会把开发中真正踩过的坑、最终采用的解决方案原原本本写出来。适合正在做物资类管理系统、仓库进销存系统,或是打算用SpringBoot+Vue搭全栈项目的同学参考。

1. 需求还原:捐赠和分配不是两张表,而是一条责任链

1.1 业务角色和权限边界

很多人拿到这类需求,第一反应是“做一个捐赠登记页面,再做一个出库页面,不就行了”。实际业务场景远比这个复杂。一个真实运转的物资管理系统,至少要承载六类角色:

角色核心职责典型权限边界
捐赠方在线填报捐赠意向、上传物资清单只能新增和查看自己的捐赠单
接收登记员线下清点物资,审核捐赠单并生成入库单可改待审核单据,不可改已入库单据
仓库管理员管理库存、库位、批次、效期看得到全量库存,但无权创建分配单
需求申报员提交辖区或科室的物资需求只能管理本组织需求单
分配审核员审批需求、生成分配出库单有权冻结库存、扣减库存
系统管理员用户、角色、菜单、字典配置全部权限,但不参与业务流程

这个权限矩阵如果不在设计阶段定清楚,后面做接口鉴权、前端按钮显隐会反复返工。我们最初把物资入库和分配出库全挂在同一个“物资管理员”角色下,结果业务方提出异议:入库是实物验收环节,分配是审批决策环节,两者混在一起,出了问题根本说不清是谁的责任。

1.2 核心业务流程:从捐赠到签收的六个节点

完整流程可以归纳成六个节点:捐赠意向登记 → 物资到达验收 → 入库确认 → 需求申报 → 分配出库 → 签收反馈。

捐赠意向登记环节,系统只需要记录捐赠方、物资名称、规格、数量、捐赠时间、联系方式。注意这里先不做库存增加,因为物资还在路上,实物没到,数量随时可能变化。我们项目里特别加了一个“承诺数量”和“实际验收数量”的字段,后续入库单生成时不允许直接改承诺数量,必须走“差额核销”逻辑,防止有人在后台悄悄把账做平。

物资到达后,接收登记员做验收。验收的关键是生成正式入库单,这时才通过事务把“物资批次表”和“库存表”真正更新。验收环节还涉及一个容易漏掉的字段:生产日期、有效期、批次号。食品、药品类物资必须的效期管理,普通物资不需要,所以我们的物资字典表里专门设计了is_expiry_managed开关,不同物资品类的表单字段由这个开关动态渲染。

需求申报由下面的组织/单位发起,说明所需物资、数量、用途、期望送达时间。申报单先进待审批列表,审核员看到的是“可用库存余额”加上“在途/冻结数量”的组合视图,避免出现审批通过后发现仓库根本没货的尴尬。

分配出库是整条责任链最重要的一环。一张分配单往往包含多种物资,每种物资的来源批次可能不同,所以设计时采用了“分配单主表 + 分配明细表 + 批次出库流水表”三层结构。出库动作一旦确认,库存余额同步扣减,同时生成对应批次的出库流水,保证以后任何一件物资都能倒查到它的入库批次。

签收反馈是很多人会忽略的闭环。物资送到需求方之后,接收人在系统确认数量、质量,上传签收单照片。有了这一步,整个链条才算真正走完——捐赠方知道自己捐的东西到了哪里,管理层能清晰看到终端的实际接收情况。

1.3 系统解决的核心问题:库存数字要经得起审计

我参与这个项目之前,很多临时记录还停留在Excel表格和多人群聊接龙上。日常小规模操作问题不大,一旦出现大批量物资进来、多批次同时分配,表格版本混乱、数据没人更新、库存数字滞后等问题就全暴露了。

这套系统真正解决的不是“录入效率”,而是三件事:第一,库存数字和单据绑定,库存只能是入库单、出库单加减法的计算结果,不允许任何人直接手工修改总数;第二,所有业务动作都有操作人、操作时间、操作前后的明细快照;第三,分配结果可追踪到终端签收,财务和审计人员随时能按时间段、按物资类别、按捐赠方拉出一致性报表。

2. 技术选型复盘:为什么是SpringBoot + Vue + MyBatis + MySQL

2.1 用SpringBoot而不是微服务的理由

项目立项时,也有人提出用微服务架构,按模块拆分成捐赠服务、库存服务、用户服务,好像更“企业级”。实际评估后我坚持用了单体SpringBoot。最直接的原因是团队规模小、交付周期紧张、业务复杂度还没到需要分布式拆分的程度。

微服务带来的分布式事务、服务间调用、日志串联、部署维护成本,在物资管理这种强事务、强一致性场景里是净负担。比如库存扣减,如果捐赠服务和库存服务分属两个应用,就需要分布式事务框架保证一致性;而单体应用里一个@Transactional注解就能把“校验库存、扣减库存、生成出库流水、更新分配单状态”四条SQL包在一个事务里,简单可靠得多。

SpringBoot对我的核心价值是自动配置和生态整合。引入spring-boot-starter-web、spring-boot-starter-security、mybatis-spring-boot-starter之后,大部分基础配置已经就绪,主要工作集中在业务代码上。内置Tomcat默认支持200个线程,对这类后台管理系统来说完全够用,省去了额外部署Web服务器的复杂度。

2.2 为什么选MyBatis而不是JPA

这个场景选MyBatis而不是Spring Data JPA,理由特别明确。物资管理系统有大量动态SQL和复杂统计查询,比如“按捐赠方、时间段、物资类别组合查询出入库明细”,JPA的自动生成SQL在这种情况下很难写出高性能查询,反而容易产生N+1问题。MyBatis把SQL控制权完全交还给开发人员,复杂查询直接手写SQL,执行计划可控,也方便DBA审核。

项目里有一个典型场景让我更坚定了这个选择:统计某个捐赠方的物资分配完成率,需要同时关联捐赠明细表、分配明细表、签收表,按物资编号分组聚合,还要计算捐赠总量、分配总量、签收总量三个指标。这种报表SQL,用MyBatis写一个resultMap就能精确映射到VO,而用JPA需要写一堆@Query注解,碰到多表聚合非常别扭。

MyBatis的另一个优势是<script>标签支持动态SQL。物资列表查询的筛选条件(物资类别、效期状态、仓库、捐赠方)都是可选的,用<where>和<if>标签动态拼接,既能复用SQL片段,又不会产生冗余查询。

2.3 Vue作为前端框架的落地思路

前端选用Vue,核心原因是组件化和生态成熟。Vue2时代我们用Element UI做后台管理,组件丰富;现在Vue3 + Element Plus的体验更顺畅,组合式API让复杂表单页的逻辑组织更清晰。

我特别强调Vue的动态路由能力。不同角色登录后看到的菜单完全不同——仓库管理员不显示“需求审批”菜单,普通捐赠方登录后只看到“我的捐赠”和“物资公示”。实现上就是在路由守卫里根据后端返回的权限码,用router.addRoute动态追加可访问路由,而不是把所有路由一次性注册。这样即使有人手动输入URL,也进不了未授权的页面。

前端还有一个容易忽略的设计:业务单据状态变化的驱动方式。分配单从“待审核”变成“待出库”,前端不能只是刷新列表,而是要在提交成功后根据后端返回的新状态更新当前详情页的状态标签和可用操作按钮。我们用Vuex/Pinia管理当前用户信息、权限码、全局字典数据,避免每个页面重复请求字典接口。

2.4 MySQL的角色,以及踩过的事务隔离级别

数据库选MySQL是团队熟悉度最高的原因。但真正上线前做性能压测时发现了一个需要警惕的现象:默认的REPEATABLE READ隔离级别下,如果同时并发执行库存扣减和库存查询,可能因为当前读和快照读不一致,出现明明有库存却扣减失败的诡异问题。

这个问题的本质是:库存扣减使用UPDATE ... WHERE stock_qty >= ${need}走的是当前读,而库存查询如果在一个尚未提交的事务里执行SELECT,走的是快照读,两者看到的数据版本可能不同。解决思路有两个:一,把库存扣减语句改成UPDATE stock SET stock_qty = stock_qty - #{qty} WHERE id = #{id} AND stock_qty >= #{qty},用受影响行数判断是否扣减成功,不预查询;二,关键报表查询设置READ COMMITTED隔离级别,获取最新的已提交数据。

3. 数据库建模的核心:物资本体、批次、库存与流水怎么解耦

3.1 主数据表的设计思路

数据库设计我习惯先拆主数据和流程数据。主数据包括:物资字典表、仓库表、库位表、供应商/捐赠方表。流程数据包括:捐赠单、入库单、分配单、需求申报单及各明细表。主数据与流程数据分离,后面做统计分析、权限控制、数据归档都会轻松很多。

物资字典表material的核心字段:

CREATE TABLE `material` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `material_code` varchar(32) NOT NULL COMMENT '物资编码', `material_name` varchar(128) NOT NULL COMMENT '物资名称', `category_id` bigint NOT NULL COMMENT '物资分类ID', `specification` varchar(128) DEFAULT NULL COMMENT '规格型号', `unit` varchar(16) NOT NULL COMMENT '计量单位', `is_expiry_managed` tinyint NOT NULL DEFAULT '0' COMMENT '是否效期管理 0否 1是', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态 1启用 0停用', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_material_code` (`material_code`) ) ENGINE=InnoDB COMMENT='物资字典表';

仓库表和库存表是一对多的关系。库存表stock不存储物资冗余名称,只存material_id、warehouse_id、batch_id、stock_qty、locked_qty五个关键字段。查询列表时再通过JOIN关联出物资名称、规格、仓库名称。这样设计的好处是库存数据高度结构化,不会出现同一个物资在表里有多条“名称不一致”的记录。

3.2 入库批次与库存的关系

同一种物资,不同批次来源于不同捐赠方,生产日期也可能不同。所以库存表不能只按物资汇总一个总数,而是必须按批次存放。批次表stock_batch记录来源类型(捐赠/采购/调拨)、来源单号、生产日期、有效期至、入库单号。

分配出库时,采用“先进先出”的原则:优先分配有效期最近的批次。用SQL实现就一行:

SELECT batch_id FROM stock_batch WHERE material_id = #{materialId} AND warehouse_id = #{warehouseId} AND remaining_qty > 0 AND (expiry_date IS NULL OR expiry_date >= CURDATE()) ORDER BY expiry_date IS NULL, expiry_date ASC, create_time ASC FOR UPDATE;

加了FOR UPDATE是防止两个分配单同时选到同一个批次,最后库存合计扣超。实际项目里,库存扣减和批次出库流水必须放在同一个数据库事务中,任何一步失败整体回滚。

3.3 库存扣减的经典SQL写法

库存扣减最怕出现“超卖”。正确写法是直接在UPDATE语句中加remaining_qty >= #{qty}的条件:

UPDATE stock SET stock_qty = stock_qty - #{qty}, update_time = NOW() WHERE id = #{stockId} AND stock_qty >= #{qty};

如果影响行数等于0,说明库存不足或库存记录已被修改,应用层立即抛出异常回滚事务。这种方法比“先SELECT再UPDATE”天然具备原子性,不需要显式加锁,性能也更好。

还有一个容易忽略的点:分配出库往往先创建“分配单”,审批通过后才真正扣库存。在审批通过之前的“预占”需求,用locked_qty字段实现。创建分配单时增加锁定数量,审批通过后真正扣减并释放锁定;审批驳回时直接释放锁定。这个设计能让审核员在审批页看到“可用库存 = stock_qty - locked_qty”,避免多张分配单同时审批导致超分。

3.4 让统计报表跑得动:明细流水和定时汇总

报表压力往往是这类系统后期的瓶颈。如果每次都实时聚合所有明细表,数据量上去以后查询会非常慢。我们采用了“流水表 + 日汇总表”的双轨方案。

明细流水表stock_flow记录每一次入库、出库、调整、报损的数量和单据来源。日汇总表stock_daily_summary按日期、仓库、物资维度预聚合每日的入库量、出库量、结余量。日常列表和详情查流水,管理层看报表查汇总表。汇总表通过xxl-job或Spring自带的@Scheduled每天凌晨重算一次,如果发现与明细流水不一致,说明有程序bug或手工调整数据,会触发告警通知。

4. 接口层与权限设计:从登录到分配物资的全链路

4.1 JWT + Spring Security的过滤器链路

系统接口安全使用JWT令牌 + Spring Security实现。登录成功后生成JWT,包含用户ID、登录名、角色编码集合,过期时间设为2小时。后端Security配置里,放行登录接口、验证码接口、物资公示接口,其余接口全部进入认证过滤器。

关键配置如下:

http.authorizeRequests() .antMatchers("/api/auth/login", "/api/auth/captcha").permitAll() .antMatchers("/api/material/public/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class);

JWT过滤器从请求头Authorization: Bearer xxxx中解析令牌,校验签名和有效期,再从Redis取一次会话状态。我们特意让JWT无状态化之外又加了一道Redis核验:管理员手动禁用某个用户时,直接把Redis里的会话Key删掉,该用户的令牌立即失效,不用等2小时过期。

4.2 核心API列表与角色权限矩阵

接口设计遵循REST风格,下面是系统里最核心的一组API:

方法路径说明可用角色
POST/api/donation/register捐赠意向登记捐赠方
PUT/api/donation/audit捐赠单审核接收登记员
POST/api/warehouse/stockin入库确认仓库管理员
GET/api/stock/query库存查询全部登录用户
POST/api/allocate/apply提交需求申报需求申报员
POST/api/allocate/approve分配单审批分配审核员
POST/api/allocate/outbound分配出库确认仓库管理员
PUT/api/allocate/sign终端签收需求方

权限控制没有全堆在代码注解里,而是抽了一个@RequirePermission("allocate:approve")自定义注解,配合AOP切面在业务入口做权限校验。这样比每个人在Service里写if(!hasPermission)更统一,也更方便测试。

4.3 Vue前端路由守卫和按钮级权限

前端路由守卫代码:

router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } const userStore = useUserStore(); if (!userStore.permissions.length) { const perms = await userStore.loadPermissions(); if (to.meta.permission && !perms.includes(to.meta.permission)) { next('/403'); return; } } next(); });

按钮级权限通过自定义指令v-permission实现。指令内部读取Vuex/Pinia里存放的权限码数组,不包含对应权限时直接从DOM上移除该按钮。用这个方式,分配确认按钮只在“分配审核员”和“仓库管理员”角色下渲染,普通用户界面根本看不到入口。

4.4 与前端联调时遇到的几个实际问题

联调阶段最容易出问题的是错误码和异常信息的约定。后端一开始返回的异常结构不统一:有的直接返回RuntimeException的message,有的返回一个封装对象,前端axios拦截器解析起来很痛苦。后来我们统一了全局异常处理,所有异常都返回:

{ "code": 40001, "message": "库存余量不足", "data": null, "timestamp": 1690000000000 }

axios响应拦截器统一判断code是否为0,非0即弹Message提示并终止后续逻辑。还有一个坑是文件上传接口超时。捐赠凭证上传往往几十张图片,默认Nginx的proxy_read_timeout是60秒,大图上传直接504。后来把上传接口单独调整了Nginx超时时间,并限制单张图片不超过5MB,前端上传前做一次压缩。

5. 踩过的坑:数据一致性、并发扣减和前端状态漂移

5.1 并发捐赠登记导致库存数量错乱

系统上线第一天就碰到了库存数量错乱。两个捐赠方同时登记同一批物资,接收登记员同时验收生成两张入库单,最后库存统计只有一张单的数量。排查下来是入库逻辑里用了“先按物资ID查询当前库存,再加新数量”的方式,两个事务读到相同的旧库存,各自+100后写回,结果只增加了100而不是200。

修复方式就是前面提到的原子更新:入库单明细里直接执行UPDATE stock SET stock_qty = stock_qty + #{qty} WHERE material_id = #{materialId}。并发下数据库行锁串行执行,后一个事务读到的必然是前一个事务提交后的最新值。这个坑在压测阶段其实有概率复现,但因为并发量不大,直到上线才暴露,教训是事务一致性的边界必须在设计阶段想清楚,不能靠低概率蒙混过关。

5.2 分配单审批节点太多,事务时间过长

另一个问题是分配单审批流程涉及多个状态流转:待审核→审核通过→待出库→出库完成。我把状态流转、库存锁定、操作日志全部写进了一个大事务,结果审核接口经常超时。后来发现不是数据库慢,而是事务里发了通知短信、调用了消息推送服务,这些外部I/O操作把数据库连接占住了,并发一高连接池直接打满。

调整策略很简单:事务里只做数据库操作(更新状态、锁定库存、写流水),所有外部通知操作通过Spring事件机制发送到事件监听器,监听器使用@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT),等事务提交成功后再执行短信和站内信推送。这样既保证核心数据强一致,又不让外部系统拖累主流程。

5.3 单据作废与出库退回的冲正逻辑

作废一张已经锁定了库存的分配单,如果只是把单据状态改为“已作废”,库存锁定数量就永远不会释放,时间长了系统里会出现越积越多的“幽灵锁定库存”。必须做冲正:新增一张“锁定释放流水”,把locked_qty回退,并记录原始分配单号,保证审计完整。

出库退回则更繁琐:物资已经出库、甚至已签收,需求方说数量不对。我们不能直接改库存,而是走一张“红字出库单”——反向增加库存,同时写一条负数出库流水。这样总库存和数据明细都能对得上。冲正逻辑虽然开发时多写了一些代码,但审计时价值巨大,没有这套机制根本答不出“某天某批物资为什么数量变了”的追问。

5.4 前端状态漂移:多标签页操作导致页面显示过期数据

前端还有个容易被忽视的问题:管理员开着多个浏览器标签页,一个标签页审核了某张分配单,另一个标签页还停留在待审核列表,用户点击“审核”按钮时又提交了一次,后端幂等性校验直接报错。

应对措施是两点:后端对核心业务单据的重复提交,用“业务单号 + 状态字段”做唯一约束和状态校验,已经流转到下一步的单据不允许再次操作;前端在关键操作后广播事件,其他标签页收到通知自动刷新列表和详情。这个体验优化虽然不复杂,但对使用者感受提升很明显。

6. 部署上线与日常维护的落地细节

6.1 部署拓扑:Nginx + Spring Boot jar + MySQL

生产环境采用最简单的单机起步拓扑:一台应用服务器跑SpringBoot jar包,一台数据库服务器跑MySQL,前面用Nginx做反向代理和静态资源托管。前端打包后的dist目录直接部署到Nginx,通过/api/路径将请求转发到后端:

server { listen 80; server_name material.example.com; root /opt/dist; index 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; proxy_read_timeout 120s; } location / { try_files $uri $uri/ /index.html; } }

jar包启动参数里,-Xms和-Xmx设置为服务器物理内存的一半,预留足够堆内存给MyBatis的结果集映射。生产环境通过systemd管理进程,日志输出到固定文件,配合logrotate做日志切割。

6.2 数据库备份与一致性检查

每日凌晨2点执行一次mysqldump全量备份,保留最近7天备份文件,同时同步到异地存储。另有一套半小时级binlog增量备份,配合全量备份可以将数据恢复到任意时间点。

我额外写了一个一致性检查脚本,每天早上遍历所有库存记录,和库存流水的汇总值做比对:

SELECT s.material_id, s.stock_qty, IFNULL(SUM(flow.qty_change), 0) AS flow_sum FROM stock s LEFT JOIN stock_flow flow ON flow.material_id = s.material_id GROUP BY s.material_id, s.stock_qty HAVING s.stock_qty != flow_sum;

有差异就告警,说明系统存在未被流程覆盖的数据变更。这套检查机制上线以来抓出过两次因为手工修数据导致的库存不平,价值非常高。

6.3 日志与监控

应用日志按天拆分,保留30天。关键操作(审核、分配、作废、冲正)写操作日志表,记录登录账号、IP、操作内容、数据快照。技术监控层面,SpringBoot引入micrometer-registry-prometheus暴露指标,Grafana展示JVM内存、线程池、接口耗时等基础指标。告警规则设了三条:接口5xx错误率超过1%持续5分钟、活跃线程数超过线程池80%、Redis连接失败超过3次。告警推送到企业微信群,运维值班人员能第一时间介入。

最后:

做完整套系统,我最大的体会是:这类管理系统的难点从来不在“某一项技术有多深”,而在整个业务流程里数据的一致性、权限的严谨性和整个链路可追溯性。SpringBoot+Vue+MyBatis这套组合,胜在成熟稳定、生态完善、出了问题能找到大量参照案例。代码写完之后,真正让大家信任系统的,是每一笔库存变动都有迹可循,每一张分配单都闭环到签收。如果后续要在这套系统上做扩展,我建议优先考虑增加移动端扫码签收和物资批次效期预警,这两块对业务一线最有实际价值。

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

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

立即咨询