☰
基于SSM框架的HPV疫苗预约管理系统设计与开发实战
2026/10/15 6:36:14 网站建设 项目流程

跟计算机毕设死磕过的人,估计都有同感:系统选题看着一大堆,真挑起来要命。太简单的像图书管理,答辩时老师两眼无光;太复杂的比如分布式秒杀,别说三个月,光环境配置就能劝退大半。我当初选的这个选题,某种程度是个“折中主义”的答案——基于SSM框架的HPV疫苗预约管理系统,既有现实故事可讲,又有足够的技术点可以展开,数据库设计也撑得起一张ER图,不会出现“一张用户表走天下”的尴尬。

HPV疫苗这两年有多火不用我多说了,九价年龄放宽之后,预约全靠手速和运气,线下排队登记更是折腾人。这个系统本质上就是做一个全流程的疫苗预约服务平台:用户在小程序或网页端看疫苗信息、选接种点、选时间、预约、查询记录;管理员在后台管疫苗库存、管预约单、管接种记录。听起来和普通“预约挂号系统”差不多,但真的落地时涉及的细节很多,比如一人三针的针次管理、疫苗批次和库存扣减的一致性、预约单状态机的流转。这篇文章我就把整个项目的技术选型、数据库设计、核心代码思路、部署踩坑经验全部摊开讲,适合正在准备毕设开题、或者已经抄起IDEA准备动手的同学参考。

1. 项目选题与技术选型的完整思路

1.1 选题背后的社会场景与业务需求

先说为什么选这个方向。HPV疫苗预约这个场景,天然自带三个特征:用户量大、预约具有时效性、业务流程有监管需求。把它作为毕设项目,无论是从业务完整度还是从数据复杂度的角度,都刚刚好卡在本科毕设该有的水平线上。你去网上搜疫苗预约系统的需求文档,多半能看到用户注册、疫苗查询、在线预约、接种记录、后台管理等几个模块,这其实是行业里对这类系统已经达成的共识。

业务上的核心痛点其实是“供需匹配”——疫苗到货量有限、每个接种点放号量有限、用户又想约到自己合适的时间。这就在业务上天然提出了两个要求:第一,系统要能管理“批次库存”;第二,预约操作必须满足时间窗和针次间隔的校验规则。这两个点恰恰是数据表设计和事务逻辑可以重点展开的地方,答辩时老师很爱问。

1.2 为什么没有盲目跟风Spring Boot

现在网上遍地都是Spring Boot的教程,但毕设选题选SSM骨架的项目依然有大批人做,原因很现实:很多高校的软件工程课程、Java Web课程,教材和实验环节用的就是SSM,学校对毕设系统的要求也往往写了“基于SSM框架”。你在选题表上填“基于Spring Boot+Vue的疫苗预约管理系统”当然也可以,但如果开题报告里写的约束就是SSM,那硬换技术栈只会给自己挖坑。SSM发展到今天已经非常成熟,Spring负责对象管理,Spring MVC负责请求分发,MyBatis负责数据持久化,三者各司其职,层次分明,反而容易把“三层架构”这件事讲清楚。这也是答辩时一个隐形加分项——老师一听你分层思路清晰,就愿意给你过。

当然,SSM也有它的痛点:配置繁琐、jar包冲突多、没有内嵌容器。这一点我在后面“部署实操”里会详细讲,提前说个结论:用Maven管理依赖、用IDEA模板创建Web项目,配合Tomcat运行,整体体验其实没有传说中那么痛苦。

1.3 项目核心模块与用户角色划分

这个系统我按最常见的B/S模式设计,一共划分三类角色:

  • 普通用户:注册登录、浏览疫苗产品目录、按接种点查看可预约时段、提交预约、取消预约、查看我的预约记录和接种记录。
  • 接种点管理员:管理自己所属接种点的疫苗库存、维护可预约时间表、审核预约单、确认签到接种、登记接种记录。
  • 系统管理员:管理所有用户账号、管理接种点信息、管理疫苗基础信息(名称、价次、厂商、适用年龄)、查看全平台统计数据。

角色的功能矩阵不要贪多,关键是每个功能都落在真实的业务闭环里。比如用户预约成功以后,管理员能在后台看到列表,点击“确认接种”后,用户的“预约单状态”从待接种变成已完成,同时生成一条接种记录,疫苗库存对应扣减。这个闭环跑通了,整个系统的完整度就出来了。

