跳过正文
  1. Posts/

电商用户画像系统

作者
John Lee
Building things with code. Writing about tech, projects, and ideas.
目录

电商用户画像系统
#

项目概述
#

电商用户画像系统是一个基于大数据技术的用户分析平台,旨在通过收集、分析用户的行为数据,构建精准的用户画像,为电商平台的个性化推荐、精准营销和用户体验优化提供数据支持。本系统整合了Spark、Hadoop、Hive、HBase、Kafka等大数据技术,实现了从数据采集到画像构建、应用的完整流程。

技术栈版本
#

组件版本说明
Spark3.5.8分布式计算引擎
Scala2.13.8开发语言
JDK11Java运行环境
Kafka3.7.0消息队列
Redis7.0+热数据缓存
MySQL8.0结构化数据存储
HBase2.5.x标签数据存储
Hive3.1.x数据仓库
Hadoop3.3.x分布式存储

知识点讲解
#

知识点1:用户画像概述
#

什么是用户画像
#

用户画像(User Profile)是将用户的多维度信息进行标签化、结构化描述的一种数据模型。它通过对用户的基础属性、行为特征、消费偏好等信息进行抽象和归纳,形成对用户的全面刻画。简单来说,用户画像就是用数据为用户"画一张像"。

类比理解:想象你在描述一位朋友——“25岁、女性、喜欢运动、经常网购、偏好中高端品牌”。用户画像就是让计算机用同样的方式理解和描述每一个用户,只不过计算机可以同时处理上千万用户的画像。

用户画像的核心价值
#

+--------------------------------------------------+
|                 用户画像核心价值                    |
+--------------------------------------------------+
|                                                    |
|  +------------+  +------------+  +------------+   |
|  | 精准营销   |  | 个性化推荐 |  | 风险控制   |   |
|  +------------+  +------------+  +------------+   |
|  | 定向投放   |  | 商品推荐   |  | 欺诈检测   |   |
|  | 差异化定价 |  | 内容推荐   |  | 信用评估   |   |
|  | 活动触达   |  | 搜索排序   |  | 流失预警   |   |
|  +------------+  +------------+  +------------+   |
|                                                    |
|  +------------+  +------------+                    |
|  | 产品优化   |  | 运营决策   |                    |
|  +------------+  +------------+                    |
|  | 功能改进   |  | 资源配置   |                    |
|  | 体验提升   |  | 战略规划   |                    |
|  +------------+  +------------+                    |
+--------------------------------------------------+

用户画像的三大应用场景
#

1. 精准营销

精准营销是用户画像最直接的应用场景。通过用户画像,企业可以:

  • 定向广告投放:根据用户的兴趣标签,将广告精准推送给目标用户群体,降低营销成本,提高转化率
  • 差异化营销策略:针对不同价值层级的用户制定不同的营销方案,如高价值用户推送高端品牌优惠,价格敏感用户推送折扣信息
  • 营销活动触达:选择用户最活跃的时间段和渠道进行营销信息推送,提升触达效果

2. 个性化推荐

个性化推荐是用户画像在产品层面的核心应用:

  • 商品推荐:基于用户的浏览、购买历史和兴趣标签,推荐可能感兴趣的商品
  • 内容推荐:根据用户的内容偏好,推荐相关的文章、视频等内容
  • 搜索排序优化:根据用户画像调整搜索结果的排序,优先展示用户偏好的品类和品牌

3. 风险控制

风险控制是用户画像在安全层面的重要应用:

  • 欺诈检测:通过用户行为画像识别异常行为,如短时间内大量下单、收货地址频繁变更等
  • 信用评估:根据用户的消费能力、还款记录等画像信息评估信用等级
  • 流失预警:通过分析用户活跃度变化趋势,提前识别可能流失的用户并采取挽回措施

知识点2:标签体系设计
#

标签的分类
#

标签体系是用户画像的核心骨架。根据标签的生成方式,可以将标签分为三大类:

+-----------------------------------------------------------------+
|                        标签体系分类                               |
+-----------------------------------------------------------------+
|                                                                   |
|  +------------------+  +------------------+  +----------------+  |
|  |   事实标签       |  |   统计标签       |  |   算法标签     |  |
|  +------------------+  +------------------+  +----------------+  |
|  | 直接从原始数据   |  | 对事实标签进行   |  | 通过机器学习   |  |
|  | 中提取的客观     |  | 统计计算得到的   |  | 算法挖掘得到   |  |
|  | 事实描述         |  | 聚合指标         |  | 的预测标签     |  |
|  +------------------+  +------------------+  +----------------+  |
|  | - 性别: 男/女    |  | - 消费总金额     |  | - 购买倾向     |  |
|  | - 年龄: 25岁     |  | - 购买频次       |  | - 流失概率     |  |
|  | - 注册日期       |  | - 平均客单价     |  | - 信用等级     |  |
|  | - 所在城市       |  | - 最近购买天数   |  | - 用户分群     |  |
|  +------------------+  +------------------+  +----------------+  |
+-----------------------------------------------------------------+

事实标签:最基础的标签类型,直接来源于原始数据,不需要复杂的计算。例如用户的性别、年龄、注册时间、所在城市等。事实标签是构建其他标签的基础。

统计标签:在事实标签的基础上进行统计计算得到的标签。例如用户的消费总金额、购买频次、平均客单价、最近一次购买距今天数等。统计标签反映了用户的行为特征和消费能力。

算法标签:通过机器学习算法对用户数据进行深度挖掘得到的预测性标签。例如用户的购买倾向评分、流失概率、信用等级、用户分群归属等。算法标签是最有价值的标签类型,但也是计算成本最高的。

标签层级设计
#

在实际的企业级项目中,标签通常按照层级进行组织,形成树状结构:

一级标签(大类)
├── 二级标签(中类)
│   ├── 三级标签(小类/具体标签)
│   ├── 三级标签
│   └── 三级标签
├── 二级标签
│   ├── 三级标签
│   └── 三级标签
└── 二级标签

电商用户画像标签体系示例

一级标签二级标签三级标签标签类型
人口属性基本属性性别事实标签
人口属性基本属性年龄段统计标签
人口属性基本属性婚姻状况事实标签
人口属性地域属性所在省份事实标签
人口属性地域属性城市等级统计标签
消费特征消费能力消费总金额统计标签
消费特征消费能力平均客单价统计标签
消费特征消费能力购买力等级算法标签
消费特征消费偏好偏好品类统计标签
消费特征消费偏好偏好品牌统计标签
消费特征消费偏好价格敏感度算法标签
行为特征活跃度最近访问天数统计标签
行为特征活跃度登录频次统计标签
行为特征活跃度活跃等级算法标签
行为特征购物行为购买频次统计标签
行为特征购物行为RFM分群算法标签
风险属性信用风险信用等级算法标签
风险属性流失风险流失概率算法标签

知识点3:RFM模型
#

RFM模型定义
#

RFM模型是用户画像中最经典、最实用的用户价值分析模型之一。它通过三个维度来衡量用户的价值:

+-------------------------------------------------------+
|                    RFM模型                              |
+-------------------------------------------------------+
|                                                         |
|  R - Recency(最近消费时间)                             |
|  ├── 用户最近一次消费距今天数                            |
|  ├── R值越小,说明用户最近刚消费过,活跃度越高           |
|  └── 反映用户的当前活跃状态                             |
|                                                         |
|  F - Frequency(消费频率)                              |
|  ├── 用户在统计周期内的消费次数                         |
|  ├── F值越大,说明用户消费越频繁,忠诚度越高            |
|  └── 反映用户的消费习惯和忠诚度                         |
|                                                         |
|  M - Monetary(消费金额)                               |
|  ├── 用户在统计周期内的消费总金额                       |
|  ├── M值越大,说明用户消费能力越强,贡献度越高          |
|  └── 反映用户的消费能力和贡献价值                       |
+-------------------------------------------------------+

类比理解:想象你经营一家餐厅——R是"这位客人多久没来了",F是"这位客人一个月来几次",M是"这位客人每次消费多少钱"。显然,最近常来、来得频繁、每次花得多的客人是最有价值的。

RFM评分规则
#

在实际应用中,我们通常将R、F、M三个维度的原始值转换为评分(一般采用1-5分制),然后根据评分进行用户分群:

R评分规则(最近消费时间越近,评分越高)

R值范围评分含义
0-30天5非常活跃
31-60天4较活跃
61-90天3一般活跃
91-180天2不太活跃
180天以上1很不活跃

F评分规则(消费频率越高,评分越高)

F值范围评分含义
20次以上5非常频繁
10-19次4较频繁
5-9次3一般频繁
2-4次2较少
1次1极少

M评分规则(消费金额越高,评分越高)

M值范围评分含义
10000元以上5高消费
5000-9999元4中高消费
2000-4999元3中等消费
500-1999元2中低消费
500元以下1低消费

用户分群策略
#

根据RFM评分的高低(以各维度平均分为阈值),可以将用户划分为8种类型:

R评分F评分M评分用户类型特征描述运营策略
重要价值用户最近有消费、频率高、金额大VIP服务、专属优惠、保持关系
重要保持用户最近有消费、频率高、但金额小提升客单价、关联推荐
重要发展用户最近有消费、频率低、金额大提升消费频次、会员激励
重要挽留用户最近有消费、频率低、金额小引导复购、优惠券刺激
重要唤回用户较久未消费、频率高、金额大唤醒召回、专属活动
一般维持用户较久未消费、频率高、金额小常规触达、保持关注
一般发展用户较久未消费、频率低、金额大重新激活、新品推荐
流失预警用户较久未消费、频率低、金额小低成本维护或放弃

知识点4:用户画像构建流程
#

用户画像的构建是一个系统化的工程,包含从数据采集到画像服务的完整流程:

+--------------------------------------------------------------------+
|                     用户画像构建流程                                 |
+--------------------------------------------------------------------+
|                                                                      |
|  [1.数据采集] --> [2.数据清洗] --> [3.标签计算] --> [4.画像存储] --> [5.画像服务]
|                                                                      |
|  +-----------+   +-----------+   +-----------+   +-----------+      +-----------+
|  | 数据采集  |   | 数据清洗  |   | 标签计算  |   | 画像存储  |      | 画像服务  |
|  +-----------+   +-----------+   +-----------+   +-----------+      +-----------+
|  | - 日志采集|   | - 去重    |   | - 事实标签|   | - Redis   |      | - REST API|
|  | - 业务数据|   | - 缺失值  |   | - 统计标签|   | - HBase   |      | - 查询服务|
|  | - 第三方  |   | - 异常值  |   | - 算法标签|   | - MySQL   |      | - 推送服务|
|  | - 爬虫    |   | - 格式化  |   | - RFM模型 |   | - Hive    |      | - 降级方案|
|  +-----------+   +-----------+   +-----------+   +-----------+      +-----------+
|                                                                      |
+--------------------------------------------------------------------+

阶段1:数据采集

数据采集是用户画像构建的第一步,数据的质量和丰富度直接决定了画像的准确性。主要数据来源包括:

  • 日志数据:用户浏览、点击、搜索等行为日志,通过Kafka、Flume等工具采集
  • 业务数据:订单、支付、退款等交易数据,来自MySQL等业务数据库
  • 第三方数据:社交数据、地理位置数据等外部数据源
  • 埋点数据:前端页面埋点采集的用户交互数据

阶段2:数据清洗

原始数据通常存在各种质量问题,需要经过清洗才能用于标签计算:

  • 去重处理:去除重复采集的数据记录
  • 缺失值处理:对缺失字段进行填充或丢弃
  • 异常值处理:识别并处理明显不合理的数据(如负数的订单金额)
  • 格式统一:统一日期格式、编码格式等

阶段3:标签计算

标签计算是用户画像构建的核心环节,根据标签类型采用不同的计算方式:

  • 事实标签:直接从清洗后的数据中提取,如性别、年龄
  • 统计标签:通过SQL聚合计算,如消费总金额、购买频次
  • 算法标签:通过机器学习模型计算,如用户分群、流失概率
  • RFM模型:计算R、F、M三个维度的评分,进行用户价值分群

阶段4:画像存储

根据数据的访问特征和使用场景,采用不同的存储方案(详见知识点5)。

阶段5:画像服务

将构建好的用户画像通过服务接口对外提供,支持实时查询和批量推送:

  • REST API:提供用户画像查询接口,支持按用户ID查询
  • 批量推送:定期将用户画像数据推送到营销系统、推荐系统等下游
  • 降级方案:当主存储不可用时,自动切换到备用数据源

知识点5:画像数据存储架构
#

用户画像数据具有多维度、多粒度、多访问模式的特点,单一存储方案无法满足所有需求。企业级系统通常采用分层存储架构:

+--------------------------------------------------------------------+
|                    画像数据存储架构                                   |
+--------------------------------------------------------------------+
|                                                                      |
|  +------------------+                                                |
|  |    Redis 7.0+    |  热数据层:实时查询、高并发访问               |
|  |    (热数据)      |  - 用户实时标签缓存                           |
|  |                  |  - 用户RFM评分                                |
|  |                  |  - 最近行为特征                               |
|  |    TTL: 24h      |  - 读写延迟 < 1ms                            |
|  +------------------+                                                |
|           |                                                          |
|           v                                                          |
|  +------------------+                                                |
|  |   HBase 2.5.x    |  标签数据层:海量标签存储、快速检索           |
|  |   (标签数据)     |  - 全量用户标签数据                           |
|  |                  |  - 标签历史版本                               |
|  |                  |  - 支持按RowKey快速查询                       |
|  +------------------+                                                |
|           |                                                          |
|           v                                                          |
|  +------------------+                                                |
|  |   MySQL 8.0      |  结构化数据层:关系查询、事务支持             |
|  |   (结构化数据)   |  - 用户基础信息                               |
|  |                  |  - 标签元数据定义                             |
|  |                  |  - 画像统计报表                               |
|  +------------------+                                                |
|           |                                                          |
|           v                                                          |
|  +------------------+                                                |
|  |   Hive 3.1.x     |  历史数据层:离线分析、数据回溯               |
|  |   (历史数据)     |  - 用户画像历史快照                           |
|  |                  |  - 标签计算中间结果                           |
|  |                  |  - 离线分析报表                               |
|  +------------------+                                                |
|                                                                      |
+--------------------------------------------------------------------+

