
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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 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实体和ListDrugBatch批次列表用一个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_userCREATE 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_roleCREATE 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_infoCREATE 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_batchCREATE 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_recordCREATE 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; } }然后使用PageT作为查询参数PageDrugVO page new Page(current, size); LambdaQueryWrapperDrug wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(keyword), Drug::getDrugName, keyword); wrapper.orderByDesc(Drug::getCreateTime); PageDrugVO 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.htmlController 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); ListDrugBatch list drugBatchMapper.selectList( new LambdaQueryWrapperDrugBatch() .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前端列表做分页和懒加载必要时再考虑读写分离。把这几个问题提前准备成答案答辩就不虚了。整篇文章写到这里我把这个项目从技术选型到论文成稿的核心链路都已经讲透了。个人经验来看毕设最忌讳的就是“代码写完了论文才开始”正确顺序应该是先定表结构再写论文的需求分析和数据库设计然后边开发边补系统实现章节最后统一调整。药品管理系统这个题目业务复杂度足够支撑一篇有分量的论文技术栈又是当前企业里最常见的前后端分离组合你用好了这套方案毕业答辩的底子就稳了。