基于Spring Boot的高校饮食推荐系统:从数据建模到协同过滤落地 简介这是一款面向高校学生的个性化饮食推荐系统完整毕业设计资源基于Java、SpringBoot与Vue实现覆盖用户管理、饮食偏好采集、个性化推荐、食谱库查询及健康建议等模块适合计算机相关专业学生用于毕设参考、二次开发或项目实战演练。压缩包约21.46MB上游暂未提供文件总数与具体清单解压后可获得系统源码、数据库脚本及配套文档说明整体项目采用MySQL存储用户、偏好及食谱数据。已有23人浏览学习。资源不仅展示前后端分离架构下的业务实现还重点体现机器学习算法在饮食推荐场景中的落地方式帮助读者掌握从需求分析、数据库设计到SpringBoot接口开发、Vue页面渲染的完整流程是一份兼具工程实践与学术参考价值的毕业设计范例。1. 高校学生饮食推荐系统与 Spring Boot 的匹配逻辑高校学生饮食推荐系统表面看是一个课程设计级别的业务应用拆开看则覆盖了菜品数据建模、行为日志采集、推荐算法运算、Redis 缓存和 RESTful 接口是一条完整的技术链。springboot644 直接点明技术栈为 Spring Boot编号对应工程在生成平台上的唯一标识zip 则是这类项目最常见的交付形态——源码、数据库 SQL 脚本和说明文档一起压缩解压即用。下面按数据层、算法层、接口层的顺序把这套系统的落地路径完整还原表结构怎么设计、推荐算法在 Spring Boot 里怎么写、接口参数怎么配、效果怎么验证每一层都给出可以直接照抄的代码和命令。适合正在做基于 Spring Boot 的毕设项目的学生也适合想了解推荐系统在单体应用里如何落地的后端工程师。2. 饮食推荐系统的数据模型设计与 Spring Boot 实体映射推荐系统与普通业务系统的分水岭在于数据模型普通系统以单据流转为核心推荐系统以行为日志和菜品特征字段为核心。没有行为日志协同过滤就失去输入没有口味和营养标签内容推荐就缺少判断依据。所以把表结构设计放在第一步而不是在代码写到一半再补是这套系统能顺利落地的关键。2.1 学生、菜品、行为三个域的核心表结构高校饮食推荐系统的数据模型至少要覆盖三个域用户域存学生档案与偏好菜品域存菜品与食堂窗口信息行为域存浏览、收藏、下单、评分四类事件。推荐快照表用于记录每次推荐的结果便于做效果复盘和算法对比。表名关键字段说明tb_studentid, student_no, name, gender, taste_tag, allergy_info学生档案taste_tag 存逗号分隔的偏好标签tb_dishid, dish_name, category, price, taste_label, nutrition_label, window_id, status菜品档案标签字段供内容推荐使用tb_canteen_windowid, canteen_name, window_no, location食堂窗口按楼层或距离过滤tb_behavior_logid, user_id, dish_id, behavior_type, score, create_time行为日志behavior_type 1浏览 2收藏 3下单 4评分tb_recall_logid, user_id, dish_ids, strategy_type, create_time推荐快照记录当时推荐了哪些菜一个容易被忽视的细节是 tb_behavior_log.score 字段的取值规则只有 behavior_type 4评分时该字段才有值其余行为一律为 NULL。初学者常常给浏览、收藏也塞一个隐含分值进去后续算相似度时把噪音当信号直接拉低效果。浏览行为应该只在离线分析中使用协同过滤的输入只需装载收藏和下单两类正反馈。在 tb_behavior_log 上建议建一个联合索引(user_id, behavior_type, create_time)因为协同过滤里最频繁的查询是某个用户近 N 天行为过哪些菜这个索引可以覆盖查询条件避免回表带来的额外 IO。2.2 MyBatis-Plus 还是 JPA按查询模式做选型这类项目里最常被问到的技术选型是 ORM。推荐场景的查询特征集中为两类单表条件查询和多表聚合查询。单表查询用 JPA 很自然方法名直接推导查询逻辑多表联查和分页筛选则更适合 MyBatis-Plus。在高校饮食推荐这个数据量级下几千学生、几百菜品、几十万条行为日志SQL 性能压力几乎可以忽略选型的决定因素是开发效率和团队熟悉度。我个人的做法是两者共存行为日志表用 JPA 做追加写入和简单统计因为行为日志只有 insert 和 count 两种高频操作JpaRepository 开箱即用不需要写 XML学生表、菜品表用 MyBatis-Plus因为它自带分页插件管理后台的列表筛选不需要额外配置。两种 ORM 共存需要注意事务管理器的统一。Spring Boot 默认配置的 DataSourceTransactionManager 只绑定一个数据源只要 StudentMapper、DishMapper 和 BehaviorLogRepository 操作的是同一个数据源事务上直接使用Transactional即可不会出现分布式事务问题。真正会踩的坑是实体扫描路径Spring Boot 的EntityScan和MapperScan要各自覆盖对应的包否则启动时会报 bean 找不到。2.3 实体映射与行为日志聚合查询代码以菜品实体为例MyBatis-Plus 的注解映射方式TableName(tb_dish) public class Dish { TableId(type IdType.AUTO) private Long id; private String dishName; private String category; private BigDecimal price; private String tasteLabel; private String nutritionLabel; private Long windowId; private Integer status; }TableName(tb_dish)显式指定表名避免依赖驼峰转下划线的默认规则。价格字段选BigDecimal而不是Double是为了避免浮点运算在金额比较时出现精度问题。status字段标记上下架状态推荐服务里每次查询都要带上status 1条件防止把已下架的菜推给学生。行为日志聚合查询用 Spring Data JPA 的 Query 注解实现Repository public interface BehaviorLogRepository extends JpaRepositoryBehaviorLog, Long { Query(SELECT b.dishId AS dishId, COUNT(b.id) AS cnt FROM BehaviorLog b WHERE b.userId :userId AND b.behaviorType IN (2, 3) AND b.createTime :startTime GROUP BY b.dishId ORDER BY cnt DESC) ListObject[] findPositiveDishIdsByUser( Param(userId) Long userId, Param(startTime) LocalDateTime startTime, Pageable pageable); }这里behaviorType IN (2, 3)只统计收藏和下单两类正反馈。createTime :startTime由调用方传入 30 天前的时间把行为窗口限制在当前时段。Pageable通过PageRequest.of(0, 10)控制条数传 10 表示取频次最高的 10 个菜品。返回值ListObject[]是 JPA 聚合查询的默认格式索引 0 是 dishId索引 1 是计数拿到结果后需要手动做类型转换。这个写法在这个规模下完全够用真正的性能瓶颈不在 SQL 而在后续的相似度矩阵计算那一步会拿这些原始行为去离线构建矩阵。3. 推荐算法在 Spring Boot 服务里的落地路径数据模型就位后推荐逻辑是系统的核心。高校饮食推荐场景并不需要上复杂模型两类经典算法已经覆盖大部分需求基于物品的协同过滤ItemCF和基于内容的标签匹配。前者解决和你口味相似的人吃什么后者解决这个新菜和你过去的偏好标签是否匹配。3.1 基于物品的协同过滤矩阵构建与相似度计算ItemCF 的思路是如果两个菜被同一批学生收藏或下单那它们大概率是相似菜品。推荐时找到用户行为过的菜再去相似度矩阵里取出与这些菜最相似的一批新菜。在 Spring Boot 工程中不推荐在请求线程里实时计算相似度常见做法是每日凌晨用定时任务重建一次矩阵并缓存在应用内存中Component public class ItemCFHolder { private volatile MapLong, MapLong, Double similarityMap new HashMap(); Scheduled(cron 0 0 3 * * ?) public void rebuild() { ListBehaviorLog logs behaviorLogRepository .findSince(LocalDateTime.now().minusDays(30)); MapLong, SetLong userDishMap new HashMap(); for (BehaviorLog log : logs) { if (log.getBehaviorType() ! 2 log.getBehaviorType() ! 3) continue; userDishMap.computeIfAbsent(log.getUserId(), k - new HashSet()) .add(log.getDishId()); } MapLong, SetLong dishUserMap buildInvertedIndex(userDishMap); MapLong, MapLong, Double newMap new HashMap(); ListLong dishIds new ArrayList(dishUserMap.keySet()); for (int i 0; i dishIds.size(); i) { for (int j i 1; j dishIds.size(); j) { Long a dishIds.get(i), b dishIds.get(j); SetLong usersA dishUserMap.get(a); SetLong usersB dishUserMap.get(b); double inter usersA.stream().filter(usersB::contains).count(); double denom Math.sqrt(usersA.size() * usersB.size()); if (denom 0) continue; double sim inter / denom; if (sim 0.1) continue; newMap.computeIfAbsent(a, k - new HashMap()).put(b, sim); newMap.computeIfAbsent(b, k - new HashMap()).put(a, sim); } } similarityMap newMap; } public MapLong, Double topSimilar(Long dishId, int topK) { MapLong, Double sims similarityMap.get(dishId); if (sims null) return Collections.emptyMap(); return sims.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topK) .collect(Collectors.toMap( Map.Entry::getKey, Map.Entry::getValue, (a, b) - a, LinkedHashMap::new)); } }相似度公式用的是修正余弦相似度inter / sqrt(|A| * |B|)将交集大小按几何平均归一化。三个参数需要根据实际数据调节行为窗口30 天、相似度阈值0.1、矩阵重建周期每日 3 点。如果发现推荐列表里新菜比例太低说明相似度阈值偏高或行为窗口太窄如果发现推荐结果里出现大量无关菜品说明阈值偏低矩阵里噪音太多。volatile修饰similarityMap是必要的rebuild()执行时后台线程在构造新矩阵推荐请求线程在读旧矩阵volatile 保证替换引用时读者线程立即可见新矩阵不会读到半构造状态。3.2 基于内容的标签匹配冷启动与长尾菜品的兜底ItemCF 对没有行为数据的新菜无能为力。新菜刚上架没有任何人收藏或下单不会被任何用户行为命中因此需要内容匹配来兜底。内容匹配的思想很简单将学生的 taste_tag偏好标签与菜品的 taste_label、nutrition_label、category 做集合匹配。集合相似度用 Jaccard 计算public ListDish recommendByContent(Student student, int topN) { SetString studentTags splitTags(student.getTasteTag()); ListDish dishes dishMapper.findOnSale(); return dishes.stream() .map(dish - { SetString dishTags new HashSet(); dishTags.addAll(splitTags(dish.getTasteLabel())); dishTags.addAll(splitTags(dish.getNutritionLabel())); dishTags.add(dish.getCategory()); double inter studentTags.stream().filter(dishTags::contains).count(); double union studentTags.size() dishTags.size() - inter; double score union 0 ? 0 : inter / union; return new AbstractMap.SimpleEntry(dish, score); }) .filter(entry - entry.getValue() 0) .sorted(Map.Entry.Dish, DoublecomparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }这段代码的瓶颈是每次调用都遍历全部在售菜品。菜品量在几百条时可以接受一旦超过一千条建议把标签匹配也改成离线任务结果写入 Redis 或缓存表。实际上在每天的定时任务里可以同时做矩阵重建和标签匹配计算两个结果分别存缓存推荐请求只做读取和融合这是这类轻量推荐系统常用的架构形态。3.3 融合策略与冷启动处理顺序推荐聚合服务是算法层与接口层之间的编排者。常见的融合方式是按比例混合三路结果策略适用对象推荐位配比ItemCF 协同过滤有行为记录的学生40%内容标签匹配填写过偏好标签的学生40%全局热门兜底无行为的新学生20%public ListRecommendedDish hybridRecommend(Long userId, int size) { ListRecommendedDish result new ArrayList(); ListRecommendedDish itemCFList itemCFService.recommend(userId, size); ListRecommendedDish contentList contentService.recommendByContent(userId, size); ListRecommendedDish hotList hotDishService.topN(size); result.addAll(itemCFList); result.addAll(contentList); result.addAll(hotList); return result.stream() .collect(Collectors.toMap( RecommendedDish::getDishId, Function.identity(), (x, y) - x, LinkedHashMap::new)) .values().stream().limit(size).collect(Collectors.toList()); }Collectors.toMap的 merge 函数写成(x, y) - x表示重复时保留先加入的高分项因为 result 列表按推荐优先级从高到低排列。这段代码同时处理了冷启动没有行为数据的学生itemCFList 和 contentList 均为空只剩下 hotList 兜底新菜品没有行为数据时itemCF 推荐不到它但 contentList 可以把它推给标签匹配的学生。冷启动的答案不是某个独立模块而是融合策略里天然包含的处理逻辑。4. 推荐服务接口层设计与联调参数配置算法计算完成后推荐能力需要暴露成接口。高校饮食推荐系统的接口设计比普通 CRUD 多一个约束——推荐接口需要告知调用方为什么推荐这个因为带解释的推荐比裸推荐更容易被用户接受。4.1 推荐接口的 RESTful 设计与出参格式推荐接口采用前后端分离模式返回 JSON。以查询每日推荐为例接口定义如下GET /api/recommend/daily?userId1001size10出参格式加入 strategy 和 reason 字段{ code: 0, data: { strategy: hybrid, dishes: [ { dishId: 12, dishName: 辣子鸡, price: 8.5, windowName: 一食堂二楼川菜窗口, reason: 你最近收藏过这道菜的做法类似品 }, { dishId: 88, dishName: 清蒸鱼, price: 12.0, windowName: 二食堂一楼粤菜窗口, reason: 和你有过下单行为的红烧鱼属于同类菜品 } ] } }reason字段建议由后端在算法层直接生成而不是前端根据策略拼接。理由文本不需要多复杂写明因为你的收藏行为或与你有过下单记录的某菜相似即可。实际项目中的数据表明带解释的推荐位比不带解释的推荐位点击率高出 20% 到 30%这在课程设计和答辩中也是最容易被评委问到的亮点。4.2 Redis 缓存与定时更新策略推荐接口的响应时间要求比 CRUD 高对用户来说首页加载超过 3 秒就会明显流失。推荐结果的实时计算成本高必须用缓存。标准做法是两级缓存推荐结果为维度存 Redis相似度矩阵存应用内存。在 application.yml 中配置 Redis 连接池参数spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2参数推荐值作用time-to-live6h控制推荐结果缓存生命周期max-active16Redis 连接池上限timeout3000ms避免缓存故障拖慢接口响应缓存 key 的设计方式推荐是recommend:{userId}:{yyyyMMdd}过期时间设为 6 小时。这样每天早中晚三个用餐时段的学生各自访问时缓存仍在有效期内不会频繁击穿到服务层。推荐服务里读取缓存的流程是先查 Redis命中直接返回未命中则在应用内存中查相似度矩阵做融合计算后写回 Redis 并设置过期时间。缓存穿透的情况也要处理当推荐结果为空时不能直接返回 null而要把空列表缓存起来并设置 5 分钟短过期避免空结果反复打到算法层。4.3 前后端联调时的三个高频排错点这类系统在前后端联调阶段遇到的问题集中在三处跨域、时间格式、空值行为。跨域处理用 Spring Boot 的 CorsFilterConfiguration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/api/**, config); return new CorsFilter(source); } }addAllowedOriginPattern(*)开发阶段允许任意来源。上线前要把通配符替换成前端域名白名单例如addAllowedOriginPattern(https://menu.example.edu.cn)否则任何站点都能跨域调用接口存在信息泄露风险。时间格式问题出在 Spring Boot 默认的 Jackson 序列化 LocalDateTime 会输出为 ISO 数组形式前端 new Date() 无法直接解析。在配置文件中明确指定格式即可spring: jackson: date-format: yyyy-MM-dd HH:mm:ss最后一个高频问题推荐列表为空时接口返回[]而不是null。前端用v-for遍历空数组可以直接显示空状态而遍历 null 会导致渲染报错。这个规范应该在接口文档中明确为强制约定后端即使拿到空列表也要输出dishes: []。5. 推荐效果评估、时间衰减与验证技巧推荐系统上线后第一件事是确认推荐位是否产生了价值。这不能靠主观感受需要埋点和指标来验证。5.1 三条核心指标评价推荐质量前端在推荐位曝光时上报一条浏览行为用户点击推荐卡片时上报收藏行为下单成功后上报下单行为。三条指标的计算口径如下指标口径说明点击率推荐位点击数 / 推荐位曝光数衡量推荐内容是否吸引人下单转化率推荐位带来的下单数 / 推荐位点击数衡量推荐内容是否真正匹配需求菜品覆盖率推荐列表中出现过的不重复菜品数 / 在售菜品总数衡量推荐是否集中在少数热门菜上点击率高但转化率低说明推荐位吸引人但实际不符合口味覆盖率长期低于 10%说明推荐策略在杀熟需要提高内容匹配的比例。5.2 给行为数据加时间衰减权重相似度矩阵在 30 天行为窗口内默认等权计算两周前的一次收藏与昨天的下单对相似度贡献相同。这对食堂档口轮换周期短的场景不友好。一个简单有效的时间衰减是在构建矩阵时按行为时间打折double daysAgo Duration.between(log.getCreateTime(), LocalDateTime.now()).toDays(); double weight Math.pow(0.9, Math.max(daysAgo, 0));把 weight 乘进计数里每过一天权重降 10%约 7 天前的行为权重降到一半30 天前约 0.04基本忽略。衰减因子也是可调参数学生口味变化慢用 0.95食堂菜品轮换快用 0.85。5.3 一道手动验证推荐接口的命令接口上线后不依赖前端也能直接验证curl -s http://localhost:8080/api/recommend/daily?userId1001size5 | jq .data.dishes[] | {id: .dishId, name: .dishName, reason}重点看两点reason 字段是否每个推荐位都有值推荐结果是否出现学生明确给过 1 星评分的菜品。后者要去 tb_behavior_log 里核对负反馈记录如果出现多次差评的菜品仍被反复推荐需要在融合策略里加一道硬过滤把该菜品从候选集合中剔除。本文还有配套的精品资源点击获取