
每年到毕业季Java方向的同学找我问得最多的一个问题就是有没有一个毕设题目既不落俗套、又能把主流技术点都撑起来最后还能顺利过答辩说实话教务管理系统、图书管理系统这类纯CRUD题目确实好做但答辩时很容易被一句“这个项目的难点在哪里”问住。反过来如果题目过分偏算法、偏底层三个月时间又根本啃不完。我这两年带学生做得最多的一个折中方案就是Java在线问诊系统。这个题目既有真实的业务场景又把登录鉴权、角色权限、实时通信、并发预约、事务一致性这些Java后端开发的高频考察点全部覆盖了难度适中而且很贴合互联网医疗这个当下的热门方向。这篇内容我就把这个项目从需求拆解、数据库设计、核心代码、再到答辩准备完整梳理一遍给准备选题或者正在写代码的同学一个可以直接抄作业的参考。1. 在线问诊系统的业务拆解与技术选型逻辑1.1 在线问诊解决了什么问题为什么适合做毕设在动手写代码之前先把业务想清楚。线下就医有一个很典型的痛点大量轻症复诊、慢病开药的患者其实不需要每次都跑到医院排队他们需要的只是一个能联系上医生、把病情说清楚、拿到医嘱和处方的通道。在线问诊系统解决的就是这个“轻问诊复诊续方”的场景。从毕设角度讲这种业务型系统的好处非常明显。第一业务规则清晰需求分析好写。患者、医生、管理员三类角色预约、问诊、病历、处方四个核心闭环每一个功能点都能在论文里讲明白。第二技术覆盖面广且难度梯度分布合理。基础功能用SSM/Spring Boot就能实现进阶功能可以加入Redis缓存、WebSocket实时通信、乐观锁并发控制、JWT无状态认证这些正好是答辩时展示个人能力的最佳素材。第三系统规模可伸缩时间不够就做核心闭环时间充裕就扩展支付、消息通知、数据统计灵活性很高。1.2 功能模块拆分患者端、医生端、管理端在线问诊系统的功能设计围绕“人找医生、医生看病、系统留痕”这条主线展开我习惯把它拆成三个端来梳理。患者端是整个系统的流量入口也是业务起点。核心功能包括注册登录、个人信息维护、按科室/医生姓名搜索医生、查看医生排班和剩余号源、在线预约挂号、支付问诊费用如果做的话、进入问诊会话、查看电子病历和处方、对本次问诊进行评价。医生端是业务执行方。核心功能包括设置可接诊的排班时段、查看待接诊列表、创建问诊会话、在线回复患者的图文消息、根据问诊内容填写电子病历、开具处方、管理历史患者。管理端负责系统运维和业务合规。核心功能包括医生资质审核与开通、科室信息维护、用户禁用/启用、基础数据字典管理、每日问诊量/预约量统计。除此以外还有一批横跨三个端的公共模块比如短信验证码、图片上传、站内消息通知这些模块不直接产生业务价值但往往是毕设展示时的加分项后面会单独讲。1.3 技术选型为什么是 Spring Boot MyBatis-Plus Redis WebSocket技术选型是很多同学纠结的点我直接给一套经过验证的组合并说明理由。后端框架选Spring Boot 2.7.x。为什么不选3.x因为3.x要求JDK17部分学校机房的JDK环境还停留在8而且MyBatis-Plus对Spring Boot 3的适配虽然已经完成但网上很多教程和参考代码还是基于2.x写的遇到问题容易踩坑。毕设的核心目标是稳不是追新。ORM框架选MyBatis-Plus。它既有MyBatis的灵活性又内置了分页插件、条件构造器、自动填充、逻辑删除这些开箱即用的能力能省掉大量重复的CRUD代码。数据库层选MySQL 5.7或8.0都可以建议8.0字符集统一utf8mb4。缓存选Redis用于三件事一是存储短信验证码和JWT黑名单二是缓存科室列表、医生排班等热点数据三是配合预约挂号的并发控制。WebSocket用于问诊会话的实时消息推送。认证方案选JWT搭配Spring拦截器做登录校验和角色权限控制比传统的Session方案更适合前后端分离的场景。前端部分给两条路如果时间紧直接写服务端渲染的Thymeleaf模板不分离开发效率高如果想让项目更完整Vue 3 Element Plus做成前后端分离项目代码量会翻倍。我的建议是除非你本身前端基础不错否则用Thymeleaf把后端逻辑讲清楚性价比更高。因为毕设答辩的主体是后端面试官和老师更关心的是你怎么解决业务问题而不是页面用了什么组件库。2. 数据库设计一张一张表拆开讲清楚2.1 数据建模的整体设计思路在线问诊系统的数据模型围绕两条业务链展开一条是“患者-预约-问诊-病历-处方”的主链一条是“医生-科室-排班”的支撑链。设计时遵循三个原则。第一个原则是角色信息与账号信息分离。user表只存登录账号、密码、手机号、角色、状态而医生的职称、简介、擅长领域、接诊价格这类业务属性放到doctor表里通过user_id关联。这样设计的原因是医生信息是垂直扩展的如果把所有字段塞进一张表字段利用率太低后续加“科室排班颜色”这类字段时还要改主表结构。第二个原则是核心状态字段用数字字典值表示。预约状态、问诊状态、病历审核状态等字段不要直接存汉字而是用0/1/2这类整数表示页面层再用枚举或字典表转义。好处是存储省空间、索引快而且状态流转在用SQL判断时写起来非常干净比如where status 1比where status 待接诊要可靠得多。第三个原则是金额、时间、逻辑删除这类公共字段的规范统一。金额一律用decimal(10,2)不能用float/double否则订单金额在累加时会出现精度误差。时间字段统一用datetime创建时间由数据库默认值CURRENT_TIMESTAMP兜底。逻辑删除字段deleted默认0配合MyBatis-Plus的TableLogic注解查询时自动过滤已删除数据。2.2 核心表结构逐张拆解下面按业务模块把每张表的字段和关键设计点列出来。user表用户表id主键、username、passwordBCrypt加密后的密文、real_name、phone、avatar、role0患者/1医生/2管理员、status0禁用/1正常、create_time、deleted。注意username要加唯一索引手机号字段phone也要加唯一索引因为系统里手机号是找回密码和登录的主要凭证。department表科室表id、name、description、sort_order、create_time、deleted。科室在系统中属于基础数据由管理员维护不需要复杂设计。doctor表医生信息表id、user_id关联user表、department_id关联科室表、title、intro、service_price、evaluation_score、consult_count、status0待审核/1通过/2驳回。这里有个细节医生的接诊价格要单独存不要用AES加密后存字符串否则后续做金额统计时每次都要解密2023年之后我见到的真实项目里很多都直接明文存储并由后端控制访问权限。schedule表排班表id、doctor_id、work_date、start_time、end_time、total_slots、used_slots、status、version、create_time。这是全系统并发控制的核心表字段version用于乐观锁used_slots表示已预约数量。设计时要注意唯一索引推荐组合索引(doctor_id, work_date, start_time, end_time)防止同一医生同一时间段被插入两条重复排班。appointment表预约订单表id、order_no业务订单号唯一、patient_id、doctor_id、schedule_id、appointment_date、start_time、end_time、amount、status0已取消/1待支付/2已支付/3已完成、create_time。订单号建议在前端生成后传入格式可以是yyyyMMddHHmmss 6位随机数后端做唯一校验。consultation表问诊会话表id、appointment_id、patient_id、doctor_id、consult_type0图文/1视频、status0待接诊/1问诊中/2已完成/3已关闭、start_time、end_time、create_time。这张表是病历和处方的主表一次预约成功且支付完成后系统会自动创建一条问诊会话记录。medical_record表电子病历表id、consultation_id、patient_id、doctor_id、chief_complaint、present_illness、diagnosis_result、treatment_advice、create_time、update_time。病历内容字段类型建议用text或varchar(2000)因为患者的病情描述一般不会太长用大文本字段反而在索引和查询上更省心。prescription表与prescription_item表处方主表与明细表prescription主表存id、record_id、doctor_id、patient_id、total_amount、status、create_time明细表存id、prescription_id、drug_id、drug_name、specification、quantity、unit_price、subtotal。明细表里的drug_name和unit_price要冗余一份不能只存drug_id因为药品价格或名称后续可能调整历史处方必须保留开单时的快照信息。message表问诊消息表id、consultation_id、sender_id、receiver_id、content_type0文本/1图片、content、is_read、create_time。消息表是问诊会话的聊天记录存储分页查询时要注意按consultation_id和create_time建联合索引否则消息一多分页会非常慢。2.3 几个容易忽视的字段设计细节多写几句我在实际带项目时反复强调的细节。排班表的version字段是画龙点睛的一笔。如果不用乐观锁两个患者同时预约最后一个号源两个事务都判断used_slots total_slots成立然后同时执行used_slots used_slots 1最终used_slots会比实际值多1而且两名患者都预约成功但医生根本接诊不过来。加上version字段后更新语句变成update schedule set used_slots used_slots 1, version version 1 where id ? and version ?如果影响行数为0说明版本已过期预约失败。这是整个系统最重要的一个并发安全防线。预约状态和问诊状态的流转关系也值得注意。预约和问诊不是两张孤立的表预约支付完成后创建问诊会话问诊结束后回写预约状态为已完成。如果这两个状态的更新没有放在同一个事务里就会出现订单显示已支付、问诊却一直停在待接诊的脏数据。后面在代码部分我会给出具体的Transactional写法。还有一个容易被忽略的是create_time和update_time的自动填充。不要在每个Service里手动set当前时间而是用MyBatis-Plus的MetaObjectHandler统一处理逻辑上保持一致代码也干净。3. 核心功能模块的实现思路与关键代码3.1 基于JWT的登录认证与角色权限控制在线问诊系统涉及三类角色接口必须做权限隔离。我用JWT生成令牌配合Spring拦截器做全局登录校验。先看JWT工具类这里使用的是jjwt 0.9.1版本依赖少、API简洁适合毕设直接上手public class JwtUtil { private static final String SECRET your-secret-key; public static String generateToken(Long userId, Integer role) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(role, role); String token Jwts.builder() .setClaims(claims) .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60 * 2)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); return token; } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }然后写一个拦截器实现HandlerInterceptor接口在preHandle里校验token并把用户信息放入ThreadLocal供后续接口使用public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } Claims claims JwtUtil.parseToken(token.substring(7)); UserContext.set(claims.get(userId, Long.class), claims.get(role, Integer.class)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }角色权限控制在拦截器上做二次扩展比如医生端的所有接口都在/doctor/**路径下管理员接口在/admin/**路径下注册两个拦截器分别在handler里判断角色值registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /api/department/list); registry.addInterceptor(new RoleInterceptor(1)) .addPathPatterns(/doctor/**); registry.addInterceptor(new RoleInterceptor(2)) .addPathPatterns(/admin/**);这里要特别强调一个实战细节登录接口不能加拦截器但用户查询自己信息的/user/info接口必须加否则任何人都能通过id遍历用户信息。excludePathPatterns的配置要反复核对漏一个接口就多一个安全漏洞。3.2 预约挂号乐观锁解决号源并发问题预约挂号的业务流程是患者选择医生排班时段 → 系统校验号源是否充足 → 创建预约订单 → 支付 → 排班used_slots加1。这里最危险的是号源超卖。我在2.3节提到了乐观锁下面给出底层完整的实现代码。Service层核心方法如下Transactional(rollbackFor Exception.class) public Appointment createAppointment(AppointmentCreateDTO dto) { Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule null || schedule.getStatus() ! 1) { throw new BizException(排班不存在或已停诊); } if (schedule.getUsedSlots() schedule.getTotalSlots()) { throw new BizException(当前时段号源已约满); } // 乐观锁更新 int rows scheduleMapper.decreaseSlots(schedule.getId(), schedule.getVersion()); if (rows 0) { throw new BizException(预约人数激增请重试); } Appointment appointment new Appointment(); appointment.setOrderNo(generateOrderNo()); appointment.setPatientId(UserContext.getUserId()); appointment.setDoctorId(schedule.getDoctorId()); appointment.setScheduleId(schedule.getId()); appointment.setAppointmentDate(schedule.getWorkDate()); appointment.setStartTime(schedule.getStartTime()); appointment.setEndTime(schedule.getEndTime()); appointment.setAmount(schedule.getServicePrice()); appointment.setStatus(1); appointmentMapper.insert(appointment); // 同时创建问诊会话初始状态为待接诊 Consultation consultation new Consultation(); consultation.setAppointmentId(appointment.getId()); consultation.setPatientId(appointment.getPatientId()); consultation.setDoctorId(appointment.getDoctorId()); consultation.setStatus(0); consultationMapper.insert(consultation); return appointment; }对应的Mapper SQL为update iddecreaseSlots update schedule set used_slots used_slots 1, version version 1 where id #{id} and version #{version} /update这两个部分缺一不可。decreaseSlots是并发控制的关键Transactional是预约记录和问诊会话一致性创建的关键。如果把这两件事拆成两个事务订单支付页面回跳时会发现预约成功但问诊会话没创建患者还要手动联系客服这种状态一定要避免。3.3 问诊会话消息收发与WebSocket实时通信问诊的核心互动体现在消息聊天上。用户可以发送文本和图片医生可以在线回复。这里我选择WebSocket做实时推送而历史消息查询走HTTP接口。WebSocket服务端我的实现方式是继承Spring的TextWebSocketHandlerComponent public class ChatWebSocketHandler extends TextWebSocketHandler { private static final MapLong, WebSocketSession SESSION_POOL new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { Long userId (Long) session.getAttributes().get(userId); SESSION_POOL.put(userId, session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JSONObject json JSONObject.parseObject(message.getPayload()); Long consultationId json.getLong(consultationId); Long receiverId json.getLong(receiverId); String content json.getString(content); messageService.saveMessage(consultationId, UserContext.getUserId(), receiverId, content); WebSocketSession receiverSession SESSION_POOL.get(receiverId); if (receiverSession ! null receiverSession.isOpen()) { receiverSession.sendMessage(new TextMessage(content)); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { SESSION_POOL.values().remove(session); } }WebSocket的鉴权要注意握手时无法通过Header直接带token浏览器WebSocket API不支持自定义Header我采用的办法是在握手前用query参数传token在HandshakeInterceptor里校验JWT并把userId放入session attributes这样进入afterConnectionEstablished时就能直接拿到用户身份。还有一个关键陷阱WebSocket发送消息不能把整个问诊记录全部推给对方必须先把消息写入数据库再推给在线接收方。我见过不止一个同学在聊天功能里先推消息再存库结果消息发送成功了数据库里却查不到前端一刷新聊天记录就空了。推送和落库的顺序在这个场景里没有回旋余地必须先存库再推送。3.4 电子病历与处方事务一致性和明细计算问诊结束后医生需要填写电子病历并视情况开具处方。这一步的业务规则是一份病历可以对应多张处方每张处方包含若干药品明细。处方明细的金额计算最容易出错。每一条明细的subtotal quantity * unit_price单张处方的total_amount 各明细subtotal之和这两层计算不能靠前端传值后端必须重算一次避免前端篡改金额。写一段参考实现Transactional(rollbackFor Exception.class) public Prescription savePrescription(PrescriptionDTO dto) { MedicalRecord record medicalRecordMapper.selectById(dto.getRecordId()); if (record null) { throw new BizException(病历不存在); } Prescription prescription new Prescription(); prescription.setRecordId(dto.getRecordId()); prescription.setDoctorId(UserContext.getUserId()); prescription.setPatientId(record.getPatientId()); BigDecimal totalAmount BigDecimal.ZERO; ListPrescriptionItem items new ArrayList(); for (PrescriptionItemDTO itemDTO : dto.getItems()) { Drug drug drugMapper.selectById(itemDTO.getDrugId()); if (drug null || drug.getStock() itemDTO.getQuantity()) { throw new BizException(药品库存不足 itemDTO.getDrugName()); } PrescriptionItem item new PrescriptionItem(); item.setDrugId(drug.getId()); item.setDrugName(drug.getName()); item.setSpecification(drug.getSpecification()); item.setUnitPrice(drug.getUnitPrice()); item.setQuantity(itemDTO.getQuantity()); item.setSubtotal(drug.getUnitPrice().multiply(BigDecimal.valueOf(itemDTO.getQuantity()))); totalAmount totalAmount.add(item.getSubtotal()); items.add(item); } prescription.setTotalAmount(totalAmount); prescription.setStatus(1); prescriptionMapper.insert(prescription); for (PrescriptionItem item : items) { item.setPrescriptionId(prescription.getId()); prescriptionItemMapper.insert(item); drugMapper.decreaseStock(item.getDrugId(), item.getQuantity()); } return prescription; }注意这里decreaseStock需要写成update drug set stock stock - #{quantity} where id #{id} and stock #{quantity}。这和排班号源的乐观锁思路一致都是在更新语句里用条件判断来保证数据安全。如果先select再update超卖是必然的这个坑在答辩现场很容易被老师追问一定要提前想明白为什么。4. 常见问题排查与毕设答辩经验4.1 实际开发中踩过的高频问题第一个高频问题排班号源偶发超卖。现象是同一个时段同时有多个患者预约成功但used_slots字段只增加了一次或者增加次数与实际订单数不符。排查思路是先看decreaseSlots的update语句里是否带了version条件再看Service方法上是否加了Transactional。很多同学把update方法写在Mapper里但忘记在xml中写where version #{version}或者乐观锁更新后没有检查影响行数。还有个隐蔽原因是事务传播级别设置不当导致更新没有提交其他线程读到的是旧版本号重复执行了更新。解决方式是decreaseSlots单独放在独立事务中并添加Transactional(propagation Propagation.REQUIRED)同时要求影响行数必须为1才允许创建订单。第二个高频问题WebSocket连接不稳定。启动项目后页面能收到消息但过一段时间就断开。这个通常是服务端WebSocket空闲超时时间设置过短或者前端没有实现心跳机制。解决方案是前端每30秒发送一次心跳ping服务端在handleTextMessage中收到ping后返回pong同时把WebSocketSession的空闲超时设置为0不限制。第三个高频问题Redis缓存失效导致科室列表获取缓慢。科室列表是一个变化极少的高频读取数据如果每次都查MySQL数据库压力会很大且页面响应明显变慢。配置了RedisCacheManager之后偶尔会出现修改科室名称后前端立即查询仍是旧数据。这是缓存与数据库一致性问题最轻量级的方案是更新科室数据后主动删除缓存key让下次查询回源到数据库再写缓存保证最终一致。第四个高频问题部署到云服务器后线上图片不显示。开发环境用的是本地绝对路径保存上传图片换到Linux服务器后路径不一致导致页面图片404。正确做法是配置一个独立的资源访问路径上传文件保存到固定目录并通过虚拟路径映射对外访问同时注意Linux下路径分隔符是/Windows下是\。第五个高频问题分页查询消息记录时数据混乱。现象是聊天记录分页后前面页的数据会重复出现在后面页。原因通常是没有按create_time做稳定排序而默认排序方式是id当消息插入的主键不是单调递增时就会出现乱序。解决方式是强制按consultation_id, id排序并在分页参数中固定排序方向。4.2 常见问题排查速查表问题现象可能原因解决方案号源超卖update语句未带version条件或未检查影响行数在Mapper.xml中使用where id? and version?影响行数为0时抛异常预约成功但问诊会话未创建两个表写入不在同一事务Service方法添加Transactional(rollbackFor Exception.class)WebSocket断线心跳机制缺失、空闲超时前端定时发送ping服务端pong应答科室修改后仍显示旧数据缓存与数据库不一致更新后删除缓存key让查询回源图片上传后页面404Windows与Linux路径差异配置统一资源映射和存储目录金额精度丢失使用了float/double金额字段统一decimal计算用BigDecimal用户登录后深链接失效token未存到本地或未做续期前端拦截401后跳转登录页并清除token多次点击提交出现重复预约接口缺乏幂等控制前端按钮置灰后端使用订单号唯一索引兜底这个速查表是我从真实开发中整理出来的希望能帮你快速定位问题只要把这些规则写进代码里很多坑就能从源头上避开。4.3 论文写作与答辩演示的几个建议在线问诊系统的毕设论文核心是需求分析、系统设计、系统实现和系统测试四个大章。需求分析里要重点写清楚三类角色的用例图以及核心业务流程的状态流转图。系统设计里把ER图和数据表字典放全数据库设计这一章往往是答辩时老师翻得最认真的一章。演示环节建议按照“患者注册→医生登录→管理员审核→患者预约→医生接诊→医生开病历→医生开处方→患者查看”这条完整的主线来操作中途不要跳步骤每一步的页面都要能打开。老师通常会随机问两个问题最容易被问到的是并发控制和消息实时推送的原理把decreaseSlots和WebSocket的代码结构记得滚瓜烂熟讲到这两个点时给出关键SQL或代码片段展示出“我知道底层是怎么实现的”基本就能拿高分。5. 最后再分享一点经验做这类带状态流转的中型业务系统最核心的心得是把状态机定义清楚。预约的状态从待支付到已支付再到已完成问诊的状态从待接诊到问诊中再到已完成每一个状态转换触发的动作都要提前列出来写成一个状态流转表编码时照着表实现。带过的学生里凡是先把这张表画清楚的整个开发过程基本没有出现“漏掉一个状态没处理”的情况凡是直接开写的后期返工率非常高。这个习惯养成了不只是毕设以后做任何Java后端项目都会受益。在线问诊系统还可以继续扩展的地方不少比如接入第三方支付、增加视频问诊的云通信能力、做医生端的数据看板、引入AI辅助分诊这些改动并不会推翻现有表结构都是往上叠模块的事情后续如果有机会我再单独整理扩展部分的实现方案。