基于Java美食推荐管理系统:从Spring Boot到协同过滤算法实战
2026/9/24 12:43:23 网站建设 项目流程

简介:一份基于 Java 的美食推荐管理系统设计与实现完整论文文档,面向计算机相关专业毕业生、Java Web 学习者及需要开发类似信息管理系统的开发者。文档以 JSP+MySQL+B/S 架构为核心,完整覆盖课题背景、国内外现状、需求分析、总体设计、数据库设计、功能模块实现与系统测试等环节。系统角色分为管理员与普通用户,包含个人中心、美食分类、热门美食管理、美食教程、美食店铺、美食社区等模块,既支持前台内容展示与交互,又具备后台数据维护能力。资源包共1个 docx 文件,约4.76MB,内容以论文正文为主,目录结构完整,可直接用于参考写作或二次开发。目前已有76人学习下载,对于需要完成类似选题毕业设计或课程项目的读者,可快速获取系统设计思路、表结构划分、页面功能规划及测试方案,节省选题和撰写时间。

1. 基于java美食推荐管理系统:这到底是一个什么段位的项目

很多刚走完java基础、准备做第一个完整后端项目的同学,都会把“美食推荐管理系统”当成课程设计来做——建个用户表、搞个菜品表、写几个增删改查的接口,最后套一个前端模板,演示完就交了。但如果你在简历上写这个项目,面试官真正追问的永远不是CRUD,而是三件事:推荐是怎么算的,系统扛不扛得住真实数据,以及你踩过哪些坑之后才把系统调稳。这个标题里的关键词是“推荐”——没有推荐算法,它只是一个管理后台;有了推荐,它才是一个有数据价值、有调参空间、能写进简历的完整项目,也是java后端完整成长路线上一个很值得认真做的中期练手项目。

这个系统的落地路径,我一般分成四段:先定架构和数据库,再实现用户端和管理端的业务闭环,然后把推荐算法从“能跑”调到“有效”,最后处理并发和冷启动这些真实世界的刺头。接下来我会把每一段该怎么做、参数怎么定、坑在哪里,按我自己的实现习惯拆给你看。这篇笔记不需要你有一整年的后端经验,但如果你连Spring Boot的基本注解都还不熟,建议先花一周把java基础补一遍再动手。

2. 系统架构与数据库设计:先让数据模型立住,推荐才有得算

2.1 技术选型:为什么Spring Boot是比SSH更稳的地基

早年的课程设计里,SSH(Struts + 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 ) ENGINE=InnoDB 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已下架' ) ENGINE=InnoDB 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 ) ENGINE=InnoDB 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 ) ENGINE=InnoDB COMMENT '评分表';

这里有两个细节我要单独拿出来讲,因为它们直接影响推荐算法的输入质量。第一个是t_rating上的uk_user_dish唯一约束——同一个用户对同一个菜品只能有一条评分记录,后续业务里用INSERT ... ON DUPLICATE KEY UPDATE去执行评分,天然就是防重复的,不需要业务代码里先查一遍再决定insert还是update。第二个是t_shop里的avg_ratingrating_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 List<DishVO> recommendForUser(Long userId, int limit) { // 1. 取全量评分数据到内存(数据量可控前提下) List<Rating> allRatings = ratingMapper.selectList(null); Map<Long, Map<Long, Integer>> userDishMap = new HashMap<>(); for (Rating r : allRatings) { userDishMap.computeIfAbsent(r.getUserId(), k -> new HashMap<>()) .put(r.getDishId(), r.getScore()); } Map<Long, Integer> targetUserRatings = userDishMap.getOrDefault(userId, Collections.emptyMap()); // 2. 遍历其他用户,计算与目标用户的余弦相似度 Map<Long, Double> similarityMap = new HashMap<>(); for (Map.Entry<Long, Map<Long, 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用户 List<Map.Entry<Long, Double>> neighbors = similarityMap.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(TOP_K_NEIGHBORS) .collect(Collectors.toList()); // 4. 候选菜品:邻居吃过且目标用户没吃过的菜 Map<Long, Integer> candidateScores = new HashMap<>(); Map<Long, Double> candidateWeights = new HashMap<>(); for (Map.Entry<Long, Double> neighbor : neighbors) { Long neighborId = neighbor.getKey(); double weight = neighbor.getValue(); Map<Long, Integer> neighborRatings = userDishMap.getOrDefault(neighborId, Collections.emptyMap()); for (Map.Entry<Long, 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 List<Long> 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(Map<Long, Integer> a, Map<Long, Integer> b) { double dot = 0, normA = 0, normB = 0; for (Map.Entry<Long, 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 BaseMapper<Rating> { @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:{...}},另一个接口裸返回一个数组,第三个接口报错时直接扔了异常堆栈给前端。我现在的写法是封装一个Result<T>泛型类,所有Controller方法都返回这个结构体,正常时code=200,业务异常时由@RestControllerAdvice统一捕获并转成code=500的JSON结构返回给前端,不让原始异常裸奔出去。这样做的好处是,前端可以在这个基础上写一套统一的后端请求错误处理,不用每个页面单独try-catch,这就是我的全栈链路里最早被我踩碎的一个坑。

5. 避坑指南:美食推荐系统开发中最常见的五个翻车点

5.1 数据库中文乱码:三处编码不一致导致的经典故障

现象:插入“麻辣香锅”之后,前端页面上显示成“????”或“麻辣 鍋”之类的乱码。

原因:数据库连接串、建表语句、页面响应头三处的字符集没有完全统一。很多人只注意到建库时设置了utf8mb4,却忘了在JDBC连接串里同样指定,结果Java的UTF-8字符串转成MySQL连接的默认字符集(latin1),入库就成了问号,这是java环境变量配置之外最让人难受的“玄学”问题。

解决:建表语句统一用utf8mb4;JDBC连接串务必加上characterEncoding=utf8;后端返回JSON时再检查一次Content-Type: application/json;charset=UTF-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 Boot+MyBatis,连接池的默认最大连接数(HikariCP默认10)看起来够用,但每次查询都泄漏一条连接,几分钟内就会被耗尽。还有一版更隐蔽的泄漏是事务方法内执行业务操作时catch住异常却忘记TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),事务上下文把连接死死攥着不释放。

解决:优先排查所有finally块是否完整;如果用了MyBatis-Plus,检查是否有地方直接调用了SqlSession,并确保它在try-with-resources里关闭。另外建议把连接池抬到maximum-pool-size: 30左右并在配置里打开连接泄漏检测,日志里能直接看到哪条链路没归还。

5.3 评分矩阵稀疏导致推荐全员为空

现象:推荐接口偶尔正常偶尔返回空列表,看日志发现相似度全部小于SIM_THRESHOLD

原因:前期需要注册大量新用户并让每个人都去评几道菜,但实际测试用户只有五六个,评过分数的菜少,导致任意两个用户之间共同评分的菜品数量非常少,余弦相似度天然趋近于0,SIM_THRESHOLD=0.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文件存一份底稿,再跑新版本对比——因为推荐的结果受到评分数据、后端缓存、用户状态等因素共同影响,不存底稿的话,你很难判断效果变化到底是算法调整带来的,还是脏数据带来的。这个方法之后做任何涉及推荐、搜索、排序的项目都用得上。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询