医院药品管理系统这个题目,在毕业设计和中小型医院信息化项目里出现频率非常高,也是我做过较多的一类Spring Boot管理系统。它表面上是普通CRUD:管理员维护用户和角色,药房维护药品,窗口扫一下处方发药,库存不够了再补个采购单。但真正做起来你会发现问题并没有这么简单——一张处方怎么变成库存扣减,一笔采购怎么变成入库流水,效期和批号怎么体现在库存余额上,这才是这套系统最考验人的地方。本文把我做这套Spring Boot医院药品管理系统的整体思路、表结构设计、核心代码逻辑和踩坑记录完整写出来,给正在选这个方向或者准备答辩的同学参考。
如果你打算把它当成一个普通增删改查项目来做,那代码肯定也能跑,但面试官一追问就会露馅。反过来,如果你把采购、发药、库存预警这些业务链路理顺了,这个项目能讲的东西其实非常多。
1. 项目动手前,先把医院药房的业务账算明白
1.1 药房管理最容易失控的三个点
我见过不止一个版本的药品管理系统,第一版几乎都长一个样子:一张药品表,字段里有drug_name、stock_num,发药就stock_num - 1,入库就stock_num + 1。这种设计在演示时没问题,但是真实药房根本没法用,原因有三个。
第一个是药品批次和效期问题。药房里的药不是按“一个药品名”来管的,同一个盐酸左氧氟沙星注射液,可能同时存在三个批号,每个批号的生产日期、有效期、进货价都可能不同。如果系统里只有一个总数量,发药时到底先发哪个批次?有效期最近的药一直压在角落里过期,这种事用Excel管都很难避免,何况系统里连批号都没有。
第二个是发药和处方的对应关系。医院药品发出去了,必须有记录能追溯:哪张处方、谁开的、发给哪个患者、哪个药师发的、发的是哪个厂的哪一批药。药品不良反应一旦需要追溯,这些信息缺一个都麻烦。普通CRUD思维很容易把发药做成一个简单的“库存减少”,完全丢失了单据上下文。
第三个是账实不一致。药房每天有成百上千次出入库动作,任何一次漏记、错记、重复操作,都会让系统库存和实物对不上。防止这个问题不能靠自觉,而是要靠业务流程设计:所有库存变动都必须源自一张上游单据,比如采购单、处方单、报损单,系统里不允许出现没有来源的“直接改库存”操作。
1.2 核心角色和流程:从采购到发药要过几道门
这个系统我建议按一个中小型医院的门诊药房加药库来建模,不接HIS、不做全流程电子病历,先把药品进销存的主干跑通。核心角色有四类:
- 系统管理员:维护用户、角色、菜单权限,处理密码重置这类事。
- 药库管理员:维护药品字典和供应商,创建采购单、执行验收入库。
- 药房药师:接收处方、审核并发药,处理退药、报损。
- 管理/财务人员:看库存金额、科室领药、月度消耗这类统计报表。
把角色理出来后,主流程就很清晰了:
药库根据药品库存上下限和安全库存发起采购单,经过审批后到货验收入库,库存增加的同时会记一条入库流水;医生开处方或者药房窗口录入处方后,药师进行处方审核和发药,系统按“近效期先出”的原则扣减对应批号的库存;如果患者退药,要有退药单把库存加回去;每天定时的任务会扫描低库存和近效期药品,生成补货建议和效期预警;最后管理层通过日/月报表查看各科室用量、药品消耗金额和库存周转情况。
我强烈建议在动手写代码之前,先在纸上把每个流转节点画出来,标清楚每张单据的状态。例如采购单可以分为草稿 → 待审核 → 已审核 → 已入库 → 已作废;处方发药则要看库存、要校验数量、要写流水,缺一步都要设计进去。业务边界清楚了,后端服务的方法边界才会清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型和项目搭建:Spring Boot版本怎么定,依赖怎么配
2.1 Spring Boot 用 2.7 还是 3.x
如果你现在新建项目,大概率会遇到这个选择。我的建议很直接:如果目标是毕业设计、课程项目,或者希望网上搜到的教程基本都能直接照抄,那就用JDK 8 + Spring Boot 2.7.18 + MyBatis-Plus 3.5.x + MySQL 8.0,这个组合非常成熟,资料最多,坑最少。
Spring Boot 3.0以后最大的变化是javax.*迁移到了jakarta.*,很多老代码和网上复制下来的工具类会直接编译报错。另外Spring Boot 3配合MyBatis-Plus需要额外的mybatis-plus-spring-boot3-starter,Swagger也不能再用老的SpringFox,得改用springdoc-openapi,注解也换成了io.swagger.core.v3。我见过不少同学追新用了Spring Boot 3.2,结果大部分时间都花在处理依赖兼容上,业务代码反而没时间写。
在2.7版本中,Spring Security的写法也已经切换到了SecurityFilterChain,直接用WebSecurityConfigurerAdapter会报警告甚至跑不起来。建议把下面这个配置方式作为基准模板:
java复制@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.authorizeHttpRequests(auth -> auth
.antMatchers("/api/auth/login", "/doc.html").permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
这段配置的意思是:登录接口放行,其他接口都要带上合法Token,服务端不创建Session,后端就变成一个纯粹的无状态接口服务。这套模式在后端管理系统里非常流行,也好和前端Vue分离部署。
2.2 围绕管理系统的依赖清单
既然是管理系统,依赖不需要贪多。核心依赖我控制在下面这些:
spring-boot-starter-web:提供Controller和Tomcat。spring-boot-starter-security:登录认证与权限校验。spring-boot-starter-validation:对前端参数做JSR 303校验。mybatis-plus-boot-starter:单表CRUD靠它的BaseMapper,复杂SQL仍然手写。mysql-connector-j:数据库驱动。jjwt-api/impl/jackson:生成和解析JWT。easyexcel:报表导出用,比直接用POI省很多内存。lombok:减少实体类的getter/setter样板代码。springdoc-openapi-ui:接口文档,注意选择兼容2.7的版本。
工程目录建议用标准的controller/service/mapper/entity/config/common分层。额外说一点,不要把业务逻辑写在Controller里。很多初写项目的人会图方便,在Controller里写一长串if/else再调updateById,后面前端改一个字段,后端排查要翻好几个文件。Controller只做参数接收、调用Service、包装返回结果,真正的业务判断和事务都放在Service层,这是我和不少人联调后感触最深的一点。
2.3 鉴权方案:Spring Security + JWT 的标准落地
药房管理系统必须做权限控制,因为不同角色能看到的东西完全不一样。药库管理员能看到进货价,药房药师只需要知道发药数量,财务不能去创建采购单。这里我采用的是经典RBAC,用户、角色、菜单(权限码)三层结构。
登录成功后,后端签发一个JWT,把用户ID、用户名、角色编码放进去。Token生成后前端存到本地,每次请求放在Authorization请求头里。后端通过一个JwtAuthenticationFilter拦截请求,解析出用户信息并放入SecurityContextHolder。
java复制@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
Claims claims = JwtUtil.parseToken(token.replace("Bearer ", ""));
if (claims != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 构造 UsernamePasswordAuthenticationToken 并放入上下文
}
}
filterChain.doFilter(request, response);
}
}
密码存储一定不要用MD5,MD5撞库太容易了,直接用Spring Security自带的BCryptPasswordEncoder。在方法级权限控制上,开启@EnableGlobalMethodSecurity(prePostEnabled = true)后,就可以在Service或Controller方法上写:
java复制@PreAuthorize("hasAuthority('drug:stock:inbound')")
public void stockIn(StockInCommand cmd) { ... }
这样就把接口权限和后面的菜单权限码统一起来了。相比给每个接口手写if(role=="admin"),这种方式维护起来要轻松得多。
3. 表结构设计:把药品字典、批次库存、流转流水拆开
3.1 核心表结构清单
这个项目我分了几组核心表,每组之间通过业务单号或外键关联:
| 分组 | 表名 | 主要作用 |
|---|---|---|
| 系统权限 | sys_user / sys_role / sys_menu | 用户、角色、菜单权限 |
| 基础资料 | drug_info / drug_category / supplier | 药品字典、分类、供应商 |
| 库存 | drug_stock | 按批次和效期保存库存数量 |
| 出入库流水 | stock_flow | 每一次库存变化的可追溯记录 |
| 采购 | purchase_order / purchase_order_item | 采购主单和明细 |
| 单据 | prescription / prescription_item | 处方头表与处方明细 |
sys_user只保留最基本的账号信息,如果以后对接科室、排班,再单独扩表。drug_info保存药品通用名、商品名、规格、生产厂家、批准文号这些基础资料,注意不要在这里放库存数量,因为库存属于“某个药品的某个批次”,放在药品表里就是把维度做粗了。
purchase_order和prescription这类单据一定要有状态字段。单据状态是业务流程的载体,审核、发药、作废都要改状态,避免出现“数据改了但没人知道是从哪个节点变的”这种问题。
3.2 药品库存表为什么按批次和效期设计
库存表是整个系统的核心表,我建议至少包含这些字段:
sql复制CREATE TABLE `drug_stock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`drug_id` BIGINT NOT NULL COMMENT '药品ID',
`batch_no` VARCHAR(64) NOT NULL COMMENT '生产批号',
`expire_date` DATE NOT NULL COMMENT '有效期至',
`stock_qty` INT NOT NULL DEFAULT 0 COMMENT '库存数量',
`lock_qty` INT NOT NULL DEFAULT 0 COMMENT '锁定数量',
`purchase_price`DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '批次进货价',
`sale_price` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '批次零售价',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_drug_expire` (`drug_id`, `expire_date`)
) ENGINE = InnoDB;
每个批次的药品单独一行,stock_qty表示当前实物数量,lock_qty表示已经被占用但还没最终出库的数量。真正可以分配的库存是stock_qty - lock_qty。这个设计和电商库存里的“可用库存/锁定库存”是同一个思路,药房发药时如果两个窗口同时想发同一批药的最后一盒,这个字段就能派上用场。
只记录库存数量还不够,因为账面上“有数量”不代表“能发货”。如果某个批次已经过期,就算库存表里还有100盒,发药接口也必须不允许出库。所以出库不能只减数量,必须校验效期。
3.3 库存流水表:账实相符的底层保障
我见过很多管理系统,库存表就是一张表,采购入库update一下,发药出库再update一下,没有流水的概念。结果月底盘点对不上账的时候,根本查不出来是哪一笔操作把库存改错了。
解决办法是引入一张stock_flow流水表,每次库存发生变化,都在同一个事务里插入一条流水。流水至少包含:关联药品ID、批号、变动类型、变动数量(正数入库、负数出库)、来源单号、操作人和变动后余额。这样做的好处非常直接:任何一笔库存异常,都可以根据来源单号反查到对应的采购单或处方,形成一条完整的证据链。
用生活类比解释的话:库存表相当于这个药柜里现在放了多少药,而流水表相当于每一次往柜子里放药和从柜子里拿药的日志。药品管理系统可以允许业务流程出错,但不允许出错之后查不到原因。有了流水表,做库存调整、盘点、报损,才算是有了依据。
4. 核心业务流程实现笔记:采购、发药、预警、报表
4.1 采购验收入库:单子审核通过之后怎么加库存
采购入库存不能直接写“药品ID,数量+100”,流程上要先有采购单。这里的基本流程是:
- 药库管理员创建采购单,选择供应商,添加需要采购的药品明细。
- 库房负责人审核采购单,状态变成“已审核”。
- 到货后录入每个批次的实际批号、生产日期、有效期和验收数量。
- 提交入库,系统在同一事务中更新库存并写流水。
这里涉及两个关键点。第一个是要防止同一张采购单被重复入库。如果上一步已经入库了,再点一次按钮还能继续加库存,那财务和实物就完全对不上了。程序员思维是“数据库插入前判断一下状态”就行,但实际并发情况下要用锁兜底,我在验收方法中会先把订单锁住再判断状态:
java复制PurchaseOrder order = purchaseOrderMapper.selectByOrderIdForUpdate(cmd.getOrderId());
if (!"APPROVED".equals(order.getStatus())) {
throw new BizException("采购单当前状态不允许入库");
}
第二个关键是同一个药品的同一个批次可能之前已经有库存了,比如采购了两批相同批号但收货日期不同,这时不需要新建库存行,而是直接给已有库存加数量。但如果是不同批号或不同效期,就必须插入新行。我的入库代码大致是这样:
java复制DrugStock stock = drugStockMapper.findByDrugIdAndBatchAndExpire(
item.getDrugId(), item.getBatchNo(), item.getExpireDate());
if (stock != null) {
drugStockMapper.increaseQty(stock.getId(), item.getReceiptQty());
} else {
drugStockMapper.insertNewStock(item);
}
stockFlowMapper.insert(buildInboundFlow(...));
入库方法整体加上@Transactional(rollbackFor = Exception.class),保证“加库存”和“写流水”要么同时成功,要么同时回滚。否则库存加了、流水没写,后续对账就一团乱。
4.2 门诊发药:并发扣库存与多批次出库逻辑
门诊发药是使用频率最高的接口,也是最容易出并发问题的环节。患者交完费拿着处方到药房窗口取药,药师在系统里录入或匹配处方,核对没问题后做发药确认。这个操作看起来只是减库存,但要处理几个细节。
第一是要校验处方状态。一张处方不能发两次药,发药前要确认处方还没被发过、没有被退药,然后锁定处方记录。第二是要按“近效期先出”选择批次,否则系统里全部库存都是准确数字,但发出去的永远是新批号,老批号的药就会堆到过期。查询批次时,应该按expire_date ASC排序,从最早效期的批次开始扣。
一个药品可能同时存在好几个批次,此时要循环扣减,第一个批次不够就减成0再去扣第二个批次。扣减单个批次的SQL用条件更新:
java复制int rows = drugStockMapper.reduceStockByQty(
stock.getId(), item.getQty(), stock.getVersion());
if (rows == 0) {
throw new BizException("药品库存不足或批次已变化,请刷新后重试");
}
对应的Mapper SQL是:
sql复制UPDATE drug_stock
SET stock_qty = stock_qty - #{qty},
version = version + 1
WHERE id = #{id}
AND version = #{version}
AND stock_qty - lock_qty >= #{qty}
这里把“库存是否足够”的校验直接放进UPDATE语句的WHERE条件里了,库存不足时受影响行数为0,代码就能感知到并发冲突。比起“先SELECT*判断再UPDATE”的做法,少了超卖窗口。一张处方里的多个药品明细,如果其中任何一条发药失败,整个事务回滚,不会出现“这个药发出去了,那个药没发出来”的半截状态。
发药成功之后,要把处方状态更新为“已发药”,同时给每个出库批次生成对应的stock_flow流水,流水的来源单号就填处方号。将来要查某个批号流向了哪张处方,一条SQL立刻能查出来。
4.3 近效期提醒与低库存预警怎么做
预警模块不需要太复杂,但实时性要求不要指望定时任务解决所有问题。我的做法是两条线并行:业务层每次出库前都校验效期,有效期已过的直接禁止出库;同时每天凌晨用定时任务扫描即将过期的批次,生成提醒到系统消息表里。
扫描近效期药品的查询条件很简单:
sql复制SELECT d.drug_name, s.batch_no, s.expire_date, s.stock_qty
FROM drug_stock s
LEFT JOIN drug_info d ON d.id = s.drug_id
WHERE s.expire_date >= CURDATE()
AND s.expire_date <= DATE_ADD(CURDATE(), INTERVAL 6 MONTH)
ORDER BY s.expire_date ASC;
定时任务用Spring自带的@Scheduled就够了:
java复制@Scheduled(cron = "0 0 3 * * ?")
public void scanNearExpiryDrugs() {
List<DrugStock> nearExpiryStocks = drugStockMapper.selectNearExpiryList(new Date(), DateUtils.addMonths(new Date(), 6));
// 写入 notice 表,避免工程重启后提醒丢失
}
低库存预警也一样,判断阈值时不要用stock_qty,而要用stock_qty - lock_qty,因为已经被锁定但还没出库的库存其实不能再分配给新处方。比较好的方式是在药品字典表里维护一个min_stock字段,预警任务统一扫描低于阈值且未在途采购的药品,自动生成“补货建议”。这里有个隐含条件,如果药品已经有一张“已审核但未入库”的采购单,就不用重复提醒了,否则药库管理员每天会被重复消息烦死。
4.4 出库统计与报表模块落地
一套管理系统没有报表,管理层根本不会用,因为日常药品消耗了多少、库存占了多少资金,这些必须靠数字说话。我的项目里报表模块主要做了这几张表:按日/月的出库金额统计、按药品分类的出库量top10、按供应商的采购金额汇总。
报表最容易踩的坑是直接在业务表上用大范围GROUP BY实时统计,表数据量上来后接口越来越慢。我的做法是准备几张统计汇总表,每天凌晨由定时任务把前一天的数据汇总进去。前端的日报、月报接口只查汇总表,看板加载速度会快很多。
报表导出用EasyExcel,不要直接用POI手写样式,否则几万条数据导出时内存很容易被打满。导出的时候留意一下权限:业务员能不能看到进货价?这个权限码要单独控制,避免价格信息被低权限的人到处导出。
5. 开发与上线过程中的典型问题排查
5.1 启动和配置类问题
刚开始搭建项目时,最常见的就是启动起不来。MySQL 8.0连接时如果报Public Key Retrieval is not allowed,通常是因为数据库账号使用了caching_sha2_password认证,连接串里加上allowPublicKeyRetrieval=true&useSSL=false能解决。还有日期问题,JDBC连接串里的serverTimezone最好显式配成Asia/Shanghai,不要依赖MySQL服务器默认时区,否则可能差8个小时。
另一个高频问题是MyBatis-Plus分页不生效。如果你写了selectPage,但没有配置分页插件,查出来的数据其实是全量。在MyBatis-Plus 3.5.x版本下,需要配置一个MybatisPlusInterceptor,并在里面添加PaginationInnerInterceptor。没有这个配置,分页逻辑会静默失效,接口返回的第一页数据和全部数据没区别,这个坑不太容易察觉。
如果用了Spring Boot 3.x,还需要额外留意javax和jakarta的包名替换、MyBatis-Plus的starter版本是否匹配。很多老代码从网上复制下来,在这个环节会集中报编译错误,排查起来比较费时间。这也是我推荐新手先用2.7的原因。
5.2 事务不生效与并发库存扣减问题
在实际调试中,事务不生效比想象中更容易出现。最容易犯的错误是在同一个Service类的内部调用自己的另一个@Transactional方法,例如this.sendInternal(),因为this调用不走Spring代理,事务注解根本没被解析,方法异常时数据库不会回滚。解决方法是把需要事务的方法放到另一个Service中注入调用,或者把事务拆分到独立的事务模板中。
还有@Transactional默认只回滚RuntimeException和Error,如果你在方法里主动throw new Exception("出错"),Spring不会帮你回滚事务。自定义业务异常最好继承RuntimeException,并在@Transactional上显式指定rollbackFor = Exception.class,这样最稳妥。
库存扣减的并发问题,我遇到过最直接的场景是测试时两个账号同时给同一张处方点发药
