每年毕业季,总有一批做“药房管理系统”的同学来找我调代码,题目大同小异,踩坑却惊人地一致:库存扣成负数、订单状态对不上、药品编码重复、数据库表字段乱得连跑都跑不起来。这个选题表面看就是标准的SpringBoot增删改查,但如果你真的只把CRUD堆出来,答辩老师随便追问一句“并发下单时库存怎么控制”“为什么用Redis缓存药品数据”,当场就容易卡住。所以这篇不打算只贴一堆代码片段,而是把基于SpringBoot的药房管理系统从需求拆解、技术选型、数据库设计、核心模块实现到部署上线的完整思路过一遍,重点讲清楚每一步为什么这么设计,以及哪些坑必须提前避开。想在毕业设计里把这个项目做到真正能跑、能讲、能拿得出手的同学,这篇可以直接跟着走。
1. 项目概述与需求拆解
1.1 这套系统到底在解决什么问题
药房管理系统绝不只是“药品增删改查”。真实药房的日常运营里,至少会涉及采购入库、库存管理、前台销售、处方记录、供应商结算、药品效期预警这一整条链路。标题里的“智能药房运营管理平台”和“药品管理与交易一体化系统”,说白了就是让药品从供应商进入库房,再到销售出库给患者,全流程都有数据记录,库存和销售最终能对上账。
做需求拆解时,最忌讳一上来就画一堆模块图然后闷头写代码。先把角色想清楚:系统里至少要区分管理员、收银员/药师,高级一点再加供应商端。管理员看的是经营日报、库存总览、利润统计;收银员只关心收银下单、库存查询、会员折扣;药师需要处理处方药审核。角色权限不提前设计好,后面所有功能页面都会变成“谁都能进”,这部分到了答辩时会被老师第一个盯上。
1.2 功能模块怎么划分才合理
标准的功能清单大概长这样:
- 用户与权限:登录、JWT鉴权、角色权限、密码修改。
- 药品档案管理:药品分类、药品基本信息、厂家规格、处方药与非处方药标记、上下架状态。
- 库存管理:采购入库单、销售出库、库存查询、库存预警、效期预警。
- 销售与交易:购物车、收银台、订单生成、模拟支付、退货退款、小票打印。
- 供应商管理:供应商档案、采购记录、结算单。
- 统计报表:今日销售额、销售趋势、药品销售排行、库存周转、利润统计。
模块划分时需要注意一个原则:不要每个模块孤立地写成一个独立表单。比如“入库单”保存后必须同时影响药品库存和供应商的待结算金额;“销售订单”生成后要同时扣减库存、记录库存流水、更新当天销售统计。很多初版项目做到最后数据对不上,就是因为各模块之间没有打通,各写各的。
1.3 核心业务流程先梳通再编码
写代码以前,一定先把主流程画清楚。
采购入库流程:创建采购单,关联供应商,生成入库单,更新药品库存,更新采购单状态。
销售出库流程:收银台选择药品加入购物车,校验库存是否足够,生成订单主表与明细,扣减库存,处理模拟支付,写库存变动流水,最后打印小票。
这两条流程里,库存扣减是重中之重的环节。我这里先放一个结论:不要用“Java里先查询库存再判断够不够”这种写法,并发场景下必出问题。至于具体怎么改,后面在数据库设计和核心模块实现部分会展开讲。前端页面也别一开始追求花哨,管理端可以用Vue加Element Plus,如果前端基础弱,先用SpringBoot自带的Thymeleaf模板引擎把业务跑通,后期再升级成前后端分离,完全来得及。
2. 技术选型与核心架构设计
2.1 技术栈为什么选这一套
毕设项目里最稳的组合就是SpringBoot加MyBatis-Plus加MySQL加Redis,前端配Vue或Thymeleaf。SpringBoot解决了配置地狱,内嵌Tomcat让项目直接以Jar包运行;MyBatis-Plus把单表CRUD代码量压缩到极小;MySQL免费且资料多;Redis既做热点缓存,也为后面做库存预扣减留了余地。这套技术栈在Java岗位招聘里也是高频出现,做完放进简历不虚。
特别提醒版本问题:SpringBoot建议用2.7.x,对应JDK8到JDK11,配合MyBatis-Plus 3.5.x很成熟。不要盲目上SpringBoot 3.x,因为3.x要求JDK17,一些学校机器和旧教程还停留在JDK8,依赖版本对不上就会出现一堆诡异错误。如果本机装的是JDK17,又拿到一个SpringBoot 2.x项目,可以在pom里指定Java版本为17,很多情况下也能跑,但最稳妥的做法还是统一到JDK8或者JDK11。
2.2 项目分层与包结构设计
一个清晰的包结构,能从结构上减少后期维护成本。我习惯这样组织:
com.pharmacy ├── common // 通用返回、异常处理、常量 ├── config // Redis、拦截器、跨域、MyBatis-Plus配置 ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 请求和响应对象 ├── vo // 视图对象 └── utils // JWT、日期、订单号生成等工具为什么要把DTO和VO单独分层?因为数据库实体类里往往有密码、进价、库存内部字段等敏感信息,直接返回给前端既不安全也不优雅。收银台提交订单时,前端只需要传药品ID和数量,后端根据数据库里的实时价格重新计算总金额,绝对不要信任前端传过来的金额字段,否则抓包改一下价格就变成一分钱买一盒药。
2.3 核心配置文件示例与解释
application.yml是SpringBoot项目启动的关键,我直接给一份基础配置:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/pharmacy?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto jwt: secret: pharmacy-token-secret-key-please-change expire: 86400这里有几个细节。第一,数据库连接URL里必须加serverTimezone=Asia/Shanghai,不然MySQL 8会报时区错误。第二,map-underscore-to-camel-case这个配置很关键,如果数据库字段叫drug_name,实体类字段叫drugName,不开这个配置,查询结果全是null。我之前帮人排查过类似问题,代码逻辑看着完全没问题,最后就是少了这一行配置。第三,MyBatis-Plus的日志配置建议开发环境打开,这样能在控制台看到真实执行的SQL,联调时排查问题效率高很多。
2.4 登录鉴权与全局异常处理
登录鉴权推荐JWT,而不是传统Session。JWT无状态,适合前后端分离,同一个token可以给管理端Web、收银端、甚至后续的小程序端共用。登录接口核心逻辑是:校验用户名密码,查询用户角色,生成token返回给前端。前端每次调接口时在请求头里带Authorization: Bearer xxx,后端写一个拦截器解析token,解析通过后把用户ID放在ThreadLocal里,后续业务代码里随时能拿到当前操作人。
全局异常处理用@RestControllerAdvice,把业务异常、参数校验异常、数据库唯一键冲突、未知异常统一包装成统一的JSON返回结构。比如前端看到code=500且message为“库存不足”,就可以弹提示框。没有全局异常处理的话,系统里任意一个小错误都会抛出一大段默认异常信息,既难看又不安全。我给项目定的返回结构一般是这样:
{ "code": 200, "message": "success", "data": {} }业务异常直接抛BusinessException,全局处理器捕获后把message替换成业务提示。这个设计看着简单,但能省下大量Controller里重复的try-catch代码。
3. 数据库设计与业务建模
3.1 核心表结构设计
数据库设计决定业务能做多深,我给出最核心的几张表。
药品表drug:
- id:主键,bigint自增
- drug_code:药品编码或条码,唯一索引
- drug_name:药品名称
- category_id:分类ID
- manufacturer:生产厂家
- spec:规格
- unit:单位,如盒、瓶、支
- approval_number:批准文号
- is_prescription:是否处方药,0否1是
- price:零售价,decimal(10,2)
- purchase_price:进价,decimal(10,2)
- stock:当前库存
- stock_warn:库存预警阈值
- expire_date:有效期
- status:上下架状态
- create_time:创建时间
用户表sys_user:账号、密码、真实姓名、角色、手机号、状态、创建时间。
库存变动表stock_log:药品ID,变动类型(入库IN、销售出库OUT、调整ADJUST),变动数量,变动前库存,变动后库存,关联订单号,操作人,备注,创建时间。
订单主表orders:订单号,总金额,优惠金额,实付金额,支付方式,订单状态,会员ID,收银员ID,操作人,创建时间。
订单明细表order_item:订单ID,药品ID,药品名称,单价,数量,金额。
这套表结构里,stock_log价值很高。如果只有drug表当前库存,库存对不上时完全没法排查;有了stock_log,每次变动都记录before_stock和after_stock,哪笔销售导致库存少了,一查便知。答辩被问到“怎么保证账实相符”时,这张表就是最好的回答。
3.2 字段规范与索引设计
字段类型有几个硬性规范。金额一律用decimal(10,2),不要用double或float。double计算订单金额会出现31.829999999998这种结果,处理起来非常恶心。库存数量用int,但在代码里要用Integer而不是int,避免拆箱空指针。
订单号order_no不要用数据库自增ID直接展示,因为容易被猜到业务量,也容易在多个表里混淆。我常用的生成规则是日期加随机序列加用户ID后四位,例如:202506101430001000123。这个格式可读性好,也方便按日期排查。
索引方面,drug_code加唯一索引,扫码枪扫条码时直接命中;orders表的cashier_id和create_time建联合索引,经营报表按日期范围统计时不会全表扫描;order_item表给order_id加普通索引,查询订单明细时能快速返回。索引不是越多越好,但核心查询路径上的索引一定要建。
3.3 并发扣库存的正确写法
这是整个系统最容易翻车的地方。直接用下面这种写法,并发下绝对出问题:
Drug drug = drugMapper.selectById(drugId); if (drug.getStock() < quantity) { throw new BusinessException("库存不足"); } drug.setStock(drug.getStock() - quantity); drugMapper.updateById(drug);两个人同时买同一个药品的最后一份库存,两个请求都先查到了stock=1,都判断库存充足,然后都执行更新,最后库存变成0或-1。解决方式是用带条件的UPDATE:
UPDATE drug SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}Java这边这样写:
int rows = drugMapper.reduceStock(drugId, quantity); if (rows == 0) { throw new BusinessException("库存不足"); }影响行数为0,说明库存不够,直接抛提示。这种写法叫乐观锁思路的变体,简单可靠。如果还想扛更高并发,可以用Redis预扣减,也就是先在Redis里检查并扣减库存,再用异步消息或者定时任务同步到数据库,但复杂度明显上升,基础一般的同学不建议在毕设里冒险。能回答出“条件UPDATE防超卖”,已经比大部分项目强了。
4. 核心模块的实现思路与代码落地
4.1 药品管理模块:CRUD也能做出亮点
药品管理看起来就是标准CRUD,但要做好几个细节。分页查询用MyBatis-Plus的分页插件,先注册拦截器:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后配合LambdaQueryWrapper写查询。注意条件为空时不拼进SQL,这种写法能避免大量的if判断:
Page<Drug> page = drugMapper.selectPage(new Page<>(pageNum, pageSize), new LambdaQueryWrapper<Drug>() .like(StringUtils.hasText(keyword), Drug::getDrugName, keyword) .eq(categoryId != null, Drug::getCategoryId, categoryId) .eq(Drug::getStatus, 1) .orderByDesc(Drug::getCreateTime));药品图片上传要单独说。文件保存时不要用用户上传的原始文件名,否则可能遇到路径穿越和XSS问题。更稳的做法是生成UUID文件名,保存到本地指定目录或者OSS,数据库只存访问路径。文件上传目录要放到项目外部或者配置可配置路径,不要直接放在jar包同级目录下,否则重新部署后文件就丢了。
4.2 库存预警与效期预警
库存预警是运营系统里非常出彩的功能。逻辑不复杂,在启动类开启定时任务:
@EnableScheduling @SpringBootApplication public class PharmacyApplication { public static void main(String[] args) { SpringApplication.run(PharmacyApplication.class, args); } }定时任务的写法:
@Scheduled(cron = "0 0 8 * * ?") public void checkStockWarn() { List<Drug> drugs = drugMapper.selectList( new LambdaQueryWrapper<Drug>().apply("stock <= stock_warn")); warnService.generateWarnRecords(drugs); }效期预警要算到期天数,用LocalDate处理最合适:
long days = ChronoUnit.DAYS.between(LocalDate.now(), drug.getExpireDate()); if (days <= 90 && days > 30) { // 黄牌提醒 } else if (days <= 30) { // 红牌提醒 }这个预警不能只出现在后台列表,收银台下单时也要做二次校验。如果药品即将过期,弹确认框让收银员确认。医疗相关系统里,“效期安全”是非常能体现设计能力的地方,一定要做。
4.3 收银台与订单交易流程
收银台是“药品管理与交易一体化”的主战场。前端展示药品列表,选药加入购物车,确认后提交订单。后端接口建议尽量精简:
@PostMapping("/order/create") public Result createOrder(@RequestBody @Valid CreateOrderRequest request) { CreateOrderResult result = orderService.createOrder(request); return Result.success(result); }订单Service里建议按这个顺序处理:
- 根据明细里的drugId查询最新药品信息。
- 校验药品是否上架、库存是否足够。
- 后端重新计算每个明细的金额和总金额。
- 生成订单号。
- 用事务保存订单主表和明细表。
- 扣减库存。
- 写库存变动流水。
- 更新当日销售统计。
整个方法加@Transactional(rollbackFor = Exception.class),任何一步失败,所有数据库操作全部回滚,避免出现“订单建了但库存没扣”之类的问题。这里有个高频坑:@Transactional默认只对RuntimeException回滚,如果自己在Service里捕获异常并吞掉,事务就不会回滚。所以Service内部不要随意try-catch,让异常直接抛出去,交给全局异常处理器。
支付环节在毕设里做模拟支付就够了。点击支付后直接标记为已支付,或者模拟生成二维码,两秒后自动回调。不要把真实商户号接进来,涉及资金安全问题,完全没有必要。
4.4 经营报表与Dashboard
给管理员首页做一个Dashboard,可以包含今日销售额、今日订单数、库存预警数、近七天销售折线图、药品销售Top10。前端用ECharts,后端提供统计接口:
@GetMapping("/dashboard") public Result dashboard() { Map<String, Object> data = new HashMap<>(); data.put("todaySales", orderService.getTodaySales()); data.put("todayOrders", orderService.getTodayOrderCount()); data.put("stockWarn", drugService.countStockWarn()); data.put("expireWarn", drugService.countExpireWarn()); data.put("trend", dashboardService.getTrend(7)); data.put("topDrugs", dashboardService.getTopDrugs(10)); return Result.success(data); }注意,统计销售额时一定要限定订单状态为“已支付”。未支付订单不计入销售额,这是业务的基本规则。如果数据量大了,报表接口每次都实时sum会很慢,可以提前建一张daily_report表,每天用定时任务汇总前一天的数据,查询时直接查汇总表。毕设数据量小,实时sum也能跑,但代码里如果体现出“我考虑过性能优化”,答辩时是可以加分的。
4.5 小票打印与扫码枪集成
这两个功能不是必需,但做了之后项目整体质感会明显提升。
小票打印最简单的方法是前端渲染一张小票样式的HTML,然后调用window.print()。把订单号、药品明细、数量、单价、合计、收银员、时间排好版,调浏览器打印为A5纸大小。它的好处是不依赖硬件,无论针式打印机还是激光打印机都能兼容。
扫码枪本质上是一个键盘输入设备,扫描条码后会自动在焦点输入框里输入一串字符,通常后面带一个回车。所以药品列表页面只需要有一个drug_code输入框,监听到回车时触发查询接口即可。后端提供按drug_code精确查询的接口,扫码枪扫一下,药品信息自动带出来,收银效率高很多,“智能药房”的感觉一下就出来了。
5. 实操过程:从初始化到运行部署
5.1 用IDEA快速初始化SpringBoot项目
打开IDEA,用Spring Initializr创建项目。需要注意的配置项:Group填com.pharmacy,Java版本选择与本机JDK一致的版本,依赖先选Spring Web、MySQL Driver、Lombok。MyBatis-Plus和Redis的依赖如果在Initializr里没有,生成后手动加到pom.xml里。SpringBoot版本建议选2.7.18,这个版本修复了大量已知问题,兼容性也最好。
如果IDEA创建项目时网络卡顿,可以先只选Spring Web,生成后自己加依赖。项目生成后第一件事不是写业务代码,而是先配置好application.yml,跑一个空的Application,确认端口能正常启动。先跑通最小的工程,后面每一步出问题都容易定位。很多人一上来就堆代码,启动报错就怀疑人生,其实问题大多出在依赖版本或配置项上。
5.2 数据库初始化与初始数据
用Navicat或DBeaver新建pharmacy数据库,按依赖顺序执行建表SQL:先建sys_user、drug_category、drug;再建orders、order_item、stock_log。管理员初始账号建议写成admin,密码存BCrypt加密后的密文。密码一定要加密,如果表的密码字段里放的是明文,演示时会让老师印象分大减。
建完表后可以先造一批测试数据。药品表至少放几十种常见药品,覆盖不同分类、不同效期、不同库存状态,包括低库存和临期药品。没有测试数据的系统,页面看起来空荡荡,也很难演示预警和报表效果。
5.3 本地运行与接口自测
启动SpringBoot后,用Apifox或Postman测接口。顺序建议是:先登录拿token,再测药品分页,再测创建订单。最常遇到的问题是跨域:前端Vue跑在5173端口,后端跑在8080端口,浏览器会拦截。SpringBoot里需要配置CORS映射:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true); } }如果本地跑测试遇到数据库连不上的问题,优先检查URL里的serverTimezone和账号密码,然后看MySQL服务是否启动。日志里如果出现Communications link failure,大概率是MySQL没起来,而不是代码问题。
5.4 用Docker部署到服务器
想让项目有“部署经验”,可以写一个极简Dockerfile:
FROM openjdk:8-jdk-alpine WORKDIR /app COPY target/pharmacy-system-1.0.0.jar app.jar EXPOSE 8080 ENTRYPOINT ["java","-jar","app.jar"]构建并运行:
docker build -t pharmacy-server . docker run -d -p 8080:8080 --name pharmacy-app \ -e DB_HOST=mysql-container \ pharmacy-server这里有个高频坑:容器里访问数据库时,连接地址不能写localhost,要写MySQL容器的服务名或者服务器内网IP。本地跑得好好的,部署到服务器就连不上数据库,十有八九是这个问题。
前端项目npm run build打包后,可以用Nginx托管静态文件,并把/api开头的请求反向代理到后端8080端口。配置类似这样:
server { listen 80; root /usr/share/nginx/html; location /api/ { proxy_pass http://127.0.0.1:8080; } }这样一个域名加80端口就能同时访问前端和管理端API,项目看起来更像正式线上系统。
6. 常见问题与排查技巧实录
6.1 启动阶段的高频报错
毕设项目最常遇到的启动问题,我整理成了对照表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| The server time zone value 报错 | MySQL时区未设置 | 连接URL加serverTimezone=Asia/Shanghai |
| Failed to configure a DataSource | 数据源配置缺失或依赖冲突 | 检查application.yml和pom依赖 |
| Port 8080 was already in use | 端口被占用 | 换端口运行,或kill占用进程 |
| Invalid bound statement | Mapper接口和XML没对应 | 检查namespace、接口方法名、扫描路径 |
| 查询结果全部为null | 驼峰映射未开启 | 配置map-underscore-to-camel-case: true |
“Invalid bound statement”出现频率最高,原因是Mapper接口和XML没有正确映射。建议启动类上加@MapperScan("com.pharmacy.mapper"),XML文件放在resources/mapper目录下,保持文件名与接口名一致。
6.2 数据库相关与数据一致性
删除药品时如果出现外键报错,说明订单明细还引用这条记录。更好的做法是给drug表加逻辑删除字段deleted,MyBatis-Plus里用@TableLogic注解,查询时自动过滤,历史订单数据也不会断。物理删数据在进销存系统里是大忌。
药品编码唯一索引冲突时,不要只在数据库层面报错。后端新增药品前先按drug_code查一次,存在则提示“药品编码已存在”。这样前端能看到友好提示,而不是一堆Duplicate entry的英文异常。
6.3 业务逻辑Bug盘点
以下问题基本每个项目都会碰到一次:
- 库存减成负数:用条件UPDATE防超卖,而不是先查再改。
- 订单金额不对:不要信前端传的总价,以后端重算为准。
- 操作了Redis但数据库没同步:检查Service方法里是否真的调用了Mapper更新,Redis只是缓存,不能当持久化存储。
- 报表数据不对:确认统计是否只包含“已支付”状态订单,草稿和未支付订单不该计入销售额。
- 分类删不掉:如果分类下已有药品,后端要先拦截,提示先转移或停用药品,再删除分类。
6.4 答辩前必准备的高频问题
答辩不只看代码,更看你能不能把自己的设计讲清楚。
老师问“为什么用SpringBoot”,不要只说“方便”,要说它通过自动配置减少了大量XML配置,内嵌Tomcat让应用可以独立运行,配合Spring生态可以快速实现Web开发。
老师问“怎么保证数据一致性”,这一题很经典,要回答:订单创建涉及订单表、库存、库存流水等多张表,使用@Transactional事务保证原子性,任何一步失败都整体回滚,库存扣减使用带条件的UPDATE防止超卖。
老师问“JWT和Session有什么区别”,可以从状态存放位置的角度讲:Session存储在服务端,客户端只保存SessionID;JWT存储在客户端,服务端不保存状态,更适合前后端分离和分布式环境,但JWT也有问题,就是token不能主动失效,所以做修改密码或强制下线时要额外设计。
把这些准备充分,就算项目本身不是特别复杂,回答也能让人觉得你确实吃透了这套系统。
最后分享一个我自己的习惯:交付毕设前,把数据库初始化SQL、README运行文档、完整演示流程都整理好,最好再录一段演示视频。这不仅是给老师看,也是自己重新梳理项目逻辑的过程。药房管理系统这个选题很经典,但经典不代表简单,把库存、订单、权限、报表这条链路全部打通,你学到的东西会远远超过“会用SpringBoot写CRUD”。