
作为一个经常折腾前后端分离项目的开发者我深知“厨艺交流平台”这种带内容发布、互动评论、用户体系的系统是最适合用来完整练手 SpringBoot Vue3 这套技术栈的实战项目。它不像电商系统那么复杂但又覆盖了用户认证、文件上传、列表分页、关联查询、点赞收藏等几乎所有后台管理系统和内容社区的通用功能。这篇文章我想完整分享一下基于 SpringBoot Vue3 MyBatis MySQL 搭建厨艺交流平台源码的过程包括设计思路、表结构、核心接口、前端页面组织方式以及我在实际部署中踩过的一些坑和建议。这套系统适合正在学习 SpringBoot 和 Vue3 的同学也适合需要快速搭一个内容社区原型用于毕业设计或项目演示的朋友。我会尽量从“为什么这么做”的角度去讲而不是单纯堆代码。你需要理解的不只是每个接口怎么写更是前后端如何约定数据、状态如何流转、权限怎么控制、以及哪些地方容易出问题。1. 整体设计与技术选型思路1.1 厨艺交流平台的核心需求拆解做任何一个项目之前先把“用户到底想在这上面干什么”想清楚。厨艺交流平台的核心用户无非两类一类是分享菜品做法的人一类是找菜谱学做菜的人。那么围绕这两类人系统的核心功能就非常清晰了。第一是用户体系注册、登录、个人信息维护。这是任何社区的基石没有登录态点赞、收藏、评论这些互动行为就无法落地。第二是内容发布用户可以发布自己的菜谱包含标题、封面图、食材清单、步骤描述、分类标签。这是平台的“内容生产”核心也是后端设计中最能体现 CRUD 功力的部分。第三是互动反馈包括收藏、点赞、评论。这三个功能看似简单但需要考虑数据表怎么设计、接口怎么划分、列表页怎样展示热度这些细节决定了系统的可用性。第四是内容发现首页肯定需要一个菜谱列表支持按分类筛选、按热度/时间排序还要有关键词搜索。这就是典型的列表查询场景结合 MyBatis 动态 SQL 能写出很优雅的查询逻辑。当你把这些功能拆完之后你会发现这套系统其实就是一个小型内容社区的标准模板。把“菜谱”替换成“帖子”“文章”“视频”这套架构依然成立。所以我一直觉得厨艺平台是最适合练手的内容类项目业务复杂度刚刚好做完之后能力的迁移性非常强。1.2 为什么选择前后端分离很多传统项目还在用 Thymeleaf 或者 JSP 做服务端渲染但近几年的主流趋势明显是前后端分离。这个选择在厨艺交流平台这个场景下有非常实际的理由。首先是开发和调试的隔离。前后端分离之后后端只需要专心提供 JSON 接口前端在本地起一个 Node 服务就能开发页面不需要把整个 Java 应用跑起来才能看到界面效果。这对于多人协作尤其重要前端不用等后端写完才能干活只要接口文档定好了两边可以并行开发。其次是部署和扩展灵活。前端构建出来是一堆纯静态文件可以扔到 Nginx也可以放到 CDN。后端打包成 Jar 包独立运行。流量大了之后后端可以横向扩容前端完全不受影响。这一点对于将来想把系统从“毕设 demo”升级成“真正上线服务”来说非常关键。再者是技术栈的现代化。Vue3 Vite 的开发体验比传统模板引擎好太多组件化开发让页面复用性很高。比如菜谱卡片这个组件首页列表能用、搜索结果能用、我的收藏也能用一次写好在多个页面调用这就是组件化的魅力。而 Java 后端只需要提供 RESTful API不用关心数据最终怎么渲染职责边界非常清楚。当然前后端分离也会带来额外的复杂度比如跨域处理、Token 鉴权、联调成本等。这些我在后面章节都会详细讲到每一个问题都是可以预见的解决方式也相对成熟。1.3 技术栈角色拆解SpringBoot、Vue3、MyBatis、MySQL技术选型不能只看流行度更要看它在这个项目里具体解决什么问题。SpringBoot是整个后端的地基。它最大的价值在于“约定大于配置”通过自动装配机制省掉了大量 XML 配置。在厨艺平台这个项目里我只需要引入 Web、MySQL 驱动、MyBatis、Lombok 等依赖SpringBoot 就能把内嵌 Tomcat、数据源、MyBatis 工厂全部初始化好。这种开箱即用的体验确实让开发者把注意力放在业务逻辑而不是环境配置上。MyBatis是持久层框架它比 JPA 更灵活SQL 由自己掌控。厨艺平台的查询场景里有很多“按条件组合搜索”的需求比如按分类、关键词、排序方式组合查询。MyBatis 的动态 SQL 能力在处理这类场景时非常优雅if标签可以根据条件拼接 SQL 片段既避免了大量 if/else 判断也让 SQL 的优化空间完全握在开发者手里。此外MyBatis 的一级缓存和二级缓存机制对于读多写少的菜谱列表场景也能带来实际的性能提升。Vue3负责前端页面交互。相比 Vue2Vue3 的组合式 APIComposition API让逻辑复用变得更容易。厨艺平台的前端有很多“打开弹窗、提交表单、刷新列表”这种交互闭环用ref、reactive、onMounted这些 API 组合起来代码比 Options API 更清晰。同时 Vite 的开发服务器启动非常快热更新几乎是毫秒级大大提升了页面调试的效率。MySQL是数据存储的核心。厨艺平台的表结构并不复杂无非是用户表、菜谱表、评论表、收藏表、点赞表。但设计好主键策略、索引、字符集和表间关系能避免很多后期的性能和维护问题。比如菜谱表的 title 字段建普通索引分类字段建普通索引评论表的外键 user_id 和 recipe_id 要建联合索引大字段用 TEXT 类型存储步骤描述。这些看似基础的操作实际对查询效率影响极大。四个组件各司其职前后端通过 JSON 交换数据MySQL 存储所有持久化数据这就是整套系统最基础的运行逻辑。2. MySQL 数据库设计从需求到建表语句2.1 核心数据模型与字段规划数据库设计是整项目的“地基”表结构如果设计得不好后期写接口和前端联调时处处是坑。我建议先画 ER 图再动手建库最简单的工具就是 Navicat 或者 Draw.io把实体关系理清楚再落 SQL。厨艺交流平台通常包含以下核心表用户表 user存储登录账号和个人信息。基本字段是 id、username、password、nickname、avatar、bio、create_time。密码字段存储加密后的密文绝对不要存明文。头像字段存的是图片的 URL 地址而不是图片二进制数据这一点需要注意。菜谱表 recipe这是内容主表字段包括 id、user_id、title、cover_image、description、ingredients、steps、category_id、view_count、like_count、favorite_count、status、create_time、update_time。其中 ingredients 和 steps 我用的是 TEXT 类型用分隔符或者 JSON 格式存多条数据。这里有一个设计取舍如果需要“结构化地”按食材搜索那就要拆成子表如果只是展示直接把 JSON 字符串存起来反而更简单实用。分类表 categoryid、name、sort_order。菜品分类不需要树形结构一级分类就够了比如川菜、粤菜、烘焙、饮品等。菜谱表通过 category_id 关联分类表查询时做 join 就能拿到分类名称。评论表 commentid、recipe_id、user_id、content、parent_id、create_time。parent_id 字段支持楼中楼回复如果不做嵌套评论这个字段可以为空。收藏表 favoriteid、user_id、recipe_id、create_time。这个表要加唯一约束联合唯一索引 UK(user_id, recipe_id)防止同一个人重复收藏同一个菜谱。点赞表 like_record结构与收藏表基本一致id、user_id、recipe_id、create_time同样要加联合唯一索引。为什么把收藏和点赞拆成独立的表而不是直接在 recipe 表里存一个收藏数因为用户“是否已收藏”“是否已点赞”是一个需要快速查询的状态。如果在菜谱表里只存 count你还得额外查用户和菜谱的关联关系。拆成明细表之后前端判断状态直接查关联记录是否存在即可而数量统计可以通过 count 查询或者冗余字段来维护。我在实际项目里是两者结合明细表记录行为菜谱表冗余 count 字段用于列表展示更新通过事务保证一致性。2.2 建表 SQL 示例我直接给一套能落地的建表 SQL大家可以根据自己的需求微调CREATE DATABASE IF NOT EXISTS culinary_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE culinary_platform; CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT 密文密码, nickname VARCHAR(50) NOT NULL COMMENT 昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像地址, bio VARCHAR(255) DEFAULT NULL COMMENT 个人简介, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE recipe ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, title VARCHAR(100) NOT NULL, cover_image VARCHAR(255) NOT NULL, description VARCHAR(500) DEFAULT NULL, ingredients TEXT, steps TEXT, category_id INT DEFAULT NULL, view_count INT NOT NULL DEFAULT 0, like_count INT NOT NULL DEFAULT 0, favorite_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE comment ( id INT NOT NULL AUTO_INCREMENT, recipe_id INT NOT NULL, user_id INT NOT NULL, content VARCHAR(1000) NOT NULL, parent_id INT DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_recipe (recipe_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE favorite ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, recipe_id INT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_recipe (user_id, recipe_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个设计细节我特别说明一下。第一字符集必须用 utf8mb4因为需要支持 emoji 和生僻字用户昵称和评论内容里这些东西很常见用 utf8 会导致存储报错。第二MySQL 5.7 和 8.0 都支持DEFAULT CURRENT_TIMESTAMP这个语法非常方便不需要在 Java 代码里手动 set 创建时间。第三status 字段我建议保留将来做内容审核、管理员下架违规菜谱时不需要物理删除记录直接改状态即可。2.3 MyBatis 映射的思路与注意事项表结构确定之后接下来就是 MyBatis 的映射关系。我习惯使用 MyBatis 的注解XML 混合模式简单的查询用注解复杂的动态 SQL 用 XML。在UserMapper里简单查询可以直接用Select注解但菜谱的列表查询建议用 XML因为条件组合多注解写动态 SQL 可读性会很差。实体类方面直接用 Lombok 的Data注解简化 getter/setter 编写这是目前最流行的做法。这里有一个经验数据库字段的 snake_case 命名和 Java 驼峰命名风格不同记得在application.yml打开驼峰映射配置mybatis: configuration: map-underscore-to-camel-case: true这一行配置能让你避免写大量的resultMap。比如数据库的create_time可以自动映射到实体类的createTime属性非常省心。还有一个重要细节MySQL 连接串要加serverTimezoneAsia/Shanghai参数不然 Java 8 以后的日期时间类型和 MySQL 的 DATETIME 会出现时区偏差。这是很容易被忽略的问题项目中所有用户发布菜谱的时间显示都会差 8 小时排查起来挺费劲的。3. SpringBoot 后端核心模块与接口实现3.1 项目初始化与必要依赖创建 SpringBoot 项目时我推荐直接使用 start.spring.io 生成基础骨架。需要注意 SpringBoot 的版本选择这两年很多人被版本坑过。如果用的是 JDK 8选 SpringBoot 2.7.x 系列如果用 JDK 17 及以上可以选 SpringBoot 3.x。我的建议是除非你想体验最新版特性否则用 2.7.x 就够了这个版本对 MyBatis 的兼容性最稳定网上资料也多遇到问题更容易查到解决方案。pom.xml 里的核心依赖很简单大致包括dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency这里特别提醒MyBatis 官方针对 SpringBoot 的 starter 版本和 SpringBoot 版本要匹配。如果 SpringBoot 3.x 用的是mybatis-spring-boot-starter2.3.x启动时会报错因为 SpringBoot 3 的包命名改了。我在迁移项目时踩过这个坑后来换成了mybatis-plus-spring-boot3-starter或者等待官方适配版本才解决。所以大家新建项目时一定要确认版本对应关系。3.2 API 模块划分后端接口我按业务模块做了清晰划分所有接口路径都以/api开头方便网关层统一拦截处理。整体模块如下认证模块 AuthControllerPOST/api/auth/register、POST/api/auth/login、GET/api/auth/info。注册和登录逻辑校验用户名、密码登录成功生成 Token 返回给前端。用户信息接口通过 Token 解析出 userId然后查库返回完整用户信息。菜谱模块 RecipeControllerGET/api/recipe/list分页条件查询、GET/api/recipe/{id}详情、POST/api/recipe发布、PUT/api/recipe/{id}编辑、DELETE/api/recipe/{id}删除。这里要区分公开接口和需要登录的接口查询类接口匿名可用写操作必须带 Token。互动模块 InteractionControllerPOST/api/recipe/{id}/like、POST/api/recipe/{id}/favorite、POST/api/recipe/{id}/comment对应的取消操作和列表查询也在这个模块里。文件上传模块 FileControllerPOST/api/upload/image接收 MultipartFile 返回图片 URL。图片可以存本地磁盘也可以接对象存储服务。我先用本地存储演示后续上线再迁移。这种按业务模块划分 controller 的方式让每个 Controller 的职责非常清晰而且 Swagger 或者 Knife4j 的接口文档也很好组织。对于前后端分离项目我建议从一开始就引入接口文档工具不管是 Swagger 还是 Apifox能让前端清晰地看到每个接口的入参和出参联调效率提升非常明显。3.3 基于 JWT 的登录鉴权实现登录鉴权是厨艺平台这类社区项目的关键环节。我选的是 JWTJSON Web Token方案相比传统 Session 方案JWT 天然适合前后端分离服务端不需要存储会话状态Token 本身携带用户身份信息前端拿到之后存到 localStorage 里每次请求放到 Header 的Authorization字段后端拦截器校验即可。实现分成三个部分。第一是工具类负责生成和解析 Token。推荐使用 jjwt 库核心代码就几十行。Token 的 payload 里放 userId 和 username过期时间设置为 24 小时。为了安全性还可以加一个 secret 密钥部署时通过环境变量注入不要写死在源码里。第二是拦截器。SpringBoot 里实现一个HandlerInterceptor在preHandle方法里读取请求头的 Token解析成功后把 userId 放进ThreadLocal或RequestAttribute方便后续的 Controller 方法获取当前用户。解析失败就返回 401 状态码前端拦截到 401 后跳回登录页。第三是配置类把需要拦截的路径和不拦截的路径区分开。比如/api/auth/login和/api/auth/register不拦截/api/recipe/list、/api/recipe/{id}这种公开浏览接口不拦截。但是点赞、收藏、评论、发布这些写入操作以及“个人中心”相关的接口必须全部拦截。我用几个关键代码片段来示意便于大家理解Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token)) { Claims claims JwtUtil.parseToken(token); if (claims ! null) { request.setAttribute(userId, claims.get(userId)); return true; } } response.setStatus(401); return false; } }有一个细节是OPTIONS请求必须放行。因为浏览器在跨域请求时如果请求头里带了Authorization这种自定义头会先发一个预检请求如果不放行所有跨域请求都会直接失败。这个坑我见过太多人踩了。3.4 菜谱发布与详情接口的业务闭环菜谱发布接口是写操作的核心我详细说说实现逻辑。前端把表单数据组装成一个 JSON 对象提交字段包含 title、coverImage、description、ingredients数组、steps数组、categoryId。后端接收后做如下处理PostMapping public Result? create(RequestBody RecipeDTO dto, HttpServletRequest request) { Long userId (Long) request.getAttribute(userId); Recipe recipe new Recipe(); BeanUtils.copyProperties(dto, recipe, ingredients, steps); recipe.setUserId(userId); // ingredients 与 steps 转换后存储 recipe.setIngredients(JSON.toJSONString(dto.getIngredients())); recipe.setSteps(JSON.toJSONString(dto.getSteps())); recipe.setStatus(1); recipeMapper.insert(recipe); return Result.success(); }这里我说说为什么 ingredients 和 steps 要用 JSON 字符串存储。如果拆成食材表和步骤表查询一个菜谱详情时需要两次子查询再组装而步骤和食材本质上只是菜谱详情页的展示数据没有单独搜索的需求。用 JSON 字符串存储插入一次搞定查询一次拿到配合前端一次 JSON.parse开发效率是最高的。对于非强业务规则的内容社区来说这种“适度冗余”的设计是合理的。当然如果将来要做结构化搜索比如“找出所有用到五花肉的菜谱”那就得拆表了各有利弊量需权衡。查询详情接口时除了把菜谱数据查出来还需要带上作者信息比如昵称和头像。用 MyBatis 的关联查询实现select idselectDetail resultMapRecipeDetailMap SELECT r.*, u.nickname, u.avatar FROM recipe r LEFT JOIN user u ON r.user_id u.id WHERE r.id #{id} /select如果当前请求是登录状态还需要额外查 favorite 表和 like_record 表返回当前用户是否已经收藏或点赞。这一个逻辑就串联起了前面设计的明细表前端拿到数据后可以直接渲染按钮的选中状态体验非常好。3.5 MyBatis 动态 SQL 让列表查询更优雅菜谱列表页的核心需求是支持分类筛选、关键词搜索、按热度/最新排序、分页返回。用 MyBatis 的动态 SQL可以一个方法搞定所有查询组合select idselectListByCondition resultTypecom.example.entity.Recipe SELECT * FROM recipe WHERE status 1 if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR description LIKE CONCAT(%, #{keyword}, %)) /if choose when testsort hot ORDER BY like_count DESC, view_count DESC /when otherwise ORDER BY create_time DESC /otherwise /choose LIMIT #{offset}, #{pageSize} /select对应的 Service 层只需要传入一个 PageQuery 对象里面封装 pageNum、pageSize、categoryId、keyword、sort 这几个参数。MyBatis 会根据参数有没有值智能拼接 SQL 条件。这样避免了在 Java 代码里搞“四五个不同查询方法”或者用 QueryWrapper 类的写法SQL 的可读性与可控性都很好。分页我这里用了最原始的LIMIT #{offset}, #{pageSize}方式数据量小完全够用。如果数据量大了要接 PageHelper也只需要加一个依赖和一行 startPage 的调用即可。考虑到厨艺平台这种内容型项目初期数据量一般不会超过几万条所以简化设计是完全可取的。列表返回给前端时我会包装成统一的分页结果结构{ code: 200, data: { list: [...], total: 124, pageNum: 1, pageSize: 10 } }统一返回结构非常关键前后端对接的成本能降到最低。我在项目里定义了一个ResultT类所有接口都返回这个对象前端所有接口都用同一套逻辑解包开发体验会好很多。4. Vue3 前端页面组织与接口联调4.1 Vite 项目初始化和目录结构前端我使用 Vite 作为构建工具这是 Vue3 官方推荐的方式。创建项目特别简单只需要执行npm create vitelatest frontend -- --template vue就可以了。相比老的 Vue CLI 的 Webpack 配置Vite 的开发服务器冷启动速度非常快HMR 热更新几乎实时生效。厨艺平台这种中大型单页应用用 Vite 能极大提升开发效率。创建完项目之后我习惯把目录按照功能组织src/ ├── api/ │ ├── auth.js │ ├── recipe.js │ └── user.js ├── assets/ ├── components/ │ ├── RecipeCard.vue │ ├── CommentList.vue │ └── Pagination.vue ├── router/ │ └── index.js ├── store/ │ └── user.js ├── views/ │ ├── home/HomePage.vue │ ├── recipe/RecipeDetail.vue │ ├── recipe/RecipePublish.vue │ ├── user/LoginPage.vue │ ├── user/RegisterPage.vue │ └── user/UserCenter.vue └── utils/ └── request.js把 api 目录单独拆出来是我强烈推荐的一个习惯。所有请求后端的函数集中在一个文件里前端组件只需要 import 对应的函数不需要知道 URL 是什么也不用在每个组件里重复写 axios 配置。等到后端接口路径调整时只需要改 api 目录下的文件维护成本大幅降低。4.2 Axios 封装与 Token 管理前端与后端交互的核心是 Axios 封装。我写了一个utils/request.js在创建 axios 实例时统一设置 baseURL并在请求拦截器里读取 localStorage 中的 token添加到Authorization头。响应拦截器统一处理状态码如果返回 401 就清空本地 token 并跳转到登录页。这种封装模式几乎是所有 Vue 项目的标配不过多解释。axios.interceptors.response.use( (response) { const res response.data; if (res.code ! 200) { if (res.code 401) { router.push(/login); } return Promise.reject(new Error(res.message)); } return res; }, (error) { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );前端项目里最容易被忽略的一个问题是设计登录页时登录成功后要读取 backUrl 参数跳回用户原本想访问的页面。比如用户浏览到某个菜谱详情页点击点赞时发现未登录被重定向到了登录页登录成功后如果直接跳到首页体验是很差的。我在代码里通过路由 query 记录来源路径登录成功后跳转回去这种细节能明显提升用户觉得系统“懂我”的感觉。4.3 核心页面的实现思路首页、列表页和详情页分别对应前台和互动页面。这里以详情页为例它把整个系统的核心能力都串起来了。在详情页打开时需要并行请求两个接口菜谱详情数据和评论列表数据。Vue3 里可以用Promise.all并行请求减少等待时间。详情页顶部是菜谱封面和标题下面是作者信息、分类标签和操作按钮点赞、收藏、评论。再往下是食材清单和步骤列表从接口返回的 JSON 字符串中JSON.parse之后渲染。评论区的实现是一个递归组件支持楼中楼如果不想做太多递归可以只做一层评论全部平铺这样简单很多。菜谱发布是一个表单联动组件。食材清单是一个动态列表用户可以点击“添加食材”按钮前端动态向数组 push 一个空对象步骤则是一个个文本域加拖拽排序。提交时把数组序列化成 JSON 字符串与表单的其余字段一起提交。这里前端要注意的一点是用户上传封面图片和提交表单是两个不同的操作先调用上传接口拿到图片 URL再把这个 URL 作为表单字段一并提交给菜谱接口。这个流程需要在上传组件里处理好回调避免上传后拿不到 URL 导致提交失败。用户中心则是展示用户发布的菜谱列表和收藏列表。这两个列表本质上都是“根据 userId 查菜谱”区别在于收藏列表多了一个连表查询。前端完全可以复用同一个 RecipeCard 组件只是传不同的 API 函数即可。这也是组件化的好处。4.4 前后端联调中的跨域问题前后端分离开发时跨域是绕不开的问题。本地开发环境下前端跑在http://localhost:5173后端跑在http://localhost:8080端口不同浏览器会默认拦截跨域请求。我的做法分两种情况。如果是开发调试我推荐使用 Vite 的代理功能在vite.config.js中配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里请求/api/recipe/list开发服务器会代理到http://localhost:8080/api/recipe/list浏览器的地址没有变化自然不存在跨域问题。生产环境同样是 Nginx 把/api前缀反向代理到后端这一套逻辑是统一的。如果不想用代理也可以在后端配置 CORS 过滤器添加响应头允许跨域。这种方式配置简单但需要明确指定允许的来源域名不建议用*通配符否则生产环境会留下安全隐患。我个人的偏好是两个方案结合开发用 Vite 代理生产用 Nginx后端不放开全域名 CORS。4.5 前端部署vue 打包放进 SpringBoot 的两种方式很多同学在部署时会问“vue 打包后怎么放进 SpringBoot 里”。这里有两条路可以走各有优劣。第一种是纯静态部署也是我最推荐的线上方案。前端执行npm run build生成dist目录里面是 index.html、js、css 等静态文件。把 dist 目录交给 Nginx配置一个 location 指向它同时把/api的请求反向代理到 Java 后端。这种方式的优点是完全解耦前端资源可以放到 CDN 加速后端可以独立扩容。第二种是整合进 SpringBoot。把dist目录复制到src/main/resources/static下面重新打包 JarSpringBoot 就会自动把静态文件作为默认资源服务。这种方式适合小项目、演示环境、或者不想单独装 Nginx 的服务器。需要注意 Vue Router 如果使用 history 模式直接访问一个非根路径会 404 或白屏需要在后端配置一个ViewController把未匹配路径转发到 index.html。Controller public class SpaController { RequestMapping(value /{path:[^\\.]*}) public String redirect() { return forward:/index.html; } }由于我们的路由模式可以改用 hash 模式直接避免这个问题但不带 hash 的 URL 看起来更专业一些所以很多项目还是倾向 history 模式转发控制器。我习惯的做法是线上用 Nginx 处理 history 回退开发时用 Vite 代理Java 内置 Tomcat 模式主要用于本地演示。5. 常见问题与排查技巧实录5.1 数据库连接和驱动类问题MySQL 版本不同驱动类不一样。MySQL 5.x 用com.mysql.jdbc.DriverMySQL 8.x 用com.mysql.cj.jdbc.Driver。SpringBoot 会自动匹配但如果手动指定驱动类写错了启动就会报ClassNotFoundException。这个问题的排查方法是看启动日志第一屏驱动类找不到会非常明显。MySQL 8.0 手写驱动类时注意用最新的格式url 一定要加?useSSLfalse否则可能会报 SSL 连接错误。连接超时和时区问题这两个也很常见。表现为应用启动不报错但第一次查询数据会偶发Connection is not available之类的问题这是因为数据库连接空闲超时被服务器断开而连接池没有及时剔除。在配置数据源时加上连接池参数Druid 的testWhileIdle和testOnReturn都设为 true能有效避免。时区我已经在前面提过url 加serverTimezoneAsia/Shanghai就足够。5.2 文件上传和图片访问问题厨艺平台上传封面图后前端拿到 URL 可能是/files/2024/11/xxx.jpg这种相对路径。如果前端是独立部署的需要把图片访问也做一个代理或者把上传目录映射成静态资源目录。SpringBoot 里可以通过一个简单的配置类把本地磁盘目录映射为/files/**访问Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath file: System.getProperty(user.dir) /upload/; registry.addResourceHandler(/files/**).addResourceLocations(uploadPath); } }另外一个常见问题是前端图片能显示但刷新页面之后图片不见了——因为你上传到了本地磁盘但项目重启后上传路径变了。预防措施是不要把上传目录放在项目 src 目录下建议放到服务器统一的/data/upload目录并通过配置项指定这样即使 Jar 包升级图片数据也不会丢。5.3 列表查询的 N1 问题和性能优化首页菜谱列表需要展示每个菜谱对应的作者头像和昵称如果直接在循环里逐条查用户表就是典型的 N1 问题数据量大时接口会很慢。解决办法是先用一条 SQL 查出菜谱列表再根据列表里的 user_id 集合用IN查询一次性查出所有作者信息在 Java 代码里组装。或者直接使用 MyBatis 的嵌套查询 延迟加载也能解决。个人经验是对于列表页一次 JOIN 查询最简单高效select idselectListWithAuthor resultTypemap SELECT r.*, u.nickname, u.avatar FROM recipe r LEFT JOIN user u ON r.user_id u.id where r.status 1 ... /where ORDER BY r.create_time DESC LIMIT #{offset}, #{pageSize} /select顺便说一下索引的注意事项。如果列表页面经常按分类查category_id上的单列索引要有如果很多查询是分类时间排序可以考虑(category_id, create_time)联合索引。单字段索引能覆盖大部分场景数据量大了之后再根据慢查询日志优化。5.4 前端白屏和刷新 404 问题Vue 项目打包部署后出现“刷新 404”或者“直接访问某个路径白屏”绝大多数是路由模式导致的。前端如果用了createWebHistory本地 dev server 会自动处理 history fallback但生产 Nginx 不会。解决办法是在 Nginx 配置一个 try_fileslocation / { try_files $uri $uri/ /index.html; }如果你不想在服务器上折腾也可以把前端路由改成createWebHashHistoryURL 里会多一个#但对小项目来说更省事。我个人建议线上还是用 history 模式 try_files 方案毕竟 URL 干净清爽也显得更专业。5.5 SpringBoot 版本过高导致的启动失败这个真值得单独说因为我见过太多同学卡在这个问题上。SpringBoot 3.x 发布之后很多新手直接选最新版然后发现项目启动时报错尤其提示ClassNotFoundException: javax.annotation.PostConstruct或者 MyBatis 的MapperFactoryBean无法实例化。SpringBoot 3 基于 Java 17把 Jakarta EE 从javax包迁移到了jakarta包很多老版本的第三方库没有适配。解决办法有三个换成 SpringBoot 2.7.x确保所有依赖都使用适配 Jakarta 的版本或者刻意用 Java 17 并升级 MyBatis starter 到 3.x 以上。我的建议很明确如果你是做毕业设计或者练手项目选 SpringBoot 2.7.18 JDK 8 这个组合最省心资料多、坑少、性能完全够用。6. 我的实操心得与扩展方向做完整套厨艺交流平台之后我个人的体会是前后端分离项目真正考验人的地方其实不在“写代码”而在于“约定”和“边界”。接口路径、返回格式、状态码含义、Token 传递、跨域处理这些必须在项目第一天就定下来并让前后端所有人遵守。如果边做边改联调阶段会非常痛苦。我建议在进入开发前先用 Apifox 或者 Swagger 把核心接口的出入参文档先画出来哪怕只是粗略的 JSON 示例后面整个开发过程都会顺畅很多。再说一个扩展方向。目前这个平台的互动数值点赞数、收藏数、浏览量是直接在 MySQL 里更新的高并发下可能产生行锁竞争。如果你打算把它做成一个真正对外开放的社区建议后续引入 Redis 缓存热菜谱的 count 先更新到 Redis再异步落库菜谱列表的首页数据也可以缓存起来减少数据库压力。技术栈上还可以考虑增加 Elacticsearch 做全文检索让菜谱搜索更智能。但这些都属于锦上添花先把基于 MySQL 的核心链路跑通、理解透彻远比直接上中间件更重要。最后分享一个前端小技巧菜谱的上传图片组件建议做“先压缩再上传”用户用手机拍的照片动辄 5MB 以上直接上传会非常慢而且占用带宽。可以用 Canvas API 做图片等比缩放压缩到 1MB 以内再提交上传速度和服务器存储压力都会明显改善。这个小改动对用户体验的提升极其显著。希望这篇文章能给你提供一个清晰的从零到一搭建厨艺交流平台的参考路线。项目源码的骨架并不复杂核心是把每个模块的边界切清楚数据流理顺剩下的就是在这些框架中填充具体的业务细节了。做的时候耐下心来遇到报错首先看版本兼容再看日志堆栈一步步来你一定能把这个系统完整跑起来的。