医疗挂号管理系统源码详解:SpringBoot+Vue3+MyBatis实现
2026/9/15 9:15:58 网站建设 项目流程

做医疗挂号管理系统,光听名字就知道是个典型的管理系统:科室、医生、号源、挂号、退号、统计,全是最常见的CRUD,但真正落地时,最烧脑的不是写增删改查,而是怎么把业务状态机理清楚、怎么在并发挂号场景下不超号。这套基于 SpringBoot + Vue3 + MyBatis + MySQL 的医疗挂号管理系统源码,采用了前后端分离架构,后端提供 RESTful 接口,前端用 Vue3 单页应用承载业务页面,数据库用 MySQL 存储业务数据。它解决的问题很具体:让患者可以在线查看科室、医生排班、选择号源并完成挂号,让医院管理人员可以维护科室、医生、排班、号源和基础用户数据,还能从挂号记录里拉出统计报表。适合在校学生拿来当毕设项目、Java 开发人员做全栈项目练习,也适合中小型门诊做一次真实业务系统的开发样本。

我拿到一套源码时,一般不会急着往上加功能,而是先把它拆开,理解每个模块存在的理由。这样后面改源码、二次开发、甚至重构,才不会改一处崩三处。下面我就按自己的理解,把这套系统的技术选型、架构设计、核心业务实现、本地部署步骤和常见问题完整过一遍。

1. 项目定位与技术选型:为什么是这套组合

1.1 系统功能清单与目标用户

医疗挂号管理系统面向三种角色:管理员、医生、患者。管理员负责基础数据维护,比如科室管理、医生管理、排班管理、号源设置、用户管理,还有每日挂号量和科室就诊量的统计。医生登录后可以看自己的排班,查看自己某个时间段内挂了多少号,也可以根据业务需要调整个人简介或部分排班信息。患者登录后可以浏览科室列表、医生列表,查看医生排班和剩余号源,然后在线挂号,也可以在我的挂号单里取消尚未就诊的号。

这三类角色对应的功能并不是互相独立的,它们围绕同一条业务主线展开:科室维护 -> 医生维护 -> 排班生成 -> 号源扣减 -> 挂号记录生成。源码里的菜单、接口、表结构基本都是沿着这条主线设计出来的。理解了这个主线,后面看任何代码都不会迷路。

从适用范围来说,这套系统不只适用于医院,社区卫生服务中心、体检中心、口腔诊所、宠物医院这类需要“按医生/按时间段预约”的场景,也基本能直接复用。把科室改成“体检套餐”、医生改成“体检医师”,挂号改成“预约登记”,核心流程就平移过去了。这也是我希望你在看源码时重点研究的点:不要把它当成一个只能跑在医院的程序,而要把它当成一套“资源预约系统”去理解。

1.2 技术栈分工与选型理由

后端用 Java + SpringBoot,这是目前国内中小型管理系统最主流的技术栈。SpringBoot 解决了传统 Spring 项目里繁重的 XML 配置问题,内嵌 Tomcat,打成 jar 包就能跑,接口开发效率很高。MyBatis 作为持久层框架,核心优势是把 SQL 控制权完全交给你,尤其适合这种业务规则多、查询条件动态变化的系统。比如“按科室、按日期、按医生名称、按号源状态”组合查询,MyBatis 的动态 SQL 写起来很直观,SQL 调优也方便,不像 JPA 那样会在复杂查询上绕弯子。

前端选择 Vue3 而不是 Vue2,一方面是 Vue3 的组合式 API 让组件逻辑复用更清晰,另一方面是现在 Element Plus、Vite 等周边生态已经非常成熟。Vue3 + Vite 的开发体验比 Vue2 + Webpack 快很多,热更新基本秒级,对需要频繁调整页面和接口联调的开发场景非常友好。前端状态管理一般用 Pinia,路由用 Vue Router,UI 组件库用 Element Plus,这三样几乎成了 Vue3 管理系统的标准组合。

MySQL 负责持久化,理由简单:免费、稳定、资料多。这类挂号系统的并发量远没到需要上分布式数据库的程度,MySQL 单库单表做好索引,完全够用。源码里一般把数据库名命名为hospital_registration或者medical_registration,字符集建议统一utf8mb4,这样才能稳妥地支持中文和生僻字。

1.3 前后端分离的落地形态

前后端分离不是简单地“前端一套代码、后端一套代码”,它意味着两端通过 JSON 接口通信,状态通过 Token 维护。后端只做业务逻辑和数据持久化,前端负责页面渲染、表单校验、路由跳转。这套系统的典型请求链路是:浏览器访问 Vue3 页面 -> 前端调用 Axios 发起请求 -> 请求经过 Vite 代理或 Nginx -> 到达 SpringBoot Controller -> Service 处理业务 -> MyBatis 操作 MySQL -> 返回 JSON -> 前端渲染。

分离的好处是前后端可以并行开发、独立部署,后端接口可以用 Postman 直接测试,前端页面可以用 Mock 数据先跑起来。对学习的人来说,分离也让职责边界更清晰:你不会在一个 JSP 页面里塞满 Java 代码,也不会在 Controller 里拼接 HTML。源码中如果已经做好了跨域配置和登录拦截,就说明项目在架构上不只是“能用”,而是考虑了真正部署时的可维护性。

2. 整体架构与核心模块拆解

2.1 后端工程结构与分层

一套规范的 SpringBoot 工程,源码目录一般长这样:

com.example.hospital ├── config │ ├── CorsConfig.java │ ├── WebMvcConfig.java │ └── MybatisPlusConfig.java(如果集成了 MyBatis-Plus) ├── controller │ ├── DeptController.java │ ├── DoctorController.java │ ├── ScheduleController.java │ ├── AppointmentController.java │ └── AuthController.java ├── service │ ├── DeptService.java │ ├── DoctorService.java │ ├── ScheduleService.java │ ├── AppointmentService.java │ └── impl ├── mapper │ ├── DeptMapper.java │ ├── DoctorMapper.java │ ├── ScheduleMapper.java │ └── AppointmentMapper.java ├── entity │ ├── SysUser.java │ ├── Department.java │ ├── Doctor.java │ ├── Schedule.java │ └── Appointment.java ├── common │ ├── Result.java │ ├── ResultCode.java │ └── BusinessException.java ├── interceptor │ └── JwtInterceptor.java ├── utils │ ├── JwtUtils.java │ └── PasswordUtils.java └── HospitalApplication.java

分层上没有强求“Service 必须拆接口和实现”,但在稍微复杂的业务里,接口 + impl 是惯例。Controller 只负责参数接收和结果封装,Service 处理业务规则,Mapper 只做数据访问。实体类与数据库表字段基本一一对应,常见字段比如create_timeupdate_time会统一处理。Result是统一返回体,一般包含 code、message、data 三个字段,前端 Axios 拦截器也是根据这个结构判断请求是否成功。

2.2 核心业务链路:从挂哪个科到号源落库

这套系统的核心链路我建议你反复看:科室 -> 医生 -> 排班 -> 号源 -> 挂号单。

患者的操作路径是:登录系统 -> 进入科室列表,找到对应科室 -> 查看该科室下的医生列表 -> 选择某位医生 -> 查看该医生的排班日期和时段 -> 点挂号 -> 系统扣减号源 -> 生成挂号记录。管理员的操作路径是:维护科室信息 -> 维护医生并归属到科室 -> 为医生创建排班,设置总号数和剩余号数 -> 查看每日号源消耗。两条路径最后汇聚到appointment表,也就是挂号记录表。

