SpringBoot+Vue3+MyBatis实战:美食推荐系统源码全解析

发布时间:2026/10/2 20:12:51
SpringBoot+Vue3+MyBatis实战:美食推荐系统源码全解析 最近帮人 review 了一套美食推荐系统的源码技术栈正好是 Java 生态里最常被问到的组合SpringBoot 做后端Vue3 写前端MyBatis 负责数据库访问MySQL 作为存储整体走前后端分离的结构。我之所以愿意花两天时间把它从头到尾捋一遍是因为这套系统覆盖了太多“面试和实战都绕不开”的点JWT 登录鉴权、统一的接口返回、分页查询、动态 SQL、前端 axios 封装、跨域处理、推荐逻辑落地。无论你是想拿一套完整的项目源码练手还是打算改造后作为课程设计、毕业设计甚至作为公司内部点餐系统的基础工程这套系统都能给你一个非常扎实的起点。这篇文章我会按实际开发顺序来拆解先说清楚这套系统的整体设计和模块划分再讲数据库表怎么建模之后分别进入后端和前端的具体实现接着单独把“推荐到底怎么算出来的”这个最容易被问倒的问题展开讲最后整理一份我在复刻和运行过程中踩过的坑。内容偏向实操代码片段我会尽量给完整你拿到之后照着一路敲下来基本就能跑通整条链路。1. 项目整体拆解这套美食推荐系统到底做了什么1.1 核心业务链路与模块划分先说业务场景。一个美食推荐系统左手是海量的菜品数据右手是用户的行为数据中间是那个把你可能爱吃的菜挑出来的推荐逻辑。整个系统拆开来看无非这么几个模块。用户模块负责注册和登录。登录这块我没有用简单的明文账号密码而是采用了 JWTJSON Web Token方案后端签发 token前端把 token 存在 localStorage 里请求时塞进请求头。菜品模块负责维护美食的基本信息菜名、所属分类、图片、价格、描述、推荐次数等。分类模块负责给美食打标签比如川菜、粤菜、日料、甜品这是后边推荐算法的重要依据。评分和收藏模块记录用户对菜品的真实反馈这两张表是整个推荐系统最核心的数据资产——没有用户行为数据推荐就是空中楼阁。推荐模块是这套系统最有含金量的地方也是面试时最容易被人追问的部分。我在系统里实现了三层推荐策略冷启动阶段用户没有任何行为数据就按热度和分类兜底推荐有了评分行为之后系统根据用户高分评价的食物分类来猜用户口味做基于分类兴趣的推荐数据量再大一点可以升级到简化版的协同过滤通过“和你品味相似的人喜欢的食物”来做推断。1.2 为什么选 SpringBoot Vue3 MyBatis MySQL 这套组合技术选型往往是项目里最重要的决策比写代码更早发生也比写代码更容易被问“为什么”。我帮你分析一下这套组合里每个角色的定位。SpringBoot 负责后端接口和业务逻辑。它把 Spring 繁琐的 XML 配置收进了自动配置里内嵌 Tomcat一键启动无论是写增删改查还是对接各种中间件开发效率都很高。而且它的生态极其丰富需要做权限可以接 Spring Security需要做缓存可以接 Redis需要做对象存储可以接 MinIO对课设、毕设和中小型项目来说非常合适。Vue3 负责前端展示和交互。相比 Vue2Vue3 的组合式 API 在逻辑复用上确实舒服很多一个页面里的搜索、列表加载、状态管理代码可以抽成自定义 hook多个组件共享同一套逻辑时不用再靠 mixin 那套容易命名冲突的老玩法。配合 Vite启动速度肉眼可见地快。MyBatis 负责数据库访问。很多人纠结为什么不用 MyBatis-Plus 或者 Spring Data JPA原因在于 MyBatis 让 SQL 完全掌握在你自己手里动态 SQL 的强大能力在复杂查询场景里非常实用。对学习 SQL 和数据库调优的人来说直接写 SQL 反而更能理解底层发生了什么。XML 文件里一段ifforeach动态拼接查询条件比 JPA 那种自动生成的 SQL 更可控排查问题也更直观。MySQL 则承担所有结构化数据的持久化。它稳定、成熟、免费无论是本机开发还是部署到云服务器资料都是最多的。这套选型基本就是国内中小型 Web 项目最经典的组合面试官看到不会意外简历上写出来也不会虚。我在实际拆解这个项目的过程中还对比过另外两个方案反 Redis 做缓存反 Security 做权限。如果你是课设和毕设导向我建议先不加等基础跑通之后再作为亮点扩展如果你是想拿到公司里去用那 Redis 缓存热菜数据和 Security 权限加强是必须补上的。技术方案从来不是越复杂越好而是刚好满足当前需求并留有升级空间。2. 数据库设计先定表再写代码2.1 五张表的字段设计与约束数据库设计是整个项目的地基。表建得不合理后面写服务和写页面都会不停地返工。我按照这个系统的实际需求设计了五张表用户表、分类表、美食表、评分表、收藏表。用户表我用了sys_user这个名字而不是user原因很简单user在 MySQL 里是保留字早期版本建表不加反引号会直接报错。虽然现在 MySQL 8 对保留字的兼容变好了但为了避免团队成员用不同版本客户端执行 SQL 时踩坑我干脆换个名字省心。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码BCrypt密文, nickname VARCHAR(50) DEFAULT COMMENT 昵称, avatar VARCHAR(255) DEFAULT COMMENT 头像地址, gender TINYINT DEFAULT 0 COMMENT 性别 0未知 1男 2女, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;分类表和美食表是典型的父子关系。分类表很简单id、name、sort。美食表则要承载推荐系统需要的核心字段除了常规的菜名、描述、价格、图片还有两个容易被忽略的字段taste_tag和recommend_count。前者用来记录口味标签比如“麻辣”“清淡”“酸甜”后边做标签匹配推荐时可以直接用后者用来做热度兜底每被推荐一次或者每被收藏一次就加一冷启动阶段就靠它撑场子。CREATE TABLE food ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, name VARCHAR(100) NOT NULL COMMENT 菜品名称, category_id BIGINT DEFAULT NULL COMMENT 分类ID, description TEXT COMMENT 菜品描述, image_url VARCHAR(255) DEFAULT COMMENT 图片地址, price DECIMAL(10,2) DEFAULT 0.00 COMMENT 价格, taste_tag VARCHAR(100) DEFAULT COMMENT 口味标签逗号分隔如麻辣,鲜香, recommend_count INT DEFAULT 0 COMMENT 推荐热度, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 录入时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT美食信息表;评分表和收藏表是推荐系统的数据血液。评分表记录“哪个用户给哪个菜品打了多少分”收藏表记录“哪个用户收藏了哪个菜品”。这两张表都要建联合唯一索引防止同一个人反复对同一道菜评分或重复收藏不然统计数据会一团糟。CREATE TABLE rating ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, food_id BIGINT NOT NULL COMMENT 菜品ID, score TINYINT DEFAULT 5 COMMENT 评分 1-5, comment VARCHAR(500) DEFAULT COMMENT 评论内容, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_food (user_id, food_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评分表; CREATE TABLE favorite ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, food_id BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_food (user_id, food_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收藏表;2.2 索引设计、外键取舍与字符集选择关于索引我的原则是频繁查询的字段建索引联合查询的字段建联合索引。评分表查得最多的是“某个用户最近评了哪些菜”所以user_id是必须建索引的收藏表同理。分类表的id会被美食表的category_id引用所以美食表要为category_id单独建索引。外键这件事我建议你在课设和毕设阶段就不要用了。原因有三外键会让插入和删除操作多一次约束检查影响性能项目演进时如果要做分库分表或者数据迁移外键会变成大麻烦真正生产环境里多数团队选择在应用层维护数据一致性数据库反而保持精简。表之间的关联关系通过 SQL JOIN 来体现并不影响你对业务的理解反而能让你更早接触到企业级的建表习惯。字符集一定要用utf8mb4而不是utf8。MySQL 的utf8实际上最多只能存三个字节的字符一些生僻字和 emoji 表情根本存不进去插入时直接报错。utf8mb4是真正的四字节 UTF-8任何字符都能存。如果你以后要维护老库记住一条命令把表和字段的字符集批量改过来否则就会出现“某道菜的描述里带了个特殊符号死活保存不进去”的诡异问题。建表还有一个容易被忽略的细节字段默认值要给全created_at用数据库的CURRENT_TIMESTAMP自动填充这样插入数据时不用每个字段都显式赋值减少应用层的空值判断。我见过很多新人写代码时在 service 层手动new Date()塞时间结果因为服务器时区问题存进去的时间永远是 UTC 的显示出来差八个小时。数据库默认值从源头规避了这个问题。3. 后端实现从接口分层到核心逻辑3.1 项目分层结构与接口清单后端工程我按标准的 Controller-Service-Mapper 三层来划分。Controller 只负责接收参数和响应结果不写业务逻辑Service 层处理具体业务比如登录校验、推荐计算Mapper 层只做数据库操作SQL 写在 XML 文件里。这样分层的好处是职责清晰Controller 很薄Service 可复用Mapper 好测试。com.food.recommend ├── controller # 接口入口 │ ├── AuthController.java │ ├── FoodController.java │ ├── RatingController.java │ └── RecommendController.java ├── service # 业务逻辑 │ ├── AuthService.java │ ├── FoodService.java │ ├── RecommendService.java │ └── RatingService.java ├── mapper # MyBatis 接口 │ ├── UserMapper.java │ ├── FoodMapper.java │ ├── RatingMapper.java │ └── FavoriteMapper.java ├── entity # 数据库实体 │ ├── User.java │ ├── Food.java │ └── Rating.java ├── common # 通用组件 │ ├── Result.java │ ├── BusinessException.java │ └── GlobalExceptionHandler.java └── util └── JwtUtil.java接口规划我列了个清单前后端联调时就照着这个来沟通谁也不要各写各的。接口地址方法说明是否需要登录/api/auth/registerPOST用户注册否/api/auth/loginPOST用户登录返回 token否/api/food/pageGET分页查询菜品支持关键词和分类筛选否/api/food/{id}GET查询菜品详情否/api/rating/submitPOST提交评分是/api/recommend/listGET获取推荐菜品列表是/api/favorite/addPOST收藏菜品是/api/favorite/removePOST取消收藏是/api/user/infoGET获取当前登录用户信息是3.2 统一返回结果、全局异常与登录鉴权前后端分离的项目里接口返回值必须有一个统一的格式不然前端每个页面都要写一堆容错逻辑。我的统一返回体ResultT包含三个字段code、msg、data。成功时 code 是 200失败时 code 是具体的错误码。前端 axios 拦截器会对 code 做个判断不为 200 就弹提示逻辑只写一遍。Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.msg OK; r.data data; return r; } public static T ResultT fail(Integer code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } }全局异常处理用RestControllerAdvice配合ExceptionHandler这是 SpringBoot 里非常实用的能力。业务代码里发现参数不合法、数据不存在、用户未登录直接throw new BusinessException(错误码, 提示语)全局处理器捕获后统一转成Result.fail返回给前端。这样 Controller 和 Service 里就不再需要到处写 try-catch整个代码干净很多。登录鉴权我选了 JWTJSON Web Token。用户登录成功后后端把用户 ID 和过期时间做签名生成 token 返回。之后前端每次请求都带Authorization: Bearer token后端通过拦截器解析 token 来识别用户身份。JWT 的好处是无状态不需要在服务端保存登录会话多实例部署时也天然支持水平扩展。密码存储则用 BCrypt 加密每次都随机加盐即使数据库泄露密文也很难被反推出来。public class JwtUtil { private static final String SECRET your-secret-key-please-change; private static final long EXPIRE_MS 7 * 24 * 60 * 60 * 1000L; public static String createToken(Long userId) { return Jwts.builder() .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MS)) .signWith(SignatureAlgorithm.HS256, SECRET.getBytes()) .compact(); } public static Long parseUserId(String token) { Claims claims Jwts.parser() .setSigningKey(SECRET.getBytes()) .parseClaimsJws(token) .getBody(); return Long.valueOf(claims.getSubject()); } }拦截器里解析到用户 ID 后把它放进ThreadLocal或者请求上下文里Service 层随时可以取到“当前操作者是谁”。评分、收藏、推荐这些接口都需要用这个身份信息来查询和写入数据。3.3 动态 SQLMyBatis 的高阶玩法MyBatis 的 XML 映射文件里最实用的就是动态 SQL。菜品分页查询接口要支持多种条件组合按关键词模糊搜菜名、按分类筛选、按最低好评度过滤。如果每种组合都写一个 SQL代码会膨胀到没法维护用where加if动态拼接一个 SQL 就能覆盖所有场景。select idselectFoodPage resultTypecom.food.recommend.entity.Food SELECT * FROM food where if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testminScore ! null AND id IN ( SELECT food_id FROM rating GROUP BY food_id HAVING AVG(score) #{minScore} ) /if /where ORDER BY recommend_count DESC LIMIT #{offset}, #{size} /select这里有一个经验模糊查询拼接%时在 XML 里用CONCAT(%, #{keyword}, %)不要直接用%${keyword}%。${}是文本替换存在 SQL 注入风险#{}是预编译占位符安全可靠。很多教学代码图省事写${}我怎么看怎么觉得不妥。分页参数的计算方式也要说清楚。前端传page和size后端起止行号要转换成offset (page - 1) * size也就是说第一页前 10 条对应 SQL 里的LIMIT 0, 10第二页前 10 条对应LIMIT 10, 10。也有人用 PageHelper 插件它底层会拦截 SQL 自动生成 count 查询和分页语句省去手动计算。但如果你想把 SQL 完全握在自己手里或者希望考试时能手写出分页过程手写 LIMIT 一定是绕不开的基础功。MyBatis 想要自动化地把数据库字段category_id映射成 Java 实体类的categoryId需要在配置里开启驼峰命名映射mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.food.recommend.entity configuration: map-underscore-to-camel-case: true不开这个配置查询结果里所有下划线字段都会是 null这是新手最常见的翻车点。我见过太多人跑来问“明明是同一个字段Java 里查出来为什么是 null”打开 XML 看一眼要么没开驼峰映射要么用了 resultType 却没建实体属性对应关系。解决方案就是上面这行配置加上尽量保持数据库字段和实体属性命名一致。4. Vue3 前端工程推荐页面从搭建到联调4.1 工程初始化与目录规划前端我用 Vite 脚手架搭建一条命令初始化工程npm create vitelatest frontend -- --template vue然后安装运行时依赖cd frontend npm install npm install axios vue-router pinia工程结构我按功能拆分而不是按文件类型硬分。按类型分目录最大的问题是当你有十几个页面时views目录会膨胀到没法看。我是这样规划的src ├── api # 接口封装 │ ├── request.js # axios 实例与拦截器 │ ├── auth.js # 登录/注册接口 │ ├── food.js # 菜品相关接口 │ └── recommend.js # 推荐接口 ├── router # 路由配置 │ └── index.js ├── stores # Pinia 状态管理 │ └── user.js ├── views # 页面组件 │ ├── Home.vue # 首页菜品浏览 │ ├── Recommend.vue # 推荐页 │ ├── Detail.vue # 菜品详情页 │ └── Login.vue # 登录页 └── components # 公共组件 ├── FoodCard.vue # 菜品卡片 └── RatingStars.vue # 评分组件路由配置用了两个小技巧一是meta.requiresAuth标记需要登录的页面路由守卫里统一拦截二是静态路由和动态路由分开登录后还能根据后端返回的权限信息继续追加动态路由这样以后做角色权限也不用推倒重来。下面是路由守卫的核心代码router.beforeEach((to) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { return { path: /login, query: { redirect: to.fullPath } } } })4.2 axios 封装与跨域方案选型axios 封装是前端工程质量的关键。直接在每个页面里调用axios.get会让代码大量重复而且 token 注入、错误提示、登录过期处理散落各处。我在request.js里创建了一个实例统一配置基础路径和超时时间再通过请求拦截器注入 token通过响应拦截器处理业务状态码。import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request跨域问题是前后端分离项目第一个绕不开的坎。浏览器里运行的前端地址是http://localhost:5173后端接口地址是http://localhost:8080端口不同就属于跨域。后端触发 CORS 预检前端报错页面一片空白新人第一次遇到往往一脸茫然。我给你的建议是开发环境用 Vite 代理解决生产环境用 Nginx 反向代理解决不到万不得已不要在后端开启全局跨域。前端代理的配置在vite.config.js里export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这段配置的意思是前端所有/api开头的请求在真正发出时会被转发到http://localhost:8080。浏览器看到的是同源请求就不会有 CORS 预检拦截了。生产环境打包后让 Nginx 把/api路径同样代理到后端服务思路完全一致。这种方案的好处是前后端代码里都不需要写跨域处理真正的跨域问题在边缘层就被解决了。4.3 推荐页与详情页的核心交互推荐页是这套系统的门面。用户进来之后先展示推荐列表每张卡片是一道菜点击进入详情页可以查看完整的菜品介绍、评分和用户评论。推荐列表我用一个自定义组合式函数来管理加载状态和数据刷新这样页面组件本身保持很薄逻辑集中在可复用的函数里。这个函数接收一个来源参数通过不同的接口路径获取数据将来要扩展“热门榜”“口碑榜”时只需要传不同的参数。export function useFoodList(fetcher) { const foodList ref([]) const loading ref(false) const page ref(1) const size ref(10) const finished ref(false) async function load() { if (loading.value || finished.value) return loading.value true try { const data await fetcher({ page: page.value, size: size.value }) foodList.value.push(...data) page.value if (data.length size.value) finished.value true } finally { loading.value false } } onMounted(load) return { foodList, loading, load, finished } }菜品卡片组件接收一个food对象点击之后跳转到详情页。详情页里有一个评分组件用户点击星星后调用评分接口前端立即更新显示不需要整页刷新。评分组件用defineProps接收初始分数和是否只读defineEmits向父组件抛事件这是 Vue3 组合式 API 的标准写法。5. 推荐逻辑落地从冷启动到协同过滤推荐系统是整个项目最容易被问倒的地方。面试图的时候你说“我做了个推荐系统”面试官一定追问“推荐算法到底是怎么实现的”。如果只能答出“按浏览量排序”这个项目在面试官心中的含金量会大打折扣。我在这套系统里实现了三级推荐策略每一级都能说清楚原理和适用场景。5.1 冷启动没有行为数据时的兜底推荐新用户注册进来评分表里没有数据收藏表里也没有数据这个时候推荐系统面对的是真正的“冷启动”。如果强行按用户行为推荐结果就是“推荐队列为空”这体验就太糟糕了。我的兜底策略是以recommend_count字段为主排序热度最高的前 10 道菜直接推给新用户。同时为了不让推荐列表永远被那几道热门菜霸占我加了一个分类均衡逻辑从所有分类里随机选一个起始分类按分类轮询取热度高的菜。这样即使同一个用户每次刷新首页看到的推荐分布也会有些差异。这部分代码很直接就是一次带条件的查询不涉及任何复杂的计算。它的价值在于撑住了系统的最小可用体验让用户感受到“系统确实在给我推荐东西”先有数据入口再谈算法优化。5.2 基于分类兴趣的推荐第一个真实算法当用户有了评分或收藏行为之后推荐就开始有“个性化”的味道了。我的思路是这样的一个用户给川菜打了高分、也给湘菜打了高分那大概率 TA 偏好重口味如果后面又给甜品打了高分说明 TA 对这个分类也有兴趣。把这些高评分记录对应的分类统计出来按出现次数降序排列取前三类再在这些分类里按热度取菜品就是一次非常合理的推荐。Mapper 里查用户兴趣分类的 SQL 长这样select idfindUserTopCategories resultTypejava.lang.Long SELECT f.category_id FROM rating r JOIN food f ON r.food_id f.id WHERE r.user_id #{userId} AND r.score 4 GROUP BY f.category_id ORDER BY COUNT(1) DESC, AVG(r.score) DESC LIMIT #{limit} /select这里有几个设计上的细节值得展开。评分阈值设为 4 分低于 4 分说明用户没那么满意不该作为兴趣标签的依据排序时先按出现次数再按平均分是为了避免“只评过一次但给了 5 分”的分类超过“评过十次但平均只有 4 分”的分类后者显然才是更稳定的兴趣信号。然后把取到的分类 ID 列表传给菜品查询SQL 里用foreach拼出IN子句按分类内热度取前五行。这个策略实现简单、解释容易而且效果在数据量小的场景下其实比复杂的协同过滤更好因为它完全基于用户自己的反馈不依赖“其他人也这样”的群体推断。5.3 简化版协同过滤像你一样的人喜欢吃这些协同过滤是推荐系统里绕不开的经典算法。完整版本要用余弦相似度计算用户向量之间的距离涉及矩阵运算对课设和毕设来说实现成本偏高。我这里做了一个基于 SQL 的简化版本本质上思路和协同过滤一脉相承但写出来更容易看懂和维护。算法分为三步。第一步找到当前用户评分很高的美食 ID 列表。第二步找到也喜欢这些美食的其他用户按重合数量降序取前 10 个。第三步把这 10 个用户的高分美食汇总排除当前用户已经消费过的剩下的按热度排序返回。这三个步骤体现了协同过滤最核心的思想物以类聚人以群分。前提是做对两件事一是通过user_id ! #{userId}把当前用户排除掉二是通过NOT IN把当前用户已经评分过的菜品排除掉否则推荐列表会把你吃过的菜再推一遍非常智障。select idfindFoodsLikedBySimilarUsers resultTypecom.food.recommend.entity.Food SELECT f.* FROM rating r JOIN food f ON r.food_id f.id WHERE r.user_id IN foreach collectionuserIds itemuid open( separator, close) #{uid} /foreach AND r.score 4 AND r.food_id NOT IN foreach collectionexcludeFoodIds itemfid open( separator, close) #{fid} /foreach GROUP BY f.id ORDER BY COUNT(1) DESC, AVG(r.score) DESC LIMIT #{limit} /select这个简化版的局限性也要讲清楚。它假设“用户重合的评分记录多”就等于“品味相似”没有考虑评分尺度差异比如一个用户习惯性给 3 分另一个用户习惯性给 5 分两个人真实偏好可能很接近但算法会把他们归为不同人群。要解决这个问题标准做法是做平均分归一化把每个用户的具体评分减去 TA 自己的总体平均分得到正负偏离度再去做相似度计算。如果你的数据量上来了可以往这个方向优化这也是在面试时展示你算法深度的一个加分项。6. 常见问题与避坑手册我在复刻这套源码时踩过的坑一套源码从纸上到跑通中间一定会经历各种波折。我把运行这套系统时最容易踩的坑整理成了一份排查手册每个问题都给出症状、根因和解决办法。6.1 前后端联调高频报错与解决方案跨域问题是前后端联调时遇见最多的坑。症状是浏览器 console 报Access to XMLHttpRequest ... has been blocked by CORS policy根因就是端口不同导致跨域或者后端响应头没带对。解决方式优先走 Vite 代理绝不在后端代码里乱开CrossOrigin(*)那样等于把整个接口暴露给任何站点调用安全上非常不合适。还有一个坑是登录接口用得好好的菜单接口突然报 401。原因往往是 token 挂在请求头里的写法不统一后端的拦截器用的是Authorization: Bearer token前端传成Authorization: token少一个Bearer前缀解析就失败了。排查方法非常直接打开浏览器 Network 面板看请求头到底发的什么一眼就明白。前端页面刷新 404 的问题也很典型。Vue 路由用了 history 模式时地址栏里的路径会被浏览器真的发给服务器。后端如果只有/api相关的 Controller访问/recommend一定会报 404。解决方案有两种要么改成 hash 模式地址带#不发这种真实请求要么后端加一个兜底转发把前端路由相关的 GET 请求全部转发到index.html交给前端路由接管。我建议在课设和毕设阶段直接用 hash 模式省心要上生产再考虑 history 模式加上后端转发。问题现象可能原因解决方案CORS 跨域报错前后端不同端口直接请求开发用 Vite 代理生产用 Nginx 反向代理401 无效 token请求头没有 Bearer 前缀检查拦截器是否注入Bearer ${token}刷新页面 404history 路由 后端无兜底改用 hash 路由或加 index.html 转发字段全是 null未开启驼峰映射配置map-underscore-to-camel-case: true中文乱码数据库字符集不是 utf8mb4建库统一使用 utf8mb46.2 数据库、分页与打包部署的典型坑MySQL 连接报错是最容易劝退新人的。最常见的是报Public Key Retrieval is not allowed这是 MySQL 8 的认证机制导致的需要在连接串里加上allowPublicKeyRetrievaltrue。还有时区问题不设置serverTimezone的话程序可能报 CST 时区无法识别的错误连接串加上serverTimezoneAsia/Shanghai就好。spring: datasource: url: jdbc:mysql://localhost:3306/food_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver分页翻页错乱的问题也常出现。前端传的页码从 1 开始SQL 里的 LIMIT 偏移量从 0 开始如果不做offset (page - 1) * size的转换第二页就会和第一页重复或者漏数据。另外一个常见问题是分页查询没有配套的 count 查询导致总页数算不出来。手写分页时一定要写一个SELECT COUNT(*)的语句或者直接用带 count 的封装工具。打包部署也有一个最容易忽视的顺序问题。很多人把前端打包出的dist目录直接拖进 SpringBoot 的static目录本地跑没问题部署到服务器就发现所有静态资源都加载不出来。原因通常是配置了context-path导致前端资源路径对不上。我的建议是前端先打包再用 SpringBoot 接管静态资源或者干脆用 Nginx 托管前端、SpringBoot 只提供接口维护成本更低结构也更清晰。我在复刻这套源码时还有一个很深的体会项目的密码、密钥这些敏感配置千万不要硬编码在代码里。JWT 的密钥、数据库密码通通放到application.yml里生产环境再用环境变量覆盖。别人拿到你的源码第一件事肯定是先搜有没有硬编码的密码这是安全意识的底线。最后再分享两个我在实操中的小建议如果你打算拿这套系统做二次开发我建议你优先考虑增加 Redis 缓存。把菜品详情和推荐列表缓存起来接口响应能从几百毫秒降到十几毫秒操作起来也不复杂就是先查缓存、没有再查库、回填缓存三件事。这个改动非常容易演示也是面试时很好的加分项。另外一个建议是给推荐系统加一层解释能力。比如在推荐页的每张卡片上标注“因为你看过 干锅肥肠所以推荐这道 剁椒鱼头”这会让用户觉得系统确实懂自己而且代码实现起来其实就是多存一条推荐理由字段。推荐系统最怕的不是不准而是用户不知道为什么出现这个推荐加了理由之后即便推荐不完美用户的接受度也会高很多。根据我个人的经验美食推荐系统这类项目最大的价值不是功能有多炫而是它能让你把一整条前后端分离的开发链路跑通从需求拆解、数据库建模到后端接口开发、前端页面渲染再到推荐算法落地和最后的部署上线。把这些环节完完整整地做一遍你对 Java Web 开发的理解会比你只看课程视频或者背面试题要深刻得多。希望这篇拆解能帮你把整套源码吃透少走一些我走过的弯路。