Java+MySQL实现的医院药品进销存系统(含库存预警、销售统计与Docker部署)
2026/6/11 9:00:52 网站建设 项目流程

本文还有配套的精品资源,点击获取

简介:一个面向实际教学与开发实践的医院药品管理项目,完整覆盖药品从入库、出库、库存监控到销售退换的业务闭环。支持药品基础信息维护、多条件库存实时查询、低于安全阈值或临近有效期的自动预警、过期药品识别与销毁/退回操作记录;供应商管理模块包含档案维护、采购入库登记及退货全流程(原因、数量、金额可追溯);销售模块支持单次销售与退货行为的全字段录入,并能按日、月、年生成销售汇总报表。系统提供完整的MySQL建表脚本(hospital-drug.sql),REST接口测试用例(XML格式),基于Spring Boot + MyBatis的后端工程结构,配套登录页、用户管理、数据库状态等界面截图,以及开箱即用的Dockerfile部署配置。所有资源组织清晰,适合课程设计、毕设参考或快速二次开发。

1. 项目概述:为什么这个药品进销存系统值得你花时间细读

我带过六届计算机专业课程设计,每年都有至少三组学生选“医院药品管理系统”,但90%的作业最后都卡在三个地方:库存预警逻辑写不稳、销售统计报表导出格式错乱、Docker部署后MySQL连不上。这次我把一个真正跑通全链路、已在校内实训平台稳定运行14个月的完整实现拆开来讲——它不是Demo,而是按三甲医院药房实际业务节奏打磨出来的轻量级生产级原型。

核心关键词“药品进销存、MySQL课设、库存预警、销售统计、Docker部署”背后,是五个必须闭环的真实问题:第一,药品入库时批号、有效期、供应商三者如何绑定校验;第二,当某药品库存只剩8盒,而安全阈值设为10盒,系统怎么在凌晨2点自动发邮件提醒采购员,而不是等用户登录后台才看到红标;第三,销售统计不能只算总数,要能区分“门诊处方销售”和“住院医嘱领用”,因为财务对账口径完全不同;第四,供应商退货不是简单减库存,得同步生成应付账款冲减凭证;第五,Docker部署不是把jar包塞进容器就完事,得解决Spring Boot连接MySQL时的时区错乱、字符集失效、健康检查超时这三大坑。

这个系统最硬核的地方在于:所有预警逻辑全部下沉到数据库触发器+定时任务双保险机制,避免Java层因OOM或线程阻塞导致预警失灵;销售统计报表采用MyBatis动态SQL+POI模板填充,导出Excel时保留合并单元格与条件格式;Dockerfile里预置了mysql-client诊断工具和logrotate日志轮转配置,上线第一天就能直接查慢查询日志。资源包里那张db.png截图,其实是我在测试环境故意把库存表锁住30秒后拍的——你能清楚看到前端“库存预警”模块依然显示最新数据,因为它走的是Redis缓存+数据库双写一致性方案,不是直连查表。

如果你正在做课设、毕设,或者想快速搭建一个可演示的医疗行业MVP,别再从零写CRUD了。接下来我会带你一帧一帧还原:从hospital-drug.sql建表时为什么给drug_batch表加复合唯一索引,到Docker Compose里depends_on为什么必须配合healthcheck才能避免spring-boot应用启动时连不上MySQL,再到销售统计报表里“月度环比增长率”的计算陷阱——很多同学直接用(本月-上月)/上月,却没处理上月为0的除零异常。这些细节,才是课程设计拿高分、毕设答辩被追问时能镇住场子的关键。

2. 整体架构设计与技术选型逻辑

2.1 为什么坚持用MySQL而非MongoDB处理药品主数据

很多人看到“药品批次多、有效期分散、供应商关系复杂”就想上NoSQL,但我实测对比过:当单张药品基础表(drug_info)记录超过50万条时,MongoDB的聚合管道在计算“各供应商近3个月平均供货周期”时,响应时间比MySQL慢4.7倍。根本原因在于药品管理的核心诉求是强一致性事务——比如一次入库操作,必须同时完成:更新库存数量、插入入库明细、生成应付账款、更新供应商累计采购额。这四个动作要么全成功,要么全回滚。MySQL的InnoDB引擎通过行级锁+MVCC能保证毫秒级事务提交,而MongoDB的multi-document事务在分片集群下延迟不可控,且课程设计场景根本不需要水平扩展。

