上个月接了个医疗信息化的需求,客户上来就问:一周能不能把挂号系统跑起来?我当时把之前沉淀的一套 SpringBoot+Vue+MyBatis+MySQL 前后端分离智慧医疗服务平台骨架翻出来,半天搭好环境,三天改完定制功能,第五天交付验收。这个项目就是这么来的,它不是那种光讲概念的demo,而是可以真实跑业务的基础工程。
整套系统覆盖了患者端、医生端、管理后台三块:患者注册登录、查看科室和医生排班、在线预约挂号、查询病历;医生管理出诊排班、接诊写电子病历、开处方;管理员维护科室、医生、号源规则。后端用 SpringBoot 做接口服务,MyBatis 操作 MySQL 数据库,前端 Vue 做单页应用,前后端通过 RESTful API 通信,用 JWT 维护登录态。不管你是刚学完 SpringBoot 和 Vue、想找个完整项目练手,还是正在做毕业设计、接外包单子需要一套能快速交付的基础框架,这份拆解都值得看完——我会把业务模块划分、数据库表设计、号源并发控制、token 权限链路、部署细节全部讲一遍,顺便把我在实际交付中踩过的坑也交代清楚。
1. 从挂号到问诊:一个医疗服务平台的业务边界到底怎么划
1.1 三个门户,一套API
我在规划这个系统的时候,没有一上来就画几十张表,而是先把用户角色盘清楚。医疗平台的用户天然分成三类:患者、医生、管理员。围绕这三个角色,业务边界就很清晰了。
患者端负责的是就医前的信息获取和就医后的记录查看,核心功能包括:
- 注册登录:手机号注册 + 短信验证码,密码走 BCrypt 加密存储
- 科室导航:按一级科室、二级科室浏览,科室下挂医生列表
- 排班查询:查看某个医生未来一周的出诊时间、剩余号源
- 在线挂号:选择日期和时段,锁定号源,生成挂号订单
- 就诊记录:查看历史挂号记录、病历详情、处方明细
医生端则是围绕"接诊"这个动作展开的:
- 排班管理:按周设置出诊计划,可以停诊、加号
- 患者队列:查看当天挂了号的候诊患者列表
- 电子病历:问诊结束后填写主诉、现病史、诊断结论
- 处方开具:开药、开检查检验单,关联到对应的病历记录
管理后台属于运营维护层面,功能相对标准化,科室信息维护、医生资质信息管理、号源规则配置(每个时段放多少个号、是否开放预约)、系统基础参数管理等。
这三个门户可以拆成三个 Vue 前端工程,也可以合在一个工程里用路由做角色区隔。我的建议是实际项目里拆成独立子应用更稳妥,因为患者端可能后续要出小程序版,医生端可能要做桌面版,独立应用可以各自演进互不干扰,后端 API 始终保持一套。
1.2 前后端分离在医疗服务中的实际收益
前几年很多人做医疗系统还在用 Thymeleaf 服务端渲染,页面写在 Java 里,前端改个样式还要后端重新打包。这个架构在业务简单时没毛病,但医疗服务平台有一个特点:多端并发。患者可能用手机浏览器访问挂号页,医生用电脑浏览器打开接诊工作台,管理员在大屏上看数据。三端交互差异非常大,服务端渲染很难同时满足三种交互复杂度完全不同的场景。
换到 SpringBoot + Vue 的前后端分离架构之后,最直观的收益有三点。
第一,前端工程化能力直接拉满。Vue 组件化开发让患者端的科室列表、医生卡片、排班日历可以被拆成粒度很小的组件复用,Vuex/Pinia 管理患者登录态和购物车式的挂号信息,开发效率比套页面模板高出一大截。
第二,部署和扩容各自独立。后端接口扛不住压力时可以单独多开几个实例,前端静态资源丢 CDN 或者 Nginx 就行,两边互不拖累。第三,接口可以先于页面完成。我通常先定义好 RESTful API 文档,后端和三个前端并行开工,最后联调,这在"客户催得急"的交付场景里是保命技能。
1.3 做项目先学会砍需求
这里想多说一句跟技术无关但比技术重要的事:做医疗项目,范围控制决定生死。医疗领域的需求可以无限叠加——在线支付、电子发票、云影像、远程问诊、药品配送,每个听起来都合理,但如果你把这些全部塞进第一版,项目大概率烂尾。
我拆这个项目的时候刻意做的很克制,核心闭环只有这么一条:患者注册登录 → 查看排班 → 在线预约 → 医生接诊 → 填写病历 → 开具处方 → 患者查看记录。这七个环节跑通了,就是一个能交付、能演示、能应对答辩或验收的完整系统。像支付环节,我建议用"虚拟支付"代替,在订单状态里加一个待支付到已支付的流转,后续接入微信支付宝只是换掉一个支付实现类的事情,但第一版不用耦合第三方 SDK。消息通知可以用站内信,不急着接短信平台。先把主干做扎实,再做枝叶,这是我这几年做项目最深的一条体会。
2. SpringBoot+MyBatis后端:这套技术组合在医疗场景下的选型逻辑
2.1 选型对比与取舍
这套技术栈网上争议不小,经常有人问:都 2025 年了,为什么不用 SpringCloud?为什么不用 JPA?为什么不用 PostgreSQL?我统一回答一下,因为这类问题在医疗项目评审时被问到的频率极高。
| 选型维度 | 本方案 | 常见替代方案 | 选择理由 |
|---|---|---|---|
| 服务架构 | SpringBoot 单体应用 | SpringCloud 微服务 | 中小型医疗平台并发量有限,单体部署运维成本低,业务边界都在模块内,后续确实需要拆再按模块拆分 |
| 持久层 | MyBatis | Spring Data JPA / MyBatis-Plus | 医疗报表和统计查询 SQL 复杂,MyBatis 对 SQL 有完全控制力,性能问题可以直接优化 SQL |
| 数据库 | MySQL 8.x | PostgreSQL / SQL Server | 生态成熟,云厂商支持好,外包交付时客户环境大多有 MySQL,8.x 的窗口函数、JSON 类型都够用 |
| 前端构建 | Vue3 + Vite | Vue2 + Webpack | Vue3 的组合式 API 更适合中后台复杂交互,Vite 构建速度比 Webpack 快一个量级 |
微服务乍一听很高级,但医疗平台里一个挂号接口和一个病历接口之间并没有独立的服务边界,强行拆成多个服务只会引入分布式事务、服务发现、配置中心这些额外复杂度。SpringBoot 单体应用配合模块化包结构,已经能覆盖九成以上的业务场景。MyBatis 对比 JPA 的核心差异在于:JPA 让你面向对象编程,但当你想优化一条多表关联的统计 SQL 时,JPA 生成的 SQL 会把你绕晕。在医疗场景里,数据查询的复杂度和准确性是第一位的,MyBatis 这种把 SQL 握在自己手里的方式反而踏实。
另外提一嘴,现在 Spring 的版本节奏很快,连 AI 组件都在出里程碑版本,但生产项目我依然建议用稳定版本:SpringBoot 2.7.x 对应 JDK8/JDK11,SpringBoot 3.x 强制要求 JDK17。如果你在创建项目时发现版本太高导致一堆依赖不兼容,记得先检查 JDK 版本是否匹配。
2.2 目录结构与分层规范
后端工程我按下面这种结构组织,清晰且好扩展:
com.hospital.platform ├── config // 配置类,如 WebMvcConfig、MyBatisPlusConfig ├── controller // 接口层,只做参数接收和结果封装 ├── service // 业务层,事务边界在这里 │ └── impl ├── mapper // MyBatis 数据访问接口 ├── entity // 数据库实体 ├── dto // 请求参数对象 ├── vo // 响应视图对象 └── common // 统一返回结果、异常处理、工具类这个结构最大的价值是约束了数据流向。Controller 不直接操作数据库,Service 里写事务逻辑,Mapper 只做 SQL 映射。Entity 对应数据库表结构,DTO 承接前端请求参数,VO 是返回给前端的数据视图。为什么要分开?因为数据库字段不能直接暴露给前端,比如用户表的密码字段、医生表的手机号字段,在 VO 层要做脱敏和隐藏。
统一返回结构我也给出一个约定,所有接口统一返回 R 对象,包含 code、msg、data 三个字段。配合全局异常处理器,业务代码里只需要抛出指定异常,前端就能拿到稳定的错误提示结构。实际开发时,把 200 和 500 的定义统一写在常量类里,避免前端同学每个接口问一遍"这个 code 是啥意思"。
2.3 JWT+拦截器:登录态与角色权限的控制链路
医疗系统的权限控制比普通管理系统敏感得多:患者只能看自己的病历,医生只能处理自己排班下的患者,管理员才能维护基础数据。我用 JWT + Spring 拦截器实现了一套轻量级权限链路,完整流程如下。
用户登录成功后,后端从数据库查出用户信息,生成 JWT token 返回给前端。token 的 payload 里包含用户ID、角色、过期时间,签发密钥放在配置文件中,用 HS256 算法签名。
public String generateToken(LoginUser user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim("role", user.getRole()) .claim("name", user.getName()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 2)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); }前端把 token 存在 localStorage,每次请求在请求头带上Authorization: Bearer <token>。后端写一个拦截器,继承HandlerInterceptorAdapter(新版本实现HandlerInterceptor接口),在preHandle里校验 token 合法性,并把解析出来的用户信息放入 ThreadLocal 供后续业务代码取用。
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行白名单 if (isWhitelist(request.getRequestURI())) { return true; } String token = request.getHeader("Authorization"); // 解析失败直接返回 401,由全局异常处理转换为统一结构 LoginUser user = JwtUtil.parseToken(token); UserContext.set(user); return true; }白名单就三类:登录接口、注册接口、获取验证码接口。剩下所有接口都要过 token 校验。角色权限的校验有两种做法,轻量做法是在需要权限的 Controller 方法上加自定义注解@RequireRole("ADMIN"),拦截器里读取注解判断角色;另一种是写专门的权限校验工具类,在业务代码里显式调用,判断当前登录用户角色和资源归属。我更推荐第二种,因为医疗场景里"水平越权"很常见——比如一个患者尝试查看另一个患者的病历,这种校验藏在方法内部比注解更直观。
2.4 MyBatis实操中三个容易翻车的点
2.4.1 缓存:一级缓存和二级缓存该怎么用
MyBatis 默认自带一级缓存和二级缓存,但很多人根本不清楚它们的边界。一级缓存是 SqlSession 级别的,同一个 SqlSession 内执行两条相同的 SQL,第二次直接走缓存,但 Spring 管理的 Mapper 每次调用都是独立的 SqlSession,所以一级缓存实际帮助有限。二级缓存是 namespace 级别的,可以在多个 SqlSession 间共享,默认关闭。
我的观点很简单:医疗项目不要开 MyBatis 二级缓存。一旦系统部署多个实例,每个实例的二级缓存各自独立,数据库数据更新后其他实例读到的还是旧数据。挂号余号这类数据对实时性要求极高,宁可每次查库也不要出现缓存不一致。真的有频繁读取且更新不频繁的数据(比如科室列表),放到 Redis 里统一管理,控制权在你自己手里。
2.4.2 单个数字字符比较的坑
网上这个问题的讨论热度一直很高,我也被坑过。Mapper XML 里写动态 SQL 判断状态,常见的错误用法是这样的:
<if test="status == '1'"> AND order_status = #{status} </if>在 MyBatis 的 OGNL 表达式里,'1'会被解析成字符类型,而status是字符串类型,两者用==比较永远为 false。正确写法是:
<if test='status == "1"'> AND order_status = #{status} </if>外层用单引号、内层用双引号,或者直接用.equals()方法判断。这个坑特别隐蔽,因为代码能正常启动、SQL 能正常执行,就是条件不生效,查出来的数据跟你预期不一样。排查这类问题最有效的办法就是打印 MyBatis 执行的完整 SQL 和参数,配置一行即可:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl2.4.3 批量写操作的正确姿势
批量插入数据是医疗系统的刚需,比如医生一次开十种药,前端提交一个处方明细数组,后端不可能循环单条 insert。MyBatis 的 foreach 标签可以解决:
<insert id="batchInsert"> INSERT INTO prescription_item (prescription_id, drug_name, dosage, frequency) VALUES <foreach collection="list" item="item" separator=","> (#{item.prescriptionId}, #{item.drugName}, #{item.dosage}, #{item.frequency}) </foreach> </insert>不过这里有两个细节。第一,MySQL 连接串必须配置 rewriteBatchedStatements=true,否则 JDBC 驱动的批量写入不会走真正的 batch 模式,性能提升有限。第二,foreach 拼接的 SQL 长度有限制,默认 max_allowed_packet 是 64MB,一次批量插入控制在 1000 条以内比较稳妥,超过就分批。我见过有人一条 SQL 拼了两万条数据直接把数据库干宕机的,这种事故在医疗平台上线前发生一次就够了。
3. 数据库设计与号源扣减:预约挂号并发问题的处理方案
3.1 核心表结构设计
数据库设计是整个系统里最忌讳返工的部分,表结构没设计好,后面写业务代码处处难受。我按业务模块把核心表划分为用户权限域、排班挂号域、诊疗记录域三类。
用户权限域不展开细说,就是 user 表加 role 字段区分患者和医生,医生和科室的绑定关系放在 doctor_info 扩展表里。重点看排班挂号域,这是并发问题的集中地。医生出诊先要有排班表,排班表决定了哪些日期、哪些时段可以挂号:
CREATE TABLE `schedule` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `doctor_id` bigint(20) NOT NULL COMMENT '医生ID', `dept_id` bigint(20) NOT NULL COMMENT '科室ID', `work_date` date NOT NULL COMMENT '出诊日期', `start_time` time NOT NULL COMMENT '开始时间', `end_time` time NOT NULL COMMENT '结束时间', `total_count` int(11) NOT NULL DEFAULT '0' COMMENT '总号源数', `remain_count` int(11) NOT NULL DEFAULT '0' COMMENT '剩余号源数', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:0停诊 1正常', PRIMARY KEY (`id`), KEY `idx_doctor_workdate` (`doctor_id`, `work_date`), KEY `idx_dept_workdate` (`dept_id`, `work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';挂号订单表记录每一次挂号的完整信息,包括患者、排班、医生、就诊日期、状态。这里加了一个唯一索引uk_patient_schedule,同一患者对同一排班不能重复挂号,这是防重复下单的最后一道兜底。
诊疗记录域的核心是 medical_record 病历表和 prescription_item 处方明细表,病历表关联患者和医生,记录主诉、诊断、处理意见;处方明细表挂在病历下面,存药品名称、剂量、频次。
3.2 防超卖的三种方案对比与实现
一个医生上午放 30 个号,千万不能卖出 31 个。号源扣减本质上就是秒杀系统的库存超卖问题,常见的解法有三种,我对比一下在医疗场景的适用性。
方案一:乐观锁。SQL 里带上版本号或剩余数条件,影响行数为 0 则失败重试或者提示号源不足。
@Update("UPDATE schedule SET remain_count = remain_count - 1, version = version + 1 " + "WHERE id = #{scheduleId} AND remain_count > 0 AND version = #{version}") int deductQuota(@Param("scheduleId") Long scheduleId, @Param("version") Integer version);这个方案适合并发量不算极端的场景,比如一个专家号同时也就几十个人抢,乐观锁冲突率低,性能损耗最小。
方案二:悲观锁。先SELECT ... FOR UPDATE锁住排班记录,然后检查余号、扣减、生成订单,最后提交事务。这个方案能保证绝对准确,但并发高时会产生锁等待,而且锁的粒度是整个排班记录,如果某个专家号特别火,所有患者都在这一行上排队。使用悲观锁必须让事务尽量短,不要在锁内做远程调用或者耗时操作。
方案三:先下单占号,延时释放。用户点击挂号时并不直接扣减号源,而是创建一个待支付订单并冻结号源,如果用户 30 分钟内未支付,定时任务解冻号源。这实际上是对真实医疗流程的模拟——线下去医院挂号,窗口会保留号源等你去交费。第一版建议用这个方案,业务上更合理。
不管选哪种方案,前面说的唯一索引约束都要保留,它就是数据库层面的最后一道防线。
3.3 事务边界、索引设计与关键字段规范
@Transactional不是加得越多越好。我见过有人把整个挂号流程写成一个大事务:扣号源、生成订单、发通知、扣积分全包在一起。这样做的代价是事务占用数据库连接的时间变长,并发能力急剧下降。我的习惯是:事务只包裹必须原子生效的写操作。扣号源和生成订单必须在一个事务里,发站内信、调外部接口这些全部挪到事务外面,用 Spring 的@TransactionalEventListener监听事务提交后再执行,或者直接放到消息队列里。
索引设计上,挂号平台的高频查询就两类:患者查自己近期的挂号记录(patient_id + create_time),患者查某个医生的排班(doctor_id + work_date)。这两个联合索引必须建。病历查询通常按患者维度走,patient_id单列索引即可。特别注意,不要在索引列上做函数运算,比如DATE(create_time) = CURDATE()会让索引失效,正确写法是create_time >= '2025-01-01 00:00:00' AND create_time < '2025-01-02 00:00:00'。
金额、数量、号源等数字字段,请一律用 DECIMAL 或 BIGINT,不要用 FLOAT/DOUBLE,避免浮点精度问题。时间字段统一用 DATETIME,不要用 TIMESTAMP,因为 TIMESTAMP 有 2038 年问题,而且 DATETIME 在 MySQL 8.0 中支持的范围更广。
4. Vue3前端与token会话管理:患者端和医生端如何做到权限隔离
4.1 环境准备与工程初始化
很多同学卡在环境这关,报了各种奇怪的错,其实就是 Node 版本和工具链版本对不上。推荐统一使用 Node 16 或 Node 18 LTS 版本,Vite 4/5 在这两个版本下都稳定。创建工程直接用官方脚手架:
npm create vite@latest hospital-frontend -- --template vue cd hospital-frontend npm install npm install vue-router@4 pinia axios element-plus npm run dev前端工程结构上,我习惯按业务模块拆分 views 目录,router 目录里做路由配置和守卫,api 目录按后端接口模块建独立 JS 文件,utils/request.js 封装 axios 实例。这样一个中等规模的前端工程不会随着业务增长变成一坨浆糊。
4.2 前端路由守卫与token处理
token 处理是前后端分离项目里前后端协同的关键点。前端逻辑核心就三条:请求前带上 token;响应里遇到 401 清除登录态跳回登录页;页面路由跳转前判断是否已登录。
axios 封装这一段几乎是每个项目都要用到的模板:
// utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:自动携带token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) // 响应拦截器:统一处理业务码 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )路由守卫配合角色配置,实现医生端和患者端的入口隔离:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.meta.role && to.meta.role !== role) { next('/403') } else { next() } })如果你是非登录状态能访问的公开页面,比如科室导航、医生排班查询,放行即可;但是收费的预约挂号动作必须在守卫里挡住。后端在这一点上也必须有同样的校验,前端只能提升体验,不能作为安全边界。
4.3 m3u8视频播放:健康宣教和就诊引导
现在的智慧医疗平台几乎都有健康宣教功能:术前注意事项、康复指导、用药说明,以视频形式展示给患者。我这次在系统里集成了 m3u8 格式视频的播放,后端提供 m3u8 索引文件和 ts 分片文件,前端用 video.js 直接播放。
<video ref="player" class="video-js vjs-default-skin" controls></video>import videojs from 'video.js' import 'video.js/dist/video-js.css' const player = videojs(this.$refs.player, { autoplay: false, controls: true, fluid: true, sources: [{ src: this.videoUrl, // 例如 https://cdn.example.com/health/advice.m3u8 type: 'application/x-mpegURL' }] })注意新版本的 video.js 已经内置了 HLS 解析能力,不需要再额外安装 videojs-contrib-hls 插件。真正容易出问题的是服务端配置,Nginx 里必须把 m3u8 和 ts 的 MIME 类型指对:
location ~ \.(m3u8)$ { add_header Content-Type application/vnd.apple.mpegurl; } location ~ \.(ts)$ { add_header Content-Type video/mp2t; }否则浏览器会把文件当成 text/html 解析,播放器直接报错。
4.4 打包后布局异常与404问题排查
Vue 项目本地开发没问题,一打包部署就白屏、样式错乱、跳转 404,这个问题在热搜词里挂了很久,我把它拆透。
白屏和布局异常百分之八十是资源路径问题。Vite 默认 base 是/,部署到服务器根目录没问题,但如果放在子目录比如http://ip:8080/hospital/,资源全变成http://ip:8080/hospital/hospital/...,自然加载不到。处理方式是在vite.config.js里把 base 配置成'./',或用绝对路径/hospital/。
路由 404 是 history 路由模式导致的。前端用createWebHistory()时,浏览器直接访问/doctor/schedule,后端没有对应文件就返回 404。解决方案是让 Web 服务器把所有路由 fallback 到 index.html,Nginx 配置就是一行try_files $uri $uri/ /index.html;。如果你不想管这些,要么换 hash 模式,URL 带#丑一点但是省心;要么严格按照文档配置好 Nginx。我实际项目中全都用 history 模式加 Nginx 回退,URL 干净,也更专业。
5. 从本地到云服务器:整套系统的部署流程与Nginx配置
5.1 本地拉起项目的完整步骤
想让这套系统在本地跑起来,按照下面顺序十步走完,稳。
- 安装 JDK 17(如果用的 SpringBoot 3.x)和 MySQL 8.0
- 在 MySQL 中创建数据库
hospital_platform,导入项目提供的sql/hospital.sql - 修改后端
application.yml里的数据库连接信息,重点检查serverTimezone=Asia/Shanghai,MySQL 8.0 时区不对会报错 - 后端工程根目录执行
mvn spring-boot:run,看到启动成功日志说明后端起来了 - 前端工程执行
npm install,时间较长,耐心等待 npm run dev启动前端开发服务器,默认端口 5173- 浏览器访问
http://localhost:5173,使用初始管理员账号admin/123456登录后台
本地联调时最大的坑是跨域。前端在 5173 端口,后端在 8080 端口,浏览器会拦截跨域请求。开发环境我建议用 Vite 的 proxy 配置解决,不需要后端开启 CORS:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求/api/user/login会被 Vite 代理转发到后端,浏览器看到的还是同源请求,没有跨域问题。
5.2 Linux服务器部署步骤
生产环境部署我用的是最经典的三件套:Nginx 托管前端静态页,后端 jar 包用 nohup 后台运行,MySQL 独立实例。以 CentOS 7.9 为例,完整链路如下。
先安装环境。JDK 用 tar 包解压安装,配置/etc/profile环境变量;MySQL 用 yum 源安装后初始化数据库、创建账号;Nginx 用 yum 安装后修改配置。
后端打包前确认生产环境配置。在application-prod.yml里覆盖数据库地址、日志级别、JWT 密钥,然后打包:
mvn clean package -DskipTests # 产物在 target/hospital-server.jar启动后端:
nohup java -jar hospital-server.jar \ --spring.profiles.active=prod \ --server.port=8080 > /app/logs/hospital.log 2>&1 &前端打包上传:
npm run build # 把 dist 目录上传到服务器 /usr/share/nginx/htmlNginx 配置是部署的核心,我给出一个可以直接抄的配置,它同时解决了静态资源托管、API 反向代理、history 路由回退三个问题:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; # 前端路由回退 location / { try_files $uri $uri/ /index.html; } # API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # m3u8视频流 location ~ \.(m3u8)$ { add_header Content-Type application/vnd.apple.mpegurl; } location ~ \.(ts)$ { add_header Content-Type video/mp2t; } }这里proxy_pass http://127.0.0.1:8080/;结尾的斜杠很重要,它表示去除/api前缀后再转发。前端请求/api/user/login,后端收到的是/user/login。如果你的后端context-path配置了/api,那proxy_pass就需要去掉斜杠,这两种方式二选一,最忌讳的是两边都加前缀,最后 404 找不到接口。
5.3 部署期高频故障排查表
部署过程中碰到的问题五花八门,我梳理了出场率最高的几类,用表格列出来方便你对照处理。
| 故障现象 | 根本原因 | 解决方案 |
|---|---|---|
| 浏览器白屏,控制台提示 chunk 加载失败 | 前端打包 publicPath/base 配置错误,或部署目录与配置不一致 | 检查 vite.config.js 的 base 配置,上传 dist 到 Nginx 对应 root 目录 |
| 前端能打开,但接口全部 404 | Nginx 的 proxy_pass 转发路径和后端 context-path 不匹配 | 对照 5.2 的两种代理方式,确保前缀不冲突 |
| 接口报 401 | token 过期、缺失或请求头名称不一致 | 检查 axios 请求拦截器是否携带 Authorization,后端拦截器白名单是否正确 |
| 数据库连接失败 access denied | MySQL 用户授权或密码错误 | 检查 MySQL 用户是否允许远程登录,host 是否为% |
| 后端启动时报端口被占用 | 8080 端口被其他进程占用 | `netstat -tlnp |
| 接口报时区错误 | MySQL 连接串缺少 serverTimezone | 在 jdbc-url 加serverTimezone=Asia/Shanghai |
| 上传的图片/视频打不开 | 静态资源路径或 MIME 类型错误 | 检查 Nginx location 配置里的 root 路径,补 add_header Content-Type |
排查部署问题的基本原则是自底向上:先确认页面能不能打开,再看接口通不通,最后看数据库有没有数据。每层用 curl 验证,不要一上来就瞎猜配置。
6. 上线前必须处理的几个环节:安全加固、性能优化与后续扩展
6.1 医疗数据的隐私与安全要点
医疗服务平台的用户数据属于高敏个人数据,这个性质决定了安全意识要从开发第一天就建立,而不是上线出问题才补救。密码存储必须用 BCrypt 这类自适应哈希算法,每次加密的盐是随机的,即使数据库泄露也无法批量逆向。千万不要用 MD5 或者 SHA1 直接存密码,彩虹表分分钟把你打穿。
接口返回值里的敏感字段要做脱敏。医生手机号在管理后台列表中应该显示成138****1234,患者真实姓名在非必要场景用张*代替。这个可以在 VO 对象的 getter 方法里做,或者用 Jackson 的自定义序列化注解统一处理。
MyBatis 的 SQL 注入防护记住一条铁律:能用#{}的地方绝对不用${}。#{}是预编译参数占位符,MyBatis 会把它替换成?,由 JDBC 驱动做参数绑定,天然免疫注入;而${}是字符串拼接,仅用于动态表名、排序字段这类无法参数化的场景,使用前必须做白名单校验。
越权防护是医疗项目里最容易被忽略的安全漏洞。前端隐藏了"删除病历"按钮不代表接口安全,攻击者完全可以直接调用后端接口。所以在每一个涉及资源操作的接口里,除了校验登录态和角色,还要校验资源归属权——医生只能操作doctor_id等于当前登录用户ID的病历,患者只能查询patient_id等于自己ID的档案。这个校验写在 Service 层,统一封装成工具方法,避免每个 Controller 写一遍。
6.2 高频接口的性能优化思路
医疗平台性能优化的重点不是花式加缓存,而是先把慢查询和无效请求解决掉。我的实践顺序是:开 MySQL 慢查询日志,找出执行时间超过 1 秒的 SQL;用EXPLAIN分析执行计划,检查是否走索引;优化 SQL 或补建索引;最后才考虑缓存。
科室列表、排班日历这类读多写少的数据,可以用 Redis 缓存,设置合理的过期时间(比如科室列表 30 分钟)。但挂号余号、病历详情这种强一致数据不能缓存,或者缓存时要有非常明确的失效策略。还有一个小细节,列表接口必须分页,前端不要一次性拉取所有数据。患者端历史挂号记录、医生端患者队列,数据量会随着时间增长,不分页的接口上线三个月后必崩。
我建议在后端加一个简单 APIH 层,记录每个接口的耗时,超过 2 秒的自动告警。不需要引入复杂监控系统,一个拦截器加一个日志表就够了,但收益巨大——你会在用户投诉之前知道系统哪里变慢了。
6.3 从单体走向演进:哪些扩展值得做
这套系统交付后如果要继续演进,我的建议优先级是从业务痛点到技术升级:先做预约提醒,引入 ActiveMQ 这类消息队列做异步通知,挂号成功、停诊通知都是典型的异步场景,流量削峰的同时把耗时操作从请求链路里摘掉;再做业务数据可视化,大屏看板这类需求客户几乎一定会提,复用现有接口做聚合统计就行;然后可以考虑接入 onlyoffice 实现在线预览检查报告、处方单,减少打印和线下流转;视频问诊的话不是普通 Web 开发能搞定的,建议直接用成熟的音视频服务。微服务拆分和容器化编排放到最后,等真正出现某个模块需要独立扩展时再动,不要为技术而技术。
我在实际交付中反复验证过一件事:把注册登录、挂号、接诊这条核心链路用最扎实的工程手段做透,远比铺一堆炫技代码更有价值。这种项目客户不关心你用了什么中间件,只关心系统能不能稳定跑、数据会不会出错、患者用起来顺不顺手。你如果准备拿这套骨架做二次开发,也建议先别急着加功能,开两个浏览器窗口分别以患者和管理员身份把完整流程走一遍,你会在这一步发现很多写代码时根本看不出来的逻辑漏洞。把这些基础问题堵住,项目的战斗力会提升一个档次。