☰
SpringBoot+Vue医药管理系统实战:批号效期与GSP合规设计
2026/10/13 15:00:18 网站建设 项目流程

做医药管理系统这个项目,我最大的感受是:它跟普通的后台管理系统完全不是一回事。药品不是图书,不是普通商品,从入库到出库,每一盒药的批号、效期、流向都得有据可查。一套真正能落地运行的医药管理系统,背后的核心不是页面漂不漂亮,而是数据模型能不能支撑起合规要求,业务流程能不能扛住真实库房的日常操作。

这次分享的项目是一套基于 SpringBoot + Vue + MyBatis + MySQL 的企业级医药管理系统,前后端分离,源码完整,覆盖了药品信息管理、库存出入库、采购销售、效期预警、权限管理等核心模块。适合正在做毕业设计、企业内部系统开发,或者想系统学习 SpringBoot 和 Vue 全栈开发的同学参考。我会把架构设计、表结构、核心代码思路、部署过程,以及我实际踩过的坑一起写出来,希望你能少走一些弯路。

1. 为什么做医药管理系统:技术选型与整体设计思路

1.1 医药管理系统的核心业务场景

在动手写代码之前,需要先搞清楚医药管理系统到底要管什么。和普通商品进销存相比,医药行业的特殊性很突出:

第一是批号管理。药品入库时必须记录生产批号,同一款药,不同批次不能混着卖,出库时要遵循先进先出原则。第二是效期管理。药品一旦过期就必须报废,系统要在药品临期前自动提醒。第三是质量追溯。出了问题要能查到这批药从哪个供应商来的、发给哪个客户了。第四是GSP合规。医药经营企业需要符合药品经营质量管理规范,系统操作要有记录、有复核,权限要分明。

这些特殊需求直接决定了数据表怎么设计、业务流程怎么走、权限怎么控制。所以做这套系统的第一个关键决策,就是先把业务模型吃透再写代码,而不是一上来就建表。

1.2 技术栈选型:为什么是 SpringBoot + Vue + MyBatis + MySQL

这个技术组合在目前的企业级管理系统开发中,可以说是最主流、最稳妥的方案之一,原因很直接:

  • SpringBoot:简化了 Spring 的繁琐配置,内嵌 Tomcat,打 jar 包就能直接运行。对于中小型团队和单体系统来说,开发效率和部署体验都非常好。
  • MyBatis:SQL 由开发者自己控制,面对医药库存这类复杂查询场景,可以精确写 SQL,利用动态 SQL 灵活拼接条件,性能优化空间也大。
  • Vue:组件化开发,响应式数据绑定,做后台管理系统的列表、表单、弹窗这类交互非常适合,Element UI 组件库还能帮我们节省大量样式开发时间。
  • MySQL:开源、稳定、使用广泛,配合 InnoDB 引擎支持事务,保证库存扣减这类操作的原子性。

这套组合的另一个好处是人才储备充足。不管是你自己后续维护,还是公司招人接手,会这套技术栈的人很多,不会出现“项目做完了没人能维护”的尴尬局面。

1.3 前后端分离架构的整体设计

系统采用前后端分离架构,后端提供 RESTful API,前端通过 Axios 发起 HTTP 请求调用接口。开发时同时启动前端项目和后端项目,前端通过代理转发请求,避免跨域问题;部署时后端打包成 jar 包,前端打包成静态资源,用 Nginx 统一托管,动静分离。

从功能模块上看,系统划分为以下几大块:

模块核心功能关键点
系统管理用户管理、角色管理、菜单管理基于 RBAC 的权限模型
药品管理药品信息、厂家、类目、剂型药品基础档案的统一维护
库存管理入库、出库、盘点、库存查询批号追踪与先进先出策略
采购管理供应商、采购单、入库验收采购与库存的联动
销售管理客户、销售单、销售退回处方药销售记录留存
报表统计库存预警、销售统计、效期报表辅助经营决策
系统监控操作日志、数据备份满足合规审计需求

这里有个很重要的设计原则:库存数据和业务单据数据必须分离。采购单、销售单是“流水账”,库存表是“当前余量”。每次业务操作同时写单号和库存流水,而不是直接改库存数量,这样才能追溯每一笔变动。

2. 数据层设计:MySQL 表结构与 MyBatis 实战用法

2.1 核心表结构设计与字段拆解