2. 系统架构与数据库设计的落地细节

2.1 前后端交互与SSM三层架构

整个系统的物理结构是标准的浏览器 + Tomcat + MySQL。浏览器端用JSP作为视图层(也有用FreeMarker的,但JSP在SSM里最省事),配置好视图解析器之后,Spring MVC通过ModelAndView把数据传回JSP用JSTL+EL渲染。这属于典型的多页面应用(MPA),好处是没有跨域问题、会话管理方便、对毕设来说非常稳妥。我见过同学硬上前后端分离,Vue调接口怎么都调不通,最后答辩前一天又改回JSP的,这种折腾真的没必要。

代码包结构建议按这个层次来规划:

com.hpv.book ├── controller // 控制层:参数接收、权限控制、返回视图或JSON ├── service // 业务层:预约事务、库存校验、状态流转 ├── mapper // 数据持久层:MyBatis接口 ├── pojo // 实体类:User、Vaccine、Appointment... ├── utils // 工具类:验证码、日期处理、常量类 └── config // 配置类(若用纯注解方式)

控制层关注“接收请求、调用服务、返回结果”,业务层关注“规则校验、事务边界、状态变更”,数据层只关注“SQL与ORM映射”。这个分层原则在毕设答辩时几乎必问,你得能说清楚“为什么预约逻辑写在service而不是controller”。

2.2 数据库表设计与关系梳理

我建了六张核心表,这也是ER图的主要构成:

表名用途关键字段示例
t_user用户账号表用户名、密码、手机号、身份证号(脱敏显示)、角色类型
t_vaccine疫苗信息表疫苗名称、适用年龄、厂商、针次总数(如3针)、简介
t_hospital接种点信息表接种点名称、地址、联系电话、经纬度(可选)
t_vaccine_stock批次库存表关联疫苗ID、库存总数、剩余数、生产批号、过期日期
t_appointment预约单表关联用户ID、接种点ID、预约时间、针次序号、状态
t_inoculation_record接种记录表关联用户ID、疫苗ID、针次序号、接种时间、接种点

特别注意两个设计细节:

第一,不要把库存数量直接写在疫苗表里。疫苗是按批次管理的,同一款疫苗会有多个批号,过期日期不一样,处理方式也不同。把库存下沉到t_vaccine_stock表,扩展性会好很多。

第二,预约单表里要冗余存一份疫苗名称和剂量信息(比如vaccine_name、dose_no),不要全程去做关联查询。原因是业务上预约单一旦生成,即使后台把疫苗信息下架了,用户仍然需要看到自己约的是“九价HPV疫苗第三针”,冗余字段能保证历史数据的可读性。这种做法违背了三范式的严格约束,但在实际业务系统里非常常见。

2.3 预约核心规则在数据层的表达方式

HPV疫苗接种有一个典型规则:这个疫苗通常要打三针,第一针和第二针间隔至少2个月,第二针和第三针间隔至少6个月,且第一针和第二针可以在不同接种点打,但系统逻辑最好还是限制“同一身份证在同一接种点同一时间段只允许一个有效预约”。这些规则必须在业务层拼接一个校验链路:

  1. 当前用户是否已实名认证(身份证号不可为空)。
  2. 用户是否存在“待接种”或“待审核”状态的有效预约单,若有,拦截。
  3. 判断本次预约的针次序号(首针、第二针、第三针),通过用户已有的接种记录推算间隔是否满足要求。
  4. 检查目标接种点在所选时间段的库存余量是否大于0。
  5. 全部通过,生成预约单,并预占库存。

这些步骤如果用代码硬写,业务层会有一大串if-else。我建议做一个AppointmentValidator工具类,把校验逻辑拆成独立的方法,每个方法只干一件事,这样答辩的时候你也能展示自己对单一职责的理解。

3. 核心功能模块的实现过程与关键代码

3.1 用户端疫苗信息浏览与条件检索

首页要展示当前可预约的疫苗列表,用户最关心的信息是:疫苗名、剩余针次价格区间、适用年龄、附近可预约的接种点。实现上就是VaccineMapper的联表查询,配合PageHelper做分页。

