代码提交

This commit is contained in:
wong
2025-11-07 15:25:50 +08:00
parent 8daa0a5d5c
commit cf75ee4150
8 changed files with 1123 additions and 141 deletions

View File

@@ -0,0 +1,249 @@
# RFM 客户价值评分体系技术实施文档
## 1. 文档目的
本文档旨在明确 RFMRecency-Frequency-Monetary客户价值评分体系的技术实现标准包括维度定义、评分规则、数据处理流程、参数配置及异常处理方案为系统开发、数据分析及业务应用提供统一依据。
## 2. 核心术语定义
| 术语 | 英文缩写 | 定义 | 数据来源 | 统计周期说明 |
| ------ | ------------ | ------------------------------- | --------- | ---------------------------------- |
| 最近消费时间 | RecencyR | 客户最后一次有效消费行为距统计截止日的时间间隔(单位:天) | 订单系统、交易日志 | 支持自定义配置(默认 3-12 个月,按业务场景调整) |
| 消费频率 | FrequencyF | 统计周期内客户发生有效消费行为的总次数 | 订单系统、交易日志 | 与 R 维度统计周期一致,剔除重复下单、取消订单等无效记录 |
| 消费金额 | MonetaryM | 统计周期内客户有效消费行为的总金额(单位:元,支持多币种换算) | 订单系统、支付日志 | 仅统计已支付完成的订单金额,剔除退款、优惠抵扣部分 |
| RFM 总分 | RFM Score | 基于 R、F、M 三个维度的分项得分,按预设权重计算的综合得分 | 系统计算生成 | 得分范围 1-15 分5 分制单项)或 1-100 分(标准化后) |
## 3. 评分规则技术规范
### 3.1 分项评分规则(默认 5 分制)
#### 3.1.1 RecencyR评分规则
* 核心逻辑:时间间隔越短,得分越高(反向映射)
* 分段标准:采用**五分位法**(按数据分布自动划分区间,避免均分失真)
| 得分 | 时间间隔区间(天) | 划分逻辑 |
| --- | ---------- | ----------------- |
| 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 FrequencyF评分规则
* 核心逻辑:消费次数越多,得分越高(正向映射)
* 分段标准:采用**五分位法**(支持最小消费次数阈值配置)
| 得分 | 消费次数区间 | 划分逻辑 |
| --- | ----------- | ------------------- |
| 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 MonetaryM评分规则
* 核心逻辑:消费金额越高,得分越高(正向映射)
* 分段标准:采用**五分位法**(支持剔除大额异常值后划分)
| 得分 | 消费金额区间(元) | 划分逻辑 |
| --- | ----------- | ------------------- |
| 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 原始指标:
* RMAX (消费时间) 到统计截止日的时间间隔(天)
* FCOUNT (DISTINCT 订单唯一标识)
* MSUM (消费金额)
1. **缺失值处理**
* 统计周期内无消费记录的客户R = 统计周期总天数F=0M=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 生成)

View File

@@ -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));
// 接收响应

View File

@@ -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,

View File

@@ -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} 已经在运行中,跳过执行");

View File

@@ -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());
}
}
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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)