Spring Boot+Vue实现按钮级权限控制的医院预约系统
2026/9/16 18:03:06 网站建设 项目流程

简介:这是一套面向计算机专业本科生的Java全栈毕业设计项目,基于Vue+SpringBoot+MySQL实现医院门诊预约挂号系统,适用于课程设计、毕设开发与权限系统学习实践。系统覆盖科室管理、医生排班、患者预约挂号、新闻公告、留言板等核心业务模块,并内置用户/角色/菜单/日志/数据字典等10余项企业级基础功能,支持按钮级细粒度权限控制,便于理解RBAC模型在真实医疗场景中的落地。资源包共342个文件,含166个Java后端逻辑类、78个Vue组件及页面、41个JS工具脚本,辅以PNG界面图、SQL建表语句、YML配置及BTL模板文件,结构完整、分层清晰,压缩包仅11.42MB,轻量易部署。已有324人学习下载,配套B站录屏演示与CSDN项目讨论帖,涵盖前后端联调流程、权限配置实操与常见问题解析,可直接复用或二次开发。

1. 这不是又一个 CRUD 演示系统:它用 Spring Boot + Vue 实现了按钮级权限控制的门诊预约闭环

你可能已经看过几十个“医院挂号系统”的毕业设计,但这个项目真正落地在「角色权限能精确到按钮」——比如患者能看到「预约挂号」按钮,却看不到「排班设置」;管理员能编辑医生信息,但无法删除科室;甚至新闻编辑岗可以发布医院公告,却无权修改用户密码。它不是靠前端 v-if 简单隐藏,而是后端 Spring Security + RBAC + 动态菜单 + 接口级鉴权四层联动,所有按钮点击前都经过@PreAuthorize("hasAuthority('sys:doctor:edit')")校验。整个系统跑在 MySQL 8.0 上,Vue 3(Composition API)+ Element Plus 做前台,Spring Boot 2.7.x(兼容 JDK 8/11)做后端,MyBatis-Plus 自动生成 CRUD,但关键业务逻辑——如号源释放规则、时段冲突检测、医生排班与号段绑定——全部手写实现。适合需要展示真实权限建模能力、理解前后端分离鉴权链路、且要通过答辩时被问“你怎么保证医生不能给自己多挂号”的计算机或软件工程专业学生。

2. 权限模型与动态菜单:从数据库表结构到 Vue 路由自动注册

2.1 RBAC 四张核心表的设计意图与字段约束

本系统采用经典 RBAC(Role-Based Access Control)模型,但扩展为五表结构:sys_user(用户)、sys_role(角色)、sys_menu(菜单/按钮资源)、sys_role_menu(角色-菜单关联)、sys_user_role(用户-角色关联)。其中sys_menu表是权限控制的核心载体,其关键字段如下:

字段名类型含义示例值注意点
menu_idBIGINT PK主键101自增
menu_nameVARCHAR(50)菜单/按钮名称“新增医生”前端显示文本
pathVARCHAR(200)Vue Router 路径/doctor/add必须与前端路由一致
componentVARCHAR(200)Vue 组件路径views/doctor/Add.vue决定页面加载位置
permsVARCHAR(100)权限标识符sys:doctor:add后端 @PreAuthorize 的依据
typeTINYINT类型:0=目录,1=菜单,2=按钮2type=2的记录即为按钮级权限
parent_idBIGINT父菜单ID100构成树形结构

提示:perms字段不是随意命名,必须与 Controller 方法上的@PreAuthorize("hasAuthority('xxx')")中的字符串完全一致。例如mpController.btl中的@PreAuthorize("hasAuthority('sys:appointment:cancel')")对应sys_menu.perms = 'sys:appointment:cancel'。若不匹配,按钮即使渲染出来,点击也会返回 403。

2.2 后端动态菜单接口:递归组装树形结构并过滤权限

Spring Boot 后端通过SysMenuServiceImpl实现菜单动态加载。关键逻辑在listMenusByUserId(Long userId)方法中:

// mpServiceImpl.btl 中的 listMenusByUserId 方法(简化版) @Override public List<SysMenu> listMenusByUserId(Long userId) { // 1. 获取用户所有角色ID List<Long> roleIds = userRoleMapper.selectRoleIdByUserId(userId); if (CollectionUtils.isEmpty(roleIds)) { return new ArrayList<>(); } // 2. 查询这些角色拥有的所有菜单ID(含按钮) List<Long> menuIds = roleMenuMapper.selectMenuIdByRoleIds(roleIds); if (CollectionUtils.isEmpty(menuIds)) { return new ArrayList<>(); } // 3. 查询菜单详情,并按 parent_id 递归组装树 List<SysMenu> allMenus = menuMapper.selectBatchIds(menuIds); return buildMenuTree(allMenus); } private List<SysMenu> buildMenuTree(List<SysMenu> menus) { // 先找顶级菜单(parent_id = 0) List<SysMenu> rootMenus = menus.stream() .filter(m -> m.getParentId().equals(0L)) .collect(Collectors.toList()); // 为每个顶级菜单递归添加子菜单 for (SysMenu root : rootMenus) { root.setChildren(getChildren(root.getMenuId(), menus)); } return rootMenus; } private List<SysMenu> getChildren(Long parentId, List<SysMenu> allMenus) { return allMenus.stream() .filter(m -> m.getParentId().equals(parentId)) .map(m -> { m.setChildren(getChildren(m.getMenuId(), allMenus)); return m; }) .collect(Collectors.toList()); }

这段代码完成三件事:① 根据用户查角色 → ② 根据角色查菜单ID集合 → ③ 将扁平菜单列表构造成带children的树形结构。注意getChildren是递归调用,避免 N+1 查询,全部在内存中完成。

2.3 前端路由自动注册:从后端菜单数据生成 Vue Router 路由表

Vue 端在src/router/index.js中不硬编码所有路由,而是通过generateRoutesFromMenu方法动态构建:

// src/utils/routerUtil.js export function generateRoutesFromMenu(menus) { const routes = []; menus.forEach(menu => { if (menu.type === 1) { // type=1 是菜单(页面),type=2 是按钮(不生成路由) const route = { path: menu.path, name: menu.menuName, component: () => import(`@/views${menu.component}`), // 动态导入 meta: { title: menu.menuName, perms: menu.perms // 用于按钮级权限指令 v-has-perm } }; if (menu.children && menu.children.length > 0) { route.children = generateRoutesFromMenu(menu.children); } routes.push(route); } }); return routes; } // src/router/index.js 中使用 const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', redirect: '/dashboard' } ] }); // 登录成功后调用此方法 export function setAsyncRoutes(menus) { const asyncRoutes = generateRoutesFromMenu(menus); asyncRoutes.forEach(route => router.addRoute(route)); // 动态添加 router.addRoute({ path: '/:pathMatch(.*)', redirect: '/404' }); // 404兜底 }

v-has-perm是自定义指令,用于控制按钮显隐:

// src/directives/hasPerm.js export default { mounted(el, binding) { const { value } = binding; const permissions = store.state.user.permissions; // 从 Vuex 或 Pinia 中获取用户权限数组 if (!permissions.includes(value)) { el.style.display = 'none'; // 或 el.parentNode.removeChild(el) } } };

这样,当管理员在后台给角色分配了sys:appointment:cancel权限,该按钮就会出现在对应用户的界面上;反之则彻底隐藏,而非仅禁用。

3. 预约挂号核心流程:号源管理、时段校验与并发安全

3.1 号源表设计与预生成策略

挂号的核心是「号源」,即某医生在某日期某时段可提供的号数。系统使用appointment_source表存储:

字段类型含义示例
source_idBIGINT PK主键1001
doctor_idBIGINT医生ID201
dept_idBIGINT科室ID301
appoint_dateDATE预约日期'2024-06-15'
time_slotVARCHAR(20)时段标识'morning' / 'afternoon'
total_numINT总号数20
used_numINT已用号数12
statusTINYINT状态:0=启用,1=停挂0

号源不是实时计算,而是提前一天由定时任务批量生成ScheduledTask.java中:

