ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue医院挂号系统:从数据库设计到前后端联调全流程解析

SpringBoot+Vue医院挂号系统:从数据库设计到前后端联调全流程解析 很多人第一眼看到“SpringBoot Vue 医院挂号就诊系统”这种标题第一反应是“又是一个烂大街的毕设模板”。但说句公道话这个选题能常年霸榜恰恰说明它踩中了毕业设计最舒服的位置——业务够真实链路够完整难度又不会让人中途放弃。Java 和 MySQL 做后端与数据存储Vue 做前端交互这套组合本身就是一个标准的前后端分离项目形状。对于一个需要在一学期甚至更短时间内交付的系统来说它几乎是“最不容易翻车”的选择之一。这篇内容我打算按自己的实战习惯来写先拆整体设计思路再讲数据库和后端核心接口然后落到 Vue 前端和联调细节最后把我在开发和答辩阶段踩过的坑整理成速查表。目的只有一个让你照着这个思路走不只是有一套能跑的代码而是能说清楚每个模块为什么这么设计、每个接口背后的逻辑是什么。以下内容适用于毕业设计、课程设计也适合完全不懂全栈的小白把它当作从 0 到 1 的练手项目。1. 整体设计为什么这个选题能兼顾完成度与含金量1.1 业务复杂度刚好卡在“能讲清楚”的区间医院挂号系统的核心流程拆到最简只有五步注册登录、科室列表、医生排班、选择号源、确认挂号。但这五步背后牵出的数据关系并不简单。医生挂在科室下排班表挂在医生下号源状态挂在排班表下预约记录又要同时关联患者和排班再往后还可以延伸到就诊记录和病历。这个数据链路属于典型的“多表关联 状态流转”比纯图书管理、学生信息管理那些纯 CRUD 系统高出一个层次又比电商秒杀、社交 Feed 这种复杂业务低一个门槛做起来不会失控写进论文里又有东西可讲。这正是它频繁被选为毕设题目的核心原因。适合什么人拿来练手也很明确Java 基础刚学完、还没接触过真实项目的小白能用这个项目把 SpringBoot 的自动装配、MyBatis/MyBatis-Plus 的数据库操作、JWT 登录鉴权、Vue 组件通信、axios 请求封装这些高频技术点全部串起来。如果你已经做过几个管理系统想尝试加一点“业务感”的话这个题目同样有空间比如号源并发控制、排班规则校验、预约状态机设计都是可以深挖的亮点。1.2 技术选型SpringBoot Vue MySQL 为什么是黄金三角先聊一个很实际的问题为什么不是 SSMSpring SpringMVC MyBatis加 JSP不是说 SSM 不能做而是 JSP 那种后端渲染、页面与 Java 代码混在一起的方式在现在的前后端分离大环境下已经明显落后。SpringBoot 把配置简化成“自动装配 约定优于配置”直接内嵌 Tomcat不用再打 war 包部署这能省掉大量环境层面的折腾。Vue 负责前端页面渲染和数据交互通过 axios 和后端接口通信后端只需要专注输出 JSON 数据。职责分离之后前端和后端可以并行开发这也是企业里真实的开发模式。MySQL 在这个项目里的角色也不可替代。它的单表操作性能足够事务支持完善InnoDB 引擎能保证挂号这类写操作的数据一致性。有人会问为什么不用 Oracle 或者 PostgreSQL对课设和毕设来说MySQL 的下载安装、可视化工具Navicat、DBeaver、网上资料数量都是最友好的而且绝大多数公司在实际招聘时也把 MySQL 当成默认技能项选它不会错。Vue 版本我建议直接上 Vue 3 配合 Element Plus 或者 Naive UI。Vue 3 的 Composition API 在逻辑复用上比 Vue 2 的 Options API 舒服很多面试时也更容易成为加分项。当然如果你的参考资料和已有的代码模板是 Vue 2 的那用 Vue 2 Element UI 也完全没问题项目本身不挑版本重点是前端能跑通。1.3 角色模型与页面骨架三种身份一条主线这个系统不需要做太复杂的权限模型三种角色就够了管理员、医生、患者。我实际划分功能时是这样做的患者端注册登录、浏览科室与医生、查看医生排班、预约挂号、取消挂号、查看个人预约记录。医生端查看当天预约列表、更新就诊状态、填写简单就诊结论。管理员端科室管理、医生账号管理、排班管理、统计报表。页面规划对应下来就是登录/注册页、首页展示科室和医生、医生排班列表页、挂号确认弹窗、个人中心/我的预约页、医生工作台、后台管理页。这套页面骨架不需要过度设计后端接口设计好、状态流转清楚前端跟着业务走就行。后面我会把关键页面的交互细节单独展开。2. 数据库设计与后端核心把这个骨架搭好项目就完成了一半2.1 数据库设计的关键取舍很多人写毕设项目最常犯的错误就是拿到需求直接建表建到一半发现字段不够用又去 ALTER TABLE 硬加。我的习惯是先画一张业务流转图再抽象出实体关系最后才落表结构。以挂号系统为例实体有用户含医生、科室、排班、预约记录、就诊记录关系是科室 1 对多 医生、医生 1 对多 排班、排班 1 对多 预约、预约 1 对 0 或 1 就诊记录。表设计时有三个到位原则第一物理外键别滥用。尽管 MySQL 支持 FOREIGN KEY但我更推荐用逻辑外键即普通索引字段这样删除数据时不会被外键约束卡住性能也更好应用层去保证数据关系即可。第二状态字段用 tinyint 或 int不要用字符串。比如挂号状态用 0 待就诊、1 已就诊、2 已取消、3 已爽约而不是存“waiting”“finished”这种排序、统计、判断都会简单很多。第三金额相关字段用 decimal 或者 int以分为单位不要用 float。挂号费这种涉及钱的数据浮点数精度问题在课程设计里不明显但养成正确的建模习惯不会错。2.2 六张核心表的建表参考下面给出一版可以直接落地的建表 SQL 核心结构。为节省篇幅我做了浓缩字段命名保持常规驼峰转下划线风格方便 MyBatis-Plus 自动映射CREATE DATABASE hospital_system DEFAULT CHARSET utf8mb4; USE hospital_system; CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50) NOT NULL, phone VARCHAR(20), role TINYINT NOT NULL DEFAULT 0 COMMENT 0患者 1医生 2管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE department ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, description VARCHAR(500) ); CREATE TABLE doctor ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT COMMENT 关联user表, department_id BIGINT COMMENT 关联department表, title VARCHAR(50) COMMENT 职称主治医师等, introduction VARCHAR(1000), avatar VARCHAR(255) ); CREATE TABLE schedule ( id BIGINT AUTO_INCREMENT PRIMARY KEY, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL, start_time VARCHAR(20) NOT NULL COMMENT 如 08:30, end_time VARCHAR(20) NOT NULL COMMENT 如 11:30, max_num INT NOT NULL DEFAULT 20, reg_num INT NOT NULL DEFAULT 0 COMMENT 已挂号数, total_appointment INT DEFAULT 0 COMMENT 累计预约人数可选, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可挂 1已满 2停诊, KEY idx_doctor_date (doctor_id, work_date) ); CREATE TABLE appointment ( id BIGINT AUTO_INCREMENT PRIMARY KEY, patient_id BIGINT NOT NULL COMMENT 患者user.id, schedule_id BIGINT NOT NULL COMMENT 排班id, visit_no INT NOT NULL COMMENT 就诊序号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待就诊 1已完成 2已取消 3爽约, remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_patient (patient_id), KEY idx_schedule (schedule_id) ); CREATE TABLE medical_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, appointment_id BIGINT NOT NULL, diagnosis VARCHAR(1000), prescription VARCHAR(1000), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这套表结构不算复杂但每一张表都有明确职责schedule 表里的reg_num是号源消耗的关键字段appointment 表则是整个业务的状态核心medical_record 是你往后扩展“就诊记录查询”功能的抓手。按这个结构往下写接口会很顺。2.3 号源状态与业务闭环挂号系统最核心的业务逻辑是号源的消耗与释放。一个排班记录有总量max_num和已挂量reg_num患者每次挂号成功reg_num 1患者取消预约reg_num - 1达到上限时排班状态自动变为已满。这套逻辑听起来简单但当你去实现“挂号”动作时会发现它不是一个简单的 INSERT而是一个需要保证原子性的“检查-扣减-落单”流程。我会在后面的后端实现环节专门讲。状态流转上appointment 表的 status 字段控制了整个预约生命周期患者创建预约时状态是 0待就诊医生确认就诊后变成 1已完成患者主动取消变成 2已取消若患者预约了但没有到诊可以由管理员或定时任务标记成 3爽约。这个状态机设计在答辩时非常容易成为亮点因为它直接体现了你对业务边界的理解。3. 后端实现Controller-Service-Mapper 三层里容易忽略的细节3.1 代码分层与统一响应格式后端我采用标准的 Controller-Service-Mapper 三层结构。Controller 只负责接收参数、调用 Service、返回结果Service 写业务逻辑比如并发挂号判断、状态流转校验、事务控制Mapper 层只做数据库操作。这样分层最大的好处是逻辑清晰且能测试Service 不依赖 HTTP 细节你可以单独写单元测试。为了统一前后端约定我会建一个通用返回类所有接口都返回这个结构Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }同时配合全局异常处理RestControllerAdvice把参数校验异常、业务异常、未知异常统一包装成上面的格式。前端拿到的永远是{ code, message, data }这个形状拦截器中只要判断 code 是否为 200 就行。这个小规范看着简单但能避免前后端人员反复沟通“这个接口到底返回啥”。3.2 登录鉴权JWT 在真实项目里的落地前后端分离项目里再像传统单体一样用 Session 已经不合适跨域和分布式场景下 JWT 是更轻的方案。登录接口做的事很简单查用户表校验用户名密码密码建议使用 BCrypt 加密存储校验通过后生成一个带用户 ID 和角色信息的 token 返回前端。我用的是com.auth0:java-jwt或者io.jsonwebtoken:jjwt两者选一个就行。核心代码如下public String createToken(Long userId, String role) { return JWT.create() .withClaim(userId, userId) .withClaim(role, role) .withExpiresAt(new Date(System.currentTimeMillis() 2 * 60 * 60 * 1000)) .sign(Algorithm.HMAC256(your-secret-key)); }token 生成后前端把它存在 localStorage 或 Pinia每次请求在请求头里带Authorization: Bearer token。后端写一个拦截器HandlerInterceptor在 preHandle 里解析 token解析成功就把用户信息放进 ThreadLocal 或 request attribute解析失败返回 401。白名单要放开登录、注册和医生/科室列表这类公开接口。关于密钥的坑不要把密钥硬编码在代码里至少放到 application.yml 配置文件中过期时间建议设短一点比如 2 小时配合前端拦截到 401 后引导用户重新登录。3.3 挂号接口的并发处理一条 SQL 解决超号问题这是我认为整个项目里最值得讲清楚的一个点。很多人实现挂号的逻辑是先 SELECT 排班在 Java 里判断reg_num max_num再 UPDATEreg_num reg_num 1最后 INSERT 预约记录。看着没问题但两个用户同时点击挂号时两个请求都可能读到同一个未满的排班然后都执行加一结果reg_num只加了一次预约却插了两条——这就是典型的超卖问题。解决思路其实和电商抢购库存是一样的把“判断 扣减”合并成一条原子操作的 SQL。推荐做法UPDATE schedule SET reg_num reg_num 1 WHERE id ? AND reg_num max_num;执行这条 UPDATE 后通过返回的影响行数判断是否扣减成功。如果影响行数为 0说明号源已满或排班状态异常本次挂号直接失败不需要再插预约记录。如果成功再执行 INSERT 插入 appointment并生成 visit_no可以用SELECT COUNT(*)1 FROM appointment WHERE schedule_id ?也可以直接用reg_num的值。整个流程加上Transactional保证 UPDATE 和 INSERT 要么同时成功要么同时回滚。这里乐观锁的概念天然蕴含在reg_num max_num条件里代码不复杂但面试官听了会满意。还要注意一个细节visit_no 的生成。如果直接用reg_num作为就诊序号前提是你刚更新完 reg_num值已经是最新的直接返回给前端就行不用再 COUNT 一次这样更省事也更准确。3.4 用 MyBatis-Plus 还是原生 SQL如果是自己从零写我会直接用 MyBatis-Plus 处理单表 CRUD比如用户注册、查询菜单、简单条件分页。它有现成的BaseMapper、IService能省掉大量样板代码。但涉及多表关联的复杂查询比如“查询医生排班列表并带出医生姓名科室名”就不要用 MP 的 LambdaQueryWrapper 强行写 join直接在 resources/mapper 目录下写 XML 更清晰。分页也不需要手写 LIMITMP 自带分页插件在配置类里注册PaginationInnerInterceptor后Service 里直接PageScheduleVO page scheduleMapper.selectPage(page, queryWrapper)就能拿到分页结果。需要注意的坑是多表关联查询返回自定义 VO 时MP 的分页插件对 XML 里的自定义 SQL 也是有效的前提是 Mapper 接口方法参数里传入了Page对象。4. 前端实现Vue 部分不只是套模板4.1 工程初始化与路由设计前端建议直接使用 Vite 创建 Vue 3 项目运行npm create vitelatest选择 Vue 模板。Vite 启动速度快、热更新体验好已经算是目前的主流。Element Plus 的引入有两种方式完整引入适合学习阶段按需自动导入适合追求优化。课设项目完整引入就行简单省心。路由设计我习惯按模块拆分/login登录注册页/home首页科室与医生列表/schedule/:doctorId排班列表页/appointment我的预约/doctor医生工作台需医生角色/admin管理后台需管理员角色。用 Vue Router 的beforeEach路由守卫做登录拦截和角色判断router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.role parseRole(token) ! to.meta.role) { next(/403) } else { next() } })4.2 axios 封装与跨域方案axios 的封装需要做两件事统一请求前缀、注入 token。我的习惯是在 src/utils/request.js 里创建一个 axios 实例const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.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) } ElMessage.error(请求失败请稍后重试) return Promise.reject(error) } )跨域问题开发环境最佳实践是 Vue 脚手架配置代理不需要在后端开 CORS。在 vite.config.js 里加server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端访问/api/user/info时Vite 会把请求转发到后端 8080浏览器的同源策略不会被触发。生产部署时改用 Nginx 反向代理配置方式我会在 4.4 小节提到。4.3 挂号页面的交互细节前端页面的难点不在“画表格”而在交互状态。挂号页的核心是“时间段卡片列表”每一张卡片展示医生的排班时段和剩余号源数据来自后端接口。需要处理的交互细节有几个剩余号源为 0 时卡片置灰且不可点击已是当天日期的排班要区分上午和下午点击卡片后弹出确认框二次确认再调用挂号接口挂号成功后要立即减少剩余号源数字而不是等刷新页面才变化。实操时我踩过最大的坑在于前端拿到了“可挂号余量”后并不代表 100% 能挂上因为后端还要做并发判断。因此不要在前端禁用“确认按钮”做硬校验后端返回“号源已满”时前端要能正确处理这个失败分支。这也是为什么上面统一响应结构很重要错误提示能直接展示给用户。4.4 打包、部署与 history 路由的坑Vue 项目写完后生产环境有两种常见部署方式。第一种是npm run build打包出 dist把 dist 里的文件复制到 SpringBoot 的src/main/resources/static目录下后端直接访问端口即可这种方式最简单适合交作业演示。第二种是用 Nginx 托管前端 dist同时把/api反向代理到后端 Java 进程这种方式更贴近真实生产环境。用 Nginx 托管时务必要解决 Vue Router 的 history 模式刷新 404 问题。配置location / { try_files $uri $uri/ /index.html; }同时注意/api的代理也要配套location /api/ { proxy_pass http://127.0.0.1:8080; }如果你没有用 history 模式而用的 hash 模式那可以跳过 try_files 配置但 URL 上会出现/#/不是很好看。能正常配置 history 模式最好这也是一个能写进论文的技术点。5. 常见问题与答辩避坑实录5.1 开发者必踩的五个环境坑我在帮别人排查这类项目时遇到过大量看似代码没问题但系统跑不起来的案例主要原因集中在下面五类现象原因解决方案启动报 8080 端口被占用电脑上其他进程占用端口application.yml中改server.port或netstat -ano查占用进程并结束连接数据库失败数据库没启动/连接 URL 配置不对确认 MySQL 服务已启动配置改成jdbc:mysql://localhost:3306/hospital_system?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8前端请求跨域报错后端未开启 CORS 或前端代理未配好开发环境优先使用 Vite proxy不要依赖后端 CORS 配置时间字段差 8 小时JDBC 时区问题在 JDBC URL 中加serverTimezoneAsia/ShanghaiLombok 注解不生效IDEA 没启用注解处理器安装 Lombok 插件Settings - Build - Compiler - Annotation Processors 勾选 Enable分页查询总条数为 0XML 自定义 SQL 没传 Page 参数检查 Mapper 方法签名是否包含PageT参数这里对 MySQL 版本多说一点如果你的 JDBC 驱动是 8.0 系列com.mysql.jdbc.Driver已经废弃需要用com.mysql.cj.jdbc.Driver。常见报错Loading class com.mysql.jdbc.Driver is deprecated就说明你还在用旧写法。5.2 答辩时必被问的几个问题及回答思路答辩老师对这类题目的提问翻来覆去就那么几个方向提前准备好就不慌。问“为什么选这个技术栈”回答思路是前端注重交互体验和组件化后端 SpringBoot 简化配置并提供成熟生态MySQL 满足数据持久化和事务需求三者结合是当前最主流的中小体量 Web 开发组合。问“系统有哪些亮点”可以把 JWT 鉴权、统一异常处理、乐观锁防超号分点列出其中并发控制尤其值得强调。问“如果用户量增大怎么办”回答方向是引入 Redis 做号源缓存和分布式锁用 RabbitMQ 异步处理预约确认数据库做读写分离。不一定要真正实现但能把思路讲清楚就能显得你对系统有整体规划。还有一点容易被忽略答辩 PPT 或文档里务必放一张“系统状态流转图”和一张“核心业务流程图”。文字描述再多也不如一张图直观还能引导老师把注意力放在你熟悉的内容上。5.3 项目还能怎么“加分”四个低成本的进阶方向如果你的时间比想象中充裕或者想拿它去面试时聊得更深以下四个扩展点性价比很高引入 Redis 做验证码存储和热门医生排班缓存既有实际意义也能讲缓存穿透、缓存击穿等面试热点。引入 Spring Security 替换手写拦截器虽然学习成本稍高但简历上写“基于 Spring Security 做权限控制”比“手写 JWT 拦截器”更有记忆点。增加 ECharts 统计报表页面让管理员能看到各科室挂号量、医生工作量趋势这是多数毕设评分明细里的加分项。增加 Excel 导出功能用 EasyExcel 导出预约报表代码量不大但实用性很强也能体现你关注用户的真实需求。这里尤其推荐第一个和第三个组合Redis 做缓存和验证码ECharts 做可视化。它们一前一后一个提升性能一个提升展示效果在答辩演示时非常有冲击力。5.4 开发效率提升接口文档与在线调试最后一个值得养成的习惯是给项目配上接口文档。课设项目通常没有专门的前后端联合调试人员但一个人写前后端时更容易忘记“接口长什么样”。将 springdoc-openapi 集成到 SpringBoot 后启动项目访问/swagger-ui.html就能看到所有接口的入参和返回结构。前端写 axios 时对照着文档填参数效率会高很多。如果不想引入太重的东西也可以使用 Apifox 或 Postman 管理接口集合把这些工具的使用经历写进文档也属于项目完成度的一部分。我个人的体会是这类系统最大的价值其实不在“代码量”和“页面数量”而在你能不能把整条链路从“用户点击挂号”到“医生看到预约列表”完整地讲清楚。做课设时觉得时间紧、东西多但如果把表结构、接口、页面三者的对应关系列一张表开发过程会非常有条理。比如我在动手写代码前会先画一张包含“页面 - 接口 - 数据表”的映射清单后端写一个接口前端就配一个页面这样不会出现前端页面写完了却找不到数据源的情况。这个习惯直到现在做商业项目也一直在用算是我从这类经典项目中收获的最扎实的经验。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进