ARTICLE DETAIL

资讯详情

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

基于Java美食推荐管理系统:从Spring Boot到协同过滤算法实战

基于Java美食推荐管理系统:从Spring Boot到协同过滤算法实战 简介一份基于 Java 的美食推荐管理系统设计与实现完整论文文档面向计算机相关专业毕业生、Java Web 学习者及需要开发类似信息管理系统的开发者。文档以 JSPMySQLB/S 架构为核心完整覆盖课题背景、国内外现状、需求分析、总体设计、数据库设计、功能模块实现与系统测试等环节。系统角色分为管理员与普通用户包含个人中心、美食分类、热门美食管理、美食教程、美食店铺、美食社区等模块既支持前台内容展示与交互又具备后台数据维护能力。资源包共1个 docx 文件约4.76MB内容以论文正文为主目录结构完整可直接用于参考写作或二次开发。目前已有76人学习下载对于需要完成类似选题毕业设计或课程项目的读者可快速获取系统设计思路、表结构划分、页面功能规划及测试方案节省选题和撰写时间。1. 基于java美食推荐管理系统这到底是一个什么段位的项目很多刚走完java基础、准备做第一个完整后端项目的同学都会把“美食推荐管理系统”当成课程设计来做——建个用户表、搞个菜品表、写几个增删改查的接口最后套一个前端模板演示完就交了。但如果你在简历上写这个项目面试官真正追问的永远不是CRUD而是三件事推荐是怎么算的系统扛不扛得住真实数据以及你踩过哪些坑之后才把系统调稳。这个标题里的关键词是“推荐”——没有推荐算法它只是一个管理后台有了推荐它才是一个有数据价值、有调参空间、能写进简历的完整项目也是java后端完整成长路线上一个很值得认真做的中期练手项目。这个系统的落地路径我一般分成四段先定架构和数据库再实现用户端和管理端的业务闭环然后把推荐算法从“能跑”调到“有效”最后处理并发和冷启动这些真实世界的刺头。接下来我会把每一段该怎么做、参数怎么定、坑在哪里按我自己的实现习惯拆给你看。这篇笔记不需要你有一整年的后端经验但如果你连Spring Boot的基本注解都还不熟建议先花一周把java基础补一遍再动手。2. 系统架构与数据库设计先让数据模型立住推荐才有得算2.1 技术选型为什么Spring Boot是比SSH更稳的地基早年的课程设计里SSHStruts Spring Hibernate是标配但放到今天我强烈建议直接用Spring Boot 2.x MyBatis-Plus 的组合。原因很直接Spring Boot把配置和依赖冲突这两个最容易劝退新手的点压到了最低内置Tomcat一个java -jar就能跑起来MyBatis-Plus则在XML之外提供了一套看着很自然的单表CRUD封装对关系不复杂的系统来说写代码的速度能快一倍。这个系统的核心并不在ORM层面所以不要在这里引入太重的框架。我在做选型时坚持一个原则推荐算法的计算逻辑必须放在Java的Service层里而不是直接写死在SQL里——原因后面你会看到协同过滤需要的是矩阵运算和内存缓存SQL只适合取数和落库强行用SQL算相似度会让性能变得很难看。2.2 角色与权限用户、商家、管理员三个视图怎么切美食推荐管理系统里最容易被做成一锅粥的就是权限设计。很多初版代码把“用户”和“商家”塞在同一张表里用一个字段区分身份接口里到处写if(user.getRole() 1)到了第5个接口就乱套。我的做法是拆成三张核心实体用户表、商家表、管理员表彼此不共享主键策略——用户ID走普通自增商家ID单独从1000开始这样即使前端传错了ID类型后端也能靠业务前缀快速拦截。三个角色的功能边界我建议你在动手写代码之前先在纸上画清楚否则后面接口会越写越乱各写各的角色核心功能关键权限点普通用户注册登录、浏览菜品、评分、收藏、看推荐列表只能操作自己的评分与收藏记录商家管理本店菜品、查看本店评分统计只能操作用户对自己店铺的反馈不能改用户资料管理员审核商家入驻、下架违规菜品、查看全站数据统计拥有全部写权限但不参与推荐这个表对应的就是系统里的过滤器链用户请求走/api/user/**商家走/api/merchant/**管理员走/api/admin/**。用三个基础URL前缀做切分比在每个Controller里判断角色更省心后面前端做路由权限控制也直接对得上。2.3 核心数据表从用户表到评分表的完整建表脚本数据库设计是这个系统的地基。我的习惯是先把所有表结构写成一个schema.sql文件放在仓库里而不是一点一点在Navicat里点出来这样每次环境重建都能一键恢复。下面是我为这个项目准备的核心表结构一共8张表先看其中最关键的四张CREATE DATABASE IF NOT EXISTS food_recommend DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci; USE food_recommend; CREATE TABLE t_user ( user_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password_hash VARCHAR(128) NOT NULL COMMENT 密码哈希, taste_type VARCHAR(20) DEFAULT NULL COMMENT 口味偏好1麻辣 2清淡 3甜口 4无偏好, location_city VARCHAR(50) DEFAULT NULL COMMENT 所在城市, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 用户表; CREATE TABLE t_shop ( shop_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 商家ID, shop_name VARCHAR(100) NOT NULL, category_id INT NOT NULL COMMENT 分类ID关联t_category, avg_rating DECIMAL(3,2) DEFAULT 0.00 COMMENT 平均评分冗余字段, rating_count INT DEFAULT 0 COMMENT 评分人数冗余字段, location VARCHAR(200) DEFAULT NULL, status TINYINT DEFAULT 0 COMMENT 0待审核 1已上架 2已下架 ) ENGINEInnoDB COMMENT 商家店铺表; CREATE TABLE t_dish ( dish_id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_id BIGINT NOT NULL, dish_name VARCHAR(100) NOT NULL, price DECIMAL(8,2) NOT NULL, tags VARCHAR(200) DEFAULT NULL COMMENT 标签辣、硬菜、下饭, pic_url VARCHAR(255) DEFAULT NULL ) ENGINEInnoDB COMMENT 菜品表; CREATE TABLE t_rating ( rating_id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, score TINYINT NOT NULL COMMENT 评分1-5, comment VARCHAR(500) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_dish (user_id, dish_id), FOREIGN KEY (user_id) REFERENCES t_user(user_id) ON DELETE CASCADE, FOREIGN KEY (dish_id) REFERENCES t_dish(dish_id) ON DELETE CASCADE ) ENGINEInnoDB COMMENT 评分表;这里有两个细节我要单独拿出来讲因为它们直接影响推荐算法的输入质量。第一个是t_rating上的uk_user_dish唯一约束——同一个用户对同一个菜品只能有一条评分记录后续业务里用INSERT ... ON DUPLICATE KEY UPDATE去执行评分天然就是防重复的不需要业务代码里先查一遍再决定insert还是update。第二个是t_shop里的avg_rating和rating_count这两个冗余字段表面上违背了第三范式但这是刻意为之前台用户每次进来都要看店铺均分如果实时用AVG()去聚合数据量一上来每一次列表页请求都伴随一次全表扫描一次的代价不起眼但并发一上来就秒崩。这个冗余字段在写入侧维护成本很低读取侧收益却很显著。2.4 索引设计与多表查询的取舍还有一个我踩过坑的细节不要给t_rating表复用一个联合索引去强行支撑所有查询。当时我为了贪省事只建了uk_user_dish(user_id, dish_id)这一个唯一索引结果做“用户最近评分历史”的查询时SQL优化器可能还是走全表扫描因为查询条件只用了user_id覆盖不了后面两个单独查询的场景。推荐管理系统的真实查询模式其实很固定按热点去建独立索引就好——t_rating表上加idx_user(user_id)、idx_dish(dish_id)t_dish表上加idx_shop(shop_id)和idx_category(category_id)。记住一个朴素的判断标准索引不是越多越好而是每个索引都要能说清楚“它服务了哪条业务SQL”。多表查询我也有一个习惯业务层尽量只做两表联查三表以上的场景优先用冗余字段解决而不是嵌套三层JOIN。比如推荐结果列表需要展示“菜名 店铺名”就不要JOIN三张表去一次取全而是先查到dish_id列表然后用IN去查店铺信息拼装——这在数据量几千条时差异无感到几十万条时响应时间会差出一个数量级。3. 推荐算法核心在Java里从零实现协同过滤而不是调库3.1 为什么选协同过滤而不是基于内容的推荐做美食推荐的时候很多人第一反应是“给菜品打标签然后按用户偏好匹配”——这就是基于内容的推荐逻辑简单但有一个致命的先天不足新菜品没有行为数据老用户的偏好画像又容易被标签体系困死比如一个人长期吃辣菜系统就永远不会给他推荐清淡的改良菜推荐结果会越来越“窄”。在用户量和菜品量都处于中小规模几百到几十万条数据时基于用户的协同过滤是性价比最高的方案。它的核心直觉很朴素找到和你口味最像的一群人看他们吃什么把这些菜推荐给你。这个方案完全不需要菜品事先有标签数据冷启动靠人工填初始评分就能对付过去而且如果你后续想升级成基于物品的协同过滤代码改动的只有相似度计算的方向架构不用动。所以我在这个项目里选了基于用户的协同过滤作为第一版算法。3.2 相似度与预测评分余弦相似度实现的关键细节实现协同过滤之前先把两个公式在代码里对齐。第一个是用户之间的相似度我选择余弦相似度而不是皮尔逊相关系数因为皮尔逊计算需要减去均值在评分数据稀疏且大量用户只评了很少几道菜时均值本身不稳定算出来的相关很容易失真。余弦相似度的公式在这里不做数学展开直接看代码里怎么落地。第二个是预测评分计算用户A对某道没吃过的菜X的预测分数时取与A相似度最高的K个用户用这些用户对X的评分做加权平均权重就是相似度。这两个公式对应到Java代码核心逻辑全部放在一个独立的RecommendService里不污染Controller层。下面这是我实现的计算流程骨架Service public class RecommendService { Resource private RatingMapper ratingMapper; Resource private DishMapper dishMapper; // 相似度阈值低于0.3的用户不做邻居 private static final double SIM_THRESHOLD 0.3; // 邻居数量取最相似的K个用户 private static final int TOP_K_NEIGHBORS 10; public ListDishVO recommendForUser(Long userId, int limit) { // 1. 取全量评分数据到内存数据量可控前提下 ListRating allRatings ratingMapper.selectList(null); MapLong, MapLong, Integer userDishMap new HashMap(); for (Rating r : allRatings) { userDishMap.computeIfAbsent(r.getUserId(), k - new HashMap()) .put(r.getDishId(), r.getScore()); } MapLong, Integer targetUserRatings userDishMap.getOrDefault(userId, Collections.emptyMap()); // 2. 遍历其他用户计算与目标用户的余弦相似度 MapLong, Double similarityMap new HashMap(); for (Map.EntryLong, MapLong, Integer entry : userDishMap.entrySet()) { if (entry.getKey().equals(userId)) continue; double sim cosineSimilarity(targetUserRatings, entry.getValue()); if (sim SIM_THRESHOLD) { similarityMap.put(entry.getKey(), sim); } } // 3. 按相似度排序取TopK用户 ListMap.EntryLong, Double neighbors similarityMap.entrySet().stream() .sorted((a, b) - Double.compare(b.getValue(), a.getValue())) .limit(TOP_K_NEIGHBORS) .collect(Collectors.toList()); // 4. 候选菜品邻居吃过且目标用户没吃过的菜 MapLong, Integer candidateScores new HashMap(); MapLong, Double candidateWeights new HashMap(); for (Map.EntryLong, Double neighbor : neighbors) { Long neighborId neighbor.getKey(); double weight neighbor.getValue(); MapLong, Integer neighborRatings userDishMap.getOrDefault(neighborId, Collections.emptyMap()); for (Map.EntryLong, Integer ratingEntry : neighborRatings.entrySet()) { Long dishId ratingEntry.getKey(); if (targetUserRatings.containsKey(dishId)) continue; candidateScores.merge(dishId, 5, Integer::sum); candidateWeights.merge(dishId, weight, Double::sum); } } // 5. 加权得分排序返回TopN ListLong rankedDishIds candidateScores.entrySet().stream() .sorted((a, b) - Double.compare( candidateWeights.get(b.getKey()) * b.getValue(), candidateWeights.get(a.getKey()) * a.getValue())) .limit(limit) .map(Map.Entry::getKey) .collect(Collectors.toList()); return dishMapper.selectBatchIds(rankedDishIds).stream() .map(DishConverter::toVO) .collect(Collectors.toList()); } private double cosineSimilarity(MapLong, Integer a, MapLong, Integer b) { double dot 0, normA 0, normB 0; for (Map.EntryLong, Integer e : a.entrySet()) { normA e.getValue() * e.getValue(); Integer scoreInB b.get(e.getKey()); if (scoreInB ! null) { dot e.getValue() * scoreInB; } } for (Integer score : b.values()) { normB score * score; } if (normA 0 || normB 0) return 0.0; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } }代码里有一个细节值得注意第3步的TOP_K_NEIGHBORS和第4步的candidateScores.merge(dishId, 5, Integer::sum)之间实际计算预测分数时我做了简化是用“出现次数×邻居权重”来排序候选菜品的而不是严格套用加权平均公式。这么做的原因很实际第一版系统数据量小用户评分行为稀疏真正的加权平均在分母趋近于0时会不稳定反而这个“权重越大且出现次数越多的菜越靠前”的公式在工程上更鲁棒等数据量明显增长了再换精确公式也不迟。这个折衷是我踩过坑之后有意为之的一次简化。3.3 冷启动处理新用户没有评分数据时推荐什么协同过滤最怕冷启动新用户进来一条评分都没有targetUserRatings是空map算出来的cosineSimilarity全为0推荐结果就是空列表。我的处理方案分两层第一层是针对完全没有行为数据的新用户直接退化为“热门榜推荐”按店铺平均评分和评分人数计出一个综合热度分这个策略简单但也最稳。第二层是评分只有一两笔的“半冷启动”用户用taste_type这个字段去做粗粒度的口味复用。比如新用户选了一个“麻辣”的偏好系统就拿所有口味为麻辣的用户的评分数据做一个虚拟邻居把这个虚拟邻居的Top20菜品作为候选。这样即使真实评分数据为零推荐结果也不是随便拍出来的。4. 核心业务功能实现从注册登录到评分推荐的一整条链路4.1 注册登录MD5加盐与Token鉴权登录是每个后端面试必问的考点也是这个系统里最容易暴露基础薄弱的地方。第一版往往直接用Sha256(password)存库然后在后续每个需要身份的Controller方法里手动去查userId——这种做法既没解决密码存储的安全强度也没有把鉴权逻辑收口。我习惯的做法是注册时用MD5加盐登录成功后签发一个简单的UUID Token放在Redis里过期时间24小时需要一个AuthInterceptor拦截所有/api/user/**的请求从Header里取Token并解析出userId。Component public class AuthInterceptor implements HandlerInterceptor { Resource private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 登录接口本身不走拦截器这里只处理已经登录的请求 String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } String realToken token.substring(7); String userId redisTemplate.opsForValue().get(login:token: realToken); if (userId null) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write({\code\:401,\msg\:\Token无效\}); return false; } request.setAttribute(currentUserId, Long.parseLong(userId)); return true; } }这里Token存储用Redis而不是内存Map是因为如果部署成多实例每个实例各自维护一份内存Token会导致用户在A机器登录后、被负载均衡切到B机器就掉线。开发阶段你可以在本机用一套简易HashMap糊弄过去但既然Spring Boot自带Redis依赖且环境成本极低直接用Redis更省心后边上生产也不用推倒重来。4.2 评分与评论唯一约束与更新逻辑评分功能的实现有一个常见的错误版本先查一遍有没有记录没有就insert有就update。然后并发请求一到两条线程同时查到没有记录同时insert数据库直接报重复键错误而后端日志里根本没有处理这个异常。正确做法我在前面建表时已经埋了伏笔——利用uk_user_dish唯一约束用MySQL的INSERT ... ON DUPLICATE KEY UPDATE一条语句解决Mapper public interface RatingMapper extends BaseMapperRating { Insert(INSERT INTO t_rating(user_id, dish_id, score, comment) VALUES(#{userId}, #{dishId}, #{score}, #{comment}) ON DUPLICATE KEY UPDATE score VALUES(score), comment VALUES(comment)) int upsertRating(Param(userId) Long userId, Param(dishId) Long dishId, Param(score) Integer score, Param(comment) String comment); }用了这个写法之后你的Service代码不需要先查再写也不用捕获DuplicateKeyException再去办法补救一行SQL就把并发场景处理干净了。另外记得在upsert之后异步更新t_shop表的avg_rating冗余字段这一步如果单独写一个事务方法同步执行会让评分的TTFB增加我只能说量小无感、量一大必炸实战里用Async拆出去更稳。4.3 统一返回体与全局异常处理给前端一个不会崩的接口接口风格统一这件事往往不是一开始就意识到的而是前后端开始联调那天才崩出来的一个接口返回{code:200, data:{...}}另一个接口裸返回一个数组第三个接口报错时直接扔了异常堆栈给前端。我现在的写法是封装一个ResultT泛型类所有Controller方法都返回这个结构体正常时code200业务异常时由RestControllerAdvice统一捕获并转成code500的JSON结构返回给前端不让原始异常裸奔出去。这样做的好处是前端可以在这个基础上写一套统一的后端请求错误处理不用每个页面单独try-catch这就是我的全栈链路里最早被我踩碎的一个坑。5. 避坑指南美食推荐系统开发中最常见的五个翻车点5.1 数据库中文乱码三处编码不一致导致的经典故障现象插入“麻辣香锅”之后前端页面上显示成“????”或“麻辣 鍋”之类的乱码。原因数据库连接串、建表语句、页面响应头三处的字符集没有完全统一。很多人只注意到建库时设置了utf8mb4却忘了在JDBC连接串里同样指定结果Java的UTF-8字符串转成MySQL连接的默认字符集latin1入库就成了问号这是java环境变量配置之外最让人难受的“玄学”问题。解决建表语句统一用utf8mb4JDBC连接串务必加上characterEncodingutf8后端返回JSON时再检查一次Content-Type: application/json;charsetUTF-8。排查时先用SHOW CREATE TABLE t_shop;看表的默认字符集再用SHOW VARIABLES LIKE character_set_connection;看连接字符集逐层排查比盲目换编码有效得多。5.2 连接池耗尽每次查询都要把连接还回去现象本地测单个功能一切正常一跑压测或者多个用户同时操作后台报Connection is not available, request timed out。原因最常见的是JDBC代码里Connection打开之后没有在finally里关闭。如果用的是Spring BootMyBatis连接池的默认最大连接数HikariCP默认10看起来够用但每次查询都泄漏一条连接几分钟内就会被耗尽。还有一版更隐蔽的泄漏是事务方法内执行业务操作时catch住异常却忘记TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()事务上下文把连接死死攥着不释放。解决优先排查所有finally块是否完整如果用了MyBatis-Plus检查是否有地方直接调用了SqlSession并确保它在try-with-resources里关闭。另外建议把连接池抬到maximum-pool-size: 30左右并在配置里打开连接泄漏检测日志里能直接看到哪条链路没归还。5.3 评分矩阵稀疏导致推荐全员为空现象推荐接口偶尔正常偶尔返回空列表看日志发现相似度全部小于SIM_THRESHOLD。原因前期需要注册大量新用户并让每个人都去评几道菜但实际测试用户只有五六个评过分数的菜少导致任意两个用户之间共同评分的菜品数量非常少余弦相似度天然趋近于0SIM_THRESHOLD0.3这个阈值下几乎筛不出邻居来。这不是代码bug是数据问题。解决我一般会把阈值从固定值改成动态兜底——如果没有一个用户相似度超过0.3就把阈值自动降到0.1再看一次如果仍然为空直接落回热门榜推荐。这种方式代码量不大但能让系统在真实项目早期数据积累不足时仍然保持可用状态这是get到生产习惯的一个关键点。5.4 并发评分下店铺均分不一致冗余字段的更新竞争现象用户端看到的店铺平均分和商家后台统计出来的平均分总是不一致。原因t_shop.avg_rating由评分接口同步更新但两个用户同时对同一家店评分时两个事务都在读旧avg_rating各自加一次再写回最后一次写入会覆盖前一次相当于丢弃了一次评分的统计。这个场景和库存扣减里的“超卖”本质上是同一类问题也是事务隔离级别的经典案例。解决最简单也最有效的方案是把同步更新改成异步聚合——评分只写t_rating表店铺的avg_rating用定时任务每5分钟重算一次或者通过消息队列异步更新保证不丢更新。为这一点去改数据库隔离级别或者给行加锁都是杀鸡用牛刀。5.5 图片与本地文件路径404前后端部署分离时的相对路径陷阱现象本地开发时菜品图片显示正常打包部署后图片全部404。原因代码里写的是dish.setPicPath(images/a.jpg)这种相对路径本地开发时静态资源由Spring Boot直接映射看着没问题部署时如果前端静态文件和接口服务分开了或者接口服务跑在/api上下文下这个路径就会指向错误的位置。解决图片路径在存库时统一存绝对路径形如https://cdn.example.com/images/a.jpg或者带完整上下文的路径/static/images/a.jpg不要在数据库里存裸文件名指望前端自己去拼上下文。另外别忘了在Spring Boot静态资源配置类里把本地磁盘的图片目录映射成/static/**否则文件在磁盘上但请求还是404那是另一处坑。6. 进阶验证与部署把推荐系统从“能跑”调到“有效”到了这个阶段你手头应该已经有了一套能登录、能评分、能出推荐列表的系统。但“推荐列表能出来”和“推荐结果真的有效”是两码事。推荐效果的验证我一般只用最简单直接的离线指标把全量评分数据按8:2拆成训练集和测试集用训练集给用户生成推荐Top10然后看测试集中用户真实评分过的菜品有多少出现在推荐列表里算一个覆盖率/命中率的指标。这个数据在数据量不大的时候波动很高属于正常现象不需要过度解读——但你要做的是把这个评估脚本留在项目里后面每次调整算法参数时都能用同一套数据复测对比这比口头说“可能有效”有说服力得多。部署层面我的习惯顺序是先在本地用mvn spring-boot:run跑通全流程再打包成jar用java -jar启动方式部署。别忘了在部署前把数据库连接配置、Redis地址从application.yml里抽到环境变量里避免因为换环境改配置导致启动失败。到这里基于java美食推荐管理系统就已经从一个课程设计变成了一套能演示、能压测、能写进简历的完整后端项目。最后说一个我在这个项目里养成的习惯每次改完推荐算法里的一个参数相似度阈值、邻居数量、候选排序权重都会把旧版本的计算结果用JSON文件存一份底稿再跑新版本对比——因为推荐的结果受到评分数据、后端缓存、用户状态等因素共同影响不存底稿的话你很难判断效果变化到底是算法调整带来的还是脏数据带来的。这个方法之后做任何涉及推荐、搜索、排序的项目都用得上。希望帮到你。本文还有配套的精品资源点击获取
返回列表