diff --git a/Server/application/api/controller/MessageController.php b/Server/application/api/controller/MessageController.php index 5ce747e35..8e4dd7e4e 100644 --- a/Server/application/api/controller/MessageController.php +++ b/Server/application/api/controller/MessageController.php @@ -472,7 +472,13 @@ class MessageController extends BaseController if (!empty($res) && empty($item['isSend']) && in_array($item['msgType'],[1,3,20,34,40,42,43,47,49])){ $friend = Db::name('wechat_friendship')->where('id',$item['wechatFriendId'])->find(); if (!empty($friend)){ - $trafficPoolId = Db::name('traffic_pool_v1')->where('identifier',$friend['wechatId'])->value('id'); + // ========== 旧版流量池代码(已废弃) ========== + // $trafficPoolId = Db::name('traffic_pool_v1')->where('identifier',$friend['wechatId'])->value('id'); + // ========== 新版流量池代码 ========== + $trafficPool = Db::name('traffic_pool')->where('identifier', $friend['wechatId'])->find(); + $trafficPoolId = $trafficPool ? $trafficPool['id'] : null; + // ========== 旧版流量池代码结束 ========== + if (!empty($trafficPoolId)){ $data = [ 'type' => 4, diff --git a/Server/application/command/CleanLogsCommand.php b/Server/application/command/CleanLogsCommand.php new file mode 100644 index 000000000..b9f0ba807 --- /dev/null +++ b/Server/application/command/CleanLogsCommand.php @@ -0,0 +1,188 @@ +setName('clean:logs') + ->setDescription('清除过期的日志文件') + ->addOption('days', 'd', Option::VALUE_OPTIONAL, '保留天数(默认:10天)', 10) + ->addOption('dry-run', null, Option::VALUE_NONE, '预览模式,不实际删除文件'); + } + + protected function execute(Input $input, Output $output) + { + $days = (int)$input->getOption('days'); + $dryRun = $input->getOption('dry-run'); + + if ($days <= 0) { + $output->writeln('保留天数必须大于0'); + return false; + } + + if ($dryRun) { + $output->writeln('运行在预览模式,不会实际删除文件'); + } + + $output->writeln("===================================="); + $output->writeln(" 清除过期日志文件"); + $output->writeln("===================================="); + $output->writeln("保留天数: {$days} 天"); + $output->writeln(""); + + // 获取日志目录 + $logPath = App::getRuntimePath() . 'log' . DIRECTORY_SEPARATOR; + + if (!is_dir($logPath)) { + $output->writeln("日志目录不存在: {$logPath}"); + return false; + } + + // 计算截止时间(保留指定天数之前的日志) + $cutoffTime = time() - ($days * 24 * 60 * 60); + $cutoffDate = date('Y-m-d H:i:s', $cutoffTime); + + $output->writeln("清除 {$cutoffDate} 之前的日志文件"); + $output->writeln(""); + + // 统计信息 + $totalFiles = 0; + $deletedFiles = 0; + $totalSize = 0; + $freedSize = 0; + + try { + // 递归扫描日志目录 + $result = $this->cleanLogDirectory($logPath, $cutoffTime, $dryRun, $output); + + $totalFiles = $result['total']; + $deletedFiles = $result['deleted']; + $totalSize = $result['totalSize']; + $freedSize = $result['freedSize']; + + } catch (\Exception $e) { + $output->writeln('清除日志时发生错误: ' . $e->getMessage() . ''); + Log::error('清除日志失败: ' . $e->getMessage()); + return false; + } + + // 输出统计信息 + $output->writeln(""); + $output->writeln("===================================="); + $output->writeln(" 清除完成"); + $output->writeln("===================================="); + $output->writeln("扫描文件数: {$totalFiles}"); + $output->writeln("删除文件数: {$deletedFiles}"); + $output->writeln("释放空间: " . $this->formatBytes($freedSize)); + + if ($dryRun) { + $output->writeln(""); + $output->writeln("预览模式:实际未删除任何文件"); + } + + return true; + } + + /** + * 递归清理日志目录 + */ + protected function cleanLogDirectory($dir, $cutoffTime, $dryRun, Output $output) + { + $total = 0; + $deleted = 0; + $totalSize = 0; + $freedSize = 0; + + if (!is_dir($dir)) { + return ['total' => 0, 'deleted' => 0, 'totalSize' => 0, 'freedSize' => 0]; + } + + $items = scandir($dir); + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir . $item; + + if (is_dir($path)) { + // 递归处理子目录 + $result = $this->cleanLogDirectory($path . DIRECTORY_SEPARATOR, $cutoffTime, $dryRun, $output); + $total += $result['total']; + $deleted += $result['deleted']; + $totalSize += $result['totalSize']; + $freedSize += $result['freedSize']; + } elseif (is_file($path)) { + $total++; + $fileSize = filesize($path); + $totalSize += $fileSize; + + // 获取文件修改时间 + $fileMTime = filemtime($path); + + // 如果文件修改时间早于截止时间,则删除 + if ($fileMTime < $cutoffTime) { + $freedSize += $fileSize; + + if ($dryRun) { + $output->writeln("[预览] 将删除: {$path} (" . date('Y-m-d H:i:s', $fileMTime) . ", " . $this->formatBytes($fileSize) . ")"); + } else { + if (@unlink($path)) { + $deleted++; + $output->writeln("已删除: {$path}"); + } else { + $output->writeln("删除失败: {$path}"); + } + } + } + } + } + + return [ + 'total' => $total, + 'deleted' => $deleted, + 'totalSize' => $totalSize, + 'freedSize' => $freedSize, + ]; + } + + /** + * 格式化字节数 + */ + protected function formatBytes($bytes, $precision = 2) + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + + if ($bytes == 0) { + return '0 B'; + } + + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= pow(1024, $pow); + + return round($bytes, $precision) . ' ' . $units[$pow]; + } +} + diff --git a/Server/application/common/model/TrafficPool.php b/Server/application/common/model/TrafficPool.php index 7ef550be6..110740d8f 100644 --- a/Server/application/common/model/TrafficPool.php +++ b/Server/application/common/model/TrafficPool.php @@ -5,12 +5,21 @@ namespace app\common\model; use think\Model; /** - * 流量池模型类 + * 流量池模型类(旧版,已废弃) + * + * @deprecated 此模型已废弃,请使用 TrafficPoolV2 模型 + * 旧表:ck_traffic_pool_v1 + * 新表:ck_traffic_pool(使用 TrafficPoolV2 模型) */ class TrafficPool extends Model { + // ========== 旧版流量池表(已废弃) ========== // 设置数据表名 - protected $name = 'traffic_pool_v1'; + // protected $name = 'traffic_pool_v1'; + // ========== 新版流量池表 ========== + // 注意:为了兼容性,暂时保留此模型,但表名已改为新版 + // 新代码请使用 TrafficPoolV2 模型 + protected $name = 'traffic_pool'; // 自动写入时间戳 protected $autoWriteTimestamp = true; diff --git a/Server/application/common/model/TrafficPoolSource.php b/Server/application/common/model/TrafficPoolSource.php index 4ce56e41f..f73f21969 100644 --- a/Server/application/common/model/TrafficPoolSource.php +++ b/Server/application/common/model/TrafficPoolSource.php @@ -221,6 +221,15 @@ class TrafficPoolSource extends Model $sourceData['chatroomOwners'] = []; $sourceData['chatroomInfo'] = null; $sourceData['displayId'] = $source['sourceWechatId'] ?: ''; + + // 尝试获取好友头像 + if (!empty($source['sourceWechatId'])) { + $sourceData['sourceAvatar'] = Db::table('ck_traffic_pool') + ->where('wechatId', $source['sourceWechatId']) + ->value('avatar') ?: Db::table('s2_wechat_friend') + ->where('wechatId', $source['sourceWechatId']) + ->value('headImgUrl') ?: ''; + } } else { $sourceData['chatroomOwners'] = []; $sourceData['chatroomInfo'] = null; @@ -434,6 +443,17 @@ class TrafficPoolSource extends Model $chatroomId = $source['sourceChatroomId']; $sourceData['chatroomOwners'] = $chatroomOwners[$chatroomId] ?? []; $sourceData['chatroomInfo'] = self::getChatroomInfo($chatroomId); + } elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) { + $sourceData['chatroomOwners'] = []; + $sourceData['chatroomInfo'] = null; + // 尝试获取好友头像 + if (!empty($source['sourceWechatId'])) { + $sourceData['sourceAvatar'] = Db::table('ck_traffic_pool') + ->where('wechatId', $source['sourceWechatId']) + ->value('avatar') ?: Db::table('s2_wechat_friend') + ->where('wechatId', $source['sourceWechatId']) + ->value('headImgUrl') ?: ''; + } } else { $sourceData['chatroomOwners'] = []; $sourceData['chatroomInfo'] = null; diff --git a/Server/application/cunkebao/config/route.php b/Server/application/cunkebao/config/route.php index eed3f5094..02e04f005 100644 --- a/Server/application/cunkebao/config/route.php +++ b/Server/application/cunkebao/config/route.php @@ -90,6 +90,8 @@ Route::group('v1/', function () { Route::put('group/update', 'app\cunkebao\controller\TrafficPoolV2Controller@updateGroup'); // 更新分组 Route::delete('group/delete', 'app\cunkebao\controller\TrafficPoolV2Controller@deleteGroup'); // 删除分组 Route::get('group/members', 'app\cunkebao\controller\TrafficPoolV2Controller@getGroupMembers'); // 获取分组成员 + Route::post('preview-users', 'app\cunkebao\controller\TrafficPoolV2Controller@previewUsers'); // 预览用户列表(根据筛选条件) + Route::get('filter-fields', 'app\cunkebao\controller\TrafficPoolV2Controller@getFilterFields'); // 获取筛选字段元数据 Route::post('group/add-members', 'app\cunkebao\controller\TrafficPoolV2Controller@addMembersToGroup'); // 添加成员到分组 Route::post('group/remove-members', 'app\cunkebao\controller\TrafficPoolV2Controller@removeMembersFromGroup'); // 移除分组成员 @@ -106,6 +108,10 @@ Route::group('v1/', function () { Route::delete('tag/remove', 'app\cunkebao\controller\TrafficPoolV2Controller@removeTag'); // 移除标签 Route::post('tag/sync-from-engine', 'app\cunkebao\controller\TrafficPoolV2Controller@syncTagsFromEngine'); // 从标签引擎同步标签 + // RFM评分相关 + Route::post('calculate-rfm', 'app\cunkebao\controller\TrafficPoolV2Controller@calculateRfm'); // 计算RFM评分 + Route::post('group/:groupId/calculate-rfm', 'app\cunkebao\controller\TrafficPoolV2Controller@calculateGroupRfm'); // 批量计算分组RFM评分 + // 分配相关 Route::post('allocate', 'app\cunkebao\controller\TrafficPoolV2Controller@allocatePool'); // 分配流量 Route::post('recycle', 'app\cunkebao\controller\TrafficPoolV2Controller@recyclePool'); // 回收流量 diff --git a/Server/application/cunkebao/controller/RFMController.php b/Server/application/cunkebao/controller/RFMController.php index 0fb4daef5..0f38336bf 100644 --- a/Server/application/cunkebao/controller/RFMController.php +++ b/Server/application/cunkebao/controller/RFMController.php @@ -46,14 +46,19 @@ class RFMController extends BaseController $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'; + $missingStrategy = isset($config['missing_strategy']) ? $confi961102'] : 'score_1'; // 权重归一化处理 $weightSum = $weightR + $weightF + $weightM; - if ($weightSum != 1.0) { + if ($weightSum != 1.0 && $weightSum > 0) { $weightR = $weightR / $weightSum; $weightF = $weightF / $weightSum; $weightM = $weightM / $weightSum; + } elseif ($weightSum == 0) { + // 如果权重全为0,使用默认权重 + $weightR = self::DEFAULT_WEIGHT_R; + $weightF = self::DEFAULT_WEIGHT_F; + $weightM = self::DEFAULT_WEIGHT_M; } // 计算时间范围 @@ -111,6 +116,8 @@ class RFMController extends BaseController // 3. 异常值处理 - 剔除大额异常订单 $mValues = array_column($customerData, 'M'); + $abnormalThreshold = null; // 初始化异常阈值 + if (!empty($mValues)) { sort($mValues); $m99Percentile = $this->percentile($mValues, 0.99); @@ -120,6 +127,11 @@ class RFMController extends BaseController foreach ($customerData as &$customer) { $customer['isAbnormal'] = $customer['M'] > $abnormalThreshold; } + } else { + // 如果没有M值数据,标记所有客户为非异常 + foreach ($customerData as &$customer) { + $customer['isAbnormal'] = false; + } } // 4. 使用五分位法计算各维度的区间阈值 @@ -127,7 +139,7 @@ class RFMController extends BaseController $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; + return $abnormalThreshold !== null ? $m <= $abnormalThreshold : true; }); $mThresholds = $this->calculatePercentiles(array_values($mValuesForPercentile), false); @@ -136,7 +148,7 @@ class RFMController extends BaseController 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); // 异常值给最高分 + $mScore = isset($customer['isAbnormal']) && $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分 // 计算RFM总分(加权求和) $rfmScore = $rScore * $weightR + $fScore * $weightF + $mScore * $weightM; @@ -146,7 +158,8 @@ class RFMController extends BaseController 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); + $range = $rfmMax - $rfmMin; + $standardScore = $range > 0 ? (int)round(($rfmScore - $rfmMin) / $range * 99 + 1) : 1; } $results[] = [ @@ -187,7 +200,7 @@ class RFMController extends BaseController ], 'statistics' => [ 'total_customers' => count($results), - 'avg_rfm_score' => round(array_sum(array_column($results, 'RFM_score')) / count($results), 2), + 'avg_rfm_score' => count($results) > 0 ? round(array_sum(array_column($results, 'RFM_score')) / count($results), 2) : 0, ] ] ]; @@ -352,7 +365,7 @@ class RFMController extends BaseController } /** - * 更新RFM值到 ck_traffic_source_v1 和 s2_wechat_friend 表 + * 更新RFM值到 ck_traffic_source_v1、s2_wechat_friend 和 ck_traffic_pool_company 表 * * @param array $results RFM计算结果数组 * @param string|null $ownerWechatId 微信ID,用于过滤更新范围 @@ -365,8 +378,11 @@ class RFMController extends BaseController $rScore = (string)$result['R_score']; $fScore = (string)$result['F_score']; $mScore = (string)$result['M_score']; + $rfmRaw = $result['R_raw']; + $rfmF = $result['F_raw']; + $rfmM = $result['M_raw']; - // 更新 ck_traffic_source_v1 表 + // 更新 ck_traffic_source_v1 表(V1旧表) // 根据 identifier 更新所有匹配的记录 $trafficSourceUpdate = [ 'R' => $rScore, @@ -389,6 +405,18 @@ class RFMController extends BaseController $wechatFriendWhere['ownerWechatId'] = $ownerWechatId; } WechatFriendModel::where($wechatFriendWhere)->update($wechatFriendUpdate); + + // 更新 ck_traffic_pool_company 表(V2新表) + // 根据 identifier 更新,identifier可能是wechatId、phone等 + $poolCompanyUpdate = [ + 'rfmF' => $rfmF, + 'rfmM' => $rfmM, + 'updateTime' => date('Y-m-d H:i:s') + ]; + Db::table('ck_traffic_pool_company') + ->where('identifier', $identifier) + ->where('isDel', 0) + ->update($poolCompanyUpdate); } } catch (\Exception $e) { diff --git a/Server/application/cunkebao/controller/TrafficController.php b/Server/application/cunkebao/controller/TrafficController.php index 711214f45..faa5ab16b 100644 --- a/Server/application/cunkebao/controller/TrafficController.php +++ b/Server/application/cunkebao/controller/TrafficController.php @@ -412,7 +412,11 @@ class TrafficController extends BaseController 'wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.alias' ] ) - ->join('traffic_pool_v1 p', 'p.identifier=tspi.identifier', 'left') + // ========== 旧版流量池代码(已废弃) ========== + // ->join('traffic_pool_v1 p', 'p.identifier=tspi.identifier', 'left') + // ========== 新版流量池代码 ========== + ->join('traffic_pool p', 'p.identifier=tspi.identifier', 'left') + // ========== 旧版流量池代码结束 ========== ->join('wechat_account wa', 'tspi.identifier=wa.wechatId', 'left') ->where($where); diff --git a/Server/application/cunkebao/controller/TrafficPoolV2Controller.php b/Server/application/cunkebao/controller/TrafficPoolV2Controller.php index 5954ac3eb..a55983028 100644 --- a/Server/application/cunkebao/controller/TrafficPoolV2Controller.php +++ b/Server/application/cunkebao/controller/TrafficPoolV2Controller.php @@ -97,12 +97,156 @@ class TrafficPoolV2Controller extends BaseController try { $group = $this->groupService->createGroup($companyId, $data, $userId); - return ResponseHelper::success(['id' => $group->id], '创建成功'); + return ResponseHelper::success([ + 'id' => $group->id, + 'groupName' => $group->groupName + ], '创建成功'); } catch (\Exception $e) { return ResponseHelper::error('创建分组失败:' . $e->getMessage()); } } + /** + * 根据筛选条件预览用户列表 + * GET /v1/traffic/pool/v2/preview-users + * + * @return \think\response\Json + */ + public function previewUsers() + { + $companyId = $this->getUserInfo('companyId'); + $ruleConfig = $this->request->param('ruleConfig'); + $page = $this->request->param('page', 1, 'intval'); + $pageSize = $this->request->param('pageSize', 20, 'intval'); + $keyword = $this->request->param('keyword', ''); + + if (empty($ruleConfig)) { + return ResponseHelper::error('筛选条件不能为空'); + } + + // 如果ruleConfig是JSON字符串,解析它 + if (is_string($ruleConfig)) { + $ruleConfig = json_decode($ruleConfig, true); + } + + // 从ruleConfig中提取keyword(如果前端放在里面的话) + if (empty($keyword) && isset($ruleConfig['keyword'])) { + $keyword = $ruleConfig['keyword']; + unset($ruleConfig['keyword']); + } + + try { + $result = $this->groupService->previewGroupMembers($companyId, $ruleConfig, $page, $pageSize, $keyword); + return ResponseHelper::success($result); + } catch (\Exception $e) { + return ResponseHelper::error('获取用户列表失败:' . $e->getMessage()); + } + } + + /** + * 获取筛选条件可选项(字段元数据) + * GET /v1/traffic/pool/v2/filter-fields + * + * @return \think\response\Json + */ + public function getFilterFields() + { + try { + $fields = [ + [ + 'field' => 'lifecycle', + 'label' => '客户周期', + 'type' => 'select', + 'options' => [ + ['label' => '新流量', 'value' => 1], + ['label' => '成长期', 'value' => 2], + ['label' => '成熟期', 'value' => 3], + ['label' => '衰退期', 'value' => 4], + ['label' => '流失期', 'value' => 5], + ] + ], + [ + 'field' => 'intentionLevel', + 'label' => '意向等级', + 'type' => 'select', + 'options' => [ + ['label' => '未知', 'value' => 0], + ['label' => '低意向', 'value' => 1], + ['label' => '中意向', 'value' => 2], + ['label' => '高意向', 'value' => 3], + ] + ], + [ + 'field' => 'level', + 'label' => '客户等级', + 'type' => 'select', + 'options' => [ + ['label' => '普通', 'value' => 0], + ['label' => '白银', 'value' => 1], + ['label' => '黄金', 'value' => 2], + ['label' => '钻石', 'value' => 3], + ] + ], + [ + 'field' => 'gender', + 'label' => '性别', + 'type' => 'select', + 'options' => [ + ['label' => '未知', 'value' => 0], + ['label' => '男', 'value' => 1], + ['label' => '女', 'value' => 2], + ] + ], + [ + 'field' => 'friendStatus', + 'label' => '好友状态', + 'type' => 'select', + 'options' => [ + ['label' => '未添加', 'value' => 0], + ['label' => '已申请', 'value' => 1], + ['label' => '已通过', 'value' => 2], + ['label' => '已拒绝', 'value' => 3], + ['label' => '已删除', 'value' => 4], + ] + ], + [ + 'field' => 'province', + 'label' => '地区', + 'type' => 'province', + ], + [ + 'field' => 'totalOrderAmount', + 'label' => '总消费金额', + 'type' => 'number', + ], + [ + 'field' => 'totalOrderCount', + 'label' => '订单数量', + 'type' => 'number', + ], + [ + 'field' => 'totalMsgCount', + 'label' => '消息数量', + 'type' => 'number', + ], + [ + 'field' => 'rfmF', + 'label' => 'RFM-F值', + 'type' => 'number', + ], + [ + 'field' => 'rfmM', + 'label' => 'RFM-M值', + 'type' => 'number', + ], + ]; + + return ResponseHelper::success($fields); + } catch (\Exception $e) { + return ResponseHelper::error('获取字段列表失败:' . $e->getMessage()); + } + } + /** * 更新分组 * @return \think\response\Json @@ -658,5 +802,96 @@ class TrafficPoolV2Controller extends BaseController return ResponseHelper::error('获取行为轨迹失败:' . $e->getMessage()); } } + + /** + * 计算并更新RFM评分 + * POST /v1/traffic/pool/v2/calculate-rfm + * + * @return \think\response\Json + */ + public function calculateRfm() + { + $companyId = $this->getUserInfo('companyId'); + $identifier = $this->request->post('identifier', null); // 可选,指定用户标识 + + try { + // 实例化RFM控制器(传递ClassTableService) + $rfmController = new RFMController($this->classTable); + + // 获取配置参数(可从请求参数中获取,或使用默认值) + $config = [ + 'cycle_days' => $this->request->post('cycle_days', 180), + 'weight_R' => $this->request->post('weight_R', 0.4), + 'weight_F' => $this->request->post('weight_F', 0.3), + 'weight_M' => $this->request->post('weight_M', 0.3), + 'score_scale' => $this->request->post('score_scale', 5), + ]; + + // 调用RFM计算方法 + // 注意:这里不传ownerWechatId,因为V2系统是按companyId区分的 + $result = $rfmController->calculateRfmFromTrafficOrder($identifier, null, $config); + + if ($result['code'] == 200) { + return ResponseHelper::success($result['data'], 'RFM计算完成'); + } else { + return ResponseHelper::error($result['msg']); + } + + } catch (\Exception $e) { + return ResponseHelper::error('RFM计算失败:' . $e->getMessage()); + } + } + + /** + * 批量更新指定分组的RFM评分 + * POST /v1/traffic/pool/v2/group/{groupId}/calculate-rfm + * + * @return \think\response\Json + */ + public function calculateGroupRfm() + { + $companyId = $this->getUserInfo('companyId'); + $groupId = $this->request->param('groupId'); + + if (empty($groupId)) { + return ResponseHelper::error('分组ID不能为空'); + } + + try { + // 获取分组成员 + $members = $this->groupService->getGroupMembers($groupId, $companyId, 1, 9999, []); + + if (empty($members['list'])) { + return ResponseHelper::error('分组无成员'); + } + + // 实例化RFM控制器(传递ClassTableService) + $rfmController = new RFMController($this->classTable); + + $successCount = 0; + $failCount = 0; + + // 为每个成员计算RFM + foreach ($members['list'] as $member) { + $identifier = $member['identifier']; + $result = $rfmController->calculateRfmFromTrafficOrder($identifier, null, []); + + if ($result['code'] == 200) { + $successCount++; + } else { + $failCount++; + } + } + + return ResponseHelper::success([ + 'total' => count($members['list']), + 'success' => $successCount, + 'fail' => $failCount + ], 'RFM批量计算完成'); + + } catch (\Exception $e) { + return ResponseHelper::error('RFM批量计算失败:' . $e->getMessage()); + } + } } diff --git a/Server/application/cunkebao/controller/plan/PostExternalApiV1Controller.php b/Server/application/cunkebao/controller/plan/PostExternalApiV1Controller.php index 6db49e5aa..0ecdebbb8 100644 --- a/Server/application/cunkebao/controller/plan/PostExternalApiV1Controller.php +++ b/Server/application/cunkebao/controller/plan/PostExternalApiV1Controller.php @@ -6,6 +6,8 @@ use library\ResponseHelper; use think\Controller; use think\Db; use app\cunkebao\service\DistributionRewardService; +use app\cunkebao\service\TrafficPoolService; +use app\common\model\TrafficPoolSource; /** * 对外API接口控制器 @@ -95,27 +97,28 @@ class PostExternalApiV1Controller extends Controller // 渠道ID(cid),对应 distribution_channel.id $channelId = !empty($params['cid']) ? intval($params['cid']) : 0; - - $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $identifier)->find(); - if (!$trafficPool) { - $trafficPoolId =Db::name('traffic_pool_v1')->insertGetId([ - 'identifier' => $identifier, - 'mobile' => !empty($params['phone']) ? $params['phone'] : '', - 'createTime' => time() - ]); - }else{ - $trafficPoolId = $trafficPool['id']; - } + // ========== 旧版流量池代码(已废弃,保留用于兼容) ========== + // $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $identifier)->find(); + // if (!$trafficPool) { + // $trafficPoolId =Db::name('traffic_pool_v1')->insertGetId([ + // 'identifier' => $identifier, + // 'mobile' => !empty($params['phone']) ? $params['phone'] : '', + // 'createTime' => time() + // ]); + // }else{ + // $trafficPoolId = $trafficPool['id']; + // } + // ========== 旧版流量池代码结束 ========== $taskCustomer = Db::name('task_customer') ->where('task_id', $plan['id']) ->where('phone', $identifier) ->find(); - // 处理用户画像 - if(!empty($params['portrait']) && is_array($params['portrait'])){ - $this->updatePortrait($params['portrait'],$trafficPoolId,$plan['companyId']); - } + // 处理用户画像(已迁移到V2流量池,此处保留兼容) + // if(!empty($params['portrait']) && is_array($params['portrait'])){ + // $this->updatePortrait($params['portrait'],$trafficPoolId,$plan['companyId']); + // } if (!$taskCustomer) { $tags = !empty($params['tags']) ? explode(',', $params['tags']) : []; $siteTags = !empty($params['siteTags']) ? explode(',', $params['siteTags']) : []; @@ -154,6 +157,60 @@ class PostExternalApiV1Controller extends Controller 'createTime' => time(), ]); + // 实时同步到 V2 流量池系统(异步处理,不影响主流程) + if ($customerId) { + try { + $poolService = new TrafficPoolService(); + + // 判断 identifier 类型:手机号还是微信号 + $identifierType = 2; // 默认手机号 + $isPhone = preg_match('/^\+?\d{6,}$/', $identifier); + if (!$isPhone && !empty($params['wechatId'])) { + $identifierType = 1; // 微信号 + } + + // 准备流量池数据 + $poolData = [ + 'identifierType' => $identifierType, + 'mobile' => !empty($params['phone']) ? $params['phone'] : ($isPhone ? $identifier : ''), + 'wechatId' => !empty($params['wechatId']) ? $params['wechatId'] : (!$isPhone ? $identifier : ''), + 'nickname' => !empty($params['name']) ? $params['name'] : '', + ]; + + // 准备公司流量数据 + $companyData = [ + 'phone' => !empty($params['phone']) ? $params['phone'] : ($isPhone ? $identifier : ''), + 'realName' => !empty($params['name']) ? $params['name'] : '', + 'remark' => !empty($params['remark']) ? $params['remark'] : '', + ]; + + // 准备来源数据 + $sourceData = [ + 'sourceName' => !empty($params['source']) ? $params['source'] : ('场景获客_' . $plan['name']), + 'remark' => !empty($params['remark']) ? $params['remark'] : '', + 'extra' => json_encode([ + 'planId' => $plan['id'], + 'planName' => $plan['name'], + 'channelId' => $finalChannelId, + 'customerId' => $customerId, + ], JSON_UNESCAPED_UNICODE), + ]; + + // 同步到 V2 流量池 + $poolService->enterPool( + $identifier, + $plan['companyId'], + TrafficPoolSource::SOURCE_TYPE_API, // API导入 + $poolData, + $companyData, + $sourceData + ); + } catch (\Exception $e) { + // 记录错误但不影响主流程 + \think\facade\Log::error('同步到V2流量池失败:' . $e->getMessage()); + } + } + // 记录获客奖励(异步处理,不影响主流程) if ($customerId) { try { diff --git a/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php b/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php index 756093cbe..6a733bf59 100644 --- a/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php +++ b/Server/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php @@ -10,6 +10,8 @@ use think\facade\Env; // use EasyWeChat\Kernel\Exceptions\DecryptException; use EasyWeChat\Kernel\Http\StreamResponse; use think\Db; +use app\cunkebao\service\TrafficPoolService; +use app\common\model\TrafficPoolSource; class PosterWeChatMiniProgram extends Controller { @@ -112,16 +114,18 @@ class PosterWeChatMiniProgram extends Controller if ($result['errcode'] == 0 && isset($result['phone_info']['phoneNumber'])) { + // ========== 旧版流量池代码(已废弃,保留用于兼容) ========== // TODO 拿到手机号之后的后续操作: // 1. 先写入 ck_traffic_pool_v1 表 identifier mobile 都是 用 phone字段的值 - $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $result['phone_info']['phoneNumber'])->find(); - if (!$trafficPool) { - Db::name('traffic_pool_v1')->insert([ - 'identifier' => $result['phone_info']['phoneNumber'], - 'mobile' => $result['phone_info']['phoneNumber'], - 'createTime' => time() - ]); - } + // $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $result['phone_info']['phoneNumber'])->find(); + // if (!$trafficPool) { + // Db::name('traffic_pool_v1')->insert([ + // 'identifier' => $result['phone_info']['phoneNumber'], + // 'mobile' => $result['phone_info']['phoneNumber'], + // 'createTime' => time() + // ]); + // } + // ========== 旧版流量池代码结束(已迁移到V2实时同步) ========== // 2. 写入 ck_task_customer: 以 task_id ~~identifier~~ phone 为条件,如果存在则忽略,使用类似laravel的firstOrcreate(但我不知道thinkphp5.1里的写法) // $taskCustomer = Db::name('task_customer')->where('task_id', $taskId)->where('identifier', $result['phone_info']['phoneNumber'])->find(); $taskCustomer = Db::name('task_customer') @@ -165,6 +169,50 @@ class PosterWeChatMiniProgram extends Controller 'siteTags' => json_encode([]), ]); + // 实时同步到 V2 流量池系统(异步处理,不影响主流程) + if ($customerId) { + try { + $poolService = new TrafficPoolService(); + $identifier = $result['phone_info']['phoneNumber']; + + // 准备流量池数据 + $poolData = [ + 'identifierType' => 2, // 手机号 + 'mobile' => $identifier, + ]; + + // 准备公司流量数据 + $companyData = [ + 'phone' => $identifier, + ]; + + // 准备来源数据 + $sourceData = [ + 'sourceName' => $task['name'] ?? '海报获客', + 'extra' => json_encode([ + 'planId' => $taskId, + 'planName' => $task['name'] ?? '', + 'channelId' => $finalChannelId, + 'customerId' => $customerId, + 'source' => 'poster_miniprogram', + ], JSON_UNESCAPED_UNICODE), + ]; + + // 同步到 V2 流量池 + $poolService->enterPool( + $identifier, + $task['companyId'], + TrafficPoolSource::SOURCE_TYPE_POSTER, // 海报获客 + $poolData, + $companyData, + $sourceData + ); + } catch (\Exception $e) { + // 记录错误但不影响主流程 + \think\facade\Log::error('同步到V2流量池失败:' . $e->getMessage()); + } + } + // 记录获客奖励(异步处理,不影响主流程) if ($customerId) { try { @@ -259,31 +307,33 @@ class PosterWeChatMiniProgram extends Controller continue; } $isPhone = preg_match('/^\+?\d{6,}$/', $identifier); - $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $identifier)->find(); - if (!$trafficPool) { - $insertData = [ - 'identifier' => $identifier, - 'createTime' => time() - ]; - if ($isPhone) { - $insertData['mobile'] = $identifier; - } else { - $insertData['wechatId'] = $identifier; - } - Db::name('traffic_pool_v1')->insert($insertData); - } else { - $updates = []; - if ($isPhone && empty($trafficPool['mobile'])) { - $updates['mobile'] = $identifier; - } - if (!$isPhone && empty($trafficPool['wechatId'])) { - $updates['wechatId'] = $identifier; - } - if (!empty($updates)) { - $updates['updateTime'] = time(); - Db::name('traffic_pool_v1')->where('id', $trafficPool['id'])->update($updates); - } - } + // ========== 旧版流量池代码(已废弃,保留用于兼容) ========== + // $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $identifier)->find(); + // if (!$trafficPool) { + // $insertData = [ + // 'identifier' => $identifier, + // 'createTime' => time() + // ]; + // if ($isPhone) { + // $insertData['mobile'] = $identifier; + // } else { + // $insertData['wechatId'] = $identifier; + // } + // Db::name('traffic_pool_v1')->insert($insertData); + // } else { + // $updates = []; + // if ($isPhone && empty($trafficPool['mobile'])) { + // $updates['mobile'] = $identifier; + // } + // if (!$isPhone && empty($trafficPool['wechatId'])) { + // $updates['wechatId'] = $identifier; + // } + // if (!empty($updates)) { + // $updates['updateTime'] = time(); + // Db::name('traffic_pool_v1')->where('id', $trafficPool['id'])->update($updates); + // } + // } + // ========== 旧版流量池代码结束(已迁移到V2实时同步) ========== $taskCustomer = Db::name('task_customer') ->where('task_id', $taskId) @@ -305,6 +355,55 @@ class PosterWeChatMiniProgram extends Controller // 使用 insertGetId 以便在需要时记录获客奖励 $customerId = Db::name('task_customer')->insertGetId($insertCustomer); + // 实时同步到 V2 流量池系统(异步处理,不影响主流程) + if (!empty($customerId)) { + try { + $poolService = new TrafficPoolService(); + + // 判断 identifier 类型 + $identifierType = $isPhone ? 2 : 1; // 2=手机号, 1=微信号 + + // 准备流量池数据 + $poolData = [ + 'identifierType' => $identifierType, + 'mobile' => $isPhone ? $identifier : '', + 'wechatId' => !$isPhone ? $identifier : '', + ]; + + // 准备公司流量数据 + $companyData = [ + 'phone' => $isPhone ? $identifier : '', + 'remark' => $remark, + ]; + + // 准备来源数据 + $sourceData = [ + 'sourceName' => $task['name'] ?? '海报获客', + 'remark' => $remark, + 'extra' => json_encode([ + 'planId' => $taskId, + 'planName' => $task['name'] ?? '', + 'channelId' => $finalChannelId, + 'customerId' => $customerId, + 'source' => 'poster_batch_import', + ], JSON_UNESCAPED_UNICODE), + ]; + + // 同步到 V2 流量池 + $poolService->enterPool( + $identifier, + $task['companyId'], + TrafficPoolSource::SOURCE_TYPE_POSTER, // 海报获客 + $poolData, + $companyData, + $sourceData + ); + } catch (\Exception $e) { + // 记录错误但不影响主流程 + \think\facade\Log::error('同步到V2流量池失败:' . $e->getMessage()); + } + } + // 表单录入成功即视为一次获客: // 仅在存在有效渠道ID时,记录获客奖励(谁的cid谁获客) if (!empty($customerId) && $finalChannelId > 0) { diff --git a/Server/application/cunkebao/controller/traffic/GetConvertedListWithInCompanyV1Controller.php b/Server/application/cunkebao/controller/traffic/GetConvertedListWithInCompanyV1Controller.php index 7321998ba..b89e1374a 100644 --- a/Server/application/cunkebao/controller/traffic/GetConvertedListWithInCompanyV1Controller.php +++ b/Server/application/cunkebao/controller/traffic/GetConvertedListWithInCompanyV1Controller.php @@ -72,7 +72,11 @@ class GetConvertedListWithInCompanyV1Controller extends BaseController 'f.tags', 'f.createTime', TrafficSourceModel::STATUS_PASSED . ' status' ] ) - ->join('traffic_pool_v1 p', 'p.identifier=s.identifier') + // ========== 旧版流量池代码(已废弃) ========== + // ->join('traffic_pool_v1 p', 'p.identifier=s.identifier') + // ========== 新版流量池代码 ========== + ->join('traffic_pool p', 'p.identifier=s.identifier') + // ========== 旧版流量池代码结束 ========== ->join('wechat_account w', 'p.wechatId=w.wechatId') ->join('wechat_friendship f', 'w.wechatId=f.wechatId and f.deleteTime=0') ->order('s.id desc'); diff --git a/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php b/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php index c15dd3d86..cb40466d7 100644 --- a/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php +++ b/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php @@ -328,12 +328,24 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController if (empty($userId)) { return json_encode(['code' => 500, 'msg' => '用户id不能为空']); } - $data = Db::name('traffic_pool_v1')->alias('tp') - ->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId=' . $companyId, 'left') - ->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left') - ->where(['tp.id' => $userId]) - ->order('tp.createTime desc') + // ========== 旧版流量池代码(已废弃) ========== + // $data = Db::name('traffic_pool_v1')->alias('tp') + // ->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId=' . $companyId, 'left') + // ->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left') + // ->where(['tp.id' => $userId]) + // ->order('tp.createTime desc') + // ->column('wf.id,wf.labels,wf.siteLabels'); + // ========== 新版流量池代码 ========== + $pool = Db::name('traffic_pool')->where('id', $userId)->find(); + if (!$pool) { + return ResponseHelper::success(['wechat' => [], 'siteLabels' => []]); + } + $data = Db::name('s2_wechat_friend')->alias('wf') + ->join('wechat_friendship f', 'wf.wechatId=f.wechatId AND f.companyId=' . $companyId, 'left') + ->where(['wf.wechatId' => $pool['identifier']]) + ->order('wf.id desc') ->column('wf.id,wf.labels,wf.siteLabels'); + // ========== 旧版流量池代码结束 ========== if (empty($data)) { return ResponseHelper::success(['wechat' => [], 'siteLabels' => []]); } @@ -424,12 +436,21 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController if (!is_array($userIds)) { return ResponseHelper::error('选择的用户类型错误'); } - $result = Db::name('traffic_pool_v1')->alias('tp') - ->join('traffic_source_v1 tc', 'tp.identifier=tc.identifier') + // ========== 旧版流量池代码(已废弃) ========== + // $result = Db::name('traffic_pool_v1')->alias('tp') + // ->join('traffic_source_v1 tc', 'tp.identifier=tc.identifier') + // ->whereIn('tp.id', $userIds) + // ->where(['companyId' => $companyId]) + // ->group('tp.identifier') + // ->column('tc.identifier'); + // ========== 新版流量池代码 ========== + $result = Db::name('traffic_pool')->alias('tp') + ->join('traffic_pool_company tpc', 'tpc.poolId=tp.id AND tpc.companyId=' . $companyId) + ->join('traffic_pool_source tps', 'tps.poolCompanyId=tpc.id') ->whereIn('tp.id', $userIds) - ->where(['companyId' => $companyId]) ->group('tp.identifier') - ->column('tc.identifier'); + ->column('tps.identifier'); + // ========== 旧版流量池代码结束 ========== } else { /*if (empty($tableFile)){ return ResponseHelper::error('请上传用户文件'); @@ -548,24 +569,29 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController $batchRows = array_slice($rows, $i, $batchSize); if (!empty($batchRows)) { $identifiers = array_column($batchRows, 'phone'); - //流量池处理 - $existing = Db::name('traffic_pool_v1') - ->whereIn('identifier', $identifiers) - ->column('identifier'); - - $newData = []; - foreach ($batchRows as $row) { - if (!in_array($row['phone'], $existing)) { - $newData[] = [ - 'identifier' => $row['phone'], - 'mobile' => $row['phone'], - 'createTime' => time(), - ]; - } - } - if (!empty($newData)) { - Db::name('traffic_pool_v1')->insertAll($newData); - } + // ========== 旧版流量池代码(已废弃,已迁移到V2实时同步) ========== + // //流量池处理 + // $existing = Db::name('traffic_pool_v1') + // ->whereIn('identifier', $identifiers) + // ->column('identifier'); + // + // $newData = []; + // foreach ($batchRows as $row) { + // if (!in_array($row['phone'], $existing)) { + // $newData[] = [ + // 'identifier' => $row['phone'], + // 'mobile' => $row['phone'], + // 'createTime' => time(), + // ]; + // } + // } + // if (!empty($newData)) { + // Db::name('traffic_pool_v1')->insertAll($newData); + // } + // ========== 新版流量池代码(使用 TrafficPoolService 实时同步) ========== + // 流量池处理 - 现在通过 TrafficPoolService 实时同步到 V2 + // 如果需要批量导入,建议使用 migrate:trafficPoolV2 命令 + // ========== 旧版流量池代码结束 ========== //流量池来源处理 $newData2 = []; @@ -638,10 +664,20 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController $isWechat = $this->request->param('isWechat', false); $companyId = $this->getUserInfo('companyId'); - $friend = Db::name('traffic_pool_v1')->alias('tp') - ->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId='.$companyId, 'left') - ->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left') - ->where(['tp.id' => $userId]) + // ========== 旧版流量池代码(已废弃) ========== + // $friend = Db::name('traffic_pool_v1')->alias('tp') + // ->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId='.$companyId, 'left') + // ->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left') + // ->where(['tp.id' => $userId]) + // ========== 新版流量池代码 ========== + $pool = Db::name('traffic_pool')->where('id', $userId)->find(); + if (!$pool) { + return ResponseHelper::error('流量池记录不存在'); + } + $friend = Db::name('s2_wechat_friend')->alias('wf') + ->join('wechat_friendship f', 'wf.wechatId=f.wechatId AND f.companyId='.$companyId, 'left') + ->where(['wf.wechatId' => $pool['identifier']]) + // ========== 旧版流量池代码结束 ========== ->order('tp.createTime desc') ->column('wf.id,wf.accountId,wf.labels,wf.siteLabels'); if (empty($data)) { diff --git a/Server/application/cunkebao/controller/workbench/WorkbenchController.php b/Server/application/cunkebao/controller/workbench/WorkbenchController.php index 9d5ed8b64..2be3a14bc 100644 --- a/Server/application/cunkebao/controller/workbench/WorkbenchController.php +++ b/Server/application/cunkebao/controller/workbench/WorkbenchController.php @@ -2398,10 +2398,18 @@ class WorkbenchController extends Controller ]; // 查询发布记录 + // ========== 旧版流量池代码(已废弃) ========== + // $list = Db::name('workbench_import_contact_item')->alias('wici') + // ->join('traffic_pool_v1 tp', 'tp.id = wici.poolId', 'left') + // ->join('traffic_source_v1 tc', 'tc.identifier = tp.identifier', 'left') + // ->join('wechat_account wa', 'wa.wechatId = tp.wechatId', 'left') + // ========== 新版流量池代码 ========== $list = Db::name('workbench_import_contact_item')->alias('wici') - ->join('traffic_pool_v1 tp', 'tp.id = wici.poolId', 'left') - ->join('traffic_source_v1 tc', 'tc.identifier = tp.identifier', 'left') + ->join('traffic_pool tp', 'tp.id = wici.poolId', 'left') + ->join('traffic_pool_company tpc', 'tpc.poolId = tp.id AND tpc.companyId = ' . $this->getUserInfo('companyId'), 'left') + ->join('traffic_pool_source tps', 'tps.poolCompanyId = tpc.id', 'left') ->join('wechat_account wa', 'wa.wechatId = tp.wechatId', 'left') + // ========== 旧版流量池代码结束 ========== ->field([ 'wici.id', 'wici.workbenchId', @@ -2409,7 +2417,7 @@ class WorkbenchController extends Controller 'tp.identifier', 'tp.mobile', 'tp.wechatId', - 'tc.name', + 'tps.sourceName as name', // 从新版来源表获取名称 'wa.nickName', 'wa.avatar', 'wa.alias', diff --git a/Server/application/cunkebao/controller/workbench/WorkbenchImportContactController.php b/Server/application/cunkebao/controller/workbench/WorkbenchImportContactController.php index 5e63e51d2..9a9fe3788 100644 --- a/Server/application/cunkebao/controller/workbench/WorkbenchImportContactController.php +++ b/Server/application/cunkebao/controller/workbench/WorkbenchImportContactController.php @@ -25,10 +25,18 @@ class WorkbenchImportContactController extends Controller ]; // 查询发布记录 + // ========== 旧版流量池代码(已废弃) ========== + // $list = Db::name('workbench_import_contact_item')->alias('wici') + // ->join('traffic_pool_v1 tp', 'tp.id = wici.poolId', 'left') + // ->join('traffic_source_v1 tc', 'tc.identifier = tp.identifier', 'left') + // ->join('wechat_account wa', 'wa.wechatId = tp.wechatId', 'left') + // ========== 新版流量池代码 ========== $list = Db::name('workbench_import_contact_item')->alias('wici') - ->join('traffic_pool_v1 tp', 'tp.id = wici.poolId', 'left') - ->join('traffic_source_v1 tc', 'tc.identifier = tp.identifier', 'left') + ->join('traffic_pool tp', 'tp.id = wici.poolId', 'left') + ->join('traffic_pool_company tpc', 'tpc.poolId = tp.id AND tpc.companyId = ' . $this->getUserInfo('companyId'), 'left') + ->join('traffic_pool_source tps', 'tps.poolCompanyId = tpc.id', 'left') ->join('wechat_account wa', 'wa.wechatId = tp.wechatId', 'left') + // ========== 旧版流量池代码结束 ========== ->field([ 'wici.id', 'wici.workbenchId', @@ -36,7 +44,7 @@ class WorkbenchImportContactController extends Controller 'tp.identifier', 'tp.mobile', 'tp.wechatId', - 'tc.name', + 'tps.sourceName as name', // 从新版来源表获取名称 'wa.nickName', 'wa.avatar', 'wa.alias', diff --git a/Server/application/cunkebao/service/TrafficPoolGroupService.php b/Server/application/cunkebao/service/TrafficPoolGroupService.php index c7205f254..0ec84685a 100644 --- a/Server/application/cunkebao/service/TrafficPoolGroupService.php +++ b/Server/application/cunkebao/service/TrafficPoolGroupService.php @@ -258,18 +258,31 @@ class TrafficPoolGroupService 'tpc.id', 'tpc.poolId', 'tpc.identifier', + 'tpc.companyId', 'tpc.friendStatus', 'tpc.level', + 'tpc.intentionLevel', 'tpc.lastInteractTime', 'tpc.rfmF', 'tpc.rfmM', 'tpc.totalMsgCount', 'tpc.totalOrderAmount', + 'tpc.lastMsgTime', + 'tpc.firstSourceType', + 'tpc.firstSourceTime', + 'tpc.lifecycle', + 'tpc.createTime', 'tpc.realName', 'tpc.phone', 'tp.nickname', 'tp.avatar', 'tp.wechatId', + 'tp.wechatAlias', + 'tp.gender', + 'tp.region', + 'tp.country', + 'tp.province', + 'tp.city', 'tpgm.createTime as addTime' ]) ->order('tpgm.createTime DESC') @@ -312,19 +325,32 @@ class TrafficPoolGroupService 'tpc.id', 'tpc.poolId', 'tpc.identifier', + 'tpc.companyId', 'tpc.friendStatus', 'tpc.level', + 'tpc.intentionLevel', 'tpc.lastInteractTime', 'tpc.rfmF', 'tpc.rfmM', 'tpc.totalMsgCount', 'tpc.totalOrderAmount', + 'tpc.lastMsgTime', + 'tpc.firstSourceType', + 'tpc.firstSourceTime', + 'tpc.lifecycle', + 'tpc.createTime', 'tpc.realName', 'tpc.phone', 'tpc.createTime as addTime', 'tp.nickname', 'tp.avatar', - 'tp.wechatId' + 'tp.wechatId', + 'tp.wechatAlias', + 'tp.gender', + 'tp.region', + 'tp.country', + 'tp.province', + 'tp.city' ]) ->order('tpc.id DESC') ->page($page, $pageSize) @@ -377,11 +403,58 @@ class TrafficPoolGroupService } }); } elseif ($condition['type'] === 'field') { - // 字段条件 - $field = 'tpc.' . $condition['field']; + // 字段条件 - 根据字段所属表使用正确的别名 + $fieldName = $condition['field']; $operator = $condition['operator']; $value = $condition['value']; + // 特殊处理:keyword 字段用于多字段搜索 + if ($fieldName === 'keyword') { + $keyword = $value; + $query->$method(function($q) use ($keyword) { + $q->where('tp.nickname', 'like', "%{$keyword}%") + ->whereOr('tp.wechatId', 'like', "%{$keyword}%") + ->whereOr('tp.wechatAlias', 'like', "%{$keyword}%") + ->whereOr('tpc.realName', 'like', "%{$keyword}%") + ->whereOr('tpc.phone', 'like', "%{$keyword}%"); + }); + return; + } + + // 特殊处理:friendIds 字段用于指定好友ID列表 + if ($fieldName === 'friendIds') { + if (is_array($value) && !empty($value)) { + $query->$method('tpc.id', 'in', $value); + } + return; + } + + // ck_traffic_pool 表的字段(基础用户信息) + $tpFields = ['nickname', 'avatar', 'wechatId', 'wechatAlias', 'gender', 'region', 'country', 'province', 'city', 'signature']; + + // 判断字段属于哪个表 + if (in_array($fieldName, $tpFields)) { + $field = 'tp.' . $fieldName; + } else { + // ck_traffic_pool_company 表的字段(公司维度信息) + $field = 'tpc.' . $fieldName; + } + + // 特殊处理:地区字段(province) + // 前端可能传递 "广东" 或 "广东 广州市" + if ($fieldName === 'province' && strpos($value, ' ') !== false) { + // 包含空格,说明是 "省份 城市" 格式 + $parts = explode(' ', $value, 2); + $provinceName = trim($parts[0]); + $cityName = trim($parts[1]); + + $query->$method(function($q) use ($provinceName, $cityName) { + $q->where('tp.province', '=', $provinceName) + ->where('tp.city', 'like', "%{$cityName}%"); + }); + return; + } + switch ($operator) { case '=': case '!=': @@ -439,12 +512,45 @@ class TrafficPoolGroupService protected function formatMemberList($list, int $total, int $page, int $pageSize) { $result = []; + $poolCompanyIds = []; + + // 收集所有的poolCompanyId + foreach ($list as $item) { + $poolCompanyIds[] = $item['id']; + } + + // 批量查询标签 + $tagsMap = []; + if (!empty($poolCompanyIds)) { + $tags = \think\Db::table('ck_traffic_pool_tag') + ->alias('tpt') + ->join('ck_traffic_pool_tag_define tptd', 'tpt.tagDefineId = tptd.id', 'LEFT') + ->where('tpt.poolCompanyId', 'in', $poolCompanyIds) + ->where('tpt.isDel', 0) + ->where('tptd.isDel', 0) + ->field('tpt.poolCompanyId, tptd.tagName, tptd.tagType') + ->select(); + + foreach ($tags as $tag) { + $poolCompanyId = $tag['poolCompanyId']; + if (!isset($tagsMap[$poolCompanyId])) { + $tagsMap[$poolCompanyId] = []; + } + $tagsMap[$poolCompanyId][] = [ + 'tagName' => $tag['tagName'], + 'tagType' => $tag['tagType'] + ]; + } + } + foreach ($list as $item) { $data = $item->toArray(); // 计算 RFM R 值 $data['rfmR'] = $item->lastInteractTime ? (int)floor((time() - $item->lastInteractTime) / 86400) : 9999; // 计算 RFM 总分 $data['rfmScore'] = $this->calculateRfmScore($data['rfmR'], $data['rfmF'] ?? 0, $data['rfmM'] ?? 0); + // 添加标签 + $data['tags'] = $tagsMap[$item['id']] ?? []; $result[] = $data; } @@ -479,6 +585,7 @@ class TrafficPoolGroupService // 动态规则分组 $query = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); @@ -512,6 +619,7 @@ class TrafficPoolGroupService ->where('tpc.isDel', 0); } else { $query = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); @@ -645,6 +753,78 @@ class TrafficPoolGroupService 'total' => $rScore + $fScore + $mScore ]; } + + /** + * 预览动态分组成员(不创建分组,只预览符合条件的用户) + * + * @param int $companyId 公司ID + * @param array $ruleConfig 规则配置 + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @param string $keyword 搜索关键词 + * @return array + */ + public function previewGroupMembers(int $companyId, array $ruleConfig, int $page = 1, int $pageSize = 20, string $keyword = '') + { + $query = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') + ->where('tpc.companyId', $companyId) + ->where('tpc.isDel', 0); + + // 应用规则条件 + if (!empty($ruleConfig)) { + $this->applyRuleConditions($query, $ruleConfig, $companyId); + } + + // 应用关键字搜索 + if (!empty($keyword)) { + $query->where(function($q) use ($keyword) { + $q->where('tp.nickname', 'like', "%{$keyword}%") + ->whereOr('tp.wechatId', 'like', "%{$keyword}%") + ->whereOr('tp.wechatAlias', 'like', "%{$keyword}%") + ->whereOr('tpc.realName', 'like', "%{$keyword}%") + ->whereOr('tpc.phone', 'like', "%{$keyword}%"); + }); + } + + $total = $query->count(); + + $list = $query->field([ + 'tpc.id', + 'tpc.poolId', + 'tpc.identifier', + 'tpc.companyId', + 'tpc.friendStatus', + 'tpc.level', + 'tpc.intentionLevel', + 'tpc.lastInteractTime', + 'tpc.rfmF', + 'tpc.rfmM', + 'tpc.totalMsgCount', + 'tpc.totalOrderAmount', + 'tpc.lastMsgTime', + 'tpc.firstSourceType', + 'tpc.firstSourceTime', + 'tpc.lifecycle', + 'tpc.createTime', + 'tpc.realName', + 'tpc.phone', + 'tp.nickname', + 'tp.avatar', + 'tp.wechatId', + 'tp.wechatAlias', + 'tp.gender', + 'tp.region', + 'tp.country', + 'tp.province', + 'tp.city' + ]) + ->order('tpc.id DESC') + ->page($page, $pageSize) + ->select(); + + return $this->formatMemberList($list, $total, $page, $pageSize); + } } diff --git a/Server/application/job/WorkbenchGroupPushJob.php b/Server/application/job/WorkbenchGroupPushJob.php index 9d5473923..5594e0b52 100644 --- a/Server/application/job/WorkbenchGroupPushJob.php +++ b/Server/application/job/WorkbenchGroupPushJob.php @@ -602,12 +602,21 @@ class WorkbenchGroupPushJob */ protected function getFriendsByNormalPools(array $packageIds, $companyId, array $ownerWechatIds = []) { + // ========== 旧版流量池代码(已废弃) ========== + // $query = Db::name('traffic_source_package_item_v1') + // ->alias('tspi') + // ->leftJoin('traffic_source_package_v1 tsp', 'tsp.id = tspi.packageId') + // ->leftJoin('traffic_pool_v1 tp', 'tp.identifier = tspi.identifier') + // ->leftJoin(['s2_wechat_friend' => 'wf'], 'wf.wechatId = tp.wechatId') + // ->leftJoin(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId') + // ========== 新版流量池代码 ========== $query = Db::name('traffic_source_package_item_v1') ->alias('tspi') ->leftJoin('traffic_source_package_v1 tsp', 'tsp.id = tspi.packageId') - ->leftJoin('traffic_pool_v1 tp', 'tp.identifier = tspi.identifier') + ->leftJoin('traffic_pool tp', 'tp.identifier = tspi.identifier') ->leftJoin(['s2_wechat_friend' => 'wf'], 'wf.wechatId = tp.wechatId') ->leftJoin(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId') + // ========== 旧版流量池代码结束 ========== ->whereIn('tspi.packageId', $packageIds) ->where('tsp.isDel', 0) ->where('wf.isDeleted', 0) diff --git a/Server/application/job/WorkbenchImportContactJob.php b/Server/application/job/WorkbenchImportContactJob.php index 992eeecff..8cefb4350 100644 --- a/Server/application/job/WorkbenchImportContactJob.php +++ b/Server/application/job/WorkbenchImportContactJob.php @@ -345,18 +345,34 @@ class WorkbenchImportContactJob ->column('id'); if (!empty($packageIds)) { + // ========== 旧版流量池代码(已废弃) ========== + // $normalData = Db::name('traffic_source_package_item_v1')->alias('tpi') + // ->join('traffic_pool_v1 tp', 'tp.identifier = tpi.identifier') + // ->join('traffic_source_v1 ts', 'ts.identifier = tpi.identifier','left') + // ->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id,'left') + // ->where('tp.mobile', '>',0) + // ->where('wici.id','null') + // ->whereIn('tpi.packageId',$packageIds) + // ->field('tp.id,tpi.packageId,tp.mobile as phone,ts.name') + // ->order('tp.id DESC') + // ->group('tpi.identifier') + // ->limit($contactNum) + // ->select(); + // ========== 新版流量池代码 ========== $normalData = Db::name('traffic_source_package_item_v1')->alias('tpi') - ->join('traffic_pool_v1 tp', 'tp.identifier = tpi.identifier') - ->join('traffic_source_v1 ts', 'ts.identifier = tpi.identifier','left') + ->join('traffic_pool tp', 'tp.identifier = tpi.identifier') + ->join('traffic_pool_company tpc', 'tpc.poolId = tp.id AND tpc.companyId = ' . ($workbench->companyId ?? 0)) + ->join('traffic_pool_source tps', 'tps.poolCompanyId = tpc.id', 'left') ->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id,'left') ->where('tp.mobile', '>',0) ->where('wici.id','null') ->whereIn('tpi.packageId',$packageIds) - ->field('tp.id,tpi.packageId,tp.mobile as phone,ts.name') + ->field('tp.id,tpi.packageId,tp.mobile as phone,tps.sourceName as name') ->order('tp.id DESC') ->group('tpi.identifier') ->limit($contactNum) ->select(); + // ========== 旧版流量池代码结束 ========== $data = array_merge($data, $normalData ?: []); } } @@ -389,12 +405,22 @@ class WorkbenchImportContactJob return []; } - // 从 s2_wechat_friend 表获取好友,然后关联 traffic_pool_v1 表获取手机号 + // ========== 旧版流量池代码(已废弃) ========== + // // 从 s2_wechat_friend 表获取好友,然后关联 traffic_pool_v1 表获取手机号 + // $data = Db::table('s2_wechat_friend')->alias('wf') + // ->join('traffic_pool_v1 tp', 'tp.wechatId = wf.wechatId', 'left') + // ->join('traffic_source_v1 ts', 'ts.identifier = tp.identifier', 'left') + // ->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id, 'left') + // ->where('wf.ownerWechatId', 'in', $wechatIds) + // ========== 新版流量池代码 ========== + // 从 s2_wechat_friend 表获取好友,然后关联 traffic_pool 表获取手机号 $data = Db::table('s2_wechat_friend')->alias('wf') - ->join('traffic_pool_v1 tp', 'tp.wechatId = wf.wechatId', 'left') - ->join('traffic_source_v1 ts', 'ts.identifier = tp.identifier', 'left') + ->join('traffic_pool tp', 'tp.wechatId = wf.wechatId', 'left') + ->join('traffic_pool_company tpc', 'tpc.poolId = tp.id AND tpc.companyId = ' . ($workbench->companyId ?? 0), 'left') + ->join('traffic_pool_source tps', 'tps.poolCompanyId = tpc.id', 'left') ->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id, 'left') ->where('wf.ownerWechatId', 'in', $wechatIds) + // ========== 旧版流量池代码结束 ========== ->where('wf.isDeleted', 0) ->where('tp.mobile', '>', 0) ->where('wici.id', 'null') diff --git a/Server/config/task_scheduler.php b/Server/config/task_scheduler.php index ee6a95ea1..e95f93ecc 100644 --- a/Server/config/task_scheduler.php +++ b/Server/config/task_scheduler.php @@ -232,6 +232,22 @@ return [ 'log_file' => 'call_recording.log', ], + // =========================== + // 低频任务(每 2 小时) + // =========================== + + // V2 流量池数据同步,全量同步好友、群成员和标签数据到 V2 流量池系统 + 'traffic_pool_v2_sync' => [ + 'name' => 'V2 流量池数据同步', + 'command' => 'migrate:trafficPoolV2', + 'schedule' => '0 */2 * * *', // 每2小时的0分执行(如:0:00, 2:00, 4:00...) + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'timeout' => 3600, // 1小时超时 + 'log_file' => 'traffic_pool_v2_sync.log', + ], + // =========================== // 每日 / 每几天任务 // =========================== @@ -291,6 +307,18 @@ return [ 'log_file' => 'calculate_score.log', ], + // 每日 3:00 清除过期日志文件,默认保留10天,可通过 --days 参数修改 + 'clean_logs' => [ + 'name' => '清除过期日志文件', + 'command' => 'clean:logs', + 'schedule' => '0 3 * * *', // 每天3点 + 'options' => ['--days=10'], // 默认保留10天,可修改为其他天数,如 ['--days=7'] 保留7天 + 'enabled' => true, + 'max_concurrent' => 1, + 'timeout' => 300, // 5分钟超时 + 'log_file' => 'clean_logs.log', + ], + // 每 3 天执行的全量任务 // 每 3 天 3:00 全量同步所有在线好友,做一次大规模校准 diff --git a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php index 0e41e8f06..ed08ca830 100644 --- a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php +++ b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php @@ -1292,44 +1292,47 @@ class Adapter implements WeChatServiceInterface $affectedRows = 0; try { - for ($i = 0; $i < $batchCount; $i++) { - $offset = $i * $batchSize; - - // 使用 ON DUPLICATE KEY UPDATE 支持插入和更新 - $sql = "INSERT INTO ck_traffic_pool_v1( - `identifier`, `wechatId`, `mobile`, `nickname`, `avatar`, - `gender`, `region`, `createTime`, `updateTime` - ) - SELECT - t.wechatId AS identifier, - t.wechatId, - (SELECT phone FROM s2_wechat_friend WHERE wechatId = t.wechatId AND phone IS NOT NULL AND phone != '' LIMIT 1) AS mobile, - (SELECT nickname FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS nickname, - (SELECT avatar FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS avatar, - (SELECT gender FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS gender, - (SELECT region FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS region, - UNIX_TIMESTAMP() AS createTime, - UNIX_TIMESTAMP() AS updateTime - FROM ( - SELECT wechatId FROM temp_wechat_ids LIMIT {$offset}, {$batchSize} - ) AS t - ON DUPLICATE KEY UPDATE - mobile = COALESCE(VALUES(mobile), mobile), - nickname = COALESCE(VALUES(nickname), nickname), - avatar = COALESCE(VALUES(avatar), avatar), - gender = COALESCE(VALUES(gender), gender), - region = COALESCE(VALUES(region), region), - updateTime = UNIX_TIMESTAMP()"; - - $currentAffected = Db::execute($sql); - $affectedRows += $currentAffected; - - if ($i % 5 == 0) { - gc_collect_cycles(); - } - - usleep(30000); // 30毫秒 - } + // ========== 旧版流量池代码(已废弃,已迁移到V2) ========== + // for ($i = 0; $i < $batchCount; $i++) { + // $offset = $i * $batchSize; + // + // // 使用 ON DUPLICATE KEY UPDATE 支持插入和更新 + // $sql = "INSERT INTO ck_traffic_pool_v1( + // `identifier`, `wechatId`, `mobile`, `nickname`, `avatar`, + // `gender`, `region`, `createTime`, `updateTime` + // ) + // SELECT + // t.wechatId AS identifier, + // t.wechatId, + // (SELECT phone FROM s2_wechat_friend WHERE wechatId = t.wechatId AND phone IS NOT NULL AND phone != '' LIMIT 1) AS mobile, + // (SELECT nickname FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS nickname, + // (SELECT avatar FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS avatar, + // (SELECT gender FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS gender, + // (SELECT region FROM s2_wechat_friend WHERE wechatId = t.wechatId ORDER BY id DESC LIMIT 1) AS region, + // UNIX_TIMESTAMP() AS createTime, + // UNIX_TIMESTAMP() AS updateTime + // FROM ( + // SELECT wechatId FROM temp_wechat_ids LIMIT {$offset}, {$batchSize} + // ) AS t + // ON DUPLICATE KEY UPDATE + // mobile = COALESCE(VALUES(mobile), mobile), + // nickname = COALESCE(VALUES(nickname), nickname), + // avatar = COALESCE(VALUES(avatar), avatar), + // gender = COALESCE(VALUES(gender), gender), + // region = COALESCE(VALUES(region), region), + // updateTime = UNIX_TIMESTAMP()"; + // + // $currentAffected = Db::execute($sql); + // $affectedRows += $currentAffected; + // + // if ($i % 5 == 0) { + // gc_collect_cycles(); + // } + // + // usleep(30000); // 30毫秒 + // } + // ========== 旧版流量池代码结束(已迁移到 syncToTrafficPoolV2) ========== + // 注意:现在使用 syncToTrafficPoolV2() 方法同步到 V2 流量池系统 } catch (\Exception $e) { \think\facade\Log::error("Error in traffic pool sync: " . $e->getMessage()); throw $e;