☰
口腔诊所预约管理系统:Java+Springboot+Vue前后端分离实战与避坑指南
2026/10/7 3:17:55 网站建设 项目流程

简介:这是一套面向高校学生与医疗信息化开发者的口腔牙科诊所预约管理系统源码,采用Java、Springboot与Vue技术栈实现前后端分离架构,可作为毕业设计、课程设计或诊所管理工具的实践参考。压缩包共393个文件,约10.41MB,其中83个Java源文件承载后端业务逻辑,40个Vue组件与24个TypeScript脚本、22个JavaScript文件构建前端交互界面,另有126张JPEG与23张PNG图片用于视觉展示,辅以XML配置、SQL脚本、Markdown说明及字体资源,目录划分清晰。项目围绕在线预约、资料管理与服务预约等核心场景展开,后端基于Springboot完成接口与权限控制,前端借助Vue组件化实现动态页面,并配有代码说明与表结构文档,便于理解整体设计思路与二次开发。目前已有486人学习下载,适合希望掌握前后端分离实战、积累医疗管理系统开发经验的学习者参考借鉴。

1. 口腔诊所排班这件事,为什么用 Java+Springboot+Vue 做前后端分离更省心

很多牙科诊所的预约还停留在纸质登记本或者微信群接龙,前台一忙就容易出现同一台牙椅被两个患者同时约上的尴尬。口腔诊所和综合医院不同,它的核心资源不是床位而是牙椅和医生的时间段,一颗种植牙可能要占掉两三个小时,复诊又要卡在特定周期上,排班逻辑比普通门诊复杂得多。基于 Java+Springboot+Vue 的口腔牙科诊所预约管理系统,本质上是把「医生—牙椅—时间段—患者」这四者的占用关系用数据库锁死,再用前后端分离的方式让前台、医生、患者各看各的界面。这套方案适合中小型诊所自建,也适合作为前后端分离项目实战的练手题材,因为业务边界清晰、表结构不复杂,但排班冲突、并发预约这些坑一个都不少。

2. 拆解口腔预约的业务模型:从牙椅占用到时间段冲突

2.1 为什么口腔诊所不能照搬普通门诊的挂号模型

普通门诊挂号是「医生+上午/下午」这种粗粒度,一个医生一上午看几十个号,谁先谁后无所谓。口腔诊所不行,一颗根管治疗要连续占用牙椅 60 到 90 分钟,正畸复诊要精确到具体时间点,而且同一个医生同一时间只能在一个牙椅旁操作。这意味着预约的最小单位不是「号」,而是「牙椅 + 医生 + 起止时间」的三元组。设计表结构时,如果只建一张 appointment 表存 patient_id 和 doctor_id,迟早会遇到「同一个医生被约到两个牙椅」的问题。常见做法是引入 chair(牙椅)和 schedule(排班时段)两张表,appointment 表通过 schedule_id 关联,schedule 表里预先锁定 doctor_id 和 chair_id 的组合,预约时只允许在空闲的 schedule 记录上创建 appointment。

2.2 核心表结构与字段设计

下面这套表结构是我在几个诊所项目里反复调整后比较稳的版本,字段不多但每个都有存在的理由。

-- 牙椅表:诊所的物理资源 CREATE TABLE chair ( id BIGINT PRIMARY KEY AUTO_INCREMENT, chair_no VARCHAR(16) NOT NULL COMMENT '牙椅编号,如 A01', room_name VARCHAR(32) COMMENT '诊室名称', status TINYINT DEFAULT 1 COMMENT '1可用 0停用' ); -- 医生排班表:把医生和牙椅在某个时间段绑定 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, chair_id BIGINT NOT NULL, work_date DATE NOT NULL COMMENT '排班日期', start_time TIME NOT NULL, end_time TIME NOT NULL, status TINYINT DEFAULT 0 COMMENT '0空闲 1已约 2锁定', UNIQUE KEY uk_doctor_time (doctor_id, work_date, start_time), UNIQUE KEY uk_chair_time (chair_id, work_date, start_time) ); -- 预约记录表 CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, patient_id BIGINT NOT NULL, treat_type VARCHAR(32) COMMENT '治疗类型:洗牙/根管/种植', status TINYINT DEFAULT 0 COMMENT '0待确认 1已确认 2已完成 3已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_schedule (schedule_id) );

逻辑说明:schedule 表上的两个唯一索引是关键,uk_doctor_time 保证同一医生同一时间不会出现在两个牙椅,uk_chair_time 保证同一牙椅同一时间不会被两个医生占用。appointment 表的 uk_schedule 唯一索引则从数据库层面杜绝了同一时段被重复预约。参数上,start_time 和 end_time 用 TIME 类型而不是 DATETIME,是因为排班是按天生成的,日期维度放在 work_date 里,这样查询某天某医生的空闲时段只需要一个 WHERE work_date = ? AND doctor_id = ? 就能命中索引。

2.3 时间段粒度怎么定

