简介:基于SpringBoot的医院药品管理系统设计与实现,是一套面向高校计算机专业毕业设计或课程设计的完整项目,覆盖用户管理、药品管理、库存管理与采购管理等核心业务模块,并采用前后端分离架构,能够帮助开发者掌握系统设计、权限控制与接口开发流程。压缩包共415个文件,约24.08MB,其中以Java后端代码、Vue前端页面、SVG资源文件、XML配置及SQL脚本为主,并附带安装/启动脚本与演示视频,便于环境搭建和功能预览。已有59人学习/浏览,适合Java学习者参考系统设计、数据库建模、接口开发与项目部署。借助该资源可获取完整源码、数据库初始化脚本、部署脚本及操作演示,熟悉SpringBoot与Vue结合的开发模式,对毕业设计中的需求分析、模块划分和论文撰写也有直接借鉴价值。
1. 医院药品管理系统不是普通增删改查:库存、批次与近效期才是毕设的价值点
把「医院药品管理系统」当作又一个 SpringBoot + Java 增删改查项目来做,是很多毕业论文翻车的起点。药品跟图书、商品最大的差别在于:药品有批次、有生产日期、有有效期,同一个药品在药架上可能同时存在三个不同批号,发药时得先到先出,效期临近还得预警。这套基于 SpringBoot 的医院药品管理系统设计与实现,真正值钱的部分是批次库存模型、出入库事务、库存并发控制和近效期扫描,而不是那几张页面。这篇笔记面向正在做毕业论文或课程设计的 Java 学生,也适合想快速搭一个药房管理原型的开发者,我会按「选型 → 建表 → 配置 → 接口 → 避坑 → 答辩验证」的顺序把整个落地路径讲清楚。
2. 技术选型先立住:SpringBoot + MyBatis + MySQL 为什么适合这类管理系统
2.1 药品管理系统的核心闭环:入库、出库、盘点与近效期预警
动手写代码之前,先把业务闭环想明白。医院药品管理系统的日常流程是:维护药品字典和供应商档案 → 做采购入库,生成入库单和入库明细 → 入库的同时把药品写进批次库存表,记录生产日期与有效期 → 门诊发药或科室领用时生成出库单和出库明细 → 按批次扣减库存 → 每天扫描低库存和近效期药品 → 月底统计采购金额与用药量排行。
这个闭环里最容易被忽略的是「批次库存」:库存不是一张表里一个数字,而是同一药品、不同批号各自保留一条库存记录。比如阿莫西林胶囊有 2026 年 3 月到期的批号 A,也有 2026 年 11 月到期的批号 B,发药时默认先扣批号 A。这是药品管理系统区别于普通商品管理系统的关键特征,数据库设计也必须围绕它展开。
2.2 MyBatis 还是 Spring Data JPA:动态查询场景下的取舍
先抛结论:这类毕设我一般推荐SpringBoot + MyBatis + MySQL,理由集中在动态查询和报表统计上。药品管理系统的查询条件非常杂:按药品名模糊查、按厂家查、按批次查、按有效期区间查、按库存上下限查,还可能组合使用。MyBatis 的 XML 里用<where>、<if>写动态 SQL 非常直观,改一行 SQL 立刻能看到效果;而 JPA 在复杂动态查询时要拼 Specification 或写 JPQL,多表关联的结果集映射也绕。
| 对比项 | MyBatis | Spring Data JPA |
|---|---|---|
| 动态多条件查询 | XML 里if + where直接写 | Specification / QueryDSL,学习曲线陡 |
| 多表关联查询 | 自定义 SQL + resultMap 映射 | JPQL 或原生 SQL,要处理懒加载 |
| 管理类系统常见 CRUD | 手写 SQL,可控 | 接口方法自带,单表很方便 |
| 中文资料与排错经验 | 非常丰富 | 相对少一些 |
这不代表 JPA 不好。如果你的毕设题目明确指定 Spring Data JPA,或者指导老师要求用,那也完全能实现,只是多条件查询和统计报表时你会多花时间。重点是答辩时能说清楚「为什么这样选」,给出的理由最好是基于业务场景的,而不是「网上都说 MyBatis 简单」。
2.3 单体单模块就够了:毕业论文项目别过度设计
很多人一听到「医院药品管理系统」,就想着上 Spring Cloud、拆微服务、加 Redis 缓存。我见过太多毕设因此烂尾。这类系统的真实并发量很低,一个药房同时发药的窗口最多几个,单体应用完全扛得住。选单模块 SpringBoot 工程,按包拆分为 controller、service、mapper、entity、dto,既好写又好讲。答辩老师问「为什么不用微服务」,你回答「业务规模不需要,微服务会引入分布式事务和部署复杂度」就够了。
下面是常用的核心 Maven 依赖,我按 Spring Boot 2.7.x 配套来给:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>注意 MyBatis starter 的版本不用追新,和 Boot 2.7 配套的 2.3.x 系列就行,太新的 starter 有时反而要求更高版本的 Boot。Lombok 能省掉实体类的 getter/setter,但如果你不熟悉它的原理,宁愿手写也别在答辩时说不清楚。Redis、MQ 这些先不引入,等核心流程跑通、确实有需要再加,不要一开始就背上分布式缓存的黑匣子。
3. SpringBoot 项目骨架与数据库设计:先建表再写代码的步骤
3.1 项目目录结构怎么摆:分包原则与 Java 包名规范
包名没有硬性标准,我习惯用com.hospital.drugmanage,一眼能看出是医院药品管理。目录结构参考如下:
src/main/java/com/hospital/drugmanage ├── DrugmanageApplication.java ├── config // WebMvc 配置、定时任务开关、跨域配置 ├── controller // 接收 HTTP 请求,不做业务逻辑 ├── service // 业务接口 │ └── impl // 业务实现,事务注解写在这里 ├── mapper // MyBatis Mapper 接口 ├── entity // 数据库表对应的实体 ├── dto // 前端入参、出参对象 ├── common // 统一返回体、业务异常、分页结果 └── resources ├── mapper // Mapper XML 文件,与接口包路径对应 ├── application.yml ├── templates // Thymeleaf 模板,如果做前后端不分离 └── static // CSS/JS/图片,或 Vue 打包后的 dist 内容实体、DTO、查询对象分开放,是我目前带人做毕设时最坚持的一点。很多同学把前端传进来的参数直接塞进实体,入库操作时drugName被前端传成了空串,表里就多了脏数据。正确做法是:entity 只用数据库字段,入参用 DTO,Mapper 层负责转换。
3.2 建表之前想清楚三个约束:批次有效期、出库策略与库存上下限
建表前先定业务约束,否则后期改表特别痛。第一个约束是「一个药品有多个批次」,所以库存表的唯一性不能建在drug_id上,而是drug_id + batch_no联合唯一,这样同一药品的不同批次能各存各的数量和有效期。第二个约束是「出库遵循近效期先出」,也就是先扣expire_date更早的批次,这要求出库逻辑里有一条按有效期排序的批次查询,而不是随便抽一个批号扣。第三个约束是「库存预警要有依据」,药品主档里保存min_stock和max_stock,定时任务拿当前库存总量与阈值比较。
金额字段一律用DECIMAL,实体用BigDecimal,不要用double。药品的入库单价、出库金额涉及财务核算,用浮点会有精度问题,这也是我在代码评审里必查的一项。表与表之间我建议不加物理外键,保留逻辑关联和普通索引就行,原因很实际:入库明细、出库明细都是高频写入表,物理外键在批量插入和分页统计时会有额外开销,毕设里用索引保证查询性能已经足够。
3.3 核心建表 SQL:药品、库存批次、入库单与出库单
下面是精简版的核心表结构,覆盖药品主档、供应商、入库、出库、批次库存五类数据,业务上已经能闭环:
-- 药品主档 CREATE TABLE `drug` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `drug_code` VARCHAR(32) NOT NULL COMMENT '医院药品编码', `drug_name` VARCHAR(100) NOT NULL COMMENT '药品通用名', `spec` VARCHAR(50) DEFAULT '' COMMENT '规格,如 0.25g*24粒', `unit` VARCHAR(10) NOT NULL DEFAULT '盒' COMMENT '单位', `dosage_form` VARCHAR(20) DEFAULT '' COMMENT '剂型', `manufacturer` VARCHAR(100) DEFAULT '' COMMENT '生产厂家', `approval_no` VARCHAR(50) DEFAULT '' COMMENT '批准文号', `min_stock` INT NOT NULL DEFAULT 0 COMMENT '库存下限', `max_stock` INT NOT NULL DEFAULT 0 COMMENT '库存上限', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_drug_code` (`drug_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 供应商 CREATE TABLE `supplier` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `supplier_name` VARCHAR(100) NOT NULL, `contact_person` VARCHAR(50) DEFAULT '', `phone` VARCHAR(20) DEFAULT '', `address` VARCHAR(200) DEFAULT '', `status` TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 入库单 CREATE TABLE `inbound_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `inbound_no` VARCHAR(32) NOT NULL COMMENT '入库单号', `supplier_id` BIGINT NOT NULL, `inbound_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1采购 2退货 3调拨', `operator` VARCHAR(50) DEFAULT '' COMMENT '操作人', `total_amount` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '总金额', `remark` VARCHAR(255) DEFAULT '', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_inbound_no` (`inbound_no`), KEY `idx_supplier` (`supplier_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 入库明细 CREATE TABLE `inbound_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `inbound_id` BIGINT NOT NULL, `drug_id` BIGINT NOT NULL, `batch_no` VARCHAR(50) NOT NULL COMMENT '批次号', `produce_date` DATE COMMENT '生产日期', `expire_date` DATE NOT NULL COMMENT '有效期至', `quantity` INT NOT NULL DEFAULT 0, `unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0, `amount` DECIMAL(12,2) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_inbound` (`inbound_id`), KEY `idx_drug` (`drug_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 批次库存 CREATE TABLE `stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `drug_id` BIGINT NOT NULL, `batch_no` VARCHAR(50) NOT NULL, `produce_date` DATE, `expire_date` DATE NOT NULL, `quantity` INT NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_drug_batch` (`drug_id`, `batch_no`), KEY `idx_expire` (`expire_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 出库单与出库明细 CREATE TABLE `outbound_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `outbound_no` VARCHAR(32) NOT NULL, `outbound_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1门诊发药 2科室领用 3报损', `patient_name` VARCHAR(50) DEFAULT '', `dept_name` VARCHAR(50) DEFAULT '', `operator` VARCHAR(50) DEFAULT '', `remark` VARCHAR(255) DEFAULT '', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_outbound_no` (`outbound_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `outbound_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `outbound_id` BIGINT NOT NULL, `drug_id` BIGINT NOT NULL, `batch_no` VARCHAR(50) NOT NULL, `quantity` INT NOT NULL DEFAULT 0, `unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0, `amount` DECIMAL(12,2) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_outbound` (`outbound_id`), KEY `idx_drug` (`drug_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;两张明细表都没有物理外键,但都建了idx_inbound、idx_outbound、idx_drug索引,查询时用 JOIN 性能和加外键差别不大,写入和删档却灵活很多。stock.version字段是乐观锁用的,对应 2.1 里说的「发药并发」场景,第 4 章会用到。inbound_no、outbound_no我建议用时间戳加随机数生成,比如IN20260101120001 + 四位随机,不要用自增 ID 当单号,答辩时这也是一个可讲的亮点。
4. 把核心流程跑通:SpringBoot 配置、入库出库接口与库存并发控制
4.1 application.yml 必调的 4 项配置:数据源、驼峰映射、时区与日期格式
SpringBoot 的自动配置省了很多事,但application.yml里有几项不调好,项目跑起来就是各种玄学报错:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital_drug?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.hospital.drugmanage.entity configuration: map-underscore-to-camel-case: true drug: expire-warn-days: 90URL 里的serverTimezone=Asia/Shanghai必须加,不然 MySQL 驱动会报时区错误。map-underscore-to-camel-case: true开启后,数据库的create_time能自动映射到实体的createTime,否则你每个实体都得写一堆@Results。Jackson 的日期格式统一成yyyy-MM-dd HH:mm:ss,前端拿到的不再是时间戳数组。drug.expire-warn-days是自定义配置项,第 4.4 节近效期预警会读它,阈值从配置里改比改代码强得多。
4.2 入库接口:事务 + MyBatis 写入的实现与参数说明
入库是整个系统写入链路最长的地方:写入库单、写入库明细、累加批次库存,三步必须在一个事务里。下面是核心 Service 方法的骨架:
@Service @RequiredArgsConstructor public class InboundServiceImpl implements InboundService { private final InboundOrderMapper inboundOrderMapper; private final InboundItemMapper inboundItemMapper; private final StockMapper stockMapper; private final DrugMapper drugMapper; @Override @Transactional(rollbackFor = Exception.class) public Long createInbound(InboundDTO dto) { // 1. 校验药品是否存在、是否停用 for (InboundItemDTO item : dto.getItems()) { Drug drug = drugMapper.selectById(item.getDrugId()); if (drug == null || drug.getStatus() == 0) { throw new BusinessException("药品不存在或已停用: " + item.getDrugId()); } } // 2. 生成入库单主单并写入 InboundOrder order = new InboundOrder(); order.setInboundNo(generateInboundNo()); order.setSupplierId(dto.getSupplierId()); order.setInboundType(dto.getInboundType()); order.setOperator(dto.getOperator()); order.setTotalAmount(calcTotalAmount(dto.getItems())); inboundOrderMapper.insert(order); // 3. 逐条写明细,并累加对应批次库存 for (InboundItemDTO item : dto.getItems()) { InboundItem detail = new InboundItem(); detail.setInboundId(order.getId()); detail.setDrugId(item.getDrugId()); detail.setBatchNo(item.getBatchNo()); detail.setProduceDate(item.getProduceDate()); detail.setExpireDate(item.getExpireDate()); detail.setQuantity(item.getQuantity()); detail.setUnitPrice(item.getUnitPrice()); detail.setAmount(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); inboundItemMapper.insert(detail); stockMapper.increaseStock(order.getId(), item.getDrugId(), item.getBatchNo(), item.getProduceDate(), item.getExpireDate(), item.getQuantity()); } return order.getId(); } }@Transactional(rollbackFor = Exception.class)这一行很关键。默认情况下 Spring 事务只在RuntimeException时回滚,如果你在业务代码里抛出的是自定义的业务异常,必须配上rollbackFor,否则事务不会回滚,会出现「入库单写进去了,明细和库存丢了一半」的灵异现场。步骤 3 里的increaseStock用INSERT ... ON DUPLICATE KEY UPDATE实现,同一个药品同一个批次再次入库时,只在原批次上累加数量,不重复建批次记录。
4.3 出库接口:乐观锁 + 库存校验,防止超卖
发药时最怕的就是并发:两个窗口同时给患者发同一种药品,库存只剩 5 盒,两边都查到 5,各扣 4,最后库存变成负数。解决这个问题靠的不是 Java 代码里if (stock.getQuantity() > num)判断,而是让数据库更新语句自己带条件。先看 Mapper XML:
<update id="deductStock"> UPDATE stock SET quantity = quantity - #{quantity}, version = version + 1 WHERE id = #{id} AND quantity >= #{quantity} AND version = #{version} </update>这段 SQL 的含义是:扣减库存时,要求扣减后数量不为负,且版本号必须和查询时一致。quantity >= #{quantity}是数量防线,version = #{version}是并发防线。调用方的写法:
@Override @Transactional(rollbackFor = Exception.class) public void dispense(DispenseDTO dto) { for (DispenseItemDTO item : dto.getItems()) { // 先查出可用批次,按有效期升序,近效期先出 List<Stock> stocks = stockMapper.selectAvailableBatches(item.getDrugId()); int remain = item.getQuantity(); for (Stock stock : stocks) { if (remain <= 0) break; int num = Math.min(remain, stock.getQuantity()); int affected = stockMapper.deductStock(stock.getId(), num, stock.getVersion()); if (affected == 0) { throw new BusinessException("库存不足或数据已变化,请刷新后重试"); } // 写出库明细,记录批次号 remain -= num; } if (remain > 0) { throw new BusinessException("该药品库存不足"); } } // 写出库主单 }这里用了乐观锁而不是SELECT ... FOR UPDATE,是因为门诊发药的单次扣减很快,乐观锁在冲突不频繁的场景下性能更好,也更好向答辩老师解释。selectAvailableBatches的排序条件是expire_date ASC, quantity > 0,保证过期早的批次先出完。还有一点要提醒:@Transactional要标在 public 方法上,不能同类内部this.dispense()自调用,否则 Spring 的 CGLIB 代理不生效,事务变成了摆设。
4.4 近效期预警:@Scheduled 定时任务与查询逻辑
近效期预警是药品管理系统区别于普通库存系统的加分功能。在启动类或配置类上加上@EnableScheduling,然后写一个定时任务,每天扫描有效期 90 天内的批次:
@Component @RequiredArgsConstructor public class StockWarningTask { private final StockMapper stockMapper; private final WarningLogMapper warningLogMapper; @Value("${drug.expire-warn-days:90}") private Integer warnDays; @Scheduled(cron = "0 0 2 * * ?") public void expireWarning() { LocalDate deadline = LocalDate.now().plusDays(warnDays); List<Stock> expireStocks = stockMapper.selectNearExpire(deadline); for (Stock stock : expireStocks) { String content = String.format( "药品ID:%d 批次:%s 有效期至:%s 剩余数量:%d", stock.getDrugId(), stock.getBatchNo(), stock.getExpireDate(), stock.getQuantity()); warningLogMapper.insert(new WarningLog(stock.getDrugId(), content)); } } }cron = "0 0 2 * * ?"表示每天凌晨 2 点执行,避开发药高峰。@Value从配置中心读取预警天数,默认 90 天。光扫描不记录没意义,所以每次预警都要写warning_log表,页面上能按日期查询。若要模拟测试,可以直接把warnDays临时改成3650,再往表里插一条快过期的批次,任务跑一次就能看到预警记录。低库存预警同理,把deadline换成min_stock对比即可。这两条任务不复杂,但演示效果非常好,答辩时属于「不用老师提醒就能主动展示」的功能点。
5. 药品管理系统毕设最容易翻车的 5 个坑:从 JAR 404 到并发超卖
5.1 JAR 打包后页面 404:静态资源与模板路径的真相
现象:IDE 里点运行一切正常,mvn package后java -jar启动,页面打不开,接口 404,或者 Thymeleaf 模板直接报找不到。
原因:SpringBoot 默认从classpath:/templates/找模板、从classpath:/static/找静态资源。如果你把页面放在了src/main/webapp下,或者模板名大小写写错,打包后资源根本没进 JAR。如果是前后端分离,Vue 构建产物放在dist目录,也得把dist里的内容拷到src/main/resources/static下再打包,否则接口对、页面缺。
解决:先确认target/classes/templates和target/classes/static下有对应文件。前端请求后端的/api/路径时,注意 Vue 的publicPath和接口代理配置,把 API 地址改成相对路径或同源地址,避免本地联调能用、打包后连不上的尴尬。
5.2 SpringBoot 版本选太高:javax 改 jakarta,Java 8 项目直接编译失败
现象:新建 SpringBoot 项目时选了默认最新版本,代码里import javax.servlet.*大面积报红,编译直接失败,Maven 依赖也拉不全。
原因:Spring Boot 3.x 把基础包从javax.*迁移到了jakarta.*,并且要求 JDK 17 及以上。很多毕业设计环境还是 JDK 8,教师机不一定装过新版本 JDK,盲目追新版本等于给自己挖坑。
解决:用 Spring Boot 2.7.x 系列,它对应 JDK 8,网上绝大多数教程、现成代码都能直接套。如果你的课程设计强制要求 Boot 3.x,那就把 JDK 升到 17,并把所有javax.servlet替换成jakarta.servlet,数据库驱动坐标也换成com.mysql:mysql-connector-j。选型这件事上,别把「赶时髦」当成技术决策。
5.3 库存扣成负数:没有版本号与校验条件的更新语句
现象:两个浏览器窗口同时发同一药品,最后库存表出现负数,出库明细总发药量比入库总量还大。
原因:扣减库存的 SQL 写成了UPDATE stock SET quantity = #{afterCalc} WHERE id = #{id}。这种写法是先查出库存,在 Java 里减完再把结果写回去。两个线程同时查到 5,各自减 4 再写回 1,丢了一次扣减。这类 bug 用 JUnit 单测很难稳定复现,属于典型的并发数据竞争。
解决:改成第 4.3 节的写法,把quantity >= #{quantity}和version = #{version}写进 UPDATE 条件,让数据库来判断「能不能减」。更新影响行数为 0 时抛业务异常,提示前端重新查询。这是我看别人毕设代码时最爱检查的地方,也是答辩时老师最容易追问的点。
5.4 本地跑得好、打包后连不上数据库:多环境配置与远程连接权限
现象:本机启动正常,把 JAR 丢到另一台机器上运行,启动时报Communications link failure或Access denied for user。
原因:application.yml里写死了localhost和本机密码。目标机器的 MySQL 服务可能没开放 3306 端口,或者用户的账号只允许localhost登录。还有些同学把serverTimezone=Asia/Shanghai删了,新机器上的 MySQL 时区不对,查询直接报错。
解决:配置分离是正路。application.yml里只留公共项,把数据源拆到application-dev.yml和application-prod.yml,启动时用spring.profiles.active=prod指定。如果不想拆文件,至少在打包前把jdbc:mysql://后的地址改成目标机器的 IP。MySQL 侧检查三件事:用户是否'root'@'%'、3306 端口是否放行、allowPublicKeyRetrieval=true是否带上。
5.5 @Scheduled 定时任务不执行与 @Transactional 不生效:两个「注解失灵」的真相
现象:代码明明写了@Scheduled(cron = "0 0 2 * * ?"),到点没反应;某段 Service 方法里前两步写库成功、第三步抛异常,数据却没回滚。
原因:@Scheduled不执行,多半是启动类或配置类漏了@EnableScheduling,或者定时任务类没有被 Spring 扫描到。@Transactional不生效,最常见的是同类里this调用内部方法,代理对象被绕过;其次是方法不是 public,Spring 基于 CGLIB 的代理拦截不到非 public 方法。
解决:定时任务单独做一个@Component类,确保在启动类同包或子包下。事务方法拆到另一个 Service 中调用,或者通过@Lazy注入自身代理来调用内部方法。我的检查顺序是:先看有没有@EnableScheduling,再看方法是不是 public,最后断点看this指向的到底是原始对象还是代理对象。这三个点排查完,绝大多数的「注解失灵」都能定位。
6. 答辩前 40 分钟怎么验证:接口自测、造数脚本与超卖复现
答辩前不要只靠页面点一点,你要能证明系统在边界条件下不崩。我建议准备三件事:造一批可复现的数据、验证近效期预警、用并发模拟证明库存扣减安全。
造数脚本用一条INSERT ... SELECT就能批量生成几千条药品和库存批次,比如:
-- 快速造 1000 条库存批次记录 INSERT INTO stock (drug_id, batch_no, produce_date, expire_date, quantity) SELECT 1, CONCAT('P', LPAD(n, 6, '0')), DATE_SUB(CURDATE(), INTERVAL (n % 1000) DAY), DATE_ADD(CURDATE(), INTERVAL (n % 365) DAY), 100 FROM (SELECT a.n + b.n * 10 + c.n * 100 AS n FROM (SELECT 0 n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a CROSS JOIN (SELECT 0 n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b CROSS JOIN (SELECT 0 n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) c) t WHERE n > 0;造完数之后,把drug.expire-warn-days调大,跑一次定时任务,看预警日志是否按批次生成。这个演示比任何页面截图都有说服力。
超卖复现可以写一个小的并发模拟:
int threadCount = 20; ExecutorService pool = Executors.newFixedThreadPool(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); AtomicInteger success = new AtomicInteger(); AtomicInteger fail = new AtomicInteger(); for (int i = 0; i < threadCount; i++) { pool.execute(() -> { try { start.await(); dispenseService.dispense(buildOneItem(1L, 5)); success.incrementAndGet(); } catch (Exception e) { fail.incrementAndGet(); } finally { done.countDown(); } }); } start.countDown(); done.await(10, TimeUnit.SECONDS); System.out.println("成功:" + success.get() + " 失败:" + fail.get());20 个线程同时扣库存,总量只有 100,最终成功次数加上剩余库存必须等于 100。失败的那些请求抛的必须是「库存不足」,而不是数据库异常或负数库存。这个脚本跑通,再多的追问你都能接住。
我自己的习惯是:答辩前最后 40 分钟绝不改业务代码,只跑验证脚本。改代码的念头一旦起来,就很容易把原本能跑的系统改崩,与其赌临场发挥,不如把时间花在「证明它能用」上。造数、并发模拟、日志检查这三件事做完,你对这套系统的把握会比改十行代码实在得多。回头想想,这套医院药品管理系统最值得投入的地方不是页面多好看,而是把库存、批次、有效期这些药品特有的业务约束做扎实,任何一个约束都是和「普通管理系统」拉开差距的证据。希望帮到你。
本文还有配套的精品资源,点击获取