Spring Boot和Vue组合做药品管理系统,几乎是毕设季的常青树。最近好几个人来找我,说自己代码都写完了,但论文不知道从哪里下手;也有人正好反过来,论文框架搭好了,回头发现系统缺模块。作为一个带过多个前后端分离项目的开发者,我今天把这套系统从技术方案到论文写作完整拆一遍,适合正在做毕设、或者想拿这个题目做小型公司级练习项目的人。这篇文章不聊虚的,直接说清楚系统怎么做、论文怎么搭、答辩会被问哪些东西。
1. 为什么药品管理系统成了前后端分离项目的经典题目
1.1 药品行业场景里有天然的业务复杂度
很多人选“图书管理系统”“学生管理系统”做毕设,因为这些题目简单、资料多。但如果你想在答辩时有点东西可讲,药品管理系统比它们更合适。原因在于,药品管理不是一个单纯的增删改查,它涉及批次、有效期、库存上下限、近效期预警、供应商、采购审批这些真实场景。
比如同样是“删除一条记录”,图书删除就是真的删掉,但药品档案删除必须做逻辑删除,因为历史入库单、出库单可能还引用着这个药品编码。再比如库存扣减,图书管理系统扣一个复本数就行,药品系统要拆批次,出库时必须指定先出哪个批次的货,这就是医药行业常说的“先进先出”规则。把这些业务规则讲清楚,论文的需求分析和系统设计部分立刻就有深度了。
还有一点很现实:药品管理系统做起来不像“图书借阅”那么通用,但也正因为如此,你可以在论文里明确写出系统目标用户是药店/药房/医院药库,而不是一个模糊的“用户”。目标用户越明确,需求分析越好写,答辩老师也更难挑出逻辑漏洞。
1.2 前后端分离对这个题目意味着什么
Spring Boot负责接口和业务逻辑,Vue负责页面交互,这套组合在今天已经是前后端分离的标配。对药品管理系统来说,前后端分离能带来两个实际好处。
一是权限控制可以做得更细。药品管理系统天然有角色区别:系统管理员、药师、采购员、库管员。前端根据登录后返回的角色渲染不同菜单,后端在每一个需要权限的接口上做校验,两层结合起来,比传统单体JSP项目清晰得多。论文里可以画一张角色权限矩阵表,比写一大堆文字更直观。
二是开发进度可以并行。如果你有同学一起做,一个人负责Spring Boot接口,一个人负责Vue页面,只要提前约定好接口文档,两边可以同时推进。即便你是单人开发,前后端分离也方便你单独调试前端样式,不会因为改个按钮颜色就要把整个项目重启一遍。
2. 技术选型:别追新版本,要追能写完系统的版本
2.1 后端这套组合更稳
技术选型是论文里非常容易翻车的部分。很多同学喜欢在“开发环境”里写个“Spring Boot 3.2”,结果发现跟MyBatis Plus、某些数据库驱动不兼容,折腾好几天才把项目跑起来。
我建议你选Spring Boot 2.7.x + MyBatis Plus + MySQL 8 + JDK 8或11,这套搭配经过了大量项目验证,网上资料也最全。如果你动手能力比较强,想用 Spring Boot 3,也要确保 MyBatis Plus 版本兼容,Spring Boot 3 要求 JDK 17 起步,很多老版本依赖不能直接搬。
为什么不用Spring Cloud微服务?药品管理系统体量不大,单机部署完全够用,微服务只会让论文答辩时被追问“你的服务拆分依据是什么”,答不好反而扣分。
其次,认证授权这块建议用JWT + 拦截器,不要一上来就引入 Spring Security。Spring Security 功能强大,但配置复杂,学习成本高,对毕设来说属于“过度设计”。用拦截器拦截需要登录的请求,在拦截器里解析JWT,把用户信息放入ThreadLocal,代码简单、逻辑容易解释清楚,论文也好画图。
2.2 前端用Vue 3还是Vue 2
如果现在才开始做,我建议直接Vue 3 + Vite + Element Plus。Element Plus 是 Element UI 的 Vue 3 版本,组件比较全,表格、弹窗、表单校验都有现成的,做管理后台非常顺手。
但如果你电脑上已经装好了 Vue 2 + Element UI 的环境,或者网上找了一个 Vue 2 的脚手架,为了图省事继续用 Vue 2 也没问题。论文里写清楚用的是哪个版本即可。需要提醒的是,Vue 2 官方已经停止维护,答辩时老师可能会提一句“为什么不使用新版本”,你要提前想好说辞,比如“Vue 3 生态在部分组件上仍有兼容成本,选择 Vue 2 能减少团队学习成本”,这个说法本身是合理的。
2.3 环境配置的关键细节
前后端分离项目,最怕环境不一致。我列一下我项目里的标准配置。
后端application.yml里最常被忽略的是时间格式和时区:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/drug_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl前端开发环境代理,在vite.config.js或者 Vue CLI 的vue.config.js里配置,主要解决/api前缀的跨域问题:
module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这两个配置在论文的“系统实现环境”里会直接出现,别等到答辩前才补上。
3. 核心功能模块设计:先画出业务流程图再写代码
3.1 用户登录、角色管理与JWT权限拦截
药品管理系统的第一道门是登录。它跟普通系统不同的是,用户登录后只能看到自己角色对应的菜单,所以后端登录接口需要返回用户信息、角色列表和菜单列表。
我的做法是把JWT放到请求头Authorization字段里,格式为Bearer token。后端写一个拦截器,拦截所有除了登录接口之外的、以/api/**开头的请求。拦截器里做的事有三件:验证token是否存在、解析token是否过期、从token里取出用户ID并查询用户状态是否正常。
核心代码大致是这样:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或token已过期"); } String token = authHeader.substring(7); Claims claims = JwtUtil.parseToken(token); // 把用户id存入request上下文,后续controller可通过ThreadLocal获取 Long userId = claims.get("userId", Long.class); UserContext.setUserId(userId); return true; } }前端配合使用Axios拦截器,在响应状态码为401时自动跳转登录页:
axios.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )这一段在论文里正文加代码总共写两页纸就很充实了,答辩也可以直接演示“登录过期后访问任意接口返回401”的效果。
3.2 药品档案与批次管理
药品档案是整个系统的主数据。这张表设计得好不好,直接影响后面所有模块的开发量。我见过不少人把药品表设计成一个大宽表,把所有字段都塞进去,结果做批次管理时发现根本没法拆。
正确做法是拆成药品信息表和药品批次表两张表。
药品信息表里存的是不变的信息:药品编码、通用名称、商品名、剂型、规格、生产厂家、批准文号、分类、单位、零售价。批次表里存的是可变的信息:批次号、生产日期、有效期、入库数量、剩余数量、供应商ID。
为什么要这样拆?因为同一个药品可能有多批进货,每批价格、有效期都不一样,出库存时必须按批次先进先出。如果只建一张表,同一个药名只能存一批数据,完全模拟不了真实业务。
新增药品时,页面会把基础信息填好,然后在页签或子表格里维护批次信息。后端接收一个DrugDTO,里面包含Drug实体和List<DrugBatch>批次列表,用一个@Transactional事务同时保存。药品编码唯一性校验不能只在前端做,后端也要查一次库,防止并发插入时产生重复编码。
3.3 入库、出库与库存流水
药品入库和出库不能只修改库存数量,还必须记录流水。原因很简单:将来出了问题可以追溯,比如某批次药品被投诉,要查这个批次的进货来源和卖给了谁(至少能查出来单记录,全流程追溯系统涉及GSP/GMP要求,这里简化)。
入库单设计为stock_in表和stock_in_item表,出库单同理。主表保存单号、操作员、供应商、入库时间、备注,明细表保存药品ID、批次ID、数量、单价、金额。点击“确认入库”后,后端在一个事务里完成三件事:
- 新增入库单主表和明细表数据;
- 更新对应批次的剩余数量;
- 插入库存变动流水记录。
出库逻辑更复杂一点。如果一张出库单包含同一个药品的不同批次,需要在页面上按批次拆分行项目,每行指定出库批次号。后端处理时逐行检查该批次剩余数量是否足够,不够就抛出异常并回滚整个事务。这个“要么全成功,要么全失败”的设计,是论文里数据库事务章节最好的例子。
3.4 库存预警与近效期预警
药品系统不能只会增删改查,还得有提醒功能。预警有两种:存量预警和效期预警。
存量预警的逻辑是:药品信息表里配置“库存上限”和“库存下限”。每次入库、出库之后,重新统计该药品的所有批次剩余数量之和,如果低于下限,就把药品状态标记为“库存不足”,需要补货。前端首页做一个统计卡片,显示库存不足的商品数量,点击可跳转到预警列表。
效期预警更关键。药品批次表里有expire_date,系统每天定时任务扫描,找出有效期在90天内的批次,标记为“近效期”;有效期已过而剩余数量大于0的,标记为“过期”。扫描用Spring Boot自带的@Scheduled定时任务实现,在启动类加一行@EnableScheduling就行。
定时任务虽然简单,但论文里要写清楚为什么需要用定时任务而不是在查询时实时算。因为实时计算虽然也能做,但每次打开首页都扫描全表效率很低,而且“过期”状态需要落库,方便后续生成报损单。
4. 数据库设计:ER图与关键表结构
4.1 核心表结构规划
我直接把建表的核心字段列出来,你可以照着建,不需要从头想。
用户表sys_user:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), role_id BIGINT, status TINYINT DEFAULT 1, create_time DATETIME, update_time DATETIME );角色表sys_role:
CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(50) NOT NULL, role_name VARCHAR(50) NOT NULL, create_time DATETIME );药品信息表drug_info:
CREATE TABLE drug_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(50) NOT NULL UNIQUE, drug_name VARCHAR(100) NOT NULL, generic_name VARCHAR(100), dosage_form VARCHAR(50), specification VARCHAR(100), manufacturer VARCHAR(200), approval_number VARCHAR(100), category VARCHAR(50), unit VARCHAR(20), retail_price DECIMAL(10,2), stock_lower_limit INT DEFAULT 0, stock_upper_limit INT DEFAULT 999, status TINYINT DEFAULT 1, is_deleted TINYINT DEFAULT 0, create_time DATETIME, update_time DATETIME );药品批次表drug_batch:
CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(50) NOT NULL, supplier_id BIGINT, product_date DATE, expire_date DATE, purchase_price DECIMAL(10,2), purchase_count INT, remaining_count INT, status VARCHAR(20) DEFAULT '正常', create_time DATETIME );库存流水表stock_record:
CREATE TABLE stock_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_id BIGINT, change_type VARCHAR(20), -- in/out change_count INT, after_count INT, related_bill_no VARCHAR(50), operator_id BIGINT, create_time DATETIME );供应商表supplier、采购单表purchase_order、采购单明细表purchase_order_item也按同样的思路建,这里不把所有SQL都贴出来,不然文章太长了。
4.2 外键与索引怎么设计
很多同学的数据库表喜欢加外键约束,但真实开发里反而尽量少用外键。理由很简单:系统里存在大量快速写入场景,比如入单出库,外键检查会拖慢性能。表之间的关联靠业务代码保证,数据库只建普通索引即可。
drug_info.drug_code必须建唯一索引,药品名称字段建普通索引用于模糊查询。drug_batch.expire_date建议单独建索引,因为定时任务要按这个字段扫描。stock_record.drug_id和batch_id建联合索引,方便查某个药品的所有流水。
4.3 数据库事务与并发锁
药品库存是“怕超卖”的数据。比如两个窗口同时出库,都查到批次剩余数量为10,都做了减库存操作,最后一个批次数量变成负数,这就不对了。
解决办法一般有两种。第一种是给批次表加乐观锁版本号字段:
UPDATE drug_batch SET remaining_count = remaining_count - #{count}, version = version + 1 WHERE id = #{id} AND version = #{version}第二种是在读批次数量时加悲观锁SELECT ... FOR UPDATE,把这一行锁住,其他事务必须等待。在毕设场景下我推荐悲观锁,逻辑更好理解,代码也更直观,直接放在事务方法里就行:
@Transactional public void outStock(StockOutDTO dto) { DrugBatch batch = batchMapper.selectByIdForUpdate(dto.getBatchId()); if (batch.getRemainingCount() < dto.getCount()) { throw new BusinessException("库存不足"); } // 更新剩余数量 }关于事务,答辩时要能说出“默认隔离级别是REPEATABLE_READ,为什么不会脏读”这类问题,自己提前把MySQL的事务知识复习一遍。
5. 开发过程中的坑:这些内容绝大多数教程不会告诉你
5.1 跨域、代理与静态资源
前后端分离项目第一次联调,最常遇到的就是跨域。浏览器拦截的是非简单请求,也就是带Content-Type: application/json的POST请求,如果你不配置跨域,前端会看到CORS错误。
后端配置CORS最简单的方式是写一个配置类:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("http://localhost:3000"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }如果你用了Vite代理,前端请求/api会转发到后端,理论上后端的CORS配置不是必须的,但建议都加上,方便以后对接手机端。
还有一个坑是后端把图片文件放到本地磁盘后,前端访问不到。解决办法是做静态资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + filePath + "/"); } }5.2 MyBatis Plus分页查询的坑
MyBatis Plus的分页插件需要手动注册,不注册的话分页不生效,它会在内存里全表查出来再截取,数据量一大就卡。
分页插件配置:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后使用Page<T>作为查询参数:
Page<DrugVO> page = new Page<>(current, size); LambdaQueryWrapper<Drug> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(keyword), Drug::getDrugName, keyword); wrapper.orderByDesc(Drug::getCreateTime); Page<DrugVO> result = drugMapper.selectDrugPage(page, wrapper);注意分页查询要是带多表关联,需要写自定义SQL。MyBatis Plus内置的page(size, current)参数顺序容易搞反,不同版本的构造器第一参数都不一样,踩过一次后就记住了,我建议你用new Page<>(current, size),current是页码,size是每页条数。
5.3 前端路由刷新后404
这个问题在部署阶段很容易碰到。前端打包后的文件放进Spring Boot的static目录,单页应用刷新/drug/list,后端找不到对应资源返回404。
解决办法是在后端加一个重定向,把未匹配的非/api路由重定向到index.html:
@Controller public class PageController { @RequestMapping(value = "/{path:[^\\.]*}") public String forward() { return "forward:/index.html"; } }但这只适合简单场景。更常见的做法是单独部署前端,用Nginx配置try_files $uri $uri/ /index.html。我建议论文里写Nginx方式,显得更工程化。
5.4 定时任务在测试环境反复触发
最后说一个隐藏坑。@Scheduled默认是单线程执行,如果你给某个定时任务设置了fixedDelay = 60000,而任务本身执行需要15秒,下一次执行会等任务结束后再等60秒。但如果你在本地开了多个服务实例,同一个任务会跑多次。
毕设场景一般单机部署,不会有这个问题,但为了论文严谨,可以提一句“资源允许时可通过分布式锁保证定时任务单实例执行”,这就够了,不用真把Redis配置写进去,免得增加无谓的复杂度。
6. 论文写作:把系统项目转化成能过审的论文
6.1 论文章节怎么排
很多毕设论文模板要求五章:绪论、相关技术、需求分析、系统设计、系统实现。后续还有系统测试和总结。
不需要把代码全文贴在论文里。我的建议是:表格和图表为主,关键代码片段为辅。核心是让老师能通过论文看清楚你解决了什么问题、怎么设计的、关键功能如何实现的。
章节结构大致是:
- 绪论:研究背景与意义,国内外现状,主要工作。
- 相关技术介绍:Spring Boot、Vue、MyBatis Plus、JWT。不要抄定义,要结合系统讲为什么选它。
- 系统需求分析:业务需求分析、功能需求分析、非功能需求分析、用例图、用例描述表。
- 系统设计:系统架构图、功能模块设计、数据库设计(ER图+表结构)、接口设计。
- 系统实现:每个核心功能的页面截图+实现说明+关键代码。
- 系统测试:测试环境、功能测试用例表、测试结果、部分性能测试。
- 总结与展望。
6.2 需求分析怎么写才不是空话
最忌讳写“本系统实现了用户登录、药品管理”这样一句带过。正确写法是画用例图加用例描述表,把每一步的参与者、前置条件、主流程、异常流程写清楚。
比如“药品入库”用例描述表可以写:
- 参与者:库管员
- 前置条件:已登录并具有入库权限;已维护供应商信息
- 主流程:选择药品、填写批次信息和数量、提交入库单、系统增加库存、记录流水
- 异常流程:药品编码不存在、有效期格式错误、批次数量超过单次上限、供应商未维护
每一张用例描述表都是一页纸工作量,论文写5个核心用例,页数自然够了,而且没有一句废话。
6.3 系统实现章节的常见误区
系统实现不要写成“我调用了xxxMapper.insert()”。应该按功能模块拆开,每个模块先写功能描述,再放页面截图,然后用文字说明核心逻辑流程,最后放不超过15行的关键代码。
比如药品预警模块,关键代码可以放定时任务扫描近效期批次的那段:
@Component public class ExpireDateTask { @Scheduled(cron = "0 0 2 * * ?") public void checkExpireDate() { LocalDate deadLine = LocalDate.now().plusDays(90); List<DrugBatch> list = drugBatchMapper.selectList( new LambdaQueryWrapper<DrugBatch>() .eq(DrugBatch::getStatus, "正常") .le(DrugBatch::getExpireDate, deadLine) .gt(DrugBatch::getRemainingCount, 0) ); for (DrugBatch batch : list) { batch.setStatus("近效期"); drugBatchMapper.updateById(batch); } } }这段代码短小又能说明业务含义,比贴一堆CRUD强多了。
6.4 测试报告要量化
测试章节不止是“系统能跑”。要写功能测试用例表,每个用例包含编号、测试名称、前置条件、操作步骤、预期结果、实际结果,结论通过或失败。建议至少写20个核心功能用例。
再补一个简单的性能测试,比如用JMeter对登录接口做并发100次的压测,记录平均响应时间、错误率。药品管理系统一般都能压到几百毫秒,写出来很加分。如果没时间做压测,也可以写“系统在高并发条件下未做完整验证,后续将优化”,但论文分数可能会打折扣。
6.5 答辩高频问题与参考答案
答辩时老师最喜欢问的问题集中在事务、权限、架构设计这三个方面,我整理几个常见问题:
为什么要拆分药品表和批次表?
答:同一药品可能有不同批次的进货,价格、有效期不一致,拆表才能支持先进先出和效期管理。出库时如何防止库存超卖?
答:在事务中先使用SELECT FOR UPDATE锁行,再判断剩余数量,解锁前完成更新,或者使用乐观锁版本号。为什么用JWT而不用Session?
答:前后端分离架构下前端可能部署在不同域名,使用JWT无状态、可跨域,服务器不需要保存会话记录,扩展性好。定时期预警的扫描频率怎么定?
答:每天凌晨2点执行,根据业务优先级选择不同阈值,90天、30天、过期分别标记,减轻系统负担。如果系统数据量变大,怎么优化?
答:数据库层面给常用字段建索引、重构大表,服务层引入缓存如Redis,前端列表做分页和懒加载,必要时再考虑读写分离。
把这几个问题提前准备成答案,答辩就不虚了。
整篇文章写到这里,我把这个项目从技术选型到论文成稿的核心链路都已经讲透了。个人经验来看,毕设最忌讳的就是“代码写完了论文才开始”,正确顺序应该是:先定表结构,再写论文的需求分析和数据库设计,然后边开发边补系统实现章节,最后统一调整。药品管理系统这个题目,业务复杂度足够支撑一篇有分量的论文,技术栈又是当前企业里最常见的前后端分离组合,你用好了这套方案,毕业答辩的底子就稳了。