我在看源码时,最关心的是“号源扣减”这一步。很多入门项目会把挂号写成“先 select 剩余号,判断大于 0,再 update 号源,再 insert 挂号”,这种写法在单用户测试时没有问题,但在真正多用户并发下,两个请求同时读到剩余号数为 1,就会都通过判断,最后把号源减成 -1,超挂。这个系统如果做得好,会在 update 语句里直接带remaining_number > 0条件,用数据库的行锁来保证不会超挂。这一点也是面试官最喜欢问的高频考点。

2.3 数据库设计要点:表结构、字段与索引

数据库设计是整个系统的地基,表结构设计不合理,后面写接口和调排查都会很痛苦。这套系统的核心表一般包括:

表名核心字段作用
sys_userid, username, password, role, real_name, phone登录用户,角色区分管理员/医生/患者
departmentid, dept_name, dept_desc, status科室信息
doctorid, dept_id, doctor_name, title, avatar, intro医生信息,关联科室
scheduleid, doctor_id, dept_id, schedule_date, start_time, end_time, total_number, remaining_number, status医生排班与号源
appointmentid, patient_id, doctor_id, schedule_id, appt_date, time_slot, status, create_time挂号单,关联排班和患者

字段命名一般用下划线风格,比如dept_idcreate_time,这样配合 MyBatis 的map-underscore-to-camel-case配置,可以自动把下划线映射成实体的驼峰属性,省去大量手动映射。

索引方面,我建议至少补上这几条:

ALTER TABLE doctor ADD INDEX idx_dept_id (dept_id); ALTER TABLE schedule ADD INDEX idx_dept_doctor_date (dept_id, doctor_id, schedule_date); ALTER TABLE appointment ADD INDEX idx_patient_id (patient_id); ALTER TABLE appointment ADD INDEX idx_schedule_id (schedule_id); ALTER TABLE appointment ADD INDEX idx_status (status);

原因很简单:患者的查询场景一般是“先按科室查医生,再按医生查排班日期,再按排班查号源”,挂号记录查询场景一般是“查某个患者的所有挂号单”或“管理后台按状态筛出所有记录”。没有索引,数据量上来之后,联表查询和条件筛选会越来越慢。MySQL 在数据量小的时候不明显,但索引是必须提前埋好的伏笔。

3. 关键功能实现:从登录鉴权到挂号扣号

3.1 JWT 登录鉴权与角色权限控制

挂号系统涉及患者隐私和医院数据,不可能让所有人都直接访问接口。这套系统的登录鉴权一般用 JWT 方案:用户登录成功后,后端生成一个带用户 ID 和角色的 Token 返回给前端;前端把它存到 localStorage 或 Pinia 里;后续每个请求在请求头带上Authorization: Bearer <token>;后端通过拦截器校验 Token,解析出当前用户信息。

JWT 工具类的大致写法如下:

