ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

协同过滤在白酒销售管理系统中的工程落地:SpringBoot+Vue+MySQL实践

协同过滤在白酒销售管理系统中的工程落地:SpringBoot+Vue+MySQL实践

接手过几个销售管理系统后,我越来越觉得"协同过滤"这四个字在不少项目里只是撑场面的概念,真正能落地、能跑出可用结果的很少。这次要聊的这套白酒销售信息管理系统(演示项目名:黔醉酒业),是少数让我觉得算法和业务真正咬合在一起的项目:SpringBoot 做后端服务,Vue 做前端管理界面,MySQL 存业务数据,整个系统导入订单数据后不但能做常规的进销存和客户管理,还内置了一套基于用户行为的协同过滤推荐引擎,可以直接给不同客户推荐他可能感兴趣的白酒商品。

这套系统最吸引我的地方在于:它不是为了炫技而塞一个算法进去,而是切中了白酒销售的一个真实痛点——客户群体差异极大,有人只认酱香,有人偏爱浓香,有人常年喝 100 元以内的口粮酒,也有人专门找年份老酒。如果只是按销量排行榜推荐,大概率会出现"卖得最好的酒推荐给所有人,但转化率并不高"的尴尬局面。本文我会从项目结构、算法落地、数据库设计、启动排错这几个角度展开,把整个系统拆开讲清楚,适合正在做电商类毕业设计、中小型销售管理系统,或者想在业务系统里引入推荐算法的开发者参考。

1. 白酒销售场景下的推荐需求,到底卡在哪里

很多人在做管理系统时,推荐模块往往是最后一个才考虑的"加分项",但白酒这种商品天然适合做个性化推荐,因为它不是一个爆款通吃的品类。

1.1 白酒订单数据与普通电商推荐的区别

普通电商的推荐逻辑通常是"看了又看"、"买了又买",商品替换成本低,用户今天买零食明天买数码,行为序列非常杂。但白酒销售不一样,它的订单数据有几个明显的特征:

  • 客户复购周期长但忠诚度高。一个习惯喝某款口粮酒的客户,可能连续半年都在买同一款,中间偶尔换换口味。
  • 商品维度强结构化。香型(酱香、浓香、清香)、度数、价格带、年份、礼盒装还是光瓶酒,这些属性对用户决策的影响很大。
  • 购买行为有场景属性。自己喝、请客、送礼、婚宴、商务宴请,不同场景买的东西完全不同。同一个客户,可能平时买 50 元的酒自己喝,但一到过节就买 500 元的礼盒送人。

这就意味着,单纯按"购买次数最多"或者"销售额最高"去做推荐,对白酒商品来说往往不精准。比如某款高端酱香酒销量很高,但买它的人绝大多数是送礼场景,把它推荐给一个平时只喝光瓶浓香酒的用户,基本不会产生点击。

1.2 推荐模块在销售管理系统里的位置

这套系统的业务模块包括客户管理、商品管理、订单管理、库存管理、数据统计,以及一个独立的推荐中心。推荐中心不是单独跑一个算法服务,而是嵌套在订单模块和客户模块之间:每当用户登录或者业务员查看客户详情页时,后端会根据该客户的历史订单行为,实时算出一份"猜你喜欢"酒品列表,展示在客户详情页和首页的推荐位里。

更实际的价值在于:对业务员而言,跟进老客户之前看一眼推荐列表,就知道这客户最近可能对什么价位、什么香型的酒有需求,带着目标去沟通,成单概率比盲推高很多。这也是这套系统把协同过滤从"技术演示"变成"业务工具"的关键。

2. 系统整体架构:SpringBoot后端、Vue前端、MySQL三层怎么分工

这套系统的架构选择很主流:前后端分离,后端 SpringBoot 负责业务接口和算法计算,前端 Vue 负责管理界面展示,MySQL 统一存储业务数据和推荐计算依赖的行为数据。

2.1 后端服务与模块划分

后端并不是一个大而全的单体应用直接把所有代码堆在一起,而是按照业务域做了模块划分。我比较欣赏的是它把推荐引擎单独拆成了一个 service 包,和订单、客户、商品这些 CRUD 模块平级。这样做的直接好处是:如果后续想把算法替换成更复杂的模型,或者把推荐计算搬到独立服务上,只需要改这个包,不影响其他业务接口。