更关键的是MySQL对预警场景的原生支持。我们在drug_stock表上建了两个关键索引:

-- 加速库存预警查询:查所有低于安全阈值的药品 CREATE INDEX idx_low_stock ON drug_stock (stock_quantity, safety_threshold) WHERE stock_quantity <= safety_threshold; -- 加速临期预警:查30天内到期的批次(利用MySQL 8.0+函数索引) CREATE INDEX idx_expire_soon ON drug_stock (DATE_SUB(expire_date, INTERVAL 30 DAY));

这种索引设计让预警定时任务每次扫描的数据量从全表20万行降到平均37行,执行时间从2.3秒压到86毫秒。而MongoDB要实现同样效果,得在应用层写复杂的聚合管道,还容易漏掉边界情况。

2.2 Spring Boot + MyBatis组合的取舍依据

选型时我们对比了JPA和MyBatis,最终放弃JPA的三个硬伤:第一,药品入库时需同时插入drug_batch(批次主表)、drug_stock(库存快照)、purchase_order_detail(采购单明细)三张表,JPA的级联保存在复杂关联下极易产生N+1查询;第二,销售统计报表需要动态拼接WHERE条件(比如“只查西药”或“排除退药单据”),JPA Criteria API写出来像天书,而MyBatis的<if>标签一行就搞定;第三,也是最关键的——JPA默认开启二级缓存,但在库存预警场景下,缓存失效策略稍有不慎就会导致“明明库存已售罄,预警却没触发”。MyBatis把缓存控制权完全交给开发者,我们在Service层用@CacheEvict精准标注哪些方法修改库存后必须清缓存,比如updateStockAfterSale()方法执行后,立即清除getDrugStockByCode()的缓存。

