☰
SpringBoot3+Vue3前后端分离:美食菜谱分享社区实战总结
2026/10/13 16:53:54 网站建设 项目流程

厨艺分享社区:记录与交流的美味家园。这是一个关于构建美食分享平台的实战项目总结,基于SpringBoot3 + Vue3 + MyBatis-Plus实现前后端分离架构。项目专注于菜谱发布、美食探索和互动交流三大核心场景。

我用了一个周末加两个工作日的晚上,把一套完整的B/S美食网站系统从零搭了出来。前后端分离,技术栈是SpringBoot 3 + Vue3 + MyBatis-Plus,数据库用的MySQL 8.0。选题很接地气——美食网站。对新手来说,业务逻辑不复杂但五脏俱全,能覆盖登录鉴权、内容发布、分页搜索、图片上传、评论互动这些核心能力;对要交课程设计或者找工作写项目经验的人来说,又是一套能讲清楚、能演示、能扩展的完整案例。

我之前带过不少朋友看这类系统,发现最大的问题不是写不出代码,而是“不知道怎么串起来”。前端Vue3的组件一套一套的,后端的Controller一堆一堆的,数据库的表一张一张的,但联调的时候总是状况百出。这篇文章我就把自己实际搭建这套系统时踩过的坑、想清楚的逻辑、最终落地的方案全整理出来,从设计到编码再到部署,一条线走完,你可以直接照着撸一套。

1. 项目整体设计与技术选型思路

先说说为什么用这套技术栈。SpringBoot在当前Java后端开发里基本是事实标准,它帮我们省掉了大量繁琐的XML配置,内嵌Tomcat让部署也变得极其简单。前端选Vue3是因为组合式API在逻辑复用和代码组织上比Vue2的选项式API舒服太多,配合Vite构建,开发时的热更新速度相当快。MyBatis-Plus则是在MyBatis基础上做的增强,单表CRUD基本上不用写SQL,分页插件也很好用,这能让开发节奏快不少。

这个系统我给它定位成三个核心板块:

  • 菜谱广场:用户可以浏览所有公开的菜谱,支持按菜系、按关键字搜索,支持分页
  • 个人中心:登录用户可以管理自己发布的菜谱、修改个人资料、查看自己收到的评论
  • 互动系统:登录用户可以对菜谱点赞、收藏、发表评论

为什么这么设计?美食网站的核心资产是内容(菜谱)和生产内容的人(用户)。菜谱广场解决的是“看什么”的问题,个人中心解决的是“怎么管理我的创作”的问题,互动系统解决的是“如何让社区有温度”的问题。三个板块互相支撑,既有内容展示的自然流量场景,又有用户行为的闭环。

前后端分离带来的直接好处是开发和部署的解耦。前端可以独立开发调试,我本地用Vite起的开发服务器配个代理就能把API请求转发到后端的8080端口,而后端只需要关心JSON数据的返回,不需要关心页面上怎么渲染。真到了部署阶段,前端build出来的纯静态文件丢给Nginx处理,后端打成一个Jar包跑起来,两边互不干扰,排查问题也轻松很多。

这套项目的数据库我设计了6张表:用户表、菜谱表、分类表、评论表、点赞表、收藏表。这里有个很重要的设计决策——点赞和收藏为什么要单独建表?很多新手喜欢在菜谱表里直接加一个like_count字段,每次点赞就UPDATE一下,看起来简单,但是无法回答“当前登录用户是否点赞过”这个问题。如果后面要做“我的点赞列表”功能,没有点赞表根本做不出来。所以,宁可多一张表,把用户行为记录下来,而菜谱表里的计数只是一个冗余的展示字段,两边的数据一致性通过事务来保证。

2. 数据库建模与核心表设计

数据库设计决定了一个项目能走多远。这套美食系统的库我命名为food_community,字符集用的utf8mb4,为什么不用utf8?因为MySQL的utf8最多支持3字节的字符,很多生僻字以及部分emoji都存不进去,菜谱名称里偶尔会出现“🍲”之类的表情符号,得用utf8mb4才保险。

2.1 用户表的设计要点

用户表是最基础的表,字段里值得说的是头像和简介。头像我存的不是图片文件本身,也不是Base64字符串,而是图片的URL路径。所有上传的图片统一定义一个映射路径,Nginx或者SpringBoot的静态资源映射会处理这个路径。简介字段给了个默认值“这个厨子很懒,什么都没写”,避免前端出现空指针。