后端核心模块大致如下:

  • 系统管理:登录认证、用户权限、操作日志。
  • 客户管理:客户档案、等级、联系人信息、购买历史。
  • 商品管理:白酒商品档案,包含香型、度数、价格、年份、库存等字段。
  • 订单管理:订单创建、订单明细、订单状态流转、退货处理。
  • 统计报表:销售额、品类销售分布、客户贡献度。
  • 推荐引擎:读取用户行为数据,计算相似度矩阵,产出推荐列表。

这里有个细节值得注意:推荐引擎模块使用了策略模式,接口层面定义了一个RecommendStrategy,下面分别有基于用户的协同过滤实现、基于物品的协同过滤实现,以及一个冷启动时使用的热门商品兜底实现。业务侧可以配置使用哪一种策略,也可以组合使用。这个设计让算法不再是一段写死的代码,而是可以按数据量和业务阶段灵活切换。

2.2 前端管理端的页面组织

前端采用标准 Vue 单页应用结构,路由按后台管理端习惯组织。页面主要包括登录页、工作台首页、客户列表与客户详情、商品管理、订单管理、推荐结果预览等。

推荐结果预览这个页面比较有意思:它不是给客户看的,而是给管理员和业务员做效果验证用的。页面左侧可以选择任意客户,右侧展示推荐引擎算出的 TopN 酒品列表,同时展示这个客户的最近购买记录和相似客户群体画像。这个页面帮了大忙,因为算法效果好坏不能只看离线指标,直接拿真实客户的行为去对比推荐结果,是最直观的验证方式。

前端通过 Axios 调用后端的 RESTful 接口,接口路径统一以/api开头,前后端联调时只需在 Vue 的.env.development文件里配置代理地址,不需要额外处理跨域。生产环境下用 Nginx 把/api反向代理到后端服务即可。

3. 协同过滤算法在白酒订单数据上的落地实现

这部分是整个系统最核心的内容。标题里写了"协同过滤算法",但真正把它搬到工程里时,有几个环节必须想清楚,否则算法代码写得再漂亮,线上也是废的。

3.1 从订单行为构造用户-酒品评分矩阵

大部分协同过滤教程用的是"用户对物品的评分",比如电影评分 1 到 5 分。但白酒销售系统根本没有评分数据,唯一的用户行为是"买过什么、买了几次、买了多少瓶"。所以第一步是把订单行为转化成评分。

我在这个项目里采用的标定方式如下:

  • 用户每购买某一款酒一次,记 1 个行为分;
  • 用户累计购买该酒的瓶数,作为数量权重;
  • 订单金额参与加权,买得越贵(比如整箱、礼盒装),说明对该商品的偏好越强;
  • 评分公式简化为:score = 1 + ln(累计购买瓶数),再用订单金额做系数缩放。

为什么不直接用购买瓶数?因为少数客户可能一次性团购大量酒水用于宴席,这种单次大单不能说明他对该酒有长期偏好。取对数可以把这类极端值压缩,让评分更能反映"稳定偏好"。

对应的 Java 实现大致如下:

public class UserWineScoreBuilder { /** * 从订单明细构建用户-酒品评分表 * key: 用户ID, value: (酒品ID, 加权评分) */ public Map<Integer, Map<Integer, Double>> buildFromOrders(List<OrderItem> orderItems) { Map<Integer, Map<Integer, Double>> userWineScore = new HashMap<>(); for (OrderItem item : orderItems) { Integer userId = item.getUserId(); Integer wineId = item.getWineId(); int buyNum = item.getBuyNum(); double amount = item.getTotalAmount(); double score = 1 + Math.log(buyNum); score = score * (1 + amount / 500.0); // 金额系数,500元为一个基准档 userWineScore .computeIfAbsent(userId, k -> new HashMap<>()) .merge(wineId, score, Double::sum); } return userWineScore; } }

数据来源上,后端每次生成推荐列表时,可以直接从订单明细表实时聚合,也可以提前跑一个定时任务把聚合结果写入单独的评分表。这套系统默认是在推荐接口被调用时实时聚合,因为演示数据量不大,实时算反而免去了"评分表数据过期"的烦恼。

3.2 用户相似度计算与推荐打分代码

有了评分矩阵,接下来就是标准的协同过滤流程:先算用户之间的相似度,再根据相似用户的行为为目标用户推荐。

相似度计算用了余弦相似度,因为它在处理稀疏向量时表现稳定,而且实现简单。核心代码如下:

public double cosineSimilarity(Map<Integer, Double> user1, Map<Integer, Double> user2) { Set<Integer> commonKeys = new HashSet<>(user1.keySet()); commonKeys.retainAll(user2.keySet()); if (commonKeys.isEmpty()) { return 0.0; } double dotProduct = 0.0; double norm1 = 0.0; double norm2 = 0.0; for (double v : user1.values()) { norm1 += v * v; } for (double v : user2.values()) { norm2 += v * v; } for (Integer key : commonKeys) { dotProduct += user1.get(key) * user2.get(key); } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); }

推荐打分时,不能简单地拿相似用户买过的酒去排序,因为每个相似用户对目标用户的影响权重不同。常见的做法是加权求和:目标用户对某款酒的预测偏好 = 所有相似用户对该酒的评分 × 用户相似度的总和。代码如下:

public List<Integer> recommendForUser( Integer targetUserId, Map<Integer, Map<Integer, Double>> userWineScore, int topN) { Map<Integer, Double> targetUserScores = userWineScore.getOrDefault(targetUserId, Collections.emptyMap()); if (targetUserScores.isEmpty()) { // 冷启动阶段,走热门商品兜底策略 return getHotWineIds(topN); } Map<Integer, Double> recommendScore = new HashMap<>(); for (Map.Entry<Integer, Map<Integer, Double>> entry : userWineScore.entrySet()) { Integer otherUserId = entry.getKey(); if (otherUserId.equals(targetUserId)) { continue; } Map<Integer, Double> otherUserScores = entry.getValue(); double similarity = cosineSimilarity(targetUserScores, otherUserScores); if (similarity <= 0.0) { continue; } for (Map.Entry<Integer, Double> wineEntry : otherUserScores.entrySet()) { Integer wineId = wineEntry.getKey(); if (targetUserScores.containsKey(wineId)) { continue; // 已购买过的酒不再推荐 } recommendScore.merge(wineId, similarity * wineEntry.getValue(), Double::sum); } } return recommendScore.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }

注意这里有个隐藏问题:如果两个用户都只买过同一款酒,相似度会高达 1.0,但它们的偏好未必真的接近。所以项目里加了一个惩罚项——两个用户共同购买的商品数越少,相似度越不可信,要乘以一个衰减系数。具体做法是:sim = cosineSimilarity * (commonCount / (commonCount + 1)),这样共同行为只有 1 次时,相似度直接打五折,防止偶发行为带来的虚假相似。

3.3 时间衰减与热门兜底

白酒消费有很强的季节性和时效性。春节前是送礼酒、宴席酒的购买高峰,中秋前后也是。如果系统不考虑时间因素,一个用户在去年春节买过某款高端礼盒酒,系统可能一年后还在给他推荐同一款,这显然不合理。

这个项目的处理方式是给行为数据加了时间衰减权重。在计算评分矩阵时,不是简单累加所有历史订单,而是按订单时间做指数衰减:

double timeDecayFactor = Math.pow(0.9, daysBetween(orderTime, now) / 30.0); score = baseScore * timeDecayFactor;

意思是每过 30 天,行为的参考价值衰减为原来的 90%,半年后就只剩 0.9 的 6 次方约 0.53。这样推荐结果会更贴近客户当前的偏好。

冷启动问题也要单独处理。新客户没有任何订单行为,协同过滤算不出任何相似用户。这个系统的兜底方案是"品类热门 + 高毛利商品"组合:取最近 30 天内销量最高的 10 款酒,再混入 2 款价格较高但库存充足的商品,保证推荐列表既不冷冰冰又兼顾利润。这个策略虽然简单,但真实业务场景里很管用。

3.4 物品协同过滤作为补充视角

基于用户的协同过滤(UserCF)更适合社区属性强的场景,比如客户之间有明显的群体特征。但白酒销售里还有一个常见情况:客户的偏好会迁移。比如一个客户从低端口粮酒逐步升级到中高端酒,此时基于物品的协同过滤(ItemCF)能更好地捕捉"喜欢 A 酒的人也会喜欢 B 酒"的规律。

这套系统里留了策略切换的配置项,在 appplication.yml 里可以配置推荐策略:

recommend: strategy: user-cf # user-cf / item-cf / hot-fallback top-n: 8 user-cf: min-common-count: 1 time-decay-days: 180 item-cf: max-similar-items: 20

我用同样的一批白酒订单数据分别跑过 UserCF 和 ItemCF,一个很直观的感受是:在用户行为稀疏的时候,ItemCF 的推荐结果更稳定;一旦核心客户订单足够密集,UserCF 的个性化更明显。所以项目初期可以用 ItemCF,等订单量积累上来之后再切 UserCF,这个切换过程因为有了配置项变得非常轻松。

4. 数据库建模:让推荐引擎拿得到干净可用的训练数据

说句实话,很多推荐系统死在数据上,不是死在算法上。如果订单表和商品表设计得一团糟,算法再强也白搭。这套系统的表结构设计有几个可圈可点的地方。

4.1 核心表结构设计

下面是几张核心表和它们的关键字段:

表名关键字段设计意图
customerid, name, phone, level, region客户档案,level 区分普通客户和重点客户
winesid, wine_name, brand, aroma_type, degree, price, year, stock白酒商品档案,aroma_type 区分香型,用于后续属性过滤
ordersid, customer_id, order_time, total_amount, status订单主表,order_time 用于时间衰减计算
order_itemsid, order_id, wine_id, buy_num, unit_price, total_amount订单明细,推荐算法的主要数据来源
user_wine_scoreid, user_id, wine_id, score, update_time用户-酒品评分持久化表,定时任务可写入

推荐引擎读取数据时,一条 SQL 就能把用户的购买行为完整取出来:

SELECT o.customer_id AS user_id, oi.wine_id, SUM(oi.buy_num) AS total_buy_num, SUM(oi.total_amount) AS total_amount, MAX(o.order_time) AS last_buy_time FROM orders o JOIN order_items oi ON o.id = oi.order_id WHERE o.status = 'COMPLETED' GROUP BY o.customer_id, oi.wine_id;

这个查询把用户、商品、数量、金额、最近购买时间都拿到了,推荐引擎拿这份结果做矩阵输入基本是零阻碍。

4.2 订单记录与评分的关系处理

这里有一个容易踩坑的地方:直接把订单表当评分表用。订单是业务数据,会随着退货、作废、修改而变动;评分是推荐依据,需要稳定。如果每次算法计算都实时扫描订单表,看似方便,但等到数据量增长之后,推荐接口会变得越来越慢,因为这个查询要全表关联。

我的建议是:平时实时算没问题,但一定要留一个user_wine_score表作为评分缓存,用 Spring 的 @Scheduled 定时任务每天凌晨跑一次全量聚合,把评分结果写进去。推荐接口优先读评分表,评分表没有命中时才实时聚合。这套系统的演示版本实时聚合和定时任务两种方式都写了,切换开关也在配置文件里,效果不错。

另外,设计 wines 表时给aroma_type、degree、price都建了索引。推荐结果出来之后,业务端可能需要按"香型、度数、价格带"做二次过滤,有索引的话过滤效率高很多。这个细节在前期不显眼,数据量到几万条之后差别非常明显。

5. 环境准备与启动排错:把"可直接运行"落到实处

标题里"可直接运行"四个字是这套系统的卖点之一。但"可直接运行"不是嘴上说说,我实际从零开始跑了一遍,把整个流程整理出来,顺便记录了启动过程中容易踩的坑。

5.1 环境版本清单与初始化步骤

先用一句话总结运行需要的东西:JDK 1.8 以上、Maven 3.6 以上、Node.js 16 以上、MySQL 5.7 或 8.0。具体步骤如下:

第一步,初始化数据库。在 MySQL 中创建数据库并导入初始化 SQL:

mysql -u root -p < database/init.sql

init.sql 里包含了建库、建表、插入演示数据的全部语句。我特意确认了一下,演示数据里包含了多个客户在不同时间点的订单记录,数据量不算大,但足够跑出有区分度的推荐结果,这一点做得很贴心。

第二步,配置数据库连接。打开后端项目的application.yml,修改数据库地址、用户名、密码:

spring: datasource: url: jdbc:mysql://localhost:3306/baijiu_sales?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword jpa: hibernate: ddl-auto: none

注意ddl-auto一定要设置为 none,因为表结构已经由 init.sql 建好了,如果让 JPA 自动建表,反而可能因为字段类型不匹配而出问题。

第三步,启动后端服务:

cd backend mvn clean package -DskipTests java -jar target/baijiu-sales-1.0.0.jar

后端默认跑在 8080 端口,启动日志里能看到 Spring Boot 的启动 banner,然后会自动执行推荐引擎的初始化预热,把热门商品数据加载到内存缓存里。

第四步,启动前端开发服务:

cd frontend npm install npm run dev

前端默认跑在 3000 端口,启动后在浏览器访问http://localhost:3000,用管理员账号登录就能进入系统。

5.2 启动过程常见报错与解决办法

我在实际运行过程中遇到的几个问题,在这里列成表格,方便刚接触这类项目的人直接对号入座:

报错现象根本原因解决办法
后端启动失败,提示Access denied for user 'root'@'localhost'数据库密码配置错误,或者 MySQL 账号不允许远程连接核对 application.yml 的密码,确认本地能正常连库
前端npm run dev页面白屏,控制台报跨域错误前端代理没有配好检查 frontend 根目录下.env.development中的代理目标地址是否为http://localhost:8080
推荐接口返回空列表数据库里没有该用户的任何订单记录,冷启动逻辑兜底未生效检查recommend.strategy配置,确认是否开启了hot-fallback
中文乱码数据库连接串缺少characterEncoding=utf8在 JDBC 连接串中加入 utf8 编码参数
端口被占用8080 或 3000 已被其他程序占用修改 application.yml 的server.port,或前端 vite.config.js 中的端口号

其中一个坑值得单独提醒:如果你本机装的是 MySQL 8.0,而项目里引用的 MyBatis 或 JPA 驱动版本较老,很可能会提示时区相关的报错(The server time zone value is unrecognized)。解决方案就是在连接串后面加serverTimezone=Asia/Shanghai,这也是上面配置文件里已经写好的默认值。如果你是从别的地方抄来的配置没有这段,记得补上。

6. 跑通之后的推荐效果调试与下一步优化方向

系统跑起来只是第一步,推荐算法最花时间的地方在这里:怎么判断推荐结果"看起来靠谱",怎么在数据不足时不让推荐列表显得太傻,以及性能怎么保持稳定。

6.1 数据量不足时的推荐失真

演示数据集大概只有几百条订单记录,分布在几十个客户身上。这种数据量下,协同过滤很容易出现"全员相似"的问题:因为总共就那几款热门酒,大家多多少少都买过,余弦相似度算出来普遍偏高,推荐结果趋向于中庸,个性化不足。

遇到这种情况我的调试思路是:先用系统自带的"客户详情 -> 推荐预览"页面,挑几个行为差异明显的客户(比如一个只买酱香、一个只买清香),看推荐结果是否真的呈现出香型差异。如果没有差异,优先检查相似度衰减系数是否生效,以及时间衰减是否把短期的偏好信号压得过低。

另外,数据量不足时还有一个常见问题:推荐结果里反复出现已售罄商品。因为协同过滤只关注"用户行为匹配",不太关注"库存是否充足"。这个系统的做法是在推荐接口返回前做一次库存过滤,把stock <= 0的商品直接剔除。这个过滤要放在算法计算之后、返回前端之前,千万别放在算法输入阶段,否则会破坏评分矩阵的完整性。

6.2 接口性能与缓存策略

推荐接口是读密集型的,每次调用都要算相似度矩阵,如果用户量到几千人、商品量到几百款,在线计算的时间就可能从几十毫秒涨到几百毫秒。对一个后台管理系统来说,几百毫秒尚能接受,但不能再多。

为了给后续用户增长留出余量,我在项目中给推荐接口加了 Redis 缓存:缓存 key 是recommend:{userId}:{strategy},缓存值是推荐商品 ID 列表,过期时间设置为 30 分钟。这样即使短时间内有大量客户详情页被访问,真正触发全量计算的概率也大大降低。

同时,商品库存和价格信息不应该随推荐结果一起缓存,因为库存是实时变动的。推荐接口只缓存商品 ID 列表,拿到 ID 后再实时查库填充商品详情,这样既保证了推荐逻辑的时效性,又避免了"推荐了但已经卖完"的尴尬。

6.3 从在线计算到离线预计算的扩展

最后聊一下扩展方向。当前这套实现是"在线计算":每次请求现算用户相似度。在小规模场景下完全够用,但如果白酒销售系统的客户和商品数量继续增长,在线计算迟早会成为瓶颈。

更成熟的方案是"离线预计算 + 在线检索":用一个定时任务每天凌晨跑一次全量相似度矩阵计算,把每个用户的 TopN 推荐列表写入 Redis 或数据库;白天的推荐接口只是做一次缓存查询,扛几千 QPS 都不成问题。如果将来数据量到了百万级,可以把相似度计算部分换成 Spark 或者 Flink 来实现,但存储层和接口层基本不用动——这正是把推荐逻辑独立封装成服务层的好处。

跑完这个项目,我的体会是:协同过滤在白酒销售管理系统里不是一个"展示型功能",它是真正能影响业务动作的工具。业务员看一眼推荐列表,就知道该给老客户推荐什么酒;管理员看一眼推荐报表,就知道哪些酒在哪些客户群里渗透率还不高。这套系统的代码结构、模块划分和算法落地方式,完全可以作为一个中小型推荐系统的参考样板,希望这篇拆解能给正在做类似项目的你一些思路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进