表结构是整个系统的地基,这里一定要花时间设计好。我实际建表时遵循了几个原则:业务数据表都有主键 id、创建时间 create_time、更新时间 update_time;状态字段用 int 或 tinyint,不用 varchar 存中文,避免后续扩展麻烦;涉及金额的字段用 decimal,不用 float 和 double,避免精度丢失。

药品主表 design 的核心字段:

  • id:主键自增
  • drug_code:药品编码,唯一索引
  • drug_name:药品通用名
  • specification:规格,比如 0.25g*24粒
  • dosage_form:剂型,片剂、胶囊、注射液
  • manufacturer:生产厂家
  • approval_number:批准文号,国药准字号
  • unit:基本单位
  • retail_price、purchase_price:零售价、采购价

库存表 inventory 是带有批号维度的,这一点和普通库存表很不一样:

  • id:主键
  • drug_id:关联药品 id
  • batch_no:生产批号
  • production_date:生产日期
  • expire_date:有效期至
  • quantity:当前库存数量
  • status:正常、临期、过期、冻结

为什么要按批号拆库存?因为同一药品不同批次,效期不一样,先进先出必须依赖批号来实现。查询某个药品的总库存,用 SUM(quantity) 按 drug_id 汇总就行;出库时则要按批号逐笔扣减,先出效期最近的批次。

库存流水表 inventory_log 记录了每一次出入库明细:

  • id:主键
  • drug_id、batch_no
  • change_type:入库、出库、盘点调整、报损
  • quantity:变动数量,正负号区分方向
  • ref_bill_no:关联单据号,比如采购单号、销售单号
  • operate_user:操作人
  • operate_time:操作时间

有了流水表之后,库存对账就变得很清晰。比如卖了一盒药,既要在销售单明细里加一条记录,又要在 inventory_log 里扣减对应的批号库存,同时更新 inventory 表里的余量。这三个操作必须放在同一个事务里,任何一步失败都要回滚。

2.2 MyBatis 动态 SQL 在库存和查询模块的应用

MyBatis 的 XML 映射文件里,动态 SQL 是处理复杂查询的利器。医药管理系统里有很多“组合条件查询”的场景,比如药品列表页,用户可能只输入药品名,可能只选择剂型,也可能输入厂家加批准文号。如果每个条件都写一个方法,代码会爆炸。用<where>加<if>标签,可以完美解决这个问题:

