做 Java 毕设最怕什么?怕题选得太“普通”,答辩时被导师一句“这套系统网上开源太多了”问住。今天要聊的这套电影周边商城系统,主技术栈是 Java + Spring,核心算法挂了协同过滤推荐,恰好是那种既有完整电商业务闭环、又有算法亮点的选题。它要解决的核心问题很明确:用户在琳琅满目的电影 IP 周边里不知道该买什么,平台通过“猜你喜欢”把候选商品推到用户面前,同时把购买、评分、收藏这些行为数据反哺成下一轮推荐依据,形成一个越用越准的闭环。这篇文章适合三类人看:打算用 Java 做毕设的学生、想给自己小商城项目增加推荐功能的后端开发者,以及准备面试时想拿“协同过滤”当实战谈资的求职者。
1. 项目到底做了什么:功能架构与选题逻辑
1.1 选题逻辑:为什么是“电影周边”而不是普通商城
很多同学做毕设第一个想法就是“做个商城系统”,但普通商城有两个问题:一是商品是通用商品,没有用户画像上的天然聚类,推荐算法很难产出“看起来合理”的结果;二是项目同质化严重,答辩时很难展示差异化亮点。
换成电影周边商城就不一样了。电影周边商品天然带 IP 属性,看同一部电影的观众,在周边偏好上存在高度相似性,比如喜欢《流浪地球》的人往往对同 IP 的模型、徽章、主题 T 恤都感兴趣。这种业务特性天然契合协同过滤算法的假设——相似偏好的人会喜欢相似商品。
另一个实际好处是数据规模可控。毕设项目不需要海量数据,几千条用户评分、几十个周边商品就能让推荐算法跑出肉眼可见的效果。要知道推荐算法最怕数据稀得像沙漠,而电影周边这种强 IP 归属的商品域,用户行为相对集中,算法效果容易在演示环节呈现出来。
1.2 技术栈选型:Spring 生态怎么搭
主流毕设要求的是 SSM(Spring + Spring MVC + MyBatis)或者 Spring Boot 二选一。如果你学校有明确框架要求,就用 Spring MVC + MyBatis 那套经典分层;如果没指定,我建议直接用 Spring Boot,开发效率高出一个量级,省下的时间全部用来打磨推荐模块。
我自己做类似项目时推荐的一套配套是这样的:
- JDK:1.8 或 11,兼容性最好,别一上来就上 17,某些老 MyBatis 版本会出幺蛾子
- 框架:Spring Boot 2.x + Spring MVC + MyBatis / MyBatis-Plus
- 数据库:MySQL 5.7 或 8.0,8.0 注意驱动版本要对应
- 前端:服务端渲染用 Thymeleaf,或者前后端分离用 Vue + 接口对接,这个看个人熟悉程度
- 权限:登录拦截自己写 HandlerInterceptor 就够了,没必要上 Spring Security,答辩时反而难解释
Spring 的 IOC 容器、AOP、事务管理这些老生常谈,面试又总爱追着三级缓存问,但放在毕设项目里,更重要的其实是把这些基础能力真正用起来——事务保证订单和扣库存一致,AOP 统一记录操作日志,拦截器做登录校验,这些才是评判一个 Java 项目“扎实不扎实”的核心。
1.3 功能模块的两大作战面
整套系统从使用层面分成前台商城和后台管理两大块。
前台商城面向普通用户,核心流程是这个链路:注册登录 → 浏览商品 / 搜索 / 按分类筛选 → 查看商品详情与评分 → 加入购物车 → 生成订单并支付(模拟) → 收货后对商品评分。用户所有和商品发生交互的行为,都会被记录到行为表和评分表里,这些数据就是推荐算法的“原料”。
后台管理面向管理员,能力边界也比较清楚:商品分类管理、商品上下架与库存维护、订单状态流转管理、用户列表查询、以及一个专门用来演示算法效果的“推荐数据查看”模块。最后这个模块相当加分,答辩时可以直接打开页面,展示某个用户的历史行为和他看到的推荐结果之间的关联,比口头讲算法生动得多。
2. 协同过滤推荐算法落地实操
2.1 UserCF 和 ItemCF,毕设项目该怎么选
协同过滤有两个主流分支:基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。
UserCF 的思路是找“和我口味相近的人”,把他们买过而我没买过的商品推荐给我。ItemCF 的思路则是“我之前喜欢的东西有没有相似款”,找到与历史偏好商品相似度高的其他商品推给我。
电影周边商城我强烈建议选 ItemCF。原因有三条:
第一,商品数量比用户数量稳定得多,物品相似度矩阵可以离线算好,在线请求时直接查结果,性能压力小。第二,电商场景下 ItemCF 的推荐理由好解释,“你收藏了《流浪地球》的运载车模型,所以推荐同 IP 的 MOSS 摆件”,这种话术一眼就能看懂。第三,用户的短期兴趣波动在商城场景里比较明显,ItemCF 对实时行为更敏感,比如用户刚浏览了某部电影的海报,立刻就能补一批同 IP 周边。
学术上这两类算法没有优劣之分,但放到一个要上线演示的电商项目里,ItemCF 的工程友好度要高太多了。
2.2 行为数据转换成评分的几种姿势
协同过滤的输入是“用户对物品的评分矩阵”。但电商系统里并不是每个用户都会老老实实打分,所以得把显式行为和隐式行为统一换算成评分。
我实际项目里采用过这样一张映射表:
| 行为类型 | 转化评分 | 说明 |
|---|---|---|
| 5 星评价 | 5.0 | 用户主动给高分,权重最高 |
| 4 星评价 | 4.0 | 正常好评 |
| 购买 | 4.5 | 愿意花钱是最强信号 |
| 加入购物车 | 4.0 | 购买意向明确 |
| 收藏 | 3.0 | 兴趣信号偏中等 |
| 浏览详情 | 1.5 | 弱信号,避免噪声过大 |
这个映射关系可以写在枚举类里集中管理,将来想调权重只改一处。另外强调一点:用户对同一商品重复产生的行为,取最高分一次,不能让刷浏览把评分刷上去,要在写入时做去重处理。
2.3 核心代码:余弦相似度与推荐生成
物品相似度计算最常用的是余弦相似度。可以把每个物品被所有用户打过分数的向量看成高维空间里的一个点,两个物品越“同向”就越相似。
工程实现上有个经典优化:不要直接双层循环遍历所有商品对,而是用“用户->商品”的倒排索引,先找出每个用户评分过的商品集合,再对集合内的商品两两组合,累加点积。这样相似度矩阵只包含真正存在共同用户的商品对,计算量小一个数量级。
/** * 计算两个商品评分向量的余弦相似度 * key 为 userId,value 为用户对该商品的评分 */ public double cosineSimilarity(Map<Long, Double> itemA, Map<Long, Double> itemB) { double dotProduct = 0.0; double normA = 0.0; double normB = 0.0; for (double score : itemA.values()) { normA += score * score; } for (double score : itemB.values()) { normB += score * score; } for (Map.Entry<Long, Double> entry : itemA.entrySet()) { Double scoreB = itemB.get(entry.getKey()); if (scoreB != null) { dotProduct += entry.getValue() * scoreB; } } if (normA == 0.0 || normB == 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }拿到相似度矩阵后,推荐生成的核心逻辑就是“加权求和 + 归一化 + 过滤已购”:
public List<Product> recommendByItemCF(Long userId, int topN) { // 1. 当前用户的历史评分 Map<Long, Double> userRatings = ratingDao.selectRatingsByUserId(userId); if (userRatings == null || userRatings.isEmpty()) { // 冷启动用户,直接走热门商品兜底 return productDao.selectHotProducts(topN); } // 2. 物品相似度矩阵(实际项目中由定时任务离线构建好) Map<Long, Map<Long, Double>> itemSimMatrix = itemSimService.getSimilarityMatrix(); // 3. 对候选商品累计加权得分 Map<Long, Double> scoreMap = new HashMap<>(); Map<Long, Double> simSumMap = new HashMap<>(); for (Map.Entry<Long, Double> rating : userRatings.entrySet()) { Long ratedItem = rating.getKey(); double rateScore = rating.getValue(); Map<Long, Double> sims = itemSimMatrix.get(ratedItem); if (sims == null) { continue; } for (Map.Entry<Long, Double> simEntry : sims.entrySet()) { Long candidateItem = simEntry.getKey(); // 排除用户已经打过分、买过的商品 if (userRatings.containsKey(candidateItem)) { continue; } double sim = simEntry.getValue(); scoreMap.merge(candidateItem, sim * rateScore, Double::sum); simSumMap.merge(candidateItem, sim, Double::sum); } } // 4. 归一化排序,取 Top-N return scoreMap.entrySet().stream() .sorted((a, b) -> Double.compare( b.getValue() / simSumMap.get(b.getKey()), a.getValue() / simSumMap.get(a.getKey()))) .limit(topN) .map(entry -> productDao.selectById(entry.getKey())) .collect(Collectors.toList()); }这段代码把算法主干封装在recommendByItemCF一个方法里,Controller 层只需要收到 userId,返回 List 渲染到页面。考题如果深问推荐原理,你能从“倒排索引”“归一化”“过滤已购”三个词展开讲,基本就是加分回答。
要注意,这里是简化版余弦相似度,实战里更稳的是“修正余弦相似度”——先对每个用户减去他的平均评分,消除部分用户天生爱打 5 分、有些人只打 3 分的尺度差异。改法不复杂,在向量里先做中心化再调余弦即可。
2.4 冷启动与数据稀疏的兜底策略
推荐系统绕不开冷启动。新用户没有行为数据,ItemCF 直接返回空列表,页面难看得要命。
我采用的策略是分三档:
新用户:查商品表的sales字段,按销量倒序,再按上架时间加权,推热门商品和新品。
新商品:给商品打上分类标签,用户在浏览某分类时,把同类目商品随机插入推荐列表的缝隙里,让新商品获得曝光机会,积累首批评分。
老用户但行为极少:只取他最近一次浏览或收藏的商品,查相似矩阵,至少凑够 6 个候选商品;如果还凑不够,就用热门商品填充到 6 个。
这套“算法推荐 + 热门兜底 + 分类填充”的混合策略,是线上电商都在用的工程方案,写进毕设论文里也算实打实的业务理解。
3. 数据库设计与核心模块实现细节
3.1 表结构设计:让推荐算法好读数据
数据库设计决定了推荐模块写起来是享受还是折磨。我的核心原则是:用户行为单独建表,订单和订单项分离,评分表加唯一索引。
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `nickname` varchar(50) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category_id` bigint(20) DEFAULT NULL, `name` varchar(100) NOT NULL, `description` text, `price` decimal(10,2) NOT NULL, `stock` int(11) DEFAULT 0, `cover` varchar(255) DEFAULT NULL, `sales` int(11) DEFAULT 0, `status` tinyint(4) DEFAULT 1, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `rating` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `product_id` bigint(20) NOT NULL, `score` double NOT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_product` (`user_id`, `product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;rating表单独拆出来的原因很直接:推荐算法每次要按用户把所有评分一次性捞进内存,如果评分埋在订单表里,查询逻辑要绕好几层,算法代码也会被业务代码污染。建独立行为表behavior(记录浏览、收藏、加购)也是同一个道理,一表一职责,算法模块读起来毫不费劲。
3.2 用户端核心链路:购物车与订单
购物车和订单模块是电商系统的“脸面”,代码结构上要稳住。购物车设计成cart表,字段是 user_id、product_id、quantity、checked 状态。生成订单时要同步做三件事:检查库存、扣减库存、创建订单记录。这三步必须包在同一个事务里,不然会出现“订单生成了,库存没扣成功”的脏数据。
订单表我建议带上一个order_no唯一业务单号,用时间戳加随机数生成,别用自增主键直接当订单号给用户看,一是暴露真实业务量,二是丑。订单状态用 tinyint 存:0 待付款、1 已付款、2 已发货、3 已完成、4 已取消,界面展示时再用枚举映射成中文状态。
3.3 后台管理模块与推荐数据可视化
后台管理不复杂,但也不能只做“增删改查”。我给后台多加了两个和推荐相关的页面:
第一个是“用户行为查询”,输入 userId 就能看到该用户的浏览、收藏、评分、购买流水。第二个是“推荐结果对比”,同一用户分别查看“算法推荐结果”和“热门商品榜单”两列列表,页面截图放进论文对比章节简直利器。
这种设计让评委一眼看到推荐算法确实在起作用,而不是光靠嘴上讲。
4. 实操过程中的坑与排错实录
4.1 推荐结果为空或不准,从哪排查
这个问题在我调试阶段出现过太多次,排查顺序很重要:先看评分数据,再看相似度矩阵,最后看过滤条件。
先确认用户行为数据真的写进表里了。很多页面埋点漏了“浏览详情”行为,或者评分按钮没接到 Dao 层,导致rating表里只有一条测试数据。算法拿一条评分数据是算不出相似物品的。
再看相似度矩阵。我用一个临时接口把矩阵串成 JSON 打到控制台,检查热门商品之间有没有非零相似度。如果热门 MOSS 摆件和 FF14 周边完全不共现,说明这两个商品在用户行为上没有交集,算法无米下锅。
最后检查过滤逻辑。最容易出的问题是userRatings.containsKey(candidateItem)没用上,导致推荐结果把用户已经买过的东西再推一遍,看着像 bug 实际是逻辑漏了。
4.2 性能优化:相似度矩阵不能现场算
刚开始我图省事,每次请求都现场构建相似度矩阵,数据量小还好,数据一旦过千条商品、上万条评分,页面响应直接掉进 3 秒大关,体验直接崩盘。
正确做法是写一个 Spring 定时任务,凌晨 2 点用@Scheduled(cron = "0 0 2 * * ?")离线构建矩阵,放到服务内存里。白天用户请求时只查内存 Map,响应时间压到 100ms 以内。
另一个优化是稀疏矩阵存储,哈希表只存非零项。商品对没有共同评分用户就不会进入矩阵,10 万个商品实际共现的商品对可能只有几千个,内存完全能扛住。答辩被问到“数据量大怎么办”,可以说后续引入 Redis 缓存矩阵、计算层下沉到离线数据仓库,这些点都能体现思考深度。
4.3 答辩高频问题清单与答法
毕设答辩问到推荐模块,来来回回就是这几个问题,提前把答案理顺:
为什么用协同过滤,不用基于内容的推荐?答:基于内容推荐要人工维护大量商品特征标签,电影周边的描述字段不规范,加工成本高;协同过滤纯靠用户行为学习偏好,冷启动靠热门兜底,更适合用户行为数据丰富的电商场景。
冷启动怎么做?答:三层兜底,新用户推热门和新品,新商品通过分类插入获得曝光,行为极少的用户用最近行为相似商品补足推荐位。
推荐效果怎么评估?答:离线阶段用留一法,每次留出一条用户真实行为当测试集,算 Top-N 精确率和召回率;线上阶段看推荐位商品点击率,做一个简单的 A/B 对比。
一个加分的收尾:承认系统的局限是算法只用了显式评分和简单行为映射,后续可以接入 ALS 矩阵分解、引入用户画像,或者做实时特征管道。不要说自己系统完美,能清晰说出下一步优化方向,反而是稳妥的答辩策略。
4.4 版本与部署的翻车点
最后聊几个环境相关的坑。Spring Boot 2.x 默认搭配 MyBatis 没问题,但如果你用 3.x,需要 JDK 17 起步,且部分老版本 mybatis-spring-boot-starter 不兼容,直接 NoClassDefFoundError 教你做人。Maven 依赖冲突也是高频问题,关键是用mvn dependency:tree查看版本树,锁定统一的 Spring Boot 版本,别手动塞一堆版本号。
部署到 Tomcat 时注意打包方式要改成 war 并重写启动类,如果直接用 jar 方式部署到外部 Tomcat 会报找不到主类。数据库连接池建议用 HikariCP,Spring Boot 默认就是它,别换成 DBCP,参数调起来温度完全不同。
写在最后的一点经验
这项目我自己从零走了一遍,最深的一个体会是:一定要先把推荐闭环单独跑通,再去铺商城其他模块。最简单的验证方式,往 rating 表手动插入两个用户对同一批商品的评分,调接口看推荐结果里有没有出现预期的相似商品,打印出相似度矩阵核对一遍数值对不对。这一步通了,后面所有模块都是锦上添花。磨刀不误砍柴工,推荐模块是这套系统的灵魂,宁可商城少两个页面,也要保证算法能跑出让评委眼前一亮的推荐结果。
还有一个小技巧,给推荐接口写一个测试类,用 JUnit 跑一个固定数据集的断言,保证后面改代码不会顺手把推荐逻辑改坏。这个习惯放到真实工作里一样受用,自动化测试兜底,改代码不慌。