基于Spring Boot的高校饮食推荐:标签相似度与协同过滤实践
2026/9/15 20:07:11 网站建设 项目流程

简介:一套基于 SpringBoot 与 Vue 的高校学生饮食推荐系统完整源码,面向高校学生与 Java 开发者,也适用于毕业设计及课程设计项目,可解决校园饮食推荐与信息管理需求。后端采用 SpringBoot、MyBatisPlus、MySQL 构建,前端使用 Vue 和 Ajax,包含用户信息管理、图片素材维护、视频素材展示等核心模块,覆盖从数据库建表、接口开发到页面渲染的完整流程,能够帮助学习者理解前后端分离系统的实际落地方式。压缩包为 zip 格式,共 647 个文件,大小约 21.22MB,主要涵盖 Java 后端源码、Vue 前端组件、SVG/JPG/PNG 图片素材,以及 YML/XML 配置与启动脚本等,整体目录结构清晰,便于用 IDEA 或 Eclipse 导入学习。目前已有 327 人学习/下载。资源内还附带数据库设计文档与一键启动脚本,可快速启动项目、核对数据表关系,并围绕用户、图片、视频等模块梳理推荐业务逻辑,适合直接用于课程设计、毕业设计答辩,也可作为 Web 开发参考项目。

1. 高校学生饮食推荐系统:食堂窗口前的选择题没你想的那么简单

学生每天在食堂窗口前纠结的三分钟,本质上是一次低成本的决策行为。这个系统要做的,就是把“今天吃什么”从拍脑袋变成有依据的排序:依据学生的口味偏好、历史选餐行为、菜品标签甚至当餐的营养约束,产出一份每人都不一样的推荐列表。它对外是推荐接口,对内是一套完整的管理链路,覆盖菜品录入、窗口维护、标签管理、用户画像、行为埋点和推荐引擎。适合两类人看:一类是做 Java 后端想完整走一遍推荐系统落地的开发者,另一类是拿基于 Spring Boot 的毕设源码做二次开发的学生。先说一个反直觉的结论:在这个场景里,矩阵分解往往不如可解释的标签权重实用,数据量摆在那,先立规则再上模型才是性价比最高的路径。

2. 数据模型先行:高校学生饮食推荐系统的用户画像与菜品标签设计

推荐系统的上限由数据决定,算法只是把数据里的关系捞出来。校园饮食场景的数据规模不大,但字段的坑不少。我先说一个常见的错误:把用户口味做成一个 String 字段,往里面塞“辣、清淡、不吃香菜”,然后推荐时用 LIKE 去匹配菜品标签。这种写法在小数据量下能跑,但一旦要算相似度、做协同过滤,你会发现数据根本没法向量化。正确姿势是把画像拆成结构化字段加标签集合,让每一列都能被算法直接消费。

2.1 用户画像表:口味标签与忌口存成 JSON 还是拆分字段

高校学生群体的画像有三个特点:口味分布集中(辣、清淡、甜占了大头)、忌口信息明确(宗教、过敏、素食)、消费能力分档(月生活费影响价格接受度)。我在这个系统的表设计里把画像拆成了两部分:固定字段存预算和学籍信息,可变标签存口味和忌口。

CREATE TABLE user_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', user_id BIGINT NOT NULL COMMENT '关联用户表', student_no VARCHAR(32) DEFAULT NULL COMMENT '学号,用于院系维度统计', budget_level TINYINT DEFAULT 2 COMMENT '消费档位:1低 2中 3高', taste_tags VARCHAR(255) DEFAULT NULL COMMENT '口味标签,JSON数组,如["辣","重口"]', taboo_tags VARCHAR(255) DEFAULT NULL COMMENT '忌口标签,JSON数组,如["香菜","花生"]', calorie_target INT DEFAULT 0 COMMENT '每日热量目标,0表示不限制', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生用户画像表';