各存储层详细说明

存储层技术选型数据内容访问模式典型延迟
热数据层Redis 7.0+实时标签、RFM评分、最近行为实时读写、高并发< 1ms
标签数据层HBase 2.5.x全量用户标签、标签历史按RowKey查询、范围扫描10-50ms
结构化数据层MySQL 8.0用户基础信息、标签元数据关系查询、事务操作5-20ms
历史数据层Hive 3.1.x历史快照、中间结果批量离线分析分钟级

数据流转策略

  • 写入:标签计算结果先写入MySQL(结构化数据)和HBase(标签数据),再异步同步到Redis(热数据缓存)
  • 查询:优先从Redis读取热数据;Redis未命中时回源HBase;历史数据分析使用Hive
  • 过期:Redis中的热数据设置TTL(通常24小时),过期后由定时任务重新加载
  • 降级:当Redis不可用时,直接查询HBase,牺牲部分性能保证可用性

系统架构
#

系统架构图
#

+----------------+     +------------+     +------------+     +------------+
| 数据采集层     | --> | 数据存储层 | --> | 数据处理层 | --> | 应用层     |
+----------------+     +------------+     +------------+     +------------+
| - Kafka 3.7.0  |     | - HDFS     |     | - Spark    |     | - 推荐系统 |
| - Flume        |     | - HBase    |     |   3.5.8    |     | - 精准营销 |
| - Logstash     |     | - MySQL    |     | - Hive     |     | - 个性化   |
|                |     |   8.0      |     | - MLlib    |     | - 风控系统 |
|                |     | - Redis    |     +------------+     +------------+
|                |     |   7.0+     |
+----------------+     +------------+

项目工程结构
#

ecommerce-user-profile/
├── pom.xml                              # Maven项目配置
├── src/
│   ├── main/
│   │   ├── resources/
│   │   │   ├── application.conf         # 配置文件(外部化配置)
│   │   │   └── log4j2.xml              # 日志配置
│   │   └── scala/
│   │       └── com/
│   │           └── ecommerce/
│   │               └── profile/
│   │                   ├── config/
│   │                   │   └── ConfigManager.scala      # 配置管理
│   │                   ├── dal/
│   │                   │   └── DataAccessLayer.scala     # 数据访问层
│   │                   ├── service/
│   │                   │   └── UserProfileService.scala  # 画像服务
│   │                   ├── producer/
│   │                   │   └── UserBehaviorProducer.scala # 数据采集
│   │                   ├── storage/
│   │                   │   └── UserBehaviorToHBase.scala  # HBase存储
│   │                   ├── builder/
│   │                   │   ├── UserProfileBuilder.scala   # 基础画像构建
│   │                   │   ├── RFMAnalyzer.scala          # RFM分析
│   │                   │   └── UserSegmentation.scala     # 用户分群
│   │                   ├── recommendation/
│   │                   │   └── PersonalizedRecommendation.scala # 个性化推荐
│   │                   └── marketing/
│   │                       └── TargetedMarketing.scala    # 精准营销
├── config/
│   └── application.conf                 # 外部配置文件(生产环境覆盖)
└── scripts/
    └── init_tables.sql                  # 数据库初始化脚本

数据模型设计
#

1. 用户基础信息模型
#

字段名数据类型描述
user_idINT用户ID(主键)
usernameVARCHAR(50)用户名
genderVARCHAR(10)性别
ageINT年龄
emailVARCHAR(100)邮箱
phoneVARCHAR(20)手机号
registration_dateDATE注册日期
last_login_dateDATE最后登录日期
user_levelINT用户等级

2. 用户行为数据模型
#

字段名数据类型描述
behavior_idBIGINT行为ID
user_idINT用户ID
product_idINT商品ID
behavior_typeVARCHAR(20)行为类型(view/click/add_to_cart/favorite/purchase)
behavior_timeTIMESTAMP行为时间
session_idVARCHAR(100)会话ID
ip_addressVARCHAR(50)IP地址
device_typeVARCHAR(50)设备类型

3. 商品数据模型
#

字段名数据类型描述
product_idINT商品ID(主键)
product_nameVARCHAR(100)商品名称
category_idINT分类ID
category_nameVARCHAR(50)分类名称
priceDECIMAL(10,2)价格
brandVARCHAR(50)品牌
stockINT库存
salesINT销量
ratingDECIMAL(3,2)评分

4. 用户画像模型
#

字段名数据类型描述
user_idINT用户ID(主键)
age_groupVARCHAR(20)年龄组
genderVARCHAR(10)性别
interest_categoriesJSON兴趣分类
purchase_powerVARCHAR(20)购买力
shopping_frequencyVARCHAR(20)购物频率
favorite_brandsJSON偏好品牌
average_order_valueDECIMAL(10,2)平均订单价值
last_purchase_dateDATE最后购买日期
user_tagsJSON用户标签
rfm_scoreVARCHAR(10)RFM评分
user_segmentVARCHAR(30)用户分群

项目配置
#

