计算机毕业设计选“Spring Boot 疫苗接种系统”的人真不少,前几天还有学弟问我这个题该怎么做。表面上看就是一个预约加接种记录管理,但社区疫苗预约与接种管理平台一旦落到真实场景,要把用户预约、疫苗库存、接种点排班、核销记录、统计报表全部串起来,工作量并不小。这篇文章就基于我带过的实际项目,把整套系统的整体拆分、核心数据表、关键代码实现、部署和答辩需要注意的坑全部过一遍,适合拿来做毕设,也适合想快速上手一个完整 Spring Boot 实战项目的开发者参考。
先给还没动手的人一个善意提醒:不要再把思路停留在“用户注册、管理员增删改查”这种两层结构上。这套系统真正的难点,在于多角色业务闭环、疫苗库存并发控制和预约状态流转。下面我会按实际开发顺序讲,而不是按教材目录讲。
1. 项目整体设计与需求拆解
1.1 系统定位:这不是一个普通的CRUD
很多同学看到“疫苗接种系统”第一反应是“疫苗表 + 接种记录表”,然后就开始写代码。但社区疫苗预约与接种管理平台明显是一个面向三类用户的业务系统:普通居民需要在线预约、查看疫苗库存、收到接种通知;接种点工作人员需要核销预约、登记接种、录入不良反应;平台管理员需要维护疫苗信息、设置接种点、查看全量统计。
这意味着你需要划分三种角色,戴上角色视角去设计功能,而不是把所有接口堆在同一个 Controller 里。我在给学弟做技术评审时发现,凡是后期返工严重的项目,几乎都是从“所有用户共用一套接口”开始的。
推荐的模块划分是:
- 用户端:注册登录、疫苗查询、预约登记、我的预约、取消预约、接种记录查询
- 接种点端:当日预约列表、核销确认、接种登记、留观计时、异常情况记录
- 管理端:用户管理、接种点管理、疫苗管理、库存调整、预约规则配置、数据看板
这三个端可以共用同一个后台项目,用 Spring Security 或自定义拦截器按权限分流,但接口路径和逻辑必须分开。毕设答辩时,评委最常问的就是“如何做权限控制”,这块你能讲清楚,基本能拿到一半的分数。
1.2 核心业务流程:预约、接种、核销、记录
在动手写代码前,一定要先把主流程画清楚,否则后面编码时会频繁改表结构。我这边经过几轮落地改动,最后沉淀下来的核心流程是:用户先注册登录,在疫苗列表中选择接种点,看到当前可预约的日期和剩余号源;选定时间段后提交预约,系统校验“是否已预约过同类疫苗”“是否在限制周期内”;预约成功生成预约单,状态为待接种;用户到现场后,工作人员根据预约编号或用户二维码进行核销,状态变成已核销;接种完成后创建接种记录,用户可在“我的记录”中查询到第几针、疫苗批号、接种时间。
这个流程里最关键的是状态的流转。我建议把预约单状态用枚举维护,而不是散落在代码里的魔法数字。状态机如下:
- 待接种(0):用户提交预约成功,等待现场核销
- 已核销(1):工作人员核销完成,准备接种
- 已完成(2):接种信息已登记,整个流程闭环
- 已取消(3):用户取消,或超时未到自动取消
- 已过期(4):预约日期已过且未核销,系统自动标记
实际上“已过期”和“已取消”可以合并,但分开的好处是数据统计时能清楚看到“爽约率”。爽约率这个指标在毕设答辩时很好用,因为它是评委能感知到的“真实业务思考”,比单纯讲 CRUD 有说服力得多。
1.3 技术选型:Spring Boot 3还是2.7,MyBatis Plus够不够用
标题里写了 Spring Boot,我建议直接用 Spring Boot 3.x + JDK 17,如果学校环境只支持 JDK 8,那就退到 Spring Boot 2.7.x。选型逻辑很简单:毕设项目要的是稳定性、生态资料多、问题能被搜索引擎快速解决。Spring Boot 3 现在已经很稳,网上踩坑记录也很多,不会卡住你。
持久层我推荐 MyBatis Plus,而不是 MyBatis 原生或 JPA。原因就两条:分页插件很好用,避免手写大量 XML;条件构造器可以直接拼查询条件,比如后台按“疫苗名称 + 日期 + 状态”组合筛选,一两行代码搞定。假如你用原生 MyBatis,这种组合查询的 SQL 会写到你怀疑人生。
前端方案上,我的建议是不要硬上前后端分离。毕设项目如果时间紧,直接在 Spring Boot 里用 Thymeleaf 渲染后台页面,用户端用一个 H5 页面包起来,稍作适配就能在手机浏览器里演示。你要选 Vue 反而容易把自己拖进“跨域”“Token 过期”“打包部署”的坑。不要贪多,先跑通全流程。
2. 数据建模与数据库表设计
2.1 用户体系:单表加角色字段,还是RBAC三张表
很多毕设喜欢做 user、role、user_role 三张表,理由是“显得规范”。但疫苗接种这类系统角色很固定,做成 RBAC 反而让权限判断变复杂。我的做法是:user 表加一个 role 字段,用 tinyint 存角色类型:0 普通用户、1 接种点工作人员、2 管理员。查询当前用户时只做一次判断,轻量直接。
真正需要拆表的是用户扩展信息。普通用户要记录姓名、身份证号、手机号;工作人员要关联接种点 ID。如果你全都塞进 user 表,表会变得臃肿。但毕设为了演示方便,也可以不分,用字段冗余解决。从“能跑通”和“好答辩”两个角度讲,user 表设计成下面这张就够了。
字段包括:id、username、password(BCrypt 加密)、real_name、id_card、phone、role、vaccine_point_id(工作人员关联接种点)、create_time、update_time。登录名用手机号或用户名都可以,身份证号只做实名展示,不在登录逻辑里用。密码千万别存明文,答辩时评委看到明文密码,印象分会掉很多。
2.2 疫苗与接种点资源建模
疫苗信息表和接种点表是资源侧的实体,也要提前设计好。vaccine 表至少要有:vaccine_name、manufacturer、dose_total、interval_days、stock、status。疫苗的批次信息我建议单独建表 vaccine_batch,记录 batch_no、production_date、expire_date、quantity,因为实际社区接种时,同一名称的疫苗可能有不同批号,接种记录要精确到批号。
接种点表 vaccine_point 要包含:point_name、address、open_time、max_count_per_day、enabled。这里有个容易忽略的点:每个接种点当天的剩余号源不是简单地等于库存,而是等于“每日最大预约量 - 当日已预约人数”。这个值需要在预约接口里动态计算,不能直接存库,否则并发下会脏读。
| 实体 | 核心字段 | 说明 |
|---|---|---|
| vaccine | vaccine_name, manufacturer, dose_total, interval_days, stock | 疫苗基础信息与总库存 |
| vaccine_batch | batch_no, production_date, expire_date, quantity | 批次追踪,精确到批号 |
| vaccine_point | point_name, address, max_count_per_day, enabled | 接种点与每日预约上限 |
| appointment_order | order_no, user_id, vaccine_id, point_id, appointment_date, status | 预约主表,状态流转核心 |
| appointment_record | order_id, batch_no, inject_position, operator_id | 接种记录,与预约对应 |
2.3 预约单状态机和关键索引
预约单表 appointment_order 是整个系统的心脏,核心字段:order_no、user_id、vaccine_id、batch_id、point_id、appointment_date、time_slot、status、cancel_reason、create_time、update_time。
很多人忽略索引设计,数据量一上来,后台筛查预约记录就非常慢,然后被评委问到性能优化时哑口无言。推荐加两个联合索引:idx_user_date(user_id, appointment_date),用于查某用户某天的预约;idx_point_status(point_id, appointment_date, status),用于统计接种点当日各状态数量。
状态字段不要用字符串“已预约”“已完成”直接存,存整数枚举,显示时再映射中文。我给学弟改过一次代码,他直接在 Java 枚举里写了“PENDING”等,但 Redis 缓存里又存了中文,最后排查了半天。规范做法是:数据库存 int,枚举类里做转换,前端展示通过接口返回的文本字段。
| 状态值 | 状态名 | 触发时机 |
|---|---|---|
| 0 | 待接种 | 用户提交预约成功 |
| 1 | 已核销 | 工作人员现场核销 |
| 2 | 已完成 | 接种记录已生成 |
| 3 | 已取消 | 用户主动取消 |
| 4 | 已过期 | 定时任务标记未到者 |
2.4 核心建表SQL参考
这里给一套简化但能直接用的核心 DDL,有两个点要注意:一是 order_no 要唯一,建议用雪花 ID 或时间戳加随机数;二是所有金额相关字段用 decimal,疫苗虽然很多是免费,但自费疫苗也存在。预约表 SQL 大致如下:
CREATE TABLE `appointment_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '预约单号', `user_id` bigint NOT NULL, `vaccine_id` bigint NOT NULL, `batch_id` bigint DEFAULT NULL, `point_id` bigint NOT NULL, `appointment_date` date NOT NULL, `time_slot` tinyint NOT NULL COMMENT '0上午 1下午 2全天', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待接种 1已核销 2已完成 3已取消 4已过期', `cancel_reason` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_date` (`user_id`, `appointment_date`), KEY `idx_point_status` (`point_id`, `appointment_date`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;我用的是 utf8mb4,不是 utf8。原因不用多说,现在谁还存不了 emoji?但在 MySQL 8 里 utf8mb4 是默认,老项目还喜欢 utf8,插入生僻字姓名时就容易报错,这种细节也值得在答辩时提一句。
接种记录表 appointment_record 和预约单是 1 对 1 关系,但如果一针对应一条记录,一个预约单也只能对应一条接种记录。字段上增加 vaccine_name、batch_no、inject_position、adverse_reaction、operator_id,这样在数据统计和溯源时就有据可查。
3. 核心功能实现与踩坑记录
3.1 用户登录鉴权:JWT拦截器的正确写法
用 JWT 做前后端接口鉴权是毕设项目的主力方案,比 Session 更适合分布式部署演示。具体做法:用户登录成功后,用 userId 和 role 生成 token,返回给前端;前端每次请求在 Header 里携带 Authorization: Bearer xxx;后端写一个拦截器解析 token,并把用户信息放进 ThreadLocal 或 request attribute。
这里有个很多新手会踩的坑:拦截器写好了,但静态资源和登录接口也被拦截了。我习惯的做法是写一个 HandlerInterceptor,exclude 掉 /user/login、/user/register、/vaccine/list、/static/**、/doc.html 这些公开路径。Spring Security 如果配置复杂,不如直接用拦截器,减轻学习成本和出错的概率,毕设完全够用。
JWT 本身有一个“无法主动失效”的问题,用户修改密码后旧 token 仍然有效。毕设项目里可以不做处理,但答辩时如果被问到,你可以说方案是加一层 Redis 黑名单或者把 token 版本号存进用户表。能说出这个改进点,会显得你考虑过安全问题。
3.2 疫苗库存扣减:乐观锁防超卖
预约时最怕出现“同一个人重复预约”和“库存超卖”。库存超卖的核心原因是多个请求同时读到 stock=10,同时判断大于 0,然后同时扣减。要解决必须用数据库层面的原子操作。
我的做法是 update 语句带上库存条件,比如:
int rows = vaccineMapper.update(null, new LambdaUpdateWrapper<Vaccine>() .set(Vaccine::getStock, vaccine.getStock() - 1) .eq(Vaccine::getId, vaccineId) .gt(Vaccine::getStock, 0)); if (rows == 0) { throw new BusinessException("疫苗库存不足"); }这条语句在 MyBatis Plus 里直接调用即可,返回的 rows 表示影响的行数。如果并发请求同时进来,只有一个能更新成功,其余影响行数为 0,就能拿到“库存不足”的异常。这比普通的 select-then-update 要安全得多,而且不用引入悲观锁,代码量很小。
还要注意,库存扣减和预约单生成必须在同一个数据库事务里,否则会出现预约单建好了、库存没扣减的情况。事务注解加在 Service 层方法上,并且要避免同类内部调用 this.save() 导致事务失效,这个细节后面单独讲。
3.3 预约时段控制与重复预约拦截
重复预约的校验,很多人只想到在代码里 select 查一遍,但并发下两个请求同时查到没有预约记录,都会通过校验,最后产生两条订单。最稳妥的做法是建一个唯一索引,让数据库兜底。
我的建议是给 appointment_order 加一个 user_id + vaccine_id + appointment_date 的联合唯一索引,表示“同一个用户同一天不能针对同一种疫苗预约两次”。但要注意,不同疫苗同一天可能都可以约,所以索引不能只放在 user_id + appointment_date 上,这里要和业务正确对齐。
另一个容易忽略的是“两针间隔天数”校验。因为疫苗表里有 interval_days,所以用户在预约第二针时,需要查一下最近一次接种该疫苗的记录,如果当前日期减上次接种日期小于 interval_days,直接拒绝。这个逻辑我建议放到 Service 层做,不要在 Controller 里写一堆 if,否则慢慢就变成面条代码。
3.4 接种核销与记录生成
现场核销推荐用“预约单号 + 手机号后四位”的组合,比单纯扫二维码更稳定。毕设项目一般没有真扫码枪,用 H5 页面输入单号也能演示。核销接口的逻辑是:根据单号查出预约单,判断状态是否为待接种;如果是,改成已核销,并返回前端确认信息;随后工作人员填写接种信息,生成接种记录。
这里要处理好“核销和接种记录生成”两个操作的关系。我的建议是把核销动作和接种记录生成放到同一个事务中,或者至少保证核销成功后记录一定能生成。因为实际场景里可能“核销了但没打成针”,需要人工处理。但毕设可以简化:核销即接种,但记录要现场填写完整。
接种记录里 operator_id 一定不要用 session 里的 userId 直接塞,应该用当前登录人的角色判断“是否有接种操作权限”,如果没有,直接抛 403。很多同学权限校验只做菜单隐藏,不校验接口,这是不安全的,也容易在答辩时被追问。
4. 管理后台与数据统计
4.1 后台接口设计:RESTful约定的取舍
管理后台接口建议统一前缀 /admin,用户端接口统一 /api,避免权限拦截规则混乱。比如:
- POST /api/user/login 用户登录
- GET /api/vaccine/list 查询可预约疫苗
- POST /api/appointment/create 创建预约
- POST /admin/appointment/cancel 管理员取消预约
- GET /admin/stats/daily 每日预约/接种统计
统一返回体也很重要,我一般用 Result<T>,包含 code、message、data 三个字段。全局异常处理器把业务异常也转成统一返回,前端不会拿到乱七八糟的堆栈信息。代码很固定,但少了它在对接前端、调试 Postman 时会有很多麻烦。
我的一个心得是:给管理员接口单独做一个“操作日志”AOP 切面,记录谁在什么时间对哪个接口做了什么操作。这样演示时,后台能看到操作日志列表,答辩时讲“操作审计”,很有说服力,实现也不复杂,也就是一个自定义注解加 AOP 拦截。
4.2 接种数据统计:按日、按疫苗、按接种点
数据统计是管理后台的重头戏,千万别等到最后再做。我见过不少学弟把预约流程写完,统计模块只放一个空的 ECharts 页面,结果演示时无法展示数据,非常尴尬。
最基础的统计接口要覆盖三个维度:按日统计预约量和接种量;按疫苗统计各疫苗预约占比;按接种点统计每日核销人数。SQL 写法上,用 GROUP BY date(create_time) 会很简单,但要注意 MyBatis Plus 的分页插件和 GROUP BY 联用时的 Total 问题。我建议统计接口不要用分页插件,直接返回 List,数据量也不大,避免 Total 被错误改写。
如果使用 ECharts 展示,后端返回的数据结构可以直接组织成 xAxis + series 两个数组,前端少做一层转换。比如按日统计:
{ "dates": ["2025-04-01", "2025-04-02"], "appointmentCount": [35, 48], "vaccinatedCount": [30, 42] }这样无论你写 JS 还是用 Vue,渲染都很顺畅。不要把后端返回明细记录,让前端自己去聚合,既浪费带宽又增加前端复杂度。
4.3 基础监控与定时任务:Spring Boot Admin和@Scheduled
毕设做到这个程度,再加一个“系统监控”模块非常加分。可以选择引入 Spring Boot Admin,服务端注册一个 admin 项目,业务模块作为 client 暴露 actuator 端点,就能看到内存、线程、健康状态等实时数据。做法不复杂,但能撑起一个“平台运维”的演示点。
定时任务用 @Scheduled 就能满足需求。系统里最常见的定时场景是每天凌晨清理“过期未核销”的预约单,把 status 从待接种变成已过期,并释放当天的可预约名额。要注意定时任务里修改大量数据时,尽量分批更新,避免一次性 update 所有历史记录导致锁表时间过长。
我用过的一个经验:在定时任务开始和结束都打印一条日志,记录处理条数与耗时。因为毕设演示时如果定时任务出问题,你至少能从日志里快速定位,不然连“有没有执行”都不知道。
5. 常见问题与排查实录
5.1 事务失效:this调用与代理对象
最典型的场景是 AppointmentServiceImpl 里有一个 public 方法 createAppointment(),它内部直接调用了 this.updateStock()、this.insertOrder(),结果其中一个失败,库存还是被扣了。原因就是 Spring 事务是基于 AOP 动态代理的,同类内部调用不会经过代理对象,所以 @Transactional 注解失效。
排查办法是自己注入自己:在 Service 里注一个 AppointmentService self,然后用 self.updateStock() 这种写法,或者干脆把涉及多表写操作的方法拆到独立 Service,再注入那个 Service。如果是老代码,可以用 AopContext.currentProxy(),但初学者不推荐。
5.2 @Scheduled定时任务没生效
有几种可能:主启动类没加 @EnableScheduling;任务方法被 private 修饰;或者任务抛出异常后没有日志。最隐蔽的是默认单线程执行,如果上一个任务没跑完,下一个任务就排队,遇到大量数据会感觉“定时任务偶尔失灵”。
我建议在配置里指定线程池,或者用 @Scheduled(cron) 的同时,任务方法内部用 try-catch 包住所有业务逻辑,防止异常把任务线程干掉。这个坑连续坑了我两次,都是在演示前一天发现定时清理没跑,最后发现是异常被吞了。
5.3 时区问题导致预约日期错乱
数据库连接串如果不加 serverTimezone=Asia/Shanghai,而服务器和本地时区不一致,你会碰到“预约日期显示成前一天”的灵异问题。另外,前端传“2025-05-01”这个字符串,后端如果直接 LocalDate.parse,不会有问题;但如果你用 Date 类型接收,跨时区转格式时就容易出偏差。
解决方案是统一用 LocalDate/LocalDateTime,并在 application.yml 里配置 jackson 的时间格式。这是一个小而关键的经验,但我见过不下五个项目在这里栽过跟头,值得单独写进避坑列表。
5.4 本地能跑,部署到服务器就404
常见原因是静态资源和前端页面打包路径不对。如果用了 Thymeleaf,把页面放在 templates 目录下是对的;如果用了 Vue 打包后的 dist,要记得放到 static 或配置资源映射。还有一点,Spring Boot 的默认 context-path 如果设置了 /api,注意前端请求是否都要带这个前缀,我帮别人排查过,项目本地能跑是因为前端代理,部署后代理没了,所有请求全 404。
部署时我建议直接用 java -jar 启动,不要用外置 Tomcat。然后通过 nohup 或者 systemd 做后台运行,设置环境变量区分 dev/prod 两个 profile。服务器上端口、数据库密码通过环境变量注入,不要把生产配置硬编码在 application.yml 里。
5.5 常见问题速查表
| 问题 | 典型现象 | 推荐解决办法 |
|---|---|---|
| 事务失效 | 一个方法里 this 调用另一个事务方法 | 注入自身代理或拆分 Service |
| 定时任务不执行 | 日志无输出,状态未更新 | 加 @EnableScheduling,方法 public,try-catch 包逻辑 |
| 日期多一天 | 预约日期变成前一天 | 数据库连接加 serverTimezone,统一 LocalDate |
| 部署后 404 | 本地接口正常,服务器请求所有路径 404 | 检查 context-path、静态资源目录、前端代理 |
| 库存超卖 | 库存显示负数,预约单多于库存 | update set stock=stock-1 where stock>0 |
6. 毕设答辩、扩展与个人经验总结
6.1 演示路径与答辩高频问题
答辩演示不要从注册开始慢慢走,时间不够。我建议的演示顺序是:先用管理员账号登录,展示今日预约总览、疫苗库存和统计图表;然后切到普通用户端,进行一整个“查询疫苗-提交预约-查看我的预约”流程;再用工作人员账号核销,生成接种记录;最后回管理后台看数据变化。这个闭环在三分钟内能讲完,而且每个环节都能被评委看到。
高频问题先准备好:为什么选 Spring Boot?库存超卖怎么解决?预约状态怎么流转?数据一致性怎么保证?如果被问到“如果预约系统访问量巨大怎么办”,可以答:Redis 缓存热点疫苗库存、MQ 削峰、数据库乐观锁兜底。不要只答“加服务器”,太虚。
6.2 可以继续加的功能点
如果你的时间充裕,或者想做出区分度,有几个扩展方向性价比很高:消息通知模块,预约成功或取消时发送邮件或短信;疫苗批次追溯,扫码查批次有效期;预约二维码核销;小程序端入口。加一个消息通知就够写一个章节,而且功能闭环更完整。
我还建议顺手做一个“接种进度提醒”的定时任务:比如第二针到期前三天,给未接种第二针的用户发短信通知。这个功能既涉及数据查询,又涉及外部接口封装,答辩时能展示的东西非常多。
6.3 写在最后:我的实操体会
这套系统我从 Spring Boot 2.6 时代做到现在,版本换过三轮,最深的体会是:不要被“单表 CRUD”骗了,业务系统真正的复杂度一定在状态流转和并发控制上。我见过太多同学花大把时间调表格样式,却在“重复预约”和“库存扣减”上只写了简单的校验,最后演示时被评委一追问就露馅。
如果你还在起步阶段,我建议先按我上面第 2 节的数据表把库建出来,哪怕什么都不写,先把预约状态机用枚举类定义好,后续的代码逻辑会顺很多。最后再提醒一句:IntelliJ IDEA 社区版完全够开发 Spring Boot,不需要折腾其他版本;用 Spring Initializr 初始化项目,选好 JDK 版本,比对着网上的老教程一步步建 Maven 项目省心得多。祝你的毕设顺利过审,答辩不翻车。