// 分页查询:获取当前用户所在城市的可预约疫苗列表 @Select({"<script>", "SELECT v.id, v.name, v.manufacturer, v.age_range, v.dose_num,", "(SELECT SUM(stock.remaining) FROM t_vaccine_stock stock WHERE stock.vaccine_id = v.id AND stock.remaining > 0) AS total_remaining ", "FROM t_vaccine v ", "WHERE v.status = 1", "<if test='keyword != null and keyword != \"\"'>", "AND (v.name LIKE CONCAT('%', #{keyword}, '%') OR v.manufacturer LIKE CONCAT('%', #{keyword}, '%'))", "</if>", "ORDER BY v.create_time DESC", "</script>"}) List<VaccineVO> selectAvailableVaccines(@Param("keyword") String keyword);

这段SQL里用了一个子查询来实时计算“总剩余库存”,在数据量几百条时性能完全够用。你的Mapper接口类似这样就行。

3.2 预约下单与防超卖的核心逻辑

这里是最值得展开的地方,也是网上很多教程讲不清楚的地方。用户点击“立即预约”后,前端把vaccineStockId、hospitalId、appointmentTime、doseNo这些参数提交到后端。后端BookService里的核心方法大概是这个流程:

@Transactional(rollbackFor = Exception.class) public void createAppointment(AppointmentRequest request) { // 1. 参数校验(简单正则) VueUtil.validateAppointmentRequest(request); // 2. 规则校验:实名、针次间隔、重复预约 List<String> errors = appointmentValidator.validate(request); if (!errors.isEmpty()) { throw new BusinessException(String.join(";", errors)); } // 3. 悲观锁或条件更新,扣减库存 int result = vaccineStockMapper.deductStock(request.getStockId()); if (result <= 0) { throw new BusinessException("该时段库存已被约完,请选择其他时段"); } // 4. 创建预约单 Appointment appointment = new Appointment(); appointment.setUserId(request.getUserId()); appointment.setStockId(request.getStockId()); appointment.setAppointmentTime(DateUtils.parse(request.getAppointmentTime())); appointment.setStatus(AppointmentState.PENDING.getCode()); appointmentMapper.insert(appointment); }

关键在于第3步,也就是“防超卖”。两种常见做法:

一种是先查库存再扣减,但这在并发下会有问题。两个请求同时查到库存为1,然后同时执行扣减和插入,就可能导致库存变成-1、预约单超额。解决方式很简单,把扣减动作做成一条带条件的UPDATE语句:

UPDATE t_vaccine_stock SET remaining = remaining - 1 WHERE id = #{stockId} AND remaining > 0

这条UPDATE本身是原子性的,remaining > 0是必要条件,返回值受影响行数为0就说明库存已经被抢完,直接抛异常回滚事务。我实测下来,这种写法在普通Tomcat并发场景下完全够用,不需要上Redis或者分布式锁,毕设展示时也更好解释。

3.3 管理端预约审核与接种结果登记

后台的预约单列表用一张表格展示所有状态,管理员可以按状态筛选:待确认、待接种、已完成、已取消、爽约。预约单的状态流转我定义成了枚举类:

public enum AppointmentState { PENDING(0, "待确认"), CONFIRMED(1, "待接种"), FINISHED(2, "已完成"), CANCELED(3, "已取消"), ABSENT(4, "爽约"); }

管理员点击“确认接种”时,系统不只是改一个状态,而是要完成三件联动的事:改预约单状态为已完成、在接种记录表插入一条记录、把该用户下一针的“可预约资格”放开。我建议把这三步封装到一个@Transactional方法里,保证要么全成功,要么全失败,绝对不要让状态改成功了但接种记录没生成。

@Transactional(rollbackFor = Exception.class) public void finishInoculation(Long appointmentId, Long adminId) { Appointment appointment = appointmentMapper.selectById(appointmentId); if (appointment == null || !AppointmentState.CONFIRMED.getCode().equals(appointment.getStatus())) { throw new BusinessException("当前预约单状态不支持此操作"); } appointment.setStatus(AppointmentState.FINISHED.getCode()); appointment.setFinishTime(new Date()); appointmentMapper.updateById(appointment); InoculationRecord record = new InoculationRecord(); record.setAppointmentId(appointment.getId()); record.setUserId(appointment.getUserId()); record.setVaccineId(appointment.getVaccineId()); record.setDoseNo(appointment.getDoseNo()); record.setInoculationTime(new Date()); record.setOperatorId(adminId); inoculationRecordMapper.insert(record); }

3.4 数据可视化:后台首页统计报表

很多毕设系统后台就放一张“欢迎页面”,太浪费了。我加了一个统计模块,用ECharts(通过CDN引入JS)在后台首页渲染两个常见图表——近30天预约量折线图、各疫苗类型预约占比饼图。后端提供一个返回JSON的Controller接口,前端用Ajax拉数据,就是这么简单。这类图表的实现代码网上能搜到很多,但放在疫苗预约系统里,能明显拉升项目的完整度。对数据库的查询也可以顺便筛掉订单状态为“已取消”的数据,让统计口径更准确。

4. 部署环境准备与从零到一的运行实战

4.1 开发工具与版本组合建议

先列一套我实际验证过、兼容性很稳的组合:

组件版本建议理由
JDKJDK 8(1.8)与Spring 5.x、Tomcat 8.5兼容性最好,避免新版本改动踩坑
Maven3.6.3稳定版,配阿里云镜像下载依赖快
MySQL5.7 或 8.05.7更稳,8.0注意驱动和时区参数
Tomcat8.5.x支持Servlet 3.1,SSM项目最常用
IDEA2020-2023版均可社区版够用,但Ultimate对Web项目支持更好
Navicat不限建库、转储SQL方便

JSP项目配置的时候,Source目录一定要把Java和resources资源文件分开标记,否则jdbc.properties和MyBatis的XML配置文件不会被编译到classes目录下,运行时会直接报“找不到配置文件”。这个问题我帮人排查过好几次,基本都是不熟悉IDEA的Project Structure目录分类导致的。

4.2 核心配置文件逐行解读

SSM的配置文件比较多,核心就三个:spring-mvc.xml、spring-mybatis.xml、web.xml。

spring-mybatis.xml里需要配置三样东西:数据源、SqlSessionFactory、Mapper扫描器。数据源我用Druid连接池,比c3p0好用且自带监控:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}" /> <property name="url" value="${jdbc.url}" /> <property name="username" value="${jdbc.username}" /> <property name="password" value="${jdbc.password}" /> <property name="initialSize" value="5" /> <property name="minIdle" value="5" /> <property name="maxActive" value="20" /> </bean>

