SSM+Vue药品管理系统开发实战:从架构设计到数据库实现

📅 发布时间:2026/9/11 23:22:19
SSM+Vue药品管理系统开发实战:从架构设计到数据库实现
简介基于SSMVue的药品管理系统毕业设计资料包面向需要完成Java课程设计与毕业设计的本科、专科学生以及希望快速搭建B/S架构管理系统的开发者。系统围绕药品全流程管理设置管理员、员工、用户三类角色覆盖药品信息维护、入库出库、在线咨询、订单管理、留言板等典型业务模块可帮助理解SSM框架整合与前后端分离开发思路。资源共890个文件压缩包约16.14MB。其中151个java文件对应后端业务逻辑与MyBatis映射54个vue文件配合163个svg、156个js实现前端界面另有sql脚本与yml配置便于初始化数据库和运行环境还包含启动批处理文件、说明文档等目录结构清晰方便定位与二次开发。资源目前已有1125人学习下载。除完整源码外附带的说明文档和数据库脚本可辅助快速部署并梳理权限控制、数据表关系等设计要点对毕业设计答辩和项目实战都有参考价值。1. 药品管理系统毕业设计为什么都选SSMVue组合药品管理系统是Java毕业设计里出现频率极高的选题几乎每届都有学生在做但真正能拿到高分的版本并不取决于功能页面的数量而取决于后端框架是否规范、数据库设计是否完整、前端交互是否真的走通了数据流。SSM是Spring、SpringMVC、MyBatis三者的合称Spring管理对象生命周期和事务SpringMVC处理请求路由和参数绑定MyBatis负责SQL映射Vue则承担前端页面渲染和数据双向绑定两者组合正好构成当前中小型管理系统的主流前后端分离架构。这套技术栈之所以被大量毕业设计采用核心原因是它同时覆盖了三层架构理解和前后端分离实战两条主线——论文里能写框架原理答辩时能演示真实接口调用。适合的人群是学过Java Web基础、想通过一个完整项目把Spring、MyBatis、Vue串起来的学生以及需要快速搭建内部管理系统的初级工程师。2. SSM三层架构与Vue请求链路从Controller到Mapper的完整路径2.1 RESTful接口设计与Controller层的最小写法SSM项目的入口在Controller层药品管理系统的所有前端请求都会先落在这里。以药品新增为例前后端约定POST /api/drug/add参数以JSON格式提交。Controller层要做的事只有三件接收参数、调用Service、返回统一结果业务逻辑不应出现在这一层。RestController RequestMapping(/api/drug) public class DrugController { Autowired private DrugService drugService; PostMapping(/add) public Result add(RequestBody Drug drug) { Integer rows drugService.addDrug(drug); return rows 0 ? Result.success() : Result.error(新增失败); } GetMapping(/list) public Result list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, DrugQuery query) { PageResultDrug page drugService.pageQuery(pageNum, pageSize, query); return Result.success(page); } }这段代码里有几个值得注意的设计细节。RestController是SpringMVC里Controller和ResponseBody的合体省掉了每个方法都单独写ResponseBody的重复劳动。RequestBody用于把前端传过来的JSON反序列化成Drug对象这要求前端axios请求的Content-Type必须是application/json否则Spring无法完成自动转换。RequestParam(defaultValue 1)给分页参数设置了默认值前端漏传页码时不会直接报空指针。2.1.1 Result响应体的状态码约定public class Result { private Integer code; private String message; private Object data; public static Result success() { return new Result(200, success, null); } public static Result success(Object data) { return new Result(200, success, data); } public static Result error(String msg) { return new Result(500, msg, null); } }统一响应体最直接的好处是前端axios拦截器可以只认code这一个字段每个页面不需要各自写一遍错误处理。code200表示业务成功code500表示业务失败code401表示未登录前端拦截器对401做统一跳转登录页处理。毕业设计答辩时如果能把为什么用code而不是HTTP状态码表示业务结果讲清楚评审通常会认可——HTTP 200只代表请求到达服务器并返回了内容不代表业务执行成功业务层校验失败时依旧要返回HTTP 200只是Result里的code变成500。没有这一层封装前端就得分辨HTTP状态码和业务失败两种场景逻辑会变得很难维护。2.2 Service层的事务边界与药品库存的业务校验Service层是业务逻辑的所在地药品管理系统里最容易出错的逻辑集中在库存变化上。新增药品时同时插入药品表和入库流水表、修改药品时校验分类是否存在、删除药品时判断是否还有关联记录这些操作所涉及的多次数据库写入必须落在同一个事务里。Spring的声明式事务用Transactional注解实现但很多初学者会把注解加在Controller方法上——这是错误的用法。正确做法是加在Service实现类或ServiceImpl方法上因为Controller处于SpringMVC层它的职责是参数接收和响应封装事务边界必须由业务层来控制。Service Transactional public class DrugServiceImpl implements DrugService { Autowired private DrugMapper drugMapper; Autowired private StockRecordMapper stockRecordMapper; Override public Integer addDrug(Drug drug) { if (drug.getStock() ! null drug.getStock() 0) { throw new BusinessException(药品库存不能为负数); } int rows drugMapper.insert(drug); if (drug.getStock() ! null drug.getStock() 0) { StockRecord record new StockRecord(); record.setDrugId(drug.getId()); record.setChangeType(1); // 1表示入库2表示出库 record.setChangeCount(drug.getStock()); record.setCreateTime(new Date()); stockRecordMapper.insert(record); } return rows; } }Transactional放在类级别意味着该类的所有public方法都默认开启事务放在方法级别则只对单个方法生效方法级别的优先级更高。库存业务校验里有一个关键设计changeType字段表示出入库方向changeCount存的是本次变更数量而不是变更后的总量。药品管理系统的库存不能由前端直接传一个新数字覆盖应该通过出入库记录逐笔累计推导出来前端每次调接口只传本次变了多少后端负责累加。这样做能保证库存变更全程可追溯答辩时顺着每条库存流水都有对应操作记录这条线展开比笼统地说系统有库存管理功能有说服力得多。2.3 Vue开发服务器的代理转发与axios拦截器前后端分离开发中前端默认跑在8080端口后端Java项目跑在9090或8081端口跨域问题必然出现。生产环境需要在SpringMVC配置里写CORS跨域过滤器开发环境则建议用Vue的代理转发更简单。在vue.config.js里配置// vue.config.js module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:9090, changeOrigin: true, pathRewrite: { ^/api: } } } } }changeOrigin: true会让后端收到的请求头Host变成目标地址避免部分容器对Host做校验时报错。pathRewrite是否去掉/api前缀取决于后端Controller的RequestMapping路径。如果后端映射是/api/drug/addpathRewrite就不应该去掉前缀如果后端映射是/drug/add就需要用pathRewrite: {^/api: }把前缀剥掉。这是前后端联调时最常见的404根源之一排查思路是先看浏览器Network面板的请求路径再对照后端网关或Controller映射。axios拦截器负责统一处理token和业务错误码// src/utils/http.js axios.interceptors.response.use( response { const res response.data if (res.code 401) { router.push(/login) return Promise.reject(未登录) } if (res.code ! 200) { Message.error(res.message) return Promise.reject(res.message) } return res }, error { Message.error(网络异常) return Promise.reject(error) } )拦截器捕获的是Result里定义的业务错误码而不是HTTP状态码。两者要区分开HTTP 200只代表请求到达服务器并返回了响应体不代表业务成功业务校验失败时后端依然返回HTTP 200只是Result里的code变成500。如果axios拦截器只看HTTP状态码前端拿到code500的响应后还会继续走成功分支页面上显示操作成功但数据库里没有写入记录这种静默失败在联调阶段很难排查。3. 药品数据库表结构与MyBatis动态SQL三张核心表一次说清3.1 药品表、分类表与库存表的字段设计药品管理系统的数据库设计是整个项目的地基答辩中数据库表怎么设计的几乎必问。最少需要五张表用户表、药品表、药品分类表、供应商表、出入库流水表。其中药品主表是核心分类和供应商是关联表出入库流水表记录每一次库存变化每张表的职责边界必须清楚。表名核心字段说明drugid, name, specification, category_id, supplier_id, stock, safe_stock, expiration_date, status药品主表保存当前库存快照drug_categoryid, name, parent_id分类支持两级parent_id为0表示一级分类supplierid, name, contact_person, phone, address供应商信息stock_recordid, drug_id, change_type, change_count, user_id, create_timechange_type取值1入库2出库userid, username, password, rolerole区分管理员与普通操作员药品主表里的safe_stock字段是安全库存阈值当前库存低于它时应触发补货预警expiration_date是有效期按日期比较可判断是否临期。这两个字段是药品管理系统区别于通用CRUD系统的标志性设计论文里的系统特色应围绕它们展开。stock字段保存当前库存快照与stock_record流水表构成当前值历史流水的双层数据模式列表查询直接取快照值不需要连表做SUM聚合要追溯历史变化时再查流水表。如果省略快照字段每次列表查询都要实时关联流水表做分组求和数据量大时会显著拖慢页面响应。3.2 MyBatis动态SQL拼接多条件筛选的写法药品列表页常见的筛选条件组合有药品名称模糊查询、分类下拉、状态下拉、有效期范围。SSM项目里这些条件不能写成多个固定SQL硬编码而要用MyBatis的where和if标签做动态拼接一个Mapper方法覆盖所有组合场景。select idselectByCondition resultTypecom.example.entity.Drug SELECT d.id, d.name, d.specification, d.stock, d.safe_stock, d.expiration_date, c.name AS category_name, s.name AS supplier_name FROM drug d LEFT JOIN drug_category c ON d.category_id c.id LEFT JOIN supplier s ON d.supplier_id s.id where if testname ! null and name ! AND d.name LIKE CONCAT(%, #{name}, %) /if if testcategoryId ! null AND d.category_id #{categoryId} /if if teststatus ! null AND d.status #{status} /if if testexpireStart ! null AND d.expiration_date gt; #{expireStart} /if if testexpireEnd ! null AND d.expiration_date lt; #{expireEnd} /if /where ORDER BY d.create_time DESC /selectwhere标签会自动去掉第一个条件前的AND关键字所以每个if里写AND不会导致SQL出错。CONCAT(%, #{name}, %)是MySQL的字符串拼接写法如果数据库换成Oracle需要改成% || #{name} || %SQL方言差异在答辩中主动提出来反而能加分。日期比较用gt;而不是是因为XML中需要实体转义lt;表示这在XML语法里是硬性要求写错会导致配置文件解析报错或查询条件失效。3.2.1 LIKE查询的传参陷阱如果直接在Java层拼好%关键字%再传入XML写成LIKE #{name}MyBatis虽然能正常工作但参数不再以预编译占位符形式进入SQL存在SQL注入风险。正确做法是把%拼接放在XML的CONCAT函数内让#{name}只作为参数占位符参与预编译。涉及到药名模糊查询的性能问题数据量在万级以下时给drug.name加普通索引即可满足大多数场景毕业设计的数据量不需要引入全文索引那是完全不同量级的复杂度。3.3 用SQL实现库存预警与临期药品的查询库存预警的SQL核心是当前库存与安全库存比较再结合状态字段控制是否只查启用中的药品SELECT d.name, d.specification, d.stock, d.safe_stock FROM drug d WHERE d.stock d.safe_stock AND d.status 1 ORDER BY (d.safe_stock - d.stock) DESC LIMIT 10这里LIMIT 10限制只返回缺货最严重的十条记录前端预警页用一个警示色的表格展示。ORDER BY (d.safe_stock - d.stock) DESC按缺口从大到小排序补货优先级一目了然。如果预警数量过多后端还可以按分类分组统计不同类别的缺货总数给采购决策提供另一层视角。临期药品用日期运算来判断MySQL的DATE_SUB和CURDATE组合可以在一次查询里解决SELECT d.name, d.expiration_date, DATEDIFF(d.expiration_date, CURDATE()) AS days_left FROM drug d WHERE d.status 1 AND d.expiration_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY) ORDER BY d.expiration_date ASCDATEDIFF返回两个日期的相差天数INTERVAL 30 DAY定义预警窗口。关键是预警天数不能写死在SQL里应该做成常量放到application.properties配置文件中Service层读取后作为参数传入Mapper。这样调整预警策略时只改配置不用改代码答辩时能体现配置化设计的思想。两条SQL都放在Mapper.xml中前端页面只负责接收查询结果渲染预警计算逻辑不进入前端代码这是前后端分离里数据边界划分的基本规范。4. 药品CRUD全链路代码结构与参数传递细节4.1 前端新增药品页Vue表单校验与数据组装前端新增药品页面是药品管理系统中最典型的表单提交场景。Vue里用data定义表单数据对象rules定义校验规则提交时先做前端校验再调接口避免无效请求占用后端资源。以下是最小可运行的表单提交逻辑// views/drug/DrugForm.vue 核心逻辑 data() { return { form: { name: , specification: , categoryId: null, supplierId: null, price: 0, stock: null, safeStock: null, expirationDate: }, rules: { name: [{ required: true, message: 药品名称必填, trigger: blur }], categoryId: [{ required: true, message: 请选择分类, trigger: change }], stock: [{ required: true, message: 初始库存必填, trigger: blur }] } } }, methods: { handleSubmit() { this.$refs.form.validate(valid { if (!valid) return axios.post(/api/drug/add, this.form).then(res { if (res.code 200) { this.$message.success(新增成功) this.$router.push(/drug/list) } }) }) } }前端校验的trigger字段值blur对应失焦校验change对应值变化即时校验下拉选择框用change输入框用blur两者混用会导致校验时机混乱。表单里stock字段对应Java实体类Drug里的stock属性前后端字段命名必须完全一致否则RequestBody反序列化时匹配不上的字段会被置为null。这种字段命名不一致导致的静默失败是SSMVue联调中最常见的问题——后端不报错前端也不报错只是数据库里某个字段始终是空值。排查方法是在Controller方法打断点查看接收到的Drug对象里哪些字段为null。4.2 后端新增药品的Service与Mapper联动前端提交的JSON到达Controller后传给Service层做业务处理最后落到MyBatis的Mapper完成数据库写入。Service层代码在第二章已展示这里补充Mapper层插入SQL的细节insert idinsert parameterTypecom.example.entity.Drug useGeneratedKeystrue keyPropertyid INSERT INTO drug (name, specification, category_id, supplier_id, price, stock, safe_stock, expiration_date, status, create_time) VALUES (#{name}, #{specification}, #{categoryId}, #{supplierId}, #{price}, #{stock}, #{safeStock}, #{expirationDate}, 1, NOW()) /insertuseGeneratedKeystrue和keyPropertyid必须配套使用作用是把数据库自增主键回写到Java对象的id属性上。这个设置直接关系到Service层里后续插入流水表时能否取到药品ID如果不用useGeneratedKeys插入药品后就得再查一次数据库获取ID两条SQL之间多出一个查询步骤代码更啰嗦且在高并发场景下可能读到错误数据。status字段写死为1表示启用前端不需要传入减少参数校验面。create_time用数据库函数NOW()生成避免依赖服务器时间和数据库时间不一致时产生偏差。4.3 PageHelper分页查询的参数封装与SQL自动拼接药品列表页几乎必然要做分页SSM项目里最常用的方案是PageHelper分页插件原理是在MyBatis执行SQL前拦截并自动拼接LIMIT ?。先引入分页插件依赖并完成配置然后Service层调用如下public PageResultDrug pageQuery(Integer pageNum, Integer pageSize, DrugQuery query) { PageHelper.startPage(pageNum, pageSize); ListDrug list drugMapper.selectByCondition(query); PageInfoDrug pageInfo new PageInfo(list); return new PageResult(pageInfo.getTotal(), pageInfo.getList()); }这里有一个PageHelper最核心的使用规范PageHelper.startPage基于ThreadLocal实现只对紧接着的下一条查询语句生效。因此startPage和selectByCondition之间不能插入任何其他SQL操作否则分页条件会被错误地消费掉导致分页失效。常见错误是Service层在启动分页前先查了一次分类列表分页效果就消失了。如果selectByCondition这个动态SQL同时被导出功能复用导出时不要再走这个方法拿分页结果直接复制SQL改为不分页查询即可。PageInfo对象封装了total、pageNum、pages等分页元数据前端分页组件直接绑定这些字段。PageResult是自定义的返回对象而非直接返回PageInfo原因是接口层不应暴露MyBatis分页插件的类型保持返回结构稳定将来替换分页方案时前端代码无需改动。5. 药品有效期批量更新与Excel导出的分段处理验证技巧药品管理系统功能做到后期批量更新有效期和导出药品台账是高频需求。最常见的错误写法是前端循环调用单条更新接口每次请求都开启一个独立事务数据量大时HTTP请求数爆炸、数据库连接反复建立整体耗时长且中途出错无法回滚。更合理的做法是把批量更新的逻辑收敛到后端一次事务里完成用列表参数一次请求传全部数据。PostMapping(/batchUpdateExpire) public Result batchUpdateExpire(RequestBody ListDrug drugList) { if (drugList null || drugList.size() 0) { return Result.error(批量数据为空); } if (drugList.size() 200) { return Result.error(单次批量数据不能超过200条); } return Result.success(drugService.batchUpdateExpire(drugList)); }这里的200条上限是一个经过考量的边界值MySQL默认max_allowed_packet为64MB200条UPDATE请求体大小远低于该限制同时200条数据的行级锁竞争在30秒事务超时时间内通常能完成。前端表格组件在复选框模式下超过200行本身就会卡顿数量上限需要前后端双端校验前端限制勾选行数后端校验入参长度形成双重防护。批量更新在JDBC层面要提速关键在于MySQL驱动的rewriteBatchedStatements参数。直接在链接URL上追加rewriteBatchedStatementstrue然后通过MyBatis的batch模式批量执行UPDATE语句驱动会把多条独立SQL合并成一条多VALUES的批量语句发送减少网络往返次数spring.datasource.urljdbc:mysql://localhost:3306/drug_management?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrue开启该参数前后的差异非常直观日志中批量更新从几十条独立SQL变成几条合并的大SQL执行耗时从几十秒压缩到两三秒。这个参数在论文里值得单独写一节属于典型的配置一行、性能翻倍调优点。Excel导出同理用EasyExcel或POI逐批读取数据库记录写入文件每写满1000行主动清除集合引用避免把全部数据同时加载进内存导致OOM。验证这两段优化是否正确的具体方法批量更新后观察数据库日志确认UPDATE语句条数大幅减少导出时用JVisualVM监控内存曲线确认内存占用平稳不呈阶梯式上涨。这两个观察点比单纯页面能跑更有说服力作为答辩时的验收标准也立得住。本文还有配套的精品资源点击获取