
先说结论这套“SpringBoot Vue MyBatis MySQL”的组合到今天依然是中小型前后端分离项目里最省心、最不容易翻车的搭配之一。我将它应用在美食信息推荐系统中前端负责展示和交互后端只出接口数据库统一管理菜品、用户和推荐数据。无论你是准备毕业设计、面试作品还是想亲手跑通一个完整的前后端分离项目这条路线的参考价值都足够高。整个系统我实际跑下来最明显的感受是难度不在某个单独的技术点而在于把“推荐”这个看似玄学的功能落成可执行的SQL和接口再把前后端两条线从开发到部署完整串起来。我能帮你做的就是把整个拆解过程、表结构设计、推荐算法简化版实现、Vue联调细节还有从零到一的部署步骤全部梳理清楚并附上我在实际操作中趟过的坑和对应的排查方式。1. 项目整体设计与技术选型思路1.1 前后端分离架构的价值在哪里所谓前后端分离核心就是让前端页面和后端逻辑不再耦合在同一个服务里。Vue通过axios异步请求后端接口拿到JSON数据后再渲染页面后端只负责业务处理和数据返回不再返回HTML模板。这样做的好处很直白前端开发和后端开发可以并行推进互不阻塞。用一个生活化类比来解释。前端是餐厅里点单的平板后端是后厨。平板负责把菜名展示给顾客、收集需求后厨负责切菜炒菜把做好的菜端出来。平板和后厨之间通过“订单”沟通这个订单就是JSON数据里面塞着菜品名称、价格、库存这些结构化信息。放到实际项目里这个架构最大的现实收益是部署灵活。前端打包成纯静态文件丢给Nginx托管后端打包成jar包独立运行互不影响。升级某一侧时不需要重新部署整台服务器这在传统JSP或者模板引擎项目里是做不到的。1.2 这套技术栈为什么能扛住中小型项目选择SpringBoot是因为它有成熟的starter生态自带内嵌容器一个java -jar命令就能把服务跑起来。比起自己搭SSH或者SSM项目省去大量XML配置对于个人项目和课程实践来说开发成本直线下降。选择Vue是因为它的数据绑定机制让“推荐列表”“搜索筛选”这类高频交互做起来很直接。数据源变了页面自动更新不用手动操作DOM心智负担小很多。选择MyBatis是因为手写SQL足够透明。美食信息的查询天然多条件组合按分类筛选、按标签匹配、按关键词模糊搜、按热度排序这些场景在XML里写动态SQL最直白。相比之下Hibernate和JPA虽然自动生成SQL但在这种多条件查询和复杂排序场景里反而不如MyBatis可控。MySQL就不用多说了开源、稳定、社区资料极其丰富。对于这套美食系统的数据规模单实例MySQL已经完全够用不需要引入更重的东西。这套组合的完整数据流是Vue页面发起请求 → axios到达后端Controller → Service处理业务 → Mapper执行SQL → MySQL返回数据 → 层层包装为统一JSON → 前端渲染。记住这条链路后面所有联调和排错都会围绕它展开。1.3 美食推荐功能的需求边界设计很多初学者一听“推荐系统”就觉得要上机器学习、协同过滤、神经网络这是没有必要的。对于个人项目或课程设计一个足够“聪明”的规则推荐算法已经能带来良好的演示效果和实际的用户感受。我在系统里设计了两种推荐策略覆盖不同场景热度推荐面向未登录用户和新用户基于菜品的点击量和收藏量计算热度分取TopN返回。这个策略没有“冷启动”问题因为任何用户进来都能看到内容。标签匹配推荐面向已登录用户先分析该用户历史收藏过的菜品标签再在所有菜品中寻找标签重合度最高的那批作为“猜你喜欢”的推荐结果。业务边界也做了明确划分除了推荐系统还需要支持分类浏览、菜品搜索、菜品详情、收藏管理、个人中心。这几个功能模块把前后端分离项目的CRUD能力完整覆盖了恰好是面试和答辩时最能体现项目完整度的地方。2. 数据库设计与推荐逻辑建模2.1 核心表结构与字段设计数据表设计决定了后续功能能不能顺畅实现。我最终确定的核心表包括用户表、菜品种类表、菜品信息表、标签表、菜品标签关联表、用户收藏表一共六张。这里把每张表的关键字段和设计意图列出来。用户表user字段类型说明idbigint主键自增usernamevarchar(50)登录账号唯一索引passwordvarchar(100)BCrypt加密后的密码绝不存明文nicknamevarchar(50)昵称avatarvarchar(255)头像URLcreate_timedatetime注册时间菜品种类表category字段类型说明idbigint主键自增namevarchar(50)分类名比如川菜、粤菜、甜品sortint排序权重数值小的排前面菜品信息表food字段类型说明idbigint主键自增category_idbigint外键关联分类表namevarchar(100)菜品名称covervarchar(255)封面图URLingredientstext主要食材逗号分隔stepstext制作步骤用分隔符处理tagsvarchar(255)冗余标签字符串便于展示click_countint点击量热度维度collect_countint收藏量热度维度statustinyint上下架状态0下线1上线create_timedatetime创建时间标签表tag和菜品标签关联表food_tag标签表只存id和name关联表把food_id和tag_id做多对多关联。为什么把标签单独拆表而不是只用菜品信息表里的tags字符串因为推荐匹配时需要做标签维度聚合关联表能用JOIN统计匹配数量在效率和表达上都优于逗号字符串的字符串匹配。用户收藏表user_favorite字段类型说明idbigint主键自增user_idbigint用户IDfood_idbigint菜品IDcreate_timedatetime收藏时间这张表上必须建联合唯一索引(user_id, food_id)防止同一用户重复收藏同一个菜品。收藏是用户与菜品的多对多关系独立成表的扩展性最好后续就算加“收藏分组”或“收藏备注”也只需要在这张表上增加字段。建表时我统一使用utf8mb4字符集而不是默认的utf8。原因很简单菜品名称或者备注信息里有可能出现emoji字符utf8mb4是真正意义上的四字节UTF-8能完整覆盖这些字符而utf8在MySQL里实际上最大只支持三字节。2.2 推荐策略落地从热度榜到标签匹配热度推荐实现起来最简单核心就是一条排序SQL。SELECT id, name, cover FROM food WHERE status 1 ORDER BY (click_count * 0.4 collect_count * 0.6) DESC LIMIT 10;这里给点击量和收藏量设置了不同的权重系数。收藏行为的成本比点击高很多一个用户愿意点“收藏”说明他对菜品的认可度更强所以收藏量权重占到60%。你可以根据实际数据表现调整权重。需要注意的是ORDER BY后面跟的表达式不是字段名所以AS别名只能写在SELECT里但ORDER BY可以用表达式原样写SQL语法上完全合法。标签匹配推荐是核心逻辑分四步走。第一步查询当前用户最近收藏的菜品ID集合。第二步把这些菜品关联的标签ID全部查出来去重后得到用户的“兴趣标签列表”。第三步写一条聚合SQL在所有上线菜品中统计命中的标签数量。第四步按命中数量降序排除已经收藏过的菜品取前N条。SELECT f.id, f.name, f.cover, COUNT(ft.tag_id) AS match_score FROM food f LEFT JOIN food_tag ft ON f.id ft.food_id WHERE f.status 1 AND ft.tag_id IN (SELECT DISTINCT ft2.tag_id FROM user_favorite uf JOIN food_tag ft2 ON uf.food_id ft2.food_id WHERE uf.user_id #{userId}) AND f.id NOT IN (SELECT food_id FROM user_favorite WHERE user_id #{userId}) GROUP BY f.id, f.name, f.cover ORDER BY match_score DESC, f.click_count DESC LIMIT 10;这条SQL的巧妙之处在于它通过COUNT(ft.tag_id)直接计算出每个候选菜品命中了多少用户感兴趣的标签命中数相同的时候再按点击量排序把高热度的菜品往前放。如果用户没有任何收藏记录前面传入的兴趣标签子查询为空那IN后面的空子查询会导致结果集为空所以我在Service层做了判断无收藏时直接走热度推荐SQL。这个兜底逻辑很关键。2.3 字段与检索的优化细节中小型项目虽然数据量不大但设计时预留优化余地不会吃亏。第一所有关联字段一定要建索引。category_id、food_tag表里的food_id和tag_id、user_favorite表里的user_id这些字段在推荐SQL中会被频繁作为关联条件和查询条件。缺失索引的情况下推荐接口在数据量增长后会出现明显的响应变慢。第二进行排序时如果有多个排序维度尽量保证主排序字段有索引或者可以通过覆盖索引完成。实际操作中我为了提升热度排序效率在food表上建了(click_count, collect_count, id)的联合索引让排序可以走索引而不是filesort。第三菜品名称的模糊搜索如果使用LIKE %关键字%无法利用B树索引数据量大时性能会下降。个人项目阶段这样写完全可以但如果想让搜索更快可以考虑引入全文索引或者Elasticsearch。不过在这个量级的美食系统里不需要过度设计保持LIKE即可只需要将“搜索词前后拼接、注意SQL注入风险”这一点提醒到位。3. 后端SpringBootMyBatis实现细节3.1 项目分层与统一接口规范后端我按经典四层结构组织代码。com.example.food ├── controller // 接口层接收参数、校验、返回结果 ├── service // 业务层处理推荐逻辑、收藏逻辑 ├── mapper // 数据访问层MyBatis的Mapper接口 ├── entity // 实体类对应数据库表 ├── dto // 数据传输对象给前端返回VO └── common // 统一返回结果、全局异常处理、工具类Controller层只做参数接收和结果包装业务逻辑一律下沉到Service层。这样做除了规范之外最重要的价值在于可测试性Service层不依赖HTTP请求对象直接用参数就能单元测试。统一返回结果我定义成Result 结构包含code、message、data三个字段。code为200表示成功400表示业务失败401表示未登录500表示系统异常。前端axios拦截器只判断HTTP状态码还不够必须同时判断业务code因为后端接口可能返回HTTP 200但业务code为401的情况。为了不让异常信息直接暴露给前端造成误解我用RestControllerAdvice做了全局异常处理。业务异常返回对应的业务状态码和友好提示系统异常统一记录日志并返回“服务器开小差了”之类的兜底提示。3.2 MyBatis配置与核心实践MyBatis在本项目中的使用有几个关键配置点。首先是application.yml里的基础配置。mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.food.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case一定要开启它能把数据库的snake_case字段自动映射成Java的驼峰属性。不开启的话实体类字段要么全部手动加TableField/Column注解要么在resultMap里逐个定义非常繁琐。log-impl设置为StdOutImpl会在控制台打印完整的SQL语句和参数。开发阶段这个太重要了排查“SQL和预期不一致”这类问题基本靠它。生产环境记得把它关掉或者改成日志框架输出避免大量SQL日志堆积。多条件查询是MyBatis最核心的实践场景。美食搜索接口会接收分类ID、关键词、标签ID等多个可选参数直接用字符串拼接很不安全。正确做法是用XML里的动态SQL标签。select idsearchFood resultTypecom.example.food.entity.Food SELECT * FROM food where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testtagId ! null AND id IN (SELECT food_id FROM food_tag WHERE tag_id #{tagId}) /if AND status 1 /where ORDER BY click_count DESC /select标签会自动去掉第一个条件多余的AND不需要手动处理。参数传递全部使用#{}预编译从根源上避免SQL注入。这里提几个和MyBatis相关的独立思考。对于二级缓存MyBatis默认是关闭的我建议在这个项目中不要开启。它的缓存粒度是namespace级别的而美食信息表存在JOIN查询如果food表和category表分别缓存但数据发生变化很容易出现脏读。只有像字典表、标签表这种几乎不关联其他表、极少更新的数据才值得考虑二级缓存。我在这个项目里连一级缓存都不依赖因为SpringBoot的SqlSession生命周期短缓存价值本来就有限。另一个容易被忽略的是TypeHandler。当Java实体里的枚举类型需要映射到数据库int字段时默认的MyBatis配置无法自动完成。我自定义了类型处理器实现setNonNullParameter和getNullableResult两个核心方法把枚举转成对应的数值存库查询时再从数值转回枚举。美食系统里“菜品上下架状态”我用枚举State表示就用到了自定义TypeHandler。3.3 推荐接口的实现流程推荐接口的真实实现流程我拆成几步讲清楚。第一步是Controller接收请求。推荐首页接口的参数是userId和limituserId从请求头里的token解析出来后传递进来。第二步是Service层核心逻辑。先判断userId是否为null为null直接走热度推荐。有userId则查询用户最近收藏记录然后聚合兴趣标签。这一步我用一条LEFT JOIN就能完成避免循环查询。第三步是调用Mapper执行标签匹配SQL返回推荐列表。Service层拿到集合后做一层转换把实体转换成带推荐原因的VO对象。第四步是拼装今日推荐和猜你喜欢两个板块。今日推荐直接取热度Top5猜你喜欢走标签匹配。这样首页不仅内容丰富展示逻辑也有层次感做起前端页面来也有话可说。Service层关键代码的逻辑如下。public ListFoodVO recommendForUser(Long userId, int limit) { // 用户无收藏记录时无法分析偏好用热门榜单兜底 if (userId null) { return foodMapper.selectHotFoods(limit); } ListLong favoritedFoodIds favoriteMapper.selectFoodIdsByUserId(userId); if (favoritedFoodIds.isEmpty()) { return foodMapper.selectHotFoods(limit); } ListFoodVO recommendList foodMapper.selectByTagMatch(userId, limit); return recommendList; }selectByTagMatch对应上面那一条带标签匹配打分的SQL它内部通过LEFT JOIN food_tag实现匹配数量统计既保证了准确性又比逐条循环判断高效得多。3.4 配置文件与多环境切换不同环境的数据库地址、密码、日志级别都不一样我把SpringBoot的Spring Profiles用起来拆成application.yml、application-dev.yml、application-prod.yml三份配置文件。application.yml只保留公共配置比如应用端口、MyBatis配置、上传文件大小限制。spring: profiles: active: dev启动时可以通过启动参数覆盖默认的profiles环境。java -jar food-system.jar --spring.profiles.activeprod开发环境连接本地MySQL生产环境连接服务器的MySQL。特别提醒数据库密码这类敏感信息不要硬编码在配置文件里提交到代码仓库推荐使用环境变量注入。在application-prod.yml中写成${DB_PASSWORD}启动时设置环境变量即可。这个习惯越早养成越好特别是项目要开源或者多人协作时能避免不小的风险。关于SpringBoot版本选择我个人的稳定组合是JDK 8 Spring Boot 2.7.x mybatis-spring-boot-starter 2.3.x。这个搭配的兼容性久经考验网上遇到的大部分坑都已经有解决方案。如果你不想花时间去适配新技术版本建议遵循这个组合。4. 前端Vue实现与跨域联调4.1 Vue工程结构与路由规划前端工程使用Vue 3 Vite Vue Router Axios搭建没有引入Pinia因为项目状态比较少用localStorage存token就足够。如果你之前熟悉Vue 2切到Vue 3的Composition API需要一点适应时间但整体开发效率确实有提升。工程目录结构如下。frontend ├── src │ ├── api // 后端接口请求封装 │ ├── assets // 静态资源 │ ├── components // 公共组件 │ ├── router // 路由配置 │ ├── views // 页面组件 │ ├── utils // 工具函数 │ └── App.vue ├── vite.config.js // Vite配置文件 └── package.json路由规划我设计了两个层级。公共路由包括首页、菜品详情页、分类页、搜索页需要登录的路由包括个人中心、收藏管理。这样路由守卫就有了明确的判断逻辑。const routes [ { path: /, component: Home }, { path: /food/:id, component: FoodDetail }, { path: /category/:id, component: CategoryPage }, { path: /login, component: Login }, { path: /user, component: UserCenter, meta: { requiresAuth: true } }, { path: /favorites, component: Favorites, meta: { requiresAuth: true } } ];使用Vue Router的createWebHistory()即history模式URL看起来更干净没有#号。但带来的副作用是部署后刷新非首页路由会出现404这个问题在部署章节会详细讲。4.2 Axios封装与前端鉴权Axios封装是前端工程质量的关键一环。我在utils/request.js里创建了统一的axios实例配置baseURL指向/api并设置超时时间为10秒。请求拦截器负责在每次请求前从localStorage取出token放到请求头Authorization字段里。service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });响应拦截器统一处理返回体。后端约定业务code为200时代表成功code为401时代表未登录或登录过期此时清除本地token并跳转到登录页。service.interceptors.response.use( response { const res response.data; if (res.code 200) { return res.data; } if (res.code 401) { localStorage.removeItem(token); router.push(/login); return Promise.reject(new Error(未登录或登录过期)); } ElMessage.error(res.message); return Promise.reject(new Error(res.message)); }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );经过这层统一封装后业务代码里只需要调用返回的数据不用每个页面都重复处理异常提示和登录跳转逻辑代码简洁很多。路由守卫再兜一层未登录用户访问需要鉴权的页面时直接跳登录页。router.beforeEach((to) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { return { path: /login, query: { redirect: to.fullPath } }; } });4.3 推荐页面交互设计与组件拆解首页是推荐的展示主战场。我的布局分三个区块顶部是搜索栏 分类入口中间是今日推荐的横向滚动卡片下方是猜你喜欢的纵向列表。组件拆分成FoodCard和RecommendList两个独立组件。FoodCard负责单张菜品卡片的展示包含封面、名称、标签、收藏数点击后通过router.push跳转到详情页。RecommendList负责接收数组数据并渲染一组FoodCard内部集成触底加载逻辑。触底加载用滚动监听实现监听到达页面底部时自动加载下一页数据。每次请求limit10page从1开始递增。这里需要注意推荐结果本身会由后端根据用户行为动态变化翻页时可能出现数据重复所以我在前端做了简单的按id去重。详情页访问时前端调用后端接口后端在返回详情数据的同一时刻执行click_count自增。为了避免频繁刷接口导致点击量虚高我在后端加了一层简单的Session级别防刷同一个用户对同一个菜品在一小时内只计一次点击。这个逻辑用HashMap缓存就能搞定不需要引入Redis。4.4 前后端联调跨域问题开发阶段前端运行在5173端口后端运行在8080端口浏览器直接跨域。解决方式是在Vite里配置代理。server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }原理是Vite启动了一个开发服务器前端请求/api路径时由开发服务器转发到8080端口的后端服务浏览器看起来是同源请求规避了跨域限制。生产阶段不需要Vite代理改用Nginx做反向代理把/api前缀的请求转发到后端服务这个在部署章节会详细说明。这里有个统一约定所有后端接口的路径都以/api开头。前端请求用相对路径/api/food/list后端Controller的RequestMapping写/api/food。这样一个约定贯穿开发和生产切换环境时不需要改一行代码。5. 完整部署流程从开发机到服务器5.1 环境准备清单部署前先确认服务器或本地环境具备这些基础条件。JDK 8及以上版本推荐JDK 8。MySQL 5.7或8.0推荐8.0。Node.js 16或18用于前端构建。Nginx用于托管前端静态资源和反向代理。Maven后端构建需要。MySQL安装完成后用root登录创建数据库和账号。CREATE DATABASE food_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER fooduserlocalhost IDENTIFIED BY 你的密码; GRANT ALL PRIVILEGES ON food_system.* TO fooduserlocalhost; FLUSH PRIVILEGES;然后导入项目里的sql文件。推荐使用Navicat或者MySQL命令行source命令。在导入之前检查一下SQL文件开头有没有USE food_system;如果缺少就手动补上否则数据会导入到默认库导致后面找不到表。5.2 后端打包与启动后端构建用Maven命令。mvn clean package -Dmaven.test.skiptrue跳过测试是因为部署阶段不需要跑单元测试可以显著加快构建速度。执行完毕后在target目录下生成food-system-1.0.0.jar。启动后端非常直接。java -jar food-system-1.0.0.jar --spring.profiles.activeprod如果你想用Tomcat部署而不是jar包方式运行也可以。需要把pom.xml里的packaging改成war并且让启动类继承SpringBootServletInitializer重写configure方法然后用mvn package生成war包丢进Tomcat的webapps目录启动Tomcat即可。但从运维角度看jar Nginx的方式更简单可靠也更容易做进程管理。我实际部署就用的这种方式。Linux服务器上建议用nohup让进程后台运行。nohup java -jar food-system-1.0.0.jar --spring.profiles.activeprod logs/app.log 21 查看日志用tail -f logs/app.log看到类似“Started Application in X seconds”的日志说明启动成功。启动失败时不要慌优先看日志里的Caused by信息多数情况下是端口被占用或数据库连接失败。5.3 前端构建与Nginx托管前端构建需要先安装依赖。npm install如果npm install因为依赖冲突失败比如报了ERESOLVE错误可以加参数跳过。npm install --legacy-peer-deps构建生产包。npm run build构建完成后在dist目录生成静态文件。用rsync或者scp把整个dist目录传到服务器上放到Nginx配置的root目录。Nginx配置是部署中最关键的一环。一个兼顾静态托管和接口反代的server块如下。server { listen 80; server_name your-domain.com; root /var/www/food/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }location /里的try_files $uri $uri/ /index.html;这一行绝对不能省。前端用了history模式刷新/user这类子路由时服务器找不到对应文件会返回404。try_files的作用是把匹配不到的文件路径回退到index.html由前端路由接管处理刷新404的问题就解决了。location /api/的proxy_pass把后端接口反向代理到8080端口。注意proxy_pass后面没有路径只有域名端口表示完整保留原始URI的/api前缀转发给后端。配置改完执行nginx -t检查语法然后nginx -s reload生效。5.4 初始化数据与系统验证系统首次启动时如果数据库里没有演示数据页面会显得很空。我在SQL文件里预设了20条热门美食数据覆盖川菜、粤菜、甜点、饮品四个分类每条数据绑定3到5个标签。这样首页推荐、分类筛选、标签匹配推荐启动后就能看到直观效果。完整验证流程按顺序走一遍访问服务器IP确认Nginx正常启动首页能打开。打开浏览器开发者工具的Network面板刷新首页确认/api/recommend接口返回200且数据完整。注册一个新账号随机收藏几道菜返回首页刷新确认“猜你喜欢”板块出现了标签匹配推荐。执行搜索“辣”或者“鸡”确认搜索结果能正常展示。找到菜品详情页刷新几次然后返回列表页看点击量是否增加。这套流程跑通说明从数据库到后端再到前端整条链路都工作了。6. 高频问题排查实录与避坑经验6.1 MySQL连接类问题SSL连接错误是初学者最容易碰到的问题。连接MySQL 8.0时Java连接器默认启用SSL但本地开发环境的MySQL证书链通常不完整就会抛出SSLHandshakeException。最直接的解决方式是在JDBC连接URL加参数禁用SSL并指定时区。url: jdbc:mysql://localhost:3306/food_system?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8时区问题表现为报错“The server time zone value йʱ is unrecognized”。原因是MySQL的时区配置和Java默认时区不一致添加serverTimezoneAsia/Shanghai就能解决。我建议不管本地还是服务器环境都把这个参数固定在连接串上有效规避半夜排查“莫名其妙报错”的痛苦。端口被占用表现为SpringBoot启动时提示Port 8080 was already in use。Windows上用netstat -ano | findstr 8080找到占用进程PID任务管理器结束进程Linux上执行lsof -i:8080定位进程再kill。6.2 MyBatis相关报错**Invalid bound statement (not found)**是我见过最频繁的MyBatis报错。出现原因通常是Mapper接口和XML文件之间的绑定关系断裂。排除思路依次是检查XML文件的namespace是否与Mapper接口全限定名一致检查Mapper接口方法名与XML中id是否一致检查Mapper接口的返回类型和XML的resultType是否匹配检查target/classes目录下是否真的打包进去了XML文件。如果XML没被编译进去检查pom.xml里有没有把mapper目录配置成资源目录。SQL日志不输出对排查问题很不方便。确认配置里设置了log-impl: org.apache.ibatis.logging.stdout.StdOutImpl。设置完还不行检查日志框架的level配置把对应包级别调整到DEBUG。二级缓存导致脏数据在美食系统里主要体现为食品分类修改后菜品列表仍显示旧分类名。解决方式是拆掉不必要的二级缓存。这个教训很典型MyBatis的二级缓存是namespace级别的跨表查询结果不在缓存失效策略的管理范围内容易留下脏数据。除非是纯字典类数据否则不要贸然开启。6.3 前端构建与部署问题npm install报错大多出在依赖版本冲突上。Vite项目经常是因为某个依赖的PeerDependency和当前版本不一致最省心的处理方式是用--legacy-peer-deps参数跳过严格校验。Vue Router history模式刷新404的报错信息通常是“Cannot GET /user”这就是我在部署章节强调的try_files配置的原因。排查时先确认Nginx配置里有没有这一行没有的话补上即可。后端能访问但前端接口404先看前端请求的路径是相对路径还是绝对路径。如果前端请求的是http://localhost:8080/api/food/list开发阶段会跨域如果请求的是/api/food/list确认Vite代理是否生效生产环境下确认Nginx的location /api/规则是否配置正确。接口返回502通常是因为后端没有启动或者Nginx代理目标地址写错。用curl http://127.0.0.1:8080/api/recommend直接访问后端地址先排除后端本身的问题再排查代理层。6.4 安全性优化与线上经验密码存储绝对不要用明文。我在用户表里存的是BCrypt加密后的哈希值Spring Security提供的BCryptPasswordEncoder生成即使数据库泄露密码本身也不会直接暴露。接口层面的校验也很重要。注册接口需要校验用户名唯一性和密码长度收藏接口需要校验菜品ID是否存在重复收藏需要返回友好提示而不是数据库报错。项目上线后我增加了两个低成本但收益明显的优化。一是把数据库密码改成通过环境变量注入即使代码仓库被公开也不会暴露服务器信息。二是配置了简单操作日志记录关键词搜索和收藏操作出现异常时可以快速定位是哪个用户、什么时间、什么操作出了问题。常见问题速查表问题现象最可能原因处理方式MySQL连接报SSL错误或乱码缺少SSL关闭与时区参数连接串加useSSLfalseserverTimezoneAsia/ShanghaiMyBatis报Invalid bound statementnamespace或id不匹配逐个核对接口与XML的绑定关系前端刷新子路由404history模式未配置回退Nginx location / 加try_files回退index.htmlnpm install失败依赖PeerDependency冲突使用--legacy-peer-deps参数重装后端能启动但页面数据为空数据库初始化失败检查SQL文件是否导入、表名是否匹配接口返回502后端未启动或代理地址错误curl后端地址排除后再检查Nginx推荐结果为空用户没有收藏记录后端自动走热度推荐兜底写到最后的几句实在话这套系统从数据库建模到前端联调再到服务器部署最容易让人半夜崩溃的不是复杂的推荐算法反而是时区参数没加、Nginx回退规则漏写这类小细节。我把这些教训整理成了一张速查表希望你能绕开我踩过的坑。如果后续想扩展我个人觉得最值得做的两件事是引入对象存储MinIO来管理用户上传的菜品图片替代当前的图片外链让系统完全自持再加一个Redis缓存层把热点推荐结果和热门菜品做缓存降低数据库压力。这两项改动都不影响现有架构属于渐进增强。按照自己做项目的需求在现有基础上继续改就好不用追求一步到位。