1. 项目背景与核心需求
医药零售行业正经历数字化转型的关键时期。根据中国医药商业协会数据,2022年全国药店总数达58.7万家,其中连锁药店占比56.8%,行业对数字化管理系统的需求年增长率超过25%。传统单机版药品管理系统存在数据孤岛、无法远程访问、扩展性差等痛点,基于B/S架构的Web系统成为行业新标准。
这个SpringBoot药品管理系统需要解决三个核心问题:
- 药品全生命周期管理(采购→入库→销售→效期监控)
- 合规性要求(GSP认证要求的批次追踪、近效期预警)
- 多终端协同(门店PC端、移动端、总部看板)
提示:医药管理系统必须符合《药品经营质量管理规范》(GSP)要求,特别是批号追踪和效期管理功能属于刚性需求。
2. 技术架构设计
2.1 整体技术栈选型
采用分层架构设计,具体技术组件如下:
| 层级 | 技术选型 | 选型理由 |
|---|---|---|
| 前端 | Thymeleaf + Bootstrap | 适合传统管理后台开发,SEO友好,与SpringBoot天然集成 |
| 后端框架 | SpringBoot 2.7.x | 约定优于配置,快速启动,内嵌Tomcat简化部署 |
| 持久层 | MyBatis-Plus + Druid | MyBatis增强工具简化CRUD,Druid提供完善的数据库监控 |
| 安全控制 | Spring Security | 完善的RBAC支持,符合医药行业分岗位权限管控需求 |
| 中间件 | Redis | 缓存药品目录等高频访问数据,减轻数据库压力 |
| 数据库 | MySQL 8.0 | 事务支持完善,社区资源丰富,与SpringBoot生态兼容性好 |
2.2 数据库关键表设计
核心表结构设计需满足GSP规范要求:
sql复制-- 药品主表
CREATE TABLE `medicine` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '药品ID',
`code` varchar(20) NOT NULL COMMENT '药品编码',
`name` varchar(100) NOT NULL COMMENT '通用名称',
`spec` varchar(50) NOT NULL COMMENT '规格',
`unit` varchar(10) NOT NULL COMMENT '单位',
`manufacturer` varchar(200) NOT NULL COMMENT '生产厂家',
`approval_number` varchar(50) NOT NULL COMMENT '批准文号',
`category_id` int NOT NULL COMMENT '分类ID',
`otc_type` tinyint NOT NULL COMMENT '处方类型(1:OTC 2:Rx)',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '状态(0:停用 1:启用)',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品主表';
-- 批次库存表(关键GSP要求表)
CREATE TABLE `batch_stock` (
`id` bigint NOT NULL AUTO_INCREMENT,
`medicine_id` bigint NOT NULL COMMENT '药品ID',
`batch_no` varchar(30) NOT NULL COMMENT '批号',
`production_date` date NOT NULL COMMENT '生产日期',
`expiry_date` date NOT NULL COMMENT '有效期至',
`stock` int NOT NULL DEFAULT '0' COMMENT '当前库存',
`price` decimal(10,2) NOT NULL COMMENT '进货价',
`selling_price` decimal(10,2) NOT NULL COMMENT '零售价',
`supplier_id` bigint NOT NULL COMMENT '供应商ID',
`warehouse_id` int NOT NULL COMMENT '仓库ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_medicine` (`medicine_id`),
KEY `idx_expiry` (`expiry_date`) COMMENT '效期索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批次库存表';
3. 核心功能实现
3.1 药品批次管理实现
医药行业核心特色功能,采用"一物一码"管理原则:
java复制// 批次入库逻辑
@Transactional
public Result batchStockIn(StockInDTO dto) {
// 校验药品基础信息
Medicine medicine = medicineMapper.selectById(dto.getMedicineId());
if (medicine == null) {
throw new BusinessException("药品不存在");
}
// GSP要求:批号不能重复
Integer count = batchStockMapper.selectCount(
new QueryWrapper<BatchStock>()
.eq("batch_no", dto.getBatchNo())
.eq("medicine_id", dto.getMedicineId()));
if (count > 0) {
throw new BusinessException("该批号已存在");
}
// 构建批次实体
BatchStock entity = new BatchStock();
BeanUtils.copyProperties(dto, entity);
entity.setStatus(1);
// 计算效期(生产日期+药品默认有效期)
LocalDate productionDate = dto.getProductionDate();
LocalDate expiryDate = productionDate.plusMonths(medicine.getExpiryMonths());
entity.setExpiryDate(expiryDate);
// 保存批次
batchStockMapper.insert(entity);
// 更新药品总库存
medicineMapper.updateStock(dto.getMedicineId(), dto.getQuantity());
// 记录操作日志(GSP要求)
operationLogService.logStockIn(dto);
return Result.success();
}
3.2 智能预警模块
实现三类核心预警:
- 库存预警:当库存低于安全库存时触发
- 近效期预警:有效期剩余30/15/7天分级预警
- 滞销预警:超过6个月未销售的药品预警
java复制// 效期预警定时任务
@Scheduled(cron = "0 0 9 * * ?") // 每天上午9点执行
public void expiryWarningJob() {
// 30天预警
List<BatchStock> warningList30 = batchStockMapper.selectList(
new QueryWrapper<BatchStock>()
.le("expiry_date", LocalDate.now().plusDays(30))
.gt("expiry_date", LocalDate.now())
.eq("status", 1));
// 15天预警
List<BatchStock> warningList15 = batchStockMapper.selectList(
new QueryWrapper<BatchStock>()
.le("expiry_date", LocalDate.now().plusDays(15))
.gt("expiry_date", LocalDate.now())
.eq("status", 1));
// 分级处理预警
processWarning(warningList30, 30);
processWarning(warningList15, 15);
// 自动下架过期药品
batchStockMapper.updateStatusByExpiry(LocalDate.now(), 0);
}
private void processWarning(List<BatchStock> list, int days) {
list.forEach(item -> {
String content = String.format("药品[%s]批号[%s]将在%d天后过期",
item.getMedicineName(), item.getBatchNo(), days);
// 插入预警记录
WarningRecord record = new WarningRecord();
record.setType(WarningType.EXPIRY);
record.setContent(content);
record.setRelId(item.getId());
warningRecordMapper.insert(record);
// 发送站内信(可扩展短信/邮件通知)
messageService.sendSystemMessage(
"效期预警",
content,
getPharmacistUsers());
});
}
4. 特殊业务场景处理
4.1 药品拆零销售
社区药房常见需求,需要特殊处理库存和价格计算:
java复制public Result sellSplitMedicine(SellSplitDTO dto) {
// 验证拆零比例
if (dto.getSplitRatio() <= 0 || dto.getSplitRatio() > 1) {
throw new BusinessException("拆零比例必须在0-1之间");
}
// 查询批次库存
BatchStock batch = batchStockMapper.selectById(dto.getBatchId());
if (batch.getStock() < dto.getQuantity()) {
throw new BusinessException("库存不足");
}
// 计算拆零后价格(保留2位小数)
BigDecimal price = batch.getSellingPrice()
.multiply(BigDecimal.valueOf(dto.getSplitRatio()))
.setScale(2, RoundingMode.HALF_UP);
// 扣减库存(需要原子操作)
int rows = batchStockMapper.updateStock(
dto.getBatchId(),
-dto.getQuantity());
if (rows == 0) {
throw new BusinessException("库存扣减失败");
}
// 记录销售(特殊标记拆零销售)
SalesRecord record = new SalesRecord();
record.setBatchId(dto.getBatchId());
record.setQuantity(dto.getQuantity());
record.setAmount(price.multiply(BigDecimal.valueOf(dto.getQuantity())));
record.setSplitFlag(1);
record.setSplitRatio(dto.getSplitRatio());
salesRecordMapper.insert(record);
return Result.success();
}
4.2 处方药销售控制
实现处方药(Rx)的"双人核对"机制:
java复制@Transactional
public Result sellRxMedicine(SellRxDTO dto) {
// 验证药品类型
Medicine medicine = medicineMapper.selectById(dto.getMedicineId());
if (medicine.getOtcType() != 2) {
throw new BusinessException("非处方药不能使用此流程");
}
// 验证处方信息
if (StringUtils.isBlank(dto.getRxNumber())) {
throw new BusinessException("处方药必须填写处方号");
}
// 第一操作人验证(通常为收银员)
User operator1 = userService.getCurrentUser();
// 第二操作人验证(药师)
if (dto.getCheckerId() == null) {
throw new BusinessException("必须指定审核药师");
}
User operator2 = userMapper.selectById(dto.getCheckerId());
if (operator2 == null || !operator2.hasRole("PHARMACIST")) {
throw new BusinessException("审核人必须是药师角色");
}
// 库存处理(略...)
// 记录双人操作日志
rxAuditService.logOperation(
dto.getMedicineId(),
operator1.getId(),
operator2.getId(),
dto.getRxNumber());
return Result.success();
}
5. 性能优化实践
5.1 药品查询优化
采用多级缓存策略:
- 本地缓存:使用Caffeine缓存高频访问的药品基础信息
- 分布式缓存:Redis缓存全量药品目录和分类树
- 数据库优化:针对组合查询建立复合索引
java复制// 多级缓存实现示例
public Medicine getMedicineWithCache(Long id) {
// 第一级:本地缓存
Medicine medicine = localCache.get(id);
if (medicine != null) {
return medicine;
}
// 第二级:Redis缓存
String redisKey = "medicine:" + id;
String json = redisTemplate.opsForValue().get(redisKey);
if (StringUtils.isNotBlank(json)) {
medicine = JSON.parseObject(json, Medicine.class);
localCache.put(id, medicine); // 回填本地缓存
return medicine;
}
// 第三级:数据库查询
medicine = medicineMapper.selectById(id);
if (medicine != null) {
// 异步更新缓存
CompletableFuture.runAsync(() -> {
redisTemplate.opsForValue().set(
redisKey,
JSON.toJSONString(medicine),
1, TimeUnit.HOURS);
localCache.put(id, medicine);
});
}
return medicine;
}
5.2 报表查询优化
针对销售统计等复杂报表:
- 使用Spring Batch进行夜间批量预处理
- 建立物化视图
- 采用列式存储ClickHouse作为分析型数据库
sql复制-- 物化视图示例(每日凌晨刷新)
CREATE MATERIALIZED VIEW mv_daily_sales
REFRESH COMPLETE ON DEMAND
START WITH SYSDATE NEXT TRUNC(SYSDATE) + 1
AS
SELECT
TRUNC(s.create_time) AS sale_date,
m.category_id,
SUM(s.quantity) AS total_quantity,
SUM(s.amount) AS total_amount,
COUNT(DISTINCT s.batch_id) AS sku_count
FROM
sales_record s
JOIN
batch_stock b ON s.batch_id = b.id
JOIN
medicine m ON b.medicine_id = m.id
WHERE
s.create_time >= TRUNC(SYSDATE) - 30
GROUP BY
TRUNC(s.create_time),
m.category_id;
6. 安全合规要点
6.1 数据加密处理
敏感字段采用AES加密存储:
java复制// 加密配置类
@Configuration
public class CryptoConfig {
@Value("${app.crypto.key}")
private String cryptoKey;
@Bean
public StringEncryptor medicineEncryptor() {
PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
encryptor.setConfig(new SimpleStringPBEConfig() {
{
setPassword(cryptoKey);
setAlgorithm("PBEWithHMACSHA512AndAES_256");
setKeyObtentionIterations(1000);
setPoolSize(4);
setSaltGenerator(new RandomSaltGenerator());
setIvGenerator(new RandomIvGenerator());
setStringOutputType("base64");
}
});
return encryptor;
}
}
// 实际使用示例
public class MedicineService {
@Autowired
private StringEncryptor encryptor;
public void saveMedicine(MedicineDTO dto) {
Medicine entity = new Medicine();
BeanUtils.copyProperties(dto, entity);
// 加密敏感字段
entity.setApprovalNumber(
encryptor.encrypt(dto.getApprovalNumber()));
medicineMapper.insert(entity);
}
}
6.2 审计日志实现
符合GSP要求的操作审计:
java复制// 审计日志切面
@Aspect
@Component
public class AuditLogAspect {
@Autowired
private AuditLogMapper auditLogMapper;
@Pointcut("@annotation(com.medsystem.annotation.GspAudit)")
public void auditPointcut() {}
@AfterReturning(pointcut = "auditPointcut()", returning = "result")
public void afterReturning(JoinPoint joinPoint, Object result) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
GspAudit annotation = method.getAnnotation(GspAudit.class);
AuditLog log = new AuditLog();
log.setModule(annotation.module());
log.setOperation(annotation.operation());
log.setOperator(UserContext.getCurrentUserId());
log.setOperateTime(LocalDateTime.now());
log.setStatus(1); // 成功
// 获取方法参数(敏感信息需脱敏)
Object[] args = joinPoint.getArgs();
if (args != null && args.length > 0) {
log.setParams(JsonUtils.toJson(args));
}
auditLogMapper.insert(log);
}
@AfterThrowing(pointcut = "auditPointcut()", throwing = "e")
public void afterThrowing(JoinPoint joinPoint, Exception e) {
// 失败日志记录(略...)
}
}
7. 部署与监控方案
7.1 容器化部署
采用Docker + Docker Compose方案:
dockerfile复制# Dockerfile示例
FROM openjdk:11-jre
WORKDIR /app
COPY target/pharmacy-system.jar ./app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"]
yaml复制# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
depends_on:
- redis
- mysql
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:6
ports:
- "6379:6379"
volumes:
- redis_data:/data
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: pharmacy
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
redis_data:
mysql_data:
7.2 监控配置
SpringBoot Actuator + Prometheus + Grafana方案:
yaml复制# application.yml配置片段
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
tags:
application: pharmacy-system
health:
db:
enabled: true
redis:
enabled: true
关键监控指标:
- JVM内存使用率
- 数据库连接池状态
- 接口响应时间P99
- 药品查询缓存命中率
- 效期预警任务执行时长
8. 项目演进方向
- 智能化升级:引入机器学习算法预测药品销量,优化采购计划
- 多终端扩展:开发微信小程序对接医保支付
- 供应链协同:通过区块链技术实现药品流通全程追溯
- 数据分析:建立客户画像实现精准营销
- 云原生改造:迁移至Kubernetes集群,实现弹性伸缩
在实际开发中,药品批次的效期管理模块需要特别注意时区问题。我们曾遇到生产环境因服务器时区设置错误导致效期预警提前一天触发的问题,最终通过统一使用UTC时间并在前端展示时转换解决。另一个经验是库存扣减一定要采用乐观锁机制,我们早期版本使用先查询再更新的方式在高并发场景下出现了超卖问题
