温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系名片!
一、项目背景与意义
随着生活节奏加快和健康饮食意识提升,越来越多用户希望在家中快速做出可口饭菜,但网络上菜谱信息分散、质量参差不齐、缺乏互动与个性化推荐。基于 Spring Boot + Vue3 构建一个菜谱分享网站,既能满足用户浏览、检索、收藏、评论菜谱的需求,也能为内容创作者提供发布与运营入口,具有较好的实用价值与技术实践意义。
本项目采用前后端分离架构:后端使用 Spring Boot 提供 RESTful API,前端使用 Vue3 + Vite 构建单页应用,数据库使用 MySQL,缓存使用 Redis,图片资源使用对象存储或本地静态资源服务。整体结构清晰,便于扩展与二次开发。
二、系统功能需求分析
2.1 用户角色划分
- 普通用户:注册登录、浏览菜谱、搜索筛选、收藏点赞、评论评分、个人中心管理。
- 内容创作者:发布菜谱、编辑菜谱、上传步骤图、查看数据统计。
- 管理员:用户管理、菜谱审核、分类与标签管理、评论管理、轮播与推荐位配置。
2.2 核心功能模块
| 模块 | 主要功能 |
|---|---|
| 用户模块 | 注册、登录、JWT 鉴权、个人信息修改、头像上传、密码重置 |
| 菜谱模块 | 菜谱发布、编辑、删除、详情展示、步骤图、食材清单、烹饪时长与难度 |
| 检索模块 | 关键词搜索、按分类/标签/难度筛选、按热度与时间排序、分页 |
| 互动模块 | 收藏、点赞、评论、评分、回复 |
| 后台管理 | 用户管理、菜谱审核、分类管理、评论治理、数据看板 |
三、技术选型与整体架构
3.1 技术栈
- 后端:Spring Boot 3.x、Spring Security + JWT、MyBatis-Plus、MySQL 8、Redis、MinIO(或本地文件存储)。
- 前端:Vue3、Vite、Vue Router、Pinia、Element Plus、Axios。
- 部署:Nginx 反向代理前端静态资源与后端接口,Docker Compose 编排 MySQL、Redis、MinIO 与应用服务。
3.2 系统架构图
flowchart LR
A[Vue3 前端 SPA] -->|Axios / REST| B[Nginx 反向代理]
B --> C[Spring Boot 应用]
C --> D[(MySQL)]
C --> E[(Redis 缓存)]
C --> F[MinIO 对象存储]
C --> G[日志与监控]
3.3 分层设计
后端按 Controller、Service、Mapper 三层组织,统一使用 Result<T> 封装响应体,通过全局异常处理器统一返回错误码;使用拦截器或 Spring Security 过滤器完成 JWT 校验,并将当前登录用户信息放入 ThreadLocal 上下文,便于业务层获取。
四、数据库设计
核心表包括用户表、菜谱表、食材表、步骤表、分类表、标签表、收藏表、评论表。以下给出关键表结构示例。
sql
CREATE TABLE `recipe` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`title` VARCHAR(120) NOT NULL,
`cover_url` VARCHAR(255) DEFAULT NULL,
`description` VARCHAR(500) DEFAULT NULL,
`category_id` BIGINT DEFAULT NULL,
`difficulty` TINYINT DEFAULT 1 COMMENT '1简单 2中等 3困难',
`cook_minutes` INT DEFAULT 0,
`author_id` BIGINT NOT NULL,
`status` TINYINT DEFAULT 0 COMMENT '0待审核 1已发布 2已下架',
`view_count` INT DEFAULT 0,
`like_count` INT DEFAULT 0,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY `idx_category` (`category_id`),
KEY `idx_author` (`author_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `recipe_step` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`recipe_id` BIGINT NOT NULL,
`step_no` INT NOT NULL,
`content` VARCHAR(500) NOT NULL,
`image_url` VARCHAR(255) DEFAULT NULL,
KEY `idx_recipe` (`recipe_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
五、后端核心接口实现
5.1 统一响应与全局异常
java
@Data
public class Result<T> {
private int code;
private String message;
private T data;
public static <T> Result<T> ok(T data) {
Result<T> r = new Result<>();
r.setCode(200);
r.setMessage("success");
r.setData(data);
return r;
}
public static <T> Result<T> fail(int code, String message) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMessage(message);
return r;
}
}
5.2 菜谱分页查询接口
java
@RestController
@RequestMapping("/api/recipes")
@RequiredArgsConstructor
public class RecipeController {
private final RecipeService recipeService;
@GetMapping
public Result<PageResult<RecipeVO>> page(@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long categoryId) {
return Result.ok(recipeService.pageQuery(page, size, keyword, categoryId));
}
@GetMapping("/{id}")
public Result<RecipeDetailVO> detail(@PathVariable Long id) {
return Result.ok(recipeService.getDetail(id));
}
@PostMapping
@PreAuthorize("hasRole('USER')")
public Result<Long> create(@RequestBody @Valid RecipeCreateDTO dto) {
return Result.ok(recipeService.create(dto));
}
}
5.3 服务层分页与缓存
java
@Service
@RequiredArgsConstructor
public class RecipeServiceImpl implements RecipeService {
private final RecipeMapper recipeMapper;
private final StringRedisTemplate redisTemplate;
@Override
public PageResult<RecipeVO> pageQuery(long page, long size, String keyword, Long categoryId) {
Page<Recipe> p = new Page<>(page, size);
LambdaQueryWrapper<Recipe> wrapper = new LambdaQueryWrapper<Recipe>()
.eq(Recipe::getStatus, 1)
.eq(categoryId != null, Recipe::getCategoryId, categoryId)
.like(StringUtils.hasText(keyword), Recipe::getTitle, keyword)
.orderByDesc(Recipe::getCreateTime);
Page<Recipe> result = recipeMapper.selectPage(p, wrapper);
List<RecipeVO> list = result.getRecords().stream()
.map(RecipeVO::from)
.collect(Collectors.toList());
return new PageResult<>(result.getTotal(), list);
}
@Override
public RecipeDetailVO getDetail(Long id) {
String key = "recipe:detail:" + id;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return JsonUtils.parse(cached, RecipeDetailVO.class);
}
RecipeDetailVO vo = recipeMapper.selectDetail(id);
redisTemplate.opsForValue().set(key, JsonUtils.toJson(vo), Duration.ofMinutes(30));
return vo;
}
}
六、前端核心实现
6.1 项目结构与路由
javascript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', component: () => import('../views/Home.vue') },
{ path: '/recipe/:id', component: () => import('../views/RecipeDetail.vue') },
{ path: '/publish', component: () => import('../views/RecipeEdit.vue'), meta: { auth: true } },
{ path: '/login', component: () => import('../views/Login.vue') }
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to) => {
const token = localStorage.getItem('token')
if (to.meta.auth && !token) return '/login'
})
export default router
6.2 Axios 封装与统一鉴权
javascript
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
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(
(res) => {
const { code, message, data } = res.data
if (code === 200) return data
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
},
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
location.href = '/login'
}
return Promise.reject(err)
}
)
export default request
6.3 菜谱列表页组合式写法
vue
<script setup>
import { ref, watch } from 'vue'
import request from '../utils/request'
const list = ref([])
const total = ref(0)
const page = ref(1)
const keyword = ref('')
async function load() {
const data = await request.get('/recipes', {
params: { page: page.value, size: 10, keyword: keyword.value }
})
list.value = data.records
total.value = data.total
}
watch([page, keyword], load, { immediate: true })
</script>
<template>
<el-input v-model="keyword" placeholder="搜索菜谱" clearable />
<el-row :gutter="16">
<el-col v-for="item in list" :key="item.id" :span="6">
<el-card @click="$router.push(`/recipe/${item.id}`)">
<img :src="item.coverUrl" style="width:100%" />
<h3>{{ item.title }}</h3>
<p>{{ item.cookMinutes }} 分钟 · 难度 {{ item.difficulty }}</p>
</el-card>
</el-col>
</el-row>
<el-pagination v-model:current-page="page" :total="total" layout="prev, pager, next" />
</template>
七、关键难点与优化思路
- 图片上传与访问:前端直传 MinIO 预签名 URL,后端只负责签发凭证与记录元数据,避免大文件经过应用服务器。
- 热点菜谱缓存:详情页使用 Redis 缓存并设置随机过期时间,防止缓存雪崩;更新菜谱时主动删除缓存。
- 搜索性能:数据量增大后可将关键词检索迁移到 Elasticsearch,MySQL 仅保留精确筛选。
- 安全控制:接口按角色鉴权,富文本内容做 XSS 过滤,上传文件校验类型与大小。
- 审核流程:菜谱发布后进入待审核状态,管理员审核通过才对外可见,降低违规内容风险。
八、部署与运行
yaml
version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: recipe
ports: ["3306:3306"]
redis:
image: redis:7
ports: ["6379:6379"]
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: admin123
ports: ["9000:9000", "9001:9001"]
app:
build: .
depends_on: [mysql, redis, minio]
ports: ["8080:8080"]
前端执行 npm install && npm run build 生成静态资源,交由 Nginx 托管,并将 /api 反向代理到后端服务即可完成上线。










九、总结
本文围绕基于 Spring Boot + Vue3 的菜谱分享网站,从需求分析、技术选型、数据库设计到前后端核心代码与部署方案进行了完整梳理。该方案结构清晰、扩展性好,可作为课程设计、毕业设计或中小型内容社区项目的参考实现。后续可继续补充个性化推荐、菜谱评分排行、移动端适配与消息通知等能力。