简介:本资源是一套面向计算机专业本科生毕业设计与课程实践的健康管理系统完整开发方案,基于Spring Boot后端框架、Vue前端框架与MySQL数据库构建,聚焦健康档案管理、实时监测、风险评估、个性化干预及医患互动等核心业务场景。压缩包共含前端Vue源码、后端SpringBoot工程、MySQL建库脚本及详细部署文档,总计8.21MB,文件结构清晰,涵盖系统管理、用户权限、随访中心、健康百科等九大功能模块,便于学习者快速理解全栈开发流程与医疗信息化系统设计逻辑。目前已有1639人下载学习,适用于毕业论文选题参考、Java+Vue综合实训项目复现或健康类信息系统二次开发基础支撑。
1. 为什么做健康管理系统:被纸质体检报告逼出来的项目
先说个真实的场景。前两年公司组织年度体检,我拿到手的是一沓A4纸,上面密密麻麻印着几十项指标,有的偏高有的偏低,但医生只给了一句"定期复查"就结束了。最让人头疼的是,去年和今年的体检报告格式居然还不一样,想对比一下血糖血脂的变化趋势,我得同时摊开两份纸质报告,拿尺子对着看。我当时就想,这种健康数据散落在各个医院、各个年份的体检报告里,完全没有被利用起来,而它们恰恰是最需要被连续追踪、被结构化存储、被直观呈现的东西。
这个健康管理系统,就是冲着这个痛点去的。它解决的不是"看病"的问题,而是"健康数据的日常管理"问题——你可以把身高体重、血压血糖、运动记录、饮食摄入统一录入到一个系统里,系统自动计算BMI、评估指标是否在正常区间,生成趋势曲线和健康建议,所有数据永久留存,随时可查、可对比、可导出。
我选SpringBoot+Vue这套组合来做,不是因为它们"流行",而是因为这套技术栈在个人项目里有着非常务实的优势:SpringBoot让后端的接口开发、数据访问、权限校验变得极其省事,一个注解就能搞定路由,一个starter就能集成数据库连接池;Vue的前端生态则让我不用花太多精力在DOM操作上,把精力集中在数据绑定和图表展示上。对于"管理系统"这种典型的CRUD+统计分析场景,没有比这组合更成熟的搭配了。
这个项目适合谁?如果你正在做毕业设计、课程设计,或者刚开始接触前后端分离开发,想找一个"不是玩具"的完整项目练手,那它很适合你。它覆盖了一条完整的链路:数据库设计、后端接口开发、鉴权体系、前端页面交互、图表可视化、打包部署。看完这篇,你能带走的不只是"我跑通了一个Demo",而是能独立复现一个可上线、可维护的系统。下面我会把整个设计过程、核心代码逻辑、还有我实际踩过的坑,全部拆开讲清楚。
2. 领域建模与数据库设计:先把表的边界划清楚
2.1 核心实体与关系梳理
做管理系统,很多人一上来就写代码,结果写到一半发现字段对不上、关系理不清、前端要的数据后端根本没存,然后开始返工。我在动手之前先花了一天梳理实体关系,这是整个项目里最值得的一笔时间投资。
健康管理系统的核心实体,我最终收敛成了五个:用户(user)、健康记录(health_record)、运动记录(exercise_record)、饮食记录(diet_record)、健康建议(health_tip)。外加几个辅助表用于支撑功能,比如系统通知、用户收藏的健康文章。
它们的业务关系是:一个用户拥有多条健康记录,一条健康记录包含一次测量时点的身高、体重、血压、血糖、心率等完整指标。运动记录和饮食记录与健康记录独立,按日期和用户关联——因为用户可能在同一天既记录了体检指标,又记录了运动量和三餐,它们从不同维度支撑健康分析。
这里有一个我特别想强调的设计要点:健康记录不要设计成"每个字段一张表"的竖表模式。我见过不少课程设计,把血压一张表、血糖一张表、体重一张表,然后为了展示一个趋势图,前端要同时调三个接口再合并数据。正确的做法就是用一张横表,一行代表一次测量记录,字段冗余一点没关系,查询时的性能收益和代码简洁度是巨大优势。竖表的行数膨胀、JOIN频繁、索引难以设计,这在个人项目里完全没有必要。
CREATE TABLE health_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '关联用户ID', height DOUBLE NOT NULL COMMENT '身高(cm)', weight DOUBLE NOT NULL COMMENT '体重(kg)', sbp INT COMMENT '收缩压(高压mmHg)', dbp INT COMMENT '舒张压(低压mmHg)', fasting_blood_sugar DOUBLE COMMENT '空腹血糖(mmol/L)', heart_rate INT COMMENT '静息心率(次/分)', measure_date DATE NOT NULL COMMENT '测量日期', record_source VARCHAR(20) DEFAULT 'MANUAL' COMMENT '数据来源:MANUAL手动录入/IMPORT批量导入', remark VARCHAR(255) COMMENT '备注', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_date (user_id, measure_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个表的核心索引是(user_id, measure_date)的联合索引。为什么?因为健康管理系统最频繁、最核心的查询就是"查某个用户在某段时间内的所有记录",用这个联合索引,MySQL可以直接走索引范围扫描,不需要额外的文件排序。这个索引设计我是在上线前做性能测试时才补上的,当时数据量到3万条之后,查一年的趋势数据慢到接近2秒,加上索引后直接降到30毫秒以内。
2.2 单位与数据字典:隐藏的坑
健康管理系统的表结构里,最大的坑不是关系设计,而是单位。
身高用什么单位?厘米还是米?体重是千克还是斤(国内的秤很多显示斤)?血糖是空腹血糖还是餐后血糖?血压是坐着测的还是站着测的?如果不在数据库层面做强制约定,后面所有的计算逻辑都会乱套。
我的做法是:数据库层面统一使用国际单位,身高存cm、体重存kg、血糖存mmol/L、血压存mmHg,这些单位在字段注释里明确写清楚。前端录入时,体重输入框允许用户切换"kg"和"斤",在提交前由前端统一换算成kg;后端接口再校验一遍数值范围,比如身高在50cm到250cm之间、体重在10kg到300kg之间才算合法。前后端都做校验,不是冗余,是因为后端校验是最后一道防线,前端校验是为了用户体验——用户填错了能立刻被提示,不用等提交到服务器再被驳回。
还要注意一个细节,BMI的计算依赖身高和体重,如果某条记录里身高填了1.75(意思是1.75米),另一条记录里身高填了175(意思是175厘米),BMI算出来会差10000倍。数值范围校验之所以必须做,就是因为这种"单位理解不一致"的脏数据会直接污染计算结果和趋势图。
2.3 健康建议表与"伪规则引擎"的设计
刚开始我想把健康建议的逻辑写死在Java代码里,比如if (bmi > 24) 返回"体重偏重,注意控制饮食"。后来我改变了设计,原因很简单:写死在代码里的规则,业务人员(或者我自己)想调整文案,必须重新编译、重新打包、重新部署。这太重了。
最终方案是新增一张健康建议表,把规则阈值和建议文案解耦。每个建议由几个字段描述:指标类型(bmi、sbp、dbp、blood_sugar等)、比较符号(>=、<、between)、阈值上下界、建议等级(INFO/WARNING/DANGER)、建议内容。后端的评估服务查询所有启用的规则,对当前记录逐条匹配,命中的规则就是这条健康记录对应的建议。
CREATE TABLE health_tip ( id BIGINT PRIMARY KEY AUTO_INCREMENT, indicator VARCHAR(30) NOT NULL COMMENT '指标标识,如bmi/sbp/fbg', operator VARCHAR(10) NOT NULL COMMENT '运算符:>=、<、between', threshold_min DOUBLE COMMENT '阈值下限', threshold_max DOUBLE COMMENT '阈值上限', advice_content VARCHAR(500) NOT NULL COMMENT '建议内容', tip_level VARCHAR(20) DEFAULT 'WARNING' COMMENT '建议等级', enabled TINYINT DEFAULT 1 );这个设计其实就是个极简版的规则引擎。它带来的好处非常直观:想调整建议文案,执行一条UPDATE语句就生效,不需要动代码;想增加一个"血氧饱和度"指标的建议,INSERT一条记录即可。我还给用户表加了一个birth_date字段,健康建议在计算时会根据年龄做差异化判断——同样是血压130/90,对年轻人可能是警告,对65岁以上老人可能就属于需要关注的临界值。年龄相关的基础信息在注册时完善,之后评估逻辑就能自动适配不同人群,这是写死在代码里很难做到灵活的。
3. 后端SpringBoot实现:从JWT认证到指标计算的完整链路
3.1 工程结构与统一响应封装
后端工程我用了标准的Maven单模块结构,没有拆多模块——虽然很多人推荐微服务拆分,但对这个体量的管理系统来说,单模块足够,拆了反而增加维护成本。结构如下:
health-backend/ ├── pom.xml └── src/main/java/com/health/ ├── HealthApplication.java ├── config/ # 跨域、全局异常、MyBatis-Plus配置 ├── controller/ # 接口层 ├── service/ # 业务逻辑层 ├── mapper/ # MyBatis-Plus数据访问层 ├── entity/ # 数据库实体 ├── dto/ # 请求响应对象 ├── common/ # 统一返回、分页、常量 └── util/ # JWT、加密等工具pom.xml里依赖就几个关键的:spring-boot-starter-web、mybatis-plus-boot-starter(3.5.3版本)、mysql-connector-j、jjwt(0.9.1,JWT令牌)、hutool-all(工具类,省得自己写日期转换)、lombok。没有引入spring-cloud一整套东西,那个对这个项目来说完全是过度设计。
SpringBoot版本我用的2.7.x而不是3.x,原因是3.x要求JDK17,而且一些starter的兼容性需要额外适配。如果你只是想快速把项目跑起来、把精力花在业务逻辑上,2.7 + JDK8/11这套组合是最稳的,网上查资料踩坑也少。
统一响应封装是必须做的。我定义了一个Result<T>类,包含code、message、data三个字段。所有Controller接口都返回这个类型,前端axios拦截器统一判断code,如果code不等于200就统一弹出错误提示。这么做的好处是,错误处理逻辑集中在前端拦截器里,业务代码里不用每个请求都写一遍错误分支。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }3.2 认证与权限:一张表搞定管理员和普通用户
这个系统有两类角色:管理员和普通用户。管理员负责管理健康建议、查看文章、查看所有用户数据;普通用户只能管理自己的数据。
我一开始考虑过引入Spring Security + 一套复杂的权限体系,后来放弃了。不是Spring Security不好,而是对这个项目来说太重了——它的过滤器链、认证管理器、权限表达式,一套配置下来少说几百行。我更倾向于用一个轻量级的拦截器+JWT方案,这就是够用且可控的权限设计。
用户表里加一个role字段,值为ADMIN或USER。登录成功后,后端生成JWT令牌,把用户ID和角色放进token里。然后写一个拦截器,实现HandlerInterceptor接口,在preHandle方法里从请求头的Authorization字段取出token,解析校验,把用户信息放进ThreadLocal供后续业务方法使用。
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 if (request.getRequestURI().contains("/auth/")) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { Claims claims = JwtUtil.parseToken(token.substring(7)); if (claims != null) { UserContext.set(claims.get("userId", Long.class), claims.get("role", String.class)); return true; } } response.setStatus(401); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }角色控制我用了一个自定义注解@RequireRole("ADMIN")配合方法拦截来实现。在需要管理员权限的Controller方法上打上这个注解,在拦截器里再判断一次用户角色。实现成本很低,但权限控制的表达变得非常清晰。
密码存储用的是BCrypt哈希,这是Spring Security里的BCryptPasswordEncoder,但我不需要引入整个Spring Security,直接把那个类拷出来用,或者用jBCrypt库也行。明文密码是绝对不存的,哪怕是自己练手的项目,也应该养成这个习惯。
3.3 指标计算与趋势分析的Service层设计
健康记录的核心计算逻辑有两个:单次测量的健康评估,和一段时间内的趋势变化。
单次测量的健康评估,核心是计算BMI和判断指标是否在正常范围内。BMI的公式非常简单,但注意实现时的类型处理,身高在数据库里存的是cm,换算成米时要除以100,计算结果是double,保留一位小数。
public double calculateBmi(HealthRecord record) { double heightM = record.getHeight() / 100.0; return BigDecimal.valueOf(record.getWeight() / (heightM * heightM)) .setScale(1, RoundingMode.HALF_UP) .doubleValue(); }趋势分析则是调复杂度的关键点。前端需要展示近30天、近90天、近1年的体重变化曲线,最粗糙的做法是前端把全部记录拉回来再自己过滤,但数据量大了之后响应体越来越大。我的做法是后端提供一个趋势接口,参数是userId、startDate、endDate、indicatorType(要查询的指标,比如weight、sbp、fasting_blood_sugar),SQL里用WHERE条件过滤时间范围,只返回目标指标和日期两个字段。
为了保证曲线平滑,我还做了一步处理:如果某一天有多条记录,只取当天平均值。这一步在SQL里用AVG函数配合GROUP BY DATE(measure_date)实现。多天平均的好处是,用户一天测了三次血压(晨起、午后、睡前),曲线只显示一个点,不会因为一天内的波动让整条曲线看起来像锯齿。
3.4 定时任务与健康提醒
健康管理不应该是"用户打开系统才能看到数据",那样活跃度会非常低。我加了一个定时任务,每天上午9点扫描前一天有健康记录的用户,分析记录中是否存在异常指标,如果有异常,给用户推送一条提醒(在系统内生成通知记录)。
实现方式是在SpringBoot启动类加上@EnableScheduling,然后定义一个定时任务类。用@Scheduled(cron = "0 0 9 * * ?")指定每天9点执行。这里有个实践细节:定时任务的查询不要直接扫全表,而是先查出"前一天有记录的用户ID列表",再针对每个用户查询其最近3次的记录评估,避免高频率的全表扫描。
这个模块我做得比较克制,没有引入消息队列。项目里所有提醒都是写库,用户登录系统后在首页的通知栏看到未读数量。引入RabbitMQ、Kafka对这种体量的项目来说纯属给自己找事,数据量到了一定级别再考虑异步化也完全来得及。
4. 前端Vue的交互设计:让健康数据看得懂而不是堆表格
4.1 路由与权限控制的落地方式
前端我用的Vue 3 + Vite + Vue Router 4 + Pinia + Element Plus + ECharts。Vite作为构建工具比Webpack快太多,尤其在开发环境冷启动和热更新体验上,简直是质的飞跃。
路由设计分为两大部分:公共页面和需要登录的页面。登录页、注册页放在公共区;首页、健康记录管理、趋势图表、健康建议、个人中心都放在需要授权的Layout布局下。
const routes = [ { path: '/login', component: Login }, { path: '/register', component: Register }, { path: '/', component: Layout, redirect: '/home', meta: { requiresAuth: true }, children: [ { path: 'home', component: Home, meta: { title: '健康总览' } }, { path: 'record', component: RecordManage, meta: { title: '健康记录' } }, { path: 'chart', component: TrendChart, meta: { title: '趋势分析' } }, { path: 'tip', component: HealthTip, meta: { title: '健康建议' } }, { path: 'profile', component: Profile, meta: { title: '个人中心' } } ] } ]权限控制落地在路由守卫里。每次路由跳转前,检查meta.requiresAuth,如果为true就看看Pinia store里有没有token和用户信息。没有就跳转登录页,并且记录下当前要去的路径,登录成功后自动跳回。
前端光有路由守卫还不够,按钮级的权限控制也要做。管理员能看到"健康建议管理"的入口和"用户管理"的入口,普通用户看不到。我用了一个简单的自定义指令v-permission,在按钮或菜单上标注需要的角色,指令内部判断当前用户角色是否匹配,不匹配就移除这个DOM元素。这种方法比在每个页面里写v-if="role === 'ADMIN'"干净得多。
4.2 组件划分:ECharts图表组件怎么和业务解耦
趋势分析页面的核心是一堆图表:体重趋势折线图、血压趋势双轴图、血糖变化曲线。如果直接在页面组件里写echarts.init,页面会越来越臃肿,而且图表需要在数据加载完成后重新渲染,生命周期管理很容易出错。
我封装了一个通用的BaseChart.vue组件,它接收一个optionprop,内部负责初始化ECharts实例、监听option变化并重新setOption、窗口resize时自动调用chart.resize、组件卸载时销毁实例。业务页面只需要根据接口数据组装出ECharts的option对象传进来,完全不关心图表的生命周期。
<!-- BaseChart.vue --> <template> <div ref="chartRef" class="chart-container"></div> </template> <script setup> import * as echarts from 'echarts' import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' const props = defineProps({ option: { type: Object, required: true } }) const chartRef = ref(null) let chart = null function renderChart() { if (!chart) { chart = echarts.init(chartRef.value) } chart.setOption(props.option) } function handleResize() { chart && chart.resize() } onMounted(() => { renderChart() window.addEventListener('resize', handleResize) }) watch(() => props.option, () => { nextTick(() => renderChart()) }, { deep: true }) onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) chart && chart.dispose() }) </script>血压的双轴图是个难点。收缩压和舒张压的数值区间都在同一量级(80-180之间),所以其实不需要双Y轴,一条轴就够。但如果你要同时展示体重(kg)和血压(mmHg),这两个量级差很多,就要配置双Y轴了。ECharts里的做法是在yAxis数组里定义两个轴,左侧轴关联体重系列,右侧轴关联血压系列。这个封装组件只是接收option,不关心业务逻辑,所以双轴图的使用方式和其他图完全一样。
4.3 表单校验与单位换算的前端处理
健康记录的录入表单是整个系统里用户最常操作的表单,它的体验直接决定了这个系统好不好用。我的表单分为两块:基础身体数据(身高、体重)和生化指标(血压、血糖、心率)。
单位换算的处理我放在表单提交前的拦截函数里。表单允许用户选择体重的显示单位(kg/斤),默认是kg。如果用户选了"斤"并输入了140,提交时自动除以2转为70kg再传给后端。身高同理,允许输入厘米,不做复杂的米/厘米切换,因为针对日常使用,厘米是最直观的。
表单校验用的是Element Plus的rules配置。我这里想分享一个心得:数值类的校验不要只校验"必填",一定要校验范围。我遇到过用户把血压的收缩压填成了800,BMI直接变成几百,趋势图被这一个脏点拉得完全没法看。所以每个数值字段都加了范围校验:身高50-250cm,体重10-300kg,血压40-260mmHg,血糖0.5-30mmol/L,心率30-220次/分。超出范围直接拦截并给出友好提示,这一条规则让后续的统计计算省了无数心。
5. 部署上线的完整操作记录:从本地跑通到服务器可用
5.1 环境准备与版本匹配
部署环节是这个系统真正从"毕设Demo"走向"能用的产品"的分水岭。我在本地调试和线上部署时踩了不少环境相关的坑,先把版本对应关系整理清楚。
| 组件 | 本地开发版本 | 服务器版本 | 说明 |
|---|---|---|---|
| JDK | 1.8 | 1.8 | SpringBoot 2.7.x推荐JDK8或11 |
| Maven | 3.8.x | 3.8.x | 后端打包构建 |
| Node.js | 16.20.2 | 16.20.2 | Vite 4要求Node >= 14.18 |
| MySQL | 8.0 | 8.0 | 使用InnoDB引擎 |
| Nginx | - | 1.24.0 | 前端静态资源服务+反向代理 |
这里特别提醒一点:Node.js不要图新直接上20的大版本,有些老项目依赖原生模块编译不过。实测Vite 4 + Node 16.20.2最稳,如果用了node-sass这类有编译期依赖的包,Node版本更要谨慎。
5.2 数据库初始化与账号配置
数据库初始化我用了一份init.sql脚本,建库、建表、插入初始数据一步到位。首次部署时执行:
mysql -u root -p < /opt/health-system/init.sql脚本里包含创建数据库health_system、五张业务表的CREATE TABLE语句、初始管理员账号(admin/admin123,密码字段存的是BCrypt哈希)、默认的健康建议规则数据。
线上数据库账号不要用root,我是单独创建了一个专用账号,只授予这个库的增删改查权限:
CREATE USER 'health_app'@'localhost' IDENTIFIED BY '你的强密码'; GRANT SELECT, INSERT, UPDATE, DELETE ON health_system.* TO 'health_app'@'localhost'; FLUSH PRIVILEGES;这样即使后端被拖库,数据库账号也拿不到系统级的权限,安全边界至少是清晰的。
SpringBoot的application-prod.yml配置文件单独放一份,数据库连接地址用环境变量占位,比如jdbc:mysql://${DB_HOST}:3306/health_system,部署时在systemd里通过Environment注入。这样配置文件和代码彻底分离,数据库密码不会出现在jar包里。
5.3 前后端打包与Nginx反向代理
后端打包:
mvn clean package -DskipTests # 目标文件:target/health-backend-1.0.0.jar前端构建:
npm install npm run build # 构建产物:dist/ 目录前端构建产物是一个纯静态目录,我把它放到服务器的/opt/health-system/dist下,用Nginx服务。同时Nginx配置反向代理,把/api/开头的请求转发给后端的http://127.0.0.1:8080。
server { listen 80; server_name your-domain.com; # 前端静态资源 root /opt/health-system/dist; index index.html; # 前端路由history模式:所有非文件路径回退到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; } }这里有几个细节值得注意。try_files $uri $uri/ /index.html;这一行是必须的,因为Vue Router使用了history模式,如果没有它,用户直接访问/home或者刷新/home页面会得到一个404。proxy_pass结尾的/也很有讲究,/api/会被替换成http://127.0.0.1:8080/,也就是说/api/record/list会转发为http://127.0.0.1:8080/record/list,这样后端Controller里就不需要在路径上统一带/api前缀了。
5.4 systemd守护进程配置
后端Java进程不能直接java -jar跑在终端里,终端一关进程就没了。我用systemd把它注册成系统服务,这样它能开机自启、异常退出自动重启、日志也能被journald统一管理。
[Unit] Description=Health System Backend After=network.target mysqld.service [Service] User=deploy WorkingDirectory=/opt/health-system Environment="DB_HOST=127.0.0.1" Environment="DB_PORT=3306" Environment="DB_NAME=health_system" Environment="DB_USERNAME=health_app" Environment="DB_PASSWORD=你的强密码" ExecStart=/usr/bin/java -Xms256m -Xmx512m -jar /opt/health-system/health-backend-1.0.0.jar --spring.profiles.active=prod Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target-Xms256m -Xmx512m是我根据服务器2G内存情况设置的堆大小,别一上来就给1G甚至2G,服务器是1核2G的话,堆给512m已经够这个系统跑了。Restart=on-failure是保证进程崩了能自动拉起来,我之前遇到过凌晨一次内存溢出把进程带崩,第二天早上用户才发现服务挂了,加上这个之后再也没出过这种问题。
配置写好后:
sudo systemctl daemon-reload sudo systemctl enable health-backend sudo systemctl start health-backend部署完成后整个验证路径就是:访问http://服务器IP看到前端登录页,用管理员账号登录,新增一条健康记录,查看趋势图,再检查后端日志里有对应的SQL执行记录。一套走通,系统就算正式上线了。
6. 实测踩过的坑和对应的解决办法
6.1 跨域配置拦了我一个小时
前后端分离开发时,前端跑在localhost:5173(Vite默认端口),后端跑在localhost:8080,浏览器的同源策略会拦截所有POST请求。这个问题开发第一天就会遇到。
解决方案是在后端加一个CORS配置类,允许指定来源、指定请求头、指定方法。这里有个特别容易被坑的点:跨域预检请求(OPTIONS)必须放行,并且 allowedHeaders 一定要包含 Authorization。因为自定义的JWT令牌是放在请求头里的,如果allowedHeaders没有显式加上Authorization,浏览器预检阶段就直接失败了,前端看到的就是"Network Error",这个错误信息非常有误导性,会让你以为是网络问题。
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }注意配置里的allowCredentials(true),它表示允许携带Cookie。我的项目虽然用的是JWT而非Cookie,但开了这个选项后,前端请求头里携带Authorization不会被拦截。如果你不设这个字段,即使代码里加了Authorization头,浏览器也可能不让它生效。
6.2 JWT过期后前端刷新页面的白屏问题
这是一次典型的前端状态管理问题。JWT令牌默认有效期我设置的是24小时,用户隔天打开系统时令牌已经过期,但Pinia store里的用户信息还在(因为刷新页面时Pinia会重新从localStorage读取)。此时用户页面可以正常显示,但所有接口调用都返回401。
最糟糕的情况出现在某个页面的created钩子里。它依赖接口返回值来渲染图表,理想流程是"取到数据 → 渲染图表",但当401时,代码会进入catch分支,图表不渲染,页面看起来就是一个白屏。用户不知道发生了什么,也不知道要重新登录。
我的解决方案有两点。第一,axios响应拦截器里全局处理401:遇到401就清除本地存储的token和用户信息,跳转登录页,并且用ElMessage提示"登录已过期,请重新登录"。第二,在路由守卫里判断token是否存在,而不是仅判断Pinia store里的对象——因为store是内存态,刷新会清空,但路由守卫要等store初始化完成后才能拿到正确的判断结果。
// axios响应拦截器 service.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') localStorage.removeItem('userInfo') router.push({ path: '/login', query: { redirect: route.fullPath } }) } return Promise.reject(error) } )6.3 ECharts在v-if容器里的渲染尺寸问题
这是我花了最多时间排查的前端问题。图表页面的Tab切换用了el-tabs或者v-if来切换不同的图表卡片,第一次切到趋势分析Tab时,图表区域显示是正常的,但一旦切换到别的Tab再切回来,图表就变成了一块扁平的、被挤压的区域,宽度或高度变成了0。
问题根源是:ECharts在初始化时读取的是容器DOM的宽度和高度,如果此时容器被display: none隐藏,它读到的尺寸就是0。即使容器在数据加载后显示了,ECharts实例的尺寸仍然停留在初始化时的0值,不会自动更新。
解决方案有两个思路。第一个是用v-show而不是v-if来切换图表显示,因为v-show只是CSS的display切换,不会销毁DOM,ECharts实例持有的是同一个DOM引用,尺寸信息不会被重置。第二个是如果非要用v-if(因为懒加载确实能减少初始渲染开销),那么每次创建图表实例之前,先判断容器尺寸是否合法,不合法就等nextTick后再初始化,并在容器显示后调用chart.resize()。
最终我的BaseChart组件里增加了对resize的监听,并且在高阶封装中为每个图表传入了一个visibleprop,当visible从false变为true时,延迟到nextTick再调用chart.resize()。实测下来,两种方式都能解决,但v-show+ resize监听更省心。
6.4 接口性能:健康趋势查询的SQL优化
数据量在几千条时,趋势查询没感觉。但当我用脚本造了三万条测试数据后,体重趋势接口的响应时间从50ms飙升到接近2秒。用MySQL的EXPLAIN一分析,发现查询是全表扫描,没有走任何索引。
问题定位很清晰:虽然health_record表上有(user_id, measure_date)的联合索引,但趋势接口的SQL写成了WHERE user_id = ? AND measure_date BETWEEN ? AND ?,MySQL优化器在某些条件下会选择全表扫描,因为表的记录数不算特别大,优化器认为走索引的回表成本可能更高。
我的解决方式是强制SQL走索引,使用MyBatis-Plus的QueryWrapper指定索引:
QueryWrapper<HealthRecord> wrapper = new QueryWrapper<>(); wrapper.eq("user_id", userId) .between("measure_date", startDate, endDate) .last("FORCE INDEX(idx_user_date)");强制索引加上之后,查询直接降到30ms以内。后来我意识到,与其强制索引,不如把趋势查询单独剥离出来,用独立的通知表在写入时同步计算趋势汇总值,查询时直接读汇总表。这个方案叫"读模型与写模型分离",虽然在这个体量下有点用力过猛,但思路是值得借鉴的——如果查询越来越复杂、越来越慢,就考虑为查询单独建一张表或者加缓存。
6.5 时间字段序列化引发的JSON格式异常
前后端联调时还有一个很隐蔽的坑:后端返回的LocalDateTime字段默认序列化结果是"2024-05-12T09:30:00",中间带一个大写的T,前端用new Date()解析在某些浏览器里没问题,但用Element Plus的日期组件回显时就会报 invalid date。
解决方案是在application.yml里统一配置Jackson的日期格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这样所有LocalDateTime字段序列化成"2024-05-12 09:30:00",前端解析无压力。这个配置在本地开发时没暴露问题,是因为前端的日期组件在某些实现里能自动兼容ISO格式,但换一个组件就暴露出来了。这种"本地没问题、上线出状况"的坑最烦人,提前统一格式能省很多心。
7. 一些真心话与后续扩展方向
项目做到这里,功能基本完整,代码也能跑、能部署、能上线。但你要问我"这个系统最大的价值是什么",我现在的答案是:它让我把前后端分离开发的全流程走了一遍,并且深刻认识到一个道理——系统好不好用,核心不是技术栈多牛,而是数据模型设计得好不好。
健康管理系统的本质,是把现实世界里散乱、多源、异构的健康数据,转换成结构化、可查询、可计算的形式。数据库表设计时多花的那一天时间,后面省下的可能是十个晚上的返工。单位的统一、时间字段的格式、索引的设计、状态字典的约定,这些看着不起眼的小事,才是系统稳定运行的关键。
后续我计划在几个方向做扩展:一个是接入设备数据,现在市面上很多体脂秤、血压计都开放了蓝牙或云端API,如果能自动同步数据,就能彻底解放用户手动录入的负担;一个是增加健康报告的PDF导出功能,把一段时间的趋势和评估结果生成一份专业报告,这对用户去线下就医时非常有用;还有一个是用更细粒度的健康评分模型替代目前的规则引擎,对用户的整体健康状态做一个综合评分,用一个数字直观呈现"今天的状态好不好"。
最后一个关于部署的小技巧分享:如果你的服务器配置不高,在application-prod.yml里把MySQL连接池的maximum-pool-size调小一点,默认的HikariCP连接池会按CPU核心数计算默认值,2核机器上默认给到10个连接,但对于这种一人一个账号的管理系统,同时在线人数通常个位数,连接池给5个完全够用,省下来的内存留给JVM堆更合适。
截止到这里,这个项目从选型、建模、编码到部署的全过程就都讲完了。里面所有的代码片段、配置、命令,都是我从实际项目中拷出来的,你用的时候按实际情况改改参数就行。如果还有哪个环节需要深挖,欢迎评论区交流。
本文还有配套的精品资源,点击获取