<select id="selectDrugList" resultType="com.example.entity.Drug"> SELECT * FROM drug <where> <if test="drugName != null and drugName != ''"> AND drug_name LIKE CONCAT('%', #{drugName}, '%') </if> <if test="dosageForm != null and dosageForm != ''"> AND dosage_form = #{dosageForm} </if> <if test="approvalNumber != null and approvalNumber != ''"> AND approval_number = #{approvalNumber} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>

药品名用 LIKE 模糊查询,剂型和批准文号用等值查询,MyBatis 会智能判断哪些条件需要拼接,哪些条件跳过。这套写法在报表查询、销售单据筛选里同样适用,写一次能复用好几年。

批量插入在采购入库场景中特别常见。一张采购单可能包含几十种药品,一条一条 insert 性能太差。MyBatis 支持批量插入:

<insert id="batchInsert" parameterType="list"> INSERT INTO inventory_log ( drug_id, batch_no, change_type, quantity, ref_bill_no, operate_user, operate_time ) VALUES <foreach collection="list" item="item" separator=","> ( #{item.drugId}, #{item.batchNo}, #{item.changeType}, #{item.quantity}, #{item.refBillNo}, #{item.operateUser}, #{item.operateTime} ) </foreach> </insert>

注意 MySQL 对单条 SQL 大小有限制,如果一次入库几千条数据,需要分批执行,每批建议控制在 500 条左右,实测性能稳定。

2.3 事务控制与库存扣减的一致性保证

库存扣减是整套系统里最容易出 Bug 的地方,因为它在并发场景下会出问题。想象一下:同一个药品只剩最后一盒,两个客户同时下单,如果两条请求同时读到库存为 1,同时执行扣减,最后库存变成了负数,这就是超卖。

解决方案是使用数据库行级锁。在扣减库存的 SQL 里加上 FOR UPDATE,让同一时间只有一个事务能操作这行数据:

<update id="deductStock"> UPDATE inventory SET quantity = quantity - #{quantity} WHERE drug_id = #{drugId} AND batch_no = #{batchNo} AND quantity >= #{quantity} </update>

这里有个关键:WHERE 条件里带上 quantity >= #{quantity},如果受影响行数为 0,说明库存不足,直接抛出异常回滚事务。配合 SELECT ... FOR UPDATE 先锁定行,再把流水和单据写入,整个流程就是安全的。

事务的边界要清晰,Spring 里用 @Transactional 注解标记 Service 层方法。需要注意事务只能控制由 Spring 管理的数据库连接,所以不能在事务方法内自己创建新连接,否则锁和回滚都会失效。这一点比较容易踩坑。

3. 后端核心模块实现与 SpringBoot 配置要点

3.1 用户权限与角色控制

医药系统的权限控制比普通系统要求更严格。GSP 规范要求不同岗位的操作权限必须分离,比如采购员不能自己审批采购单,验收员和采购员不能是同一人。系统采用经典的 RBAC 权限模型,共五张表。

用户表 sys_user 和角色表 sys_role 是多对多关系,通过 sys_user_role 关联;角色和菜单的关系通过 sys_role_menu 关联。后端拦截器在每次请求时解析用户的 token,拿到用户 id 后查询所有角色,再查询菜单权限,把权限字符集合存到本地线程变量中,供后续接口校验使用。

SpringBoot 里用拦截器实现统一鉴权,这个方案最简单直接:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); // 解析 token,获取用户信息和权限 // 校验接口权限码,没有权限则返回 403 return true; } }

密码处理方面,一定不能用 MD5 明文加盐这种方案,推荐使用 BCrypt 加密。Spring Security 框架自带 BCryptPasswordEncoder,生成的结果每次都不一样,而且自带盐值,安全性高出很多。

3.2 效期预警与批次管理的定时任务

效期预警是医药系统的灵魂功能。系统里我用 SpringBoot 自带的 @Scheduled 注解实现定时任务,每天凌晨跑一次,扫描所有库存批次:

@Component public class ExpiryCheckTask { @Scheduled(cron = "0 0 2 * * ?") public void checkExpiry() { // 找出 90 天内过期的批次,标记为临期 // 找出已过期的批次,标记为过期并冻结销售 // 生成预警记录插入预警表 } }

预警规则我做了分级:距离有效期不足 90 天的批次标记为黄色预警,不足 30 天标红色预警,已过期的批号直接冻结,不允许出库。药品列表和库存页面都会实时显示预警状态,首页也放了一个临期药品的统计卡片,这样库房管理员一打开系统就能看到。

这里要注意一个细节:定时任务默认是单线程串行执行的,如果任务多了要配置线程池,避免一个任务卡死影响其他任务。另外批号冻结要设计“解冻”入口,因为有些药品可能延期注册或者换批号,需要一个人工干预的接口。

3.3 SpringBoot 配置文件的关键项

我在 application.yml 里沉淀了一套比较稳妥的配置,直接贴出来:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/medical?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.example.mapper: debug

重点说明几个关键配置:serverTimezone 必须设置成 Asia/Shanghai,否则日期时间查询会差 8 小时;map-underscore-to-camel-case 打开后,数据库的 create_time 字段能自动映射到 Java 实体类里的 createTime 属性,少写很多 resultMap;log-impl 在开发阶段设为 StdOutImpl,方便在控制台直接看到 SQL 执行日志;上线前记得关掉这个配置,否则日志量太大占磁盘。

SpringBoot 版本和 MyBatis Starter 的兼容性也是一个坑。SpringBoot 3.x 的 jakarta 命名空间和 2.x 的 javax 完全不同,如果你习惯看老教程,直接拿 2.7 的代码往 SpringBoot 3.2 上跑,大概率编译报错。如果是从零开始做项目,最稳妥的选择是 SpringBoot 2.7.x + MyBatis Spring Boot Starter 2.3.x,资料多,坑少,稳定运行没问题。

4. 前端 Vue 篇:从环境准备到业务页面落地

4.1 Vue 环境配置与工程初始化

很多新手在 Vue 环境配置上就会卡住,这里整理一下完整流程。先装 Node.js,版本建议选 16.x 或 18.x LTS。有个容易踩的坑:Node 版本太高,比如 22.x,老版本 Vue CLI 创建的工程可能起不来,报 OpenSSL 错误。解决办法是升级构建工具版本,或者直接降 Node 版本。我推荐用 Vite 而不是 Vue CLI 来初始化工程,Vite 启动速度快很多,对 ESM 的支持也更现代。

创建工程和执行命令:

npm create vite@latest medical-ui -- --template vue cd medical-ui npm install npm install vue-router@4 axios element-plus echarts npm run dev

开发时前端端口默认 5173,后端端口 8080,存在跨域问题。常见做法是在项目根目录创建 vite.config.js,配置代理:

export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

前端请求地址统一写成 /api/xxx,后端 Controller 的 RequestMapping 前缀也统一是 /api。这样在开发环境走代理,在生产环境用 Nginx 反代,前端代码一行不用改。

4.2 权限路由与菜单的动态加载

医药系统的菜单权限是区分角色的。管理员能看到系统管理菜单,验收员只能看到采购验收相关页面,销售员看到的是销售开单页面。如果每个角色都写死一份路由表,后期加一个菜单要改 n 个文件,维护成本太高。

更优雅的做法是动态路由。用户登录后,后端返回当前用户的菜单列表,前端用 router.addRoute 动态添加路由:

// 登录成功后获取用户菜单 const menuList = await getMenuList() // 前端根据菜单数据动态注册路由 menuList.forEach(item => { router.addRoute({ path: item.path, name: item.name, component: () => import(`@/views/${item.component}.vue`), meta: { title: item.title, icon: item.icon } }) })

菜单存储在前端 localStorage 里,刷新页面时重新加载。这里有个坑:Vite 的 import 只能静态分析,变量方式写 import(@/views/${item.component}.vue) 偶尔会打包不出完整路由文件,解决办法是把所有页面组件都手动 import 到一个 map 对象里,再用 map[item.component] 取组件。如果你也想省事,可以像 @/router/dynamicRoutes 一样先把页面组件和路径对应关系写清楚。

所有需要登录的请求,在 Axios 请求拦截器里统一加上 token:

axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })

响应拦截器里统一处理 401 未登录和 403 无权限,跳转到登录页或提示无权限,业务代码里不用到处判断登录状态。

4.3 数据可视化与表单校验的细节

首页的销售统计图表我用 ECharts 实现,这是一个非常成熟的图表库。折线图展示近一个月的销售额走势,柱状图展示药品分类销量排行,饼图展示库存占比。数据来源是后端聚合查询接口,一次请求返回多维统计数据,前端直接填充到图表配置里。

const salesChart = echarts.init(document.getElementById('salesChart')) salesChart.setOption({ xAxis: { type: 'category', data: dateList }, yAxis: { type: 'value' }, series: [ { name: '销售额', type: 'line', data: saleAmountList, smooth: true, areaStyle: { opacity: 0.15 } } ] })

药品表单的校验也比一般表单复杂,因为字段多、业务规则多。用 Element Plus 的 Form 组件,rules 里可以配置多种验证规则。比如新增药品时,批准文号必须是国药准字格式,我用正则做了校验;零售价必须大于采购价;效期必须晚于生产日期。这些规则写在前端,用户填完立刻就有反馈,不用等提交到后端再报错,体验好很多。

Vue 组件的 computed 属性在库存统计场景特别好用。比如药品列表里要显示“可售库存”,它的逻辑是总库存减去已锁定库存再减去过期数量,这个计算逻辑写在 computed 里,只要库存数据变化,页面自动更新,不用手动刷新。

5. 部署打包、常见问题与避坑实录

5.1 前后端构建与部署流程

部署我采用的是经典的前后端分离部署方式。后端在项目根目录执行 Maven 打包命令:

mvn clean package -DskipTests

打包完 target 目录下会生成一个 medical-system.jar。因为 SpringBoot 内置了 Tomcat,所以只需要 JDK 环境,上传到服务器后执行:

nohup java -jar medical-system.jar --spring.profiles.active=prod > app.log 2>&1 &

前端执行构建:

npm run build

dist 目录下会生成静态资源文件,把它上传到服务器,和 Nginx 放在一起。Nginx 配置需要做两件事:一是把 / 路径的请求指向前端静态资源目录;二是把 /api 开头的请求反代到后端的 8080 端口:

server { listen 80; server_name your-domain.com; root /opt/medical/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

这个配置里有一个 Vue Router 的经典坑需要提醒:如果使用 history 模式,刷新页面时 Nginx 找不到路由对应的物理文件,会报 404。必须在 Nginx 里加一个 fallback 配置:

location / { try_files $uri $uri/ /index.html; }

这样所有前端路由都指向 index.html,由 Vue Router 自己解析跳转。

5.2 高频率报错与排查方案

我把实际开发中遇到的高频问题整理成了一张速查表,每个问题都是真实踩过坑后总结出来的:

现象原因解决方案
MyBatis 一直卡在 Downloading...Maven 仓库无法访问或依赖下载超时配置阿里云镜像源,检查网络代理设置
前端 npm install 报 ERESOLVE 错误依赖版本冲突使用 npm install --legacy-peer-deps
MyBatis 批量插入数据没写成功也不报错事务未提交或事务方法未生效检查 @Transactional 是否在 public 方法上,检查 SpringBoot 启动类是否扫描到了 Service
MySQL 连接报 Public Key Retrieval 错误MySQL 8.0 认证机制变化JDBC URL 添加 allowPublicKeyRetrieval=true&useSSL=false
中文乱码数据库或连接字符集不一致数据库表设置 utf8mb4,URL 添加 characterEncoding=utf8
Vue 页面刷新 404前端路由 history 模式Nginx 配置 try_files 回退到 index.html

MyBatis 批量插入不报错但数据没写进去,这个坑我印象最深刻。第一次遇到时,我 debug 了整整一下午,发现 SQL 执行成功但数据库就是没数据。后来定位到是事务问题——Service 方法里的批量插入在没有 @Transactional 的方法里,默认每个 SQL 自动提交一次,但循环里先插主表再插明细表,每步都单独提交了,最后因为外键约束导致的异常被吞掉了。所以批量操作一定要保证事务边界。

还有一个 SpringBoot 版本太高的坑。如果项目用的是 SpringBoot 3.x,MyBatis 的 starter 要用 mybatis-spring-boot-starter 3.0 以上版本,而且所有 javax.servlet 相关的包都要改成 jakarta.servlet,DataSource 配置也有变化。我建议新手不要一上来就追新版本,稳定运行的 2.7.x 对学习和使用都更友好。

5.3 项目的扩展方向

这套系统的架构,后续可以继续扩展的方向非常明确:

药品追溯码功能。国内药品流通正在全面推进药品追溯体系建设,可以在现有批号管理基础上,增加药品追溯码(药品电子监管码)的扫码记录功能。每一盒药从出厂到销售终端,每个环节扫码都有记录,实现真正意义上的全程追溯。

温湿度监控预警。GSP 对药品储存环境有严格要求,阴凉库、冷藏库都要有温湿度监控。可以对接温湿度传感器设备,定时采集数据,超出范围立即报警,数据留存备查。

与硬件设备对接。医院或药房的自动发药机、智能货架,可以通过 API 接口与系统对接,扫码后自动扣减库存,提高工作效率。

小程序端应用。给业务员配一个移动端小程序,可以在现场开销售单、查库存、扫描批号,数据实时同步到主系统。

这些扩展方向都是在当前架构基础上增加功能模块,不用推翻重写,这也是当初认真设计表结构带来的好处。

5.4 开发时间线参考

最后分享一个实际的项目排期参考,单人或两人团队从这个系统开始到上线,大概的节奏是这样的:

阶段周期关键交付物
需求梳理与数据库设计1周表结构文档、ER 图
后端基础框架与用户权限1周SpringBoot 工程、RBAC 模块
药品与库存模块1周2天出入库、库存查询接口
采购与销售模块1周采购单、销售单完整流程
前端框架与核心页面2周登录、首页、药品管理、库存管理页面
报表与预警功能3天ECharts 图表、定时任务
联调与部署上线3天Nginx 部署、生产环境验证

实际开发中会有很多意外情况,建议多预留 30% 的时间余量。特别是前后端联调阶段,接口字段对不上、时间格式不一致、分页参数不同名,这些问题层出不穷。对不上的时候不要急,先约定一份接口文档,字段名统一,会省很多事。

我做这套系统最大的体会是:企业级项目最关键的部分往往不是炫酷的技术,而是对业务的理解深度。医药行业的批号、效期、GSP 规范这些细节,才是系统真正的护城河。技术架构只是骨架,业务逻辑才是血肉。如果只盯着 SpringBoot 和 Vue 怎么用,而忽略了这批药从哪来到哪去,那做出来的系统顶多算个玩具。希望这次的分享能帮你在做医药管理系统时,少踩几个我自己踩过的坑。

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

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

立即咨询