public class JwtUtils { private static final String SECRET = "你的签名密钥"; private static final long EXPIRE_TIME = 2 * 60 * 60 * 1000; public static String generateToken(Integer userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }

拦截器要做两件事:一是放行登录接口和静态资源,二是校验非放行接口的 Token。角色权限可以放在 Controller 的方法上,也可以直接在拦截器里判断 URL 前缀。比如/api/admin/**只允许管理员访问,/api/doctor/**只允许医生访问。一般不建议在这套系统里直接引入 Spring Security,因为配置复杂度会明显上升;用 JWT + 拦截器 + 角色判断,最贴合中小型管理系统的实际需求。

前端部分,Vue3 里需要在 Axios 请求拦截器里统一加上 Token:

axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })

同时用 Vue Router 的全局前置守卫做登录控制:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })

这套组合写起来简单,但已经能覆盖绝大多数业务系统的登录状态管理需求。

3.2 号源扣减与防超挂的设计

挂号是并发敏感操作,最常见的问题是同一个号源被多个患者同时抢到。源码里如果做得规范,会用一个带条件的 update 来解决,而不是在代码里先查询再更新。

Service 层核心逻辑:

@Transactional(rollbackFor = Exception.class) public boolean createAppointment(AppointmentDTO dto) { int rows = scheduleMapper.decreaseRemaining(dto.getScheduleId()); if (rows == 0) { throw new BusinessException("当前号源已挂完,请选择其他时段"); } Appointment appointment = new Appointment(); appointment.setPatientId(dto.getPatientId()); appointment.setDoctorId(dto.getDoctorId()); appointment.setScheduleId(dto.getScheduleId()); appointment.setStatus("已挂号"); appointmentMapper.insert(appointment); return true; }

对应的 MyBatis XML:

<update id="decreaseRemaining"> UPDATE schedule SET remaining_number = remaining_number - 1 WHERE id = #{scheduleId} AND remaining_number > 0 </update>

这里的关键是 MySQL 的UPDATE会对匹配的行加锁,两条并发请求同时执行时,第二条会等第一条提交后再执行,此时remaining_number已经被减掉,条件remaining_number > 0就可能不成立,影响行数为 0,于是抛业务异常。这个方案不需要分布式锁,也不需要 Redis,单库场景下完全是够用的。

同时还要在appointment表上加一个唯一约束,防止同一个患者对同一个排班重复挂号:

ALTER TABLE appointment ADD UNIQUE KEY uk_patient_schedule (patient_id, schedule_id);

有了这个约束,就算前端双击提交、用户刷新后重复提交,数据库层也能兜住最后一层防线。取消挂号时也要做相反操作,在同一个事务里把挂号单状态改成“已取消”,并把对应排班的remaining_number加回去。

3.3 Vue3 前端核心页面与接口对接

前端我用 Vue3 + Vite + Element Plus 重构过类似页面,体验确实比 Vue2 时代舒服很多。创建项目可以用:

npm create vite@latest hospital-frontend -- --template vue cd hospital-frontend npm install npm install element-plus axios vue-router pinia

页面结构一般包含登录页、首页布局、科室管理、医生管理、排班管理、挂号页面、我的挂号单、统计页面。核心的挂号页面通过接口动态加载数据,流程大概是:

  1. 进入页面后,onMounted里请求科室列表
  2. 选择科室后,请求该科室下的医生列表
  3. 选择医生后,请求该医生的排班列表
  4. 排班列表中显示日期、时段、剩余号数,点击“挂号”按钮时调用后端接口

排班列表的关键展示字段是“剩余号源”,这个值来自后端schedule.remaining_number。源码里如果对已满的排班做了禁用按钮处理,细节就到位了。更完整的做法是后端在返回列表时额外返回isFull字段,前端根据这个字段控制按钮状态,而不是在前端拿剩余号数自己判断,因为判断逻辑应该以服务端为准。

Axios 请求和响应拦截器也是前端联调的关键。响应拦截器一般这样写:

axios.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { ElMessage.error('网络异常,请检查后端服务') return Promise.reject(error) } )

统一错误提示最大的好处是,后端只要把错误信息放进message,前端不用在每个页面都重复写try/catch

4. 实操部署与运行:从源码到本地跑起来

4.1 环境与版本匹配建议

我把这套系统在本地跑起来的经验拆成几步来说,先从环境版本说起。不同源码用的版本可能不一样,最容易踩坑的是 SpringBoot 版本和 JDK 版本不匹配。

组件建议版本说明
JDK8 或 11SpringBoot 2.x 用 JDK 8/11 都行
Maven3.6+后端依赖管理
Node.js16.20+ 或 18+Vue3 + Vite 对 Node 版本有要求
MySQL5.7 或 8.0建议 8.0,字符集 utf8mb4
IDEIntelliJ IDEA前后端都建议用 IDEA

如果你拿到的源码是 SpringBoot 3.x,那 JDK 必须用 17 或更高,因为 SpringBoot 3 基于 Spring Framework 6,底层要求 Jakarta EE 9+,不再兼容 JDK 8。反过来,如果你是 JDK 17 却拿了一套 SpringBoot 2.3 的源码,也可能由于旧版依赖和 JDK 版本兼容问题导致启动异常。看源码第一件事,先打开pom.xml确认 SpringBoot 版本,然后配对应 JDK,这个动作能省下很多折腾时间。

4.2 MySQL 初始化与数据导入

先确保 MySQL 服务已经安装并启动。然后创建一个数据库,并导入源码附带的 SQL 文件:

CREATE DATABASE hospital_registration DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hospital_registration; SOURCE /你的目录/hospital_registration.sql;

如果是在 Navicat 或 DataGrip 里操作,也可以直接新建数据库后,右键运行 SQL 文件。导入完成后,重点检查三类表:用户表、科室医生表、排班挂号表。源码里一般会预置几个演示账号,比如管理员、医生、患者各一个,方便启动后直接登录测试。如果 SQL 文件里没有,那就需要自己在sys_user表手工插入一条管理员记录,密码字段通常存的是 BCrypt 加密后的字符串,不要直接写明文。

4.3 后端启动配置与 MyBatis 常见设置

后端启动前,需要修改application.yml里的数据库连接信息:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_registration?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你自己的密码 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.hospital.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这里我建议开发阶段一定打开map-underscore-to-camel-case,并开启 SQL 日志。开启日志后,控制台会打印出每条 SQL 和参数,调试业务逻辑时能看到实际执行的语句,排查问题效率会高很多,这也是很多人容易忽略的一点。

数据库连接 URL 里有几个参数值得留意:serverTimezone=Asia/Shanghai解决时区问题,useSSL=false避免 MySQL 8 连接时 SSL 警告,allowPublicKeyRetrieval=true解决连接 MySQL 8 时偶尔出现的 public key retrieval 报错。这三个参数我建议直接保留。

启动后端时,可以在 IDEA 里直接运行主类,也可以打包后运行:

mvn clean package -DskipTests java -jar target/hospital-system-1.0.jar

后端启动成功的标志是控制台出现 Tomcat started on port(s): 8080。启动失败时优先看日志里的红色报错,大部分情况不是端口被占用,就是数据库连接不上。

4.4 前端启动与接口代理配置

前端进入项目目录,先装依赖:

npm install

如果安装速度很慢,可以临时切换镜像源,或者在项目根目录创建.npmrc文件配置国内镜像。

启动开发服务器:

npm run dev

默认访问地址一般是http://localhost:5173/。前后端联调时,前端要解决跨域问题。最方便的思路是使用 Vite 的 dev server 代理,把/api开头的请求转发到后端8080端口。在vite.config.js中配置:

export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

这样前端请求/api/login时,Vite 会自动转发到http://localhost:8080/api/login,浏览器端看起来是同一个域名,跨域问题就绕过去了。如果后端接口路径没有统一/api前缀,就需要和后端约定好,或者调整代理规则。很多刚接触前后端分离的人在这里反复踩坑,明明后端接口能通,但前端页面一调就报跨域,原因往往就是代理路径和后端实际路径没对齐。

5. 常见问题与排查技巧实录

5.1 后端接口跨域与 401 登录失效

跨域问题最常见的表现是:浏览器控制台提示Access-Control-Allow-Origin,或者请求状态是CORS error。如果你不走 Vite 代理,而是直接用http://localhost:8080请求后端,那后端必须配置跨域。SpringBoot 里可以写一个配置类:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); } }

这里有个细节:如果开启allowCredentials(true),就不能把allowedOrigins写成*,必须写具体域名。写*时浏览器会认为请求不安全,依旧报跨域错误。所以我建议直接明确填前端地址,开发阶段填http://localhost:5173,部署阶段填正式域名。

登录失效的问题则通常和 Token 过期时间或者拦截器放行配置有关。如果前端发现接口每隔一段时间就 401,先检查JwtUtils里的过期时间是否设置得太短;如果某些接口不需要登录却被拦截了,则检查拦截器是否只拦截/api/**而不是拦截所有路径。

5.2 MyBatis 查询结果字段为 null 或动态 SQL 失效

做这套系统时,MyBatis 里最容易出的问题有两个。第一个是实体属性有值但数据库字段查出来是 null,大多数情况是数据库字段是下划线命名,比如doctor_name,实体属性是doctorName,而 MyBatis 没有开启驼峰映射。加了map-underscore-to-camel-case: true之后,大部分问题可以解决。如果遇到多表联查的返回值不是实体类,而是自定义 DTO,那 XML 里就不能只写resultType,通常需要配resultMap做字段映射,或者给 SQL 查询字段起与 DTO 属性一致的别名。

第二个常见坑是 MyBatis 比较单个数字字符时行为不符合预期。比如 XML 里写:

<if test="status == 1">

如果status是字符串类型,MyBatis/OGNL 可能把1当成字符常量'a'之类的值去比较,导致条件一直不生效。遇到这种情况,我的习惯是改成:

<if test='status != null and status == "1"'>

或者直接在 Java 代码里把参数类型转成 Integer,再到 XML 里比较。这个细节很偏,但一旦遇到就会卡很久,搜“mybatis 单个数字字符比较”能搜出一堆同款问题。

5.3 Vue3 页面刷新 404 与打包部署

本地开发时,Vue Router 如果用createWebHistory(),在浏览器地址栏直接刷新某个子路由页面,可能会出现 404。原因是开发服务器没有把未知路径回退到index.html。Vite 开发环境一般会自动处理,但打包后部署到 Nginx 时一定要注意配置:

location / { try_files $uri $uri/ /index.html; }

这样做是让 Nginx 在找不到对应文件时,把所有路径交给 Vue Router 处理。如果你不想处理这个配置,也可以把 Vue Router 改成createWebHashHistory(),地址栏会变成/#/appointment的形式,刷新不会 404,不过地址看起来不够干净。生产环境我一般建议用 history 模式 + Nginx 回退配置,这样更规范。

打包前端时执行:

npm run build

生成dist目录后,把里面的文件放到 Nginx 的html目录下,后端 jar 包单独运行,或者用 Nginx 反向代理把/api请求转发到后端端口。整个部署链路就是:前端静态文件由 Nginx 托管,后端接口跑在8080,Nginx 负责把/api请求代理过去。这也是前后端分离最常见的生产部署形态。

5.4 源码改造与功能扩展建议

跑通源码之后,如果你还想继续往上加功能,我建议优先做这几个方向:第一,把验证码从普通的图片验证码升级为 Redis 存储的短信验证码,这需要引入 Redis,但能明显提升预约场景的真实感;第二,增加挂号费的在线支付模拟,在挂号单表里加一个支付状态字段,下单后走模拟支付回调;第三,把管理后台的统计页面做成图表可视化,前端用 ECharts,后端新增聚合查询接口,统计每日挂号量、科室挂号占比、医生工作量等数据。

这几个扩展方向都不会破坏原有表结构,都是在现有业务链路上加新字段、新接口、新页面。源码最值得学习的地方,恰恰是它给你搭好了一套完整的业务骨架,你在上面添砖加瓦时,能清楚看到每一块砖应该放在哪里。

如果你现在正在跑这套系统,最后分享一个我调试挂号模块时的土办法:打开 MyBatis 的 SQL 日志,然后在挂号按钮上操作一次,复制控制台打印出来的 update 和 insert 语句,直接拿到 Navicat 里手动执行一遍。尤其是update schedule set remaining_number = remaining_number - 1 where id = ... and remaining_number > 0这条,手动执行能看到影响行数,是 1 还是 0 一目了然。很多时候业务逻辑看起来没问题,实际在数据库层执行失败,都是靠这种方式发现问题的。用这个办法排查,比我盯着代码干想要快得多。

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

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

立即咨询