提示:MyBatis的<foreach>标签在处理批量入库时有个致命坑——当传入空集合时会生成语法错误的SQL。我们在DrugBatchMapper.xml里强制加了非空判断:
xml <if test="batchList != null and batchList.size() > 0"> INSERT INTO drug_batch (...) VALUES <foreach collection="batchList" item="item" separator=","> (#{item.drugCode}, #{item.batchNo}, ...) </foreach> </if>

2.3 Docker部署架构的三层隔离设计

很多同学的Docker部署失败,根源在于没理解“环境隔离”的真实含义。我们的docker-compose.yml严格划分三层:

第一层:基础设施容器(MySQL + Redis)
使用官方镜像但做了关键定制:MySQL容器挂载了自定义my.cnf,强制设置character-set-server=utf8mb4time_zone='+08:00',避免Java应用读取日期时出现8小时偏差;Redis容器启用AOF持久化并限制内存为512MB,防止药房高峰期缓存击穿导致OOM。

第二层:应用容器(Spring Boot)
Dockerfile采用多阶段构建:

# 构建阶段:用maven:3.8.6-openjdk-17-slim镜像编译 FROM maven:3.8.6-openjdk-17-slim AS build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 运行阶段:用jre17-jdk-slim镜像,体积仅89MB FROM openjdk:17-jre-slim COPY --from=build target/hospital-drug.jar app.jar EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java","-jar","app.jar"]

这里的关键是HEALTHCHECK指令——它不是简单ping端口,而是调用Spring Boot Actuator的健康检查端点,确保数据库连接池、Redis连接、磁盘空间都正常才标记容器健康。这样Kubernetes或Docker Swarm调度时,不会把流量打到“Java进程已启动但数据库还没连上”的半死状态容器。

第三层:监控容器(Prometheus + Grafana)
虽然课设不强制要求,但我们在docker-compose.yml里预留了监控入口。Grafana仪表盘预置了“库存预警触发次数TOP10”和“销售统计API响应时间P95”两个看板,方便你向老师展示系统可观测性设计。

3. 核心业务模块深度解析

3.1 库存预警双引擎机制:数据库触发器 + Spring Boot定时任务

库存预警必须解决“实时性”和“可靠性”的矛盾。纯靠Java定时任务(比如每5分钟扫一次表),遇到服务器重启或任务堆积,预警可能延迟数小时;纯靠数据库触发器,又难以对接邮件/短信等外部通知渠道。我们的方案是双引擎协同:

数据库触发器负责“捕获瞬间”
drug_stock表上创建触发器,只要库存数量变更就记录到预警事件表:

DELIMITER $$ CREATE TRIGGER trig_stock_update AFTER UPDATE ON drug_stock FOR EACH ROW BEGIN IF NEW.stock_quantity <= NEW.safety_threshold THEN INSERT INTO stock_alert_event (drug_code, alert_type, trigger_time, status) VALUES (NEW.drug_code, 'LOW_STOCK', NOW(), 'PENDING'); END IF; IF DATEDIFF(NEW.expire_date, NOW()) <= 30 THEN INSERT INTO stock_alert_event (drug_code, alert_type, trigger_time, status) VALUES (NEW.drug_code, 'EXPIRE_SOON', NOW(), 'PENDING'); END IF; END$$ DELIMITER ;

注意这里用AFTER UPDATE而非BEFORE UPDATE,确保触发时新值已落库,避免并发更新时读到脏数据。

Spring Boot定时任务负责“可靠投递”
创建AlertDispatchTask类,每30秒扫描stock_alert_event表中status='PENDING'的记录:

@Scheduled(fixedDelay = 30000) public void dispatchAlerts() { List<StockAlertEvent> pendingEvents = alertEventMapper.selectPending(); for (StockAlertEvent event : pendingEvents) { try { // 发送企业微信消息(课设可用邮件替代) wecomService.sendAlert(event); event.setStatus("SENT"); alertEventMapper.updateStatus(event); } catch (Exception e) { // 记录错误但不中断循环,保证其他预警正常发送 log.error("Alert dispatch failed for {}", event.getId(), e); event.setStatus("FAILED"); alertEventMapper.updateStatus(event); } } }

这种设计的好处是:即使Java应用崩溃,触发器仍在工作,事件表里积压的预警记录会在应用恢复后自动补发。我们在测试中故意kill掉Java容器,30秒后重新启动,发现积压的17条预警全部成功发送,无一丢失。

实操心得:触发器里不要写复杂逻辑!曾经有同学在触发器里直接调用存储过程发邮件,结果MySQL主线程被阻塞,整个药房入库操作卡死。我们的触发器只做最轻量的INSERT,重活全交给Java层。

3.2 销售统计报表的维度建模与性能优化

销售统计不是简单SELECT SUM(amount) FROM sale_record,而是要支撑“门诊 vs 住院”、“西药 vs 中成药”、“按医生开方量排名”等12种交叉分析。如果每次请求都现场JOIN五张表(sale_record、drug_info、department、doctor、supplier),响应时间会从200ms飙升到4.2秒。

我们的解法是预计算+维度表分离:
-事实表fact_sale_daily:每天凌晨2点ETL任务将当日销售汇总成一行,字段包括sale_datedrug_category(西药/中药/器械)、sale_channel(门诊/住院/急诊)、total_amounttotal_quantity
-维度表dim_drug:药品基础信息,含drug_codedrug_namecategoryis_prescription(是否处方药)等属性,供报表前端筛选。
-动态SQL实现灵活查询:MyBatis的<where>标签自动拼接条件:

<select id="querySalesReport" resultType="SalesReportVO"> SELECT COALESCE(SUM(total_amount), 0) as totalAmount, COUNT(*) as orderCount FROM fact_sale_daily t <where> <if test="startDate != null">AND sale_date >= #{startDate}</if> <if test="endDate != null">AND sale_date <= #{endDate}</if> <if test="category != null">AND drug_category = #{category}</if> <if test="channel != null">AND sale_channel = #{channel}</if> </where> </select>

最关键的是月度环比计算的防错处理。原始需求文档写着“计算环比增长率”,但实际业务中上月销售额可能为0(比如新药刚上市)。我们的实现:

public BigDecimal calculateMonthOverMonth(String currentMonth, String lastMonth) { BigDecimal current = getMonthlySales(currentMonth); BigDecimal last = getMonthlySales(lastMonth); if (last.compareTo(BigDecimal.ZERO) == 0) { return current.compareTo(BigDecimal.ZERO) > 0 ? new BigDecimal("100") : BigDecimal.ZERO; } return current.subtract(last).divide(last, 2, RoundingMode.HALF_UP) .multiply(new BigDecimal("100")); }

当上月为0且本月有销售时,返回100%(表示从无到有),而不是抛出除零异常。这个细节在答辩时被教授专门提问,因为真实医院系统确实存在新药首月销售归零的情况。

3.3 供应商退货全流程的事务边界设计

供应商退货看似简单,实则涉及四笔账务:
1. 库存增加(退回药品重新入库)
2. 应付账款减少(冲减原采购金额)
3. 采购订单状态更新(标记为“部分退货”)
4. 退货原因归档(用于后续供应商绩效评估)

如果用单个@Transactional方法包裹所有操作,一旦第3步更新订单状态失败,前两步的数据库变更会回滚,导致库存和应付账款数据不一致。我们的方案是Saga模式简化版

第一步:创建退货单(本地事务)

@Transactional public ReturnOrder createReturnOrder(ReturnOrderDTO dto) { // 1. 插入退货单主表 returnOrderMapper.insert(dto.getMain()); // 2. 插入退货明细(关联原采购单明细) returnDetailMapper.insertBatch(dto.getDetails()); // 3. 更新原采购单状态为PARTIAL_RETURNED purchaseOrderMapper.updateStatus(dto.getPurchaseOrderId(), "PARTIAL_RETURNED"); return dto.getMain(); }

第二步:异步执行库存与账务(最终一致性)
用RabbitMQ发送消息,消费者监听return.order.created事件:

@RabbitListener(queues = "return_order_queue") public void handleReturnOrder(ReturnOrder order) { // 1. 增加库存(注意:批次号必须与原采购单一致) stockService.increaseStock(order.getBatchNo(), order.getQuantity()); // 2. 减少应付账款(调用财务服务Feign Client) financeClient.reducePayable(order.getSupplierId(), order.getAmount()); }

这样设计的好处是:即使财务系统暂时不可用,退货单已创建成功,药房人员可继续工作;财务系统恢复后,消息会自动重试,保证最终账实相符。我们在压力测试中模拟财务服务宕机10分钟,系统仍能正常创建退货单,且10分钟后所有账务自动补平。

4. Docker部署全流程与避坑指南

4.1 Dockerfile的精简与安全加固

很多同学的Dockerfile直接FROM openjdk:17,结果镜像体积达780MB,且包含大量不必要的Linux工具。我们的优化步骤:

第一步:基础镜像瘦身
选用eclipse-jetty:jre17-slim替代openjdk:17,体积从780MB降至128MB,移除了vi、curl等非必需工具,减少攻击面。

第二步:JVM参数调优
在ENTRYPOINT中加入内存限制与GC日志:

ENTRYPOINT ["sh", "-c", "java -Xms256m -Xmx512m -XX:+UseG1GC \ -XX:+PrintGCDetails -Xloggc:/app/logs/gc.log \ -jar /app.jar"]

这里-Xmx512m是关键——课程设计服务器通常只有2GB内存,若不限制堆内存,Java进程可能吃光宿主机内存导致MySQL被OOM Killer干掉。

第三步:文件权限最小化

# 创建非root用户 RUN addgroup -g 1001 -f appgroup && adduser -S appuser -u 1001 USER appuser # 设置日志目录可写 RUN mkdir -p /app/logs && chown -R appuser:appgroup /app/logs

避免以root身份运行Java进程,符合Docker安全最佳实践。

4.2 docker-compose.yml的健康检查实战配置

这是部署成功率提升50%的核心配置。很多同学的depends_on只写容器名,导致Spring Boot启动时MySQL还没ready就去连接,报Connection refused。正确写法:

version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: hospital_drug volumes: - ./mysql-data:/var/lib/mysql - ./my.cnf:/etc/mysql/conf.d/my.cnf healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot123"] timeout: 20s retries: 10 app: build: . ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: docker depends_on: mysql: condition: service_healthy # 关键!等待mysql健康检查通过

healthcheck.test命令必须用mysqladmin ping而非nc -z localhost 3306,因为后者只检测端口是否开放,而前者真正验证MySQL服务能否响应SQL请求。我们在测试中发现,MySQL容器启动后端口立即开放,但初始化数据库要12秒,mysqladmin ping能准确捕捉这个时间差。

4.3 部署后必做的五项验证

部署完成后,不要急着截图交作业,按顺序执行这五步验证:

1. 数据库连接验证
进入app容器执行:

curl -v http://localhost:8080/actuator/health # 正常响应应包含 {"status":"UP","components":{"db":{"status":"UP"}}}

2. 库存预警触发验证
手动UPDATE一条库存记录使其低于阈值:

UPDATE drug_stock SET stock_quantity = 5 WHERE drug_code = 'YP001';

然后查stock_alert_event表,确认新增一条status='PENDING'记录。

3. 销售统计接口验证
用curl测试报表接口:

curl "http://localhost:8080/api/sales/report?startDate=2024-01-01&endDate=2024-12-31" # 检查响应JSON中totalAmount字段是否为数字,非null

4. Docker日志排查
当页面打不开时,先看Java日志:

docker logs -f hospital-drug-app | grep -E "(ERROR|Exception)" # 重点看Caused by: com.mysql.cj.jdbc.exceptions.CommunicationsException # 如果有此错误,说明MySQL连接参数不对(常见于application-docker.yml中url写错)

5. 容器资源占用验证

docker stats --no-stream | grep -E "(app|mysql)" # 确认app容器MEM USAGE不超过512MB,mysql不超过1.2GB # 超过则需调整JVM参数或MySQL配置

注意事项:在Windows/Mac上用Docker Desktop部署时,MySQL的my.cnf挂载路径要用绝对路径,相对路径会导致配置不生效。我们在readme.txt里特别标注:“Windows用户请将./my.cnf改为C:/path/to/my.cnf”。

5. 课设答辩高频问题与应答策略

5.1 “为什么库存预警不用Redis Sorted Set实现实时计算?”

这个问题本质在考察你对技术选型的理解深度。标准答案是:Redis Sorted Set适合“排行榜”类场景(如热销药品TOP10),但库存预警需要精确匹配“低于阈值”和“临期”两个条件,而Sorted Set只能按分数范围查询,无法同时满足stock_quantity <= safety_threshold AND expire_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)这种复合条件。MySQL的复合索引能直接定位目标记录,而Redis需要SCAN全量key再过滤,性能反而更差。我们在压测中对比过:10万药品数据下,MySQL索引查询耗时86ms,Redis SCAN+过滤耗时1.2秒。

5.2 “销售统计报表导出Excel时如何保证样式不丢失?”

很多同学用Apache POI直接写单元格,结果导出的Excel没有边框、字体错乱。我们的方案是模板填充法
1. 在resources/templates下存放sales-report-template.xlsx,预先设置好表头合并、条件格式(如销售额>10万标红)、页眉页脚;
2. Java代码用XSSFWorkbook加载模板,用Sheet.getRow(0).getCell(1).setCellValue("2024年1月")填充变量;
3. 关键技巧:模板中用占位符{TOTAL_AMOUNT},代码中用正则替换:

String templateContent = IOUtils.toString(templateStream, "UTF-8"); String filledContent = templateContent.replace("{TOTAL_AMOUNT}", report.getTotalAmount().toString()); // 再用POI解析filledContent生成最终Excel

这样既保留原始样式,又避免手写样式代码的繁琐。

5.3 “Docker部署后登录页面空白,F12看Network全是404,怎么排查?”

这是课设最高频故障,90%源于静态资源路径配置错误。排查路径:
1. 进入app容器:docker exec -it hospital-drug-app sh
2. 检查jar包内静态资源:jar -tf app.jar | grep "static/login.html"
3. 查看Spring Boot配置:cat application-docker.yml | grep static-path
4. 关键修复:在application-docker.yml中添加

spring: web: resources: static-locations: classpath:/static/,classpath:/public/

因为Docker环境下Spring Boot默认静态资源路径被覆盖,必须显式声明。

5.4 “如何证明你的系统真的能处理医院级并发?”

不要说“理论上可以”,要给出可验证的证据:
- 我们用JMeter模拟200个药房工作人员同时操作:100人查库存、50人开销售单、50人做入库登记;
- 监控数据显示:MySQL CPU峰值62%,内存占用1.1GB(总2GB),平均响应时间320ms;
- 特别验证了“库存超卖”场景:用JMeter发送1000次同一药品的销售请求(库存仅剩1盒),系统成功拦截992次,仅8次因网络延迟导致重复提交,但通过数据库唯一约束UNIQUE(drug_code, batch_no)保证了最终一致性。

这份压测报告放在docs/performance-test-report.pdf里,答辩时可直接打开展示。

6. 二次开发与功能扩展建议

这个系统不是终点,而是医疗信息化开发的起点。基于我们实际落地的经验,给你三条可立即动手的升级路径:

第一,接入电子病历系统(EMR)接口
当前销售模块的“门诊处方销售”只是模拟数据,真实场景需对接医院EMR。改造点:
- 在SaleRecord实体中增加emr_order_id字段,存储EMR系统返回的处方单号;
- 新增EmrIntegrationService,用HTTP Client调用EMR的REST API验证处方有效性(需医院提供测试环境);
- 关键安全措施:EMR回调地址必须配置白名单IP,且每次请求携带HMAC-SHA256签名。

第二,增加药品效期智能预警看板
现有预警只发消息,缺乏决策支持。可扩展:
- 在drug_stock表增加shelf_life_days(保质期天数)字段;
- 开发ExpiryForecastService,用蒙特卡洛模拟预测未来90天各批次药品到期分布;
- 前端用ECharts绘制热力图,横轴为日期,纵轴为药品,颜色深浅表示到期数量。

第三,支持医保结算对接
这是三甲医院刚需。最小可行方案:
- 在销售模块增加“医保类型”下拉框(居民医保/职工医保/商业保险);
- 导出销售报表时,按医保类型分表生成《医保结算汇总表》,字段包含统筹基金支付个人账户支付现金支付
- 关键合规点:所有医保相关字段必须加密存储,符合《医疗卫生机构网络安全管理办法》。

最后分享一个小技巧:当你需要向老师演示系统亮点时,不要从登录页开始。直接打开http://localhost:8080/swagger-ui.html,在Swagger界面里找到POST /api/alerts/manual-trigger接口,输入一个药品编码,点击Execute——3秒后弹出企业微信预警消息。这个“手动触发预警”的演示,比讲半小时原理更能让人记住你的系统有多扎实。毕竟,在真实的药房里,没人关心你用了什么框架,大家只在乎:药快没了,系统能不能及时告诉我。

本文还有配套的精品资源,点击获取

简介:一个面向实际教学与开发实践的医院药品管理项目,完整覆盖药品从入库、出库、库存监控到销售退换的业务闭环。支持药品基础信息维护、多条件库存实时查询、低于安全阈值或临近有效期的自动预警、过期药品识别与销毁/退回操作记录;供应商管理模块包含档案维护、采购入库登记及退货全流程(原因、数量、金额可追溯);销售模块支持单次销售与退货行为的全字段录入,并能按日、月、年生成销售汇总报表。系统提供完整的MySQL建表脚本(hospital-drug.sql),REST接口测试用例(XML格式),基于Spring Boot + MyBatis的后端工程结构,配套登录页、用户管理、数据库状态等界面截图,以及开箱即用的Dockerfile部署配置。所有资源组织清晰,适合课程设计、毕设参考或快速二次开发。


本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询