diff --git a/Server/RFM客户价值评分体系技术实施文档.md b/Server/RFM客户价值评分体系技术实施文档.md new file mode 100644 index 00000000..4b5fb84d --- /dev/null +++ b/Server/RFM客户价值评分体系技术实施文档.md @@ -0,0 +1,249 @@ +# RFM 客户价值评分体系技术实施文档 + +## 1. 文档目的 + +本文档旨在明确 RFM(Recency-Frequency-Monetary)客户价值评分体系的技术实现标准,包括维度定义、评分规则、数据处理流程、参数配置及异常处理方案,为系统开发、数据分析及业务应用提供统一依据。 + +## 2. 核心术语定义 + + + +| 术语 | 英文缩写 | 定义 | 数据来源 | 统计周期说明 | +| ------ | ------------ | ------------------------------- | --------- | ---------------------------------- | +| 最近消费时间 | Recency(R) | 客户最后一次有效消费行为距统计截止日的时间间隔(单位:天) | 订单系统、交易日志 | 支持自定义配置(默认 3-12 个月,按业务场景调整) | +| 消费频率 | Frequency(F) | 统计周期内客户发生有效消费行为的总次数 | 订单系统、交易日志 | 与 R 维度统计周期一致,剔除重复下单、取消订单等无效记录 | +| 消费金额 | Monetary(M) | 统计周期内客户有效消费行为的总金额(单位:元,支持多币种换算) | 订单系统、支付日志 | 仅统计已支付完成的订单金额,剔除退款、优惠抵扣部分 | +| RFM 总分 | RFM Score | 基于 R、F、M 三个维度的分项得分,按预设权重计算的综合得分 | 系统计算生成 | 得分范围 1-15 分(5 分制单项)或 1-100 分(标准化后) | + +## 3. 评分规则技术规范 + +### 3.1 分项评分规则(默认 5 分制) + +#### 3.1.1 Recency(R)评分规则 + + + +* 核心逻辑:时间间隔越短,得分越高(反向映射) + +* 分段标准:采用**五分位法**(按数据分布自动划分区间,避免均分失真) + + + +| 得分 | 时间间隔区间(天) | 划分逻辑 | +| --- | ---------- | ----------------- | +| 5 分 | \[0, T1] | 统计周期内最近消费的 20% 客户 | +| 4 分 | (T1, T2] | 统计周期内次近消费的 20% 客户 | +| 3 分 | (T2, T3] | 统计周期内中间消费的 20% 客户 | +| 2 分 | (T3, T4] | 统计周期内较久消费的 20% 客户 | +| 1 分 | (T4, Tmax] | 统计周期内最久消费的 20% 客户 | + + + +* 区间计算方式:T1=PERCENTILE\_CONT (0.2)、T2=PERCENTILE\_CONT (0.4)、T3=PERCENTILE\_CONT (0.6)、T4=PERCENTILE\_CONT (0.8),其中 Tmax 为统计周期总天数 + +#### 3.1.2 Frequency(F)评分规则 + + + +* 核心逻辑:消费次数越多,得分越高(正向映射) + +* 分段标准:采用**五分位法**(支持最小消费次数阈值配置) + + + +| 得分 | 消费次数区间 | 划分逻辑 | +| --- | ----------- | ------------------- | +| 5 分 | \[F4, +∞) | 统计周期内消费次数最多的 20% 客户 | +| 4 分 | \[F3, F4) | 统计周期内消费次数次多的 20% 客户 | +| 3 分 | \[F2, F3) | 统计周期内消费次数中间的 20% 客户 | +| 2 分 | \[F1, F2) | 统计周期内消费次数较少的 20% 客户 | +| 1 分 | \[Fmin, F1) | 统计周期内消费次数最少的 20% 客户 | + + + +* 区间计算方式:F1=PERCENTILE\_CONT (0.2)、F2=PERCENTILE\_CONT (0.4)、F3=PERCENTILE\_CONT (0.6)、F4=PERCENTILE\_CONT (0.8),其中 Fmin 为 1(仅统计有效消费次数≥1 的客户) + +#### 3.1.3 Monetary(M)评分规则 + + + +* 核心逻辑:消费金额越高,得分越高(正向映射) + +* 分段标准:采用**五分位法**(支持剔除大额异常值后划分) + + + +| 得分 | 消费金额区间(元) | 划分逻辑 | +| --- | ----------- | ------------------- | +| 5 分 | \[M4, +∞) | 统计周期内消费金额最高的 20% 客户 | +| 4 分 | \[M3, M4) | 统计周期内消费金额次高的 20% 客户 | +| 3 分 | \[M2, M3) | 统计周期内消费金额中间的 20% 客户 | +| 2 分 | \[M1, M2) | 统计周期内消费金额较低的 20% 客户 | +| 1 分 | \[Mmin, M1) | 统计周期内消费金额最低的 20% 客户 | + + + +* 区间计算方式:M1=PERCENTILE\_CONT (0.2)、M2=PERCENTILE\_CONT (0.4)、M3=PERCENTILE\_CONT (0.6)、M4=PERCENTILE\_CONT (0.8),其中 Mmin 为统计周期内最小有效订单金额 + +### 3.2 总分计算规则 + +#### 3.2.1 加权求和公式 + +$RFM_{Score} = R_{Score} \times W_R + F_{Score} \times W_F + M_{Score} \times W_M$ + + + +* 权重配置:支持自定义(默认配置:$W_R=0.4$,$W_F=0.3$,$W_M=0.3$) + +* 权重约束:$W_R + W_F + W_M = 1.0$,且单个权重取值范围为 \[0.1, 0.8] + +#### 3.2.2 得分标准化(可选) + + + +* 若需将总分映射为 1-100 分,采用线性标准化公式: + + $RFM_{StandardScore} = \frac{RFM_{Score} - RFM_{Min}}{RFM_{Max} - RFM_{Min}} \times 99 + 1$ + +* 其中:$RFM_{Min}=W_R \times 1 + W_F \times 1 + W_M \times 1$,$RFM_{Max}=W_R \times 5 + W_F \times 5 + W_M \times 5$ + +## 4. 数据处理流程 + +### 4.1 数据输入要求 + + + +| 数据项 | 数据类型 | 格式要求 | 校验规则 | +| ------ | ------------- | ------------------- | ------------ | +| 客户唯一标识 | String/Int | 全局唯一(如用户 ID、会员 ID) | 非空、去重 | +| 订单唯一标识 | String/Int | 全局唯一(如订单号) | 非空、去重 | +| 消费时间 | DateTime | yyyy-MM-dd HH:mm:ss | 需在统计周期内 | +| 消费金额 | Decimal(18,2) | 大于 0 | 剔除负数、0 值 | +| 订单状态 | String | 枚举值(已支付、已取消、已退款等) | 仅保留 “已支付” 状态 | + +### 4.2 数据预处理步骤 + + + +1. **数据过滤**: + +* 剔除统计周期外的订单数据 + +* 剔除订单状态为 “已取消”“已退款”“无效” 的记录 + +* 剔除员工内部订单、测试订单(按订单标签或用户标签过滤) + +* 剔除单笔金额超过$M_{99分位值} \times 3$的异常大额订单(可配置开关) + +1. **数据聚合**: + +* 按客户唯一标识分组,计算 R、F、M 原始指标: + + + * R:MAX (消费时间) 到统计截止日的时间间隔(天) + + * F:COUNT (DISTINCT 订单唯一标识) + + * M:SUM (消费金额) + +1. **缺失值处理**: + +* 统计周期内无消费记录的客户:R = 统计周期总天数,F=0,M=0,分项得分均为 1 分 + +* 单个指标缺失(如仅缺失 M):按 1 分计分项得分 + +### 4.3 评分计算流程 + + + +```mermaid +graph TD + A[数据输入] --> B[数据过滤] + B --> C[数据聚合计算R/F/M原始值] + C --> D[缺失值处理] + D --> E[按五分位法划分各维度区间] + E --> F[计算R/F/M分项得分] + F --> G[按权重计算RFM总分] + G --> H[可选:标准化为1-100分] + H --> I[输出客户RFM评分结果] +``` + +## 5. 参数配置说明 + + + +| 参数名称 | 配置项 | 取值范围 | 默认值 | 配置方式 | +| ------- | ---------------------- | ------------- | ------ | --------------- | +| 统计周期 | cycle\_days | 30-365 | 180 | 系统配置页手动输入 | +| R 维度权重 | weight\_R | 0.1-0.8 | 0.4 | 系统配置页滑动条调整 | +| F 维度权重 | weight\_F | 0.1-0.8 | 0.3 | 系统配置页滑动条调整 | +| M 维度权重 | weight\_M | 0.1-0.8 | 0.3 | 系统配置页滑动条调整 | +| 异常金额阈值 | abnormal\_money\_ratio | 1.5-5.0 | 3.0 | 系统配置页手动输入(倍数关系) | +| 评分分制 | score\_scale | 5 分制 / 100 分制 | 5 分制 | 系统配置页单选 | +| 缺失值处理策略 | missing\_strategy | 按 1 分计 / 剔除客户 | 按 1 分计 | 系统配置页单选 | + +## 6. 异常处理方案 + +### 6.1 数据异常 + + + +| 异常类型 | 表现形式 | 处理逻辑 | 影响范围 | +| ------ | ------------------------------ | ---------------------- | --------------- | +| 重复订单 | 同一客户同一时间相同订单号 | 去重保留 1 条有效记录 | 不影响 F、M 计算 | +| 大额异常订单 | 单笔金额 > $M_{99分位值} \times 异常阈值$ | 自动标记,可选择剔除或保留 | 仅影响 M 维度区间划分 | +| 消费时间异常 | 消费时间晚于统计截止日 | 视为无效数据,剔除 | 不影响最终结果 | +| 客户标识重复 | 同一客户多个唯一标识 | 按客户合并规则(如手机号、身份证号关联)聚合 | 需提前完成客户统一 ID 映射 | + +### 6.2 计算异常 + + + +| 异常类型 | 触发条件 | 处理逻辑 | 输出结果 | +| -------- | --------------------- | --------------------------------------- | --------------- | +| 维度区间为空 | 某维度所有客户数据相同(如 F 均为 1) | 强制均分 5 个区间 | 分项得分按 1-5 分依次分配 | +| 权重总和不为 1 | 配置权重时计算错误 | 系统自动归一化处理($W'_X = W_X / (W_R+W_F+W_M)$) | 不影响总分有效性 | +| 统计周期过短 | 小于 30 天导致数据量不足 | 系统给出警告,允许强制执行 | 区间划分可能失真,建议延长周期 | + +## 7. 输出结果格式 + +### 7.1 单客户评分结果 + + + +| 字段名 | 数据类型 | 示例 | +| --------- | ------------- | ----------------------- | +| 客户 ID | String | CUST2023001 | +| R 原始值(天) | Int | 15 | +| R 得分 | Int | 5 | +| F 原始值(次) | Int | 8 | +| F 得分 | Int | 4 | +| M 原始值(元) | Decimal(18,2) | 2560.00 | +| M 得分 | Int | 5 | +| RFM 总分 | Decimal(5,2) | 4.70 | +| 标准化得分(可选) | Int | 94 | +| 统计周期 | String | 2023-01-01 至 2023-06-30 | +| 计算时间 | DateTime | 2023-07-01 00:30:25 | + +### 7.2 批量输出文件格式 + + + +* 支持 CSV、Parquet、JSON 格式导出 + +* 编码格式:UTF-8 + +* 压缩方式:默认 GZIP(可配置关闭) + +## 8. 业务适配建议 + + + +| 业务场景 | 统计周期建议 | 权重调整建议 | 特殊配置 | +| --------------- | ------- | --------------------------- | ----------------- | +| 快消零售 | 3-6 个月 | $W_R=0.5, W_F=0.3, W_M=0.2$ | 提高 R 维度权重,关注复购及时性 | +| 高客单价行业(如奢侈品、家居) | 12 个月 | $W_R=0.3, W_F=0.2, W_M=0.5$ | 提高 M 维度权重,关注消费能力 | +| 新品推广期 | 1-3 个月 | $W_R=0.6, W_F=0.2, W_M=0.2$ | 重点关注近期新客户 | +| 会员体系运营 | 6-12 个月 | $W_R=0.4, W_F=0.4, W_M=0.2$ | 提高 F 维度权重,鼓励高频消费 | + +> (注:文档部分内容可能由 AI 生成) \ No newline at end of file diff --git a/Server/application/api/controller/WebSocketController.php b/Server/application/api/controller/WebSocketController.php index 63a93ca1..773cbb3d 100644 --- a/Server/application/api/controller/WebSocketController.php +++ b/Server/application/api/controller/WebSocketController.php @@ -67,12 +67,11 @@ class WebSocketController extends BaseController // 调用登录接口获取token $headerData = ['client:kefu-client']; - $headerData[] = 'verifysessionid:3f21df29-6d8a-4980-ae8a-bf15ef17955f'; - $headerData[] = 'verifycode:0k3g'; + $headerData[] = 'verifysessionid:2fbc51c9-db70-4e84-9568-21ef3667e1be'; + $headerData[] = 'verifycode:5bcd'; $header = setHeader($headerData, '', 'plain'); - $result = requestCurl('https://kf.quwanzhi.com:9991/token', $params, 'POST', $header); + $result = requestCurl('https://s2.siyuguanli.com:9991/token', $params, 'POST', $header); $result_array = handleApiResponse($result); - if (isset($result_array['access_token']) && !empty($result_array['access_token'])) { $this->authorized = $result_array['access_token']; $this->accountId = $userData['accountId']; @@ -116,7 +115,7 @@ class WebSocketController extends BaseController ]; $content = json_encode($result); - $this->client = new Client("wss://kf.quwanzhi.com:9993", + $this->client = new Client("wss://s2.siyuguanli.com:9993", [ 'filter' => ['text', 'binary', 'ping', 'pong', 'close', 'receive', 'send'], 'context' => $context, @@ -669,7 +668,6 @@ class WebSocketController extends BaseController "wechatChatroomId" => 0, "wechatFriendId" => $dataArray['wechatFriendId'], ]; - // 发送请求 $this->client->send(json_encode($params)); // 接收响应 diff --git a/Server/application/api/controller/WebSocketControllerCopy.php b/Server/application/api/controller/WebSocketControllerCopy.php index 33bf77f3..12c516bd 100644 --- a/Server/application/api/controller/WebSocketControllerCopy.php +++ b/Server/application/api/controller/WebSocketControllerCopy.php @@ -43,7 +43,7 @@ class WebSocketControllerCopy extends BaseController // 设置请求头 $headerData = ['client:kefu-client']; $header = setHeader($headerData, '', 'plain'); - $result = requestCurl('https://kf.quwanzhi.com:9991/token', $params, 'POST',$header); + $result = requestCurl('https://s2.siyuguanli.com:9991/token', $params, 'POST',$header); $result_array = handleApiResponse($result); if (isset($result_array['access_token']) && !empty($result_array['access_token'])) { @@ -81,7 +81,7 @@ class WebSocketControllerCopy extends BaseController $content = json_encode($result); - $this->client = new Client("wss://kf.quwanzhi.com:9993", + $this->client = new Client("wss://s2.siyuguanli.com:9993", [ 'filter' => ['text', 'binary', 'ping', 'pong', 'close','receive', 'send'], 'context' => $context, diff --git a/Server/application/command/WorkbenchGroupPushCommand.php b/Server/application/command/WorkbenchGroupPushCommand.php index 6fd1fde7..59c70ddc 100644 --- a/Server/application/command/WorkbenchGroupPushCommand.php +++ b/Server/application/command/WorkbenchGroupPushCommand.php @@ -34,6 +34,7 @@ class WorkbenchGroupPushCommand extends Command // 检查队列是否已经在运行 $queueLockKey = "queue_lock:{$this->queueName}"; + Cache::rm($queueLockKey); if (Cache::get($queueLockKey)) { $output->writeln("队列 {$this->queueName} 已经在运行中,跳过执行"); Log::warning("队列 {$this->queueName} 已经在运行中,跳过执行"); diff --git a/Server/application/cunkebao/controller/RFMController.php b/Server/application/cunkebao/controller/RFMController.php index 693e808e..f9163db1 100644 --- a/Server/application/cunkebao/controller/RFMController.php +++ b/Server/application/cunkebao/controller/RFMController.php @@ -2,10 +2,207 @@ namespace app\cunkebao\controller; +use think\Db; +use app\store\model\TrafficOrderModel; +use app\common\model\TrafficSource; +use app\store\model\WechatFriendModel; + +/** + * RFM 客户价值评分控制器 + * 基于 RFM 客户价值评分体系技术实施文档实现 + */ class RFMController extends BaseController { + // 默认配置参数 + const DEFAULT_CYCLE_DAYS = 180; // 默认统计周期(天) + const DEFAULT_WEIGHT_R = 0.4; // R维度权重 + const DEFAULT_WEIGHT_F = 0.3; // F维度权重 + const DEFAULT_WEIGHT_M = 0.3; // M维度权重 + const DEFAULT_ABNORMAL_MONEY_RATIO = 3.0; // 异常金额阈值倍数 + const DEFAULT_SCORE_SCALE = 5; // 默认5分制 + /** - * 计算 RFM 评分(默认规则) + * 从 traffic_order 表计算客户 RFM 评分 + * + * @param string|null $identifier 流量池用户标识 + * @param string|null $ownerWechatId 微信ID,为空则统计所有数据 + * @param array $config 配置参数 + * - cycle_days: 统计周期(天),默认180 + * - weight_R: R维度权重,默认0.4 + * - weight_F: F维度权重,默认0.3 + * - weight_M: M维度权重,默认0.3 + * - abnormal_money_ratio: 异常金额阈值倍数,默认3.0 + * - score_scale: 评分分制(5或100),默认5 + * - missing_strategy: 缺失值处理策略('score_1'或'exclude'),默认'score_1' + * @return array + */ + public function calculateRfmFromTrafficOrder($identifier = null, $ownerWechatId = null, $config = []) + { + try { + // 合并配置参数 + $cycleDays = isset($config['cycle_days']) ? (int)$config['cycle_days'] : self::DEFAULT_CYCLE_DAYS; + $weightR = isset($config['weight_R']) ? (float)$config['weight_R'] : self::DEFAULT_WEIGHT_R; + $weightF = isset($config['weight_F']) ? (float)$config['weight_F'] : self::DEFAULT_WEIGHT_F; + $weightM = isset($config['weight_M']) ? (float)$config['weight_M'] : self::DEFAULT_WEIGHT_M; + $abnormalMoneyRatio = isset($config['abnormal_money_ratio']) ? (float)$config['abnormal_money_ratio'] : self::DEFAULT_ABNORMAL_MONEY_RATIO; + $scoreScale = isset($config['score_scale']) ? (int)$config['score_scale'] : self::DEFAULT_SCORE_SCALE; + $missingStrategy = isset($config['missing_strategy']) ? $config['missing_strategy'] : 'score_1'; + + // 权重归一化处理 + $weightSum = $weightR + $weightF + $weightM; + if ($weightSum != 1.0) { + $weightR = $weightR / $weightSum; + $weightF = $weightF / $weightSum; + $weightM = $weightM / $weightSum; + } + + // 计算时间范围 + $endTime = time(); // 统计截止时间(当前时间) + $startTime = $endTime - ($cycleDays * 24 * 3600); // 统计起始时间 + + // 构建查询条件 + $where = [ + ['isDel', '=', 0], + ['createTime', '>=', $startTime], + ['createTime', '<', $endTime], + ]; + + // identifier 条件 + if (!empty($identifier)) { + $where[] = ['identifier', '=', $identifier]; + } + + // ownerWechatId 条件 + if (!empty($ownerWechatId)) { + $where[] = ['ownerWechatId', '=', $ownerWechatId]; + } + + // 1. 数据过滤和聚合 - 获取每个客户的R、F、M原始值 + $orderModel = new TrafficOrderModel(); + $customers = $orderModel + ->where($where) + ->where(function ($query) { + // 只统计有效订单(actualPay大于0) + $query->where('actualPay', '>', 0); + }) + ->field('identifier, MAX(createTime) as lastOrderTime, COUNT(DISTINCT id) as orderCount, SUM(CAST(actualPay AS DECIMAL(18,2))) as totalAmount') + ->group('identifier') + ->select(); + + if (empty($customers)) { + return [ + 'code' => 200, + 'msg' => '暂无数据', + 'data' => [] + ]; + } + + // 2. 计算每个客户的R值(最近消费天数) + $customerData = []; + foreach ($customers as $customer) { + $recencyDays = floor(($endTime - $customer['lastOrderTime']) / (24 * 3600)); + $customerData[] = [ + 'identifier' => $customer['identifier'], + 'R' => $recencyDays, + 'F' => (int)$customer['orderCount'], + 'M' => (float)$customer['totalAmount'], + ]; + } + + // 3. 异常值处理 - 剔除大额异常订单 + $mValues = array_column($customerData, 'M'); + if (!empty($mValues)) { + sort($mValues); + $m99Percentile = $this->percentile($mValues, 0.99); + $abnormalThreshold = $m99Percentile * $abnormalMoneyRatio; + + // 标记异常客户(但不删除,仅在计算M维度区间时考虑) + foreach ($customerData as &$customer) { + $customer['isAbnormal'] = $customer['M'] > $abnormalThreshold; + } + } + + // 4. 使用五分位法计算各维度的区间阈值 + $rThresholds = $this->calculatePercentiles(array_column($customerData, 'R'), true); // R是反向的 + $fThresholds = $this->calculatePercentiles(array_column($customerData, 'F'), false); + // M维度排除异常值计算区间 + $mValuesForPercentile = array_filter(array_column($customerData, 'M'), function($m) use ($abnormalThreshold) { + return isset($abnormalThreshold) ? $m <= $abnormalThreshold : true; + }); + $mThresholds = $this->calculatePercentiles(array_values($mValuesForPercentile), false); + + // 5. 计算每个客户的RFM分项得分 + $results = []; + foreach ($customerData as $customer) { + $rScore = $this->scoreByPercentile($customer['R'], $rThresholds, true); // R是反向的 + $fScore = $this->scoreByPercentile($customer['F'], $fThresholds, false); + $mScore = $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分 + + // 计算RFM总分(加权求和) + $rfmScore = $rScore * $weightR + $fScore * $weightF + $mScore * $weightM; + + // 可选:标准化为1-100分 + $standardScore = null; + if ($scoreScale == 100) { + $rfmMin = $weightR * 1 + $weightF * 1 + $weightM * 1; + $rfmMax = $weightR * 5 + $weightF * 5 + $weightM * 5; + $standardScore = (int)round(($rfmScore - $rfmMin) / ($rfmMax - $rfmMin) * 99 + 1); + } + + $results[] = [ + 'identifier' => $customer['identifier'], + 'R_raw' => $customer['R'], + 'R_score' => $rScore, + 'F_raw' => $customer['F'], + 'F_score' => $fScore, + 'M_raw' => round($customer['M'], 2), + 'M_score' => $mScore, + 'RFM_score' => round($rfmScore, 2), + 'RFM_standard_score' => $standardScore, + 'cycle_start' => date('Y-m-d H:i:s', $startTime), + 'cycle_end' => date('Y-m-d H:i:s', $endTime), + 'calculate_time' => date('Y-m-d H:i:s'), + ]; + } + + // 按RFM总分降序排序 + usort($results, function($a, $b) { + return $b['RFM_score'] <=> $a['RFM_score']; + }); + + // 6. 更新 ck_traffic_source 和 s2_wechat_friend 表的RFM值 + $this->updateRfmToTables($results, $ownerWechatId); + + return [ + 'code' => 200, + 'msg' => '计算成功', + 'data' => [ + 'results' => $results, + 'config' => [ + 'cycle_days' => $cycleDays, + 'weight_R' => $weightR, + 'weight_F' => $weightF, + 'weight_M' => $weightM, + 'score_scale' => $scoreScale, + ], + 'statistics' => [ + 'total_customers' => count($results), + 'avg_rfm_score' => round(array_sum(array_column($results, 'RFM_score')) / count($results), 2), + ] + ] + ]; + + } catch (\Exception $e) { + return [ + 'code' => 500, + 'msg' => '计算失败:' . $e->getMessage(), + 'data' => [] + ]; + } + } + + /** + * 计算 RFM 评分(兼容旧方法,使用固定阈值) * @param int|null $recencyDays 最近购买天数 * @param int $frequency 购买次数 * @param float $monetary 购买金额 @@ -23,7 +220,9 @@ class RFMController extends BaseController ]; } - // 默认规则 + /** + * 使用固定阈值计算R得分(保留兼容性) + */ protected static function scoreR_Default(int $days): int { if ($days <= 30) return 5; @@ -32,6 +231,10 @@ class RFMController extends BaseController if ($days <= 120) return 2; return 1; } + + /** + * 使用固定阈值计算F得分(保留兼容性) + */ protected static function scoreF_Default(int $times): int { if ($times >= 10) return 5; @@ -41,6 +244,10 @@ class RFMController extends BaseController if ($times >= 1) return 1; return 0; } + + /** + * 使用固定阈值计算M得分(保留兼容性) + */ protected static function scoreM_Default(float $amount): int { if ($amount >= 2000) return 5; @@ -50,6 +257,145 @@ class RFMController extends BaseController if ($amount > 0) return 1; return 0; } + + /** + * 计算百分位数(五分位法) + * @param array $values 数值数组 + * @param bool $reverse 是否反向(R维度需要反向,值越小得分越高) + * @return array 返回[0.2, 0.4, 0.6, 0.8]分位数的阈值数组 + */ + private function calculatePercentiles($values, $reverse = false) + { + if (empty($values)) { + return [0, 0, 0, 0]; + } + + // 去重并排序 + $uniqueValues = array_unique($values); + sort($uniqueValues); + + // 如果所有值相同,强制均分5个区间 + if (count($uniqueValues) == 1) { + $singleValue = $uniqueValues[0]; + if ($reverse) { + return [$singleValue, $singleValue, $singleValue, $singleValue]; + } else { + return [$singleValue, $singleValue, $singleValue, $singleValue]; + } + } + + $percentiles = [0.2, 0.4, 0.6, 0.8]; + $thresholds = []; + + foreach ($percentiles as $p) { + $thresholds[] = $this->percentile($uniqueValues, $p); + } + + return $thresholds; + } + + /** + * 计算百分位数 + * @param array $sortedArray 已排序的数组 + * @param float $percentile 百分位数(0-1之间) + * @return float + */ + private function percentile($sortedArray, $percentile) + { + if (empty($sortedArray)) { + return 0; + } + + $count = count($sortedArray); + $index = ($count - 1) * $percentile; + $floor = floor($index); + $ceil = ceil($index); + + if ($floor == $ceil) { + return $sortedArray[(int)$index]; + } + + $weight = $index - $floor; + return $sortedArray[(int)$floor] * (1 - $weight) + $sortedArray[(int)$ceil] * $weight; + } + + /** + * 根据五分位法阈值计算得分 + * @param float $value 当前值 + * @param array $thresholds 阈值数组[T1, T2, T3, T4] + * @param bool $reverse 是否反向(R维度反向:值越小得分越高) + * @return int 得分1-5 + */ + private function scoreByPercentile($value, $thresholds, $reverse = false) + { + if (empty($thresholds) || count($thresholds) < 4) { + return 1; + } + + list($t1, $t2, $t3, $t4) = $thresholds; + + if ($reverse) { + // R维度:值越小得分越高 + if ($value <= $t1) return 5; + if ($value <= $t2) return 4; + if ($value <= $t3) return 3; + if ($value <= $t4) return 2; + return 1; + } else { + // F和M维度:值越大得分越高 + if ($value >= $t4) return 5; + if ($value >= $t3) return 4; + if ($value >= $t2) return 3; + if ($value >= $t1) return 2; + return 1; + } + } + + /** + * 更新RFM值到 ck_traffic_source 和 s2_wechat_friend 表 + * + * @param array $results RFM计算结果数组 + * @param string|null $ownerWechatId 微信ID,用于过滤更新范围 + */ + private function updateRfmToTables($results, $ownerWechatId = null) + { + try { + foreach ($results as $result) { + $identifier = $result['identifier']; + $rScore = (string)$result['R_score']; + $fScore = (string)$result['F_score']; + $mScore = (string)$result['M_score']; + + // 更新 ck_traffic_source 表 + // 根据 identifier 更新所有匹配的记录 + $trafficSourceUpdate = [ + 'R' => $rScore, + 'F' => $fScore, + 'M' => $mScore, + 'updateTime' => time() + ]; + TrafficSource::where('identifier', $identifier)->update($trafficSourceUpdate); + + // 更新 s2_wechat_friend 表 + // wechatId 对应 identifier + $wechatFriendUpdate = [ + 'R' => $rScore, + 'F' => $fScore, + 'M' => $mScore, + 'updateTime' => time() + ]; + $wechatFriendWhere = ['wechatId' => $identifier]; + if (!empty($ownerWechatId)) { + $wechatFriendWhere['ownerWechatId'] = $ownerWechatId; + } + WechatFriendModel::where($wechatFriendWhere)->update($wechatFriendUpdate); + } + + } catch (\Exception $e) { + // 记录错误但不影响主流程 + \think\Log::error('更新RFM值失败:' . $e->getMessage()); + } + } } diff --git a/Server/application/cunkebao/controller/WorkbenchController.php b/Server/application/cunkebao/controller/WorkbenchController.php index 9499c40f..fde42fa7 100644 --- a/Server/application/cunkebao/controller/WorkbenchController.php +++ b/Server/application/cunkebao/controller/WorkbenchController.php @@ -109,13 +109,26 @@ class WorkbenchController extends Controller $config = new WorkbenchGroupPush; $config->workbenchId = $workbench->id; $config->pushType = !empty($param['pushType']) ? 1 : 0; // 推送方式:定时/立即 + $config->targetType = !empty($param['targetType']) ? intval($param['targetType']) : 1; // 推送目标类型:1=群推送,2=好友推送 $config->startTime = $param['startTime']; $config->endTime = $param['endTime']; $config->maxPerDay = intval($param['maxPerDay']); // 每日推送数 $config->pushOrder = $param['pushOrder']; // 推送顺序 - $config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环 + // 根据targetType存储不同的数据 + if ($config->targetType == 1) { + // 群推送 + $config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环 + $config->groups = json_encode($param['wechatGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 群组信息 + $config->friends = json_encode([], JSON_UNESCAPED_UNICODE); // 好友信息为空数组 + $config->devices = json_encode([], JSON_UNESCAPED_UNICODE); // 群推送不需要设备 + } else { + // 好友推送:isLoop必须为0,设备必填 + $config->isLoop = 0; // 好友推送时强制为0 + $config->friends = json_encode($param['wechatFriends'] ?? [], JSON_UNESCAPED_UNICODE); // 好友信息(可以为空数组) + $config->groups = json_encode([], JSON_UNESCAPED_UNICODE); // 群组信息为空数组 + $config->devices = json_encode($param['deviceGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 设备信息(必填) + } $config->status = !empty($param['status']) ? 1 : 0; // 是否启用 - $config->groups = json_encode($param['wechatGroups'], JSON_UNESCAPED_UNICODE); // 群组信息 $config->contentLibraries = json_encode($param['contentGroups'], JSON_UNESCAPED_UNICODE); // 内容库信息 $config->socialMediaId = !empty($param['socialMediaId']) ? $param['socialMediaId'] : ''; $config->promotionSiteId = !empty($param['promotionSiteId']) ? $param['promotionSiteId'] : ''; @@ -216,7 +229,7 @@ class WorkbenchController extends Controller $query->field('workbenchId,distributeType,maxPerDay,timeType,startTime,endTime,devices,pools,account'); }, 'groupPush' => function ($query) { - $query->field('workbenchId,pushType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,contentLibraries'); + $query->field('workbenchId,pushType,targetType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,friends,devices,contentLibraries'); }, 'groupCreate' => function($query) { $query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups'); @@ -289,13 +302,25 @@ class WorkbenchController extends Controller if (!empty($item->groupPush)) { $item->config = $item->groupPush; $item->config->pushType = $item->config->pushType; + $item->config->targetType = isset($item->config->targetType) ? intval($item->config->targetType) : 1; // 默认1=群推送 $item->config->startTime = $item->config->startTime; $item->config->endTime = $item->config->endTime; $item->config->maxPerDay = $item->config->maxPerDay; $item->config->pushOrder = $item->config->pushOrder; $item->config->isLoop = $item->config->isLoop; $item->config->status = $item->config->status; - $item->config->groups = json_decode($item->config->groups, true); + // 根据targetType解析不同的数据 + if ($item->config->targetType == 1) { + // 群推送 + $item->config->wechatGroups = json_decode($item->config->groups, true) ?: []; + $item->config->wechatFriends = []; + $item->config->deviceGroups = []; + } else { + // 好友推送 + $item->config->wechatFriends = json_decode($item->config->friends, true) ?: []; + $item->config->wechatGroups = []; + $item->config->deviceGroups = json_decode($item->config->devices ?? '[]', true) ?: []; + } $item->config->contentLibraries = json_decode($item->config->contentLibraries, true); $item->config->lastPushTime = ''; } @@ -413,7 +438,7 @@ class WorkbenchController extends Controller $query->field('workbenchId,distributeType,maxPerDay,timeType,startTime,endTime,devices,pools,account'); }, 'groupPush' => function ($query) { - $query->field('workbenchId,pushType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,contentLibraries'); + $query->field('workbenchId,pushType,targetType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,friends,devices,contentLibraries'); }, 'groupCreate' => function($query) { $query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups'); @@ -484,7 +509,19 @@ class WorkbenchController extends Controller case self::TYPE_GROUP_PUSH: if (!empty($workbench->groupPush)) { $workbench->config = $workbench->groupPush; - $workbench->config->wechatGroups = json_decode($workbench->config->groups, true); + $workbench->config->targetType = isset($workbench->config->targetType) ? intval($workbench->config->targetType) : 1; // 默认1=群推送 + // 根据targetType解析不同的数据 + if ($workbench->config->targetType == 1) { + // 群推送 + $workbench->config->wechatGroups = json_decode($workbench->config->groups, true) ?: []; + $workbench->config->wechatFriends = []; + $workbench->config->deviceGroups = []; + } else { + // 好友推送 + $workbench->config->wechatFriends = json_decode($workbench->config->friends, true) ?: []; + $workbench->config->wechatGroups = []; + $workbench->config->deviceGroups = json_decode($workbench->config->devices ?? '[]', true) ?: []; + } $workbench->config->contentLibraries = json_decode($workbench->config->contentLibraries, true); unset($workbench->groupPush, $workbench->group_push); } @@ -603,11 +640,11 @@ class WorkbenchController extends Controller } - // 获取群 - if (!empty($workbench->config->wechatGroups)){ + // 获取群(当targetType=1时) + if (!empty($workbench->config->wechatGroups) && isset($workbench->config->targetType) && $workbench->config->targetType == 1){ $groupList = Db::name('wechat_group')->alias('wg') ->join('wechat_account wa', 'wa.wechatId = wg.ownerWechatId') - ->where('wg.id', 'in', $workbench->config->groups) + ->where('wg.id', 'in', $workbench->config->wechatGroups) ->order('wg.id', 'desc') ->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wa.avatar,wa.alias,wg.avatar as groupAvatar') ->select(); @@ -616,6 +653,19 @@ class WorkbenchController extends Controller $workbench->config->wechatGroupsOptions = []; } + // 获取好友(当targetType=2时) + if (!empty($workbench->config->wechatFriends) && isset($workbench->config->targetType) && $workbench->config->targetType == 2){ + $friendList = Db::table('s2_wechat_friend')->alias('wf') + ->join('s2_wechat_account wa', 'wa.id = wf.wechatAccountId', 'left') + ->where('wf.id', 'in', $workbench->config->wechatFriends) + ->order('wf.id', 'desc') + ->field('wf.id,wf.wechatId,wf.nickname as friendName,wf.avatar as friendAvatar,wf.conRemark,wf.ownerWechatId,wa.nickName as accountName,wa.avatar as accountAvatar') + ->select(); + $workbench->config->wechatFriendsOptions = $friendList; + }else{ + $workbench->config->wechatFriendsOptions = []; + } + // 获取内容库名称 if (!empty($workbench->config->contentGroups)) { $libraryNames = ContentLibrary::where('id', 'in', $workbench->config->contentGroups)->select(); @@ -748,13 +798,26 @@ class WorkbenchController extends Controller $config = WorkbenchGroupPush::where('workbenchId', $param['id'])->find(); if ($config) { $config->pushType = !empty($param['pushType']) ? 1 : 0; // 推送方式:定时/立即 + $config->targetType = !empty($param['targetType']) ? intval($param['targetType']) : 1; // 推送目标类型:1=群推送,2=好友推送 $config->startTime = $param['startTime']; $config->endTime = $param['endTime']; $config->maxPerDay = intval($param['maxPerDay']); // 每日推送数 $config->pushOrder = $param['pushOrder']; // 推送顺序 - $config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环 + // 根据targetType存储不同的数据 + if ($config->targetType == 1) { + // 群推送 + $config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环 + $config->groups = json_encode($param['wechatGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 群组信息 + $config->friends = json_encode([], JSON_UNESCAPED_UNICODE); // 好友信息为空数组 + $config->devices = json_encode([], JSON_UNESCAPED_UNICODE); // 群推送不需要设备 + } else { + // 好友推送:isLoop必须为0,设备必填 + $config->isLoop = 0; // 好友推送时强制为0 + $config->friends = json_encode($param['wechatFriends'] ?? [], JSON_UNESCAPED_UNICODE); // 好友信息(可以为空数组) + $config->groups = json_encode([], JSON_UNESCAPED_UNICODE); // 群组信息为空数组 + $config->devices = json_encode($param['deviceGroups'] ?? [], JSON_UNESCAPED_UNICODE); // 设备信息(必填) + } $config->status = !empty($param['status']) ? 1 : 0; // 是否启用 - $config->groups = json_encode($param['wechatGroups'], JSON_UNESCAPED_UNICODE); // 群组信息 $config->contentLibraries = json_encode($param['contentGroups'], JSON_UNESCAPED_UNICODE); // 内容库信息 $config->socialMediaId = !empty($param['socialMediaId']) ? $param['socialMediaId'] : ''; $config->promotionSiteId = !empty($param['promotionSiteId']) ? $param['promotionSiteId'] : ''; @@ -957,6 +1020,7 @@ class WorkbenchController extends Controller $newConfig = new WorkbenchGroupPush; $newConfig->workbenchId = $newWorkbench->id; $newConfig->pushType = $config->pushType; + $newConfig->targetType = isset($config->targetType) ? $config->targetType : 1; // 默认1=群推送 $newConfig->startTime = $config->startTime; $newConfig->endTime = $config->endTime; $newConfig->maxPerDay = $config->maxPerDay; @@ -964,7 +1028,11 @@ class WorkbenchController extends Controller $newConfig->isLoop = $config->isLoop; $newConfig->status = $config->status; $newConfig->groups = $config->groups; + $newConfig->friends = $config->friends; + $newConfig->devices = $config->devices; $newConfig->contentLibraries = $config->contentLibraries; + $newConfig->socialMediaId = $config->socialMediaId; + $newConfig->promotionSiteId = $config->promotionSiteId; $newConfig->createTime = time(); $newConfig->updateTime = time(); $newConfig->save(); diff --git a/Server/application/cunkebao/validate/Workbench.php b/Server/application/cunkebao/validate/Workbench.php index ba9cb462..89b8c3bb 100644 --- a/Server/application/cunkebao/validate/Workbench.php +++ b/Server/application/cunkebao/validate/Workbench.php @@ -38,13 +38,16 @@ class Workbench extends Validate 'contentGroups' => 'requireIf:type,2|array', // 群消息推送特有参数 'pushType' => 'requireIf:type,3|in:0,1', // 推送方式 0定时 1立即 + 'targetType' => 'requireIf:type,3|in:1,2', // 推送目标类型:1=群推送,2=好友推送 'startTime' => 'requireIf:type,3|dateFormat:H:i', 'endTime' => 'requireIf:type,3|dateFormat:H:i', 'maxPerDay' => 'requireIf:type,3|number|min:1', 'pushOrder' => 'requireIf:type,3|in:1,2', // 1最早 2最新 'isLoop' => 'requireIf:type,3|in:0,1', 'status' => 'requireIf:type,3|in:0,1', - 'wechatGroups' => 'requireIf:type,3|array|min:1', + 'wechatGroups' => 'checkGroupPushTarget|array|min:1', // 当targetType=1时必填 + 'wechatFriends' => 'checkFriendPushTarget|array', // 当targetType=2时可选(可以为空) + 'deviceGroups' => 'checkFriendPushDevice|array|min:1', // 当targetType=2时必填 'contentGroups' => 'requireIf:type,3|array|min:1', // 自动建群特有参数 'groupNameTemplate' => 'requireIf:type,4|max:50', @@ -114,9 +117,19 @@ class Workbench extends Validate 'pushOrder.in' => '推送顺序错误', 'isLoop.requireIf' => '请选择是否循环推送', 'isLoop.in' => '循环推送参数错误', + 'targetType.requireIf' => '请选择推送目标类型', + 'targetType.in' => '推送目标类型错误,只能选择群推送或好友推送', 'wechatGroups.requireIf' => '请选择推送群组', + 'wechatGroups.checkGroupPushTarget' => '群推送时必须选择推送群组', 'wechatGroups.array' => '推送群组格式错误', 'wechatGroups.min' => '至少选择一个推送群组', + 'wechatFriends.requireIf' => '请选择推送好友', + 'wechatFriends.checkFriendPushTarget' => '好友推送时必须选择推送好友', + 'wechatFriends.array' => '推送好友格式错误', + 'deviceGroups.requireIf' => '请选择设备', + 'deviceGroups.checkFriendPushDevice' => '好友推送时必须选择设备', + 'deviceGroups.array' => '设备格式错误', + 'deviceGroups.min' => '至少选择一个设备', // 自动建群相关提示 'groupNameTemplate.requireIf' => '请设置群名称前缀', 'groupNameTemplate.max' => '群名称前缀最多50个字符', @@ -155,18 +168,18 @@ class Workbench extends Validate protected $scene = [ 'create' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups', 'interval', 'maxLikes', 'startTime', 'endTime', 'contentTypes', - 'syncInterval', 'syncCount', 'syncType', - 'pushType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'contentGroups', - 'groupNamePrefix', 'maxGroups', 'membersPerGroup', + 'syncCount', 'syncType', 'accountGroups', + 'pushType', 'targetType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'wechatFriends', 'contentGroups', 'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax', + 'distributeType', 'timeType', 'accountGroups', ], 'update_status' => ['id', 'status'], - 'edit' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups', + 'update' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups', 'interval', 'maxLikes', 'startTime', 'endTime', 'contentTypes', - 'syncInterval', 'syncCount', 'syncType', - 'pushType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'contentGroups', - 'groupNamePrefix', 'maxGroups', 'membersPerGroup', + 'syncCount', 'syncType', 'accountGroups', + 'pushType', 'targetType', 'startTime', 'endTime', 'maxPerDay', 'pushOrder', 'isLoop', 'status', 'wechatGroups', 'wechatFriends', 'deviceGroups', 'contentGroups', 'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax', + 'distributeType', 'timeType', 'accountGroups', ] ]; @@ -183,4 +196,69 @@ class Workbench extends Validate } return true; } + + /** + * 验证群推送目标(当targetType=1时,wechatGroups必填) + */ + protected function checkGroupPushTarget($value, $rule, $data) + { + // 如果是群消息推送类型 + if (isset($data['type']) && $data['type'] == self::TYPE_GROUP_PUSH) { + // 如果targetType=1(群推送),则wechatGroups必填 + $targetType = isset($data['targetType']) ? intval($data['targetType']) : 1; // 默认1 + if ($targetType == 1) { + // 检查值是否存在且有效 + if (!isset($value) || $value === null || $value === '') { + return false; + } + if (!is_array($value) || count($value) < 1) { + return false; + } + } + } + return true; + } + + /** + * 验证好友推送目标(当targetType=2时,wechatFriends可选,可以为空) + */ + protected function checkFriendPushTarget($value, $rule, $data) + { + // 如果是群消息推送类型 + if (isset($data['type']) && $data['type'] == self::TYPE_GROUP_PUSH) { + // 如果targetType=2(好友推送),wechatFriends可以为空数组 + $targetType = isset($data['targetType']) ? intval($data['targetType']) : 1; // 默认1 + if ($targetType == 2) { + // 如果提供了值,则必须是数组 + if (isset($value) && $value !== null && $value !== '') { + if (!is_array($value)) { + return false; + } + } + } + } + return true; + } + + /** + * 验证好友推送时设备必填(当targetType=2时,deviceGroups必填) + */ + protected function checkFriendPushDevice($value, $rule, $data) + { + // 如果是群消息推送类型 + if (isset($data['type']) && $data['type'] == self::TYPE_GROUP_PUSH) { + // 如果targetType=2(好友推送),则deviceGroups必填 + $targetType = isset($data['targetType']) ? intval($data['targetType']) : 1; // 默认1 + if ($targetType == 2) { + // 检查值是否存在且有效 + if (!isset($value) || $value === null || $value === '') { + return false; + } + if (!is_array($value) || count($value) < 1) { + return false; + } + } + } + return true; + } } \ No newline at end of file diff --git a/Server/application/job/WorkbenchGroupPushJob.php b/Server/application/job/WorkbenchGroupPushJob.php index 8ecbc1a9..e4a1f648 100644 --- a/Server/application/job/WorkbenchGroupPushJob.php +++ b/Server/application/job/WorkbenchGroupPushJob.php @@ -58,7 +58,7 @@ class WorkbenchGroupPushJob { try { // 获取所有工作台 - $workbenches = Workbench::where(['status' => 1, 'type' => 3, 'isDel' => 0])->order('id desc')->select(); + $workbenches = Workbench::where(['status' => 1, 'type' => 3, 'isDel' => 0,'id' => 256])->order('id desc')->select(); foreach ($workbenches as $workbench) { // 获取工作台配置 $config = WorkbenchGroupPush::where('workbenchId', $workbench->id)->find(); @@ -87,27 +87,13 @@ class WorkbenchGroupPushJob } - // 发微信个人消息 + // 发送消息(支持群推送和好友推送) public function sendMsgToGroup($workbench, $config, $msgConf) { // 消息拼接 msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件) // 当前,type 为文本、图片、动图表情包的时候,content为string, 其他情况为对象 {type: 'file/link/...', url: '', title: '', thunmbPath: '', desc: ''} - // $result = [ - // "content" => $dataArray['content'], - // "msgSubType" => 0, - // "msgType" => $dataArray['msgType'], - // "seq" => time(), - // "wechatAccountId" => $dataArray['wechatAccountId'], - // "wechatChatroomId" => 0, - // "wechatFriendId" => $dataArray['wechatFriendId'], - // ]; - - $groups = json_decode($config['groups'], true); - $groupsData = Db::name('wechat_group')->whereIn('id', $groups)->field('id,wechatAccountId,chatroomId,companyId,ownerWechatId')->select(); - if (empty($groupsData)) { - return false; - } + $targetType = isset($config['targetType']) ? intval($config['targetType']) : 1; // 默认1=群推送 $toAccountId = ''; $username = Env::get('api.username', ''); @@ -117,89 +103,49 @@ class WorkbenchGroupPushJob } // 建立WebSocket $wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); + + if ($targetType == 1) { + // 群推送 + $this->sendToGroups($workbench, $config, $msgConf, $wsController); + } else { + // 好友推送 + $this->sendToFriends($workbench, $config, $msgConf, $wsController); + } + } + + /** + * 发送群消息 + */ + protected function sendToGroups($workbench, $config, $msgConf, $wsController) + { + $groups = json_decode($config['groups'], true); + if (empty($groups)) { + return false; + } + + $groupsData = Db::name('wechat_group')->whereIn('id', $groups)->field('id,wechatAccountId,chatroomId,companyId,ownerWechatId')->select(); + if (empty($groupsData)) { + return false; + } + foreach ($msgConf as $content) { $sendData = []; $sqlData = []; - foreach ($groupsData as $groups) { + foreach ($groupsData as $group) { // msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件) $sqlData[] = [ 'workbenchId' => $workbench['id'], 'contentId' => $content['id'], - 'groupId' => $groups['id'], - 'wechatAccountId' => $groups['wechatAccountId'], + 'groupId' => $group['id'], + 'friendId' => null, + 'targetType' => 1, + 'wechatAccountId' => $group['wechatAccountId'], 'createTime' => time() ]; - //内容 - if (!empty($content['content'])) { - //京东转链 - if (!empty($config['promotionSiteId'])){ - $WorkbenchController = new WorkbenchController(); - $jdLink = $WorkbenchController->changeLink($content['content'],$config['promotionSiteId']); - $jdLink = json_decode($jdLink, true); - if($jdLink['code'] == 200){ - $content['content'] = $jdLink['data']; - } - } - $sendData[] = [ - 'content' => $content['content'], - 'msgType' => 1, - 'wechatAccountId' => $groups['wechatAccountId'], - 'wechatChatroomId' => $groups['id'], - ]; - } - - switch ($content['contentType']) { - case 1: - //图片解析 - $imgs = json_decode($content['resUrls'], true); - if (!empty($imgs)) { - foreach ($imgs as $img) { - $sendData[] = [ - 'content' => $img, - 'msgType' => 3, - 'wechatAccountId' => $groups['wechatAccountId'], - 'wechatChatroomId' => $groups['id'], - ]; - } - } - break; - case 2: - //链接解析 - $url = json_decode($content['urls'], true); - if (!empty($url[0])) { - $url = $url[0]; - $sendData[] = [ - 'content' => [ - 'desc' => '', - 'thumbPath' => $url['image'], - 'title' => $url['desc'], - 'type' => 'link', - 'url' => $url['url'], - ], - 'msgType' => 49, - 'wechatAccountId' => $groups['wechatAccountId'], - 'wechatChatroomId' => $groups['id'], - ]; - } - - break; - case 3: - //视频解析 - $video = json_decode($content['urls'], true); - if (!empty($video)) { - $video = $video[0]; - } - $sendData[] = [ - 'content' => $video, - 'msgType' => 43, - 'wechatAccountId' => $groups['wechatAccountId'], - 'wechatChatroomId' => $groups['id'], - ]; - break; - } - + // 构建发送数据 + $sendData = $this->buildSendData($content, $config, $group['wechatAccountId'], $group['id'], 'group'); if (empty($sendData)) { continue; } @@ -214,6 +160,262 @@ class WorkbenchGroupPushJob } } + /** + * 发送好友消息 + */ + protected function sendToFriends($workbench, $config, $msgConf, $wsController) + { + $friends = json_decode($config['friends'], true); + $devices = json_decode($config['devices'] ?? '[]', true); + + // 如果好友列表为空,则根据设备查询所有好友 + if (empty($friends)) { + if (empty($devices)) { + // 如果没有选择设备,则无法推送 + Log::warning('好友推送:未选择设备,无法推送全部好友'); + return false; + } + + // 根据设备查询所有好友 + $friendsData = Db::table('s2_company_account') + ->alias('ca') + ->join(['s2_wechat_account' => 'wa'], 'ca.id = wa.deviceAccountId') + ->join(['s2_wechat_friend' => 'wf'], 'wf.wechatAccountId = wa.id') + ->where([ + 'ca.status' => 0, + 'wf.isDeleted' => 0, + 'wa.deviceAlive' => 1, + 'wa.wechatAlive' => 1 + ]) + ->whereIn('wa.currentDeviceId', $devices) + ->field('wf.id,wf.wechatAccountId,wf.wechatId,wf.ownerWechatId') + ->group('wf.id') + ->select(); + } else { + // 查询指定的好友信息 + $friendsData = Db::table('s2_wechat_friend') + ->whereIn('id', $friends) + ->where('isDeleted', 0) + ->field('id,wechatAccountId,wechatId,ownerWechatId') + ->select(); + } + + if (empty($friendsData)) { + return false; + } + + // 获取所有已推送的好友ID列表(去重,不限制时间范围,用于过滤避免重复推送) + $sentFriendIds = Db::name('workbench_group_push_item') + ->where('workbenchId', $workbench->id) + ->where('targetType', 2) + ->column('friendId'); + $sentFriendIds = array_filter($sentFriendIds); // 过滤null值 + $sentFriendIds = array_unique($sentFriendIds); // 去重 + + // 获取今日已推送的好友ID列表(用于计算今日推送人数) + $today = date('Y-m-d'); + $todayStartTimestamp = strtotime($today . ' 00:00:00'); + $todayEndTimestamp = strtotime($today . ' 23:59:59'); + $todaySentFriendIds = Db::name('workbench_group_push_item') + ->where('workbenchId', $workbench->id) + ->where('targetType', 2) + ->whereTime('createTime', 'between', [$todayStartTimestamp, $todayEndTimestamp]) + ->column('friendId'); + $todaySentFriendIds = array_filter($todaySentFriendIds); // 过滤null值 + $todaySentFriendIds = array_unique($todaySentFriendIds); // 去重 + + // 过滤掉所有已推送的好友(不限制时间范围,避免重复推送) + $friendsData = array_filter($friendsData, function($friend) use ($sentFriendIds) { + return !in_array($friend['id'], $sentFriendIds); + }); + + if (empty($friendsData)) { + Log::info('好友推送:所有好友都已推送过'); + return false; + } + + // 重新索引数组 + $friendsData = array_values($friendsData); + + // 计算剩余可推送人数(基于今日推送人数) + $todaySentCount = count($todaySentFriendIds); + $maxPerDay = intval($config['maxPerDay']); + $remainingCount = $maxPerDay - $todaySentCount; + + if ($remainingCount <= 0) { + Log::info('好友推送:今日推送人数已达上限'); + return false; + } + + // 限制本次推送人数(不超过剩余可推送人数) + $friendsData = array_slice($friendsData, 0, $remainingCount); + + // 批量处理:每批最多500人 + $batchSize = 500; + $batches = array_chunk($friendsData, $batchSize); + + foreach ($msgConf as $content) { + foreach ($batches as $batchIndex => $batch) { + $sqlData = []; + + foreach ($batch as $friend) { + // 构建发送数据 + $sendData = $this->buildSendData($content, $config, $friend['wechatAccountId'], $friend['id'], 'friend'); + + if (empty($sendData)) { + continue; + } + + // 发送个人消息 + foreach ($sendData as $send) { + if ($send['msgType'] == 49){ + $sendContent = json_encode($send['content'], 256); + } else { + $sendContent = $send['content']; + } + $wsController->sendPersonal([ + 'wechatFriendId' => $friend['id'], + 'wechatAccountId' => $friend['wechatAccountId'], + 'msgType' => $send['msgType'], + 'content' => $sendContent, + ]); + } + + // 准备插入发送记录 + $sqlData[] = [ + 'workbenchId' => $workbench['id'], + 'contentId' => $content['id'], + 'groupId' => null, + 'friendId' => $friend['id'], + 'targetType' => 2, + 'wechatAccountId' => $friend['wechatAccountId'], + 'createTime' => time() + ]; + } + + // 批量插入发送记录 + if (!empty($sqlData)) { + Db::name('workbench_group_push_item')->insertAll($sqlData); + Log::info("好友推送:第" . ($batchIndex + 1) . "批,推送了" . count($sqlData) . "个好友"); + } + + // 如果不是最后一批,等待一下再处理下一批(避免一次性推送太多) + if ($batchIndex < count($batches) - 1) { + sleep(1); // 等待1秒 + } + } + } + } + + /** + * 构建发送数据 + */ + protected function buildSendData($content, $config, $wechatAccountId, $targetId, $type = 'group') + { + $sendData = []; + + // 内容处理 + if (!empty($content['content'])) { + // 京东转链 + if (!empty($config['promotionSiteId'])) { + $WorkbenchController = new WorkbenchController(); + $jdLink = $WorkbenchController->changeLink($content['content'], $config['promotionSiteId']); + $jdLink = json_decode($jdLink, true); + if ($jdLink['code'] == 200) { + $content['content'] = $jdLink['data']; + } + } + + if ($type == 'group') { + $sendData[] = [ + 'content' => $content['content'], + 'msgType' => 1, + 'wechatAccountId' => $wechatAccountId, + 'wechatChatroomId' => $targetId, + ]; + } else { + $sendData[] = [ + 'content' => $content['content'], + 'msgType' => 1, + ]; + } + } + + // 根据内容类型处理 + switch ($content['contentType']) { + case 1: + // 图片解析 + $imgs = json_decode($content['resUrls'], true); + if (!empty($imgs)) { + foreach ($imgs as $img) { + if ($type == 'group') { + $sendData[] = [ + 'content' => $img, + 'msgType' => 3, + 'wechatAccountId' => $wechatAccountId, + 'wechatChatroomId' => $targetId, + ]; + } else { + $sendData[] = [ + 'content' => $img, + 'msgType' => 3, + ]; + } + } + } + break; + case 2: + // 链接解析 + $url = json_decode($content['urls'], true); + if (!empty($url[0])) { + $url = $url[0]; + $linkContent = [ + 'desc' => $url['desc'], + 'thumbPath' => $url['image'], + 'title' => $url['desc'], + 'type' => 'link', + 'url' => $url['url'], + ]; + if ($type == 'group') { + $sendData[] = [ + 'content' => $linkContent, + 'msgType' => 49, + 'wechatAccountId' => $wechatAccountId, + 'wechatChatroomId' => $targetId, + ]; + } else { + $sendData[] = [ + 'content' => $linkContent, + 'msgType' => 49, + ]; + } + } + break; + case 3: + // 视频解析 + $video = json_decode($content['resUrls'], true); + if (!empty($video)) { + $video = $video[0]; + } + if ($type == 'group') { + $sendData[] = [ + 'content' => $video, + 'msgType' => 43, + 'wechatAccountId' => $wechatAccountId, + 'wechatChatroomId' => $targetId, + ]; + } else { + $sendData[] = [ + 'content' => $video, + 'msgType' => 43, + ]; + } + break; + } + + return $sendData; + } + /** * 记录发送历史 @@ -260,23 +462,51 @@ class WorkbenchGroupPushJob if ($totalSeconds <= 0 || empty($config['maxPerDay'])) { return false; } - $interval = floor($totalSeconds / $config['maxPerDay']); + $targetType = isset($config['targetType']) ? intval($config['targetType']) : 1; // 默认1=群推送 - // 查询今日已同步次数 - $count = Db::name('workbench_group_push_item') - ->where('workbenchId', $workbench->id) - ->whereTime('createTime', 'between', [$startTimestamp, $endTimestamp]) - ->count(); - if ($count >= $config['maxPerDay']) { - return false; - } - - // 计算本次同步的最早允许时间 - $nextSyncTime = $startTimestamp + $count * $interval; - if (time() < $nextSyncTime) { - return false; + if ($targetType == 2) { + // 好友推送:maxPerDay表示每日推送人数 + // 查询今日已推送的好友ID列表(去重,仅统计今日) + $sentFriendIds = Db::name('workbench_group_push_item') + ->where('workbenchId', $workbench->id) + ->where('targetType', 2) + ->whereTime('createTime', 'between', [$startTimestamp, $endTimestamp]) + ->column('friendId'); + $sentFriendIds = array_filter($sentFriendIds); // 过滤null值 + $count = count(array_unique($sentFriendIds)); // 去重后统计今日推送人数 + + if ($count >= $config['maxPerDay']) { + return false; + } + + // 计算本次同步的最早允许时间(按人数计算间隔) + $interval = floor($totalSeconds / $config['maxPerDay']); + $nextSyncTime = $startTimestamp + $count * $interval; + if (time() < $nextSyncTime) { + return false; + } + } else { + // 群推送:maxPerDay表示每日推送次数 + $interval = floor($totalSeconds / $config['maxPerDay']); + + // 查询今日已同步次数 + $count = Db::name('workbench_group_push_item') + ->where('workbenchId', $workbench->id) + ->where('targetType', 1) + ->whereTime('createTime', 'between', [$startTimestamp, $endTimestamp]) + ->count(); + if ($count >= $config['maxPerDay']) { + return false; + } + + // 计算本次同步的最早允许时间 + $nextSyncTime = $startTimestamp + $count * $interval; + if (time() < $nextSyncTime) { + return false; + } } + return true; } @@ -293,13 +523,14 @@ class WorkbenchGroupPushJob return false; } + $targetType = isset($config['targetType']) ? intval($config['targetType']) : 1; // 默认1=群推送 + if ($config['pushType'] == 1) { $limit = 10; } else { $limit = 1; } - //推送顺序 if ($config['pushOrder'] == 1) { $order = 'ci.sendTime desc, ci.id asc'; @@ -307,11 +538,10 @@ class WorkbenchGroupPushJob $order = 'ci.sendTime desc, ci.id desc'; } - - // 基础查询 + // 基础查询,根据targetType过滤记录 $query = Db::name('content_library')->alias('cl') ->join('content_item ci', 'ci.libraryId = cl.id') - ->join('workbench_group_push_item wgpi', 'wgpi.contentId = ci.id and wgpi.workbenchId = ' . $workbench->id, 'left') + ->join('workbench_group_push_item wgpi', 'wgpi.contentId = ci.id and wgpi.workbenchId = ' . $workbench->id . ' and wgpi.targetType = ' . $targetType, 'left') ->where(['cl.isDel' => 0, 'ci.isDel' => 0]) ->where('ci.sendTime <= ' . (time() + 60)) ->whereIn('cl.id', $contentids) @@ -329,9 +559,9 @@ class WorkbenchGroupPushJob // 复制 query $query2 = clone $query; $query3 = clone $query; - // 根据accountType处理不同的发送逻辑 + // 根据isLoop处理不同的发送逻辑 if ($config['isLoop'] == 1) { - // 可以循环发送 + // 可以循环发送(只有群推送时才能为1) // 1. 优先获取未发送的内容 $unsentContent = $query->where('wgpi.id', 'null') ->order($order) @@ -340,8 +570,20 @@ class WorkbenchGroupPushJob if (!empty($unsentContent)) { return $unsentContent; } - $lastSendData = Db::name('workbench_group_push_item')->where('workbenchId', $workbench->id)->order('id desc')->find(); - $fastSendData = Db::name('workbench_group_push_item')->where('workbenchId', $workbench->id)->order('id asc')->find(); + $lastSendData = Db::name('workbench_group_push_item') + ->where('workbenchId', $workbench->id) + ->where('targetType', $targetType) + ->order('id desc') + ->find(); + $fastSendData = Db::name('workbench_group_push_item') + ->where('workbenchId', $workbench->id) + ->where('targetType', $targetType) + ->order('id asc') + ->find(); + + if (empty($lastSendData) || empty($fastSendData)) { + return []; + } $sentContent = $query2->where('wgpi.contentId', '<', $lastSendData['contentId'])->order('wgpi.id ASC')->group('wgpi.contentId')->limit(0, $limit)->select(); @@ -350,7 +592,7 @@ class WorkbenchGroupPushJob } return $sentContent; } else { - // 不能循环发送,只获取未发送的内容 + // 不能循环发送,只获取未发送的内容(好友推送时isLoop=0) $list = $query->where('wgpi.id', 'null') ->order($order) ->limit(0, $limit)