作为一个写过不少前后端分离项目的开发者,看到这个标题的第一反应是——美食烹饪互动平台,这个选题其实挺有意思的。它不像电商、后台管理系统那样烂大街,功能上又天然带社交属性和内容属性,菜谱发布、食材分类、收藏点赞、评论互动,一套做下来几乎覆盖了主流业务系统百分之八十的通用能力。拿它练手或者作为毕业设计、简历项目,性价比非常高。
这篇文章我会把整个项目从技术选型、数据库设计、前后端核心实现到部署联调过程中容易踩的坑,完整梳理一遍。内容偏实战,提到的代码片段和表结构都是可以直接拿去用的程度。如果你是正在学SpringBoot和Vue3,想找个完整项目练手,或者准备做一个美食类内容平台的开发者,这篇应该能帮你省下不少自己摸索的时间。
1. 项目整体拆解:美食互动平台要解决什么问题
开始写代码之前,先把需求想明白。美食烹饪互动平台,核心是"内容 + 互动"四个字。用户来这个平台干什么?第一,找菜谱——今天想做个红烧肉,搜一下,看步骤、看食材、看成品图。第二,学烹饪——跟着步骤做,遇到问题留言问作者。第三,分享自己的作品——做了拿手菜,发出来晒一晒,收获点赞和评论。第四,沉淀关系——关注几个做菜合口味的作者,之后定期看他们更新。
这四个核心场景,对应到系统功能上,至少需要这么几个模块:
- 用户模块:注册、登录、个人信息维护、头像上传。这块是所有业务系统的基础。
- 菜谱模块:菜谱的发布、编辑、删除、详情展示、分类筛选、关键词搜索。这是平台的内容核心。
- 互动模块:收藏、点赞、评论、关注。这是平台的社交血液,也是和普通CMS系统最大的区别。
- 辅助模块:食材分类、菜系标签、热门排行榜、个人主页等。这些细节决定产品体验是否完整。
从技术角度再看,这套系统天然适合前后端分离架构。内容展示类页面需要灵活的前端交互,比如菜谱步骤的逐步展开、图片懒加载、搜索结果实时过滤;后台接口则要稳定、清晰、可复用,一套API同时支撑Web端和将来的移动端。SpringBoot + Vue3 + MyBatis + MySQL这套组合,在这个场景下既不过度设计,又足够撑起一个五脏俱全的完整系统。
我见过不少人一上来就纠结微服务、分布式、Redis缓存,结果连基本的菜谱CRUD都没写利索。这个项目正确的打开方式是:先把单体架构做扎实,接口设计规范、表结构合理、代码分层清晰,有余力再考虑性能优化和架构升级。对一个互动平台的第一版来说,单体应用完全够用,跑起来简单,出了问题也好排查。
1.1 核心功能模块划分
把需求转化成功能模块,我习惯画一张思维导图式的脑图,列出来所有页面和接口,再做减法。这个项目的页面级模块大致是这样:
- 首页:菜谱推荐列表、热门榜单、分类入口。数据来源是后台按浏览量、收藏量、时间倒序等维度聚合查询。
- 菜谱列表页:按分类筛选、按关键词搜索、分页加载。
- 菜谱详情页:基本信息、食材清单、步骤列表、作者信息、评论列表、收藏/点赞按钮。
- 发布/编辑菜谱页:表单 + 图片上传 + 步骤动态增删。
- 个人中心:我的菜谱、我的收藏、我的关注、粉丝列表、编辑个人资料。
- 登录/注册页:账号密码 + Token鉴权。
对应的后端接口大致是:
- 用户相关:POST /api/auth/register、POST /api/auth/login、GET /api/user/info、PUT /api/user/info、POST /api/user/avatar —— 注册登录和资料维护。
- 菜谱相关:GET /api/recipes(带分页和筛选参数)、GET /api/recipes/{id}、POST /api/recipes、PUT /api/recipes/{id}、DELETE /api/recipes/{id} —— 菜谱CRUD。
- 互动相关:POST /api/recipes/{id}/like、POST /api/recipes/{id}/favorite、POST /api/recipes/{id}/comment、GET /api/recipes/{id}/comments、POST /api/user/follow —— 点赞、收藏、评论、关注。
- 统计相关:GET /api/rank/hot、GET /api/user/stats —— 排行榜和个人数据统计。
这样列完,整个系统的轮廓就出来了。后面所有开发和测试工作,本质上就是在把这些接口一个个填实。
1.2 用户角色与权限边界
一个内容平台,用户角色至少分成两种:普通用户和内容创作者,再往上就是后台管理员。普通用户可以浏览、收藏、评论、点赞,可以编辑自己发布的内容。创作者是普通用户的一种自然升级形态——只要发布了菜谱,就是创作者,不需要额外申请。管理员则可以下架违规菜谱、删除不当评论、封禁用户。
在实现上,直接用JWT(JSON Web Token)做登录态管理最省事。用户登录成功后,后端签发一个Token,里面包含用户ID和角色标识,前端每次请求把Token放在请求头里。后端通过拦截器解析Token,拿到当前用户信息,再判断是否有权限执行某操作。
权限控制的关键点在于:只有菜谱的作者才能修改和删除自己的菜谱。这个判断不能用前端传的参数来信任,必须在后端从Token里拿用户ID,再和菜谱表里的author_id比对。我见过有人图省事,让前端把"当前用户ID"作为参数传给后端做校验,结果随便伪造个ID就能删别人的内容,这是高危漏洞,务必注意。
1.3 一个完整业务链路的设计思路
拿"用户发布一道菜谱并收到评论"这个最核心的链路来说,完整的数据流是这样的:用户在前端填写菜谱标题、封面图、食材清单、步骤描述,点击发布 → 前端把表单数据(文本字段 + 图片URL数组)组装成JSON,POST到后端接口 → 后端校验Token和参数,把菜谱基本信息插入主表,食材和步骤分别插入子表 → 菜谱出现在列表页和分类页 → 其他用户打开详情页,看到完整内容 → 看完之后觉得不错,提交评论 → 后端把评论写入评论表,同时给菜谱作者推送一条站内消息(如果做了通知功能)→ 作者通过个人中心看到新评论。
这条链路包含了多表写入、权限校验、前端富交互、关联查询等几乎所有核心知识点。项目开发过程中,每做完一个这样的链路,整个系统的完成度就往前推进一截。所以我建议的推进顺序是:先做注册登录和菜谱CRUD,把主链路跑通,再做收藏点赞评论,最后做排行榜、个人统计这类锦上添花的功能。
2. 技术选型解析:这套组合背后的逻辑
技术选型没有绝对的对错,只有合不合适。SpringBoot + Vue3 + MyBatis + MySQL这个组合之所以常见,是因为每一层都有不可替代的优势,而且它们之间的配合非常顺滑。
2.1 后端SpringBoot:约定大于配置的生态红利
SpringBoot最爽的一点是自动配置。建一个Maven项目,引入spring-boot-starter-web、spring-boot-starter-validation这些依赖,一个启动类就能把Web服务跑起来。对于开发者来说,不需要去搞一堆XML配置,注解就搞定了大部分事情。
具体到这个项目,SpringBoot提供的几个关键能力帮了大忙:
- Spring MVC的RESTful接口支持。@RestController注解直接暴露JSON接口,@RequestBody自动把请求体绑定为DTO对象,参数校验有@Valid这套注解,写接口的效率比手写Servlet高太多。
- 依赖注入容器。Service层、Mapper层全部交给容器管理,用@Autowired或构造器注入就行,代码之间没有硬编码的new,后期测试替换Mock对象也方便。
- 统一的异常处理机制。通过@RestControllerAdvice可以定义一个全局异常处理器,业务异常、参数校验异常、系统异常分别返回不同的JSON结构。前端只需要约定好code、message、data三段式结构,就能统一处理各种错误提示。
版本选择上,国内大多数教程用的是SpringBoot 2.7.x,它基于Java 8,兼容性最好。如果你用的是Java 17及以上,直接用3.x版本体验更好,但要注意3.x对Java 8不友好,且部分第三方组件的兼容版本要确认。我建议新手直接2.7.x,稳。
2.2 前端Vue3:组合式API让业务逻辑更好组织
Vue3最核心的变化是组合式API(Composition API)。用选项式API写一个小页面还行,一旦组件里塞进数据请求、交互逻辑、状态管理,代码就会被拆散到data、methods、computed、watch等各个部分,改一个功能要来回跳。组合式API允许按功能点把逻辑聚在一起,可读性和可维护性好很多。
在美食烹饪互动平台的前端里,几个典型场景能明显体现出Vue3的优势:
- 菜谱表单的动态字段。食材清单和制作步骤都是可以增删的数组,在script setup里用reactive定义一个数组,渲染层用v-for循环渲染输入框,加一行删一行都只操作数组,实时响应。这在选项式API里写法会更绕。
- 组合式封装请求逻辑。把请求菜谱列表、加载更多、搜索这些逻辑抽成一个useRecipeList组合函数,多个页面复用的时候,直接调用函数拿到响应式数据和方法,不用复制粘贴代码。
- 状态管理Pinia。相比Vuex,Pinia的API简洁得多,去掉了mutations那层概念,直接改state。用户的登录状态、个人信息这种全局数据放Pinia里,任何组件都能读写。
构建工具这里直接用Vite。它在开发时的冷启动速度和热更新速度比Webpack快了一个量级,配置也简单,创建项目一条命令搞定。
2.3 MyBatis:灵活SQL和代码可读性的平衡
选MyBatis而不是JPA,核心原因有两个。
第一,这个项目的查询逻辑不复杂,但组合条件很多。菜谱列表页可能同时按分类、关键词、排序方式筛选。如果用了JPA或者Spring Data,这种多条件动态查询要写Specification之类的东西,语法别扭。MyBatis里一个动态SQL标签就解决了,清晰直观。
第二,团队里大多数人更熟悉SQL,写原生SQL可以精确控制查询语句,方便针对具体业务做优化。比如热门排行榜要按收藏数排序同时关联用户昵称,一个JOIN加GROUP BY搞定,SQL里一目了然。
不过原生MyBatis也有痛点,最典型的是写简单的单表CRUD时,XML里的SQL模板很啰嗦。所以实际项目里我更推荐引入MyBatis-Plus。它保留了MyBatis的全部原生能力,又提供了BaseMapper这样的通用Mapper接口,单表增删改查不需要写SQL,直接用内置方法就行。复杂的多表关联和动态查询再自己写XML。两者结合,效率和灵活性兼得。
2.4 MySQL:内容型业务系统的可靠底座
MySQL属于那种不一定出彩但永远不会掉链子的选择。美食互动平台的并发量在早期阶段通常不高,一台普通的MySQL实例完全撑得住。用InnoDB引擎,支持事务、外键和行级锁,菜谱发布这种涉及多表写入的操作,用事务保证数据一致性,出现异常直接回滚。
字符集强烈建议建库时就定成utf8mb4,不然用户昵称带个表情符号,存进去就是乱码,这种问题排查起来很让人头疼。排序规则用utf8mb4_general_ci就行,不需要纠结。
表结构设计上,这个项目大概需要这么几张核心表,后面我会展开讲:用户表、分类表、菜谱表、菜谱食材表、菜谱步骤表、评论表、点赞表、收藏表、关注关系表。九张表互相通过外键逻辑关联(实际开发中我一般不在数据库层面配物理外键,而是靠应用层逻辑维护,后面会说明原因),构成了整个系统的数据骨架。
3. 数据库设计:九张核心表的结构与思路
数据库设计直接决定后续开发效率。同一个需求,表结构设计得好,查询接口写得轻松,扩展也方便;设计不好,写SQL的时候处处别扭,甚至要推翻重来。
3.1 用户与关注关系设计
用户表是系统的基础,字段如下:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `bio` varchar(255) DEFAULT NULL COMMENT '个人简介', `role` tinyint NOT NULL DEFAULT '1' COMMENT '角色 1-普通用户 2-管理员', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态 1-正常 0-封禁', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';密码字段注意两点。第一,长度至少100,因为BCrypt加密后的密码串比较长,varchar(50)存不下。第二,不要用MD5这种可逆性差的简单哈希,BCrypt自带盐值,抗暴力破解能力强得多。Spring Security里自带BCryptPasswordEncoder,单独引入spring-security-crypto包也可以直接用。
关注关系是典型的多对多关系。设计一张中间表,记录谁关注了谁:
CREATE TABLE `user_follow` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '关注者ID', `follow_user_id` bigint NOT NULL COMMENT '被关注者ID', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_follow` (`user_id`,`follow_user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关注关系表';唯一约束放在(user_id, follow_user_id)上,有效防止重复关注。查询某个用户关注了多少人、有多少粉丝,分别对user_id和follow_user_id做COUNT即可。
3.2 菜谱内容的多表拆分
菜谱表是平台内容的核心。由于食材清单和制作步骤都是可变长度的列表(红烧肉可能有8个步骤,煮泡面可能只有2步),把它们单独拆成子表,避免在主表里塞逗号分隔字符串,这是设计上的关键决策。
菜谱主表:
CREATE TABLE `recipe` ( `id` bigint NOT NULL AUTO_INCREMENT, `author_id` bigint NOT NULL COMMENT '作者ID', `category_id` bigint DEFAULT NULL COMMENT '分类ID', `title` varchar(100) NOT NULL COMMENT '菜谱标题', `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图URL', `description` varchar(500) DEFAULT NULL COMMENT '简介', `difficulty` tinyint DEFAULT '1' COMMENT '难度 1-简单 2-中等 3-困难', `cook_time` int DEFAULT NULL COMMENT '烹饪时长(分钟)', `view_count` int NOT NULL DEFAULT '0' COMMENT '浏览量', `like_count` int NOT NULL DEFAULT '0' COMMENT '点赞数', `favorite_count` int NOT NULL DEFAULT '0' COMMENT '收藏数', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态 1-已发布 0-草稿', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_author_id` (`author_id`), KEY `idx_category_id` (`category_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜谱表';like_count和favorite_count这类统计字段和点赞表的数据是冗余的。查询详情时直接读这个字段,省去每次实时COUNT的开销;点赞和收藏操作发生时,在事务里同时更新主表和明细表。这样设计在并发量适中的场景下完全够用,也能保证列表页排序的响应速度。
食材和步骤子表:
CREATE TABLE `recipe_ingredient` ( `id` bigint NOT NULL AUTO_INCREMENT, `recipe_id` bigint NOT NULL, `name` varchar(100) NOT NULL COMMENT '食材名称', `amount` varchar(50) DEFAULT NULL COMMENT '用量,如"500克"', `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序', PRIMARY KEY (`id`), KEY `idx_recipe_id` (`recipe_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食材表'; CREATE TABLE `recipe_step` ( `id` bigint NOT NULL AUTO_INCREMENT, `recipe_id` bigint NOT NULL, `content` text NOT NULL COMMENT '步骤描述', `image` varchar(255) DEFAULT NULL COMMENT '步骤图URL', `sort_order` int NOT NULL DEFAULT '0' COMMENT '步骤序号', PRIMARY KEY (`id`), KEY `idx_recipe_id` (`recipe_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='步骤表';查询详情时,一条SQL查主表,两条SQL查子表,组装后返回给前端,逻辑清晰。发布菜谱时,在同一个事务里分别插入主表、清空子表、批量插入子表,数据一致性有保障。
分类表比较简单:
CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '分类名称', `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分类表';预先插入常用数据:家常菜、川菜、粤菜、烘焙甜品、汤羹粥、小吃夜宵等,前端首页的分类入口直接从这里读。
3.3 互动数据模型:点赞、收藏、评论
互动相关的三张表,设计逻辑是类似的。点赞表和收藏表本质是用户和菜谱的关联关系,唯一区别是业务语义不同。
CREATE TABLE `recipe_like` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `recipe_id` bigint NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_recipe` (`user_id`,`recipe_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞表'; CREATE TABLE `recipe_favorite` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `recipe_id` bigint NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_recipe` (`user_id`,`recipe_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表'; CREATE TABLE `recipe_comment` ( `id` bigint NOT NULL AUTO_INCREMENT, `recipe_id` bigint NOT NULL, `user_id` bigint NOT NULL, `content` varchar(500) NOT NULL COMMENT '评论内容', `parent_id` bigint DEFAULT NULL COMMENT '父评论ID,支持楼中楼', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_recipe_id` (`recipe_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';评论表里加parent_id可以支持回复功能,同一菜谱下的评论按时间倒序查出之后,前端拼成树形结构展示。如果不想做二级回复,保留parent_id字段但不实现前端交互也完全可以,白名单里留出扩展空间。
3.4 索引设计与查询性能考量
几张核心表建完,索引策略有几个关键点。第一,外键关联字段必须建索引,比如recipe表的category_id、comment表的recipe_id,否则关联查询时会全表扫描,数据量一上来就出问题。第二,排序字段如果经常用,也考虑建索引,菜谱列表按create_time排序时,idx_create_time就能生效。第三,unique索引除了保证数据唯一性,对按用户名登录这种高频查询也有很大性能提升。
为什么不在数据库层面配物理外键?这是实战中很多人的习惯。物理外键会带来几个问题:删数据时要考虑级联操作,容易误删关联数据;双表写入时表锁和行锁的范围更大,高并发场景下性能变差;项目迭代中改表结构也更麻烦。实际做法是,应用层保证数据完整性——删除菜谱时,在Service层写SQL同时删除食材、步骤、评论和互动记录,事务提交后,数据依然是干净的。
4. 后端SpringBoot核心实现:从分层架构到关键业务逻辑
工程结构清晰是项目长期维护的基石。这个项目我把后端按标准分层来组织,每层只负责自己的事。
4.1 项目分层与命名规范
com.example.foodplatform ├── controller # 接口层,接收请求参数,返回JSON │ ├── AuthController.java │ ├── RecipeController.java │ ├── UserController.java │ └── CommentController.java ├── service # 业务层,核心逻辑全部在这里 │ ├── impl │ │ ├── RecipeServiceImpl.java │ │ └── ... │ └── RecipeService.java ├── mapper # 数据访问层,继承BaseMapper │ ├── RecipeMapper.java │ └── ... ├── entity # 与数据库表对应的实体类 ├── dto # 数据传输对象,接收前端参数 ├── vo # 视图对象,返回给前端的数据 ├── config # 配置类,如CORS、拦截器 ├── common # 通用类,如Result、异常处理 └── utils # 工具类,如JWT工具Controller层要薄,只做参数接收和数据返回,不写业务代码。Service层负责具体逻辑,事务注解加在Service层方法上。Mapper层只写SQL和数据映射。这样分层的核心好处是,出问题的时候定位快:接口报错先看Controller参数,业务逻辑错了进Service看,SQL慢就排查Mapper。三层之间用DTO和VO隔开,前端传参的字段结构和数据库表结构不一致时,不会互相污染。
DTO和VO必须分开。举个例子,发布菜谱时前端传的JSON里有title、coverImage、description、ingredients数组、steps数组,这些字段在菜谱表、食材表、步骤表里各存一半。后端不能直接把前端JSON绑定到entity上,而是先绑定DTO,然后在Service层拆解、组装、分别入库。返回给前端的数据也可以裁剪掉敏感字段,比如用户密码、创建时间等。
4.2 统一返回结构与全局异常处理
前后端分离项目,接口返回格式必须统一。我用的结构是这个:
{ "code": 200, "message": "操作成功", "data": { } }code为200表示成功,其他业务码表示各类失败。对应的Java类:
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.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }全局异常处理用@RestControllerAdvice实现。哪些场景需要自定义异常?比如菜谱不存在(404)、没有权限修改(403)、用户已存在(400)、参数校验失败(400)。把这些都定义成RuntimeException的子类,在Service层抛出来,由全局异常处理器统一捕获并转换成Result返回。这样Controller里不需要写try-catch,代码干净得多。
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<Void> handleBusinessException(BusinessException e) { return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleValidException(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldError().getDefaultMessage(); return Result.error(400, msg); } @ExceptionHandler(Exception.class) public Result<Void> handleException(Exception e) { return Result.error(500, "系统异常,请稍后重试"); } }4.3 注册登录与JWT鉴权实现
登录注册这块,核心实现分三步。第一步,注册接口接收用户名、密码、昵称,校验用户名是否已存在,密码用BCrypt加密之后写入用户表。第二步,登录接口校验用户名密码,成功后用JWT工具生成Token返回给前端。第三步,前端后续所有请求在请求头里带Authorization: Bearer {token},后端拦截器统一解析和校验。
JWT工具类的关键代码:
@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; // 单位毫秒 public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(userId.toString()) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Long getUserId(String token) { Claims claims = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); return Long.valueOf(claims.getSubject()); } }拦截器里的逻辑也比较简单:解析Token,拿到用户ID,放到ThreadLocal里,后续Controller和Service通过上下文工具类获取当前登录用户。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { // 解析token,成功后放行,失败返回401 } return true; } }JWT的secret一定要放到配置文件里,不要写死在代码里。生产环境的secret需要足够长、足够随机。Token过期时间设成7天比较合理,太短用户要频繁登录,太长有安全隐患。
4.4 菜谱发布功能的事务处理
发布菜谱是典型的写多张表场景。Service层方法上添加@Transactional注解,方法内按顺序执行:插入菜谱主表拿到ID、批量插入食材列表、批量插入步骤列表。其中一个插入失败,整个事务回滚,不会出现菜谱有主记录但没食材这种脏数据。
@Transactional(rollbackFor = Exception.class) public Long createRecipe(RecipeCreateDTO dto) { // 1. 组装主表实体 Recipe recipe = new Recipe(); BeanUtils.copyProperties(dto, recipe); recipe.setAuthorId(currentUserId()); recipeMapper.insert(recipe); // 2. 插入食材列表 if (dto.getIngredients() != null) { for (IngredientDTO item : dto.getIngredients()) { RecipeIngredient ingredient = new RecipeIngredient(); ingredient.setRecipeId(recipe.getId()); ingredient.setName(item.getName()); ingredient.setAmount(item.getAmount()); ingredient.setSortOrder(item.getSortOrder()); recipeIngredientMapper.insert(ingredient); } } // 3. 插入步骤列表(代码结构同上) // ... return recipe.getId(); }MyBatis-Plus的insert方法默认为每条SQL单独执行。批量插入性能在数据量不大时没问题,但如果一步插几百条食材,建议用自定义XML里的foreach批量插入,一条SQL搞定,速度提升明显。
点赞和收藏功能要防止重复操作。前端按钮点击后,后端先查唯一索引(user_id + recipe_id),存在就提示"已点赞",不存在才插入并更新计数。这里的并发检查靠数据库的唯一索引兜底,即使两个人同时请求,也只有一个能插入成功,另一个会报唯一约束异常。
4.5 菜谱列表的多条件动态查询
菜谱列表页最常用的查询是:按分类筛选、按关键词模糊搜索标题、按时间或热度排序、分页。MyBatis的XML动态SQL是干这个的利器。
<select id="selectRecipePage" resultType="com.example.foodplatform.vo.RecipeListVO"> SELECT r.id, r.title, r.cover_image AS coverImage, r.difficulty, r.cook_time AS cookTime, r.view_count AS viewCount, r.like_count AS likeCount, r.favorite_count AS favoriteCount, r.create_time AS createTime, u.nickname AS authorName, u.avatar AS authorAvatar FROM recipe r LEFT JOIN user u ON r.author_id = u.id <where> <if test="categoryId != null"> AND r.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND r.title LIKE CONCAT('%', #{keyword}, '%') </if> AND r.status = 1 </where> ORDER BY <choose> <when test="sort == 'hot'">r.view_count DESC</when> <otherwise>r.create_time DESC</otherwise> </choose> LIMIT #{offset}, #{size} </select>标签会自动去掉子句前多余的AND,这是常用技巧。排序字段用 做了白名单限制,避免用户通过参数直接拼接SQL注入。分页参数offset和size由前端传入。
这里有个经验之谈:LIKE模糊查询在数据量大的情况下性能很差,但一个学习项目或者初期平台,几百上千条菜谱完全不用担心,正常写就行。真要优化,要么上ES要么用全文索引,那是后面的事,现在不要过度设计。
4.6 图片上传:本地存储方案
菜谱封面、步骤图、用户头像,本质都是图片上传。云存储要申请账号配密钥,学习项目直接本地存储最省事,后续要迁移也很容易,因为数据库里存的是访问URL,换成云存储URL前缀就行。
后端实现方案:配置一个虚拟路径映射实际磁盘路径。在application.yml里加配置:
file: upload-dir: /home/app/resources/images/ access-path: /images/**对应的配置类:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceHandler("file:" + uploadDir); } }上传接口接收MultipartFile,用UUID生成新文件名,避免重名覆盖,然后保存到磁盘。保存成功后返回访问路径,前端直接用这个路径拼在img标签的src上。
文件类型校验很有必要。只允许jpg、png、jpeg、webp等格式,限制文件大小5MB以内(Spring配置文件里也可以配max-file-size)。否则用户传个MP4进来,服务端磁盘直接被打满。
5. 前端Vue3实现要点:从工程搭建到交互细节
前端是整个系统给人的第一印象,画面好看与否、操作是否顺滑,直接决定用户愿不愿意用。Vue3 + Vite + Pinia + Element Plus(或Naive UI)这套组合,能让开发效率翻倍。
5.1 工程初始化与目录结构
创建项目直接用它:
npm create vite@latest food-web -- --template vue安装依赖:
npm install vue-router@4 pinia axios element-plus @element-plus/icons-vue目录结构:
src ├── api # 接口请求封装 │ ├── recipe.js │ ├── auth.js │ └── user.js ├── router # 路由配置 ├── stores # Pinia状态 │ └── user.js ├── views # 页面组件 │ ├── HomeView.vue │ ├── RecipeDetailView.vue │ └── RecipeEditView.vue ├── components # 公共组件 │ ├── RecipeCard.vue │ ├── CommentList.vue │ └── RecipeStepEditor.vue ├── utils # 工具函数 │ └── request.js └── App.vueaxios请求统一封装是一个必须做的事。在utils/request.js里配置baseURL、请求拦截器自动附加Token,响应拦截器统一处理错误码。这样每个业务接口只需要写请求方法本身,不重复处理token和错误提示这些公共逻辑。
5.2 接口请求封装示例
核心页面的接口封装,以菜谱模块为例:
import request from '../utils/request' // 分页查询菜谱列表 export function getRecipes(params) { return request.get('/api/recipes', { params }) } // 查询菜谱详情 export function getRecipeDetail(id) { return request.get(`/api/recipes/${id}`) } // 发布菜谱 export function createRecipe(data) { return request.post('/api/recipes', data) } // 点赞 export function likeRecipe(id) { return request.post(`/api/recipes/${id}/like`) }这些函数在组件里被调用后,拿到的直接就是data数据,因为响应拦截器已经把Result包装解开了。
5.3 关键页面的组件化设计
首页是列表流,上面是分类标签,下面是菜谱卡片列表,滚动到底部自动加载下一页。菜谱卡片单独抽成RecipeCard组件,接收一个recipe对象,渲染封面图、标题、作者昵称、点赞数等信息。这个组件在首页、搜索页、个人中心都会用到,抽出来一次性写好吃遍所有场景。
菜谱详情页是交互最集中的地方。顶部是封面图和标题简介,接着是食材清单,然后是步骤列表,每个步骤前有编号,可以配图。页面底部是评论区和点赞收藏按钮。这个页面需要同时展示菜谱信息、作者信息、互动状态(当前用户是否已点赞、已收藏),这些数据可以从详情接口一次性返回。
发布菜谱页用动态表单。食材和步骤都是可增删的数组,每个数组项渲染一个输入区域。步骤项之间用分隔线隔开,点击"添加步骤"按钮往数组里push一个空对象,渲染层自动多出一行输入框。为了更好的体验,步骤编辑器可以单独拆成RecipeStepEditor组件,内部管理步骤列表,向外emit变更事件,父组件通过v-model双向绑定。
5.4 路由守卫与状态持久化
用户登录状态用Pinia管理,但页面刷新后Pinia里的数据会清空。所以登录成功后不仅要存Token到localStorage,还要把用户基本信息也存一份。刷新页面时,在路由守卫或者初始化逻辑里,先从localStorage恢复用户状态,再根据Token请求后端更新用户资料。
路由守卫主要控制权限。
router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.isLogin) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })发布菜谱页、个人中心这些需要登录的页面,在路由meta里标注requiresAuth: true。用户没登录点了直接跳转登录页,登录成功后带着redirect参数跳回原页面,体验很丝滑。
5.5 图片懒加载与列表性能
菜谱列表页如果一口气渲染几十张封面图,浏览器请求压力会很大,尤其是网络慢的时候。Element Plus自带el-image组件,内置懒加载属性,只需:
<el-image :src="recipe.coverImage" lazy fit="cover"></el-image>列表滚动加载实现也不复杂。监听window的scroll事件,判断滚动位置是否接近页面底部,接近时触发下一页加载,加载完成后拼接新数据到列表里。防抖要加上,不然滚动一次触发十几次请求。
分页加载时要小心v-for的key值。菜谱ID是唯一的,直接拿它当key,避免列表重渲染时出现状态错乱。删除某个菜谱后,也要从列表中过滤掉对应条目,而不是重新拉全量列表。
6. 联调与部署过程中的常见问题排查实录
前后端分离项目,开发模式没问题不代表部署时一帆风顺。这里我把自己实际踩过的坑整理成问题排查记录,每一个都费过不少排查时间,写出来帮大家避雷。
6.1 跨域配置:为什么前端请求到不了后端
最经典的坑。开发阶段前端跑在localhost:5173(Vite默认端口),后端跑在localhost:8080,前端发请求,浏览器的同源策略直接把请求拦截了,控制台报CORS错误。
解决办法是在后端加跨域配置,允许指定来源访问。
@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); } }allowedOrigins改成allowedOriginPatterns是因为allowCredentials(true)时,allowedOrigins不能是通配符*,这是SpringBoot的规范限制。如果前端用了带Cookie的请求,这里必须配allowCredentials(true)。
生产环境部署后,如果前端静态资源用Nginx托管,后端接口也通过Nginx反向代理,前后端同域就彻底没有跨域问题了。开发阶段保留上面这段配置,部署阶段用Nginx配置转发,两不误。
6.2 MyBatis查询结果全为null:驼峰映射的坑
用MyBatis时,如果数据库字段是cover_image这种下划线命名,Java实体属性是coverImage这种驼峰命名,查询结果会全部映射为null。原因很简单,MyBatis默认没有开启下划线到驼峰的自动映射。
解决办法,在application.yml里配置:
mybatis: configuration: map-underscore-to-camel-case: true如果用了MyBatis-Plus,它默认是开启的,所以用MyBatis-Plus时一般不遇到这个问题。自己手写XML时,则要确认这条配置已经生效,或者SQL里显式写别名。
6.3 文件上传失败:默认上传大小限制
SpringMVC默认的单文件上传大小限制是1MB,默认请求总大小是10MB。菜谱封面图动辄两三MB,发布时直接报错MultipartException。
需要在配置里调整:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB前端也要同步限制,Element Plus的el-upload组件配了:limit和:before-upload校验,超过大小给用户清晰提示,而不是等后端报错。
6.4 空指针异常:联查数据缺失的定位思路
列表或详情页显示作者昵称时,如果有个用户被删了,LEFT JOIN查出来昵称是null,页面不处理就直接渲染空白或报错。
处理思路分三步。第一,前端模板里做空值兜底,要么显示默认昵称,要么显示"已注销用户"。第二,后端VO的字段允许为null,但文档里声明的返回结构要说清楚。第三,如果业务上不允许用户被直接删除(比如有内容关联),更合理的方案是不提供物理删除,而是把用户status改为0(封禁),这样内容还在,只是账号不可登录。我强烈推荐用这种方式保留关联数据。
6.5 排行榜数据重复:缺少分页导致的数据错乱
热门排行榜按浏览量倒序,如果列表是分页加载的,排行榜接口的排序查询和分页一起用时要特别注意:ORDER BY字段有重复值时,分页结果可能出现同一行数据在不同页重复出现。比如有五条记录浏览量都是100,按view_count排序后它们的相对顺序不固定,MySQL每次查询顺序可能不同,第一页显示了记录A,第二页又显示了A。
解决办法很简单,排序加上次要字段,比如ORDER BY view_count DESC, id DESC,确保顺序完全确定。这是个非常隐蔽的问题,数据量小的时候看不出来,数据量一大就冒出来了。
6.6 事务失效的常见原因
@Transactional失效的情况,最典型的有三种。第一,方法不是public的,Spring的代理机制要求事务方法必须是public。第二,同类内部方法调用,比如Service里方法A调了方法B,A没事务注解,B有@Transactional,B的事务不生效,因为代理只拦了外部调用A的方法入口。第三,事务注解加到了private方法上,同样不起作用。
还有一个容易被忽略的细节:@Transactional默认只回滚RuntimeException,如果方法里抛了受检异常(Exception的子类而非RuntimeException),事务不会回滚。要么方法声明里指定rollbackFor = Exception.class,要么确保所有业务异常都是RuntimeException类型。上面我的代码示例里写的就是rollbackFor = Exception.class,这是最保险的写法。
6.7 部署阶段的环境配置管理
本地开发、测试、生产三个环境,数据库连接、文件上传路径、JWT密钥这些配置都不一样。用SpringBoot的多环境配置特性,一套代码到处跑:
spring: profiles: active: devapplication-dev.yml放开发配置,application-prod.yml放生产配置。启动时通过--spring.profiles.active=prod参数指定环境。生产环境的数据库密码、JWT密钥用环境变量注入,不要明文写在配置文件里提交到代码仓库。
部署方式上,最简单的方案是打jar包直接跑:mvn clean package,然后把target下的jar包传到服务器,java -jar启动。前端构建之后,把dist目录放到Nginx的html目录,同时配置反向代理把/api开头请求转发到后端服务。一套流程下来,一个域名就能同时服务前后端。
写在最后的几点个人体会
这个项目从零到完整可运行,我前后大概花了两周业余时间,中间踩的坑基本都写在上面了。通读一遍应该能感觉到,它不是一个从教程里复制粘贴就能交差的Demo,而是真正需要考虑需求、设计数据结构、处理边界情况的完整项目。做完之后,你对SpringBoot的核心机制、Vue3的组合式API、多表联合查询和事务控制的理解,会比看十遍文档来得扎实。
最后分享一个小建议:不要拘泥于标题里列出的技术栈。这个平台的服务端代码写顺了,完全可以再拆出管理后台,复用大部分后端接口;菜谱的数据结构清晰,做成App的接口也毫不违和。核心的架构思路和代码设计是通用的,换皮不换骨而已。做的时候多想一步,做出来的东西就能多走一步。