资讯动态

SSM+MyBatisPlus+Vue实现预约挂号系统:号源扣减与防并发设计

发布时间:2026/9/14 7:50:33 来源:尧图企业网站定制
简介一份面向计算机专业毕业设计及Java Web初学者的医院门诊在线预约挂号管理系统项目源码基于SSMSpringSpringMVCMyBatisPlus框架整合Vue前端涵盖患者在线挂号、科室/医生查询、后台管理等核心流程适合直接运行学习或作为二次开发基础。压缩包共640个文件大小约15.61MB以152个Java后端类、111个Vue页面组件为主辅以44个JS脚本、25个XML配置、36个JPG和32个PNG素材以及SQL数据库脚本、PDF和Word文档结构层次分明便于按功能模块查阅。目前已有139人学习下载。通过源码可掌握SSM与Vue前后端分离开发、Maven项目构建、MySQL数据库设计等关键技能资源附带数据库初始化脚本与运行说明如build.bat/run.bat能够帮助快速搭建环境、理解预约挂号系统的业务逻辑与实现细节。1. 为什么挂号预约比普通 CRUD 更难先认识号源扣减这个核心早上八点的门诊大厅人工窗口前排着长队热门科室的号在开诊后十几分钟内全部放完。这个 Web 项目把排号和候诊从线下挪到线上用户在浏览器里就能完成科室选择、医生排班查询和在线预约挂号。技术栈是 Java SSMSpring SpringMVC MyBatis MyBatisPlus Vue MySQL 5.7前后端通过 Ajax 通信。它适合两类人需要交付设计说明书和可运行代码的计算机专业毕业生以及想快速搭出一套中小医院预约原型的 Web 开发者。拆这个项目时我建议把注意力放在号源扣减这一环而不是普通 CRUD。并发情况下怎么保证不超卖、不重复挂号这才是这套系统真正值得研究的地方。2. SSM MyBatisPlus Vue预约挂号系统的分层与数据库建模2.1 为什么 SSM 组合在这个场景里依然实用选这套技术栈不是因为新而是因为它刚好覆盖这个项目的全部诉求。Spring 负责 Bean 管理和声明式事务SpringMVC 负责接口路由和参数绑定MyBatis 负责 SQLMyBatisPlus 在 MyBatis 之上把科室、医生、用户这类单表 CRUD 做了封装不用再手写重复的 insert、select 和分页。JDK 1.8 配合 Tomcat 8 和 MySQL 5.7是多数学校机房和云主机都能直接跑起来的环境这决定了后期部署成本低。Vue 在这里只承担视图层。预约页面需要科室、医生、排班三级联动还要在提交时展示 loading 状态并用 Ajax 异步刷新号源余量这些交互在 Vue 2 里用 data 和 computed 就能完成不需要引入重量级状态管理库。SSM 方案的边界同样清楚如果目标是日均几万次请求的三甲医院单机 MySQL 加事务锁会撑不住那就要换成微服务和消息队列但对中小医院预约系统和课程设计场景这套分层足够支撑业务闭环。2.2 核心表结构设计预约挂号的最小数据集合是五张表科室、医生、排班、就诊人、预约记录。其中排班表是业务核心号源总量和剩余量放在这张表上而不是挂在医生表里。表关键字段含义设计要点departmentid, name, intro科室信息前端联动第一级doctorid, department_id, name, title医生信息按科室过滤scheduleid, doctor_id, schedule_date, period, total_num, remain_num, version某医生某半天号源remain_num 与 version 扣减关键patientid, name, id_card, phone就诊人/用户手机号可作为登录名appointmentid, patient_id, schedule_id, status, create_time预约记录patient_id schedule_id 加唯一索引排班表单独成表的意义在于同一个医生一周有多个不同时段的排班每个排班的号源总量不同只有把日期和号源集合在一起才能支撑“某天下午还剩几个号”这种查询。如果复用 doctor 表里的字段每次放新号源都要改医生记录历史预约也无法追溯。version 字段是为了配合 MyBatisPlus 乐观锁后面第 3 章会专门展开。2.3 Maven 依赖与工程目录后端工程是标准的 Maven 聚合结构前端代码放在 web 目录下通过 Vue CLI 独立构建。pom.xml 里最关键的依赖如下properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target mybatis-plus.version3.5.x/mybatis-plus.version /properties dependencies !-- SpringMVC 与 Spring 核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId /dependency !-- MyBatisPlus单表 CRUD 与乐观锁插件 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency !-- MySQL 5.7 常见驱动版本实际版本与项目保持一致 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency /dependencies这段依赖说明三点第一spring-webmvc 是后端接口的基础Spring 容器通过注解扫描 Controller 和 Service第二MyBatisPlus 的 starter 会连带引入 MyBatis 和 Spring 集成依赖不需要再单独声明第三mysql-connector-java 的版本必须和服务端 MySQL 5.7 匹配如果项目由 MySQL 8 降级而来这里容易出现驱动类找不到的报错。2.4 一次预约请求的完整链路整个调用链路在项目里可以拆成六步浏览器访问 Vue 路由进入预约页面页面加载时通过 Ajax 拉取科室列表用户选择科室触发loadDoctors请求/api/doctor/list?departmentId1选择医生后请求/api/schedule/list返回一周内的排班和剩余号数用户点击预约提交前端将 patientId 和 scheduleId 通过 POST 发送到后端SpringMVC 的 Controller 接收参数并校验进入 Service 层事务方法Service 调用 MyBatis 更新号源库存插入预约记录返回 JSON 给前端刷新余量。开发环境里前端跑在 8081 端口后端跑在 8080 端口两个端口之间通过 vue.config.js 里的 devServer 转发解决跨域生产环境则用 nginx 做统一入口。理解这条链路再看项目代码就能把前端 src 目录下的api封装、后端controller和service三部分对应起来。3. 后端核心实现排班查询、号源扣减与防重复挂号3.1 乐观锁配置为什么不用 synchronized预约系统最关键的一个问题同一时间有 30 个用户请求同一个排班而号源只剩 5 个怎么保证最终只成功 5 次。最常见的错误写法是先 select 检查remain_num 0再 update 减一这两条语句之间没有原子性并发下会全部通过检查造成超卖。我建议这个项目使用 MyBatisPlus 乐观锁插件给 schedule 表加 version 字段更新时带上where version #{oldVersion}版本不匹配则影响行数为 0重新读取再尝试。悲观锁也就是select ... for update在这个场景里也能用但它会把排班记录锁住并发不高时没必要承担额外锁等待时间。Configuration MapperScan(com.hospital.register.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 乐观锁插件updateById 时自动附加 version 条件并自增 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }配置这个插件后调用scheduleMapper.updateById()时MyBatisPlus 会自动把实体的 version 字段带入where并把version version 1写进set。需要注意实体类上一个声明Version private Integer version;3.2 排班列表查询接口预约页面打开时需要展示“本周可预约的排班”查询条件是科室和日期。这里不直接写死 SQL用 LambdaQueryWrapper 构造条件逻辑更清晰Override public ListScheduleVO listSchedule(ScheduleQuery query) { LambdaQueryWrapperSchedule wrapper new LambdaQueryWrapper(); wrapper.eq(query.getDepartmentId() ! null, Schedule::getDepartmentId, query.getDepartmentId()) .eq(query.getScheduleDate() ! null, Schedule::getScheduleDate, query.getScheduleDate()) .ge(Schedule::getRemainNum, 1) .orderByAsc(Schedule::getScheduleDate, Schedule::getPeriod); ListSchedule scheduleList baseMapper.selectList(wrapper); // 批量查出医生信息拼成 VO 返回前端 ListLong doctorIds scheduleList.stream() .map(Schedule::getDoctorId).distinct().collect(Collectors.toList()); MapLong, Doctor doctorMap doctorService.listByIds(doctorIds).stream() .collect(Collectors.toMap(Doctor::getId, Function.identity())); // 组装 ScheduleVO包含医生姓名、职称、科室名等展示字段 }逻辑说明eq的第一个参数是 boolean 条件前端没传科室 id 时该条件被忽略可以避免在代码里写一堆 if else。ge(Schedule::getRemainNum, 1)只查出还有余号的排班减少无效数据量。orderByAsc先按日期再按上午下午排序保证页面展示顺序和用户习惯一致。Controller 层只需要接收参数并调用这个 Service 方法返回ResultListScheduleVO统一结构体即可。前端拿到数据后直接渲染表格或卡片余量为 0 的排班就不会出现在列表里。3.3 预约下单一条 update 语句解决超卖下单接口是整个项目里最值得讲的一段代码。核心思路是不先查再减而是直接用一条带条件的更新语句扣减号源用数据库的影响行数判断是否成功。Transactional(rollbackFor Exception.class) public ResultLong createAppointment(Long patientId, Long scheduleId) { // 1. 查出排班信息判断号源 Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule null || schedule.getRemainNum() 0) { return Result.fail(号源已满); } // 2. 检查同一就诊人是否重复预约同一排班 Long count appointmentMapper.selectCount( new LambdaQueryWrapperAppointment() .eq(Appointment::getPatientId, patientId) .eq(Appointment::getScheduleId, scheduleId)); if (count 0) { return Result.fail(请勿重复挂号); } // 3. 原子扣减remaining 大于 0 才允许减 Schedule deducted new Schedule(); deducted.setId(scheduleId); deducted.setRemainNum(schedule.getRemainNum() - 1); int rows scheduleMapper.deductRemainNum(scheduleId); if (rows 0) { return Result.fail(号源被其他患者抢先一步); } // 4. 插入预约记录 Appointment appointment new Appointment(); appointment.setPatientId(patientId); appointment.setScheduleId(scheduleId); appointment.setStatus(0); // 0 待就诊1 已完成2 已取消 appointmentMapper.insert(appointment); return Result.success(appointment.getId()); }对应的 XML 更新语句update iddeductRemainNum update schedule set remain_num remain_num - 1 where id #{scheduleId} and remain_num gt; 0 /update这段 SQL 的关键在于where remain_num 0。两个请求同时到达时数据库的 update 操作会串行执行第一个请求把 remain_num 从 5 改成 4第二个请求再执行时条件remain_num 0依然成立但如果是最后一个号源被抢后面的请求就会因为条件为假而影响 0 行返回“抢不到”。这样就不需要在 Java 层加锁也不用担心 synchronized 在集群环境下失效。Transactional保证扣减号源和插入预约记录要么都成功要么都回滚。如果插入失败扣减掉的号源会随着事务回滚恢复不会出现“号没了但没预约上”的脏数据。这里我建议把传播行为保持默认的REQUIRED由 Spring 控制事务边界即可。3.4 唯一索引兜底防止重复预约的最后一道防线应用层的 count 检查在并发下并不是绝对可靠两个请求同时查询 count 都是 0然后同时走到插入步骤逻辑上就会产生两条重复预约。所以在数据库层面必须加唯一索引作为兜底alter table appointment add unique key uk_patient_schedule (patient_id, schedule_id);加了唯一索引后重复插入会直接抛出 DuplicateKeyException。Service 层可以在外层 catch 这个异常并转换成“请勿重复挂号”的业务提示。这个设计要点在答辩时很加分能说清楚应用层检查和数据库约束各自解决什么问题而不是笼统说“我加了判断”。还需要注意逻辑删除字段的坑如果 appointment 表有deleted逻辑删除字段唯一索引要改为(patient_id, schedule_id, deleted)否则同一条记录取消后再次预约会因为逻辑删除前的旧数据导致唯一索引冲突。4. Vue 前端与 .bak 文件恢复从布局组件到预约页面4.1 布局组件之间如何协作项目 src 目录下能看到IndexMain.vue.bak、IndexAsideStatic.vue.bak、IndexHeader.vue.bak、BreadCrumbs.vue.bak这些文件去掉 .bak 后缀后就是系统的主布局。IndexHeader是顶部导航栏展示系统名称和当前登录用户退出登录也在上面IndexAsideStatic是左侧静态菜单把预约挂号、预约记录、个人中心这些路由分组展示BreadCrumbs是面包屑导航根据当前路由动态显示层级。这三个组件组合在IndexMain.vue的模板里形成后台管理系统的经典三栏结构template el-container classlayout-wrapper el-aside width220px IndexAsideStatic :active-menu$route.path / /el-aside el-container el-header IndexHeader :user-nameuserName logouthandleLogout / /el-header el-main BreadCrumbs :itemsbreadcrumbItems / router-view / /el-main /el-container /el-container /templateIndexHeader、IndexAsideStatic、BreadCrumbs三个组件各司其职头部负责用户信息和操作入口侧边栏负责路由跳转面包屑让用户知道当前停留在哪一级页面。预约挂号的表单页作为router-view的嵌套路由渲染在el-main区域里这套结构适合大多数中小管理后台。4.2 预约页面的联动交互与提交预约页面通常放在 views 目录下核心交互是三级联动选科室、选医生、选时段。change 事件驱动下一级数据加载。template el-form :modelquery label-width90px el-form-item label科室 el-select v-modelquery.departmentId changehandleDepartmentChange el-option v-ford in departments :keyd.id :labeld.name :valued.id / /el-select /el-form-item el-form-item label医生 el-select v-modelquery.doctorId changeloadSchedule el-option v-fordoc in doctors :keydoc.id :labeldoc.name doc.title :valuedoc.id / /el-select /el-form-item el-form-item label日期时段 el-select v-modelquery.scheduleId placeholder选择还有余号的排班 el-option v-fors in schedules :keys.id :labels.scheduleDate s.period 余 s.remainNum :values.id :disableds.remainNum 0 / /el-select /el-form-item el-form-item el-button typeprimary :loadingsubmitting clicksubmit {{ submitting ? 提交中... : 立即预约 }} /el-button /el-form-item /el-form /templateexport default { data() { return { query: { departmentId: null, doctorId: null, scheduleId: null }, submitting: false } }, methods: { async handleDepartmentChange() { this.query.doctorId null this.query.scheduleId null const { data } await api.get(/doctor/list, { params: { departmentId: this.query.departmentId } }) this.doctors data }, async submit() { if (!this.query.scheduleId) return this.$message.warning(请选择排班) this.submitting true try { await api.post(/appointment, { scheduleId: this.query.scheduleId }) this.$message.success(预约成功) this.loadSchedule() } finally { this.submitting false } } } }这段代码展示了两个容易被忽略的参数细节el-option的 value 用排班 id 而不是日期字符串因为后端预约接口只需要 scheduleIddisabled绑定remainNum 0让前端在请求后端之前就阻止无效点击。load- schedule成功后重新拉取列表页面上的余号数字才会从 5 变成 4。4.3 项目里的 *.vue.bak 到底是什么项目根目录出现update-password.vue.bak、main.js.bak这类文件通常是开发者在 IDEA 里改代码前手动复制出来的备份。上一版能跑新版本还没调通先留一个.bak作为回退点这是很朴素但有效的备份习惯。恢复方式是把.bak复制回原文件名# 单个文件恢复 cp src/views/update-password.vue.bak src/views/update-password.vue # 批量恢复所有 vue 备份 find . -name *.vue.bak -exec bash -c cp $0 ${0%.bak} {} \;Windows 环境用copy /Y update-password.vue.bak update-password.vue。这些备份文件建议在稳定后统一删除并提交到 git否则会被当成源码打进 jar 包造成无意义的体积膨胀。如果遇到的是 git pull 之后本地改的代码丢失优先看 IDEA 的 Local History找到修改时间点直接 revert这比任何备份文件都来得快。4.4 Ajax 封装与跨域配置项目里把 axios 封装成 api 模块统一设置 baseURL 为/api请求拦截器里附带登录 token响应拦截器统一处理后端返回的code/message/data结构。开发环境跨域通过 vue.config.js 解决module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }devServer.proxy的target是后端服务地址changeOrigin: true让请求头里 Host 字段等于 target 的域名后端在获取来源时不至于校验失败。这里有个容易踩的坑后端接口路径如果已经带了/api前缀前端请求就不能再加一遍否则会出现/api/api/appointment404。建议把前缀统一放在 axios baseURL 里controller 层只写/appointment这种业务路径。5. run.bat / 2-run.bat / build.bat从 IDEA 到 nginx 的部署路径5.1 四个脚本的职责区分项目根目录的 bat 脚本是给不会敲命令的队友准备的快捷方式。按照常见约定我梳理出这些脚本的实际用途并在下面表格里给出对照关系。脚本通常负责的内容说明run.bat启动前端开发服务器npm run serve供本地浏览器调试2-run.bat启动后端服务Tomcat 或java -jar和前端配合使用build.bat构建前端生产包npm run build输出 dist 目录3-build.bat构建后端生产包mvn package或mvn clean package一个典型的 run.bat 内容大致是echo off cd /d %~dp0 call npm install call npm run serve pausecd /d %~dp0表示切换到脚本所在目录避免用户把脚本复制到别处后路径出错。call npm install首次运行时会安装依赖包之后再启动直接注释掉这一行能节省十几秒。后端脚本同理mvn clean package之后把 war 包扔进 Tomcat 的 webapps 目录启动 Tomcat 即可。5.2 后端配置与数据库初始化后端配置集中在 application.properties 里最需要修改的是数据源和端口server.port8080 spring.datasource.urljdbc:mysql://localhost:3306/hospital_register?useUnicodetruecharacterEncodingutf8useSSLfalse spring.datasource.usernameroot spring.datasource.password123456 spring.datasource.driver-class-namecom.mysql.jdbc.Driver mybatis-plus.mapper-locationsclasspath*:mapper/**/*.xmlMySQL 5.7 使用com.mysql.jdbc.Driver如果数据库升级到 MySQL 8驱动要换成com.mysql.cj.jdbc.Driver并且 url 增加serverTimezoneAsia/Shanghai否则会报时区错误。初始化数据表时建议直接导入项目提供的 hospital_register.sql里面包含科室、医生等基础数据省去手动造数的时间。5.3 nginx 部署多个 Web 项目生产环境里前端打包出的 dist 目录并不一定要放在后端服务里。更常见的做法是把前端静态资源交给 nginx后端只在 8080 端口暴露接口。如果一台服务器还要跑其他 Web 项目通过 location 区分路径即可server { listen 80; # 门诊预约挂号系统前端 location /register/ { alias /opt/hospital/web/; try_files $uri $uri/ /register/index.html; } # 预约系统后端接口 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 另一个 Web 项目可以放在其他路径下 location /pacs/ { alias /opt/pacs/web/; try_files $uri $uri/ /pacs/index.html; } }try_files $uri $uri/ /register/index.html是 Vue 路由 history 模式的关键否则刷新子页面会 404。proxy_pass把/api/开头的请求转发到后端服务前端打包时就不要再拼写后端完整地址统一写相对路径/api/xxx即可。6. 并发压测验证与最后几个高频坑6.1 用并发请求验证不超卖项目跑通后最好用实际请求验证号源扣减是否符合预期。最简单的办法是用 curl 配合 xargs 模拟并发# 模拟 30 个并发请求预约同一个 scheduleId seq 30 | xargs -P 30 -I {} curl -s -X POST \ http://localhost:8080/api/appointment?scheduleId101patientId{} \ -H Content-Type: application/json # 查看该排班剩余号数 mysql -uroot -p -e select remain_num from schedule where id101如果排班总数是 10最终remain_num应该为 0且预约记录正好 10 条。多子几次会发现有些请求返回“号源被其他患者抢先”这就是where remain_num 0在起作用。还可以顺手对账一下扣减总数select count(*) from appointment where schedule_id 101; -- 结果应等于 total_num - remain_num6.2 端口、驱动、日期格式三个高频问题第一前端页面打不开大概率是 8081 端口被占用。Windows 下用netstat -ano | findstr 8081查 PID再taskkill /PID xxx /F结束进程。第二启动后端时报ClassNotFound: com.mysql.jdbc.Driver检查 pom 里驱动版本是否匹配 MySQL 5.7。第三预约时间显示为时间戳而不是2024-06-01 10:30在后端配置统一的 Jackson 日期格式或者在前端用 dayjs 格式化。最后留一个对账技巧把 schedule 表加一列expect_remain压测后对比实际余号和总号数差值能快速判断是否有超卖比反复看日志直观得多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价