ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3+MyBatis美食推荐系统实战:从数据设计到部署

SpringBoot+Vue3+MyBatis美食推荐系统实战:从数据设计到部署 开门见山说个事。如果你是带着“想找个能跑通的前后端分离练手项目”的目的搜到这篇文章那么这个基于 Java SpringBoot Vue3 MyBatis 的美食信息推荐系统确实是个很合适的样板。它覆盖了 Web 开发中最典型的链路Vue3 负责页面交互SpringBoot 提供接口服务MyBatis 操作 MySQL 存储数据再加上一个简单的基于用户行为的推荐逻辑。你可以直接把它当成一个“最小可用的完整系统”去研究也可以在此基础上扩展成真正能上线的产品。这系统不是那种只有增删改查的玩具项目。它把后端接口、前端页面、数据库表结构、推荐策略全部串起来了尤其适合正在准备 Java 开发岗面试、或者刚学完 SpringBoot 和 Vue3 想找个综合项目练手的朋友。我从实际开发的角度把整个系统从设计到落地的关键细节拆开讲一遍包括数据库表怎么设计、MyBatis 哪里容易踩坑、Vue3 联调要注意什么、推荐逻辑怎么做才不“弱智”最后再附上一份我亲身经历的问题排查记录。1. 项目整体设计思路与选型考量1.1 为什么选择前后端分离架构很多人做毕设或者练手项目习惯用 Thymeleaf 这种服务端渲染方案后端返回 HTML 页面一套代码解决所有问题。选前后端分离不是因为“流行”而是因为这套结构更贴近企业开发真实形态也更容易把问题边界切开。在这个美食推荐系统里前端是 Vue3 单页应用跑在 Vite 开发服务器上通过 axios 调用后端接口后端是 SpringBoot 应用只负责处理业务逻辑和数据存取返回 JSON。这样做的直接好处是前端开发和后端开发可以完全并行——我在写推荐算法接口的时候前端同事可以同时做页面。另一个好处是接口可以复用同一个 /api/food/list 接口网页端调用将来如果要做小程序端照样可以调用。坏处也明显部署复杂度上升了、跨域问题必须解决、接口联调阶段容易来回扯皮。这些问题我在第 3 章会具体讲。1.2 技术栈选型的理由技术栈选型这块我的原则是“稳定、熟悉、够用”不追新但也别用太老的东西。SpringBoot我用的是 2.7.x 版本。不是 3.x 不能用而是 2.7 生态最成熟网上能搜到的资料最多出了报错也更容易找到解决方案。SpringBoot 3.x 有个坑是 javax 包名改成了 jakarta很多老教程直接跑不通对刚上手的人来说非常不友好。Vue3组合式 API script setup语法。相比 Vue2 的选项式 API组合式的逻辑复用确实方便写起来也更紧凑。MyBatis不用 MyBatis-Plus。虽然 Plus 的 BaseMapper 确实省事但用了它以后很多人连 SQL 都不会写了。我的建议是初学者至少先手写一遍 XML 映射文件搞清楚 #{} 和 ${} 的区别再考虑偷懒。MySQL8.0 版本。8.0 是当前绝对主流安装和配置网上教程特别多坑也比 5.7 少。我自己用的是 macOS 上的 Homebrew 安装方式后面会提到一个排序规则导致的报错。选这套组合本质上是因为它足够“典型”。典型意味着你踩过的坑别人也踩过遇到问题很容易找到答案这就是学生项目和企业小型项目的最大公约数。2. 数据库设计与后端核心实现2.1 美食推荐系统的表结构设计数据库设计我花了比较多心思因为一个推荐系统的核心不在代码而在数据组织方式。如果表设计不合理后面的推荐逻辑怎么写都别扭。我最终的 MySQL 库表结构如下user 用户表id、用户名、密码BCrypt 加密存储、头像、偏好口味字段偏好字段很重要这是推荐算法的输入之一。category 美食分类表id、分类名川菜、粤菜、日料、甜品等、排序号、是否启用。food 美食信息表id、菜名、分类 id、食材描述、做法步骤TEXT 类型存长文本、封面图 URL、价格、评分、是否推荐。user_favorite 收藏表id、用户 id、美食 id、收藏时间。联合唯一索引 (user_id, food_id) 防止重复收藏。user_like 点赞表id、用户 id、美食 id、点赞时间。同样加联合唯一索引。browse_history 浏览记录表id、用户 id、美食 id、浏览时间。这张表是推荐算法的重要数据来源。recommend_log 推荐日志表可选id、用户 id、推荐结果 JSON、生成时间。用来做推荐效果的离线评估。外键我全部没有物理创建只保留逻辑关联。原因是在实际项目中物理外键对数据迁移、删改操作的灵活性影响很大很多公司规范里也明确禁止使用物理外键。数据一致性靠业务代码保证。表设计时的几个关键细节美食表中的“做法步骤”用 TEXT 类型但前端展示时要做分段处理我是在后端把文本按换行符拆成数组再返回给前端渲染。价格字段用 DECIMAL(10,2)不要用 DOUBLE。DOUBLE 在数据库里是近似值做金额计算时会出现 0.1 0.2 ! 0.3 的尴尬情况。所有表都加了 create_time 和 update_time 两个时间戳字段用 MyBatis 的自动填充或者直接在插入时手动写死。2.2 推荐逻辑的实现思路做推荐系统最怕一上来就谈协同过滤、深度学习。真实项目里最简单的就是最好的。我实现的是一个“基于内容 简单协同过滤”的混合推荐策略核心思路是基于用户画像的冷启动推荐新用户没有行为数据时根据注册时选择的偏好口味推荐对应分类下的高评分美食。基于分类偏好的推荐统计用户收藏和点赞最多的美食分类然后推荐该分类下用户没看过的、评分排名靠前的菜品。基于相似用户的推荐简单版协同过滤找到收藏行为相似的其他用户把他们收藏过但当前用户没看过的美食推荐出来。实现上我写了一个RecommendService核心流程是public ListFood recommendForUser(Long userId, int limit) { ListFood result new ArrayList(); // 1. 获取用户偏好分类 ListLong favCategories userBehaviorMapper.findTopCategoriesByUserId(userId); // 2. 基于分类偏好推荐 if (!favCategories.isEmpty()) { ListFood foods foodMapper.findFoodsByCategories(favCategories, userId, limit); result.addAll(foods); } // 3. 基于相似用户推荐 ListLong similarUserIds userBehaviorMapper.findSimilarUserIds(userId); if (!similarUserIds.isEmpty()) { ListFood similarFoods foodMapper.findFoodsFavoredByUsers(similarUserIds, userId, limit); result.addAll(similarFoods); } // 4. 少量随机打散避免推荐结果太固定 if (result.size() limit) { ListFood randomFoods foodMapper.findRandomFoodsByLimit(limit - result.size()); result.addAll(randomFoods); } return deduplicate(result).stream().limit(limit).collect(Collectors.toList()); }这里的 SQL 写法很关键比如“查出用户没看过的美食”我用的是NOT IN (SELECT food_id FROM browse_history WHERE user_id ?)确保推荐出来的东西不是用户已经浏览过的。推荐接口的实现并不复杂但要注意性能问题。如果数据量大这种多表查询会越来越慢到时候就需要加 Redis 缓存了。我在第 5 章会展开讲优化方案。2.3 手写 MyBatis XML 映射时的关键细节MyBatis 是这套系统里最容易被低估的环节。我只推荐用 XML 方式写 SQL理由很简单复杂的多表关联 SQL 写注解里又丑又难维护改 SQL 还要重新编译。我踩过最印象深刻的坑是#{}和${}的区别。#{}是预编译占位符MyBatis 会把它替换成?然后设置参数可以防止 SQL 注入${}是纯字符串拼接直接替换进 SQL 里存在注入风险。我在写动态排序时用了${}因为排序字段不能作为预编译参数结果被安全扫描工具标出来了。另一个高频坑是返回映射。MyBatis 查询结果到实体类的映射有两种方式resultType自动映射和resultMap手动映射。自动映射要求数据库字段名和实体属性名满足驼峰转换否则查出来的永远是 null。我开发时保险做法是开启下划线转驼峰配置mybatis: configuration: map-underscore-to-camel-case: true但如果明细表字段特别多我还是建议写 resultMap一个是避免歧义另一个是在多表联查时可以直接做嵌套映射一步到位。第 2 章里我重点讲了表结构、推荐逻辑和 MyBatis 用法接下来就要进入前端部分了。Vue3 这边也有不少值得展开的细节特别是如果你是从 Vue2 切过来的话。3. 前端 Vue3 开发与联调要点3.1 用 Vite 搭建前端工程前端项目我用 Vite 搭建没用 vue-cli。Vite 基于 ESModule冷启动速度比 Webpack 快好几个数量级开发体验是质变。安装命令很简单npm create vitelatest food-frontend -- --template vueVue3 项目创建后第一件事是装路由和状态管理库。路由用官方 Vue Router 4状态管理我用的 Pinia不是 Vuex因为 Pinia 的 API 更简洁对 TypeScript 支持也更好。项目结构大致是src/ api/ # axios 请求封装 assets/ # 静态资源 components/ # 公共组件 router/ # 路由配置 stores/ # Pinia 状态 views/ # 页面组件关于script setup语法我写 Vue2 习惯是 export default {} 中写 data、methods、computed现在script setup直接在顶层声明变量和函数模板直接用。这种写法的好处是代码更扁平逻辑也更容易抽离成自定义 hook。我抽了一个useFoodList的自定义函数把获取美食列表、分页参数、加载状态统一封装组件只要调用useFoodList()拿到数据和操作函数就行。Vue3 开发中最要留意的是响应式。用reactive包裹对象、用ref包裹基本类型。一个经典错误是直接解构 reactive 对象后解构出来的变量丢失了响应性。所以我在组件里统一用storeToRefs来解构 Pinia 的 state。3.2 前后端接口联调与跨域问题前后端分离开发联调是绕不开的一关。我遇到的第一个问题是跨域。我的后端跑在http://localhost:8080前端 Vite 开发服务器跑在http://localhost:5173两个端口不同浏览器默认会拦截跨域请求。解决方案是后端加一个全局 CORS 配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }注意allowCredentials(true)的情况下allowedOrigins不能写*必须明确指定来源。这是我调了快一小时才解决的问题。除了跨域前后端接口规范也要提前统一。我定了一条规则所有接口返回统一结构体。{ code: 200, message: success, data: { ... } }后端用ResultT泛型类封装前端 axios 响应拦截器统一处理code ! 200的场景不把错误处理散落在每个页面。3.3 美食展示页与推荐页的实现系统的核心页面有三个首页随机推荐、分类浏览页、美食详情页。首页进来时调用推荐接口/api/recommend/{userId}拿到推荐列表。列表卡片包含封面图、菜名、分类标签、评分。这里有一个体验优化的细节图片懒加载。我用了 Vue3 内置的懒加载指令方式或者用第三方库vue-lazyload图片进入视口再加载否则多图列表会明显卡顿。详情页展示的内容包括食材清单、做法步骤、评分、是否收藏/点赞。做法步骤是数组前端用 v-for 循环渲染成步骤列表。收藏和点赞都调独立接口成功后更新 UI 状态。前端开发中我遇到的最大坑其实很基础列表渲染 key 不能用 index 做唯一标识。比如删除列表中间某个元素后index 会变化Vue 复用节点导致状态错乱。我一开始用id index后来直接改成用唯一 id。接口联调阶段推荐实用习惯是在前端 mock 数据先不依赖后端接口把页面结构和交互都做好等后端接口写好之后只改 axios 请求 url 就行。我在src/api目录下建了一个mock.js专门准备假数据这样两边真的可以完全并行。4. 常见问题与排查技巧实录这一节我纯粹记录自己在这个项目中实际踩过的坑。有些问题看起来简单但排查过程能占一下午。4.1 MySQL 安装与连接期的典型问题我安装 MySQL 8.0 后遇到的第一个报错是java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed原因是连接 URL 里没有加allowPublicKeyRetrievaltrue。MySQL 8.0 默认使用 caching_sha2_password 插件客户端首次连接时需要从服务器获取公钥Java 侧必须显式允许。解决方案是在 JDBC URL 里加三个参数jdbc:mysql://localhost:3306/food_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse第二个坑是时区问题。早期 MySQL 驱动版本遇到serverTimezone不设置会报 CST 相关错误。以上 URL 直接写死Asia/Shanghai是最稳妥的。建议先把时区、编码、SSL、公钥获取这四个参数都配齐能省掉后面 80% 的连接问题。第三个问题是 MySQL 8.0 的默认排序规则。我在开发时发现某种情况下中文排序非常奇怪查了半天才发现是数据库默认排序规则为utf8mb4_0900_ai_ci和老项目的utf8mb4_general_ci有差异。如果中文排序不是你关注的重点先用默认的也没问题但如果要做中文名称排序需要建表时指定utf8mb4_general_ci。4.2 MyBatis 使用中的参数传递问题MyBatis 最常见的报错是org.apache.ibatis.binding.BindingException: Parameter xxx not found. Available parameters are [arg0, arg1, param1, param2]这个错误出现在 Mapper 接口方法有多个参数但没有用Param注解时。MyBatis 默认只能识别param1、param2这种方式或者 arg0、arg1。解决方案简单直接多参数时一律用 Param 注解ListFood findFoodsByCategories(Param(categoryIds) ListLong categoryIds, Param(userId) Long userId, Param(limit) int limit);另一个我踩过的坑是IN查询时传 List 参数XML 里要写成foreach循环。这里特别要留意如果 List 为空不要执行这条 SQL否则会出现IN ()语法错误。我在代码里提前判断了空集合的情况。MyBatis 的缓存问题也值得一提。一级缓存是 SqlSession 级别的默认开启二级缓存需要配置默认关闭。我在列表接口加上二级缓存后发现修改美食信息后缓存没有及时失效导致页面上看到旧数据。后来我取消了对写操作频繁的表使用缓存只在推荐结果这种“读了不常改”的场景下使用避免一致性问题。个人建议新手一开始先不要碰二级缓存等你能准确说出什么时候缓存会失效再开。4.3 Vue3 前端的兼容与构建坑Vue3 Vite 的坑主要集中在依赖版本上。一个很坑的事情是Vite 安装时如果 Node 版本太旧低于 14.18会直接报错。开发前先node -v检查版本否则后续所有操作都会被阻塞。另一个是axios 响应拦截器里拿不到 data 字段。我在拦截器里只 returnresponse结果每个页面拿到的都是 axios 的完整响应体里面包含 status、headers、data 等等必须先从中取出data.data才是业务数据。所以我在响应拦截器里统一做了处理让页面直接拿到后端返回的 Result 对象。Vue3 项目中还有一个老生常谈的问题刷新 vs 路由跳转。如果你在地址栏手动刷新某个二级页面路由如果用了 history 模式需要后端配置 fallback 到 index.html否则会 404。这个问题在打包部署后特别常见。我在 SpringBoot 的静态资源配置里做了处理确保非 /api 路径都重定向到 index.html。5. 推荐策略背后的“为什么”与后续扩展5.1 从“能跑”到“好用”的推荐细节很多类似项目的推荐接口就是查一个“评分最高”列表完全不管用户看过什么。我刚上一个版本时也这样但测试用下来体验很差——每次刷新看到的都一样而且刚看过的菜还会继续出现。后来我加了两层处理去重逻辑当前用户收藏过、浏览过的美食不出现在推荐列表里。这个用 SQL 的 NOT IN 就能做但要注意 NOT IN 中如果子查询包含 NULL整个查询结果会为空。这是 SQL 里的经典陷阱我在写 SQL 时加了IS NOT NULL条件。随机因子推荐列表末尾加几条随机菜避免推荐结果永远是一个固定序列也增加了用户发现新菜的概率。推荐接口还必须考虑冷启动问题。最开始没有用户行为数据的用户推荐接口如果直接查收藏、点赞统计结果必然为空。我针对冷启动用户实现了“按注册时选择的偏好口味推荐”如果没选过兜底推荐全站评分 TOP20。5.2 性能扩展与面试题式提问如果这个系统的数据量变大推荐接口做全表扫描会越来越慢。我的扩展方案是分两条线走Redis 缓存把热门美食列表、用户推荐结果缓存到 Redis设置合理的过期时间比如 30 分钟避免每次请求都穿透到数据库。缓存更新的策略建议先更新数据库再删除缓存而不是先删缓存再更新数据库后者容易出现缓存击穿问题。异步计算推荐结果不要求在用户请求时实时计算可以用定时任务在凌晨批量给每个用户生成推荐列表存到 recommend_log 表用户访问时直接读取。这些点正好都是 Java 面试时常见的高频考点。面试官问“你的项目里哪里用了缓存”你就可以说推荐列表问“缓存和数据一致性怎么保证”你就引到先更新数据库再删缓存这条策略上。同理MyBatis 的一二级缓存原理、Vue3 的响应式原理、MySQL 的索引优化都是这个项目里能跟面试官聊得很深的话题。我个人觉得做这类系统最忌讳的就是背答案式地把技术栈堆上去却不理解每个技术选型为了解决什么问题。比如你选了 Vue3能说出组合式 API 的复用优势吗选了 MyBatis能说出#{}和${}注入区别吗选了 MySQL能说出为什么 join 查询要做索引优化吗这些才是项目经验里真正被看中的部分。5.3 部署时容易忽略的配置细节部署前后端分离项目我建议用 Nginx 做前置服务器把静态资源和后端接口分开代理。但如果你图省事只想用一个 SpringBoot jar 包跑起来也有简单方案把 Vue3 打包后的dist文件放到 SpringBoot 的src/main/resources/static目录下。这一步要注意Vue3 打包时需要把base路径设置为相对路径。在vite.config.js中全局配置路径修改base为./。否则打包后 JS 和 CSS 引用路径是/assets/xxx.js部署到子目录会 404。这个细节很多人不知道发布的页面白屏第一个排查点就是这里。单 jar 部署的另一个问题是路由 history 模式 404。SpringBoot 默认不认识 Vue Router 的路径规则访问/food/123这种地址会交给后端处理结果 404。我的解决方案是配置 WebMvc 的 view controller让这些路径全部 forward 到 index.htmlConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{spring:\\w}).setViewName(forward:/index.html); registry.addViewController(/**/{spring:\\w}).setViewName(forward:/index.html); } }实际生产我还是更推荐 Nginx因为可以开启 gzip、缓存静态资源、做反向代理这些 SpringBoot 做不到那么精细。写在最后的个人心得这个美食信息推荐系统虽然不算大但完整走下来从数据库建模到推荐逻辑从 MyBatis 映射到 Vue3 组合式 API从开发调试到打包部署几乎把 Web 全栈开发的所有关键环节都过了一遍。我在实际开发中最深的体会是项目能不能“跑起来”其实是最低标准真正拉开差距的是那些写在细节里的东西——接口怎么封装、异常怎么处理、缓存怎么用、SQL 怎么写才不走全表扫描。另外再分享一个很实在的建议如果你打算把这类项目写进简历一定要能亲自讲清楚推荐接口的完整调用链。面试官随便问一句“用户打开首页后前端发了什么请求后端做了什么操作经过了哪些表最终怎么返回数据”如果答得磕磕绊绊那项目经验就会被大打折扣。这套系统正好是一个能让你把这条链路彻底讲明白的载体。
返回列表