每年到毕设季,总有同学在选题上犯难:既要功能完整能跑通,又得有算法亮点不至于被导师说“没有技术含量”。标题里这个“基于协同过滤算法的农场管理系统”就是这两年很典型的选题方向——SpringBoot打底做综合管理,协同过滤算法做推荐模块,业务上覆盖农场日常管理,算法上又有实实在在的落地场景。我身边已经有几个同学做完类似的题目,答辩效果都不错。这篇就结合我自己的实操经验,把这个题目从选题到落地完整拆一遍:系统的功能模块怎么划、协同过滤算法怎么接到SpringBoot项目里、数据库表怎么设计、核心接口怎么写,以及调试中容易踩的坑。打算做同类题目的,可以直接照着这个思路搭。
1. 项目整体设计与功能模块拆解
1.1 选题思路:为什么“管理系统+推荐算法”是个好组合
农场管理系统如果只做增删改查,那确实太普通了,市面上类似的模板一抓一大把。但加上协同过滤推荐之后,整个题目的性质就变了:它从一个纯业务系统,变成了一个有算法模型、有数据处理流程、有推荐逻辑的复合系统。导师看到的关键词是“协同过滤”“个性化推荐”,这两点足够支撑论文里的算法设计章节,也足够在答辩时展开讲几分钟。
从实际业务角度看,农场的推荐场景其实非常自然。农场里通常有多个农产品批次,比如不同品种的蔬菜、水果、禽蛋;农场的会员或采购商会有历史订单和浏览记录。基于这些数据,系统可以给当前用户推荐他可能感兴趣的农产品,或者预测某个品种在某个用户群体里的受欢迎程度。这个逻辑比生硬地在电商系统里套推荐算法要合理得多,因为农场产品的SKU相对固定,用户行为数据也集中,做出来的推荐结果更容易解释。
1.2 功能模块划分:一套标准的角色权限式管理系统
按毕业设计的惯例,这个系统把用户分成三类角色,分别对应农场里的实际身份:
- 管理员:管理农场基本信息、用户账号、订单数据、农产品上下架、系统统计。权限最大。
- 农场员工/农户:录入生产记录(播种、施肥、收获),维护产品库存,处理订单发货。
- 会员/采购商(普通用户):浏览农产品、下单购买、查看订单、收藏产品、接收推荐列表。
对应到页面和接口上,功能模块就分成这几块:
- 用户管理模块:登录、注册、角色权限控制。
- 农场生产管理模块:土地地块管理、种植批次管理、农资记录、采收记录。
- 产品管理模块:农产品分类、产品信息维护、库存更新、上下架。
- 订单管理模块:购物车、订单生成、订单状态流转、历史订单查询。
- 推荐模块:基于协同过滤算法生成“猜你喜欢”“相似用户热买”推荐列表。
- 数据统计模块:按产品/时间维度展示订单量、销售额趋势。
模块划分清楚之后,项目结构就比较明朗了:后端按controller/service/mapper分层,前端用Vue或Thymeleaf都行,关键是把推荐算法的代码独立出一个recommend包,不要和业务代码混在一起。
1.3 技术选型的理由:SpringBoot + Vue + MySQL + MyBatis Plus
这套组合是当前Java毕设的绝对主流,也是我实际用下来最顺手的方案。
- SpringBoot 2.x:约定大于配置,不需要写一堆XML配置文件,内嵌Tomcat直接启动。同时它对MyBatis Plus、Spring Security这些配套组件的支持非常好,社区资料多,出问题了随便一搜就有答案。
- MyBatis Plus:比原生MyBatis少写大量重复SQL,内置分页插件、代码生成器,适合快速开发。
- MySQL 8.0:所有业务表和推荐算法需要的用户行为数据表都存在MySQL里,对毕设体量完全够用。
- Vue 2 + Element UI:管理后台的标配,组件现成,界面不丑。如果不想前后端分离,用Thymeleaf + Bootstrap也能省掉很多跨域联调的麻烦,看你个人对前端的熟悉程度。
之所以不推荐再引入Redis、MQ这类组件,是因为毕设的核心评价维度是逻辑完整性,而不是架构复杂度。协同过滤算法本身就是亮点,把基础功能做稳、把推荐流程跑通,比堆砌一堆中间件更实际。真有精力,加个Redis做推荐结果的缓存倒是合理的优化方向。
2. 协同过滤算法落地详解:从原理到工程实现
2.1 算法选型:UserCF还是ItemCF
协同过滤分两大类,一类是基于用户的(UserCF),一类是基于物品的(ItemCF)。起点同样是“相似度”,但逻辑完全相反。
**UserCF(基于用户的协同过滤)**的核心是:找到和你兴趣相似的一群人,把这些人喜欢的但你还没接触过的东西推荐给你。在农场系统里,如果用户对“有机生菜”下过单,系统去查还有哪些用户也买了有机生菜,这些“邻居用户”还买了什么,就可以推荐过来。
**ItemCF(基于物品的协同过滤)**的核心是:计算物品之间的相似度,推荐与你历史喜欢的物品相似的其他物品。逻辑像“买了A的人常常也买B”,更侧重物品本身的关联性。
在农场管理系统的场景下,我建议采用ItemCF为主,原因有三:
- 农场产品数量远小于用户数量,物品相似度矩阵的计算成本可控,系统压力小。
- 农产品的品类属性稳定,推荐结果容易被用户理解和接受。例如用户买过“红富士苹果”,系统推荐“烟台苹果”或“苹果礼盒装”,用户会觉得“这个推荐靠谱”。
- 农场用户的总量通常不会很大,如果用户行为数据稀疏,UserCF很难找到真正的“邻居”,推荐效果会明显变差。ItemCF至少能靠物品本身的分类属性兜底。
2.2 推荐系统三大核心步骤:数据准备、相似度计算、TopN推荐
无论选哪种算法,落地路径都是下面这三步。
第一步,数据准备。协同过滤的基础是用户对物品的评分或行为数据。在农场系统里,最直接的评分来源是订单和收藏。我采用的方案是:用户对某产品下单,记行为分5分;加入购物车未下单,记3分;收藏或浏览了详情页,记1分。这一步需要在订单模块、购物车模块里埋点,把用户行为记录到一张独立的行为日志表里,再定时或实时聚合生成用户-物品评分矩阵。
第二步,相似度计算。常用的相似度度量有余弦相似度、皮尔逊相关系数、杰卡德相似系数。我项目里用的是改进的余弦相似度,公式是:
sim(i, j) = sum(u ∈ U)(R(u,i) * R(u,j)) / (sqrt(sum(R(u,i)^2)) * sqrt(sum(R(u,j)^2)))其中R(u,i)表示用户u对物品i的评分。Java里用一个双重循环遍历评分矩阵就能实现,数据量小完全不需要额外依赖。
第三步,TopN推荐。对用户u,遍历他评分过的每一个物品i,找到与i相似度最高的K个物品,排除掉用户已经买过的,把相似度加权求和,排序取前N个作为推荐结果返回。
整个算法流程用伪代码描述就是下面这样:
输入:用户-物品评分矩阵,目标用户uid,近邻数K,推荐数N 1. 获取用户uid的所有评分记录 2. 构建物品相似度矩阵(可预计算并缓存) 3. for 用户uid评过的每个物品i: for 物品i的近邻物品j (按sim(i,j)降序取前K个): if 用户没有评过分物品j: score[uid][j] += sim(i,j) * rating(uid, i) 4. 对score排序取前N个物品输出2.3 算法性能优化:相似度矩阵预计算与缓存
如果每次用户请求推荐,都现场重新计算一遍所有物品两两之间的相似度,虽然数据量小时能跑通,但这个过程明显拖慢响应速度,而且大量重复计算浪费资源。我做了一个优化:把物品相似度矩阵的构建放在服务启动时执行一次,或者加一个定时任务每6小时刷新一次,计算结果存到内存Map里,到时候直接查表。
对于更大体量,建议把推荐结果直接缓存到Redis,key设计成recommend:itemcf:{userId},过期时间24小时,用户每次刷新推荐页直接命中缓存,连相似度计算都省了。毕设里如果能把这个缓存方案写进设计文档,也是个不错的加分项。
3. 数据库表设计与数据建模
数据库设计决定了后面所有逻辑的实现难度。这个系统我按业务与算法两条线来组织数据表,业务表负责日常管理,算法相关的表负责给推荐模块供数。
3.1 核心业务表
| 表名 | 核心字段 | 说明 |
|---|---|---|
| sys_user | id, username, password, role, phone, region | 用户表,role区分管理员/员工/会员 |
| farm_land | id, land_name, area, crop_type, status | 地块表,记录农场的土地资源 |
| product_info | id, product_name, category_id, price, stock, unit, status | 农产品表,status控制上下架 |
| product_category | id, category_name, parent_id | 农产品分类表 |
| order_master | id, order_no, user_id, total_amount, status, create_time | 订单主表 |
| order_detail | id, order_id, product_id, quantity, price | 订单明细表 |
| cart_item | id, user_id, product_id, quantity, checked | 购物车表 |
| favorite_record | id, user_id, product_id, create_time | 收藏记录表 |
| user_behavior | id, user_id, product_id, behavior_type, score, create_time | 用户行为日志表(推荐算法核心数据来源) |
订单设计成主表+明细表是标准做法,一方面避免单表存储冗余,另一方面统计“哪些用户买过哪些商品”时,直接联表查order_detail就行,很方便。
3.2 行为数据如何变为评分矩阵
很多同学表建完了,不知道协同过滤的数据从哪里来,这里重点说一下。
user_behavior这张表是推荐系统的“原料库”。我定义行为类型behavior_type包括:purchase(下单)、add_cart(加购)、favorite(收藏)、view(浏览)。每种行为映射一个分数:purchase=5,add_cart=3,favorite=2,view=1。
每次用户触发这些动作时,controller层的代码里去调用一个BehaviorRecorder工具类,异步写入一条user_behavior记录。注意,同一用户对同一商品可能有多条行为记录(先浏览、再收藏、最后下单),生成评分矩阵时取最大分即可,不要简单累加,否则行为次数多会造成分数虚高。
生成评分矩阵的SQL就是一行聚合查询:
SELECT user_id, product_id, MAX(score) AS rating FROM user_behavior GROUP BY user_id, product_id;把查询结果放到Map<Long, Map<Long, Double>>里,Java版的稀疏矩阵就构建好了。
3.3 为什么用稀疏矩阵,不用完整二维数组
理论上说,用户-物品评分矩阵是二维结构,但现实中绝大部分用户只对极少数产品有过行为,完整二维数组里全是0和空值。内存浪费还是小问题,关键是等价于把“无行为”当成“评分为0”,直接参与余弦相似度计算后,会让分母异常庞大,相似度被严重稀释。
所以我在代码里用Map嵌套Map来表示稀疏矩阵,只存有值的数据。代码逻辑如下:
Map<Long, Map<Long, Double>> userItemTable = new HashMap<>(); // key: userId, value: 该用户对各个产品的评分Map Map<Long, Map<Long, Double>> itemUserTable = new HashMap<>(); // key: productId, value: 评过该产品的用户及评分构建时同时维护这两张表,userItemTable方便获取“用户买过什么”,itemUserTable方便获取“谁买过这个物品”,后面的相似度计算两种视角都要用。
4. 核心功能模块实现与关键代码
4.1 项目骨架与推荐模块的包结构
项目的包结构如果乱七八糟,答辩时自己都讲不清。下面这个结构是我自己整理的,清晰分层,推荐算法部分独立成包:
com.farm.management ├── controller │ ├── AdminController.java │ ├── UserController.java │ ├── ProductController.java │ ├── OrderController.java │ └── RecommendController.java ├── service │ ├── ProductService.java │ ├── OrderService.java │ └── RecommendService.java ├── mapper ├── entity ├── common │ ├── Result.java │ ├── JwtUtil.java │ └── BehaviorRecorder.java └── recommend ├── CollaborativeFilter.java ├── ItemSimilarityCalculator.java ├── UserBehaviorCollector.java └── RecommendDataBuilder.java4.2 协同过滤核心类:CollaborativeFilter
这是整个系统里最有“含金量”的一个类,也是答辩时导师大概率会盯上的地方。核心方法就是算相似度和生成推荐列表。
public class CollaborativeFilter { // 预计算的物品相似度矩阵 private Map<Long, Map<Long, Double>> itemSimilarityMatrix; /** * 计算物品相似度矩阵 * itemUserTable: key=productId, value=Map<userId, rating> */ public void calculateItemSimilarity( Map<Long, Map<Long, Double>> itemUserTable) { Map<Long, Map<Long, Double>> simMatrix = new HashMap<>(); List<Long> itemIds = new ArrayList<>(itemUserTable.keySet()); for (int i = 0; i < itemIds.size(); i++) { long itemA = itemIds.get(i); Map<Long, Double> usersA = itemUserTable.get(itemA); for (int j = i + 1; j < itemIds.size(); j++) { long itemB = itemIds.get(j); Map<Long, Double> usersB = itemUserTable.get(itemB); // 取同时对两个物品评过分的用户集合 Set<Long> commonUsers = new HashSet<>(usersA.keySet()); commonUsers.retainAll(usersB.keySet()); if (commonUsers.isEmpty()) { continue; } double dotProduct = 0, normA = 0, normB = 0; for (long userId : commonUsers) { dotProduct += usersA.get(userId) * usersB.get(userId); } for (double rating : usersA.values()) { normA += rating * rating; } for (double rating : usersB.values()) { normB += rating * rating; } double sim = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-7); simMatrix.computeIfAbsent(itemA, k -> new HashMap<>()).put(itemB, sim); simMatrix.computeIfAbsent(itemB, k -> new HashMap<>()).put(itemA, sim); } } this.itemSimilarityMatrix = simMatrix; } /** * 基于ItemCF给指定用户生成TopN推荐 * userItemTable: key=userId, value=Map<productId, rating> */ public List<Long> recommend( Long userId, Map<Long, Map<Long, Double>> userItemTable, int topN, int neighborK) { Map<Long, Double> userRatings = userItemTable.get(userId); if (userRatings == null || userRatings.isEmpty()) { return Collections.emptyList(); } double epsilon = 1e-7; Map<Long, Double> candidateScores = new HashMap<>(); // 遍历用户评过分的每个物品,取其近邻物品 for (Map.Entry<Long, Double> entry : userRatings.entrySet()) { Long ratedItem = entry.getKey(); Double userScore = entry.getValue(); Map<Long, Double> neighbors = itemSimilarityMatrix.getOrDefault(ratedItem, Collections.emptyMap()); neighbors.entrySet().stream() .sorted((e1, e2) -> Double.compare(e2.getValue(), e1.getValue())) .limit(neighborK) .forEach(neighbor -> { Long candidate = neighbor.getKey(); if (userRatings.containsKey(candidate)) { return; // 排除已购买/已评分的物品 } double weight = neighbor.getValue() * userScore; candidateScores.merge(candidate, weight, Double::sum); }); } return candidateScores.entrySet().stream() .sorted((e1, e2) -> Double.compare(e2.getValue(), e1.getValue())) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }这里有两个细节容易忽略:一是分母加一个极小值1e-7防止除零;二是只统计同时评过两个物品的用户集合,否则相似度会被大量默认“未知”的数据污染。这两个处理在论文里写出来,导师会觉得你确实理解了算法的本质。
4.3 推荐接口怎么跟业务模块串联
推荐模块不能是孤岛,它必须跟产品模块、用户模块联动。我在RecommendController里预留了几个接口:
GET /recommend/user/{userId}:返回基于ItemCF的“猜你喜欢”个性推荐。GET /recommend/hot:返回全局热销TOP10,用于无行为用户冷启动。GET /recommend/similar/{productId}:返回指定产品的相似产品,用于产品详情页的“相关推荐”。
第2个接口的设计很关键,它专门解决“冷启动”问题。因为新用户没有历史行为数据,协同过滤算法直接跑不出结果,返回空列表体验太差。我采用的方案是:如果recommend()方法返回空列表,就退回查订单表,按销售数量排序返回热度前10的产品。这样至少在界面上永远有内容。
有了推荐列表之后,还要拼上产品名称、图片、价格这些业务字段才能返回给前端。我的做法是:先拿到productId列表,再用MyBatis Plus的selectBatchIds一次性查出完整产品信息,避免N+1查询。
4.4 登录鉴权与权限控制
虽然这个题目核心是推荐算法,但管理系统的基础功能也不能掉链子。登录模块我用的JWT + Spring Security拦截器方案。
用户登录成功后,后端生成一个JWT令牌返回给前端,前端每次请求都带上Authorization: Bearer <token>。服务端拦截器解析token,取出userId和role,存到ThreadLocal里,供后续controller直接用。
权限控制按角色划分,具体规则很简单:
/admin/**接口要求role=1(管理员)/staff/**接口要求role=2(员工)/api/**接口登录即可访问/recommend/**接口只要是登录用户都能访问
如果你的前端用的Vue,建议路由也做一层动态权限控制,根据登录返回的role字段来动态生成可访问的菜单。这样前后端的权限体系就完整对齐了。
5. 常见问题与调试实录
5.1 新用户没有行为数据,推荐接口返回空列表
这个问题我上面提到了,是最容易遇到的。
- 现象:新建一个用户账号,登录后打开推荐页,接口返回的数组是空的。
- 原因:协同过滤需要用户历史行为,新用户没有评分记录,算法无从算起。
- 解决:在RecommendService里做兜底逻辑。先尝试用ItemCF推荐,如果得到空列表,就转为查全局热销榜TOP10。热销榜SQL示例:
SELECT d.product_id, SUM(d.quantity) total_sales, p.product_name FROM order_detail d LEFT JOIN product_info p ON d.product_id = p.id GROUP BY d.product_id ORDER BY total_sales DESC LIMIT 10;5.2 相似度矩阵算出来全是0
我实际调试时遇到过:所有物品的相似度打印出来都是0,推荐结果自然为空。
最后排查发现,问题出在评分矩阵构建时,把用户的评分都当成“未评分=0”存进去了,导致点积全为0。后来改成只存有行为的记录,稀疏矩阵才正常。
这里要提醒一点:不要用Float类型存储评分,浮点数精度问题会导致相似度计算出现很诡异的偏差,统一用Double或者BigDecimal。
5.3 推荐出来的商品用户已经买过了
- 原因:推荐算法代码里,排除已购商品的逻辑没有生效。
- 排查:先看
userRatings.containsKey(candidate)这行代码是否执行正确,再确认user_behavior表里的数据是否记录了购买行为。 - 解决:在推荐候选集生成后,加一层过滤,把订单表里已购买的商品ID集合再过滤一遍。有时候评分表数据不全,但订单表是准的,双保险更稳。
5.4 前端请求跨域导致登录后接口报403
前后端分离项目必踩的问题。后端配置一个跨域过滤器就行:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意,如果加了Spring Security,预检请求OPTIONS也要放行,否则还是会拦掉。
5.5 算法结果无法解释,论文里不知道怎么分析
很多同学的算法跑通了,但论文写得不深。我的建议是论文里重点写三块:
- 算法流程详述:画出数据流向,从user_behavior表到评分矩阵,再讲到相似度计算的数学公式。
- 推荐场景分析:针对农场业务解释为什么选ItemCF,结合订单和收藏数据说明推荐结果的业务含义。
- 效果验证:设计一个简单的离线测试——随机选20个用户,把他们的行为数据隐藏一部分,用剩余数据推荐,看隐藏的商品是否出现在推荐结果里,统计命中率。这个命中率不用做得很复杂,有数据有结论就能说明问题。
5.6 系统性能优化建议
如果答辩被问“这个系统还有哪些可以优化”,可以这么回答:推荐结果做Redis缓存、评分矩阵增量更新而不是全量重建、计算相似度时用Spark分布式处理等。回答到这里,基本就超出同水平同学一个档次了。
写在最后的一点心得
做完这个项目最大的感受是:协同过滤听起来高深,真正落地起来其实就是“数据收集—相似度计算—TopN排序”三段式,难点不在算法本身,而在工程化过程中对业务数据的理解——你要清楚用户的哪些行为可以代表兴趣、评分怎么映射、推荐结果在业务上怎么解释才合理。这个题目之所以适合作为毕设,恰恰在于它把算法和业务揉在了一起,两头都有东西可以写。
如果你是照着这个思路自己动手做,遇到卡住的环节,优先检查数据表有没有对,user_behavior有没有正确写入,再怀疑算法代码。八成以上的“推荐不对”问题,都是数据没喂到位。最后再分享一个小技巧:把推荐模块里相似度矩阵的打印日志留在生产环境,答辩演示时,打开后台控制台给导师看实时的相似度计算过程,比PPT上写一百行公式都管用。