
最近帮一个朋友搭了一套“厨艺交流平台”技术栈正好就是标题这串关键词Java SpringBoot Vue3 MyBatis MySQL前后端分离源码跑通之后又给他整理了完整文档。这类型项目在毕设、个人作品集、外包单里出现频率极高但很多拿到源码的人其实卡在“不知道整体怎么串联”上后端接口怎么暴露前端怎么请求数据库的几张表之间怎么关联今天我就以一套可运行的厨艺交流平台为例把从设计到联调部署的关键环节完整拆一遍顺带把平时容易踩的坑也一并说清楚。先说清楚这套东西能做什么一个用户注册登录后可以发布自己的拿手菜浏览别人的菜谱按分类找菜对感兴趣的菜谱进行点赞、收藏和评论还可以进入个人中心管理自己发布的内容。整体就是一个小而完整的社区型业务闭环。对于正在学后端开发、准备面试、或者需要交付一个完整全栈项目的朋友这套源码和拆解思路都有不错的参考价值。整个系统不依赖第三方重型组件一台装了 JDK、Maven、Node.js 和 MySQL 的电脑就能跑起来。1. 项目整体设计与技术选型1.1 为什么是SpringBoot Vue3 MyBatis选这套组合不是因为“流行”而是它在中小型业务系统里最平衡。SpringBoot 负责把后端服务跑起来内置 Tomcat把配置简化到极致MyBatis 负责和数据库打交道SQL 由你控制比 JPA 更直观排查问题也简单Vue3 配合 Vite 带来的开发体验非常顺组件化写法写页面也清晰。前后端分离的核心在于后端只出 JSON 数据前端通过 HTTP 请求拿数据渲染页面两者通过接口约定协作互不干扰。有人会问为什么不用 JSP 做服务端渲染因为现在的前端交互复杂度已经很高了尤其是厨艺社区这种有多页面、评论、点赞、用户中心操作的应用分离式开发还能让前端和后端各自动态发布部署也更灵活。后端只管 API前端可以是网页可以之后套小程序或者 App这一点非常重要。1.2 数据库设计几张核心表撑起整个业务这套厨艺交流平台的业务不复杂但表结构设计直接影响后续开发效率。我习惯先梳理核心实体用户、菜谱、分类、评论、收藏、点赞。对应的 SQL 表如下表名说明核心字段user用户表id, username, password, nickname, avatar, create_timecategory分类表id, name, sortrecipe菜谱表id, user_id, category_id, title, cover, description, steps, ingredients, view_count, create_timecomment评论表id, recipe_id, user_id, content, create_timefavorite收藏表id, user_id, recipe_id, create_timelike_record点赞表id, user_id, recipe_id, create_time注意 recipe 表的 steps 和 ingredients我建议直接用 TEXT 类型存结构化文本比如 JSON 数组字符串。这样前端拿到后解析就能渲染步骤列表和用料清单避免为子数据再建多张表。对于这种轻量级平台完全够用也比做三张关联表更省事。另外点赞表和收藏表都做了唯一约束比如UNIQUE KEY uk_user_recipe (user_id, recipe_id)防止重复数据。数据库默认使用 InnoDB 引擎utf8mb4 字符集因为菜谱描述里常有人输入 emoji 表情utf8mb4 才存得下。1.3 后端项目结构分包清晰比炫技重要后端我用 Maven 构建工程结构按照传统的 controller / service / mapper / entity 分层。src/main/java/com/cookhub/ ├── controller # 接收前端请求返回 JSON ├── service # 业务逻辑层处理业务规则 ├── mapper # MyBatis 接口定义数据库操作方法 ├── entity # 数据库实体类 ├── dto # 数据传输对象接口参数和返回体 ├── config # 配置类拦截器、跨域等 ├── common # 统一返回结果、异常处理、工具类 └── CookHubApplication.java这种结构的核心好处是每一层只干一件事。controller 里不做业务判断service 里不拼 SQLmapper 里不写业务逻辑。很多人把项目写乱就是因为 controller 里堆了太多代码最后改需求时牵一发动全身。分层清晰之后团队协作和后续扩展都很方便。2. 后端核心实现与关键细节2.1 SpringBoot 工程搭建与依赖配置创建工程可以到 Spring Initializr 生成也可以直接手工建 Maven 项目。我建议直接使用 SpringBoot 2.7.x 或 3.x。如果使用 JDK8稳妥选择 2.7如果使用 JDK17选 3.x。下面给出核心的pom.xml依赖片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency /dependenciesSpringBoot 3.x 需要把mybatis-spring-boot-starter换成 3.0 以上版本而且不能再直接使用spring-boot-starter-web里的旧 API这个要特别注意后面常见问题里再展开。配置写在application.ymlserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cookhub?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.cookhub.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置必须要开它能把数据库的create_time自动映射成实体里的createTime省掉大量手写 resultMap 的功夫。log-impl在调试阶段可以打印 SQL正式环境记得去掉避免日志刷屏。2.2 MyBatis XML 映射与动态 SQL接口和 XML 分开是 MyBatis 常见做法尤其对于稍微复杂一点的查询比如菜谱列表需要关联分类名称、用户昵称、点赞数、收藏数用注解拼 SQL 会非常痛苦。我在RecipeMapper.xml里写了一个用于分页查询的 SQLselect idselectRecipePage resultTypecom.cookhub.dto.RecipeListDTO SELECT r.id, r.title, r.cover, r.description, c.name AS categoryName, u.nickname AS authorName, r.view_count, COUNT(DISTINCT lr.id) AS likeCount, COUNT(DISTINCT fr.id) AS favoriteCount FROM recipe r LEFT JOIN category c ON r.category_id c.id LEFT JOIN user u ON r.user_id u.id LEFT JOIN like_record lr ON r.id lr.recipe_id LEFT JOIN favorite fr ON r.id fr.recipe_id where if testcategoryId ! null AND r.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (r.title LIKE CONCAT(%, #{keyword}, %) OR r.description LIKE CONCAT(%, #{keyword}, %)) /if /where GROUP BY r.id, r.title, r.cover, r.description, c.name, u.nickname, r.view_count ORDER BY r.create_time DESC /select这个 SQL 里的LEFT JOIN很重要因为用户未点赞、未收藏时关联表里没有记录如果使用INNER JOIN会直接把这些菜谱丢掉。GROUP BY配合COUNT(DISTINCT ...)可以避免点赞和收藏两个一对多关系叠加后产生的重复行。很多人第一次写这种统计查询会漏掉 DISTINCT结果一个赞被数成好几个数据看着很离谱。2.3 用户登录鉴权与密码安全处理用户模块是整个系统的入口。密码绝对不允许明文存库我用的方案是 MD5 加盐。虽然有人觉得 MD5 不够安全但作为学习项目完全够用生产环境可以用 BCrypt但为了简洁这里我演示一下加盐思路public static String encrypt(String password, String salt) { String source password salt; return DigestUtils.md5DigestAsHex(source.getBytes(StandardCharsets.UTF_8)); }注册时为用户生成一个随机盐值例如 UUID 前 8 位然后将password salt加密后存入数据库。登录时从数据库查出盐值再对输入的密码做同样加密比对结果。登录成功后后端使用 Session 保存用户状态并通过 HTTP Only Cookie 把 SessionID 交给浏览器。这种方式在前后端分离下也适用只要前端请求携带 Cookie 即可。我额外加了一个LoginInterceptor统一拦截需要登录才能访问的接口。比如发布菜谱、点赞、收藏、评论这些操作如果 Session 里没有用户信息就返回 401 和统一提示信息。拦截器配置里要注意放行登录注册和首页菜谱列表等公开接口否则前端一进来就会被拦闹出“首页都打不开”的笑话。2.4 文件上传与图片存储方案菜谱封面图是刚需。最简单的方案是上传到本地磁盘然后在数据库里存访问路径。SpringBoot 里配置一个虚拟路径映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: System.getProperty(user.dir) /upload/); } }前端上传文件时后端接口用MultipartFile接收生成新的文件名防止路径冲突使用 UUID 加原始扩展名。文件保存后返回给前端一个相对路径比如/upload/202501/xxxx.jpg。前端保存菜谱时把这个路径写入 recipe 表的 cover 字段。注意文件上传目录最好放在项目根目录之外否则重新打包或者版本更新时可能被清掉我一般单独建一个upload目录并在配置里指定。这种方式的好处是不需要额外引入对象存储服务本地跑通成本最低。如果准备部署到云服务器之后可以平滑换到 OSS 或 MinIO只需改文件存储的工具类前端路径约定保持不变。3. 前端 Vue3 开发与接口对接3.1 Vite 初始化项目与工程结构前端使用 Vue3 配合 Vite比 Vue CLI 快很多。初始化命令npm create vitelatest cookhub-web -- --template vue cd cookhub-web npm install npm install vue-router4 pinia axios element-plus我用的是 Vue Router 4 做路由Pinia 做状态管理Element Plus 做 UI 组件库。工程结构如下src/ ├── api/ # 接口请求函数 ├── assets/ ├── components/ # 通用组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态存储 ├── views/ # 页面组件 ├── utils/ # 工具函数 └── App.vue在厨艺平台里我最常使用的是 Element Plus 的卡片、分页、表单和消息提示。比如菜谱广场用el-card展示每一道菜列表下方用el-pagination做分页。这里有个经验分页参数要和后端约定好我习惯用pageNum和pageSize返回体带total总数。后端封装一个PageResult对象前端page变化时重新请求接口逻辑非常清晰。3.2 基于 Axios 的请求封装与统一异常处理前端每个页面都去写fetch会很繁琐我用 Axios 封装了一个公共请求模块统一配置 baseURL、超时时间、请求拦截器、响应拦截器。import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) 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 { ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } ) export default request前后端统一约定返回体格式{ code: 200, msg: success, data: ... }。后端使用Result类封装所有接口响应前端拿data直接用出错了统一弹提示。这种约定能避免前端每个人写不同的处理逻辑非常关键。跨域问题在开发阶段通过 Vite 的 proxy 解决。在vite.config.js里server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/recipe/list会被代理到后端http://localhost:8080/api/recipe/list。生产部署时再用 Nginx 做反向代理。跨域的坑我后面详细说。3.3 核心页面拆解菜谱广场、详情页、个人中心菜谱广场页面是用户第一眼看到的界面核心是筛选和列表。顶部分类 tab点击切换到对应分类中间搜索框按关键词查询下面菜谱卡片展示封面、标题、作者、点赞数。页面加载时调用getRecipePage接口参数带上pageNum、pageSize、categoryId、keyword。查询结果通过reactive对象维护分页变化时重新拉数据。详情页是核心交互页面。除了展示菜谱基本信息还要展示用料清单、步骤列表、评论区。这里我用了三个 Tab 来切换“用料”“步骤”“评论”避免页面过长。页面初始化时同时请求菜谱详情、点赞状态、收藏状态。注意点赞和收藏按钮的状态需要登录后才能判断所以接口里返回liked和favorited两个布尔值后端根据当前登录用户查询。前端拿到状态后高亮按钮点击时调用点赞或取消点赞接口用乐观更新策略先改前端状态再请求接口请求失败再回滚体验更跟手。个人中心展示用户发布的菜谱列表和收藏列表。这里需要注意接口必须从 Session 中获取当前用户 ID而不是让前端传用户 ID否则任何人都可以看别人的私密数据。后端代码里不要信任前端传的 userId关键数据要从登录态中取。4. 前后端联调与部署上线全流程4.1 本地开发环境配置与联调开发环境我推荐手动同时启动前后端。后端直接用 IDEA 启动前端在cookhub-web目录下执行npm run dev然后访问http://localhost:5173。前后端联调最容易出问题的是接口路径不一致。我一般会在后端 Controller 里统一加上/api前缀然后前端 proxy 也匹配/api这样就保证开发环境和生产环境路径一致。以下是一个典型的 ControllerRestController RequestMapping(/api/recipe) public class RecipeController { GetMapping(/list) public ResultPageResultRecipeListDTO list( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, Integer categoryId, String keyword) { return Result.success(recipeService.queryPage(pageNum, pageSize, categoryId, keyword)); } }联调阶段我习惯在后端配置里打开 MyBatis SQL 日志这样前端一旦请求控制台就能看到实际执行的 SQL。如果返回数据不对直接定位是 SQL 错了、参数没传、还是返回结构不对效率高很多。4.2 打包部署方案前端 dist 放入后端前后端分离项目最常见的有两种部署方式。一种是用 Nginx 托管前端静态资源同时反向代理后端接口另一种是更简单的单服务方案把前端npm run build生成的dist目录放进 SpringBoot 的src/main/resources/static目录下然后只启动一个 8080 端口。这样访问http://ip:8080就能看到页面后端接口路径仍然是/api/**。我推荐学习阶段用第二种部署简单不容易出跨域问题。把dist拷入 resources 后需要重新打包。注意前端资源里如果有绝对路径可能需要调整vite.config.js的base: ./否则打包后资源路径找不到。如果用第一种 Nginx 方案配置核心是 location 块location / { root /var/www/cookhub; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/api/; }这个配置里try_files是为了配合 Vue Router 的 history 模式避免刷新页面时出现 404。如果路由是 hash 模式则不一定要配这个但 history 模式观感更好。4.3 MySQL 初始化脚本与数据导入我习惯在项目根目录放一个sql/init.sql把建库、建表、插入测试数据全部写在里面。这样拿到源码的人可以直接执行脚本跑起来不用靠猜。脚本开头一般是CREATE DATABASE IF NOT EXISTS cookhub DEFAULT CHARACTER SET utf8mb4; USE cookhub;然后创建表。测试数据我建议至少插入 5 个用户、5 个分类、20 条菜谱这样前端列表和分类筛选功能一跑起来就有真实感。写菜谱内容时可以多用中文贴近实际比如红烧肉、麻婆豆腐。导入数据用 MySQL 命令行或者 Navicat 都可以。如果遇到Data too long错误检查字段是不是 VARCHAR 太短像描述和步骤必须用 TEXT。5. 常见问题与排查经验5.1 数据库连接报错与时区问题本地运行时最常碰到的报错是数据库连接失败。第一个检查点MySQL 服务有没有启动。Windows 下可以在“服务”里看 MySQL 状态也可以用命令行mysql -uroot -p试连。第二个检查点application.yml里的密码和实际密码是否一致。第三个点就是时区问题MySQL 8.0 默认时区导致连接报错解决办法是 URL 上加上serverTimezoneAsia/Shanghai或者直接在 MySQL 里执行set global time_zone 08:00。另外一定要加useSSLfalse避免本机 SSL 连接问题。5.2 MyBatis 映射文件不生效很多人把RecipeMapper.xml放在了src/main/java目录下但 Maven 默认不会把 java 目录下的 xml 文件打包到 classpath导致运行时报Invalid bound statement。解决办法有两种把 XML 放到src/main/resources/mapper目录或者在pom.xml中配置resources把 xml 也纳入打包。我用的是第一种简单直接。还有一种情况是mybatis.mapper-locations配置路径写错比如classpath:mapper/*.xml和实际目录不一致也会静默不报错但找不到 SQL。建议启动时留意日志是否打印了Loaded ... mapper信息。5.3 前端跨域与 Session 失效开发阶段如果不用 Vite proxy而是直接让前端请求http://localhost:8080就会触发跨域。此时浏览器会拦 Cookie 携带导致 Session 一直失效登录后刷新又变成未登录。解决办法是我前面说的 Vite proxy请求路径保持同源。如果使用了 Nginx也要配好/api的proxy_pass。实在要用后端 CORS 配置需要注意allowCredentials(true)同时allowedOrigin不能是*必须是具体的源地址。否则浏览器一样拦截 Cookie这是很多人调试登录功能时最容易忽视的坑。5.4 打包后页面空白或404前端npm run build之后如果直接打开本地dist/index.html大概率是空白页因为资源路径都是绝对路径。解决办法是修改vite.config.js里的base: ./让资源路径变成相对路径。如果部署后刷新页面出现 404这说明 Vue Router 使用了 history 模式而 Nginx 没有配置try_files。改成 hash 模式也能解决但我更建议 Nginx 补上 location 配置这样 URL 更好看。还有一个很隐蔽的问题SpringBoot 单服务部署时如果前端路由用的 history 模式后端没有做 fallback刷新时会直接抛 404。此时需要在后端加一个转发规则让非/api的路径都到index.html。不过如果你把dist放进 static 并且用 hash 模式就省心很多。6. 从源码到项目我个人的经验沉淀这套厨艺交流平台是我近期完整走完的一条链路从数据库设计到后端接口再到前端页面和部署每一步都能看到明显的成败点。我觉得最有价值的部分不是 CRUD 本身而是以下几点第一点是前后端接口约定要提前定死返回格式统一用Result字段命名统一用驼峰联调时能少掉一半扯皮第二点是数据库设计不用过度设计但外键逻辑要清楚多对多关系要学会用关联查询而不是建一堆冗余字段第三点是一切安全相关的地方都不能偷懒比如密码加密、Session 校验、上传文件类型校验哪怕只是个人项目也要按规范写因为这些习惯会带进工作里。最后分享一个小技巧当你拿到一个前后端分离项目源码时不要急着改代码先看三样东西——数据库脚本、application.yml、前端接口封装。把这三样理清整个项目的地图就在脑子里了。遇到接口报错先从浏览器 Network 里看请求状态码和响应体再定位到底层 SQL 还是业务逻辑。这比盲猜变量名有效得多。厨艺交流平台这种规模的项目非常适合做全栈理解的实践样本跑通一遍你对 SpringBoot、Vue3、MyBatis 的配合方式会有一个整体性的掌握。