本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接上手就能跑的医院排队叫号系统,专为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.ymlspring.datasource.url怎么连上本地3306端口,能看到SecurityConfig.javahttp.authorizeRequests()如何用正则匹配URL放行静态资源,还能在Thymeleaf模板里找到<span th:text="${patient.name}">张三</span>这种最朴素的数据绑定。没有炫技的微服务注册中心,没有烧脑的响应式编程,只有扎实的@Service层事务注解、@Transactional(rollbackFor = Exception.class)背后对ACID的敬畏,以及pom.xml里那一行行被反复验证过的依赖版本——比如mybatis-spring-boot-starter:2.2.2spring-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.javacallNext(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统一拦截,不需要配置proxyTabledevServer.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需要手动配置SecurityManagerRealmFilterChainDefinitionMap,对新手来说配置项太多,容易出错;
- 权限模型更贴近医院场景:医院有明确的角色层级——超级管理员(可操作所有科室)、科室管理员(只能管理本部门)、窗口护士(只能叫号)。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,而非过时的MD5SHA-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 BYCOUNT(*)的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表的某个“总号源数”,而是执行以下逻辑:

  1. 查询当日排班表: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'
  2. 汇总所有在职医生的号源:SUM(morning_quota) + SUM(afternoon_quota)
  3. 减去已挂号人数:SELECT COUNT(*) FROM queue_record WHERE dept_id=? AND DATE(create_time)=? AND status='WAITING'

这个计算过程封装在DeptService.javagetAvailableQuota()方法里。好处显而易见:
- 支持弹性排班:院长在后台修改某医生的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.javacallNext()方法触发:

@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_iddept_id均为外键,强制引用patient.iddept.id,确保数据完整性;
  • doctor(医生表):dept_id外键关联dept.id,表示医生所属科室;
  • doctor_schedule(医生排班表):doctor_id外键关联doctor.idwork_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.xmlspring-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.ymlspring.datasource.url被注释或写错;
- 排查:检查src/main/resources/application.yml,确认urlusernamepassword三者齐全,且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.xmlspring-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.htmlsrc/main/resources/templates/目录下(不是src/main/webapp/);
2. 检查application.ymlspring.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系统暂时不可用,请稍后再试");
        }
    }
}

这里体现了两个重要原则:
- 超时控制setConnectTimeoutsetReadTimeout避免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.ymlserverTimezone=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()WebSocketConfigSecurityConfig)。

当评委说“你讲讲核心功能”,你直接打开demo.mp4,30秒结束,全场安静——因为最好的代码,是让人一眼看懂的代码。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接上手就能跑的医院排队叫号系统,专为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文档,没有加密、没删减、没占位符。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