pom.xml
#

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.ecommerce</groupId>
    <artifactId>user-profile</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <name>E-Commerce User Profile System</name>
    <description>基于Spark的电商用户画像系统</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <scala.version>2.13.8</scala.version>
        <scala.binary.version>2.13</scala.binary.version>
        <spark.version>3.5.8</spark.version>
        <hadoop.version>3.3.6</hadoop.version>
        <hbase.version>2.5.5</hbase.version>
        <kafka.version>3.7.0</kafka.version>
        <jedis.version>5.0.2</jedis.version>
        <mysql.connector.version>8.0.33</mysql.connector.version>
        <typesafe.config.version>1.4.3</typesafe.config.version>
        <slf4j.version>2.0.9</slf4j.version>
        <log4j.version>2.21.1</log4j.version>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- Scala -->
        <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
            <version>${scala.version}</version>
        </dependency>

        <!-- Spark Core -->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-core_${scala.binary.version}</artifactId>
            <version>${spark.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- Spark SQL -->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-sql_${scala.binary.version}</artifactId>
            <version>${spark.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- Spark Streaming -->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-streaming_${scala.binary.version}</artifactId>
            <version>${spark.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- Spark Streaming Kafka -->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-streaming-kafka-0-10_${scala.binary.version}</artifactId>
            <version>${spark.version}</version>
        </dependency>

        <!-- Spark SQL Kafka -->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-sql-kafka-0-10_${scala.binary.version}</artifactId>
            <version>${spark.version}</version>
        </dependency>

        <!-- Spark MLlib -->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-mllib_${scala.binary.version}</artifactId>
            <version>${spark.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- Spark Hive -->
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-hive_${scala.binary.version}</artifactId>
            <version>${spark.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- Hadoop Common -->
        <dependency>
            <groupId>org.apache.hadoop</groupId>
            <artifactId>hadoop-common</artifactId>
            <version>${hadoop.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- Hadoop Client -->
        <dependency>
            <groupId>org.apache.hadoop</groupId>
            <artifactId>hadoop-client</artifactId>
            <version>${hadoop.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- HBase Client -->
        <dependency>
            <groupId>org.apache.hbase</groupId>
            <artifactId>hbase-client</artifactId>
            <version>${hbase.version}</version>
        </dependency>

        <!-- HBase Common -->
        <dependency>
            <groupId>org.apache.hbase</groupId>
            <artifactId>hbase-common</artifactId>
            <version>${hbase.version}</version>
        </dependency>

        <!-- Kafka Clients -->
        <dependency>
            <groupId>org.apache.kafka</groupId>
            <artifactId>kafka-clients</artifactId>
            <version>${kafka.version}</version>
        </dependency>

        <!-- Redis Jedis -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>${jedis.version}</version>
        </dependency>

        <!-- MySQL Connector -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>${mysql.connector.version}</version>
        </dependency>

        <!-- Typesafe Config -->
        <dependency>
            <groupId>com.typesafe</groupId>
            <artifactId>config</artifactId>
            <version>${typesafe.config.version}</version>
        </dependency>

        <!-- SLF4J API -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
        </dependency>

        <!-- Log4j2 -->
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>${log4j.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-slf4j2-impl</artifactId>
            <version>${log4j.version}</version>
        </dependency>

        <!-- ScalaTest -->
        <dependency>
            <groupId>org.scalatest</groupId>
            <artifactId>scalatest_${scala.binary.version}</artifactId>
            <version>3.2.17</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- Scala Maven Plugin -->
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
                <version>4.8.1</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>testCompile</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <scalaVersion>${scala.version}</scalaVersion>
                    <args>
                        <arg>-target:jvm-11</arg>
                        <arg>-deprecation</arg>
                        <arg>-feature</arg>
                        <arg>-unchecked</arg>
                    </args>
                </configuration>
            </plugin>

            <!-- Maven Shade Plugin(打包Fat Jar) -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.5.1</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <filters>
                                <filter>
                                    <artifact>*:*</artifact>
                                    <excludes>
                                        <exclude>META-INF/*.SF</exclude>
                                        <exclude>META-INF/*.DSA</exclude>
                                        <exclude>META-INF/*.RSA</exclude>
                                    </excludes>
                                </filter>
                            </filters>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

            <!-- Maven Compiler Plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

application.conf 配置文件
#

# ============================================
# 电商用户画像系统 - 配置文件
# 注意:生产环境中密码等敏感信息应通过环境变量或密钥管理系统注入
# ============================================

# MySQL 配置
mysql {
  url = "jdbc:mysql://localhost:3306/ecommerce?useSSL=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"
  user = "ecommerce_user"
  # 生产环境请通过环境变量覆盖: MYSQL_PASSWORD
  password = ${?MYSQL_PASSWORD}
  driver = "com.mysql.cj.jdbc.Driver"
  pool-size = 10
  connection-timeout = 30000
}

# Redis 配置
redis {
  host = "localhost"
  port = 6379
  # 生产环境请通过环境变量覆盖: REDIS_PASSWORD
  password = ${?REDIS_PASSWORD}
  database = 0
  timeout = 3000
  pool {
    max-total = 50
    max-idle = 20
    min-idle = 5
    max-wait-millis = 3000
    test-on-borrow = true
    test-on-return = false
    test-while-idle = true
  }
  cache {
    # 热数据缓存过期时间(秒)
    ttl-seconds = 86400
    # 缓存键前缀
    key-prefix = "user:profile:"
  }
}

# Kafka 配置
kafka {
  bootstrap-servers = "localhost:9092"
  consumer {
    group-id = "user-profile-group"
    auto-offset-reset = "latest"
    enable-auto-commit = false
    max-poll-records = 500
    session-timeout-ms = 30000
  }
  producer {
    acks = "all"
    retries = 3
    batch-size = 16384
    linger-ms = 5
    buffer-memory = 33554432
  }
  topics {
    user-behavior = "user-behavior"
    access-logs = "access-logs"
    profile-update = "profile-update"
  }
}

# Spark 配置
spark {
  app-name = "EcommerceUserProfile"
  master = "local[*]"
  config {
    "spark.executor.memory" = "4g"
    "spark.executor.cores" = "2"
    "spark.driver.memory" = "2g"
    "spark.sql.shuffle.partitions" = "200"
    "spark.default.parallelism" = "100"
    "spark.serializer" = "org.apache.spark.serializer.KryoSerializer"
    "spark.sql.adaptive.enabled" = "true"
    "spark.sql.adaptive.coalescePartitions.enabled" = "true"
    "spark.streaming.backpressure.enabled" = "true"
  }
}

# HBase 配置
hbase {
  zookeeper-quorum = "localhost"
  zookeeper-property-clientPort = "2181"
  tables {
    user-behavior = "user_behavior"
    user-tags = "user_tags"
  }
}

# RFM 模型配置
rfm {
  # 统计周期(天)
  period-days = 365
  # R 评分阈值(天)
  r-thresholds = [30, 60, 90, 180]
  # F 评分阈值(次)
  f-thresholds = [1, 5, 10, 20]
  # M 评分阈值(元)
  m-thresholds = [500, 2000, 5000, 10000]
}

# 画像服务配置
profile-service {
  port = 8080
  # 降级开关:当Redis不可用时是否降级到HBase查询
  degradation-enabled = true
  # 批量查询最大数量
  batch-max-size = 100
  # 查询超时时间(毫秒)
  query-timeout-ms = 5000
}

核心代码实现
#

1. 配置管理 - ConfigManager.scala
#

package com.ecommerce.profile.config

import com.typesafe.config.{Config, ConfigFactory}
import org.slf4j.{Logger, LoggerFactory}

import scala.jdk.CollectionConverters._
import scala.util.Try

/**
 * 配置管理器
 *
 * 使用 Typesafe Config 库管理应用配置,支持:
 * - 多环境配置(开发/测试/生产)
 * - 环境变量覆盖(敏感信息不硬编码)
 * - 配置热加载
 * - 配置验证
 *
 * 设计原则:
 * - 所有敏感信息(密码等)通过环境变量注入,不在代码或配置文件中硬编码
 * - 提供类型安全的配置读取方法
 * - 提供默认值机制,避免因缺少配置导致启动失败
 */
object ConfigManager {

  private val logger: Logger = LoggerFactory.getLogger(ConfigManager.getClass)

  // 加载配置,优先使用环境变量覆盖
  private val config: Config = {
    val baseConfig = ConfigFactory.load()
    // 允许通过环境变量覆盖配置项
    // 例如:MYSQL_PASSWORD 环境变量会覆盖 mysql.password 配置
    val envOverrides = ConfigFactory.systemEnvironment()
    envOverrides.withFallback(baseConfig).resolve()
  }

  /**
   * 获取原始 Config 对象
   */
  def getConfig: Config = config

  // ==================== MySQL 配置 ====================

  def getMySQLUrl: String = config.getString("mysql.url")

  def getMySQLUser: String = config.getString("mysql.user")

  def getMySQLPassword: String = {
    // 优先从环境变量获取密码
    sys.env.getOrElse("MYSQL_PASSWORD", {
      if (config.hasPath("mysql.password")) {
        val pwd = config.getString("mysql.password")
        if (pwd == null || pwd.isEmpty) {
          logger.warn("MySQL密码未配置,请设置环境变量 MYSQL_PASSWORD 或配置 mysql.password")
        }
        pwd
      } else {
        logger.error("MySQL密码未配置!请设置环境变量 MYSQL_PASSWORD")
        ""
      }
    })
  }

  def getMySQLDriver: String = config.getString("mysql.driver")

  def getMySQLPoolSize: Int = Try(config.getInt("mysql.pool-size")).getOrElse(10)

  // ==================== Redis 配置 ====================

  def getRedisHost: String = config.getString("redis.host")

  def getRedisPort: Int = config.getInt("redis.port")

  def getRedisPassword: String = {
    sys.env.getOrElse("REDIS_PASSWORD", {
      if (config.hasPath("redis.password") && config.getString("redis.password") != null) {
        config.getString("redis.password")
      } else {
        "" // Redis 可以设置无密码
      }
    })
  }

  def getRedisDatabase: Int = Try(config.getInt("redis.database")).getOrElse(0)

  def getRedisTimeout: Int = Try(config.getInt("redis.timeout")).getOrElse(3000)

  def getRedisMaxTotal: Int = Try(config.getInt("redis.pool.max-total")).getOrElse(50)

  def getRedisMaxIdle: Int = Try(config.getInt("redis.pool.max-idle")).getOrElse(20)

  def getRedisMinIdle: Int = Try(config.getInt("redis.pool.min-idle")).getOrElse(5)

  def getRedisMaxWaitMillis: Long = Try(config.getLong("redis.pool.max-wait-millis")).getOrElse(3000L)

  def getRedisCacheTTL: Int = Try(config.getInt("redis.cache.ttl-seconds")).getOrElse(86400)

  def getRedisKeyPrefix: String = Try(config.getString("redis.cache.key-prefix")).getOrElse("user:profile:")

  // ==================== Kafka 配置 ====================

  def getKafkaBootstrapServers: String = config.getString("kafka.bootstrap-servers")

  def getKafkaGroupId: String = Try(config.getString("kafka.consumer.group-id")).getOrElse("user-profile-group")

  def getKafkaAutoOffsetReset: String = Try(config.getString("kafka.consumer.auto-offset-reset")).getOrElse("latest")

  def getKafkaMaxPollRecords: Int = Try(config.getInt("kafka.consumer.max-poll-records")).getOrElse(500)

  def getKafkaUserBehaviorTopic: String = Try(config.getString("kafka.topics.user-behavior")).getOrElse("user-behavior")

  def getKafkaAccessLogsTopic: String = Try(config.getString("kafka.topics.access-logs")).getOrElse("access-logs")

  def getKafkaProfileUpdateTopic: String = Try(config.getString("kafka.topics.profile-update")).getOrElse("profile-update")

  // ==================== Spark 配置 ====================

  def getSparkAppName: String = Try(config.getString("spark.app-name")).getOrElse("EcommerceUserProfile")

  def getSparkMaster: String = Try(config.getString("spark.master")).getOrElse("local[*]")

  def getSparkConfig: Map[String, String] = {
    Try {
      config.getConfig("spark.config").entrySet().asScala.map { entry =>
        entry.getKey -> entry.getValue.unwrapped().toString
      }.toMap
    }.getOrElse(Map.empty)
  }

  // ==================== HBase 配置 ====================

  def getHBaseZkQuorum: String = Try(config.getString("hbase.zookeeper-quorum")).getOrElse("localhost")

  def getHBaseZkClientPort: String = Try(config.getString("hbase.zookeeper-property-clientPort")).getOrElse("2181")

  def getHBaseUserBehaviorTable: String = Try(config.getString("hbase.tables.user-behavior")).getOrElse("user_behavior")

  def getHBaseUserTagsTable: String = Try(config.getString("hbase.tables.user-tags")).getOrElse("user_tags")

  // ==================== RFM 配置 ====================

  def getRFMPeriodDays: Int = Try(config.getInt("rfm.period-days")).getOrElse(365)

  def getRFMRThresholds: List[Int] = Try {
    config.getIntList("rfm.r-thresholds").asScala.map(_.intValue()).toList
  }.getOrElse(List(30, 60, 90, 180))

  def getRFMFThresholds: List[Int] = Try {
    config.getIntList("rfm.f-thresholds").asScala.map(_.intValue()).toList
  }.getOrElse(List(1, 5, 10, 20))

  def getRFMMThresholds: List[Int] = Try {
    config.getIntList("rfm.m-thresholds").asScala.map(_.intValue()).toList
  }.getOrElse(List(500, 2000, 5000, 10000))

  // ==================== 画像服务配置 ====================

  def getProfileServicePort: Int = Try(config.getInt("profile-service.port")).getOrElse(8080)

  def isDegradationEnabled: Boolean = Try(config.getBoolean("profile-service.degradation-enabled")).getOrElse(true)

  def getBatchMaxSize: Int = Try(config.getInt("profile-service.batch-max-size")).getOrElse(100)

  def getQueryTimeoutMs: Long = Try(config.getLong("profile-service.query-timeout-ms")).getOrElse(5000L)

  /**
   * 验证关键配置是否完整
   *
   * @return 验证结果,None表示通过,Some包含错误信息
   */
  def validate(): Option[String] = {
    val errors = scala.collection.mutable.ArrayBuffer[String]()

    if (getMySQLUrl.isEmpty) errors += "MySQL URL 未配置"
    if (getMySQLUser.isEmpty) errors += "MySQL 用户名未配置"
    if (getMySQLPassword.isEmpty) errors += "MySQL 密码未配置(请设置环境变量 MYSQL_PASSWORD)"
    if (getRedisHost.isEmpty) errors += "Redis 主机未配置"
    if (getKafkaBootstrapServers.isEmpty) errors += "Kafka bootstrap servers 未配置"

    if (errors.nonEmpty) {
      val msg = s"配置验证失败:\n${errors.mkString("\n")}"
      logger.error(msg)
      Some(msg)
    } else {
      logger.info("配置验证通过")
      None
    }
  }

  /**
   * 打印当前配置(隐藏敏感信息)
   */
  def printConfig(): Unit = {
    logger.info("========== 当前配置 ==========")
    logger.info(s"MySQL URL: $getMySQLUrl")
    logger.info(s"MySQL User: $getMySQLUser")
    logger.info(s"MySQL Password: ${if (getMySQLPassword.nonEmpty) "******" else "未配置"}")
    logger.info(s"Redis Host: $getRedisHost:$getRedisPort")
    logger.info(s"Redis Password: ${if (getRedisPassword.nonEmpty) "******" else "未配置"}")
    logger.info(s"Kafka Servers: $getKafkaBootstrapServers")
    logger.info(s"Spark Master: $getSparkMaster")
    logger.info(s"HBase ZK: $getHBaseZkQuorum:$getHBaseZkClientPort")
    logger.info(s"降级开关: $isDegradationEnabled")
    logger.info("==============================")
  }
}

2. 数据访问层 - DataAccessLayer.scala
#

package com.ecommerce.profile.dal

import com.ecommerce.profile.config.ConfigManager
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.slf4j.{Logger, LoggerFactory}

import java.sql.{Connection, DriverManager, PreparedStatement, ResultSet}
import java.util.Properties
import scala.jdk.CollectionConverters._
import scala.util.{Try, Using}

/**
 * 数据访问层
 *
 * 封装所有数据源的读写操作,提供统一的数据访问接口。
 * 职责:
 * - MySQL 数据读写
 * - HBase 数据读写
 * - Redis 缓存操作
 * - Kafka 数据源接入
 *
 * 设计原则:
 * - 所有连接配置通过 ConfigManager 获取,不硬编码
 * - 提供连接池管理,避免频繁创建/销毁连接
 * - 统一异常处理,确保资源释放
 * - 读写操作支持重试机制
 */
object DataAccessLayer {

  private val logger: Logger = LoggerFactory.getLogger(DataAccessLayer.getClass)

  // ==================== MySQL 操作 ====================

  /**
   * 获取 MySQL JDBC 连接属性
   */
  private def getMySQLProperties: Properties = {
    val props = new Properties()
    props.setProperty("user", ConfigManager.getMySQLUser)
    props.setProperty("password", ConfigManager.getMySQLPassword)
    props.setProperty("driver", ConfigManager.getMySQLDriver)
    props.setProperty("useSSL", "true")
    props.setProperty("characterEncoding", "utf8mb4")
    props
  }

  /**
   * 从 MySQL 读取数据
   *
   * @param spark   SparkSession 实例
   * @param table   表名
   * @param columns 可选的列名列表,为空则读取全部列
   * @return DataFrame
   */
  def readFromMySQL(spark: SparkSession, table: String, columns: Seq[String] = Seq.empty): DataFrame = {
    require(table.nonEmpty, "表名不能为空")

    val query = if (columns.nonEmpty) {
      s"SELECT ${columns.mkString(", ")} FROM $table"
    } else {
      s"SELECT * FROM $table"
    }

    logger.info(s"从MySQL读取数据: $query")

    try {
      spark.read
        .format("jdbc")
        .option("url", ConfigManager.getMySQLUrl)
        .option("dbtable", s"($query) AS t")
        .option("user", ConfigManager.getMySQLUser)
        .option("password", ConfigManager.getMySQLPassword)
        .option("driver", ConfigManager.getMySQLDriver)
        .option("fetchsize", "1000")
        .option("numPartitions", "4")
        .option("partitionColumn", "user_id")
        .option("lowerBound", "1")
        .option("upperBound", "10000000")
        .load()
    } catch {
      case e: Exception =>
        logger.error(s"从MySQL读取数据失败: ${e.getMessage}", e)
        throw new RuntimeException(s"MySQL读取失败: ${e.getMessage}", e)
    }
  }

  /**
   * 写入数据到 MySQL
   *
   * @param df       待写入的 DataFrame
   * @param table    目标表名
   * @param saveMode 写入模式(overwrite/append/upsert)
   */
  def writeToMySQL(df: DataFrame, table: String, saveMode: String = "overwrite"): Unit = {
    require(table.nonEmpty, "表名不能为空")

    logger.info(s"写入数据到MySQL表: $table, 模式: $saveMode, 记录数: ${df.count()}")

    try {
      df.write
        .format("jdbc")
        .option("url", ConfigManager.getMySQLUrl)
        .option("dbtable", table)
        .option("user", ConfigManager.getMySQLUser)
        .option("password", ConfigManager.getMySQLPassword)
        .option("driver", ConfigManager.getMySQLDriver)
        .option("batchsize", "1000")
        .option("isolationLevel", "READ_COMMITTED")
        .mode(saveMode)
        .save()

      logger.info(s"数据写入MySQL成功: $table")
    } catch {
      case e: Exception =>
        logger.error(s"写入MySQL失败: ${e.getMessage}", e)
        throw new RuntimeException(s"MySQL写入失败: ${e.getMessage}", e)
    }
  }

  /**
   * 使用 JDBC 直连执行 SQL 查询(适用于小数据量查询)
   *
   * @param sql    SQL 语句
   * @param handler 结果集处理函数
   * @tparam T 返回类型
   * @return 查询结果
   */
  def executeQuery[T](sql: String)(handler: ResultSet => T): Option[T] = {
    var connection: Connection = null
    var stmt: PreparedStatement = null
    try {
      connection = DriverManager.getConnection(
        ConfigManager.getMySQLUrl,
        ConfigManager.getMySQLUser,
        ConfigManager.getMySQLPassword
      )
      stmt = connection.prepareStatement(sql)
      val rs = stmt.executeQuery()
      Some(handler(rs))
    } catch {
      case e: Exception =>
        logger.error(s"执行SQL查询失败: ${e.getMessage}", e)
        None
    } finally {
      if (stmt != null) Try(stmt.close())
      if (connection != null) Try(connection.close())
    }
  }

  /**
   * 使用 JDBC 直连执行 SQL 更新
   *
   * @param sql SQL 语句
   * @return 受影响的行数
   */
  def executeUpdate(sql: String): Int = {
    var connection: Connection = null
    var stmt: PreparedStatement = null
    try {
      connection = DriverManager.getConnection(
        ConfigManager.getMySQLUrl,
        ConfigManager.getMySQLUser,
        ConfigManager.getMySQLPassword
      )
      stmt = connection.prepareStatement(sql)
      stmt.executeUpdate()
    } catch {
      case e: Exception =>
        logger.error(s"执行SQL更新失败: ${e.getMessage}", e)
        -1
    } finally {
      if (stmt != null) Try(stmt.close())
      if (connection != null) Try(connection.close())
    }
  }

  // ==================== Kafka 操作 ====================

  /**
   * 从 Kafka 读取流数据
   *
   * @param spark  SparkSession 实例
   * @param topic  Kafka 主题
   * @return DataFrame
   */
  def readFromKafka(spark: SparkSession, topic: String): DataFrame = {
    require(topic.nonEmpty, "Kafka主题不能为空")

    logger.info(s"从Kafka读取流数据: $topic")

    try {
      spark.readStream
        .format("kafka")
        .option("kafka.bootstrap.servers", ConfigManager.getKafkaBootstrapServers)
        .option("subscribe", topic)
        .option("startingOffsets", ConfigManager.getKafkaAutoOffsetReset)
        .option("maxOffsetsPerTrigger", ConfigManager.getKafkaMaxPollRecords.toString)
        .option("failOnDataLoss", "false")
        .load()
    } catch {
      case e: Exception =>
        logger.error(s"从Kafka读取数据失败: ${e.getMessage}", e)
        throw new RuntimeException(s"Kafka读取失败: ${e.getMessage}", e)
    }
  }

  /**
   * 从 Kafka 批量读取数据
   *
   * @param spark    SparkSession 实例
   * @param topic    Kafka 主题
   * @param startingOffsets 起始偏移量(默认 earliest)
   * @param endingOffsets   结束偏移量(默认 latest)
   * @return DataFrame
   */
  def readBatchFromKafka(spark: SparkSession, topic: String,
                         startingOffsets: String = "earliest",
                         endingOffsets: String = "latest"): DataFrame = {
    require(topic.nonEmpty, "Kafka主题不能为空")

    logger.info(s"从Kafka批量读取数据: $topic")

    try {
      spark.read
        .format("kafka")
        .option("kafka.bootstrap.servers", ConfigManager.getKafkaBootstrapServers)
        .option("subscribe", topic)
        .option("startingOffsets", startingOffsets)
        .option("endingOffsets", endingOffsets)
        .load()
    } catch {
      case e: Exception =>
        logger.error(s"从Kafka批量读取数据失败: ${e.getMessage}", e)
        throw new RuntimeException(s"Kafka批量读取失败: ${e.getMessage}", e)
    }
  }

  // ==================== HBase 操作 ====================

  /**
   * 获取 HBase 连接配置
   */
  def getHBaseConfig: org.apache.hadoop.conf.Configuration = {
    val conf = org.apache.hadoop.hbase.HBaseConfiguration.create()
    conf.set("hbase.zookeeper.quorum", ConfigManager.getHBaseZkQuorum)
    conf.set("hbase.zookeeper.property.clientPort", ConfigManager.getHBaseZkClientPort)
    conf
  }

  // ==================== SparkSession 管理 ====================

  /**
   * 创建 SparkSession
   *
   * 使用 try-catch-finally 确保资源释放。
   * 所有 SparkSession 的创建都应通过此方法,以统一配置和资源管理。
   *
   * @param appName   应用名称
   * @param hiveSupport 是否启用Hive支持
   * @return SparkSession 实例
   */
  def createSparkSession(appName: String = ConfigManager.getSparkAppName,
                         hiveSupport: Boolean = false): SparkSession = {
    logger.info(s"创建SparkSession: $appName, Hive支持: $hiveSupport")

    try {
      val builder = SparkSession.builder()
        .appName(appName)
        .master(ConfigManager.getSparkMaster)

      // 应用 Spark 配置
      ConfigManager.getSparkConfig.foreach { case (key, value) =>
        builder.config(key, value)
      }

      if (hiveSupport) {
        builder.enableHiveSupport()
      }

      val spark = builder.getOrCreate()
      spark.sparkContext.setLogLevel("WARN")
      logger.info(s"SparkSession创建成功: $appName")
      spark
    } catch {
      case e: Exception =>
        logger.error(s"创建SparkSession失败: ${e.getMessage}", e)
        throw new RuntimeException(s"SparkSession创建失败: ${e.getMessage}", e)
    }
  }

  /**
   * 安全执行 Spark 作业
   *
   * 使用 try-catch-finally 确保 SparkSession 正确释放。
   * 这是执行 Spark 作业的推荐方式。
   *
   * @param appName   应用名称
   * @param hiveSupport 是否启用Hive支持
   * @param block     业务逻辑块
   * @tparam T 返回类型
   * @return 业务逻辑执行结果
   */
  def withSparkSession[T](appName: String = ConfigManager.getSparkAppName,
                           hiveSupport: Boolean = false)(block: SparkSession => T): T = {
    try {
      val spark = createSparkSession(appName, hiveSupport)
      block(spark)
    } catch {
      case e: Exception =>
        logger.error(s"Spark作业执行失败: ${e.getMessage}", e)
        throw new RuntimeException(s"Spark作业执行失败: ${e.getMessage}", e)
    } finally {
      val s = SparkSession.getActiveSession.orNull
      if (s != null) {
        try {
          s.stop()
          logger.info(s"SparkSession已释放: $appName")
        } catch {
          case e: Exception =>
            logger.warn(s"释放SparkSession时出错: ${e.getMessage}", e)
        }
      }
    }
  }
}

3. 画像服务 - UserProfileService.scala
#

package com.ecommerce.profile.service

import com.ecommerce.profile.config.ConfigManager
import org.apache.hadoop.hbase.{HBaseConfiguration, TableName}
import org.apache.hadoop.hbase.client.{Connection, ConnectionFactory, Get}
import org.apache.hadoop.hbase.util.Bytes
import org.slf4j.{Logger, LoggerFactory}
import redis.clients.jedis.{Jedis, JedisPool, JedisPoolConfig}

import java.util
import scala.jdk.CollectionConverters._
import scala.util.{Try, Using}

/**
 * 用户画像服务
 *
 * 提供用户画像的查询、缓存和降级功能。
 *
 * 核心特性:
 * - Redis 连接池管理(JedisPool)
 * - 多级缓存策略(Redis -> HBase -> MySQL)
 * - 降级方案(Redis不可用时自动降级到HBase)
 * - 数据验证和异常处理
 *
 * 缓存策略:
 * 1. 首先查询 Redis 热数据缓存
 * 2. Redis 未命中时,查询 HBase 标签数据
 * 3. HBase 未命中时,查询 MySQL 结构化数据
 * 4. 查询结果回填到 Redis 缓存
 *
 * 降级方案:
 * - Redis 不可用 -> 直接查询 HBase
 * - HBase 不可用 -> 查询 MySQL(功能受限)
 * - 全部不可用 -> 返回默认画像数据
 */
object UserProfileService {

  private val logger: Logger = LoggerFactory.getLogger(UserProfileService.getClass)

  // Redis 连接池(懒加载,线程安全)
  private lazy val jedisPool: JedisPool = {
    val poolConfig = new JedisPoolConfig()
    poolConfig.setMaxTotal(ConfigManager.getRedisMaxTotal)
    poolConfig.setMaxIdle(ConfigManager.getRedisMaxIdle)
    poolConfig.setMinIdle(ConfigManager.getRedisMinIdle)
    poolConfig.setMaxWaitMillis(ConfigManager.getRedisMaxWaitMillis)
    poolConfig.setTestOnBorrow(true)
    poolConfig.setTestOnReturn(false)
    poolConfig.setTestWhileIdle(true)

    val password = ConfigManager.getRedisPassword
    if (password.nonEmpty) {
      new JedisPool(poolConfig, ConfigManager.getRedisHost, ConfigManager.getRedisPort,
        ConfigManager.getRedisTimeout, password)
    } else {
      new JedisPool(poolConfig, ConfigManager.getRedisHost, ConfigManager.getRedisPort,
        ConfigManager.getRedisTimeout)
    }
  }

  // HBase 连接(懒加载)
  @volatile private var hbaseConnection: Connection = _

  private def getHBaseConnection: Connection = {
    if (hbaseConnection == null || hbaseConnection.isClosed) {
      synchronized {
        if (hbaseConnection == null || hbaseConnection.isClosed) {
          val conf = HBaseConfiguration.create()
          conf.set("hbase.zookeeper.quorum", ConfigManager.getHBaseZkQuorum)
          conf.set("hbase.zookeeper.property.clientPort", ConfigManager.getHBaseZkClientPort)
          hbaseConnection = ConnectionFactory.createConnection(conf)
          logger.info("HBase连接已创建")
        }
      }
    }
    hbaseConnection
  }

  // ==================== Redis 操作 ====================

  /**
   * 从 Redis 获取资源
   *
   * 使用 JedisPool 连接池管理 Redis 连接,确保连接正确归还。
   *
   * @param block 使用 Jedis 执行的操作
   * @tparam T 返回类型
   * @return 操作结果,Redis不可用时返回None
   */
  private def withRedis[T](block: Jedis => T): Option[T] = {
    var jedis: Jedis = null
    try {
      jedis = jedisPool.getResource
      Some(block(jedis))
    } catch {
      case e: Exception =>
        logger.warn(s"Redis操作失败,将降级处理: ${e.getMessage}")
        None
    } finally {
      if (jedis != null) {
        try {
          jedis.close()
        } catch {
          case e: Exception =>
            logger.warn(s"归还Redis连接失败: ${e.getMessage}")
        }
      }
    }
  }

  /**
   * 从 Redis 缓存获取用户画像
   *
   * @param userId 用户ID
   * @return 画像JSON字符串,未命中返回None
   */
  def getProfileFromCache(userId: Int): Option[String] = {
    val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
    withRedis { jedis =>
      val value = jedis.get(key)
      if (value != null) {
        logger.debug(s"Redis缓存命中: userId=$userId")
        value
      } else {
        logger.debug(s"Redis缓存未命中: userId=$userId")
        null
      }
    }.flatMap(Option(_))
  }

  /**
   * 将用户画像写入 Redis 缓存
   *
   * @param userId    用户ID
   * @param profileJson 画像JSON字符串
   */
  def setProfileToCache(userId: Int, profileJson: String): Unit = {
    val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
    withRedis { jedis =>
      jedis.setex(key, ConfigManager.getRedisCacheTTL, profileJson)
      logger.debug(s"用户画像已写入Redis缓存: userId=$userId, TTL=${ConfigManager.getRedisCacheTTL}s")
    }
  }

  /**
   * 批量将用户画像写入 Redis 缓存
   *
   * 使用 Pipeline 提升批量写入性能。
   *
   * @param profiles 用户ID到画像JSON的映射
   */
  def batchSetProfileToCache(profiles: Map[Int, String]): Unit = {
    if (profiles.isEmpty) return

    withRedis { jedis =>
      val pipeline = jedis.pipelined()
      profiles.foreach { case (userId, profileJson) =>
        val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
        pipeline.setex(key, ConfigManager.getRedisCacheTTL, profileJson)
      }
      pipeline.sync()
      logger.info(s"批量写入Redis缓存完成: ${profiles.size}条记录")
    }
  }

  /**
   * 从 Redis 缓存删除用户画像
   *
   * @param userId 用户ID
   */
  def removeProfileFromCache(userId: Int): Unit = {
    val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
    withRedis { jedis =>
      jedis.del(key)
      logger.debug(s"用户画像已从Redis缓存删除: userId=$userId")
    }
  }

  // ==================== HBase 操作 ====================

  /**
   * 从 HBase 获取用户标签数据
   *
   * @param userId 用户ID
   * @return 标签数据Map,查询失败返回None
   */
  def getTagsFromHBase(userId: Int): Option[Map[String, String]] = {
    try {
      val connection = getHBaseConnection
      val table = connection.getTable(TableName.valueOf(ConfigManager.getHBaseUserTagsTable))
      try {
        val get = new Get(Bytes.toBytes(userId.toString))
        val result = table.get(get)

        if (result.isEmpty) {
          logger.debug(s"HBase中未找到用户标签: userId=$userId")
          None
        } else {
          val tags = result.getNoVersionMap.asScala.flatMap { case (cf, columns) =>
            columns.asScala.map { case (qualifier, value) =>
              Bytes.toString(qualifier) -> Bytes.toString(value)
            }
          }.toMap
          logger.debug(s"HBase标签查询成功: userId=$userId, 标签数=${tags.size}")
          Some(tags)
        }
      } finally {
        table.close()
      }
    } catch {
      case e: Exception =>
        logger.warn(s"HBase查询失败,将降级处理: userId=$userId, error=${e.getMessage}")
        None
    }
  }

  // ==================== 多级查询(核心方法) ====================

  /**
   * 获取用户画像(多级缓存查询)
   *
   * 查询顺序:Redis -> HBase -> MySQL
   * 每级查询成功后,会回填到上一级缓存。
   *
   * @param userId 用户ID
   * @return 用户画像JSON字符串
   */
  def getUserProfile(userId: Int): Option[String] = {
    require(userId > 0, s"用户ID必须大于0,当前值: $userId")

    logger.info(s"查询用户画像: userId=$userId")

    // 第一级:查询 Redis 缓存
    val redisResult = getProfileFromCache(userId)
    if (redisResult.isDefined) {
      logger.info(s"用户画像查询成功(Redis缓存): userId=$userId")
      return redisResult
    }

    // 第二级:查询 HBase
    val hbaseResult = getTagsFromHBase(userId)
    if (hbaseResult.isDefined) {
      val profileJson = hbaseResult.get.map { case (k, v) => s""""$k":"$v"""" }.mkString("{", ",", "}")
      // 回填 Redis 缓存
      setProfileToCache(userId, profileJson)
      logger.info(s"用户画像查询成功(HBase,已回填Redis): userId=$userId")
      return Some(profileJson)
    }

    // 第三级:查询 MySQL(降级方案)
    if (ConfigManager.isDegradationEnabled) {
      val mysqlResult = getProfileFromMySQL(userId)
      if (mysqlResult.isDefined) {
        // 回填 Redis 和 HBase 缓存
        setProfileToCache(userId, mysqlResult.get)
        logger.info(s"用户画像查询成功(MySQL降级,已回填缓存): userId=$userId")
        return mysqlResult
      }
    }

    logger.warn(s"用户画像未找到: userId=$userId")
    None
  }

  /**
   * 批量获取用户画像
   *
   * @param userIds 用户ID列表
   * @return 用户ID到画像的映射
   */
  def batchGetUserProfile(userIds: Seq[Int]): Map[Int, String] = {
    require(userIds.nonEmpty, "用户ID列表不能为空")
    require(userIds.size <= ConfigManager.getBatchMaxSize,
      s"批量查询数量不能超过${ConfigManager.getBatchMaxSize},当前: ${userIds.size}")

    logger.info(s"批量查询用户画像: count=${userIds.size}")

    val result = scala.collection.mutable.Map[Int, String]()

    // 先从 Redis 批量查询
    val missedIds = scala.collection.mutable.ArrayBuffer[Int]()
    withRedis { jedis =>
      val pipeline = jedis.pipelined()
      val keys = userIds.map(id => s"${ConfigManager.getRedisKeyPrefix}$id")
      keys.foreach(key => pipeline.get(key))
      val responses = pipeline.syncAndReturnAll().asScala.zipWithIndex

      responses.foreach { case (response, index) =>
        val value = response.asInstanceOf[String]
        if (value != null) {
          result(userIds(index)) = value
        } else {
          missedIds += userIds(index)
        }
      }
    }

    // Redis 未命中的从 HBase 查询
    if (missedIds.nonEmpty) {
      val hbaseProfiles = scala.collection.mutable.Map[Int, String]()
      missedIds.foreach { userId =>
        val hbaseResult = getTagsFromHBase(userId)
        if (hbaseResult.isDefined) {
          val profileJson = hbaseResult.get.map { case (k, v) => s""""$k":"$v"""" }.mkString("{", ",", "}")
          hbaseProfiles(userId) = profileJson
          result(userId) = profileJson
        }
      }

      // 回填 Redis 缓存
      if (hbaseProfiles.nonEmpty) {
        batchSetProfileToCache(hbaseProfiles.toMap)
      }
    }

    logger.info(s"批量查询完成: 总查询${userIds.size}条, Redis命中${userIds.size - missedIds.size}条, HBase命中${result.size - (userIds.size - missedIds.size)}条")
    result.toMap
  }

  /**
   * 从 MySQL 查询用户画像(降级方案)
   *
   * @param userId 用户ID
   * @return 画像JSON字符串
   */
  private def getProfileFromMySQL(userId: Int): Option[String] = {
    import com.ecommerce.profile.dal.DataAccessLayer._

    executeQuery(
      s"SELECT user_id, age_group, gender, purchase_power, shopping_frequency, " +
        s"rfm_score, user_segment FROM user_profiles WHERE user_id = $userId"
    ) { rs =>
      if (rs.next()) {
        val profile = Map(
          "user_id" -> rs.getInt("user_id").toString,
          "age_group" -> Option(rs.getString("age_group")).getOrElse("未知"),
          "gender" -> Option(rs.getString("gender")).getOrElse("未知"),
          "purchase_power" -> Option(rs.getString("purchase_power")).getOrElse("未知"),
          "shopping_frequency" -> Option(rs.getString("shopping_frequency")).getOrElse("未知"),
          "rfm_score" -> Option(rs.getString("rfm_score")).getOrElse("000"),
          "user_segment" -> Option(rs.getString("user_segment")).getOrElse("未分类")
        )
        profile.map { case (k, v) => s""""$k":"$v"""" }.mkString("{", ",", "}")
      } else {
        null
      }
    }.flatMap(Option(_))
  }

  // ==================== 资源管理 ====================

  /**
   * 关闭所有连接资源
   *
   * 在应用关闭时调用,确保所有连接正确释放。
   */
  def shutdown(): Unit = {
    logger.info("正在关闭用户画像服务资源...")

    // 关闭 Redis 连接池
    try {
      if (jedisPool != null && !jedisPool.isClosed) {
        jedisPool.close()
        logger.info("Redis连接池已关闭")
      }
    } catch {
      case e: Exception =>
        logger.warn(s"关闭Redis连接池时出错: ${e.getMessage}")
    }

    // 关闭 HBase 连接
    try {
      if (hbaseConnection != null && !hbaseConnection.isClosed) {
        hbaseConnection.close()
        logger.info("HBase连接已关闭")
      }
    } catch {
      case e: Exception =>
        logger.warn(s"关闭HBase连接时出错: ${e.getMessage}")
    }

    logger.info("用户画像服务资源关闭完成")
  }

  // 注册 JVM 关闭钩子,确保资源释放
  sys.addShutdownHook {
    shutdown()
  }
}

4. 数据采集模块 - UserBehaviorProducer.scala
#

package com.ecommerce.profile.producer

import com.ecommerce.profile.config.ConfigManager
import org.apache.kafka.clients.producer.{KafkaProducer, ProducerConfig, ProducerRecord}
import org.slf4j.{Logger, LoggerFactory}

import java.util.Properties
import scala.util.Random
import scala.util.control.NonFatal

/**
 * 用户行为数据采集器
 *
 * 通过 Kafka 采集用户行为数据,包括浏览、点击、加购、收藏、购买等行为。
 *
 * 改进点:
 * - 所有配置通过 ConfigManager 获取,不硬编码
 * - 使用 try-catch-finally 确保 Kafka Producer 正确关闭
 * - 添加数据验证逻辑
 * - 支持优雅关闭(ShutdownHook)
 */
object UserBehaviorProducer {

  private val logger: Logger = LoggerFactory.getLogger(UserBehaviorProducer.getClass)

  @volatile private var running = true

  def main(args: Array[String]): Unit = {
    logger.info("用户行为数据采集器启动中...")

    // 注册优雅关闭钩子
    sys.addShutdownHook {
      logger.info("接收到关闭信号,正在优雅停止...")
      running = false
    }

    val props = new Properties()
    props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, ConfigManager.getKafkaBootstrapServers)
    props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer")
    props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer")
    props.put(ProducerConfig.ACKS_CONFIG, ConfigManager.getConfig.getString("kafka.producer.acks"))
    props.put(ProducerConfig.RETRIES_CONFIG, ConfigManager.getConfig.getInt("kafka.producer.retries").asInstanceOf[Integer])
    props.put(ProducerConfig.BATCH_SIZE_CONFIG, ConfigManager.getConfig.getInt("kafka.producer.batch-size").asInstanceOf[Integer])
    props.put(ProducerConfig.LINGER_MS_CONFIG, ConfigManager.getConfig.getInt("kafka.producer.linger-ms").asInstanceOf[Integer])

    var producer: KafkaProducer[String, String] = null

    try {
      producer = new KafkaProducer[String, String](props)
      val random = new Random()

      val behaviorTypes = Array("view", "click", "add_to_cart", "favorite", "purchase")
      val categories = Array("电子产品", "服装", "食品", "图书", "家居", "美妆", "运动", "母婴")
      val devices = Array("PC", "Mobile", "Tablet")
      val topic = ConfigManager.getKafkaUserBehaviorTopic

      var sendCount = 0L

      logger.info(s"开始发送用户行为数据到Kafka主题: $topic")

      while (running) {
        try {
          val behaviorId = System.currentTimeMillis()
          val userId = 1000 + random.nextInt(9000)
          val productId = 100 + random.nextInt(900)
          val behaviorType = behaviorTypes(random.nextInt(behaviorTypes.length))
          val behaviorTime = System.currentTimeMillis()
          val sessionId = java.util.UUID.randomUUID().toString
          val ipAddress = s"192.${168 + random.nextInt(2)}.${random.nextInt(256)}.${random.nextInt(256)}"
          val deviceType = devices(random.nextInt(devices.length))
          val category = categories(random.nextInt(categories.length))

          // 数据验证
          require(userId > 0, s"用户ID无效: $userId")
          require(productId > 0, s"商品ID无效: $productId")
          require(behaviorTypes.contains(behaviorType), s"行为类型无效: $behaviorType")

          val behaviorData =
            s"""{"behavior_id":$behaviorId,"user_id":$userId,"product_id":$productId,""" +
              s""""behavior_type":"$behaviorType","behavior_time":$behaviorTime,""" +
              s""""session_id":"$sessionId","ip_address":"$ipAddress",""" +
              s""""device_type":"$deviceType","category":"$category"}"""

          val record = new ProducerRecord[String, String](topic, userId.toString, behaviorData)
          producer.send(record)

          sendCount += 1
          if (sendCount % 100 == 0) {
            logger.info(s"已发送 $sendCount 条用户行为数据")
          }

          Thread.sleep(500)
        } catch {
          case NonFatal(e) =>
            logger.error(s"发送数据时出错: ${e.getMessage}", e)
            Thread.sleep(1000) // 出错后等待1秒再重试
        }
      }

      logger.info(s"数据采集器正常停止,共发送 $sendCount 条数据")
    } catch {
      case NonFatal(e) =>
        logger.error(s"数据采集器异常: ${e.getMessage}", e)
    } finally {
      if (producer != null) {
        try {
          producer.close()
          logger.info("Kafka Producer 已关闭")
        } catch {
          case NonFatal(e) =>
            logger.warn(s"关闭Kafka Producer时出错: ${e.getMessage}")
        }
      }
    }
  }
}

5. HBase存储模块 - UserBehaviorToHBase.scala
#

package com.ecommerce.profile.storage

import com.ecommerce.profile.config.ConfigManager
import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.sql.functions._
import org.apache.spark.sql.streaming.OutputMode
import org.apache.spark.sql.types._
import org.apache.hadoop.hbase.{HBaseConfiguration, TableName}
import org.apache.hadoop.hbase.client.{ConnectionFactory, Put}
import org.apache.hadoop.hbase.util.Bytes
import org.slf4j.{Logger, LoggerFactory}

import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal

/**
 * 用户行为数据 HBase 存储
 *
 * 从 Kafka 实时读取用户行为数据,写入 HBase。
 *
 * 改进点:
 * - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
 * - 所有配置通过 ConfigManager 获取
 * - HBase 连接在 foreachBatch 中按批次创建和关闭
 * - 添加数据验证和异常处理
 */
object UserBehaviorToHBase {

  private val logger: Logger = LoggerFactory.getLogger(UserBehaviorToHBase.getClass)

  def main(args: Array[String]): Unit = {
    DataAccessLayer.withSparkSession("UserBehaviorToHBase") { spark =>
      import spark.implicits._

      // 从 Kafka 读取用户行为数据
      val behaviorStream = spark.readStream
        .format("kafka")
        .option("kafka.bootstrap.servers", ConfigManager.getKafkaBootstrapServers)
        .option("subscribe", ConfigManager.getKafkaUserBehaviorTopic)
        .option("startingOffsets", "latest")
        .option("failOnDataLoss", "false")
        .load()

      // 定义 Schema
      val schema = new StructType()
        .add("behavior_id", LongType)
        .add("user_id", IntegerType)
        .add("product_id", IntegerType)
        .add("behavior_type", StringType)
        .add("behavior_time", LongType)
        .add("session_id", StringType)
        .add("ip_address", StringType)
        .add("device_type", StringType)
        .add("category", StringType)

      // 解析 JSON 数据
      val behaviorData = behaviorStream
        .selectExpr("CAST(value AS STRING)")
        .select(from_json($"value", schema).as("data"))
        .select("data.*")
        .filter($"user_id".isNotNull && $"behavior_type".isNotNull) // 数据验证

      // 写入 HBase
      val query = behaviorData.writeStream
        .foreachBatch { (batchDF: org.apache.spark.sql.DataFrame, batchId: Long) =>
          logger.info(s"处理批次: $batchId, 记录数: ${batchDF.count()}")

          if (batchDF.count() > 0) {
            var connection: org.apache.hadoop.hbase.client.Connection = null
            var table: org.apache.hadoop.hbase.client.Table = null

            try {
              // 创建 HBase 连接
              val conf = HBaseConfiguration.create()
              conf.set("hbase.zookeeper.quorum", ConfigManager.getHBaseZkQuorum)
              conf.set("hbase.zookeeper.property.clientPort", ConfigManager.getHBaseZkClientPort)

              connection = ConnectionFactory.createConnection(conf)
              table = connection.getTable(TableName.valueOf(ConfigManager.getHBaseUserBehaviorTable))

              batchDF.collect().foreach { row =>
                try {
                  // 数据验证
                  val userId = row.getInt(1)
                  val behaviorType = row.getString(3)
                  if (userId > 0 && behaviorType != null && behaviorType.nonEmpty) {
                    val rowKey = s"${row.getLong(4)}-${row.getLong(0)}"
                    val put = new Put(Bytes.toBytes(rowKey))

                    put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("user_id"), Bytes.toBytes(userId))
                    put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("product_id"), Bytes.toBytes(row.getInt(2)))
                    put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("behavior_type"), Bytes.toBytes(behaviorType))
                    put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("session_id"), Bytes.toBytes(row.getString(5)))
                    put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("ip_address"), Bytes.toBytes(row.getString(6)))
                    put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("device_type"), Bytes.toBytes(row.getString(7)))
                    put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("category"), Bytes.toBytes(row.getString(8)))

                    table.put(put)
                  }
                } catch {
                  case NonFatal(e) =>
                    logger.warn(s"写入HBase单条记录失败: ${e.getMessage}")
                }
              }

              logger.info(s"批次 $batchId 写入HBase完成")
            } catch {
              case NonFatal(e) =>
                logger.error(s"批次 $batchId 写入HBase失败: ${e.getMessage}", e)
            } finally {
              // 确保资源释放
              if (table != null) {
                try table.close()
                catch { case NonFatal(e) => logger.warn(s"关闭HBase Table失败: ${e.getMessage}") }
              }
              if (connection != null) {
                try connection.close()
                catch { case NonFatal(e) => logger.warn(s"关闭HBase Connection失败: ${e.getMessage}") }
              }
            }
          }
        }
        .outputMode(OutputMode.Update())
        .start()

      query.awaitTermination()
    }
  }
}

6. MySQL存储模块 - UserInfoToMySQL.scala
#

package com.ecommerce.profile.storage

import com.ecommerce.profile.dal.DataAccessLayer
import org.slf4j.{Logger, LoggerFactory}

/**
 * 用户基础信息 MySQL 存储
 *
 * 改进点:
 * - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
 * - 所有数据库配置通过 ConfigManager 获取,不硬编码密码
 * - 添加数据验证
 */
object UserInfoToMySQL {

  private val logger: Logger = LoggerFactory.getLogger(UserInfoToMySQL.getClass)

  def main(args: Array[String]): Unit = {
    DataAccessLayer.withSparkSession("UserInfoToMySQL") { spark =>
      import spark.implicits._

      // 生成模拟用户数据
      val userData = Seq(
        (1, "user1", "男", 25, "user1@example.com", "13800138001", "2023-01-01", "2023-12-01", 2),
        (2, "user2", "女", 30, "user2@example.com", "13800138002", "2023-01-02", "2023-12-02", 3),
        (3, "user3", "男", 28, "user3@example.com", "13800138003", "2023-01-03", "2023-12-03", 2),
        (4, "user4", "女", 35, "user4@example.com", "13800138004", "2023-01-04", "2023-12-04", 4),
        (5, "user5", "男", 22, "user5@example.com", "13800138005", "2023-01-05", "2023-12-05", 1),
        (6, "user6", "女", 45, "user6@example.com", "13800138006", "2023-02-01", "2023-12-06", 5),
        (7, "user7", "男", 19, "user7@example.com", "13800138007", "2023-03-01", "2023-12-07", 1),
        (8, "user8", "女", 33, "user8@example.com", "13800138008", "2023-04-01", "2023-12-08", 3),
        (9, "user9", "男", 27, "user9@example.com", "13800138009", "2023-05-01", "2023-12-09", 2),
        (10, "user10", "女", 40, "user10@example.com", "13800138010", "2023-06-01", "2023-12-10", 4)
      )

      val userDF = spark.createDataFrame(userData)
        .toDF("user_id", "username", "gender", "age", "email", "phone",
          "registration_date", "last_login_date", "user_level")

      // 数据验证:过滤无效数据
      val validUserDF = userDF
        .filter($"user_id" > 0)
        .filter($"age" > 0 && $"age" < 150)
        .filter($"username".isNotNull && $"username" =!= "")

      logger.info(s"有效用户数据: ${validUserDF.count()} 条")

      // 写入 MySQL
      DataAccessLayer.writeToMySQL(validUserDF, "users", "overwrite")

      logger.info("用户数据已写入MySQL")
    }
  }
}

7. 基础画像构建 - UserProfileBuilder.scala
#

package com.ecommerce.profile.builder

import com.ecommerce.profile.dal.DataAccessLayer
import com.ecommerce.profile.service.UserProfileService
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.slf4j.{Logger, LoggerFactory}

/**
 * 基础用户画像构建器
 *
 * 从用户基础信息和行为数据中构建用户画像,包括:
 * - 年龄分组
 * - 兴趣分类
 * - 购物频率
 * - 平均订单价值
 * - 偏好品牌
 * - 用户标签
 *
 * 改进点:
 * - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
 * - 所有数据库配置通过 ConfigManager 获取
 * - 构建完成后自动更新 Redis 缓存
 * - 添加数据验证和异常处理
 */
object UserProfileBuilder {

  private val logger: Logger = LoggerFactory.getLogger(UserProfileBuilder.getClass)

  def main(args: Array[String]): Unit = {
    DataAccessLayer.withSparkSession("UserProfileBuilder", hiveSupport = true) { spark =>
      import spark.implicits._

      try {
        // 1. 读取用户基础信息
        val userDF = DataAccessLayer.readFromMySQL(spark, "users")
        logger.info(s"读取用户基础信息: ${userDF.count()} 条")

        // 2. 读取用户行为数据
        val behaviorDF = DataAccessLayer.readFromMySQL(spark, "user_behavior")
        logger.info(s"读取用户行为数据: ${behaviorDF.count()} 条")

        // 3. 构建用户画像

        // 3.1 年龄分组
        val ageGroupDF = userDF
          .withColumn("age_group",
            when($"age" < 18, "未成年")
              .when($"age" >= 18 && $"age" < 30, "青年")
              .when($"age" >= 30 && $"age" < 50, "中年")
              .otherwise("老年")
          )

        // 3.2 兴趣分类
        val interestDF = behaviorDF
          .filter($"category".isNotNull)
          .groupBy($"user_id", $"category")
          .agg(count("*").as("count"))
          .orderBy($"user_id", $"count".desc)
          .groupBy($"user_id")
          .agg(collect_list(struct($"category", $"count")).as("interests"))

        // 3.3 购物频率
        val frequencyDF = behaviorDF
          .filter($"behavior_type" === "purchase")
          .groupBy($"user_id")
          .agg(
            count("*").as("purchase_count"),
            datediff(current_date(), min($"behavior_time")).as("days_since_first_purchase")
          )
          .withColumn("shopping_frequency",
            when($"days_since_first_purchase" === 0, "高")
              .otherwise(
                when($"purchase_count" / $"days_since_first_purchase" >= 0.5, "高")
                  .when($"purchase_count" / $"days_since_first_purchase" >= 0.1, "中")
                  .otherwise("低")
              )
          )

        // 3.4 平均订单价值
        val orderValueDF = try {
          spark.sql(
            """
              |SELECT user_id, AVG(order_amount) as average_order_value
              |FROM orders
              |GROUP BY user_id
              |""".stripMargin)
        } catch {
          case e: Exception =>
            logger.warn(s"从Hive查询订单数据失败,使用默认值: ${e.getMessage}")
            spark.emptyDataFrame
        }

        // 3.5 偏好品牌
        val brandDF = try {
          spark.sql(
            """
              |SELECT user_id, product_brand, COUNT(*) as count
              |FROM order_items oi
              |JOIN products p ON oi.product_id = p.product_id
              |GROUP BY user_id, product_brand
              |ORDER BY user_id, count DESC
              |""".stripMargin)
        } catch {
          case e: Exception =>
            logger.warn(s"从Hive查询品牌数据失败: ${e.getMessage}")
            spark.emptyDataFrame
        }

        val favoriteBrandsDF = if (!brandDF.isEmpty) {
          brandDF
            .groupBy($"user_id")
            .agg(collect_list($"product_brand").as("favorite_brands"))
        } else {
          spark.emptyDataFrame
        }

        // 3.6 购买力评估
        val purchasePowerDF = frequencyDF
          .withColumn("purchase_power",
            when($"purchase_count" >= 20, "高")
              .when($"purchase_count" >= 5, "中")
              .otherwise("低")
          )

        // 3.7 合并所有特征
        var userProfile = ageGroupDF
          .join(interestDF, Seq("user_id"), "left")
          .join(purchasePowerDF.select($"user_id", $"shopping_frequency", $"purchase_power"), Seq("user_id"), "left")

        if (!orderValueDF.isEmpty) {
          userProfile = userProfile.join(orderValueDF, Seq("user_id"), "left")
        }
        if (!favoriteBrandsDF.isEmpty) {
          userProfile = userProfile.join(favoriteBrandsDF, Seq("user_id"), "left")
        }

        // 4. 生成用户标签
        val userProfileWithTags = userProfile
          .withColumn("user_tags", array(
            when($"age_group" === "青年", "青年用户"),
            when($"gender" === "男", "男性用户").otherwise("女性用户"),
            when($"shopping_frequency" === "高", "高频用户"),
            when($"purchase_power" === "高", "高购买力用户"),
            when($"average_order_value" > 1000, "高价值用户")
          ))
          .withColumn("user_tags", expr("filter(user_tags, x -> x is not null)"))

        // 5. 保存用户画像到 MySQL
        val finalProfile = userProfileWithTags.select(
          $"user_id",
          $"age_group",
          $"gender",
          $"interests".as("interest_categories"),
          $"purchase_power",
          $"shopping_frequency",
          $"favorite_brands",
          $"average_order_value",
          $"user_tags"
        )

        DataAccessLayer.writeToMySQL(finalProfile, "user_profiles", "overwrite")

        logger.info("用户画像构建完成")
        finalProfile.show(20, false)

        // 6. 更新 Redis 缓存
        try {
          val profileData = finalProfile.collect()
          val profileMap = profileData.map { row =>
            val userId = row.getInt(0)
            val profileJson =
              s"""{"user_id":$userId,"age_group":"${row.getString(1)}","gender":"${row.getString(2)}",""" +
                s""""purchase_power":"${row.getString(4)}","shopping_frequency":"${row.getString(5)}"}"""
            userId -> profileJson
          }.toMap

          UserProfileService.batchSetProfileToCache(profileMap)
          logger.info(s"用户画像已更新到Redis缓存: ${profileMap.size} 条")
        } catch {
          case e: Exception =>
            logger.warn(s"更新Redis缓存失败(不影响主流程): ${e.getMessage}")
        }

      } catch {
        case e: Exception =>
          logger.error(s"用户画像构建失败: ${e.getMessage}", e)
          throw e
      }
    }
  }
}

8. RFM分析 - RFMAnalyzer.scala
#

package com.ecommerce.profile.builder

import com.ecommerce.profile.config.ConfigManager
import com.ecommerce.profile.dal.DataAccessLayer
import com.ecommerce.profile.service.UserProfileService
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.slf4j.{Logger, LoggerFactory}

/**
 * RFM 分析器
 *
 * 基于 RFM 模型对用户进行价值分群。
 *
 * RFM 模型三个维度:
 * - R (Recency):最近消费时间距今天数,越小越好
 * - F (Frequency):统计周期内消费次数,越多越好
 * - M (Monetary):统计周期内消费总金额,越大越好
 *
 * 评分规则:
 * - 每个维度按阈值划分为 1-5 分
 * - 根据各维度评分与平均值的比较,将用户分为 8 种类型
 *
 * 改进点:
 * - RFM 阈值通过配置文件管理,方便调整
 * - 使用 DataAccessLayer 管理 SparkSession
 * - 评分结果写入 MySQL 和 Redis
 */
object RFMAnalyzer {

  private val logger: Logger = LoggerFactory.getLogger(RFMAnalyzer.getClass)

  /**
   * R 评分计算:最近消费时间越近,评分越高
   */
  private def calculateRScore(recencyDays: Int): Int = {
    val thresholds = ConfigManager.getRFMRThresholds
    if (recencyDays <= thresholds(0)) 5
    else if (recencyDays <= thresholds(1)) 4
    else if (recencyDays <= thresholds(2)) 3
    else if (recencyDays <= thresholds(3)) 2
    else 1
  }

  /**
   * F 评分计算:消费频率越高,评分越高
   */
  private def calculateFScore(frequency: Int): Int = {
    val thresholds = ConfigManager.getRFMFThresholds
    if (frequency > thresholds(3)) 5
    else if (frequency > thresholds(2)) 4
    else if (frequency > thresholds(1)) 3
    else if (frequency > thresholds(0)) 2
    else 1
  }

  /**
   * M 评分计算:消费金额越大,评分越高
   */
  private def calculateMScore(monetary: Double): Int = {
    val thresholds = ConfigManager.getRFMMThresholds
    if (monetary > thresholds(3)) 5
    else if (monetary > thresholds(2)) 4
    else if (monetary > thresholds(1)) 3
    else if (monetary > thresholds(0)) 2
    else 1
  }

  /**
   * 根据 RFM 评分确定用户分群
   *
   * 以各维度平均分为阈值,高于平均为"高",低于平均为"低"
   */
  private def determineUserSegment(rScore: Int, fScore: Int, mScore: Int,
                                    avgR: Double, avgF: Double, avgM: Double): String = {
    val rHigh = rScore >= avgR
    val fHigh = fScore >= avgF
    val mHigh = mScore >= avgM

    (rHigh, fHigh, mHigh) match {
      case (true, true, true)   => "重要价值用户"
      case (true, true, false)  => "重要保持用户"
      case (true, false, true)  => "重要发展用户"
      case (true, false, false) => "重要挽留用户"
      case (false, true, true)  => "重要唤回用户"
      case (false, true, false) => "一般维持用户"
      case (false, false, true) => "一般发展用户"
      case (false, false, false) => "流失预警用户"
    }
  }

  def main(args: Array[String]): Unit = {
    DataAccessLayer.withSparkSession("RFMAnalyzer", hiveSupport = true) { spark =>
      import spark.implicits._

      try {
        val periodDays = ConfigManager.getRFMPeriodDays
        logger.info(s"开始RFM分析,统计周期: $periodDays 天")

        // 1. 读取订单数据
        val orderDF = try {
          DataAccessLayer.readFromMySQL(spark, "orders")
        } catch {
          case e: Exception =>
            logger.warn(s"从MySQL读取订单数据失败,尝试从Hive读取: ${e.getMessage}")
            spark.sql(
              s"""
                 |SELECT user_id, order_amount, order_time
                 |FROM orders
                 |WHERE order_time >= date_sub(current_date(), $periodDays)
                 |""".stripMargin)
        }

        if (orderDF.isEmpty) {
          logger.warn("订单数据为空,无法进行RFM分析")
          return
        }

        // 2. 计算 RFM 原始值
        val rfmRawDF = orderDF
          .filter($"user_id".isNotNull && $"order_amount" > 0)
          .groupBy($"user_id")
          .agg(
            datediff(current_date(), max($"order_time")).as("recency"),
            count("*").as("frequency"),
            sum($"order_amount").as("monetary")
          )

        // 3. 计算 RFM 评分
        // 先注册评分 UDF
        spark.udf.register("r_score", (recency: Int) => calculateRScore(recency))
        spark.udf.register("f_score", (frequency: Int) => calculateFScore(frequency))
        spark.udf.register("m_score", (monetary: Double) => calculateMScore(monetary))

        val rfmScoredDF = rfmRawDF
          .withColumn("r_score", expr("r_score(recency)"))
          .withColumn("f_score", expr("f_score(frequency)"))
          .withColumn("m_score", expr("m_score(monetary)"))

        // 4. 计算各维度平均评分
        val avgScores = rfmScoredDF.agg(
          avg("r_score").as("avg_r"),
          avg("f_score").as("avg_f"),
          avg("m_score").as("avg_m")
        ).head()

        val avgR = avgScores.getDouble(0)
        val avgF = avgScores.getDouble(1)
        val avgM = avgScores.getDouble(2)

        logger.info(s"RFM平均评分 - R: $avgR, F: $avgF, M: $avgM")

        // 5. 用户分群
        spark.udf.register("determine_segment",
          (rScore: Int, fScore: Int, mScore: Int) =>
            determineUserSegment(rScore, fScore, mScore, avgR, avgF, avgM)
        )

        val rfmResultDF = rfmScoredDF
          .withColumn("rfm_score",
            concat($"r_score", lit(""), $"f_score", lit(""), $"m_score")
          )
          .withColumn("user_segment",
            expr("determine_segment(r_score, f_score, m_score)")
          )
          .select(
            $"user_id",
            $"recency",
            $"frequency",
            $"monetary",
            $"r_score",
            $"f_score",
            $"m_score",
            $"rfm_score",
            $"user_segment"
          )

        // 6. 输出分析结果
        logger.info("RFM分析结果:")
        rfmResultDF.show(20, false)

        // 分群统计
        val segmentStats = rfmResultDF
          .groupBy($"user_segment")
          .agg(
            count("*").as("user_count"),
            avg($"monetary").as("avg_monetary"),
            avg($"frequency").as("avg_frequency")
          )
          .orderBy($"user_count".desc)

        logger.info("用户分群统计:")
        segmentStats.show(false)

        // 7. 保存 RFM 分析结果
        DataAccessLayer.writeToMySQL(rfmResultDF, "user_rfm_analysis", "overwrite")

        // 8. 更新用户画像表中的 RFM 字段
        val updateDF = rfmResultDF.select($"user_id", $"rfm_score", $"user_segment")
        DataAccessLayer.writeToMySQL(updateDF, "user_segments", "overwrite")

        // 9. 更新 Redis 缓存
        try {
          val rfmData = rfmResultDF.select($"user_id", $"rfm_score", $"user_segment").collect()
          val rfmMap = rfmData.map { row =>
            val userId = row.getInt(0)
            val profileJson =
              s"""{"user_id":$userId,"rfm_score":"${row.getString(1)}","user_segment":"${row.getString(2)}"}"""
            userId -> profileJson
          }.toMap

          UserProfileService.batchSetProfileToCache(rfmMap)
          logger.info(s"RFM结果已更新到Redis缓存: ${rfmMap.size} 条")
        } catch {
          case e: Exception =>
            logger.warn(s"更新Redis缓存失败(不影响主流程): ${e.getMessage}")
        }

        logger.info("RFM分析完成")

      } catch {
        case e: Exception =>
          logger.error(s"RFM分析失败: ${e.getMessage}", e)
          throw e
      }
    }
  }
}

9. 用户分群 - UserSegmentation.scala
#

package com.ecommerce.profile.builder

import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.ml.clustering.KMeans
import org.apache.spark.ml.evaluation.ClusteringEvaluator
import org.apache.spark.ml.feature.{StandardScaler, VectorAssembler}
import org.apache.spark.sql.functions._
import org.slf4j.{Logger, LoggerFactory}

/**
 * 用户分群(基于机器学习)
 *
 * 使用 K-Means 聚类算法对用户进行分群,结合 RFM 分析结果,
 * 实现更精细的用户价值分层。
 *
 * 改进点:
 * - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
 * - 所有数据库配置通过 ConfigManager 获取
 * - 添加模型评估和参数调优
 */
object UserSegmentation {

  private val logger: Logger = LoggerFactory.getLogger(UserSegmentation.getClass)

  def main(args: Array[String]): Unit = {
    DataAccessLayer.withSparkSession("UserSegmentation") { spark =>
      import spark.implicits._

      try {
        // 读取用户画像数据
        val profileDF = DataAccessLayer.readFromMySQL(spark, "user_profiles")
        logger.info(s"读取用户画像数据: ${profileDF.count()} 条")

        if (profileDF.isEmpty) {
          logger.warn("用户画像数据为空,无法进行分群")
          return
        }

        // 准备特征
        val featureDF = profileDF
          .withColumn("age_group_num",
            when($"age_group" === "未成年", 0)
              .when($"age_group" === "青年", 1)
              .when($"age_group" === "中年", 2)
              .otherwise(3)
          )
          .withColumn("gender_num", when($"gender" === "男", 0).otherwise(1))
          .withColumn("frequency_num",
            when($"shopping_frequency" === "低", 0)
              .when($"shopping_frequency" === "中", 1)
              .otherwise(2)
          )
          .withColumn("value_num",
            when($"average_order_value" < 500, 0)
              .when($"average_order_value" < 1500, 1)
              .otherwise(2)
          )
          .na.fill(0, Seq("age_group_num", "gender_num", "frequency_num", "value_num"))

        // 特征向量化
        val assembler = new VectorAssembler()
          .setInputCols(Array("age_group_num", "gender_num", "frequency_num", "value_num"))
          .setOutputCol("features")

        val features = assembler.transform(featureDF)

        // 特征标准化
        val scaler = new StandardScaler()
          .setInputCol("features")
          .setOutputCol("scaledFeatures")
          .setWithStd(true)
          .setWithMean(false)

        val scaledFeatures = scaler.fit(features).transform(features)

        // 寻找最优 K 值
        val maxK = math.min(10, profileDF.count().toInt / 10).max(2)
        var bestK = 5
        var bestSilhouette = -1.0

        logger.info(s"开始寻找最优K值,范围: 2 到 $maxK")

        for (k <- 2 to maxK) {
          try {
            val kmeans = new KMeans()
              .setK(k)
              .setSeed(42L)
              .setFeaturesCol("scaledFeatures")
              .setPredictionCol("cluster")

            val model = kmeans.fit(scaledFeatures)
            val predictions = model.transform(scaledFeatures)
            val evaluator = new ClusteringEvaluator()
              .setFeaturesCol("scaledFeatures")
              .setPredictionCol("cluster")

            val silhouette = evaluator.evaluate(predictions)
            logger.info(s"K=$k, Silhouette=$silhouette")

            if (silhouette > bestSilhouette) {
              bestSilhouette = silhouette
              bestK = k
            }
          } catch {
            case e: Exception =>
              logger.warn(s"K=$k 聚类失败: ${e.getMessage}")
          }
        }

        logger.info(s"最优K值: $bestK, Silhouette: $bestSilhouette")

        // 使用最优 K 值进行聚类
        val bestKMeans = new KMeans()
          .setK(bestK)
          .setSeed(42L)
          .setFeaturesCol("scaledFeatures")
          .setPredictionCol("cluster")

        val bestModel = bestKMeans.fit(scaledFeatures)
        val predictions = bestModel.transform(scaledFeatures)

        // 评估模型
        val evaluator = new ClusteringEvaluator()
          .setFeaturesCol("scaledFeatures")
          .setPredictionCol("cluster")
        val silhouette = evaluator.evaluate(predictions)
        logger.info(s"最终模型 Silhouette score: $silhouette")

        // 分析聚类结果
        val clusterAnalysis = predictions
          .groupBy($"cluster")
          .agg(
            count("*").as("user_count"),
            avg($"age_group_num").as("avg_age_group"),
            avg($"gender_num").as("avg_gender"),
            avg($"frequency_num").as("avg_frequency"),
            avg($"value_num").as("avg_value")
          )

        logger.info("聚类分析结果:")
        clusterAnalysis.show(false)

        // 为用户添加聚类标签
        val userWithCluster = predictions
          .select(
            $"user_id",
            $"cluster",
            when($"cluster" === 0, "高价值高频用户")
              .when($"cluster" === 1, "普通价值高频用户")
              .when($"cluster" === 2, "高价值低频用户")
              .when($"cluster" === 3, "普通价值低频用户")
              .otherwise("低价值用户").as("user_segment")
          )

        // 保存聚类结果
        DataAccessLayer.writeToMySQL(userWithCluster, "user_segments_ml", "overwrite")

        logger.info("用户分群完成")
        userWithCluster.show(20, false)

      } catch {
        case e: Exception =>
          logger.error(s"用户分群失败: ${e.getMessage}", e)
          throw e
      }
    }
  }
}

10. 个性化推荐 - PersonalizedRecommendation.scala
#

package com.ecommerce.profile.recommendation

import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.ml.evaluation.RegressionEvaluator
import org.apache.spark.ml.recommendation.ALS
import org.apache.spark.sql.functions._
import org.slf4j.{Logger, LoggerFactory}

/**
 * 个性化推荐
 *
 * 基于 ALS 协同过滤算法,根据用户行为数据为用户推荐商品。
 *
 * 改进点:
 * - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
 * - 所有数据库配置通过 ConfigManager 获取
 * - 添加模型评估和冷启动策略
 */
object PersonalizedRecommendation {

  private val logger: Logger = LoggerFactory.getLogger(PersonalizedRecommendation.getClass)

  def main(args: Array[String]): Unit = {
    DataAccessLayer.withSparkSession("PersonalizedRecommendation") { spark =>
      import spark.implicits._

      try {
        // 读取用户行为数据
        val behaviorDF = DataAccessLayer.readFromMySQL(spark, "user_behavior")
        logger.info(s"读取用户行为数据: ${behaviorDF.count()} 条")

        if (behaviorDF.isEmpty) {
          logger.warn("用户行为数据为空,无法进行推荐")
          return
        }

        // 转换为评分数据
        val ratingDF = behaviorDF
          .withColumn("rating",
            when($"behavior_type" === "purchase", 5.0)
              .when($"behavior_type" === "add_to_cart", 4.0)
              .when($"behavior_type" === "favorite", 3.0)
              .when($"behavior_type" === "click", 2.0)
              .otherwise(1.0)
          )
          .select($"user_id", $"product_id", $"rating")
          .filter($"user_id".isNotNull && $"product_id".isNotNull)

        // 划分训练集和测试集
        val Array(training, test) = ratingDF.randomSplit(Array(0.8, 0.2), seed = 42L)

        // 构建 ALS 模型
        val als = new ALS()
          .setMaxIter(10)
          .setRegParam(0.01)
          .setUserCol("user_id")
          .setItemCol("product_id")
          .setRatingCol("rating")
          .setColdStartStrategy("drop")
          .setNonnegative(true)

        val model = als.fit(training)

        // 评估模型
        val predictions = model.transform(test)
        val evaluator = new RegressionEvaluator()
          .setMetricName("rmse")
          .setLabelCol("rating")
          .setPredictionCol("prediction")

        val rmse = evaluator.evaluate(predictions)
        logger.info(s"Root-mean-square error = $rmse")

        // 为所有用户推荐商品
        val userRecommendations = model.recommendForAllUsers(10)

        // 保存推荐结果
        DataAccessLayer.writeToMySQL(userRecommendations, "user_recommendations", "overwrite")

        logger.info("个性化推荐完成")
        userRecommendations.show(10, false)

      } catch {
        case e: Exception =>
          logger.error(s"个性化推荐失败: ${e.getMessage}", e)
          throw e
      }
    }
  }
}

11. 精准营销 - TargetedMarketing.scala
#

package com.ecommerce.profile.marketing

import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.sql.functions._
import org.slf4j.{Logger, LoggerFactory}

/**
 * 精准营销
 *
 * 根据用户画像和分群数据,筛选目标用户群体,支持:
 * - 高价值用户营销
 * - 流失风险用户挽回
 * - 特定兴趣用户定向推送
 *
 * 改进点:
 * - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
 * - 所有数据库配置通过 ConfigManager 获取
 * - 添加数据验证
 */
object TargetedMarketing {

  private val logger: Logger = LoggerFactory.getLogger(TargetedMarketing.getClass)

  def main(args: Array[String]): Unit = {
    DataAccessLayer.withSparkSession("TargetedMarketing") { spark =>
      import spark.implicits._

      try {
        // 读取用户画像和分群数据
        val profileDF = DataAccessLayer.readFromMySQL(spark, "user_profiles")
        val segmentDF = DataAccessLayer.readFromMySQL(spark, "user_segments")

        // 合并数据
        val userData = profileDF.join(segmentDF, Seq("user_id"), "left")
        logger.info(s"用户画像+分群数据: ${userData.count()} 条")

        // 1. 针对高价值用户的营销
        val highValueUsers = userData
          .filter($"user_segment" === "重要价值用户" || $"user_segment" === "重要发展用户")
          .select($"user_id", $"age_group", $"gender", $"shopping_frequency", $"user_segment")

        logger.info("高价值用户列表:")
        highValueUsers.show(false)

        // 2. 针对流失风险用户的营销
        val churnRiskUsers = userData
          .filter($"user_segment" === "流失预警用户" || $"user_segment" === "重要唤回用户")
          .select($"user_id", $"age_group", $"gender", $"shopping_frequency", $"user_segment")

        logger.info("流失风险用户列表:")
        churnRiskUsers.show(false)

        // 3. 针对特定兴趣用户的营销
        val categoryInterestUsers = userData
          .filter(array_contains($"interest_categories.category", "电子产品"))
          .select($"user_id", $"age_group", $"gender", $"shopping_frequency")

        logger.info("对电子产品感兴趣的用户列表:")
        categoryInterestUsers.show(false)

        // 4. 保存营销目标用户
        val marketingTargets = userData
          .withColumn("marketing_type",
            when($"user_segment" === "重要价值用户", "VIP维护")
              .when($"user_segment" === "重要发展用户", "价值提升")
              .when($"user_segment" === "流失预警用户", "流失挽回")
              .when($"user_segment" === "重要唤回用户", "召回激活")
              .otherwise("常规触达")
          )
          .select($"user_id", $"user_segment", $"marketing_type")

        DataAccessLayer.writeToMySQL(marketingTargets, "marketing_targets", "overwrite")

        logger.info("营销目标用户已保存")

        // 5. 输出营销策略建议
        val strategyStats = marketingTargets
          .groupBy($"marketing_type")
          .agg(count("*").as("target_count"))
          .orderBy($"target_count".desc)

        logger.info("营销策略统计:")
        strategyStats.show(false)

      } catch {
        case e: Exception =>
          logger.error(s"精准营销分析失败: ${e.getMessage}", e)
          throw e
      }
    }
  }
}

12. 数据库初始化脚本 - init_tables.sql
#

-- ============================================
-- 电商用户画像系统 - 数据库初始化脚本
-- MySQL 8.0+
-- ============================================

-- 创建数据库
CREATE DATABASE IF NOT EXISTS ecommerce
  DEFAULT CHARACTER SET utf8mb4
  DEFAULT COLLATE utf8mb4_unicode_ci;

USE ecommerce;

-- 1. 用户基础信息表
CREATE TABLE IF NOT EXISTS users (
  user_id INT PRIMARY KEY,
  username VARCHAR(50) NOT NULL,
  gender VARCHAR(10),
  age INT,
  email VARCHAR(100),
  phone VARCHAR(20),
  registration_date DATE,
  last_login_date DATE,
  user_level INT DEFAULT 1,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_gender (gender),
  INDEX idx_age (age),
  INDEX idx_user_level (user_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2. 用户行为数据表
CREATE TABLE IF NOT EXISTS user_behavior (
  behavior_id BIGINT PRIMARY KEY,
  user_id INT NOT NULL,
  product_id INT NOT NULL,
  behavior_type VARCHAR(20) NOT NULL,
  behavior_time TIMESTAMP NOT NULL,
  session_id VARCHAR(100),
  ip_address VARCHAR(50),
  device_type VARCHAR(50),
  category VARCHAR(50),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_user_id (user_id),
  INDEX idx_behavior_type (behavior_type),
  INDEX idx_behavior_time (behavior_time),
  INDEX idx_user_behavior (user_id, behavior_type),
  INDEX idx_user_time (user_id, behavior_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 3. 商品数据表
CREATE TABLE IF NOT EXISTS products (
  product_id INT PRIMARY KEY,
  product_name VARCHAR(100) NOT NULL,
  category_id INT,
  category_name VARCHAR(50),
  price DECIMAL(10,2),
  brand VARCHAR(50),
  stock INT DEFAULT 0,
  sales INT DEFAULT 0,
  rating DECIMAL(3,2) DEFAULT 0.00,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_category (category_id),
  INDEX idx_brand (brand),
  INDEX idx_price (price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 4. 用户画像表
CREATE TABLE IF NOT EXISTS user_profiles (
  user_id INT PRIMARY KEY,
  age_group VARCHAR(20),
  gender VARCHAR(10),
  interest_categories JSON,
  purchase_power VARCHAR(20),
  shopping_frequency VARCHAR(20),
  favorite_brands JSON,
  average_order_value DECIMAL(10,2),
  last_purchase_date DATE,
  user_tags JSON,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_age_group (age_group),
  INDEX idx_gender (gender),
  INDEX idx_purchase_power (purchase_power),
  INDEX idx_shopping_frequency (shopping_frequency)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 5. 用户RFM分析表
CREATE TABLE IF NOT EXISTS user_rfm_analysis (
  user_id INT PRIMARY KEY,
  recency INT COMMENT '最近消费距今天数',
  frequency INT COMMENT '消费频次',
  monetary DOUBLE COMMENT '消费总金额',
  r_score INT COMMENT 'R评分(1-5)',
  f_score INT COMMENT 'F评分(1-5)',
  m_score INT COMMENT 'M评分(1-5)',
  rfm_score VARCHAR(10) COMMENT 'RFM组合评分',
  user_segment VARCHAR(30) COMMENT '用户分群',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_rfm_score (rfm_score),
  INDEX idx_user_segment (user_segment),
  INDEX idx_r_score (r_score),
  INDEX idx_f_score (f_score),
  INDEX idx_m_score (m_score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 6. 用户分群表(机器学习分群结果)
CREATE TABLE IF NOT EXISTS user_segments (
  user_id INT PRIMARY KEY,
  rfm_score VARCHAR(10),
  user_segment VARCHAR(30),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_user_segment (user_segment)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 7. 用户分群表(ML分群结果)
CREATE TABLE IF NOT EXISTS user_segments_ml (
  user_id INT PRIMARY KEY,
  cluster INT,
  user_segment VARCHAR(30),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_cluster (cluster),
  INDEX idx_user_segment (user_segment)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 8. 用户推荐结果表
CREATE TABLE IF NOT EXISTS user_recommendations (
  user_id INT,
  recommendations ARRAY<STRUCT<product_id: INT, rating: FLOAT>>,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 9. 营销目标用户表
CREATE TABLE IF NOT EXISTS marketing_targets (
  user_id INT,
  user_segment VARCHAR(30),
  marketing_type VARCHAR(30),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (user_id, marketing_type),
  INDEX idx_marketing_type (marketing_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 10. 标签元数据表
CREATE TABLE IF NOT EXISTS tag_metadata (
  tag_id INT AUTO_INCREMENT PRIMARY KEY,
  tag_name VARCHAR(100) NOT NULL COMMENT '标签名称',
  tag_category VARCHAR(50) COMMENT '一级分类',
  tag_subcategory VARCHAR(50) COMMENT '二级分类',
  tag_type VARCHAR(20) COMMENT '标签类型:事实/统计/算法',
  tag_description VARCHAR(500) COMMENT '标签描述',
  is_active TINYINT DEFAULT 1 COMMENT '是否启用',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE INDEX idx_tag_name (tag_name),
  INDEX idx_tag_category (tag_category),
  INDEX idx_tag_type (tag_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

可视化模块
#

Grafana仪表板配置
#

配置Grafana连接MySQL数据源,创建以下仪表板:

  1. 用户画像概览仪表板

    • 用户年龄分布
    • 用户性别分布
    • 用户兴趣分类分布
    • 用户购物频率分布
    • 用户价值分布
  2. RFM分析仪表板

    • 各分群用户数量
    • RFM评分分布
    • 分群特征对比
    • 分群价值分析
  3. 营销效果仪表板

    • 营销活动转化率
    • 不同用户群的响应率
    • 营销ROI分析

数据可视化代码
#

# dashboard.py
import os
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import mysql.connector

# 从环境变量获取数据库连接信息
MYSQL_HOST = os.environ.get("MYSQL_HOST", "localhost")
MYSQL_USER = os.environ.get("MYSQL_USER", "ecommerce_user")
MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD", "")
MYSQL_DATABASE = os.environ.get("MYSQL_DATABASE", "ecommerce")

# 连接MySQL
conn = mysql.connector.connect(
    host=MYSQL_HOST,
    user=MYSQL_USER,
    password=MYSQL_PASSWORD,
    database=MYSQL_DATABASE
)

try:
    # 读取用户画像数据
    profile_df = pd.read_sql("SELECT * FROM user_profiles", conn)

    # 读取RFM分析数据
    rfm_df = pd.read_sql("SELECT * FROM user_rfm_analysis", conn)

    # 读取用户分群数据
    segment_df = pd.read_sql("SELECT * FROM user_segments", conn)

    # 设置中文字体
    plt.rcParams['font.sans-serif'] = ['SimHei']
    plt.rcParams['axes.unicode_minus'] = False

    # 1. 用户年龄分布
    plt.figure(figsize=(12, 6))
    sns.countplot(x='age_group', data=profile_df, order=['未成年', '青年', '中年', '老年'])
    plt.title('用户年龄分布')
    plt.xlabel('年龄组')
    plt.ylabel('用户数')
    plt.savefig('age_distribution.png', dpi=150, bbox_inches='tight')
    plt.close()

    # 2. 用户性别分布
    plt.figure(figsize=(8, 8))
    gender_counts = profile_df['gender'].value_counts()
    gender_counts.plot(kind='pie', autopct='%1.1f%%', colors=['#66b3ff', '#ff9999'])
    plt.title('用户性别分布')
    plt.savefig('gender_distribution.png', dpi=150, bbox_inches='tight')
    plt.close()

    # 3. RFM用户分群分布
    plt.figure(figsize=(12, 6))
    segment_counts = rfm_df['user_segment'].value_counts()
    segment_counts.plot(kind='barh', color='steelblue')
    plt.title('RFM用户分群分布')
    plt.xlabel('用户数')
    plt.ylabel('用户分群')
    plt.savefig('rfm_segment_distribution.png', dpi=150, bbox_inches='tight')
    plt.close()

    # 4. RFM评分热力图
    plt.figure(figsize=(10, 8))
    rfm_pivot = rfm_df.groupby(['r_score', 'f_score'])['user_id'].count().unstack(fill_value=0)
    sns.heatmap(rfm_pivot, annot=True, fmt='d', cmap='YlOrRd')
    plt.title('RFM评分分布热力图')
    plt.xlabel('F评分')
    plt.ylabel('R评分')
    plt.savefig('rfm_heatmap.png', dpi=150, bbox_inches='tight')
    plt.close()

    # 5. 购物频率与购买力关系
    plt.figure(figsize=(12, 6))
    cross_tab = pd.crosstab(profile_df['shopping_frequency'], profile_df['purchase_power'])
    cross_tab.plot(kind='bar', stacked=True)
    plt.title('购物频率与购买力关系')
    plt.xlabel('购物频率')
    plt.ylabel('用户数')
    plt.legend(title='购买力')
    plt.savefig('frequency_power_relation.png', dpi=150, bbox_inches='tight')
    plt.close()

    print("可视化图表生成完成")

finally:
    conn.close()

项目实现步骤
#

1. 环境搭建
#

# 1. 安装基础组件(以CentOS为例)
# JDK 11
sudo yum install java-11-openjdk java-11-openjdk-devel
export JAVA_HOME=/usr/lib/jvm/java-11

# Scala 2.13.8
wget https://downloads.lightbend.com/scala/2.13.8/scala-2.13.8.tgz
tar -xzf scala-2.13.8.tgz -C /usr/local/
export SCALA_HOME=/usr/local/scala-2.13.8

# 2. 安装大数据组件
# Hadoop 3.3.6, Spark 3.5.8, Kafka 3.7.0, HBase 2.5.x, Hive 3.1.x
# (参考各组件官方安装文档)

# 3. 安装 Redis 7.0+
sudo yum install redis
sudo systemctl start redis
sudo systemctl enable redis

# 4. 安装 MySQL 8.0
sudo yum install mysql-server
sudo systemctl start mysqld
sudo systemctl enable mysqld

# 5. 创建数据库和表
mysql -u root -p < scripts/init_tables.sql

# 6. 配置环境变量(敏感信息)
export MYSQL_PASSWORD="your_secure_password"
export REDIS_PASSWORD="your_redis_password"

2. 项目构建
#

# 编译打包
mvn clean package -DskipTests

# 提交 Spark 作业
spark-submit \
  --class com.ecommerce.profile.builder.UserProfileBuilder \
  --master yarn \
  --deploy-mode cluster \
  --conf spark.dynamicAllocation.enabled=true \
  target/user-profile-1.0.0.jar

3. 数据采集
#

  1. 启动Kafka服务和主题
  2. 运行数据采集程序,生成模拟数据
  3. 验证数据是否正确发送到Kafka

4. 数据存储
#

  1. 运行数据存储程序,将数据写入HBase和MySQL
  2. 验证数据是否正确存储
  3. 配置数据备份策略

5. 用户画像构建
#

  1. 运行基础画像构建程序(UserProfileBuilder)
  2. 运行RFM分析程序(RFMAnalyzer)
  3. 运行用户分群程序(UserSegmentation)
  4. 验证画像数据的准确性

6. 应用开发
#

  1. 实现个性化推荐系统(PersonalizedRecommendation)
  2. 实现精准营销系统(TargetedMarketing)
  3. 启动用户画像服务(UserProfileService)

7. 可视化展示
#

  1. 配置Grafana数据源
  2. 创建仪表板和图表
  3. 验证可视化效果

项目扩展
#

  1. 实时用户画像:基于实时数据流,实现实时用户画像更新
  2. 跨渠道数据整合:整合线上线下数据,构建全渠道用户画像
  3. 深度学习模型:使用深度学习模型提升用户画像的准确性
  4. 隐私保护:实现差分隐私技术,保护用户隐私
  5. 多语言支持:支持多语言用户画像构建和分析
  6. 标签血缘追踪:记录标签的计算逻辑和数据来源,支持标签溯源
  7. A/B测试平台:基于用户画像进行A/B测试,验证营销策略效果

项目总结
#

本项目实现了一个完整的企业级电商用户画像系统,涵盖了数据采集、存储、处理、分析和应用的全流程。通过整合Spark 3.5.8、Hadoop、HBase、Kafka 3.7.0、Redis 7.0+、MySQL 8.0等大数据技术,系统能够构建精准的用户画像,为电商平台的个性化推荐、精准营销和用户体验优化提供数据支持。

系统具有以下特点:

  1. 多维度画像:从人口统计学、行为、兴趣、RFM等多个维度构建用户画像
  2. 实时性:使用Kafka和Spark Streaming实现实时数据处理
  3. 智能分析:使用RFM模型和K-Means机器学习算法进行用户分群和行为预测
  4. 分层存储:Redis热数据 + HBase标签数据 + MySQL结构化数据 + Hive历史数据
  5. 配置外部化:所有配置通过Typesafe Config管理,敏感信息通过环境变量注入
  6. 安全可靠:消除硬编码密码,Redis连接池管理,多级降级方案
  7. 资源管理:SparkSession使用try-catch-finally确保释放,JedisPool连接池管理
  8. 服务化:提供多级缓存查询服务,支持降级和批量查询

通过本项目,我们掌握了企业级用户画像系统的设计和实现方法,包括配置管理、数据访问层封装、多级缓存策略、降级方案等工程实践,为未来的大数据项目开发打下了坚实的基础。