多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring Boot + Vue 高校医务室预约系统全栈开发实践

Spring Boot + Vue 高校医务室预约系统全栈开发实践 做高校医务室预约系统这类项目的同学和同行这几年是越来越多了。原因其实很现实Spring Boot Vue 这套前后端分离组合既能支撑起一个完整可运行的业务项目也能在简历里讲成有真实使用场景的作品而高校医务室的业务恰好又足够“小而全”——预约挂号、医生排班、健康档案、后台管理每块业务都不算难但串起来却能把全栈能力练得比较扎实。这篇文章没什么高深理论我把当时做完这套系统的完整思路拿出来聊聊包括需求怎么拆、表怎么设计、预约并发怎么处理、Spring Boot 后端和 Vue 前端怎么落地以及部署上线和排障过程中的一些经验教训。无论你是准备拿它做毕业设计或课程设计还是想在企业里快速搭一套类似的预约平台按这个思路走基本不会跑偏。1. 项目全貌与核心需求拆解1.1 高校医务室预约到底在解决什么先想明白业务背景。高校医务室跟三甲医院不一样它服务的人群相对固定就是本校师生病种也偏向感冒发烧、跌打损伤、慢性复查这几类。但它的痛点很突出看病人流高度聚集在课间和饭后校医人数少学生来了只能现场排队而排队时间长又会把普通不适拖得更难受。更麻烦的是以往的纸质登记大多散落在收费记录里想统计季度常见病、跟踪某个学生的复诊情况基本靠翻本子。所以这个系统的定位其实有两层。第一层是预约调度学生提前选定日期和时段校医按排班接诊现场不需要长时间等待医务室也能按号源准备药品和人力。第二层是健康管理就诊记录进入电子档案学生可以查看自己的历史病历和健康数据校医能看到同一位学生的既往情况管理员还能按院系、时间维度做简单的常见病统计。预约负责引流档案负责沉淀两个模块靠“一次就诊”这条链路串在一起这才是完整的高校健康管理平台。1.2 为什么是 Spring Boot Vue技术选型上现在类似系统的主流答案就是 Spring Boot 加 Vue这背后有三层原因。第一层是后端生态成熟Spring Boot 约定优于配置一个 starter 就能把 Web、数据访问、参数校验、事务管理全部接好内置 Tomcat 意味着打包成 jar 就能跑开发调试成本很低对个人开发者非常友好。网上教程、面试题、开源项目也大部分围绕这套技术栈遇到问题搜得到不容易卡死。第二层是前端 Vue 的渐进式设计对单人全栈开发特别合适。你不需要一开始就搞重型工程化方案一个组件、一个路由、一个 store按需引入即可等页面多起来了基于 Vite 的构建和热更新也很省心。第三层是分离架构本身带来的好处后端只出 REST 接口前端只处理界面前后端可以并行开发部署也能分开这个模式在职场上也是最普遍的协作方式。相比之下传统的 Thymeleaf 模板拼页面的做法学习成本低一些但做出来的系统在展示和扩展上都要吃亏不少。1.3 功能矩阵先定下来动手写代码前我建议先把功能矩阵画清楚省得做到一半发现缺角色、缺页面。我按三种角色拆功能供你直接参考。角色核心功能关键动作学生登录注册、浏览公告、查看医生排班、在线预约/取消、预约记录、健康档案选日期、选时段、填写病情描述、查看自己的就诊历史校医登录、排班管理、接诊记录、查看预约列表、维护健康档案设定可预约时段、填写看诊结论、在档案中追加病情记录管理员用户管理、医生信息管理、公告发布、数据统计、系统配置分配校医角色、查看预约率、导出简单统计报表我这里有意把“数据统计”放到了管理员端而不是让前端图表满天飞。原因是预约数据量没那么大统计逻辑用 SQL 分组聚合就能完成前端一个表格加两个柱状图就够用没必要引入重量级 BI。后续如果要做“按院系统计感冒发生率”只需在 appointment 表关联 user 表的 department 字段做 group by扩展成本很低。2. 数据库设计与核心难点2.1 核心表结构怎么设计数据库是这个项目的命根子预约错乱、档案丢失基本都是表设计埋的雷。我最终落地的核心表包括用户表、医生信息表、排班表、预约表、健康档案表、公告表。重点说几个容易出问题的设计。用户表 sys_user字段包括 id、username、password、real_name、role、student_no、department、phone、create_time。password 只存加密后的哈希不要明文。role 用字符串枚举 student、doctor、admin 就行权限粒度没复杂到需要单独建角色表。医生信息表 doctor_info核心字段是 doctor_user_id 关联 sys_user、title、department、intro、avatar。不要把所有医生资料都塞进用户表因为医生信息里有职称、简介这类字段和学生身份不是一回事拆开更干净。排班表 schedule 是最关键的一张表。字段包括 id、doctor_id、work_date、period、total、remaining、statusperiod 我用枚举表示上午、下午、晚上三个时段。这里最关键的是加一个唯一索引UNIQUE KEY uk_doctor_date_period (doctor_id, work_date, period)这个唯一索引保证同一个医生同一天同一时段只能有一条排班记录业务上天然防重。预约表 appointment字段包括 id、schedule_id、student_user_id、student_name、reason、status、create_time。其中 status 我用枚举已预约、已完成、已取消。这张表也要加唯一约束但约束字段要谨慎UNIQUE KEY uk_schedule_student (schedule_id, student_user_id)这表示同一个学生不能在同一时间段重复预约。实际业务中“取消后重新预约”不能受影响所以约束只在 status 为已预约时生效具体要靠下面讲的并发控制配合而不是单纯依赖数据库唯一约束。健康档案表 health_record字段包括 id、user_id、record_type、content、doctor_id、create_time。record_type 用来区分是体检数据、就诊记录还是健康宣教备注。content 统一存文本即可第一版别想复杂真要做结构化体检指标再拆一张指标表就行。2.2 预约并发这个坎怎么过预约系统最典型的场景是热门时段放号 20 个几十上百个学生同时点预约。如果 service 层只写“先查剩余号源大于 0 就插入预约”并发条件下一定会超卖。我见过不少项目在这里翻车演示时人多一点预约记录就像雪崩一样超出排班名额。解决方案要分两层。第一层是扣减号源用条件更新也就是把“查询再更新”换成一条语句UPDATE schedule SET remaining remaining - 1 WHERE id #{scheduleId} AND remaining 0这条 SQL 执行后返回影响行数。如果影响行数为 1说明扣减成功可以继续插入预约记录如果影响行数为 0说明已约满直接抛出“该时段已满”的业务异常。配合事务把扣减号源和插入预约放在一个方法里数据库行锁保证了并发安全。第二层是同一学生防重。除了唯一索引还要在插入预约前判断该学生是否有同时间处于已预约状态的记录。哪怕前面判断通过了唯一索引也会在并发插入时兜底把重复记录直接挡在数据库外面不会产生脏数据。再往上加一层乐观锁也行在 schedule 表加 version 字段更新时带 where version #{oldVersion}。不过条件更新已经够用再加 version 反而增加代码复杂度我实测下来没必要。真正要注意的是别用 synchronized 或本机锁因为系统部署多个实例后本地锁互相之间拦不住不如数据库条件更新来得通用。2.3 健康档案的权限边界健康档案属于敏感数据权限边界要从接口设计阶段就划清楚。我的原则是学生只能读自己的档案校医可以读自己接诊过的学生档案管理员只能看统计数据不能打开具体病历内容。具体实现上登录后从 JWT 中解析 userId 和 role在查询档案的 Service 方法中强制拼接归属条件。比如学生查询xxxxMapper.selectById 之前先判断 record 的 user_id 是否等于当前登录用户不是则抛权限异常。校医查询则需要额外判断接诊记录里有没有当前医生不能因为登录了医生账号就能看全校学生的健康信息。这些边界看起来繁琐但真上线后能规避很大风险。给老师演示的时候特别是一键导出学生健康档案这种功能一定要做操作日志管理员做了什么、谁看了哪位学生的档案都要留痕。安全不是加分项是底线项。3. Spring Boot 后端落地细节3.1 项目结构与核心依赖后端项目结构我按标准分层来走分包清晰对个人维护和答辩都友好。controller 只接收参数和返回结果service 写业务逻辑mapper 操作数据库entity 放实体类config 放配置类common 放统一返回和异常处理。pom.xml 里核心依赖就这几样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency这里要提醒一下版本问题。现在很多教程直接上 Spring Boot 3.x但 3.x 要求 JDK 17如果你的电脑还是 JDK 8或者你用的是学校机房的老环境请老老实实选 Spring Boot 2.7.x对应把 MyBatis-Plus 选 3.5.3 左右就行。热词里天天有人问“Spring Boot 版本太高”绝大部分都是 JDK 版本没跟上导致启动直接报错不是代码问题。application.yml 里重点写清楚数据源和时区server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/school_health?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true时区这段是我踩坑换来的。如果 serverTimezone 不配或者配成 UTC排班表里存的日期在页面显示时会莫名其妙差 8 小时预约记录看起来就像“背约”排查起来非常折腾。所有时间字段统一用 Asia/Shanghai后端、MySQL、前端三层保持一致能省很多事。3.2 统一返回体和全局异常处理接口返回格式从第一天就要统一不然前端 axios 拦截器怎么写都会别扭。我定义了一个 Result 类格式固定为 code、message、dataData public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(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 做统一提示而不是每次都要去抓莫名其妙的堆栈信息。RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public ResultVoid handleBusinessException(BusinessException e) { return Result.error(e.getCode(), e.getMessage()); } ExceptionHandler(MethodArgumentNotValidException.class) public ResultVoid handleValidException(MethodArgumentNotValidException e) { return Result.error(400, e.getBindingResult().getFieldError().getDefaultMessage()); } }这个写法看起来基础但价值很大。尤其是前端联调时接口报错原因一眼能看到是“参数没传”还是“业务不允许”不用两边来回猜。3.3 JWT 登录认证和角色权限登录接口校验用户名密码后用 JWT 生成 token 返回前端。JWT 里我放三个字段userId、username、role。后续前端请求在 Header 里带 Authorization: Bearer token后端用一个拦截器统一解析。核心拦截器逻辑不复杂Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录); } Claims claims Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token.substring(7)) .getBody(); UserContext.set(claims); return true; } }再配合一个 ThreadLocal 的 UserContext 存放当前登录用户信息Service 层随时可以取不需要反复读数据库。角色权限我用自定义注解加拦截器二次校验比如 RequireRole(doctor)演示时讲权限设计也会有东西可说。3.4 排班预约服务的核心实现预约服务这段代码是整个系统最重要的部分我直接给出当时可运行的核心写法Service Transactional(rollbackFor Exception.class) public class AppointmentServiceImpl implements AppointmentService { Autowired private ScheduleMapper scheduleMapper; Autowired private AppointmentMapper appointmentMapper; Override public Appointment createAppointment(AppointmentRequestDto dto) { // 1. 扣减号源条件更新保证不超卖 int rows scheduleMapper.decreaseRemaining(dto.getScheduleId()); if (rows 0) { throw new BusinessException(500, 该时段已约满); } // 2. 插入预约记录唯一索引兜底防重复 Appointment appointment new Appointment(); appointment.setScheduleId(dto.getScheduleId()); appointment.setStudentUserId(UserContext.getUserId()); appointment.setStudentName(UserContext.getUsername()); appointment.setReason(dto.getReason()); appointment.setStatus(booked); try { appointmentMapper.insert(appointment); } catch (DuplicateKeyException e) { throw new BusinessException(500, 您已预约该时段请勿重复操作); } return appointment; } }scheduleMapper.decreaseRemaining 对应的 SQL 就是 2.2 节里的条件更新语句。事务注解保证扣号源和插预约要么同时成功要么同时回滚。这里还有个小细节事务上加 rollbackFor Exception.class不写的话 Spring 默认只对 RuntimeException 回滚业务异常是自定义 RuntimeException 子类尚可但显式写出来更稳妥。4. Vue 前端项目搭建与页面实现4.1 从创建项目到成功跑起来前端我用的 Vue 3 Vite。创建命令很简单npm create vuelatest按提示选择需要的功能如果只是做预约系统Router、Pinia 选上TS 看个人习惯。我建议新手先选 JavaScript这样心智负担小一点毕竟项目核心是业务不是类型体操。创建完以后 npm install 如果报 ERESOLVE 错误多半是 node 版本和依赖版本冲突升级 node 到 18 基本能解决。这里有个高频问题值得单独说一下。很多同学把整个项目文件夹压缩发给别人node_modules 动辄几百兆对方解压后运行还会报各种路径错。标准做法是只发源码接收方自己执行 npm install 安装依赖。如果是 Git 协作记得在 .gitignore 里写死 node_modules这个不写仓库体积瞬间飙升。环境配置上我习惯在项目根目录建 .env.development 和 .env.production分别配置接口地址。开发环境用 Vite 代理生产环境用 nginx 转发前端代码里只写相对路径 /api不写死 IP。4.2 页面路由与目录规划src 下的目录我按 views、components、router、store、api、utils 组织。页面按角色拆分student、doctor、admin 三个文件夹每个角色相关的页面放在一起找起来特别顺手。路由是典型的动态路由登录后根据角色加载对应菜单。学生端能进预约页、档案页医生端能进排班管理、接诊记录管理员端能进用户管理、统计页。前端动态路由的写法各家不同我用的是一个通用思路router.addRoute 在登录后按角色批量添加。需要注意刷新后 404 问题。开发模式下用 createWebHistory 时刷新某个深层路由会出现白屏这是路由 history 模式和服务器配置不匹配导致的。开发阶段可以在 vite.config.js 里配 historyApiFallback生产环境则是 nginx 的 try_files 配置后面部署章节详细说。4.3 Axios 封装与接口对接axios 二次封装是必须做的不然每个页面都写一遍完整请求代码后面改 baseURL 或者加 token 头时能让人崩溃。import axios from 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) { return res.data } if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) }, error { return Promise.reject(error) } ) export default request这样页面里调接口就非常清爽比如预约列表只需要写一行 request.get(/appointment/my)。响应拦截器里统一处理 401 跳登录页每个接口都不用重复写“未登录就跳转”的逻辑。Pinia 用来存用户信息登录成功后把用户对象和 token 存进去同时同步到 localStorage刷新后还能恢复登录态。这个场景下不需要 VuexPinia 更简洁占用也小。4.4 预约页面和组件化细节预约页面是学生端最核心的交互我拆成了三个步骤选日期、选医生时段、填写病情描述确认。日期选择用日期组件限制只能选未来 7 天内且是工作日的时间。医生时段列表用一个卡片式组件展示每张卡片显示医生姓名、职称、剩余号源数剩余为 0 时置灰不可点击。这里用到了 Vue 的插槽slot机制卡片组件内部只做展示框架按钮内容由父组件通过插槽传入这样不同场景可以复用同一个卡片组件。表单校验我直接用了组件自带校验规则病情描述限制 200 字以内必填项没填不让点提交。提交按钮要加防重复标记请求发出后立刻禁用等响应回来再恢复避免手抖连点产生两条预约。这个细节看起来小但在演示时非常能体现基本功。样式冲突问题也值得提一下。如果组件里不写 scoped全局样式很容易互相污染比如两边都用 .btn 类样式就乱了。我所有组件的 style 标签都加 scoped公共样式放到 assets 里的全局 css 文件。热词里经常有人搜“vue样式冲突”十有八九就是没用 scoped 或者全局样式命名太随意。4.5 扩展一点健康宣传视频播放如果平台后续要放健康宣传视频或者心理辅导课程前端播放 m3u8 格式视频不用装大而全的播放器。引入 hls.js 就能在浏览器里直接播放代码量很小且免安装插件。我实测过跨域和格式兼容问题hls.js 处理得都比较稳定适合做个视频学习模块给平台增加一点使用场景。这里不需要复杂封装一个组件里初始化 hls 实例即可。5. 联调部署与问题排查速查5.1 前后端联调时做什么前后端联调阶段最容易出问题的就是接口路径和跨域。我的规矩是后端所有接口都以 /api 开头控制器类上统一加 RequestMapping(/api/xxx)。前端开发环境用 Vite 代理把 /api 转发到 localhost:8080这样浏览器没有跨域问题。vite.config.js 的代理配置片段export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })生产环境则是 nginx 配置 location /api 反向代理到后端端口。前后端联调时先约定好接口文档可以用 Apifox 或直接写在 README 里避免前端等后端、后端等前端互相干等。5.2 打包与部署方案后端部署最简单的方式是打成 jar 包直接运行。在项目根目录执行 mvn clean package出来一个 jar 文件然后 java -jar 运行即可。运行环境需要 JDK 版本和打包版本一致JDK 8 的包放到 JDK 17 环境跑通常会报错反过来一样。前端构建执行 npm run build输出 dist 目录把 dist 内容放到 nginx 的 html 目录下。nginx 关键配置server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files 这行就是解决前端路由刷新 404 的关键它保证所有路径最终都回退到 index.html由前端路由接管。如果服务器装了宝塔面板可以直接用宝塔的 Java 项目管理器部署 jar 包或者用 Docker 跑后端镜像。Docker 部署也不复杂写个 Dockerfile 基于 java 镜像把 jar 复制进去启动就行。数据库建议单独跑 MySQL 容器或者用宝塔自带的 MySQL不要塞进同一个容器里方便后期备份。这里回应一个很多新手问的点Spring Boot 可以不内置 Tomcat 吗答案是可以把 spring-boot-starter-tomcat 依赖去掉或标记为 provided打包成 war 放到外部 Tomcat 的 webapps 下也能跑。但在 2025 年这个时间点jar 加内置 Tomcat 的方式明显更轻量也更好做 Docker 部署外部 Tomcat 方案除非你们学校机房有强制要求否则不建议绕这个路。5.3 高频问题排查速查表我把这个项目从搭建到上线遇到的高频问题整理成了一张速查表方便直接对着排查。现象可能原因解决方法Spring Boot 启动报 UnsupportedClassVersionErrorJDK 版本和框架版本不匹配Spring Boot 3.x 用 JDK 172.7.x 用 JDK 8二选一保证一致Maven 依赖下载慢或失败默认中央仓库访问慢修改 settings.xml 配置镜像源Vue npm install 报 ERESOLVE依赖树冲突或 node 版本过低升级 node 到 18或换用 pnpm 安装依赖创建项目后 tsconfig not found手改文件时破坏了 TS 配置路径用 create-vue 重新生成模板或检查 tsconfig.app.json 引用路径前端刷新页面 404路由 history 模式未配置回退开发环境配 historyApiFallback生产环境 nginx 配 try_files接口跨域报错前端域名和后端端口不一致开发环境用 Vite proxy生产环境用 nginx 反代数据库中文乱码连接串没指定 utf8url 添加 characterEncodingutf8库表字符集用 utf8mb4端口被占用本地有多个服务在跑换端口或查进程结束后重启预约总数和已预约数对不上排班号源扣减和预约创建没有事务Service 方法加 Transactional(rollbackFor Exception.class)页面时间多了 8 小时时区配成了 UTC连接串 serverTimezoneAsia/Shanghai前端和数据库统一5.4 控制台和日志排查经验最后说点排障的实践经验。后端出问题时先看控制台有没有 Java 异常堆栈别急着问前端。我见过不少同学一看到接口报错就截图给前端结果最后发现是后端数据库没连上。统一处理异常后大部分业务错误会以 Result 的 code 返回前端拦截器也会统一提示排起错来很快。前端的问题多发生在控制台 Network 面板里。打开浏览器开发者工具点一下请求看请求 URL、请求头、响应体基本能定位是路径错了、跨域了还是参数没传对。这个习惯养成之后联调效率能高一倍。部署后的日志也要提前规划。后端 jar 启动时用 nohup java -jar xxx.jar app.log 21 把日志输出到文件出问题直接 tail -200 app.log 看最后 200 行。数据库执行慢的时候用 explain 看一下 SQL 是否走了索引。排班表、预约表的数据量不算大但 work_date 和 schedule_id 的索引一定要加不然统计时段报表会明显卡顿。6. 预约系统做完之后还能怎么扩展这个系统的核心链路做到这里已经完整可用了。如果时间充裕我建议在原有基础上加一个简单的消息通知功能预约成功或取消时给学生发送一条站内信提醒。技术上就是建一张 notification 表预约事务提交后向这张表插一条记录学生在首页角标看到未读数量。整个扩展不需要引入消息队列因为业务量还没到那个级别。健康管理维度也可以加深。第一版健康档案只有医生填写的文本描述后续可以加体检数据录入页面比如身高、体重、血压、视力这些数值字段按学期维度展示变化曲线。前端用 ECharts 画一个折线图后端提供一个按 userId 查询历年体检数据的接口这个功能在答辩时很加分因为它是真正的数据沉淀不是简单的 CRUD。另外可以考虑接入校园一卡通数据进行身份同步。虽然这个扩展涉及外部系统接口设计上只要在学生登录时加一个“学号 统一身份密码”的校验或者提供一个第三方登录入口就能避免单独维护一套学生账户体系。不过这个优先级取决于学校的信息化程度第一版用账号密码注册登录完全没问题。整套系统做下来我最满意的地方不是页面有多漂亮而是预约表的那几个唯一索引帮我在演示时扛住了全班同学的集中访问。最后想分享一个很容易被忽略的小细节上线前一定要把服务器时区、MySQL 时区和前端展示时区统一我用 Asia/Shanghai 后排班日期就没再错过一天。希望这份拆解能对正在做类似项目的你有点帮助如果在预约并发处理上你有更好的办法欢迎多交流。
返回列表