
简介一份基于Vue与SpringBoot的医院门诊预约挂号管理系统项目资源面向医疗信息化初学者、Java全栈开发者以及毕业设计人群覆盖登录注册、科室管理、医生管理、门诊预约等核心业务。后端使用SpringBoot和MyBatis读写MySQL前端基于Vue.js构建交互界面并借助Redis缓存菜单信息以降低数据库压力整体采用前后端分离思路便于学习企业级项目的基本架构。资源共683个文件压缩包约6.88MB其中以185个Java后端类、118个JS脚本、78个Vue组件、144张PNG图片为主同时包含SQL初始化脚本、YAML配置、Markdown说明和前端样式文件代码与素材划分明确方便按模块查阅和二次开发。包内项目文件兼有分层目录结构、接口逻辑与数据表脚本能够帮助读者理解从页面交互到数据库持久化的完整流程适合作为课程设计、毕业设计或入门实战的参考案例。目前已有40人学习浏览内容相对完整可直接导入工程运行体验。1. 医院门诊预约挂号管理系统Vue SpringBoot 技术栈到底在做什么县城医院门诊大厅挂号窗口七点就排起长队专家号十分钟被抢空而候诊区里真正按时来的患者只占三分之二。医院门诊预约挂号管理系统要解决的正是把患者、科室、医生、号源和就诊订单放进同一套可控流程患者注册登录后按科室筛医生、按排班选号管理员维护科室、医生和门诊班次SpringBoot 提供后端接口MyBatis 负责读写 MySQLRedis 承担菜单这类热点数据的缓存。这个选题业务闭环完整、复杂度适中既是高校毕设的高频方向也是中小医院自建预约系统最常见的落地形态。2. 先建模再写码科室、医生、排班和预约订单的六张 MySQL 表2.1 从业务到表字段用户、科室、医生、排班、订单、菜单怎么拆我拿到这类系统的第一件事不是写接口而是把业务对象画成表。登录注册对应一张用户表患者和管理员在同一个表里用role区分科室管理一张科室表医生属于某个科室、又有自己的出诊排班所以医生表和排班表拆开预约挂号最终落在订单上订单关联用户、医生和排班菜单缓存要用到菜单表它同时也是后端做权限控制的依据。六张表搭出整个业务骨架。建表时字段尽量克制。用户表除了用户名密码还要存real_name、phone、id_card和role医生表存dept_id、title、specialty和status排班表是核心之一它决定号源是否充足订单表用status表示就诊状态。下面是一份可以直接拿去改的 MySQL 5.7 建表脚本字段类型和索引都按真实项目习惯设置CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt密文, real_name VARCHAR(50) DEFAULT NULL COMMENT 姓名, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, id_card VARCHAR(30) DEFAULT NULL COMMENT 身份证号, role TINYINT NOT NULL DEFAULT 0 COMMENT 0患者 1管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE department ( id BIGINT NOT NULL AUTO_INCREMENT, dept_name VARCHAR(100) NOT NULL, location VARCHAR(200) DEFAULT NULL COMMENT 门诊位置, intro VARCHAR(500) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT科室表; CREATE TABLE doctor ( id BIGINT NOT NULL AUTO_INCREMENT, dept_id BIGINT NOT NULL, name VARCHAR(50) NOT NULL, title VARCHAR(50) DEFAULT NULL COMMENT 职称, specialty VARCHAR(200) DEFAULT NULL COMMENT 擅长, status TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_dept_id (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT医生表; CREATE TABLE schedule ( id BIGINT NOT NULL AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, work_date DATE NOT NULL COMMENT 出诊日期, period TINYINT NOT NULL DEFAULT 0 COMMENT 0上午 1下午, total INT NOT NULL DEFAULT 30 COMMENT 总号源数, booked INT NOT NULL DEFAULT 0 COMMENT 已预约数, status TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_doctor_date (doctor_id, work_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT门诊排班表; CREATE TABLE appointment ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, appointment_date DATE NOT NULL, period TINYINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待就诊 1已完成 2已取消 3爽约, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status), KEY idx_schedule (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约订单表; CREATE TABLE sys_menu ( id BIGINT NOT NULL AUTO_INCREMENT, parent_id BIGINT NOT NULL DEFAULT 0, menu_name VARCHAR(50) NOT NULL, path VARCHAR(100) DEFAULT NULL COMMENT 前端路由, component VARCHAR(100) DEFAULT NULL, icon VARCHAR(50) DEFAULT NULL, sort INT DEFAULT 0, visible TINYINT DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜单表;几个关键设计解释一下。schedule.total表示一天某个午别的总号源booked是已预约数实际剩余号源用total - booked算出来不单独建库存表避免多表同步的麻烦。appointment.status承担状态机职责0 到 3 分别表示待就诊、已完成、已取消、爽约取消预约时把 0 改成 2医生端看到的是同一个字段。sys_menu.parent_id支持两级菜单path存前端路由地址后面 Redis 缓存直接缓存整个菜单列表。2.2 号源扣减不建独立库存表用订单状态机控制超卖很多新手会把号源设计成一张独立库存表预约时先减库存再生成订单。逻辑上没错但一个门诊系统把库存表拆出来反而引入一致性问题库存表和订单表不在一个事务里减了库存没生成订单号就凭空消失了。我用的做法是把booked直接放在schedule表上生成订单和预约数加一在同一个事务里完成。超卖的控制不靠查一次booked再判断而是靠一条原子更新的 SQLUPDATE schedule SET booked booked 1 WHERE id ? AND booked total受影响行数为 1 说明号源还有行数为 0 说明已经约满直接抛出业务异常。配合appointment表上(user_id, schedule_id)的唯一索引同一个患者同一班次只能有一条记录。这两道防线比先 SELECT 再 UPDATE 可靠得多。爽约处理也简单就诊日期当天结束后写一个定时任务把依旧处于 0 状态的订单改成 3这比后台逐个标记省心。2.3 索引与 MyBatis 映射规划哪里该加索引哪里别用外键患者查自己的订单列表走idx_user_status出诊表按医生和日期查排班走idx_doctor_date。MySQL 5.7 的 InnoDB 联合索引遵循最左前缀原则查询条件里带了doctor_id再带work_date就能用上联合索引。至于为什么不建外键——外键约束会让删除科室、删除医生这类操作变得繁琐业务约束交给事务和应用层保证数据库层只做性能相关的索引这是企业里做 MySQL 排序、分页查询时默认接受的取舍。MyBatis 映射规划上我会在application.yml里开驼峰映射map-underscore-to-camel-case: true数据库的下划线字段直接映射到 Java 的驼峰属性省掉一大半resultMap。单表查询用注解或简单 XML多表关联才写 XML。列表排序规则写在 SQL 里用ORDER BY create_time DESC这类 MySQL 排序完成不要在 Java 内存里做二次排序。3. SpringBoot MyBatis 读写 MySQL后端接口链路从 Mapper 到预约事务3.1 依赖与版本选型版本追低不追高能跑通才算数后端骨架推荐 Spring Boot 2.7.x我用的是 2.7.18配套 mybatis-spring-boot-starter 2.3.x、mysql-connector-java 8.0.33、JDK 8 或 11。SpringBoot版本太高是高频翻车点——Spring Boot 3.x 强制 JDK 17且javax包名改成了jakarta很多老项目的代码和第三方 starter 不兼容。做毕业设计或中小型交付没必要冒这个风险。!-- pom.xml 关键依赖版本号按实际仓库为准 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId version2.7.18/version /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId version2.7.18/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencyMySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver和 5.7 时代的com.mysql.jdbc.Driver不一样连接串务必加上serverTimezoneAsia/Shanghai、useSSLfalse和allowPublicKeyRetrievaltrue。后两个参数在 MySQL 8.0 上不写经常报 Public Key Retrieval is not allowed。Redis 的序列化器我习惯用StringRedisTemplate配合 JSON简单直观避免 Java 原生序列化在跨版本时的反序列化问题。application.yml里还要记得开 MyBatis 的 SQL 打印不然排查问题就像在黑匣子里捞针。# application.yml 核心片段 server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue username: root password: your_password redis: host: localhost port: 6379 timeout: 3000ms mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpllog-impl是开发阶段看到 MyBatis 执行 SQL 和参数的关键必须开上线后换成 logback 的 debug 级别输出到日志文件即可。mapper-locations指定 XML 所在目录如果 Mapper 接口和 XML 不在同一个包这个路径写错会导致启动直接报 Invalid bound statement这是启动失败里最常见的坑。3.2 Mapper 动态 SQL科室筛选、医生分页和模糊查询医生列表页有科室筛选、姓名模糊查询、分页三个诉求用 MyBatis 动态 SQL 拼条件核心是where和if的配合避免写死 SQL。!-- DoctorMapper.xml -- ?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.hospital.doctor.mapper.DoctorMapper !-- 医生分页查询支持科室和姓名模糊 -- select idselectDoctorPage resultTypecom.hospital.doctor.entity.Doctor SELECT d.id, d.dept_id, d.name, d.title, d.specialty, t.dept_name FROM doctor d LEFT JOIN department t ON d.dept_id t.id where d.status 1 if testdeptId ! null and deptId ! 0 AND d.dept_id #{deptId} /if if testkeyword ! null and keyword ! AND d.name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY d.id ASC LIMIT #{offset}, #{size} /select !-- 统计总数分页接口必须返回 -- select idcountDoctor resultTypelong SELECT COUNT(*) FROM doctor d where d.status 1 if testdeptId ! null and deptId ! 0 AND d.dept_id #{deptId} /if if testkeyword ! null and keyword ! AND d.name LIKE CONCAT(%, #{keyword}, %) /if /where /select /mapperwhere标签会自动处理第一个AND多余的场景不用手动加WHERE 11。deptId ! 0这个条件对应前端不选科室时传 0减少一次无意义的查询。LIKE CONCAT(%, #{keyword}, %)用参数拼接而不是${keyword}直接拼进 SQL防止注入。LIMIT #{offset}, #{size}是 MySQL 分页的标准写法offset (currentPage - 1) * size在 Service 层算好再传进来。3.3 Service 层预约事务原子更新加唯一索引挡住所有并发预约是整套系统的核心写入操作并发场景集中在同一班次的号源争夺上。下面的代码用Transactional包住整个流程先锁定排班数据并做原子更新再插入订单最后更新用户缓存中的待就诊数量。// AppointmentServiceImpl.java 核心方法 Service RequiredArgsConstructor public class AppointmentServiceImpl implements AppointmentService { private final ScheduleMapper scheduleMapper; private final AppointmentMapper appointmentMapper; Override Transactional(rollbackFor Exception.class) public Appointment createOrder(AppointmentCreateReq req) { // 1. 原子扣减号源返回受影响行数 int rows scheduleMapper.decreaseBooked(req.getScheduleId()); // rows 0 说明号源已满或排班被禁用 if (rows 0) { throw new BizException(该班次号源已约满); } // 2. 查排班信息用于补全订单快照 Schedule schedule scheduleMapper.selectById(req.getScheduleId()); if (schedule null) { throw new BizException(排班不存在); } // 3. 插入订单依靠唯一索引兜底 Appointment appointment new Appointment(); appointment.setOrderNo(generateOrderNo(schedule.getWorkDate())); appointment.setUserId(req.getUserId()); appointment.setDoctorId(schedule.getDoctorId()); appointment.setScheduleId(schedule.getId()); appointment.setAppointmentDate(schedule.getWorkDate()); appointment.setPeriod(schedule.getPeriod()); appointment.setStatus(0); appointmentMapper.insert(appointment); return appointment; } }decreaseBooked的 SQL 是UPDATE schedule SET booked booked 1 WHERE id #{scheduleId} AND booked total AND status 1受影响行数为 0 时直接抛异常事务回滚连订单插入都不会执行。这里没有用 SELECT FOR UPDATE是因为 UPDATE 本身带行锁且更短更高效这是处理号源扣减这类场景最常用的做法。rollbackFor Exception.class保证任何异常都回滚。如果重启服务后出现重复订单靠的就是(user_id, schedule_id)唯一索引拦截。3.4 Controller 层与统一返回体接口规整才能联调顺畅Controller 层我习惯把所有接口返回统一结构code、message、data三件套。前端 axios 拦截器只看code为 200 走成功分支其余弹 message。下面是一个典型的登录接口。// AuthController.java RestController RequestMapping(/api/auth) RequiredArgsConstructor public class AuthController { private final AuthService authService; PostMapping(/login) public ResultLoginVO login(RequestBody LoginReq req) { // req 里有 username 和 password LoginVO vo authService.login(req.getUsername(), req.getPassword()); return Result.ok(vo); // data 里带 token 和用户基本信息 } PostMapping(/register) public ResultVoid register(RequestBody RegisterReq req) { authService.register(req); return Result.ok(); } }登录接口单独放在AuthController不混业务。Result是泛型统一返回体前端response.data.data取业务数据。所有接口路径以/api开头和前端转发规则对齐。4. Redis 做菜单缓存与登录态让缓存真正扛住高并发读4.1 注册登录BCrypt 加密、Token 签发与 Redis 会话密码绝不允许明文进数据库注册时用 BCrypt 加密登录时用BCryptPasswordEncoder.matches校验。Token 这块我不用 JWT直接用 UUID 作为 token 存 Redis登录成功生成随机 UUID以login:token:{uuid}为 keyvalue 存userId和role过期时间 2 小时。注销时删除对应 key 就能立即使 token 失效比 JWT 的黑名单方案简单得多适合这个规模的项目。// AuthServiceImpl.java Service RequiredArgsConstructor public class AuthServiceImpl implements AuthService { private final UserMapper userMapper; private final BCryptPasswordEncoder encoder; private final StringRedisTemplate stringRedisTemplate; Override public LoginVO login(String username, String rawPassword) { User user userMapper.selectByUsername(username); // 用户不存在或者密码不匹配统一提示避免暴露账号是否存在 if (user null || !encoder.matches(rawPassword, user.getPassword())) { throw new BizException(用户名或密码错误); } if (user.getStatus() ! 1) { throw new BizException(账号已被禁用); } String token UUID.randomUUID().toString().replace(-, ); // 结构login:token:{token} - {userId:1,role:0} stringRedisTemplate.opsForValue().set( login:token: token, JSON.toJSONString(new LoginCache(user.getId(), user.getRole())), 2, TimeUnit.HOURS ); LoginVO vo new LoginVO(); vo.setToken(token); vo.setRealName(user.getRealName()); vo.setRole(user.getRole()); return vo; } }BCryptPasswordEncoder每次加密结果都不同所以不能用比较必须走matches方法。Redis key 带上login:前缀方便和菜单缓存区分线上排查也清晰。过期时间 2 小时是门诊系统的默认值如果需要更长的免登录时长改成 12 小时并加一点随机偏移防止大量用户同时过期导致 Redis 压力尖峰。4.2 菜单缓存读写链先 Redis 后 MySQL空了回填改了删除菜单是高频读、低频写的数据非常适合做缓存。业务上每次登录后前端都要拉一次用户菜单如果每次都查 MySQL压力全在数据库。标准链路是先从 Redis 读命中直接返回未命中再查 MySQL回填缓存。菜单变更时主动删除对应 key而不是等它自然过期。// MenuServiceImpl.java Service RequiredArgsConstructor public class MenuServiceImpl implements MenuService { private final MenuMapper menuMapper; private final StringRedisTemplate stringRedisTemplate; Override public ListMenuVO getUserMenus(Long roleId) { String key menu:role: roleId; // 1. 先查 Redis String cached stringRedisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(cached)) { return JSON.parseArray(cached, MenuVO.class); } // 2. Redis 没有查 MySQL ListMenuVO list menuMapper.selectMenusByRole(roleId); if (list null || list.isEmpty()) { // 防止穿透空值也缓存但过期时间缩短 stringRedisTemplate.opsForValue().set(key, [], 30, TimeUnit.SECONDS); return new ArrayList(); } // 3. 回填缓存24 小时加随机数避免同时过期 stringRedisTemplate.opsForValue().set( key, JSON.toJSONString(list), 24 * 60 * 60 new Random().nextInt(3600), TimeUnit.SECONDS ); return list; } }这段逻辑里藏着三个细节。第一空列表也缓存但过期时间只有 30 秒防止恶意请求反复穿透 MySQL。第二过期时间加随机数避免所有菜单 key 同一时刻失效后全部回源数据库。第三管理员在后台改了菜单调用stringRedisTemplate.delete(menu:role: roleId)主动删 key下次访问自然回填。这是 Redis 缓存治理中最基础的读写模式菜单、科室列表、医生列表都可以复用。4.3 Redis 数据类型怎么选菜单用 String 序列化 JSON别用 Hash 硬拼Redis 五种基本类型里菜单缓存我固定用 String JSON。用 Hash 存储时虽然可以逐字段更新但嵌套的菜单结构要手动序列化二级菜单读出来还要自己组装树代码冗余且容易出错。List 适合做队列或时间线不适合做随机读的缓存。这里的取舍一句话就能说清数据是整体读取、整体写入的就直接用 String需要频繁改某个字段才考虑 Hash。// 用 RedisTemplate 还是 StringRedisTemplate // 这里用 StringRedisTemplatevalue 本身就是 JSON 字符串 stringRedisTemplate.opsForValue().set(menu:role: roleId, json, 86400L, TimeUnit.SECONDS);StringRedisTemplate的 key 和 value 都是 String不会出现乱码问题。如果你非要用RedisTemplateString, Object记得配 Jackson 序列化器否则 key 会带\xAC\xED前缀在 Redis Desktop Manager 里看着像乱码这是新手经常困惑的点。还有一类高频问题问 Redis 能做分布式锁吗——能但预约场景我用原子 UPDATE 已经满足需求分布式锁留给多实例部署的库存扣减再说不要为了用锁而用锁。5. Vue 前端对接与联调避坑5 个让新手翻车的真实场景5.1 Vue 工程与环境配置接口转发、路由模式与打包进 SpringBoot前端我用 Vue 2.7 Element UI工程用 Vue CLI 创建。开发阶段最头疼的是跨域前后端分别跑在 3000 和 8080 端口浏览器直接发请求会被 CORS 拦。解决方案不是在后端写CrossOrigin而是在 Vue CLI 的vue.config.js里做接口转发// vue.config.js module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, // 后端接口地址 changeOrigin: true, pathRewrite: { ^/api: } // 按后端实际前缀调整 } } } };前端请求/api/auth/logindevServer 会把请求转发到http://localhost:8080/auth/login浏览器感知不到跨域。changeOrigin必须开否则后端收到请求的 Host 还是前端地址某些鉴权逻辑会误判。路径重写在接口前缀不一致时才需要前后端都统一用/api开头时可以不加pathRewrite。Vue Router 建议用 hash 模式History 模式需要后端额外支持部署到 SpringBoot 静态目录后刷新页面容易 404。5.2 axios 拦截器统一携带 token401 时重新登录登录态保持是所有管理系统的共同诉求。axios 封装成全局实例请求拦截器把 localStorage 里的 token 塞进Authorization头响应拦截器统一处理 401。// utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器带上 token 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 ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) // token 失效跳登录页 } Message.error(网络异常请稍后重试) return Promise.reject(error) } )baseURL设为/api开发环境走 devServer 转发生产环境直接命中同域接口。401 跳登录是硬需求后端拦截器发现 token 不存在或过期统一返回 HTTP 401前端就清理本地 token。这里有个细节不要用alert弹错误用 Element UI 的 Message 组件避免阻塞页面。5.3 避坑记录5 个现象、原因与解决过程1. 菜单缓存不生效改了菜单页面没变化。现象是管理员改了菜单名称用户刷新页面还是旧菜单。原因是有缓存的 key 没删Redis 里存的还是旧数据。解决菜单变更接口里调用delete(menu:role: roleId)或者直接flushdb验证一次。我后来在代码里加了日志每次删缓存都打一条方便查。2. MySQL 连不上Docker 启动报端口占用。现象是docker run mysql启动失败报Bind for 0.0.0.0:3306 failed: port is already allocated。原因是本机装了 MySQL 或者上次容器没删。解决检查netstat -ano找占用进程或者改容器端口映射-p 3307:3306同时改应用连接串。如果用的是 MySQL 8.0 镜像记得容器环境变量加-e TZAsia/Shanghai否则时间差 8 小时排班日期对不上。3. Spring Boot 版本太高导致 MyBatis 自动配置失效。现象是启动成功但Mapper注入报NoSuchBeanDefinitionException或者Invalid bound statement。原因是 Spring Boot 3.x 下用了老的mybatis-spring-boot-starter自动配置类没生效。解决换回 Spring Boot 2.7.x mybatis-starter 2.3.x或者改用mybatis-spring-boot-starter3.x 并确认包名是jakarta。做这个项目不建议追新版本能顺畅跑通比版本新更重要。4. 并发预约超卖同一班次约出去超过 total 个号。现象是压测时同一排班预约成功数超过 30订单表出现重复。原因是 Service 层只做了 SELECT 判断没有原子 UPDATE也没有唯一索引。解决把逻辑改成UPDATE schedule SET booked booked 1 WHERE booked total并在appointment表加(user_id, schedule_id)唯一索引。这两步做完再去压测就只剩业务提示了。5. 前端打包放进 SpringBoot 后页面 404。现象是npm run build生成 dist拷到src/main/resources/static后打 jar 包访问首页 404。原因是 Vue Router 用了 History 模式刷新/doctor这种路径时后端没有对应路由。解决Router 改 hash 模式路径变成/#/doctor所有资源由静态目录直接命中。如果用 History 模式后端要加一个转发规则把非/api请求都转到index.html成本高且容易踩漏。6. 上线前最后一步用 curl 与 JMeter 验证预约接口再预热 Redis 缓存6.1 最小验证登录拿 token再发一次预约请求上线前一天我习惯先用 curl 把核心链路跑一遍确认环境和代码是通的再上压测工具。下面的命令串展示从登录到发起预约的最小流程能在浏览器里手动点通不代表接口在真实环境可靠。# 1. 登录取 token curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:patient01,password:123456} # 返回 data.token xxxx # 2. 把 token 放进请求头查科室列表 curl http://localhost:8080/api/department/list \ -H Authorization: Bearer xxxx # 3. 查看某科室下的医生和排班 curl http://localhost:8080/api/schedule/list?doctorId2 \ -H Authorization: Bearer xxxx # 4. 发起预约聚焦 booked total 的排班 curl -X POST http://localhost:8080/api/appointment/create \ -H Authorization: Bearer xxxx \ -H Content-Type: application/json \ -d {scheduleId:10}上面每一条 curl 都在验证一个真实环节第一步验证登录和 Redis 会话写入了第二步验证 token 鉴权第三步验证 MySQL 查询和排序第四步验证事务回滚。第四步故意对一个已经约满的排班发请求应该返回业务错误而不是 500。把这些命令整理成一个 shell 脚本上线时执行一遍5 分钟内能确认环境健康。6.2 Redis 缓存预热与降级启动时刷菜单故障时回源菜单类缓存依赖第一次访问触发回填一旦缓存被清空比如恢复 Redis 数据后第一个访问的用户要等 MySQL 查询完成虽然只慢几百毫秒但高峰期会放大。我一般写一个ApplicationRunner应用启动时主动预热菜单缓存。// MenuCacheWarmer.java 启动预热 Component RequiredArgsConstructor public class MenuCacheWarmer implements ApplicationRunner { private final MenuService menuService; Override public void run(ApplicationArguments args) { // 常见做法启动时把所有角色的菜单都刷一遍 menuService.getUserMenus(0L); // 患者端菜单 menuService.getUserMenus(1L); // 管理员菜单 log.info(menu cache warmed up); } }预热代码不复杂但要注意异常处理Redis 暂时连不上时getUserMenus会走 MySQL 回源项目照样能跑只是性能下降。所以run方法里可以加 try-catch 包住预热逻辑不让缓存故障拖垮启动。这个降级思路是缓存中间件必须接受的现实——缓存挂了系统要能退回直连数据库的可用状态而不是跟着一起死。最后说一个我自己踩过的教训菜单缓存预热我一度只写了患者端漏了管理员端结果管理员一登录就触发全量 MySQL 查询Redis 里根本没有对应 key。后来我列了一个预热清单每个roleId都要刷一遍定时任务每 30 分钟刷新一次菜单 key避免长时间运行后数据不一致。做这类管理系统缓存代码本身不难难的是把每个角色的缓存 key 都覆盖到。把这个预热脚本纳入发布流程上线前后各跑一次能省掉很多“系统刚发布就慢得像蜗牛”的尴尬。希望帮到你。本文还有配套的精品资源点击获取