@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 public void generateTomorrowSource() { LocalDate tomorrow = LocalDate.now().plusDays(1); // 查询所有在职医生 List<Doctor> doctors = doctorMapper.selectList(new QueryWrapper<Doctor>().eq("status", 1)); for (Doctor doctor : doctors) { // 为每个医生生成上午、下午两个时段 generateSourceForDoctor(doctor, tomorrow, "morning"); generateSourceForDoctor(doctor, tomorrow, "afternoon"); } } private void generateSourceForDoctor(Doctor doctor, LocalDate date, String slot) { AppointmentSource source = new AppointmentSource(); source.setDoctorId(doctor.getDoctorId()); source.setDeptId(doctor.getDeptId()); source.setAppointDate(date); source.setTimeSlot(slot); source.setTotalNum(20); // 默认20个号 source.setUsedNum(0); source.setStatus(0); appointmentSourceMapper.insert(source); }

注意:cron = "0 0 2 * * ?"表示每天 02:00:00 执行。若部署服务器时区非东八区,需在application.yml中配置spring.jackson.time-zone: GMT+8并确保 JVM 启动参数-Duser.timezone=GMT+8,否则定时任务会错乱。

3.2 预约提交的原子性校验与乐观锁更新

用户点击“立即预约”时,后端AppointmentController执行:

@PostMapping("/appoint") public Result<?> appoint(@RequestBody Appointment appointment) { // 1. 校验号源是否存在且可用 LambdaQueryWrapper<AppointmentSource> sourceQw = new LambdaQueryWrapper<>(); sourceQw.eq(AppointmentSource::getDoctorId, appointment.getDoctorId()) .eq(AppointmentSource::getAppointDate, appointment.getAppointDate()) .eq(AppointmentSource::getTimeSlot, appointment.getTimeSlot()) .eq(AppointmentSource::getStatus, 0); AppointmentSource source = appointmentSourceMapper.selectOne(sourceQw); if (source == null) { return Result.fail("号源不存在或已停挂"); } if (source.getUsedNum() >= source.getTotalNum()) { return Result.fail("号源已满,请选择其他时段"); } // 2. 使用乐观锁更新 used_num,防止超卖 LambdaUpdateWrapper<AppointmentSource> updateQw = new LambdaUpdateWrapper<>(); updateQw.eq(AppointmentSource::getSourceId, source.getSourceId()) .setSql("used_num = used_num + 1") .gt(AppointmentSource::getUsedNum, source.getUsedNum() - 1); // 旧值校验 int updated = appointmentSourceMapper.update(null, updateQw); if (updated == 0) { return Result.fail("预约失败:号源已被抢完,请刷新重试"); } // 3. 保存预约记录 appointment.setAppointStatus(1); // 1=已预约 appointment.setCreateTime(new Date()); appointmentMapper.insert(appointment); return Result.success("预约成功"); }

这里的关键是第 2 步:setSql("used_num = used_num + 1")直接在 SQL 层做原子自增,gt(...)条件确保更新前used_num未被其他事务修改。这是比SELECT ... FOR UPDATE更轻量的并发控制方案,适用于高并发挂号场景。

3.3 前端挂号页的时段选择与实时余号联动

Vue 页面Appointment.vue使用el-date-picker选日期,el-radio-group选时段,并通过watch实时查询余号:

<template> <div> <el-date-picker v-model="form.appointDate" type="date" placeholder="选择日期" /> <el-radio-group v-model="form.timeSlot"> <el-radio-button label="morning" :disabled="!morningAvailable">上午</el-radio-button> <el-radio-button label="afternoon" :disabled="!afternoonAvailable">下午</el-radio-button> </el-radio-group> <p>上午余号:{{ morningRemain }} / {{ totalNum }}</p> <p>下午余号:{{ afternoonRemain }} / {{ totalNum }}</p> </div> </template> <script setup> import { ref, watch } from 'vue' import { getAppointmentSource } from '@/api/appointment' const form = ref({ appointDate: null, timeSlot: 'morning' }) const morningRemain = ref(0) const afternoonRemain = ref(0) const totalNum = 20 watch(() => form.value.appointDate, async (newVal) => { if (!newVal) return const res = await getAppointmentSource({ doctorId: 201, // 实际从路由参数或 store 获取 appointDate: newVal }) morningRemain.value = res.data.morning?.remain || 0 afternoonRemain.value = res.data.afternoon?.remain || 0 }, { immediate: true }) // getAppointmentSource API 返回格式: // { data: { morning: { remain: 5 }, afternoon: { remain: 12 } } } </script>

这种设计让用户在选日期后立刻看到各时段余号,无需反复提交再提示“号已满”,大幅提升体验。

4. 系统基础模块集成:MyBatis-Plus 代码生成与文件上传统一处理

4.1 MyBatis-Plus Generator 配置解析:从 entity.btl 到 mapper.xml

项目中的entity.btlmpController.btlmpServiceImpl.btl等文件,是 MyBatis-Plus CodeGenerator 生成的模板(.btl为 Beetl 模板后缀)。以entity.btl为例,其核心逻辑是:

// entity.btl 片段 package ${package.Entity}; import com.baomidou.mybatisplus.annotation.*; import java.io.Serializable; import java.time.LocalDateTime; <#if table.hasDateTimeField> import java.time.LocalDateTime; </#if> <#if table.hasDateField> import java.time.LocalDate; </#if> /** * ${table.comment!} */ <#if table.hasKeyField> @TableName("${table.name}") </#if> public class ${table.className} implements Serializable { private static final long serialVersionUID = 1L; <#list table.fields as field> <#if field.keyFlag> /** * ${field.comment!} */ @TableId(type = IdType.${field.idType}) private ${field.propertyType} ${field.propertyName}; <#else> /** * ${field.comment!} */ <#if field.fill != ""> @TableField(fill = FieldFill.${field.fill}) </#if> private ${field.propertyType} ${field.propertyName}; </#if> </#list> <#list table.fields as field> <#if field.propertyName != "serialVersionUID"> public ${field.propertyType} get${field.propertyName?cap_first}() { return ${field.propertyName}; } public void set${field.propertyName?cap_first}(${field.propertyType} ${field.propertyName}) { this.${field.propertyName} = ${field.propertyName}; } </#if> </#list> }

生成器读取数据库元数据(如sys_user表字段),将id字段识别为keyFlag=true,自动加上@TableId;将create_time字段识别为fill="INSERT",生成@TableField(fill = FieldFill.INSERT)。这省去了手动编写 90% 的实体类和 Mapper XML。

4.2 文件上传统一入口:基于 MinIO 的多模块复用设计

系统中「医生头像」「新闻配图」「留言板附件」均走同一套上传逻辑。后端FileController.java提供通用接口:

@PostMapping("/upload") public Result<?> upload(@RequestParam("file") MultipartFile file, @RequestParam(value = "module", required = false) String module) { // module 可选值:doctor / news / message,用于分目录存储 String bucket = "hospital"; String objectName = generateObjectName(module, file.getOriginalFilename()); try { minioClient.putObject( PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); String url = minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .bucket(bucket) .object(objectName) .method(Method.GET) .build() ); return Result.success(url); } catch (Exception e) { log.error("文件上传失败", e); return Result.fail("上传失败:" + e.getMessage()); } } private String generateObjectName(String module, String filename) { String ext = FilenameUtils.getExtension(filename); String uuid = UUID.randomUUID().toString().replace("-", ""); return StringUtils.defaultString(module, "common") + "/" + uuid + "." + ext; }

前端调用时只需传module=doctor,后端就存到minio://hospital/doctor/xxx.jpg。Vue 中封装uploadFile(module, file)方法,所有模块复用同一逻辑,避免重复造轮子。

5. 部署与调试技巧:MySQL 8.0 兼容性、Vue 开发代理与日志定位

5.1 MySQL 8.0 连接报错Public Key Retrieval is not allowed的根因与修复

本地启动 Spring Boot 时若报错java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed,是因为 MySQL 8.0 默认启用caching_sha2_password认证插件,而旧版 JDBC 驱动(< 8.0.16)不支持。解决方案有二:

方案一(推荐):升级驱动并配置参数

# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver

关键参数allowPublicKeyRetrieval=true允许客户端请求公钥,useSSL=false关闭 SSL(开发环境可接受)。

方案二:修改 MySQL 用户认证方式

-- 登录 MySQL ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456'; FLUSH PRIVILEGES;

此命令将 root 用户认证方式降级为兼容性更好的mysql_native_password

注意:若使用 Docker 运行 MySQL 8.0,需在docker run命令中加--default-authentication-plugin=mysql_native_password参数,否则容器内新建用户默认仍是caching_sha2_password

5.2 Vue 开发环境代理配置:解决跨域与/api前缀问题

Vue CLI 项目在vue.config.js中配置代理,将/api/**请求转发至 Spring Boot:

// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', // Spring Boot 启动端口 changeOrigin: true, pathRewrite: { '^/api': '' // 去掉 /api 前缀,后端 Controller 映射为 @RequestMapping("/xxx") 而非 "/api/xxx" } } } } }

这样前端调用axios.get('/api/user/list'),实际请求的是http://localhost:8080/user/list。若后端 Controller 使用@RequestMapping("/api/user"),则pathRewrite应改为'': '/api'

5.3 日志快速定位法:从异常堆栈反查业务模块

当线上出现NullPointerException时,不要只看最后一行。以mpController.btl生成的 Controller 为例,典型日志:

2024-06-10 14:22:33.123 ERROR 12345 --- [nio-8080-exec-2] c.h.c.m.AppointmentController : 预约失败 java.lang.NullPointerException: null at com.hospital.controller.AppointmentController.appoint(AppointmentController.java:87) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ...

AppointmentController.java:87是关键线索。打开该文件第 87 行,通常是某个对象未判空:

// 第87行 String deptName = deptService.getById(appointment.getDeptId()).getDeptName(); // 如果 getDeptId() 为 null,此处 NPE

此时应检查前端是否传了deptId,或appointment对象是否被正确反序列化。在@RequestBody Appointment appointment上加@Valid注解,并在Appointment实体类中加@NotNull校验,可提前拦截非法请求,避免 NPE。

验证权限是否生效,可在SysMenuServiceImpl.listMenusByUserId方法首行加log.info("查询用户 {} 的菜单", userId);,然后登录不同角色账号,观察日志中输出的菜单 ID 是否与后台分配一致。这是最直接的权限链路验证方式。

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

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

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

立即咨询