最近在整理新一年的毕设项目资源,600套项目筛下来,真正让我觉得值得单独拿出来聊一聊的,SpringBoot做的“智慧药店药品信息管理系统”算一个。编号14099,在整批项目里属于典型的“行业属性管理系统”,表面看是增删改查,实际上藏着药品批号、有效期、库存流水、销售扣减这些药店业务逻辑,难度又没有明显超标,对准备做Java方向毕设的同学来说,性价比相当高。
这套系统解决的问题很具体:药店里的药品到底该怎么管。普通商品只需要管数量和价格,药品却绕不开批准文号、生产批号、生产日期、有效期、供应商、库存上下限、采购价和售价,还得有近效期预警和低库存预警。把这些逻辑理清楚,项目就有了真实业务做支撑,答辩时讲“为什么这样设计”也会顺畅得多。
适合三类人看:一是还没定题、正在纠结做什么的Java方向学生;二是已经选了管理系统方向、想把项目做出区分度的人;三是想系统学习SpringBoot项目结构,但又不想看一堆脱离业务的demo代码的开发者。下面我按实际做项目的顺序来拆:选题思路、数据模型、核心实现、排错经验、答辩准备,一路讲到底。
1. 选题思路与整体设计:为什么“智慧药店”值得做
1.1 同类管理系统对比:药店这个主题差在哪
管理系统类毕设年年都有,图书管理、学生管理、仓库管理早就被写烂了。老师看到这类题目基本没有新鲜感,答辩时问题也容易往深处扎:“你的系统解决了什么实际问题?”“库存是怎么扣的?”“并发同时下单会不会出错?”这些追问,很多同学在普通CRUD项目里根本没有考虑过。
药店药品信息管理不一样,它的业务约束天然比普通商品管理复杂。第一,药品档案字段多,而且有行业规范,批准文号、生产批号、规格、单位、厂家、供应商都得有,还必须带有效期。第二,库存进出要讲究批号和效期,不能像普通库存那样随便扣一个数,销售时一般按“近效期先出”的原则来处理。第三,药店信息化有合规的影子,低库存要预警,近效期要提示,过期药品不能销售,这些约束恰好是项目深度的来源。
所以,同样是SpringBoot写一套系统,选药店这个主题,业务复杂度恰到好处。不会简单到没东西可讲,也不会复杂到短时间做不完。对毕设来说,这是很合适的“中间难度”题目。
1.2 功能模块划分:先跑通主链路,再来谈大而全
刚拿到这种题目,很容易犯的一个错是一上来就想着把会员管理、统计报表、电子处方全塞进去,最后每个模块都做了一半,没有一个能完整讲清楚。我的建议是先圈定核心闭环,跑通之后再扩展。
核心模块可以这样划分:
- 基础数据:药品分类、药品厂家、供应商。
- 药品档案:药品编码、名称、规格、批准文号、批号、生产日期、有效期、采购价、售价、库存上下限、上下架状态。
- 采购入库:入库单、入库明细,入库后自动增加对应药品的库存,并写一条库存流水。
- 销售出库:销售单、销售明细,逐条校验库存和有效期,扣减库存并写流水。
- 库存预警:低库存药品列表、近效期药品列表。
- 系统管理:用户登录、角色权限、修改密码。
第一版先把“药品档案—采购入库—销售出库—库存预警—库存流水”这条主链路跑通。统计报表、会员管理、处方登记都是增量功能,等主链路稳定了再加,整体节奏就不会失控。“智慧”这两个字,不需要靠炫酷界面去体现,数据完整、流程严谨、预警及时,就是最实在的智慧。
1.3 技术栈选型:这套组合为什么不容易翻车
SpringBoot不用多说,Java毕设的绝对主流。它内嵌Tomcat,不需要单独部署容器,自动配置帮我们省掉大量XML配置,一个jar包就能跑。版本选择上要特别注意:如果本地是JDK 8,就用SpringBoot 2.7.x;如果能装JDK 17或21,直接用3.x也没有问题。网上很多“项目跑不起来”的求助,八成是版本错配导致的,这一点第三章会详细排雷。
持久层我推荐MyBatis-Plus,而不是原生MyBatis。单表CRUD不需要手写SQL,内置BaseMapper直接用,内置分页插件也相当方便。药品管理这种业务,单表操作占大多数,MyBatis-Plus能把开发量砍掉三分之一。手写SQL集中在组合查询、统计报表这些地方就够了,没必要到处写XML。
数据库用MySQL,5.7和8.0都行。要不要上Redis?毕设阶段不是必须的,但想拉高项目层次,后面用Redis缓存药品分类和热点药品,属于锦上添花,不影响主流程。前端可以用Vue3+Element Plus做前后端分离,也可以用Thymeleaf做服务端渲染。时间紧张就用Thymeleaf,想展示前后端联调能力就选Vue3,两种方案都有大量现成项目可参考,不存在“选错”的问题。
2. 核心业务拆解:数据模型与关键逻辑
2.1 数据库设计:一张药品表远远不够
先看药品主表,字段设计直接影响后面所有功能。我给的版本是这样的:
CREATE TABLE `drug` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `drug_code` varchar(32) NOT NULL COMMENT '药品编码', `drug_name` varchar(100) NOT NULL COMMENT '药品名称', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `manufacturer_id` bigint(20) DEFAULT NULL COMMENT '厂家ID', `supplier_id` bigint(20) DEFAULT NULL COMMENT '供应商ID', `specification` varchar(50) DEFAULT NULL COMMENT '规格', `unit` varchar(10) DEFAULT NULL COMMENT '单位', `approval_number` varchar(50) DEFAULT NULL COMMENT '批准文号', `batch_number` varchar(50) DEFAULT NULL COMMENT '生产批号', `production_date` date DEFAULT NULL COMMENT '生产日期', `expiry_date` date DEFAULT NULL COMMENT '有效期至', `purchase_price` decimal(10,2) DEFAULT NULL COMMENT '采购价', `sale_price` decimal(10,2) DEFAULT NULL COMMENT '售价', `stock` int(11) DEFAULT '0' COMMENT '当前库存', `stock_lower_limit` int(11) DEFAULT '0' COMMENT '库存下限', `stock_upper_limit` int(11) DEFAULT NULL COMMENT '库存上限', `status` tinyint(1) DEFAULT '1' COMMENT '状态 1上架 0下架', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_drug_code` (`drug_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品信息表';除了drug表,整个系统还需要几组配套表:drug_category药品分类表、manufacturer厂家表、supplier供应商表,这三张属于基础资料表;stock_in和stock_in_item是入库单主表和明细表;sale_order和sale_order_item是销售单主表和明细表;最后再加一张stock_log库存流水表和sys_user用户表。业务拆开成多张表,而不是什么东西都放一张大表里,数据库设计这一项答辩时就有内容可讲。
这里我想特别强调一下库存流水表。每次库存变动,入库加库存、销售减库存,都要在stock_log里记一条记录,包含药品ID、变动类型、变动数量、操作时间。这张表是系统“有据可查”的证明,也是后续对账的底表。很多管理系统项目会漏掉这个设计,你把它加上,项目的完整性立刻上一个台阶。
2.2 有效期和库存预警的逻辑到底怎么写
两个预警逻辑是整个系统的亮点,也是药店业务里最贴实际场景的部分。
近效期药品预警,就是查“有效期在当前日期+N天之内”的药品,N一般取90天或者180天。实现上可以直接用SQL条件:
SELECT * FROM drug WHERE status = 1 AND expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY);低库存预警更简单,查询库存已经小于等于库存下限的药品:
SELECT * FROM drug WHERE status = 1 AND stock <= stock_lower_limit;关于实现方案,有两种选择。一是在查询列表时实时计算,也就是上面这种写法,优点是数据永远实时,缺点是大表时扫描压力稍大,但毕设阶段加个索引就够了。二是每天用定时任务扫描,生成一张预警记录表,适合做消息通知、邮件提醒这类场景,但状态会滞后,还需要额外维护一套表。对毕设来说,我建议用查询时实时算的方式,少一张表就少一套同步逻辑,代码也更直观。排序字段建议在expiry_date和stock上建索引,查询效率没有问题。
2.3 分层实现与SpringBoot核心注解使用
项目结构按标准的三层来组织:Controller负责参数接收和结果返回,不写业务逻辑;Service负责业务规则和事务控制;Mapper负责数据库操作,MyBatis-Plus环境下直接继承BaseMapper。
SpringBoot的注解体系是整个项目的骨架,我整理了一张常用表:
| 注解 | 位置 | 作用 |
|---|---|---|
| @RestController | Controller类 | 返回JSON,方法默认带@ResponseBody |
| @RequestMapping / @GetMapping | 类或方法 | 映射HTTP路径 |
| @RequestBody / @PathVariable / @RequestParam | 方法参数 | 绑定请求数据 |
| @Service | Service实现类 | 注册为Spring Bean |
| @Transactional | Service方法 | 开启数据库事务 |
| @Mapper / @MapperScan | Mapper接口 | 让MyBatis扫描到Mapper |
| @TableName / @TableId / @TableField | 实体类 | MyBatis-Plus表映射 |
很多同学天天用@SpringBootApplication,但不清楚它拆开是什么。它其实是三个注解的组合:@SpringBootConfiguration标记配置类,@EnableAutoConfiguration开启自动配置,@ComponentScan扫描当前包及子包的组件。自动配置的原理,核心就是SpringBoot启动时会去加载META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(2.x是spring.factories)里的配置类,再配合@ConditionalOnClass、@ConditionalOnMissingBean这些条件注解按需装配。这段话本身就是答辩时“SpringBoot自动配置原理”的标准答法,项目做完顺手把这个理清楚,比死背八股文管用。
3. 实操落地:从项目初始化到核心流程跑通
3.1 IDEA创建项目与版本选择细节
实际创建项目时,有几个环节容易卡住,我按步骤来说。
用IDEA新建工程,选择Spring Initializr。网络不好可以换成阿里云镜像地址,创建速度会快很多。注意Boot版本下拉框,本地JDK是8,就选2.7.x;JDK 17以上,选3.x。如果目录里没有合适的版本,或者创建完依赖下载失败,基本就是网络源的问题,换成阿里云Maven仓库就能解决。
依赖勾选时,先选Spring Web、MySQL Driver、Validation、Lombok。MyBatis-Plus需要手动引入,因为IDEA骨架里没有。pom.xml关键依赖长这样:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里明确一下,2.7.18是SpringBoot 2.x的最后一个维护版本,稳定性有保障,也是JDK 8环境下的最佳选择。如果选3.x,MySQL驱动坐标会变成com.mysql:mysql-connector-j,包名也从javax.换成jakarta.,很多旧代码就不能直接用了。
3.2 核心配置:数据源、JSON序列化与分页插件
项目跑起来之前,先把application.yml写对。这份配置我保留了最关键的几项:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/smart_pharmacy?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8Url里两个参数特别重要。serverTimezone=Asia/Shanghai不加,数据库连接会报时区错误,或者时间差8小时;MySQL 8默认认证插件是caching_sha2_password,非SSL连接不加allowPublicKeyRetrieval=true,可能报Public Key Retrieval is not allowed。这两个问题一周能在各种技术群里看到好几遍。
MyBatis-Plus的配置里,map-underscore-to-camel-case会自动把数据库下划线字段映射成实体类驼峰属性,减少大量无意义的代码。分页插件必须单独定义配置类,否则Page参数不生效,查出来的list还是全量数据:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个坑很多人踩过,我一直没理解为什么官方不默认注册分页插件,后来想明白了,因为要留出给开发者自定义的空间。总之,不配这个Bean,列表分页就是摆设。
3.3 从药品上架到出库扣减的核心代码
先看统一返回结构,一个项目的规范程度从这里开始:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.setCode(500); r.setMessage(message); return r; } }然后看销售出库的核心逻辑,这是整个系统技术含量最高的地方。简单粗暴的写法是:先查库存,判断够不够,再执行update。这种做法在并发场景下有严重问题,两个用户同时买同一个药,都读到了库存为1,然后各自扣一次,库存变成负数,这就是超卖。
我推荐用条件更新,一次性把“库存充足、药品上架、未过期”三个条件放进SQL:
@Service public class SaleServiceImpl implements SaleService { @Autowired private SaleOrderMapper saleOrderMapper; @Autowired private SaleOrderItemMapper saleOrderItemMapper; @Autowired private DrugMapper drugMapper; @Autowired private StockLogMapper stockLogMapper; @Override @Transactional(rollbackFor = Exception.class) public Result<?> createSale(SaleOrderDTO dto) { SaleOrder order = new SaleOrder(); order.setOrderNo("SO" + System.currentTimeMillis()); order.setTotalAmount(dto.getItems().stream() .map(i -> i.getSalePrice().multiply(BigDecimal.valueOf(i.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add)); saleOrderMapper.insert(order); for (SaleOrderItemDTO item : dto.getItems()) { int rows = drugMapper.deductStock(item.getDrugId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("药品库存不足或已下架: " + item.getDrugId()); } SaleOrderItem orderItem = new SaleOrderItem(); orderItem.setOrderId(order.getId()); orderItem.setDrugId(item.getDrugId()); orderItem.setQuantity(item.getQuantity()); orderItem.setSalePrice(item.getSalePrice()); saleOrderItemMapper.insert(orderItem); StockLog log = new StockLog(); log.setDrugId(item.getDrugId()); log.setType("OUT"); log.setQuantity(item.getQuantity()); log.setCreateTime(LocalDateTime.now()); stockLogMapper.insert(log); } return Result.ok(order.getId()); } }Mapper里的条件更新语句长这样:
@Update("UPDATE drug SET stock = stock - #{num}, update_time = NOW() " + "WHERE id = #{drugId} AND status = 1 AND stock >= #{num} AND " + "(expiry_date IS NULL OR expiry_date > CURDATE())") int deductStock(@Param("drugId") Long drugId, @Param("num") Integer num);这段代码有个非常实用的设计:UPDATE语句自带WHERE条件,MySQL的行锁会保证同一时刻只有一个事务能成功更新,受影响行数为0就说明库存不足或药品已下架。过期药品也卖不出去,SQL条件里直接加了expiry_date > CURDATE()。事务加在整个方法上,库存扣了一半如果后面出异常,会自动回滚,不会出现“流水记了但库存没扣”这种数据不一致问题。
采购入库就是反向逻辑:新增入库单主表和明细表,循环update drug表把stock增加,再写type='IN'的库存流水。业务闭环到这里就能完整串起来了。
4. 毕设排错指南:SpringBoot项目的常见坑
4.1 启动失败和版本错配的处理思路
跑项目报错是常态,关键是能通过日志快速定位。我把毕设阶段最常见的几个错误整理成了表:
| 报错现象 | 常见原因 | 处理方式 |
|---|---|---|
| UnsupportedClassVersionError | JDK版本低于SpringBoot要求 | 降Boot版本或升级JDK |
| Invalid byte tag in constant pool: 15 | JDK8跑SpringBoot3 | 改用2.7.x或换JDK17+ |
| Port 8080 was already in use | 端口被占用 | 改server.port,或杀掉占用进程 |
| Failed to configure a DataSource | 数据源配置缺失 | 检查url、用户名、密码 |
| Unknown database | 数据库没建 | 先执行CREATE DATABASE |
| Public Key Retrieval is not allowed | MySQL8认证插件问题 | url加allowPublicKeyRetrieval=true |
版本问题是头号杀手。SpringBoot 3.x基于Jakarta EE,很多包名从javax.改成了jakarta.,如果你抄的代码还是老一套,一启动就编译不过。想稳定完成毕设,我强烈推荐2.7.x加JDK8加MyBatis-Plus 3.5.x这个低风险组合。如果你更想用新特性,选3.x加JDK17,那就去找Boot3的项目参考,别混着抄。
4.2 Long精度与时间序列化问题
药品表主键如果用了MyBatis-Plus默认的雪花算法,它是一个Long类型。传到前端JavaScript之后,Number精度不够,最后几位会变成0,导致根据ID查详情时报错或者查不到。毕设数据量小,这个问题不一定能复现,但讲给答辩老师听,很容易被认为是“系统不够严谨”。
解法很简单,在ID字段上配置ToStringSerializer:
@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> builder.serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); }时间格式是另一个容易出现的问题。LocalDateTime如果不配置格式,返给前端是一串数组,特别难看。在application.yml里配置jackson的date-format和time-zone能解决大部分场景,再配合字段上的@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")就比较稳妥了。数据库日期字段用date类型,实体类对应LocalDate,不要用String去接,否则排序、比较都会出问题。
4.3 库存并发和事务的坑
库存扣减如果写成“先select查库存,判断stock > 0,再update stock = stock - 1”,两个用户同时买同一个药,可能都读到库存为1,然后各自扣一次,库存变成0甚至负数。毕设并发量小,但答辩老师大概率会问“你怎么保证不超卖”,这个问题答不上来很伤。
正确做法就是前面代码里的条件更新,一次性执行UPDATE ... SET stock = stock - #{num} WHERE stock >= #{num}。MySQL的行锁保证同一时刻只有一个事务能更新成功,受影响行数为0说明库存不够或者被抢走了。这种写法简洁高效,比“查一次锁一次”的方案工程量小得多,也更容易讲清楚。
事务这块也有三个经典坑。@Transactional加在private方法上不生效,因为Spring用的是代理机制,内部调用不会经过代理;方法里catch住异常不往外抛,事务不会回滚;受检异常默认不回滚,所以最好统一写@Transactional(rollbackFor = Exception.class)。我自己踩过最典型的坑是,把入库和扣库存写到一个方法里,前半段成功、后半段抛异常,因为没加事务,库存就多了出来。后来规范了写操作统一进事务方法,流水和主表一起成功一起失败,问题才彻底解决。
5. 答辩准备与项目扩展思路
5.1 高频答辩问题与回答切入点
给几个必问题目的回答思路,都是从真实项目里提炼的,比纯背八股文有说服力。
第一个,为什么选SpringBoot?答:快速集成、自动配置、内嵌容器、生态成熟。具体到项目里,不需要单独部署Tomcat,一个java -jar就能跑起来,开发效率高。
第二个,SpringBoot自动配置原理?答:核心是@EnableAutoConfiguration,启动时加载AutoConfiguration.imports或spring.factories中的配置类,通过@ConditionalOnClass、@ConditionalOnMissingBean等条件注解按需装配。
第三个,SpringBoot启动流程大概是什么?答:创建SpringApplication,推断应用类型,加载Initializer和Listener,准备Environment,创建容器,刷新容器,执行runner。答到刷新容器和自动装配两层就够用。
第四个,库存扣减如何防超卖?答:条件更新加事务,SQL里直接写stock >= #{num},必要时再加乐观锁version字段或Redis预扣库存。
第五个,有效期预警怎么设计?答:查询时用SQL条件expiry_date <= DATE_ADD(CURDATE(), INTERVAL 90 DAY),并在expiry_date上加索引。
第六个,药品入库和销售为什么要写库存流水?答:保证数据可追溯,是库存对账的依据,也是药品流通管理的基本要求。
第七个,如果做成前后端分离,跨域怎么处理?答:后端配置CorsFilter或@CrossOrigin,前端也可以用代理转发。
这些回答不一定每个字都标准,但每一个都能落到自己的项目代码上,老师一听就知道你是真做过,不是背出来的。
5.2 可以快速加分的扩展点
主流程已经很完整了,想冲刺更高分可以从这几个方向扩展。
Redis缓存。用SpringBoot整合Redis,缓存药品分类和首页热点数据,减少数据库压力。配置一个RedisConfig,用StringRedisTemplate操作字符串,代码量不大,但能讲出一个完整的缓存方案。这是“Redis在SpringBoot中的使用”最典型的落地场景。
消息队列。下单成功后发送MQ消息,触发补货提醒或者异步写操作日志。比如SpringBoot整合ActiveMQ或RabbitMQ,引入一个简单队列,把销售事件丢进去。时间不够就只写设计思路,不一定要真部署一套Broker。
多数据源。把系统操作日志放到另一个MySQL库,用MyBatis-Plus的多数据源插件@DS切换数据源。这个属于架构层面的加分项,很多评分表都会给这类设计加分。
前端升级成Vue3+Element Plus。把后端接口按REST风格规范化,前端单独工程跑起来,配合JWT做登录态,就是标准的前后端分离项目形态。这一套组合在市场上非常常见,做完以后写进简历也有内容可说。
扩展的原则很明确:不要影响主流程的稳定性,先跑通再装饰,否则答辩现场真机演示时出了bug,反而得不偿失。
5.3 一点真实体会
这套系统我在不同阶段看,感受完全不一样。第一次写的时候,我也是图省事,一张drug表把所有字段堆完,CRUD写完就以为结束了。后来重新设计,加了入库单、销售单、库存流水,才发现系统的骨架一下子清晰了——不是表越多越好,而是每一张表的职责要清楚,业务闭环要完整。
如果让我给一个最直接的建议,就是:把“药品档案—采购入库—销售出库—库存预警—库存流水”这几个环节想明白再动手写代码。数据库表设计好了,后面代码写起来非常顺;表设计随意,后面每一步都在填坑。
这个项目做完以后,我顺手把SpringBoot的自动配置、事务、缓存这些点单独整理了一遍,后面面试被问到类似问题,都能从项目里拿出真实场景来回答,这一点比单纯刷题有价值得多。哪怕你目前的目标只是顺利毕业,这套“从业务出发反推技术方案”的思路,学下来也不亏。