这段 DDL 里有三个值得留意的点。budget_level用 TINYINT 而不是字符串,是为了排序和区间查询方便,推荐时可以直接写budget_level <= 3做价格过滤。taste_tagstaboo_tags用 JSON 字符串而不是独立关联表,是因为校园场景下标签总量小(几十个),JSON 存储能减少一次 JOIN,读取时用JSON_EXTRACT或直接反序列化成 List 都很快。taboo_tags是推荐系统的硬约束,在召回后必须做过滤,否则一个对花生过敏的学生看到花生酱拌面,推荐系统就失去了意义。

2.1.1 画像查询与更新的 Mapper 写法

MyBatis Plus 对 JSON 字段的处理需要一点技巧。实体类里用List<String>接收,数据库用 String,手动在 setter 里做序列化。

public class UserProfile { private Long id; private Long userId; private Integer budgetLevel; private String tasteTags; private String tabooTags; @TableField(exist = false) private List<String> tasteTagList; public List<String> getTasteTagList() { if (this.tasteTagList == null && this.tasteTags != null) { this.tasteTagList = JSON.parseArray(this.tasteTags, String.class); } return this.tasteTagList; } }

这里把 JSON 解析放在 getter 里,是为了让推荐引擎调用时无感知。@TableField(exist = false)表示该字段不做 ORM 映射,数据库里没有对应列。每次从库里查出来,只要调getTasteTagList()就能拿到反序列化后的列表,写入时则在 service 层把 List 转成 JSON 字符串再 set。序列化用 Fastjson 或 Jackson 都可以,看项目里已有的依赖,别为这个再引一个重框架。

2.2 菜品库与标签词典:标签是推荐系统的上限

菜品表是整个系统的内容底座。菜品没有标签,推荐引擎再强也是无米之炊。我在设计时坚持一个原则:标签必须来自受控词典,不能由录入人员自由输入。自由输入的后果是“微辣”“中辣”“特辣”这种近义标签泛滥,相似度计算完全失真。

标签分类标签示例是否参与向量化备注
口味辣、清淡、甜、酸、咸用户画像核心匹配维度
食材鸡肉、牛肉、鱼、蔬菜与忌口标签联动
加工油炸、蒸煮、烧烤关联热量与健康推荐
场景早餐、午餐、晚餐、夜宵按时段过滤
档口一楼面食、二楼自选用作结果筛选

菜品表和标签字典的建表语句我通常会写成两个表加一张关联表,而不是在菜品表里存一个逗号拼接的 tags 字段。原因很简单:标签要参与统计和过滤,关联表能直接走索引。

CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '菜品名称', window_id BIGINT NOT NULL COMMENT '所属窗口', price DECIMAL(6,2) NOT NULL DEFAULT 0.00 COMMENT '价格', calorie INT DEFAULT 0 COMMENT '热量,单位千卡', monthly_sales INT DEFAULT 0 COMMENT '月售,用于冷启动热度排序', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', deleted TINYINT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE dish_tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dish_id BIGINT NOT NULL, tag_name VARCHAR(32) NOT NULL, KEY idx_dish_id (dish_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

后期做推荐时,SQL 写起来很直接:SELECT d.* FROM dish d INNER JOIN dish_tag t ON d.id = t.dish_id WHERE t.tag_name IN ('辣','鸡肉') GROUP BY d.id HAVING COUNT(DISTINCT t.tag_name) >= 2。这个查询的含义是找同时具备“辣”和“鸡肉”两个标签的菜,用于基于标签的内容召回。注意HAVING在这里不是用来分组统计的,而是做标签交集数过滤,这是内容推荐里最常用的一个 SQL 写法。

2.3 持久层选型:springboot 项目里用 MyBatis Plus 的 3 个理由

