上个月我完整把一套线上医疗服务系统从零撸了一遍,技术栈就是标题里的那套:后端Java + Spring Boot,前端Vue,数据库用的MySQL。整个系统做下来,最大的感受是:这类业务系统真正难的不是某个框架用法,而是角色权限怎么分、业务流程怎么闭环、并发挂号怎么防超卖这几个设计点。所以这篇文章我不会只丢一堆代码,而是从整体设计、表结构、后端核心逻辑、前端联调、上线部署一路讲下来,把我踩过的坑和验证过能用的方案都写上。
这套系统能做什么?简单说就是把线下医院常见的预约挂号、科室导诊、在线问诊、病历管理、处方记录搬到浏览器里,分患者端、医生端、管理后台三块。如果你正准备做毕业设计,或者刚学完Spring Boot想找一个完整的前后端分离实战项目,再或者打算往医疗信息化方向转,这篇文章应该能让你少走不少弯路。
1. 系统设计:先理清楚“给谁用”和“怎么分工”
1.1 角色划分与核心业务流程
做业务系统第一件事不是写代码,而是确定用户角色和他们各自的操作闭环。这套线上医疗系统我划分了三类角色:
- 患者:注册登录、浏览科室和医生、查看排班、在线预约、查看病历和处方、取消预约。
- 医生:查看自己的排班,处理待接诊患者,在线填写病历、开处方,查看历史问诊记录。
- 管理员:维护科室、医生信息、排班规则、号源数量、系统公告。
角色定下来,核心业务流程就清楚了:患者先按科室找到医生,再看医生的排班时间,选一个时间段提交预约,系统扣减号源;到时间后在线上问诊,医生结束问诊后补充病历和处方,患者可以随时查看自己的历史记录。
这个流程看着简单,实际落地时我建议先把流程图手动画一遍再动手,尤其是排队状态流转:预约状态分为待就诊、已就诊、已取消,问诊状态分为待接诊、问诊中、已完成。状态没有提前定好,后面写接口会特别痛苦,因为每个状态变更都要校验合法性。
提示:我在项目初期用一张状态机表把所有“状态从A到B是否允许”标出来,这个动作帮我避免了至少十几个if判断上的逻辑漏洞。
1.2 技术选型:为什么是Spring Boot + Vue
前后端分离现在基本是标配,选Spring Boot + Vue并不是因为它时髦,而是这套组合足够"稳":
后端选Spring Boot,核心原因是它把配置简化到了一个非常舒服的程度。内嵌Tomcat,不再需要单独配置服务器;自动装配机制让数据库连接、Web配置、JSON序列化这些琐碎事大部分都能零配置搞定;和MyBatis-Plus配合之后,基础CRUD连SQL都不用写。我自己做项目习惯用Spring Boot 2.7.x版本,搭配JDK 8,兼容性最好,网上能查到的资料也最多,比追新版本省心很多。
前端选Vue,看重的是组件化开发和数据双向绑定。同一个医生卡片组件,首页展示用、搜索结果用、预约确认弹窗用,复用起来极其方便。生态系统里Element UI组件库可以直接拿来搭后台表格、表单、弹窗,开发效率非常高。
为什么坚持前后端分离而不是用Thymeleaf做服务端渲染?核心原因是部署和维护上的自由度:前端打包成静态文件扔给Nginx,后端打成一个jar包独立运行,两边可以单独升级、分开扩容。对一个小型医疗系统来说,这个架构的伸缩空间足够了。
| 技术栈 | 具体选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7 | 稳定,资料多 |
| ORM | MyBatis-Plus | 单表CRUD零SQL,复杂查询用XML |
| 数据库 | MySQL 8.0 | 主流,事务支持成熟 |
| 认证 | JWT | 无状态,适合前后端分离 |
| 前端框架 | Vue 2.x + Vue Router + Vuex | 组件化,生态成熟 |
| UI库 | Element UI | 后台管理页面效率高 |
| 构建工具 | Maven | 版本依赖管理严格 |
1.3 权限模型:后端拦截,前端控菜单
权限这块一定不能偷懒。我的方案是后端用RBAC模型控制接口访问,前端只做路由和菜单的展示层控制。数据库里做用户表、角色表、权限表,再加用户-角色和角色-权限两张关联表。后端拦截器解析出JWT里的用户身份,再判断当前接口需要什么权限,没权限直接返回401。
前端用Vue Router的全局守卫,根据登录状态下发的角色信息动态生成菜单和路由。要注意这是体验层面的控制,真正的安全闸门永远在后端。有人只做前端隐藏按钮就以为权限安全了,那只是自欺欺人——别人用Postman直接调接口照样能访问。
2. 后端核心实现:从表结构到业务逻辑
2.1 数据库建模:七张核心表怎么设计
数据库设计是一整套系统的地基。这套系统核心表我归纳成七张:用户表(user)、科室表(department)、医生表(doctor)、排班表(schedule)、预约挂号表(registration)、病历表(medical_record)、问诊记录表(consultation)。关键设计点我拆开说:
排班表是挂号系统的核心。它需要记录医生某一天在某个时间段出诊,以及这个时间段的号源总量和已预约量。不能简单把"出诊时间"存成一个字符串,我设计字段时包含了:
- doctor_id:关联医生
- schedule_date:日期
- time_slot:时间段,比如 08:00-09:00
- total_count:号源总量
- booked_count:已预约数量
用数据库约束保证booked_count <= total_count,每次挂号都会在同一个事务里更新这个数字,后面会详细讲怎么防止超卖。
挂号表是业务流程的核心,关键字段包括用户id、排班id、挂号单号、状态、创建时间。这里有两个容易被忽略的点:一是给user_id + schedule_id加唯一索引,让数据库从底层挡住同一用户重复预约同一个时间段;二是状态字段用tinyint数字字典,比如0待就诊、1已就诊、2已取消,比直接用字符串更节省空间,索引效率也更高。
病历表和挂号表是一对一关系,通过registration_id关联,记录主诉、查体、诊断、医生建议等。处方不单独建表的话,可以直接在病历表里用JSON字符串存药品列表,对早期版本来说这个方案最省事。
建表SQL要提交到Git仓库里作为项目的一部分。有人习惯用MyBatis-Plus的代码生成器直接根据Java实体类反向建表,我个人的建议是:建表这件事一定要手动控制,因为字段类型、长度、索引、默认值都需要经过设计,自动生成的表往往后患无穷。Navicat里建完表,导出SQL脚本,放一份在项目里,任何时候都能一键初始化数据库。
2.2 JWT登录认证的实现方式
登录认证我采用JWT,因为前后端分离场景下Session方案要处理跨域CORS的Cookie携带问题,麻烦还容易出bug。JWT的做法是登录成功后后端生成一个带过期时间的token返回给前端,前端存到localStorage里,后续每个请求在HTTP头里带上Authorization: Bearer <token>。
token生成我用的是io.jsonwebtoken这个库,核心代码也就几十行:
public String generateToken(Integer userId, String role) { Date now = new Date(); Date expireDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }服务端要做的就是写一个拦截器,在请求进入Controller之前解析token。解析失败、过期、角色不匹配都直接返回错误响应码,不放行。拦截器选HandlerInterceptor而不是Filter,是因为它能拿到Spring MVC的路由匹配信息,做细粒度的权限控制更方便。
注意:JWT一旦签发在过期前是无法主动作废的,所以如果项目里需要“强制下线”这类功能,建议额外引入Redis做token黑名单,或者把token版本号存库里。我做第一个版本时忽略了这一点,后来加退出登录功能时才发现不搞一个token失效机制根本没法彻底退出。
2.3 预约挂号怎么防并发超卖
这是整个系统业务逻辑里最有含金量的一段。挂号本质上是库存扣减,和秒杀场景非常像。最常见的错误写法是先查排班表,发现booked_count小于total_count,然后执行update,在并发情况下两个请求同时查到同一个数字,同时通过检查,双双执行update,号源就被透支了。
正确做法是把“校验+扣减”合并成一条原子性的更新语句:
int updated = scheduleMapper.updateCountIfAvailable(scheduleId); // SQL: // UPDATE schedule // SET booked_count = booked_count + 1 // WHERE id = ? AND booked_count < total_count这条SQL利用MySQL单条更新语句的行锁特性,同一时刻只有一个请求能更新同一行数据。更新成功返回的行数是1就说明号源充足,返回0就说明没号了直接提示用户。这一步是整个挂号系统性能和安全性的核心,一定要用这种原子操作,绝对不要拆成两步去写。
扣减完号源再插入预约记录,这两步必须放在同一个事务里。另外在挂号表加user_id + schedule_id的唯一索引,就能阻止同一个用户反复抢占同一个时间段。
取消预约的逻辑刚好相反:先校验状态是待就诊,然后把状态改成已取消,同时执行UPDATE schedule SET booked_count = booked_count - 1 WHERE id = ? AND booked_count > 0,把号源释放回去。有一个坑是:用户反复点击取消按钮,如果代码里没做状态校验,会重复释放号源,所以每次取消前必须先确认当前状态是待就诊,更新时用WHERE status = 0再更新。
2.4 在线问诊与病历的业务闭环
患者完成挂号后,医生端工作台会列出待接诊的记录。医生点击“开始问诊”后,问诊状态从待接诊变为问诊中;结束问诊时,医生需要同时填写病历和处方,然后状态变为已完成。问诊记录和病历这两张表都在同一个事务里写入,避免病历创建成功但问诊状态没更新的脏数据。
这里有个设计心得:不要把业务状态散落在各个if分支里,最好用状态码数字集中管理,写一个枚举类,比如:
public enum RegistrationStatus { PENDING(0, "待就诊"), FINISHED(1, "已就诊"), CANCELED(2, "已取消"); private final int code; private final String desc; }这样所有接口判断状态时都引用枚举,不会出现魔法数字满天飞的情况。
3. 前端Vue实现与前后端联调细节
3.1 Vue工程初始化与环境配置
前端部分我用Vue CLI初始化项目。环境上建议Node版本稳定用16.x或者18.x LTS版,npm下载依赖慢的问题可以换成淘宝镜像源:
npm config set registry https://registry.npmmirror.com vue create medical-frontend cd medical-frontend npm install element-ui axios vuex vue-router -S工程结构我习惯这样组织:src/api统一放接口请求封装,src/router放路由配置,src/store放全局状态,src/views按角色分目录放页面。关键一步是封装axios实例,把所有重复逻辑收敛在这里:
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )这种封装的意义在于,所有页面组件里调接口时只需要关心业务数据,不需要重复写token注入和错误处理代码。
3.2 核心页面实现:预约挂号与医生工作台
预约挂号的交互流程是:进入医生列表页面,按科室筛先筛选,点击医生卡片进入详情,看到最近7天的排班数据,选择一个时间段,确认弹窗,提交预约。
排班数据接口返回的是这样一个结构:
[ { "scheduleId": 101, "date": "2025-06-10", "timeSlot": "08:00-09:00", "total": 20, "booked": 15, "remain": 5 } ]前端拿到数据后就可以渲染出排班卡片,并判断remain是否大于0来判断是否可点击。提交预约时把scheduleId传给后端,后端返回成功或者说号已满。
医生工作台是另一个重点页面。医生登录后看到自己的患者列表,每一项显示患者基本信息、预约时间段、当前状态。点击接诊按钮后进入问诊页面,左侧是患者信息,右侧是问诊历史对话,底部是病历填写表单。这个页面核心是把病历表单拆成独立组件,因为诊断字段、处方字段、备注字段的校验逻辑比较复杂,独立出来更好维护。
组件化开发的价值在这里体现得很明显:医生列表页用到的卡片组件,在后台管理页面的医生管理表格里可以复用;预约时间段的组件,在手机端模拟视图里也可以复用。不要把每个页面都从零写一遍,先看看哪些区块是可以抽出来的。
3.3 跨域和联调中最容易踩的坑
前后端分离开发最经典的坑就是跨域。开发环境我的处理方式是启用Vue CLI的proxy代理:页面请求是/api开头的路径,devServer把它转发到后端的8080端口。配置在vue.config.js里:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样做的好处是开发环境下前端代码里的请求路径不需要写完整域名,和后端部署在Nginx后同域的效果一致。我强烈不建议在开发时直接在前端代码里写死http://localhost:8080这种绝对地址,后面上线改起来极其痛苦。
后端接口返回时间字段的问题也是联调高发区。Java 8的LocalDateTime默认被Jackson序列化成数组,前端拿到的是[2025,6,10,10,30,0]这种结构,根本没法直接用。我一早就做了全局配置:
@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> builder .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) .deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }统一全项目的时间格式,联调效率提升巨大。
提示:IDE里开发前后端项目的时候,建议把IDEA的Vue插件装上,不然vue文件全是纯文本没有高亮,特别别扭。前端起服务用
npm run serve,后端直接跑Spring Boot主类,两边同时运行,联调时前端页面改动会热更新,后端改动通过devtools自动重启,这套开发节奏非常顺。
4. 常见问题与排查技巧实录
4.1 前后端联调经典报错
报错一:接口调用跨域报错
浏览器里提示CORS policy或者blocked by CORS。开发环境用上面的proxy解决;如果后端也配置了@CrossOrigin,要留意两者叠加时可能出现的奇怪行为——我遇到过一次前端配了proxy后,后端CORS配置反而导致多了一次OPTIONS预检,把请求搞乱了。我的建议是:开发环境用proxy,生产环境用Nginx反向代理,后端不单独配跨域,分工明确。
报错二:返回数据是null或字段对不上
这类问题九成是命名风格不一致。数据库字段是下划线风格schedule_date,Java实体类用的是驼峰scheduleDate,JSON输出默认也是驼峰,但前端如果按schedule_date取字段就会拿不到。排查思路是先在浏览器Network面板里看真实响应体,确认字段名后再去改前端取值,不要盲目猜。
报错三:前端一直弹401
先看浏览器请求头里Authorization有没有带上token,再看后端控制台有没有解析token的异常日志。如果刷新页面就丢登录状态,几乎所有原因都是localStorage的key写错了或者拦截器读取的key和写入时不一致。
4.2 业务逻辑上的隐藏坑
并发挂号把号源扣成负数:这个问题我前面讲过解决方案,用原子的条件更新语句是唯一靠谱的做法。加锁也行但性能差,而且分布式环境下JVM锁根本不管用。
同一个用户重复预约:只靠代码判断不可靠,并发场景下两次请求可能都通过了业务校验,正确方案是在数据库层做唯一索引兜底。
取消预约后号源没有释放:取消操作和号源释放必须在一个事务里。我写过一版把两步拆开的,结果出现用户取消成功但管理员后台显示号源没回来,排查了好久。
时间校验缺失:预约时前端只判断了"这个时间段的号源余量",没有判断"这个时间段是否已经过去"。后来加上后端校验:挂号的排班日期必须大于等于当前日期,且如果日期是当天,时间段必须还没结束。这类校验不复杂,但漏掉就会出现一堆脏数据。
医生权限越级:医生的工作台接口如果直接传一个registrationId就能查到任意患者的病历,这就是越权漏洞。正确做法是从token里解析出当前医生的id,然后在查询条件里强制拼上doctor_id = 当前登录医生,而不是只依赖前端传参数。
4.3 部署上线的操作经验
项目做完要上线,我踩过的坑值得说一遍。
后端打包用Maven打包成jar:
mvn clean package -DskipTests java -jar medical-server.jarjar包放服务器上,启动命令用nohup java -jar medical-server.jar > app.log 2>&1 &,日志重定向到文件,排查问题就不会两眼一抹黑。生产环境数据库密码不能用明文写在配置文件里,用环境变量覆盖:
spring: datasource: username: ${DB_USERNAME} password: ${DB_PASSWORD}前端打包后生成dist目录,把里面的静态文件放到Nginx的html目录下。Nginx配置里做两件事:/路径指向前端静态文件,/api路径反向代理到后端jar的8080端口:
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; 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那行特别重要,不加的话刷新页面到/patient/register这种路由时Nginx会直接404,这是Vue Router使用history模式的标准踩坑点。
部署验证清单分享一个我每次上线前都会过一遍的检查项:启动日志有没有报错、数据库连接是否正常、登录接口是否通、预约流程是否完整走通、前端刷新不404、图片资源能正常加载、日志文件有持续输出。这七项全绿再宣布上线,不然就是在给运维挖坑。
4.4 一点实操体会
整套项目做完,我自己的体会是:医疗类业务系统,算法和酷炫技术不是重点,数据一致性和流程严谨性才是根本。挂号超卖、病历错乱、越权查看这些问题的杀伤力远比页面不够好看严重得多。
如果你准备拿这个题目做毕业设计或者项目练手,建议按这个顺序推进:先画清角色流程和状态机,再把七张核心表建好,然后后端按登录认证→科室医生查询→预约挂号→病历管理的顺序走通,最后做前端页面和联调。前后端分离开发时,接口路径和参数结构一定要先和后端同学统一好,我自己习惯先写一份简单的API文档再动手编码,能省掉后面大量无意义的联调返工。
最后再分享一个小技巧:开发时把后端接口的测试数据在Navicat里提前造好,比如多名医生、多天排班、号源数量不同的时间段,联调时直接能看到余量为0的班次怎么显示、满号后预约按钮怎么禁用。真实业务数据永远是检验代码逻辑的最好工具,比你自己对着空表猜测"应该没问题"靠谱得多。