ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue医院挂号系统全栈实战:从数据库设计到防超卖

SpringBoot+Vue医院挂号系统全栈实战:从数据库设计到防超卖 医院挂号就诊系统这种全栈项目这几年在毕设和课设榜单里几乎是常驻选手。技术选型上绝大多数人最后都会落到同一套组合SpringBoot做后端接口Vue做前端页面Java写业务逻辑MySQL存数据MyBatis操作SQL。这套组合不算最前沿但胜在生态成熟、资料齐全、面试官熟悉作为练手项目性价比很高。不过网上打着“完整源码”旗号的资源很多能把这套系统从数据库设计到前后端联调讲明白的却很少。这篇文章我就把自己做这个项目的完整过程摊开聊从号源并发怎么防超卖到跨域报错怎么排查全部按实操顺序过一遍。适合正在准备毕设或课设的同学也适合想补全栈视野的初级开发。1. 整体设计与技术选型思路1.1 为什么是SpringBootVue而不是JSP或Thymeleaf这个系统本质上是典型的CRUD加业务状态流转项目用户注册登录、科室医生查询、排班展示、挂号退号管理员维护基础数据和查看统计。业务不算复杂但交互联动不少。有人会纠结页面量不大用JSP、Thymeleaf这种模板引擎是不是更省事我的看法是模板引擎渲染那种服务端页面处理“科室切换带动医生列表刷新、医生切换带动排班表刷新”这种联动交互非常别扭往往要写一堆AJAX拼接DOM的代码维护起来很痛苦。前后端分离之后前端用Vue组件化开发这种联动就是天然的响应式数据流选科室、选医生、选排班各用一个下拉框或列表数据用变量绑定change事件里更新数据就行。后端则稳定地出JSON接口两边可以并行开发前端用Mock数据也能先跑起来。部署上后端打jar包前端打包成静态文件丢Nginx或直接塞进SpringBoot的static目录方式灵活。所以这不是为了追新而是贴合项目结构的合理选择。1.2 为什么坚持用MyBatis而不是MyBatis-Plus或JPA现在很多人一上来就上MyBatis-Plus因为BaseMapper自带单表CRUD开发速度快。但医院挂号系统真正耗时间的恰恰是多表关联和动态条件查询按日期范围加科室加医生姓名联合查排班、统计某医生一个月挂号量、查某患者的历史挂号记录。这些场景手写SQL反而更可控SQL长什么样你完全清楚出了问题直接复制到Navicat里跑一遍就知道结果对不对。MyBatis的动态SQL标签where、if、foreach在这种多条件查询里非常好用。比如排班查询日期可选、科室可选、医生可选用where加几个if就搞定了不需要在Java代码里拼SQL字符串。另外用MyBatis还有一个隐性收益逼着你把SQL基本功练扎实。不少用惯了MyBatis-Plus的同学写个三表联查都要想半天这在面试里是很减分的。数据库选型上MySQL建议5.7以上或8.0。新机器我直接推荐8.0驱动用com.mysql.cj.jdbc.Driver连接串记得带上serverTimezoneAsia/Shanghai。这些细节后面配置部分会具体说到。1.3 模块划分先把边界画清楚再动手写代码之前一定要把功能边界理清楚。这个系统我从三个视角拆视角功能清单典型页面患者端注册登录、科室列表、医生列表、排班查询、在线挂号、我的挂号、退号首页、挂号页、我的挂号页医生端查看我的排班、查看已挂号患者列表、标记就诊完成医生工作台管理端科室管理、医生管理、排班管理、挂号统计、公告管理后台管理页一开始就把模块边界划清后面建表、写接口、设计页面都会顺很多。别上来就建表先花半天把功能清单和页面原型理清楚省下的绝对不止半天。2. 数据库设计与核心业务逻辑2.1 核心表结构六张表起步我建了用户表、科室表、医生表、排班表、挂号单表、公告表外加一张管理员表。每张表都带上create_time和update_time建议用DATETIME DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP自动维护省不少事。挂号单表是整个系统的核心直接放设计CREATE TABLE t_registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 患者ID, schedule_id BIGINT NOT NULL COMMENT 排班ID, reg_no VARCHAR(32) NOT NULL COMMENT 挂号单号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待就诊 1已完成 2已取消 3已退号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_reg_no (reg_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;reg_no建议做成唯一索引作为业务单号展示给用户比如“HS20250102001”这种带日期和序号的格式。数据库主键自增不对外暴露对外一律用业务单号这是基本功。排班表里有个非常关键的设计除了医生、科室、日期、上午下午时段这些基础字段一定要有total_num和remain_num两个字段一个放总号源一个放剩余号源。很多人第一反应是“剩余号源嘛查一下挂号单表count不就行了”。但挂号场景读远大于写用户端每次进排班页都要显示剩余号源每次都去count订单表代价太大。单独存一个剩余数字字段查询瞬间完成这就是空间换时间的经典应用。2.2 号源扣减并发问题的缩小版这是整个系统最值得细讲的地方也最容易翻车。我见过很多同学的写法是这样先查排班表判断remain_num 0执行UPDATE扣减插入挂号单问题在哪两个用户同时挂最后一个号时第1步都查到了remain_num 1然后各自执行扣减最后都可能插入挂号单号源就超卖了。真实场景下表现出来就是用户看到有号提交后被告知号源已满数据却已经坏了。解决办法是把“判断”和“扣减”合并成一条原子UPDATE利用MySQL的行锁保证同一时间只有一个请求能真正减掉号源UPDATE t_schedule SET remain_num remain_num - 1 WHERE id #{scheduleId} AND remain_num 0在Java代码里判断影响行数int rows scheduleMapper.decreaseRemain(scheduleId); if (rows 0) { throw new BusinessException(该时段号源已挂完请选择其他时段); }影响行数为0说明要么排班不存在要么号源已经为0。不要再用“先查后改”的老思路这条原子UPDATE就是最简洁的防超卖方案。如果以后订单量真的大到需要引入Redis预扣减和MQ异步落库那也是在这个基础上演进的基本思想一致。2.3 状态流转用数字枚举存状态挂号单的状态我建议用TINYINT数字存不要直接存中文。数字存的好处是查询快、占空间小、改枚举名不影响历史数据。状态之间的跳转不是任意的我梳理成这几个方向待就诊可以退号status 0 → 3退号时必须把remain_num加回来医生确认就诊完成status 0 → 1已取消一般是后台管理员操作status 2已退号不能再退已完成不能再改状态退号操作同样必须放在事务里更新挂号单状态 排班表剩余号源加回两个操作要么都成功要么都失败。我还会在挂号单表加一个cancel_time字段记录退号时间方便后续统计爽约率。这种状态机的思考方式在电商订单系统里同样适用只不过那里状态更多、更复杂但思路是相通的。3. 后端实操SpringBootMyBatis核心实现3.1 项目结构分层要清晰我惯用的分包方式是下面这种简单直接面试被问到也容易讲清楚com.hospital ├── controller // 接收请求参数校验 ├── service // 业务逻辑事务管理 ├── mapper // MyBatis接口 XML ├── entity // 数据库实体 ├── dto // 请求/响应对象 ├── common // Result统一返回、异常、工具类 └── config // 拦截器、跨域等配置不建议把业务逻辑堆在Controller里。挂号、退号这种涉及多表操作和状态流转的地方必须下沉到Service层。Controller只做参数校验和结果封装我统一用了ResultT作为返回结构Data public class ResultT { private Integer code; private String message; private T data; }约定code 200表示成功其他为业务错误码。前端axios拦截器里统一判断code弹提示消息不用每个页面重复写错误处理。3.2 application.yml几个容易踩坑的配置配置看起来简单但有几个点特别容易出问题。我直接贴一份可用的配置spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case不开启的话数据库的remain_num映射不到实体类的remainNum属性上查出来的对象全是null而且不报错排查起来很痛苦。log-impl配置成StdOutImpl控制台会直接打印SQL语句和参数排查询问题效率翻倍。allowPublicKeyRetrievaltrue主要是MySQL 8.0连接时偶发的公钥检索报错加上了省心。3.3 登录鉴权别上Spring Security够用就行课设和毕设级别的项目不建议引入Spring Security全家桶配置复杂度高学习成本大。我自己用的是Token方案逻辑简单清晰面试也好解释登录成功生成UUID或JWT作为Token存在Redis里键是Token值是用户ID设置过期时间前端把Token存到localStorage每次请求带在Authorization头里后端拦截器校验Token放行或返回401拦截器注册方式Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/login, /api/register, /api/department/**); } }密码存储一定要用BCrypt哈希不要用明文或者简单MD5。Spring Security虽然整体没用上但它的BCryptPasswordEncoder类可以单独引入依赖使用一条matches方法就能完成密码校验。这是我反复强调的点密码安全是基本功不是可选项。3.4 挂号核心接口事务和回滚时刻不能忘挂号Service层核心代码Service public class RegistrationService { Transactional(rollbackFor Exception.class) public String register(RegisterRequest req) { // 1. 校验排班是否存在 Schedule schedule scheduleMapper.selectById(req.getScheduleId()); if (schedule null) { throw new BusinessException(排班不存在); } // 2. 原子扣减号源 int rows scheduleMapper.decreaseRemain(req.getScheduleId()); if (rows 0) { throw new BusinessException(号源已挂完); } // 3. 生成挂号单 Registration reg new Registration(); reg.setUserId(req.getUserId()); reg.setScheduleId(req.getScheduleId()); reg.setRegNo(genRegNo()); reg.setStatus(0); registrationMapper.insert(reg); return reg.getRegNo(); } }这里说一个特别容易踩的坑Transactional默认只回滚RuntimeException和Error。如果你的BusinessException继承的是Exception不加rollbackFor Exception.class事务不会回滚扣掉的号源就永远少一个。另一个坑是方法内部调用失效——同一个Service里方法A调方法BB上的Transactional不生效因为走的是this引用而不是代理对象。把事务放在Service的公开方法上从Controller调用不要自己调自己。3.5 动态SQL查询MyBatis的优势窗口排班查询这种多条件场景动态SQL的价值是碾压级的。比如按日期、科室、医生三选多XML里这样写select idselectByCondition resultTypecom.hospital.entity.Schedule SELECT s.*, d.name AS doctor_name, dep.name AS dept_name FROM t_schedule s LEFT JOIN t_doctor d ON s.doctor_id d.id LEFT JOIN t_department dep ON s.dept_id dep.id where if testdate ! null and date ! AND s.schedule_date #{date} /if if testdeptId ! null AND s.dept_id #{deptId} /if if testdoctorId ! null AND s.doctor_id #{doctorId} /if /where ORDER BY s.schedule_date, s.time_slot /selectwhere标签会自动去掉第一个多余的ANDif标签让条件按需拼接。这个写法的好处是参数传几个就查几个不传就全查代码完全不臃肿。统计类查询也同样适合比如按月统计医生挂号量一条GROUP BY加COUNT就完成。4. 前端实操Vue核心页面与联调4.1 环境准备Node版本别乱装前端环境这块看起来简单实际翻车的不少。我建议先装nvm来管理Node版本不要直接装一个最新版就开工。Vue 2的CLI项目用Node 14或16比较稳Vue 3配Vite可以用Node 16以上。装完之后node -v和npm -v先确认版本再执行vue create或npm create vite。项目初始化语言选JavaScript就行这个项目用不上TypeScript别给自己增加额外的类型定义负担。UI组件库Vue 2配Element UIVue 3配Element Plus几行npm install就能引入。表格、表单、弹窗、消息提示都有现成组件开发速度快得多。4.2 axios封装与开发环境跨域axios一定要统一封装不然每个页面都直接this.$http.get后期改baseURL和加Token会改到崩溃。我惯用的封装import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) service.interceptors.response.use( response response.data, error { ElMessage.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } ) export default service跨域是前后端分离联调第一道坎几乎每个人都会遇到。前端跑在8080后端跑在8081浏览器直接请求后端接口就会被同源策略拦下来。开发环境最省事的方案是配devServer代理让请求走开发服务器转发// vue.config.js module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }这样前端页面请求/api/xxx时devServer会转发到后端的8081端口浏览器看到的始终是同源请求跨域问题直接消掉。这个方案的好处是不用动后端代码开发环境和生产环境逻辑分离生产环境用Nginx做同样的事。4.3 挂号页面的三级联动挂号页面是前端最核心的交互场景选科室 → 选医生 → 选排班时段三个层级逐级联动。实现方式不复杂核心是监听上级下拉框的change事件清空下级数据再请求新数据。举一个简化的例子const handleDeptChange async (deptId) { doctorList.value [] scheduleList.value [] if (!deptId) return const res await getDoctorsByDept(deptId) doctorList.value res.data } const handleDoctorChange async (doctorId) { scheduleList.value [] if (!doctorId) return const res await getSchedulesByDoctor(doctorId) scheduleList.value res.data }有几个实用经验值得分享。第一号源数据不要只查一次就不管了用户停留在页面的时候号源可能已经被别人挂走。我建议在用户点了“剩余号源”或准备提交时再调一次接口刷新剩余数量为0就直接禁用提交按钮并提示换时段。第二提交前加二次确认弹窗避免用户误点。第三挂号成功之后不要急着跳转先展示挂号单号让用户截图或记下来这个体验细节很加分。4.4 我的挂号页面状态展示“我的挂号”是患者端的高频页面展示列表、状态筛选、退号操作都在这。状态我用el-tag组件配合不同颜色展示待就诊是蓝色已完成是绿色已退号是灰色已取消是黄色。退号按钮只有待就诊状态才显示点击后弹出确认框明确提示“退号后号源会重新放出请谨慎操作”。接口调用失败时要保留原状态不要让前端先把状态改成已退号等后端确认成功再更新UI这是前后端状态同步的基本原则。5. 常见问题与排查技巧实录5.1 跨域报错的排查顺序如果看到浏览器控制台报这个Access to XMLHttpRequest at http://localhost:8081/api/xxx from origin http://localhost:8080 has been blocked by CORS policy先别慌按顺序排查。第一步确认是不是开发环境如果是直接用devServer代理根本不需要后端处理。第二步看代理是否生效打开浏览器Network看请求URL是不是还是8081如果是说明代理没配上或没重启devServer。第三步如果确实要跨域直接请求后端可以用全局CorsFilter或CrossOrigin临时解决但生产环境不建议这样放开。5.2 MyBatis的经典“坑”清单映射问题排第一。实体类是remainNum数据库是remain_num不开map-underscore-to-camel-case查出来全是null不报错就是结果不对。动态SQL里字符串判空if testname ! null and name ! 别只判null空串会把你坑到怀疑人生。#{}和${}的区别也要搞清楚#{}是预编译占位符安全${}是字符串拼接有SQL注入风险。排序字段这种动态拼SQL的场景用${}要自己过滤白名单不要直接接用户输入。5.3 MySQL连接与中文乱码连接报错“Public Key Retrieval is not allowed”在URL后面加allowPublicKeyRetrievaltrue。中文乱码检查三处建表字符集用utf8mb4连接串带characterEncodingutf8页面和数据库统一UTF-8。端口占用Windows用netstat -ano | findstr 3306Mac或Linux用lsof -i:3306找到进程ID之后按实际情况处理。5.4 常见问题速查表我把这个项目最常踩的问题整理成一个表方便对照问题现象可能原因解决方案跨域请求被拦截浏览器同源策略开发环境用devServer代理生产用Nginx反向代理实体类属性全是null没开驼峰映射配置map-underscore-to-camel-case: true事务不回滚号源被扣没业务异常继承Exception未指定回滚Transactional(rollbackFor Exception.class)同一个类方法内事务不生效走this调用代理对象未介入拆到不同Service或从外部调用时间字段相差8小时连接串缺时区URL加serverTimezoneAsia/Shanghai控制台不打印SQL没配置日志实现MyBatis配置log-impl: StdOutImplMySQL 8连接报公钥检索错误驱动安全策略URL加allowPublicKeyRetrievaltrue这些坑每个我都踩过有的当时排查到凌晨现在回头看都是配置或规范问题。项目做完之后我最大的体会是这类全栈项目真正的价值不在于挂号这个业务本身而在于它用最小的成本把后端开发高频遇到的几类问题完整串了一遍。并发扣减、状态流转、动态查询、跨域联调、事务控制每一项都是真实业务系统里的核心难点。做完这个项目再去看电商订单、预约平台这类系统会发现套路都是相通的。最后分享一个小经验如果时间允许给排班查询加一层Redis缓存把科室列表和热门医生的排班缓存起来页面加载速度会提升一个档次面试聊到性能优化时这也是个能说两分钟的加分点。
返回列表