
在做毕业设计或者课程项目的时候“家庭美食食谱网站”这种题目真的非常常见而一旦把“SSM”和“Vue”这两个关键词放进去整个系统的定位就清晰了——这是一个典型的前后端分离Java全栈项目。后端用SSM框架Spring SpringMVC MyBatis处理业务逻辑和数据持久化前端用Vue搭建用户界面两者通过JSON格式的接口进行通信。这种组合在校园项目里出现频率极高一方面是SSM足够轻量、上手快另一方面是Vue的组件化开发方式让页面维护变得简单。如果你正在为这类系统找思路、定方案、排坑这篇文章就是按实际开发顺序来写的从功能拆分到数据库设计从前端路由到接口联调最后把部署时容易踩的坑也一并整理出来。我自己做过好几个类似的项目包括菜谱分享、美食社区、个人食谱管理整体流程早就摸透了。这类系统的核心价值不在功能有多花哨而在于把“菜谱展示、分类检索、用户收藏、后台维护”这条主线串得顺能把CRUD做得干净、接口设计得合理就已经超过大部分同期项目了。下面我按实际开发顺序把整个SSM269家庭美食食谱网站系统从头到尾拆一遍。1. 整体设计为什么SSMVue会成为这类项目的标配1.1 技术选型背后的核心考量先说SSM。Spring负责管理对象依赖SpringMVC负责把前端请求分发到对应的Controller方法MyBatis负责把数据库查询结果映射成Java对象。这三者分工明确加起来就是一套完整的后端处理链路。很多人会问都2025年了为什么不直接用Spring Boot我的看法是如果你的目标是尽快把系统跑起来Spring Boot确实更高效但如果这是毕业设计或者教学项目SSM能让你更清楚地看到配置文件、依赖注入、事务管理这些底层逻辑是怎么工作的答辩的时候也更有东西可讲。而且从就业角度看很多老系统的维护还离不开SSM理解这套框架的运作方式并不会白费功夫。再说Vue。它最吸引人的地方是组件化开发和响应式数据绑定。页面上的菜谱卡片、搜索框、分页条、收藏按钮都可以拆成独立的Vue组件各自维护自己的状态页面之间互不干扰。数据变了视图自动更新不需要手动操作DOM。这种开发方式在菜谱这种图文内容较多的场景下特别友好——你只需要维护好data里的数据页面结构会自动跟着变。前后端分离是本项目另一个关键决策。后端只提供一组RESTful接口不关心页面长什么样前端通过axios发送请求获取数据渲染到页面上。这样做的好处是分工清晰、调试方便前端起一个dev server后端跑在Tomcat上两者独立开发互不影响。部署的时候前端打包成静态文件丢给Nginx后端打war包放进Tomcat再用反向代理把请求转发过去整体架构很干净。1.2 功能模块划分用户端和管理端分开设计我第一次做这类系统的时候很容易把所有功能堆在一起后来发现开发到后面会越来越乱。正确做法是在设计阶段就把系统拆成两个端用户端和管理端各司其职。用户端的核心功能就是围绕“找菜谱”和“存菜谱”来展开用户注册与登录我用的是用户名加密码的方式密码经过MD5加密后存储。虽然MD5不算特别安全但作为课程项目足够说明问题如果想让方案更完整可以换成加盐加密或者BCrypt。菜谱列表与分类浏览首页展示推荐菜谱顶部是分类导航点击不同分类就切换到对应的菜谱列表。菜谱搜索支持通过菜谱名称进行模糊搜索搜索框输入关键词后后端用LIKE语句做匹配。菜谱详情页展示封面图、食材清单、操作步骤以及该菜谱所属的分类信息。收藏与取消收藏登录用户可以收藏喜欢的菜谱在“我的收藏”页面统一查看。评论互动登录用户可以在详情页发表评论列表按时间倒序展示。管理端的核心是内容维护管理员登录独立的管理员账户登录后进入后台管理界面。分类管理增删改查美食分类比如“家常菜”“烘焙甜品”“汤羹粥品”等。菜谱管理管理员可以新增菜谱填标题、上传封面、写步骤流程、编辑已有菜谱、删除违规或过期内容。用户管理查看注册用户列表必要时禁用某个用户账号。评论管理删除不当评论。这样一拆数据库的表结构就很容易推出来前后端的模块边界也清楚了。写代码的时候用户端一个工程管理端一套路由互不干扰后期维护也省心。2. 数据库设计与后端核心实现2.1 数据表设计思路5张表就够了数据库是这类项目的重点也是答辩时候老师最喜欢问的部分。表设计得合理后端的代码就好写表设计得混乱后面全是补丁式开发。我的经验是五张核心表外加一张管理员表足够支撑整个系统。t_user用户表id、username、password、avatar、create_time。用户表字段不要贪多够用就行。t_category分类表id、name、description。分类表用于管理菜谱归属比如“家常菜”“烘焙甜品”“汤羹粥品”等。t_recipe菜谱表id、title、cover_image、ingredients、steps、category_id、user_id、create_time。其中ingredients用文本字段存食材steps用长文本存操作步骤category_id关联分类表。t_favorite收藏表id、user_id、recipe_id、create_time。收藏表本质是用户表和菜谱表的多对多关联表联合唯一索引防止重复收藏。t_comment评论表id、user_id、recipe_id、content、create_time。菜谱表的设计有几个细节要注意。ingredients字段我建议用TEXT类型前台展示时按行分割即可。很多初学者会把食材和步骤拆成两张子表这么做虽然结构上更规范但对于课程项目来说反而增加了不必要的复杂度。分类字段用category_id外键关联查询时通过JOIN获取分类名称这个设计扩展性也更好——以后想按分类统计数量、做分类筛选一条SQL就能搞定。步骤字段steps如果用MySQL的TEXT类型最多能存65535个字符存各类菜谱的操作流程完全够用。2.2 MyBatis Mapper层的关键配置MyBatis的SQL映射文件是整个后端开发的核心很多学生在service层写了大堆代码结果简单需求都靠循环查数据库这种写法在高效场景下很吃亏。开发时要尽量避免“N1查询问题”——比如查询菜谱列表时循环查每个菜谱的分类名称应该是用JOIN一次性查出来。菜品列表的分页查询是系统中的高频操作。我的做法是使用PageHelper插件它的原理是在MyBatis执行SQL之前通过拦截器自动拼接LIMIT语句。使用方式很简单在Service层调用PageHelper.startPage(pageNum, pageSize)后紧接着执行的查询就是分页查询返回的PageInfo对象里会自动带上total、pages等分页信息再用一个Result对象包装返回给前端。Override public PageResultRecipeVO getRecipePage(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); ListRecipeVO list recipeMapper.selectRecipeList(keyword); PageInfoRecipeVO pageInfo new PageInfo(list); PageResultRecipeVO result new PageResult(); result.setTotal(pageInfo.getTotal()); result.setList(pageInfo.getList()); return result; }对应的Mapper XML里菜谱列表的SQL要注意用LEFT JOIN把分类名称带出来select idselectRecipeList resultMapRecipeVOMap SELECT r.id, r.title, r.cover_image, r.ingredients, r.steps, r.create_time, c.name AS category_name FROM t_recipe r LEFT JOIN t_category c ON r.category_id c.id where if testkeyword ! null and keyword ! AND r.title LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY r.create_time DESC /select这里有个小坑要提醒LIKE查询不要写成 %${keyword}% 这种字符串拼接形式会有SQL注入风险。正确的做法是用CONCAT函数配合#{}占位符MyBatis会帮你做好参数化处理既安全又能防止特殊字符导致查询出错。2.3 收藏功能的事务处理收藏功能的逻辑很直接用户点击收藏按钮时前端发送一个包含recipeId的请求后端先判断当前用户是否已经收藏过没有则插入一条收藏记录。问题是判断和插入是两个步骤如果不加控制用户快速双击收藏按钮就可能插入两条重复记录。解决办法有两个层面。第一层是数据库层面在t_favorite表上建立联合唯一索引ALTER TABLE t_favorite ADD UNIQUE KEY uk_user_recipe (user_id, recipe_id);第二层是业务层面在Service方法上加上事务和异常处理Override Transactional(rollbackFor Exception.class) public ResultVoid addFavorite(Long userId, Long recipeId) { int count favoriteMapper.checkFavorite(userId, recipeId); if (count 0) { return Result.error(已收藏过该菜谱); } favoriteMapper.insert(userId, recipeId); return Result.success(); }这样可以做到万无一失。就算是并发请求进来数据库的唯一索引也会拦截掉重复数据。这个细节在答辩时被问到“如何防止重复收藏”时直接讲数据库唯一索引业务判断两层保障印象分能高不少。3. 前端Vue页面搭建与接口联调3.1 从环境准备到项目初始化很多人在Vue环境搭建这一步就卡住了。其实就三步装Node.js、装Vue CLI、创建项目。Node.js我建议装LTS版本不要追新。版本太新可能导致部分依赖包兼容性报错处理起来非常浪费时间。装好后验证一下node -v npm -v然后安装Vue CLInpm install -g vue/cli创建项目vue create recipe-frontend创建过程中会问你选什么预设我一般选Manual方式然后把Router勾上其他先按默认来。Vue 2还是Vue 3的问题如果项目没有特殊限制建议直接上Vue 3组合式API写起来确实清爽而且新项目用新方案不会错。前端工程结构我习惯按功能拆分src/api封装axios的接口模块src/router路由配置src/views页面组件一个路由对应一个文件夹src/components公共组件比如菜谱卡片、分页条src/store状态管理登录后保存用户信息3.2 axios封装与跨域处理axios封装是前端工程里容易被忽略但特别重要的环节。如果不做封装每个页面都直接调用axios.get一旦后端接口地址变了或者需要统一加token所有地方都要改这种场景我经历过确实很麻烦。统一封装的好处是——请求地址只需要维护baseURL一处请求发出去前统一带上token响应回来时统一解包code和data还能在拦截器里统一弹错误提示。import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器统一带上token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) // 响应拦截器统一处理业务码 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { alert(res.msg || 请求出错了) return Promise.reject(new Error(res.msg)) } return res.data }, error { alert(网络异常请稍后重试) return Promise.reject(error) } ) export default request开发环境的跨域问题是前后端联调的第一道坎。前端地址是localhost:8080后端接口是localhost:8081浏览器会拦截跨域请求。解决办法是前端利用Vue CLI的devServer做代理在vue.config.js里配置module.exports { devServer: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样前端页面里请求/api/recipe/list会被代理到http://localhost:8081/recipe/list浏览器看到的还是同源请求就不会有跨域报错了。在开发和部署阶段这种代理方式能省下不少力气。3.3 路由设计页面流转怎么串起来路由配置决定了用户如何从一个页面跳到另一个页面。我的路由设计遵循“用户流程最短”的原则首页菜谱流→ 分类页 → 详情页 → 收藏/评论所有页面通过路由串联起来。const routes [ { path: /, component: HomeView, meta: { title: 首页 } }, { path: /category/:id, component: CategoryView, meta: { title: 分类浏览 } }, { path: /recipe/:id, component: RecipeDetailView, meta: { title: 菜谱详情 } }, { path: /login, component: LoginView, meta: { title: 登录 } }, { path: /favorites, component: FavoriteView, meta: { title: 我的收藏, requiresAuth: true } } ]这里有两个容易踩坑的点。第一个是路由传参像菜谱详情页我用的是/recipe/:id这种动态路由详情页组件里通过this.$route.params.id获取菜谱id然后请求后端接口。第二个是登录拦截收藏页是需要登录才能访问的可以用全局前置守卫来判断router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })路由守卫这个细节经常被考官问到“如果用户未登录就访问收藏页怎么办”有了这段配置回答起来就很有底气。3.4 首页与菜谱详情页的组件化拆解首页是整个系统门面做得好不好直接影响第一印象。我的首页布局是顶部导航栏、分类导航栏、菜谱卡片瀑布流、分页条。其中菜谱卡片被抽成了公共组件RecipeCard在首页和分类页会复用多次。RecipeCard组件里的核心是接收一个recipe对象然后展示封面、标题、分类名称和收藏数量。用户点击卡片时跳转到详情页template div classrecipe-card clickgoDetail img :srcrecipe.coverImage :altrecipe.title / div classrecipe-card-info h3{{ recipe.title }}/h3 span classcategory-tag{{ recipe.categoryName }}/span /div /div /template script export default { name: RecipeCard, props: { recipe: { type: Object, required: true } }, methods: { goDetail() { this.$router.push(/recipe/${this.recipe.id}) } } } /script详情页是用户停留时间最长的页面信息层次要清晰。从上到下依次展示大图、标题、分类标签、食材清单、操作步骤、收藏按钮、评论区。其中步骤我建议用有序列表渲染每一条步骤是一个独立的li用户照着操作不会乱。食材清单用无序列表每条食材后面最好带有用量比如“鸡蛋 3个”“生抽 两勺”这样才方便用户照着准备。评论区用子组件CommentList来实现父组件只负责传入recipeId评论的加载、提交都由子组件自己处理。这种组件化方式能让每个组件职责单一方便调试和复用。4. 管理后台与权限控制4.1 后台管理界面的权限校验后台管理页面绝不能再走用户端那套逻辑了权限校验必须单独做。我的做法是管理员登录后后端返回一个带有角色的token前端路由守卫里检查当前用户角色是不是admin不是就直接跳回首页。// 后端登录接口管理员账号的role为1 public ResultMapString, Object login(String username, String password) { User user userMapper.selectByUsername(username); if (user ! null user.getPassword().equals(MD5Util.md5(password))) { if (user.getRole() 1) { String token createToken(user); MapString, Object data new HashMap(); data.put(token, token); data.put(username, user.getUsername()); data.put(role, user.getRole()); return Result.success(data); } return Result.error(该账号无管理权限); } return Result.error(用户名或密码错误); }后端在Controller的拦截器里也要对上避免懂前端的人改了本地role字段就进入后台这是不少后台系统管理上的漏洞。好的做法是写一个AdminInterceptor只拦截/admin/**路径校验请求头里的token解析出来的角色。4.2 菜谱管理的增删改查管理端的核心操作是菜谱管理本质上就是对t_recipe表的增删改查。新增和编辑我共用一个表单页面通过id是否为空来判断是新增还是编辑。图片上传用的是表单提交方式前端把文件传给后端后端保存到本地磁盘然后把访问路径返回给前端存进数据库。这里最大的坑是图片保存路径。如果直接把图片保存到Tomcat的webapps目录下服务器重启后图片就可能丢失。更好的做法是保存到服务器的一个独立目录比如/usr/local/recipe-images/然后在Tomcat的server.xml里配置虚拟路径映射Context docBase/usr/local/recipe-images path/images /这样前端访问http://localhost:8081/images/xxx.jpg就能拿到图片而图片本身保存在项目外部重启不会丢。我自己之前做过一个博客系统就是图片存在项目目录下结果服务器一重启图片全没了光恢复图片就折腾了一天这个坑一定要记住。编辑菜谱时要注意如果用户没有重新上传图片后端就不能把原有cover_image字段清空。我通常的做法是前端编辑时把原来的图片路径隐藏存着后端接口里先判断前端是否传了新图片传了就用新路径没传就保持原路径。4.3 管理端路由守卫与界面布局管理端和用户端用相同的Vue工程但路由前缀不同。管理端路由统一加/admin前缀比如/admin/recipe、/admin/category、/admin/user。前台路由守卫在进入这些页面时先检查用户是否登录再检查用户角色是否为admin。管理端界面布局用侧边栏顶栏的经典结构左边是菜单导航分类管理、菜谱管理、评论管理、用户管理右边是内容区域。侧边栏在Element-UI或者Naive UI这类组件库里都有现成的Layout组件直接拿来用就能减少很多样式工作量。我个人的偏好是项目里用得称手的UI组件库就不要频繁换类型这会大幅缩短开发时间。整体来说管理端页面以表格为主、表单弹窗为辅功能要的就是清晰高效样式上也简洁一些更好。5. 常见问题与排查技巧实录5.1 前端端口占用导致的项目启动失败这个问题几乎每个新手都遇到过。vue create创建完项目后执行npm run serve结果提示端口被占用。解决办法有两个最简单粗暴关掉占用端口的进程。Windows下用netstat -ano | findstr 8080查出PID然后在任务管理器里结束进程。规范做法在vue.config.js里修改端口比如devServer下的port改成8090这样不影响其他项目。刚接触Vue时常常为这种问题卡很久其实它就是环境干扰项目本身的配置完全没问题别为环境问题反复花大成本。5.2 页面能打开但接口全是404遇到这种情况不用急着看后端代码先确认三件事第一前端代理是否配置正确路径里/api是否被正确转发第二后端接口地址是否带上了项目的context-path比如后端部署在/recipe路径下那么接口地址就是/recipe/recipe/list很容易漏掉前缀第三后端Controller里是否忘记加RequestMapping注解。这些都是低级的配置问题但排查起来很费时间。我的习惯是打开浏览器的开发者工具看图里的Network请求列表先看请求URL是否符合预期再看响应状态码是404还是500基本问题瞬间就清楚了。5.3 PageHelper分页数据不正确PageHelper分页有个经典问题startPage之后如果紧跟的不是查询语句分页就会失效或者错位。比如startPage之后先做了一次count查询后面真正查询记录时页码就对不上了。另外如果项目中多个插件同时使用PageHelper插件的配置顺序也可能导致分页失效。我的建议是把所有分页查询放到一个独立的Service方法里startPage后面紧跟分页查询查询完立即返回中间不夹任何多余操作这个方法用起来能省去不少排查时间。5.4 前端npm install慢或者失败国内网络环境不使用加速镜像的话npm install的体验会比较差甚至直接失败。解决办法是把npm源切换成淘宝镜像npm config set registry https://registry.npmmirror.com设置完后再执行npm install速度会快很多。如果还是失败通常是某个依赖包的版本兼容性问题这时候先删除node_modules目录和package-lock.json再重新执行npm install。这个过程虽然有点粗暴但效果明显特别是Vue CLI在升级版本后经常遇到依赖打架的情况靠它来解决通常有效。5.5 中文乱码问题后端返回中文乱码多半是编码设置不对。SpringMVC环境下要在web.xml里配置CharacterEncodingFilter强制所有请求和响应都使用UTF-8编码。同时要注意在Tomcat的server.xml里的Connector节点上加URIEncodingUTF-8GET请求的参数才不会有乱码。页面显示上HTML的meta标签里也需要声明charsetUTF-8。这三个地方都检查一遍乱码问题基本能根治虽然编码问题不复杂但往往要排查上很长时间。6. 部署上线与项目答辩准备6.1 前后端分离部署的三层结构部署前的准备后端打成war包前端执行npm run build生成dist目录。部署时的结构建议后端war包放到Tomcat的webapps目录启动Tomcat后自动解压。前端dist目录里的静态文件交给Nginx托管监听80端口。Nginx配置反向代理把/api开头的请求转发到Tomcat的8080端口。Nginx的关键配置server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这里最容易被忽略的是try_files这行配置。如果不加它用户访问首页没问题但刷新/recipe/12这样的详情页时会报404。因为前端路由是history模式服务端必须把所有请求都回退到index.html由前端路由再解析。不加这行配置刷新页面就白屏这个问题是部署环节最典型的坑。6.2 答辩提纲与项目亮点如果你这个项目是毕业设计答辩准备很关键。PPT演示的流程建议是先放系统演示视频再讲技术架构图最后展示核心代码和数据库设计。老师大概率会问这几个问题为什么选择SSM框架前后端分离的好处是什么如何防止SQL注入收藏功能的并发重复问题怎么解决图片上传后服务器重启图片丢失怎么办这几个问题在本文前面的章节里都有对应答案如果能把每个问题的来龙去脉讲清楚展示出“不仅会用还理解为什么”的项目水平答辩基本就稳了。我自己做项目评审时最打动的往往不是功能花哨而是对方能说清楚每个技术决策的取舍逻辑。另外项目说明书里建议把用户端和管理端的功能做成两张表格表格里列出功能名称、功能描述、对应接口地址能让阅读者快速掌握项目全貌也显得条理分明。6.3 个人经验体会这类相对完整的全栈项目做下来我最大的收获不是某个API怎么写而是跳出了页面级开发的局限真正理解了从前端点击到数据库落盘的全过程。SSM和Vue的组合虽然现在看起来不像微服务、Serverless那么前沿但它依然有不可替代的价值——它清晰、轻量、生态成熟而且能让你把JavaWeb开发的基础逻辑学透。做项目的时候不用太纠结“老不老”重要的是自己亲手把系统从零到一搭起来、跑起来、发现问题、解决问题这个过程学到的东西远比框架本身值钱得多。最后再分享一个小建议做完后录一个完整的演示视频把核心功能和异常处理流程都走一遍后期无论是答辩还是写文档这段视频都能带来实实在在的帮助。