
去年夏天我给自己定的减脂目标一直卡在平台期罪魁祸首说实话不是练得少而是吃得乱。每天练完打开外卖软件看到高碳水的套餐就下意识想点又根本没有一个工具告诉我“今天的运动消耗能不能抵消这一顿”。于是我把这个困扰搬到了代码里做了一个基于 SpringBoot 的运动健身食谱与膳食推荐系统。这套系统的核心不是把菜谱列出来而是基于用户的健康档案和运动记录算出每天应该摄入多少热量再从这个热量基准出发去匹配食谱。文章里我会把整个项目从需求拆解、表结构设计、推荐算法到前后端打包部署中遇到的所有问题完整复盘一遍。它适合正在做 SpringBoot 方向项目、想搞明白推荐逻辑怎么落地或者做膳食类选题的同学参考里面的表结构和算法思路可以直接抄作业。1. 需求分析健身食谱推荐不是“查询”而是“计算”1.1 用户到底想要什么很多类似的系统把重点放在“菜谱展示”上进页面就是一张张精美的食物图片和热量表。但健身用户真正关心的是我今天练了多长时间、做了哪些动作可以多吃多少我减脂三个月热量缺口应该控制在多少我现在 70 公斤下一阶段增肌每天的蛋白质目标是多少克这些问题都绕不开一个共同点推荐结果必须从个体数据出发。一个 25 岁、身高 165、体重 55 公斤的女生和一个 30 岁、身高 178、体重 85 公斤的男生即便都选择了“减脂”每日的热量预算和碳水占比也完全不同。如果系统只是做一个“低脂食谱列表”那不叫推荐叫陈列。真正有价值的推荐是把性别、年龄、身高、体重、活动等级、运动目标这些参数作为输入计算出一个个性化的热量窗口再从这个窗口反推食谱组合。1.2 现有应用的两个明显短板我在做需求调研时翻了市面上几款食谱 App发现它们普遍有两个问题。第一个是只给热量不给约束条件。一个鸡胸肉沙拉标注 320 大卡看起来健康但用户并不知道这 320 大卡占总预算的多少更不知道如果再搭配一碗米饭和一杯酸奶会不会超预算。第二个是运动与饮食完全割裂。运动记录是一套数据饮食推荐是另一套数据系统不会告诉你“今天多跑了 5 公里你可以在晚餐增加 200 大卡的碳水”。这两个短板恰恰是 SpringBoot 后端最适合解决的——只要有用户数据、运动数据和食谱数据推荐逻辑就能被完整计算出来。1.3 功能模块的边界划分我把系统拆成了六个模块每个模块都有清晰的边界用户健康档案维护性别、年龄、身高、体重、活动等级、目标类型减脂/增肌/维持、目标体重。食材管理维护常见食材的营养成分以每 100 克为单位。食谱管理维护三餐食谱包括食材搭配、烹饪步骤和汇总后的营养数据。运动库与运动记录维护运动项目的 MET 值记录用户每天的运动时长。膳食推荐引擎根据档案计算基础代谢和每日热量预算再结合运动记录动态调整最终匹配食谱。记录与反馈保存每天的推荐结果用户可以记录实际进食的食谱形成闭环数据。这套模块设计的核心思想是把“推荐”变成一个有输入、有计算、有输出的完整链路而不是简单地查表返回。反馈数据将来还能用于算法迭代这一点在后面“演进方向”我会展开说。2. 技术栈落定SpringBoot 3.2 版本选型与三件套的搭配2.1 SpringBoot 3.x 不是 2.x 的简单升级而是“迁移版”我在项目初期差点照着两年前的教程选了 SpringBoot 2.7后来考虑到 JDK 版本和长期维护最终定了SpringBoot 3.2.5 JDK 17。这个版本选择需要特别谨慎因为 3.x 和 2.x 有一个关键差异包名从 javax 迁移到了 jakarta。也就是说你在 2.x 里写的import javax.persistence.*、import javax.validation.*在 3.x 里全部要改成import jakarta.persistence.*、import jakarta.validation.*。依赖坐标也得跟着升级比如 MyBatis-Plus 要用 3.5.5 以上版本才支持 SpringBoot 3.x旧版的mybatis-plus-boot-starter在启动阶段就会直接报ClassNotFoundException。另外有一个很重要的机制需要理解SpringBoot 自动装配原理。SpringBootApplication是由SpringBootConfiguration、EnableAutoConfiguration、ComponentScan三个注解组合而成的。其中EnableAutoConfiguration会通过AutoConfiguration.imports文件加载所有候选的自动配置类再按ConditionalOnClass、ConditionalOnProperty等条件决定哪些配置真正生效。我建议新手不要只看使用层面的注解而是找个时间点开spring-boot-autoconfigure源码看一下理解了这一步后面遇到“配置明明写了却没生效”的问题会省很多排查时间。2.2 为什么持久层选 MyBatis-Plus而不是 JPA 或原生 MyBatis我之前写过一段时间 JPA也写过原生 MyBatis最后在这个项目里选了MyBatis-Plus 3.5.5。核心原因是这个项目有大量单表 CRUD 操作比如用户信息更新、运动记录插入、推荐日志保存用 MyBatis-Plus 的BaseMapper可以少写很多重复 SQL。条件构造器也很好用比如筛选“所有热量在 400 到 600 大卡之间的午餐食谱”LambdaQueryWrapperRecipe wrapper new LambdaQueryWrapper(); wrapper.between(Recipe::getTotalCalorie, 400, 600) .eq(Recipe::getMealType, lunch); ListRecipe list recipeMapper.selectList(wrapper);但是要注意MyBatis-Plus 不适合硬怼复杂查询。比如“推荐记录表关联食谱表和食材表一次查出用户一周的完整膳食明细”这种多表关联场景还是得老老实实写 XML。我的原则是单表操作用 MyBatis-Plus多表统计用手写 SQL不要为了省事把所有查询都交给条件构造器否则 SQL 会变得非常难维护。2.3 前端选型Vue 3 Element Plus Vite前端我选了 Vue 3 Element Plus Vite没有选若依这类现成脚手架。若依确实集成度高开箱即用但它的代码生成器会把很多实现细节隐藏掉对想搞懂底层原理的人来说反而是一种干扰。自己做前后端分离的好处是每个接口、每个页面结构都能完全掌控。打包策略上我采用 Vite 构建后把dist目录里的静态资源复制到 SpringBoot 的src/main/resources/static最终由一个 JAR 包同时提供前后端服务。这个方案对部署很友好但路径配置上坑不少这个我在第 5 章会专门讲。3. 数据库建模把“膳食推荐”变成可计算的数据结构3.1 用户健康档案一切计算的起点用户表是整个推荐系统的输入源头字段设计直接决定后续算法能不能跑起来。核心表如下字段类型说明idbigint主键nicknamevarchar昵称gendertinyint0-女1-男ageint年龄heightdecimal身高单位 cmweightdecimal当前体重单位 kgactivity_levelvarchar活动等级sedentary/light/moderate/high/extremegoal_typevarchar目标lose/gain/maintaintarget_weightdecimal目标体重单位 kgcreated_atdatetime创建时间活动等级字段是后面计算 TDEE 的关键我用了字符串枚举而不是数字是为了让接口返回值可读。每个等级对应的活动系数在代码里维护比如moderate对应 1.55。这里有个容易被忽略的点体重是动态数据用户每周可能都在变。如果只存当前体重那历史推荐记录就无法回溯所以我加了weight_record表每周记录一次体重变化既能画趋势图也能在重新计算时参考最近的体重。3.2 食材库与食谱库营养数据的结构设计食材表food设计比较简单每 100 克为基准存储营养成分food_name食材名称category分类比如鸡胸肉属于“高蛋白肉类”calorie热量大卡protein蛋白质克fat脂肪克carb碳水化合物克fiber膳食纤维克食谱表recipe则直接冗余了汇总后的营养数据包括total_calorie、total_protein、total_fat、total_carb、meal_typebreakfast/lunch/dinner/snack。有些设计会把食谱的营养数据完全实时计算也就是通过recipe_food关联表去左连接食材表求和。但我在实际开发中发现食谱创建后食材一般不会频繁变化提前把汇总值算好存起来查询性能会好很多也不会让推荐引擎每次都要联表聚合。recipe_food关联表里除了recipe_id和food_id我还会记录grams和这一份食材对应的calorie、protein等值。这样即使食材表后来被修改历史食谱的营养快照也不会受影响相当于做了数据版本控制。3.3 运动库与运动记录MET 值换算逻辑运动库exercise表需要存储每个运动项目的 MET 值。METMetabolic Equivalent of Task是代谢当量用来估算运动强度。比如散步 MET 约为 3.5跑步 MET 约为 9.8游泳 MET 约为 8.0。运动消耗的计算公式很简单消耗热量大卡 MET × 体重kg× 运动时长小时这个公式是后面动态调整每日热量预算的基础。运动记录表exercise_record需要存用户 ID、运动项目 ID、时长分钟、运动日期以及计算出来的calorie_burned字段。有人会问既然有 MET 和体重为什么还要冗余一个calorie_burned因为体重在变如果用户后来修改了体重历史运动记录也应该保持当时的状态冗余字段是为了保持历史快照的一致性。3.4 推荐记录表让算法结果可以被追溯推荐引擎每次运行后我都会把结果写入recommend_loguser_id用户 IDrecommend_date推荐日期target_calorie当天的热量目标target_protein蛋白质目标recommend_recipe_ids推荐食谱 ID多个用逗号分隔reason推荐理由比如“热量匹配度高”“蛋白质充足”status状态0-未查看1-已查看2-已选餐这张表一开始我没设计后来发现没法排查“为什么系统推荐了 A 食谱用户却吃了 B 食谱”。有了推荐日志再去对比用户的选餐记录就能很清楚地看出推荐算法的命中率。对后续优化算法很有价值。4. 推荐算法与核心逻辑从基础代谢率到食谱评分4.1 基础代谢率计算Mifflin-St Jeor 公式推荐链路的第一步是算基础代谢率BMR也就是人在静息状态下维持生命所需的热量。我采用了比较通用的 Mifflin-St Jeor 公式public double calculateBmr(UserInfo user) { double weight user.getWeight(); double height user.getHeight(); int age user.getAge(); double base 10 * weight 6.25 * height - 5 * age; if (user.getGender() 1) { return base 5; } else { return base - 161; } }这个公式相比旧的 Harris-Benedict 公式在现代人群的估算上更准确而且在代码里实现成本很低。要注意的是BMR 只是一个基础值不能直接作为推荐热量因为用户还要走路、工作、训练这些都需要额外能量。4.2 每日热量目标 TDEE 与目标调整TDEE每日总能量消耗的计算方式是 BMR 乘以活动系数活动等级活动系数典型场景sedentary1.2久坐办公很少运动light1.375每周运动 1-3 次moderate1.55每周运动 3-5 次high1.725每周运动 6-7 次extreme1.9体力劳动 高强度训练算出 TDEE 之后再根据目标调整减脂在 TDEE 基础上减去 300 到 500 大卡制造安全的热量缺口。增肌在 TDEE 基础上增加 200 到 400 大卡为肌肉合成提供盈余。维持体重直接使用 TDEE。这里我加了一个安全的降落伞逻辑无论怎么减每日推荐热量不能低于基础代谢率的 1.1 倍。因为极端节食不仅会造成肌肉流失还会让基础代谢下降导致复胖。代码层面就是一行Math.max(targetCalorie, bmr * 1.1)。4.3 运动消耗如何影响当日预算很多计算器给你一个固定热量就不管了但运动日和非运动日的消耗差异很大。我的做法是基础热量目标仍然按 TDEE 计算但运动记录可以“追加”当日预算。为什么只加一部分因为 TDEE 里的活动系数已经预估了日常活动消耗如果再把运动消耗全额加到预算上容易高估。所以我只把运动消耗的 50% 加入当日可摄入热量double exerciseCalorie sumTodayExerciseCalorie(userId); double availableCalorie targetCalorie exerciseCalorie * 0.5;这样既鼓励用户记录运动又不会让热量预算虚高算是一个比较稳妥的中间策略。4.4 食谱评分热量接近度、蛋白质满足度与多样性有了当日可摄入热量下一步就是候选食谱的匹配。如果只是按热量范围筛选推荐结果会很粗糙。我设计了一个三部分加权的评分规则热量接近度权重 50%候选食谱的热量与目标热量的差值越小得分越高。蛋白质满足度权重 30%蛋白质的目标值由体重 × 每公斤所需蛋白质量计算得出。减脂期我给的是每公斤体重 1.8 克到 2.2 克增肌期是 2 克左右。食材多样性与膳食纤维权重 20%食谱中食材种类越多、纤维含量越高得分越高。具体实现上首先用 SQL 把热量在availableCalorie - 200和availableCalorie 200范围内的食谱捞出来再在内存里按评分公式排序。之所以不用全量数据库排序是因为评分逻辑涉及多个加权项数据库里写复杂的表达式反而难维护。下面是核心的评分代码public double scoreRecipe(Recipe r, double availableCalorie, double targetProtein) { double calorieScore 1.0 - Math.abs(r.getTotalCalorie() - availableCalorie) / availableCalorie; double proteinScore Math.min(1.0, r.getTotalProtein() / targetProtein); double diversityScore Math.min(1.0, r.getIngredientCount() / 5.0) * 0.5 Math.min(1.0, r.getTotalFiber() / 10.0) * 0.5; return calorieScore * 0.5 proteinScore * 0.3 diversityScore * 0.2; }如果第一轮没有足够食谱命中系统会主动把热量范围扩大到±300 大卡同时降低蛋白质满足度的权重优先保证用户不至于没有推荐可看。这种兜底逻辑在真实项目中非常重要因为它避免了一个常见尴尬算法算得再准库里没数据时用户打开首页只能看到空白。4.5 每日推荐刷新与 SpringBoot 定时任务推荐结果不需要每次都实时计算。我的策略是每天凌晨生成一次用户白天访问时直接读取当天的recommend_log。这个功能用 SpringBoot 内置的Scheduled注解就够不需要引入其他分布式任务框架Scheduled(cron 0 5 0 * * ?) public void generateDailyRecommendation() { ListUserInfo users userInfoMapper.selectList(null); for (UserInfo user : users) { // 计算目标热量、目标蛋白 // 执行食谱评分 // 写入 recommend_log } }定时任务的关键点是避开数据库访问高峰期所以我选了凌晨 0:05。还有一个坑Scheduled默认是单线程执行的。如果一个用户的计算卡住后续用户都会被阻塞。我后来给任务执行器配了线程池容量设置为 10再用用户 ID 分片批量执行才彻底解决阻塞问题。5. 前后端打包与上线真实的坑比代码多5.1 Vue 打包放进 SpringBoot 的静态资源路径问题第一次把 Vue 的dist目录复制到 SpringBoot 的static下启动时首页能打开但样式和 JS 全挂了。打开浏览器控制台发现assets/index-xxxx.js返回 404。问题出在 Vite 默认的打包路径是绝对路径/assets/xxx。部署到独立域名时没问题但放进 SpringBoot 的静态目录后请求路径变成了http://localhost:8080/assets/xxx如果项目有 context-path路径就完全对不上。解决办法是在vite.config.js里设置base: ./让打包产物使用相对路径。同时后端要新增一个页面跳转的 Controller把根路径/转发到index.htmlController public class PageForwardController { GetMapping(/) public String index() { return forward:/index.html; } }这里还有一个容易踩的坑是 Vue Router 的 history 模式。如果前端用了 history 路由刷新/meal/1这种路径时SpringBoot 会因为找不到对应 Controller 而返回 404。最简单的处理方式是写一个捕获所有前端路由的转发GetMapping(value {/meal/**, /profile/**, /exercise/**}) public String forward() { return forward:/index.html; }把所有前端自己的路由前缀全部放行到index.html让 Vue Router 自己接管页面渲染。5.2 定时任务在 Linux 服务器上的时区问题本地开发时定时任务每天凌晨 0:05 准时执行一切正常。把 JAR 包部署到一台 CentOS 服务器上后第二天 8 点查看数据库推荐记录还没生成。排查半天才发现是时区问题Linux 服务器默认时区是 UTC比北京时间晚 8 个小时。Scheduled的 cron 表达式基于 JVM 默认时区运行所以 0:05 在 UTC 时区对应北京时间是 8:05。解决方案有两个。一是在启动命令里加-Duser.timezoneAsia/Shanghai二是在配置文件中显式设置spring: jackson: time-zone: Asia/Shanghai最稳妥的做法是双管齐下同时确保 MySQL 连接串也带上serverTimezoneAsia/Shanghai否则数据库里的created_at字段也会出现 8 小时偏差。5.3 参数校验与全局异常处理用户提交的数据五花八门比如年龄填 -5、身高填 300、热量目标为负数。如果每个接口都手写if (age 0)这种判断代码会非常臃肿。我用jakarta.validation的注解在实体上做校验public class UserProfileRequest { NotNull(message 年龄不能为空) Min(value 10, message 年龄不能小于10) Max(value 100, message 年龄不能大于100) private Integer age; NotNull(message 体重不能为空) DecimalMin(value 20.0, message 体重不能低于20kg) private BigDecimal weight; }然后配合RestControllerAdvice做统一异常捕获让前端收到的永远是可读的 JSON 结构。这个看似基础的操作对项目体验的提升非常明显。没有统一异常处理时MethodArgumentNotValidException默认会返回一段长长的 Spring 内部异常信息前端根本没法解析。5.4 MyBatis-Plus 自动填充失效的排查过程我在设计用户表时created_at和updated_at用了 MyBatis-Plus 的自动填充功能也就是在插入和更新时自动写入时间。但第一次测试时数据库里的created_at是空的问题很奇怪。排查链路是这样的先确认实体类上有没有TableField(fill FieldFill.INSERT)有再检查有没有实现MetaObjectHandler有然后给insertFill方法打了日志发现根本没有被调用。最后才发现我的MetaObjectHandler实现类没有加Component注解Spring 容器里根本没有这个 Bean。MyBatis-Plus 在启动时找不到 Handler就静默跳过了自动填充。这种问题不会报错数据也能正常插入只是时间字段全部为空非常难察觉。修复方式只需加一行Component但排查过程花了将近半天。写出来提醒大家自动填充有问题第一反应先看 Handler 有没有被 Spring 管理。5.5 事务自调用与 CGLIB 代理的经典坑事务失效是 SpringBoot 开发里非常经典的问题。我有一次在同一个 Service 中写了一个内部方法调用内层方法加了Transactional外层方法没有加结果发现事务根本没有生效。原因在于 SpringBoot 默认使用 CGLIB 代理。Spring 的事务是通过代理对象拦截方法实现的。外部调用 Service 方法时进入的是代理对象但类内部方法之间的this.method()调用走的是原始对象代理逻辑完全不会介入Transactional自然失效。解决办法有三种把内层方法单独拆到另一个 Service 中让外部调用经过代理。注入自身代理对象用代理调用方法。使用AopContext.currentProxy()获取当前代理。我选择了第一种把“保存推荐日志”和“更新用户状态”拆成独立 Service。这样职责更清晰也从根上避免了自调用问题。6. 项目还能继续演进的三个方向6.1 推荐算法升级从规则评分到协同过滤当前的评分公式本质上还是规则系统优点是逻辑透明、可解释缺点是它不知道用户对食谱的偏好。比如用户可能讨厌香菜或者对海鲜过敏但规则系统完全感知不到。如果持续记录用户的选餐行为和过敏标签就可以用协同过滤的思路做二次推荐找到“和你身体目标相近、口味偏好相似”的用户群体把他们常吃的食谱优先推荐给你。推荐结果解释从“热量匹配”变成“和你情况差不多的用户也选择了它”用户体验会完全不同。6.2 搜索体验升级集成 HanLP 分词系统里的食谱搜索目前只支持数据库LIKE模糊匹配但中文食谱的搜索有个天然难点用户搜“鸡胸”可能匹配到“鸡胸肉炒青椒”搜“鸡脯肉”又匹配不到。如果引入 HanLP 分词在 SpringBoot 里把搜索词先分词再对分词结果做索引匹配就能解决同义表达的问题。比如“鸡脯肉”分词后得到“鸡脯肉”再根据同义词表映射到“鸡胸肉”搜索召回率会明显提升。6.3 引入实时计算SpringBoot 整合 Flink如果用户佩戴运动手环运动数据就不再是每天手动录入而是每秒钟不断产生的心率、步频、消耗数据。这时可以考虑把运动数据流接入消息队列再用 Flink 做实时计算动态更新用户当日的热量消耗。SpringBoot 整合 Flink 的常见做法是SpringBoot 负责接收设备和客户端上报的数据写入 KafkaFlink 消费 Kafka 做窗口聚合再把结果写回 Redis 或 MySQL。架构上完全可行但我建议不要一上来就这么重。如果日活还不高数据量在每秒几千条以内用 SpringBoot 里的异步队列加批量写入就足够撑住了。引入了 Flink就要同时引入 Kafka、状态管理、故障恢复等一系列运维复杂度项目收益未必抵得上成本。如果非要我给个建议我会说先别急着上 Flink 和微服务把规则推荐和营养模型做扎实这才是系统的灵魂。这套项目后来迭代了不少版本但核心还是那几张表和那个评分公式。数据库的结构会变化框架会升级只有“从个体数据出发去计算需求”这个思路才是一直没有变的东西。