粒度太细(比如 15 分钟一格)会导致 schedule 表数据量膨胀,一个医生一天 8 小时就是 32 条记录,十个医生一个月接近一万条。粒度太粗(比如 2 小时一格)又没法适配洗牙这种短项目。我一般按治疗类型反推:洗牙 30 分钟、补牙 45 分钟、根管 90 分钟、种植 180 分钟,取最大公约数 30 分钟作为基础粒度,生成排班时按 30 分钟一条批量插入,预约时根据 treat_type 占用连续的 N 条 schedule。这样既不会数据爆炸,又能灵活组合。

3. Springboot 后端:预约接口与并发冲突处理

3.1 项目骨架与依赖选择

后端用 Springboot 搭,版本不要盲目追新。热搜里常有人问「springboot版本太高」怎么办,我的血泪经验是:如果团队里有人还在用 JDK 8,就老老实实选 Springboot 2.7.x,它对 MyBatis、Druid 这些老牌库的兼容性最稳。JDK 17 起步的团队可以上 3.x,但要注意 3.x 把 javax 换成了 jakarta,老代码迁移时包名要全局替换。依赖上核心就四个:spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

参数说明:mybatis-spring-boot-starter 的版本要和 Springboot 主版本匹配,2.3.x 对应 Springboot 2.7,3.x 对应 Springboot 3.x。mysql-connector-j 是 MySQL 8 之后的官方驱动名,老项目里看到的 mysql-connector-java 已经改名了,别混用。

3.2 预约接口的并发控制

预约接口最怕的是两个人同时点「确认预约」,如果只靠先查后插,中间那几毫秒就会产生重复预约。常见做法有三种:数据库唯一索引兜底、悲观锁 SELECT ... FOR UPDATE、乐观锁版本号。我一般用唯一索引加捕获异常的组合,简单可靠。

