前段时间重新整理了一个老项目——前后端分离的美食推荐商城系统,技术栈是SpringBoot + Vue + MyBatis + MySQL。核心关键词“前后端分离”决定了它的整体架构形态:Vue负责页面渲染和用户交互,SpringBoot只提供纯JSON接口,MyBatis负责数据库访问,MySQL作为持久层存储。这个组合几乎是目前毕业设计、外包项目和企业内部系统中出现频率最高的一套阵容,但真正把它跑通、部署上线、还能给别人讲解清楚,每一步都有不少门道。我把自己从建表到打包部署的完整过程梳理成这篇实战笔记,适合正在做同类系统的同学,以及想快速掌握前后端分离工程化细节的开发者参考。
很多人有个误区,觉得“前后端分离”就是把前端的html页面放到nginx上,后端起个SpringBoot接口就完事了。真实情况要复杂得多,比如登录状态该由谁维护、推荐接口的数据从哪来、跨域请求怎么处理、打包后前端调接口的地址怎么切换、刷新页面为什么404……这些问题几乎每个做这类项目的人都会撞上。下面我按自己实际开发的顺序,把整个系统的核心设计、关键代码、部署步骤和踩坑记录都展开讲清楚。
1. 为什么是SpringBoot + Vue + MyBatis + MySQL这套组合,以及美食推荐商城的技术定位
这套组合能成为前后端分离项目的“标准答案”,不是没有原因的。我在做这个项目之前也对比过SpringCloud微服务、Redis缓存、Elasticsearch搜索这些更重的方案,但对于一个以推荐为核心的美食商城来说,保持工程结构简单、逻辑清晰、可解释性强,远比堆砌技术栈更重要。
1.1 四件套各自扮演的角色,以及选型的底层逻辑
先说说为什么选MyBatis而不是JPA或MyBatis-Plus。在这个项目里,推荐逻辑需要大量多表关联查询,比如“用户最近浏览的菜品种类”“同分类下销量最高的前10条”,这类SQL用MyBatis的XML映射文件写起来最直观,SQL是显式的、可控的,调优也方便。JPA虽然开发速度快,但复杂查询的自动生成SQL不够通透明了;MyBatis-Plus虽然开发效率高,但依赖重、自定义SQL还是要写XML,对新手来说反而容易“糊涂账”。所以项目中我采用了纯MyBatis加XML映射文件的方式,每个SQL都摆在明面上,面试或答辩时讲起来也底气足。
Vue选的是Vue 3 + Vite + Element Plus这套组合。Vite的启动速度和热更新比Vue CLI快一个量级,开发调试舒服很多;Element Plus自带表格、表单、分页、弹窗这些后台管理常用的组件,做商品管理和推荐位配置的页面时能省下大量重复工作。用户端则是自己写的卡片式布局,控制力更强,也更符合美食展示的视觉需求。
SpringBoot在这个架构里的定位很单纯:只做接口服务,不输出任何页面。所有的静态资源、路由跳转、页面渲染全交给Vue处理。这样做的好处是前后端可以完全并行开发,后端只需要保证接口契约稳定,前端可以随时mock数据联调。
1.2 美食推荐商城的业务范围,和普通电商系统的差异
这个系统的业务范围比我想象中要大,但绝不能无限膨胀。我最终划定的核心功能是这么几条:用户注册登录、菜品分类浏览、基于用户行为的个性化推荐、推荐位配置(管理员可手工干预)、购物车、下单、订单管理、菜品评价。没有做秒杀、优惠券、支付对接这些重业务,因为对“设计与实现”类项目来说,把主链路做完整、把推荐逻辑讲清楚,已经足够体现设计能力了。
美食推荐和普通电商最大的区别在于“推荐”二字。普通电商的核心是搜索和SKU管理,而美食推荐的核心是“用户看到什么”。这就意味着数据库设计时不能只存商品和订单,还要存用户行为数据——浏览记录、收藏记录、购买记录。推荐引擎不是单独的一个服务,而是在现有业务表基础上,通过统计和关联SQL计算出来的结果。我在开发时把推荐拆成了三条路径:热门榜单推荐、同分类相似推荐、基于用户行为特征的个性化推荐。这三条路径的数据来源和计算方式完全不同,后面会展开讲。
1.3 项目模块划分:如何让代码结构一眼就被看懂
前后端分离项目最忌讳的就是所有代码堆在一起。我前后端都分了模块,前端按views(页面)、components(组件)、router(路由)、store(状态管理)、api(接口请求)来组织;后端按controller、service、mapper、entity、dto、config、interceptor分包。这里有个细节值得强调:entity和dto一定要分开。接口入参和出参是两个东西,直接拿数据库实体类当接口参数,看起来省事,但一旦表结构变更或者接口需要扩展字段,会连带影响所有调用方。我习惯用一个Result统一响应体,包含code、message、data三个字段,后端所有接口都返回这个结构,前端拿到之后统一处理。
2. 数据库实体设计,以及美食推荐逻辑的数据支撑
数据库是整个系统的地基。美食推荐这个场景下,表结构设计得好不好,直接决定后面推荐功能写起来顺不顺畅。我建表时画了十几遍草图,最终保留了9张业务表。下面把核心表的字段和它们之间的关联关系讲清楚。
2.1 核心表的字段设计与关联关系
用户表user,除了常规的id、username、password之外,我特意加了tags字段,这是一个用逗号分隔的标签字符串,比如“川菜,辣味,夜宵”。标签是用户注册时自己勾选的口味偏好,也是后面做个性化推荐的重要依据。菜品表dish是推荐的主体,包含name、category_id、price、description、image、sales(累计销量)、rating(平均评分)、recommend_weight(推荐权重)。
分类表category很简单,就是id和name,但它的作用非常大——推荐计算中大量操作都是“按分类聚合”进行的。用户行为相关的表有两张:user_favorite(收藏表)和user_browse_log(浏览记录表)。浏览记录表我保留了user_id、dish_id、browse_time三个字段,每次用户查看菜品详情时插入一条,这是同分类相似推荐的原始数据来源。
订单相关的表是orders和order_item,一主一从。order_item里记录了每个订单包含的菜品,是购买行为数据的来源。评价表comment挂在菜品下面,记录用户评分和文字评价,评分会实时汇总更新到dish.rating。最后还有一张recommend_config表,这是管理员配置首页推荐位的表,每个推荐位绑定一个分类或者一组菜品ID,实现“人工干预”的功能。
2.2 三条推荐路径分别依赖哪些数据
先看热门榜单。这条路径最简单也最直观,不需要用户行为数据,直接一条SQL按销量和评分的加权值排序就能拿结果。我用的公式是销售热度 = 销量数 * 0.6 + 好评数 * 0.4,不需要实时计算,每晚定时跑一次更新到dish表的hot_index字段,前台直接按这个字段倒序查。
再看同分类相似推荐。当用户浏览某个菜品详情页时,页面下方会显示“猜你喜欢”列表,这里的逻辑是:取当前菜品所属分类,查询该分类下评分高于4.0、销量排名前N的菜品,过滤掉当前菜品本身。这条路径依赖category表、dish表和商品的销售评分数据。
最后是个性化推荐,也是整个系统里最有含金量的部分。逻辑分三步:先从user_browse_log查出用户最近浏览的菜品ID集合,映射到分类ID集合;再从user_favorite和order_item中提取用户偏好的分类和口味标签;最后综合这两部分数据,按分类出现频次排序,取Top3分类,每个分类下按销量和评分推荐2-3道菜。如果用户是新注册的,没有任何行为数据,就降级为热门榜单推荐,不做特殊处理。这样设计的好处是每一层都有明确的数据来源和计算规则,代码实现起来不复杂,讲解时也有层次感。
2.3 MyBatis多表关联与动态SQL的写法要点
MyBatis的XML映射文件是这个项目数据访问的核心。我建表时所有字段都采用下划线命名,在SpringBoot配置里开启了map-underscore-to-camel-case,这样数据库的category_id就能自动映射到实体的categoryId,省去了大量手写resultMap的工作。
复杂查询主要靠动态SQL。举个例子,个性化推荐里的“按分类频次排序”这一步,需要关联浏览记录表和菜品表,并且用GROUP BY统计:动态SQL还用在菜品列表页的筛选上,前端传分类ID、价格区间、排序方式,Mapper里用<where>标签拼接条件,避免拼接SQL时出现多余的AND或WHERE关键字。这里有一个非常重要的经验:动态SQL拼接时一定要用<where>而不是手工拼where 1=1,虽然效果一样,但后者代码风格太差,面试时会是减分项。
3. SpringBoot后端核心实现:登录鉴权、推荐接口与统一异常处理
后端开发我按照“接口定义 -> Service实现 -> Mapper落地”的顺序进行。先画好接口文档,再写逻辑,避免边写边改导致代码混乱。这一部分重点讲三个核心模块:登录鉴权怎么做的、推荐接口的路由和计算逻辑怎么组织的、异常处理如何做到前端少写判断。
3.1 基于Token的登录鉴权,不引入Redis也能跑通全流程
登录鉴权是前后端分离项目第一个绕不开的点。传统session方案在前后端分离架构下会有跨域Cookie携带的问题,所以我采用了JWT(JSON Web Token)方案:用户登录成功后,后端签发一个有效期为24小时的token返回给前端。前端把token存在本地存储中,每次请求在Axios拦截器里加上Authorization: token请求头。后端用拦截器统一校验token合法性。
项目没有引入Redis,token校验逻辑是对JWT字符串做签名解析,解析失败或过期就返回401状态码,前端拦截401响应并跳转登录页。这里有一个真实经历想分享:第一次做拦截器时,我把放行路径写错了,导致登录接口也被拦截,前端登录请求始终返回401,排查了将近一个小时才发现是路径匹配的问题。后面我总结出一套稳妥的写法:addPathPatterns("/**")拦截所有请求,再通过excludePathPatterns显式放行/api/user/login、/api/user/register、/api/dish/**等无需登录就能访问的接口(菜单浏览需要允许游客访问)。
登录后的用户信息怎么在各个Service之间传递?我没有用参数一路传递的方式,而是写了一个UserContext类,基于ThreadLocal存储当前登录用户的ID。拦截器校验token通过后,解析出userId存入ThreadLocal,后续Service层任意位置都能取到当前用户。请求结束后在拦截器的afterCompletion方法里清理ThreadLocal,防止内存泄漏。
3.2 推荐接口的路由设计与三层实现逻辑
我按照“前端需要什么数据”来设计接口,而不是按照“数据库有什么表”来设计。推荐相关共设计了三个接口:
GET /api/dish/hot返回热门榜单,Controller里只做一件事——从Service取前10条菜品的hot_index倒序结果,组装成卡片数据返回。菜品卡片需要同时包含图片、名称、价格、平均评分、累计销量,这些字段分布在不同表里,所以在Mapper层做了一次三表关联查询(dish、category、comment的聚合结果)。
GET /api/dish/similar/{dishId}返回同分类相似推荐。Service的逻辑是:根据菜品ID查出分类ID,再查同分类下评分高于4.0的菜品,按销量排序取前5条,排除当前菜品。如果同分类下菜品不足5条,剩余位用热门榜补齐,这个兜底策略很多人会忽略,但实际体验差异很大——用户看到“相关推荐为空”的页面会直接失去兴趣。
GET /api/dish/recommend返回个性化推荐,也是最复杂的一个接口。Service层逻辑分四步:
public List<DishVO> getPersonalRecommend(Integer userId) { // 1. 查询用户浏览和收藏行为,提取分类偏好 List<Integer> browseCategoryIds = browseLogMapper.selectCategoryIdsByUserId(userId); List<Integer> favCategoryIds = favoriteMapper.selectCategoryIdsByUserId(userId); // 2. 合并分类ID,统计频次,取Top3 Map<Integer, Integer> freqMap = new HashMap<>(); for (Integer id : browseCategoryIds) freqMap.merge(id, 1, Integer::sum); for (Integer id : favCategoryIds) freqMap.merge(id, 2, Integer::sum); List<Map.Entry<Integer, Integer>> sorted = freqMap.entrySet().stream() .sorted(Map.Entry.<Integer, Integer>comparingByValue().reversed()) .limit(3).collect(Collectors.toList()); // 3. 按推荐分类集合查询菜品,并按销量评分排序 List<DishVO> result = new ArrayList<>(); for (Map.Entry<Integer, Integer> entry : sorted) { List<DishVO> dishes = dishMapper.selectByCategoryOrderByHot(entry.getKey(), 3); result.addAll(dishes); } // 4. 兜底:不足10条用热门榜补充 if (result.size() < 10) { List<DishVO> hotDishes = dishMapper.selectHotList(10 - result.size()); result.addAll(hotDishes); } return result; }收藏行为的权重是浏览行为的2倍,这个系数我是在测试时手动调整出来的。如果只统计浏览次数,很多用户只是随手翻翻,推荐结果会偏离真正感兴趣的品类;加上收藏和购买的加权后,推荐准确率明显提升。这个经验虽然看似只是改一个数字,但背后是对用户行为层次的思考。
3.3 统一响应体、全局异常处理与参数校验
接口返回格式统一这件事,我在这个项目中吃了不少教训。最初每个接口返回的数据结构都不太一样,前端要针对每个接口单独解析,联调效率很低。后来我定义了一个Result<T>类:
public class Result<T> { private Integer code; // 200成功,401未登录,500系统错误 private String message; // 提示信息 private T data; // 业务数据 }配合@RestControllerAdvice实现全局异常处理。业务中自定义异常BusinessException(比如购物车商品不存在、余额不足),校验类异常MethodArgumentNotValidException(参数缺失或格式错误),以及兜底的Exception,分别返回对应的错误码和提示信息。这样前端只需要判断code === 200即可进入正常逻辑,其他情况直接弹出message提示,不需要每个接口写一遍错误处理。
参数校验我用的javax.validation注解,比如@NotBlank、@Min、@NotNull,配合@Validated在Controller入参上开启校验。这是一个提升开发效率的好习惯——参数校验逻辑从Service搬到注解上,代码干净很多,也避免了非法数据直接插入数据库造成的后续问题。
4. Vue前端如何组织页面、路由与接口请求
前端开发这件事,很多人以为就是“套模板改样式”,其实工程化程度一点都不比后端低。这个项目的前端部分包含了用户端和管理后台两套界面,共二十多个页面,我把它们组织成一个清晰的单页应用,通过Vue Router管理路由跳转,通过Pinia管理全局状态,通过Axios统一发起HTTP请求。
4.1 动态路由与页面布局
用户端页面包括首页、分类列表页、菜品详情页、购物车页、订单页、个人中心页;管理后台包括菜品管理页、分类管理页、订单管理页、推荐位配置页、用户管理页。用户端首屏我直接展示推荐内容,因为这是整个项目的核心卖点。
路由配置分了静态路由和动态路由两部分。动态路由指的是管理员登录后要动态追加的后台管理页面:普通用户的localStorage和Vuex里没有admin角色标记,所以不能访问管理后台。Vue Router的beforeEach导航守卫里做了角色判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.path.startsWith('/admin') && role !== 'ADMIN') { next('/403') } else { next() } })之前踩过一个坑:动态路由加载后再次刷新页面,路由表会丢失,导致刷新后直接404。解决方案很明确,在router/index.js的初始化阶段,先读取本地存储中的角色信息,通过addRoute把有权限的路由重新注册一遍。这个问题出现频率极高,凡是做动态路由的项目基本都会遇到,提前处理能省很多事。
4.2 Axios封装与跨域处理
Axios封装是前后端分离项目中非常基础但重要的一环。我在src/api/request.js里做了统一配置:
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = token return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('登录过期')) } return res }, error => { Message.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } )注意这里的baseURL是/api,这是为了配合部署时使用nginx反向代理转发。开发环境下,我在vite.config.js里配置了代理:/api前缀的请求转发到http://localhost:8080,这样开发时不存在跨域问题,也不需要后端配置CORS。生产环境部署时,nginx里同样配置/api前缀转发到后端服务地址。用同一个路径规则贯穿开发和生产,是前后端分离项目最简洁稳妥的做法。
4.3 核心页面与组件的实现思路
首页是用户对系统的第一印象,也是推荐功能的前端呈现。页面结构分为三块:顶部是搜索框和分类导航,中间是“热门榜单”横向滚动的卡片区域,下方是“猜你喜欢”瀑布流列表。推荐的卡片组件DishCard抽取出来复用,传入菜品数据对象,组件内部完成图片懒加载、评分星星展示、价格标签的渲染。图片加载失败时使用默认占位图,这个细节最初没处理,导致某几条脏数据让页面出现破图,观感很差。
菜品详情页是用户行为的触发点,用户在进入此页时,前端会调用POST /api/browse/record接口上报一次浏览行为。注意这个请求用navigator.sendBeacon还是普通Axios?我实际用的是普通Axios,因为项目里没有做埋点采集的复杂需求,只要保证请求发出即可。这个接口给个性化推荐提供了原始数据来源,是整条推荐业务链路能否运转的关键。详情页还包含“加入购物车”“立即购买”“评分评价”三个核心操作,以及“相关推荐”区域,相关推荐就是调用的GET /api/dish/similar/{dishId}接口。
管理后台我重点做了“推荐位配置”页面。这个页面里列出了所有菜品,管理员可以通过拖拽或者手动设置权重值的方式调整recommend_weight字段。前端通过el-table展示,用el-input-number设置权重,保存时批量提交到后端。这块功能看似简单,但它让“人工干预推荐”变成了现实,也是解释项目亮点时的加分项。新增菜品时,el-upload组件完成图片上传,后端将文件保存到本地指定目录,同时返回访问URL保存在菜品记录中。
5. Maven多环境打包与前后端分离部署的完整流程
部署环节是这个项目从“能跑”到“能给别人演示”的最后一道坎。前后端分离部署的坑非常多,比如前端刷新404、后端端口被占用、数据库时区报错、跨域拦截,任何一个没处理干净都可能在演示现场翻车。我把整个部署流程和排查经验完整记录下来。
5.1 后端Maven打包与多环境配置
后端项目打包前,我先在application.yml中配置了多环境:本地开发环境连本地MySQL,生产环境连云服务器数据库。用SpringBoot的spring.profiles.active切换,打包时配合Maven的profile参数指定环境。SpringBoot自带Tomcat,直接打包成可执行的jar,用java -jar命令就能启动。
打包命令是mvn clean package -DskipTests,一定要跳过测试,否则测试用例失败会导致打包中断。生成的文件在target目录下,文件名形如food-recommendation-0.0.1-SNAPSHOT.jar。启动命令是nohup java -jar food-recommendation-0.0.1-SNAPSHOT.jar --server.port=8080 > app.log 2>&1 &,这个命令会把日志输出到app.log文件,即使关闭终端服务也不会停掉。
启动后的第一件事是查看日志,确认端口监听正常、数据库连接成功。常见的报错有两个:一是端口被占用,解决方式是先查端口再杀进程;二是数据库连接失败,报错信息多半是Connection refused或Access denied for user,需要检查数据库服务的启动状态、账号密码。MySQL 8.x版本还特别容易出现时区报错,解决方案是在JDBC连接串上增加参数serverTimezone=Asia/Shanghai和useSSL=false。
5.2 前端构建与Nginx配置
前端构建其实非常简单,在package.json所在目录执行npm run build,Vite会把所有页面打包成静态文件,生成到dist目录。构建完成后的dist目录就是前端部署的根目录。
生产环境我用Nginx托管前端,并通过反向代理把/api请求转发到后端服务。这里给出一个可以直接套用的简化配置:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /images/ { alias /home/upload/images/; } location / { try_files $uri $uri/ /index.html; } }关键点有三个:第一,try_files $uri $uri/ /index.html这一行必须写。前端路由是history模式,用户直接访问/dish/12这个URL时,Nginx上并不存在这个物理路径文件,如果不重写到index.html,刷新页面就会404。第二,location /api/的proxy_pass http://127.0.0.1:8080;,这里末尾没有带斜杠,意思是将完整的/api/xxx路径原样转发给后端,后端Controller定义的@RequestMapping("/api/dish")就能正常匹配。如果proxy_pass写成了http://127.0.0.1:8080/,Nginx会把/api前缀去掉再转发,后端的路由就要去掉/api前缀,两边的约定必须一致,这是最容易踩坑的地方。第三,由于前端通过/api代理访问后端,所有请求都是“同源”的,不需要在SpringBoot里配置CORS跨域,能有效降低安全风险。
5.3 MySQL初始化与常见连接错误处理
MySQL5.7和8.x我都部署过,推荐新项目直接上8.x,因为字符集、性能、窗口函数都比5.7好很多。部署后第一步是初始化库表,项目根目录下我放了一个init.sql,包含完整的建库、建表、初始数据脚本,一条命令导入:
mysql -u root -p < init.sql连接配置是另一个高频出问题的地方。我建议为了减少问题,创建独立数据库账号并只授权业务库权限,不要用root作为应用账号。SpringBoot的application.yml配置示例:
spring: datasource: url: jdbc:mysql://121.x.x.x:3306/food_recommend?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: food_app password: xxxxxx driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrieval=true这个参数是我最新一次部署时加上的,MySQL8.x默认使用caching_sha2_password认证,有些客户端会报Public Key Retrieval is not allowed错误,加上这两个参数(useSSL=false同样重要)基本能一次解决。字符集问题也不要忽视,如果建库时没有指定utf8mb4,插入中文后会变成乱码,所以我建库语句里固定写DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci。
6. 开发过程中真正值得记录的踩坑经历与优化方向
做这个项目的过程中,我记录的踩坑笔记比项目代码本身还要长。这一节挑几个典型问题,把完整的排查链路写出来,而不是只给结论。
6.1 “我的列表为什么接口返回了,数据却全是0?”——MyBatis字段映射的坑
有段时间菜品列表接口返回的数据里,价格、库存、分类ID全都是0或null,唯独名称正常。排查过程是这样的:先看数据库,数据是好的;用Postman直接请求接口,返回的JSON里数值字段确实丢失了。然后怀疑是实体类字段类型问题,检查Dish实体类发现字段名是categoryId、price,数据库列名是category_id、price,两者对不上。
这个问题的根因是application.yml中的map-underscore-to-camel-case配置没有生效。排查配置后发现,它在mybatis.configuration节点下,而我当时写在了mybatis节点下,层级不对,SpringBoot根本没有读取到。修正后的配置如下:
mybatis: configuration: map-underscore-to-camel-case: true这个问题的本质是我对SpringBoot配置组织方式不熟悉导致的,但排查过程中我发现了一个更稳妥的方案:在XML映射文件中为每个查询显式声明resultType,或者对特殊字段写resultMap。虽然代码量会多一点点,但完全绕开了配置可能失效的风险。
6.2 “前端刷新页面就404,刷新就丢登录状态”——动态路由的两个连锁问题
这个坑属于典型的“开发时没觉察、发布后翻车”。开发模式下一切正常,因为Vite的dev server内置了history fallback,自动把路由重写到index.html。但生产环境Nginx没有这个能力,所以必须在Nginx里配置try_files,同时后端在打包时需要生成正确的dist目录,将Nginx的root指到该目录,并确保location /能匹配到index.html。
第二个连锁问题是登录状态丢失。Vue应用刷新后,所有JavaScript变量重新初始化,如果token只存在内存的Pinia store里,刷新即丢失。正确做法是持久化存储token,并在应用启动时从本地存储恢复状态。我用的是Pinia的持久化插件,核心思路是在store定义里声明persist: true,它会自动把指定state同步到localStorage,刷新后重新恢复。这里要提醒一句:持久化token之前,务必检查路由守卫的逻辑是否依赖localStorage.getItem('token'),否则会出现“数据在但状态不在”的诡异表现。
6.3 推荐结果出现重复食品,去重与多样性优化的经验
开发个性化推荐接口时,测试了一段时间后发现推荐列表经常出现重复菜品。仔细排查发现,Top3分类里有重叠的菜品,比如“川菜”和“辣味”分类下都有“水煮鱼”,用户浏览历史同时命中两个分类时,菜品就会重复出现两次。另外,热门榜兜底时也会与已推荐菜品重复。
解决办法不复杂,在Service层用一个LinkedHashSet收集结果,天然去重且保持插入顺序。但如果仅仅依赖去重,推荐多样性会大打折扣——用户看到的永远是同一批热门菜。后续我在去重基础上加了“同分类内最多推荐3道菜”的限制,保证每次推荐结果里至少有2个不同分类的菜品。这些优化虽然只改了十几行代码,但用户体感差异非常明显,也是项目演示时能展开讲的亮点。
6.4 图片上传与存储的后续扩展思路
最初的图片存储方案是最朴素的“上传到本地目录,数据库存URL,Nginx以静态资源方式访问”。这是最快能跑通的方案,但有两个隐患:第一,程序重启或服务器磁盘操作后图片可能丢失;第二,多人同时访问时静态资源请求会占用主进程资源。
如果后续想升级,热搜词里有几个方向可以参考。一个是MinIO,对象存储服务,可以单独部署,SpringBoot通过SDK接入,图片上传走putObject接口,返回的访问地址是MinIO的对外地址。MinIO的好处是扩容简单、权限可控,比较适合作为一个真实的“文件服务”模块来展示。另一个是TinyPNG或thumbnailator做图片压缩,美食图片通常很大,不压缩会导致首页加载缓慢。这些都是增量优化,不会影响现有结构,可以在主功能全部稳定之后逐步引入。
6.5 推荐效果如何提升,协同过滤和用户行为埋点的进阶思路
现在项目里的推荐逻辑属于“基于规则”的推荐,有明确公式和业务含义,开发简单、可解释性强。但要说推荐效果有质的飞跃,还需要引入协同过滤的思路——用户对菜品的评分、浏览时长、加购行为、购买频次,这些都可以量化成特征向量,用余弦相似度或皮尔逊相关系数寻找“相似用户”,再推荐相似用户偏好的菜品。这个方向我目前只在接口的Service层预留了扩展点,没有把算法完整搬进来,因为纯Java实现协同过滤的代码量相当可观,而且需要额外的离线计算任务支撑。如果读者时间充裕,建议尝试一下基于商品的ItemCF简化版本——统计“购买过A也购买过B”的共现次数,生成关联规则表,这比用户协同过滤更贴合食品商城的业务场景,而且不需要用户量很大就能跑出效果。
还有一点容易被忽略:用户行为数据的埋点质量,决定了任何推荐算法上限。项目里我预埋了浏览、收藏、购买、评价四类行为记录,但真正的埋点系统还需要在这四类事件上附带上下文信息,比如“从哪个页面进入的”“页面停留时长”“是否搜索后点击”。这些数据虽然不参与当前版本的推荐计算,但是后续引入机器学习模型时的必备原料。开发时就把埋点字段设计好,等于为项目预留了进化空间。
写在最后的一点体感分享
整个项目从设计到部署上线,我一个人的开发周期大约花了两周半的时间,其中真正的编码时间不到一半,其余时间几乎都耗在了“以为能用结果不能用”的细节上。比如明明写的SQL单独执行没问题,放进MyBatis的XML里却报BadSqlGrammarException,最后发现是<符号没转义;又比如前端npm install时版本冲突,锁了半天版本才发现是Node版本过旧导致Vite跑不动。这些经验现在看起来都很基础,但当时确实每次都能卡我半小时以上,所以我把它们全部写进了项目的README里,并在关键位置加了注释。
我始终觉得,一个前后端分离项目能否真正算“完成”,判别标准不是代码能跑,而是换一台干净的机器,按照你的部署文档能从零开始跑通整个系统。从这个标准看待,美食推荐商城的推荐算法、数据库设计、接口契约、部署方案,每一环都有它存在的理由,也都有可优化的空间。如果正在看这篇文章的你打算照着做一个类似系统,建议先把数据库表和推荐数据流理解透,再动手写代码,否则很容易陷入“一直在写接口却搞不清为什么写”的泥潭。推荐功能是这个系统区别于普通商城的主心骨,把它做好,整个项目的价值也就立住了。