简介:一份基于Spring Cloud微服务架构的综合药品管理系统毕业设计源码,面向计算机相关专业毕业生及微服务学习者,用于理解药品进销存、供应商与销售管理等业务场景下的系统拆分与实现。包内共1027个文件,以375个Java源文件、167个JavaScript脚本、77个Vue组件及99张PNG图片为主,另有SQL数据库脚本、YML配置和前端样式文件,覆盖后端服务、管理界面与数据初始化等完整工程结构,压缩包仅7.23MB,核心目录medicine-master便于直接导入与查阅。目前已有207人学习,适合作为毕业设计选题参考或Spring Cloud实战项目进行二次开发。通过源码可学习Eureka服务注册发现、API网关路由、Ribbon/Feign调用、Spring Security权限控制及前后端分离开发,同时可参考其数据库设计与部署思路,是一套能帮助快速搭建药品管理微服务项目的完整蓝本。
1. 从毕业设计到生产级:药品管理系统为什么需要Spring Cloud
综合药品管理系统听起来只是一个“管理药品信息”的CRUD系统,但当你真正开始设计数据模型和接口时,会发现业务远比想象中复杂:药品有多规格、多批次、多供应商,库存要支持近效期预警和批次追溯,采购、销售、处方审核之间还有强一致性的库存扣减需求。用单体应用撑着倒也能跑,可一旦你需要在毕业设计答辩里讲清楚“系统为什么这么拆”“模块之间怎么协作”,Spring Cloud就成了解答这些问题的关键线索。
本文假设你已经掌握Spring Boot的基本用法,带你从零构建一个可运行的药品管理微服务系统,内容包括微服务拆分方法、Nacos注册与配置中心、OpenFeign调用、网关路由、分布式事务和缓存策略。每一段代码都来自我实际搭建时的做法,你可以直接复制到自己的项目里改造。整个系统跑通后,你再去看网上那份“毕业设计源代码”,会更容易读懂它为什么那么设计。
2. 药品管理系统的微服务拆分:领域模型与Spring Cloud组件选型
2.1 先划业务域:药品管理系统需要几个服务
我在动手写代码前,会先把业务名词列出来:用户、角色、药品、药品批次、供应商、采购单、入库单、出库单、库存记录、处方、销售订单。这些名词可以自然聚合成几个高内聚的领域。
常见的保守拆分方案如下:
| 服务名称 | 核心实体 | 主要接口 |
|---|---|---|
| user-service | 用户、角色、权限 | 登录、用户CRUD、权限校验 |
| drug-service | 药品基础信息、厂家、分类 | 药品查询、药品详情 |
| stock-service | 批次、库存、入库单、出库单 | 库存查询、入库、出库、批次近效期列表 |
| order-service | 处方、销售订单、订单明细 | 创建订单、扣库存、订单查询 |
这个数量级对于毕业设计正合适。四个服务拆得太少,体现不出Spring Cloud价值;拆得太多,你光维护服务间调用关系就要花掉大量时间。
2.2 注册中心与配置中心:选择Nacos而不是Eureka
过去Spring Cloud的标配是Eureka和Spring Cloud Config,但最近几年新项目基本都转向Nacos。Nacos同时承担注册中心和配置中心两个角色,这意味着你不需要再单独维护一个config-server。
我当前的依赖版本选择如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <spring-cloud.version>2021.0.8</spring-cloud.version> <spring-cloud-alibaba.version>2021.0.5.0</spring-cloud-alibaba.version> </properties>在bootstrap.yml里需要同时配置注册中心地址和配置中心数据源:
spring: application: name: stock-service cloud: nacos: server-addr: 127.0.0.1:8848 discovery: namespace: public group: DEFAULT_GROUP config: file-extension: yaml namespace: public group: DEFAULT_GROUP shared-configs: -><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>网关路由配置:
spring: cloud: gateway: routes: - id: user-route uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=2 - id: drug-route uri: lb://drug-service predicates: - Path=/api/drug/** filters: - StripPrefix=2 - id: stock-route uri: lb://stock-service predicates: - Path=/api/stock/** filters: - StripPrefix=2这里的关键是uri: lb://stock-service,Gateway会根据服务名从Nacos拿到实际实例列表,再通过LoadBalancer做轮询或负载均衡。StripPrefix=2表示在转发前丢弃前两段路径,也就是去掉/api/stock这两个段位,让target服务直接收到业务路径。
3. 从零搭建库存与药品服务的代码实现:数据库设计、REST API与调用链
3.1 数据库设计:批次与库存分开表
药品库存系统的核心是“批次”和“库存”分开建模。一张药品表只存通用信息,一张批次表存每批药品的批号、生产日期、有效期,一张库存表存“某个批次在某仓库的数量”。近效期预警只需对批次表做失效日期查询,不需要在药品明细里维护复杂的库存状态。
我用MySQL数据库,核心建表脚本如下:
CREATE TABLE drug_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(32) NOT NULL UNIQUE, drug_name VARCHAR(128) NOT NULL, category VARCHAR(64), spec VARCHAR(64), unit VARCHAR(16), manufacturer VARCHAR(128), approval_number VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 ); CREATE TABLE stock_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(64) NOT NULL, production_date DATE, expire_date DATE NOT NULL, supplier_id BIGINT, purchase_price DECIMAL(10,2), sale_price DECIMAL(10,2), KEY idx_drug_batch (drug_id, batch_no), KEY idx_expire (expire_date) ); CREATE TABLE stock_balance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, KEY idx_batch (batch_id) );stock_balance表里我加了一个version字段,这是为后面做乐观锁扣库存准备的,具体逻辑在第四章展开。
3.2 药品服务的基础CRUD:用MyBatis-Plus还是JPA
在这个项目里,我推荐MyBatis-Plus,因为毕业设计通常需要展示SQL可控性,而MyBatis-Plus的LambdaQueryWrapper写起来比原生XML快很多,还内置了分页插件。下面是一个药品查询的Mapper示例:
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface DrugInfoMapper extends BaseMapper<DrugInfo> { }对应的Service层用IService:
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.springframework.stereotype.Service; @Service public class DrugInfoService extends ServiceImpl<DrugInfoMapper, DrugInfo> { public DrugInfo findDrugByCode(String drugCode) { LambdaQueryWrapper<DrugInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(DrugInfo::getDrugCode, drugCode) .eq(DrugInfo::getDeleted, 0); return this.getOne(wrapper); } public Page<DrugInfo> pageSearch(String keyword, int pageNo, int pageSize) { LambdaQueryWrapper<DrugInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), DrugInfo::getDrugName, keyword) .or() .like(StringUtils.hasText(keyword), DrugInfo::getDrugCode, keyword); return this.page(new Page<>(pageNo, pageSize), wrapper); } }代码说明:LambdaQueryWrapper可以避免手写字符串列名,编译期就能发现字段名拼写问题。or()方法要与前面的like条件逗号分隔,如果你写成like().or().eq(),SQL会变成where drug_name LIKE ? OR drug_code = ?,条件解析完全不同,这里很容易踩坑。
Controller层直接返回统一响应体:
@RestController @RequestMapping("/drug") public class DrugInfoController { @Resource private DrugInfoService drugInfoService; @GetMapping("/page") public R<Page<DrugInfo>> page( @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") int pageNo, @RequestParam(defaultValue = "10") int pageSize) { return R.ok(drugInfoService.pageSearch(keyword, pageNo, pageSize)); } }参数说明:required = false表示前端可以不带keyword,不传时直接查全量;分页参数默认值写死在注解里,避免每次调用都要显式传值。
3.3 服务间调用:从订单服务调库存服务
现在走一条完整链路:order-service创建订单时,需要同步检查并扣减库存。这里选择使用OpenFeign调用库存服务。在order-service中引入依赖并定义Feign接口:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>Feign接口必须与库存服务Controller的请求映射保持一致:
import com.medsys.common.R; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @FeignClient(name = "stock-service", path = "/stock") public interface StockFeignClient { @PostMapping("/deduct") R<Void> deductStock(@RequestBody StockDeductDTO dto); @GetMapping("/near-expire") R<List<BatchVO>> nearExpire(@RequestParam("days") int days); }主启动类上必须加上@EnableFeignClients:
@SpringBootApplication @EnableFeignClients public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }调用链说明:订单服务启动后,Feign会基于服务名从Nacos获取库存服务的实例列表,然后通过LoadBalancer选择一个实例发起HTTP调用。这个过程中你不必手工管理URL地址,库存服务再扩展一个副本时,Feign会自动感知。
3.4 接口防重:用Redis做一个简单幂等键
药品订单接口面临重复提交的问题,尤其是前端超时重试场景。常见做法是用Redis的SETNX做幂等键。我在库存扣减接口中这样实现:
@PostMapping("/deduct") public R<Void> deductStock(@RequestBody StockDeductDTO dto) { String idempotentKey = "deduct:" + dto.getOrderNo(); Boolean success = redisTemplate.opsForValue() .setIfAbsent(idempotentKey, "processing", Duration.ofSeconds(30)); if (Boolean.FALSE.equals(success)) { return R.fail("重复请求,订单正在处理中"); } try { stockService.deduct(dto); } finally { // 处理完成后删除key,保证同一订单后续重试能够覆盖 redisTemplate.delete(idempotentKey); } return R.ok(); }代码说明:setIfAbsent就是SETNX,Redis集群下也能保证原子性。锁超时时间设为30秒,考虑的是扣库存逻辑正常执行不超过1秒,给足余量但也不会让锁堆积太久。
4. 综合业务落地:事务一致性、分布式锁与缓存策略
4.1 本地事务与分布式事务边界
在微服务架构里,订单服务和库存服务各自拥有独立数据库。一个创建订单的操作,会先写订单表,再调用库存服务扣库存。这两个动作单纯靠Spring的@Transactional无法保证原子性,因为事务边界只存在于单个数据库连接内。
这时候有两种常见方案:
- 基于可靠消息的最终一致性:订单创建成功后将消息写入本地消息表,通过MQ异步通知库存服务;
- 使用Seata AT模式:在业务方法上标注
@GlobalTransactional,Seata框架通过全局锁和undo_log表实现分布式事务。
毕业设计我更推荐使用Seata AT模式,因为这不需要改造业务SQL,只需在每个服务数据库中增加一张undo_log表。依赖配置如下:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>在order-service的方法上添加注解:
@GlobalTransactional(name = "create-order", timeoutMills = 10000) public void createOrder(OrderCreateDTO dto) { // 本地事务:写入订单主表和明细表 orderMapper.insert(OrderDO.builder().orderNo(dto.getOrderNo()).build()); // 远程事务:扣减库存 stockFeignClient.deductStock(dto.toDeductDTO()); }注意:@GlobalTransactional的timeoutMills要设置合理一点,常见坑是设置太短,导致库存服务响应稍慢就报全局事务超时。建议设置10秒以上。
4.2 库存扣减的并发陷阱:乐观锁 + Redis锁
库存扣减是药品系统的核心高并发场景。单纯用update stock_balance set quantity = quantity - 1 where id = ? and quantity >= 1在单库条件下没问题,但服务间调用时会出现超卖风险。我选择在库存服务本地使用乐观锁。
UPDATE stock_balance SET quantity = quantity - #{deductCount}, version = version + 1 WHERE id = #{balanceId} AND quantity >= #{deductCount} AND version = #{version}对应MyBatis-Plus的UpdateWrapper:
UpdateWrapper<StockBalance> wrapper = new UpdateWrapper<>(); wrapper.eq("id", dto.getBalanceId()) .eq("version", dto.getVersion()) .ge("quantity", dto.getDeductCount()) .setSql("quantity = quantity - " + dto.getDeductCount() + ", version = version + 1"); int rows = this.update(wrapper); if (rows == 0) { throw new BusinessException("库存不足或版本冲突"); }代码说明:setSql使用原生SQL片段,这是MyBatis-Plus允许的逃生舱。rows == 0表示update条件没有命中,可能是版本号被其他线程改了,也可能是库存不够。先返回失败让上层重试或提示用户。
如果扣减操作还涉及多个批次扣减顺序,我会再加一层Redis分布式锁,保证同一药品批次的操作串行化:
String lockKey = "stock:lock:" + dto.getBatchNo(); String lockValue = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new BusinessException("系统繁忙,请稍后重试"); } try { doDeduct(dto); } finally { String currentValue = (String) redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } }最后一个assert判断是为了防止误删别的线程设置的锁。锁超时时间只有5秒,如果业务的扣减在那段时间内没有完成,锁会自动释放,其他请求会同时进入。像扣减库存这种轻操作,5秒足够完成。
4.3 缓存策略:药品详情的多级缓存
药品基础信息被读多写少,适合缓存。常见做法是先用Redis缓存,再用本地Caffeine作为一级缓存。这里有一个容易忽略的点:药品信息修改后,必须先更新数据库,再删除缓存而不是直接更新缓存,否则并发更新会导致缓存里残留旧值。
我通常写一段简单的缓存工具类:
@Component public class DrugCacheService { @Resource private RedisTemplate<String, Object> redisTemplate; private final Cache<String, DrugInfo> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); public DrugInfo getDrug(Long drugId) { // 先查本地缓存 DrugInfo drugInfo = localCache.getIfPresent(drugId.toString()); if (drugInfo != null) { return drugInfo; } // 再查Redis String redisKey = "drug:info:" + drugId; DrugInfo redisValue = (DrugInfo) redisTemplate.opsForValue().get(redisKey); if (redisValue == null) { drugInfo = drugInfoMapper.selectById(drugId); redisTemplate.opsForValue().set(redisKey, drugInfo, Duration.ofHours(2)); } else { drugInfo = redisValue; } // 回填本地缓存 localCache.put(drugId.toString(), drugInfo); return drugInfo; } public void evictCache(Long drugId) { redisTemplate.delete("drug:info:" + drugId); localCache.invalidate(drugId.toString()); } }代码说明:本地缓存最大容量10000条,超过后Caffeine会按照W-TinyLFU算法淘汰。先更新数据库再evictCache,确保下一次读取不会拿到旧值。
5. 打包部署与调试细节:源代码该如何改造成自己的毕业设计
拿到一份可运行的Spring Cloud药品管理系统源代码,你最迫切的是让它能在本地跑起来。第一步是确认Nacos和Redis版本,我通常用Nacos 2.2.0,Redis 6.x以上。启动Nacos服务后,把配置中心里common.yaml里的数据库地址改成你的本机MySQL地址。
接着用Maven的打包命令:
mvn clean package -DskipTests每个子模块都会生成独立jar包,进入gateway目录执行:
nohup java -jar gateway-service.jar --spring.profiles.active=dev > gateway.log 2>&1 &启动顺序建议是:先启动user-service、drug-service、stock-service这类的纯业务服务,最后启动order-service和gateway。实际排错时有个技巧:用浏览器直接访问一个业务服务的随机端口,比如http://localhost:52001/drug/page,看能不能返回JSON。直接访问业务服务能隔离出问题是出在网关路由还是服务本身。
调试OpenFeign调用时,建议在订单服务本地开启Feign日志:
logging: level: com.medsys.order.feign: DEBUG feign: client: config: default: loggerLevel: FULLloggerLevel: FULL会打印请求头、请求体、响应体和元数据。常见的400错误通常是DTO字段名对不上,后端接收不到参数;500错误一般是库存服务内部异常,看看目标服务的日志即可。
另一个常见坑是当前不会命中断点。这通常不是代码问题,而是因为你的IDE调试的服务实例和访问的服务实例不是同一个。比如你同时启动了两个库存服务实例,网关负载均衡轮流转发请求,但你的断点只打在其中一个实例上。解决方法是给每个服务设置独一无二的端口,或者关闭LoadBalancer的重试策略,让请求固定走到带断点的实例。
最终的毕业设计演示,我建议准备一份验证脚本,按顺序调用这几个接口:登录获取token、创建药品、录入批次、入库、创建订单扣库存、查询近效期列表。确认每个接口响应时间都在500毫秒以内,答辩时展示Nacos上四个服务注册成功的截图,再贴一段Feign调用的日志,整个项目的完整度和技术深度就都够了。
本文还有配套的精品资源,点击获取