简介:企业级管理系统中,库存流转是业务闭环的基石。以药品管理系统为例,其核心不在于堆砌功能,而在于入库、出库与库存数据的一致性。通过SpringBoot+MyBatis-Plus搭建精简架构,以六张核心表支撑药品字典、供应商、入库单、出库单及批次库存管理;利用@Transactional保证入库单与库存更新的原子性,并使用原子SQL解决并发扣库存成负数的问题。同时,通过定时任务实现近效期预警,为系统增加业务深度。这种从数据建模到事务控制、再到并发优化的设计思路,既适用于医院药品管理,也可泛化到一般进销存系统。基于SpringBoot毕设实践,可梳理出一条可落地的药品库存闭环实现方案。
1. 医院药品管理系统毕设:功能闭环比代码量更重要
做“医院药品管理-JAVA-基于SpringBoot”这个毕设题目的同学,十有八九不是被语法卡住的,而是卡在“第一步到底该写什么”:这个系统需要哪些模块、几张表、模块之间怎么关联,第一个能跑通的功能闭环是什么。在我眼里,这个题目真正的验收标准只有一个——药品从供应商进来,通过入库单变成库存,再通过出库单发到临床科室,最后能在库存列表查询到准确数量,整个过程经得起演示和追问。所以这篇笔记不按教科书顺序讲 SpringBoot 和 MyBatis-Plus 的语法,而是按一条能落地的主线走:先定表和模块,再跑通入库/出库核心流程,然后讲并发、日期、版本这几个高频坑,最后说清楚论文怎么写、答辩怎么演示才不翻车。
2. SpringBoot 药品管理系统的数据建模与架构:六张表撑起全部业务
2.1 功能模块怎么拆:由入库到出库这条主线推导
设计医院药品管理系统,最容易犯的错是照抄商用系统功能列表,把处方、收费、发药全部搬进来,然后陷入模块迷宫。医院是复杂业务环境没错,但从毕设评审角度,老师关注的是一条主线:药品库存流转是否完整、库存数据是否对得上。我一般把功能收敛成四个模块:药品字典管理、药品库存管理、供应商管理、系统管理。四个模块自上而下围住一条主流程——采购入库、库存累计、领用出库、库存削减。
这四个模块对数据模型的影响很小:药品字典和供应商字典只是基础数据,库存管理承载入库单和出库单,系统管理只涉及用户和角色。如果非要给管理员、药师、普通用户各配一套权限体系,也能做,但毕设没必要把权限系统当核心亮点;把库存流程走到位,再做简单角色过滤,已经是中等偏上的完成度。尤其“药品入库→库存增加”“出库申请→库存扣减”这两个事务边界,如果做不清楚,写论文和答辩都会被反复追问,因为这是药品管理系统区别于“花架子 CRUD”的关键点。
2.2 六张核心表的字段设计:围绕库存流转而不是药品字典
我建议这个毕设的数据库建六张表,全部围绕库存流转展开。表结构如果一开始就正确,后面 Controller 和页面返工就少很多。
| 表名 | 含义 | 关键字段 | 关联关系 |
|---|---|---|---|
| drug | 药品字典 | drug_name / specification / dosage_form / manufacturer / approval_number / unit / price | 无 |
| drug_stock | 药品库存 | drug_id / batch_no / quantity / expiry_date / warehouse_loc | 关联 drug.id |
| supplier | 供应商字典 | supplier_name / contact_person / phone / address | 无 |
| stock_in_order | 入库单 | order_no / supplier_id / drug_id / quantity / unit_price / total_price / in_date | 关联 drug.id、supplier.id |
| stock_out_order | 出库单 | order_no / drug_id / quantity / recipient_dept / recipient / out_date | 关联 drug.id |
| sys_user | 系统用户 | username / password / real_name / role | 无 |
这六张表里,drug_stock 是真正的核心。我见过很多人把库存表设计成“药名 + 总数量”两个字段,表面没问题,但药品批号和有效期就变成可有可无的冗余列。真实药房里,同一盒药的不同批号、不同有效期库存必须分开管理,否则近效期预警根本做不准。所以 drug_stock 的逻辑唯一键是 drug_id 加 batch_no 两个字段,expiry_date 挂在库存记录上而不是药品主表上。DDL 里直接体现:
CREATE TABLE drug_stock ( id BIGINT AUTO_INCREMENT PRIMARY KEY, drug_id BIGINT NOT NULL, batch_no VARCHAR(50) NOT NULL, quantity INT NOT NULL DEFAULT 0, expiry_date DATE NOT NULL, warehouse_loc VARCHAR(50), UNIQUE KEY uk_drug_batch (drug_id, batch_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里的 UNIQUE KEY 是给“药品 + 批号”做防重约束,防止同一批药品重复插入两条库存记录。顺带说一句,网上有人用 MyBatis-Plus 反射实体类做“自动生成建表 SQL”的小脚本,本质是拼字符串,实用但不成体系;毕设我建议手写 DDL,因为你要能向老师解释清楚每个字段存在的理由。
另一个设计重点是价格。drug 表的 price 字段只用于列表展示,真正的结算价格记录在 stock_in_order 表的 unit_price 和 total_price 里。原因很简单:同一盒药今天进货价和三个月前可能不一样,入库单必须把“当时的单价、数量、总价”固化下来,才能让库存金额有据可查。这些设计理由在论文“数据库设计”章节可以直接变成段落,而且经得住追问。
2.3 三层架构与包结构:Controller 薄、Service 厚、Mapper 单一
SpringBoot 毕设流行三层架构,分层清晰本身就是一个评分点。一个可直接参考的包结构:
com.hospital.drug ├── controller # 参数接收 + 简单校验,不写业务 ├── service # 核心业务逻辑,事务边界 ├── mapper # 数据访问,继承 BaseMapper ├── entity # 表映射实体类 ├── config # 分页插件、CORS 配置等 ├── common # 统一返回体 Result、全局异常处理 └── HospitalDrugApplication.java职责边界大致是:controller 只做参数绑定和调用一个 service 方法,方法体尽量小于 20 行;service 承载核心业务逻辑,@Transactional 全部标在这一层;mapper 大多数时候只继承 MyBatis-Plus 的 BaseMapper,遇到复杂查询再写 XML;common 里放一个 Result 统一返回结构,前端拿到什么字段一目了然。
实际写代码时最常犯的毛病,是业务判断堆在 Controller 里:前端校验一遍、Controller 校验一遍、Service 又查一遍库存。这种写法带着写页面时的手感,看起来没问题,但一旦两个 service 方法互相调用,就会发现 Controller 里的校验完全没法复用,只能复制粘贴代码。所以一开始就守住分层边界,比多写几个看似聪明的技巧重要得多。
3. 用 SpringBoot + MyBatis-Plus 跑通药品入库出库:从 pom.xml 到 @Transactional
3.1 创建工程与 pom.xml:先锁 SpringBoot 2.7.18,别追高版本
第一步不是打开 Spring Initializr 点下载,而是把依赖组合固定在一个经过验证的组合上。我推荐 Spring Boot 2.7.18 + Java 8 + MyBatis-Plus 3.5.3.1。为什么不建议用 3.x?因为 SpringBoot 3.x 把 javax 包名整体改成 jakarta,你从大多数视频教程和网盘源码里复制的代码会直接报依赖错误。毕设的核心诉求是稳定跑通,不是版本新。
pom.xml 关键配置:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这组依赖对应一个最小可用工程。spring-boot-starter-web 负责 Web 容器和接口,mybatis-plus-boot-starter 是 MyBatis 的增强包,mysql-connector-j 是 MySQL 8.x 驱动,版本由 Spring Boot 父工程管理,不需要自己写 version;lombok 给实体类省去 Getter/Setter。四个依赖就能把 CRUD 跑起来,不要再加 Redis、Shiro、JWT 之类的东西——它们对毕设没有直接帮助,反而增加配置和答辩风险。
提示:版本先锁 2.7.18。2024 年之后的教程大量转向 3.x,如果你跟着新教程做,javax 转 jakarta 的改动会浪费两到三天。
3.2 application.yml 三处必改:数据源、下划线映射、SQL 日志
工程跑起来之前,绝大多数问题出在 application.yml。一个接近实际的最小配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_drug?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有三处容易踩坑。第一,连接字符串里问号后面的参数不能省,尤其 serverTimezone=Asia/Shanghai 缺失时,部分 MySQL 版本会立刻报“Server time zone”错误。第二,map-underscore-to-camel-case: true必须开启,否则数据库表字段 drug_name 无法映射到实体类的 drugName 属性,查询结果全是 null。第三,log-impl 指向 StdOutImpl,MyBatis 会把每条 SQL 打印到控制台,查 bug 不用再猜到底执行了什么语句。
想省去后面配置麻烦的话,启动类现在就把两个注解加好:
@SpringBootApplication @MapperScan("com.hospital.drug.mapper") @EnableScheduling public class HospitalDrugApplication { public static void main(String[] args) { SpringApplication.run(HospitalDrugApplication.class, args); } }@MapperScan 告诉 Spring 去扫 mapper 包里的接口,@EnableScheduling 是给后续药品过期预警的定时任务预留开关。这两个注解提早加上不产生副作用,后面能少改一次启动类。
3.3 实体类与 Mapper 接口:两个注解解决映射问题
把 drug 表对应的实体写出来,是最直白的起步示例:
@Data @TableName("drug") public class Drug { @TableId(type = IdType.AUTO) private Long id; private String drugName; private String specification; private String dosageForm; private String manufacturer; private String approvalNumber; private String unit; private BigDecimal price; private LocalDateTime createTime; private LocalDateTime updateTime; }逻辑说明:@Data来自 Lombok,编译期自动生成 Getter/Setter;@TableName("drug")把实体类和库表绑定,不写会导致运行期找不到表;@TableId(type = IdType.AUTO)声明主键自增,配合数据库的 AUTO_INCREMENT。MyBatis-Plus 默认开启下划线转驼峰,drug_name 自动对应 drugName,不需要在 XML 里写繁琐的 resultMap。
Mapper 接口更简单:
@Mapper public interface DrugMapper extends BaseMapper<Drug> { }BaseMapper 里已经内置了 insert、deleteById、updateById、selectById、selectPage 等常用方法,单表 CRUD 基本不用手写 SQL。这个接口上的@Mapper是兜底注解,即使启动类漏写 @MapperScan,这个 Mapper 也能被容器抓到。两个机制不冲突,建议都保留。
3.4 入库流程:@Transactional 让库存与流水永远一致
药品入库的核心不是 insert 一张入库单,而是“入库单 + 库存更新”必须同时成功或同时失败。写这段业务时,SpringBoot 最重要的注解就是 @Transactional:
@Service public class StockInService { @Resource private StockInOrderMapper orderMapper; @Resource private DrugStockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void stockIn(StockInOrder order) { // 入库单号后端生成,避免前端伪造 order.setOrderNo("RK" + System.currentTimeMillis()); // 总价 = 单价 × 数量,不信任前端传的 totalPrice order.setTotalPrice(order.getUnitPrice() .multiply(BigDecimal.valueOf(order.getQuantity()))); orderMapper.insert(order); // 同一药品同一批号对应同一条库存记录 DrugStock stock = stockMapper.selectOne( new LambdaQueryWrapper<DrugStock>() .eq(DrugStock::getDrugId, order.getDrugId()) .eq(DrugStock::getBatchNo, order.getBatchNo()) ); if (stock == null) { DrugStock newStock = new DrugStock(); newStock.setDrugId(order.getDrugId()); newStock.setBatchNo(order.getBatchNo()); newStock.setExpiryDate(order.getExpiryDate()); newStock.setQuantity(order.getQuantity()); newStock.setWarehouseLoc(order.getWarehouseLoc()); stockMapper.insert(newStock); } else { stock.setQuantity(stock.getQuantity() + order.getQuantity()); stockMapper.updateById(stock); } } }这里有三个关键点。第一,@Transactional(rollbackFor = Exception.class)比默认的@Transactional更稳,默认只对 RuntimeException 回滚,受检异常会漏掉;入库操作涉及写单和改库存两个动作,必须显式指定回滚所有异常。第二,查询条件用 LambdaQueryWrapper 而不是裸 QueryWrapper,字段名在编译期检查,不会因为手写字符串拼错。第三,入库单号用时间戳生成,并发下有重复概率,论文阶段可以优化成“日期 + 序号”结构,这里先保证流程能跑通。
顺带一提,这个 rollbackFor 细节也是 JAVA 面试里常问的事务考点,答辩时能主动讲出来,比被动等老师提问印象好得多。出库流程的核心逻辑与入库对称,但多一个“库存够不够”的判断,这个判断题放到下一章避坑部分讲,因为它是并发问题的重灾区。
4. 药品管理系统避坑记录:并发扣库存、日期格式与 SpringBoot 版本
4.1 并发扣库存成负数:一条原子 SQL 解决
现象:两个科室同时领用同一种药品,库存只剩 5 盒,一次操作后库存变成 -1。出库单和库存都对不上,页面出现负库存。
原因:出库代码是“select 查库存 → if 判断 → update 扣减”三步走。两个线程同时查到库存为 5,都通过判断,都执行 update,自然扣成负数。这属于典型的并发边界问题,单机部署也一样会发生,只是概率低。
解决:把“判断”和“扣减”合并成一条 SQL,让数据库自己判断库存是否充足:
@Mapper public interface DrugStockMapper extends BaseMapper<DrugStock> { int deductStock(@Param("drugId") Long drugId, @Param("quantity") Integer quantity); }<!-- resources/mapper/DrugStockMapper.xml --> <update id="deductStock"> update drug_stock set quantity = quantity - #{quantity} where drug_id = #{drugId} and quantity >= #{quantity} </update>扣减操作返回受影响行数,0 就说明库存不足或药品不存在,Service 抛业务异常回滚。这种写法比在 Java 层加 synchronized 轻量得多。MyBatis-Plus 的乐观锁 @Version 也能解决,但要额外配置插件和版本字段,对毕设来说,一条原子 SQL 更容易讲清楚,也经得起答辩追问。
提示:@Transactional 方法必须写在 public 方法上且从外部调用。如果在同一个类里 this.stockOut() 调用,事务不会生效,这是事务失效最常见的原因。
4.2 LocalDateTime 解析 400:日期字段类型要匹配请求数据
现象:前端传"inDate": "2024-06-01 10:30:00"能正常接收,但传"expiryDate": "2026-06-01"后端直接报 400,日志里出现 HttpMessageNotReadableException。
原因:Jackson 反序列化时,LocalDateTime 要求输入字符串带时分秒,LocalDate 只接收年月日。如果实体里有效期字段也声明成 LocalDateTime,前端只要不带时分秒就解析失败。
解决:按业务语义选择字段类型,不要统一用 String:
// 有效期:只管年月日 @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate expiryDate; // 入库时间:需要精确到时分秒 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime inDate;前端 input type="date" 提交的永远是yyyy-MM-dd,所以有效期用 LocalDate 是唯一正确选择;入库时间关注时分秒,用 LocalDateTime。这里容易再犯的错是把所有日期字段都设成 String,那样数据库的 date 类型语义就废了,排序和范围查询都会变得很别扭。
4.3 数据库字段命名与驼峰映射对不上
现象:插入数据正常,但查询结果里 drugName 一直是 null,数据库里明明有值;或者用 QueryWrapper 做条件查询时生成 SQL 的列名和实际表结构不符。
原因:map-underscore-to-camel-case没开,MyBatis 不会把 drug_name 转成 drugName。反过来,开了之后表里某列叫 drugname(没有下划线),映射也会失效。
解决:固定遵守“表字段全下划线、实体字段全驼峰”的约定。配合 MyBatis-Plus 的 Configuration 配置和 BaseMapper,单表操作几乎感受不到映射过程的存在。排查方法也很简单:打开控制台 SQL 日志,看打印出来的列名和实体字段对不对得上。日志本身就是调试黑匣子最直接的突破口。
4.4 SpringBoot 版本太高导致源码与教程不一致
现象:从网上下载的参考工程导入 IDEA 后大量报错,尤其是 javax.servlet 这个包直接红波浪线;或者新建工程用 SpringBoot 3.x 启动后,MyBatis-Plus 版本与 jakarta 命名空间对不上,项目反复启动失败。
原因:SpringBoot 3.x 是一次大版本跨越,javax 包整体迁移成 jakarta,这个变化会同时影响 spring-boot-starter-web、MyBatis-Plus 等多个依赖。网上多数毕设源码和视频课程基于 2.x,导入 3.x 工程必然报错,学的时候也没人专门教迁移。
解决:毕设直接把版本组合固定成下面这一套:
| 组件 | 版本 |
|---|---|
| Spring Boot | 2.7.18 |
| Java | 8 或 11 |
| MyBatis-Plus | 3.5.3.1 |
| MySQL Connector/J | 由 2.7.18 父工程管理 |
这套不是最新,但被教材、论文模板、教学视频覆盖最广,出了问题能很快检索到答案。SpringBoot 版本高并不等于项目含金量高,把精力耗在版本兼容上才是真正的本末倒置。
4.5 Mapper 接口注入失败:No qualifying bean of type
现象:应用启动直接报Field drugMapper in ... required a bean of type 'DrugMapper',服务根本起不来。
原因:Spring 容器没有把 Mapper 接口注册成 Bean。通常是三个原因里的一个:启动类没写 @MapperScan,Mapper 接口没写 @Mapper,或者扫描路径与实际包路径不一致。
解决:启动类统一加@MapperScan("com.hospital.drug.mapper"),接口上保留 @Mapper 双保险。如果包路径确实拼错,启动类写对位置就能解决。另外,如果引入了 module 多模块结构,扫描路径必须精确到 Mapper 接口所在子模块的包,不能直接扫父包。排查顺序建议是:先看控制台是否打印了 Mapper XML 的加载日志,再检查实体类是否有 @TableName,最后才怀疑 Bean 注册问题。这个顺序能把多数“启动失败”在五分钟内定位。
5. 答辩前一公里:过期预警定时任务、接口文档与演示顺序
5.1 用 @Scheduled 做药品过期预警,给系统加一个亮点
纯 CRUD 的管理系统容易被评审扣“业务深度不够”的印象分,一个成本最低的加分点是有效期预警。前面启动类已经加了 @EnableScheduling,这里直接写任务:
@Component public class ExpiryWarningTask { @Resource private DrugStockMapper stockMapper; // 每天凌晨 2 点执行一次 @Scheduled(cron = "0 0 2 * * ?") public void checkNearExpiry() { LocalDate today = LocalDate.now(); LocalDate deadline = today.plusDays(30); List<DrugStock> stocks = stockMapper.selectList( new LambdaQueryWrapper<DrugStock>() .le(DrugStock::getExpiryDate, deadline) .ge(DrugStock::getExpiryDate, today) ); // 遍历 stocks,生成预警记录或写告警日志, // 前端管理页面轮询展示“近效期药品”模块 } }cron 表达式0 0 2 * * ?表示每天 2 点 0 分 0 秒执行,想白天演示效果就改成0 0 * * * ?每小时跑一次。这里的使用前提是实体中 expiryDate 用了 LocalDate,与第 4.2 节的建议完全呼应;如果字段类型混用,这个定时任务会在比较时踩坑。一个定时任务 + 一个库存查询接口,就能让论文里的“设计与实现”章节多出一块可写内容。
5.2 论文与答辩演示的收尾顺序
写论文“设计与实现”章节时,不需要把 Controller、Service、Mapper 全贴进去。更有效的做法是放一张入库出库的流程时序图,然后集中讲两个技术点:一个是 @Transactional 保证“入库单 + 库存”事务一致,另一个是出库接口用一条原子 SQL 做库存扣减。这两点讲清楚,比贴二十页跳着看的代码更能体现你真实做过设计与调试。
答辩演示则按“新增药品字典 → 新增供应商 → 创建入库单 → 查看库存列表 → 创建出库单 → 再查库存列表”的黄金顺序跑,3 分钟跑完闭环,之后有时间再演示过期预警定时任务和 Swagger 接口文档页面。这里还要记住一点:答辩时如果老师问起库存表为什么按批号拆,直接答“批号可追踪、近效期先出”就够了,这是药品库存管理的行业常识。
我后来养成了习惯:凡是管理信息系统题材的 SpringBoot 毕设,一律先建库表、再写 Service、最后补 Controller 和页面。这个顺序看起来很笨,但能保证任何时候库存数据都看得见、算得清,也是我赶论文凌晨换来的血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取