密码字段怎么处理?直接用MD5加密肯定不行,这个已经是常识了,MD5在暴力破解面前几乎没有抵抗力。我用的BCrypt算法,它每次加密同一段明文会生成不同的密文,因为里面掺入了随机盐,验证的时候再用专门的方法做比对,安全性和易用性平衡得很好。Spring Security自带了BCryptPasswordEncoder,不过我没引入完整的Spring Security,因为那会带来很多不必要的拦截逻辑和配置复杂度,单独把加密工具抽出来用就行。

2.2 菜谱表和分类表的关系

菜谱表是核心业务表,它包含三块信息:基础信息(菜名、封面图、分类ID)、内容模块(食材清单、制作步骤)和统计模块(浏览量、点赞量、收藏量)。食材和步骤我选择用TEXT类型存储,里面用约定的分隔符把多个条目串起来。前端在展示的时候,用split方法拆成数组渲染成列表。

为什么不把食材和步骤单独拆成两张子表?在更复杂的系统里确实会这么做,但美食网站的菜谱内容属于“创建后基本不再结构化修改”的数据,拆表引入的连接查询成本大于收益。用约定分隔符存TEXT字段,实现量小,查询也快,对于这个体量的系统是合理的选择。

分类表只有三个字段:ID、分类名称、排序值。我预置了家常菜、川菜、粤菜、甜品烘焙、汤粥主食这几类,用户发布菜谱的时候用下拉框选择。这里有一个隐藏的细节——菜谱表的分类字段我用的是外键约束关联分类表的主键,但实际查询的时候,我更倾向于在SQL里用JOIN把分类名称带出来,而不是在业务层做二次查询。

2.3 评论表与用户行为表的索引设计

评论表相对简单,包含菜谱ID、用户ID、评论内容、父评论ID、评论时间。父评论ID用来支持楼中楼回复,如果为NULL就是一级评论。索引我建了(recipe_id, create_time)的联合索引,这是评论列表最常用的查询路径——查某道菜的全部评论、按时间倒序。

点赞表设计的核心是唯一约束,UNIQUE KEY (user_id, recipe_id)保证一个用户对同一道菜只能点赞一次。业务上,用户点击点赞按钮时,后端先尝试INSERT,如果成功说明之前没点赞过,点赞计数加一;如果抛出DuplicateKey异常,就执行DELETE,点赞计数减一。这样不需要先查询再判断再操作,一条路径就完成了状态的切换,也天然避免了并发情况下同一用户点出两个赞的脏数据。

收藏表同理,只是存储的字段多了一个收藏时间,方便在“我的收藏”页面按时间倒序展示。这个表比较简单,但它在业务上的意义是串联起了用户和菜谱之间的“长期关注”关系,后续做推荐系统的时候可以直接从这个表里挖掘用户偏好。

3. 后端核心实现与接口设计

后端我分了四层:Controller接收和校验请求、Service处理业务逻辑、Mapper负责数据库操作、DTO和VO负责数据传递。很多人写小项目会省掉DTO和VO,直接拿实体类Entity往前端怼,图省事。但实际操作下来你会发现,实体类里有些字段(比如用户密码的哈希值)是绝对不应该出现在接口返回里的,而且前端需要的某些字段(比如菜谱的分类名称)在实体类里根本没有。用VO做一层转换,既安全又灵活,这层操作不能省。

3.1 统一返回体与全局异常处理

前后端分离的项目,最怕的就是接口返回格式五花八门。有的接口返回一个对象,有的接口返回一个数组,报错的时候有的返回String、有的返回Map,前端处理起来心态直接爆炸。所以第一件事就是定义统一的返回体Result 。

@Data public class Result<T> implements Serializable { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { ... } public static <T> Result<T> error(String message) { ... } }

code我使用HTTP语义的状态码,200表示成功,400表示参数错误,401表示未登录,500表示服务器内部错误。前端axios拦截器里根据code做全局处理,401就跳登录页,400就弹出错误提示,200就正常放行。

全局异常处理我写了一个@RestControllerAdvice类,在这个类里拦截两类异常:业务异常(自定义的BizException,比如“菜谱不存在”“无权限编辑”)和系统异常(比如NullPointerException、SQLException)。业务异常返回对应的错误码和提示,系统异常统一返回500和“服务器开小差了”,并且把堆栈信息打到日志里方便排查。

