跟计算机毕设死磕过的人,估计都有同感:系统选题看着一大堆,真挑起来要命。太简单的像图书管理,答辩时老师两眼无光;太复杂的比如分布式秒杀,别说三个月,光环境配置就能劝退大半。我当初选的这个选题,某种程度是个“折中主义”的答案——基于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个月,且第一针和第二针可以在不同接种点打,但系统逻辑最好还是限制“同一身份证在同一接种点同一时间段只允许一个有效预约”。这些规则必须在业务层拼接一个校验链路:
- 当前用户是否已实名认证(身份证号不可为空)。
- 用户是否存在“待接种”或“待审核”状态的有效预约单,若有,拦截。
- 判断本次预约的针次序号(首针、第二针、第三针),通过用户已有的接种记录推算间隔是否满足要求。
- 检查目标接种点在所选时间段的库存余量是否大于0。
- 全部通过,生成预约单,并预占库存。
这些步骤如果用代码硬写,业务层会有一大串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 开发工具与版本组合建议
先列一套我实际验证过、兼容性很稳的组合:
| 组件 | 版本建议 | 理由 |
|---|---|---|
| JDK | JDK 8(1.8) | 与Spring 5.x、Tomcat 8.5兼容性最好,避免新版本改动踩坑 |
| Maven | 3.6.3 | 稳定版,配阿里云镜像下载依赖快 |
| MySQL | 5.7 或 8.0 | 5.7更稳,8.0注意驱动和时区参数 |
| Tomcat | 8.5.x | 支持Servlet 3.1,SSM项目最常用 |
| IDEA | 2020-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 本地部署步骤实录
按这个顺序往下走,基本半小时能把环境跑通:
- MySQL里新建数据库
hpv_book,字符集选utf8mb4,导入我提前写好的init.sql。 - IDEA里以Maven方式导入项目,等待依赖解析完成。
- 修改
jdbc.properties里的用户名密码,保证IP、端口、库名都对得上。 - 点击IDEA右上角Tomcat配置,Deployment里添加
war exploded,Application context设为/hpv_book。 - 启动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.ContextLoaderListener | Spring-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疫苗预约管理系统这个选题,切口适中,技术栈经典,业务有真实社会背景,确实是一个能让人静下心来做出完整度的题目。如果你正在开题或者已经动手了,希望这篇东西能帮你少踩几个坑。后面如果想了解具体某一块,比如数据库表结构怎么建、或者权限控制怎么做,可以再聊。