1. 项目定位与整体设计拆解
1.1 为什么选择“智慧医疗网上预约与问诊”这个题目
每年到了毕业设计选题季,总有一大批人抱着“做管理系统”的心态来找我聊项目。说实话,图书管理系统、班级管理系统这类题目不是不能做,但功能做来做去还是“登录、增删改查、导出Excel”三板斧,答辩时评委一听题目就知道你后面要放什么PPT,想拿高分很难。“基于Spring Boot的智慧医疗网上预约与问诊系统”就不一样了,它看起来是一个预约挂号平台,但拆开看,里面有患者、医生、管理员三种角色,有科室、排班、号源、预约订单、在线问诊、病历记录这种复杂的业务流,还牵扯到并发扣减号源、Token鉴权、状态机流转这些真正能拿出来讲的点。你把这个系统从数据库设计到接口实现完整走一遍,Java基础、Spring Boot使用、项目部署这些硬能力基本都能覆盖到,而且做完之后简历上的“项目经验”一栏也有东西可以写。
这个题目适合谁?我的建议是,如果你Java基础已经学完IO、集合、MySQL增删改查和JDBC,但一直没有找到合适的综合项目练手,用它当课程设计或者毕业设计很合适。如果你是准备找工作的同学,想用一个小项目突击Spring Boot实战,这套系统同样值得参考。它的核心价值不是“把代码跑起来”,而是在做项目过程中把事情掰开揉碎:比如用户预约时为什么会出现“号源超卖”,排班表为什么要设计成一条记录对应一个时段,这些内容才是面试官和答辩老师真正在意的。
我在整理这个项目的时候,拿到手的是一套完整流程资料:源码、配套文档、部署说明、演示视频。这里要提醒一句,资料是全的,但你不能把“能跑起来”当成终点。最好的用法是先不看代码,自己画一遍整个业务流程,再对照源码去检查你的设计哪里漏了。下面我从整体设计、数据库、核心业务、部署排错四个角度,把这套系统的关键点逐个拆开讲。
1.2 技术选型不是越新越好,稳定与可控才是关键
很多同学一拿到题目就想着“用Spring Boot 3.x + Spring Security + Vue 3 + Redis Cluster”,好像版本越高越有面子。真到写代码那一步,光是Spring Security的自定义认证流程就能卡你一周,最后PPT上写“集成Spring Security”,被评委追问Filter链原理,答不上来反而扣分。所以我的建议是,毕设项目不要追求最新,要追求“你能够完整讲清楚原理”。
这套系统推荐的基础组合是:Spring Boot 2.7.x + MyBatis-Plus + MySQL 8.0 + Redis + JWT。选这个组合有几个理由。Spring Boot 2.7.18是目前2.x里比较稳定的维护版本,市面上大部分老项目、网上的踩坑解决方案也都是基于2.x写的,遇到问题一搜基本有答案。MyBatis-Plus能帮你把单表CRUD从几十行XML里解放出来,但多表查询和复杂SQL我建议还是手写XML,答辩时要能说出来“为什么这里不用LambdaQueryWrapper”,因为关联查询需要控制join条件和索引。Redis在这个项目里不是装饰品,号源扣减、验证码有效期、Token黑名单都可以用它,哪怕只做了其中一件,也能在答辩时体现出你对高并发场景有基础认知。
JWT这部分我强烈建议自己写拦截器实现,而不是直接引入Spring Security或者Shiro。原因很现实:Shiro要理解Session、过滤器、授权链,Security的配置文件更是劝退新手;自己写HandlerInterceptor或者OncePerRequestFilter,只需要明白三个问题:Token怎么生成、怎么校验、怎么在业务代码里拿到当前用户。这套逻辑用半天就能写完,但你对登录鉴权的理解会比“调用框架API”深得多。
前端怎么选?时间充足可以前后端分离,Vue3 + Element Plus或者Vite + Layui都行;时间紧张就老老实实用Thymeleaf模板 + Layui,把后端接口和页面渲染放在一起,部署时一个jar包搞定,省去Nginx配置和跨域处理的很多麻烦。我在实际做项目时的经验是,如果你的论文重点在后端并发控制和数据库设计,前端越简单越好,页面能展示出数据流转效果即可,没必要在样式上死磕。
2. 核心业务模块与数据库设计实操
2.1 先理清角色权限和状态流转,再动手建表
这个项目最常见的翻车现场,是刚拿到题目就开建表,边写边想字段,结果做了一半发现“预约”和“问诊”两个流程串不起来。我建议先画角色泳道图,再开始设计。
系统里主要有三种角色:患者(PATIENT)、医生(DOCTOR)、管理员(ADMIN)。患者端的动作是注册登录、浏览科室和医生、查看排班、提交预约、发起问诊、查看病历;医生端的动作是维护排班、管理预约患者、处理问诊消息、写病历;管理员端的动作是审核科室、管理医生账号、查看统计报表。
预约这个核心业务一定要用状态机来管理。我给出一套比较标准的预约状态定义:PENDING表示已预约待就诊,COMPLETED表示已完成,CANCELLED表示已取消,ABSENT表示患者未到诊。注意,不是所有状态都要做,但状态值一定要提前定义好,写成常量类:
public class AppointmentStatus { public static final int PENDING = 0; public static final int COMPLETED = 1; public static final int CANCELLED = 2; public static final int ABSENT = 3; }正常动作是:预约创建时状态为PENDING -> 医生接诊完成就诊后置为COMPLETED -> 用户在就诊前主动取消时置为CANCELLED -> 超过就诊时间但状态仍是PENDING时,由定时任务置为ABSENT。状态只能单向流转,不要允许用户把COMPLETED改回去。这个结论会在后面的接口判断里反复提到:所有更新状态的操作,SQL中都一定要加status条件,防止重复提交导致状态错乱。
2.2 数据库表设计:一张表解决一个业务问题
数据库设计是这个项目的根,后面所有接口、论文、答辩问题都围绕它展开。我建议建表时按业务边界拆分,不要把所有字段堆到一张大表里。核心表至少要有:用户表、科室表、医生表、排班表、预约表、问诊表、病历表。
用户表大家都会建,但要提醒几个点:用户名建议用手机号或者自定义用户名,要加唯一索引;密码不要存明文,用BCrypt或者至少MD5加盐;角色字段用tinyint存数字而不是字符串,程序里再做映射;身份证号、手机号这类敏感字段如果只是毕设,可以保留,但要清楚JWT里不能放这些信息。
医生表不要和用户表混在一起。医生有职称、科室、简介、挂号费这些个人属性,患者没有;如果都塞到user表里,用户表字段会越来越空。我的设计是user表存账号通用字段,doctor表通过user_id和user表关联,doctor表里记录dept_id、title、reg_fee、introduction等。科室单独一张department表,存名字、科室描述、状态。
排班表和预约表是整个系统的核心,重点关注号源和时段。下面这个表结构是经过实际验证比较顺手的版本:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| schedule | doctor_id, work_date, period_type, start_time, end_time, total_no, remain_no, version | 一个医生某一天某个午别的号源池 |
| appointment | patient_id, schedule_id, visit_date, period, status, cancel_reason | 患者预约产生的订单 |
| consultation | appointment_id, patient_id, doctor_id, symptom, reply, status | 问诊会话与回复内容 |
| medical_record | appointment_id, diagnosis, advice, create_time | 医生写的就诊记录 |
排班表里的version字段是我特意加的。为什么?因为用户预约时要更新remain_no,这个字段在高并发下会产生“超卖”问题。用version做乐观锁,每次更新都检查版本号,就能保证同时只有一个请求能成功扣号。这个点我会在第三部分详细写SQL。另外,预约表一定要加唯一索引来防止同一患者重复挂同一个号源,比如:
ALTER TABLE appointment ADD UNIQUE KEY uk_patient_schedule (patient_id, schedule_id);你可能会说,患者想看同一个医生,连续挂两个星期怎么办?两个星期的schedule_id是不同的,所以外键约束选择schedule_id而不是日期字段,不影响正常使用。但同一患者同一天同一时段挂同一个医生,就会被唯一索引拦截,这符合业务逻辑。
2.3 建表过程中的常见坑:编码、时区、金额字段
建表时先把基础设施调对,后面会省很多事。第一是字符集,数据库、表、连接URL三个地方都要用utf8mb4,不要用utf8。utf8在MySQL里最坑的地方是存不了emoji和部分生僻汉字,患者名字里一旦出现特殊字符,插入直接报错。连接URL里要加useUnicode=true&characterEncoding=utf-8,并且确认数据库实例的character_set_server为utf8mb4。
第二是时间字段。建议统一用DATETIME而不是TIMESTAMP,TIMESTAMP有2038年的上限,而且会受MySQL和JDBC时区影响,经常出现“本地时间差8小时”的破事。如果项目要跨时区部署,建议使用DATETIME加JVM默认时区Asia/Shanghai。如果已经踩了时区坑,可以在连接URL里加serverTimezone=Asia/Shanghai,再将数据库时区设置成系统当前时区。
第三是金额字段。挂号费、检查费,不要用float和double,数据库和Java的float都会出现浮点精度问题。用DECIMAL(10,2)存,Java实体里用BigDecimal接收。这个知识点在答辩时是送分题,你主动提出来老师会对你另眼相看。
3. 关键业务实现:从预约到问诊的完整链路
3.1 登录鉴权:JWT + 拦截器,不依赖重量级框架
整套系统里用户最先接触的就是登录,也是最容易出问题的地方。登录流程并不复杂:前端把手机号和密码传给后端,后端校验通过后生成JWT返回给前端,前端后续所有请求都在Header里带Authorization: Bearer ,后端通过拦截器统一解析。
JWT生成的核心代码长这样:
String token = Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", user.getRole()) .claim("username", user.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000L)) .signWith(SignatureAlgorithm.HS256, jwtSecret) .compact();这里有两个注意点。第一,JWT的过期时间不要设太长,半天或者两小时比较合适;如果用户要长时间在线,可以再加一个refresh_token机制,但这超出了毕设难度,不做也不影响答辩。第二,JWT的payload虽然经过签名无法篡改,但它只是Base64编码,没有加密,任何拿到Token的人都能解码看到里面的内容。所以绝对不能把身份证号、手机号这类敏感信息写进claim里,只放userId、username、role这些业务需要的东西。
拦截器我建议用Spring的HandlerInterceptor:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); // 校验token,解析出userId和role,放入ThreadLocal或request attribute } }配置拦截的时候,要区分白名单:登录、注册、科室列表、首页数据这些接口要放行,预约、问诊、后台管理这些接口必须校验。我见过很多同学把静态资源也拦截了,页面上全是样式丢失,其实只要在WebMvcConfigurer里排除/static/**就好。如果同一个API需要不同角色,就可以自定义一个@RequireRole注解:
@RequireRole({"DOCTOR", "ADMIN"}) @PostMapping("/schedule/add") public Result addSchedule(...) { ... }使用注解比在方法体里if判断角色优雅得多,答辩时提一句“用自定义注解实现了细粒度权限控制”,加分效果很明显。
3.2 号源扣减:先考虑并发,再写更新SQL
预约挂号这个业务的核心难点是并发。想象一下,一个专家的号只有20个,20个用户同时点击“立即预约”,如果代码写成先select剩余号源,判断大于0,再update减少1,在高并发场景下几乎必然发生超卖。因为多个请求查到的remain_no可能都是1,然后各自走更新逻辑,最后20个号卖了25单。
解决这个问题的标准姿势是数据库乐观锁:把“检查余号”和“扣减余号”合并在一条SQL里,让数据库来保证原子性。
@Update("UPDATE schedule SET remain_no = remain_no - 1, version = version + 1 " + "WHERE id = #{scheduleId} AND remain_no > 0") int deductRemain(@Param("scheduleId") Long scheduleId);affected rows等于0,说明要么号没了,要么更新失败,这时候直接抛业务异常“号源不足”,事务回滚,预约订单也不会插入。如果你用了version字段,也可以在WHERE条件里加上version = #{version},不过对毕设来说,只要用remain_no > 0这个条件就够了,因为它本身就是最简洁的乐观锁。
为什么建议整个方法加@Transactional?因为扣号源和插入预约订单是两件事,必须放在同一个事务里,任何一个失败都要回滚,否则就会出现“订单插进去了但号源没扣,或者号源扣了订单没创建”的数据不一致。事务失效的情况也要留意:最常见的坑是同类内部方法调用,比如Service里的A方法调B方法,如果A没有事务而B有事务注解,Spring代理可能不会生效。解决办法是在Controller里调用Service的入口方法,或者把独立操作拆成两个Service。
如果你想让答辩更有深度,可以继续提Redis方案:把号源数量提前放到Redis,用decr原子扣减,扣减成功后再异步落库。但要注意,Redis扣减成功、数据库写入失败时要做补偿,否则Redis里的号源和数据库不一致。毕设用数据库乐观锁已经足够,把Redis方案放在论文的“系统扩展”一节写,既显得你有思考,又不会被问得太深。
3.3 取消预约要释放号源,状态流转要防重复
取消预约是预约系统里很不起眼但很容易写崩的功能。业务规则是:只有PENDING状态的预约可以取消,取消后号源要加回来,且不能因为用户连续点了两次取消按钮就把号源加两次。
正确的做法是使用条件更新:
@Update("UPDATE appointment SET status = #{cancelStatus}, cancel_reason = #{reason} " + "WHERE id = #{appointmentId} AND status = #{pendingStatus}") int cancelAppointment(...);只有当更新影响行数为1时,才继续执行号源回补SQL:
@Update("UPDATE schedule SET remain_no = remain_no + 1 WHERE id = #{scheduleId}") int addBackRemain(@Param("scheduleId") Long scheduleId);这两条SQL同样要在一个事务里执行。如果你把先查预约状态、判断、再更新的三步写法搬上去,就等着被并发问题折磨吧。我实际调试时遇到过一个问题:连续点击取消按钮后,状态字段变成了CANCELLED,但号源被加了两次。原因是前后两个请求都查到了PENDING状态,都走完了回补逻辑。加上WHERE status条件后,第二个请求影响行数是0,业务直接返回“该预约已被处理”,问题解决。
还有一个细节:如果用户取消了预约,但该号已经被别人挂走了,回补号源会变为超量发售吗?不会,因为号源总数total_no不变,remain_no只是取回,不会超过total_no;唯一需要考虑的是半路取消时该时段号源可能变成冷门时段,但这属于业务策略问题,不用在毕设里纠结。
3.4 排班生成与医生问诊的实现思路
排班功能最忌讳手工一条条插入,尤其是按周批量生成号源时,代码里写for循环一条条insert,数据量一大直接卡死。我的做法是在排班管理页面提供一个“批量生成排班”入口:选医生、开始日期、结束日期、上午/下午时段、总号数,然后一次性批量插入。如果用MyBatis-Plus,直接用saveBatch方法;用XML就写foreach批量insert。同时给schedule表加唯一索引(doctor_id, work_date, period_type),防止同一个医生同一天同一个午别生成两条排班记录。
问诊模块可以根据你剩余的开发时间灵活选择实现深度。最简版本是“留言式问诊”:患者在预约完成后可以向医生发起问诊,提交病情描述;医生在医生端看到待回复问诊,写回复内容;患者查看回复。这个流程只需要一张consultation表和两个接口,能完整演示业务闭环,而且不涉及实时通信,代码量小,答辩也能讲清楚。如果时间富余,可以引入WebSocket做在线聊天,但以我的经验,毕设阶段WebSocket很容易陷进“在线状态管理”的坑,先把留言版跑通再扩展,效果反而更好。
病历模块同样可以精简:医生在接诊完成后,在一个表单里填写诊断结果和治疗建议,存入medical_record表,患者端“我的病历”里展示。不要把病历和问诊混在一起,各管各的表,界面也要分开,演示视频做起来会更有层次。
3.5 统一返回、全局异常和日志:必备的工程化细节
小项目如果每个接口都返回Map、返回JSONObject,到后期调用方根本不知道接口有哪些字段。工程上最好定义一个统一返回体:
public class Result<T> { private Integer code; private String message; private T data; // success, error 静态方法 }然后用@RestControllerAdvice做全局异常处理,把业务异常、参数校验异常、兜底异常全部转成Result格式。这样前端在处理时只需要判断code是否为200即可。日志方面,在关键业务入口加@Slf4j输出入参、出参和耗时,特别是扣号源、取消预约这几个敏感操作,日志是排查问题的第一帮手。
4. 部署上线与常见问题排查
4.1 从IDEA跑通到服务器部署,完整步骤一次说清
我在帮人排查部署问题的时候,最怕听到的反馈就是“我本地跑得好好的,一部署就崩”。这里把标准流程写出来,按顺序做很少出问题。
第一步,检查JAVA环境。本地用JDK 8还是JDK 11,服务器就必须用同一个版本,否则打包出的jar可能出现版本不兼容。用mvn package命令打包前,先在IDEA右侧Maven面板点clean,再点package,生成target目录下的jar文件。如果之前跑过老版本,建议把target目录删掉再重新打包,避免旧类文件残留。
第二步,准备服务器环境。我习惯先用命令检查基础环境版本:
java -version mysql -V redis-server --version如果服务器上有旧版本Java,可以用alternatives命令切换,或者直接设置JAVA_HOME环境变量。MySQL初始化时,把项目里的medical.sql导入数据库,注意执行顺序:先建库,再执行SQL文件。导入完成之后去user表看一眼初始管理员账号是否正常,这条路径要提前走一遍,确认默认密码能登录,才能进入下一步。
第三步,上传jar包并启动。用scp或者宝塔文件管理器把jar传到指定目录,比如/opt/medical/。启动命令:
nohup java -jar medical-system.jar --spring.profiles.active=prod > app.log 2>&1 &后台启动后,一定要看app.log里有没有“Started Application”字样,不要盲目访问接口。如果怀疑启动失败,用tail -f app.log实时看日志。真正稳妥的做法是配置systemd服务,这样服务器重启后服务还能自动拉起,但不做也不影响演示。
第四步,配置Nginx。如果是前后端分离项目,把前端dist目录放到Nginx的html路径,然后配置一个location /api/反向代理到后端:
location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }如果前端和后端是同一个服务(Thymeleaf方案),只需要把jar包端口开放给浏览器访问即可。千万不要为了省事直接关掉服务器防火墙,更不要在安全组里放行所有端口,只开放80、443和必须的SSH端口就够了。
4.2 本地和部署环境最容易踩的几个坑
第一个坑是跨域。开发时前端跑8081、后端跑8080,浏览器就会拦截非简单请求。最快解决方法是Controller类上加@CrossOrigin,但这只适合开发调试,上线后还是要用Nginx做同源代理。有的同学直接把跨域注解删了,结果部署后所有页面打开白屏,因为前端代码还在请求http://localhost:8080,这是绝对不该出现的。前端请求路径统一用/api开头,代理就会帮我们转发。
第二个坑是端口被占。后端服务启动时经常报“Port 8080 was already in use”,排查方式是:
lsof -i:8080 kill -9 进程PID但更推荐在配置里把后端端口改成一个不常用的端口,比如8090,减少冲突概率。
第三个坑是数据库连接串。很多同学本地连的是127.0.0.1:3306,打包时忘记改成服务器地址或者云数据库地址,启动日志里一堆Communications link failure。数据库账号密码也不要写死在代码里,用application-prod.yml单独配置,再通过启动参数--spring.profiles.active=prod指定。
第四个坑是MySQL 8和驱动版本不匹配。pom.xml里如果用mysql-connector-java 5.x连接MySQL 8,会报Unknown character set或者SSL连接错误。建议使用com.mysql:mysql-connector-j:8.0.x,URL里加上useSSL=false和serverTimezone=Asia/Shanghai。
4.3 答辩之前,建议把这些问题过一遍
答辩时间有限,老师不会让你把整个项目演示完,但会挑几个核心问题问。我前面反复提的“并发扣号源”一定是重点,准备两分钟口头解释:为什么不用先查后更新,乐观锁是怎么生效的。第二个高频问题就是登录安全:JWT过期时间多长、敏感信息会不会泄露。提前把这两个问题的答案准备顺,就已经赢了一大半。
还有几个小问题容易被忽略:一是IDEA和MySQL默认账号密码的问题,有的源码默认账号是admin/admin123,如果数据库里没有这个记录,演示时输入管理员账号一直登录失败,现场会非常尴尬,所以部署说明里一定要写清楚默认账号和初始化SQL执行结果;二是演示视频最好是按业务流程连续录制,不要一个功能一个视频,5到10分钟把“注册登录、查科室、选医生、预约、医生接诊、写病历”一条流程走通,老师看起来最直观;三是配套文档里要把ER图、用例图、流程图放全,代码截图可以放核心方法,但不要全文贴上去,那会显得很凑数。
4.4 我的个人经验:做这个项目,最大的收获是什么
做过几轮类似的医疗预约项目之后,我最大的感受是,这类系统真正考验人的不是代码量,而是对业务边界的理解。排班表该怎么设计,号源扣减怎么保证不超卖,取消预约怎么防重复操作,这些问题在网上随便一搜都有答案,但只有自己动手把数据表建出来、把并发场景跑一遍,才会真正理解为什么一条SQL里要加status和remain_no条件。如果你把这套流程做完,再把代码里的关键方法讲一遍,那不仅是完成一个毕设,更是给未来的面试积累了一个很有说服力的项目案例。
最后再分享一个挺实用的小技巧:不管源码里有没有现成的测试数据,自己在库里多造几组有差异的数据,比如不同科室、不同医生、不同费用、不同预约状态。演示的时候从上到下点一遍,页面里能看到各种状态切换,明显比只有一个“待就诊”状态丰富得多。这也算是我踩过几次坑之后换来的经验吧。