毕业设计选了个“在线健康体检服务平台”,用Java整套做下来,从前期调研到答辩顺利过关,前后花了大概两个月。这个题目看起来普通,实际暗坑不少:既要照顾预约流程的业务闭环,又要处理报告生成、权限控制、并发抢号这些技术点,但反过来也正因为覆盖面广,它几乎能把Java后端常见的知识点全部串起来。如果你正在纠结毕设选题,或者已经选了类似题目不知道怎么往下铺,这篇内容可以当一份完整参考。我会从项目定位、技术选型、数据库设计、核心代码、常见坑点一路讲下去,保证你花时间看完能少走一半弯路。
1. 为什么选这个题目:在线健康体检服务平台的定位与拆解
1.1 毕业设计选题的真实痛点
很多人在选题时踩过同一个坑:题目看起来“高大上”,做起来却发现全是堆页面,没有真正的业务深度。比如做一个“图书管理系统”,核心就一张CRUD表,功能做完连自己都不好意思写进简历。反过来,选“电商秒杀”这类题又容易被问到分布式事务、消息队列,难度一下拉满,一个人短时间根本撑不住。
“在线健康体检服务平台”这个题目最大的优势是它落在中间位置:业务上不缺复杂度,技术上也够发挥空间。体检不是简单下单,它有套餐选择、机构选择、日期时段、订单状态流转、报告生成、医生审核、报告下载这些环节,每一个环节都有真实的逻辑约束。技术上你可以只做到单机事务,也可以加上Redis缓存、分布式锁、消息通知,伸缩余地很大。整套做完,前后端、数据库、并发、文件上传、权限拦截都能展示出来,面试聊项目时每个点都能往下挖。
而且这个题有明确的现实出处——现在的体检中心几乎都在做线上预约和电子报告,你做完的东西是有实际对照物的,不虚。
1.2 用户角色与业务流程梳理
不要一上来就写代码,先把角色理清楚。我当时花了三天时间画角色图和状态图,后面写代码基本没返工。
体检平台至少涉及四类角色:
- 普通用户:浏览套餐、选择机构、提交预约、支付(可选)、查看报告。
- 体检机构:可以理解为体检中心/医院,机构管理员负责设置可预约时段、录入体检结果。
- 医生/护士:录入具体体检项目的结果数据、上传检验图像、提交报告。
- 系统管理员:管理用户、机构、套餐、订单、报告审核、数据统计。
业务流程上,主链路是“用户选择套餐→填写个人信息→选择机构与时段→锁定号源→生成预约订单→机构端确认或用户到店→体检完成后医生逐项录入结果→系统合成报告→用户查看并下载报告”。
这里面有几个容易被忽略的状态节点:订单要允许“待支付”“已预约”“体检完成”“报告生成”“已取消”这几个状态;报告要有“草稿”“待审核”“已发布”状态。状态节点设计得清楚,后面代码里所有的if判断会舒服很多。
1.3 技术选型的核心思路与原因
我用的技术栈是:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis + JWT + Vue 3 + Element Plus。纯Java后端和前端Vue分离开发,这也是当下毕业设计的主流搭配,答辩时老师也更容易认可。
选Spring Boot而不是SSH或Servlet/JSP,原因很直接:Spring Boot会自动配置大量基础设施,让你把精力放在业务逻辑上,而不是去手写一堆配置文件。MyBatis-Plus解决了单表CRUD的重复劳动,分页查询也内置了,非常适合毕设这个体量。Redis这里主要干两件事:一是缓存套餐列表和机构时段数据,减少数据库压力;二是配合分布式锁处理同一时段并发预约。如果你不想引入Redis,用数据库唯一索引加乐观锁也能实现,但有了Redis,项目里的“并发处理”就能讲出故事。
前端Vue 3 + Element Plus是当前主流搭配,组件全、文档多,预约表单、表格展示、文件上传这些都有现成组件。如果你前端基础一般,强烈不建议手写原生HTML,太费时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:数据库设计、权限模型与预约状态机
2.1 数据库表设计:一张图看懂表关系
体检平台的表设计比一般管理系统多两层,这里我给出核心表的字段思路,照着建就能用。表不是越多越好,关键是关系的闭环。
核心表大致有:用户表(user)、机构表(institution)、套餐表(package)、套餐明细表(package_item)、时段表(time_slot)、预约订单表(appointment_order)、报告表(report)、报告明细表(report_item)、支付记录表(payment)。
重点关注三张关键表的设计:
appointment_order(预约订单表):
- id、order_no(订单号,唯一索引)、user_id、institution_id、package_id、slot_id(预约时段)、appointment_date(体检日期)、status(0待支付/1已预约/2已完成/3已取消/4已过期)、contact_name、contact_phone、id_card(身份证)、price(订单金额)、create_time。
- 这里建议加一个
lock_token字段,就是预留的锁标记,后面讲并发时会用到,很多新手不会想到预留这个位。
time_slot(时段表):
- id、institution_id、slot_date、start_time、end_time、total_slots(总号源)、booked_slots(已预约数)、status。
- 时段表是预约并发的核心战场,
total_slots和booked_slots两个字段一放,后面做库存校验就非常直观。
report_item(报告明细表):
- id、report_id、item_name(项目名)、result_value(结果值)、unit(单位)、ref_range(参考范围)、result_status(正常/异常/偏高/偏低)、doctor_remark(医生备注)、is_abnormal(是否异常)。
- 报告明细表设计得好,体检结论汇总和异常项筛选都很好做。
我当时在表设计上做过一次返工,原因是“套餐和机构”的关系一开始想简单了:套餐是全局的,但同一套餐在不同机构的价格可能不同,而且不同机构不是所有套餐都提供。最后我拆出了一个institution_package关联表,单独存机构下的套餐价格和可用状态。这个细节在答辩时还被老师专门问过,属于加分项。
2.2 权限模型:四种角色如何用一套JWT串联
权限这块我没用Spring Security,因为毕设阶段引入Security会让配置比重失衡,学习成本和调试成本都偏高。我用的是JWT + 拦截器 + 注解的方式,自己控制,很简单也够用。
流程是这样的:用户登录时根据账号密码查出用户信息,生成包含userId、userType、expireTime的JWT令牌返回给前端。前端每次请求在Header里带上Authorization: Bearer <token>,后端拦截器统一解析校验,然后从token中取出用户信息放入ThreadLocal。在处理业务方法时,通过自定义注解@RequireRole("ADMIN")来判断当前用户角色是否允许访问。
具体实现思路大概是这样:
java复制public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从Header获取token
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
// 解析校验
Claims claims = JwtUtil.parseToken(token);
if (claims == null) {
response.setStatus(401);
response.getWriter().write("登录状态已失效");
return false;
}
// 存入上下文
UserContext.set(User.builder()
.id(claims.get("userId", Integer.class))
.type(claims.get("userType", Integer.class))
.build());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContext.clear();
}
}
这里有个实战细节要提醒:ThreadLocal用完一定要remove(),不然在Tomcat线程池复用的情况下,下一次请求会读到上一次的登录用户,这就是网上常说的“串用户”问题。我第一版就踩了这个坑,带token的用户A访问完,紧接着不带token用户B访问,结果后端还能返回A的数据,查了半天才发现是ThreadLocal没清理。
2.3 预约状态机:业务的顺序逻辑如何实现
预约状态我前面提了,现在具体讲讲状态是怎么流转的。状态机这块写好了,能省大量逻辑分支代码。
正常顺序:待支付(0)→已预约(1)→已完成(2)。异常分支:待支付取消(0→3)、已预约取消(1→3)、超时未支付自动置为过期(0→4)。
很多人的状态更新写成“前端传status,后端直接改”,这是大忌。正确做法是后端根据当前订单状态判断是否允许转移,不允许就抛异常。举个例子:
java复制if (!order.getStatus().equals(OrderStatus.PENDING_PAY)) {
throw new BizException("当前状态不允许支付");
}
if (!order.getStatus().equals(OrderStatus.PAID)) {
throw new BizException("当前状态不允许取消预约,请联系客服");
}
这条规则背后的逻辑是:订单状态必须由服务端自行推导,不能信任前端传入的任何status字段。我把这个点写进设计文档后,涉及到订单的所有接口都变得很干净,不会出现“订单已支付还能再取消”之类的脏数据。
3. 实操过程:从搭建骨架到实现预约与报告闭环
3.1 项目搭建与分层结构:用Maven多模块还是单模块?
先说结论:毕设就用单模块,不要整微服务,也不要强行Maven多模块。单模块结构清晰、调试方便、打包部署都简单,老师验收时也好解释。
我的包结构是这样的:
code复制com.health.app
├── config // 配置类,拦截器、CORS、Redis配置
├── controller // 控制器层
├── service // 业务层
│ └── impl // 业务实现
├── mapper // MyBatis-Plus Mapper接口
├── entity // 实体类
├── dto // 前端交互对象
├── vo // 视图对象
├── common // 通用返回结果、异常、枚举
├── utils // JWT、日期、文件等工具
└── aspect // 切面(日志)
模型驱动还是数据库驱动?我是先建表,然后反向生成实体类,MyBatis-Plus官网有代码生成器,一键生成entity、mapper、service、controller那一套。但生成之后一定要手动改,尤其要注意时间字段的格式注解和逻辑删除字段。
3.2 预约接口的核心逻辑与并发控制
预约是整个系统最核心的接口,也是并发要求最高的地方。同一个体检机构上午9点只有20个号,结果有100个人同时抢,这时候就要防止超约。
我先说我第一版写的问题代码:
java复制@Transactional
public AppointmentOrder createOrder(CreateOrderDTO dto) {
TimeSlot slot = slotMapper.selectById(dto.getSlotId());
if (slot.getBookedSlots() < slot.getTotalSlots()) {
slot.setBookedSlots(slot.getBookedSlots() + 1);
slotMapper.updateById(slot);
// 创建订单
return orderMapper.insert(order);
}
throw new BizException("该时段预约已满");
}
表面看没问题,但两个请求同时通过slotMapper.selectById读到已预约数量19,都判断小于20,然后都执行了加一操作,最终booked_slots变成21,超卖1个。这就是典型的并发竞态。
解决办法按难度从低到高有三种:
第一种:数据库乐观锁。
给time_slot表加一个version字段,更新时带上版本号判断:
java复制// mapper中自定义更新
UPDATE time_slot SET booked_slots = booked_slots + 1, version = version + 1
WHERE id = #{id} AND version = #{version}
如果影响行数为0,说明字段已被别人改了,重试即可。这种方案简单可靠,不依赖Redis。
第二种:Redis分布式锁。
用一个Redis键当作时段的锁,预约前先尝试加锁,拿到锁再执行查询和更新。
java复制String lockKey = "lock:slot:" + dto.getSlotId();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked == null || !locked) {
throw new BizException("系统繁忙,请稍后重试");
}
try {
// 业务逻辑
} finally {
redisTemplate.delete(lockKey);
}
锁的过期时间必须设置,防止业务执行一半宕机导致锁永久不释放。这属于兜底方案,也是面试官最喜欢追问的点,你要能答出来“这个锁只能针对一个JVM实例,如果要跨服务得用Redisson分布式锁”,这么说就已经超过大部分应届生了。
第三种:数据库唯一索引兜底。
给预约表加联合唯一索引(user_id, slot_id, appointment_date, status),规定一个用户在同一机构同一时段只能有一个有效订单。就算前面逻辑漏了,数据库也会拦一道,保证不重复预约。这个做法非常推荐,成本极低,收益很大。
我的最终实现是“乐观锁+唯一索引”双保险,Redis锁在毕设阶段反而没加,因为需要额外配置Redis环境,不便于老师部署验收。这里给你一个真诚的建议:如果你答辩环境不能保证Redis可用,就不要赌,用数据库方案反而扎实可靠。
3.3 报告生成的两种路径:Excel导入与PDF合成
体检报告的功能点和预约不一样,它的难点在于内容不是用户填的,而是医生/机构录入后生成的,而且结果还分正常、异常,异常项要醒目标注。
我的实现拆成了四步:
第一步:医生录入结果。 医生根据预约订单中的套餐明细,逐项录入检测结果、单位、参考范围,由前端表格组件完成,一行对应report_item表的一条记录。
第二步:自动计算异常项。 后端收到明细后对比参考范围(参考范围解析成两个数值,比如3.9-6.1),超出就标记异常并给出偏高或偏低提示。这一步千万别丢给前端做,因为不同年龄段、不同性别的参考范围不同,后端统一控制才安全。
第三步:生成PDF报告。 后端从report_item表读取数据,用一个简单的模板引擎生成PDF报告,包含用户基本信息、体检机构、每个项目名称、结果、参考范围、结果状态和医生结论。毕设场景不必引入复杂的报表组件,用iText或者开源的Pdfbox都行,写一个生成工具类,把数据填充到写死的模板表格里即可。PDF的好处是文件只读,不易篡改,而且可以直接上传到OSS或本地磁盘供下载。
第四步:报告审核与发布。 医生提交报告后状态变为“待审核”,管理员或机构负责人审核通过后才对用户可见。这里要特别注意:用户不能看到未审核的报告,所以查询报告时状态条件必须加上,漏掉就涉及隐私合规问题了。
3.4 文件上传与静态资源处理:别让报告和图片把项目拖垮
体检报告涉及的图片不少:检验单照片、CT影像文件等。我是用本地上传方式,在项目下创建一个upload目录,用UUID重命名文件防止文件名冲突。上传接口接收MultipartFile,校验文件大小和类型,转存后返回访问URL。
java复制@PostMapping("/api/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.fail("上传文件不能为空");
}
// 限制大小 10MB
if (file.getSize() > 10 * 1024 * 1024) {
return Result.fail("文件大小不能超过10MB");
}
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
String filename = UUID.randomUUID().toString().replace("-", "") + ext;
File dest = new File(uploadDir, filename);
file.transferTo(dest.getAbsoluteFile());
return Result.ok("/upload/" + filename);
}
静态资源映射在Spring Boot里要显式配置,否则上传成功的文件访问不到:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceHandler("file:" + uploadDir + "/");
}
}
最后一个坑:如果上传目录放在项目内,打包成JAR后路径会变化,导致文件访问不到。建议把上传目录配置成绝对路径,比如/data/health/upload,并在application.yml中做成可配置项,这样部署时改配置就行。
4. 常见问题与排查技巧实录:毕设阶段最容易踩的坑
4.1 并发预约超卖:现象、定位与修复
现象:用JMeter并发100个用户抢同一个时段,后台booked_slots超过了total_slots,订单数也超了。
定位思路:先在数据库查看time_slot和appointment_order数据,确认超卖范围;然后看写代码里update操作是否带了版本条件;再看service层方法上是否加了@Transactional;最后看隔离级别是否为默认的READ_COMMITTED。
修复:使用乐观锁(version字段)或者UPDATE time_slot SET booked_slots = booked_slots + 1 WHERE id = ? AND booked_slots < total_slots这种原子更新SQL。第二种方法不需要version字段,一条UPDATE就解决了,比较适合毕设。
经验:排查并发类bug唯一可靠的手段就是压测,不要靠肉眼review。装一个JMeter,5分钟就能验证修复效果。
4.2 Lombok不生效,编辑器报警报错
这个坑特别典型:代码中用了@Data,但IDEA里就是找不到getter/setter方法,编译直接报错。原因一般是Lombok插件版本和编译器的版本不匹配,或者依赖版本没对应上。
解决办法:IDEA中打开Settings → Plugins,确认Lombok插件已安装且启用;再确认项目依赖版本,Spring Boot 2.x推荐lombok 1.18.30以上;最后在Build, Execution, Deployment → Compiler → Annotation Processors中勾选“Enable annotation processing”。这三个步骤做完,99%的Lombok问题都能解决。
4.3 时区与日期问题导致的预约日期错乱
预约日期是前后端高频交互的数据,非常容易因为时区不一致错位。比如用户在页面上选了“2025-06-10”,前端传到后端变成“2025-06-09T16:00:00.000Z”,再入库又偏移一次,等用户查询时发现预约日期比预期早了一天。
我在项目里定了一个铁律:前后端传输日期全部用字符串,格式统一为yyyy-MM-dd HH:mm:ss;日期字段在MySQL中用datetime类型;后端实体用LocalDateTime,不做任何时区转换。
具体配置MyBatis时注意,JDBC连接串上加serverTimezone=Asia/Shanghai,这样日期格式就不会出问题:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/health_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
4.4 前台列表串数据:ThreadLocal未清理的典型症状
前面提过“串用户”问题。症状表现为:A用户登录后访问某个接口,返回数据正常;B用户退出登录或不带token访问同一接口,居然能看到部分A用户的敏感数据。这个bug非常隐蔽,因为不是必现的,时有时无。
排查方法:在拦截器preHandle中打印ThreadLocal的值,访问几次就能发现线程复用的痕迹——上一次请求的用户信息还留在当前线程里。修复很简单,在afterCompletion中调用UserContext.clear()即可。这个bug在面试中提到会非常加分,因为它属于即懂并发原理又懂框架细节的证明。
4.5 套餐列表排序和搜索的小技巧
热词里出现了排序和并发,我看有不少人会纠结套餐列表的排序怎么做。其实大多数场景一个ORDER BY就搞定,不需要写复杂算法。比如按价格从低到高:
java复制page.addOrder(new OrderItem().setColumn("price").setAsc(true));
但如果是“推荐套餐排序”,我额外维护了一个热度字段,每次套餐被预约时热度+1,这样推荐排序就是一句SQL的事:ORDER BY hot_score DESC, price ASC。简单有效,数据也好解释。
搜索上,我用MyBatis-Plus的like条件和多字段拼接做模糊搜索支持,关键词覆盖套餐名称、机构名称、体检项目名称。如果做了全文索引,还能顺便讲一讲MySQL的全文检索,属于锦上添花。
5. 从毕设到面试:如何把体检平台讲成项目亮点
5.1 项目包装的三个发力点
很多同学做完项目,简历上写“实现了预约功能、报告管理功能”,这种写法毫无竞争力。换一种思路:把“实现功能”升级为“解决工程问题”。我当时在简历上写的是:
- 设计并实现了高并发预约场景下的防超卖方案:结合数据库乐观锁与唯一索引,保证同一时段、同一用户最多一条有效预约。
- 基于JWT无状态认证方案,配合拦截器和ThreadLocal上下文,解决多角色权限鉴权,并处理了Tomcat线程复用导致的数据串扰问题。
- 设计预约状态机模型(待支付→已预约→已完成→已取消),由后端控制状态流转,避免脏数据产生。
这三点都是真实做过的细节,面试官问到任何一点你都能展开。项目不在多,在于你有没有把每件事讲清楚背后的“为什么”。
5.2 面试高频问题:我整理过一套自问清单
把毕设做完只是一个前提,你要能回答上来几个关键问题才算真正吃透:
- 为什么用JWT而不是Session?能不能说说各自优缺点?
- 乐观锁和悲观锁的区别,为什么这里选择乐观锁?
- 索引为什么能加速查询?联合唯一索引是什么场景用的?
- 如果用户支付成功后,报告生成失败了怎么处理?
- 时段表库存扣减是原子操作吗?为什么
UPDATE ... WHERE booked_slots < total_slots能做到不超卖? - 这个系统如果要做部署,你怎么保证数据库数据不丢?
简历上如果写了Redis,那必须能回答Redis的过期策略、缓存穿透、缓存击穿的区别。没做过就是没做过,宁可少写一个点,也不能在面试官追问时露馅。
5.3 后续还能怎么扩展:给自己留一个演进方向
一个毕设做出来不意味着结束,我建议你预留一个演进方向,这样答辩和面试都有“后续计划”可讲。这个项目我预留的方向是:引入消息队列,当用户支付成功时发送消息到MQ,用于通知体检机构准备报告单;引入定时任务,每天凌晨扫描过期未支付的订单批量置为取消;引入图表统计,把机构体检人数、异常项占比用数据可视化展示。这三个方向都不难,任何一个做出来,都能给“这个项目还有想象空间”加分。
按我个人的体会,这类Java医疗健康方向的项目,真正难的不是某个单一接口,而是业务状态的流转闭环:从预约到报告,每个节点都要考虑边界。第一次写代码时可以先按最简单的流程跑通全链路,再回头加并发控制、权限校验、缓存优化。这样每一步都有反馈,不会因为一上来就追求完美导致心态崩掉。最后再补个小技巧:开发时把数据库、前端、后端三个终端全部打开,看到任何报错立刻定位,比写完再调试高效得多。祝你顺利通过答辩,也希望这篇内容能帮你把项目真正装进脑子里。