如果你是MySQL 8.0,驱动类要写com.mysql.cj.jdbc.Driver,url需要追加serverTimezone=Asia/Shanghai,否则会报时区错误。我当年第一次配MySQL 8.0,卡在这里半个多小时,数据库客户端能连上,代码启动就报The server time zone value '�й���ʱ��',根本原因就是时区参数没指定。

spring-mvc.xml里重点配置注解驱动和视图解析器:

<mvc:annotation-driven /> <context:component-scan base-package="com.hpv.book.controller" /> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/" /> <property name="suffix" value=".jsp" /> </bean> <mvc:resources mapping="/static/**" location="/static/" />

最后一行mvc:resources很关键,它负责放行CSS、JS、图片这些静态资源,如果不配,浏览器会返回404且样式全丢。

4.3 本地部署步骤实录

按这个顺序往下走,基本半小时能把环境跑通:

  1. MySQL里新建数据库hpv_book,字符集选utf8mb4,导入我提前写好的init.sql。
  2. IDEA里以Maven方式导入项目,等待依赖解析完成。
  3. 修改jdbc.properties里的用户名密码,保证IP、端口、库名都对得上。
  4. 点击IDEA右上角Tomcat配置,Deployment里添加war exploded,Application context设为/hpv_book。
  5. 启动Tomcat,浏览器访问http://localhost:8080/hpv_book。

如果你是第一次玩Tomcat配置,注意Deployment那一栏千万别把Application context改成/,否则后面所有URL都要跟着改。直接保持项目名作为context path,后端Controller里返回的视图路径只要相对地匹配就行。

为了演示方便,可以预先造一批演示账号:普通用户账号、接种点管理员账号、系统管理员账号。这样答辩时登录管理员账户,直接展示预约列表和统计数据,节省现场输入账号密码的时间。

4.4 答辩时老师常问的几个追问

项目跑起来以后别光顾着做演示,老师大概率会顺口问几句:

  • “预约并发怎么保证不超卖?”→ 答:库存扣减使用带条件(remaining > 0)的UPDATE原子操作,配合事务,超卖时受影响行数为0,回滚。
  • “为什么做三针间隔的校验?”→ 答:这是疫苗接种业务的硬性要求,在Service层通过查询既往接种记录计算间隔天数来约束,同时按针次序号控制预约资格。
  • “如果两个用户同时抢最后一针名额呢?”→ 答:同上,数据库行锁解决了并发覆盖问题,可以适当提一下乐观锁版本号的方案作为可扩展优化点。
  • “SSM的Bean生命周期说一下?”→ 这属于底层基础,提前背一遍,瞬时打分不少。

5. 开发踩坑实录与常见问题速查

这部分是我最想写的,全是实操里真实遇到过的坑。我整理成一个表,你能直接照着排查。

现象根因排查方式与解决
启动Tomcat报ClassNotFoundException: org.springframework.web.context.ContextLoaderListenerSpring-web jar包缺失或未打包到WEB-INF/lib检查Maven依赖,确认spring-web在pom里,并且右侧Maven面板执行package,看目标目录有无依赖jar
JSP页面EL表达式原样输出${user.name}web.xml中Servlet版本低于2.4,EL默认关闭把web.xml的表头声明换成3.0以上版本,或者直接在JSP页面加<%@ page isELIgnored="false" %>
页面中文全变乱码请求编码过滤器没配置在web.xml里加Spring的CharacterEncodingFilter,强制UTF-8,同时确认JSP页面顶部<%@ page contentType="text/html;charset=UTF-8" %>
表单提交后Controller收到null实体类属性名与前端name值不一致检查前端name和后端实体字段的驼峰命名对应关系,比如前端vaccineName对应后端vaccineName,不要写成vaccinename
预约成功但库存没减@Transactional没生效或方法内部try-catch吞了异常检查是否引入spring-tx,启动类是否开启@EnableTransactionManagement(若用SSM,则在配置里配了事务管理器后会生效);内部不要把异常吞掉,事务回滚依赖异常传播
MyBatis说Invalid bound statement (not found)Mapper接口和XML的namespace不对应,或XML没被扫描检查XML文件是否在resources目录下且路径和Mapper接口包路径一致;在spring-mybatis.xml里检查mapper-locations通配符
MySQL 8.0连接报Public Key Retrieval异常驱动版本与MySQL 8.0认证规则不兼容升级mysql-connector-java到8.0以上,并在url后加allowPublicKeyRetrieval=true(开发环境可以这样)
前端静态资源样式加载不出来拦截器拦截了静态资源排查spring-mvc.xml的mvc:resources或拦截器配置,注意mvc:exclude-methods等配置是否误伤

再额外补充一点关于PageHelper的细节,很多同学用分页插件出现“查出来的数据总是第一页,或者total不对”,多半是因为没有在PageHelper.startPage之后立即执行Mapper查询,中间哪怕多了一次无关查询都会让分页失效。正确用法:

PageHelper.startPage(pageNum, pageSize); List<VaccineVO> list = vaccineMapper.selectAvailableVaccines(keyword); PageInfo<VaccineVO> pageInfo = new PageInfo<>(list);

注意PageHelper.startPage方法返回的Page对象不要接收,否则后续方法会串。这个坑我印象很深,当时花了一晚上才反应过来。

另外,数据库表名和字段名尽量别用MySQL保留字,比如order、desc,如果非要用,SQL里就要写成反引号包裹,否则会报语法错误。我有一张预约表最初设计叫order,结果SQL都写不顺,后来统一改成t_appointment,一次搞定。

6. 项目后续扩展方向与移植思路

如果时间充裕,这个项目能扩展的地方其实很多。比如最直接的:把浏览器端从JSP换成Vue或小程序,后端改成纯JSON接口,就变成了一个前后端分离版本;再比如给“疫苗预约”增加消息通知——预约成功、接种前一天短信或邮件提醒,可以引入阿里云短信SDK,这块业务逻辑可以和业务系统完全解耦写个独立模块;或者用定时任务(Spring Schedule)扫描超时未到场的预约单,自动置为爽约并释放库存。这些扩展点每一个都能作为“项目创新点”写进论文的展望部分,有同学运气好还能靠爽约释放这个细节和老师多聊几句。

我在实际做的过程中最大的感受是:毕设项目的价值不在于用了多新的技术,而在于你有没有把一条业务线完完整整跑通,并且能说清楚每个逻辑为什么这么设计。HPV疫苗预约管理系统这个选题,切口适中,技术栈经典,业务有真实社会背景,确实是一个能让人静下心来做出完整度的题目。如果你正在开题或者已经动手了,希望这篇东西能帮你少踩几个坑。后面如果想了解具体某一块,比如数据库表结构怎么建、或者权限控制怎么做,可以再聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询