这套系统我用的是 MyBatis Plus 而不是 Spring Data JPA,有三个具体原因。第一,推荐系统的查询特征是多表 JOIN 加统计函数,MyBatis 的 XML 里写这种 SQL 比 JPQL 直观得多,排查慢查询也更方便。第二,毕设和新项目里抄作业的人多,MyBatis Plus 的BaseMapper提供了现成的 CRUD,能省掉一批样板代码。第三,逻辑删除和自动填充是 MyBatis Plus 的内置能力,上面的deleted字段加一个配置就能全局生效。

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency>

依赖版本这块有个坑需要提前说。如果你的 Spring Boot 版本是 3.x,这个依赖要换成mybatis-plus-spring-boot3-starter,包名变了。我一般建议这个项目锁 Spring Boot 2.7.x,因为线上跑 JDK 8 最常见,2.7 是兼容性和生态最稳的一档。市面上能搜到的基于 Spring Boot 的饮食推荐系统源码,大多也是 2.7 这个版本基线,拿来做二次开发时踩坑少。

3. 推荐引擎实现:基于 springboot 的标签相似度与用户协同过滤算法

这一步是整个系统的核心,也是大多数源码里被阉割的部分。很多项目所谓的推荐,就是按销量倒序排一下,返回给前端完事。真正的做法分三层:先在标签维度算内容相似度,再用用户行为做协同过滤,最后把两个结果加权融合。下面这套逻辑是纯 Java 实现,不依赖任何外部推荐框架,适合 springboot 项目直接落地。

3.1 把用户画像和菜品标签转成向量:余弦相似度计算

要让计算机理解“用户想要什么”和“这道菜是什么”,最直接的方式是把它们映射到同一个向量空间。做法是维护一个全局标签索引表,每个标签对应一个维度。用户画像里命中标签就置 1,忌口标签置 -1;菜品命中标签置 1,没有的维度默认 0。

public class VectorUtil { /** * 计算两个向量的余弦相似度,取值范围[-1, 1] * 1表示完全一致,0表示无关联,负数表示方向相反 */ public static double cosine(double[] a, double[] b) { if (a.length != b.length) { throw new IllegalArgumentException("向量维度不一致"); } double dot = 0.0; double normA = 0.0; double normB = 0.0; for (int i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } if (normA == 0.0 || normB == 0.0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } }

这个方法的计算逻辑不复杂,但有三个参数细节要说明。normAnormB在循环里累加的是各维度的平方和,开根号之后才是向量的模长,所以归一化在公式里已经完成了。当某个向量的所有维度都是 0,说明这个用户或菜品还没有任何标签,直接返回 0 而不是抛异常,避免冷启动数据进来之后把推荐接口打挂。返回值是 -1 到 1,负数说明用户忌口和菜品标签完全冲突,在排序时可以直接过滤掉。

用户画像向量的构建,要把忌口标签做成负权重,这一个小改动就能明显提升推荐精度。

public double[] buildUserVector(Long userId) { UserProfile profile = userProfileMapper.selectOne( new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, userId)); Map<String, Integer> tagIndex = tagDictService.getTagIndex(); double[] vector = new double[tagIndex.size()]; List<String> tastes = profile.getTasteTagList(); for (String tag : tastes) { Integer idx = tagIndex.get(tag); if (idx != null) { vector[idx] = 1.0; } } List<String> taboos = JSON.parseArray(profile.getTabooTags(), String.class); for (String tag : taboos) { Integer idx = tagIndex.get(tag); if (idx != null) { vector[idx] = -1.0; } } return vector; }

注意这段代码里tagIndex.get(tag)返回 null 的情况,说明用户画像是脏数据,写了标签字典里不存在的值。我一般会在这里埋一个日志,统计脏标签出现频率,定期反哺后台录入校验规则。向量的长度由tagIndex.size()决定,后续新增标签会导致向量维度变化,已经入库的向量要重算,这也是设计标签字典时要保证其稳定性的原因。

3.2 基于用户的协同过滤:用“饭搭子”的行为补全推荐候选

