Spring Boot医院药品管理系统:从库存模型到并发扣减的完整实现

发布时间:2026/9/16 4:43:05
Spring Boot医院药品管理系统:从库存模型到并发扣减的完整实现 简介医院药品管理系统的完整源码包基于SpringBootVue、MyBatisPlus与MySQL构建面向Java学习者、毕业设计开发及中小型医院药品信息化管理场景。系统覆盖药品信息管理、用户登录与权限、数据维护等核心环节前后端分离可帮助读者理解企业级Web项目从数据库设计、表结构规划到后端接口联调与前端渲染的完整开发链路。包内共415个文件以Java源码109个、Vue组件54个、SVG图标161个及XML配置为主附带三段可执行的bat脚本、Word/PDF说明文档与图片视频素材压缩包约25.03MB目前已有275人学习下载。这套源码不仅提供完整可运行项目还整合了部署脚本和说明文档编译后可快速复现药品管理流程适合通过对照代码掌握SpringBoot与Vue的整合思路、后端接口设计以及增删改查实现文档部分也能辅助撰写毕业设计或系统说明书整体目录和模块划分清晰便于按需查阅。1. 医院药品系统源码比普通 CRUD 多出来的不是代码是库存模型网上以“医院药品系统源码”为名的项目十个里有七八个是标准增删改查药品表、供应商表、入库单、出库单跑起来不难一进药房现场就露馅。医院药品管理真正难的是一批药品发了一半过期、同一通用名不同厂家混放、月底盘点总账和实物对不上。基于 Spring Boot 的医院药品系统代码层面的难度反而排在后头先是库存模型再是并发扣减最后才是那些表单向开发。这篇把医院药品管理系统的后端 Java 代码从表设计讲到上线前要补的库存快照适合刚接手相关项目的 Spring Boot 开发也适合准备把业务写进简历的人。2. 建模医院药品系统的批次与库存先于接口代码定下来设计一张药品管理系统的表不建议按前端页面逐个建表那样做出来的界面很完整账永远盘不准。先定主数据、批次、库存和流水四类核心表后续所有接口都围绕它们展开。2.1 为什么医院药品不能像普通商品只存数量普通电商的 SKU 库存只关心“还剩多少件”但医院药品在入库时必须登记生产批号和有效期至。同一通用名、同一厂家不同批号的药品不能混着发同一批号也可能因为补货来自不同渠道厂商名称都不同。如果把批号放到一条库存字段里发药时只能猜效期预警只能写死字符串截取。因此核心库存不挂在药品主数据上而是挂到“药品批次”上。一批药一个drug_batch记录一个批次对应一条drug_stock当前库存。这样查某个批号能到多少、哪个先过期都有明确落点。2.2 药品主数据、批次、库存三张表的 DDL先看药品主数据注意把规格、剂型、最小单位单独建字段不要塞进名称里。药房里的“0.25g*24片”和“0.25g*48片”是两种规格名称可以一样规格必须分列。CREATE TABLE drug_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, drug_code VARCHAR(64) NOT NULL COMMENT 院内药品编码, drug_name VARCHAR(128) NOT NULL COMMENT 药品通用名, trade_name VARCHAR(128) DEFAULT NULL COMMENT 商品名, spec VARCHAR(64) NOT NULL COMMENT 规格0.25g*24片, dosage_form VARCHAR(32) NOT NULL COMMENT 剂型片剂/注射剂, unit VARCHAR(16) NOT NULL COMMENT 最小发药单位片/支/袋, manufacturer VARCHAR(128) DEFAULT NULL COMMENT 生产厂家, purchase_price DECIMAL(10,4) DEFAULT NULL COMMENT 采购价带4位小数, retail_price DECIMAL(10,4) DEFAULT NULL COMMENT 零售价, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_drug_code (drug_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品主数据;这段 DDL 的关键是不用汉字做编码只把汉字放进注释。drug_code做唯一键后续入库单、处方接口都拿它传参避免前端传一个“阿莫西林胶囊”让后端到处模糊匹配。金额字段用DECIMAL(10,4)而不是doubleJava 里对应BigDecimal否则累计采购金额时会出小数点误差。批次表要记录有效期和生产日期并单独建有效期索引方便后面做效期预警。CREATE TABLE drug_batch ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, drug_id BIGINT NOT NULL COMMENT 关联drug_info.id, batch_no VARCHAR(64) NOT NULL COMMENT 生产批号, manufacturer VARCHAR(128) DEFAULT NULL COMMENT 实际生产厂家, production_date DATE DEFAULT NULL COMMENT 生产日期, valid_until DATE NOT NULL COMMENT 有效期至, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在用 0停用, PRIMARY KEY (id), KEY idx_drug_id (drug_id), KEY idx_valid_until (valid_until) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品批次;同一药品可能同时存在两个批号所以这张表通过drug_id batch_no查具体批次。如果同一个drug_id下出现了相同batch_no正常应当复用已有批次而不是新建否则库存会被拆成两条盘点时很难解释。库存表是余额表只保存当前每个批次剩余多少。CREATE TABLE drug_stock ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, batch_id BIGINT NOT NULL COMMENT 关联drug_batch.id, quantity INT NOT NULL DEFAULT 0 COMMENT 当前可用数量, locked_quantity INT NOT NULL DEFAULT 0 COMMENT 已锁定待发数量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_batch (batch_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品批次库存;这张表刻意不带药品名称、不带批号文本只留batch_id一个外联字段。quantity是真正的可发库存locked_quantity是护士站已经确认但还没有实际发出的锁定数量。version字段用于并发控制后面代码里再展开。最后还要有一张流水表。库存余额可以被改写但流水只记录每一次变化不允许 update。月底对账、盘盈亏追溯都靠它。CREATE TABLE drug_stock_flow ( id BIGINT NOT NULL AUTO_INCREMENT, stock_id BIGINT NOT NULL COMMENT 关联drug_stock.id, batch_id BIGINT NOT NULL COMMENT 便于按批号汇总, biz_type TINYINT NOT NULL COMMENT 10入库 20出库 30报损 40盘点调整, biz_no VARCHAR(64) NOT NULL COMMENT 入库单号/处方号/报损单号, change_qty INT NOT NULL COMMENT 正数入库负数出库, before_qty INT NOT NULL, after_qty INT NOT NULL, operator_id BIGINT NOT NULL COMMENT 操作人ID, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_batch (batch_id), KEY idx_biz_no (biz_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品库存流水;这里before_qty和after_qty不是冗余。出库时出现负库存如果只记录 -5无法判断扣减前库存是多少记录前后值后任何一次异常都能直接在流水里定位是入少还是出多。2.3 实体类与 MyBatis-Plus 映射使用 MyBatis-Plus 时Java 字段下划线转驼峰需要打开默认配置里通常已经开启。实体类尽量和表字段一一对应不要偷懒省略字段尤其是version。Data TableName(drug_stock) public class DrugStock { TableId(type IdType.AUTO) private Long id; private Long batchId; private Integer quantity; private Integer lockedQuantity; Version private Integer version; }Version是 MyBatis-Plus 的乐观锁注解。更新时要先查出版本号再执行带版本条件的 update更新成功后版本号 1。若两个事务同时读到同一个 version后提交者影响行数为 0说明库存已经被改过此时应当重试或抛出异常。quantity用Integer而不用int避免数据库允许空值时拆箱报 NPE。3. 入库与出库Spring Boot 事务里把库存流水一次写完库存模型定好以后后端 Java 代码的核心不是 Controller 写得多么规范而是入库、出库两个方法能不能同时保证“库存余额更新”和“流水写入”的一致性。下面按最常见的 MyBatis-Plus 方案做。3.1 技术选型为什么用 Spring Boot 加 MyBatis-Plus医院药品系统的复杂查询集中在批次汇总、效期统计、出入库明细这些场景用 MyBatis 的 XML 写动态 SQL 比 JPA 自动装配更直观。Spring Boot 负责自动配置和事务管理MyBatis-Plus 负责单表 CRUD 与分页Redis 用来做缓存热点药品数据不参与记账。依赖如下dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency需要注意Spring Boot 版本高一点时mysql 驱动坐标已经改成com.mysql:mysql-connector-j如果从本地仓库找到的是旧包启动时会报 Driver 加载失败。配置集中在application.ymlspring: datasource: url: jdbc:mysql://localhost:3306/hospital_drug_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: changeit driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto这里serverTimezoneAsia/Shanghai必须显式指定否则 MySQL 8 会使用服务器默认时区时间差 8 小时。map-underscore-to-camel-case打开后batch_id自动映射到batchId实体类不需要写一长串TableField。3.2 入库主单、明细、批次、库存四个动作在一个事务里完成入库接口不能只往入库单表插一条数据就返回。完整入库业务包含四个动作写入入库单头与明细、找到或创建批次、累加库存、写入库存流水。这四个动作必须处于同一个事务任何一个失败都全部回滚。Service public class DrugInboundService { private static final int BIZ_INBOUND 10; Resource private DrugBatchMapper drugBatchMapper; Resource private DrugStockMapper drugStockMapper; Resource private StockFlowMapper stockFlowMapper; Transactional(rollbackFor Exception.class) public Long createInbound(InboundInDTO in) { if (in.getItems() null || in.getItems().isEmpty()) { throw new IllegalArgumentException(入库明细不能为空); } // 1. 入库单头、明细的插入就在这个事务里先拿到入库单号 Long inboundNo createInboundHeader(in); for (InboundItem item : in.getItems()) { // 2. 按 drugId batchNo 判断批次是否已存在 DrugBatch batch drugBatchMapper.selectByDrugAndBatchNo( item.getDrugId(), item.getBatchNo()); if (batch null) { batch new DrugBatch(); batch.setDrugId(item.getDrugId()); batch.setBatchNo(item.getBatchNo()); batch.setValidUntil(item.getValidUntil()); drugBatchMapper.insert(batch); } // 3. 查到库存记录就累加查不到就新建 DrugStock stock drugStockMapper.selectByBatchId(batch.getId()); if (stock null) { stock new DrugStock(); stock.setBatchId(batch.getId()); stock.setQuantity(item.getQuantity()); stock.setLockedQuantity(0); stock.setVersion(0); drugStockMapper.insert(stock); } else { int affected drugStockMapper.increaseQuantity(stock.getId(), item.getQuantity()); if (affected ! 1) { throw new RuntimeException(库存累加失败); } } // 4. 写入库存流水before_qty 和 after_qty 必须完整记录 stockFlowMapper.insertFlow(batch.getId(), BIZ_INBOUND, String.valueOf(inboundNo), item.getQuantity(), updatedQuantity); } return inboundNo; } }这个方法的参数对象InboundItem至少应该包含drugId、batchNo、validUntil、quantity。第 2 步的“查不到新建”逻辑意味着同一批次第二次入库不会新增批次记录只会累加同一个drug_stock。updateQuantity字段在真实代码里应该写清楚推荐在累加后重新selectById拿到最新数量后再写流水主流程中不要手算“旧值加增量”避免并发下流水 after 值不准确。increaseQuantity的 SQL 必须写在 XML 里让数据库完成原子累加update idincreaseQuantity update drug_stock set quantity quantity #{delta} where id #{id} /update使用set quantity quantity #{delta}而不是传一个计算后的新值是为了避免经典查询再更新问题。两个并发入库事务同时读到 quantity100分别加 50 和 30若是先读后写会有一方覆盖另一方改成数据库增量表达式后两次更新最终结果是 180。3.3 出库扣减把“余量判断”交给数据库而不是 Java 代码出库最常见的是按处方发药。发药前界面显示“库存 10 盒”但同一秒内另一个窗口也在发药。如果 Java 代码先查库存判断够用再执行 update两个请求都能通过判断最后后一次更新就会把库存扣成负数。解决办法是把判断条件写进 update 的 where 子句。Transactional(rollbackFor Exception.class) public void deductByPrescription(DeductInDTO dto) { if (StringUtils.isBlank(dto.getPrescriptionNo())) { throw new BizException(处方号不能为空); } // 出库单头、明细的插入省略和入库一样在同一事务里 for (DeductItem item : dto.getItems()) { int affected drugStockMapper.deductQuantity(item.getStockId(), item.getQty()); if (affected ! 1) { throw new BizException(库存不足或批次已被锁定); } // 扣减成功后读取最新库存用于写流水 DrugStock stock drugStockMapper.selectById(item.getStockId()); stockFlowMapper.insertFlow(stock.getBatchId(), BIZ_OUTBOUND, dto.getPrescriptionNo(), -item.getQty(), stock.getQuantity()); } }对应的 XML 更新语句是update iddeductQuantity update drug_stock set quantity quantity - #{qty} where id #{id} and quantity #{qty} /updatequantity #{qty}就是并发控制的关键。数据库 InnoDB 在更新这一行时会加行锁第二个事务会等待第一个事务提交后再更新此时第二个事务发现数量不足直接影响行数为 0Java 代码抛出业务异常。这里不再依赖 Java 里的 if 判断判断逻辑交给数据库执行才是真正安全的扣减。如果医院存在“先锁定库存、后续再实际扣减”的需求步骤可以拆成两步第一步把quantity减掉、locked_quantity加上第二步确认发药后再把locked_quantity减掉。锁定的目的不是让库存显示变少而是确保其他窗口能看到“即将被占用”。3.4 一张流水表决定对账是否轻松入库、出库、报损、盘点调整每一种业务类型都要有独立的biz_type和业务单号。流水表设计得越细月底对账越容易。biz_type含义对应业务单号10入库入库单号20出库处方号或领用单号30报损报损单号40盘点调整盘点任务号对账时不需要数库存余额只需要按批次聚合流水SELECT batch_id, SUM(change_qty) AS total_change FROM drug_stock_flow WHERE created_at BETWEEN ? AND ? GROUP BY batch_id;该结果与期初库存相加应当等于期末库存。如果不等优先查缺少了哪种biz_type的流水而不是直接改余额。流水表的biz_no可以允许重复因为一个处方可能发多批药但必须保证同一批次下biz_no与change_qty的组合能对应到唯一事实。4. 拿到 springboot 医院药品管理系统源码后先把这三处跑通从网上找到的“基于 springboot 的医院药品管理系统源码”大概率会遇到两种情况一种是代码能直接启动但没有任何初始化数据另一种是启动报各种版本错误。不要先急着看页面先把后端按下面顺序跑通。4.1 环境与启动顺序先确认三件事JDK 版本、Maven 仓库依赖是否完整、MySQL 初始化脚本是否执行。Spring Boot 项目最忌直接双击 IDEA 里的 main 方法然后看着报错一层层猜先看pom.xml中 parent 版本对应的 JDK 要求。mvn clean package -DskipTests java -jar target/hospital-drug-system-0.0.1-SNAPSHOT.jar这段命令是通用做法。target目录下的 jar 名不固定以pom.xml里artifactId和version拼接结果是准。如果mvn构建很慢检查 Maven 镜像是否配置成国内源很多“源码跑不起来”实际上是依赖下载超时。如果只想快速开发调试也可以直接mvn spring-boot:run启动后先看控制台日志里有没有Tomcat started on port(s): 8080再用curl探活而不是先打开浏览器。4.2 先调后端接口不要先调前端页面接手的项目无论自带多少前端页面建议先绕过登录直接调两个核心接口创建入库单、查询库存。这样可以快速验证数据库和事务配置是否正常。curl -s -X POST http://localhost:8080/api/inbounds \ -H Content-Type: application/json \ -d {batchNo:B241215,drugCode:YP00001,quantity:100,validUntil:2027-06-30}入参里batchNo是真实生产批号validUntil是有效期至quantity是本次入库数量。后端应返回入库单号。然后查库存curl -s http://localhost:8080/api/stocks?drugCodeYP00001返回列表里应该能看见该批次的剩余数量为 100。如果返回空不要急着查接口先用 SQL 直接查三张表SELECT s.id, b.batch_no, s.quantity, s.version FROM drug_stock s JOIN drug_batch b ON b.id s.batch_id JOIN drug_info d ON d.id b.drug_id WHERE d.drug_code YP00001;这一步能判断问题出在接口还是出在数据写入。如果 SQL 查得到说明入库接口逻辑正常可能只是查询接口的过滤条件写错反之则是事务回滚。4.3 三个容易让 Java 老手也翻车的点现象常见原因排查位置入库后库存没有增加Transactional被同类方法自调用事务不生效检查调用入口是否从外部 Service 进入并发扣减库存出现负数没有使用update ... where quantity #{qty}而是先查后减查看 XML 或 Mapper 中扣减 SQL 的 where 条件列表页响应很慢循环查询数据库每次取一条药品名称打开 MyBatis SQL 日志观察是否出现大量相同结构 SQL第一种情况经常出现在“一个 Service 里的公开方法调用另一个公开方法”时。Spring 的事务代理只对从外部进入的调用生效同类内部调用绕过了代理对象于是Transactional不生效。可以注为自己调自己拆成两个 Service或者把事务边界放到最外层调用方法上。第二个问题和数据库隔离级别无关InnoDB 的快照读在并发账目场景中不可靠。正确做法是把“检查余量”变成 SQL 条件影响行数为 0 时就说明判断失败。第三个问题优化方式是把药品名称查询冗余到出库明细里或者在 DTO 中一次性查出来映射不要用for循环访问数据库。5. 效期预警与库存快照让医院药品管理系统在月底扛得住系统上线后最有价值的功能不是录入界面而是每个月月底对账和每天早上的效期预警。这两块都可以用最简单的定时任务实现。5.1 用 Scheduled 做效期预警别在列表里实时扫药品有效期不能靠用户每天手动翻列表。常见做法是在批次表上扫valid_until提前三个月生成预警记录。Component public class ExpiryRemindJob { Scheduled(cron 0 5 0 * * ?) public void remind() { LocalDate deadline LocalDate.now().plusMonths(3); ListDrugBatch expiringBatches drugBatchMapper.selectExpiringBatches(deadline); for (DrugBatch batch : expiringBatches) { // 写入 drug_notice 表记录批次ID、药品ID、预警日期 } } }这段代码每天零点零五分执行一次不进接口层避免列表页每次打开都做全表扫描。selectExpiringBatches的 SQL 条件就是valid_until deadline且status 1。需要把它写到drug_batch表查询不查流水因为有效期属于批次属性与库存变动无关。5.2 月底对账直接查快照不再扫三个月流水流水表越来越大以后月底汇总会变慢。常见做法是每天凌晨执行一次库存快照把当天结束时各批次余量保存到一张单独的快照表。CREATE TABLE drug_stock_snapshot ( id BIGINT NOT NULL AUTO_INCREMENT, snapshot_date DATE NOT NULL, drug_id BIGINT NOT NULL, batch_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_snapshot_date (snapshot_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT每日库存快照;每天定时任务执行前先删除同一snapshot_date的旧数据再执行INSERT ... SELECTINSERT INTO drug_stock_snapshot (snapshot_date, drug_id, batch_id, quantity) SELECT CURRENT_DATE, b.drug_id, s.batch_id, s.quantity FROM drug_stock s JOIN drug_batch b ON b.id s.batch_id;快照的意义在于把“期初余额”固定下来。月底对账时直接取前一天快照的quantity再计算当天drug_stock_flow的SUM(change_qty)两者相加应与当天快照一致。第二天的流水对不上时优先核对前一夜快照和当天流水即可。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询