
做了几年Java后端手上过的大大小小管理系统不少但Spring Boot写药店管理系统这个题目几乎是每年毕业设计和中小型软件公司接单里最常出现的需求之一。药店管理系统听起来只是“进销存”真要落地却比普通商城系统麻烦不少原因在于药品有批号、效期、GSP记录、处方药管控这些特有的业务规则。这篇文章就围绕“Spring Boot Java 药店管理系统”这个主题把我在实际项目里的设计思路、表结构、关键代码、踩坑记录一次说清楚适合正在做毕设、想转行做Java后端、或者准备接手药店项目的朋友参考。这个项目本质上解决的是药店日常经营中的三件事药品怎么进得来、怎么卖得出去、库存怎么管得准。Spring Boot负责把后端服务搭建起来Java提供稳定可靠的跨平台运行基础再搭配MySQL存业务数据、Redis扛高并发下的库存扣减这套组合在中小型药店场景下足够扎实。下面我会从整体设计开始一直讲到具体实现和问题排查内容偏实操你跟着思路走至少能把一个可运行的骨架搭出来。1. 项目整体设计与模块拆解1.1 药店管理系统的核心业务链路药店的业务链路比一般零售复杂核心流程是“采购入库 - 库存上架 - 前台销售 - 销售出库 - 效期预警 - 库存盘点 - 采购补货”。我接手过的几个项目里最容易出问题的地方并不是页面写得多炫而是业务链路里某些状态没闭环。比如采购单审核通过后没有自动增加库存销售退货时没有把库存加回去最后月底盘点账实不符。设计系统时不要把每个模块当成孤岛。采购模块产生的入库单要联动库存表销售模块的订单要联动批次库存扣减同时生成GSP记录中的销售流水。处方药还要单独记录购买人信息、药师审核信息这是药店管理系统中合规性要求最严格的部分。所以模块拆分虽然看着多但底层所有的数据变化都应围绕“药品库存台账”这一条主线展开谁动了库存谁就得留下可追溯的流水。适合用Spring Boot做是因为它的自动装配机制把数据源、事务、Web框架这些基础配置简化到极致。你只需要在pom.xml里引入spring-boot-starter-web、spring-boot-starter-data-jpa或MyBatis-Plus依赖写一个启动类整个服务就能跑起来。对药店这种业务逻辑不算特别复杂、但对事务一致性要求高的系统Spring Boot声明式事务一行Transactional就能搞定比传统SSH框架省掉一半配置。1.2 为什么选Spring Boot Java而不是其他方案有人会问药店管理系统用PHP、Python甚至Excel不也能做吗能但药店管理系统要面对的是长期维护、多人协作、数据安全、高并发窗口期比如促销活动这些现实问题。Java生态的稳定性和Spring Boot的生产力让它在企业级管理系统里依然是首选。顺便说一句Java是静态类型语言编译期就能发现很多低级错误把运行时问题提前拦截这对药店这种数据准确性要求很高的场景非常友好。Spring Boot自带的依赖管理和自动装配也让新手避免了很多配置地狱。早期用Spring MVC项目光配置数据源、事务管理器、视图解析器就能折腾一天现在Spring Boot只需在application.yml里写几行连接信息再用SpringBootApplication启动Tomcat和默认配置都自动完成。项目启动后访问控制器一个最小可用的后端服务就出来了。这一点做毕设尤其划算因为导师看的是系统完整度和业务逻辑不是看你手写XML配置的能力。另外药店管理系统几乎必然要对接后续的医保接口、会员系统、电子处方平台Spring Boot的模块化结构和丰富的starter组件让这些对接变得容易。它有成熟的HTTP客户端、消息队列starter遇到外部接口不稳定时还能用Spring Retry做重试策略这些在真实药店项目里会派上大用场。1.3 项目模块划分我习惯按功能域把后端拆成如下几个模块而不是简单按Controller、Service、Mapper三层打散。如果只是演示级别三层结构够用但实际项目中模块边界清晰比技术分层更重要。系统基础模块用户管理、角色权限、菜单管理、操作日志。这个模块是Spring Security和JWT的主战场。药品信息模块药品字典、分类、厂家、批次、效期、GSP资料。这里的关键是药品唯一编码和批号组合。采购模块供应商、采购订单、采购入库、退货单。库存模块库存台账、批次库存、盘点、报损报溢、库存预警。销售模块销售订单、销售退货、收银台、处方药登记。报表模块销售统计、进销存报表、毛利分析、效期预警列表。模块间通过数据库事务和消息事件同步比如采购入库后库存增加报表模块通过统计查询实时读取不需要引入消息队列。真正的模块边界体现为“不要跨Service互相调用对方Mapper”而是调用对方提供的Service方法。Spring Boot默认的单体架构对这种规模完全合适强行上微服务反而增加维护成本。等药店连锁到上百家再考虑按店铺维度拆服务也不迟。2. 数据库设计与核心表结构2.1 药品信息与库存设计药品信息表是整个系统的地基需要把通用字段和药店特有字段分开。通用字段包括药品编码、名称、规格、单位、生产厂家、批准文号、剂型、分类药店特有字段包括处方药标识、医保类型、存储条件、GSP验收状态、零售价、采购价。这里要特别强调药品编码不能只用自增ID建议使用国药准字号或者企业内部唯一编码否则不同厂家同名药品会在库存里混在一起。库存设计是药店系统的技术难点。如果你只建一张简单的药品库存表用库存数量字段存总数很快就会发现批次和效期根本没法管理。一旦某批药品临期需要先出系统必须能定位到具体批次。我的做法是拆两张表药品总库存表负责快捷查询剩余总量批次库存表负责每批药品的入库时间、生产日期、有效期、库存数量、供应商信息。这样前台展示列表走总表出库时从批次表按先进先出或近效期先出规则锁行扣减。库存表结构核心字段只需记住几个主键ID、药品编码、批号、生产日期、有效期至、库存数量、锁定数量、可用数量、单位进价、单位售价、仓库位置。可用数量等于库存数量减锁定数量这个锁定数量在创建销售订单时会用到避免多人同时下单导致超卖。表上建立联合索引药品编码批号有效期至查询效率会提高很多。还有一个容易忽略的点药店GSP认证要求药品验收记录、温湿度记录、养护记录等系统里最好预留一个gsp_record表至少把验收结论、验收人、复核人字段留出来。哪怕第一期不做数据库字段先设计好后面扩展时就不用大改表结构。2.2 效期与批次设计效期管理是药店管理系统区别于普通进销存的核心。药品有效期通常精确到年月日但生产日期往往精确到日有些药品标注有效期至2026年9月有些标注至2026年9月30日系统里统一用date类型存有效期至不做模糊处理。展示层可以格式化存储层必须精确。为什么因为效期预警规则要用日期比较模糊字符串会导致排序和比较全部出错。批次表记录入库时的原始效期每次出库时按规则扣减对应批次。先进先出是最基本的规则药店实际更常用“近效期先出”也就是按有效期升序扣减。对有效期特别短的药品还要设置一个停售阈值比如距有效期30天内只提醒、15天内禁止销售具体阈值可以在系统配置表里维护。这里我提供一段常用的批次扣减查询SQL思路先按药品编码查询所有可用数量大于0的批次按有效期升序排序再逐条扣减直到满足销售数量。SELECT batch_id, batch_no, expire_date, available_qty FROM drug_batch_stock WHERE drug_code #{drugCode} AND available_qty 0 ORDER BY expire_date ASC FOR UPDATE;FOR UPDATE是为了在数据库层面锁住这些行防止两个请求同时扣同一批次。虽然Redis扣减库存更快但批次出库这种场景还是数据库锁更稳妥。这里有个细节如果只锁第一行但第二行也在扣减范围必须把所有需要扣减的行都查出来并锁定否则并发下另一笔订单可能读到不一致的旧数量。2.3 订单、采购、销售流程设计采购订单和销售订单建议分开设计但字段风格保持一致。采购单要有采购单号、供应商、采购员、审核状态、采购总金额采购单明细要有药品编码、批号、数量、进价、生产日期、有效期。这里最容易忽略的是同一个采购单里同一药品可能因为不同批次拆成多行不要用药品编码做唯一约束要用药品编码加批号加有效期联合处理。销售订单同样需要主表和明细表。销售主表记录收银员、顾客信息、处方药标识、总金额、实收金额、找零金额、支付方式销售明细表记录药品、数量、单价、成本价、关联的批次号。保留成本价非常重要因为后续毛利报表必须用销售收入减去对应批次的采购成本。很多新手只存售价月底对不上毛利就是因为没有在明细里冗余成本价。销售退货是另一个常见漏洞点。退货需要原路找到销售明细和批次把库存加回去同时生成一笔负数销售记录或者单独的退货单。我建议退货单独建表至少保留原销售单号、退货原因、经手人、退货金额这样既方便统计销售净额也方便未来处理客诉和药监检查。3. 核心功能实现与实操要点3.1 Spring Boot项目初始化与必要依赖初始化一个药店管理系统的Spring Boot项目我推荐直接去Spring Initializr网站生成基础工程避免在IDEA里手动建项目时版本选择出问题。Java版本选8或11都行Spring Boot选2.7.x还是3.x需要权衡3.x要求Java 17以上并且javax.servlet包改成了jakarta.servlet如果你习惯老写法直接上3.x容易在复制代码时踩命名空间坑。做毕设或中小项目我用2.7.18比较多稳定坑少网上资料也全。pom.xml里必加的依赖包括spring-boot-starter-web、spring-boot-starter-validation、mybatis-plus-boot-starter、mysql-connector-java、spring-boot-starter-security、jjwt、lombok。如果要做缓存再加spring-boot-starter-data-redis。MyBatis-Plus在药店管理系统里非常实用内置的BaseMapper提供增删改查配合LambdaQueryWrapper写条件查询能少写大量重复SQL。自定义复杂报表SQL走XML文件两种方式混合使用即可。启动类不要写任何业务代码就一个标准入口。配置application.yml时数据源参数要单独拆出来不要硬编码。我习惯把数据库连接、Redis连接、JWT密钥都放到application-prod.yml里本地开发用application-dev.yml部署时通过spring.profiles.active切换。这样换环境不用改动代码。JWT密钥至少要64位随机字符串直接写在配置里我也干过后来发现Git仓库泄露会连带密钥泄露正确做法是用环境变量注入。3.2 登录与权限控制JWT Spring Security药店系统必须区分角色至少包括系统管理员、店长、收银员、药师。收银员能开销售单但不能查看成本价和采购价药师能审核处方、查看处方药销售记录店长能看报表和库存调价管理员管账号权限。很多毕设把权限做成前端按钮隐藏但真正的安全控制必须放在后端。用户登录后返回JWT令牌前端每次请求带上Authorization请求头后端通过Spring Security过滤器链统一校验。自己从零封装JWT解析并不复杂核心逻辑是登录成功后生成一个包含用户ID、角色、过期时间的令牌在每次请求时解析并放入SecurityContext。推荐使用jjwt库代码比较简洁。需要注意一套常见问题Spring Security的默认过滤器链会拦截所有请求你得在SecurityConfig里放行登录接口、静态资源和Swagger路径否则前端调登录都进不来。Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login, /swagger-ui/**, /v3/api-docs/**).permitAll() .antMatchers(/api/sale/**).hasAnyRole(CASHIER, ADMIN) .anyRequest().authenticated() .and() .exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint); http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }这里要提醒加了Spring Security之后所有API默认都需要认证排查的时候要区分是浏览器缓存问题还是令牌过期问题。我调试时喜欢在过滤器里加一个临时日志打印每个请求的URI和认证状态定位问题会快很多。另外一个细节JWT过期时间建议设短一些比如4小时刷新令牌逻辑先不实现但数据库里维护用户状态字段账号被禁用时即使JWT没过期也要在过滤器里手动检查避免离职员工拿着旧令牌继续访问。3.3 药品库存扣减与并发控制库存扣减是药店系统里最容易出并发问题的地方。最简单的实现是查询库存数量判断充足后做减法但这套逻辑在多人同时收银时必然出现超卖。我见过一个真实案例一个药店搞会员日85折活动同一款感冒灵在10秒内被下单50笔库存只剩30盒结果系统照单全收后面手工退单退到崩溃。解决思路有几个层次。单个服务实例下最稳的做法是使用数据库乐观锁或悲观锁。乐观锁在库存表加version版本号更新时检查version是否匹配UPDATE drug_stock SET available_qty available_qty - #{qty}, version version 1 WHERE drug_code #{drugCode} AND version #{oldVersion} AND available_qty #{qty};如果更新影响行数为0说明有人改过数据或者库存不足业务层抛出异常提示重试。悲观锁则直接SELECT FOR UPDATE锁行简单直接。还有更常见的方案是把库存扣减操作原子化放进RedisLong remain redisTemplate.opsForValue().decrement(stockKey, qty); if (remain 0) { redisTemplate.opsForValue().increment(stockKey, qty); throw new BusinessException(库存不足); }但Redis扣减后必须异步同步回MySQL这个过程又涉及数据一致性。对于药店这种并发量不会有秒杀那么极端的场景我推荐数据库乐观锁加事务就够了坏处是并发时部分请求会冲突重试但胜在数据不会错。如果非要上Redis至少用Redisson的分布式锁给药品编码上锁或者用Lua脚本保证原子性别用简单的get-then-set。3.4 报表与统计实现报表功能看着简单写不好会拖垮数据库。药店每天销售流水几千条生效期后计算月度汇总用Java内存分组再返回前端也不是不行但数据一多响应就慢。我更推荐直接用SQL做聚合让MySQL算完再返回。比如查询近30天每日销售额SELECT DATE(create_time) AS day, SUM(total_amount) AS amount FROM sale_order WHERE create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND status PAID GROUP BY DATE(create_time) ORDER BY day;复杂报表比如按药品分类统计销售排行可以先用MyBatis-Plus的selectMaps写自定义SQL返回ListMapString, Object再在Service层组装成前端需要的JSON结构。不要直接在Controller里执行EntityManager查询污染控制器也不方便复用。如果需要导出Excel用EasyExcel写一个导出工具类一行注解就能把字段映射成列头比Apache POI手写样式少很多坑。报表的性能关键在索引。销售订单表的订单时间、状态明细表的药品编码这些字段一定要建索引。统计数据时尽量避免全表扫描。还有一个小技巧报表查询接口不要强制实时可以用带条件的Redis缓存比如库存超过30分钟的销售报表直接走缓存店长手动刷新时才重新计算。4. 常见问题与排查技巧实录4.1 批次效期管理容易踩的坑第一个坑是误把生产日期当效期算。药品入库时如果生产日期和有效期是分开录入的就要明确“有效期至”字段是最终日期。有些药品写“有效期24个月”你需要在入库时自动计算有效期至而不是靠人手工填。我在项目里写过一个简单规则如果导入数据只有生产日期和有效期月数系统自动生成expireDate并且拒绝未来入库时已经过期的药品。第二个坑是批次扣减顺序搞错。先进先出和近效期先出的区别在业务上有严格意义如果同一药品第一批有效期还有两年第二批还有三个月先进先出会把第一批先卖掉导致第二批过期报废。药店必须配置成近效期先出按expire_date升序扣减。我见过一个药店系统按入库时间升序扣批次差点让一批快到期的药压在库里这属于业务规则设计失误不是代码bug。第三个坑是效期预警阈值没有做进数据库。有人用定时任务每天扫描所有批次把三个月内到期列表推送出来。定时任务本身没问题但如果没有阈值差异化配置所有药品都按90天预警冷链药品和普通药品就会混淆。实际中我在系统配置表里维护不同药品分类的预警天数每天定时任务生成预警记录而不是直接在查询里写死。4.2 Spring Boot版本与依赖冲突Spring Boot版本太高不一定好用特别是遇到旧版MyBatis-Plus和Spring Boot 3.x时分页插件可能直接失效因为底层接口变了。如果你在配置中心或者博客上复制一段旧代码先确认Spring Boot主版本。热词里有人问“Spring Boot版本太高怎么降级”实际最稳妥的方法是Spring Initializr重新选版本然后对比pom.xml差异。依赖冲突的典型症状是NoClassDefFoundError或者Bean创建失败。排查方法很土但有效先在IDEA的Maven面板执行mvn dependency:tree看看报错类在哪些jar包里有多个版本再逐个exclude掉不是目标版本的传递依赖。顺便提一句如果引入minio、activemq这类组件注意starter版本是否和Spring Boot版本兼容。很多jar包在2.x下正常3.x下就因为javax到jakarta迁移而报错。还有一个小细节Spring Boot项目启动时报“Invalid block tag”通常不是代码错误而是application.yml里缩进和注释格式不对。YAML对空格极其敏感不要在属性值后面加中文注释改用单独一行注释。4.3 高并发库存超卖问题前文提过乐观锁和Redis方案这里再说说事务边界。Transactional放在Service实现类的方法上一定要确保该方法被代理调用不能同类内部调用。有次我把一个普通方法加了Transactional然后在同一个Service里被另一个方法调用事务根本没生效直到出现数据不一致才发现。解决办法是拆一个独立的InventoryService或者在事务方法内部调用事务Mapper。超卖的另一种场景是销售订单创建和库存扣减不在同一事务里。正确顺序是创建订单明细尝试扣减批次库存全部成功后提交。如果先扣库存再创建订单订单失败时库存就少了如果先创建订单再扣库存库存不足时订单就会留下脏数据。建议把订单创建和库存扣减放在一个Service方法里用事务保证要么全成功要么全回滚。排查超卖问题时不要只盯代码先检查是否出现了唯一索引缺失。如果订单号重复也能插入业务层再严谨也会被并发穿透。给订单表加上唯一订单号索引失败时让数据库给出明确错误比业务代码默默吞掉异常更能快速发现问题。4.4 部署与运维实用建议药店管理系统的部署环境一般是一台4核8G的云服务器推荐使用Docker Compose一键编排后端、MySQL、Redis。不要在生产库使用root账号单独创建药店系统专用数据库账号密码用环境变量传入。这是我实际遇到过的问题测试环境数据库被扫库攻击因为端口暴露且口令太弱日志里全是连接失败记录。Spring Boot项目打包用mvn clean package -Dmaven.test.skiptrue生成可执行jarDockerfile用多阶段构建先maven镜像编译再jre镜像运行。这样镜像小很多启动也快。如果服务器内存紧张JVM参数务必加上-Xms256m -Xmx512mSpring Boot默认堆大小是物理内存四分之一在1G内存的小机器上不限制可能直接OOM。部署后要做的第一件事不是看功能而是配好日志清理。Spring Boot默认日志会一直涨建议用logback-spring.xml配置按天滚动和保留30天。接口层面的错误日志必须包含请求参数和堆栈否则线上问题排查基本靠猜。我习惯在全局异常处理器里统一把异常信息打到logger.error并在响应里只返回“系统异常请稍后重试”避免把SQL细节暴露给前端。最后再分享一个我保存至今的项目习惯数据库迁移脚本用Flyway管理每次改动表结构都新增一个V开头的SQL脚本不要手动去生产库执行ALTER TABLE。这样项目从开发到灰度表结构永远一致药店系统后续要加医保对接功能时改动起来也不用担心环境差异。做药店管理系统这类项目最大的收获不是把CRUD写熟练而是理解了“业务规则驱动技术选型”这句话。效期管理、批次扣减、库存一致性每一个难点背后都对应着真实门店里的经营痛点。你拿着Spring Boot和Java这套技术栈如果只做表面增删改查几天就能写完但要把药品进销存做扎实需要花大量时间跟业务人员核对规则。我个人建议是先跑通一个最小闭环再逐步加报警机制和报表优化那样才不容易被复杂的GSP规则拖垮节奏。