尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

医院预约挂号系统毕业设计:号源状态管理与并发防超卖实现

医院预约挂号系统毕业设计:号源状态管理与并发防超卖实现 简介医院预约挂号系统毕业设计资料包面向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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 尾部参数useUnicodetruecharacterEncodingutf8中文不乱码url 尾部参数serverTimezoneAsia/Shanghai数据库时区对齐本地map-underscore-to-camel-casetrueremain_number自动映射为remainNumberlog-implStdOutImpl控制台打印 SQL方便联调时排错Mapper 层最核心的是两条自定义 UPDATE一个扣减、一个归还Mapper public interface ScheduleMapper extends BaseMapperOutpatientSchedule { 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 LambdaQueryWrapperRegistration() .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 ResultListScheduleVO getAvailable(RequestParam String date) { return Result.ok(scheduleService.getAvailableList(date)); } }挂号请求是POST /api/registration取消是PUT /api/registration/{id}/cancel。三个接口的参数约定如下参数位置必填说明datequery是格式 YYYY-MM-DD查询某天可预约排班scheduleIdbody是排班表主键userIdheader开发期当前登录用户 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 vitelatest 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-modelcurrentDate typedate value-formatYYYY-MM-DD changeloadSchedules / el-table :datascheduleList v-loadingloading el-table-column propdeptName label科室 / el-table-column propdoctorName label医生 / el-table-column proptitle label职称 / el-table-column proptimeSlot label时段 / el-table-column propamount label挂号费 / el-table-column propremainNumber label剩余号 / el-table-column label操作 width120 template #default{ row } el-button typeprimary sizesmall :disabledrow.remainNumber 0 clickdoBook(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会减一界面上的剩余号才会变。如果不刷新用户会看到余量始终不变误以为系统没反应。前端这层的:disabledrow.remainNumber 0只是减少无效点击它不能在并发场景下充当安全判断。两个用户同时看到剩余 1 个号并点击前端都会放行最终靠的是第 3 章那条AND remain_number 0的条件更新谁先执行谁拿到号另一个收到“号源已约满”的提示。这个逻辑在答辩时可以主动讲出来。4.3 本地联调最容易卡住的 3 个点上一段提到“data 里的数据要变”但很多同学卡在这里我用文本列出三种典型问题。第一跨域。在vite.config.js配代理即可不需要后端开CrossOriginimport { 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-formatYYYY-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: 1POST 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 节的重复挂号检查再快速点击同一个排班的两次预约第二次会看到“您已预约过该时段”去重逻辑也顺带展示了一遍。本文还有配套的精品资源点击获取
返回列表