@Transactional(rollbackFor = Exception.class) public Result createAppointment(Long scheduleId, Long patientId, String treatType) { // 先查排班是否空闲 Schedule schedule = scheduleMapper.selectById(scheduleId); if (schedule == null || schedule.getStatus() != 0) { return Result.fail("该时段已被预约"); } // 更新排班状态,利用数据库行锁 int updated = scheduleMapper.updateStatus(scheduleId, 1); if (updated == 0) { return Result.fail("手慢了,该时段刚被约走"); } // 插入预约记录,uk_schedule 唯一索引兜底 try { Appointment appt = new Appointment(); appt.setScheduleId(scheduleId); appt.setPatientId(patientId); appt.setTreatType(treatType); appointmentMapper.insert(appt); } catch (DuplicateKeyException e) { throw new BizException("重复预约"); } return Result.ok(); }

逻辑说明:updateStatus 的 SQL 写成 UPDATE schedule SET status = 1 WHERE id = ? AND status = 0,靠 MySQL 的行锁保证只有一个事务能更新成功,返回影响行数为 0 就说明被别人抢先了。参数上,@Transactional 的 rollbackFor 要显式写 Exception.class,否则遇到 DuplicateKeyException 这种运行时异常虽然会回滚,但遇到受检异常不会,容易埋雷。

3.3 排班查询接口与缓存

前台打开预约页面时要实时看到哪些时段空闲,这个查询频率很高但数据变化不快,适合加一层缓存。我一般用 Spring Cache 加 Redis,key 按 doctor_id + work_date 拼。

@Cacheable(value = "schedule", key = "#doctorId + ':' + #workDate") public List<ScheduleVO> listFreeSlots(Long doctorId, String workDate) { return scheduleMapper.selectFreeByDoctorAndDate(doctorId, workDate); }

参数说明:@Cacheable 的 key 用 SpEL 表达式拼接,注意 workDate 传进来是 String 类型,如果传 Date 要加 #workDate.time 否则 toString 结果不稳定。缓存过期时间在配置文件里设 5 分钟,因为排班变动不频繁,5 分钟延迟可以接受。预约成功后要主动 evict 掉对应 key,否则前台看到的还是旧数据。

4. Vue 前端:预约日历与动态路由的落地细节

4.1 预约日历组件的选型与数据绑定

前端用 Vue 3 加 Element Plus,日历部分不建议自己从零写,用 el-calendar 或者第三方日历组件都行。核心是把后端返回的 schedule 列表映射成日历上每一天的可用状态。

// 获取某医生某月的排班,按日期分组 async function loadMonthSchedule(doctorId, month) { const res = await axios.get('/api/schedule/month', { params: { doctorId, month } }) // res.data 是 [{workDate: '2025-06-01', freeCount: 3}, ...] const map = {} res.data.forEach(item => { map[item.workDate] = item.freeCount }) return map }

逻辑说明:后端返回的是按天聚合的空闲数量,前端用对象做映射,日历渲染时根据 freeCount 决定当天显示绿色(有空)还是灰色(约满)。参数上,month 传 '2025-06' 这种格式,后端用 DATE_FORMAT(work_date, '%Y-%m') 匹配。注意时区问题,如果服务器是 UTC 而诊所在东八区,work_date 可能差一天,建议数据库连接串里加 serverTimezone=Asia/Shanghai。

4.2 动态路由与权限控制

诊所系统里前台、医生、管理员看到的菜单不一样,用 Vue 的动态路由按角色下发。登录后后端返回该角色的路由表,前端用 router.addRoute 动态挂载。

// 登录后根据角色加载路由 const routes = await fetchUserRoutes() routes.forEach(route => { router.addRoute('layout', { path: route.path, name: route.name, component: () => import(`@/views/${route.component}.vue`) }) })

参数说明:component 用动态 import 时路径要写死前缀,Vite 对完全动态的路径解析不了,常见做法是用 import.meta.glob 预扫描所有 views 下的文件,再按 key 匹配。路由守卫里要判断目标路由是否已挂载,否则刷新页面会白屏,这是动态路由最常见的翻车点。

4.3 前后端联调的跨域与打包

开发阶段前端跑 5173 端口,后端跑 8080,跨域用 Vite 的 proxy 解决,不要在后端加 @CrossOrigin 到处撒。

// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }

上线时前端 npm run build 产出 dist 目录,常见做法是把 dist 放进 Springboot 的 src/main/resources/static 下一起打包,这样只有一个 jar 包,部署简单。注意 Vue Router 要用 history 模式的话,Springboot 里要加一个转发配置,把所有非 /api 的 404 请求指回 index.html,否则刷新子路由会 404。

5. 避坑与排查:口腔预约系统上线后最容易翻车的五件事

5.1 现象:同一时段出现两条预约记录

原因:只靠代码里的先查后插,没有数据库唯一索引兜底,并发时两个请求都查到空闲然后都插入成功。解决:在 appointment 表的 schedule_id 上加 UNIQUE 索引,代码里捕获 DuplicateKeyException 返回友好提示。这个后悔药一定要提前吃,上线后再加索引要处理历史脏数据。

5.2 现象:排班查询接口越来越慢

原因:schedule 表按天生成,几个月后数据量到几十万,查询没走对索引。解决:确认 uk_doctor_time 和 uk_chair_time 两个联合索引存在,查询条件里 work_date 和 doctor_id 的顺序要和索引一致。如果还是慢,考虑按月分表或者定期归档过期排班。

5.3 现象:前端日历显示的可用时段和后端不一致

原因:缓存没及时清除,或者时区导致日期偏移。解决:预约成功后主动删除对应 key 的缓存;数据库连接串加 serverTimezone 参数;前端传日期时统一用 YYYY-MM-DD 字符串,不要传 Date 对象。

5.4 现象:动态路由刷新后白屏

原因:addRoute 是运行时添加的,刷新页面后路由表还没加载完,匹配不到目标路由。解决:在路由守卫里判断,如果目标路由不存在且用户已登录,先拉取路由表再 next({ ...to, replace: true }) 重新导航。

5.5 现象:Springboot 打包后前端页面 404

原因:Vue Router 用了 history 模式,Springboot 没有配置 fallback 转发。解决:写一个 WebMvcConfigurer,把 /api 之外的请求都转发到 index.html,或者干脆用 hash 模式省事,代价是 URL 里多个 #。

6. 把预约系统做扎实的两个进阶技巧

第一个技巧是给排班加「缓冲时间」。口腔治疗之间需要消毒牙椅、整理器械,实际占用时间比治疗时间长。我一般会在 schedule 生成时,在每个时段后面留 15 分钟缓冲,比如 9:00-9:30 的治疗,schedule 实际占 9:00-9:45。这样医生不会因为前一个患者拖堂而连环迟到。实现上就是在生成排班时把 end_time 往后延,预约时按 treat_type 计算需要几个连续 slot,把缓冲也算进去。

第二个技巧是用定时任务做预约提醒和爽约处理。用 Spring 的 @Scheduled 每天凌晨跑一次,把前一天未确认的预约自动取消,释放排班;再给当天预约的患者发提醒。这里要注意定时任务在集群部署时会重复执行,常见做法是加 Redis 分布式锁或者用数据库的乐观锁标记任务已执行。

@Scheduled(cron = "0 0 2 * * ?") public void releaseExpiredAppointments() { // 把超过24小时未确认的预约置为取消,排班状态回滚为空闲 int count = appointmentMapper.cancelExpired(); scheduleMapper.releaseByExpiredAppointments(); log.info("释放过期预约 {} 条", count); }

参数说明:cron 表达式 "0 0 2 * * ?" 表示每天凌晨 2 点执行,避开诊所营业时间。cancelExpired 的 SQL 里用 create_time < DATE_SUB(NOW(), INTERVAL 24 HOUR) 筛选,releaseByExpiredAppointments 用 UPDATE schedule s JOIN appointment a ON s.id = a.schedule_id SET s.status = 0 WHERE a.status = 3 批量回滚。这两个操作要放在同一个事务里,否则可能出现预约取消了但排班没释放的情况。

我自己做这类系统最大的教训是:别在业务逻辑里省数据库约束。唯一索引、外键、非空约束这些看起来「碍事」的东西,恰恰是并发场景下最后的防线。代码可以改,脏数据洗起来是真的头疼。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询