简介:面向毕业设计与课程设计场景,这套基于Java+Springboot+Vue+Mysql实现的医院门诊预约挂号系统,适合有一定基础、希望掌握前后端分离开发全流程的学习者。系统包含医生管理、类型管理、评论管理、用户管理、统计分析、消息管理、广告管理、意见反馈及系统信息等功能模块,可完整体验预约挂号业务的后台数据管理与前端交互。压缩包共376个文件,以Java源码、Vue组件、JavaScript脚本、SQL数据库脚本和图片素材为主,整体大小7.69MB,结构按模块拆分,便于查阅和复用。目前已有63人学习下载。这份资料定位为项目参考,读者可根据源码、数据库脚本和页面逻辑梳理业务脉络,并在此基础上自行调试、扩展功能,适合作为课程设计或初期项目立项的实战素材。
1. 医院门诊预约挂号系统的选题价值与整体拆解
去医院挂号最耗神的不是排到窗口,而是窗口队伍几分钟才挪一步。预约挂号系统要解决的核心问题,是把"号源"变成可量化、可并发扣减的数据资源:医生排班生成号源,患者按时间片锁定,爽约后释放回池。用 Java+SpringBoot+Vue+MySQL 来实现,恰好覆盖后端接口、数据库事务、前端组件通信三条主线,对毕业设计和课程设计来说属于演示效果好、答辩好讲、代码量适中的选题。下面按"业务建模 → 后端接口 → 前端页面 → 联调排错"推进,给出一套能直接落地的最小方案,并标注几个答辩时大概率被追问的边界问题。
2. 门诊预约系统的业务建模与MySQL表结构设计
2.1 三种角色与两条主流程
预约挂号系统的角色至少分三种:患者、医生、管理员。多数课程设计只要求患者端和管理端,医生角色退化为排班数据里的静态关联对象。系统主流程可以压缩成两条:排班流——管理员为其医生在某个日期生成排班记录,并设定该时段的号源总数;预约流——患者选择医生、日期和时段,提交后锁号,就诊状态从未就诊变为已完成或已取消。
落到数据库层面,第一条流程是一批 INSERT,第二条流程的核心是一条带条件的 UPDATE。理解到这一层,建表思路就清晰了:不允许在代码里先查一遍余号再决定是否插入预约记录,这种方式在并发场景下必然出现超卖,后续排错成本远高于建表时多写几个字段。
2.2 五张核心表的设计与字段约束
我用五张表支撑上述流程:user、department、doctor、schedule、appointment。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id、username、password、real_name、phone、role | role 用 1/2/3 区分患者、医生、管理员 |
| department | id、dept_name、intro | 科室,供前端筛选医生 |
| doctor | id、user_id、dept_id、title | 医生挂在科室下,title 用于展示 |
| schedule | id、doctor_id、work_date、time_slot、total、booked、status | 某医生某天某时段的号源池 |
| appointment | id、schedule_id、patient_id、appointment_no、status、create_time | 预约记录,通过 schedule_id 关联排班 |
这里有几个设计决策值得沿用到其他管理系统里:
time_slot不存"上午/下午"中文,存1/2,前端映射显示文本。后续要扩夜间门诊,只需约定枚举值 3,不需要改表结构。schedule同时维护total和booked,而不是等到查询时COUNT(*)预约记录。这是读多写少场景下的经典冗余计数方案,下一节展开讲。appointment.status用整数状态机:0 已取消、1 已预约、2 已完成。取消操作把状态从 1 改为 0,而不是物理删除。保留历史记录对医生查看患者来源有实际意义。
建表脚本片段如下,其余表按同样风格补全即可:
CREATE TABLE `schedule` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `doctor_id` INT NOT NULL, `work_date` DATE NOT NULL, `time_slot` TINYINT NOT NULL COMMENT '1上午 2下午', `total` INT NOT NULL DEFAULT 20 COMMENT '号源总数', `booked` INT NOT NULL DEFAULT 0 COMMENT '已预约数', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停诊', UNIQUE KEY `uk_doctor_date_slot` (`doctor_id`, `work_date`, `time_slot`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_doctor_date_slot是联合唯一索引,从数据库层面拦截同一医生同一天同一时段的重复排班。appointment表上还要为schedule_id建普通索引,取消预约和统计都靠它回表,否则随着预约记录增长,联表查询会明显变慢。
2.3 冗余计数的必要性
如果不在schedule里维护booked,每次调用查询接口都要执行SELECT COUNT(*) FROM appointment WHERE schedule_id = ? AND status = 1。这个语句在预约量起来后是排名前几的慢查询。维护冗余计数后,剩余号源拿主键一次扫描就能算出来:
SELECT total - booked AS remain FROM schedule WHERE id = ?;代价集中在写路径:预约成功时booked + 1,取消时booked - 1。这个代价值得付,因为预约系统的读请求量级远高于写请求,而且写操作可以用带条件的 UPDATE 原子完成。答辩被问"超卖怎么防"时,把下一章的 UPDATE 方案讲出来,比空谈乐观锁更有说服力。
3. SpringBoot后端:从JWT鉴权到号源扣减的完整链路
3.1 工程结构与JWT登录
后端按controller / service / mapper / entity四层分包,不引入微服务概念。工程骨架示例:
com.example.hospital ├── controller # 接收请求,不做业务处理 ├── service # 预约、取消、查询业务 ├── mapper # MyBatis 接口 ├── entity # 与表对应的实体类 ├── config # WebMvc 配置、跨域配置 └── util # JWT工具、统一返回结果毕业设计不推荐上 Spring Security 完整配置,配置链路长,答辩时不好讲透。我用拦截器加 JWT 的方式管理登录态:登录成功签发 token,前端每次请求携带,拦截器校验通过再放行。JWT 工具类核心代码如下:
// JwtUtil.java —— 只保留生成和解析两个核心方法 public class JwtUtil { private static final String SECRET = "your-secret-key"; public static String generateToken(Long userId, Integer role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }生成 token 时把userId和role放进 JWT 的 claim,过期时间定 24 小时;解析时用同一个密钥串,解析成功则 token 未被篡改。role字段用于接口鉴权,比如管理端接口要求role == 3,普通患者调用返回 403。登录业务里比对数据库密码,课程设计用明文存储可以接受;如果论文涉及安全优化,改成MD5(password + salt)也能顺手答上。
统一返回结果类也要在项目早期定义好,否则每个接口返回类型不一致,前端 Axios 拦截器写起来会很别扭:
public class Result { private Integer code; // 200 成功,401 未登录,500 业务异常 private String message; private Object data; public static Result success(Object data) { Result r = new Result(); r.code = 200; r.message = "success"; r.data = data; return r; } public static Result error(Integer code, String message) { Result r = new Result(); r.code = code; r.message = message; return r; } }Result把业务状态和 HTTP 状态解耦:HTTP 只表达传输层成功或失败,业务错误统一走 200 + code。前端拿到code === 200再处理数据,否则弹message。这比靠 HTTP 410、422 这类冷门状态码通信直观得多。
3.2 排班查询与剩余号源计算
排班查询接口入参是doctorId和workDate,返回医生姓名、科室、时段、余号。Mapper 直接用注解写 SQL,减少 XML 文件数量,课程设计维护起来更轻松:
@Select("SELECT d.real_name AS doctorName, dept.dept_name AS deptName, " + "s.work_date AS workDate, s.time_slot AS timeSlot, " + "(s.total - s.booked) AS remain " + "FROM schedule s " + "JOIN doctor d ON s.doctor_id = d.id " + "JOIN department dept ON d.dept_id = dept.id " + "WHERE s.doctor_id = #{doctorId} AND s.work_date = #{workDate} " + "AND s.status = 1") List<ScheduleVO> selectByDoctorAndDate(@Param("doctorId") Long doctorId, @Param("workDate") String workDate);ScheduleVO这里值得解释一下:它作为值对象只暴露前端需要的字段,booked内部计数没必要传给前端,减少接口面等于减少安全性讨论范围。remain在 SQL 里算好,Java 层不参与减法。workDate用 String 接收是刻意为之,避免 Java 的Date与 MySQL 的DATE在 JDBC 层发生时区偏移,这个坑在第五章联调里细说。
3.3 预约接口:一行UPDATE解决并发扣减
预约动作的核心不是 INSERT,而是先做一次带条件的 UPDATE。正确写法:
@Transactional public boolean bookAppointment(Long patientId, Long scheduleId) { // 1. 原子扣减号源,条件自带防超卖 int updated = scheduleMapper.deductStock(scheduleId); if (updated == 0) { throw new BusinessException("该时段号源已约满"); } // 2. 扣减成功才插入预约记录 Appointment appointment = new Appointment(); appointment.setScheduleId(scheduleId); appointment.setPatientId(patientId); appointment.setAppointmentNo(generateAppointmentNo(scheduleId)); appointment.setStatus(1); appointmentMapper.insert(appointment); return true; }Mapper 中对应方法:
@Update("UPDATE schedule SET booked = booked + 1 " + "WHERE id = #{scheduleId} AND booked < total AND status = 1") int deductStock(@Param("scheduleId") Long scheduleId);逻辑说明:UPDATE 走主键索引加行锁,同一时刻只有一条事务能把booked加一;另一个并发请求到达时booked已等于total,条件booked < total不成立,影响行数为 0,直接走"已约满"分支。这就是数据库层面的原子更新,比SELECT ... FOR UPDATE显式加锁简洁,也不用处理休眠唤醒。
@Transactional保证deductStock和appointmentMapper.insert同生共死。这里有一个容易写错的细节:有人担心扣减成功后插入失败,事务回滚会不会让booked残留加一。不会,InnoDB 的回滚会把本次 UPDATE 的影响一并还原,不需要手动写补偿 SQL。
3.4 取消预约与状态迁移
取消预约要把appointment.status从 1 改成 0,同时释放schedule.booked一个号。两个操作共享一个事务:
@Transactional public void cancelAppointment(Long appointmentId, Long patientId) { // 先确认预约属于当前用户且未取消 Appointment appoint = appointmentMapper.selectByIdAndPatient(appointmentId, patientId); if (appoint == null || appoint.getStatus() != 1) { throw new BusinessException("预约记录不存在或已取消"); } appointmentMapper.updateStatus(appointmentId, 0); // 释放号源,booked 允许的最小值是 0 scheduleMapper.releaseStock(appoint.getScheduleId()); }@Update("UPDATE schedule SET booked = booked - 1 WHERE id = #{id} AND booked > 0") int releaseStock(@Param("id") Long scheduleId);booked > 0这个条件正常业务不会触发,但加上它可防止异常数据把booked减成负数。前一个selectByIdAndPatient的查询把patientId作为条件,确保患者只能取消自己的预约,这是接口层越权防护的最低要求。答辩提到这个点,能体现设计意识。
3.5 科室与医生接口
预约首页通常需要按科室找医生,两个查询接口逻辑简单,但要注意返回结构。科室接口一次返回科室和医生列表,避免前端发 N 次请求:
@GetMapping("/department/withDoctors") public Result listDepartmentsWithDoctors() { List<DepartmentVO> list = departmentMapper.selectWithDoctors(); return Result.success(list); }DepartmentVO内部嵌套List<DoctorVO>,SQL 里先查科室再按科室 ID 批量查医生,Java 层做内存组装。这个"嵌套查询 + 组装"在课程设计里比连表一次查出扁平结果更好扩展,后续增改字段不用动 SQL。
4. Vue前端:路由、排班日历与预约表单的落地
4.1 项目初始化与路由表设计
前端用 Vite 创建 Vue 3 项目,依赖精简到vue-router、axios、element-plus三个,减少安装体积和上手成本:
npm create vite@latest hospital-front -- --template vue cd hospital-front npm install vue-router@4 axios element-plus路由表按"需登录"和"公开"划分,用全局前置守卫控制跳转:
// src/router/index.js const routes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: () => import('@/layout/Index.vue'), redirect: '/home', children: [ { path: 'home', component: () => import('@/views/Home.vue') }, { path: 'schedule', component: () => import('@/views/Schedule.vue') }, { path: 'my', component: () => import('@/views/MyAppointment.vue') } ] } ]; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path !== '/login' && !token) { next('/login'); } else { next(); } });localStorage.getItem('token')是刷新后维持登录态的常规手段,token 过期由后端拦截器 401,前端统一跳转登录页。路由懒加载() => import()让首屏只加载登录页代码,这是包体积优化里成本最低的一步,写了它答辩也有内容可讲。
4.2 排班页:日历选择配合表格
Element Plus 的el-calendar自带月视图和日期切换,比手写日历少很多边界处理。配合el-table展示当天排班:
<template> <div class="schedule-page"> <el-calendar v-model="selectedDate" @change="loadSchedule" /> <el-table :data="scheduleList" v-loading="loading"> <el-table-column prop="doctorName" label="医生" /> <el-table-column prop="deptName" label="科室" /> <el-table-column label="时段"> <template #default="{ row }"> {{ row.timeSlot === 1 ? '上午' : '下午' }} </template> </el-table-column> <el-table-column prop="remain" label="剩余号源" /> <el-table-column label="操作"> <template #default="{ row }"> <el-button type="primary" size="small" :disabled="row.remain <= 0" @click="openBookDialog(row)">预约</el-button> </template> </el-table-column> </el-table> </div> </template>日历切换触发loadSchedule,按选中日期重新拉取排班。操作列的remain <= 0置灰属于第一层用户体验优化,真正的超卖防护在后端那条带条件的 UPDATE。这两者职责不同,答辩时如果被问"前端禁用了后端还要不要防",答案是要,因为接口可以直接被非法调用。
4.3 Axios封装与预约表单提交
Axios 实例统一挂 token,响应拦截器统一处理 401 和业务异常。后端返回结构是{ code, message, data },拦截器直接返回response.data,业务代码就不用反复解壳:
// src/api/request.js import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '@/router'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }); request.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } else if (error.response && error.response.data.message) { ElMessage.error(error.response.data.message); } return Promise.reject(error); } );预约确认用 Dialog 弹窗,用户在弹窗内确认医生和时段,点击确认后提交:
async function submitBooking() { const res = await request.post('/appointment/book', { scheduleId: currentRow.value.id }); if (res.code === 200) { ElMessage.success('预约成功'); loadSchedule(); } }只传scheduleId是刻意设计,患者身份从 JWT 里解析,避免前端伪造userId。预约成功后重新调用loadSchedule()刷新余号,界面即时反馈。
4.4 我的预约页与状态展示
个人预约列表展示当前用户的记录,关键在状态映射和时间格式化:
<el-tag :type="statusType(row.status)"> {{ statusText(row.status) }} </el-tag>const statusMap = { 0: { text: '已取消', type: 'info' }, 1: { text: '已预约', type: 'success' }, 2: { text: '已完成', type: 'primary' } };列表接口传patientId从 token 取,前端不传。取消按钮只在status === 1时显示,点击后调取消接口并刷新列表。这个页面是预约流的终点,也是后端 3.4 节取消逻辑的前端落点。
5. 联调必查的三个细节:跨域、日期与Token排查
5.1 Vite代理解决开发环境跨域
前端开发服务器默认 5173,SpringBoot 跑 8080,跨域不可避免。在vite.config.js里配代理,比后端加 CORS 注解更接近生产行为:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }rewrite把前端/api前缀剥掉,后端 Controller 的映射路径不用重复写/api。统一了这个约定,之后部署到 Nginx 只需改代理目标,前端代码零改动。
5.2 日期参数统一走字符串
前端日历选中的2025-06-01如果直接绑定到 Java 的Date类型,@JsonFormat时区没对齐就会出现日期偏一天。最省事的做法是接口入参直接用String接收:
@GetMapping("/schedule") public Result getSchedule(@RequestParam String workDate) { return Result.success(scheduleService.listByDate(workDate)); }前端把2025-06-01作为字面量传过去,MySQL 识别为合法DATE,直接落库存入schedule.work_date。如果项目里已经用了Date,则确认 SpringBoot 配置spring.jackson.time-zone=GMT+8,否则表单里选的日期在 JSON 反序列化时照样偏一天。
5.3 页面刷新401的定位顺序
联调最常见现象是刷新后接口全报 401,或者预约按钮点了没反应。定位顺序:F12 打开 Network 面板,看请求头里有没有Authorization,再看响应体是不是后端统一的ResultJSON。请求头缺失说明拦截器没挂上 token,检查localStorage的 key 和登录页写入时是否一致;有 token 但 401,查 JWT 密钥是不是在多个工具类里复制成了不同值。这套流程对答辩现场出 bug 同样有效,先看网络请求再查代码,不凭感觉改配置。
本文还有配套的精品资源,点击获取