1. 项目概述
1.1 为什么需要一套抗疫资源管理系统
2020年初那段时间,我接到过不少类似的求助,社区、街道、临时组建的防疫小组,手里都有大量物资要管:口罩、防护服、消毒液、帐篷、测温枪、扩音喇叭、方便面、饮用水……账目基本靠Excel,甚至靠纸质登记表。真正经历过的人都知道,物资收发一乱,后果很麻烦——前线人员领不到防护物资,捐助方对不上账目,盘点时才发现库存数字和实物对不上。
这套“Spring Boot抗疫资源管理系统”就是在那个背景下搭起来的。它本质上是一个面向基层防疫场景的进销存加任务派发系统,覆盖物资入库、出库、申领审批、库存预警、捐赠登记、发放记录查询这些核心链路。虽然挂的是“抗疫”两个字,但抽掉场景壳子,它就是一套标准的、带审批流的资源管理系统,换一身皮就能用于防汛、救灾、大型活动后勤保障。
我为什么坚持用Spring Boot来做?因为这套系统的使用者大多是社区工作人员和志愿者,部署环境可能是一台老旧台式机,也可能是一台临时租的云主机。Spring Boot的“一个Jar包跑起来”特性在这种场景下太珍贵了,不需要装Tomcat,不需要配复杂的Web服务器,把JDK装好,Jar丢上去,启动命令一敲,系统就能跑。这对基层IT环境极度友好。
1.2 这套系统到底能做什么
简单列一下核心功能清单:
- 物资管理:物资类别维护、入库登记、出库登记、库存实时查询、库存上下限预警。
- 申领审批:社区/小组在线提交申领单,管理员审批通过后自动扣减库存。
- 捐赠管理:记录捐赠方信息、捐赠物资明细,支持打印捐赠回执。
- 人员与权限:系统用户分管理员、仓库管理员、普通申领员三个角色,权限控制精确到按钮级别。
- 报表统计:按时间段统计出入库数量、物资流向、库存周转情况。
- 操作日志:关键操作全程留痕,谁在什么时间做了什么操作,全部可追溯。
这套系统既能解决“物资从哪来、到哪去、剩多少”的问题,也能解决“谁领的、谁批的、什么时候还/消耗了”的问题。对于需要向公众或上级交代物资去向的基层组织来说,这套系统的价值不只是提效,更是“免责”和“信任”。
1.3 适合谁阅读这篇内容
如果你是以下任何一种情况,这篇内容值得认真读完:
- 准备做毕业设计的计算机相关专业学生,正在寻找一个业务完整、技术栈主流、容易讲解和演示的项目类型。
- 刚接触Spring Boot,想通过一个真实业务系统把SSM、MySQL、Thymeleaf或Vue串起来的开发者。
- 基层单位的信息化负责人,想了解一套物资管理系统到底应该包含什么功能、数据库该怎么设计。
我会把整个项目从需求拆解到技术选型,再到核心代码链路、数据库建模、权限控制、部署联调,以及我在实际调试中踩过的坑,全部拆开讲清楚。这篇内容比课程设计说明书要“接地气”得多,很多细节都是实战中才能感受出来的。
2. 需求拆解与整体设计思路
2.1 把模糊需求转化为功能模块
做管理系统最容易犯的错,就是需求还没理清楚就急着建表。我一般先画角色和业务流程图,把“谁在用、要干什么、顺序是什么”搞清楚。
这套系统涉及三类核心角色:
| 角色 | 核心诉求 | 对应系统功能 |
|---|---|---|
| 系统管理员 | 管理账号、查看全量数据、审批特殊事项 | 用户管理、数据总览、权限配置 |
| 仓库管理员 | 快速登记出入库、实时核对库存 | 物资入库、出库登记、库存管理、盘点 |
| 普通申领员 | 提交申领请求、查看审批进度 | 在线申领、申领记录查询、库存查询 |
用大白话讲,就是仓库管理员负责“进”和“出”,普通申领员负责“要”,系统管理员负责“管”。申领流程我设计成:申领员填单 -> 仓库管理员审核 -> 审核通过自动出库 -> 申领员确认收到。整个链路有状态字段控制,不允许跳过步骤。
2.2 为什么选择Spring Boot + MyBatis Plus + Thymeleaf
技术选型上,我见过太多“杀鸡用牛刀”的项目,也见过太多“牛刀杀不了鸡”的项目。这套系统我最终定的组合是:
- Spring Boot 2.x:框架基石,自动配置极大减少配置代码。
- MyBatis Plus:数据访问层。相比原生MyBatis,它内置了通用Mapper,单表CRUD几乎不用写SQL,配合分页插件,开发效率能提升至少30%。
- Thymeleaf:服务端渲染模板引擎。系统使用场景在局域网或低带宽环境,服务端渲染的页面加载更直接,不需要Node.js环境二次构建。
- MySQL 5.7+:存储核心业务数据,事务支持成熟可靠。
- Druid:数据库连接池,提供监控面板,排查慢SQL非常方便。
可能有人会质疑:为什么不用前后端分离?为什么不用Vue + Spring Boot?
我的理由是:这个项目的使用环境决定了它不适合过度复杂的前后端分离架构。社区工作人员打开页面就是要操作,不是要体验“单页应用丝滑跳转”的。前后端分离意味着至少两条链路要维护,服务器上要跑前端静态资源服务,接口要处理跨域,对临时部署环境来说,这些全是潜在故障点。服务端渲染的Thymeleaf方案,所有页面模板和静态资源打包在同一个Jar里,部署复杂度最低。
2.3 功能模块划分与开发顺序
整个系统我按“基础数据先行、业务链路其次、报表权限收尾”的顺序开发:
第一步,做“物资类型”和“供应商/捐赠方”这类基础字典模块。没有物资类型,后面的入库出库全都没法谈。
第二步,做库存管理。库存表是核心中的核心,所有入库、出库动作都围绕库存表的增减展开。
第三步,做申领审批流。这个模块是业务闭环的关键,也是权限控制的重点测试区。
第四步,做统计报表和操作日志。这两块放到最后,是因为它们依赖前期的数据积累,功能上属于“锦上添花”,但又是系统完整性的重要佐证。
这个顺序能保证任何一个阶段系统都能跑通核心流程,不会出现在最后一天还在补基础功能的情况。
3. 数据库设计与核心表结构解析
3.1 库存表的设计思路
库存表是这套系统的“心脏”。我见过不少项目把库存建模得特别复杂,什么批次库存、仓位库存、序列号管理,但社区抗疫物资管理根本不需要这么重,物资的特异性也没那么强。我用一张库存表加一张库存流水表搞定。
CREATE TABLE `stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `material_id` BIGINT NOT NULL COMMENT '物资ID', `material_name` VARCHAR(100) NOT NULL COMMENT '物资名称(冗余)', `specification` VARCHAR(100) DEFAULT '' COMMENT '规格型号', `unit` VARCHAR(20) NOT NULL COMMENT '计量单位', `quantity` INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', `safety_stock` INT NOT NULL DEFAULT 0 COMMENT '安全库存下限', `updated_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_material_spec` (`material_id`, `specification`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物资库存表';这里有个关键设计:库存表存的是“当前有多少”,流水表存的是“每次动了多少”。两张表配合,既能快速查询实时库存,又能追溯所有变更明细。
3.2 出入库流水表设计
流水表必须要“不可变”,任何用户操作只允许插入新记录,不允许修改、删除旧记录。这一点决定了整个系统的数据可信度。
CREATE TABLE `stock_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `material_id` BIGINT NOT NULL, `record_type` TINYINT NOT NULL COMMENT '1=入库 2=出库 3=申领发放 4=盘点调整', `quantity` INT NOT NULL COMMENT '变动数量(正数,方向由type决定)', `before_quantity` INT NOT NULL COMMENT '本次变动前库存', `after_quantity` INT NOT NULL COMMENT '本次变动后库存', `source_order_id` BIGINT DEFAULT NULL COMMENT '关联单号ID', `remark` VARCHAR(255) DEFAULT '' COMMENT '备注', `create_by` VARCHAR(50) NOT NULL COMMENT '操作人', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_material_id` (`material_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存变动流水表';记录 before_quantity 和 after_quantity 是我特别强调的设计。为什么要冗余这两个字段?因为一旦数据出问题,要还原任意时间点的库存状态,光靠变动数量和当前库存是推不回去的。有了每次变动前后的快照,排查数据问题就变成了简单的SQL查询。
3.3 申领单与申领明细表
申领单是业务主表,申领明细是子表,一对多关系。主子表的设计能满足“一次申领多种物资”的真实场景。我见过有系统让用户一次只能申领一种物资,然后反复填单,体验极差,其实数据库层面加一张子表成本很低。
CREATE TABLE `apply_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '申领单号', `apply_dept` VARCHAR(100) NOT NULL COMMENT '申领单位/小组', `applicant` VARCHAR(50) NOT NULL COMMENT '申领人', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=待审核 1=已通过 2=已拒绝 3=已发放', `audit_by` VARCHAR(50) DEFAULT NULL COMMENT '审核人', `audit_time` DATETIME DEFAULT NULL COMMENT '审核时间', `audit_remark` VARCHAR(255) DEFAULT '' COMMENT '审核意见', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物资申领单';申领明细表的核心字段有:
CREATE TABLE `apply_order_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `material_id` BIGINT NOT NULL, `material_name` VARCHAR(100) NOT NULL, `apply_quantity` INT NOT NULL COMMENT '申请数量', `actual_quantity` INT DEFAULT NULL COMMENT '实际发放数量', `unit` VARCHAR(20) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='申领明细表';为什么要有 apply_quantity 和 actual_quantity 两个字段?因为库存可能不足,审批人可能只批准一部分。实发数小于申请数是常态,把两个字段分开,才能留痕“少给了多少”。
3.4 用户与角色表设计
权限设计上我采用了RBAC(基于角色的访问控制)模型。用户表、角色表、用户角色关联表、菜单/权限表、角色权限关联表,一共五张表。这套模型是通用方案,扩展性好,以后要加“审计员”角色,只需要插入角色数据和授权数据,代码层面几乎不用改动。
用户表和角色表的SQL本身不复杂,我就不贴全了,核心是权限表的结构思路:
CREATE TABLE `menu` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID', `menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称', `path` VARCHAR(200) DEFAULT NULL COMMENT '路由地址', `perms` VARCHAR(200) DEFAULT NULL COMMENT '权限标识,如 stock:in:add', `icon` VARCHAR(100) DEFAULT NULL, `sort_order` INT DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';这里的perms字段是重中之重。后端接口做接口级鉴权时,Shiro或Spring Security就是根据当前用户拥有的 perms 值判断是否放行。比如仓库管理员登录后,后端接口会校验他是否持有stock:in:add这个权限标识,没有就直接拒绝。
4. 核心代码链路与实现关键点
4.1 项目工程结构
先看一眼整个项目包结构,心里有个地图:
com.example.resource ├── controller # 控制器层,只做参数接收和结果封装 ├── service # 业务逻辑层,事务、审批流核心都在这里 ├── mapper # MyBatis Plus数据访问接口 ├── entity # 数据库实体映射 ├── dto # 数据传输对象,接收前端参数 ├── vo # 视图对象,返回前端数据 ├── config # 配置类,如MyBatisPlus分页插件、跨域配置 ├── handler # 全局异常处理 └── common # 通用工具类、返回结果封装类、常量这个结构的核心思想是分层。Controller层“只做传话的”,Service层“只做生意的”,Mapper层“只做搬砖的”。严禁在Controller里写复杂业务逻辑,否则后续事务控制会让你欲哭无泪。
4.2 用户认证与权限控制的实现
认证这块我用的Shiro。可能有人会说Spring Security更主流,但在那个阶段和这个需求规模下,Shiro的上手成本低太多,配置简单,API直观,完全够用。
核心配置简化后大概是这样的:
@Configuration public class ShiroConfig { @Bean public ShiroFilterChainDefinition shiroFilterFactoryBean() { DefaultShiroFilterChainDefinition definition = new DefaultShiroFilterChainDefinition(); definition.addPathDefinition("/login", "anon"); definition.addPathDefinition("/css/**", "anon"); definition.addPathDefinition("/js/**", "anon"); definition.addPathDefinition("/images/**", "anon"); definition.addPathDefinition("/**", "authc"); return definition; } @Bean public AuthorizingRealm realm() { return new ResourceRealm(); } }Realm是Shiro的“数据源”,它告诉你如何从数据库查询用户、如何加载用户的角色和权限:
public class ResourceRealm extends AuthorizingRealm { @Autowired private UserService userService; @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username = (String) token.getPrincipal(); SysUser user = userService.getUserByUsername(username); if (user == null) { return null; } return new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), getName()); } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); List<String> perms = userService.getUserPermsByUsername(username); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.addStringPermissions(perms); return info; } }然后在Controller方法上直接加注解:
@RequiresPermissions("stock:in:add") @PostMapping("/api/stock/in") public Result addStock(@RequestBody StockInDTO dto) { return stockService.stockIn(dto); }如果你的代码跑起来发现@RequiresPermissions不生效,大概率是Shiro和Spring AOP代理的整合没配好。检查一下是否加了@EnableAspectJAutoProxy,这是当年的高频问题。
4.3 入库业务的实现要点
入库是整个系统的业务起点。物资从供应商或捐赠方到达仓库后,仓库管理员执行入库操作。核心业务逻辑集中在 Service 层:
@Override @Transactional(rollbackFor = Exception.class) public Result stockIn(StockInDTO dto) { // 1. 校验物资类型是否存在 Material material = materialMapper.selectById(dto.getMaterialId()); if (material == null) { return Result.error("物资不存在"); } // 2. 更新库存(存在则累加,不存在则新增) Stock stock = stockMapper.selectByMaterialIdAndSpec(dto.getMaterialId(), dto.getSpecification()); int beforeQuantity = 0; if (stock == null) { stock = new Stock(); stock.setMaterialId(dto.getMaterialId()); stock.setMaterialName(material.getName()); stock.setSpecification(dto.getSpecification()); stock.setUnit(dto.getUnit()); stock.setQuantity(dto.getQuantity()); stock.setSafetyStock(0); stockMapper.insert(stock); } else { beforeQuantity = stock.getQuantity(); stock.setQuantity(stock.getQuantity() + dto.getQuantity()); stockMapper.updateById(stock); } // 3. 插入库存流水 StockRecord record = new StockRecord(); record.setMaterialId(dto.getMaterialId()); record.setRecordType(1); record.setQuantity(dto.getQuantity()); record.setBeforeQuantity(beforeQuantity); record.setAfterQuantity(stock.getQuantity()); record.setRemark(dto.getRemark()); record.setCreateBy(SecurityUtils.getCurrentUsername()); stockRecordMapper.insert(record); return Result.success(); }注意红线:这里必须加@Transactional(rollbackFor = Exception.class)。如果不加,可能出现“库存表更新成功,但流水表插入失败”的情况。一旦出现,你就等着半夜对账的时候哭吧。为什么特别强调 rollbackFor=Exception.class?因为 Spring 默认只回滚运行时异常,如果你代码里抛的是受检异常(比如IO异常),默认情况下事务不会回滚。
4.4 出库与申领审批流程的联动
出库有两种触发方式:一是仓库管理员直接点击出库(针对计划内物资调拨),二是申领单审批通过后系统自动出库。
我强烈建议把这两种方式统一起来。申领单审批通过时,内部实际上就是执行一次出库动作。这样代码逻辑不会分叉,库存扣减只走一条路,避免不同代码路径扣减逻辑的一致性出问题。
@Override @Transactional(rollbackFor = Exception.class) public Result auditApply(Long orderId, Integer status, String auditRemark) { ApplyOrder order = applyOrderMapper.selectById(orderId); if (order == null || order.getStatus() != 0) { return Result.error("单据不存在或已处理"); } if (status == 2) { // 拒绝:只更新状态 order.setStatus(2); order.setAuditRemark(auditRemark); order.setAuditBy(SecurityUtils.getCurrentUsername()); order.setAuditTime(new Date()); applyOrderMapper.updateById(order); return Result.success(); } // 审批通过:逐条扣减库存 List<ApplyOrderDetail> details = applyOrderDetailMapper.selectByOrderId(orderId); for (ApplyOrderDetail detail : details) { Stock stock = stockMapper.selectByMaterialId(detail.getMaterialId()); if (stock == null || stock.getQuantity() < detail.getActualQuantity()) { throw new RuntimeException("库存不足,审批失败,请调整实发数量"); } stock.setQuantity(stock.getQuantity() - detail.getActualQuantity()); stockMapper.updateById(stock); // 写流水 StockRecord record = new StockRecord(); record.setMaterialId(detail.getMaterialId()); record.setRecordType(3); record.setQuantity(detail.getActualQuantity()); record.setBeforeQuantity(stock.getQuantity() + detail.getActualQuantity()); record.setAfterQuantity(stock.getQuantity()); record.setSourceOrderId(orderId); record.setCreateBy(SecurityUtils.getCurrentUsername()); stockRecordMapper.insert(record); } order.setStatus(1); order.setAuditRemark(auditRemark); order.setAuditBy(SecurityUtils.getCurrentUsername()); order.setAuditTime(new Date()); applyOrderMapper.updateById(order); return Result.success(); }这段代码里埋了一个坑:库存扣减使用的 UpdateById 在并发情况下可能出现“超卖”。如果你所在的环境并发量高,应该把stockMapper.selectById和updateById换成带条件扣减的SQL,比如:
UPDATE stock SET quantity = quantity - #{num} WHERE material_id = #{materialId} AND quantity >= #{num}然后判断影响行数,如果影响行数为0,说明库存不足。这是一个典型的乐观锁思路,在低并发的社区场景其实用不上,但我还是建议大家写代码时养成这个习惯。
4.5 库存预警实现思路
库存预警不是系统主动发消息,而是通过定时任务扫描。我用Spring自带的@Scheduled注解实现,每30分钟扫描一次所有库存表,凡是低于安全库存下限的物资,向管理员的站内信表插入一条预警记录。同时在前端首页做展示。
@Component public class StockWarnTask { @Autowired private StockMapper stockMapper; @Autowired private WarnMessageMapper warnMessageMapper; @Scheduled(cron = "0 */30 * * * ?") public void scanStockWarn() { List<Stock> lowStockList = stockMapper.selectList( new LambdaQueryWrapper<Stock>() .apply("quantity <= safety_stock") ); for (Stock stock : lowStockList) { // 去重,同一物资同一天不重复预警 Long count = warnMessageMapper.selectCount( new LambdaQueryWrapper<WarnMessage>() .eq(WarnMessage::getMaterialId, stock.getMaterialId()) .eq(WarnMessage::getWarnDate, LocalDate.now()) ); if (count == 0) { WarnMessage warn = new WarnMessage(); warn.setMaterialId(stock.getMaterialId()); warn.setWarnDate(LocalDate.now()); warn.setMessage("物资[" + stock.getMaterialName() + "]库存低于安全值,当前库存:" + stock.getQuantity()); warn.setReadFlag(0); warnMessageMapper.insert(warn); } } } }这个任务看似简单,有个细节容易被忽略:库存表中如果有些物资从一开始就没有设置 safety_stock(默认0),那只要库存降到0就会预警,这是合理的。但如果设置错了导致 safety_stock 大于初始库存量,就会立刻触发预警,所以在做基础数据维护时要提醒管理员正确设置安全库存值。
5. 部署、调试与常见问题排查
5.1 本地开发环境搭建
开发环境我推荐这套组合:
| 组件 | 版本/工具 | 说明 |
|---|---|---|
| JDK | 1.8 | 稳定,生态兼容性好 |
| Maven | 3.6+ | 依赖管理 |
| MySQL | 5.7 或 8.0 | 测试环境随意,生产建议5.7 |
| 数据库管理工具 | Navicat 或 DataGrip | 个人偏好DataGrip |
| IDE | IntelliJ IDEA | 社区版够用 |
启动前必须确认三件事:MySQL服务是否启动,数据库名和密码是否与application.yml配置一致,init.sql是否已导入。
spring: datasource: url: jdbc:mysql://localhost:3306/resource_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver这里特别提醒:serverTimezone=Asia/Shanghai这个参数一定要加。不加的话,Java 8 以上的时区设置和 MySQL 默认时区不一致,查询出来的时间会差8个小时。无数人踩过这个坑,我甚至帮同事排查过半小时,最后发现就是时区参数没写。
5.2 打包与部署
Spring Boot 项目打包,很多人直接用mvn package,结果发现 Jar 包启动后找不到主类。正确命令是:
mvn clean package -DskipTests打包完成后,Jar 包路径在target/目录下。部署到服务器:
nohup java -jar resource-system.jar --spring.profiles.active=prod > logs/run.log 2>&1 &把日志输出到文件里是个好习惯。不然你用java -jar直接跑,关掉SSH窗口,进程就没了。nohup加后台运行,日志文件可以在排查问题时看现场。
上传文件时注意:如果服务器上已经有旧版本 Jar 在运行,要先停掉旧进程再替换,不然可能出现文件占用(Windows)或端口冲突。停进程的标准做法是:
ps -ef | grep resource-system kill -9 进程号5.3 我调试过程中踩过的三个典型问题
问题一:登录后循环重定向或者页面404。
排查思路:先看拦截器配置。Shiro或登录拦截器很容易把静态资源路径也拦截掉,导致CSS、JS加载失败,页面样式全丢,看起来就像网页“崩了”。解决办法是在配置中放行静态资源:
/css/** /js/** /images/** /favicon.ico还有个隐蔽问题:如果你用了 Thymeleaf,Controller 层返回视图时路径拼错也会404。检查返回值是不是return "admin/stock-list",而页面文件要在templates/admin/stock-list.html,这个层级不能错。
问题二:MyBatis Plus查询的分页无效。
当年我在一个问题里发现,分页插件的配置文件没写对。MyBatis Plus 3.x 时代,分页插件必须显式声明:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }漏了这个配置,分页查询事实上把全表数据都查出来,然后在内存里做一个假分页,数据量一大会明显变慢。
问题三:库存数据莫名其妙不一致。
这类问题用好流水表就很好解决。比如某物资系统中库存显示10个,但实物只有5个。排除法:查流水表,看最近出库记录里有没有一条出库数量对不上;再看有没有盘点单,有时候是盘点调整没写在流水里。只要流水表设计规范,这类数据问题90%都能在十分钟内定位。
5.4 并发场景下的数据一致性心得
社区物资发放高峰期,会出现多人同时操作的情况。比如志愿者A在给某小组发放物资,同时仓库管理员在登记新到货的物资。如果没有锁,库存账目容易乱。
我的处理方案是双保险:
第一,数据库层面,关键库存扣减SQL使用乐观锁,WHERE quantity >= #{num}这种条件判断,影响行数为0就拒绝操作。
第二,系统层面,在 Service 方法上加入synchronized或使用分布式锁(单机部署时synchronized就够)。注意:Spring 的synchronized只能锁住当前 JVM 实例,如果你将来做了多节点部署,就要引入 Redis 分布式锁。就这个系统的部署规模来说,单机 + synchronized 完全够用。
5.5 系统上线前的逐项检查清单
我每次做完这类项目,都会按下面这个清单过一遍,避免上线后手忙脚乱:
- 数据库备份是否开启?核心表是否有自动备份机制?
- 配置文件中是否关闭了开发环境调试页面(比如 Druid 监控页面是否需要限制外网访问)?
- 管理员初始密码是否已强制修改?
- 服务器磁盘空间是否足够?日志文件会不会涨满?
- 端口是否对外开放?防火墙规则是否正确?
- 有没有做并发验收测试,比如10个用户同时提交申领单?
这些项看着琐碎,真出了问题全是大事。
6. 项目延展与优化空间
6.1 从单机到多节点的扩展路径
当前这套系统是单机部署,一个应用实例连一个MySQL。如果某天业务量暴涨,比如从社区升级到街道,甚至全城区物资统一调度,单机就会成为瓶颈。
扩展方案有两种思路:
第一种,应用层水平扩展。把 Jar 包部署到多台服务器,前面架一台 Nginx 做负载均衡。但要注意,一旦多节点部署,所有synchronized失效,必须引入 Redis 分布式锁。而且需要引入 Spring Session 来统一会话管理。
第二种,数据库读写分离。把库存表的读操作分流到从库,但写操作仍然集中在主库。这套系统的写入频率其实不高,瓶颈大概率在并发扣减,所以主从分离收益不大,不如优化SQL和使用乐观锁。
6.2 从“登记工具”到“决策辅助系统”
如果只是记录出入库,这套系统和一个高级Excel没有本质区别。真正让它价值倍增的是数据分析和决策支持。
可以做一张“物资消耗趋势图”:统计最近7天、30天内各物资的日均消耗量,结合当前库存和安全库存,系统自动计算“还能支撑多少天”。这个数字对管理层制定采购计划、申请上级调拨物资非常关键。
还可以做“申领热门物资排名”:按申领次数或申领数量排序,找出最紧缺的物资,提前备货。这些功能本质都是SELECT语句加GROUP BY,技术上没有门槛,但业务价值非常高。
6.3 对资源调度类系统开发的通用启示
做这套系统的过程中我最大的感悟是:资源管理系统的难点不在于技术,而在于对业务的理解。
什么是好的资源管理系统?我认为有三条标准:
第一,数据及时准确。操作一步到位,库存实时反映真实情况。 第二,流程清晰可追溯。每件物资发到哪去了,谁批准的,谁领走的,拉出来的数据链条完整无断点。 第三,对使用者友好。界面要简单直白,就算是不懂计算机的仓库管理员,培训十分钟就能上手。
把这三个标准记住,以后无论做防汛物资、救灾物资、活动后勤物资还是企业办公用品的系统,都能在这个思路上延展。技术框架可以换,前端界面可以换,但这个业务模型的内核是可复用的。
我后来再接到类似需求时,基本都是在这个模型基础上做名词替换和流程微调,开发周期能压缩到一周以内。也希望这篇拆解能帮你少走一些弯路,遇到同样需求时,脑子里能有一个成型的底稿可以抄。