疫苗预约系统实战:SpringBoot+MyBatis-Plus从业务建模到并发控制
1. 项目定位与需求拆解1.1 疫苗预约网站到底在解决什么问题做毕设选“疫苗预约”这类题目第一眼看上去像常规的增删改查但真上手就会发现它比学生管理系统、图书管理系统复杂一个档次。核心原因在于疫苗预约背后有真实业务约束库存不能超卖、一个人不能重复预约同一个批次、针剂有有效期、不同年龄段的用户对疫苗种类有不同限制。把这些业务规则落到代码里才是这个题目的价值。很多同学做系统只关注“功能能不能通”却忽略了业务本身的逻辑闭环。拿疫苗预约来说用户选一个接种点、选一个时间段、选一种疫苗提交预约单管理员在后台看到预约记录确认接种后扣减库存。如果只是简单地在数据库里存一条预约记录那出了问题很难解释。比如两个人同时提交最后一针的预约系统怎么保证只有一个成功这就是并发控制的问题。再比如用户预约了却没去号源被占用了一天怎么办这就涉及超时释放的问题。我建议把疫苗预约系统定位成一个“带真实业务场景的管理平台”而不是“展示框架用法的CRUD”。做到这一点答辩时把业务逻辑讲清楚就是明显加分项。适合的人群也很明确需要完成Java Web方向毕业设计的本科生、想通过一个完整项目巩固SpringBootMyBatis-Plus技术栈的初学者以及想在简历上增加一个稍有深度的项目经验的同学。1.2 三类角色与核心功能地图疫苗预约系统的角色通常不需要太复杂三到四类足够覆盖业务普通用户、接种医生/护士、系统管理员。用户角色负责预约、查询、取消医生角色负责接种登记、查看当日待接种名单管理员负责疫苗批次管理、接种点管理、放号配置、数据统计。功能设计上我建议按“端角色”拆分而不是堆砌功能菜单。用户端相对独立管理端按角色控制菜单权限即可。角色核心功能关键操作普通用户疫苗查询、预约登记、个人预约记录、取消预约选择接种点、选择时间段、提交预约接种医生待接种名单、扫码/确认接种、登记接种人信息确认接种、扣减库存系统管理员疫苗批次管理、库存管理、接种点管理、放号管理、数据统计放号配置、调整库存权限功能列表看起来中规中矩但每一个都藏着开发细节。比如“取消预约”不是简单把记录删掉要判断预约状态是否允许取消、释放号源、更新库存。再比如“确认接种”要同时更新预约单状态和库存这两个操作必须在一个事务里完成。这些细节我会在第4部分展开讲。2. 技术选型与工程结构2.1 SpringBoot版本选择是第一个坑热搜里出现了“springboot版本太高”这几乎是每个用SpringBoot做毕设的人都会踩的坑。尤其现在是2026年前后SpringBoot 3.x已经非常普及很多教程也都升级了。但3.x有硬性要求JDK必须17且部分第三方组件的兼容性不如2.x稳定。如果你之前在B站或GitHub拿到的项目是基于SpringBoot 2.x写的照搬到3.x环境下大概率会直接启动失败。我的建议如果你对SpringBoot生态还不熟悉优先用2.7.x版搭配JDK 8这是国内大量教程和开源项目的默认组合资料最多踩坑成本最低。如果坚持用3.x确认JDK版本没装错并且所有依赖都升级到支持Jakarta命名空间的版本。所谓“springboot版本太高”本质上是依赖之间版本不匹配导致的问题而不是SpringBoot本身有什么大毛病。做毕设图稳选型上保守一点没毛病。SpringBoot本身只是个容器框架核心优势是自动配置和起步依赖。疫苗预约系统需要的模块无非是Web、数据访问、校验、定时任务这几块通过spring-boot-starter-web、mybatis-plus-boot-starter、spring-boot-starter-validation这些起步依赖就能快速组装起来不用像早期SSM那样手工声明一堆Bean。2.2 分层结构与目录规划工程结构直接决定代码可维护性。不少毕设项目Controller里写SQL、Service里放业务逻辑和查询混在一起短时间能跑通但答辩追问“有几个人负责开发”的时候自己都容易绕晕。疫苗预约系统代码量不算大按标准三层结构来就够了src/main/java/com/example/vaccine/ ├── controller/ // 接口层 │ ├── UserController.java │ ├── VaccineController.java │ └── AppointmentController.java ├── service/ // 业务层 │ ├── AppointmentService.java │ ├── VaccineStockService.java │ └── UserService.java ├── mapper/ // 数据访问层MyBatis-Plus │ ├── UserMapper.java │ ├── VaccineBatchMapper.java │ └── AppointmentMapper.java ├── entity/ // 实体类 ├── dto/ // 前端交互对象 ├── config/ // 配置类拦截器、CORS等 └── common/ // 统一返回体、异常处理等这种结构的好处是每一层职责清晰Controller只做参数接收和结果封装Service处理业务规则Mapper只负责数据读写。答辩时被问到“代码怎么组织”能说出分层理由比背概念要实在得多。2.3 MyBatis-Plus在疫苗系统中的取舍MyBatis-Plus在热搜词里被反复提到尤其是“根据实体类生成建表SQL”这个需求。MP的基操是实体类与表字段映射通过注解或驼峰命名自动映射BaseMapper提供了insert、selectById等通用方法90%的单表操作不需要手写SQL。配合分页插件列表查询也很方便。但“根据实体类生成建表SQL”这件事要注意MP本身不提供自动建表功能常见做法是把实体类字段整理成CREATE TABLE语句手动执行或者用MyBatis-Plus Generator生成代码但数据库表还得自己建。网上有人用flyway或liquibase做版本化建表毕设没必要这么重。我建议直接在数据库工具里建表实体类字段跟着数据库设计走两边保持一致即可。MP真正省心的地方在于条件构造器。比如筛选“某个接种点未来三天的可预约号源”用LambdaQueryWrapper几行就能写清楚不用写XML映射SQL。不过涉及多表联查、复杂聚合统计时还是要老老实实写SQL。一个项目里同时用MP的通用方法和手写SQL完全没有问题这也是实际开发中最常见的用法。3. 核心数据模型设计3.1 疫苗批次与接种点的关系必须拆开疫苗预约系统最容易犯的设计错误是把“疫苗”当成一个固定对象然后建一张表存总量。实际上疫苗有严格批次概念同一个接种点同一个疫苗类型可能同时存在多个批次每个批次有效期、库存、生产企业都不同。用户预约时看不到批次信息但接种时医生要按批次登记万一出现质量问题时要能按批次追溯。我设计时把疫苗拆成了三层vaccine疫苗目录表存疫苗名称、剂次说明、适用年龄等静态信息。vaccine_batch批次表存生产批次号、有效期、每批次总量、锁定数量。vaccine_point接种点表存名称、地址、联系电话、可预约时间窗口。可预约数量不在疫苗表里直接减而是通过一个“库存快照”字段来扣减。简单做法是每条批次的current_stock字段表示当前可用量预约成功时扣1取消/超时释放时加1。有的系统把“可预约库存”和“实际库存”分开前者受控于放号操作后者受控于实际库存盘点。毕设阶段用一个current_stock字段已经足够但字段注释要写清楚语义。3.2 预约单状态机设计预约单是整个系统数据流转的核心。状态设计不好后面写业务逻辑就会到处是if判断甚至出现状态混乱。我采用的是五状态模型已预约(0) - 已接种(1) 已预约(0) - 已取消(2) 已预约(0) - 超时关闭(3) 已预约(0) - 已爽约(4)用整型或者字符串枚举都行个人更推荐整型因为数据库里存字符串容易写出五花八门的值。代码里对应的枚举类public enum AppointmentStatus { RESERVED(0, 已预约), INOCULATED(1, 已接种), CANCELED(2, 已取消), TIMEOUT_CLOSED(3, 超时关闭), MISSED(4, 已爽约); private final int code; private final String desc; AppointmentStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }状态机里最容易忽略的是“超时关闭”和“已爽约”的区别。超时关闭是用户预约成功后没有在限定时间内确认系统自动把号源释放爽约是预约当天的号源被占用用户当天没去过期后标记为爽约。这两个状态在统计用户信用度时是不同维度。毕设即使不做信用体系把状态区分清楚后面扩展也方便。3.3 建表语句参考数据库推荐MySQL 8.x字符集用utf8mb4。核心表结构参考下面的SQL实际做的时候还需要补充索引和默认值CREATE TABLE appointment ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 预约用户ID, batch_id bigint(20) NOT NULL COMMENT 疫苗批次ID, point_id bigint(20) NOT NULL COMMENT 接种点ID, appointment_date date NOT NULL COMMENT 预约接种日期, appointment_time varchar(20) NOT NULL COMMENT 预约时间段 如09:00-10:00, status int(11) NOT NULL DEFAULT 0 COMMENT 0-已预约 1-已接种 2-已取消 3-超时关闭 4-已爽约, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_batch_id (batch_id), KEY idx_point_date (point_id, appointment_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约单表;这里有个容易被忽略的索引设计点查询“某接种点某天的待接种列表”是高频操作所以索引要建立在(点ID,日期)组合上而不是只建立单列索引。CREATE TABLE vaccine_batch ( id bigint(20) NOT NULL AUTO_INCREMENT, vaccine_id bigint(20) NOT NULL COMMENT 疫苗目录ID, batch_no varchar(64) NOT NULL COMMENT 生产批次号, expire_date date NOT NULL COMMENT 有效期至, total_stock int(11) NOT NULL DEFAULT 0 COMMENT 本批次总量, current_stock int(11) NOT NULL DEFAULT 0 COMMENT 当前可预约库存, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_vaccine_id (vaccine_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT疫苗批次表;version字段是专门为并发控制加的后面第4部分会重点讲。4. 业务逻辑实现与代码细节4.1 放号与预约时的并发控制乐观锁是最优选疫苗预约最核心的技术难点是“并发扣库存”。用户在同一个时间段疯狂点预约系统底层就是多个线程同时在执行库存扣减。如果不加任何控制两个请求读到库存都是1然后各自执行库存减一最后库存变成-1超卖了。解决思路有几类数据库悲观锁select for update、乐观锁版本号、Redis分布式锁。对毕设而言我推荐用乐观锁理由很直接实现简单、不用引入Redis依赖、同时能满足业务诉求。核心写法是更新时带上版本号条件Update(UPDATE vaccine_batch SET current_stock current_stock - 1, version version 1 WHERE id #{batchId} AND current_stock 0 AND version #{version}) int deductStock(Param(batchId) Long batchId, Param(version) Integer version);执行时返回受影响行数如果返回0说明当前版本号变了或者库存已为0本次预约失败提示“号源已满请重新选择”。这里关键点在于查询库存和扣减库存这两个操作之间即使有多个请求同时进入数据库行锁会在更新时生效后提交的更新因为版本号不匹配而失败超卖的问题就被挡住了。用乐观锁时有个重要的代码习惯不要把version从查询到更新隔太长时间。业务代码里经常看到先查VaccineBatch对象然后判断库存再执行update中间如果夹杂了大量不相关逻辑会导致锁冲突概率上升。正确的做法是让查询、判断、更新尽量紧挨在一起或者直接用一条SQL完成条件判断。4.2 预约事务边界哪些操作必须在一个事务里疫苗预约提交接口涉及的操作有插入预约单、扣减库存。这两个操作必须在一个事务里执行否则可能出现“预约单创建成功但库存没扣减”的数据不一致。SpringBoot里用Transactional标注Service方法默认遇到RuntimeException就回滚。Transactional(rollbackFor Exception.class) public AppointmentResult createAppointment(AppointmentDTO dto) { // 1. 查询批次信息 VaccineBatch batch vaccineBatchMapper.selectById(dto.getBatchId()); // 2. 校验批次存在、库存充足、未过期 if (batch null || batch.getCurrentStock() 0) { return AppointmentResult.fail(号源不足); } if (batch.getExpireDate().isBefore(LocalDate.now())) { return AppointmentResult.fail(该批次疫苗已过期); } // 3. 插入预约记录 Appointment appointment new Appointment(); appointment.setUserId(dto.getUserId()); appointment.setBatchId(batch.getId()); appointment.setPointId(dto.getPointId()); appointment.setStatus(AppointmentStatus.RESERVED.getCode()); appointmentMapper.insert(appointment); // 4. 乐观锁扣库存 int rows vaccineStockService.deductStock(batch.getId(), batch.getVersion()); if (rows 0) { throw new BusinessException(预约失败号源被抢光); } return AppointmentResult.success(); }这段代码里有个细节容易被忽略先插入预约单再扣库存。如果先扣库存再插入预约单扣库存成功了但插入预约单失败事务回滚会把扣减也撤销问题不大。但业务上更推荐先创建预约单因为预约单是业务事实扣库存是资源占用动作。另一个细节是自定义异常BusinessException必须继承RuntimeException否则Spring默认只对运行时异常回滚普通异常不会触发回滚。这也是很多同学写了throws Exception结果事务不生效的原因。4.3 超时未确认自动释放号源定时任务怎么设计用户提交预约后系统一般会给一个“待确认”的时间窗口。毕设中我常用的方案是提交预约后30分钟内未支付或未确认系统自动取消并释放库存。实现方式有两种一种是用户查询时懒判断一种是定时任务批量清理。懒判断的问题在于号源在超时后仍会被他人查询到但无法预约体验不好。定时任务更稳妥SpringBoot里用Scheduled注解就能实现分钟级扫表。Component public class AppointmentTimeoutTask { private static final int EXPIRE_MINUTES 30; Scheduled(fixedDelay 60 * 1000) Transactional(rollbackFor Exception.class) public void closeTimeoutAppointments() { LocalDateTime deadline LocalDateTime.now().minusMinutes(EXPIRE_MINUTES); ListAppointment timeoutList appointmentMapper.selectList(new LambdaQueryWrapperAppointment() .eq(Appointment::getStatus, AppointmentStatus.RESERVED.getCode()) .lt(Appointment::getCreateTime, deadline)); for (Appointment appointment : timeoutList) { appointment.setStatus(AppointmentStatus.TIMEOUT_CLOSED.getCode()); appointmentMapper.updateById(appointment); // 释放库存 vaccineStockService.increaseStock(appointment.getBatchId()); } } }这个实现的要点fixedDelay是指上次任务执行完之后隔60秒再执行如果任务本身耗时较长fixedDelay比fixedRate更适合避免任务重叠。扫表的时候用了状态和时间两个条件这样已接种、已取消的单不会重复处理。如果要防止多实例部署时重复执行同一批单可以在应用层加一个Redis分布式锁或者数据库里用乐观锁更新状态毕设单机环境不加也说得过去。4.4 JWT登录鉴权如何落到这个项目里疫苗预约网站的用户端和管理端都需要登录。简单起见我推荐用JWT做无状态登录而不是传统的Session方案。JWT的好处是前后端分离时天然适用登录后返回token前端每次请求带上Authorization头后端拦截器解析token即可拿到用户信息。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } try { Claims claims Jwts.parser() .setSigningKey(vaccine-secret-key) .parseClaimsJws(token) .getBody(); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } } }用户角色权限可以通过JWT里的role字段携带拦截器里判断当前请求的接口所需角色是否匹配。管理端接口就加RequireRole(ADMIN)这种自定义注解或者简单点在拦截器里直接判断请求路径前缀。毕设不需要做成Spring Security那一整套过滤器链只要拦截器够用就行。JWT实现中有个坑密钥写在代码里不够安全实际项目中放在application.yml或用环境变量注入。另外JWT默认不会过期就永远有效所以签发时一定要设置过期时间expiration一般设定24小时过期的token后台已无法使用用户需要重新登录。4.5 接种登记与数据统计报表医生端“确认接种”是另一个关键事务。确认接种时预约单状态从“已预约”改为“已接种”同时要记录接种人实际信息。如果库存已经扣过了这里就不用再扣库存只更新状态即可。为了避免医生重复确认更新时要带上状态条件。Update(UPDATE appointment SET status #{targetStatus} WHERE id #{id} AND status #{expectStatus}) int updateStatus(Param(id) Long id, Param(expectStatus) int expectStatus, Param(targetStatus) int targetStatus);调用时返回0说明状态已经被其他人改过了提示“该预约单状态已变更请刷新”。这个方法本质也是乐观锁思想在很多场景下比先select再update要省事得多。统计报表可以做一个简单的管理端页面查询每天的预约人数、接种人数、取消人数。SQL上用GROUP BY appointment_date, status就能搞定配合ECharts在前端画几张图表。这部分建议放到最后做因为它是锦上添花的功能而前面预约、扣库存、权限这些才是系统骨架。5. 常见问题与排查实录5.1 SpringBoot版本太高导致启动失败我做这个项目时最开始用了SpringBoot 3.2结果启动报错ClassNotFound: javax.servlet.Filter。原因很明确3.x从javax迁移到了Jakarta命名空间很多从网上抄的老代码还在用javax.*。排查方法很简单看报错信息里是javax还是jakarta如果是前者要么换回2.7.x版本要么把所有依赖都升级兼容版本。热搜词里的“springboot版本太高”基本就是这个意思。处理办法是先用2.7.18版本重开项目JDK用8后续所有依赖都用2.x对应的版本问题彻底消失。毕业设计最怕环境折腾稳是第一位的。5.2 数据库连不上排查顺序有讲究Access denied for user rootlocalhost这种错误最常见通常是用户名密码或host配置不对。排查顺序先看application.yml里数据库URL是不是jdbc:mysql://localhost:3306/vaccine_db?useUnicodetruecharacterEncodingutf8再确认数据库服务器是否启动。如果是云服务器上的MySQL还要看3306端口是否开放。我在本机测试时遇到过MySQL服务没启动就启动项目明确报错通信链路失败实际上直接启动MySQL服务就行。5.3 日期字段前端显示时间戳实体类里LocalDate字段返回前端变成一串数字这是Jackson序列化没配置导致的。在application.yml里加spring: jackson: date-format: yyyy-MM-dd time-zone: GMT8如果是LocalDateTime类型加上yyyy-MM-dd HH:mm:ss即可。5.4 MyBatis-Plus条件构造器容易踩的坑用LambdaQueryWrapper查日期范围时写le(Appointment::getCreateTime, deadline)没问题但如果用字符串字段比较注意数据库字段类型是datetime传入的String会被当字符串比较格式不统一就会查不出数据。一律传LocalDateTime或Date类型不要传拼好的字符串。还有一个高频坑updateById默认只更新非null字段如果想更新某个字段为null需要在实体类的该字段上标注TableField(updateStrategy FieldStrategy.IGNORED)否则会跳过。5.5 常见问题速查表现象可能原因解决方案项目启动报javax/fakarta错误SpringBoot 3.x与老依赖冲突换2.7.x或升级依赖数据库密码正确仍拒绝连接host配置指向错误检查jdbc url的host和端口接口返回401token过期或未带请求头检查前端拦截器、设置token有效期预约时总提示号源不足乐观锁版本号不匹配频繁检查扣库存条件是否包含version定时任务重复执行项目多实例部署加分布式锁或单机部署中文乱码连接字符集未配置url加characterEncodingutf86. 值得扩展的几个加分项6.1 面向面试怎么讲这个项目疫苗预约系统放到简历上面试官最容易问的点是“如何防止超卖”。这个问题不用背八股文核心思路就是数据库乐观锁版本号扣减库存的SQL自带current_stock 0条件更新影响行数为0时主动失败重试。再深入一层可以提到“如果用户量再大RedisLua脚本来做库存预扣”让面试官看到你有扩展思维。顺便提一下热搜词里出现了“冒泡排序java”“java面试题”“mybatisplus根据实体类生成建表SQL”这类关键词说明这个题目的受众很多在准备面试和毕设双线作战。我的建议是毕设里遇到的技术问题正好是面试题的最佳素材。比如状态机设计面Java基础时聊“枚举如何管理业务状态”比空背枚举语法效果好得多。6.2 前后端分离时Vue端配合要点如果前端用Vue3预约页面需要调用后端接口并携带JWT token。Axios拦截器统一加请求头http.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; });同时响应拦截器里遇到401要跳转登录页。这一块流程不难但前后端联调时最容易出问题的是接口字段大小写不一致比如后端返回appointmentDate前端写成appointmentdate结果页面数据渲染不出来。约定好驼峰命名后前端代码里全部保持一致。6.3 落库通知与扩展思路这个项目后期还可以加“预约成功短信通知”或“微信公众号模板消息”思路是预约事务提交成功后发个异步消息不阻塞主流程也不需要引入重量级消息队列用Spring的事件机制ApplicationEventPublisher就够了。这部分我建议不要死磕先把核心流程跑顺答辩时能说出扩展方向效果远大于堆功能数量。还有一个值得做的需求是“接种提醒”预约日期的前一天晚上把第二天有预约记录的用户列表查出来批量发送提醒。这个用前面已经写好的定时任务框架就能实现只是业务逻辑从“关闭超时单”变成“发送提醒”。做加分项时优先挑复用已有代码的性价比高还能体现你对系统的全局把控。6.4 放号策略的简单实现热搜里的“springboot定时任务”对应到疫苗系统除了超时关闭还能做“放号”。有些接种点每天只开放一定数量的号源凌晨0点自动放出三天的号。实现方式同样是Scheduled(cron 0 0 0 * * ?)每次放号时批量更新对应vaccine_batch.current_stock。这里要注意的是每天放的是“未来某天”的号日期偏移要用LocalDate.now().plusDays(n)计算别把今天的号放到昨天去。## 个人实操心得 做这个项目前前后后改了三版。第一版只是把预约记录的增删改查跑通结果是答辩时被问“库存怎么扣得准”直接卡壳。第二版加了乐观锁但版本号字段放在库存表里更新还是有个并发问题没解决。最后稳定下来的方案是前面写的那样预约单创建和库存扣减放一个事务扣库存SQL带版本号条件并检查返回行数。这个方案不复杂但能覆盖日常环境下的并发场景。 踩过几次坑之后我的体会是毕设项目的核心不在用多高深的技术而在于把业务逻辑闭环想清楚。从“用户能提交预约”到“系统能保证不超卖”中间每一步都是可以做文章的地方。疫苗预约这个题目最好的地方在于业务足够接地气稍微往深处挖一点就有真实系统的味道。 最后再分享一个小技巧开发时在Service层打印关键业务日志比如扣库存成功失败、预约单状态变更。答辩演示的时候遇到“我约不进去”这种问题看日志能立刻定位是并发冲突还是库存确实不足比对着数据库猜半天要快得多。这套系统如果还想继续扩展下一步我建议做接种记录追溯把每针的批号和接种人关联上业务价值会更完整。