
医院挂号就诊这种系统说难不难说简单也不简单。核心就三件事挂得上号、看得上病、管得好排班。但要真正落地牵扯到的细节远不止这几个业务动作。今天分享的这个基于SpringBootVue3MyBatis的前后端分离实现就是从预约、就诊到管理一条线打通。简单说下这是干什么的患者端可以注册、挂专家号、查号、取消预约医生端可以做接诊登记、查看患者病历、开检查单管理端就是医院信息科或者门诊部常用的那个后台负责排班管理、号源配置、挂号记录统计。适合的对象一个是想系统学习前后端分离实战的Java开发者一个是真的有这类项目需求、想快速搭一套可演示原型的团队。我打算分几块来拆先讲整体设计和为什么这么选型再逐个功能模块拆需求然后重点讲数据库和MyBatis这一层的实战细节最后聊聊前后端联调时踩过的坑和一些排查经验。1. 项目整体设计与技术选型前后端分离这件事在2026年已经不是要不要选的问题而是团队协作模式的默认前提。医院挂号这种系统页面多、角色杂、业务状态多如果还是JSP那种服务端渲染后期改需求会非常痛苦。前后端分离之后后端专注提供API前端专注交互两边各自可以独立迭代排班规则改了不影响页面页面样式改了不动后端接口。1.1 为什么选择前后端分离架构拿挂号这个场景举例子。每天早上八点整是号源释放的高峰患者端会频繁刷新医生排班列表、点击预约按钮、查询剩余号源。这些操作如果都由后端模板渲染返回每一次刷新都是一次完整的服务端渲染压力还得额外承担网络带宽消耗——HTML、CSS、JS全部重新加载。而Vue3作为单页应用SPA首屏加载一次之后后续的号源刷新只是通过接口拉取JSON数据数据量小、响应快体验上差距非常明显。同时医院这类项目通常有多个入口患者可能在微信公众号、自助机、电脑网页多个端挂同一个号。如果采用前后端分离后端API可以同时给手机端、网页端复用不需要针对每个端单独写一套服务端逻辑。这个复用价值在实际项目里非常关键因为患者端的迭代频率往往远高于后台管理端分离之后两个团队可以异步推进。1.2 技术栈选型的底层逻辑SpringBoot选型的理由很直接约定大于配置。医疗类项目对事务、并发、权限的要求很高Spring全家桶在事务管理和Spring Security权限控制上非常成熟踩坑资料也多。Vue3的话Composition API在管理大型组件和复用逻辑上比Options API更干净比如排班日历组件、挂号表单组件这种逻辑复杂且复用性强的模块用setup函数加组合式函数代码组织起来会舒服很多。MyBatis在医院这种以查询统计为主的系统里比JPA更合适。挂号系统里SQL的复杂度通常不在单表CRUD而在“剩余号源数统计”“医生排班时间段查询”“科室工作量统计”这种多表联查。MyBatis让你把SQL牢牢抓在手里慢查询了可以精准优化而不需要去猜JPA底层到底帮你怎么组织的SQL。MySQL就不多说了开源免费存储容量对这种业务量完全够用。需要注意的一个点是MySQL的InnoDB引擎默认支持行级锁在挂号并发扣减号源场景下配合事务机制是可以保证并发安全的基础。2. 系统核心功能模块拆解挂号就诊系统核心角色有三类患者、医生、管理员。所有功能模块都是围绕这三类角色展开的。2.1 患者端设计挂号、查询、缴费流程患者端的核心流程就是“选科室-找医生-看排班-约号-预约成功”。这个流程看起来简单但每一步都有细节。第一步是科室列表。这里不只是一个静态列表需要考虑科室的分类层级比如内科下面还有心血管内科、消化内科这种二级科室。我们数据库里设计了一张t_dept表parent_id字段指向父级科室前端用递归组件渲染出科室树。第二步是医生列表。患者选择科室之后需要展示该科室下的医生及其简介、职称、擅长方向。这里我建议在医生基本信息中冗余一个科室ID字段而不是每次通过关联查询获取因为首页的检索频率太高多一次join就多一次延迟。第三步是排班日历查询。这个部分是整个系统交互最频繁的模块。医生有一周内的排班计划每天可能分为上午、下午、晚上三个时段每个时段有固定号源总数和一个已挂数量。前端展示排班卡片时调用的接口是查询某医生在日期范围内的排班同时返回当天还可以挂的号。第四步就是真正的挂号操作。这个动作在数据库层面是一个事务操作先查剩余号源确认大于0然后扣减号源插入挂号记录。这里有个并发问题两个人同时抢最后一个号怎么处理我会在后面数据库章节详细讲。挂完号之后患者可以在“我的预约”里看到挂号记录和候诊信息也可以取消预约。取消预约释放号源这个操作要和前面的挂号一样注意并发一致性不然会出现释放后号源变多的诡异现象。2.2 医生端设计接诊、开单、检查流程医生端相对患者端来说简单一些因为核心操作集中在“看患者”和“做记录”。医生登录后能看到当前排班时段下已挂号的病人列表按取号顺序排列。点击一个患者进入接诊页面可以看到患者基本信息、历史就诊记录、历史诊断结果。接诊页面里医生要做的几件事录入主诉、录入诊断结果、开检查单或开处方。这里的体验细节也很关键如果系统响应慢医生会直接抱怨所以这一段的接口查询要特别精简一次请求尽量把患者信息、历史记录都带回来减少医生端的等待。医生端的另一个重要模块是排班申请或排班确认。有的医院排班是管理员统一排医生只能看“我的排班”页面查询即可有的医院需要医生自己提交可出诊时间然后管理员审核我们系统默认是管理员统排。2.3 管理员端设计排班、统计、配置管理管理员端是整个系统复杂度最高的部分因为它要管理的不只是数据还有规则。排班管理是核心中的核心。管理员为每个医生按周配置排班计划每个时段设置号源总数。这个功能的前端是一个日历组件可切换周每个格子可以设置时段和号源数数据提交后一次性保存一周的排班配置。统计报表这一块常用的几个维度按日期的挂号量统计、按科室的挂号量统计、按医生的工作量统计、取消率统计。这些统计数据的获取我在MyBatis里用了大量group by和日期函数在后面SQL部分会详细展示。管理员还有一个重要职责是基础数据维护比如科室增删改、医生账号开通、系统公告发布。这些实际上就是标准的CRUD但在权限控制上要注意管理员只能操作自己范围内的数据不能越权。3. 数据库设计与MyBatis持久层实现这一部分是干货最密集的地方我逐层拆解。3.1 核心表结构设计先列出关键表然后逐个解释设计逻辑。第一张是用户表t_user。字段包括id、username、password、real_name、phone、id_card、role、create_time等。role字段用int区分1患者、2医生、3管理员。需要注意password肯定不能存明文我用的是BCrypt加密Spring Security框架里自带这个加密器不需要额外引入其他密码加密库。第二张是医生信息表t_doctor。字段有id、user_id、dept_id、title、intro、good_at等。这张表的设计关键是doctor_id和user_id的关联user表管登录账号doctor表管医生的专业信息两者通过user_id外键关联。为什么要分开因为登录认证和业务信息是两回事登录不关心医生的职称开单不关心登录密码。第三张是科室表t_dept。字段为id、parent_id、dept_name、sort_order等。parent_id为0代表顶级科室不为0则是子科室。第四张是排班表t_schedule。字段为id、doctor_id、schedule_date、period、total_number、remain_number、version。period字段的取值很简单0上午、1下午、2晚上。这个表是整个系统的并发控制核心我特意加了version字段做乐观锁。第五张是挂号订单表t_registration。字段为id、patient_id、doctor_id、schedule_id、reg_date、period、status、create_time。status字段区分0已预约、1已就诊、2已取消、3已退号。还有一张就诊记录表t_medical_record用于存储医生接诊录入的信息。字段为id、registration_id、doctor_id、patient_id、chief_complaint、diagnosis、prescription、create_time。3.2 数据库设计时要避开的坑这里我要特别提醒几个数据库设计时的坑都是我实际踩过的。第一不要在排班表里“剩余号源数”之外再加一个“已预约数”这样会产生数据不一致的风险。我们的方案是只存total_number和remain_number挂号时扣减remain_number取消时回补remain_number。所有统计报表需要已约数时用total_number - remain_number计算得出避免两处维护同一份数据。第二挂号记录表一定要加reg_date和period字段的联合索引因为患者查“我的预约”和医生查“接诊列表”都是按时间范围加时段筛选的。这个索引不加数据量到了几万条之后会明显变慢。第三排班表添加schedule_date和doctor_id的唯一索引保证同一个医生同一天不能有重复的排班配置。有了唯一索引后端代码里就不用再做一次“查重”逻辑直接靠数据库拦截报异常再提示前端。第四t_registration表的status字段建议加索引因为统计取消率、查待就诊列表这些业务都以status为核心过滤条件。3.3 MyBatis XML配置与SQL写法实战MyBatis的核心用法是XML映射文件。先展示一个排班查询的核心SQL。针对“查询某医生在日期范围内的排班及剩余号源”这个需求SQL写起来并不复杂SELECT id, doctor_id, schedule_date, period, total_number, remain_number FROM t_schedule WHERE doctor_id #{doctorId} AND schedule_date BETWEEN #{startDate} AND #{endDate} ORDER BY schedule_date, period这里有一个性能优化点注意需要软删除字段。假设我们后续要支持停诊操作可以增加status字段标记但已生成的排班记录不建议物理删除因为挂号记录表里有外键关联。物理删除排班会直接破坏数据完整性。再看挂号扣减号源这个核心操作这也是并发安全的关键。如果直接UPDATE排班表的remain_number在并发环境下会丢更新。我用的方案是乐观锁先查询出当前版本号version然后执行UPDATE t_schedule SET remain_number remain_number - 1, version version 1 WHERE id #{scheduleId} AND remain_number 0 AND version #{version}这里几个关键写法remain_number 0这个条件在SQL层面就挡了越界无论如何都不会扣成负数。version #{version}是乐观锁的实现如果其他线程已经改过这条记录version不匹配影响行数为0我们就可以判断此次挂号冲突需要重试或给前端提示。在Service层我配合Transactional注解把这个UPDATE放在事务里。即使两个请求同时进入数据库行锁会串行化这两个UPDATE后一个会因为version不匹配或remain_number条件不满足而失败。有人说那直接用悲观锁加SELECT ... FOR UPDATE不也一样吗一样当然一样但对于高并发挂号场景乐观锁在正常低竞争的时段性能更好不需要长时间持有行锁更符合一次挂号操作毫秒级完成的实际场景。再展示一个统计报表的SQL这里使用了日期函数和case when组合SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS reg_date, COUNT(*) AS total_count, SUM(CASE WHEN status 0 THEN 1 ELSE 0 END) AS booked_count, SUM(CASE WHEN status 2 THEN 1 ELSE 0 END) AS canceled_count FROM t_registration WHERE reg_date BETWEEN #{startDate} AND #{endDate} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY reg_date注意GROUP BY的字段和SELECT字段要严格一致。MySQL在only_full_group_by模式下如果select和group by不一致会直接报错。很多初学者在这里栽过跟头。MyBatis的XML文件里还有一个容易被忽视的细节和符号在XML里不能直接用必须转义为lt;和gt;。很多新人第一次写范围查询时被这个卡住报语法错误半天找不到原因。当然也可以选择用![CDATA[]]包住整段SQL但注意CDATA内的内容不会被XML解析器处理如果有if这类动态标签就会嵌套出错建议简单SQL直接转义符号复杂动态SQL再用CDATA。3.4 MyBatis缓存机制在挂号系统中的实际应用提到MyBatis就绕不开缓存。MyBatis默认打开一级缓存SqlSession级别的本地缓存二级缓存需要手动配置。但在挂号系统里我要特别提醒排班和号源相关的查询不能开启二级缓存。为什么因为号源是一个随时变动的高频数据如果二级缓存里存了静态的排班结果当另一个线程完成挂号扣减了号源缓存还是会返回旧数据导致前端显示有余号但实际已经挂完。这属于典型的缓存一致性问题。比较好的实践是科室列表、医生简介这类几乎不变的数据可以开启二级缓存减少数据库压力而排班号源查询每次都走数据库直查保证数据实时准确。启动类上的EnableCaching以及Mapper方法上的Cacheable在这里都能帮到忙但业务上要灵活选择。4. 前后端联调与部署细节完成数据库设计和后端基本CRUD之后真正让系统从“能跑”变成“好用”的是前后端的配合。这里我主要讲讲Vue3和SpringBoot联调时几个容易踩的坑。4.1 Vue3的接口封装与状态管理Vue3中请求后端接口我用的是Axios。这里最重要的不是怎么发请求而是如何统一处理token和错误码。我习惯在src/utils/request.js中封装一个axios实例import axios from axios import { ElMessage } from element-plus import { getToken, removeToken } from ./auth const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 401) { removeToken() window.location.href /login return Promise.reject(new Error(未授权)) } if (res.code ! 200) { ElMessage.error(res.message || 系统异常) return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request这样统一拦截的好处是前端业务代码里只需要关心拿到数据后怎么用不需要在每个接口调用处重复写错误提示和状态判断。token过期时自动跳登录也省得各处散落登录判断逻辑。状态管理方面如果项目不是特别复杂我建议用Pinia而不是Vuex。Pinia更简洁去掉了Vuex的mutation概念直接改state。比如用户信息、当前登录角色、医院公告这些全局数据放Pinia里管理就够用import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ userId: , role: , realName: , token: }), actions: { setUserInfo(info) { this.userId info.userId this.role info.role this.realName info.realName } } })4.2 跨域问题与统一返回结构前后端分离项目最常见的第一个联调问题就是跨域。本地开发环境中Vue3跑在8080端口SpringBoot跑在8081端口直接请求就会遇到跨域。最简单的解决方案是在SpringBoot里配置跨域映射Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这里有一个细节如果启用了Spring SecurityCORS配置和Security的过滤器链顺序也很关键。在Spring Security的配置类里要把.cors()启用否则CORS配置可能不会生效。另外setAllowCredentials(true)和addAllowedOrigin(http://localhost:8080)是配套的如果用通配符*加Credentials会报错。所以我用的addAllowedOriginPattern(*)是为了在允许Credentials的同时支持任意来源如果是生产环境建议还是指定具体域名。为了前端统计和管理接口统一使用一个Result类进行返回结构。这里给出最简单的版本Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }前端Axios响应拦截器里面判断res.code ! 200就是拿这个code做的判断。这个统一结构的价值在于前端封装可以高度自动化不需要每个后端接口单独写一遍状态处理逻辑。4.3 Vue3打包后与SpringBoot的部署方案部署时有一个常见的做法把Vue3构建后的dist目录放到SpringBoot的静态资源目录下实现同一个端口访问。这个方案的好处是省去Nginx配置内网演示或中小型项目足够用。具体操作执行npm run build后把生成的dist文件夹内容拷贝到SpringBoot项目的src/main/resources/static目录下然后通过Controller做前端路由的history模式转发。Vue3的history模式路由刷新页面时如果直接命中后端路径会404需要配置一个路由转发Controller public class PageController { RequestMapping(value /{path:[^\\.]*}) public String forward() { return forward:/index.html; } }这个正则[^\\.]*匹配不包含点号的路径避免拦截到静态资源如.js、.css。这里是个小细节但忘记配置会导致上线后用户一刷新页面就整个白屏。生产环境更推荐的还是用Nginx单独部署前端静态资源后端服务独立一个端口。Nginx配置大致如下server { listen 80; server_name your-domain; location / { root /var/www/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8081/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里try_files的作用就是解决history路由刷新404的问题。Nginx把所有非静态文件的请求都转发到index.html由前端路由接管。/api/开头的接口则反向代理到后端的SpringBoot服务这个配置能跑很多年非常稳定。5. 常见问题与排查技巧实录这部分内容是我在开发和运维过程中实际踩过的一些坑整理成速查表希望能帮后来者省时间。5.1 并发环境下挂号超卖问题上面讲了用乐观锁控制排班表的remain_number但这里补充一个最容易忽略的问题乐观锁的冲突处理。当UPDATE影响行数为0时说明有人抢先一步此时服务端应当捕获这个结果并立即给前端一个明确提示“当前号源已被抢完或系统繁忙请重新尝试”。而不是让前端再傻傻地发一次请求。我还见过有人在这个场景下用“先查后插”的逻辑先SELECT判断remain_number 0再插挂号记录最后再UPDATE扣减。这个顺序在单机低并发下没问题但在并发高时会存在典型的竞态两个线程都通过了SELECT判断然后同时插入记录和更新最终号源超卖。所以一定要用数据库层面的原子操作UPDATE ... WHERE remain_number 0把并发安全托底给数据库行锁。5.2 数据库连接池参数设置SpringBoot MyBatis默认使用HikariCP连接池。很多人对这个连接池不设置参数直接跑小项目没事但挂号系统在早高峰并发抢号时连接池打满会出现连接获取超时。我建议针对挂号场景做如下配置spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000这里核心是maximum-pool-size和connection-timeout。连接池不是越大越好每个连接都占用数据库内存和CPU资源过大反而拖垮数据库。我根据实际压测经验20个连接对这个量级完全够了。connection-timeout设成3000毫秒超过3秒拿不到连接前端就返回超时避免患者长时间等待转圈。5.3 前端路由权限控制后台管理系统的前端还有一个常见问题路由权限。医院管理系统里的医生端和管理员端如果一起打包部署那么普通患者就能直接输入/admin路径访问管理页面。这个问题前端后端都要堵。后端拦截Spring Boot里配合Spring Security给接口加权限注解例如PreAuthorize(hasRole(ADMIN))保证越权操作接口返回403。前端拦截在Vue Router中用路由守卫配合Pinia里的用户role信息判断访问权限清除无效路由缓存。实践经验是如果前端只做隐藏按钮而不做路由拦截照样会被会F12打开控制台的人绕过。所以前端路由守卫只能防君子真正的权限底线必须交给后端接口鉴权。5.4 MyBatis常见的两个警告第一个是Invalid bound statement (not found)。这个报错绝大多数情况是Mapper接口和Mapper XML文件没有对应上。检查几个点Mapper接口的Mapper注解是否加了application.yml里的mapper-locations路径是否指向了classpath:mapper/*.xmlXML里面的namespace是否和接口全限定名一致。第二个是“慢SQL”。我在测试阶段会通过日志打印SQL和查询时间排查慢查询。可以在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样控制台会打印自动生成的SQL排查问题时能直观看到慢SQL是不是因为缺索引是不是有隐式类型转换导致索引失效。比如在SQL里日期字段和字符串比较时MySQL可能隐式转换导致索引失效。项目上线后不建议开启SQL日志打印会泄露表结构信息且严重影响性能。5.5 前端打包常见问题Vue3打包有个常见配置错误publicPath没有设置。当构建后的静态资源引用路径是绝对路径/js/app.js部署到子目录时全部404。如果在子目录部署要设置base配置Vite的写法是在vite.config.js里设置base: ./这样引用的资源会变成相对路径部署时灵活很多。另一个坑是接口地址写死。开发时把baseURL写成http://localhost:8081打包上线后还是没有改结果部署服务器之后前端请求全打本机。我建议用环境变量区分// .env.development VITE_API_BASE_URL /api // .env.production VITE_API_BASE_URL /api配合Nginx把/api代理到后端这就实现了前端代码一份开发生产通用。或者用import.meta.env.MODE判断环境然后区分不同域名原理也一样关键是别在代码里写死。6. 系统扩展方向与演进思考说完了实战层面最后聊聊这个系统后续可以怎么演进。目前我们实现的是单店单院区的挂号模式。如果医院有多个院区就可以在排班表、科室表上增加院区维度campus_id同时挂号订单增加院区字段就能快速支持多院区统一挂号。如果要做更复杂的号源策略比如不同号源等级专家号、普通号、不同价格体系可以在排班表上增加号源类型字段挂号时按号源类型扣减。这类演进的需求通常会从“支付对接”开始比如接入医院的HIS系统、对接微信支付宝支付流程这些就需要在挂号状态流转上增加“已支付”“待支付”“已退费”等状态。技术上还有一个演进方向是接口缓存。目前我们为了避免数据不一致没有启用排班的二级缓存但可以在接口层做更精细化的缓存设计。比如用Redis缓存科室列表和医生简介排班表核心数据实时查询这样在并发高峰能显著降低数据库压力。前端也可以把排班数据做本地缓存比如在页面切换时用keep-alive缓存上一页的请求结果避免重复请求。在部署架构上如果访问量继续上升可以从单机部署逐步演进到前后端分离部署加Redis集群加MySQL主从。不过对于大部分中小型医院的挂号需求现有这套架构已经足够稳定关键是先把基础做好SQL索引设计合理、缓存使用得当、权限控制到位系统的扩展能力自然就有了。回到最初说的医院挂号就诊系统看上去是“几个页面加几张表”但真把一个项目从零搭到可交付涉及的地方非常零散希望这篇文章能帮读者省去一些试错成本。我个人在实际操作中最大的体会是前后端分离虽然增加了联调成本但换来的开发效率和后期维护便利是值得的。排班和号源的并发处理永远是这类系统最核心、最容易出问题的点建议读者在自研时一定要在数据库设计阶段就考虑好。先码到这里如果后续你也在做类似的系统欢迎随时交流。