从“毕业设计”和“简历项目”这两个关键词出发,这个系统几乎踩中了所有经典要素:Java 后端、Vue3 前端、前后端分离、MySQL 增删改查。市面上类似的模板很多,但真正能跑通、能讲清楚、能经得起面试追问的版本却不多。这篇文章不准备做“源码搬运工”,而是把搭建过程中的关键决策、代码结构和最容易翻车的细节拆开讲清楚。无论你是准备毕设答辩,还是想在简历里加一个完整项目,读完这篇可以少走很多弯路。
1. 为什么“美食菜谱管理系统”会成经典项目
先说结论:这类系统不是技术含量最高的项目,但它是“性价比最高”的练手项目。
从学生视角看,它覆盖了前后端分离项目最核心的链路:前端页面发起请求,后端接收参数、查询数据库、返回 JSON,前端再把数据渲染到表格里。这条链路看似简单,却正好命中企业招聘时最常问的“你独立做过什么项目”和“你怎么理解前后端交互”。把这条链路讲透,比堆砌一堆从来没有运行过的“高并发秒杀”更能打动面试官。
从技术视角看,美食菜谱这个业务域很适合做增删改查演示。菜品有名称、分类、图片、简介、步骤等字段,天然适合做列表展示、分类筛选、条件搜索和表单校验。相比传统的“学生管理系统”或“图书管理系统”,菜谱主题更容易在界面设计上做出视觉效果,也更容易在后期扩展出收藏、评分、模糊搜索等加分功能。
从务实视角看,所谓“2 小时搭建”并不是夸张,但它有前提:后端用 SpringBoot 快速生成基础工程,前端用 Vue3 + Vite 跳过繁琐的 Webpack 配置,数据库表结构设计得足够简单。只要环境装好、依赖版本不冲突,跑通一个最小闭环确实可以控制在两小时以内。前提是理解每一步在做什么,而不是盲目复制粘贴。
这篇文章默认你已经有 Java 基础,会基本 Maven 操作,知道 Vue 组件的大致写法。如果还不太熟,建议先照着代码跑通,再回头补基础。
2. 系统功能与技术选型
2.1 功能清单
美食菜谱管理系统的核心功能不算多,但足够支撑一个完整的毕业设计:
| 功能模块 | 说明 |
|---|---|
| 菜品管理 | 菜品的增删改查,包括名称、分类、图片、简介、步骤 |
| 分类管理 | 菜系分类(川菜、粤菜、西餐等),菜品与分类关联 |
| 模糊搜索 | 按菜品名称或描述关键字查询 |
| 分页展示 | 菜品列表分页显示,前后端配合完成 |
| 数据统计 | 简单的分类统计或总数统计,用于首页展示 |
这些功能可以对标常见的“后台管理系统”套路。没有权限体系、没有 Redis 缓存、没有消息队列,但作为基础项目反而更合适——过于复杂的功能会让答辩失去重点。
2.2 技术栈说明
| 层次 | 技术选型 | 说明 |
|---|---|---|
| 后端框架 | SpringBoot | 快速构建 RESTful API |
| 数据库 | MySQL | 存储菜品和分类数据 |
| 持久层 | Spring Data JPA / MyBatis-Plus | 二选一,文章以 MyBatis-Plus 演示 |
| 前端框架 | Vue3 | Composition API 组合式写法 |
| 构建工具 | Vite | 开发调试效率高 |
| UI 组件 | Element Plus | 表格、表单、弹窗现成组件 |
| HTTP 请求 | Axios | 前端调用后端接口 |
| 前后端联调 | 开发代理 | Vite devServer 代理到 SpringBoot 端口 |
这里必须先说明一个容易踩坑的点:前后端分离不等于两个项目分开放就行。它要求前后端通过 HTTP 接口通信,意味着必须解决跨域问题、接口约定问题、数据格式统一问题。实操中很多新手卡住的不是增删改查本身,而是跨域、参数接收方式不一致、JSON 格式对不上。
3. 环境准备与前置条件
3.1 基础环境清单
| 工具 | 建议版本 | 说明 |
|---|---|---|
| JDK | JDK 8 或 JDK 17 | SpringBoot 2.x 用 JDK 8 稳妥,3.x 建议 JDK 17 |
| Maven | Maven 3.6+ | 后端依赖管理 |
| Node.js | Node 16.20+ 或 Node 18+ | 前端构建环境 |
| MySQL | MySQL 5.7 或 8.x | 数据库 |
| IDE | IntelliJ IDEA 或 VS Code | 后端推荐 IDEA,前端 VS Code 也可以 |
| 数据库工具 | Navicat / DataGrip / 命令行 | 建库建表 |
版本号这里不写死,因为不同电脑环境差异很大。一个更稳妥的判断是:SpringBoot 3.x 虽然新,但有些旧教程和依赖不兼容,做毕设求稳优先选 SpringBoot 2.7.x;Vue3 搭配 Vite 版本用官方默认即可。
3.2 环境安装后的验证
安装完成后,不要急着写代码,先确认环境都可用。
后端对项目采用"存储过程+定时任2s部署"的思路,DATABASE 增删改查由存储过程完成,前端对项目采用"存储过程+定时任2s部署"的思路;但后端对项目在验证时先使用一个简单的 REST 接口,而前端则用一个基础页�面验证。
在命令行分别执行:
java -version mvn -v node -v npm -v mysql --version能看到版本输出基本就没问题。如果命令提示找不到,先检查 PATH 环境变量,这是新手最常见的启动失败原因。
3.3 数据库准备
创建一个数据库,例如food_recipe,字符集选择utf8mb4,因为菜谱步骤里可能出现特殊字符,utf8mb4比utf8兼容性更好。
CREATE DATABASE IF NOT EXISTS food_recipe DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE food_recipe;然后创建分类表和菜品表。两张表之间是一对多关系。先写分类表,再写菜品表。
CREATE TABLE category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL COMMENT '分类名称', sort INT DEFAULT 0 COMMENT '排序号' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜系分类表'; CREATE TABLE recipe ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category_id BIGINT NOT NULL COMMENT '分类ID', name VARCHAR(100) NOT NULL COMMENT '菜品名称', image VARCHAR(255) COMMENT '图片地址', description TEXT COMMENT '菜品简介', steps TEXT COMMENT '制作步骤', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';如果采用 MyBatis-Plus,表名可以直接用实体类映射。要注意category和recipe都不是 MySQL 保留字,可以放心使用,但为了稳妥,建议在配置里开启驼峰映射功能。
4. 后端 SpringBoot 实现
4.1 创建 SpringBoot 项目
后端工程结构建议按 Controller、Service、Mapper、Entity 分包,清晰易懂。用 IDEA 直接创建 Spring Initializr 项目,或者从 Spring 官网生成压缩包导入。
核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>如果你的 SpringBoot 版本是 2.7.x,数据库驱动写mysql-connector-java;如果是 3.x,默认是com.mysql:mysql-connector-j。这个细节经常会让人启动报错。
4.2 配置文件 application.yml
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/food_recipe?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto三个容易忽略的地方:
第一,serverTimezone必须设置。不设这个参数,某些 MySQL 版本下连接会报时间错误。
第二,map-underscore-to-camel-case开启后,数据库字段create_time才能自动映射到实体类属性createTime。
第三,log-impl配置成 StdOutImpl,开发阶段可以在控制台看到 SQL 语句,排错非常方便,正式环境再关掉。
4.3 实体类和 Mapper
实体类直接用 Lombok 简化代码:
package com.example.food.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("recipe") public class Recipe { @TableId(type = IdType.AUTO) private Long id; private Long categoryId; private String name; private String image; private String description; private String steps; private LocalDateTime createTime; }Mapper 接口只需要继承BaseMapper:
package com.example.food.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.food.entity.Recipe; import org.apache.ibatis.annotations.Mapper; @Mapper public interface RecipeMapper extends BaseMapper<Recipe> { }MyBatis-Plus 把单表增删改查都封装好了,不需要手写 XML。这里特别说明:使用 MyBatis-Plus 不等于不懂 SQL。面试官如果问你底层原理,你要能说出BaseMapper是如何通过泛型推断表名和字段的,以及Wrapper是如何构造条件的。
4.4 Controller 和 Service
后端对外提供 RESTful API,界面给前端一个统一的返回结构。创建一个通用返回类Result:
package com.example.food.common; import lombok.Data; @Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }菜品接口:
package com.example.food.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.food.common.Result; import com.example.food.entity.Recipe; import com.example.food.mapper.RecipeMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/recipe") public class RecipeController { @Autowired private RecipeMapper recipeMapper; @GetMapping public Result<Page<Recipe>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { QueryWrapper<Recipe> wrapper = new QueryWrapper<>(); if (keyword != null && !keyword.isEmpty()) { wrapper.like("name", keyword).or().like("description", keyword); } wrapper.orderByDesc("create_time"); Page<Recipe> result = recipeMapper.selectPage(new Page<>(page, size), wrapper); return Result.success(result); } @GetMapping("/{id}") public Result<Recipe> detail(@PathVariable Long id) { Recipe recipe = recipeMapper.selectById(id); return Result.success(recipe); } @PostMapping public Result<Recipe> add(@RequestBody Recipe recipe) { recipeMapper.insert(recipe); return Result.success(recipe); } @PutMapping public Result<Recipe> update(@RequestBody Recipe recipe) { recipeMapper.updateById(recipe); return Result.success(recipe); } @DeleteMapping("/{id}") public Result<Void> delete(@PathVariable Long id) { recipeMapper.deleteById(id); return Result.success(null); } }这个 Controller 把分页查询、详情、新增、修改、删除都覆盖了。需要注意两个点:
第一,@RequestParam用来接收查询参数,@PathVariable用来接收路径参数,@RequestBody用来接收 JSON 请求体。前端传参方式的区别就在这里,面试中经常被问。
第二,模糊搜索的or()使用要小心。like("name", keyword).or().like("description", keyword)实际生成的是name LIKE ? OR description LIKE ?,如果不加外层括号,后续再加其他条件时 SQL 可能会出现逻辑错误。更稳妥的写法是用and(wrapper -> wrapper.like(...).or().like(...)),但在这里示例已经能满足需求。
Controller 直接调 Mapper 是为了演示方便。实际工程建议拆出 Service 层,至少保留一个事务控制的入口,后续扩展收藏、评论时不会让 Controller 越来越重。
4.5 跨域配置
前后端分离项目联调时,浏览器会拦截跨域请求。最直接的解决方式是在后端加 CORS 配置。可以写一个配置类:
package com.example.food.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns("*")在 SpringBoot 2.7 和 3.x 里兼容性都比较好。开发阶段放开所有来源,等部署上线再收紧到具体域名,这是更稳妥的做法。
启动后端后,浏览器直接访问http://localhost:8080/api/recipe?page=1&size=10,如果能看到 JSON 数据,说明后端接口已经通了。
5. 前端 Vue3 实现
5.1 创建 Vite 项目
npm create vite@latest food-web -- --template vue cd food-web npm install项目创建完成后,安装运行时依赖:
npm install element-plus @element-plus/icons-vue axios vue-router如果网络下载慢,可以配置 npm 镜像源,但注意只改变包下载源,不要影响代码逻辑。
5.2 项目结构
保持简洁:
src/ api/ recipe.js router/ index.js views/ RecipeList.vue RecipeEdit.vue App.vue main.js前端没有必要把目录拆得太散,对毕设和简历项目来说,清晰比复杂更重要。
5.3 封装 axios 请求
// 文件路径:src/api/request.js 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.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, (error) => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request基于接口定义封装菜品请求:
// 文件路径:src/api/recipe.js import request from './request' export function getRecipeList(params) { return request.get('/recipe', { params }) } export function addRecipe(data) { return request.post('/recipe', data) } export function updateRecipe(data) { return request.put('/recipe', data) } export function deleteRecipe(id) { return request.delete(`/recipe/${id}`) }这里有一个前后端协作的关键点:封装的getRecipeList传的是params,最后生成的是?page=1&size=10&keyword=xxx;而新增和修改走的是data,axios 会序列化成 JSON 放在请求体里。正好对应后端@RequestParam和@RequestBody两种接收方式。如果前后端约定不一致,接口就会报 400 或 415,所以先想清楚参数格式再写代码。
5.4 设置开发代理
前端请求写的是/api/recipe,但后端端口是 8080,前端 Vite 默认端口是 5173,直接请求会跨域。开发阶段最常见的做法是在vite.config.js里配置代理:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })配置之后,前端请求/api/recipe会被代理转发到http://localhost:8080/api/recipe,浏览器看起来是同源请求,避免跨域问题。这种方式也比后端 CORS 更适合平时开发,因为后端 CORS 配置越收越紧时,前端代理不受影响。
5.5 菜品列表页
列表页是整套系统的核心界面,包含搜索栏、表格、分页和操作按钮。
<!-- 文件路径:src/views/RecipeList.vue --> <template> <div class="recipe-list"> <el-card> <div class="search-bar"> <el-input v-model="keyword" placeholder="输入菜品名称或描述" clearable style="width: 300px" @keyup.enter="handleSearch" /> <el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="success" @click="handleAdd">新增菜品</el-button> </div> <el-table :data="tableData" border stripe> <el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="name" label="菜品名称" min-width="140" /> <el-table-column prop="categoryId" label="分类ID" width="100" /> <el-table-column prop="description" label="简介" show-overflow-tooltip min-width="200" /> <el-table-column prop="createTime" label="创建时间" width="180" /> <el-table-column label="操作" width="220" fixed="right"> <template #default="{ row }"> <el-button size="small" @click="handleEdit(row)">编辑</el-button> <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" layout="total, prev, pager, next" @current-change="loadData" /> </el-card> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { getRecipeList, deleteRecipe } from '../api/recipe' import { useRouter } from 'vue-router' const router = useRouter() const keyword = ref('') const page = ref(1) const size = ref(10) const total = ref(0) const tableData = ref([]) const loadData = async () => { const res = await getRecipeList({ page: page.value, size: size.value, keyword: keyword.value }) tableData.value = res.data.records total.value = res.data.total } const handleSearch = () => { page.value = 1 loadData() } const handleAdd = () => { router.push({ name: 'RecipeEdit' }) } const handleEdit = (row) => { router.push({ name: 'RecipeEdit', query: { id: row.id } }) } const handleDelete = (row) => { ElMessageBox.confirm(`确定删除菜品「${row.name}」吗?`, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(async () => { await deleteRecipe(row.id) ElMessage.success('删除成功') loadData() }) } onMounted(() => { loadData() }) </script> <style scoped> .search-bar { display: flex; gap: 12px; margin-bottom: 16px; } </style>这段代码展示了几个 Vue3 常见的写法:<script setup>组合式 API、模板里的v-model:current-page分页绑定、通过useRouter()跳转路由。如果你的 Vue2 基础偏强,第一次接触script setup会有点不习惯,但它确实是 Vue3 官方推荐的主流写法,也是面试中的高频考点。
5.6 编辑页
新增和编辑共用一个页面,通过路由参数是否存在判断是编辑还是新增。
<!-- 文件路径:src/views/RecipeEdit.vue --> <template> <el-card> <template #header> <span>{{ form.id ? '编辑菜品' : '新增菜品' }}</span> </template> <el-form :model="form" label-width="100px" style="max-width: 600px"> <el-form-item label="菜品名称"> <el-input v-model="form.name" placeholder="请输入菜品名称" /> </el-form-item> <el-form-item label="分类ID"> <el-input-number v-model="form.categoryId" :min="1" :max="999" /> </el-form-item> <el-form-item label="图片地址"> <el-input v-model="form.image" placeholder="https://..." /> </el-form-item> <el-form-item label="菜品简介"> <el-input v-model="form.description" type="textarea" :rows="3" /> </el-form-item> <el-form-item label="制作步骤"> <el-input v-model="form.steps" type="textarea" :rows="6" /> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSave">保存</el-button> <el-button @click="routerBack">返回</el-button> </el-form-item> </el-form> </el-card> </template> <script setup> import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { getRecipeList, addRecipe, updateRecipe } from '../api/recipe' const route = useRoute() const router = useRouter() const form = ref({ id: null, name: '', categoryId: 1, image: '', description: '', steps: '' }) const loadDetail = async () => { const id = route.query.id if (!id) return const res = await getRecipeList({ page: 1, size: 1, keyword: form.value.name }) // 这里仅为演示,实际请根据 id 调用详情接口 } const handleSave = async () => { if (!form.value.name) { ElMessage.warning('菜品名称不能为空') return } if (form.value.id) { await updateRecipe(form.value) } else { await addRecipe(form.value) } ElMessage.success('保存成功') router.push({ name: 'RecipeList' }) } const routerBack = () => { router.push({ name: 'RecipeList' }) } onMounted(() => { if (route.query.id) { form.value.id = Number(route.query.id) // 在实际项目中,这里应调用详情接口回显数据 } }) </script>必须坦诚说明:这个编辑页的“数据回显”逻辑我简化了。正常项目应该新写一个/recipe/{id}详情接口,在onMounted中通过detail(id)查询后把数据填进form。这里只保留了新增和保存的主流程,原因是很多初学者会把回显逻辑写得过度复杂。正式项目请补全详情接口,下面给出后端 Controller 中对应的detail方法。
5.7 路由配置
// 文件路径:src/router/index.js import { createRouter, createWebHistory } from 'vue-router' import RecipeList from '../views/RecipeList.vue' import RecipeEdit from '../views/RecipeEdit.vue' const routes = [ { path: '/', name: 'RecipeList', component: RecipeList }, { path: '/edit', name: 'RecipeEdit', component: RecipeEdit } ] const router = createRouter({ history: createWebHistory(), routes }) export default router完整的入口文件:
// 文件路径:src/main.js import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import App from './App.vue' import router from './router' const app = createApp(App) app.use(ElementPlus) app.use(router) app.mount('#app')到这里,前端最基本的页面、路由、请求封装已经齐全,可以开始联调。
6. 运行验证与联调
6.1 启动后端
在 IDEA 里直接运行启动类,也可以使用命令行:
mvn spring-boot:run观察控制台日志,出现类似Started DemoApplication in xxxx seconds表示启动成功。再访问接口:
curl http://localhost:8080/api/recipe?page=1&size=10如果返回 JSON 中包含records、total字段,说明后端正常。如果前端返回的字段结构和后端对不上,比如后端返回data.records,前端却读data.list,列表就一定空白。
6.2 启动前端
在food-web目录下执行:
npm run dev浏览器访问http://localhost:5173。输入一个关键字搜索,查看 Network 面板中的请求路径:
- 前端请求
/api/recipe?page=1&size=10&keyword=xxx - 代理转发到
http://localhost:8080/api/recipe?page=1&size=10&keyword=xxx - 后端返回 JSON
- 前端渲染到表格
只要这四步链路能走通,增删改查功能就基本完成。
6.3 验证用例
| 功能 | 操作 | 预期结果 |
|---|---|---|
| 分页查询 | 打开列表页 | 表格展示第一页数据,分页组件显示总条数 |
| 模糊搜索 | 输入“红烧”并回车 | 表格只显示名称或描述包含“红烧”的菜品 |
| 新增 | 点击新增,填写表单并保存 | 列表新增一条记录 |
| 修改 | 点击编辑,修改名称并保存 | 列表对应数据更新 |
| 删除 | 点击删除,确认弹窗点确定 | 列表数据减少一条 |
如果某一项失败,优先打开浏览器的开发者工具看请求状态码。常见的状态码含义:
| 状态码 | 含义 | 常见原因 |
|---|---|---|
| 404 | 接口路径不存在 | 前端请求路径或后端类上 RequestMapping 写错 |
| 400 | 请求参数格式错误 | 前端传 JSON,后端要表单参数,或字段名不一致 |
| 415 | 请求体类型不支持 | 缺少@RequestBody,或 Content-Type 不对 |
| 500 | 后端异常 | 查看 IDEA 控制台异常堆栈 |
实际开发中,前后端联调 70% 的问题都出在参数接收方式和字段名不一致上。先看请求 Payload,再看后端日志,比盲目改代码有效得多。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 后端启动报数据库连接错 | MySQL 未启动、密码不对、url 写错 | 检查 MySQL 服务,确认 application.yml 配置 | 修正连接配置,确认数据库已创建 |
启动报Access denied for user | 用户名或密码错误 | 在 Navicat 中测试连接 | 修改密码配置,或重置数据库密码 |
| 前端请求接口 404 | 后端接口路径与前端baseURL拼接不一致 | 查看浏览器 Network 请求完整 URL | 统一/api前缀,检查@RequestMapping |
| 返回数据正常但表格不显示 | 后端返回结构是{code,message,data},前端读取字段不对 | 在 axios 拦截器打印response | 确认res.data.records还是res.data.list |
| 新增或修改后数据没变化 | 前端传参是 JSON,后端没有@RequestBody | 查看后端日志参数是否为空 | 后端方法加@RequestBody,前端序列化格式保持 JSON |
| 时间字段显示格式不对 | 后端 LocalDateTime 序列化格式没配置 | 看接口返回的 JSON | 配置 jackson date-format 或前端做格式化处理 |
| 删除后列表不刷新 | 删除成功但没调用loadData() | 查看控制台是否有删除请求 | 在删除回调后重新查询列表 |
这组问题是从大量实际调试中提炼出来的。如果你的项目报错不在表里,还有一个通用排错思路:从请求入口开始,一层层确认。前端看 Network,后端看 Console,数据库看 SQL 日志,往下走总能定位到问题层。
8. 最佳实践与工程建议
8.1 目录与命名规范
后端不要把所有类都堆在controller一个包里。建议按功能模块拆分,比如recipe包里有RecipeController、RecipeService、RecipeMapper、RecipeEntity。前端页面组件用大驼峰命名,方法和变量用驼峰,路由路径用小写。这些细节面试官扫一眼代码就能看出来。
8.2 参数校验不能只依赖前端
前端表单校验只是体验,后端必须做基础参数校验。比如新增菜品时,菜品名称不能为空,分类 ID 必须存在。可以用 Spring 的@Validated注解,也可以在 Controller 里手动判断。无论用哪种,核心原则一致:后端永远不能信任前端传过来的数据。做毕设时可能觉得没必要,但面试官问“如何防止脏数据入库”时,答案就在这里。
8.3 接口统一返回结构
所有接口统一返回Result结构,好处是前端可以写统一的拦截器处理错误和加载状态。如果一会儿返回Map,一会儿返回String,前端就没法统一处理。这个设计看似简单,但很多真实团队都做得不彻底。
8.4 数据库操作注意事项
- 删除功能在生产环境不要硬删除,建议加
deleted字段做逻辑删除。 - 图片 URL 不要直接存 Base64 大文本,存 URL 路径就好。
- 表字段名避免使用 MySQL 保留字,比如
order、group。 - 大批量数据操作前先备份,这个项目虽然数据量不大,但习惯要从平时养成。
8.5 面向面试的准备
把这个项目写进简历时,不要写“负责前后端分离美食网站开发”这种空话。可以写成:
- 独立设计 MySQL 数据库表结构,完成菜谱与分类的增删改查接口。
- 基于 Vue3 + Element Plus 实现后台管理页面,封装 axios 请求并解决开发跨域问题。
- 使用 MyBatis-Plus 简化数据访问层开发,理解分页查询和条件构造器底层原理。
面试官大概率会追问的问题包括:跨域是怎么产生的?@RequestBody和@RequestParam区别?MyBatis-Plus 为什么不写 SQL 也能查询?Vue3 的ref和reactive区别?分页插件底层如何实现?这些内容都能在本文中找到对应落点,建议针对每个问题准备一个简短的、结合本项目场景的回答。
8.6 生产部署思路
本地跑通以后,如果想把项目部署到服务器,有两种常见路径:
路径一,前端执行npm run build生成dist静态文件,用 Nginx 部署,后端打成 jar 包用java -jar运行,Nginx 再把/api请求代理到后端端口。
路径二,如果不想用 Nginx,可以直接把前端dist目录放进 SpringBoot 的src/main/resources/static下,打成同一个包部署。但这会失去前后端分离的意义,更适合演示和兜底,不适合体现工程能力。
如果想要简历项目完整度更高,建议选路径一,并重点写明 Nginx 配置、jar 包启动命令和进程管理方式,这是很多面试官认可的工程能力。
9. 总结与后续学习方向
2 小时跑通这个项目,真正帮助你建立的不是“我会写增删改查”,而是理解一条完整的前后端协作链路:数据库表如何设计、后端如何暴露接口、前端如何请求数据、数据如何通过 JSON 流动。这条链路是无数 Web 项目的骨架,以后不管切换到什么业务领域,骨架都是类似的。
跑通之后,不要停在“能运行”这一步。你可以继续做三件很有价值的事:第一,把分类表真正做成下拉选择,替换掉现在的前端手工输入分类 ID;第二,补充菜品详情的独立接口和页面路由,完善数据回显;第三,给项目加一个简单的登录页面和拦截器,让后端接口只有登录后才能访问。这三件事做完,项目的完整度和面试表现力会再上一个台阶。
如果时间允许,还可以继续研究 SpringBoot 的拦截器实现权限校验、MyBatis-Plus 的分页插件的拦截原理、Vue3 组件通信和路由守卫。这个项目虽然不大,但每深入一层,都能延伸到面试题库里最常出现的问题。把代码放进简历之前,最好先在本地从头到尾独立操作一遍,确保每一步都心里有数。能讲清楚的项目才有价值,否则它只是你硬盘里一段没有灵魂的压缩包。