1. 项目概述与整体设计思路
1.1 一个随手记菜谱的想法,最后做成了完整系统
我翻到去年做的这个 Spring Boot 健康食谱推荐系统源码,项目编号 32007,才发现当初那个“给家里人做个吃饭小工具”的想法,最后折腾成了一整套前后端都能跑的项目。这个系统本身不复杂,但它把 Spring Boot 项目开发里最常碰到的那套东西都串起来了:用户体系、数据建模、推荐算法、接口设计、部署打包,全部都有落地代码,新手跟一遍能少踩很多坑。
这个健康食谱推荐系统的核心功能,用大白话说就是三件事:
- 用户进来先填健康档案(身高、体重、年龄、性别、运动习惯),系统自动算出每日热量需求。
- 系统根据这个热量需求,从食谱库里筛选出符合“低卡”“高蛋白”“少油少盐”等标签的食谱推荐给用户。
- 用户可以对推荐结果点赞、收藏、评分,系统根据反馈不断调整后续推荐内容。
这个思路放在几年前可能还觉得新鲜,但现在做健康管理类产品已经很常见了,本质就是把“营养师的经验”转化为“可计算的规则和算法”。对于 Spring Boot 学习者来说,这个项目最值的不是代码本身,而是它把“数据 → 规则 → 推荐 → 反馈 → 优化”这条链路完整实现了一遍,每一环都有对应的代码可以参考。
1.2 技术选型:为什么是 Spring Boot 而不是 SSM 或 SSH
很多入门同学会问,为什么不选经典的 SSM(Spring + SpringMVC + MyBatis)或者更老的 SSH(Struts + Spring + Hibernate)?我当年也纠结过这个问题,后来实际做完才想明白,Spring Boot 在这类项目里的优势不是“功能更强”,而是“省出来的精力可以投入在业务上”。
我直接给你对比一下选型时我踩过的经验和看到的情况:
| 对比维度 | SSM 手动搭建 | Spring Boot |
|---|---|---|
| 环境配置 | 需要手写大量 XML 配置,数据源、事务、拦截器全靠自己声明 | 自动配置 + starter 依赖,一份 application.yml 搞定大部分配置 |
| 内置服务器 | 需要单独安装 Tomcat,再打成 WAR 包部署 | 内置 Tomcat/Jetty,直接 java -jar 启动,打成 JAR 包就能跑 |
| 第三方框架集成 | 每个集成都要查文档写配置 | 引入 starter 依赖后基本零配置,MyBatis、Redis、JPA 都一样 |
| 项目结构规范 | 全靠约定,不同人写出来千奇百怪 | 官方推荐的目录结构,分包清晰 |
| 学习曲线 | 先要把 Spring IOC/AOP 和 SpringMVC 流程吃透,才能跑通一个项目 | 可以先上手做功能,遇到原理再回去看 |
我自己的体会是,SSM 适合学原理,Spring Boot 适合干活。这个健康食谱推荐系统里,业务逻辑占了很大比重(热量计算、推荐策略、用户反馈),如果时间都耗在配置 Tomcat 和写 XML 上,核心功能反而没精力打磨。所以最终选了 Spring Boot 2.7.x + MyBatis Plus + MySQL 这个组合,兼顾稳定性和开发效率,JDK 用的还是主流的 1.8,后面我会专门说一下为什么没有用 Spring Boot 3。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统整体设计与数据建模
2.1 功能模块拆分:用户端 + 管理端
我在设计功能模块的时候,参考了市面上健康饮食类 App 的常见玩法,但没有做得很臃肿,核心就是围绕“推荐”这个关键词展开。整个系统的功能模块拆成两大块,实际开发时前后端可以并行推进。
用户端模块:
- 注册登录:用户名 + 密码,密码做了 MD5 加盐处理,登录后签发 JWT Token。
- 健康档案:维护身高、体重、年龄、性别、运动频率这些信息,信息变化后重新计算推荐结果。
- 食谱浏览:按菜品类目(主食、汤羹、凉菜、热菜)、标签(低卡、高蛋白、素食)浏览,支持关键词搜索。
- 智能推荐:首页默认推荐,也可以按“早餐/午餐/晚餐”时段切换推荐内容。
- 互动反馈:收藏食谱、给推荐内容点赞或点踩,这些行为会进入用户偏好记录表。
- 个人中心:查看收藏历史、浏览历史、修改健康档案。
管理端模块:
- 食谱管理:管理端对食谱做增删改查,库存不足下架,菜品更新上架。
- 用户管理:查看用户列表、禁用异常用户。
- 推荐统计:查看每个食谱被推荐次数、被点赞次数、被踩次数,用它来评估推荐效果。
- 标签管理:维护“低卡”“高蛋白”“少油”“少盐”“素食”等标签字典。
模块划分的原则很简单:用户侧做“小而美”,管理侧做“够用就行”。不要一上来就设计十几个模块,很多功能看着炫酷但没人用,反而让代码变得又臭又长。这个项目里我把用户侧做细了,管理侧只保留必要的维护能力,整体的代码量控制在了一个可维护的范围内。
2.2 数据库设计:核心表和字段说明
数据库我用的是 MySQL 8.0,库名取的是 healthy_recipe_db。初始化时一共设计了 7 张核心表,下面挑重要的几张说一下设计思路。
用户表 user:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键自增 |
| username | VARCHAR(50) | 用户名,唯一索引 |
| password | VARCHAR(255) | 密码(MD5 + 随机盐) |
| salt | VARCHAR(32) | 加密盐值 |
| role | TINYINT | 角色:0 普通用户,1 管理员 |
| status | TINYINT | 状态:0 正常,1 禁用 |
| create_time | DATETIME | 注册时间 |
健康档案表 health_profile,一个用户只有一条有效档案,字段包括身高、体重、年龄、性别、活动系数。活动系数我用的是一组固定枚举值:久坐办公 1.2、轻度活动 1.375、中度活动 1.55、高强度运动 1.725。这个系数最终会参与热量计算,所以设计的时候直接做了字段约束。
食谱表 recipe 是最核心的业务表,它的字段决定了推荐系统能做多细:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| name | VARCHAR(100) | 食谱名称 |
| category | VARCHAR(20) | 分类:早餐/午餐/晚餐/加餐 |
| calories | DOUBLE | 总热量(千卡) |
| protein | DOUBLE | 蛋白质含量(克) |
| fat | DOUBLE | 脂肪含量(克) |
| carbohydrate | DOUBLE | 碳水化合物含量(克) |
| ingredients | TEXT | 主要食材,逗号分隔 |
| steps | TEXT | 做法步骤,用于展示 |
| image | VARCHAR(255) | 图片路径 |
| status | TINYINT | 0 上架,1 下架 |
| create_time | DATETIME | 创建时间 |
食谱标签关联表 recipe_tag_ref 做多对多关联,同时冗余标签名,避免高频查询时反复 join。用户行为表我拆成了两张:favorite_record(收藏记录)和 rating_record(评分/点赞/点踩记录)。行为表的设计特别重要,因为推荐系统的反馈数据全靠它来收集,字段不能少,我加了一个 action_type 字段,用 1 表示点赞、0 表示无操作、-1 表示点踩,这样统计正负反馈就很简单。
2.3 健康档案与热量需求计算逻辑
推荐系统要靠谱,第一步不是选算法,而是把用户的基础代谢率和每日热量需求算对。这里用到的公式是 Mifflin-St Jeor 方程,也是目前临床和营养领域用得比较多的估算方式:
- 男性基础代谢率 = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄(年) + 5
- 女性基础代谢率 = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄(年) - 161
算出基础代谢率之后,再乘以活动系数,就得到每日热量总需求。这个过程我直接写成了一个独立工具类 CalorieUtil,便于单元测试。
java复制public class CalorieUtil {
private static final double SEDENTARY_FACTOR = 1.2;
private static final double LIGHT_FACTOR = 1.375;
private static final double MODERATE_FACTOR = 1.55;
private static final double ACTIVE_FACTOR = 1.725;
public static double calcBMR(UserProfile profile) {
if (profile.getGender().equals("male")) {
return 10 * profile.getWeight()
+ 6.25 * profile.getHeight()
- 5 * profile.getAge()
+ 5;
}
return 10 * profile.getWeight()
+ 6.25 * profile.getHeight()
- 5 * profile.getAge()
- 161;
}
public static double calcDailyCalorie(UserProfile profile) {
double bmr = calcBMR(profile);
switch (profile.getActivityLevel()) {
case "sedentary": return bmr * SEDENTARY_FACTOR;
case "light": return bmr * LIGHT_FACTOR;
case "moderate": return bmr * MODERATE_FACTOR;
case "active": return bmr * ACTIVE_FACTOR;
default: return bmr * LIGHT_FACTOR;
}
}
public static double calcMealCalorie(UserProfile profile, String mealType) {
double daily = calcDailyCalorie(profile);
if ("breakfast".equals(mealType)) return daily * 0.3;
if ("dinner".equals(mealType)) return daily * 0.3;
return daily * 0.4;
}
}
这里有个经验值得说一下:早餐和晚餐分别占全天热量的 30%,午餐占 40%,加餐可以从午餐或晚餐里匀出来,这套比例是通用的营养建议,我直接拿来用。推荐系统在执行时,会根据当前时段算出该餐的热量范围,再去匹配食谱,而不是拿全天热量去匹配单个食谱,不然就会出现“一顿饭把全天配额吃光”的离谱情况。
3. 推荐引擎的实现:从规则到算法
3.1 第一版:基于标签的规则过滤
推荐引擎是这个系统最核心的部分。我第一次实现的时候没有直接上协同过滤,而是先用“规则过滤”把整个流程跑通。规则过滤的思路非常简单:先根据用户健康档案算出热量需求,然后在食谱库里筛掉热量过高或过低的,再根据用户偏好标签(低卡、高蛋白、素食等)做加权排序。
规则过滤的具体步骤是:
- 从
health_profile读取用户档案,调用CalorieUtil.calcMealCalorie算出当前餐次的目标热量。 - 设定允许的热量浮动范围,比如目标热量 ± 20%,超过范围直接淘汰。
- 对剩下候选食谱,根据用户偏好做打分:命中一个偏好标签加 10 分。
- 按综合得分排序,取前 N 条返回。
这套逻辑虽然简单,但效果非常稳定,尤其适合项目初期食谱数据量不大(比如就几十上百条)的时候。它的最大优点不是推荐有多准,而是每一步计算都可解释——被推荐的食谱为什么出现、为什么排在前边,原因清清楚楚,排查问题极其方便。我第一次测试时就发现有个食谱热量明显算错了,直接看打分过程定位到营养成分录入错误,修完数据推荐结果立刻正常。
3.2 第二版:基于内容的推荐
规则过滤跑通之后,我开始做基于内容的推荐,核心思想是“跟你喜欢的食谱相似的食谱,你也可能会喜欢”。
这里需要给食谱算相似度,特征向量就从食谱的标签和营养成分里抽取。我把标签拆成多个特征维度,比如“低卡=1,高蛋白=1,少油=1,少盐=0,素食=0”,同时把三大营养素归一化后也塞进向量。相似度计算利用余弦相似度:
cosine_similarity = (A·B) / (|A| × |B|)
在 Java 里实现余弦相似度没有 Python scikit-learn 那么方便,但代码也不长。我封装了一个 SimilarityUtil,核心方法如下:
java复制public class SimilarityUtil {
public static double cosineSimilarity(Map<String, Double> vectorA,
Map<String, Double> vectorB) {
Set<String> unionKeys = new HashSet<>(vectorA.keySet());
unionKeys.addAll(vectorB.keySet());
double dotProduct = 0.0;
double normA = 0.0;
double normB = 0.0;
for (String key : unionKeys) {
double valueA = vectorA.getOrDefault(key, 0.0);
double valueB = vectorB.getOrDefault(key, 0.0);
dotProduct += valueA * valueB;
normA += valueA * valueA;
normB += valueB * valueB;
}
if (normA == 0.0 || normB == 0.0) {
return 0.0;
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
}
基于内容的推荐流程是:先从用户行为表里找到用户点赞、收藏过的食谱,把它们当作“种子食谱”;然后遍历食谱库,计算每个候选食谱与所有种子食谱的相似度,取最高分作为该食谱的得分;最后按得分排序返回。这套方案的覆盖率高,只要是和种子食谱标签沾边的食谱都有机会被捞出来,用户体验比单靠规则过滤好很多。
3.3 第三版:协同过滤与冷启动处理
要不要上协同过滤,当时我纠结了很久。协同过滤的效果上限更高,能捕捉到用户之间意想不到的相似偏好,但它的实现复杂度和数据依赖也更高。这个项目数据量不大,直接做基于用户的协同过滤容易出现矩阵稀疏问题——用户才几十个,行为数据寥寥无几,算出来的相似度矩阵基本没有参考价值。
最终方案是:保留基于内容的推荐作为核心,同时引入一个轻量的“基于用户的协同过滤”作为辅助。它的逻辑简单说就是两句话:先找“和你喜欢吃的东西相似”的用户,再把那些用户收藏过但你还没看过的食谱推荐给你。这个场景适合食谱数量不多、但用户行为持续累积的中后期状态。
协同过滤里最关键的一步是计算用户相似度。我用的还是余弦相似度,只是把向量从“食谱特征”换成了“用户对食谱的行为得分”。行为得分映射规则是:点赞 2 分,收藏 3 分,点踩 -1 分,浏览 0.5 分,没有行为 0 分。这样设计的好处是,轻轻一点踩也能在数值上体现出来,避免稀疏情况下两个用户因为都点踩了同一道菜而被判定为相似,实际上这俩可能是互相看不顺眼的口味。
冷启动问题我分了两种情况处理:
- 新用户冷启动:用户没有行为数据,直接走 3.1 的规则过滤,按健康档案推荐。
- 新食谱冷启动:食谱没有用户反馈,先用基于内容的推荐让它进入候选池,一旦被推荐过两次并产生反馈,协同过滤就能接上了。
3.4 推荐接口的完整实现
推荐服务最终被封装为一个 RecommendService,对外提供统一的接口。完整流程我用代码串起来,你直接看会更清楚:
java复制@Service
public class RecipeRecommendService {
@Autowired
private RecipeService recipeService;
@Autowired
private UserBehaviorService behaviorService;
@Autowired
private UserProfileService profileService;
public List<RecipeVO> recommend(Long userId, String mealType, int limit) {
UserProfile profile = profileService.getByUserId(userId);
List<Recipe> candidates = filterByCalorie(profile, mealType);
List<Recipe> seedRecipes = behaviorService.getLikedOrFavoritedRecipes(userId);
if (seedRecipes.isEmpty()) {
return rankByRule(candidates, profile, limit);
}
Map<Long, Double> contentScores = computeContentBasedScore(candidates, seedRecipes);
Map<Long, Double> cfScores = computeCFScore(userId, candidates);
Map<Long, Double> finalScores = new HashMap<>();
for (Recipe recipe : candidates) {
double content = contentScores.getOrDefault(recipe.getId(), 0.0);
double cf = cfScores.getOrDefault(recipe.getId(), 0.0);
finalScores.put(recipe.getId(), 0.7 * content + 0.3 * cf);
}
return finalScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(limit)
.map(entry -> recipeService.convertToVO(entry.getKey()))
.collect(Collectors.toList());
}
private List<Recipe> filterByCalorie(UserProfile profile, String mealType) {
double target = CalorieUtil.calcMealCalorie(profile, mealType);
double min = target * 0.8;
double max = target * 1.2;
return recipeService.findAll().stream()
.filter(r -> r.getCalories() >= min && r.getCalories() <= max)
.collect(Collectors.toList());
}
}
这里有两个值得注意的细节。
第一个是加权项的系数设置。内容得分占 0.7,协同过滤占 0.3,这个比例不是随便定的。食谱推荐场景下,用户的健康需求和口味偏好更稳定,所以内容相似度应该占主导;协同过滤作为泛化补充,权重过高会导致“有些人喜欢辣的,但和你口味相似的人最近在狂吃甜食,结果你也全被推甜食”这种离谱结果。实际调参时可以把权重改成配置项,放 application.yml 里,方便临时调整。
第二个是候选集的差异度控制。如果用户连续三天刷到的都是同一批食谱,很容易疲劳。我在排序结果里加了一个简单的去重逻辑——用户最近点踩或者浏览过且没有点赞的食谱,排到最后;前 5 条里同一分类最多出现 2 个。这个小改动非常影响用户对推荐系统的观感,没有它,用户会觉得“系统是不是坏了,怎么老是这几个菜”。
4. 关键业务模块实战
4.1 用户登录与 JWT 鉴权
用户模块虽然基础,但代码质量的高低直接决定了系统的安全性。这个项目用了 JWT 做无状态鉴权,用户登录成功后服务端签发一个 Token,客户端后续请求带上这个 Token,服务端校验通过后就能确认用户身份,不需要在每个请求里都查一次数据库。
JWT 的依赖在 pom.xml 里加上 jjwt 相关依赖,核心配置就三个字段:签名密钥、过期时间、Token 前缀。签名密钥我用的是一段随机生成的字符串,实际项目里一定要放到配置中心或者环境变量里,别写死在代码里。过期时间我设的是 24 小时,太久不安全,太短影响体验。
登录接口的核心逻辑:
java复制@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private UserService userService;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody LoginDTO dto) {
User user = userService.authenticate(dto.getUsername(), dto.getPassword());
if (user == null) {
return Result.error("用户名或密码错误");
}
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
return Result.success(new LoginVO(token, user.getUsername(), user.getRole()));
}
}
密码存储我用了 MD5 加盐。虽然现在有些文章会说 MD5 不够安全,推荐 BCrypt,但对这种课程设计或者学习项目来说,MD5 加盐已经足够应付大多数场景,而且实现简单,方便解释原理。如果你想进阶,把密码加密替换成 BCrypt 也就是换一个 PasswordEncoder 的事,业务代码完全不用动。加盐的做法很简单:注册时生成一个随机字符串当作盐,拼在密码后面再 MD5,盐存到数据库的 salt 字段里。验证时从库里取出该用户对应的盐,重新计算比对。
4.2 食谱管理模块
食谱管理是管理端的核心,也是推荐系统的数据源头。如果食谱数据本身质量不高,后面算法再精妙也白搭。我在这里踩过一个大坑:早期录入食谱时,热量字段有手填的,也有根据食材自动计算的,两者经常不一致,直接导致推荐结果忽高忽低,排查半天才发现是脏数据的问题。
后来我在管理端加了一个校验逻辑:食谱的 calories 字段如果和食材营养累计值相差超过 10%,就弹出警告,提示运营人员确认。实现思路是在 Recipe 实体上写一个自定义校验注解 @CalorieConsistent,在保存时同步校验三大营养素是否合理。这一步看似简单,但解决了一个推荐系统里最容易被忽视的问题——数据质量决定推荐质量。
管理端的食谱列表还做了多条件筛选:按分类、按热量区间、按标签,方便运营快速定位问题菜品。新增食谱时支持上传图片,图片保存到本地磁盘一个 upload 目录,数据库存相对路径。图片上传这里有个坑:项目要打成 JAR 包部署的话,上传目录不能放在项目内部,否则每次重新部署文件就丢了。我的做法是把上传路径做成配置项,在 application.yml 里指定绝对路径,比如 /data/healthy-recipe/upload。
4.3 收藏与评分模块
收藏和评分这两个模块直接对接推荐引擎,是用户行为数据的主要来源。收藏接口很简单,插入一条 favorite_record 记录;评分接口稍复杂一点,因为用户对同一个食谱可能反复操作——先点赞,后来又取消,再后来改成点踩。
我的设计是使用唯一索引 (user_id, recipe_id) 防止重复记录,每次操作走 INSERT ... ON DUPLICATE KEY UPDATE 的方式更新 action_type 字段。这样既能保证数据不重复,也能保留用户最新的行为意图。在 MyBatis Plus 里,这个逻辑可以用 saveOrUpdate 配合自定义 SQL 实现。
行为记录之后需要异步同步到推荐模块。由于这个项目数据量不大,我一开始直接同步调用,后来发现用户在列表页频繁点赞时接口响应明显变慢,这才把行为写入改成异步。用 Spring 自带的 @Async 注解就能实现,在启动类上加上 @EnableAsync,在行为记录方法上加上 @Async,然后在推荐评分时手动刷新一次该用户的相似度缓存。这里特别提醒一句,异步方法不能和调用方法在同一个类里,否则 Spring 的代理机制不会生效,异步调用会悄悄变成同步。这个坑我实际踩过,排查了很久。
5. 常见问题与踩坑实录
5.1 Spring Boot 版本太高引发的 JDK 兼容问题
这个项目做完之后,我身边好几个朋友也照着做,结果卡在最开始的环境搭建阶段。最典型的问题就是:从 Spring Initializr 拉出来的项目默认是 Spring Boot 3.x,需要 JDK 17,但本机装的是 JDK 8,项目一启动就报错:
code复制java.lang.UnsupportedClassVersionError: org/springframework/boot/loader/Launcher
has been compiled by a more recent version of the Java Runtime
这个问题在网上的搜索热度非常高,因为很多教程还是基于 JDK 8 + Spring Boot 2.x 写的,新手在版本上很容易被绊住。我当时的处理方式是统一固定在 Spring Boot 2.7.18 版本,搭配 JDK 8,它也是 2.x 系列的最后一个版本,各种依赖的兼容性都打磨得比较好了。
如果你确实想用 Spring Boot 3.x,那必须用 JDK 17 及以上,并且 MyBatis Plus 要换用 mybatis-plus-spring-boot3-starter,Java EE 包名从 javax.* 变成了 jakarta.*,很多老代码不能直接跑。对于大多数毕业设计或者学习项目,我的建议是:没有特殊需求就稳稳地停在 Spring Boot 2.7 + JDK 8,等你把项目逻辑跑通了,再升级到 3.x 也不迟。
5.2 循环依赖:Spring Boot 2.6 之后的默认关闭
开发过程中,我曾经在两个 Service 之间写了互相调用的逻辑,UserBehaviorService 需要调用 RecipeRecommendService 查询推荐结果,而 RecipeRecommendService 又需要调用 UserBehaviorService 获取用户行为数据。Spring Boot 2.6 之前这种循环依赖默认可以自动解决,项目能正常启动;升级到 2.6 之后,直接报错:
code复制Relying upon circular references is discouraged and they are prohibited by default.
第一次看到这个报错我是懵的,因为代码在 2.5 版本下还能跑。后来查了 Spring Boot 的发布说明,才知道 2.6 版本开始默认禁止循环依赖。解决方案有两种:
- 使用
@Lazy注解在其中一个依赖上加懒加载,打破初始化顺序,但这只是治标。 - 重构代码,把互相依赖的逻辑提取到一个新的服务层,让调用关系变成单向的。我在代码里把相似度计算的逻辑抽到了独立的
SimilarityService中,两个 Service 都去调用它,循环依赖自然消失了。
长期用下来,推荐第二种方案,代码结构会更健康,也方便写单元测试。
5.3 推荐数据稀疏的冷启动问题
推荐系统常见的一个情况是:用户刚注册,一条行为数据都没有,协同过滤的相似度矩阵完全算不出来。我之前的处理方式已经提过,会在 RecommendService 里判断种子食谱是否为空,为空就降级到规则过滤。但这里还有个容易忽略的细节:如果用户填了健康档案,但档案里的目标热量在食谱库中一条都匹配不上(比如极端低卡饮食,热量低于 800 千卡),推荐结果就是空的。
我最终加了兜底逻辑:热量过滤找不到结果时,放宽热量范围到目标热量的上下 50%,再从结果中挑热量最低的几款。同时给前端返回一个提示,“亲,当前没有完全符合的食谱,先看看这些低卡选择吧”,避免用户面对空页面发呆。这样就算数据极端,系统也不会完全失效。
根据实际观察,初期食谱数据不足的时候,规则过滤的命中率最高;等用户行为数据累积到人均 10 条以上,基于内容的推荐效果才明显优于纯规则。所以说,不要一上来就纠结算法复杂度,先把基础规则和数据积累做好,后期算法才有用武之地。
5.4 打包部署到 Docker 的注意点
项目做完要部署,现在普遍的做法是打成 JAR 包扔到 Docker 里跑。打包这一步,Spring Boot 官方插件只需要在 pom.xml 里配置:
xml复制<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
执行 mvn clean package,在 target 目录下就能得到可直接运行的 JAR 包。注意打出来的包名默认是 项目名-版本号.jar,如果你嫌名字太长,可以在插件里配 finalName 指定。
Dockerfile 我参考了生产运维同事推荐的做法:
dockerfile复制FROM openjdk:8-jre-alpine
WORKDIR /app
COPY target/healthy-recipe-system.jar app.jar
EXPOSE 8080
ENV TZ=Asia/Shanghai
ENTRYPOINT ["java", "-jar", "-Xms256m", "-Xmx512m", "app.jar"]
这里有两个容易踩的坑。
第一个是容器时间问题。不设置 TZ=Asia/Shanghai 的话,容器内部默认 UTC 时区,数据库里记录的 create_time 会差 8 个小时,查日志和数据分析的时候非常烦人。在 Dockerfile 里加上环境变量是最省事的方案。
第二个是上传文件目录。前面提到的图片上传路径如果配置成了相对路径,容器重启后文件会丢失。我实际的做法是把 upload.path 通过 -e 参数传给容器,比如 -e UPLOAD_PATH=/data/upload,然后在宿主机上把 /data 挂载到容器的 /data。这样重新部署容器,图片还在,不会出现用户明天一刷新图片全裂了的情况。
6. 使用感受与建议
这个健康食谱推荐系统从设计到落地,我最大的一个体会是:项目真正的难点不在代码,而在把推荐目标拆成可执行的规则、再把这些规则变成稳定的接口。刚开始做的时候,我总想着推荐算法要花哨、要复杂,结果发现连“用户三餐热量怎么分配”这种最基本的业务问题都没想清楚,算法再高级也没有意义。
如果你也想照着源码自己搞一个类似的项目,我给几个过来人的建议:
第一,先把 CalorieUtil 这类基础工具类写好并测试通过,再动手写推荐接口。热量计算是整个推荐逻辑的地基,地基歪了,上面全白搭。我当时给这个工具类写了至少五个测试用例,覆盖男女不同性别、不同活动水平、不同身高年龄的组合,确保万无一失。
第二,食谱种子数据别只录入几行测试数据就完事。推荐系统的观感完全依赖数据量,我建议至少准备 50 道菜以上,覆盖早餐、午餐、晚餐三个时段,并且每个时段都有低卡、高蛋白、素食等不同标签的分布。当时我比对着常见食物营养成分表录了好久,看着麻烦,但后面调试推荐结果时省了无数时间。
第三,后期扩展方向不用想太远,但架构上要留好口子。比如想把推荐结果从“单菜推荐”升级成“一日三餐组合推荐”,本质上还是在算出候选食谱后加一道组合优化的逻辑;想接大模型做菜谱文案生成,预留一个适配器接口就行。最重要是别让代码写死在某一种推荐策略上,因为我前面也说到,策略是会根据数据情况变化的,写死了就只能推翻重来。
最后再分享一个小技巧:推荐系统的效果评估不要只看点击率或者收藏率,要看用户有没有“连续回来”的动作。如果用户今天来了、明天来了、隔一周又来了,说明推荐结果是真的让他觉得有用,比任何指标都更能说明问题。这个项目做到后期,能明显看到部分用户的收藏列表越攒越长,那种感觉还是很踏实的。
