简介:医院预约挂号系统毕业设计资料包,面向Java及JavaWeb方向的计算机专业学生,覆盖用户注册登录、预约挂号、取消预约、医生与科室信息管理等核心业务模块,同时展示从需求分析、数据库设计到功能实现的基本流程。系统采用MVC分层架构,融合Servlet、JSP、JSTL、MySQL数据库以及HTML/CSS/JavaScript等前端技术,并涉及HTTPS加密、防SQL注入、XSS防护、权限控制与日志监控等安全性和运维设计,适合作为毕业设计或课程设计的参考实现。压缩包大小约6.26MB,当前文件总数与类型明细暂未公开,下载解压后可直接查看项目源码、文档或相关素材;该资源已有1458人学习使用。借助这份资料,可以梳理医院预约挂号系统的数据库ER模型设计、核心流程代码组织、模块划分、部署测试要点等完整思路,对快速搭建同类项目、完善毕业设计文档、准备答辩演示具有直接的参考价值。
1. 医院预约挂号系统毕业设计,先把“号源状态”这个业务模型立住
医院预约挂号系统是毕业设计里的高频选题,但我见过太多项目把功夫花在页面数量上:登录、科室列表、医生列表、挂号表单、后台管理,一套 CRUD 做完,唯独缺了最该讲清楚的主线——同一个医生同一天的一个号源,被多个用户同时点击时,系统怎么保证不超卖。答辩老师喜欢追问的恰恰是这个,而很多实现是回答不了的。
下面的方案用一套稳妥且容易自圆其说的技术栈展开:后端 Spring Boot + MyBatis-Plus + MySQL,前端 Vue 3 + Element Plus。整体思路是:先用数据表把“号源状态”这块地基立住,再实现排班查询、挂号和退号接口,然后把 Vue 页面接上,最后给出一个可以现场演示的并发验证动作。正在定方案的本科生可以用它走通主线,已经写了半截代码的也能对照找漏。
2. 数据库表设计先行:排班、挂号与用户三块模型怎么落
2.1 从业务里抽出 5 张核心表
用一句话描述业务:患者选择科室和医生,看到某天上午或下午还剩多少号,提交预约生成一条挂号记录;没去就诊可以在限定时间内取消,取消后号源归还。这里面能抽出的核心实体有五个:用户表、科室表、医生表、排班表、挂号记录表。
常见的设计失误是只建“医生表”,然后在医生表里放一个“今日余号”字段,这种做法只能静态展示,到了同一位医生一天放两次号、上下车不同号价时,整个结构就撑不住了。正确做法是把每天动态变化的“号量”单独放一张排班表,医生表只存静态资料,挂号记录表负责流水和状态。三者的职责划分如下:
| 表名 | 承载的内容 | 常见的设计错误 |
|---|---|---|
| sys_user | 登录身份与角色 | 不分角色,患者和管理员混在一起 |
| doctor / department | 医生静态资料与科室归属 | 把余号数量放到医生表里 |
| outpatient_schedule | 某医生某天某时段的放号量 | 一条记录覆盖整天,无法细致控制时段 |
| registration | 一次挂号动作与状态流转 | 直接 delete 流水,退号无法追溯 |
建表 SQL 我按 MySQL 8 的写法给出,注释写在字段后面:
-- 用户表,role 区分患者、医生、管理员 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT '密码,存 BCrypt 或加盐哈希后的值', real_name VARCHAR(30) COMMENT '真实姓名', phone VARCHAR(20) COMMENT '手机号', role TINYINT DEFAULT 1 COMMENT '1-患者 2-医生 3-管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 科室表 CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL COMMENT '科室名,例如骨科、心内科', intro VARCHAR(500) COMMENT '科室简介' ); -- 医生表,归属于某个科室 CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL COMMENT '科室ID', name VARCHAR(30) NOT NULL, title VARCHAR(20) COMMENT '主任医师/副主任医师/主治医师', specialty VARCHAR(200) COMMENT '擅长领域' ); -- 排班表:核心号源库存 CREATE TABLE outpatient_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, schedule_date DATE NOT NULL COMMENT '出诊日期', time_slot VARCHAR(20) NOT NULL COMMENT '时段:MORNING/AFTERNOON/EVENING', total_number INT DEFAULT 20 COMMENT '总号数', remain_number INT DEFAULT 20 COMMENT '剩余号数,扣减核心字段', amount DECIMAL(10, 2) DEFAULT 0 COMMENT '挂号费', version INT DEFAULT 0 COMMENT '乐观锁版本,后续改放号数可用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_doctor_date_slot (doctor_id, schedule_date, time_slot) ); -- 挂号记录表:流水与状态 CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reg_no VARCHAR(32) NOT NULL COMMENT '挂号流水号,给用户展示用', user_id BIGINT NOT NULL COMMENT '患者ID', schedule_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0-已挂号 1-已就诊 2-已取消 3-爽约', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_schedule_id (schedule_id), KEY idx_user_id (user_id) );排班表里那个version字段现在不参与扣减逻辑,主要是给后期扩展用。比如管理员后台要手工调整某个时段放号量,就可以要求UPDATE ... WHERE id=? AND version=?,避免两个人同时改数据互相覆盖。把这个字段写进论文的“可扩展性”小节,答辩时不愁没话说。
2.2 状态流转与可预约排班查询
挂号记录的状态是典型的“状态机”:初始是 0(已挂号),医生完成接诊后改为 1(已就诊);患者在就诊前主动取消改为 2(已取消);预约了但没来就诊、后台定时任务可以把状态改为 3(爽约)。这套状态机不需要额外引入工作流引擎,用一个枚举类维护即可。
(随后紧随之后的没有任何 break,直接做。好吧,前面有个水平线 —— 回头或不需要。我把它去掉,可以直接接入 SQL 部分。)
继续写查询某日可用排班的语句,这个 SQL 直接给后端接口复用:
SELECT s.id AS schedule_id, dp.dept_name, d.name AS doctor_name, d.title, s.schedule_date, s.time_slot, s.amount, s.remain_number FROM outpatient_schedule s JOIN doctor d ON s.doctor_id = d.id JOIN department dp ON d.dept_id = dp.id WHERE s.schedule_date = '2024-06-18' AND s.remain_number > 0 ORDER BY dp.dept_name, s.time_slot;这里说三个要点。schedule_date直接用日期字符串与 DATE 类型比较,MySQL 会走索引,前提是排班表加过KEY idx_date_time (schedule_date, time_slot)。remain_number > 0的过滤很重要,它保证前端列表只返回真正可预约的号源,页面上已经挂满的时段就不展示为可点击。最后一个点是,SQL 里查出来的remain_number是“查询瞬间的剩余量”,它只能用于展示,不能作为后端判断是否允许挂号的唯一依据,后端判断要放到下章讲的 UPDATE 条件里。
注意:不要把号源信息写进医生表的字段,例如
today_remain。医生表保存的是静态属性,凡是“某一天、某一个时段”的动态信息都必须落到排班表,否则退号加回、多时段放号都要额外打补丁。
2.3 演示数据怎么生成
答辩前总得有号可挂。给所有医生生成未来一天上午、下午各 20 个号:
INSERT INTO outpatient_schedule (doctor_id, schedule_date, time_slot, total_number, remain_number) SELECT d.id, DATE_ADD(CURDATE(), INTERVAL 1 DAY), s.slot, 20, 20 FROM doctor d CROSS JOIN ( SELECT 'MORNING' AS slot UNION ALL SELECT 'AFTERNOON' ) s;这段 SQL 用CROSS JOIN把每个医生和两个时段做笛卡尔积,一条 insert 就能给全科医生铺完整天的号源。要生成未来三天,把INTERVAL 1 DAY改成2、3各执行一次即可。正式答辩时建议不要用定时任务这种没验证过的代码,提前用 SQL 准备好的数据更可控。
3. Spring Boot 后端实现:排班查询、挂号和取消接口
3.1 最小配置与 Mapper 层防超卖语句
后端只挑最能体现核心业务的代码讲。先确认pom.xml里有spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j和lombok。application.yml里最容易接错的是 MySQL 连接串:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl参数说明如下表:
| 配置项 | 值 | 含义 |
|---|---|---|
| url 尾部参数 | useUnicode=true&characterEncoding=utf8 | 中文不乱码 |
| url 尾部参数 | serverTimezone=Asia/Shanghai | 数据库时区对齐本地 |
| map-underscore-to-camel-case | true | remain_number自动映射为remainNumber |
| log-impl | StdOutImpl | 控制台打印 SQL,方便联调时排错 |
Mapper 层最核心的是两条自定义 UPDATE,一个扣减、一个归还:
@Mapper public interface ScheduleMapper extends BaseMapper<OutpatientSchedule> { @Update(""" UPDATE outpatient_schedule SET remain_number = remain_number - 1 WHERE id = #{scheduleId} AND remain_number > 0 """) int deductAvailable(@Param("scheduleId") Long scheduleId); @Update(""" UPDATE outpatient_schedule SET remain_number = remain_number + 1 WHERE id = #{scheduleId} """) int restoreAvailable(@Param("scheduleId") Long scheduleId); }3.2 Service 层:挂号和取消的事务边界
挂号 Service 是整个项目的核心,实现“扣减号源 + 插入流水”两件事,并且放在同一个事务里,任何一个失败都回滚:
@Service @RequiredArgsConstructor public class RegistrationService { private final ScheduleMapper scheduleMapper; private final RegistrationMapper registrationMapper; @Transactional(rollbackFor = Exception.class) public BookResult book(Long scheduleId, Long userId) { // 1. 判断当前用户是否已预约过这个排班 Long count = registrationMapper.selectCount( new LambdaQueryWrapper<Registration>() .eq(Registration::getScheduleId, scheduleId) .eq(Registration::getUserId, userId) .eq(Registration::getStatus, 0)); if (count != null && count > 0) { throw new BizException("您已预约过该时段,请先取消再重新预约"); } // 2. 条件扣减号源,这是防超挂的关键动作 int rows = scheduleMapper.deductAvailable(scheduleId); if (rows == 0) { throw new BizException("该时段号源已被约满,请选择其他时间"); } // 3. 生成挂号记录并插入 OutpatientSchedule schedule = scheduleMapper.selectById(scheduleId); Registration reg = new Registration(); reg.setRegNo("GH" + System.currentTimeMillis() + RandomUtil.randomNumbers(4)); reg.setUserId(userId); reg.setScheduleId(scheduleId); reg.setStatus(0); registrationMapper.insert(reg); // 4. 把展示信息返回给前端 return BookResult.builder() .regNo(reg.getRegNo()) .doctorName(schedule.getDoctorName()) .scheduleDate(schedule.getScheduleDate()) .timeSlot(schedule.getTimeSlot()) .build(); } @Transactional(rollbackFor = Exception.class) public void cancel(Long regId, Long userId) { Registration reg = registrationMapper.selectById(regId); if (reg == null || !reg.getUserId().equals(userId)) { throw new BizException("挂号记录不存在或无权操作"); } if (reg.getStatus() != 0) { throw new BizException("当前状态不允许取消"); } // 先归还号源,再更新状态,同一事务 int rows = scheduleMapper.restoreAvailable(reg.getScheduleId()); if (rows == 0) { throw new BizException("取消失败,请重试"); } reg.setStatus(2); registrationMapper.updateById(reg); } }简单地梳理一下这个实现里的理论判断。
为什么第一步和第二步不能换顺序?如果先把排班数量减掉再查重,用户在重复点击时,第二次请求会在查询时发现状态为 0 的记录并直接失败,但号源已经被扣过一次,整体事务回滚会把这次扣减也还原,所以实际上顺序对结果影响不大。反过来,如果第一步查到“已预约”直接抛异常而不走事务,后面的号源也不会受影响。真正不能省的是第二步这个AND remain_number > 0的条件更新,它把“判断还有没有号”和“把号减一”合并成一条 SQL,由数据库行锁保证原子性。并发请求同时进来时,MySQL 会串行执行这一行,只有一条语句能真正把值减掉。
统一异常处理记得写一个全局切面,把BizException包装成{ code, message, data }的 JSON 返回。这样前端拦截器判断起来很简单,也能避免后端直接把异常堆栈抛给页面。
3.3 Controller 与 Postman 自测
排班查询的请求路径定为GET /api/schedule/available,参数是日期:
@RestController @RequestMapping("/api") @RequiredArgsConstructor public class ScheduleController { private final ScheduleService scheduleService; @GetMapping("/schedule/available") public Result<List<ScheduleVO>> getAvailable(@RequestParam String date) { return Result.ok(scheduleService.getAvailableList(date)); } }挂号请求是POST /api/registration,取消是PUT /api/registration/{id}/cancel。三个接口的参数约定如下:
| 参数 | 位置 | 必填 | 说明 |
|---|---|---|---|
| date | query | 是 | 格式 YYYY-MM-DD,查询某天可预约排班 |
| scheduleId | body | 是 | 排班表主键 |
| userId | header | 开发期 | 当前登录用户 ID,测试时可固定传 1 |
提示:开发阶段可以在 Controller 里用
@RequestHeader(value = "userId", defaultValue = "1")把 userId 兜底成默认值,方便 Postman 调试。项目完工前一定要改成从登录态获取,否则任何人都能冒充别人挂号。
启动服务后,先用 Postman 请求排班接口看返回,再提交一个挂号请求。如果返回 JSON 里有regNo、医生名、日期、时段,说明后端主链路已经通。
4. Vue 3 前端联调:排班列表、二次确认与余量刷新
4.1 前端工程与 axios 拦截器
前端用 Vue 3 + Vite + Element Plus,创建命令是:
npm create vite@latest hospital-front cd hospital-front npm install npm install element-plus axiosElement Plus 是当下 Vue 3 项目里很顺手的开源组件库,表格、日期选择器、消息提示都齐。重点写一下 axios 封装:
// src/utils/http.js import axios from 'axios' import { ElMessage } from 'element-plus' const http = axios.create({ baseURL: '/api', timeout: 10000 }) http.interceptors.request.use(config => { const userId = localStorage.getItem('userId') || '1' config.headers['userId'] = userId return config }) http.interceptors.response.use( res => { const body = res.data if (body.code === 0) { return body.data } ElMessage.error(body.message || '请求失败') return Promise.reject(new Error(body.message)) }, err => { ElMessage.error(err.response?.data?.message || '网络异常') return Promise.reject(err) } ) export default http这个拦截器定义了一个前后端约定:后端code = 0表示成功,返回data字段给页面;code非 0 时统一弹出错误信息。把 userId 塞进请求头是毕设阶段的简化方案,正式登录只要改成携带 token 就行,其余逻辑不动。
4.2 排班列表页面:预约动作怎么写
排班页面的模板核心是日期选择器加表格:
<template> <div> <el-date-picker v-model="currentDate" type="date" value-format="YYYY-MM-DD" @change="loadSchedules" /> <el-table :data="scheduleList" v-loading="loading"> <el-table-column prop="deptName" label="科室" /> <el-table-column prop="doctorName" label="医生" /> <el-table-column prop="title" label="职称" /> <el-table-column prop="timeSlot" label="时段" /> <el-table-column prop="amount" label="挂号费" /> <el-table-column prop="remainNumber" label="剩余号" /> <el-table-column label="操作" width="120"> <template #default="{ row }"> <el-button type="primary" size="small" :disabled="row.remainNumber <= 0" @click="doBook(row)" > 预约 </el-button> </template> </el-table-column> </el-table> </div> </template>脚本部分:
import { ref, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import http from '../utils/http' const currentDate = ref(new Date().toISOString().slice(0, 10)) const scheduleList = ref([]) const loading = ref(false) const loadSchedules = async () => { loading.value = true try { scheduleList.value = await http.get('/schedule/available', { params: { date: currentDate.value } }) } finally { loading.value = false } } const doBook = async (row) => { try { await ElMessageBox.confirm( `确认预约 ${row.doctorName} ${row.timeSlot} 的号源?`, '挂号确认' ) } catch (e) { return } const data = await http.post('/registration', { scheduleId: row.scheduleId }) ElMessage.success(`预约成功,流水号:${data.regNo}`) loadSchedules() } onMounted(loadSchedules)这段代码里真正有业务含义的是最后三行:预约成功后必须重新拉一次列表。因为后端已经扣减了号源,重新请求接口后remainNumber会减一,界面上的剩余号才会变。如果不刷新,用户会看到余量始终不变,误以为系统没反应。
前端这层的:disabled="row.remainNumber <= 0"只是减少无效点击,它不能在并发场景下充当安全判断。两个用户同时看到剩余 1 个号并点击,前端都会放行,最终靠的是第 3 章那条AND remain_number > 0的条件更新,谁先执行谁拿到号,另一个收到“号源已约满”的提示。这个逻辑在答辩时可以主动讲出来。
4.3 本地联调最容易卡住的 3 个点
上一段提到“data 里的数据要变”,但很多同学卡在这里,我用文本列出三种典型问题。
第一,跨域。在vite.config.js配代理即可,不需要后端开@CrossOrigin:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })第二,日期格式。el-date-picker默认返回 Date 对象,如果不加value-format="YYYY-MM-DD",传给后端的字符串会带时分秒,和数据库 DATE 比较总出不来数据。这个格式要显式写。
第三,余量没刷新。先打开浏览器 Network 面板,确认挂号请求返回后有没有再次发GET /schedule/available。如果没有,说明loadSchedules()没被调用;如果有,再看后端控制台StdOutImpl打出来的 SQL,确认 UPDATE 真的执行过。两步基本能定位到问题。
5. 答辩加分的三件事:去重限制、索引与一次并发演示
5.1 给“同一人同一时段重复挂号”做两道拦截
Service 里的selectCount是第一道业务拦截,能挡住正常重复点击。如果页面里快速双击,两个请求几乎同时到达,业务判断可能漏掉,所以可以在数据库层加一个唯一索引:
ALTER TABLE registration ADD UNIQUE KEY uk_user_schedule_active (user_id, schedule_id, status);这个唯一索引之所以把status也纳入,是为了让“已取消”的记录不阻塞再次预约。第一次挂号成功时status = 0,存在索引里;取消后同一行变成status = 2,同样的(user_id, schedule_id)组合又能插入一条新的status = 0记录。唯一约束只在“有效挂号”这个维度上生效,既能防重复,又不妨碍退号后重挂。
5.2 用 explain 检查一条慢查询
“我的挂号记录”页面常见的列表查询是WHERE user_id = ? ORDER BY create_time DESC,可以加联合索引:
CREATE INDEX idx_user_time ON registration (user_id, create_time DESC);加完之后执行:
EXPLAIN SELECT * FROM registration WHERE user_id = 1 AND status = 0 ORDER BY create_time DESC;只要key列显示idx_user_time,就说明这条查询没有全表扫描。这个动作本身很简单,但大部分毕设项目不会做,答辩时提一句“我验证过执行计划”,比空口说“我建了索引”有说服力得多。
5.3 现场演示:20 个并发请求抢 20 个号
最后留一个可以当场演示的验证方案。先把某条排班的remain_number重置为 20,再临时把 Service 里的重复挂号selectCount注释掉,避免同一个 userId 触发去重分支影响计数。然后执行:
ab -n 20 -c 20 -H "userId: 1" -p book.json -T application/json \ http://localhost:8080/api/registrationbook.json的内容是{"scheduleId":1}。这个命令的意思是:总共发 20 个请求,同时并发 20 个,自定义请求头userId: 1,POST body 从文件读取。
命令跑完,执行两条 SQL 对账:
SELECT remain_number FROM outpatient_schedule WHERE id = 1; SELECT COUNT(*) FROM registration WHERE schedule_id = 1 AND status = 0;第一个返回 0,第二个返回 20,说明扣减和流水写入完全一致。如果第一个是负数或者第二个少于 20,就去检查 SQL 里是否漏了AND remain_number > 0,或者@Transactional回滚配置是否生效。演示完成后恢复第 5.1 节的重复挂号检查,再快速点击同一个排班的两次预约,第二次会看到“您已预约过该时段”,去重逻辑也顺带展示了一遍。
本文还有配套的精品资源,点击获取