尧图精选

前后端分离的医院挂号预约管理系统:SpringBoot+Vue实战解析

🕒 发布时间:2026/10/1 22:31:27 📁 来源:尧图网络
先说明一下这套系统的技术含量不在于功能多复杂而在于它把前后端分离、权限控制、数据一致性这些常见需求拧成了一股绳。你拿它做毕设、做课设、甚至改造成企业内部预约平台都能直接落地。1. 整体设计与技术选型1.1 核心需求拆解医院挂号预约管理系统拆开看就是两条业务线一条是 C 端患者的预约流程另一条是 B 端管理员的维护流程。患者侧核心动作是“搜医院、选科室、看排班、约号、查记录”管理员侧核心动作是“维护医院与科室、维护医生与排班、处理号源与停诊”。中间还夹着一个医生角色医生可以看到自己的排班和患者预约列表但不能改号源。所以系统必须有三种角色管理员、医生、患者。权限控制是这类的核心难点。很多新手做这类系统时习惯把角色判断写在每个接口里比如if(role 1)这种写法后期改一处需求就要翻一堆代码。正确做法是在拦截器或过滤器里统一鉴权接口上只声明所需角色业务代码里完全不关心角色判断只关心“这个用户能不能操作这行数据”。一个标准的前后端分离架构SpringBoot 只负责输出 JSONVue 负责页面渲染和路由控制。核心配置我放在下面server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0MyBatis Plus 的逻辑删除必须从第一天就加上不然后面做用户停用、医生离职这种功能会非常痛苦。map-underscore-to-camel-case必须打开否则数据库doctor_name映射不到doctorName字段前端拿到一堆 null。1.2 技术栈选型背后的取舍后端用 SpringBoot 2.7.x 而不是 3.x。原因很简单3.x 基于 Jakarta EE很多老教程里的写法不通用你自己用没问题但网上能查到的资料八成都是 2.x。数据库选 MySQL 8.xORM 层直接用 MyBatis Plus它把分页、条件构造、逻辑删除全封装好了比 JPA 更适合这种 CRUD 密集型业务。权限这块我没用 Shiro 也没用 Spring Security而是自己基于 JWT 写了一套轻量拦截器。不是说不该用框架而是这个项目的权限模型太简单了——三种角色每个接口固定角色JWT 里塞一个role字段就够。引入 Spring Security 意味着你要处理它那张庞大的过滤器链还要配UserDetailsService、PasswordEncoder对项目本身来说属于过度设计。前端用 Vue2 Element UI。为什么不是 Vue3不是 Vue3 不好而是 Vue2 的中文资料和组件库生态太成熟了毕设场景下你遇到的每个报错都能在网上搜到答案。Element UI 的表格、表单、弹窗、日期选择器正好覆盖管理后台的全部需求。前端目录结构我是按模块拆的不是按文件类型拆的src/ api/ // 按业务模块封装axios请求 user.js hospital.js schedule.js appointment.js assets/ components/ router/ index.js permission.js // 全局前置守卫 store/ views/ login/ patient/ // C端页面首页、排班、预约、我的记录 admin/ // B端页面医院管理、医生管理、排班管理 doctor/ // 医生端页面我的排班、我的患者 utils/ request.js // axios封装 auth.js // token存取这个结构最直观的好处是C 端和 B 端页面物理隔离改一端不会误伤另外一端。后端接口也按这个思路分模块controller层里同样分patient、admin、doctor三个包业务边界清晰联调时也好定位问题。2. 数据库设计与核心表结构2.1 设计思路数据库是这类系统的地基。表设计错了后面写接口、写页面全是歪的。我当时设计了九张表user、hospital、department、doctor、schedule、appointment、time_slot、category外加一张系统日志表。核心关联关系是一个医院有多个科室一个科室有多个医生一个医生在多个时段有排班一个排班被多个患者预约。这个逻辑链条必须先从脑子里过一遍再动手建表。说个常见的错误设计有人把医生直接挂在科室下科室挂在医院下看起来没问题但实际业务里一个医生可能同时在多个医院坐诊多点执业所以doctor表里应该存hospital_id而不是通过科室间接关联。这个细节直接决定了你以后扩展功能时要不要重构表。2.2 核心表结构说明用户表这个没什么悬念主键、用户名、密码BCrypt 哈希、角色、手机号、状态。医生表是重点它除了关联医院和科室还要存title职称、intro简介、avatar这些字段直接决定 C 端展示页面的丰富程度。排班表schedule是核心中的核心CREATE TABLE schedule ( id bigint NOT NULL AUTO_INCREMENT, doctor_id bigint NOT NULL COMMENT 医生ID, work_date date NOT NULL COMMENT 出诊日期, time_slot_id bigint NOT NULL COMMENT 时段ID, total_count int NOT NULL DEFAULT 0 COMMENT 总号源, remain_count int NOT NULL DEFAULT 0 COMMENT 剩余号源, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0停诊, deleted tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_doctor_date_slot (doctor_id,work_date,time_slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT医生排班表;uk_doctor_date_slot这个唯一索引非常关键。它保证同一个医生在同一天、同一个时段只能有一条排班记录。如果没用唯一索引程序里就得先查再插并发一高就会产生重复排班数据全乱。数据库层面的约束是你最后一道防线程序里的判断都可能有漏洞但唯一索引不会。号源用total_count和remain_count两个字段不要在预约表里去count(*)计算余号。医疗系统的号源必须要快照因为预约表的数据量会越来越大每次实时 count 会越来越慢。排班的remain_count就是当前状态的快照预约成功就减一取消预约就加一简单直接。2.3 时段表与日期生成策略时段表time_slot是预先定义好的固定时间段比如上午 08:00-08:30、08:30-09:00……下午 14:00-14:30具体粒度看需求。排班不是按天生成的而是按“日期 时段”生成。管理员操作时选定一个医生、一个日期范围、勾选时段、填号源数量后端循环生成多条排班记录。生成排班之后会遇到一个问题如果医生一周前就排了下周一的班但下周一临时有事需要停诊。这时候不是删掉排班数据而是把status改成0停诊。已经预约该时段的人要么在 C 端看到停诊提示并收到通知要么在系统里做自动改签。真正的生产环境还有短信通知但毕设场景里做一个站内消息提醒就足够了。3. 后端核心接口与关键实现3.1 JWT 登录与权限拦截登录接口的逻辑不复杂根据用户名查用户用 BCrypt 验证密码通过后生成 JWT。JWT 的 payload 里我放了三个字段userId、role、name。不放太多信息JWT 本身是 Base64 编码的可以被解码出来放敏感信息就是裸奔。生成 JWT 的工具类用的是io.jsonwebtoken:jjwt这个库从 0.9.1 版本开始 API 就有变化网上教程经常混着讲。我用的是 0.11.5 版本构建和解析方式如下// 生成token private String createToken(MapString, Object claims, String subject) { return Jwts.builder() .setClaims(claims) .setSubject(subject) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }注意如果你的 jjwt 版本是 0.11.xsignWith传的是SecretKey对象而不是字符串。字符串密钥长度不能小于 256 位32 字节不然启动会报Key length must be at least 256 bits。这个坑我踩过当时密钥写了个my-secret启动直接崩。拦截器里要做两件事一是校验 token 有效性并解析出用户信息放入ThreadLocal二是根据接口上的RequireRole注解做角色校验。业务代码里只需要从ThreadLocal里拿当前用户不需要再解析 token。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行OPTIONS预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); // 解析token失败则抛出未登录异常 // 解析成功后将用户信息存入UserContext } }这里的OPTIONS预检请求必须放行否则前后端分离部署时浏览器跨域请求会直接 403。我在联调阶段因为漏了这行代码前端所有非简单请求全部失败排查了半天才看到控制台明明有请求但后端日志里根本没进来。3.2 排班管理与号源生成排班接口是管理端的核心。设计上要支持两种录入方式单日排班和批量排班。批量排班的场景是医生固定每周一、三、五上午出诊管理员选好重复规则后端一次生成多条。批量的实现逻辑不复杂核心是“先查重再插入”。我用了双重校验插入前用countByDoctorIdAndDate判断当天该时段是否已有排班插入时再靠数据库的唯一索引兜底。如果出现重复插入捕获DuplicateKeyException并返回友好提示。直接让异常抛给前端显示 500 页面用户根本看不懂。号源数量建议细化到住院号、复诊号、初诊号或者直接就是普通号、专家号。最简单的做法是给department表加一个default_count字段管理员创建排班时自动带出默认号源数不用每次手敲。C 端查询排班时只查status 1 且 remain_count 0 且 work_date 今天的记录。用户看到的界面是一个星期几的排班表格可以按日期翻页。对应 SQL 用 MyBatis Plus 的QueryWrapper就能搞定不需要手写 XMLLambdaQueryWrapperSchedule wrapper new LambdaQueryWrapper(); wrapper.eq(Schedule::getDoctorId, doctorId) .eq(Schedule::getStatus, 1) .gt(Schedule::getRemainCount, 0) .ge(Schedule::getWorkDate, LocalDate.now()) .orderByAsc(Schedule::getWorkDate);3.3 在线预约的事务与并发控制预约接口是整个系统中并发压力最大、最容易出错的地方。核心流程是校验患者是否登录校验排班是否存在且未停诊校验剩余号源如果是复诊还要校验是否已在该医生这里约过然后创建预约记录最后扣减号源。这四步如果按顺序请求再逐条执行并发一高就会超卖。两个用户同时看到号源还剩 1 个同时点预约都通过了校验结果都成功了号源变成 -1。解决办法有两个方向乐观锁或唯一索引兜底。我是两个都用了。第一步用乐观锁扣减号源int updated scheduleMapper.reduceRemainCount(scheduleId); if (updated 0) { throw new ServiceException(号源已被约满请选择其他时段); }对应的 SQL 长这样UPDATE schedule SET remain_count remain_count - 1, version version 1 WHERE id #{id} AND remain_count 0这里的关键是remain_count 0这个条件。如果更新的影响行数为 0说明这条排班的号源已经被抢光了直接抛异常。该操作必须在开启事务的方法里执行且在插入预约记录之前完成。这样的顺序能保证预约占用了号源INSERT 失败时再回滚把号源加回来。第二步是重复预约拦截。用户重复提交、刷新页面导致重复 POST单靠前端按钮禁用挡不住恶意请求。我在appointment表上加了一个唯一索引UNIQUE KEY uk_patient_schedule (user_id, schedule_id)两个并发请求同时插入同一条记录时只有一条能成功另一条抛DuplicateKeyException捕获后提示“您已预约过该时段”。这个方案简单粗暴但极其有效。预约成功后的返回值不仅要包含预约记录 ID还要返回号源剩余数前端拿到后立即刷新排班页面的余号显示。不然用户成功预约后页面还停留在原来的余号数字上刷新太慢会造成二次误操作。3.4 Redis 缓存与验证码Redis 在这个项目里承担三块职责图形验证码存储、短信验证码存储如果有、热门医生排班数据的缓存。图形验证码我用的Kaptcha生成图片验证码文本存 Rediskey 是captcha:{uuid}过期时间 5 分钟。登录时前端把uuid和验证码一起提交后端从 Redis 取出比对比对完立即删除防止同一验证码被重放。不要用 Session 存验证码前后端分离后 Session 的跨域问题会让你怀疑人生。热门科室的医生排班列表是可以缓存的。比如某个三甲医院的热门科室患者搜索量大每次查排班都打到 MySQL 不划算。我用 Redis 存一份“科室 日期 → 排班摘要”的缓存过期时间 5 分钟排班变化后手动删缓存。实际测试下来首页接口响应时间从 300ms 降到了 80ms 左右感知很明显。4. 前端 Vue 实现的关键细节4.1 路由与权限守卫前端路由按角色拆成三个模块patientRoutes、adminRoutes、doctorRoutes。不是一开始全部注册而是在登录后根据角色动态添加路由。用 Vue Router 4Vue2 对应 Router 3的addRoutes或者addRoute方法逐个注册。全局前置守卫是这套系统的安全闸门router.beforeEach((to, from, next) { const token getToken() if (!token) { if (to.path /login) return next() return next(/login) } if (to.path /login) return next(/) // 动态路由已注册直接放行 if (hasRoutes) return next() // 首次进入根据角色动态注册路由 generateRoutes().then(accessRoutes { router.addRoutes(accessRoutes) next({ ...to, replace: true }) }) })核心逻辑是先判断有没有 token有 token 再判断路由是否加载过。第一次进入系统时后端返回该用户的角色和菜单权限前端根据角色动态生成可访问路由表然后用router.addRoutes注册。配合后端的接口鉴权双保险。一个很容易被忽略的坑是动态路由注册后直接next()可能会导致页面空白因为当前要跳转的路由在addRoutes之前还不在路由表里。正确的做法是next({...to, replace: true})让 router 重新匹配一次。4.2 axios 封装与请求拦截Axios 封装是前端所有接口请求的入口。我在utils/request.js里做了几件事创建实例设置baseURL和超时时间、请求拦截器里从 Cookie 或 LocalStorage 取 token 塞到请求头、响应拦截器里统一处理错误码。const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config { if (getToken()) { config.headers[Authorization] getToken() } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message({ message: res.message, type: error }) if (res.code 401) { // token失效清除登录状态并跳转登录页 } return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { // 处理未授权 } return Promise.reject(error) } )后端返回格式我是统一定义的{ code: 200, message: success, data: {...} }。前端只认 code 字段HTTP 状态码基本只用 200 和 401 两种。这个约定要从前端到后端贯穿一致否则联调时每个接口都在对字段效率极低。有个实战教训响应拦截器里直接Message.error每次都弹窗如果接口报错时前端又跳转路由会出现一连串重复弹窗。我后面改成只对用户直接操作触发的请求弹窗比如预约、取消这类 POST 请求页面初始化时拉的列表数据报错则只 console 不打扰用户。4.3 核心业务页面实现C 端首页是搜索框 医院列表 科室入口。搜索支持医院名称模糊查询和科室名称查询。这里一定要防抖用户在搜索框里连续输入时节流到 300ms 后才发一次请求。不用做很复杂直接一个debounce函数包一层就行。排班日历页是整个系统前端最难写的部分。难点不在逻辑而在 Element UI 的日期组件限制。排班日期只能选今天和未来 7 天的日期所以要用picker-options禁用过去的日期pickerOptions: { disabledDate(time) { return time.getTime() Date.now() - 8.64e7 } }选完日期后同一日期下展示该医生的上午/下午各时段排班卡片每张卡片显示时段、余号和“立即预约”按钮。余号为 0 或排班停诊时按钮置灰并显示对应文案。这里要用v-loading控制列表加载状态不然切换日期时旧数据还在页面上闪过。预约确认页要显示完整的预约信息医院名称、科室、医生、职称、出诊日期、时段。用户确认后点击“提交预约”成功后展示预约号这个预约号我直接用预约表主键格式化生成。不要用自增 ID 裸奔展示至少要拼个业务前缀比如YY20250101001。个人中心分为两块我的预约列表和取消预约。取消预约逻辑在前端再加一次二次确认弹窗防止误触。取消后后端把号源加回来列表状态变成“已取消”。这块必须同步刷新列表的剩余号源数字前端可以在取消成功的回调里重新拉一次排班详情。5. 联调部署与问题排查实录5.1 跨域配置与前后端联调前后端分离开发时最痛的问题是跨域。后端端口 8080前端开发环境端口 8081端口不同就是跨域。用 Vue CLI 的话在vue.config.js里配代理不要把浏览器访问的所有接口都直接打到后端module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样前端请求/api/user/login会被代理到后端的/user/login浏览器里看不到跨域身影。但后端 CORS 配置依然要加因为生产环境前端静态文件可能通过 Nginx 代理转发。我后端加了一个全局 CORS 配置类允许所有来源生产环境限定域名。联调阶段最容易遇到的坑是日期格式。后端返回LocalDateTime默认序列化格式是2025-01-08T10:30:00前端 Element UI 的el-date-picker显示会出问题。我在后端做了全局 Jackson 配置Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder - { builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; }还有一个前端时间显示问题后端返回的2025-01-08字符串在 JS 里 new Date 解析时如果传的是连字符格式Safari 会解析失败返回 Invalid Date。这是因为 Safari 不支持这种格式的严格解析。解决办法是前端统一用dayjs或自己写一个格式化函数不要依赖原生 Date。5.2 生产部署方案生产环境我用的是经典三件套Nginx 托管前端静态文件反向代理后端接口后端连 MySQL 和 Redis。Nginx 配置是核心server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那行必须加。Vue 打包后是纯静态文件直接访问根路径没问题但刷新/patient/schedule这种深层路径时Nginx 找不到对应的静态文件会返回 404。try_files的作用是找不到文件就回退到index.html让 Vue Router 接管。proxy_pass后面的斜杠也很关键。http://localhost:8080/末尾带斜杠表示把/api/前缀替换为空如果不带斜杠后端接收到的路径会变成/api/user/login而不是/user/login。我在 Nginx 配置上吃过这个亏前端登录页反复报 404。后端启动时用nohup java -jar hospital-0.0.1-SNAPSHOT.jar app.log 21 挂后台注意加上-Dfile.encodingUTF-8参数防止日志中文乱码。用 SpringBoot Maven 插件打包前先把前端 build 出来的dist目录拷到后端项目的static目录下这样也可以做成单包部署。5.3 常见问题速查表现象排查方向解决办法前端登录成功后刷新页面报 404Nginx 没有 fallback 到 index.html配置try_files $uri $uri/ /index.html跨域请求被拦截后端未放行 OPTIONS 预检请求拦截器里判断 OPTIONS 直接返回 true同一个患者重复下单成功预约表缺唯一索引加uk_user_schedule唯一索引并捕获重复键异常排班接口查询慢数据量大且缺少索引work_date和doctor_id分别建普通索引日期显示为2025-01-08T10:30:00后端 LocalDateTime 序列化格式问题配置全局 Jackson 日期格式化打包后 static 资源访问 404Maven 构建未包含前端静态资源用 maven-resources-plugin 将 dist 拷入 resources端口被占用本地已启动其他服务lsof -i:8080找到进程并杀掉Redis 连接失败Redis 服务未启动或密码错误检查 Redis 状态配置 spring.redis.passwordVue 页面白屏无报错路由模式配置错误history 模式下需要 Nginx 配合改用 hash 模式最保险这里重点说说分布式下的数据一致性问题。我在写预约接口时遇到过“事务内调用外部服务导致事务失效”的情况。原因是 Spring 事务代理默认只在方法内部通过代理对象调用时才生效。如果同一个类里的方法 A 调用方法 BB 上的Transactional注解不会生效。正确的做法是把扣号源和创建预约记录的逻辑放在不同的 Service 类里或者将实现拆到不同的方法中通过代理调用。我后来把预约流程拆成了AppointmentService.create()和ScheduleService.reduceCount()用编程式事务TransactionTemplate兜底。5.4 部署后必须验证的边界场景部署完成后不能只测一遍主流程就完事需要把边界场景过一遍。我整理了一份冒烟测试清单未登录时直接访问预约接口后端必须返回 401前端跳转登录页。已预约的患者再次点击同一个排班的预约按钮后端必须返回友好提示不能报 500。管理员停诊后患者端排班日历不能再显示该时段已预约记录必须展示停诊标记。号源数量为 1 时两个账号同时预约同一排班最终只能成功一条失败方提示号源不足。取消预约后再重新预约同一排班号源数量要正确恢复预约记录不能被唯一索引误拦截。这些场景覆盖了大部分会导致线上事故的逻辑漏洞。尤其是第二条和第三条很多系统在正常流程里测不出来但一旦发生就是用户直接投诉。实际使用下来的体会说句实在话这套系统我自己独立开发到上线前后花了大概三周时间。最花时间的不是写代码而是处理各种“看似没问题但实际就是不对”的边界情况。比如号源超卖、重复下单、停诊后的用户通知这些才是系统的灵魂。很多教程demo只教你写好 CRUD但真正评价一个管理系统合不合格的是数据倒灌、并发请求、异常降级这些极端场景下的表现。给后面做类似项目的朋友一个建议先花两天时间把数据库表结构和接口文档定死再开始写前后端代码。我最早就是上来直接建表写代码写了一半发现预约和排班的关联逻辑有歧义被迫改了 4 张表的结构浪费了整整一天。契约先行的成本最低返工的代价最高。如果后续想扩展这个项目可以做三个方向一是接入支付实现线上缴费二是用 WebSocket 做排队叫号实时通知三是把排班算法升级成基于医生工作量的智能排班。每一步都不难但都会让项目从“课设水平”变成“能用的产品”。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →