花三周时间把美食推荐系统从零写到能跑技术栈就选最常见的 SpringBoot、Vue、MyBatis、MySQL 这一套。写业务接口其实不费劲真正难的是让“推荐”这个词扎扎实实落进代码里——不是随便查几条热门菜扔给用户而是让算法去理解“谁爱吃什么、什么和什么搭”。今天把这套系统的完整思路、表结构设计、推荐算法落地、前后端联调过程还有我踩过的那些坑一次性整理出来。无论你是准备拿它做毕业设计还是想学 Java 全栈实战这篇应该能帮你省下不少时间。1. 项目整体思路与技术选型分析1.1 为什么是 SpringBoot Vue 这套组合做管理系统类项目SpringBoot Vue 现在已经算事实上的标准答案了。SpringBoot 的好处在于你不用再像传统 SSM 那样手工配置一堆 XML内嵌 Tomcat、自动配置、起步依赖一个 main 方法直接把服务拉起来。美食信息推荐系统本质上属于信息管理 推荐逻辑的结合体后端需要处理用户、菜品、评分、收藏、浏览记录这些实体关系SpringBoot 在这一层的开发效率确实高。Vue 这边选它主要看中组件化开发和响应式数据绑定。美食推荐页要展示推荐列表、轮播图、评分组件、筛选栏如果用传统模板字符串去拼 DOM后期维护完全是灾难。Vue 的单文件组件把 HTML、CSS、JS 放在一起改一个推荐卡片的样式不用全局搜索代码。搭配 Vue Router 做页面路由、Vuex 或 Pinia 做状态管理前后端通过 JSON 交互开发体验非常顺。1.2 系统核心功能拆解抛开亮眼的推荐算法这个系统的基础功能其实是一个标准的内容管理平台。我把它拆成四个模块用户模块注册、登录、个人信息维护。登录我用 JWT 做无状态认证服务端不保存 Session前端每次请求带 token 即可。美食管理模块管理员对菜品做增删改查包括菜品名称、分类、价格、图片、口味标签、推荐指数等字段的维护。这个模块也是 MyBatis 动态 SQL 的重点使用场景。用户行为模块评分、收藏、浏览记录。推荐算法依赖的就是这些行为数据所以每一步操作都要落库。推荐模块根据用户的历史行为计算推荐列表同时支持热度榜、相似菜品推荐、个性化推荐三种输出形式。1.3 目录结构与项目分层后端我按标准的三层架构来组织Controller 层只做参数接收和结果返回Service 层放业务逻辑Mapper 层通过 MyBatis 与 MySQL 交互。实体类单独放在 entity 包里VO视图对象和 DTO数据传输对象分开避免把数据库字段直接暴露给前端。新手写代码最容易犯的毛病就是把业务逻辑写在 Controller 里刚开始觉得方便后面需求一改你就知道难受了。前端目录按 views、components、router、store、api 区分。views 放页面级组件components 放可复用的卡片、评分、分页等业务组件api 目录统一封装 axios 请求不要在页面里到处散落请求代码。2. 推荐算法落地的完整设计2.1 三种主流推荐方案的取舍推荐算法是这套系统的灵魂也是我花时间最多的地方。目前主流玩法有三种基于用户的协同过滤、基于物品的协同过滤、基于内容的推荐。我最初想把三种全做进去后来发现数据量不够硬上复杂模型效果反而不如简单方案所以最终采用了混合策略。基于用户的协同过滤核心逻辑找到与你口味最相似的一批用户把他们评分高而你没吃过的菜品推荐给你。生活化类比是——你身边有个朋友和你口味几乎一样他爱吃的川菜馆你大概率也喜欢。基于物品的协同过滤反过来先找到与你吃过的菜相似的菜品。基于内容的推荐则是根据菜品的分类、口味标签、价格区间来做属性匹配。这三种方案各有短板。基于用户的协同过滤在用户量少时矩阵极度稀疏算出来的相似度不靠谱基于内容的推荐容易导致推荐结果单一永远都在推荐同类型菜品。我的取舍是新用户没有行为数据走热度榜兜底老用户行为数据足够走协同过滤协同过滤结果不足时用内容推荐补齐。2.2 协同过滤的关键步骤与相似度计算基于用户的协同过滤实现分为四步构建用户评分矩阵横轴是用户纵轴是菜品。计算目标用户与其他用户的相似度。取出 TopN 相似用户。聚合这些用户的高分菜品过滤掉目标用户已吃过的生成推荐列表。相似度计算我用的是余弦相似度公式不复杂。两个用户分别对菜品的评分为向量 A 和向量 B先求两个向量的点积再除以各自模长的乘积。用 Java 实现时需要先把用户评分数据从 MySQL 查出来转成 Map 结构再逐对计算。为了控制计算量我在实际代码里做了两层优化第一只计算活跃用户两两之间的相似度太久没登录的不参与计算第二推荐列表生成后按加权得分排序加权得分等于相似度乘评分把相似度最高的用户的评分权重放大这样推荐结果更贴合用户真实偏好。2.3 冷启动问题的工程化解法冷启动是推荐系统绕不开的话题新用户没有行为记录新菜品没有评分数据算法直接失效。我的处理方式是分层兜底新用户第一次进入页面推荐接口直接返回热度榜。热度分数我在 SQL 里直接计算公式是评分平均分的 0.4 权重、收藏数的 0.3 权重、浏览数的 0.3 权重之和。这样不用单独维护热度字段也方便调整权重。新菜品上线后先进入“新品尝鲜”分区用内容推荐方式把它推荐给品类偏好匹配的用户比如一款新出的川菜就推给经常浏览川菜的用户。用户行为累积超过 5 条后系统自动切换到协同过滤推荐同时保留一个“可能喜欢”的补充位用基于内容的推荐填补协同过滤推荐不足的位置。这三层结构配合能保证任何新用户打开页面都有内容可看。3. 数据库设计与 MyBatis 整合细节3.1 五张核心表的字段设计数据库设计我踩过不少坑一开始图省事把评分直接挂在订单上结果发现无法做协同过滤后面重新设计了表结构。最终核心表有五个用户表 userid、username、passwordBCrypt 加密存储、nickname、avatar、created_time。菜品表 foodid、name、category、price、taste_tags用逗号分隔的口味标签如“麻辣,川菜,下饭”、description、img_url、location、status。评分表 ratingid、user_id、food_id、score1 到 5、created_time。这里加了一个唯一索引约束同一个用户对同一道菜只能有一条评分记录用数据库层面防止重复评分。收藏表 favoriteid、user_id、food_id、created_time。同样加了 user_id food_id 唯一索引。行为记录表 behavior_logid、user_id、food_id、behavior_typeview/favorite/rating、behavior_value比如评分分值、created_time。这里有一个设计心得行为记录表是推荐系统的数据底座用户每次点击菜品详情都要插入一条浏览记录。前端在菜品卡片上绑定点击事件进入详情页时调用后端的记录接口接口内部异步写入行为表不阻塞页面跳转。3.2 MyBatis 动态 SQL 与自定义 TypeHandlerMyBatis 是这个系统的持久层核心面向推荐系统的查询往往条件组合多变动态 SQL 是刚需。菜品的多条件筛选就是一个典型场景用户点击分类标签、价格区间、口味筛选查询条件不固定。在 Mapper XML 里用where标签加if判断前端传什么参数就拼什么条件不需要为每种组合写单独的 SQL。这样一个方法通吃列表页、搜索页、管理后台的联动查询。口味标签 taste_tags 我存的是逗号分隔的字符串前端展示容易但要在 SQL 里做模糊匹配时效率不高。我自定义了一个 TypeHandler在 Java 实体类里用 List 接口味标签插入时自动转成逗号分隔字符串查询时自动拆回 List。这个设计让 Service 层代码比手动 split 干净很多也避免了写一堆重复的字符串处理逻辑。3.3 缓存与批量插入的性能细节推荐系统有一个天生的性能矛盾数据量越大计算越慢但用户等待推荐的耐心很有限。我在两个层面做了处理。第一层是 MyBatis 的二级缓存。热门菜品列表、推荐结果这类读多写少的数据开启二级缓存后同一条 SQL 在缓存有效期内不再重复查数据库。这里要特别注意如果你的项目有多个命名空间缓存无法跨 Mapper 同步菜品数据变更后可能读到脏数据。我的解决办法是给涉及菜品修改的操作手动调用clearCache()确保更新后缓存立刻失效。第二层是批量插入。用户浏览行为是高频操作一条条 insert 显然不行。MyBatis 的foreach标签支持在一条 insert 语句里拼多条记录我实测过批量插入 100 条比逐条插入性能提升约 10 倍。控制好单次批量的大小我通常限制在 500 条以内避免 SQL 语句过长超过 MySQL 的 max_allowed_packet 限制。4. SpringBoot 后端核心实现与接口设计4.1 统一返回结构、异常处理与分页约定后端接口设计如果一开始不定规范前后端联调就是灾难。我在项目里定义了一个统一的 Result 类结构固定为code状态码、message提示信息、data业务数据。成功是 200参数错误是 400未登录是 401服务器异常是 500。前端拿到响应后先判断 code 再做后续处理不用每次都尝试解析 data 结构。异常处理我没有在每个 Controller 里写 try-catch而是用RestControllerAdvice统一拦截。业务异常、参数校验异常、未知异常各自对应一个处理方法返回规范化的错误 JSON。比如用户重复收藏时抛一个自定义的业务异常前端拿到 400 和 message直接弹出提示框即可。分页查询统一使用 PageHelperService 层调用方法时直接传 pageNum 和 pageSize查询结果自动封装了 total、pages 等分页数据。前端表格组件只需要把数据填入即可。4.2 JWT 认证与拦截器配置用户登录成功后后端生成一个 JWT token 返回给前端前端存储在 localStorage 中之后每个请求在请求头里带上Authorization: Bearer token。我用拦截器统一处理 token 校验自定义一个 JwtInterceptor实现HandlerInterceptor接口在preHandle方法里从请求头解析 token。校验通过就把用户 ID 放到 request 属性里后续 Controller 直接取。校验失败返回 401。需要放行的接口直接加到拦截器的排除列表里比如登录、注册、热门推荐列表。这里有一个安全细节密码不能明文存库。用户注册时用 Spring Security Crypto 模块的 BCryptPasswordEncoder 加密登录时只比对加密后的哈希值。即使数据库泄漏也不会直接暴露用户明文密码。4.3 推荐接口的完整调用链推荐接口是系统的高光模块我设计了一个/api/recommend/list接口内部根据用户状态走不同分支。流程如下获取请求头中的用户 ID由拦截器注入。查询该用户的行为记录条数。行为少于 5 条走热度榜推荐SQL 里按热度公式排序直接返回。行为充足加载用户的评分矩阵计算相似用户聚合推荐结果。协同过滤结果不足 10 条进入内容推荐模块补齐。将推荐结果按分数降序返回给前端展示。实际开发中我发现一个问题如果每次请求都实时计算协同过滤接口响应时间会飙升。我的优化方案是把推荐结果缓存 30 分钟用户下次打开页面直接读缓存只有缓存过期或行为数据发生变化时才会触发重新计算。5. Vue 前端开发与前后端联调实战5.1 脚手架搭建与路由设计前端我用 Vue CLI 创建项目选择 Vue 2 版本配合 Vue Router 3 和 Vuex 3这套组合稳定性高网上能查到的资料也最多。如果你用 Vue 3对应路由是 Vue Router 4、Pinia语法差异不大但要注意生态组件的版本匹配。路由设计上我做了动态路由和权限跳转。普通用户能访问首页、推荐、美食详情、个人中心管理员能额外访问后台管理页面。前端在路由守卫里判断 localStorage 中的用户角色字段没有权限的直接重定向到首页。菜单栏数据也在路由配置里统一管理添加新页面只需要在路由表中加一条记录不需要改多个地方的菜单配置。动态路由这块我特别提一句不要一上来就追求动态权限路由实际项目里 90% 的场景用静态路由加守卫判断就够了。动态加路由容易出刷新页面后路由丢失的问题处理起来很烦但适合管理员菜单动态展示的场景。5.2 axios 封装与 API 管理前端请求我用 axios 统一封装。核心思路是先创建一个实例设置 baseURL 和超时时间然后在请求拦截器里从 localStorage 取 token 并添加到请求头。响应拦截器统一处理业务错误code 不是 200 就弹出错误提示401 则清空本地登录信息并跳转登录页。这样业务代码里不用每一个接口都写错误处理逻辑。API 层单独建一个 api 目录每个模块一个文件。例如api/food.js封装菜品相关的 getAllFoods、getFoodDetail、addFood、updateFood 方法。组件中通过import { getRecommendList } from /api/recommend调用。好处不言而喻接口地址集中在单独文件里后端改了地址只改一处。跨域问题我这边直接在后端加了 CORS 配置允许前端开发服务器的域名请求访问。生产环境用 Nginx 做反向代理把/api路径转发到后端服务同时解决跨域和静态资源托管问题。5.3 推荐页与菜品详情的组件化实现推荐页是整个前端最重要的页面。我把页面拆成三块顶部轮播图展示本周热门菜品、中部个性化推荐列表、底部“可能喜欢”列表。三块数据分别调对应接口各自维护 loading 状态。推荐卡片组件 FoodCard 接收一个 food 对象作为 props展示图片、名称、价格、评分星级和收藏按钮。星级评分我封装成单独的 StarRating 组件支持半星显示。收藏按钮点击后调用收藏接口已收藏的菜品显示实心图标再次点击取消收藏。这个交互状态要在收藏接口返回成功后在前端同步切换体验比较流畅。美食详情页展示完整信息、用户评分表单、相似菜品推荐。评分表单用滑块组件选择 1 到 5 分提交成功后刷新页面中的平均评分和推荐列表。详情页里同时埋了浏览记录上报逻辑用户进入页面即调用行为上报接口。6. 实战中踩过的坑与排查经验6.1 环境配置与版本兼容问题这套系统依赖的组件版本比较多环境坑也是新手最容易卡住的地方。先列一个我用下来稳定的版本组合SpringBoot 2.7.18、MyBatis Spring Boot Starter 2.3.1、MySQL 8.0.36、JDK 1.8、Node 16、Vue CLI 5.0.8。JDK 17 对应 SpringBoot 3.x 可以用但 MyBatis 相关依赖要换新版配置写法也有变化对新手不友好。MySQL 8.0 的时区问题必须处理。JDBC URL 里不加serverTimezoneAsia/Shanghai连上数据库后所有时间字段都会偏差 8 小时。另一个常见问题是 MySQL 8.0 的驱动类名从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.Driver老教程里抄来的配置直接用会报找不到类。Maven 依赖冲突也遇到过。举例SpringBoot 自带了一个旧版 Jackson如果你手动引入另一个版本的 Jackson运行时可能出现奇怪的反序列化错误。排查方法很简单执行mvn dependency:tree看依赖树把冲突的排除掉即可。6.2 数据层面的隐蔽问题MyBatis 的默认缓存只在同一个 SqlSession 内生效但很多人配置完一级缓存发现不生效其实是 Spring 每次请求都新建了 SqlSession。二级缓存配好后又遇到另一类问题菜品的修改发生在另一个 Mapper 中而查询缓存属于菜品 Mapper修改后查不到最新数据。我的解决办法是修改操作成功后手动调用sqlSession.clearCache()简单有效。另一个隐蔽问题来自 MyBatis 的驼峰映射。MySQL 字段用下划线命名如user_nameJava 属性是驼峰命名如userName如果不开启map-underscore-to-camel-case: true查询结果里 userName 永远为 null。这个问题排查起来很费时间因为不报错只是数据取不到。6.3 推荐效果不理想时的调试思路很多人把推荐系统做完后发现推出来的东西完全没有逻辑比如给喜欢吃辣的用户推荐甜点。排查思路分三步第一步检查行为数据是否累积足够数据量小于几十条时协同过滤基本不可用第二步检查评分矩阵构建是否正确打印出矩阵看看里面的值是否符合预期第三步检查相似度计算是否被零向量干扰比如两个用户各自只评分了一道菜算出来的余弦相似度可能为 0。后来我加了一个调试用的辅助接口直接返回推荐算法的中间结果包括相似用户列表、相似度分数、候选菜品及得分。前端做一个隐藏调试面板方便我随时查看算法状态这个工具对调参帮助非常大。6.4 前后端联调时的常见磕绊前端在本地开发时后端接口地址填localhost:8080后端没配跨域浏览器就报 CORS 错误。我建议后端直接写一个全局 CORS 配置类放行所有来源、所有方法、所有请求头。上线后通过 Nginx 同源访问这个配置也不会带来安全问题。另一个常见问题是接口返回的字段名对不上。后端实体类里是foodName前端写成name前端展示永远是空。我用的方法是后端统一返回 VO 对象而不是实体类VO 字段名和前端页面需求一一对应前后端约定好 JSON 字段命名规则联调时这类问题基本消灭。写在最后的一些体会这个项目做下来我对推荐系统的理解比看一百篇论文都深。算法只是一部分更多的是数据和工程问题数据怎么收集、怎么存储、怎么保证实时性、怎么兜底冷启动、怎么排查推荐效果差。如果你也是拿这个项目练手我建议你别急着改算法先把行为数据埋点做好、表结构设计合理、接口边界清晰推荐效果自然慢慢变好。最后分享一个我留的小扩展点现在这个系统的推荐是异步计算后缓存的后续我可以引入定时任务每天凌晨重新计算一次全量用户推荐列表并写入推荐结果表。这样用户白天访问时直接查表返回连 30 分钟缓存都不用等响应速度会更快。像这种“预计算 缓存 兜底”的思路在很多真实推荐系统里都是标准做法值得你深入玩一玩。
阅读完成 · 觉得有帮助?