基于 Spring Boot + Vue3 的菜谱分享网站设计与实现

温馨提示:本人主页置顶文章(点我)开头有 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 的菜谱分享网站,从需求分析、技术选型、数据库设计到前后端核心代码与部署方案进行了完整梳理。该方案结构清晰、扩展性好,可作为课程设计、毕业设计或中小型内容社区项目的参考实现。后续可继续补充个性化推荐、菜谱评分排行、移动端适配与消息通知等能力。

相关推荐
凤山老林1 小时前
Spring Boot 集成 TOTP 双因素认证与恢复码机制
java·spring boot·后端
vipxieliang1 小时前
ThinkPHP MVC 模式入门:从控制器到视图的完整实践
后端·php·thinkphp
小冯不疯1 小时前
物料主数据同步实战:从金蝶云星空到小满OKKICRM的单一策略深度拆解
java·大数据·数据库
vipxieliang1 小时前
Spring Boot 测试策略:单元测试、切片测试与集成测试
java·spring boot·后端
vipxieliang1 小时前
Go 1.24 工具链改进实战:工具依赖管理与 go vet 新分析器
后端·go
掘金者阿豪1 小时前
慢 SQL 优化别急着加索引:从订单分页查询聊聊 KES 执行计划
后端
涡能增压发动积1 小时前
AI 对一位老开发的副作用总结
后端
她的男孩1 小时前
条件分支两边审批一样,却发成两份待办:Forge Admin 审批设计器的公共节点合并
spring boot·低代码·工作流引擎
估值探索者1 小时前
【Python量化技术指标实战 04】布林带通道突破,怎么知道是真突破还是假突破?
java·开发语言·python·接口·股票数据api接口