代码提交
This commit is contained in:
249
Server/RFM客户价值评分体系技术实施文档.md
Normal file
249
Server/RFM客户价值评分体系技术实施文档.md
Normal file
@@ -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 生成)
|
||||
@@ -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));
|
||||
// 接收响应
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 已经在运行中,跳过执行");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user