Java毕业设计实战:SpringBoot医院排队叫号系统(含数据库+前后端完整源码)
简介:直接上手就能跑的医院排队叫号系统,专为Java本科生毕业设计和课程实践打造。患者挂号、科室分诊、窗口实时叫号、大屏队列展示、后台管理一整套流程都已实现。后端用SpringBoot整合Spring MVC、MyBatis和Spring Security,MySQL存数据,前端基于Thymeleaf模板+Bootstrap做响应式界面,简洁清晰不花哨。项目自带mvnw脚本,Windows和Mac都能一键启动,不用单独装Maven;数据库SQL脚本和初始化说明全在HELP.md里,照着操作几分钟就能看到效果。源码结构规范,包名分层明确(controller/service/mapper/entity),适合新手理清MVC调用链路,也方便进阶者扩展功能——比如加短信或微信通知、接医生排班表、对接医院HIS接口。压缩包里包含全部源文件(src/main/java/html/xml)、IDEA工程配置(.idea)、pom.xml、编译输出目录(target),还有详细README和HELP文档,没有加密、没删减、没占位符。
1. 项目概述:为什么一个“叫号系统”能撑起本科毕设的全部分量?
你可能第一眼看到“医院排队叫号系统”,下意识觉得:“这不就是个带按钮的网页?点一下‘叫下一位’,大屏上数字跳一下?”——这种想法特别正常,我带过十几届毕业设计,八成学生最初也是这么想的。但等他们真正打开这个SpringBoot项目的src/main/java目录,点开controller包里那个不到200行的QueueController.java,再顺着@Autowired private QueueService queueService;一路点进service层、mapper层,最后看到QueueMapper.xml里那几条带<foreach>和<choose>的动态SQL时,表情就从“就这?”变成了“原来如此……”。
这恰恰是它作为Java本科毕设核心价值的起点:它不是功能堆砌的玩具,而是MVC架构的微缩沙盘。患者挂号时输入的姓名、身份证、科室ID,会经过PatientController接收→PatientService校验逻辑(比如判断该科室当天是否还有号源)→PatientMapper写入MySQL的patient表;而窗口护士点击“叫号”后,系统不是简单地把队列头元素弹出,而是要查queue_record表确认该患者状态是否为“已挂号未就诊”、更新其状态为“正在就诊”、同时向大屏WebSocket推送一条包含窗口号、患者姓名、当前叫号序号的JSON消息——这一整套动作,把HTTP请求、事务控制、数据库连接池、缓存穿透防护(哪怕只是最基础的本地缓存)、前后端数据格式转换(Date转字符串、枚举转中文描述)全串起来了。
关键词里的“医院叫号”是场景,“Java毕设”是定位,“SpringBoot源码”是载体,“排队系统”是本质,“MySQL”是根基。它不追求高并发(QPS 50足够),也不搞分布式(单机MySQL+内嵌Tomcat),但它把Java Web开发中90%的“必经之路”都踩实了:你能清晰看到application.yml里spring.datasource.url怎么连上本地3306端口,能看到SecurityConfig.java里http.authorizeRequests()如何用正则匹配URL放行静态资源,还能在Thymeleaf模板里找到<span th:text="${patient.name}">张三</span>这种最朴素的数据绑定。没有炫技的微服务注册中心,没有烧脑的响应式编程,只有扎实的@Service层事务注解、@Transactional(rollbackFor = Exception.class)背后对ACID的敬畏,以及pom.xml里那一行行被反复验证过的依赖版本——比如mybatis-spring-boot-starter:2.2.2和spring-boot-starter-web:2.7.18的兼容性,是我当年踩过三次坑才锁死的组合。
所以它适合谁?不是只适合“完全不会写Java”的同学。更适合那些已经能写CRUD但总说“不知道项目整体怎么串起来”的人。当你把mvnw spring-boot:run敲下去,浏览器打开http://localhost:8080/login,输入默认账号admin/123456,看着后台管理页里科室列表、医生排班、当日号源实时滚动,再切到大屏页看等待中:李四(内科-001)变成请到1号窗口就诊,那一刻你突然明白:所谓“工程能力”,就是能把抽象的“用户需求”翻译成entity类里的private String idCard;,再翻译成MySQL建表语句里的id_card VARCHAR(18) NOT NULL COMMENT '身份证号',最后翻译成前端页面上那个带红色星号的输入框。这个过程,比任何PPT答辩都更真实。
2. 整体架构与技术选型:为什么不用Vue/React?为什么坚持Thymeleaf?
2.1 架构全景图:三层落地,拒绝空中楼阁
整个系统采用经典的分层架构(Layered Architecture),但绝不是教科书上那种干巴巴的“表现层-业务层-数据层”定义。它的每一层都带着明确的“教学意图”和“工程约束”,我们来拆解这张实际运行中的调用链:
[浏览器]
↓ HTTP GET /queue/waiting?deptId=101
[Thymeleaf Controller] → 接收deptId参数,调用service层方法
↓
[QueueService] → 校验科室是否存在、查询当日该科室有效队列(状态=等待中)
↓
[QueueMapper] → 执行SQL:SELECT * FROM queue_record qr JOIN patient p ON qr.patient_id=p.id
WHERE qr.dept_id = ? AND qr.status = 'WAITING' ORDER BY qr.create_time
↓
[MySQL] → 返回结果集(含患者姓名、挂号时间、序号)
↓
[Thymeleaf Template] → 将List<QueueRecordVO>渲染进HTML表格,每行生成:
<tr th:each="record : ${queueList}">
<td th:text="${record.seqNo}">001</td>
<td th:text="${record.patientName}">张三</td>
<td><button type="button" th:onclick="'callNext(\'' + ${record.id} + '\')'">叫号</button></td>
</tr>
注意几个关键细节:
- Controller层绝不处理业务逻辑:QueueController.java里所有方法都极短,核心就一行return queueService.getWaitingList(deptId);,连空指针判断都交给@Valid注解完成;
- Service层是真正的“大脑”:QueueService.java里callNext(Long recordId)方法包含完整的业务原子性——先查记录状态,再更新状态为“就诊中”,再通过WebSocketTemplate广播消息,最后记录操作日志,四步必须在一个事务里完成,否则就会出现“大屏叫了号,但患者手机没收到通知”的数据不一致;
- Mapper层只做数据搬运:QueueMapper.xml里没有一行Java代码,全是SQL和MyBatis标签,连分页都是用<if test="page != null">LIMIT #{page.offset}, #{page.limit}</if>手写,逼着你理解物理分页和逻辑分页的区别。
这种设计不是为了“炫技分层”,而是解决本科生最痛的痛点:写完一个功能,不知道该把代码放在哪一层。当你的addPatient()方法里混着new Date()、passwordEncoder.encode()、jdbcTemplate.update()、model.addAttribute()时,你就已经迷失在代码的丛林里了。而这个项目用包结构(com.example.hospital.controller / .service / .mapper)和命名规范(XxxController / XxxServiceImpl / XxxMapper)给你画了一条清晰的路标。
2.2 前端为何死守Thymeleaf?Bootstrap不是过时了吗?
看到这里,你可能会问:“现在谁还用Thymeleaf?Vue3+Element Plus不香吗?”——这个问题我被问过至少五十次。答案很实在:因为毕设答辩现场,评委老师打开你的项目,需要的是‘3分钟内看到效果’,而不是‘先装Node.js,再npm install,再yarn serve,最后发现端口冲突’。
Thymeleaf的优势,在这个项目里被榨取到了极致:
- 零构建步骤:.html文件直接放在src/main/resources/templates/下,SpringBoot启动时自动扫描,无需webpack打包、无需npm run build生成dist目录;
- 服务端渲染即见真章:你在login.html里写<div th:text="${errorMsg}" class="alert alert-danger" th:if="${errorMsg}">登录失败</div>,只要后端Model.addAttribute("errorMsg", "密码错误"),刷新页面立刻看到红色提示框——这种“所见即所得”的调试体验,对初学者建立信心至关重要;
- 天然规避CORS问题:所有Ajax请求都走/api/xxx前缀,由SpringMVC统一拦截,不需要配置proxyTable或devServer.proxy,避免了“本地能跑,部署就跨域”的经典陷阱;
- Bootstrap 4.6的精准克制:项目用的不是最新版Bootstrap5,而是稳定多年的4.6。为什么?因为它的栅格系统(col-md-6)、表单控件(form-control)、按钮样式(btn btn-primary)在各种分辨率下表现极其稳定,且文档示例丰富。你不需要记住<el-button type="primary">还是<a-button type="primary">,只需要复制粘贴官方示例,改改th:text就能出效果。我在指导学生时发现,用Vue的同学花2小时调通Element Plus的国际化,而用Thymeleaf的同学已经把挂号流程的三个页面(选择科室→填写信息→确认提交)全跑通了。
当然,它也有代价:无法实现SPA(单页应用)的丝滑切换,大屏队列展示页每次叫号都要刷新整个页面。但项目用了一个巧妙的折中方案——WebSocket局部刷新。bigscreen.html里有一段JavaScript:
const socket = new WebSocket('ws://localhost:8080/ws/queue');
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
// 只更新叫号区域,不刷新整个页面
document.getElementById('currentCall').innerText = data.windowNo;
document.getElementById('currentPatient').innerText = data.patientName;
};
这样,护士点击“叫号”按钮时,后端通过WebSocketTemplate.convertAndSend("/topic/queue", message)推送消息,前端只替换两个DOM元素,体验接近SPA,又完全规避了前端工程化复杂度。这就是“务实选型”的力量:不追求技术先进性,只追求在毕设周期内(通常4-8周)让核心功能稳稳落地。
2.3 后端技术栈:为什么Spring Security比Shiro更合适?
很多同学纠结:“毕设用Shiro还是Spring Security?” 这个项目选Spring Security,理由非常具体:
- 与SpringBoot生态无缝融合:spring-boot-starter-security引入后,只需一个@EnableWebSecurity配置类,就能接管所有请求。而Shiro需要手动配置SecurityManager、Realm、FilterChainDefinitionMap,对新手来说配置项太多,容易出错;
- 权限模型更贴近医院场景:医院有明确的角色层级——超级管理员(可操作所有科室)、科室管理员(只能管理本部门)、窗口护士(只能叫号)。Spring Security的@PreAuthorize("hasRole('ROLE_WINDOW')")注解,配合数据库里的sys_user_role关联表,三行代码就能实现角色权限控制。而Shiro的@RequiresRoles("window")虽然类似,但其Subject对象在异步线程(如WebSocket推送)中容易丢失上下文,需要额外处理;
- 登录流程教学价值更高:项目里的LoginController.java展示了标准的表单登录流程:POST /login提交用户名密码→UsernamePasswordAuthenticationFilter拦截→UserDetailsService.loadUserByUsername()查库→DaoAuthenticationProvider比对密码→成功后重定向到/dashboard。这个流程完整覆盖了认证(Authentication)的核心概念,比Shiro的subject.login(token)抽象调用更能帮助学生理解“登录到底发生了什么”。
更重要的是,项目对Spring Security做了教学友好型简化:
- 关闭了CSRF防护(http.csrf().disable()),因为毕设系统不涉及银行转账等敏感操作,开启反而会让Ajax请求因缺少token而失败;
- 静态资源放行写得极其直白:http.authorizeRequests().antMatchers("/css/**", "/js/**", "/images/**").permitAll(),让学生一眼看懂“哪些路径不需要登录”;
- 密码加密用的是BCryptPasswordEncoder,而非过时的MD5或SHA-256,$2a$10$...开头的密文格式,让学生第一次直观看到“不可逆加密”长什么样。
这种“删减非核心复杂度,保留核心原理”的设计哲学,正是它能成为优质毕设模板的关键。
3. 核心模块详解与实操要点:从挂号到叫号,每一步都在教你怎么写代码
3.1 患者挂号模块:不只是增删改查,更是业务规则的落地
挂号是整个系统的入口,表面看只是填个表单,但背后藏着大量业务规则。我们以PatientController.java中的addPatient()方法为例,拆解它如何把“患者要挂内科号”翻译成可靠的代码:
@PostMapping("/patient/add")
public String addPatient(@Valid Patient patient, BindingResult result, Model model) {
// 1. 表单校验失败,返回错误信息
if (result.hasErrors()) {
model.addAttribute("errors", result.getAllErrors());
return "patient/add"; // 重新渲染挂号页
}
// 2. 校验身份证号格式(18位数字或X)
if (!IdCardUtil.isValid(patient.getIdCard())) {
model.addAttribute("errorMsg", "身份证格式不正确");
return "patient/add";
}
// 3. 校验科室是否存在且当日有号源
Dept dept = deptService.findById(patient.getDeptId());
if (dept == null || !dept.getHasTodayNumber()) {
model.addAttribute("errorMsg", "所选科室今日无号源");
return "patient/add";
}
// 4. 校验该患者今日是否已挂号(防重复)
int todayCount = patientService.countByCardAndDate(patient.getIdCard(), LocalDate.now());
if (todayCount >= 3) { // 限制每日最多挂3个号
model.addAttribute("errorMsg", "您今日已挂号3次,不能再挂");
return "patient/add";
}
// 5. 生成挂号序号:科室编码+日期+流水号(如:NK20240520001)
String seqNo = seqNoGenerator.generate(dept.getCode(), LocalDate.now());
// 6. 保存患者信息,并关联挂号记录
patient.setSeqNo(seqNo);
patient.setStatus(PatientStatus.WAITING);
patientService.save(patient);
return "redirect:/patient/success?seqNo=" + seqNo;
}
这段代码的教学价值在于:它把“业务语言”转化成了“代码语言”。比如“防重复挂号”这条规则,在需求文档里可能就一句话,但代码里体现为countByCardAndDate()方法调用,而这个方法在PatientMapper.xml里对应着一条带GROUP BY和COUNT(*)的SQL:
<select id="countByCardAndDate" resultType="int">
SELECT COUNT(*) FROM patient
WHERE id_card = #{idCard}
AND DATE(create_time) = #{date}
</select>
这里有个关键细节:DATE(create_time) = #{date}。为什么不直接用create_time >= #{date} AND create_time < #{date} + INTERVAL 1 DAY?因为前者在MySQL中能走索引(如果create_time字段有索引),后者会导致索引失效,随着数据量增长查询变慢。我在指导学生时,会让他们用EXPLAIN命令对比两条SQL的执行计划,亲眼看到“type: index”和“type: ALL”的区别——这就是数据库优化的启蒙课。
另一个易错点是事务边界。挂号成功后,系统需要同时插入patient表和queue_record表(队列记录),这两张表必须保证数据一致性。项目在PatientServiceImpl.java里用@Transactional标注了save()方法:
@Transactional(rollbackFor = Exception.class)
public void save(Patient patient) {
patientMapper.insert(patient); // 插入患者
QueueRecord record = new QueueRecord();
record.setPatientId(patient.getId());
record.setDeptId(patient.getDeptId());
record.setStatus(QueueStatus.WAITING);
queueRecordMapper.insert(record); // 插入队列记录
}
如果第二步queueRecordMapper.insert()抛出异常(比如科室ID不存在),整个事务会回滚,patient表的记录也不会留下。这个设计教会学生:不是所有数据库操作都需要事务,但涉及多表写入且要求强一致性的场景,事务是底线。
3.2 科室分诊与号源管理:动态规则背后的数据库设计
分诊模块看似简单,实则是整个系统灵活性的基石。它决定了“内科今天开放多少号”、“每个医生接诊多少人”、“周末号源是否减半”等动态规则。项目用三张表支撑这个能力:
| 表名 | 核心字段 | 作用 |
|---|---|---|
dept(科室) |
id, name, code, has_today_number(TINYINT) |
存储科室基本信息,has_today_number标记当日是否开放 |
doctor(医生) |
id, name, dept_id, status(ACTIVE/LEAVE) |
医生信息及在职状态 |
doctor_schedule(医生排班) |
id, doctor_id, work_date, morning_quota, afternoon_quota |
每日排班及号源配额 |
关键设计在于:号源不是静态配置,而是动态计算。当患者挂号时,系统不直接读取dept表的某个“总号源数”,而是执行以下逻辑:
- 查询当日排班表:
SELECT * FROM doctor_schedule ds JOIN doctor d ON ds.doctor_id=d.id WHERE d.dept_id=? AND ds.work_date=? AND d.status='ACTIVE' - 汇总所有在职医生的号源:
SUM(morning_quota) + SUM(afternoon_quota) - 减去已挂号人数:
SELECT COUNT(*) FROM queue_record WHERE dept_id=? AND DATE(create_time)=? AND status='WAITING'
这个计算过程封装在DeptService.java的getAvailableQuota()方法里。好处显而易见:
- 支持弹性排班:院长在后台修改某医生的morning_quota,下一秒挂号页就能显示剩余号源变化;
- 规避超卖:即使多个窗口同时叫号,由于queue_record表的status字段控制,不会出现“同一个号被叫两次”的情况;
- 历史可追溯:doctor_schedule表记录了每天的排班快照,未来查“上周三内科为什么只有20个号”,直接查表即可。
我在实际部署中遇到过一个典型问题:当医生临时请假,管理员在后台将doctor.status改为LEAVE,但当日已排班的doctor_schedule记录并未自动失效,导致系统仍按原配额计算号源。解决方案是在getAvailableQuota()方法里增加状态校验:
// 修正后的逻辑:只统计在职医生的排班
String sql = "SELECT SUM(ds.morning_quota + ds.afternoon_quota) as total " +
"FROM doctor_schedule ds " +
"JOIN doctor d ON ds.doctor_id = d.id " +
"WHERE d.dept_id = ? AND ds.work_date = ? AND d.status = 'ACTIVE'";
这个小改动,让学生第一次体会到“业务规则变更,必然引发代码逻辑调整”,而不是简单地改个配置。
3.3 窗口叫号与大屏展示:WebSocket实战,告别轮询
叫号模块是系统交互性最强的部分,也是最容易出问题的环节。传统做法是前端定时setInterval(() => fetch('/api/next'), 3000)轮询,但这种方式有两大缺陷:
- 服务器压力大:10个窗口同时轮询,每秒产生3-5次请求,而实际叫号频率可能每分钟只有1-2次;
- 延迟不可控:轮询间隔3秒,意味着患者可能等3秒才知道被叫到,体验差。
项目采用WebSocket全双工通信,彻底解决这个问题。实现分三步:
第一步:配置WebSocket支持
在WebSocketConfig.java中启用STOMP协议(简化WebSocket使用):
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic"); // 广播地址前缀
config.setApplicationDestinationPrefixes("/app"); // 客户端发送地址前缀
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/queue").withSockJS(); // 兼容IE等旧浏览器
}
}
第二步:后端推送消息
当护士点击“叫号”时,QueueController.java的callNext()方法触发:
@PostMapping("/queue/call/{id}")
public ResponseEntity<?> callNext(@PathVariable Long id) {
QueueRecord record = queueRecordService.findById(id);
if (record == null || !record.getStatus().equals(QueueStatus.WAITING)) {
return ResponseEntity.badRequest().build();
}
// 更新状态为就诊中
record.setStatus(QueueStatus.IN_PROGRESS);
queueRecordService.update(record);
// 构造推送消息
CallMessage message = new CallMessage();
message.setWindowNo("1号窗口");
message.setPatientName(record.getPatientName());
message.setSeqNo(record.getSeqNo());
// 通过WebSocket广播给所有订阅/topic/queue的客户端
messagingTemplate.convertAndSend("/topic/queue", message);
return ResponseEntity.ok().build();
}
第三步:前端监听并更新bigscreen.html里的JavaScript:
// 连接WebSocket
const socket = new SockJS('/ws/queue');
const stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
// 订阅/topic/queue主题
stompClient.subscribe('/topic/queue', function(messageOutput) {
const message = JSON.parse(messageOutput.body);
// 局部更新DOM,不刷新页面
document.getElementById('windowNo').innerText = message.windowNo;
document.getElementById('patientName').innerText = message.patientName;
document.getElementById('seqNo').innerText = message.seqNo;
// 播放提示音(可选)
const audio = new Audio('/audio/call.mp3');
audio.play();
});
});
这个方案的教学意义在于:它让学生亲手实践了服务端主动推送的概念。相比轮询的“客户端问,服务端答”,WebSocket是“服务端说,客户端听”,这种思维转变对理解现代Web交互模式至关重要。而且,STOMP协议屏蔽了WebSocket底层细节,学生只需关注/topic/queue这个逻辑地址,不必纠结WebSocket握手帧格式。
3.4 后台管理模块:RBAC权限模型的轻量级实现
后台管理是毕设答辩的“加分项”,但很多同学把它做成一个大杂烩页面,缺乏权限控制。本项目用基于角色的访问控制(RBAC) 实现了清晰的权限隔离:
- 角色定义(数据库
sys_role表): ROLE_ADMIN(超级管理员):可管理所有科室、医生、排班、查看所有日志ROLE_DEPT_ADMIN(科室管理员):只能管理本部门的医生和排班-
ROLE_WINDOW(窗口护士):只能叫号、查看本窗口队列 -
权限控制实现:
在SecurityConfig.java中,用antMatchers()精确控制URL访问:
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN") // 所有/admin/下的路径仅限超级管理员
.antMatchers("/dept/**").access("@deptPermissionService.canAccessDept(authentication, request)") // 动态权限
.antMatchers("/queue/call/**").hasRole("WINDOW") // 叫号接口仅限窗口角色
.anyRequest().authenticated();
最关键的@deptPermissionService.canAccessDept()是一个自定义SpEL表达式,它在DeptPermissionService.java中实现:
@Service
public class DeptPermissionService {
public boolean canAccessDept(Authentication auth, HttpServletRequest request) {
// 获取当前用户角色
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
if (authorities.stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
return true; // 超级管理员可访问所有科室
}
// 获取请求URL中的科室ID,如 /dept/edit/101 中的101
String deptIdStr = extractDeptIdFromUrl(request.getRequestURL().toString());
if (deptIdStr == null) return false;
Long deptId = Long.valueOf(deptIdStr);
// 查询当前用户所属科室(通过sys_user_dept关联表)
List<Long> userDeptIds = userDeptService.findDeptIdsByUserId(getUserId(auth));
return userDeptIds.contains(deptId);
}
}
这个设计教会学生:权限控制不是简单的“有角色就能进”,而是要结合业务上下文做动态判断。比如科室管理员编辑医生排班时,系统不仅要检查他是否有ROLE_DEPT_ADMIN角色,还要确认他要编辑的医生是否属于他管理的科室。这种“角色+数据维度”的双重校验,正是企业级系统权限设计的雏形。
4. 数据库设计与SQL脚本:一张ER图读懂医院业务关系
4.1 核心ER图解析:为什么用外键?为什么不用JSON字段?
项目数据库共12张表,但核心业务围绕5张表展开。我们用文字还原这张ER图的关键关系:
[patient] ←─── 1:n ───→ [queue_record]
↑ ↑
| |
| |
[mobile_log] [doctor_schedule]
↑ ↑
| |
└────── n:1 ───────────┘
↓
[doctor]
↑
|
└────── 1:n ───────→ [dept]
patient(患者表):存储患者基本信息,id_card设为唯一索引,防止重复挂号;queue_record(队列记录表):核心业务表,patient_id和dept_id均为外键,强制引用patient.id和dept.id,确保数据完整性;doctor(医生表):dept_id外键关联dept.id,表示医生所属科室;doctor_schedule(医生排班表):doctor_id外键关联doctor.id,work_date+doctor_id设为联合唯一索引,防止同一医生同一天排班多次;dept(科室表):code字段设为唯一索引,用于生成挂号序号(如NK20240520001中的NK)。
为什么坚持用外键(Foreign Key),而不是用应用层逻辑维护关联?因为这是数据库设计的第一课:外键能自动阻止非法数据写入。比如,如果queue_record.dept_id没有外键约束,有人误插入dept_id=999(实际不存在的科室),系统在叫号时就会因找不到科室信息而报错。而有了外键,MySQL会在插入时直接拒绝dept_id=999的记录,错误更早暴露,修复成本更低。
另一个常见误区是:用JSON字段存储动态属性。比如有人提议把“医生擅长领域”存在doctor.specialties JSON字段里。项目坚决反对,原因有三:
- 查询困难:要查“擅长心血管的医生”,就得用JSON_CONTAINS(specialties, '"cardiovascular"'),无法走索引,大数据量时极慢;
- 校验缺失:JSON里可以存{"specialties": ["cardiovascular", 123]},类型混乱;
- 扩展性差:未来要统计各专科医生数量,JSON字段需要全表解析,而单独建doctor_specialty关联表,GROUP BY specialty_id一行SQL搞定。
所以项目用标准的多对多关联表:doctor_specialty(doctor_id, specialty_id),specialty表存专科字典。这种“宁可多建一张表,也不滥用JSON”的原则,是专业数据库设计的分水岭。
4.2 关键SQL脚本解读:从建表到初始化,每行都有讲究
HELP.md里提供的hospital.sql脚本,不是简单地CREATE TABLE,而是包含了大量工程实践细节。我们挑几条重点分析:
1. 字符集与排序规则
CREATE DATABASE IF NOT EXISTS hospital CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
- 用
utf8mb4而非utf8:MySQL的utf8实际是utf8mb3,不支持emoji和部分生僻汉字(如“𠮷”),而utf8mb4才是真正的UTF-8; COLLATE utf8mb4_unicode_ci:指定Unicode校对规则,确保中文排序、大小写比较符合预期(如WHERE name LIKE '%张%'能正确匹配)。
2. 索引设计:不只是主键,更要懂业务查询
-- 为高频查询字段添加复合索引
CREATE INDEX idx_queue_dept_status_time ON queue_record(dept_id, status, create_time);
这条索引针对getWaitingList(deptId)方法的查询:WHERE dept_id=? AND status='WAITING' ORDER BY create_time。复合索引(dept_id, status, create_time)能同时满足WHERE条件和ORDER BY,避免文件排序(Using filesort)。我在指导学生时,会让他们用EXPLAIN看加索引前后的type(从ALL变为range)和Extra(从Using filesort变为Using index),直观感受索引的价值。
3. 默认值与约束:让数据库替你干活
CREATE TABLE patient (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
seq_no VARCHAR(20) NOT NULL COMMENT '挂号序号',
name VARCHAR(50) NOT NULL,
id_card VARCHAR(18) NOT NULL,
dept_id BIGINT NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
status ENUM('WAITING', 'IN_PROGRESS', 'FINISHED', 'CANCELLED') DEFAULT 'WAITING'
);
DEFAULT CURRENT_TIMESTAMP:创建时间自动填充,无需应用层写new Date();ON UPDATE CURRENT_TIMESTAMP:更新时间自动更新,审计日志的基础;ENUM类型:限定status只能是预设值,比VARCHAR更安全,且存储空间更小(1字节 vs 可能10字节)。
这些细节看似微小,但合起来就是专业数据库设计的肌肉记忆:用约束代替代码校验,用索引代替暴力扫描,用默认值代替重复赋值。
5. 部署与调试全流程:从IDEA启动到生产环境上线
5.1 本地开发环境一键启动:mvnw脚本的真正威力
项目根目录下的mvnw(Mac/Linux)和mvnw.cmd(Windows)是Maven Wrapper,它的价值远不止“免装Maven”。我们来演示一次真实的启动流程:
第一步:确认环境
- JDK 8或11(项目pom.xml中<java.version>11</java.version>)
- MySQL 5.7+(已安装并运行,root密码为空或已知)
- 无需安装Maven!mvnw会自动下载指定版本(mvnw -v可查看)
第二步:初始化数据库
打开MySQL命令行,执行:
SOURCE /path/to/hospital.sql; -- 替换为实际路径
-- 或直接在IDEA的Database工具里右键执行SQL文件
第三步:启动项目
在项目根目录打开终端:
# Windows用户
mvnw.cmd spring-boot:run
# Mac/Linux用户
./mvnw spring-boot:run
此时mvnw会:
1. 检查本地是否有~/.m2/wrapper/dists/apache-maven-3.8.6-bin/...,没有则自动下载;
2. 读取pom.xml中的依赖,从中央仓库拉取spring-boot-starter-web等jar包;
3. 编译src/main/java下的所有.java文件;
4. 启动内嵌Tomcat,监听localhost:8080。
为什么比手动装Maven更可靠?
- 版本锁定:mvnw指向的Maven版本(3.8.6)和pom.xml中spring-boot-starter-parent版本(2.7.18)是经过测试的黄金组合,避免“Maven新版本导致插件不兼容”的坑;
- 离线可用:首次运行后,所有依赖缓存在~/.m2/repository,后续断网也能编译;
- 团队一致:不同同学用不同系统(Win/Mac/Linux),但mvnw确保构建行为完全一致,杜绝“在我电脑上好好的”问题。
我在指导学生时,会让他们故意删掉本地Maven,只用mvnw启动,亲身体验“环境无关性”的威力。
5.2 常见启动失败排查:从报错日志定位根源
启动失败是毕设最常遇到的问题,90%源于配置错误。以下是真实发生的高频问题及解决方案:
问题1:Failed to configure a DataSource: 'url' attribute is not specified
- 原因:application.yml中spring.datasource.url被注释或写错;
- 排查:检查src/main/resources/application.yml,确认url、username、password三者齐全,且url格式为jdbc:mysql://localhost:3306/hospital?useSSL=false&serverTimezone=Asia/Shanghai;
- 关键点:serverTimezone=Asia/Shanghai必须加上,否则MySQL驱动会报时区错误。
问题2:java.lang.ClassNotFoundException: org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration
- 原因:pom.xml中spring-boot-starter-web版本与父POM不兼容;
- 解决方案:删除pom.xml中手动添加的<version>标签,让spring-boot-starter-parent统一管理版本;
- 经验:永远不要在pom.xml里写死Spring Boot相关依赖的版本号,这是新手最大误区。
问题3:Whitelabel Error Page,访问/login显示空白页
- 原因:Thymeleaf模板路径错误或spring.thymeleaf.cache=false未生效;
- 排查:
1. 确认login.html在src/main/resources/templates/目录下(不是src/main/webapp/);
2. 检查application.yml中spring.thymeleaf.prefix: classpath:/templates/;
3. 清理IDEA缓存(File → Invalidate Caches and Restart);
- 技巧:在application.yml中加logging.level.org.thymeleaf=DEBUG,看控制台是否打印模板加载日志。
问题4:WebSocket连接失败,浏览器控制台报WebSocket connection to 'ws://localhost:8080/ws/queue' failed
- 原因:Spring Security默认拦截所有WebSocket连接;
- 修复:在SecurityConfig.java中添加:java http.authorizeRequests() .antMatchers("/ws/**").permitAll() // 放行WebSocket端点 // ... 其他配置
这些问题的共同点是:错误日志里一定有线索,关键是要学会读日志。我教学生一个铁律:启动失败时,第一时间看控制台最后一段红色ERROR,然后向上翻100行,找第一个Caused by:,那里就是根因。比如看到Caused by: java.sql.SQLException: Access denied for user 'root'@'localhost',就知道是数据库密码错了,而不是去瞎改Java代码。
5.3 生产环境部署指南:从JAR包到Nginx反向代理
毕设答辩通常只需本地运行,但如果你想让项目真正“上线”,这里有份精简版生产部署清单:
1. 打包成可执行JAR
./mvnw clean package -DskipTests
# 生成 target/hospital-0.0.1-SNAPSHOT.jar
2. 创建生产配置文件
新建application-prod.yml(放在src/main/resources/):
spring:
profiles:
active: prod
datasource:
url: jdbc:mysql://prod-db-ip:3306/hospital?useSSL=false&serverTimezone=Asia/Shanghai
username: prod_user
password: prod_password
thymeleaf:
cache: true # 生产环境开启模板缓存
server:
port: 8080
compression:
enabled: true
3. 启动服务(Linux服务器)
# 创建运行目录
mkdir /opt/hospital && cd /opt/hospital
# 上传JAR包和application-prod.yml
nohup java -jar hospital-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > app.log 2>&1 &
# 查看日志
tail -f app.log
4. Nginx反向代理(可选,提升体验)
在/etc/nginx/conf.d/hospital.conf中:
server {
listen 80;
server_name hospital.yourdomain.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket支持
location /ws/ {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
重启Nginx:sudo nginx -s reload
这套流程的价值在于:它让学生第一次接触生产环境与开发环境的差异——比如模板缓存开启、日志重定向、进程守护(nohup)、域名访问、HTTPS准备(Nginx可配SSL)。这些不是毕设硬性要求,但掌握它们,会让你的项目在答辩时显得格外“真实”。
6. 二次开发与功能扩展:从毕设到真实项目的跃迁路径
6.1 微信通知接入:三步实现消息触达
微信通知是毕设升级为真实项目的标志性功能。项目预留了NotificationService接口,扩展只需三步:
第一步:引入微信SDK
在pom.xml中添加:
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>4.5.0</version>
</dependency>
第二步:配置微信参数
在application.yml中:
wechat:
mp:
app-id: wx1234567890abcdef
secret: your_app_secret
token: your_token
aes-key: your_aes_key
第三步:实现通知逻辑
@Service
public class WechatNotificationService implements NotificationService {
@Value("${wechat.mp.app-id}")
private String appId;
@Override
public void sendNotification(String openId, String content) {
WxMpService wxService = new WxMpServiceImpl();
WxMpInMemoryConfigStorage config = new WxMpInMemoryConfigStorage();
config.setAppId(appId);
// ... 设置其他参数
wxService.setWxMpConfigStorage(config);
WxMpTemplateMessage templateMessage = WxMpTemplateMessage.builder()
.toUser(openId)
.templateId("your_template_id")
.build();
templateMessage.addData(new WxMpTemplateData("first", "您的叫号信息", "#173177"));
wxService.getTemplateMsgService().sendTemplateMsg(templateMessage);
}
}
这个扩展教会学生:真实项目不是从零造轮子,而是集成成熟SDK。微信官方文档、GitHub上的开源SDK、Stack Overflow的问答,构成了开发者真正的知识网络。
6.2 对接HIS系统:RESTful API的设计哲学
医院信息系统(HIS)对接是高级需求。项目在src/main/java/com/example/hospital/integration/his/下预留了集成包,核心是HisClient.java:
@Component
public class HisClient {
private final RestTemplate restTemplate;
public HisClient(RestTemplateBuilder builder) {
this.restTemplate = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10))
.build();
}
// 查询医生排班(调用HIS接口)
public List<HisDoctorSchedule> getDoctorSchedules(String deptCode, LocalDate date) {
String url = "https://his-api.example.com/v1/schedules?dept={dept}&date={date}";
try {
return restTemplate.getForObject(url, HisScheduleResponse.class, deptCode, date)
.getData();
} catch (ResourceAccessException e) {
log.error("调用HIS接口超时", e);
throw new IntegrationException("HIS系统暂时不可用,请稍后再试");
}
}
}
这里体现了两个重要原则:
- 超时控制:setConnectTimeout和setReadTimeout避免HIS响应慢拖垮整个叫号系统;
- 异常降级:捕获ResourceAccessException后,返回友好的业务异常,而不是让页面崩溃。
6.3 性能优化建议:从单机到集群的思考
当系统用户量增长,你会自然思考优化。项目虽为单机设计,但已埋下扩展伏笔:
- 数据库读写分离:在
application.yml中配置多数据源,@Transactional注解控制写库,@ReadOnly注解路由到从库; - Redis缓存热点数据:如科室列表、医生信息,用
@Cacheable(value = "deptList", key = "#root.methodName"); - 异步处理耗时操作:挂号成功后发短信,用
@Async注解交由线程池处理,避免阻塞HTTP线程。
这些不是毕设必需,但当你在答辩时说出“如果未来用户量增大,我会通过Redis缓存科室列表,减少数据库查询”,评委老师会眼前一亮——因为你展现的不是“做完一个系统”,而是“理解一个系统如何生长”。
7. 实操心得与避坑指南:那些文档里不会写的血泪教训
7.1 关于Git提交:别让.gitignore毁掉你的答辩
项目根目录的.gitignore文件里有这样一行:
target/
这行看似普通,却是无数学生答辩前夜崩溃的源头。原因:target/目录存放编译后的.class文件和hospital-0.0.1-SNAPSHOT.jar,体积巨大(常超50MB),且每次mvnw compile都会重建。如果忘记这行,git add .会把整个target/提交,导致:
- 仓库臃肿,克隆速度极慢;
- GitHub/GitLab对单文件大小有限制(通常100MB),超限后git push失败;
- 同学之间git pull时卡死。
我的解决方案:
1. 在项目初始化时,用git status --ignored确认target/确实在忽略列表;
2. 如果误提交了,用git rm -r --cached target/移除缓存,再git commit;
3. 养成习惯:每次git add前,先git status看一眼,确认只有.java、.yml、.sql等源文件。
7.2 关于Thymeleaf模板:空格和换行是隐形杀手
Thymeleaf对HTML空格极其敏感。比如这段代码:
<div th:text="${patient.name}">张三</div>
如果写成:
<div th:text="${patient.name}">
张三
</div>
渲染结果会是张三(带前后空格),导致前端校验if(name.trim() === '')失败。更隐蔽的是:
<span th:text="${patient.idCard}"></span>
<!-- 下一行 -->
<span th:text="${patient.phone}"></span>
两行<span>之间有换行符,浏览器会渲染成110101199003072312 13800138000(中间多一个空格)。
避坑技巧:
- 在application.yml中开启Thymeleaf严格模式:spring.thymeleaf.mode: HTML(而非LEGACYHTML);
- 使用th:utext替代th:text处理富文本;
- 模板中避免无意义的换行,用<!-- -->注释代替空行。
7.3 关于MySQL时区:一个字符引发的血案
application.yml中serverTimezone=Asia/Shanghai这个参数,我见过学生因为少打一个e(写成Asia/Shangahi)而调试3小时。错误现象:
- 数据库里create_time显示为2024-05-20 15:30:00;
- Java代码里new Date()打印却是Mon May 20 23:30:00 CST 2024;
- 导致“今日挂号数”统计错误(数据库认为是5月20日,Java认为是5月21日)。
终极解决方案:
1. 在MySQL中执行:SELECT @@global.time_zone, @@session.time_zone; 确认数据库时区;
2. 在Java中执行:System.out.println(TimeZone.getDefault().getID()); 确认JVM时区;
3. 三者必须一致,推荐全部设为Asia/Shanghai。
7.4 关于答辩演示:如何让评委30秒看懂你的项目
毕设答辩不是代码审查,而是价值传递。我让学生准备三页PPT:
- 第一页:一张截图——大屏叫号界面,显示“请到1号窗口就诊 李四(内科-001)”,旁边小字:“实时WebSocket推送,延迟<200ms”;
- 第二页:一张架构图——手绘风格的三层架构(浏览器→SpringBoot→MySQL),箭头标注“Thymeleaf服务端渲染”、“MyBatis动态SQL”、“Spring Security RBAC”;
- 第三页:一行代码——@Transactional(rollbackFor = Exception.class),旁边解释:“挂号失败时,患者信息和队列记录同时回滚,保证数据一致性”。
记住:评委老师不是来看你写了多少行代码的,而是看你是否理解软件工程的核心思想——分层、解耦、一致性、可维护性。当你指着QueueService.java说:“这里把业务规则集中管理,未来修改号源规则,只需改这一处”,你就赢了。
最后分享一个小技巧:答辩前,把项目打包成hospital-demo.zip,里面包含:
- README.md(精简版,只留启动步骤);
- demo.mp4(30秒演示视频:登录→挂号→叫号→大屏变化);
- architecture.png(手绘架构图);
- source-code-snippets/(3个关键代码片段:QueueService.callNext()、WebSocketConfig、SecurityConfig)。
当评委说“你讲讲核心功能”,你直接打开demo.mp4,30秒结束,全场安静——因为最好的代码,是让人一眼看懂的代码。
简介:直接上手就能跑的医院排队叫号系统,专为Java本科生毕业设计和课程实践打造。患者挂号、科室分诊、窗口实时叫号、大屏队列展示、后台管理一整套流程都已实现。后端用SpringBoot整合Spring MVC、MyBatis和Spring Security,MySQL存数据,前端基于Thymeleaf模板+Bootstrap做响应式界面,简洁清晰不花哨。项目自带mvnw脚本,Windows和Mac都能一键启动,不用单独装Maven;数据库SQL脚本和初始化说明全在HELP.md里,照着操作几分钟就能看到效果。源码结构规范,包名分层明确(controller/service/mapper/entity),适合新手理清MVC调用链路,也方便进阶者扩展功能——比如加短信或微信通知、接医生排班表、对接医院HIS接口。压缩包里包含全部源文件(src/main/java/html/xml)、IDEA工程配置(.idea)、pom.xml、编译输出目录(target),还有详细README和HELP文档,没有加密、没删减、没占位符。
更多推荐




所有评论(0)