ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue医院门诊预约挂号系统实战:从表设计到并发扣减

SpringBoot+Vue医院门诊预约挂号系统实战:从表设计到并发扣减 简介面向毕业设计与课程设计场景这套基于JavaSpringbootVueMysql实现的医院门诊预约挂号系统适合有一定基础、希望掌握前后端分离开发全流程的学习者。系统包含医生管理、类型管理、评论管理、用户管理、统计分析、消息管理、广告管理、意见反馈及系统信息等功能模块可完整体验预约挂号业务的后台数据管理与前端交互。压缩包共376个文件以Java源码、Vue组件、JavaScript脚本、SQL数据库脚本和图片素材为主整体大小7.69MB结构按模块拆分便于查阅和复用。目前已有63人学习下载。这份资料定位为项目参考读者可根据源码、数据库脚本和页面逻辑梳理业务脉络并在此基础上自行调试、扩展功能适合作为课程设计或初期项目立项的实战素材。1. 医院门诊预约挂号系统的选题价值与整体拆解去医院挂号最耗神的不是排到窗口而是窗口队伍几分钟才挪一步。预约挂号系统要解决的核心问题是把号源变成可量化、可并发扣减的数据资源医生排班生成号源患者按时间片锁定爽约后释放回池。用 JavaSpringBootVueMySQL 来实现恰好覆盖后端接口、数据库事务、前端组件通信三条主线对毕业设计和课程设计来说属于演示效果好、答辩好讲、代码量适中的选题。下面按业务建模 → 后端接口 → 前端页面 → 联调排错推进给出一套能直接落地的最小方案并标注几个答辩时大概率被追问的边界问题。2. 门诊预约系统的业务建模与MySQL表结构设计2.1 三种角色与两条主流程预约挂号系统的角色至少分三种患者、医生、管理员。多数课程设计只要求患者端和管理端医生角色退化为排班数据里的静态关联对象。系统主流程可以压缩成两条排班流——管理员为其医生在某个日期生成排班记录并设定该时段的号源总数预约流——患者选择医生、日期和时段提交后锁号就诊状态从未就诊变为已完成或已取消。落到数据库层面第一条流程是一批 INSERT第二条流程的核心是一条带条件的 UPDATE。理解到这一层建表思路就清晰了不允许在代码里先查一遍余号再决定是否插入预约记录这种方式在并发场景下必然出现超卖后续排错成本远高于建表时多写几个字段。2.2 五张核心表的设计与字段约束我用五张表支撑上述流程user、department、doctor、schedule、appointment。表名关键字段说明userid、username、password、real_name、phone、rolerole 用 1/2/3 区分患者、医生、管理员departmentid、dept_name、intro科室供前端筛选医生doctorid、user_id、dept_id、title医生挂在科室下title 用于展示scheduleid、doctor_id、work_date、time_slot、total、booked、status某医生某天某时段的号源池appointmentid、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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ListScheduleVO 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() { ListDepartmentVO list departmentMapper.selectWithDoctors(); return Result.success(list); }DepartmentVO内部嵌套ListDoctorVOSQL 里先查科室再按科室 ID 批量查医生Java 层做内存组装。这个嵌套查询 组装在课程设计里比连表一次查出扁平结果更好扩展后续增改字段不用动 SQL。4. Vue前端路由、排班日历与预约表单的落地4.1 项目初始化与路由表设计前端用 Vite 创建 Vue 3 项目依赖精简到vue-router、axios、element-plus三个减少安装体积和上手成本npm create vitelatest hospital-front -- --template vue cd hospital-front npm install vue-router4 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 classschedule-page el-calendar v-modelselectedDate changeloadSchedule / el-table :datascheduleList v-loadingloading el-table-column propdoctorName label医生 / el-table-column propdeptName label科室 / el-table-column label时段 template #default{ row } {{ row.timeSlot 1 ? 上午 : 下午 }} /template /el-table-column el-table-column propremain label剩余号源 / el-table-column label操作 template #default{ row } el-button typeprimary sizesmall :disabledrow.remain 0 clickopenBookDialog(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 :typestatusType(row.status) {{ statusText(row.status) }} /el-tagconst statusMap { 0: { text: 已取消, type: info }, 1: { text: 已预约, type: success }, 2: { text: 已完成, type: primary } };列表接口传patientId从 token 取前端不传。取消按钮只在status 1时显示点击后调取消接口并刷新列表。这个页面是预约流的终点也是后端 3.4 节取消逻辑的前端落点。5. 联调必查的三个细节跨域、日期与Token排查5.1 Vite代理解决开发环境跨域前端开发服务器默认 5173SpringBoot 跑 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-zoneGMT8否则表单里选的日期在 JSON 反序列化时照样偏一天。5.3 页面刷新401的定位顺序联调最常见现象是刷新后接口全报 401或者预约按钮点了没反应。定位顺序F12 打开 Network 面板看请求头里有没有Authorization再看响应体是不是后端统一的ResultJSON。请求头缺失说明拦截器没挂上 token检查localStorage的 key 和登录页写入时是否一致有 token 但 401查 JWT 密钥是不是在多个工具类里复制成了不同值。这套流程对答辩现场出 bug 同样有效先看网络请求再查代码不凭感觉改配置。本文还有配套的精品资源点击获取
返回列表