ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot+Vue实现按钮级权限控制的医院预约系统

Spring Boot+Vue实现按钮级权限控制的医院预约系统 简介这是一套面向计算机专业本科生的Java全栈毕业设计项目基于VueSpringBootMySQL实现医院门诊预约挂号系统适用于课程设计、毕设开发与权限系统学习实践。系统覆盖科室管理、医生排班、患者预约挂号、新闻公告、留言板等核心业务模块并内置用户/角色/菜单/日志/数据字典等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 3Composition API Element Plus 做前台Spring Boot 2.7.x兼容 JDK 8/11做后端MyBatis-Plus 自动生成 CRUD但关键业务逻辑——如号源释放规则、时段冲突检测、医生排班与号段绑定——全部手写实现。适合需要展示真实权限建模能力、理解前后端分离鉴权链路、且要通过答辩时被问“你怎么保证医生不能给自己多挂号”的计算机或软件工程专业学生。2. 权限模型与动态菜单从数据库表结构到 Vue 路由自动注册2.1 RBAC 四张核心表的设计意图与字段约束本系统采用经典 RBACRole-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按钮2type2的记录即为按钮级权限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 ListSysMenu listMenusByUserId(Long userId) { // 1. 获取用户所有角色ID ListLong roleIds userRoleMapper.selectRoleIdByUserId(userId); if (CollectionUtils.isEmpty(roleIds)) { return new ArrayList(); } // 2. 查询这些角色拥有的所有菜单ID含按钮 ListLong menuIds roleMenuMapper.selectMenuIdByRoleIds(roleIds); if (CollectionUtils.isEmpty(menuIds)) { return new ArrayList(); } // 3. 查询菜单详情并按 parent_id 递归组装树 ListSysMenu allMenus menuMapper.selectBatchIds(menuIds); return buildMenuTree(allMenus); } private ListSysMenu buildMenuTree(ListSysMenu menus) { // 先找顶级菜单parent_id 0 ListSysMenu 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 ListSysMenu getChildren(Long parentId, ListSysMenu 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是递归调用避免 N1 查询全部在内存中完成。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) { // type1 是菜单页面type2 是按钮不生成路由 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主键1001doctor_idBIGINT医生ID201dept_idBIGINT科室ID301appoint_dateDATE预约日期2024-06-15time_slotVARCHAR(20)时段标识morning / afternoontotal_numINT总号数20used_numINT已用号数12statusTINYINT状态0启用1停挂0号源不是实时计算而是提前一天由定时任务批量生成。ScheduledTask.java中Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 public void generateTomorrowSource() { LocalDate tomorrow LocalDate.now().plusDays(1); // 查询所有在职医生 ListDoctor doctors doctorMapper.selectList(new QueryWrapperDoctor().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: GMT8并确保 JVM 启动参数-Duser.timezoneGMT8否则定时任务会错乱。3.2 预约提交的原子性校验与乐观锁更新用户点击“立即预约”时后端AppointmentController执行PostMapping(/appoint) public Result? appoint(RequestBody Appointment appointment) { // 1. 校验号源是否存在且可用 LambdaQueryWrapperAppointmentSource 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防止超卖 LambdaUpdateWrapperAppointmentSource 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-modelform.appointDate typedate placeholder选择日期 / el-radio-group v-modelform.timeSlot el-radio-button labelmorning :disabled!morningAvailable上午/el-radio-button el-radio-button labelafternoon :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.btl、mpController.btl、mpServiceImpl.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字段识别为keyFlagtrue自动加上TableId将create_time字段识别为fillINSERT生成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; }前端调用时只需传moduledoctor后端就存到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?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver关键参数allowPublicKeyRetrievaltrue允许客户端请求公钥useSSLfalse关闭 SSL开发环境可接受。方案二修改 MySQL 用户认证方式-- 登录 MySQL ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 123456; FLUSH PRIVILEGES;此命令将 root 用户认证方式降级为兼容性更好的mysql_native_password。注意若使用 Docker 运行 MySQL 8.0需在docker run命令中加--default-authentication-pluginmysql_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 是否与后台分配一致。这是最直接的权限链路验证方式。本文还有配套的精品资源点击获取
返回列表