这套机制最大的价值在于——Controller层终于可以只关注“参数拿到了,调Service,返回结果”,不用再写一堆try-catch或者if-else的容错代码。代码清爽了,维护性也上来了。

3.2 JWT登录鉴权与拦截器实现

登录这块我用的JWT(JSON Web Token)方案。流程是这样的:用户提交账号密码,后端用BCrypt校验密码,通过后生成一个JWT令牌返回给前端,前端存在localStorage里,之后每次请求都在HTTP头部的Authorization字段带上这个令牌。

JWT令牌的Payload部分我塞了用户ID和用户名,签名用HMAC-SHA256算法,密钥是我在配置文件里自定义的一长串随机字符串。为什么不塞太多信息进去?因为JWT是明文编码的(只是做了签名防篡改,内容本身可以解码),放太多敏感信息等于裸奔。用户头像和昵称这类会变化的信息,前端每次请求时从后端接口实时获取更可靠。

拦截器实现的是HandlerInterceptor接口,在preHandle方法里取请求头的Token做解析:

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token)) { try { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", claims.get("userId")); return true; } catch (Exception e) { // token无效或过期 } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }

在WebMvcConfigurer配置类里注册这个拦截器,excludePathPatterns放行登录、注册、首页菜谱列表、菜谱详情、图片访问这些公开接口。这里有个很容易踩的坑,放行的路径和Controller里定义的路径必须一致,特别是静态资源路径。我之前把上传目录映射到了/upload/**,结果忘了在exclude里加上,导致图片全被拦截器拦了,前端一片裂图。调试了好久才发现是this的问题。

3.3 菜谱发布的文件上传与内容组装

菜谱发布是系统的核心写操作,前端提交的数据分两部分:文字字段(菜名、分类ID、简介、食材清单、步骤描述)和一个封面图片文件。我实现的接口是一个multipart/form-data混合类型请求。

回到代码层面,Controller方法的签名是public Result<Long> createRecipe(@RequestPart("file") MultipartFile file, @RequestPart("recipe") RecipeForm form),前端用FormData对象把文件和数据塞进去一起Post。注意这里的@RequestPart,它和@RequestParam的区别在于前者专门处理multipart请求中的数据部分,而且能直接绑定JSON字符串到对象,用起来非常丝滑。

文件上传的保存路径我没有写成绝对路径,而是在application.yml里配成了一个可配置项upload.dir,配合一个自定义的静态资源映射方法,把本地磁盘路径映射到/upload/**访问路径上。这样部署到Linux服务器时只需要改配置文件里的路径,不用改动代码重新打包。

为了防止文件名重复导致互相覆盖,我用UUID重命名了存储的文件名,原始文件名只保留文件扩展名判断。图片校验方面,除了检查扩展名,我还用ImageIO验证了文件头信息,防止伪造后缀上传非图片文件。这个校验在真实的互联网系统里尤为重要——别人上传一个伪装成JPG的可执行文件,再配合服务器配置的漏洞,就能直接拿下服务器。个人项目虽然风险小,但规范养成要一开始就做对。

食材和步骤的组装我在Service层做了约定:

  • 食材:前端传来的是JSON数组字符串,比如[{"name":"五花肉","amount":"500g"},{"name":"冰糖","amount":"30g"}],后端解析后转成JSON字符串存进TEXT字段
  • 步骤:前端传来的是一个文本数组,每项是一步的描述,后端按换行符或者分隔符拼成一个长字符串

前端展示的时候,拿到的是一整块字符串,需要拆回数组渲染。这套方案虽然在数据完整性上比不上真正的结构化表设计,但在一个以展示和分享为目的的美食社区里,完全够用。复杂度是要控制在一个合理范围之内的。

3.4 列表分页与多条件检索

菜谱广场的列表页涉及分页、关键字搜索、分类筛选、排序等多重条件。我用MyBatis-Plus的Page对象配合LambdaQueryWrapper实现:

public PageResult<RecipeVO> queryRecipeList(Integer page, Integer size, String keyword, Long categoryId, String sortBy) { Page<Recipe> p = new Page<>(page, size); LambdaQueryWrapper<Recipe> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(Recipe::getTitle, keyword) .or().like(Recipe::getDescription, keyword)); } if (categoryId != null) { wrapper.eq(Recipe::getCategoryId, categoryId); } // 排序字段动态选择 wrapper.orderByDesc("hot".equals(sortBy) ? Recipe::getViewCount : Recipe::getCreateTime); // ... }

MyBatis-Plus的这个链式查询写起来确实爽,但有几个细节要注意。第一,第一个and里套的or查询必须用wrapper.and(...)包一层,否则生成的SQL逻辑会出问题——关键词匹配菜名或者关键词匹配简介,这两个条件是“或”的关系,但同时要保证跟在分类筛选之后是“且”的关系,括号不能少。第二,动态排序如果直接用orderByDesc传的条件分支,在代码审查时容易混淆,更稳妥的做法是用一个if-else结构分别指定排序列。

分页插件我配置了PaginationInnerInterceptor,它的原理是拦截SQL,自动拼接LIMIT语句。有个小提醒,如果Page的页码是从1开始的(这是我推荐的约定,前端页码初始值设1,而不是0),传给LIMIT的参数要做(current - 1) * size的偏移量计算,这个逻辑MyBatis-Plus已经封装好了,但如果你自己手写SQL分页,很容易在这里出错。

列表查询的返回结果里,菜谱封面图URL、分类名称、作者名称这些字段都是在原实体里没有的,所以我定义了一个RecipeVO,在Service层做对象转换。这一步用Stream方便:

List<RecipeVO> records = p.getRecords().stream().map(r -> { RecipeVO vo = new RecipeVO(); BeanUtils.copyProperties(r, vo); vo.setAuthorName(userMapper.selectById(r.getUserId()).getNickname()); vo.setCategoryName(categoryMapper.selectById(r.getCategoryId()).getName()); return vo; }).collect(Collectors.toList());

很多人会担心这里N+1问题——每查一条菜谱就要额外查一个用户和一个分类。在这个系统里菜谱量级不会太大,这个写法完全没问题。但如果数据量真的起来了,正确的做法是用一次JOIN查询把需要的关联字段全部查出来,而不是用循环查数据库。我在项目里已经是这么实现的,JOIN查询配合Mapper XML里的resultMap映射,性能更优,代码也更整洁。

4. 前端页面与交互实现

前端我用Vue3 + Vite + Pinia + Vue Router + Element Plus搭的。Element Plus是Vue3版本的Element UI,组件丰富,表格、表单、弹窗、分页这些基础组件开箱即用,写管理后台和社区类页面非常高效。样式上我做了自定义主题色,把主色调成暖橙色,跟美食的调性比较搭。

4.1 路由设计与导航守卫

路由结构我划分成公共页面和用户页面两类。公共页面有首页(菜谱广场)、菜谱详情、登录、注册。用户页面有个人中心、发布菜谱、我的收藏、我的点赞。用户页面的路由需要登录才能访问,我通过路由守卫里的beforeEach钩子做验证:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })

登录之后跳回来源页面这个细节非常加分。用户在点收藏按钮时被弹到登录页,登录成功后再点一次收藏就行,而不是登录完回到首页重新找那道菜。体验上的小优化是重定向参数实现的,成本几乎为零。

4.2 API封装与请求拦截

前端所有HTTP请求我都放在src/api目录下,按模块拆文件:user.js、recipe.js、comment.js、like.js。文件里是封装好的函数,组件里只负责调用,这也算是在前端实践分层思想。

axios实例的封装是重中之重:

const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } else { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )

注意baseURL用的是/api而不是完整的后端地址,这是配合开发服务器的Proxy做的。Vite配置文件里设置:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

这样前端的请求打给/api/recipe/list,Vite开发服务器自动把它转发到http://localhost:8080/recipe/list,解决了跨域问题。到了生产环境,Nginx的location配置做同样的转发即可,前端代码一行不用改。

有朋友会问,后端接口路径是/recipe/list,前端代理加了/api前缀,后端是怎么对上的?两种方案:一种是后端统一加一个context-path来做前缀匹配,另一种是代理转发时用rewrite把/api前缀去掉。我用的后者,Nginx或者Vite proxy都支持路径重写。

4.3 图片上传的前端处理

发菜谱页面有一个封面图上传区。Element Plus的el-upload组件支持自定义上传行为,我这边的做法是:

const handleUpload = async (options) => { const fd = new FormData() fd.append('file', options.file) const url = await uploadImage(fd) form.coverUrl = url }

因为后端接口接收的字段名是file,FormData的append必须保持一致。上传成功后返回的URL就是服务器静态映射后的访问路径,直接绑定到img标签的src上就能预览。

还有一个隐藏细节,图片上传前的本地预览。在用户还没点提交的时候,我们希望他能在表单里先看到自己选的那张图。这个借助URL.createObjectURL(file)可以实现,它生成一个内存中的临时URL,比base64转字符串的方式内存占用小很多,页面关闭后自动释放。

4.4 菜谱详情页与评论列表交互

菜谱详情页是整个系统信息量最大的页面:菜品展示、作者信息、食材清单、制作步骤、点赞收藏按钮、评论时间线。这块我把它拆成了四个子组件,保持单组件代码量在500行以内,维护起来舒服。

评论功能我做了比较完整的时间线效果,每条评论包含头像、昵称、评论内容、发布时间,并且对一级评论和楼中楼做了阶梯缩进展示。时间显示上,我做了一个formatTime函数,对刚刚、几分钟前、几小时前、昨天、具体日期这几种场景做了不同显示。这个小功能很多项目都是勉强显示一个时间戳就行,但对用户的感受影响还是蛮大,一个清晰的相对时间比一串2024-05-18 14:30:00的数字亲切得多。

详情页的浏览量加一我放在了后端接口里,每次前端进入详情页就调用一下,后端用UPDATE语句直接加一。这里埋了一个隐患——刷量问题。真实系统肯定会有更复杂的策略,比如IP去重、用户去重,但在这个项目里我选择了简单方案,在实现复杂度上做一个取舍。

5. 系统联调、部署上线与经验复盘

本地联调阶段,我遇到过不少接口通了但数据不对的尴尬。印象最深刻的一次,前端拿到的id一直是字符串"1",后端的Long类型倒也能转,但一旦前端用它做类型比较或者拼接URL,就会出现"1" !== 1这种隐蔽bug。这是JavaScript的弱类型和Java的强类型之间的天然摩擦。我的解决方案是在后端的JSON序列化配置中对Long类型做ToString处理,保证前端拿到的始终是字符串,前后端约定统一。

另一个坑在后端配置跨域。开发阶段用Vite代理,完全没有跨域问题。但生产部署如果前后端是不同域名的,就得在后端加CORS配置。SpringBoot的CORS配置很容易遗漏一个点——allowCredentials(true)和allowedOrigins("")不能同时使用,浏览器会直接拒绝这个响应。必须显式指定精确的来源地址,或者用allowedOriginPatterns("")配合allowCredentials(true)。

部署上线我用的是最经典的架构:一台Linux服务器,Nginx托管前端静态文件,反向代理转发后端API请求。项目打包时,前端执行npm run build生成dist目录,dist目录上传到Nginx的html目录;后端执行mvn package -DskipTests生成可执行Jar包,直接java -jar跑起来,通过systemd守护进程管理,实现开机自启和异常重启。

MySQL数据库上线的第一件事是备份策略。虽然个人站点的数据量不大,但每天凌晨用mysqldump全量备份一次的成本几乎为零,关键时候能救命。我写了一个简单的shell脚本,配合crontab定时任务,把备份文件保留最近7天,超期的自动删除。

这套系统做完之后,我在代码层面做了一个cutting line——把所有Service层的类重读了一遍,专门找事务的缺失。哪些操作需要事务?总结下来就两类:一是写入多张表的业务联动作(比如注册时创建用户和默认收藏夹),二是读改写三步操作(比如点赞时先查再改)。凡涉及这两类,一律在方法上加@Transactional注解,并考虑好抛出异常时的回滚边界。

实际使用这个系统一段时间后,我的体会是,一开始把它当作一个“会跑的CRUD”来开发,这种心态能大幅降低起步的心理障碍;但等跑通了全流程,你会自然而然地开始思考并发、缓存、数据库索引优化、前端性能优化这些更深层的课题。这也是我强烈建议新手把“美食网站”当成第一个前后端分离项目的理由——业务足够熟悉、细节足够丰富、复杂度足够用来练手,但又不至于让人一头扎进某个大坑里出不来。过程中踩过的每一个问题,都是下一套系统里能直接复用的经验资产。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询