内容推荐的局限在于它只能推荐“跟用户画像相似”的菜,如果学生某天想换换口味,画像就帮不上忙。这时需要基于用户的协同过滤,逻辑映射到现实里就是:找一群口味喜好跟你相似的同学,看他们最近吃过什么,从里面挑你还没吃过的菜推荐给你。

public List<Long> recommendByUserCf(Long userId, int topK, int limit) { double[] targetVector = userVectorBuilder.buildUserVector(userId); // 找出相似度最高的 K 个用户作为“饭搭子” List<Long> similarUserIds = userProfileMapper.selectList(null).stream() .map(UserProfile::getUserId) .filter(id -> !id.equals(userId)) .sorted(Comparator.comparingDouble(id -> -VectorUtil.cosine(targetVector, userVectorBuilder.buildUserVector(id)))) .limit(topK) .collect(Collectors.toList()); // 聚合饭搭子评分在4分以上的菜品,按出现次数排序 Map<Long, Integer> dishScore = new HashMap<>(); for (Long uid : similarUserIds) { List<UserBehavior> behaviors = behaviorMapper.selectList( new LambdaQueryWrapper<UserBehavior>() .eq(UserBehavior::getUserId, uid) .ge(UserBehavior::getRating, 4) .last("LIMIT 50")); for (UserBehavior behavior : behaviors) { dishScore.merge(behavior.getDishId(), 1, Integer::sum); } } return dishScore.entrySet().stream() .sorted(Map.Entry.<Long, Integer>comparingByValue().reversed()) .limit(limit) .map(Map.Entry::getKey) .collect(Collectors.toList()); }

这里面有三个参数决定了协同过滤的效果。topK是相似用户数,取 10 到 30 之间比较合适,太小会让候选集稀疏,太大把无关用户也拉进来稀释精度。ge(UserBehavior::getRating, 4)做了行为筛选,只采纳评分 4 分以上的行为作为正反馈,避免把学生仅浏览过但不喜欢的菜也当推荐候选。每个饭搭子只取最近 50 条行为,是为了控制查询范围,防止活跃用户的行为主导了整个推荐结果。

这个实现方式的瓶颈在于第 2 行到第 6 行,每次都要遍历全量用户计算两两相似度。校园场景用户量在一万人以内时完全没问题,如果将来扩到十万级,需要把用户向量预计算并缓存在 Redis 里,这个优化放到下一章讲。

3.3 混合推荐与冷启动:权重怎么定,没数据时推荐什么

内容推荐和协同过滤单独用都有短板,常见做法是加权融合。我在这个系统里用的是线性加权,两份候选列表先分别算分,再按权重合并,最后统一排序。

参数默认值说明
contentWeight0.6内容推荐分数权重,画像越完整占比越高
cfWeight0.4协同过滤分数权重,行为数据越多占比越高
minScore0.3过滤低分候选,防止杂音进入结果
coldStartLimit7冷启动用户直接取热度前 7
public List<RecommendItem> recommend(Long userId, int size) { List<RecommendItem> contentHits = contentRecommend(userId, 50); List<RecommendItem> cfHits = userCfRecommend(userId, 50); Map<Long, RecommendItem> merged = new HashMap<>(); for (RecommendItem item : contentHits) { item.setScore(item.getScore() * contentWeight); merged.put(item.getDishId(), item); } for (RecommendItem item : cfHits) { merged.merge(item.getDishId(), item, (existing, incoming) -> { existing.setScore(existing.getScore() + incoming.getScore() * cfWeight); return existing; }); } return merged.values().stream() .filter(item -> item.getScore() >= minScore) .sorted(Comparator.comparingDouble(RecommendItem::getScore).reversed()) .limit(size) .collect(Collectors.toList()); }

这段代码体现了混合推荐的核心逻辑:同一个菜品如果同时被内容推荐和协同过滤命中,它的分数会叠加,排到更前面。contentRecommenduserCfRecommend都限制返回 50 条候选,避免合并时内存膨胀。冷启动用户的行为数据为空,协同过滤返回空列表,此时merged里只有内容推荐的分数,结果退化为纯内容推荐。

真正的冷启动是连画像都没有的新用户。我在 controller 层做了兜底判断,用户画像不存在时直接按monthly_sales热度排序取前 N 条。这个兜底逻辑要放在调用 recommend 之前,因为画像向量构建在缺数据时会返回一个全 0 向量,放进余弦相似度的结果是 0,最终推荐列表为空。

4. 接口、缓存与工程化:让 springboot 推荐服务在每日高峰期扛住访问

算法跑通只是第一步,系统要能用还得过工程化这一关。食堂的就餐高峰集中在中午 11 点半到 12 点半和下午 5 点半到 6 点半,这期间学生打开 App 的请求量是平时的十几倍。推荐引擎如果每次都实时计算全量数据,数据库连接会被瞬间打满。这一章讲接口设计、Redis 缓存和启动排错,把推荐服务从“能在本地跑通”变成“能在服务器上撑住”。

4.1 推荐接口怎么设计:每日推荐、按窗口筛选与分页

推荐接口的职责划分要清晰。/api/v1/recommend/daily负责返回给用户当天的推荐列表,/api/v1/dish/search负责按窗口和标签筛选,两者不要混在一个接口里。接口返回的结构固定为菜品基础信息加推荐分数,方便前端直接渲染卡片。

@RestController @RequestMapping("/api/v1") public class RecommendController { @GetMapping("/recommend/daily") public Result<List<RecommendVO>> daily(@RequestHeader("X-User-Id") Long userId, @RequestParam(defaultValue = "10") Integer size) { if (size <= 0 || size > 50) { size = 10; } List<RecommendVO> recommendList = recommendService.recommendWithCache(userId, size); return Result.ok(recommendList); } }

@RequestHeader("X-User-Id")从请求头拿用户 ID,而不是从参数里传,是为了避免学生手动拼接 URL 去刷接口。生产环境一般会用 JWT 令牌并配合拦截器解析出用户身份,再放入当前的ThreadLocal上下文里,controller 直接从上下文取。这里为了演示简洁我直接在接口层接收 Header,但拦截器的鉴权逻辑不能省,否则任何人都能拿到别人的推荐列表。

4.2 Redis 缓存每日推荐结果:过期时间与穿透处理

推荐结果的实时性要求不高,同一个学生在一个饭点内看到的推荐列表不需要秒级刷新,非常适合做缓存。我用 Redis 以用户 ID 和推荐数量为 key,缓存推荐结果,过期时间设为 6 小时,正好覆盖一个饭点周期。

public List<RecommendVO> recommendWithCache(Long userId, int size) { String key = "recommend:user:" + userId + ":size:" + size; String cached = redisTemplate.opsForValue().get(key); if (StringUtils.hasText(cached)) { return JSON.parseArray(cached, RecommendVO.class); } List<RecommendVO> result = doRecommend(userId, size); String json = JSON.toJSONString(result); if (result.isEmpty()) { // 空结果也缓存3分钟,防止缓存穿透 redisTemplate.opsForValue().set(key, "[]", 3, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, json, 6, TimeUnit.HOURS); } return result; }

缓存 key 的设计按业务前缀加冒号划分,便于后续按模式批量清理。空结果缓存 3 分钟是一个经验值,太短扛不住瞬间的并发穿透,太长会导致新增菜品后用户仍然看到空列表。缓存更新不使用主动删除策略,因为推荐结果依赖的数据变化没那么频繁,等待过期自然重建即可,省掉一套消息通知机制。

key 模式过期时间用途
recommend:user:{userId}:size:{size}6 小时用户每日推荐列表
recommend:hot:rank10 分钟全局热度榜
dish:detail:{dishId}30 分钟菜品详情缓存

4.3 IDEA 里创建 springboot 项目的常见启动报错与定位

从零搭这套系统时,我见过的大部分启动失败都集中在三个问题上,这一节值得仔细看一遍。第一个是 Spring Boot 版本和 JDK 版本不匹配。很多人在 IDEA 的 Spring Initializr 里直接选了最新版,本地却装的是 JDK 8,启动时报找不到jakarta.servlet.Filter。Spring Boot 3.x 起底层从javax迁移到jakarta,最低要求 JDK 17。如果你用 JDK 8,就把 Spring Boot 版本锁在 2.7.x。

第二个问题出在数据库连接。启动时遇到底部日志Caused by: com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure,先别查代码,直接用命令行工具连一下数据库确认网络和账号密码。这个报错在本地 90% 是 MySQL 没启动,或者application.yml里的 URL 端口写错。

spring: datasource: url: jdbc:mysql://localhost:3306/diet_recommend?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root

serverTimezone=Asia/Shanghai这个参数强烈建议加上,否则 MySQL 8 驱动会报时区错误。第三个问题是端口被占用,日志显示Port 8080 was already in use。要么把本机的其他 Java 进程关掉,要么在启动参数里指定--server.port=8081。这三个坑都排查完之后,系统基本能在本地跑起来,下一步才是对接真实的菜品数据。

5. 推荐结果可解释与离线验证:调权重时不再凭感觉

推荐系统上线后,最难回答两个问题:为什么给学生推荐这道菜,以及推荐效果到底行不行。这一章讲两个实操技巧,一个让推荐结果能向学生解释,另一个让调参前能看到数字依据。

5.1 给推荐结果加推荐理由

学生看到推荐列表时,如果只是一张菜品卡片,信任感很低。我在返回的RecommendVO里增加了一个reasons字段,把推荐理由拼接成可读文本。实现时复用第 3 章的标签命中逻辑。

private List<String> buildReasons(UserProfile profile, Dish dish) { List<String> reasons = new ArrayList<>(); List<String> dishTags = dishTagService.listTagNames(dish.getId()); for (String taste : profile.getTasteTagList()) { if (dishTags.contains(taste)) { reasons.add("你偏好'" + taste + "'口味"); } } if (dish.getCalorie() > 0 && profile.getCalorieTarget() > 0 && dish.getCalorie() <= profile.getCalorieTarget()) { reasons.add("热量在你设定的目标范围内"); } return reasons.size() > 0 ? reasons : Collections.singletonList("大家都在吃"); }

buildReasons只做两层判断:第一层匹配口味标签,第二层匹配热量约束。如果都没命中,就返回“大家都在吃”,用热度兜底解释。这段逻辑放在 service 层,避免 controller 里出现业务计算。

5.2 离线回放:用 7 天的行为数据验证推荐效果

验证推荐效果不需要上线上 A/B 测试那么重,用离线回放就能获得可信结论。做法是拿前 6 天的行为数据算推荐,拿第 7 天的真实点击行为做对比,统计推荐列表的命中率。

指标计算方式参考范围
命中率推荐列表中当天被点击的菜品数 / 推荐列表长度大于 12%
覆盖率被推荐到的菜品数 / 在售菜品总数大于 40%
多样性推荐列表中不同标签分类数大于 6

统计脚本用 SQL 就能完成大部分工作。先找出 7 天内被学生收藏或评分 4 分以上的菜品:

SELECT dish_id, COUNT(*) AS fav_cnt FROM user_behavior WHERE action_type = 2 AND create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY dish_id ORDER BY fav_cnt DESC LIMIT 10;

这条 SQL 查出近 7 天被收藏最多的前 10 个菜品。将它与推荐接口返回的前 10 做集合交集,交集数量除以 10 就是命中率。两组的重合度如果持续低于 10%,说明推荐算法跟真实偏好脱节,优先检查标签词典的质量,而不是调整contentWeightcfWeight的权重比值。调参前先跑一遍这个统计,把每组权重下的命中率记录在表格里,连续看一周,比在后台页面里用眼睛估计效果直观得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询