ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue美食推荐系统实战:从数据库设计到推荐算法

SpringBoot+Vue美食推荐系统实战:从数据库设计到推荐算法 最近我把一套基于SpringBootVue的美食信息推荐系统完整梳理了一遍从数据库设计到前端页面从管理员后台到用户推荐中间踩了不少坑。这套系统包含JavaMySQLMyBatis的完整后端源码和Vue前端工程刚好适合正在准备毕业设计、课程设计或者想学前后端分离开发的小伙伴。网上类似项目很多但大多数只有代码没有设计思路或者推荐逻辑写得玄乎。这篇文章我会把项目拆开讲清楚为什么这样选型、表为什么这样建、推荐到底怎么算、前后端怎么联调、部署有哪些坑。照着做你至少能省下一周的查错时间。1. 这套美食推荐系统到底是什么从需求到功能拆分1.1 系统角色与核心流程先想清楚场景用户面对一堆美食不知道吃什么需要系统根据他的口味把可能喜欢的菜推到首页而管理员要维护这些美食数据总不能每次都用SQL命令去改。于是就有了两个端面向普通用户的推荐展示端和面向管理员的维护管理端。普通用户的核心路径是注册登录 → 浏览首页推荐 → 搜索或分类筛选美食 → 查看详情 → 收藏/取消收藏 → 查看收藏夹。管理员的核心路径是后台登录 → 管理美食分类 → 管理菜品信息增删改查 → 管理标签 → 查看用户列表。两条路径在数据库里交汇推荐模块依赖用户历史行为数据管理模块依赖基础数据表的完整性。我把推荐模块做成“基于标签匹配”的轻量方案而不是动辄上协同过滤。原因很简单毕设或练手项目的数据量不大用户行为稀疏复杂算法很难展示出效果反而会因为冷启动问题让人看不懂代码。基于标签的方案能保证每个用户都能立刻得到推荐结果代码量不大算法逻辑也能在答辩时讲清楚。1.2 功能清单与页面规划我整理了一张功能表方便对照开发进度模块功能点说明用户模块注册、登录、个人信息修改注册时可选口味偏好标签推荐模块首页Top-N推荐、热门推荐基于用户行为与标签计算美食模块列表、分类筛选、关键词搜索、分页、详情美食信息含图片、标签、描述交互模块收藏、取消收藏、最近浏览记录用户行为数据用于推荐后台管理分类管理、美食管理、标签管理、用户管理管理员专属有独立登录入口数据统计展示美食总数、用户数、收藏量后台首页用简单统计卡片展示页面规划上前端路由主要分为首页推荐流、美食列表页、美食详情页、登录注册页、个人中心我的收藏和浏览记录、后台管理页内嵌多个子页面。不要做太多花哨的页面核心是把数据流跑通视觉上用现成的UI库撑起来就够了。1.3 为什么这个项目适合作为毕设/练手项目这类系统最大的优点是“麻雀虽小五脏俱全”你能接触到前后端分离开发、RESTful接口设计、ORM映射、权限控制、简单推荐算法、文件上传、打包部署几乎覆盖了一个JavaWeb工程师日常工作的所有环节。对毕设来说老师最看重的不是功能多夸张而是逻辑完整、自己能讲清楚。推荐模块就是最好的“亮点”即使实现不复杂也足够在一堆纯CRUD的题目里脱颖而出。对练手的人来说这套系统的难点恰好在推荐逻辑和联调部署上跳过去就只是CRUD跳过去再把前后端跑通你的工程能力会上一个台阶。源码我放在了文末的结构说明里但我更建议你边读边自己敲尤其是我后面讲的几个坑直接百度往往解决不了自己复现一遍才能记住。2. 技术选型的真实考量SpringBootVueMySQLMyBatis组合2.1 后端选型SpringBoot与SSM对比现在做JavaWeb项目几乎不用手工配置SSM了。SpringBoot把SpringMVC、Spring、MyBatis的整合配置自动化内嵌Tomcatmvn spring-boot:run就能启动。对毕设而言用SpringBoot可以减少大量配置文件把精力放在业务逻辑上。但要注意SpringBoot版本不要乱选我一开始用了Spring Boot 3.x结果很多MyBatis和PageHelper的老依赖在Jakarta命名空间下报错折腾半天。后来换成2.7.x所有问题消失。如果你是从SSM转过来的就理解成SpringBoot是帮你把web.xml、spring-mvc.xml、mybatis-config.xml这些手工配置全替你做了但底层还是那套东西。所以面试官问底层时你还是要能答出来DispatcherServlet和SqlSessionFactory的流程。2.2 前端选型VueElementUI/ElementPlus前端我用的是Vue2 ElementUI原因很现实网上大部分项目源码、答疑帖都是Vue2遇到问题好查而且学校机房或老项目环境Node版本可能不支持Vue3。如果你从零开始并且用的是较新Node版本用Vue3 ElementPlus也完全可以核心代码差异不大。Vue带来的好处是组件化和数据驱动。页面被拆成头部组件、美食卡片组件、分页组件等数据通过axios从后端取视图自动更新。配合vue-router做路由配合Vuex或Pinia存登录状态前端逻辑清晰很多。学习时重点关注组件的props和emit以及created生命周期里怎么请求数据。2.3 数据库与ORMMySQLMyBatis为什么不用JPAMySQL免费、轻量、教程多是绝大多数Java项目的默认数据库。ORM我选了MyBatis而不是JPA/Hibernate。区别在于JPA帮你自动生成SQL但复杂的多表查询一旦出错很难排查MyBatis把SQL写在Mapper.xml里查询过程完全可控写动态SQL时也更直观。对于“美食按标签匹配”、“用户收藏联表”这种多条件查询MyBatis的where标签非常好用。还有一个现实因素国内Java岗位面试对MyBatis的考察频率远高于JPA很多公司还在用MyBatis-Plus。用MyBatis做毕设面试时能聊的细节更多比如一级缓存、二级缓存、#{}和${}的区别、TypeHandler什么时候用这些都是加分项。2.4 完整源码的项目结构后端包结构、前端目录后端我采用经典的分层结构包名按com.example.foodfood-backend/ ├── src/main/java/com/example/food/ │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # MyBatis接口 │ ├── entity/ # 实体类 │ ├── config/ # 跨域、拦截器配置 │ ├── common/ # 统一返回结果、异常处理 │ └── utils/ # JWT工具、密码工具 ├── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ └── application.yml # 项目配置 └── pom.xml前端用Vue CLI创建目录如下food-frontend/ ├── src/ │ ├── api/ # axios请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── store/ # 登录状态管理 │ ├── views/ # 页面组件 │ ├── App.vue │ └── main.js ├── vue.config.js # 开发代理配置 └── package.json看到这个结构你应该能感觉到后端负责数据和业务规则前端负责渲染和交互通过JSON通信。后面所有代码都围绕这个结构展开。3. 数据库设计表结构、关系与字段细节3.1 核心表设计数据库是系统的地基表建不好后面写SQL全是泪。我总共设计了7张核心表用户表、美食表、分类表、标签表、美食标签关联表、收藏表、浏览记录表。下面给出关键DDL和设计思路。用户表userCREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, nickname varchar(50) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, pref_tags varchar(255) DEFAULT NULL COMMENT 注册时选择的口味标签id逗号分隔, role tinyint(1) DEFAULT 0 COMMENT 0普通用户 1管理员, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;美食表foodCREATE TABLE food ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, category_id int(11) DEFAULT NULL, image varchar(255) DEFAULT NULL, description text, price decimal(10,2) DEFAULT NULL, rating decimal(3,1) DEFAULT NULL COMMENT 评分, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;标签表tag和美食标签关联表food_tag多对多关系用一个关联表解决。收藏表favorite和浏览记录表browse_record分别记录用户行为。收藏表的联合唯一索引很关键避免同一用户重复收藏同一道菜CREATE TABLE favorite ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, food_id int(11) NOT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_food (user_id,food_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;浏览记录表不设唯一索引每次访问都插入一条用于统计用户最近的口味倾向。数据量大后可以只保留最近100条但毕设项目不需要那么复杂。3.2 关键SQL与索引设计推荐逻辑要用到“用户收藏过的美食对应的标签”所以关联查询的出入口都在标签关联表上。我最常用的查询是-- 查询用户收藏的美食id SELECT food_id FROM favorite WHERE user_id #{userId}; -- 根据美食id列表查询标签id SELECT tag_id FROM food_tag WHERE food_id IN (...); -- 根据标签id列表查询包含这些标签的美食并根据匹配度排序 SELECT f.*, COUNT(ft.tag_id) AS match_count FROM food f JOIN food_tag ft ON f.id ft.food_id WHERE ft.tag_id IN (...) GROUP BY f.id ORDER BY match_count DESC LIMIT 10;索引方面除了主键外food.category_id建普通索引food_tag.tag_id和food_tag.food_id各建索引。数据量不大时这些索引没什么感觉但写论文时如果能解释清楚“索引是为了加速多对多关联查询”能显得更专业。3.3 使用MyBatis动态SQL实现多条件查询多条件筛选分类、关键词、价格区间如果用Java拼接SQL字符串容易出错且没法复用。MyBatis提供了where和if标签可以根据参数是否为空自动追加条件select idselectFoodList resultTypecom.example.food.entity.Food SELECT * FROM food where if testcategoryId ! null AND category_id #{categoryId} /if if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if /where ORDER BY create_time DESC /select注意#{}是预编译参数防SQL注入${}是字符串拼接永远不要用。如果直接在XML里写会被解析成标签需要写成lt;或者用![CDATA[ ]]。我一开始就吃了这个亏SQL一直报语法错。4. 后端核心实现接口设计、业务逻辑与推荐算法落地4.1 RESTful接口设计规范与统一返回体前后端分离的项目接口格式一定要统一。我定义了一个Result类Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMsg(success); r.setData(data); return r; } public static T ResultT error(String msg) { ResultT r new Result(); r.setCode(500); r.setMsg(msg); return r; } }Controller统一返回Result例如PostMapping(/login) public ResultLoginVO login(RequestBody User user) { return Result.success(userService.login(user)); }主要接口清单POST /api/user/register注册POST /api/user/login登录GET /api/food/list?pageNum1pageSize8categoryId2分页条件查询GET /api/food/detail/{id}详情POST /api/favorite/add收藏DELETE /api/favorite/{foodId}取消收藏GET /api/recommend/list?limit10获取推荐后台管理接口单独放在/api/admin/下4.2 用户登录鉴权JWT还是SessionSession方案在前后端分离下需要处理Cookie跨域比较麻烦。我选了JWT登录成功后生成一个token字符串前端存到localStorage每次请求在Header里带Authorization: token。核心代码是一个工具类public class JwtUtil { private static final String SECRET your-secret-key; private static final long EXPIRE 7 * 24 * 60 * 60 * 1000L; public static String generateToken(Long userId, String username, String role) { return Jwts.builder() .claim(userId, userId) .claim(username, username) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }再用一个拦截器HandlerInterceptor统一校验token排除登录注册接口。这里有个关键细节如果你用的Spring Boot 2.7.xJWT依赖建议用io.jsonwebtoken:jjwt-api、jjwt-impl、jjwt-jackson的组合版本用0.11.5比较稳。Spring Boot 3.x会因包名变更导致工具类报错这也是我不推荐高版本的原因之一。4.3 推荐系统核心逻辑基于标签的Top-N推荐实现这是我整篇文章最想讲的部分。推荐思路分两步先计算出用户最感兴趣的标签集合再根据美食与这些标签的覆盖度打分。第一步从用户历史行为中提取标签权重。用户注册时选择了pref_tags逗号分隔的标签ID同时他收藏的菜和浏览过的菜也有标签。我给三种来源分配不同权重注册偏好标签权重2收藏美食的标签权重3最近浏览美食的标签权重1第二步遍历所有美食计算该美食的标签与用户偏好标签的交集数量作为匹配得分。还要考虑热门程度兜底得分相同的美食按收藏数降序。核心代码如下public ListFood recommend(Long userId, int limit) { // 1. 统计用户标签权重 MapLong, Integer tagWeight getUserTagWeight(userId); if (tagWeight.isEmpty()) { // 冷启动返回热门美食 return foodMapper.selectHotFoods(limit); } // 2. 查询全部标签及美食关联 ListFood allFoods foodMapper.selectAllWithTags(); // 3. 计算每道菜与用户标签的相似度 allFoods.forEach(food - { long matchCount food.getTagIds().stream() .filter(tagWeight::containsKey) .count(); double score matchCount 0.0001 * food.getFavoriteCount(); food.setRecommendScore(score); }); // 4. 排序取Top-N allFoods.sort((a, b) - Double.compare(b.getRecommendScore(), a.getRecommendScore())); return allFoods.stream().limit(limit).collect(Collectors.toList()); }这个算法虽然简单但效果直观。用户收藏过“麻辣香锅”后系统会推荐同样带“辣”标签的美食。答辩时可以补充说如果数据量大了可以把标签向量化用余弦相似度替代交集计数思路是一样的但代码更复杂一般毕设不需要。4.4 MyBatis分页查询与PageHelper集成分页是后台管理、美食列表的刚需。MyBatis本身不提供分页我用了PageHelper插件。在pom.xml引入dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependencyService层用法非常简单PageHelper.startPage(pageNum, pageSize); ListFood list foodMapper.selectFoodList(query); PageInfoFood pageInfo new PageInfo(list);然后返回pageInfo.getTotal()和list即可。注意一个经典坑PageHelper.startPage只对紧随其后的第一条SQL生效如果中间夹了其他查询分页会错乱。所以一定要把startPage放在Mapper查询的前一行别写在循环或条件分支里。5. 前端Vue实现页面渲染、路由与交互5.1 Vue项目的创建与目录组织我用Vue CLI搭的项目。如果还没建工程先全局安装脚手架npm install -g vue/cli vue create food-frontend cd food-frontend npm install element-ui axios vue-router vuex --save npm run servevue.config.js里配置开发代理解决联调时的跨域问题module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };这样前端请求/api/food/list会自动转发到后端8080端口。生产环境则把npm run build生成的dist目录放到后端src/main/resources/static下由SpringBoot直接托管不需要单独部署Nginx。5.2 核心页面首页、美食列表、美食详情、用户中心、后台管理首页是推荐页用两个组件推荐位和美食卡片。created里请求/api/recommend/list拿到数据后在v-for里渲染卡片。美食照片如果没有真实图片我用占位图或本地静态图但生产环境最好上传图片后台管理里可以放一个“上传图片”按钮。美食列表页用分页组件el-pagination current-changeloadFoods :current-pagepageNum :page-sizepageSize :totaltotal layoutprev, pager, next /el-pagination搜索框绑定keyword点击搜索时触发loadFoods。分类筛选用el-select数据从分类接口拉。核心就是把查询参数传给后端后端返回的数据更新到table或卡片列表。美食详情页要有参数路由{ path: /food/:id, name: FoodDetail, component: FoodDetail }详情页里显示封面、名字、标签、简介还有收藏按钮。收藏按钮的显示状态根据当前用户是否收藏决定登录后请求/api/favorite/check?foodIdxxx验证没有写这个接口就改成点击收藏时调Post。细节虽小但答辩时演示体验会好很多。后台管理页是一套独立布局左侧菜单右侧router-view。包括美食管理表格、新增编辑弹窗、分类管理列表、用户管理列表。新增编辑弹窗里最重要的是表单校验必填字段要设置rules。5.3 axios封装与跨域处理我封装了一个request.js统一处理token注入和错误提示import axios from axios import { Message } from element-ui const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } else { Message.error(error.response?.data?.msg || 请求失败) } return Promise.reject(error) } )跨域在开发环境靠proxy解决生产环境因为前端文件被后端托管同域自然没有跨域。但如果你把前端部署到Nginx、后端跑在SpringBoot那Nginx要配置proxy_pass到8080或者后端加一个CORS过滤器。5.4 Vue路由守卫与登录状态管理未登录用户访问收藏页或后台管理页时应该跳转到登录页。在router/index.js里写全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login?redirect to.fullPath) } else { next() } })登录后的用户信息存到Vuex里store模块负责保存用户对象、昵称、头像。路由守卫里可以再校验角色后台管理的路由要求角色为1否则提示无权限。这里我踩过一个坑用localStorage存token但用户状态放在内存里刷新页面后Vuex就没了。解决方法是刷新时从token解析用户信息调用store.dispatch(initUser)。6. 完整源码的部署与运行从环境安装到达成可用6.1 环境准备JDK、Maven、Node.js、MySQL版本选择我建议统一用以下版本组合亲测兼容性最好JDK 1.8 或 11Spring Boot 2.7.x支持到JDK 17但1.8最稳Maven 3.6.3Node.js 14或16Vue2项目用16最稳Node 18有时会报OpenSSL错误MySQL 5.7 或 8.0Navicat 或 MySQL Workbench管理数据库检查命令java -version、mvn -version、node -v、npm -v。如果Maven下载依赖太慢在settings.xml里配置阿里云镜像。6.2 数据库初始化与配置文件的坑我把建表语句和初始数据整理成了init.sql启动前执行一遍即可。application.yml中的配置要点如下spring: datasource: url: jdbc:mysql://localhost:3306/food_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.food.entity configuration: map-underscore-to-camel-case: trueMySQL 8.0的驱动是com.mysql.cj.jdbc.Driver5.7才是com.mysql.jdbc.Driver。serverTimezone不加的话时间字段会报“Cannot create a connection”的错。map-underscore-to-camel-case设为true后user_name才能自动映射到userName否则全是null。6.3 前后端分离开发时的联调与打包部署开发阶段后端启动在8080前端npm run serve启动在8081通过代理请求接口分离调试。毕设需要演示最好打包成单机应用先在前端执行npm run build生成dist目录把dist里的所有文件复制到后端src/main/resources/static/下重新mvn package打成一个jar。直接java -jar food-system-1.0.jar访问http://localhost:8080就是完整系统。这样免去启动两个服务演示时不容易出故障。6.4 常见启动报错的排查清单我整理了实际运行中最高频的几类问题报错信息原因解决办法Failed to configure a DataSourceapplication.yml连接串写错检查url、user、pass确认MySQL已启动Invalid bound statement (not found)Mapper接口和XML的namespace/方法名不一致确认XML里namespace为接口全限定名id为方法名Cause: java.sql.SQLSyntaxErrorExceptionSQL关键字或符号错误用lt;代替检查表名是否有冲突如orderPageHelper only supportsstartPage后不是查询语句把startPage移到Mapper查询上一行No Access-Control-Allow-Origin header跨域未处理开发用vue代理生产加CorsFilterVue运行报ERESOLVE unable to resolve dependency treenpm版本过高与旧依赖冲突用npm install --legacy-peer-deps这些坑我每个都踩过一遍尤其是MyBatis的映射问题往往不是代码逻辑错而是配置漏了。排查时先看控制台完整异常别只看第一行。7. 开发过程中最有价值的踩坑记录与改进思路7.1 MyBatis自动映射下划线到驼峰的坑前面提到过map-underscore-to-camel-case我再展开讲一下。如果你表字段是create_time实体类是createTime而没有开启这个配置MyBatis查询结果里createTime就是null。你以为SQL写错了实际上只是映射问题。调试技巧在application.yml里设置mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl打印完整SQL和参数能省下大量排查时间。还有一种类似的坑是关联查询中同名字段覆盖。比如联表查美食和分类两张表都有id字段后查的会覆盖前面的。解决方法是SQL里显式使用别名SELECT f.id as food_id, c.id as category_id, c.name as category_name FROM food f JOIN category c ...7.2 Vue打包后接口404、刷新404的解决前后端分离开发时前端路由用history模式很好看但打包部署后一刷新页面就会404因为SpringBoot没有为前端路由配置转发。最简单的方法是把vue-router改成hash模式URL会带#刷新不会404代价是URL有点丑。如果一定要用history可以在后端加一个WebMvcConfigurer将非接口路径都转发到index.html。毕设演示我建议直接用hash模式少一处风险。接口404的另一个常见原因是context-path。如果你在application.yml里配置了server.servlet.context-path: /food那所有接口都要以/food/api/...访问前端baseURL也要改。毕业设计尽量不要设置context-path能省掉很多联调麻烦。7.3 推荐效果不好时的优化方向我的推荐逻辑简单但项目答辩时老师可能会问“如果用户没收藏过怎么办”“推荐结果不够准怎么办”。可以从两个方向回答冷启动新用户没有行为数据时用热门美食列表兜底注册时让用户选口味标签其实就是最简单的冷启动方案。个性化增强把“交集计数”升级为“余弦相似度”也就是将用户标签权重和美食标签向量计算余弦角度。公式是cos (A·B) / (|A| × |B|)代码量增加十几行但听起来更专业。也可以引入“收藏/浏览时间衰减”最近一周的行为权重更高。最后再说一点个人体会。很多人拿到这种完整源码项目第一反应是启动成功、页面能点就完事了。但我真实开发中最大的收获恰恰来自那些报错MyBatis映射不上、JWT解析失败、Vue路由刷新404、PageHelper分页错乱。如果你现在正在复现这套系统建议不要急着复制粘贴哪怕照着敲一遍对这些报错建立肌肉记忆都值了。项目代码本身不难难的是你遇到问题以后能不能准确描述问题、定位问题这也是面试官最想看到的工程师素质。顺着这条路走下来你收获的不只是这一套代码而是完整的JavaWeb工程思维。
返回列表