Spring Boot药店管理系统:从业务拆解到部署避坑指南

发布时间:2026/9/8 9:50:44
Spring Boot药店管理系统:从业务拆解到部署避坑指南 Spring Boot药店管理系统这类题目在课程设计和毕业设计里出现频率极高几乎所有Java方向的学生都绕不开“XX管理系统”这个经典命题。但说实话大部分同学做出来的东西只是把增删改查套了一层壳数据库几张表、页面几个表格答辩能跑通就完事。而“药店管理系统”这个场景恰恰是管理系统里少有的、具备真实业务复杂度的题目——它涉及库存批次、药品有效期、销售主从表、多条件检索、权限分级这些不只是“增删改查”的内容。这篇文章我就围绕一个可落地的药店管理系统从业务拆解、技术选型、数据库设计、核心实现到部署避坑完整讲一遍我是怎么做的希望能给准备做类似项目的同学一些参考。需要说明的是文中涉及的代码和结构基于Spring Boot MyBatis-Plus MySQL这套主流组合这也是目前该题目最常用、也最方便后续扩展的技术路线。无论你是照着做还是在此基础上魔改这篇文章的核心思路都能复用。1. 药店管理系统到底在管什么从业务场景到功能清单1.1 药店的日常运转需要哪些业务闭环先别急着写代码做这个项目的第一步是搞清楚药店真实的业务流转是什么样的。很多同学一上来就建表、写增删改查结果做完一看药品能新增能删除但“库存怎么扣减”“过期药品怎么预警”“一笔销售怎么对应多张明细”这些问题全都没想清楚。一家小型药店的日常业务核心可以拆成两条链采购入库链药店向供应商进货每个药品批次进入门店库存对应的是入库单可能一个入库单包含多种药品。销售出库链顾客来买药收银台生成一笔销售单一笔销售单包含多行药品明细每一行明细都要扣减对应库存。这两条链之外还有基础数据和辅助功能药品信息维护、药品分类、供应商信息、员工账号、客户或者说会员信息、以及财务相关的统计报表。如果项目里只有一张“药品表”那严格意义上不叫药店管理系统那叫药品信息登记表。真正的难点和加分项恰恰在于采购和销售这两条业务链背后牵扯出的主从表结构和库存流转逻辑。1.2 功能清单哪些功能是核心哪些是加分项我的做法是先划分角色再为每个角色定功能。药店系统最常见的角色有三种系统管理员、店长经理、收银员/普通员工。权限分级是这个项目区别于普通CRUD的重要标志也是最容易在答辩时被老师追问的点。核心功能模块我整理如下表模块核心功能优先级说明用户登录与权限登录、退出、会话校验、角色权限拦截必做可用Spring Security也可以自己写拦截器药品管理药品新增、编辑、上下架、详情必做关联药品分类、厂家供应商药品分类分类维护、树形或平级分类必做用于前台检索与统计供应商管理供应商增删改查、联系人信息必做简化版可以并入采购模块采购入库入库单创建、入库历史必做入库即增加库存批次库存管理当前库存查询、库存预警、批次详情必做预警是药店系统的特色功能销售管理收银开单、销售历史、销售明细必做一笔销售单对应多行明细退货管理销售退货、采购退货选做有精力就加属于加分项统计报表近7日销售趋势、热销药品Top10、利润统计加分项用ECharts画图展示药品过期预警按有效期筛选即将过期或已过期批次加分项药店场景的核心特色功能清单出来之后项目边界就明确了一个基础版要覆盖的是用户权限 药品/分类/供应商基础维护 采购入库 库存管理 销售开单这五个部分。超过这个范围的功能比如会员储值、医保对接、扫码枪硬件对接属于真商业项目才需要考虑的方向课程设计阶段不建议一上来就铺开做。2. 技术选型为什么是 Spring Boot 3 MyBatis-Plus而不是 JSP Servlet2.1 从传统 Java Web 到 Spring Boot这套技术栈解决的核心痛点如果放在十年前基于Java Web的药店管理系统的主流方案是JSP Servlet JDBC数据库连接自己管理事务自己处理页面用JSP脚本渲染。这套方案放在今天不是说不能做而是太痛苦了开发效率低、配置繁琐、前后端耦合严重、难以测试。Spring Boot解决了两个最核心的问题自动配置和开箱即用。引入依赖后内嵌的Tomcat帮你把Web容器启动了application.yml里简单几行配置就能连上数据库再也不用手写web.xml和各种XML配置文件。我在这套系统里选择的是Spring Boot 3.2.x JDK 17 MyBatis-Plus 3.5.x MySQL 8.x。这组组合是目前大多数新项目的默认起点。注意Spring Boot 3.x 要求 JDK 17 及以上不要再用 JDK 8 跑 Spring Boot 3 项目Maven 编译阶段就会直接报错。如果你电脑上装的是 JDK 8要么换 JDK 17要么把 Spring Boot 降级到 2.7.x二选一别硬刚。2.2 MyBatis-Plus 带来的效率提升少写大量重复 SQL药店管理系统有大量单表CRUD操作如果用原生MyBatis每写一个实体类就要配套一个Mapper接口和一个XML文件里面是大量的insert、update、delete、selectById模板语句。纯手写这些东西机械重复不说还容易在字段名对上对下时出错。MyBatis-Plus的核心价值是继承BaseMapperT之后常用的单表操作直接就有了不需要写任何SQL。对应的Service层继承IServiceT还能拿到saveOrUpdate、lambdaQuery、page这些现成的封装方法。举个实际例子药品的分页多条件查询用MyBatis-Plus写起来非常简洁public PageDrug pageDrugs(int pageNum, int pageSize, String keyword, Long categoryId, Integer status) { PageDrug page new Page(pageNum, pageSize); LambdaQueryWrapperDrug wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Drug::getName, keyword) .eq(categoryId ! null, Drug::getCategoryId, categoryId) .eq(status ! null, Drug::getStatus, status) .orderByDesc(Drug::getCreateTime); return drugMapper.selectPage(page, wrapper); }这一整段代码如果用XML手写你得先去拼接动态SQL条件再处理分页方言MySQL的LIMIT再写count查询一页代码瞬间翻倍。而这里20行以内全部搞定还不容易出错。这就是我选MyBatis-Plus最直接的原因——把精力留给真正有业务逻辑的地方。2.3 前端方案Thymeleaf 还是前后端分离这个问题每次做项目都会被问。药店管理系统这个题目我给的判断标准很简单如果时间紧、想尽快跑通全流程用Thymeleaf模板引擎如果打算在简历上写成“前后端分离项目”用Vue Element Plus Axios。Thymeleaf的优势是服务端渲染页面数据在后端拼好直接返回HTML不需要考虑跨域、Token传递这些前后端分离才有的麻烦。缺点也很明显页面和Java代码逻辑耦合前端改动必须重启后端交互体验相对粗糙。前后端分离的优势是接口和页面完全解耦以后扩展小程序、App端可以直接复用后端接口。代价是你需要同时维护两套工程部署时处理静态资源还得解决跨域和登录状态传递。我最终选的是Thymeleaf AdminLTE/Bootstrap的组合原因很实际这是课程设计级项目核心考核点是Java后端逻辑和数据库设计页面美观度通过现成模板可以快速补齐。Thymeleaf还能直接用th:each遍历数据渲染表格比纯前端拉取接口再拼接DOM省事太多。如果你确实想走前后端分离我建议后端只写RESTful接口返回统一结果集ResultT前端用Vue3 Element Plus Vite来搭登录状态用JWT Axios拦截器处理。这条路能走通但准备时间基本要翻倍你自己权衡。3. 数据库设计药品库存这类业务的表结构要避开哪些坑3.1 核心表结构设计思路数据库是一套管理系统真正的地基。药店管理系统我最终设计了8张核心表这里挑重点的讲用户表sys_user字段类型说明idbigint主键usernamevarchar(50)登录名唯一passwordvarchar(100)BCrypt加密后的密文real_namevarchar(50)真实姓名rolevarchar(20)ADMIN / MANAGER / CASHIERstatustinyint1启用 0禁用密码存储这一点必须强调明文存储是管理系统项目中非常严重的安全缺陷。哪怕只是课程设计也应该用BCryptPasswordEncoder加密后再存库。药品表drug字段类型说明idbigint主键drug_codevarchar(50)药品编码唯一namevarchar(100)药品通用名category_idbigint分类IDmanufacturervarchar(100)生产厂家specificationvarchar(100)规格如0.25g*24片unitvarchar(20)单位盒/瓶/袋sale_pricedecimal(10,2)零售价purchase_pricedecimal(10,2)进货价prescription_typetinyint处方药/非处方药statustinyint上架/下架create_timedatetime创建时间库存批次表stock_batch字段类型说明idbigint主键drug_idbigint药品IDbatch_novarchar(50)批次号supplier_idbigint供应商IDquantityint当前库存量production_datedate生产日期expire_datedate有效期至create_timedatetime入库时间销售主表sale_order和销售明细表sale_order_item这两个是典型的主从表结构。主表记录一笔销售的整体信息单号、总金额、收银员、销售时间从表记录这笔销售的每一行药品明细药品ID、数量、单价、小计金额。为什么要拆两张表因为一笔销售单包含多种药品如果把药品信息直接存主表里就得在一个字段里存很多内容的冗余结构后面统计、退货全部没法做。拆成主从表之后按单号查明细、按药品统计销量都是最简单的SQL。3.2 为什么库存一定要按批次管理这是这个项目里相当关键的一个设计决策。假设进货价在变同一款药不同时间从不同供应商那里进价格可能不一样。如果库存表只存一个总数那么销售出库时你根本不知道卖出去的这批药是哪个批次进的、什么时候到期。药店的实际场景里药品有效期监管是硬性要求过期的药不能卖、临期的药要预警这些全都依赖批次维度。所以我的设计是药品主数据里只存药品的基本信息和默认售价库存总数通过stock_batch表动态汇总。每次采购入库新增一条或多条批次记录每次销售出库按先进先出FIFO原则优先扣除最早到期的批次数量。批次表的好处有三个能知道当前库存分布在哪些批次、各有多少能计算哪些批次临近有效期做临期预警采购退货时能精确定位到某一批次这个设计在答辩时只要讲出来老师基本就知道你不是第一次做管理系统。3.3 金额与数量的类型选择BigDecimal 和整数类型的取舍这里分享一个小经验。钱的字段零售价、进货价、总金额一律用 decimal(10,2) BigDecimal绝对不要用 float 或 double。这不是教条而是因为二进制浮点数在计算机里表示十进制小数时有精度误差。你去算一笔0.1 0.2结果不是0.3而是一长串小数这在涉及钱的系统里是不可接受的。数量字段则用 int 或者 bigint。很多人习惯把库存量和价格一样用decimal这是没必要的药品的销售单位是盒/瓶/袋都是整数用整数类型还能避免0.5盒这种语义不清的状况。还有一点和金额相关的建议明细表和主表都要存当时的价格快照。什么意思你录入一笔销售单时应该把当时这批药的单价同步到明细表里而不是在明细里只放一个 drug_id查询时再去药品表里 join 价格。因为药品的零售价是允许调整的如果价格改了历史销售单里的价格也会跟着变数据就失真了。价格快照这个设计虽然只多了一步操作但对数据的真实性有很大意义。4. 核心模块实现拆解登录、库存预警、销售结账4.1 登录模块从 Session 到 JWT 的演进以及我的取舍前后端不分离的前提下最稳妥的方案是Session HttpSession 拦截器。用户登录成功后把用户ID、用户名、角色存进Session再写一个HandlerInterceptor在请求进入Controller之前检查Session里有没有用户信息没有就重定向到登录页。如果用Spring Security功能确实更强但配置复杂度也更高。对这个项目来说一个拦截器加一个Aspect或者拦截器注解已经完全够用了。这里我分享一个细节拦截器校验权限时不要只判断“是否登录”还要判断“角色是否匹配”。举个具体场景收银员登录后不应该能访问用户管理相关的URL。如果只做了登录校验、没做角色校验那任何登录用户都能访问所有页面权限分级就形同虚设了。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(/login); return false; } // 进一步校验角色权限 String uri request.getRequestURI(); if (uri.startsWith(/admin) !ADMIN.equals(loginUser.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } return true; } }注册这个拦截器的时候要注意排除静态资源/js/**、/css/**、/images/**、/login这些路径不能拦截否则页面样式全加载不出来登录页也进不去。4.2 库存预警写一条有效的 SQL 比堆代码更重要库存预警是药店系统的特色功能也是很多同学容易做得“假模假式”的地方。预警分两种维度数量预警和效期预警。数量预警的逻辑很简单当某药品当前批次总库存低于设定的最低库存阈值时列出该药品。实现时要么在药品表加一个min_stock字段要么在分类表里统一配置。我选了前者因为不同药品的安全库存差异很大比如急救药可以备多些常规药少备一些统一配置不符合实际情况。效期预警才是药店系统真正区分于普通进销存系统的地方它要查的是“有哪些批次的药品在90天内即将到期或者已经过期”SQL可以这样写SELECT b.drug_id, d.name, b.batch_no, b.quantity, b.expire_date, DATEDIFF(b.expire_date, CURDATE()) AS remain_days FROM stock_batch b LEFT JOIN drug d ON b.drug_id d.id WHERE b.quantity 0 AND b.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) ORDER BY b.expire_date ASC;如果希望把已过期批次的也展示出来把BETWEEN条件换成b.expire_date DATE_ADD(CURDATE(), INTERVAL 90 DAY)即可。这段SQL的核心不是语法有多难而是把“临期判断”这个业务规则明确地翻译成数据条件。写之前先问自己多少天内算临期已过期要不要显示过期批次能不能继续销售这三个问题没想清楚SQL永远写不对。回到代码层面预警的展现方式也有讲究。我的做法是在管理后台首页放一个“预警看板”库存不足的药品数量、临期药品数量、已过期药品数量用三个数字卡片展示点击进去是具体列表。这么做数据一目了然比单纯在列表页加一个筛选按钮更有“系统”的感觉。4.3 销售结账事务、扣库存、并发一个都不能少销售结账是整个系统里业务复杂度最高的操作没有之一。它的流程不是简单插入一条记录而是由一个完整业务操作组合起来的创建销售主单sale_order计算总金额遍历购物车中的每一项药品生成销售明细sale_order_item校验每种药品的库存是否充足扣减对应批次的库存如果库存不足整个销售操作要回滚这几步操作必须在一个数据库事务里完成否则就会出现“销售单创建了库存却没扣”或者“库存扣了销售单却没保存”这两种数据不一致的问题。Spring 里用Transactional就可以实现声明式事务加在Service方法上即可。但有几个坑需要注意第一个坑事务不生效。Transactional加在非public方法上不生效通过this调用同类内的另一个Transactional方法也不生效。所以销售开单的逻辑要放在独立的Service类中从Controller或外部Service调用不要自己调自己。第二个坑库存扣减的并发问题。如果两个收银员同时卖同一盒药各自的请求都读到库存10然后都执行UPDATE stock_batch SET quantity quantity - 1就可能导致超卖。简单有效的做法是用乐观锁更新库存的SQL加上数量条件。UPDATE stock_batch SET quantity quantity - #{count} WHERE drug_id #{drugId} AND batch_no #{batchNo} AND quantity #{count}这里quantity #{count}这个条件特别重要它保证扣减操作自带“余额校验”如果库存不够这条更新影响的行数就是0代码里通过int rows stockBatchMapper.update(...)判断rows 0就知道该批次库存不足或已被并发修改然后抛异常触发回滚。**第三个坑重复提交。**用户结账时如果网络卡顿可能连点两次“确认结账”产生两笔一模一样的销售单。这个问题最基础的解法是前端按钮置灰提交后禁用按钮再进阶一点是后端做幂等控制比如用唯一订单号做去重。课程设计阶段做到前端防重复点击就够了但你要知道后面还有这道坎。4.4 通用 CRUD 的自动化MyBatis-Plus 的实用姿势做管理系统不要去一个模块一个模块地手动堆CRUD代码那既累又没技术含量。配合MyBatis-Plus正确的姿势是实体类加TableName、TableId注解Mapper接口继承BaseMapperTService接口继承IServiceT实现类继承ServiceImplM, T这样药品、分类、供应商这几个基础模块的简单增删改查几乎不用写任何SQL和业务代码Controller里直接调用IService提供的方法就能完成。重点讲一下逻辑删除。删除药品这个操作要慎重因为药品一旦被销售单引用物理删除会让历史数据变成“悬空引用”。我的做法是在表里加一个deleted字段配合MyBatis-Plus的TableLogic注解。删除操作变成更新deleted 1查询时框架自动过滤掉已删除的数据。这个做法在答辩时说“因为要保留历史业务数据的完整性”是非常加分的回答。创建时间和更新时间这两个字段也很常见配合MetaObjectHandler可以实现插入和更新时自动填充Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体类对应字段上加TableField(fill FieldFill.INSERT)或TableField(fill FieldFill.INSERT_UPDATE)即可。这个机制把大量业务代码从每张表的新增方法里解放出来了。5. 从开发到部署真实项目里绕不开的十个坑5.1 Spring Boot 3 的版本兼容陷阱搜索热词里有一句“springboot版本太高”说的就是这个事。Spring Boot 3.0对比2.x最大的变化是Jakarta EE命名空间迁移javax.servlet变成了jakarta.servlet。很多旧教程、旧依赖在Spring Boot 3里会直接编译报错。另一个常见坑是MyBatis-Plus版本匹配。MyBatis-Plus 3.5.x之后才支持Spring Boot 3对应mybatis-plus-spring-boot3-starter这个依赖坐标。如果你用的是Spring Boot 3还引入老版本的mybatis-plus-boot-starter启动时会直接报ClassNotFoundException之类的错误。我吃过的亏是Spring Boot 3.2 MyBatis-Plus 3.5.3.1这个组合分页插件一切正常但切换成3.5.5之后PaginationInnerInterceptor的配置略微有变化。所以我的建议是确定一套版本组合就不要再频繁升级。课程设计要求的是稳定跑通不是追新。5.2 数据库连接配置时区、编码、SSL连接MySQL时最容易踩的坑是时区问题。如果你在application.yml里这样写spring: datasource: url: jdbc:mysql://localhost:3306/pharmacy_db?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8useSSLfalse这里serverTimezone不配置或者配置成UTC那么后端写入的时间会比北京时间早8个小时所有的时间显示和统计都会错乱。useSSLfalse是为了避免本地连接时SSL握手报错生产环境建议反过来设置为true。另外MySQL 8.x默认的驱动类是com.mysql.cj.jdbc.DriverMyBatis-Plus一般能自动识别但有些组合下需要手动指定如果启动报驱动找不到就加上这一行spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver5.3 分页查询和模糊搜索的性能坑基础版的药品列表直接selectPage没问题但数据量一旦上来有几个细节要注意。一是模糊查询别用%keyword%前导通配符比如LIKE %阿莫西林%。这种写法在数据库层面无法走索引数据量大了以后会全表扫描。但药店管理系统这个体量一般几千条药品数据不构成性能瓶颈所以这里不用过度优化知道有这回事就行。二是我强烈建议你在列表页加上筛选条件药品名称关键字、分类下拉、上下架状态。不要只做一个无筛选的列表然后靠前端分页。这个功能在答辩时很能说明你考虑了“管理场景下的操作效率”。5.4 打包和部署Maven 打包后运行不起来本地IDEA里运行得好好的一打包部署就出问题这种情况遇到过不止一次。最常见的原因有静态资源路径问题Thymeleaf模板里如果写了以/开头的绝对路径打包后放到子路径部署会404。统一用th:href{/css/app.css}这类模板语法让它自己拼上下文路径。端口冲突服务器上8080端口被占用启动失败。打包前检查application.yml里的server.port部署时用--server.port8081这种命令行参数动态覆盖最省事。数据库连接不上本地连的是localhost服务器上连的是另一台机器的数据库打包前记得把application.yml里的数据库地址改成服务器能访问的地址。一个更推荐的部署方式是用Maven插件打成Fat Jar然后在命令行里用java -jar pharmacy-system.jar直接启动。这在云服务器上配合systemd或者简单的shell脚本就能做到守护运行。前端资源因为是内置的不存在跨域问题比前后端分离部署简单很多。6. 这个项目的下一步从课程设计到可上线的小微系统6.1 功能扩展会员、报表、批号追溯如果你做完核心功能之后还有余力我建议优先往这三个方向扩展。会员管理是很多药店的刚需。可以加上一张member表销售开单时选择会员消费金额累计积分按积分分级打折。这部分涉及的功能不复杂但对业务完整度的提升非常明显。统计报表是另一个性价比高的扩展方向。用ECharts展示近7日销售额折线图、分类销售额饼图、畅销药品Top10柱状图。数据来源就是销售明细表SQL写几个GROUP BY查询就行。页面效果却非常出彩答辩演示时看着也专业。批号追溯是更进阶的方向。每一笔销售明细除了记录药品ID再记录批次号。这样就能回答“这盒药是哪个批次进的、哪一天卖给了哪个会员”这个问题。真实药店在药品召回时必须要有这个能力这是进销存系统数据完整性的高级体现。6.2 安全增强密码加密、SQL注入、XSS防护虽然课程设计对安全没有强制要求但如果你简历上写了这个项目面试官一定会追着安全问题问。我在这里说三个能立刻落地的安全措施。首先是密码加密。不要用MD5MD5已经可以被彩虹表秒破。用Spring Security自带的BCryptPasswordEncoder每次加密自动加盐同一个密码两次加密结果不同这才能算合格。其次是SQL注入防御。MyBatis的#{}是预处理参数占位能有效防止SQL注入但如果你图方便在XML里写了${}直接拼接参数那就是一个典型的注入点。原则很简单能用#{}就别用${}。再就是XSS防护。用户输入的药品名称、供应商名称如果直接渲染到页面上可能携带恶意脚本。最简单的做法是在后端统一对请求参数做转义过滤或者在前端模板里对输出做escape。Thymeleaf默认对HTML标签是转义的这本身就是一层保护不要轻易用th:utext输出用户输入的内容。6.3 写文档和答辩让老师看到你的设计思想做这个项目到最后文档和代码同样重要。我不建议把README写成“怎么启动项目”的工具说明而是应该把系统设计思路讲清楚。文档里我建议包含这样几个部分系统概述和角色分析这三种角色分别能干什么核心业务流程图采购入库、销售出库两条链路数据库ER图和设计说明重点讲为什么库存要按批次管、为什么销售要拆主从表核心难点解答并发扣库存怎么解决、临期药品怎么预警答辩时最忌讳的是照着PPT念功能列表。你先讲清楚“药店业务有哪些环节”再说“我的系统怎么覆盖这些环节”、“哪些环节是重点难点”、“怎么解决的”这一套逻辑下来比单纯演示页面要加分得多。我在检查最终代码前的习惯是把整个项目从零开始删除重构至少跑通一遍mvn clean package加java -jar启动。如果你能做到这一步从部署到答辩的每一个环节你都心里有底。这套方法在我做过的管理系统项目里一直沿用项目能不能交付从来不是看代码写了多少而是看整个链路跑了几遍。