做毕设选“基于SpringBoot的健康管理系统”的人很多,但大多数都是套个框架就开始写增删改查,最后做出来的东西既没有健康管理的业务闭环,也体现不出SpringBoot的核心价值。最近后台陆续收到几个同学的提问,问这个题目到底怎么设计才算完整、技术选型怎么定、表怎么建、鉴权和文件存储怎么接。这篇文章我把自己做这套系统的完整思路和落地过程整理出来,包括功能边界梳理、数据库设计、核心模块编码、JWT鉴权、MinIO文件存储、Vue前端打包集成,还有运行阶段踩过的坑。无论你是正在准备毕设,还是想把SpringBoot的开发流程系统地捋一遍,这篇都可以当作一个能直接参考的工程模板。
1. 健康管理系统到底在做什么:功能边界与技术选型的对应分析
1.1 这个系统不是普通的CRUD:四大业务闭环
如果只看表面,健康管理系统确实就是用户信息、指标数据、运动记录的增删改查。但真正设计的时候,你会发现如果只按这种思路做,系统会变成一个“录入工具”,没有实用价值。健康管理系统的核心应该是形成一个闭环:采集、建档、分析、干预。
采集:用户每天录入体重、血压、心率、血糖、血氧这些指标,也可以考虑预留穿戴设备数据同步的接口。建档:把用户的基础信息、既往病史、过敏史、家族病史、体检报告统一归档,形成一份动态更新的健康档案。分析:根据指标时间序列生成趋势图,计算BMI、体重变化率,判断是否存在异常区间,给出简单的健康建议。干预:系统根据规则给用户发送提醒,比如“该测血压了”“体重连续一周上涨”“某项指标连续三次异常,建议就医检查”。
这四个环节落到系统里,对应的就是用户模块、健康档案模块、指标管理模块、提醒与建议模块,再加上管理员端的数据统计和用户管理。功能边界先划清楚,后面的表设计和接口设计才不会乱。我在动手写代码之前,先把每个模块的用户故事列了一遍,比如“用户登录后能看到最近30天的血压趋势”“管理员可以按时间段查看所有用户的指标异常情况”,然后再往表结构和接口上映射。这个过程不能省,因为健康管理系统的数据链路比一般管理系统长,跳着设计很容易漏表。
1.2 为什么选SpringBoot而不是SSM或微服务
说实话,虽然现在面试题里全是微服务、分布式、Spring Cloud,但一个单体管理系统用SpringBoot是最合理的。原因有三点。
第一点很直接:自动配置把样板配置砍掉一大截。传统SSM项目要手写web.xml、spring-mvc.xml、mybatis-config.xml,还要配一堆bean,很多配置跟业务没有半毛钱关系,纯粹是浪费时间。SpringBoot用starter机制把依赖和默认配置打包好,引入依赖就能跑。我实际搭骨架的时间大约缩短了三分之二。
第二点是内置Tomcat让部署变简单。毕设项目通常要拿到别的机器上演示,传统SSM要装Tomcat、配端口、部署war包,SpringBoot直接java -jar跑一个可执行jar,配合多环境配置文件,演示环境五分钟就能起。
第三点是生态成熟,找资料容易。SpringBoot相关的博客、组件、面试题太多了,遇到问题基本都搜得到方案。这个理由听起来不太“技术”,但做项目的时候确实重要——可维护性不只包括代码质量,还包括有没有人替你踩过坑。
微服务框架当然不是不行,但一个健康管理系统连单机性能瓶颈都没碰到过,强行拆微服务只会增加服务发现、配置中心、链路追踪这些跟业务无关的复杂度。技术选型要服务于真实需求,而不是为技术炫技服务。
1.3 完整技术栈清单与版本匹配
我的最终选型是这样的:
| 层次 | 技术选型 | 版本/说明 |
|---|---|---|
| 后端框架 | SpringBoot | 2.7.x,JDK 1.8 |
| ORM | MyBatis-Plus | 3.5.x,配合BaseMapper和分页插件 |
| 数据库 | MySQL | 8.0 |
| 缓存 | Redis | 验证码存储、token黑名单 |
| 文件存储 | MinIO | 体检报告、头像等对象存储 |
| 鉴权 | JWT + HandlerInterceptor | jjwt 0.9.1 |
| 前端 | Vue 3 + Vite | Element Plus + ECharts |
| 构建部署 | Maven | 前端dist并入后端static |
选SpringBoot 2.7.x而不是3.x,是因为3.x要求JDK 17,而且包名从javax改成jakarta,MyBatis-Plus等配套组件当时还有兼容问题。如果你的JDK已经是17及以上,用SpringBoot 3.x没问题,但要注意引入MyBatis-Plus专用starter,网上搜代码时也要注意版本差异。这个决策在第7章会详细展开,这里先记住一个原则:毕设类的项目,尽量选稳定且资料多的版本组合,不要盲目追新。
2. 数据库设计推演:从需求到表结构的完整思路
2.1 核心业务表与字段级设计
我最终建的几张核心表是:用户表、健康档案表、健康指标记录表、运动记录表、饮食记录表、提醒规则表、提醒记录表、体检报告表。这里挑几张重点表讲字段设计。
用户表字段:id、username、password(bcrypt加密后的密文)、nickname、phone、email、avatar、status(0禁用 1正常)、create_time、update_time、deleted(逻辑删除)、version(乐观锁版本号)。
健康档案表字段:id、user_id(唯一索引)、gender、birthday、height、weight、blood_type、allergy_history、medical_history、family_history、smoking(是否吸烟)、drinking(是否饮酒)、exercise_frequency、create_time、update_time。
健康指标记录表字段:id、user_id、record_time(测量时间,注意不是创建时间)、systolic_pressure、diastolic_pressure、heart_rate、blood_sugar、blood_oxygen、temperature、bmi、remark、create_time。
这几张表看起来常规,但有三个细节值得单独说。
第一,指标记录表的record_time一定要单独设计。很多人喜欢直接用create_time代替测量时间,但用户可能补录昨天的数据,create_time就不是真实测量时间,趋势图就会出错。我在接口设计时还允许前端传一个测量时间参数,后端不做“只允许今天”的限制,这样业务上更灵活。
第二,身高体重分开存,用DECIMAL(5,1)而不是FLOAT。BMI不要在录入时就算好存进去,应该存身高体重,展示时再算。如果存了BMI,用户后面改了身高,历史BMI全是错的。这条规则对所有衍生指标都适用:能算出来的字段不要落库。
第三,健康档案表加smoking、drinking这种行为字段,是因为健康评估逻辑经常用到这些因子。一开始不建好,后面做健康建议模块又要改表,而alter table在数据有量之后成本就不一样了。
2.2 表关系与约束设计的关键决策
用户表与健康档案表是一对一,user_id加了唯一索引。很多人把健康信息直接堆在用户表里,这样表会越来越宽,而且下次体检之后想保留历史也没有地方放。更合理的做法是健康档案只存“当前状态”,需要保留历史轨迹时再加一张健康档案快照表,每次体检后往快照表里写一条记录。
用户表与指标记录表是一对多。指标记录表对user_id和record_time建联合索引,因为查询基本上都是“某个用户某段时间的数据”,没有这个索引,数据量到几万条以后查询就会明显变慢。我在第7章会给出一个实际案例,补了这个索引之后接口耗时从120ms降到20ms。
提醒规则表和提醒记录表也简单说明。规则表存的是业务规则,比如“每天8点提醒测血压”“体重连续7天上涨提醒”,记录表存的是实际发送记录。规则表不要写死SQL条件,把条件字段化,比如min_value、max_value、duration_days、remind_time,之后改规则只需要改数据库记录,不用改代码。
这里涉及一个常见的设计决策:提醒规则到底是写死在代码里还是放数据库?我的建议是放数据库。健康管理领域的规则后续大概率要调整,比如正常血压范围标准变了,如果写死在Java代码里就要改代码重新部署,放数据库只需要update一条记录。很多系统后期难维护,就是因为在“这个配置到底放哪一层”的问题上太随意。
2.3 几个容易被忽视的字段设计细节
第一个是逻辑删除字段deleted。SpringBoot+MyBatis-Plus里用@TableLogic注解就能自动处理,查询时自动加deleted=0条件。但加了逻辑删除之后,username的唯一索引冲突问题就来了:用户删了之后,再注册同名用户会失败。解决办法是删除时把username改成带时间戳的值,或者用复合唯一索引把deleted包含进去。我在项目里选择了前者,因为处理逻辑更直观。
第二个是乐观锁字段version。健康档案表这种可能被多个页面同时编辑的数据,建议加version字段配合MyBatis-Plus的@Version实现乐观锁。虽然毕设场景下并发不大,但能说清楚这个设计意图,在答辩时是加分项。
第三个是create_time和update_time不要手动填。MySQL里直接设置DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP,MyBatis-Plus也有MetaObjectHandler自动填充,两条路都可以,但不要在Java代码里手动set时间——总会漏,总会不一致。我做项目时统一用数据库默认值,MyBatis-Plus实体里对应字段标@TableField(fill = FieldFill.INSERT),两步保证一致。
3. 项目骨架搭建与自动装配原理的应用
3.1 分层架构与目录职责划分
项目我按controller-service-mapper三层来分,但每层包内的类职责划分得更细一点。
controller只做参数接收、参数简单校验、调用service、返回统一结果,不写业务逻辑。service是业务逻辑核心层,事务控制也加在这里,比如“新增健康档案的同时写入一条归档日志”这种跨表操作必须放在同一个事务方法里。mapper对应MyBatis-Plus的BaseMapper接口,复杂查询用XML或@Select注解。dto是接收前端参数的请求对象,用于参数校验。vo是返回给前端的数据对象,把实体里不想暴露的字段(比如密码)过滤掉。entity对应数据库表。config放配置类,比如MyBatis-Plus分页插件、拦截器注册、跨域配置。common放通用工具类、统一返回对象、全局异常处理、常量类。
这个目录本身不稀奇,但很多人因为赶进度把代码全堆在service里,一个service类几百行,后面加功能时非常痛苦。分层不是做给别人看的,是给自己降低后续维护成本的。特别是健康管理这种功能模块很多的系统,每层职责清晰之后,新增一个统计接口基本不用动别的模块的代码。
提一下包命名:package命名上我用com.example.health.controller这种形式,业务模块用包内再分包的方式区分auth、health、report、remind。实际开发中,按业务分包比按技术分包更好扩展。按技术分包会产生com.example.health.controller里有几十个类挤在一起的情况,但按业务分包后,每个业务包内自成体系,改健康档案模块不会碰到提醒模块的文件。
3.2 统一响应、全局异常与自定义业务异常
前后端分离项目,接口返回格式必须统一。我定义了一个Result类,字段包含code、message、data。成功时code为200,业务失败时code为500,未登录时code为401。前端axios拦截器拿到code之后统一处理,不用每个接口单独判断。
配合统一响应的是一套全局异常处理。用@RestControllerAdvice可以拦截所有controller抛出的异常,核心是区分两类异常:
一类是业务异常,比如“档案不存在”“指标数据超出合理范围”,这类异常要在service层主动抛出,由全局异常处理器转成对应的提示信息。业务异常不要打印一堆堆栈,前端看到的应该是干净的业务消息。
另一类是系统异常,比如数据库连接失败、空指针,这类异常要记录完整堆栈到日志,然后返回给前端一个通用提示,比如“系统繁忙,请稍后重试”,不要把异常细节暴露给前端。
我自定义了一个BusinessException类,带错误码和错误消息,service里直接throw new BusinessException(ErrorCode.NOT_FOUND, "健康档案不存在")。全局异常处理器里用@ExceptionHandler(BusinessException.class)单独处理,再用@ExceptionHandler(Exception.class)兜底所有未知异常。这个机制看起来简单,但实际联调阶段价值非常大,前端最烦的就是后端返回的报错内容每次都不一样,有的还带Java堆栈。统一之后,前后端联调效率提升明显。
3.3 自动装配原理:从配置生效到排查思路
SpringBoot的自动装配原理是高频面试题,也是理解“为什么加一个依赖就多了一堆功能”的关键。
核心机制是@SpringBootApplication组合注解,它里包含@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan。最关键的是@EnableAutoConfiguration,它通过@Import导入AutoConfigurationImportSelector类,这个类会扫描所有jar包里的META-INF/spring.factories文件(SpringBoot 2.7及以前)或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(2.7之后的推荐方式),拿到所有自动配置类的全限定名,然后逐个尝试加载,配合@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty这些条件注解,按条件决定哪些自动配置真正生效。
举一个实际例子:项目引入spring-boot-starter-web之后,为什么直接就能支持HTTP接口?因为自动配置类里有一个DispatcherServletAutoConfiguration,它看到classpath里有DispatcherServlet类,就会自动装配前端控制器;但如果你自己定义了DispatcherServlet,@ConditionalOnMissingBean就不会再重复装配。
理解这套机制对排查问题非常有用。遇到“配置没生效”的情况,第一反应应该是去检查条件是否满足,而不是反复重启。启动类上加debug=true,启动时会打印所有自动配置的生效/失效报告,排查“为什么MinIO的配置没生效”“为什么连接池选了HikariCP而不是Druid”这类问题,一目了然。有一次我发现配置文件里的Redis地址一直没生效,后来看自动配置报告才意识到自定义的RedisTemplate覆盖了默认配置,导致@ConditionalOnMissingBean判断为“已有bean不再创建”,这不是配置没读到,而是条件判断的结果。
4. 核心业务模块实现:健康档案、指标趋势与图表接口
4.1 健康档案的新建与更新策略
健康档案模块第一个要解决的问题是“用户可能没有档案”。注册成功之后并不会自动生成档案,所以接口设计时要区分两种情况:档案存在则更新,不存在则创建。我用的是service层先按userId查档案,存在就走updateById,不存在就insert。但这里有个并发隐患:两个请求同时发现档案不存在,可能插入两条数据。解决方案是用user_id唯一索引兜底,插入时如果重复就catch DuplicateKeyException转成更新。
档案更新时要注意一点:不要把前端传来的空字符串直接覆盖数据库里的旧值。用户可能只想改体重,但前端把整个表单都提交过来了,某个字段值为空。我的做法是空字符串和null一律不更新,用UpdateWrapper只set非空字段,或者在前端做必填校验,把必填字段都校验完再提交。这个处理逻辑在service层写一个固定的“非空更新”工具方法,其他模块也能复用。
前面提过,BMI、体脂率这些衍生指标不要存库。我在VO层动态计算,返回给前端的对象里带一个bmi字段,但表里不落库。同理,健康建议这种按规则算出来的内容,也不要为了省事直接写进数据库——规则变化后历史数据会很尴尬。
4.2 指标录入、参数校验与自定义校验注解
指标录入接口接收的数据达到8个指标字段以上,如果不做校验,用户的血压值可能填成-30,血糖填成500。前端的校验可以被绕过,所以后端必须校验。
我用的是JSR-303规范的校验注解,@NotNull、@DecimalMin、@DecimalMax这些直接加到dto字段上,在controller参数上加@Validated就能自动触发。但这套标准注解有个麻烦:不同指标字段的合理范围差异很大,而且标准注解的参数是写死的。比如收缩压范围90-139,舒张压60-89,血糖3.9-6.1,每个字段都要写一遍规则。
所以我自定义了一个@HealthRange注解,支持配置min和max,内部用ConstraintValidator实现,校验逻辑可复用。核心代码如下:
@Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = HealthRangeValidator.class) public @interface HealthRange { String message() default "指标数据超出合理范围"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; int min() default Integer.MIN_VALUE; int max() default Integer.MAX_VALUE; }public class HealthRangeValidator implements ConstraintValidator<HealthRange, BigDecimal> { private int min; private int max; @Override public void initialize(HealthRange constraintAnnotation) { this.min = constraintAnnotation.min(); this.max = constraintAnnotation.max(); } @Override public boolean isValid(BigDecimal value, ConstraintValidatorContext context) { if (value == null) { return true; } return value.compareTo(BigDecimal.valueOf(min)) >= 0 && value.compareTo(BigDecimal.valueOf(max)) <= 0; } }dto里就可以这么用:
@NotNull(message = "收缩压不能为空") @HealthRange(min = 80, max = 200, message = "收缩压需在80-200之间") private BigDecimal systolicPressure;这里有个小坑要提醒:自定义校验器遇到null时直接返回true,因为是否必填交给@NotNull判断,两个注解职责分离。如果自定义注解里把null判定为false,那用户不填字段时报的就不是“不能为空”而是“范围不合法”,错误信息就错位了。
4.3 趋势图数据接口:SQL聚合与VO设计
健康管理系统最核心的展示功能是趋势图。前端用ECharts画折线图,接口需要返回类似这样的数据:
{ "dates": ["2025-05-01", "2025-05-02", "2025-05-03"], "systolic": [118, 121, 117], "diastolic": [76, 78, 75], "heartRate": [72, 70, 74] }这个接口的SQL写法是重点。按天聚合血压平均值的查询如下:
SELECT DATE_FORMAT(record_time, '%Y-%m-%d') AS record_date, AVG(systolic_pressure) AS avg_systolic, AVG(diastolic_pressure) AS avg_diastolic, AVG(heart_rate) AS avg_heart_rate FROM health_indicator_record WHERE user_id = #{userId} AND record_time BETWEEN #{startTime} AND #{endTime} AND deleted = 0 GROUP BY record_date ORDER BY record_date ASC有一个经验:别在Java代码里做循环查数据库。比如前端要显示最近30天的趋势,最差的做法是循环30次查询。正确做法是上面这一条SQL直接拿到全部数据,SQL层面的分组聚合非常快。如果数据量大,后续还可以按月份预聚合,但在毕设规模下,分组查询完全够用。
vo类的设计也简单,TrendVO里有List dates、List systolic等字段。mapper返回List<Map<String,Object>>再组装成VO,或者用@Select注解直接映射到自定义的Outcome对象,两种方式都可以。
这里我踩过一个小坑:用int接收AVG的结果,小数点被截断,血压平均值变成了118而不是118.4。AVG返回的精度类型要对应,统一用BigDecimal接收。这个坑在金额、体重字段上也很常见,写SQL聚合查询时千万注意接收类型。
趋势接口之后可以跟联动一下:前端拿到最近30天的指标数据后,除了画图,还能判断有没有连续异常天数。比如“连续3天收缩压超过140”,前端展示一个警告条,后端提醒模块再根据这个条件发提醒。业务上的分析逻辑不做在图表接口里,而是放到独立的健康评估服务里,保持接口单一职责。
5. 鉴权与对象存储集成:JWT方案和MinIO接入实录
5.1 为什么我用JWT+拦截器而不是Spring Security
很多毕设同学上来就用Spring Security,结果搞不明白过滤器链和配置类,浪费时间。Spring Security本身很强大,但它的设计目标是企业级安全框架,大量配置和组件,对付管理员加普通用户两种角色的系统有点杀鸡用牛刀。
我的方案是JWT加HandlerInterceptor:用户登录成功后,后端生成JWT token返回给前端,前端把token存在localStorage里,每次请求在请求头带上Authorization: Bearer 。后端写一个拦截器,拦截需要认证的接口,解析token、校验过期、取出用户id和角色,塞进ThreadLocal,controller里随时可取当前登录用户。
这套方案的好处:代码量小,每个环节都看得懂,出问题能快速定位;答辩的时候也能把JWT原理和代码讲清楚,比背Spring Security配置更有说服力。它的不足是token无法在服务端强制失效、权限粒度控制也不如Spring Security细致,但两个角色的系统完全够用。如果后续要扩展精细权限,可以基于拦截器引入RBAC表结构,用自定义注解@RequireRole控制接口权限。
5.2 JWT拦截器与用户上下文实现细节
JWT依赖我用的是jjwt库,0.9.1版本配JDK1.8比较稳,新版API变化比较大。生成token的核心代码:
String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + expireTime)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();拦截器里解析token:
String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); Long userId = Long.parseLong(claims.getSubject()); UserContextHolder.set(userId, (String) claims.get("role")); return true; } catch (ExpiredJwtException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录"); } catch (JwtException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "token无效"); } } throw new BusinessException(ErrorCode.UNAUTHORIZED, "未登录");注册拦截器时,一定要设置放行路径。登录接口、注册接口、静态资源、图片预览等都要排除:
registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/auth/register", "/api/file/preview/**");这里有一个容易犯的错:拦截器里校验失败后直接return false,前端会收到空响应而不是JSON。因为不是controller抛出的异常,全局异常处理器也接不到。我在项目里改成拦截器内直接throw BusinessException,Spring MVC环境下拦截器里抛出的异常同样会被@RestControllerAdvice捕获,返回格式就和业务异常一致了。
UserContextHolder我实现了一个简单的ThreadLocal封装。请求处理完必须在afterCompletion里remove,否则线程池复用时会出现用户信息串号。这个问题只在并发量上来后出现,但排查起来非常隐蔽。
5.3 MinIO接入SpringBoot的完整步骤与踩坑
文件存储是健康管理系统的刚需模块,体检报告要上传PDF、图片。把文件直接传到服务器磁盘上省事,但不安全也不方便扩展,这个项目我选了MinIO。
为什么是MinIO?因为它兼容S3 API、部署简单,本地一条命令就能起,还提供Web管理界面,适合学习和毕设演示。如果学校要求公网可访问,也可以换对象存储服务商,API用法基本兼容。
接入步骤第一步,本地起MinIO服务:
minio server /data --address ":9000" --console-address ":9001"第二步,SpringBoot工程里引入MinIO Java SDK,配置MinioClient:
@Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(minioProperties.getEndpoint()) .credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey()) .build(); }第三步,写上传代码,核心是Bucket名称、对象名称、ContentType几个参数:
public String upload(MultipartFile file, String objectName) { boolean found = minioClient.bucketExists( BucketExistsArgs.builder().bucket(bucketName).build()); if (!found) { minioClient.makeBucket( MakeBucketArgs.builder().bucket(bucketName).build()); } minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return previewUrl + "/" + bucketName + "/" + objectName; }踩坑记录里最典型的有三个。
第一个是预览地址403。默认情况下MinIO的bucket是私有权限,直接拼出来的URL访问不了。两种解决方式:一是上传后调用getPresignedObjectUrl生成带签名的临时URL,适合体检报告这种隐私文件;二是如果就是要公开访问图片,在MinIO控制台里设置bucket的Access Policy为public。健康管理系统的体检报告我建议用带签名URL,用户头像这类公开资源用public策略。
第二个是端口问题。启动地址是127.0.0.1:9000,上传之后生成的URL如果写成localhost,预览时部分浏览器会拦截。最好把endpoint统一配置成服务器IP或域名,避免文件上传后访问不了。
第三个是SDK版本兼容。MinIO Java SDK 8.x的putObject参数和7.x差别很大,网上搜到的代码版本对不上,编译报错的时候先看异常信息指定的是哪个方法,再对照pom里的版本。
6. Vue前端与SpringBoot部署整合:打包、路由与静态资源
6.1 开发环境下的代理与跨域配置
前后端分离开发的时候,前端跑在5173端口(Vite默认),后端跑在8080,跨域绕不开。我的处理方案是开发环境不开启后端CORS,直接用Vite的proxy代理。
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求 /api/xxx 会被Vite开发服务器转发到后端8080,浏览器看起来是同一个源,不存在跨域问题。后端就不需要写CorsFilter了。
生产部署时情况不同。如果前后端分开部署,就需要在后端加跨域配置。如果前端打包后由SpringBoot直接托管,则同源,不需要跨域配置。我实际项目里是方案B,所以后端一直没写CORS相关代码。
6.2 Vue打包产物集成进SpringBoot的两种方案
方案A:前端独立部署。Vue打包的dist文件丢到Nginx,后端jar包单独跑,用Nginx做反向代理。这个方案适合生产环境,但毕设通常一台机器演示,运维成本略高。
方案B:把Vue打包后的dist目录复制到SpringBoot的src/main/resources/static下,SpringBoot直接托管静态资源。这样打出的jar包自带页面,启动后浏览器访问8080就能看到完整系统,演示非常方便。
方案B的具体操作分两步。第一步前端构建:
npm run build构建产物在dist目录。第二步把dist目录里的文件全部复制到后端的resources/static目录,然后重新打包后端。我为了省事写了一个脚本,构建完前端自动复制到后端再执行mvn package,一条命令出jar:
cd frontend && npm run build rm -rf ../backend/src/main/resources/static/* cp -r dist/* ../backend/src/main/resources/static/ cd ../backend && mvn package -DskipTests这里有个注意点:SpringBoot默认静态资源路径之一就是classpath:/static/,你要把dist目录里的内容直接放在static根目录下,也就是static/index.html能直接访问。千万别把dist文件夹本身拷进去,变成static/dist/index.html,访问路径会多一层,容易出问题。
6.3 history路由刷新404的三种解决方式
Vue Router默认是hash模式,URL里有#号,刷新不会404,但不好看。如果改成history模式,URL干净了,但直接访问 /health 这样的路径刷新时,后端找不到对应controller,会返回404。
解决办法有三种,按推荐顺序:
第一种,如果项目用的是hash模式,就干脆别改成history了。毕设演示时URL多个#号没有任何影响,最省事。
第二种,坚持history模式的话,后端加一个ViewController把前端路由转发到index.html:
@Controller public class ViewController { @RequestMapping(value = {"/health/**", "/statistics/**", "/profile/**"}) public String forward() { return "forward:/index.html"; } }注意这个配置只针对刷新场景,而且要确认不会误伤带后缀的静态资源请求。
第三种,在Nginx里配置try_files,适合生产部署:
location / { try_files $uri $uri/ /index.html; }我在实际项目中用的第一种,路由不算复杂,hash模式完全够用,少踩一个坑。
7. 运行调优与踩坑汇总:版本、索引、静态资源拦截
7.1 SpringBoot版本太高引发的连锁问题
这个坑最近特别普遍,很多人用Spring Initializr直接创建SpringBoot 3.3、3.4项目,JDK选17,结果后面全是兼容问题。
第一个是包名迁移。SpringBoot 3.x把javax.改成jakarta.,网上抄的旧代码基本都要改import。第二个是组件兼容,MyBatis-Plus要引入专门的starter,部分旧插件可能不兼容。第三个是starter的默认配置有变化,一些配置项改了名字。
我的建议是:除非JDK已经是17且能熟练处理这些问题,否则SpringBoot 2.7.x加JDK 8是毕设项目的稳妥组合。如果确实用了3.x,排查问题时优先看官方迁移文档,不要搜旧文章照抄。
顺手提一句,SpringBoot 2.7支持在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports中声明自动配置类。如果答辩时想演示自动装配原理,可以自己写一个小工具starter,用这个文件的方式比传统spring.factories更清晰。
7.2 数据库慢查询与索引优化
系统跑了一段时间,指标记录表数据量上来之后,趋势接口开始变慢。打开MySQL慢查询日志,耗时最长的SQL基本都是按user_id和record_time查询的。检查发现联合索引没建上,后来补了索引:
ALTER TABLE health_indicator_record ADD INDEX idx_user_time (user_id, record_time);补完之后,同样接口的耗时从120ms降到20ms左右。
还有一个容易被忽略的坑:DATE_FORMAT(record_time, '%Y-%m-%d')这种写法会破坏索引。因为DATE_FORMAT是函数,MySQL没法直接用索引。如果要按天查询,查询条件尽量写成record_time >= ? AND record_time < ?的范围查询,SQL里聚合时可以用函数,但WHERE条件里的字段不要套函数。这个细节面试也会问,实现的时候顺手做对就好。
7.3 拦截器放行顺序导致样式丢失
这个坑在前端文件首次放到static目录后很常见。用户登录页所有CSS、JS都404,页面白板。排查下来发现是因为拦截器addPathPatterns("/**")拦截了一切请求,包括静态资源请求,token校验失败直接抛异常,导致样式等静态资源全部加载失败。
解决办法是把静态资源路径加进excludePathPatterns:
.excludePathPatterns("/static/**", "/favicon.ico", "/index.html", "/assets/**", "/js/**", "/css/**")如果用了Vue打包产物,静态资源名称会带hash,路径前缀是assets,所以assets/**必须放行。这里也提醒一句:拦截器的addPathPatterns不要写成/**把所有东西都拦住,尽量用/api/**这类前缀控制业务接口,避免静态资源受影响。
7.4 多环境配置与日志的实用建议
最后讲两个能让项目维护轻松一点的习惯。
第一个是多环境配置。application.yml放公共配置,application-dev.yml和application-prod.yml分别放不同环境的数据库、Redis地址。打包时用--spring.profiles.active=prod切换,外置参数还可以用环境变量覆盖。我演示时数据库在本地,部署在服务器,代码完全不用改,只改启动参数。
第二个是日志分段。用logback-spring.xml可以按天生成日志文件:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/health-system.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/health-system.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> </appender>排查问题最大的帮手就是日志。service层关键操作至少打一条info日志,异常路径打error日志带完整堆栈,这样上线之后复现问题、定位问题才有依据。我见过不少项目,代码写完了日志一条没有,出了故障全靠猜,这种状态在项目交付阶段非常被动。
最后说点个人心得。这套系统的技术栈没有用到任何前沿的东西,但把SpringBoot的配置机制、拦截器、自动装配、对象存储、静态资源托管这些基础能力完整地串了一遍。做完你会发现,很多以前在面试题里背过的概念——“自动装配原理是什么”“JWT和Session有什么区别”“前后端分离怎么部署”——都成了自己亲手验证过的经验。如果你也在做类似的毕设或练手项目,建议先把功能边界和表结构设计想清楚,再动手写代码,而不是一边写一边改。先把地基打稳,后面SpringBoot带给你的开发效率才会真正发挥出来。