1. 选题这关:疫苗预约系统为什么值得做,又难在哪
1.1 “预约”的业务本质,不只是做一个表单
某同学把毕设题目发我,说要做一个“基于SpringBoot的疫苗预约系统设计与实现”。我第一反应是:这题选得聪明。疫苗预约的业务模式足够清晰,又够练人——它不是那种写几个增删改查页面就能交差的系统,而是真正要处理并发、状态、数据一致性这些项目级问题的方向。整套做完,你对SpringBoot的理解会比单纯啃一个月书都深。
很多人拿到“疫苗预约系统”这六个字,第一反应是“用户在网页上填个表单,管理员在后台看个列表”。如果按这个思路做,做出来的就是一堆静态页面的堆砌,答辩时老师一问业务流程就露馅。疫苗预约系统的业务本质,实际上是三件事:
第一,资源管理。疫苗不是无限供应的,每个接种点每天能接待的人数有限,每个时间段能放出的号源也有限,所以必须抽象出“号源”这个概念。号源模型设计得好不好,直接决定系统能否真实落地上线。你以为你在做预约,其实你在做资源调度。
第二,状态流转。一次预约从创建开始,要经历“待接种—已接种 / 已取消 / 已过期”的转变,而且状态之间不是随意跳转的。整个生命周期就是一张状态机,每一步都要有触发条件和操作者。这个状态机设计,恰恰是区分“会写代码”和“会设计系统”的分水岭。
第三,并发控制。热门接种点放号那一刻,可能同时有几百上千人点击同一个时段。如果每个请求都直接执行“库存减一”,100个并发请求能把10个号源扣成负数,这就是经典的“超卖问题”。怎么防止超卖,恰好是毕设答辩环节最容易被追问的点,也是本项目开发过程中最值得花时间研究的地方。
1.2 三个难点决定了这道题的含金量
先说并发扣减。数据库里的库存字段,不能简单地读出来、减一、写回去。比如剩余号源是10,两个请求同时读到10,各自减一后写回9,最终库存变成9而不是8,这就是丢失更新。解决方案无非三条路:悲观锁(SELECT ... FOR UPDATE,同一时刻只允许一个事务修改)、乐观锁(更新时带上版本号或库存条件,影响行数为0就重试或报错)、Redis分布式锁(把锁放到缓存里,适合高并发分布式场景)。单机版毕设系统里,乐观锁是性价比最高的方案,代码简单、无需额外组件、答辩时又能讲出道理。
再说状态校验。一次预约能否取消?取消后库存要不要恢复?预约时间过了还没去接种怎么办?这些问题背后都是状态机的规则。如果把状态字段写死成一个int,到处都能修改,那系统迟早出bug。正确做法是:所有状态变更都走统一的服务方法,方法内部先判断当前状态是否允许目标状态,不允许就直接抛异常。
最后说流程闭环。用户从注册登录、选择接种点和疫苗、选中时段、提交预约,到线下接种后由工作人员核销,再到生成接种记录,最后在“我的接种记录”里查看全程。这条链路里任何一个环节断开,系统体验都会很别扭。做毕设最忌讳只做“前端页面+后台CRUD”,两套东西互相不连通,那叫页面集合,不叫系统。
1.3 这道题适合哪些人做
如果你正在准备Java方向的毕业设计,或者想在简历上写一个“完整业务系统”的项目经历,疫苗预约这个题目的适配度很高。它的功能边界清晰,不像“电商系统”那样无边无际;它的技术深度又足够,能让你在文书中写上“处理并发库存扣减”“设计预约状态机”“实现定时任务处理过期预约”这类有含金量的描述。
难度上我给一个大概判断:有SpringBoot基础、会MySQL基本操作的读者,花一到两周能把主流程跑通;再花一周完善管理员端和统计报表,整个项目就相当完整了。如果完全没有JavaWeb基础,可能需要先补一补SpringBoot的请求处理、MyBatis数据库操作这两块再动手。
2. 技术栈选型:SpringBoot为核心的组合怎么搭才稳
2.1 核心框架:SpringBoot + MyBatis-Plus
项目的名字直接点明了SpringBoot,这没什么好纠结的。SpringBoot在当前Java生态里的统治地位实在太稳了:内嵌Tomcat、自动配置、起步依赖,几行配置就能把一个Web服务跑起来。毕设选题选它,评委老师熟悉,资料也多,遇到问题搜一下全是答案。
数据访问层我建议用MyBatis-Plus,而不是纯MyBatis。原因很实在:疫苗预约系统里有大量简单的单表增删改查,比如用户管理、疫苗列表、接种点信息维护。MyBatis-Plus的BaseMapper接口直接给了selectById、insert、updateById这些现成方法,写代码效率翻倍,少掉的重复劳动时间可以用来打磨核心业务逻辑。
需要注意一个细节:MyBatis-Plus的updateById默认只会更新非空字段,如果你想把某个字段置为null,得用UpdateWrapper显式指定。这种小坑在开发时很容易遇到,后面我会专门讲。
2.2 辅助组件:MySQL、Redis、前端怎么选
数据库选MySQL 8.0,理由不用多说。有一个小提醒:数据库连接串务必带上时区参数,写成serverTimezone=Asia/Shanghai,否则默认时区是UTC,预约时间的展示会比北京时间早8个小时。这个坑我见过不止一次,很多人查了半天代码发现日期不对,最后是连接串的问题。
Redis在这个项目里属于“加分项”而非“必选项”。如果你的毕设时间充裕,可以用Redis做两件事:一是缓存疫苗和接种点的热点信息,减少数据库查询压力;二是在预约下单时用Redis的原子操作做号源预扣,进一步提升并发下的准确性。但如果时间紧张,可以先把Redis放一放,把乐观锁方案做好,一样能通过答辩。最忌讳的是Redis引入了一堆,却讲不清楚它到底解决了什么问题,那反而成了减分项。
前端有两种选择:一是前后端分离,Vue3 + Element Plus,通过接口和SpringBoot通信;二是服务端渲染,直接用Thymeleaf模板引擎。个人建议,如果你前端基础还不错,就上前后端分离,更贴近真实企业项目的开发模式,做出来的界面也更现代化;如果前端能力比较弱,Thymeleaf是更稳妥的选择,不用考虑跨域、Token存储这些额外问题,渲染出来的页面直接在浏览器里看效果。
2.3 版本搭配与项目初始化
版本搭配尽量求稳,推荐一组经过大量项目验证的组合:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | 教学环境兼容性最好 |
| SpringBoot | 2.7.x | 稳定、资料多,别追求最新 |
| MyBatis-Plus | 3.5.x | 与SpringBoot 2.x搭配成熟 |
| MySQL | 8.0 | 连接驱动用8.0.x |
| Redis(可选) | 5.x以上 | 单机版够用 |
初始化的时候直接用Spring Initializr生成项目骨架,勾选Spring Web、MySQL Driver、Lombok这几个依赖,再把MyBatis-Plus依赖手动加到pom.xml里。重点提醒一句:Lombok虽然方便,但@Data生成的equals和hashCode在实体类继承场景下容易出问题,疫苗预约系统的实体类结构简单,一般不会有这种坑,但心里有个数总没坏处。
3. 业务功能拆解:用户、接种点、管理员三条主线的闭环
3.1 角色划分与核心用例
系统里一共有三类角色:普通用户(预约疫苗的人)、接种点工作人员(现场核销、登记接种)、系统管理员(维护疫苗和号源、查看统计报表)。三条角色主线对应三类不同视角的操作诉求,功能设计时先按角色把用例列出来,后面写代码才不会东一榔头西一棒子。
普通用户端是最核心的链路。注册登录后,用户需要能看疫苗列表,看接种点列表,选择一个接种点后能看到未来若干天每天各时段的剩余号源,选中时段后提交预约。预约成功后,“我的预约”列表里能看到这张预约单的实时状态;预约时间前可以取消;完成接种后能看到接种记录,包括第几针、接种日期、疫苗批号等信息。
接种点工作人员不需要自己注册账号,由管理員在后台创建。工作台场景也很集中:查看当天预约名单,按预约编号或手机号查询某位用户,确认用户到场后点击“登记接种”,系统自动生成接种记录,同时把预约状态从“待接种”置为“已接种”。
管理员的功能就是典型的后台管理系统:疫苗信息维护、接种点信息维护、号源配置(每天放多少号、每个时段放多少号)、用户管理、预约记录查询与导出、按周/月的预约量统计报表。这里建议做一个简单的数据看板,用图表展示每天的预约人数、接种完成率、各接种点热度,答辩时展示效果特别加分。
3.2 预约状态机:整个系统的“交通规则”
状态机设计是这个项目的灵魂所在。预约单的状态我设计了四个基础状态:
| 状态值 | 含义 | 触发条件 |
|---|---|---|
| 0 | 待接种 | 用户成功提交预约 |
| 1 | 已接种 | 工作人员登记核销 |
| 2 | 已取消 | 用户在规定时间内主动取消 |
| 3 | 已过期 | 预约时间已过,用户未到场 |
状态流转规则必须写清楚:只有“待接种”状态能变成“已接种”“已取消”“已过期”。已接种的不能取消,已取消的不可能再变成已接种,已过期的更不能回退成待接种。代码层面,所有状态变更都放到Service层统一封装,比如cancelAppointment()方法里第一行就校验appointment.getStatus() == 0,状态不对直接抛业务异常。这样就把状态机的规则收口到几个方法里,而不是散落在各个controller里到处setStatus()。
如果是给需要打两针的疫苗做扩展,还可以加上“第二针待预约”的状态。第一针接种完成后,系统根据疫苗的间隔天数自动计算可预约日期,给用户生成一条新的预约引导记录。这个扩展功能在答辩时可以作为亮点提一下,但毕设基础版本建议先不做,把四状态跑稳了再说。
3.3 一段真实的“切分”经验
我见过不少做这个题目的人卡在同一个地方:拿到题目不知道从哪儿下手,今天写用户登录,明天写疫苗管理,后天又跳回去改预约表结构,项目做了一个月还是一盘散沙。我的经验是,按数据流而不是按页面来切分任务。
第一步先把“预约”这条主链路打通:建库建表、做登录、做疫苗列表、做接种点列表、做预约下单、做我的预约列表。第二步补线下闭环:工作人员登录、核销登记、生成接种记录、用户查看接种记录。第三步才是管理端的花活:号源配置、统计报表、数据看板。每一步都是能独立运行、能看到效果的小里程碑,这样进展才有感知,不会做着做着失去方向感。
4. 数据库设计:六张核心表与字段背后的取舍
4.1 基础信息表:用户、疫苗、接种点
数据库设计是系统真正的地基。疫苗预约系统里我建议从六张表起步:用户表、疫苗表、接种点表、号源表、预约表、接种记录表。
用户表字段相对固定,核心是:id、username、password、phone、real_name、id_card、create_time。密码必须加密存储,推荐用BCrypt,别用MD5,MD5彩虹表一查就破,答辩老师看到明文或MD5,印象分会大打折扣。身份证号属于敏感信息,数据库存密文或正常存储都可以,但页面展示时一定要做脱敏,比如110***********1234这样的格式。
疫苗表管理的是“有哪些疫苗可预约”,字段包括name(疫苗名称)、type(疫苗类型)、manufacturer(生产企业)、dosage(需接种剂次)、interval_days(两针间隔天数)、description(适用人群和注意事项)。这里要区分一个概念:疫苗表存的是“品种信息”,具体某个接种点还有多少库存、每天放多少号,是后面号源表的事。
接种点表字段有name、address、phone、open_time、close_time、daily_limit(每日总号源上限)。实际开发中,接种点表往往会关联一个“当前可预约疫苗列表”,这属于多对多关系,简单处理方式是再加一张关联表,或者在号源表里通过vaccine_id间接关联,具体看你的业务复杂度。
4.2 核心业务表:号源、预约、接种记录
号源表是整个并发设计的核心,字段我建议这样设计:
| 字段 | 说明 |
|---|---|
| id | 主键 |
| site_id | 接种点ID |
| vaccine_id | 疫苗ID |
| appoint_date | 可预约日期 |
| time_slot | 时段(如09:00-10:00) |
| total_stock | 总号源数 |
| remain_stock | 剩余号源数 |
| version | 乐观锁版本号 |
这里有一点值得展开:为什么不直接给疫苗表加一个总库存字段,每次预约时直接减?因为疫苗预约的核心粒度是“某接种点某天的某个时段”,而不是疫苗总量。如果把库存打到疫苗表上,你根本没法回答“明天上午还有没有号”这个问题。号源表把“可预约资源”这个概念显式建模出来,预约下单时操作的就是这张表的remain_stock,逻辑非常清晰。
预约表记录一次完整的预约行为,字段包括order_no(预约编号,建议用时间戳+随机数生成)、user_id、appointment_source_id(关联号源表)、vaccine_id、site_id、status、appointment_date、time_slot、create_time、cancel_time。这里我特意把vaccine_id和site_id冗余到预约表里,虽然可以顺着appointment_source_id联表查出来,但冗余之后查询列表时少了一次JOIN,代码写起来也直观得多。适当的字段冗余在业务表设计里是很常见的取舍,不必事事追求三范式。
接种记录表在用户完成接种后生成,字段有appointment_id(关联预约单)、user_id、vaccine_id、site_id、dose_number(第几针)、batch_no(疫苗批号,现场人工录入)、inoculate_time、operator_id(登记工作人员)。这张表是面向查询设计的,用户端“我的接种记录”、管理端“接种率统计”都指向它,所以建议把用户姓名、疫苗名称也冗余进去,减少联表。
4.3 索引与字段规范
数据库设计里最容易忽略但最影响性能的是索引。疫苗预约系统的核心查询场景有:登录时按username查用户;查询号源时按site_id + appoint_date + time_slot组合查;用户查“我的预约”时按user_id + status查;定时任务扫过期预约时按status + appointment_date查。这些查询条件就应该建成联合索引:号源表的idx_site_date_slot、预约表的idx_user_status、预约表的idx_status_date。
字段类型方面,金额、数量这类数值字段用int或decimal,别用varchar存数字,否则排序和统计都会出问题。时间字段统一用datetime,不要用timestamp存2038年之后的时间。所有业务表都建议加上create_time和update_time两个审计字段,排查数据问题时能派上大用场。
5. 核心业务实现:预约下单、余量扣减、状态回退的完整逻辑
5.1 预约下单:一条SQL胜过一堆判断
预约下单的完整流程是:用户选择号源记录,系统校验用户资格,执行余量扣减,创建预约记录,返回预约详情。最关键的扣减操作,我用一条带条件的UPDATE来完成,而不是“先SELECT再UPDATE”。代码大致是这样的:
@Transactional public OrderVO createAppointment(AppointmentCreateRequest request) { // 1. 参数校验:预约时间是否已过、时段是否开放 AppointmentSource source = appointmentSourceMapper.selectById(request.getSourceId()); if (source == null || source.getAppointDate().isBefore(LocalDate.now())) { throw new BusinessException("号源不存在或已过期"); } // 2. 唯一性校验:同一用户同一疫苗不能有重复待接种预约 Integer count = appointmentMapper.selectCount( new LambdaQueryWrapper<Appointment>() .eq(Appointment::getUserId, request.getUserId()) .eq(Appointment::getStatus, 0) .eq(Appointment::getVaccineId, source.getVaccineId())); if (count != null && count > 0) { throw new BusinessException("您已有待接种的预约,请勿重复预约"); } // 3. 核心:乐观锁扣减余量 int rows = appointmentSourceMapper.deductStock(source.getId(), source.getVersion()); if (rows == 0) { throw new BusinessException("当前时段号源已约满,请更换时段"); } // 4. 创建预约记录 Appointment appointment = new Appointment(); appointment.setOrderNo(OrderNoGenerator.generate()); appointment.setUserId(request.getUserId()); appointment.setAppointmentSourceId(source.getId()); appointment.setVaccineId(source.getVaccineId()); appointment.setSiteId(source.getSiteId()); appointment.setStatus(0); appointment.setAppointmentDate(source.getAppointDate()); appointment.setTimeSlot(source.getTimeSlot()); appointmentMapper.insert(appointment); return convertToVO(appointment); }对应的Mapper方法用自定义SQL实现:
UPDATE appointment_source SET remain_stock = remain_stock - 1, version = version + 1, update_time = NOW() WHERE id = #{id} AND version = #{version} AND remain_stock > 0这条SQL的巧妙之处在于:通过remain_stock > 0和version两个条件,保证了数据在更新那一刻是未被并发修改过的。如果两个请求同时执行,第一个请求成功后version变了,第二个请求的version条件不成立,影响行数为0,代码里就能捕捉到并提示用户“号源已约满”。
5.2 为什么不用 synchronized 或悲观锁
有读者可能会问:单机应用直接加synchronized锁住扣减方法不就行了?答案是:如果整个服务部署在一台机器上,synchronized确实能解决并发问题,但有两个隐患。一是synchronized锁的是整个方法,会导致所有用户的所有预约操作串行化,性能大幅下降;二是如果项目将来拆成多实例部署,JVM锁在不同实例之间不互通,超卖问题依然存在。
悲观锁SELECT ... FOR UPDATE倒是能解决实例互通问题,但它的代价是:在事务结束前,这一行数据被数据库锁住,其他所有访问这行数据的请求都要排队等待。在号源这种高竞争场景下,悲观锁容易造成大量线程阻塞,数据库连接池很快就会被占满。
乐观锁牺牲的是“抢不到的用户需要重试”的体验,换来的却是系统的吞吐量和简单性。对毕设项目来说,这是最合理的平衡点。如果你用了Redis,还可以用Redis的DECR命令做号源预扣,把热点扣减操作从数据库转移到缓存层,扛并发的能力会更强,但实现复杂度也上去了,需要仔细权衡。
5.3 取消预约与过期处理:回滚号源的边界条件
取消预约的逻辑比很多人想象的复杂。用户点击取消时,系统先检查状态是否为“待接种”,再检查当前时间距离预约时间是否超过某个阈值(比如6小时或24小时),然后才能执行取消。取消时号码源要加回,预约状态置为“已取消”。这里有一个容易遗漏的点:必须是“扣减成功之后才创建预约记录”,取消时则相反,先改预约状态,再加回号源。顺序反了会有一个短暂的窗口期,用户看到预约已经取消,但号源还没恢复。
过期处理我建议用Spring的定时任务实现:
@Component public class AppointmentExpireTask { @Scheduled(cron = "0 0 1 * * ?") public void expireAppointments() { // 查出所有待接种且预约时间早于今天的预约 List<Appointment> expiredList = appointmentMapper.selectList( new LambdaQueryWrapper<Appointment>() .eq(Appointment::getStatus, 0) .lt(Appointment::getAppointmentDate, LocalDate.now())); for (Appointment appointment : expiredList) { // 将预约置为已过期,同时恢复号源余量 appointmentMapper.updateStatusWithLock(appointment.getId(), 3, 0); appointmentSourceMapper.increaseStock(appointment.getAppointmentSourceId()); } } }这段代码要注意两个细节。一是每次批量处理前,用status + appointment_date联合索引查出符合条件的记录,避免全表扫描。二是更新状态时也建议加上条件WHERE status = 0,防止用户刚好在定时任务执行的同时取消了预约,两个操作互相覆盖。把状态字段放到WHERE条件里,天然就是一种乐观锁保护。
5.4 接种登记:从预约到记录的一次闭环
工作人员点击“登记接种”后,服务端做的事情是:校验预约状态必须为“待接种”,将预约状态改为“已接种”,创建一条接种记录,记录实际接种时间和疫苗批号。如果该疫苗需要接种两针,系统还可以顺手计算出第二针的可预约日期,通过站内消息或页面的方式引导用户预约第二针。
这个环节也体现了事务的重要性。状态修改和接种记录创建必须放到同一个事务里,要么都成功,要么都失败。如果先改了状态、创建记录时数据库字段超长抛了异常,那用户的预约状态就莫名其妙变成了“已接种”而没有记录,这种数据不一致问题在实际项目里排查起来非常头疼。
6. 开发中的坑与答辩追问:这些经验值得提前知道
6.1 三个容易踩的坑
第一个坑是事务不生效。Spring的@Transactional本质是通过代理实现的,有三类场景会导致事务静默失效:一是方法在同类内部直接调用,比如Controller调Service里一个方法,这个方法又调了同类另一个@Transactional方法,后者的事务不生效;二是方法被private修饰,代理无法拦截;三是方法内try-catch把异常吞掉了,Spring看到的是“方法正常返回”,自然就不会回滚。建议的做法是:事务方法必须public、异常必须向外抛出、不同Service之间的调用才能触发代理事务。
第二个坑是数据库时区。连接串不带serverTimezone参数时,MySQL驱动默认使用服务器时区,如果你的服务器和数据库不在同一个时区,预约日期可能会出现偏移。之前帮一个开发者排查过这种问题,用户预约3月10日的号源,后台记录却是3月9日,折腾了半天最后就是连接串少写了一个参数。
第三个坑是前端控件显示的时间格式。日期明明是2026-05-20,传到后端变成了2026-05-20T00:00:00,直接存进LocalDate字段时报错。解决办法是统一约定前端用yyyy-MM-dd格式传参,后端用@DateTimeFormat注解接收,或者前端直接用Element Plus的value-format属性指定格式。
6.2 答辩高频问题与回答思路
老师问:为什么选择SpringBoot做这个系统?
回答思路:SpringBoot简化了Spring框架的配置流程,内嵌Web容器让项目能独立运行,起步依赖机制让依赖管理变得清晰;同时它有庞大的社区生态,资料丰富、排错方便,非常适合中小型业务系统的快速开发。
老师问:如何防止库存超卖?
回答思路:核心是用乐观锁配合事务,UPDATE语句中加入remain_stock > 0和version条件,当并发请求同时修改同一行数据时,只有一个请求能成功。同时强调,如果业务量进一步扩大,可以引入Redis预扣号源、消息队列削峰等方案。这样说既展示了当前方案的扎实性,又体现了对架构演进方向的理解。
老师问:数据库表为什么这样设计?
回答思路:从业务出发,预约的最小资源粒度是“某接种点某天某时段的号源”,所以单独建号源表;预约表和接种记录表适度冗余了关联字段,是为了在列表查询时减少联表;核心查询条件都建立了联合索引,保证数据量增长后查询性能不退化。
老师问:这个项目的难点在哪里?
回答思路:难点不在某个单一功能,而在库存扣减的并发准确性和预约状态机的完整性。这两块恰好是一个业务系统真正有技术含量的地方。
6.3 规模把控:不要盲目堆技术
最后聊一个很现实的建议。毕设的本质是展示你对软件开发全流程的理解,而不是炫技。我见过有人给疫苗预约系统就引入了SpringCloud Gateway、Nacos、Seata分布式事务、Kafka消息队列,结果演示的时候光启动服务就花十分钟,代码里到处是不知道怎么填的参数。这种“大炮打蚊子”的做法,不仅不会加分,反而会让老师觉得你对技术选型缺乏判断力。
把单体SpringBoot做扎实,数据库设计合理,并发方案讲得清,状态机逻辑严谨,前后端功能闭环完整,统计报表能跑出真实数据,这已经是一个相当优秀的毕业设计了。
真要我总结这次做项目的体会,我最想说的是:疫苗预约系统的核心不是疫苗,不是预约,而是“资源+状态+时间”这三个维度的组合。你把这三件事想透了,代码怎么写都是顺的;想不透,换什么框架都是打补丁。整个过程下来,我对SpringBoot事务边界、乐观锁的适用场景、状态机在业务系统里的落地方式都有了比看书深刻得多的理解。如果你也打算做这个题目,建议先把号源表和预约表的设计定下来,用笔在纸上画一遍预约状态流转,再动手写代码——这套动作用不了一个小时,却能帮你少走好几天的弯路。