ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot医院药品管理系统设计与实现:进销存与效期预警实战

2026/10/3 3:17:38 拓冰建站 浏览量
Spring Boot医院药品管理系统设计与实现:进销存与效期预警实战 每年到这个时间点总会有几个人来问我同一个问题“Spring Boot的项目从哪下手”去年是图书管理今年轮到了药品管理。说实话“springboot基于Java医院药品管理系统的设计与实现”这种题目不是最炫的但绝对是最实用的一类——业务闭环完整、模块边界清楚、技术栈主流拿来练手或者当毕业设计都再合适不过。这篇就从系统设计讲到我实际写代码踩过的坑尽量把做这类进销存系统的思路一次说透。医院药品管理系统本质上是个“进销存权限预警”的综合业务系统。它不像电商秒杀那样追求高并发也不像AI项目那样炫技它考的是你对业务的理解、对事务边界的把控、对数据一致性的敏感度。做完这一整套你对后端开发的基本功会有质的提升。1. 系统整体设计与模块拆解先想清楚业务再动手1.1 药房管理真正“痛”的点在哪我不是在医院药房待过但调研过不少做医疗信息化的朋友。药品管理最怕的不是日常记录麻烦而是三个问题第一库存“对不上”。今天入库30盒明天发药18盒理论上剩12盒实际一盘点只有10盒中间那两盒去哪了系统里完全没有追溯路径。第二药品“过期没人知道”。很多药是分批次的一个批次快到期了系统没提醒等护士发药时才发现这批药已经失效整批报废不说还有医疗事故风险。第三缺货“靠感觉”。某款药快卖完了等医生开处方才发现没货患者白跑一趟药房主任被投诉。所以一个能用的药品管理系统核心就是把“采购入库、库存台账、发药出库、效期预警、库存预警”这条链子串起来。所有设计都要围绕“药房人员怎么减少重复劳动、怎么避免低级错误”来展开而不是为了做一个CRUD页面而做CRUD。1.2 功能模块怎么切才合理我见过不少初学者的系统一上来就列了十几个菜单什么“药品管理”“药品分类”“药品单位”“药品产地”全部分开结果逻辑混乱数据冗余得一塌糊涂。正确的做法是先把角色和流程想清楚再收敛模块。这个系统我最终切成六个核心模块用户与权限模块登录、角色区分管理员、药剂师、采购员、护士、菜单权限控制。这是后面所有操作的身份基础。药品档案模块药品的基础信息比如药品名称、通用名、规格、剂型、生产厂家、批准文号。注意药品档案不等于库存它描述的是“这个药品是什么”。供应商模块记录供货商名称、联系人、电话。采购单要关联它方便后期统计哪个供应商供货多、哪批货有问题。采购入库模块创建采购单、审核采购单、执行入库。入库的同时要生成批次信息并增加对应药品的库存余额。库存与发药模块库存台账查询、批次库存明细、发药/出库登记出库时按批次先进先出原则扣减库存。预警与统计模块库存低于安全下限的预警、药品近效期预警、月度出入库统计报表。模块切到这里就够了。像“盘点”“调拨”“报损”这些功能医疗药房确实有但作为练手项目可以先不碰否则工作量一下子膨胀核心链路反而做不深。1.3 一条业务主线串起所有表整个系统的业务主线是创建采购单 → 采购单审核 → 验收入库 → 库存余额增加 → 医生/护士发药 → 出库扣减库存 → 库存低于安全线 → 系统预警 → 再次发起采购。这条线和电商订单系统很相似采购单就是“进货订单”发药记录就是“销售出库单”库存表就是“SKU库存”。只是药品多了一个批次效期维度同一个药品不同批次的到期时间不同出库时必须先出快过期的批次。这条逻辑想通了数据库表设计就水到渠成。2. 技术选型与项目结构这套组合怎么配才稳2.1 技术栈的“标准答案”长什么样这个项目最稳妥的技术组合我推荐Spring Boot 2.7.x JDK 8 MyBatis Plus MySQL 5.7/8.0 Redis可选 Vue 2/3或 Thymeleaf。很多人在选型上纠结半天其实没有意义。做管理系统这种业务技术选型的第一原则是“稳”而不是“新”。Spring Boot 2.7 JDK 8是经过海量生产环境验证的组合资料多、坑少遇到问题一搜就有答案。MyBatis Plus则把单表CRUD和分页查询简化到极致能把精力集中在业务逻辑上。Redis在这个项目里不是必须的但如果要做“登录token缓存”或者“药品基础数据缓存”加一个也不算复杂。前端用Vue Element UI是最常见的搭配前后端分离也好展示如果不想写前端直接用Thymeleaf套后台模板也行但表现力会差一些。技术组件推荐选择理由后端框架Spring Boot 2.7.x稳定、兼容JDK 8、生态成熟ORMMyBatis Plus 3.5.xCRUD快、分页插件好用、代码量少数据库MySQL 8.0性能好、支持JSON、与InnoDB配合优秀权限方案JWT 拦截器无状态、前后端分离友好、实现简单接口文档Knife4j对接方便、生成接口文档答辩利器前端Vue 2 Element UI组件多、中文文档全、上手快2.2 为什么是Spring BootMyBatis Plus而不是SSH或JPA早些年做这种系统很多人用SSHStrutsSpringHibernate或者SSMSpringSpringMVCMyBatis配置繁琐得吓人。Spring Boot最关键的价值是自动装配它把框架的启动和配置过程大幅简化你引入一个spring-boot-starter-web内嵌Tomcat就自动跑起来了。配合application.yml集中配置数据源、Redis、日志开发体验比XML配置文件时代顺畅太多。MyBatis Plus对于“管理系统”这类以单表增删改查为主的项目几乎是作弊器一样的存在。内置的BaseMapper提供selectById、insert、updateById等常用方法QueryWrapper可以条件拼接查询分页插件PaginationInnerInterceptor一行配置就搞定。对比JPAMyBatis Plus更直观写复杂SQL时可以自己控制不会出现“表结构一改JPQL报错”的烦恼。对刚接触企业级开发的初学者MyBatis Plus的学习成本明显更低。2.3 分层架构与包结构约定无论业务规模多大我还是习惯经典的三层架构Controller负责接收参数和返回结果Service负责业务逻辑和事务Mapper负责数据库操作。Entity作为和表结构对应的实体类DTO/VO负责接口层的数据传递这样前端拿到的字段和后端表字段解耦不至于把密码字段直接甩给前端。建议的包结构如下实际开发中照着建就行com.hospital.drug ├── config // 配置类MyBatis Plus分页插件、拦截器注册、Knife4j ├── controller // 接口层 ├── service │ ├── impl ├── mapper ├── entity // 数据库表映射实体 ├── dto // 入参/出参对象 ├── vo // 视图返回对象 ├── common // 通用类统一返回结果、业务异常、常量 └── utils // 工具类JWT工具日期工具3. 数据库设计与核心业务逻辑几个硬骨头得啃下来3.1 六张核心表的设计思路表设计是整个系统的地基也是最容易被初学者做砸的部分。我按“药品档案-批次-库存-采购-发药-用户”的维度来拆药品档案表drugid、drug_name药品名称、specification规格比如0.25g*24片、dosage_form剂型、manufacturer生产厂家、approval_number批准文号、unit单位盒/瓶、drug_type处方药/非处方药。注意一个容易踩的坑规格和单位是业务字段但它决定了后面发药时“一次给几片、按什么单位记账”设计时一定要和业务人员核对清楚。药品批次表drug_batchid、drug_id、batch_no批次号、production_date生产日期、expire_date有效期至、quantity该批次当前数量。批次是药品和普通商品最大的差异点同一个药品可能同时存在三个批次的货出库时必须能按批次精准扣减。药品库存表drug_stockid、drug_id、stock_count库存总数量、safety_min安全库存下限、safety_max安全库存上限、update_time。这张表是台账表它的stock_count必须和drug_batch表里同drug_id的quantity总和保持相等。供应商表supplierid、supplier_name、contact_person、contact_phone、status。简单但必须有采购单要关联它。采购单表purchase_orderid、order_no采购单号、supplier_id、total_amount、status待审核/已审核/已入库、create_time、create_user_id。采购明细见下面。采购单明细表purchase_order_itemid、order_id、drug_id、purchase_price采购单价、quantity采购数量、total_amount小计。为什么采购要拆主表和明细表因为一张采购单可能包含多种药品不拆表的话数据冗余而且没法做汇总统计。发药记录表dispensing_record充当出库流水核心字段包括id、drug_id、batch_id、patient_name患者姓名、quantity、dispensing_time、operator_id。这张表的意义在于每一次库存扣减都有据可查。3.2 批次效期管理近效期预警怎么写批次效期逻辑是整个系统里最有“医院特色”的部分。药品不是入库了就完事效期短的药要优先出库这就是医药行业的“先进先出FIFO”。数据库层面我们只需要记录batch_no、production_date、expire_date剩下的逻辑在出库时处理。近效期预警的SQL核心是利用DATEDIFF判断剩余天数。比如要查“未来90天内到期”的批次SELECT b.*, d.drug_name FROM drug_batch b LEFT JOIN drug d ON b.drug_id d.id WHERE b.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND b.quantity 0 ORDER BY b.expire_date ASC;如果只想看已经过期的就把条件改成expire_date CURDATE()。业务上还应该配合一个定时任务每天自动把近效期批次推送到预警列表。不过做毕业设计的话直接在查询接口里返回这个列表就够了不一定要真上Quartz或Spring Task但可以给评委展示一下你意识到了这个场景。3.3 库存余额与安全库存预警库存预警的逻辑其实一句话当drug_stock.stock_count小于等于safety_min时说明库存触及警戒线。但实际开发中要注意一点不能只拿单个字段存储的“当前库存”来判断因为有些药品可能因为门急诊临时集中涌入库存瞬间被压到很低。所以预警查询最好是动态的QueryWrapperDrugStock wrapper new QueryWrapper(); wrapper.le(stock_count, safety_min); // stock_count safety_min ListDrugStock warnList drugStockMapper.selectList(wrapper);这里的le是用MyBatis Plus条件构造器实现小于等于比较。如果后台要支持“报损出库”还需要在扣减库存的方法里先更新stock_count再判断是否触发预警。经验之谈预警不要每次都全量扫描全表数据量上来之后会慢更好的做法是加一个updatetime索引或者单独维护一张预警记录表定时任务去刷新。3.4 金额和数量精度用BigDecimal别用double药品的采购价、零售价、金额合计一律用BigDecimal。很多人不以为然觉得0.1元的误差无所谓但金额计算是连续运算double的浮点误差会逐步累积最后对账对不上非常痛苦。BigDecimal使用上也有细节数据库字段用DECIMAL(10,2)Java属性用BigDecimalJSON序列化时注意精度和科学计数法。比如金额比较大的时候Jackson默认可能把BigDecimal序列化成科学计数法形式1.2E3前端很难看。我一般会加一个全局Jackson配置把BigDecimal格式化后输出。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(BigDecimal.class, new ToStringSerializer()); }; } }顺便说一句药品数量盒、瓶、片这种离散值用Integer就行千万别用BigDecimal。有些药是按“片”发、按“盒”收的如果换算牵扯小数就单独设计换算字段而不是把库存数量本身搞成带小数点的。4. 实操过程与核心代码实现从零到能跑起来4.1 项目初始化和关键配置先到Spring Initializr生成基础工程或者直接在IDEA里新建Spring Initializr项目。依赖勾选Spring Web、MyBatis、MySQL Driver、Lombok。然后手动往pom.xml加入MyBatis Plus和Knife4j的依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.github.xiaoymin/groupId artifactIdknife4j-openapi2-spring-boot-starter/artifactId version4.4.0/version /dependency注意MyBatis Plus 3.5.x和Spring Boot 2.7是兼容的如果你用了Spring Boot 3.xMyBatis Plus的包名和starter会有所不同需要对应升级到mybatis-plus-spring-boot3-starter同时javax.servlet要换成jakarta.servlet这个坑我后面会再提。application.yml里的关键配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_drug?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true一定要配置map-underscore-to-camel-case否则下划线字段名和Java驼峰字段对不上查出来一堆null。分页插件在配置类里注册Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }4.2 登录状态与接口保护拦截器比过滤器更好用这个项目里有三种角色管理员、药剂师、采购员。不同角色能访问的接口不同最简单可靠的做法是JWT 拦截器。登录接口校验账号密码后生成token返回前端前端每次请求在Header里带上Authorization: Bearer xxx。然后写一个AuthInterceptor继承HandlerInterceptor在preHandle方法里校验tokenComponent public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } String userId jwtUtil.parseToken(token); if (userId null) { response.setStatus(401); return false; } request.setAttribute(userId, userId); return true; } }然后在WebMvcConfigurer里注册拦截器配置放行路径Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /doc.html, /webjars/**, /v2/api-docs, /error); }这里有个容易漏的坑如果你用Knife4j在线调试不把doc.html和webjars放行后面接口文档打不开你以为代码坏了实际是拦截器把文档页也给拦了。我第一次做的时候就卡了半小时后来才反应过来。4.3 入库业务为什么必须用事务包住药品入库的基本流程是采购单状态从“已审核”改成“已入库”采购明细里的每种药品要新增批次记录同时把对应药品的库存余额增加。这三个步骤里任何一步失败数据都会不一致——比如采购单状态变成了已入库库存却没加上那账就平不了。所以入库Service一定要加Transactional。我的核心实现大致长这样Service RequiredArgsConstructor public class StockServiceImpl implements StockService { private final PurchaseOrderMapper purchaseOrderMapper; private final PurchaseOrderItemMapper purchaseOrderItemMapper; private final DrugBatchMapper drugBatchMapper; private final DrugStockMapper drugStockMapper; Override Transactional(rollbackFor Exception.class) public void doInbound(Long orderId, Long operatorId) { // 1. 校验采购单状态 PurchaseOrder order purchaseOrderMapper.selectById(orderId); if (order null || !APPROVED.equals(order.getStatus())) { throw new BusinessException(采购单不存在或未审核不能入库); } // 2. 遍历采购明细生成批次并增加库存 ListPurchaseOrderItem items purchaseOrderItemMapper.selectList( new QueryWrapperPurchaseOrderItem().eq(order_id, orderId)); for (PurchaseOrderItem item : items) { DrugBatch batch new DrugBatch(); batch.setDrugId(item.getDrugId()); batch.setBatchNo(generateBatchNo()); batch.setProductionDate(LocalDate.now()); // 实际项目中效期和生产日期来自验收单这里演示简化处理 batch.setExpireDate(LocalDate.now().plusYears(2)); batch.setQuantity(item.getQuantity()); drugBatchMapper.insert(batch); // 3. 更新库存余额不存在则新增 DrugStock stock drugStockMapper.selectOne( new QueryWrapperDrugStock().eq(drug_id, item.getDrugId())); if (stock null) { DrugStock newStock new DrugStock(); newStock.setDrugId(item.getDrugId()); newStock.setStockCount(item.getQuantity()); newStock.setSafetyMin(50); newStock.setSafetyMax(500); drugStockMapper.insert(newStock); } else { stock.setStockCount(stock.getStockCount() item.getQuantity()); drugStockMapper.updateById(stock); } } // 4. 更新采购单状态 order.setStatus(INBOUND); purchaseOrderMapper.updateById(order); } }注意rollbackFor Exception.class这个参数必须写。Spring的Transactional默认只在RuntimeException和Error时回滚如果你在业务代码里捕获了异常向上抛了一个checked exception不写这个参数事务就不会回滚库存照样更新采购单状态停在中间简直是最隐蔽的坑。4.4 出库扣减并发场景下的库存安全医院门诊发药看起来是单机操作但药房两个窗口同时在发同一种药的情况很常见。如果代码只是普通的“先查库存、判断够不够、再扣减”并发情况下会出现两个请求同时读到库存为10各自认为还够然后分别扣了8最后库存变成了2实际总共扣了16库存为负数。解决思路有两个按成本从低到高方案一乐观锁加版本号。在drug_stock表加一个version字段更新时把version作为条件更新成功说明没有并发冲突更新影响行数为0说明有人已先改过就重新查询再重试一次。UPDATE drug_stock SET stock_count stock_count - #{quantity}, version version 1 WHERE id #{id} AND version #{version} AND stock_count #{quantity}方案二悲观锁SELECT ... FOR UPDATE。在事务里先锁住这行库存记录再执行扣减。对医院这种并发量不高的场景悲观锁完全够用而且逻辑更好理解SELECT * FROM drug_stock WHERE drug_id #{drugId} FOR UPDATE然后Java里拿着锁住的stock接着做扣减判断和update。不过要记住FOR UPDATE必须在事务内才有效所以调用方或者Service方法本身必须开启事务。我一般在发药Service上加上Transactional方法内先查后更这样锁的粒度和事务边界是一致的。出库时批次的选择可以按“先过期先出”排序ListDrugBatch batches drugBatchMapper.selectList(new QueryWrapperDrugBatch() .eq(drug_id, drugId) .gt(quantity, 0) .orderByAsc(expire_date));然后遍历批次依次扣数量扣完一个批次再扣下一个。这段逻辑看起来啰嗦但它保证了药品效期管理的基本规则也是答辩时能讲出东西的亮点。5. 常见问题与排查技巧实录这些坑我替你踩过了5.1 MyBatis Plus分页插件“不生效”或“分页查询没用”这是初学者遇得最多的问题。现象是调用selectPage返回结果里records是全量数据total也不对。排查方向基本是两点一是插件没注册。Spring Boot下必须显式定义MybatisPlusInterceptor并加入PaginationInnerInterceptor很多人漏了这一步。二是MyBatis Plus版本与Spring Boot版本不匹配。我建议Spring Boot 2.7项目直接用3.5.5以上的MyBatis Plus不需要纠结如果你非要上Spring Boot 3.x记得用mybatis-plus-spring-boot3-starter这个坐标否则启动就报ClassNotFoundException。另外分页查询的SQL如果自己写了LIMIT分页插件会再次拼一个LIMIT导致数据不对甚至报错。自定义复杂SQL时只写查询条件分页交给Page参数不要在里面手写LIMIT。5.2 LocalDateTime接口返回“一串数字”或格式不对如果接口返回的日期是2025-06-01T10:30:00甚至一串数字时间戳多半是Jackson没配置好LocalDateTime的序列化格式。网上很多配置只针对java.util.Date比如spring.jackson.date-format对Java 8的LocalDateTime根本不起作用。正确做法是注册一个Jackson定制器Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializers(LocalDateTimeSerializer.of( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(LocalDateTimeDeserializer.of( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; }配置后所有LocalDateTime字段统一输出yyyy-MM-dd HH:mm:ss前端直接展示不用再处理。如果你用的是Fastjson也要单独设置SerializerFeature.WriteDateUseDateFormat。这类问题越早统一配置越好避免每个Controller各自转换代码脏得没法看。5.3 事务不生效、数据半更新的三种典型情况我梳理过这个项目里最容易出现“事务像没写一样”的三种情况第一种同类内部调用。StockService里有方法A调用同类的方法B方法B上标了Transactional但调用发生在this.methodB()层面事务注解被Spring代理旁路了不会生效。解决方式是把B方法抽到另一个Service或者自己注入自己再或者直接在当前方法开启事务把整个逻辑包含进去。第二种异常被吞掉了。很多人在service里写try-catchcatch里只打了日志没向上抛事务管理器完全感知不到异常自然不回滚。正确做法是catch后throw new RuntimeException(e)或者干脆不catch让全局异常处理器统一处理。第三种rollbackFor没设。前面说过了默认只回滚RuntimeException遇到checked exception不会回滚。统一写Transactional(rollbackFor Exception.class)是最稳的。5.4 常见问题速查表现象可能原因解决办法查询结果字段全是null没开map-underscore-to-camel-caseyml里配置该选项为true分页不生效没注册MybatisPlusInterceptor注册分页插件并指定DbType接口返回的日期变成时间戳Jackson没配置LocalDateTime注册序列化/反序列化定制器入库后库存没增加但单子状态变了事务没包住或异常被吞统一rollbackFor并检查try-catch高并发下库存扣成负数缺少乐观锁/悲观锁Update带stock_count?条件加锁登录后访问接口一直401拦截器没放行token校验路径检查AuthInterceptor和放行配置BigDecimal金额变成科学计数法默认JSON序列化注册ToStringSerializer写在最后的几句实在话做完这套药品管理系统最大的感受不是“我会写CRUD了”而是我终于理解了什么叫“业务驱动设计”。没有药房对库存、批次、效期的强约束我根本想不到要设计drug_batch和drug_stock两张表也想不到出库时要按效期排序。技术上再往后走如果要把这个项目扩展成真正能上线的系统我建议优先补齐三块一是引入Spring Security做更细粒度的权限控制二是加一套基于XXL-Job或Spring Task的定时巡检任务来自动刷新预警单三是把报表模块接上阿里云OSS这类对象存储导出出入库日报。第一次接触这类项目的人别追求一步到位先把采购入库、发药出库、库存预警这条主线跑通后面自然知道下一步该往哪补。真到答辩现场你能把“为什么库存要拆批次表”讲清楚比堆十个花哨功能管用得多。