项目标题把“协同过滤推荐”放到二手交易平台里,本身就是一件挺有意思的事。市面上很多同类型的毕业设计、课程项目,说是有推荐功能,实际打开数据库一看,就是一个“猜你喜欢”的假接口,随机捞几条数据塞给前端。而这个基于SpringBoot+Vue搭建的闲置二手物品交易平台,是真的把协同过滤算法落到了实处:有用户行为采集、有相似度计算、有定时批量更新推荐结果,还有冷启动兜底方案。再加上完整源码和万字开发文档,无论你是想完成一个合格的毕设项目,还是想在简历里写出“有推荐算法落地经验”,这套东西都很有参考价值。
这套项目的技术选型思路和大多数主流全栈项目一致:SpringBoot负责后端接口、业务逻辑和事务控制,Vue负责前端页面渲染和用户交互,中间通过RESTful API通信。MySQL存放业务数据,Redis做缓存加速。整个系统不是一上来就堆各种高大上的组件,而是把精力集中在“交易闭环跑通”和“推荐真正可用”这两件事上。这篇文章我会从系统设计、数据库建模、推荐算法实现、后端核心接口、前端交互、部署调试到踩坑记录,一条线完整讲清楚。
1. 项目定位与整体设计思路
1.1 技术选型背后的取舍
先说后端。SpringBoot在这个项目里的优势其实不在于它“新”,而在于它足够稳。SpringBoot自带的自动配置让项目不用像传统SSH整合那样写一堆XML,注解驱动开发对初学者和后端开发者来说都友好。配合MyBatis-Plus操作数据库,CRUD几乎不用写SQL,开发效率能提升一大截。但这套项目没有无脑依赖MyBatis-Plus,自定义的SQL、联表查询还是保留手写的方式,这样在数据量和查询逻辑变复杂以后,依然有足够的控制力。
前端选择Vue而不是React,核心原因是Vue的上手曲线平缓,模板语法接近HTML直觉,配合Element UI做后台管理页面几乎就是搭积木。Vuex管理全局状态,Vue Router做页面路由,axios做网络请求代理,整体工程化程度适中,不会像大型中后台项目那样复杂到劝退新人,又能让学的人真正感受到“工程化开发”是怎么回事。
数据库选MySQL 8.0,理由就不用多说了。这个体量的交易平台,用MySQL完全够,而且在索引优化、事务控制、数据一致性这几个核心点上,MySQL的资料和工具链最成熟,遇到问题随便查都能找到答案。
1.2 功能模块划分
从用户角色角度看,这个系统分成普通用户端和管理员端。
普通用户的核心场景包括:注册登录、浏览分类、搜索商品、查看商品详情、收藏商品、发布闲置、编辑下架商品、下单购买、确认订单、评价交易对象。这些功能基本覆盖了一个C2C交易平台的核心链路。
管理员端的功能更偏运维:用户管理(禁用异常账号)、商品管理(审核和上下架)、订单管理(查看和标记状态)、数据统计(用户数、商品数、成交额等简单看板)。
这个模块划分没有堆砌“社区”“聊天”“积分商城”这类花哨功能,边界控制得很好。从项目完成度角度来说,把交易闭环和推荐模块扎扎实实做完,比塞一堆半成品功能要值钱得多。
1.3 推荐模块在系统中的位置
推荐模块在系统里不是独立的微服务,而是作为后端Service层的一个模块存在。这种设计有几个实际考虑:第一,推荐逻辑需要直接读取用户行为表、商品表、订单表,放在Service层可以复用业务数据,不用搞复杂的跨服务调用。第二,推荐结果的生成是离线完成的,和用户请求路径隔离,即使推荐计算失败也不会影响交易主流程。第三,把算法部分封装成独立Service,后续想替换成基于物品的协同过滤,或者接入别的算法框架,改动范围都限定在一个模块内。
2. 数据库设计与核心表结构
2.1 基础业务表设计
系统的核心表大概有11张。我挑重点说几个。
用户表和普通系统的区别不大,主键自增ID,用户名建唯一索引,密码存的是BCrypt加密后的密文,而不是明文,这一点很重要。状态字段用int(0正常、1禁用),方便管理员操作,也方便后期扩展更多状态。
商品表的字段设计得更贴近二手交易场景。除了标题、描述、价格、分类这些基础字段,专门加了原价字段original_price,用来展示价格折扣力度。图片字段用逗号分隔存储多个图片URL,虽然不算最规范的范式做法,但在这个场景下够用,而且查出来以后直接分割字符串就能用。商品状态用int表示,0草稿、1上架、2已售、3下架。这里要注意,商品表里的user_id字段是发布者ID,必须建索引,因为后续商品列表页、卖家个人页都要按这个字段查询。
订单表的order_no字段设计成独立的业务编号,没有直接用自增ID。原因是自增ID会暴露订单量和业务数据规模,而且以后对接第三方支付时,业务编号是必须的。金额字段用decimal(10,2),这是老生常谈,但确实重要,float和double在金额计算上会出精度问题。
收藏表是一个典型的关联表,核心字段就id、user_id、product_id、create_time。为了防止用户重复收藏同一件商品,建了联合唯一索引uk_user_product。这也算是数据库设计里的基本功,但很多新人会漏掉这一步,导致后期要写一堆去重逻辑。
2.2 推荐相关表结构
协同过滤推荐需要三类数据支撑:用户行为、相似度矩阵、推荐结果。
用户行为表behavior_log记录四类行为:浏览、收藏、下单、评价。字段有user_id、product_id、behavior_type、behavior_time。这张表是推荐算法的原始原料,数据量会持续增长,项目里预留了定时清理策略,只保留最近三个月的行为记录。这个思路很重要,因为过期的行为数据对推荐准确性其实是有负作用的,用户最近三个月不感兴趣的东西,未必代表他现在不感兴趣。
相似度矩阵表存的是用户和用户之间的相似度值。字段设计成user_a、user_b、similarity,保留四位小数。这张表的规模是用户数的平方,所以只适合中小规模系统。对于几百用户的演示项目,完全没问题,如果未来用户量激增,还是要走分布式计算引擎或者图数据库方案。
推荐结果表是“空间换时间”的核心。每次离线计算完成后,直接为每个用户生成TopN推荐商品列表,存入recommend_result表。用户访问首页时,推荐接口就查这张表,再把商品信息拼装完整返回。这样推荐接口的耗时几乎是恒定的,不会因为用户数量和商品数量的增长而变慢。
2.3 索引设计心得
这个项目实际建的索引,我整理了一份,方便你们直接抄:
| 表名 | 索引名 | 字段 | 类型 |
|---|---|---|---|
| product | idx_user_id | user_id | 普通索引 |
| product | idx_category_id | category_id | 普通索引 |
| orders | idx_buyer_id | buyer_id | 普通索引 |
| orders | idx_seller_id | seller_id | 普通索引 |
| orders | idx_order_no | order_no | 唯一索引 |
| favorite | uk_user_product | user_id, product_id | 联合唯一索引 |
| behavior_log | idx_user_time | user_id, behavior_time | 联合普通索引 |
| recommend_result | uk_user_product | user_id, product_id | 联合唯一索引 |
注意:不要给text类型字段建索引。商品描述这种长文本字段如果想做搜索,用MySQL的LIKE模糊查询已经足够应付几千条数据了。真要上全文检索,得等到数据量到十万级以上再考虑Elasticsearch之类的组件,别在项目初期引入没必要的复杂度。
3. 协同过滤推荐算法的落地实现
3.1 为什么用离线批处理而不是实时计算
一开始我确实动过“实时推荐”的念头,用户看完一个商品就马上推相关的。但算一笔账就明白了:假设系统里有500个用户、2000个商品,每次请求都要实时找相似用户、计算推荐分数,光是扫描用户行为表就要遍历几万条记录。在没有大数据组件加持的单机应用里,接口响应时间会直接飙到几百毫秒甚至秒级,首页加载效率无法接受。
所以这个项目采用定时任务离线计算的方案:每天凌晨2点,SpringBoot结合@Scheduled注解执行推荐任务,扫描当天新增的用户行为,更新相似度矩阵,为活跃用户计算TopN推荐列表,写入recommend_result表。白天所有推荐接口直接读结果表。
3.2 用户相似度计算:余弦相似度实现
推荐模块先实现的是基于用户的协同过滤(UserCF)。二手交易平台的行为数据很稀疏,用户就收藏了几个商品,行为矩阵大量是零值,这种场景下余弦相似度比较合适,因为它不受用户行为绝对值大小的影响,只看方向一致性。
计算过程分三步:
- 构建“物品-用户”倒排表
- 统计用户两两之间共同交互的物品数量
- 代入余弦相似度公式
公式是:sim(A,B) = (A向量·B向量) / (|A向量| × |B向量|)
在Java里用字典和循环手工实现了这个过程,没有引入重量级矩阵库。代码逻辑简洁而且每步都能打印结果调试,对于这个数据规模,性能完全够用:
public Map<String, Map<String, Double>> calcUserSimilarity(List<Behavior> behaviors) { // 物品到用户的倒排表 Map<Long, List<Long>> productToUsers = new HashMap<>(); for (Behavior b : behaviors) { productToUsers.computeIfAbsent(b.getProductId(), k -> new ArrayList<>()).add(b.getUserId()); } // 统计用户两两共同交互的物品数量 Map<String, Integer> cooccurrence = new HashMap<>(); Map<Long, Integer> userItemCount = new HashMap<>(); for (List<Long> users : productToUsers.values()) { for (int i = 0; i < users.size(); i++) { Long u1 = users.get(i); userItemCount.merge(u1, 1, Integer::sum); for (int j = i + 1; j < users.size(); j++) { Long u2 = users.get(j); String key = u1 < u2 ? u1 + "_" + u2 : u2 + "_" + u1; cooccurrence.merge(key, 1, Integer::sum); } } } // 套公式 Map<String, Map<String, Double>> result = new HashMap<>(); for (Map.Entry<String, Integer> entry : cooccurrence.entrySet()) { String[] pair = entry.getKey().split("_"); Long u1 = Long.parseLong(pair[0]); Long u2 = Long.parseLong(pair[1]); int cnt = entry.getValue(); double sim = cnt / (Math.sqrt(userItemCount.get(u1)) * Math.sqrt(userItemCount.get(u2))); result.computeIfAbsent(String.valueOf(u1), k -> new HashMap<>()) .put(String.valueOf(u2), sim); result.computeIfAbsent(String.valueOf(u2), k -> new HashMap<>()) .put(String.valueOf(u1), sim); } return result; }这里有个重要的细节:行为类型不是一视同仁的。项目中把收藏行为的权重设为1,浏览行为权重为0.5,购买行为权重为2。为什么要这样做?因为浏览可能是误点,参考价值低;收藏代表明确的兴趣信号;购买行为意味着真实的购买意愿和信任,权重自然最高。这个权重调整对推荐效果的影响,比换一个高级算法框架要明显得多。
3.3 推荐分数预测与商品过滤
在拿到用户相似度矩阵之后,为目标用户生成推荐列表,用的是加权求和公式:
score(U, I) = Σ(sim(U,V) × r(V,I)) / Σsim(U,V)
也就是说,目标用户U对未交互过的商品I的预测兴趣分,等于所有相似用户V对商品I评分的加权平均,权重就是U和V的相似度。
算法层处理完,还有一道过滤环节:已经买过的商品必须过滤掉,已经下架的商品必须过滤掉,自己发布的商品也必须过滤掉。这个过滤条件要是漏了,就会出现一个用户买完手机,第二天推荐里还推着这台已售手机的尴尬情况。
3.4 冷启动问题的工程化兜底
新注册用户没有任何行为数据,协同过滤算法对他没有任何可计算的输入。这个项目用了一个务实的方案:
- 用户行为记录少于3条时,返回热门商品列表,按照商品浏览量降序取前20
- 行为记录达到3条后,才启用协同过滤推荐结果
热门商品数据怎么来?定时任务里额外算出一张“热门商品缓存表”,商品按一个综合热度值排序,热度值综合考虑浏览数和收藏数。这个兜底方案简单有效,用户从第一个页面开始就有内容可看,不会因为“暂无推荐”而流失。
4. SpringBoot后端核心模块实现
4.1 JWT认证与权限拦截
后端使用JWT做无状态认证。登录接口校验成功后,生成一个有效期24小时的token返回。前端在请求头中携带 token,后端通过拦截器解析并取出当前用户ID。
拦截器的核心逻辑并不复杂:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; // 放行跨域预检 } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { Claims claims = JwtUtils.parseToken(token.substring(7)); request.setAttribute("userId", claims.get("userId")); return true; } response.setStatus(401); return false; } }使用JWT方案时有几个细节需要注意。token必须设置过期时间,不能一劳永逸。所以过期时间设置为24小时,用户长期不访问后再发请求必须重新登录。还有,敏感操作(比如修改密码、确认收货)除了token校验,后端还应该做二次校验,防止账号被盗后的高风险操作。
4.2 商品发布与下单的事务控制
商品发布流程:前端上传图片,后端保存到本地或者OSS;填写标题、描述、价格、分类;后端校验参数合法性,标题2到50字符,价格大于0,默认状态为“上架”。
下单流程是整个系统里对事务要求最严格的部分。整个操作必须放在同一个事务里:校验商品状态是否为“上架”,校验买家不能购买自己发布的商品,更新商品状态为“已售”,生成订单记录。任一步失败都要回滚,不能出现“商品卖出去了但订单没生成”的数据不一致状态。
C2C模式下,一个商品只有一件,所以没有设计库存数字段,而是用商品状态字段直接保证“每件商品只能被一个买家买走”。这比引入库存表更简单,也更贴合闲置交易的实际情况。
实现方式很简单,在Service方法上加@Transactional注解,默认的传播行为就够了。
4.3 推荐接口的响应组装
推荐接口的路径是/api/recommend/home,逻辑分几步:
- 从请求上下文中解析userId
- 查recommend_result表,取用户TopN推荐商品ID
- 如果结果为空,改用热门商品表兜底
- 根据商品ID批量查询商品详情、卖家信息
- 判断当前用户是否已收藏该商品,拼接favorited字段
- 返回统一格式
这里最值得说的是“批量查询”这一点。拼装推荐列表时,对商品ID集合用MyBatis-Plus的listByIds一次查出所有商品,而不是在循环里逐条查询。这个细节直接影响接口性能,尤其当前端一次要渲染20个推荐商品时,循环查询的N+1问题会瞬间暴露出来。
5. Vue前端页面与交互实现
5.1 前端路由与页面结构
前端工程使用@vue/cli创建,目录组织清晰。主要页面组件包括:
- 首页Home.vue:搜索栏、分类导航、轮播图、推荐商品瀑布流
- 商品详情页ProductDetail.vue:轮播图、价格、描述、卖家信息、相似推荐
- 发布商品页Publish.vue:图片上传、表单校验
- 个人中心My.vue:我的商品、我的订单、我的收藏、个人信息
- 后台管理页:用户管理、商品管理、订单管理、数据统计
路由守卫是保证页面访问安全的重要一环,需要登录才能访问的页面设置meta.requiresAuth,路由守卫里检查token是否存在,不存在就跳转登录页。
router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !store.state.token) { next('/login'); } else { next(); } });5.2 推荐位的设计
首页推荐区域采用“猜你喜欢”的瀑布流布局,商品卡片显示封面图、标题、价格、卖家昵称、浏览量和收藏数。点击卡片进入商品详情页。
推荐位做了分页加载,每次请求20条。由于推荐结果表本身已经按分数排好序,翻页时不会出现重复商品。这个交互设计看似简单,但和后台的分页接口逻辑是对齐的,前端不用做复杂的去重处理。
另外,商品详情页也接了一个“相似推荐”区域。这里调用的是同一套推荐接口,但传入的参数不同,取的是当前商品分类下其他推荐商品。这样设计让推荐模块不只是首页的装饰,而是贯穿用户浏览路径的实用功能。
5.3 axios封装与Vuex状态管理
项目里对axios实例做统一封装,设置baseURL和请求拦截器:
const service = axios.create({ baseURL: '/api', timeout: 10000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; });Vuex主要管理两类全局数据:登录后的用户token和用户基本信息、购物车和搜索结果这类跨组件共享的数据。token存localStorage,但注意只存token和userID这类不敏感信息,密码、手机号、地址等敏感信息一律从后端接口获取,不持久化在前端。
6. 运行部署与源码文档使用指南
6.1 环境要求与启动步骤
把这套源码跑起来,环境要求如下:
- JDK 1.8+
- Maven 3.6+
- MySQL 8.0+
- Node.js 14+
- IDE推荐IntelliJ IDEA + VS Code
后端的启动步骤:
- 创建数据库,执行项目中的schema.sql初始化脚本
- 修改application.yml中的数据库用户名和密码
- 在backend目录执行
mvn install - 运行启动类,端口默认8080
前端的启动步骤:
- 命令行进入frontend目录
- 执行
npm install安装依赖 - 执行
npm run serve - 浏览器访问localhost:8081,前端dev server自动代理API请求到8080端口
整个启动流程比较简单,没有依赖外部缓存或中间件。推荐算法跑的定时任务也是在后端应用内实现的,单机就能跑通。
6.2 压缩包内容与开发文档说明
项目压缩包含以下内容:
- backend/:SpringBoot后端完整源码
- frontend/:Vue前端完整源码
- docs/:万字开发文档,Word格式
- sql/:数据库初始化脚本
- README.md:快速开始说明、功能演示截图、测试账号
万字开发文档里最值得关注的是接口文档部分,每个接口都标注了请求方法、请求参数、响应示例。这对二次开发特别友好,接手项目的人不需要去翻源码猜某个接口的入参结构,直接对照文档就能联调。另外系统设计部分也把数据库ER图和模块划分讲得比较清楚,适合用来写毕设论文。
6.3 二次开发方向建议
如果拿到源码想改成自己的项目和简历项目,这几个扩展方向比较推荐:
- 在协同过滤基础上增加基于物品的ItemCF,和现有UserCF结果加权合并,提升推荐多样性
- 接入第三方支付模拟接口,把“下单”完善成“付款-发货-确认收货”完整闭环
- 接入云存储,把商品图片从本地存储迁移到OSS,解决服务端磁盘空间越攒越满的问题
- 增加站内信或WebSocket通知,买家下单后给卖家发提醒
7. 踩坑记录与常见问题排查
7.1 推荐结果过于集中,热门商品霸屏
第一次跑完推荐任务后,发现部分热门商品几乎出现在所有用户的推荐列表里,推荐多样性很差。热门商品被大量用户收藏,相似用户对它的评分权重很高,算法天然偏向它们。解决办法是给热门商品加惩罚因子:
score_final = score_original / (1 + log(1 + productPopularity))
productPopularity用商品的收藏数或浏览量衡量。这个惩罚项执行之后,推荐列表里长尾商品的曝光明显上升。这也是真实推荐系统里“去热门化”的常见做法。
7.2 JWT过期导致前端循环跳转
调试时遇到一个诡异问题:前端反复跳到登录页,明明token没过期,但每次刷新都跳。排查发现是axios响应拦截器在遇到401时调用router.push,但token刷新逻辑没处理好,导致循环触发。
解决方案是加一个跳转标志位,同一时间只允许跳转一次:
let isRedirecting = false; service.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401 && !isRedirecting) { isRedirecting = true; router.push('/login'); } return Promise.reject(error); } );7.3 跨域请求Cookie丢失
开发环境下前端跑8081端口,后端跑8080端口,axios发请求必然跨域。解决方案是后端配置CorsFilter,允许指定来源和常用方法。但有个容易被忽略的细节:默认跨域配置不会携带Cookie。如果以后想用session或者登录态,前端axios要设置withCredentials: true,后端CorsFilter也要设置allowCredentials=true。
7.4 列表接口响应体过大导致加载慢
商品描述和详情信息一开始都放在列表接口里返回,导致接口响应体很大,页面加载明显卡顿。后来把列表接口和详情接口做了字段拆分:列表只返回标题、价格、封面图、浏览量、收藏数这些核心字段,完整描述放到详情接口单独查询。这个优化让列表接口的响应时间从约400ms降到了约120ms。
以下是一个常见问题速查表,方便直接参考:
| 问题现象 | 排查重点 | 解决方案 |
|---|---|---|
| 推荐列表全是热门商品 | 查看算法是否加了热门惩罚 | 引入热度惩罚因子 |
| token没过期却401跳登录 | 检查axios响应拦截器循环调用 | 加isRedirecting标志位 |
| 跨域请求没有Cookie | 检查CorsFilter和axios中的withCredentials | 两端同时开启credentials |
| 列表接口加载慢 | 检查是否返回了大字段内容 | 拆分列表接口和详情接口 |
| 推荐结果重复 | 检查过滤条件,是否有商品状态判断 | 过滤已购买、已下架、自己的商品 |
| 定时任务没有执行 | 检查是否开启@EnableScheduling | 启动类加该注解 |
这套系统从设计到实现,核心原则一直是“务实”。推荐算法用经典方法,不追求花哨;前后端选主流框架,不搞冷门方案;数据库模型按真实业务场景建模,不加冗余设计。如果你正在做类似项目,先把基础交易链路跑通,再琢磨推荐模块的细节,最后用真实数据测一测推荐效果,你会发现这种“一步一个脚印”的推进方式,比一上来就搭复杂架构要靠谱得多。这套源码和开发文档拿到手之后,第一遍按README把项目跑起来,第二遍对照开发文档逐模块看代码,第三遍把数据库表结构和推荐算法跑一遍数据验证,三遍下来,你对整个系统的理解就算彻底到位了。