拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot + Vue 预约挂号系统:从权限拦截到号源并发扣减的完整实现

简介一套基于Java SpringBoot Vue Mysql实现的医院门诊预约挂号系统适合毕业设计、课程设计、大作业或初期项目立项面向有一定编程基础、希望学习前后端分离开发的学生。系统覆盖医生管理、类型管理、评论管理、用户管理、统计分析、消息通知、广告展示、意见反馈等模块其中医生管理可录入、修改和查询医生名称、价格、职称等资料用户管理支持新增、编辑和删除用户统计分析结合活动数据辅助管理员掌握系统运营状况。资源包共376个文件以Java源码、Vue组件、TypeScript/JavaScript脚本和SQL脚本为核心包含jpeg/png/svg等界面素材便于查看效果另有表结构文档和配置文件压缩包仅7.69MB轻量易部署。所有代码作为参考资料需要具备一定编码基础进行调试、排错与二次开发。目前已有63人学习适合需要真实业务案例完善自身项目的开发者。1. 门诊预约挂号系统的业务闭环与 Spring Boot Vue 技术栈选型医院门诊预约挂号系统听起来是一张医生表加一张预约表的 CRUD实际拿到手才会发现入口是挂号难点全集中在号源扣减和管理员权限两条链路上。这份基于 Java Spring Boot Vue MySQL 的毕业设计级项目把医生管理、类型管理、评论管理、用户管理、统计分析、消息管理、广告管理、意见反馈、系统信息九个模块装进一个可运行的架子很适合作为课程设计或毕设的主项目来拆。工程里真正值得读的三个类是 AccessInterceptor、ThingController 和 OverViewController分别负责管理端鉴权、预约对象聚合和运营指标统计。本文顺着权限拦截、前端路由、号源并发、统计聚合这条线把代码按可复现的方式逐段讲清楚。2. AccessInterceptor 管理端鉴权与 Spring Boot 分层结构拆解2.1 从表结构文档看项目边界项目根目录放了一份表结构.docx这是把数据模型当作第一份交付物的习惯。表结构先行意味着后面所有代码的字段口径都以它为基准TypeScript 或 Java 实体类里的命名不统一时回到这份文档对字段最快。这个项目的后端入口由 controller、service、mapper 三层构成其中 ThingController 是比较特别的一个类它没有按 DoctorController、ScheduleController 去拆而是把科室、医生、号源这类可预约对象统一叫 thing对外暴露的是预约对象的查询与占用接口内部再按 thingType 分发到不同的业务处理器。顺着表结构文档往下看会发现 doctor_type 不是 doctor 表里的一个枚举字段而是独立子表doctor 表通过外键关联它。选子表而不是字段枚举换来的是后台改类型名不用动代码代价是查询多一次 joinOverViewController 做统计时因此必须联表取类型名称。这种权衡在课程设计里很常见优先保证管理端可维护性而不是极端查询性能。2.2 AccessInterceptor 拦什么、放什么管理端接口统一走 /admin/ 前缀C 端预约接口走 /api/ 前缀AccessInterceptor 在 preHandle 阶段根据 URI 前缀分流。管理员 token 校验失败直接返回 401不进入 Controller避免每个管理接口重复写权限判断。public class AccessInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri request.getRequestURI(); String token request.getHeader(Authorization); if (uri.startsWith(/admin/)) { if (token null || !token.startsWith(Bearer )) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } // 省略 token 签名校验细节resolve 返回 null 表示 token 不存在或已过期 LoginUser user tokenService.resolve(token.replace(Bearer , )); if (user null || !admin.equals(user.getRole())) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } UserContext.set(user); return true; } // C 端预约接口要求登录但不要求管理员角色 if (uri.startsWith(/api/appointment/)) { if (token null || !token.startsWith(Bearer )) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } LoginUser user tokenService.resolve(token.replace(Bearer , )); if (user null) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } UserContext.set(user); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这段代码把鉴权逻辑收敛到一处Authorization 头固定带 Bearer 前缀截取后交给 tokenService 解析uri 前缀决定走管理员校验还是普通登录校验afterCompletion 里必须清理 UserContext因为 Tomcat 线程池会复用线程不清的话下一个请求可能读到上一个用户的登录态。实际项目里 tokenService 内部一般用 JWT 或存在 Redis 里的会话串毕设项目用 JWT 更省事因为不需要额外维护服务端会话存储。管理端接口的权限规则可以收敛成下面这张表写文档和写代码时都用同一份口径。URI 前缀是否要求登录角色要求实际落到哪个模块/admin/**是admin医生、类型、评论、用户、广告等管理/api/auth/**否无登录、注册/api/doctor/**否无科室和医生列表游客可浏览/api/appointment/**是无创建预约、查询我的预约/api/feedback/**是无用户提交意见反馈2.3 ThreadLocal 上下文与统一响应封装UserContext 用 ThreadLocal 绑定当前线程的 LoginUser解决 Controller 到 Service 再到 Mapper 层层传 userId 的问题。public class UserContext { private static final ThreadLocalLoginUser HOLDER new ThreadLocal(); public static void set(LoginUser user) { HOLDER.set(user); } public static LoginUser get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }这里有一个容易踩的坑Async 异步方法在新线程里执行拿不到主线程的 ThreadLocal所以异步发送通知短信时要把 userId 当方法参数显式传进去。另一个坑是使用 remove 而不是简单的 set(null)避免线程池复用导致旧值残留。配合 AccessInterceptor 的 afterCompletion理论上每个请求结束都会清掉但业务代码里如果手动 new 了线程池处理任务也要在 finally 里调用 UserContext.clear。统一响应体 Result 的作用是让前端 axios 拦截器判断业务成功还是失败而不是依赖 HTTP 状态码。HTTP 200 只代表请求到达了后端业务上号源不足应该返回 code500 而 HTTP 状态码仍是 200这样前端处理逻辑可以统一收敛到 response 拦截器里。public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.data data; return r; } public static T ResultT fail(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }再加一个 RestControllerAdvice 全局异常处理器把 BizException 转成 Result.fail把未捕获异常打印日志后返回系统繁忙给前端。这样 Controller 里不需要大量 try catch业务代码只关心正常流转路径。3. Vue 路由拦截与预约流程页面的前后端串联实现3.1 路由表与登录守卫前端项目入口是根目录的 index.htmlVue 实例挂在它上面所有页面收敛到一张路由表里。这套项目的路由设计把管理端和 C 端挂在同一个 router 下靠 meta 字段区分权限等级而不是开两套独立的前端工程。const routes [ { path: /login, name: Login, component: () import(/views/Login.vue), meta: { public: true } }, { path: /appointment, name: Appointment, component: () import(/views/Appointment.vue), meta: { requiresAuth: true } }, { path: /admin, component: () import(/layout/AdminLayout.vue), meta: { requiresAuth: true, role: admin }, children: [ { path: doctors, component: () import(/views/admin/DoctorList.vue) }, { path: statistics, component: () import(/views/admin/Statistics.vue) } ] } ]登录守卫写在 router.beforeEach 里每次跳转前先看目标路由的 meta。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.public) { next() return } if (!token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.meta.role admin) { const role localStorage.getItem(role) if (role admin) { next() } else { next({ path: /403 }) } return } next() })beforeEach 里带上了 redirect 参数登录成功后可以跳回用户原本想访问的页面localStorage 里的 role 只是前端体验层的判断真正拦截非法管理员的是后端 AccessInterceptor前端隐藏菜单不等于后端放行。这里要注意 meta.public 的判断放在最前面登录页、注册页、404 页这类公共页面如果也先查 token会让已登录用户访问登录页时被重定向走体验反而差。页面与 meta 配置的对应关系可以按下表维护路由路径meta 关键配置对应页面/loginpublic: true登录页/appointmentrequiresAuth: true预约挂号页/appointment/listrequiresAuth: true我的预约列表/admin/doctorsrequiresAuth role: admin医生管理/admin/statisticsrequiresAuth role: admin统计分析页3.2 预约页面四步选择与号源刷新预约流程按科室、医生、日期、号源四个步骤推进前端两个方法最核心一个是按 doctorId 和 date 拉取号源列表一个是提交预约。async function loadSchedules(doctorId, date) { const res await request.get(/api/doctor/schedules, { params: { doctorId, date } }) if (res.code 200) { schedules.value res.data } } async function reserve(scheduleId) { const res await request.post(/api/appointment, { scheduleId: scheduleId, source: web }) if (res.code 200) { ElMessage.success(预约成功) router.push(/appointment/list) } else { ElMessage.error(res.message) } }loadSchedules 的查询参数是 doctorId 和 date后端返回当天的号源数组每个号源对象里有 startTime、endTime、remainCount。前端根据 remainCount 是否为 0 来决定是否把预约按钮置灰但置灰只是视觉提示真正扣减号源的判断必须回到后端做否则两个用户同时看到剩余 1 个号前端置灰状态还没来得及刷新就可能重复提交。reserve 里的 scheduleId 不能由用户自己拼接必须在 loadSchedules 返回的列表里选择后端收到后还要校验这个号源确实属于当前选中的医生和日期。3.3 axios 拦截器与联调转发axios 实例统一在 request.js 里创建请求拦截器负责注入 token响应拦截器负责处理登录失效。const service axios.create({ baseURL: /api }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) localStorage.removeItem(role) router.push(/login) } return res }, error { return Promise.reject(error) } )baseURL 写成 /api 而不是完整域名是为了配合本地开发服务器的请求转发。Vite 项目在 vite.config.js 里配置转发规则server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }前端跑在 5173后端跑在 8080浏览器直接跨域但经过转发后后端看到的请求来自同源绕开了 CORS 的配置环节。这里只转发 /api 开头的请求静态资源和 node_modules 里的依赖仍然由 Vite 本地处理。如果项目是 Vue CLI 创建把同样的配置搬到 vue.config.js 的 devServer.proxy 里键名和值完全一致。线上部署时前端打包成静态文件由 Nginx 把 /api 反向转发到后端服务proxy 配置就只在开发环境生效了。4. MySQL 号源扣减的并发控制与预约表单防重设计4.1 排班与预约这两张表的字段怎么定预约系统的核心表是 doctor_schedule 和 appointment排班表描述医生在某天某时段放了多少号预约表记录哪个用户占了这个时段。建表脚本是理解整个业务链路的入口。CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL COMMENT 医生ID, schedule_date DATE NOT NULL COMMENT 出诊日期, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, total_count INT NOT NULL DEFAULT 0 COMMENT 总号数, remain_count INT NOT NULL DEFAULT 0 COMMENT 剩余号数, UNIQUE KEY uk_doctor_date (doctor_id, schedule_date, start_time), KEY idx_doctor_date (doctor_id, schedule_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT医生排班表; CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 预约用户ID, schedule_id BIGINT NOT NULL COMMENT 排班ID外键到 doctor_schedule, appointment_no VARCHAR(32) NOT NULL COMMENT 预约流水号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待就诊 1已就诊 2已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_user_schedule (user_id, schedule_id), KEY idx_schedule_id (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约记录表;排班表把 total_count 和 remain_count 分开存total_count 是放号总量每次扣减只动 remain_count。预约表的关键在 uk_user_schedule 唯一索引同一用户同一排班只能存在一条记录这是防重复预约的数据库兜底。appointment_no 是取号用的流水号生成规则可以是日期加自增序列也可以直接用时间戳加随机数只要保证不重复。整个项目涉及的业务表可以按下表快速对照表名作用关键外键备注user用户信息无前端用户与管理员共用role 字段区分doctor_type医生类型无管理端独立维护doctor医生信息doctor_type_id名称、价格、职称、备注doctor_schedule医生排班doctor_id剩余号数核心字段appointment预约记录user_id, schedule_id唯一索引防重comment评论user_id, doctor_id管理端可审核message消息公告无全站用户可见ad广告位无详情页右侧展示feedback意见反馈user_id管理端查看4.2 扣减号源时先把 SELECT 忘掉并发环境下的经典错误是先 SELECT 剩余号数再判断大于 0然后 UPDATE 减一。两个事务同时读到剩余号数 1都认为可以预约最终产生两条预约记录号源变成 -1。正确做法是直接在 UPDATE 语句里把 remain_count 0 作为条件。Mapper public interface DoctorScheduleMapper { Update(UPDATE doctor_schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count 0) int deductRemain(Long scheduleId); }Service 层把扣减和插入预约记录放到同一个事务里。Service public class AppointmentService { Transactional(rollbackFor Exception.class) public Long createAppointment(AppointmentCreateDTO dto) { // 条件更新返回 0 表示号源不足或排班不存在 int updated scheduleMapper.deductRemain(dto.getScheduleId()); if (updated 0) { throw new BizException(该时段号源不足请更换其他时段); } Appointment appointment new Appointment(); appointment.setUserId(UserContext.get().getId()); appointment.setScheduleId(dto.getScheduleId()); appointment.setAppointmentNo(generateNo()); appointment.setStatus(0); appointmentMapper.insert(appointment); return appointment.getId(); } }deductRemain 使用原子性的条件 UPDATE数据库层面保证同一时刻只有一个事务能把 remain_count 从 1 减到 0。updated 0 不一定是排班不存在更常见的是剩余号数已经被别的请求扣完所以抛出业务异常给前端提示号源不足。事务里先扣号源再插入预约记录如果后面 insert 失败整个事务回滚号源数量也回到原值。如果先插入预约再扣号源极端情况下扣减失败但预约记录已经落库除非再写补偿逻辑否则数据就对不上了。这里还要注意事务里抛出的 BizException 必须继承 RuntimeExceptionSpring 默认只对运行时异常回滚事务受检异常默认不回滚。这是毕设里最容易被忽略的一行配置稍不注意就会出现号源扣了但预约没记录下来的脏数据。4.3 唯一索引兜底与 DuplicateKeyException条件 UPDATE 挡住了超卖但同一个用户在前端连点两次提交按钮request 拦截器做不到幂等两条请求几乎同时到达后端。第二条请求的扣减可能也执行成功因为两个排班段的号源充足此时要靠 uk_user_schedule 把第二条约预记录拦下来。try { appointmentMapper.insert(entity); } catch (DuplicateKeyException e) { throw new BizException(您已预约该时段请勿重复提交); }DuplicateKeyException 是 Spring 对 MySQL 主键冲突或唯一索引冲突的封装捕捉它转成业务提示比先查一次预约表再判断要可靠得多。查了再插入依然有时间窗两个请求同时查都会得到没有预约然后同时插入最终还是依赖唯一索引兜底。所以正确顺序是条件 UPDATE 扣号源防止超卖唯一索引防止同用户重复预约两个机制缺一不可。排查这种并发问题时优先看两个地方一是 MySQL 慢查询日志里有没有长时间的锁等待二是 appointment 表有没有多余的重复索引。extra 索引在并发插入时多一次索引维护表数据量上来之后写入性能会明显变差但课程设计阶段影响不大重点还是把 uk_user_schedule 这类兜底约束建对。5. 门诊统计聚合查询的 OverViewController 套路与列表复用技巧5.1 OverViewController 与聚合查询OverViewController 是统计页的数据装配类负责把预约数据按医生、按日期、按类型聚合后交给前端图表。统计逻辑不能逐条 count必须用 GROUP BY 在数据库层完成聚合。SELECT doctor_id, COUNT(*) AS order_count FROM appointment WHERE create_time #{startTime} AND create_time #{endTime} GROUP BY doctor_id ORDER BY order_count DESC;这段 SQL 返回每个医生在指定时间段内的预约数量startTime 和 endTime 用半开区间避免边界时间被重复计算到两个统计周期。OverViewController 拿到聚合结果后再根据 doctor_id 一次性查出医生姓名和类型组装成前端需要的图表数据结构。这里要避免在循环里逐条发 SQL 查医生信息正确做法是先把 doctor_id 收集成 List再用 WHERE id IN 一次查出全部。5.2 列表页参数化复用一个表格组件后台的医生列表、用户列表、评论列表、广告列表长得几乎一样都是表格加搜索栏加分页。与其复制四个页面不如把表格组件参数化用一份列配置驱动渲染。// 列配置示例 const doctorColumns [ { label: 医生名称, field: doctorName, width: 140 }, { label: 职称, field: title, width: 100 }, { label: 所属类型, field: typeName, width: 120 }, { label: 预约价格, field: price, width: 100 } ] const userColumns [ { label: 昵称, field: nickname, width: 140 }, { label: 手机号, field: mobile, width: 140 }, { label: 注册时间, field: createTime, width: 180 } ]组件内部根据传入的 columns 动态渲染 el-table-column列表数据请求、分页、导出逻辑全部收敛在一个封装组件里。新增一个管理页面时只需要新增一份 columns 配置和对应的请求方法不需要复制整个表格模板。配合菜单表里的 viewName 字段把每个菜单项映射到一个页面组件前端用动态组件去匹配后端管理端加模块时甚至不用改路由配置只加一条菜单记录就够了。这套做法把统计页的数据装配和后台列表的复用逻辑分开处理OverViewController 只管聚合查询和数据结构拼装通用表格组件只管渲染和交互。两个技巧单独看都不复杂但放在一起能让管理端的开发节奏明显变快新增一个统计维度时只改 SQL新增一个列表页时只改配置真正需要手写模板的场景就很少了。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门