资讯动态

SpringBoot3+Vue3前后端分离:美食菜谱分享社区实战总结

发布时间:2026/10/13 16:54:05 来源:尧图企业网站定制
厨艺分享社区记录与交流的美味家园。这是一个关于构建美食分享平台的实战项目总结基于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 ResultT implements Serializable { private Integer code; private String message; private T data; public static T ResultT success(T data) { ... } public static T ResultT 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登录鉴权与拦截器实现登录这块我用的JWTJSON 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;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }在WebMvcConfigurer配置类里注册这个拦截器excludePathPatterns放行登录、注册、首页菜谱列表、菜谱详情、图片访问这些公开接口。这里有个很容易踩的坑放行的路径和Controller里定义的路径必须一致特别是静态资源路径。我之前把上传目录映射到了/upload/**结果忘了在exclude里加上导致图片全被拦截器拦了前端一片裂图。调试了好久才发现是this的问题。3.3 菜谱发布的文件上传与内容组装菜谱发布是系统的核心写操作前端提交的数据分两部分文字字段菜名、分类ID、简介、食材清单、步骤描述和一个封面图片文件。我实现的接口是一个multipart/form-data混合类型请求。回到代码层面Controller方法的签名是public ResultLong 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 PageResultRecipeVO queryRecipeList(Integer page, Integer size, String keyword, Long categoryId, String sortBy) { PageRecipe p new Page(page, size); LambdaQueryWrapperRecipe 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方便ListRecipeVO 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());很多人会担心这里N1问题——每查一条菜谱就要额外查一个用户和一个分类。在这个系统里菜谱量级不会太大这个写法完全没问题。但如果数据量真的起来了正确的做法是用一次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/listVite开发服务器自动把它转发到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 }因为后端接口接收的字段名是fileFormData的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”来开发这种心态能大幅降低起步的心理障碍但等跑通了全流程你会自然而然地开始思考并发、缓存、数据库索引优化、前端性能优化这些更深层的课题。这也是我强烈建议新手把“美食网站”当成第一个前后端分离项目的理由——业务足够熟悉、细节足够丰富、复杂度足够用来练手但又不至于让人一头扎进某个大坑里出不来。过程中踩过的每一个问题都是下一套系统里能直接复用的经验资产。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