简介:这是一份电商精准营销推荐系统的完整源码包,后端采用SpringBoot框架,前端采用Vue技术,实现了前后端分离的现代Web开发模式。项目围绕电商平台的用户推荐场景,设计了用户管理、商品展示、推荐策略配置等核心功能模块,代码结构清晰,适合初学者和进阶学习者作为毕业设计、课程设计、大作业或工程实训课题。压缩包共五百二十五个文件,大小约十三点四四兆字节,其中一百五十九个Java文件负责后端业务逻辑与数据访问,一百一十九个Vue文件构成前端页面组件,另有五十个JavaScript文件、二十一个CSS样式文件、XML配置映射文件以及SQL数据库初始化脚本,覆盖全栈开发的主要文件类型,目录划分明确,便于按模块学习与二次开发。项目基于JDK8、Tomcat7与MySQL5.7构建,附带SQL脚本,可快速搭建运行环境。目前已经有一百零三人学习下载,源码经过调试可直接运行,在此基础上扩展推荐算法或调整业务模块都比较方便,具有较高的学习与参考价值。
1. 电商精准营销推荐系统到底是什么:先看清这个 Spring Boot + Vue 项目能解决谁的问题
做电商系统的朋友应该都有体会:商品上千件,用户进来转一圈就走,转化率上不去,运营只能靠群发促销邮件和短信硬推。这个标题里的系统,解决的正是这个问题——用推荐算法把「用户可能想买的东西」推到首页、商品详情页和购物车旁边,让用户多停留、多下单。技术上,它拆成两半:后端用 Java 的 Spring Boot 写推荐逻辑和接口,前端用 Vue 搭用户界面。适合谁?适合正在做电商毕设、中小型电商后台开发、或者想在自己项目里接一个推荐模块的 Java 工程师。一句话讲透:推荐系统不是玄学,它就是把用户行为数据变成商品排序权重的工程化过程,而 Spring Boot + Vue 是目前做这件事最稳的组合之一。
2. 电商精准营销不等于「猜你喜欢」:先拆业务模块再定技术选型
2.1 推荐系统在电商里的真实链路:从行为采集到精准触达
很多人一提到推荐系统,第一反应就是协同过滤算法,然后就开始写相似度计算。这其实是把黑匣子打开了,但顺序反了——做电商精准营销推荐,第一步不是写算法,而是先搞清楚推荐结果用在哪几个位置。
我做这类项目时的拆法比较固定,按业务链路分四层。
第一层是行为采集层。用户在电商网站上的浏览、点击、加购、下单、收藏这些动作,都要落成一条结构化的行为日志。常见做法是建一张user_behavior表,字段至少包含user_id、item_id、behavior_type、create_time。behavior_type用数字区分:1 表示浏览、2 表示加购、3 表示下单。别看这张表简单,后期所有推荐逻辑都靠它喂数据。
第二层是用户画像与商品画像层。用户画像关注的是「这个用户最近对什么品类感兴趣」——通过行为日志聚合出来。商品画像则靠商家在后台录入的商品类目、价格区间、标签来构建,比如「运动鞋 / 300-500 元 / 夏季新款」。这一层不需要很重的大数据体系,MySQL 加几张维表就够了,除非你要做实时推荐。
第三层是推荐引擎层。它负责把画像和实时行为转成候选商品集合。这里才轮到算法上场:协通过滤算「和你相似的人买了什么」,基于内容的推荐算「你浏览过的商品有哪些相似款」。对中小型电商系统来说,这两类算法已经覆盖 80% 的场景,不需要上深度学习。
第四层是营销触达层。推荐结果不只是首页推荐位。精准营销还包括:用户加购没下单,三天后给他推送一张优惠券;用户浏览了某类商品但没买,在购物车页给他推荐同类低价款。落到代码上,就是推荐接口返回的数据会被不同的业务模块消费,有的走页面接口,有的走短信或消息推送。
这四层里,真正值得花时间设计的是第二层和第三层之间的数据契约——推荐引擎输入什么、输出什么,必须提前定义清楚。我一般定义成统一的RecommendResponse,里面包含userId、商品列表、每个商品的推荐权重分数和推荐理由(比如「与你浏览过的 XX 相似」)。这样后续接短信推送、接首页推荐位,只需要改调用方,不用动算法核心。
2.2 Spring Boot 与 Vue 的选型理由:为什么这对组合适合推荐系统落地
聊完业务链路,再回答一个很多人纠结的问题:为什么这个标题选 Spring Boot + Vue,而不是 Python + React,或者 PHP 一把梭?
先说后端 Spring Boot。推荐系统不是只有算法,还要把算法包成稳定的 HTTP 接口,处理并发请求、事务、权限。Spring Boot 在这件事上有天然优势:内置 Tomcat,spring-boot-starter-web加一个注解就能吐 REST 接口;配合 MyBatis-Plus 操作 MySQL 行为日志表,不需要写一堆 JDBC 样板代码。另一个实际考虑是,国内电商后台的存量系统绝大部分是 Java 系,你做一个推荐系统模块,最终要嵌到别人的订单系统、商品系统里,Spring Boot 的亲和力最强。
这里有个常见误区要提醒一下:不要一上来就引入 Flink、Kafka 做实时计算。像标题这类系统,核心动作是「基于用户的历史行为周期性地算出推荐列表」,它是准实时的,不是毫秒级的。用 Spring Boot 自带的定时任务@Scheduled每天凌晨跑一次推荐结果缓存,白天读缓存,性能完全够。强行上 Flink 的结果就是运维成本翻倍,产出却不明显——这个「头重脚轻」的坑我在早期项目里踩过。
前端选 Vue,核心原因是生态和上手门槛。电商管理后台的典型界面是「左侧菜单 + 右侧表格/卡片」,Vue 全家桶里的 Vue Router 管路由、Vuex 或 Pinia 管全局状态、Element UI 或 Vant 直接提供商品卡片和推荐位组件,三件套拼起来比 React 少写很多胶水代码。另外,Vue 对后端工程师更友好,模板语法接近 HTML 直觉,你写推荐接口的人顺手就能把管理页面调通,不必专门等一个前端资源。Vue 2 还是 Vue 3 的问题,新项目直接上 Vue 3 配合 Vite,性能更好;如果项目是维护态的,那 Vue 2 也没必要强行升级。
选型定下来之后,项目结构我会默认拆成三个目录:backend(Spring Boot 服务)、frontend(Vue 工程)、sql(初始化脚本)。下面一章就照着这个结构把最小系统跑通。
3. 把推荐系统跑起来:从行为表、协同过滤到 Vue 页面联调
3.1 初始化数据库:用户行为表与商品表的设计
动手写代码之前,先把表结构定住。推荐系统最忌讳边写边改表,后面算法的 SQL 全依赖表字段。我一般用下面这套建表脚本,直接跑在 MySQL 5.7 以上版本。
-- 商品表:推荐系统的基础数据源 CREATE TABLE `product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID', `title` varchar(128) NOT NULL COMMENT '商品标题', `category_id` bigint(20) NOT NULL COMMENT '商品类目ID,关联类目表', `price` decimal(10,2) NOT NULL COMMENT '商品价格', `tags` varchar(255) DEFAULT NULL COMMENT '商品标签,逗号分隔,如:夏季,运动,新款', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '上下架状态:1上架 0下架', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; -- 用户行为表:所有推荐算法的数据来源 CREATE TABLE `user_behavior` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '用户ID', `item_id` bigint(20) NOT NULL COMMENT '商品ID', `behavior_type` tinyint(4) NOT NULL COMMENT '行为类型:1浏览 2加购 3下单', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`, `create_time`), KEY `idx_item_id` (`item_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='用户行为日志表';这份脚本里有三个设计点值得注意。
第一个是user_behavior表刻意不加UNIQUE约束,因为用户可能对同一个商品在不同时间产生多次浏览行为,加了唯一约束会导致行为日志丢失。第二个是idx_user_time这个联合索引,它服务的是「查某个用户最近 N 条行为」这个高频查询,按用户加时间排序能直接走索引,避免文件排序。第三个是behavior_type用数字而不是字符串,是为了后续在 SQL 里用SUM聚合不同行为的权重。
3.2 Spring Boot 后端骨架编写:实体类、Mapper 与推荐接口
数据库就绪后,创建一个 Spring Boot 工程。JDK 用 1.8 或 11 都行,Spring Boot 用 2.7.x 这个版本段——原因在第 4 章避坑里细讲,先记住不要追 3.x 最新版就行。pom.xml里引入 Web、MyBatis-Plus、MySQL 驱动、Lombok 四个核心依赖。
实体类写法如下。MyBatis-Plus 的@TableName注解把类映射到表,字段用@TableId标记主键。
@Data @TableName("product") public class Product { @TableId(type = IdType.AUTO) private Long id; private String title; private Long categoryId; private BigDecimal price; private String tags; private Integer status; private LocalDateTime createTime; } @Data @TableName("user_behavior") public class UserBehavior { @TableId(type = IdType.AUTO) private Long id; private Long userId; private Long itemId; private Integer behaviorType; private LocalDateTime createTime; }逻辑说明:@Data来自 Lombok,自动生成 getter/setter,省掉样板代码。IdType.AUTO表示主键由数据库自增,对应建表语句里的AUTO_INCREMENT。这里有个细节:create_time字段在 Java 侧用LocalDateTime而不是Date,配合 MyBatis-Plus 的自动填充,避免时区转换的坑——国内服务器用Asia/Shanghai时区,如果用Date会出现相差 8 小时的问题。
Mapper 接口就三行。MyBatis-Plus 的BaseMapper已经把单表 CRUD 做完了,不需要写 XML:
@Mapper public interface ProductMapper extends BaseMapper<Product> { } @Mapper public interface UserBehaviorMapper extends BaseMapper<UserBehavior> { }参数说明:@Mapper注解让 Spring 容器扫描到这个接口并生成代理实现类。如果项目里配置了@MapperScan("com.example.recommend.mapper"),这个注解可以省略不写。加入它是为了单个文件也能独立识别,调试时少一个报错点。
3.3 推荐引擎核心代码:基于物品的协同过滤实现
现在到最关键的部分——推荐算法。这里我用「基于物品的协同过滤(ItemCF)」来实现,它的核心思想是:如果用户 A 买了商品 X,又买了商品 Y,那么给用户 B 推荐商品 X 时,Y 也应该有比较高的权重。通俗说就是「买了这个商品的人,通常还买了那个商品」。
这个算法落地分三步:第一步,统计商品两两之间的共现次数,得到相似度矩阵;第二步,根据用户的历史行为找到他感兴趣的候选商品;第三步,按相似度加权排序,输出 Top-N 推荐结果。完整代码我拆成三段。
先写相似度矩阵计算:
@Service public class ItemCfService { @Resource private UserBehaviorMapper behaviorMapper; /** * 计算商品间的共现矩阵:遍历所有行为日志,统计商品两两共同出现的次数 * 返回 Map: key = itemId_1 + "_" + itemId_2, value = 共现次数 */ public Map<String, Integer> buildCoOccurrenceMatrix() { List<UserBehavior> behaviors = behaviorMapper.selectList( new LambdaQueryWrapper<UserBehavior>() .select(UserBehavior::getUserId, UserBehavior::getItemId) .in(UserBehavior::getBehaviorType, 2, 3) // 只取加购和下单行为 ); Map<String, Integer> coCountMap = new HashMap<>(); Map<Long, List<Long>> userItemsMap = new HashMap<>(); // 先按用户分组,聚合每个用户购买/加购过的商品列表 for (UserBehavior behavior : behaviors) { userItemsMap.computeIfAbsent(behavior.getUserId(), k -> new ArrayList<>()).add(behavior.getItemId()); } // 对每个用户的商品列表做两两组合,累加共现次数 for (List<Long> itemList : userItemsMap.values()) { for (int i = 0; i < itemList.size(); i++) { for (int j = i + 1; j < itemList.size(); j++) { Long id1 = itemList.get(i); Long id2 = itemList.get(j); String key = id1 + "_" + id2; coCountMap.put(key, coCountMap.getOrDefault(key, 0) + 1); } } } return coCountMap; } }逻辑说明:LambdaQueryWrapper是 MyBatis-Plus 提供的条件构造器,in(UserBehavior::getBehaviorType, 2, 3)表示只统计加购和下单行为,排除浏览——因为浏览行为噪音太大,用户可能误点,并不能真实反映购买意向。如果你的场景里浏览数据也能反映兴趣,可以把 1 也加进去,但推荐精度通常会下降。商品两两组合用双重循环,时间复杂度是 O(n²),n 是单个用户的商品数量,一般不会超过几十,性能没问题。
再写针对某个用户的推荐逻辑:
/** * 根据目标用户的历史行为,结合共现矩阵生成推荐列表 * @param userId 目标用户ID * @param candidateItems 需要排除的商品ID,通常是用户已购买过的 * @param topN 返回的推荐数量 */ public List<Long> recommend(Long userId, List<Long> candidateItems, int topN) { // 1. 查询目标用户的行为记录:他浏览过、加购过哪些商品 List<UserBehavior> userBehaviors = behaviorMapper.selectList( new LambdaQueryWrapper<UserBehavior>() .eq(UserBehavior::getUserId, userId) ); // 2. 提取用户的行为商品集合 Set<Long> userItemSet = new HashSet<>(); for (UserBehavior behavior : userBehaviors) { userItemSet.add(behavior.getItemId()); } // 3. 遍历用户行为商品,去共现矩阵里找相似商品,累加相似度分数 Map<String, Integer> coMatrix = buildCoOccurrenceMatrix(); Map<Long, Double> scoreMap = new HashMap<>(); for (Long userItem : userItemSet) { for (Map.Entry<String, Integer> entry : coMatrix.entrySet()) { String[] pair = entry.getKey().split("_"); Long id1 = Long.parseLong(pair[0]); Long id2 = Long.parseLong(pair[1]); Integer coCount = entry.getValue(); if (id1.equals(userItem)) { // 用户行为商品与推荐商品在共现矩阵中共同出现过 // 累加时给不同行为不同权重 scoreMap.put(id2, scoreMap.getOrDefault(id2, 0.0) + coCount * getBehaviorWeight(userItem, userId)); } else if (id2.equals(userItem)) { scoreMap.put(id1, scoreMap.getOrDefault(id1, 0.0) + coCount * getBehaviorWeight(userItem, userId)); } } } // 4. 过滤掉用户已经买过的商品,按分数排序取Top-N return scoreMap.entrySet().stream() .filter(e -> !userItemSet.contains(e.getKey())) .filter(e -> !candidateItems.contains(e.getKey())) .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } private double getBehaviorWeight(Long itemId, Long userId) { UserBehavior latest = behaviorMapper.selectOne( new LambdaQueryWrapper<UserBehavior>() .eq(UserBehavior::getUserId, userId) .eq(UserBehavior::getItemId, itemId) .orderByDesc(UserBehavior::getCreateTime) .last("LIMIT 1") ); if (latest == null) { return 1.0; } // 行为权重:下单=3,加购=2,浏览=1 switch (latest.getBehaviorType()) { case 3: return 3.0; case 2: return 2.0; default: return 1.0; } }参数说明:topN一般设 10 到 20,首页推荐位放 10 个商品比较合适,超过 20 用户要向下滚动很久才看到底部,转化率反而下降。candidateItems这个参数用于排除已经在购物车或已经下单的商品,避免推荐结果里出现「推荐一个用户刚买过的东西」这种尴尬场景。getBehaviorWeight做了临时查询,每次调用都走一次数据库,这段在数据量大时有优化空间——可以把用户行为一次性加载到内存,但中小系统这个写法最直观。
最后写接口层。这里我偷了个懒,没把相似度矩阵存进 Redis,而是每次请求时临时计算。数据量小的阶段这是最稳的:
@RestController @RequestMapping("/api/recommend") public class RecommendController { @Resource private ItemCfService itemCfService; @Resource private ProductMapper productMapper; /** * 获取用户推荐列表 * @param userId 用户ID * @param size 推荐数量,默认10 */ @GetMapping("/{userId}") public Result getRecommend(@PathVariable Long userId, @RequestParam(defaultValue = "10") Integer size) { // 查询在架商品作为候选池,排除掉 user 已购的在外层过滤 List<Product> products = productMapper.selectList( new LambdaQueryWrapper<Product>().eq(Product::getStatus, 1) ); List<Long> candidateIds = products.stream().map(Product::getId).collect(Collectors.toList()); List<Long> recommendIds = itemCfService.recommend(userId, candidateIds, size); // 根据推荐ID回查商品完整信息,返回给前端 List<Product> result = recommendIds.isEmpty() ? products.subList(0, Math.min(size, products.size())) : productMapper.selectBatchIds(recommendIds); return Result.success(result); } }逻辑说明:冷启动场景——当用户没有任何行为数据时,recommend返回空列表,这段代码会自动回退到「默认推荐」,即直接返回在架商品的前 N 个。这个降级逻辑很关键,宁可推荐热门商品,也不能让前端拿到空数组导致页面空白。Result是统一响应体,里面包含code、msg、data三个字段,前端 axios 拦截器统一处理。
3.4 Vue 前端页面开发:路由、Axios 请求与商品推荐展示
后端接口就绪后,前端要做的事情是「推荐位渲染 + 用户身份传递」。用户身份用模拟的userId就行——真实系统里从登录态里面取。先配路由:
// router/index.js import { createRouter, createWebHistory } from 'vue-router'; import HomePage from '../views/HomePage.vue'; import ProductDetail from '../views/ProductDetail.vue'; const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'home', component: HomePage }, { path: '/product/:id', name: 'product-detail', component: ProductDetail } ] }); export default router;路由配置注意一点:我用了createWebHistory,这种 history 模式在开发环境没问题,但打包上线后如果服务器没配 try_files 会出现刷新 404,第 4 章避坑里会写具体解法。如果你不想碰这个坑,可以改用createWebHashHistory。
API 请求封装,用 axios 实例统一指定baseURL:
// api/recommend.js import axios from 'axios'; const request = axios.create({ baseURL: 'http://localhost:8080/api', timeout: 5000 }); // 请求拦截器:每个请求自动带上用户 ID request.interceptors.request.use(config => { const userId = localStorage.getItem('userId') || '1'; config.params = { ...config.params, userId }; return config; }); // 获取推荐商品列表 export function getRecommendList(size = 10) { return request.get(`/recommend/${localStorage.getItem('userId') || 1}`, { params: { size } }); }逻辑说明:axios 拦截器里统一塞userId,是为了避免每个页面组件里都重复写这个参数。timeout: 5000是 5 秒超时——推荐接口如果 5 秒内没返回,前端直接提示「推荐加载失败」,不要无限转圈。baseURL如果走前后端同源部署(Vue 打包后放进 Spring Boot 的static目录),这里可以写空字符串,用相对路径/api就行。
页面组件里调用推荐接口,渲染商品卡片列表:
<template> <div class="recommend-section"> <h2>猜你喜欢</h2> <div v-if="loading" class="loading">推荐加载中...</div> <div v-else-if="productList.length > 0" class="product-grid"> <div v-for="product in productList" :key="product.id" class="product-card"> <router-link :to="`/product/${product.id}`"> <div class="product-title">{{ product.title }}</div> <div class="product-price">¥{{ product.price }}</div> </router-link> </div> </div> <div v-else class="empty">暂无推荐商品</div> </div> </template> <script setup> import { ref, onMounted } from 'vue'; import { getRecommendList } from '../api/recommend'; const productList = ref([]); const loading = ref(true); onMounted(async () => { try { const res = await getRecommendList(10); productList.value = res.data.data; } catch (err) { console.error('推荐接口请求失败:', err); productList.value = []; } finally { loading.value = false; } }); </script>这是组合式 API 的写法,ref定义响应式变量,onMounted是组件挂载后的生命周期。要注意res.data.data这个双层的结构——第一层data是 axios 的响应体,第二层data是我后端Result.success()里放的业务数据。前后端分离项目经常有人在这个字段名上翻车,建议前后端约定好统一用result或者统一用data,别混用。
3.5 用 Postman 跑通接口联调:验证推荐链路完整可用
代码写完后,先不要急着启动前后端,用一个最小数据集验证推荐逻辑是否正确。我在sql目录里放一组测试数据,构造了 3 个用户、5 个商品,行为如下:
| 用户 | 行为 | 商品ID |
|---|---|---|
| 1 | 加购、下单 | 1、2 |
| 2 | 加购 | 1、3 |
| 3 | 浏览 | 4 |
从共现矩阵可以推出来:商品 1 与商品 2 在用户 1 的行为里共现,商品 1 与商品 3 在用户 2 的行为里共现。那么给用户 3 推荐时,因为他浏览过商品 4,而商品 4 没有跟任何商品共现,系统会降级到热门推荐(比如商品 1)。这样就能验证冷启动降级逻辑是否生效。
启动后端mvn spring-boot:run,启动前端npm run dev,打开 Postman 请求GET http://localhost:8080/api/recommend/3?size=5,返回的 JSON 里应包含商品 1 和商品 2 的信息。如果返回结果是空数组或者报 500,说明前面某个环节遗漏了——排查顺序是:先看数据库行为数据有没有插入成功,再看 Mapper 能不能查到数,最后看接口缩进有没有配错。
4. 实战避坑记录:Spring Boot 与 Vue 联调里的 5 个高频问题
4.1 Spring Boot 版本太高导致启动失败
我遇到过不少照着教程搭项目的人,上来就用 Spring Boot 3.x + JDK 17,结果spring.factories文件不生效、javax包全部变成jakarta,代码里一堆红色报错。这个标题对应的实现方案是 2021-2023 年间的经典结构,那时候主流的 Spring Boot 版本是 2.5.x 到 2.7.x,配合 JDK 8 或 11 最稳妥。如果你用 3.x,不仅要改所有import javax.*为import jakarta.*,而且很多第三方 starter 还没有跟进适配,MyBatis-Plus 的旧版本直接跑不起来。
正确的做法是创建项目时指定版本:spring initializr里选 Spring Boot 2.7.18,这是 2.x 系列的最终维护版本,坑最少。如果你的需求确实需要 3.x,那 MyBatis-Plus 要用 3.5.3 以上的版本,且所有涉及javax.servlet的依赖都要替换成jakarta.servlet。新手我建议别折腾。
4.2 vue 打包放进 Spring Boot 后刷新 404
这个问题的标准说法是:Vue Router 用了history模式,打包后的index.html里路由是/product/3这样的路径。前端资源放到 Spring Boot 的src/main/resources/static目录后,Spring Boot 内置的 Tomcat 找不到/product/3这个物理文件,于是返回 404。
原因是 history 路由模式依赖服务器把所有路径都重写到index.html,而 Spring Boot 默认不做这个转发。两个解法任选其一。
方案一,后端加转发配置:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { // 前端路由直接访问时的 fallback,转发到 index.html registry.addViewController("/{path:[^\\.]*}").setViewName("forward:/index.html"); } }方案二,前端路由改用 hash 模式:createWebHashHistory()。hash 模式下 URL 变成/#/product/3,浏览器不会向服务器发起对/product/3的请求,自然不会 404。缺点是不好看,但在内网管理系统里完全可以接受。我的习惯是:做对外官网型项目用 history + 后端转发,做内部管理后台直接用 hash,省事。
4.3 CORS 跨域:前端端口和后端端口互相不认
前后端分离开发时,前端跑在http://localhost:5173(Vite 默认端口),后端跑在http://localhost:8080,前端通过 axios 发起请求时,浏览器会拦截跨域请求,控制台报Access-Control-Allow-Origin错误。这不是后端代码的问题,而是浏览器同源策略在拦路。
解决跨域最稳妥的方式是在后端加全局 CORS 配置,而不是在前端代理上绕。Spring Boot 2.7 里实现如下:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); // 允许所有来源,生产环境建议换成具体域名 config.addAllowedMethod("*"); // 允许所有 HTTP 方法 config.addAllowedHeader("*"); // 允许所有请求头 config.setAllowCredentials(true); // 允许携带认证凭证 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }参数说明:addAllowedOriginPattern("*")和setAllowCredentials(true)不能同时用addAllowedOrigin("*"),因为 Spring 5.3 之后规定带凭证的请求不能匹配通配符来源。如果你只配了前端开发地址http://localhost:5173,那上线后线上域名又会跨域,所以开发阶段用*最省心,上线前再收紧。
4.4 推荐结果只推荐热门商品,个性化完全失效
这个问题很隐蔽,特征是:所有用户拿到的推荐列表一模一样,都是销量最高的那几件。排查下来,八成是相似度计算里把浏览行为也当成正反馈参与了共现矩阵。浏览是最廉价的行为,用户可能只是误点、或者被标题吸引但发现详情页货不对板。如果浏览和下单在权重上没有差异,热销商品会通过大量浏览行为占据共现矩阵的绝大多数高分,千人一面的结果就出现了。
解决方法是给行为权重拉开差距:下单权重 10、加购权重 5、浏览权重 1,并且在构建共现矩阵时跳过浏览行为,只统计加购和下单。另一个补充手段是给热门商品做降权,比如把每个商品的共现次数再除以sqrt(该商品的全局出现次数),这样热门商品的相似度分数会被拉低,长尾商品才有机会露出。
4.5 用户没有行为数据时接口返回空列表,前端白屏
冷启动问题在真实项目里特别常见:新用户注册后什么操作都没有,推荐接口返回空数据,前端v-for渲染空数组,页面留白。更严重的情况是用户行为表里确实查不到记录,SQL 没问题,但接口就因为空结果直接抛了 NPE——products.subList(0, size)当products为空时直接报错。
遇到冷启动,常规做法有两个,代码里都要兜住。第一层是接口层降级:检测到推荐列表为空时,返回「最新上架」或者「评分最高」的商品列表兜底。第二层是前端兜底:productList.value = []之后,模板里渲染「暂无推荐,逛点别的吧」这样一句话,配合返回热门商品,保证页面永远有东西可看。我见过太多系统因为这一层没做,被测试提了个「新用户首页空白」的致命 bug。
5. 落地后的推荐质量验证:两个比看代码更值得投入的手段
系统的推荐接口跑通,只能说明技术链路通了,但推荐得好不好,还需要量化验证。这个环节直接决定你的系统能不能从「能跑」变成「有用」。
第一个手段是离线指标验证。在行为数据里按时间切一刀:前 70% 的数据做训练集,后 30% 做测试集。用训练集跑推荐算法,生成每个用户的 Top-10 推荐列表,然后看测试集里用户真实产生行为的商品有多少出现在推荐列表里。这个比例叫做「命中率」。举例:测试集里有 100 个用户产生了下单行为,其中 35 个用户下单的商品出现在推荐列表里,命中率就是 35%。一般来说,一个电商推荐系统的离线命中率在 10% 到 30% 之间算正常,低于 5% 说明算法或数据有问题。把这段逻辑写在RecommendEvaluator类里,作为一个独立的测试入口,每次改动推荐算法后跑一遍,比眼睛看推荐结果可靠得多。
第二个手段是在线效果对比。把用户流量分成两份,一份走推荐算法,一份走原来的「按销量排序」的兜底逻辑,对比两组用户的点击率和下单转化率。这个过程不用自己搭 AB 测试平台,参考开源做法手动实现:在推荐接口返回时加一个strategy字段标记是recommend还是hot,前端埋点把曝光和点击都打上这个标记,一周后拉数据看哪个策略的转化率高。我自己的习惯是至少跑两周再下结论——新推荐位有新鲜感,第一周数据虚高很正常。
最后说一个我从实践里悟出来的经验:推荐系统最容易翻车的环节不是算法不够高级,而是数据质量太脏——行为日志丢失、商品类目混乱、用户 ID 不统一。我吃过一次大亏:线上推荐准确率突然暴跌,排查了一个多星期,最后发现是埋点代码上线后有个分支把浏览行为重复上报了两次,共现矩阵被脏数据带偏。所以这个项目真正值得你多花时间的,不是琢磨更花哨的深度学习模型,而是先把行为数据的采集、清洗、监控做好。数据地基稳了,一个简单的 ItemCF 就能跑出不错的效果。希望帮到你。
本文还有配套的精品资源,点击获取