
我做了三年计算机毕业设计辅导接过的课题里药店管理系统可能是出现频率最高的一类但大多数同学交上来的东西都停留在“能跑就行”的阶段。今年带的一个学生用Java从头搭了一套药店销售与库存管理系统从选题、数据库设计到答辩演示我把整个过程完整记录下来。这篇文章不谈虚的框架对比直接拆解这套系统从零到一的完整实现路径包括表结构怎么设计、库存报警阈值怎么算、并发下单怎么处理以及答辩时老师最常追问的几个坑。1. 内容整体设计与思路拆解1.1 为什么药店管理系统适合做Java毕设药店销售管理系统这个题目能成为经典毕设选题不是因为新颖而是因为它恰好覆盖了Java Web开发的核心知识点又不至于复杂到学生做不出来。从业务角度拆解一个药店的核心流程无非是三件事药品入库、前台售药、库存盘点。这三件事对应到技术层面就变成了增删改查、事务控制、权限管理、报表统计这几个Java开发的基础能力。更重要的是药店业务有一个其他管理系统不太具备的特点——库存和销售是强耦合的。每卖出一盒药库存必须同步扣减这就在业务层面天然引入了事务一致性的问题给了你在设计上展示技术深度的空间。我给学生定的技术选型很保守JDK 17 Spring Boot 2.7 MyBatis Plus MySQL 8.0前端用Vue 3 Element Plus。没上微服务、没上Redis集群、没上消息队列。原因很简单这是一个单体应用就能完全覆盖的业务场景硬套分布式架构只会让论文答辩变得难以自圆其说。但我在设计里保留了三个能体现水平的细节库存操作全部走数据库行级锁、销售和扣库存放在同一个事务里、药品基础数据用逻辑删除代替物理删除。这三个细节是答辩时拉开档次的关键。1.2 需求分析阶段的三个关键决策第一个决策是用户角色划分。我把系统用户定位为三种角色收银员、库管员、管理员。收银员只能操作销售收银台和查看当日流水库管员负责采购入库、库存调整和近效期查询管理员拥有全部权限包括员工账号管理和销售报表导出。权限控制用Spring Security JWT实现后端接口做了基于角色的方法级鉴权。第二个决策是药品信息的建模粒度。药店里的药品有很多特殊属性处方药和非处方药要区分、拆零销售需要维护最小销售单位、不同批次的进货价可能不同。如果只建一张药品表和一张库存表做出来的系统会很单薄。最终我拆成了五张核心表药品信息表、库存批次表、供应商表、销售订单表、订单明细表。其中库存批次表是整个设计的核心每一批进货都是一个独立批次记录销售时按照效期先进先出的原则自动选择批次扣减库存。第三个决策是销售流程的完整性。很多学生做的系统卖药就是一减一加没有任何中间状态。我在这套系统里设计了完整的订单状态机待支付、已支付、已退款。订单产生时先锁定库存支付成功后正式扣减退款时释放库存。这个设计在答辩时被老师追问了很久但也恰恰是因为这个细节学生拿到了不错的分数。2. 核心细节解析与实操要点2.1 数据库表结构设计五张核心表的字段规划先上结论这套系统的数据库一共11张表其中5张是业务核心表。每一张表的字段设计都有明确的业务对应关系不搞花里胡哨的冗余设计。药品信息表是最基础的档案表字段包括药品编码唯一索引、通用名、商品名、规格、生产厂家、批准文号、药品类型处方/非处方、存储条件、零售价。这里有一个容易踩的坑零售价是含税价还是不含税价必须在表设计阶段定死。库存批次表是整个系统的关键。字段包括批次号、药品ID、供应商ID、进货价、批号、生产日期、有效期至、入库数量、剩余数量、效期状态。我在这个表上建了一个联合索引药品ID 有效期至目的就是支持按效期排序的先进先出扣减逻辑。销售订单表记录了每一笔销售的汇总信息字段包括订单号、收银员ID、订单状态、应收金额、实收金额、优惠金额、支付方式、下单时间、退款时间。订单明细表则是每笔订单的药品行项目记录药品ID、批次ID、销售单价、数量、小计金额。为什么要单独记录批次ID一方面是为了跟踪每一盒药是从哪个批次出的货另一方面是退款时能准确释放对应批次的库存。供应商表相对简单但有两个字段不能省供货资质编号和联系人电话。这是药店的合规要求药品进货必须能追溯到供应商资质。2.2 库存扣减的核心逻辑事务与行级锁库存扣减是这个系统里技术含量最高的一个点。我先说一个很多同学会犯的错误先查库存够不够不够就提示够就执行减库存的UPDATE语句。这种做法在单用户测试时没问题但在两个收银员同时卖同一盒药时就会出现超卖。正确的做法是把检查库存和扣减库存合并成一条原子SQLUPDATE stock_batch SET remaining_quantity remaining_quantity - #{quantity} WHERE batch_id #{batchId} AND remaining_quantity #{quantity}这条SQL的执行效果是如果剩余库存大于等于本次购买数量则扣减成功并返回受影响行数1否则返回0。应用层只需要判断返回值0就回滚事务提示库存不足。整个过程由数据库的行锁机制保证并发安全两个请求同时操作同一个批次时后到的那个会等待前一个事务提交后重新评估条件。服务层的完整逻辑是接收下单请求生成全局唯一订单号遍历购物车中的每一项药品按有效期升序找到所有剩余数量大于0的批次逐批次执行上面的原子扣减SQL直到凑够购买数量任意一个批次扣减失败抛出异常触发整体回滚全部扣减成功后插入订单主表和明细表提交事务这套逻辑放在一个被Transactional注解包裹的方法里任何一个环节异常数据库自动回滚到下单前的状态。用生活场景类比的话就像去超市结账时收银员先把所有商品扫码锁定等你付款完成后才真正减库存如果中途有人取消了订单这些商品会重新回到货架上。2.3 近效期预警与库存报警的实现思路药店的库存管理与普通商品管理最大的区别是有效期。药品过了保质期就是废品所以系统必须提供近效期预警功能。我的实现方案是每日定时任务扫描库存批次表把有效期在3个月内的批次标记为预警状态在库存盘点页面和首页Dashboard上同时展示。预警分三个等级90天以上为关注、30到90天为警告、30天内为紧急。每个等级在界面上用不同的颜色标签区分。库存报警则是针对库存数量的阈值控制。我在药品信息表上加了两个字段库存上限和库存下限。库存在下限以下时系统自动生成采购建议单库存超过上限时系统在入库环节弹出提示防止过量采购。下限值怎么定常规做法是结合近三个月的日均销量和采购周期计算补货点 日均销量 × 采购周期天数 × 1.5的安全系数。这个公式可以在系统设置里调整参数每个药品单独配置。2.4 前端页面的核心交互设计前端我用了Vue 3 Element Plus整个系统一共9个页面登录页、收银台、药品管理、库存管理、批次管理、供应商管理、订单管理、报表统计、系统设置。最有设计感的页面是收银台。收银台的设计思路是仿照真实收银系统左侧是药品搜索区支持药品编码扫码枪输入和模糊搜索两种方式中间是已选药品列表每一行显示药品名、单价、数量、小计数量支持加减按钮和直接输入右侧是结算区显示应收总额、实收金额、找零金额。结算完成后自动打印小票小票上包含订单号、药品明细、金额、收银员和下单时间。药品搜索这里有一个很实用的细节搜索时优先匹配药品编码的精确命中其次是通用名的模糊匹配。扫码枪的话它的输入方式本质上是快速键盘输入输入完会自动加回车键所以只要在搜索框上监听回车事件提交搜索即可不需要额外对接硬件SDK。3. 实操过程与核心环节实现3.1 开发环境准备与项目初始化如果你打算照着这篇文章做自己的毕设开发环境建议保持在以下版本区间JDK 17、Maven 3.8、MySQL 8.0、Node 16。JDK 17是当前Spring Boot 2.7和3.x都支持的长期支持版本MyBatis Plus对它的兼容性也很稳定。项目结构上我按Maven多模块的思路拆分了后端但考虑到毕设代码量不大实际用单模块也能讲清楚。推荐的后端包结构是controller接收HTTP请求做参数校验返回统一结果封装service业务逻辑层事务边界全部在这一层控制mapperMyBatis Plus的Mapper接口继承BaseMapper获得基础CRUD能力entity数据库实体类字段与表结构一一对应dto数据传输对象用于接口入参和出参的封装config配置类包括安全配置、跨域配置、MyBatis Plus分页插件配置common统一返回结果、异常处理、常量定义工程创建不用手动去Spring Initializr网站上点选了直接在你常用的IDE里新建Spring Boot工程依赖选上Spring Web、MySQL Driver、Lombok就行。MyBatis Plus和JWT的依赖版本需要仔细核对一下不同版本之间的兼容性坑不少我用的是MyBatis Plus 3.5.3和jjwt 0.9.1。3.2 登录认证与权限控制的完整搭建认证方案选型时我考虑过Session和JWT两种方式。最终选了JWT核心原因是前后端分离的结构下JWT不需要后端维护会话状态扩展性和答辩可讲性都更好。JWT的集成步骤拆开讲登录接口接收用户名和密码校验通过后用用户的ID、角色、过期时间生成TokenToken的有效期设为2小时过期时间通过常量配置写一个拦截器或过滤器从请求头Authorization里解析Token并验证签名解析出的用户信息存入ThreadLocal供后续业务代码获取当前操作人Spring Security的配置类里放行登录接口和静态资源其余接口统一走认证密码存储这块多说一句绝对不要明文存储密码。我当时用了BCrypt加密用户在注册或管理员创建账号时密码经过BCrypt哈希后存入数据库登录校验时用BCrypt的matches方法比对。这样即使数据库泄露攻击者也不能直接拿到明文密码。角色权限方面我用了PreAuthorize注解做方法级控制PreAuthorize(hasRole(ADMIN)) PostMapping(/api/user) public Result createUser(RequestBody UserCreateDTO dto) { ... } PreAuthorize(hasAnyRole(ADMIN, CASHIER)) PostMapping(/api/sale/checkout) public Result checkout(RequestBody CheckoutDTO dto) { ... }这里有一个Spring Security的坑注解里写的角色名称会自动加上ROLE_前缀去匹配所以在数据库和角色枚举里存的角色名应该是ADMIN、CASHIER、STOCK_MANAGER这种不带ROLE_前缀的写法。3.3 销售下单与库存扣减的代码走读销售下单是整个系统的核心流程我把核心代码按步骤拆开讲。第一步在Service层创建订单主记录SaleOrder order new SaleOrder(); order.setOrderNo(generateOrderNo()); order.setCashierId(currentUserId()); order.setOrderStatus(0); // 0待支付 1已支付 2已退款 order.setTotalAmount(computedTotal); order.setPaidAmount(paidAmount); order.setDiscountAmount(discountAmount); order.setPayMethod(payMethod); order.setCreateTime(LocalDateTime.now()); saleOrderMapper.insert(order);第二步遍历购物车明细逐批次扣减库存for (CartItemDTO item : items) { int needQty item.getQuantity(); ListStockBatch batches stockBatchMapper .selectAvailableBatches(item.getDrugId(), LocalDate.now()); for (StockBatch batch : batches) { if (needQty 0) break; int deductQty Math.min(batch.getRemainingQuantity(), needQty); int rows stockBatchMapper.deductStock(batch.getId(), deductQty); if (rows 0) { throw new BizException(药品[ item.getDrugName() ]库存不足); } needQty - deductQty; // 插入销售明细 SaleOrderDetail detail new SaleOrderDetail(); detail.setOrderId(order.getId()); detail.setDrugId(item.getDrugId()); detail.setBatchId(batch.getId()); detail.setSalePrice(item.getSalePrice()); detail.setQuantity(deductQty); saleOrderDetailMapper.insert(detail); } if (needQty 0) { throw new BizException(药品[ item.getDrugName() ]库存不足); } }第三步支付成功后将订单状态置为已支付事务提交order.setOrderStatus(1); saleOrderMapper.updateById(order); // 事务方法返回Spring自动提交这里有一个非常关键的细节selectAvailableBatches查询出来的批次必须加FOR UPDATE行锁否则并发场景下两个事务可能同时读到同一个批次的剩余数量导致超卖。我是在XML里手动写的这条查询语句配合pessimistic_lock后缀的方法名让MyBatis Plus识别。3.4 报表统计与数据导出的实现细节报表统计这块我做了一个销售趋势分析页面支持按天、按周、按月切换查看销售额走势同时展示药品销售排行Top10和分类销售占比。实现上用的是MySQL的日期函数处理分组SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, SUM(paid_amount) AS total_amount, COUNT(*) AS order_count FROM sale_order WHERE order_status 1 AND create_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day这种聚合查询在数据量小的时候性能完全没压力就算你有几十万条订单数据在MySQL 8.0上跑这种分组查询也就是毫秒级。数据导出我用了更简单的方式——前端导出。查询接口返回当前筛选条件下的全量明细数据前端用SheetJS库在前端生成Excel文件并触发下载。对于毕设系统这个方案完全够用也避免了后端引入POI依赖带来的版本冲突问题。不过要提醒一个边界情况如果筛选条件查出的数据超过了1万行前端的Excel生成会明显变慢到时候可以适当限制导出行数或者提示用户缩小时间范围。4. 常见问题与排查技巧实录4.1 事务不生效这个经典的坑这个坑我相信每个做Spring Boot毕设的人都踩过。我在给系统加库存扣减逻辑时发现下单成功后库存没有扣减而订单却正常插入了。排查了很久才发现问题出在方法调用方式上。我当时是在同一个类的另一个方法里直接调用了带Transactional的方法。Spring的事务原理是基于代理对象的通过this关键字调用本类方法时绕过了代理对象事务注解完全没有生效。解决办法有两个一是把事务方法拆分到另一个Service类里通过注入的依赖调用二是在当前类注入自身代理构造器注入ApplicationContext再取Bean。第一种是推荐的做法因为代码结构更清晰。排查事务问题的另一个常用手段是看数据库连接的状态。在日志配置里打开MyBatis的SQL日志输出观察执行UPDATE语句前后是否出现了Committing transaction或Rolling back transaction的日志。如果看到Rolling back就说明事务确实进入回滚逻辑了。4.2 超卖问题为什么测试并发时库存变成了负数这个问题是我在压测阶段发现的。用JMeter模拟20个并发请求同时购买库存只有10盒的药品结果跑完后库存变成了负数。排查过程分了三步第一步检查扣减库存的SQL发现起初用的是先查后改的非原子操作就是先SELECT remaining_quantity判断大于0再执行UPDATE SET remaining_quantity remaining_quantity - 1。并发场景下两个请求同时读到剩余1盒同时判断满足条件先后执行UPDATE库存就变成了-1。第二步改为原子SQL就是前面提到的UPDATE ... WHERE remaining_quantity #{quantity}。这一步解决了一部分的超卖问题。第三步补上了批次查询的行锁。因为多批次扣减场景下即使每次UPDATE是原子的两个事务并发读取可用批次列表时可能把同一个批次的剩余库存重复计算进可售数量导致后一个事务虽然整体库存不够但仍然在前一个事务提交后继续执行扣减。给批次查询加FOR UPDATE锁后并发场景下的扣减结果才完全正确。这里分享一个通用的排查思路凡是涉及数量扣减、余额扣减这类金额或者数量的变更一定不要用先查后改的模式全部改成条件更新的原子操作。这是面试中也很常考的知识点。4.3 前端接口联调时的跨域问题前后端分离开发时最烦的就是跨域报错。前端在8080端口启动Vue开发服务器后端跑在8081端口浏览器的同源策略会拦截前端发出的Ajax请求。我的解决方案是在后端加一个全局跨域配置类Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/api/**, config); return new CorsFilter(source); } }这里有一个注意点允许的路径匹配要写成/api/**不要写成/**避免把不需要跨域的路径也放开。同时如果接入Spring Security需要确保跨域配置在安全过滤器链之前生效否则跨域预检请求会被安全过滤器拦截。JWT的Token在跨域场景下也有一个小坑带Token的请求在预检OPTIONS阶段不会携带自定义请求头如果安全配置没有放行OPTIONS请求前端的实际请求根本发不到后端。我最终的安全配置里放行了一切OPTIONS方法请求。具体排查思路是这样如果前端请求直接报403而不是跨域报错八成是安全过滤器把OPTIONS拦截了如果报的是CORS错误则是跨域配置没有生效优先检查过滤器顺序。4.4 药品搜索性能退化的小问题测试阶段发现一个问题药品表数据量只有500多行但搜索响应有时候要1秒以上。排查后发现模糊查询的写法导致索引失效。问题出在查询语句是这样写的WHERE drug_name LIKE %${keyword}%这种两边带百分号的模糊查询是没法走索引的。解决办法有两个方向第一个方向是限制查询范围改为前缀匹配WHERE drug_name LIKE ${keyword}%这样数据库能用上联合索引但代价是用户必须记得药品名的开头几个字体验不好。第二个方向是增加一个专门的搜索字段存药品名称的拼音首字母缩写搜索时优先匹配这个字段和编码字段。实际效果很好收银员不用记得完整药名输入首字母就能查到。最终我的方案是两者结合编码字段走前缀匹配精确命中名称字段走拼音首字母匹配都没有结果再走模糊查询兜底。搜索响应基本都在100毫秒以内。4.5 一套实用的常见问题速查表整理了一套我在调试这个系统时沉淀下来的问题速查表覆盖了最容易出问题的一些环节现象可能原因排查方法登录成功后访问接口返回401Token过期或签名验证失败检查系统时间是否准确Token过期时间是否设置过短库存扣减成功但订单表没有数据事务方法内部异常被捕获后未抛出检查Service方法是否捕获了异常但没有重新抛出导致事务无法回滚数据库连接池报Too many connections连接池默认配置过小或存在大事务调整HikariCP连接池参数检查是否有长事务未释放连接药品查询结果重复出现同一药品多批次关联查询产生了笛卡尔积检查Mapper的关联查询是否遗漏了批次表的主键条件退款后库存没有恢复退款流程没有连同扣库存一起放在事务里检查退款方法上是否有Transactional扣减库存和退款操作是否在同一事务打包运行后上传头像报404上传文件的绝对路径与访问路径不匹配手动在配置文件中指定上传根目录并通过静态资源映射暴露访问路径这些问题的共性是表面上看起来是代码bug实际上大多是事务、并发、路径映射、过滤器顺序这几个JVM体系的基础知识掌握不牢。把这一块啃透毕设肯定能省不少心。5. 答辩展示要点与经验沉淀5.1 演示时的操作路径答辩演示环节最忌讳的是临场翻车。针对这套系统我建议的演示顺序是这样的先展示登录页面登录后直接进入Dashboard看板让老师看到一个有数据的首页而不是空白页。Dashboard上要有今日销售额、订单量、预警批次数量这几个核心指标我用ECharts做了折线图展示近7天的销售趋势。接着操作一次完整的售药流程搜索药品、加入购物车、结算、支付、查看小票打印效果。支付完成后切换到库存管理页面刷新刚才卖掉的药品展示库存数量确实减少了。这一步是为了直观呈现销售和库存的联动。然后是反向操作找一个库存不足的药品执行售药流程系统应弹出库存不足的提示。再演示退款流程退款成功后回到库存页面看库存是否恢复。这两步展示的正是系统的事务一致性设计是最容易拿分的演示环节。最后是报表统计页面分别切换日、周、月维度展示销售趋势图再展示药品销售排行和库存预警列表把系统的数据分析能力呈现完整。5.2 我认为这套设计里最值钱的两个细节第一个是批次扣减时保留了销售明细与批次的关联关系。大部分毕设系统卖药就是简单减一下库存总数根本不会记录哪个批次的药被卖掉了。有了批次关联就能回答老师在答辩时的追问同一款药两个进货批次的效期不同系统是怎么决定先卖哪个批次的答案就是效期升序排序的先进先出策略。第二个是库存预警的量化口径。如果仅仅做成“库存小于10就预警”这个阈值拍脑袋定的话会被老师追问为什么是10而不是5。我用了补货点的计算公式把日均销量、采购周期和安全系数都暴露成可配置参数每个药品独立计算。这种把业务规则量化落地的能力正是答辩时体现工程能力的关键。5.3 这套系统后续还能怎么扩展如果一个毕设做完就觉得完事了那上限也就是个及格分。真正有想法的同学会把系统的可扩展性想清楚在答辩时讲出未来可以怎么做。我给学生梳理了几个扩展方向第一个是引入缓存层。把药品信息和高频访问的库存数据放进Redis减轻数据库压力收银台的搜索响应可以进一步压缩到几十毫秒。这个方案在数据量增长到百万级、并发量上来之后是刚需。第二个是增加多门店支持。在药品表和库存表上增加门店ID字段所有查询默认按当前登录用户所属门店过滤就能把一个单体药店系统升级成连锁药店的区域版本。工作量不大但系统的适用场景会立刻上一个台阶。第三个是接入电子处方流转。处方药销售时可以对接外部处方平台做电子处方校验这是药店行业合规化的大趋势。对于有余力的同学把这个做成模拟仿真模块在论文的立题高度上会有明显提升。如果你正准备做类似的Java毕设项目我强烈建议不要停留在把系统跑通这个水平线上。从数据库建模的规范性、事务边界的控制粒度到业务规则的可解释性每一处细节都是答辩现场加分的弹药。这篇文章里的表结构和并发扣减方案可以直接照搬但我更希望你能理解每个设计决策背后的原因那才是做毕设真正能带走的东西。