diff --git a/application/api/controller/AccountController.php b/application/api/controller/AccountController.php index ce27ce5..b3a6662 100644 --- a/application/api/controller/AccountController.php +++ b/application/api/controller/AccountController.php @@ -60,10 +60,22 @@ class AccountController extends BaseController $result = requestCurl($this->baseUrl . 'api/Account/myTenantPageAccounts', $params, 'GET', $header); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } + // 保存数据到数据库 if (!empty($response['results'])) { foreach ($response['results'] as $item) { - $this->saveAccount($item); + if (is_array($item)) { + $this->saveAccount($item); + } } } diff --git a/application/api/controller/AllotRuleController.php b/application/api/controller/AllotRuleController.php index db590d6..f544111 100644 --- a/application/api/controller/AllotRuleController.php +++ b/application/api/controller/AllotRuleController.php @@ -41,11 +41,18 @@ class AllotRuleController extends BaseController $result = requestCurl($this->baseUrl . 'api/AllotRule/all', [], 'GET', $header); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + // 保存数据到数据库 if (!empty($response)) { AllotRuleModel::where('1=1')->update(['isDel' => 1]); foreach ($response as $item) { - $this->saveAllotRule($item); + if (is_array($item)) { + $this->saveAllotRule($item); + } } } diff --git a/application/api/controller/CallRecordingController.php b/application/api/controller/CallRecordingController.php index a811261..970c22b 100644 --- a/application/api/controller/CallRecordingController.php +++ b/application/api/controller/CallRecordingController.php @@ -68,10 +68,22 @@ class CallRecordingController extends BaseController $result = requestCurl($this->baseUrl . 'api/CallRecording/list', $params, 'GET', $header); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } + // 保存数据到数据库 if (!empty($response['results'])) { foreach ($response['results'] as $item) { - $this->saveCallRecording($item); + if (is_array($item)) { + $this->saveCallRecording($item); + } } } diff --git a/application/api/controller/DeviceController.php b/application/api/controller/DeviceController.php index 33dd45d..cb5ffc2 100644 --- a/application/api/controller/DeviceController.php +++ b/application/api/controller/DeviceController.php @@ -74,10 +74,22 @@ class DeviceController extends BaseController $result = requestCurl($this->baseUrl . 'api/device/pageResult', $params, 'GET', $header); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } + // 保存数据到数据库 if (!empty($response['results'])) { foreach ($response['results'] as $item) { - $this->saveDevice($item); + if (is_array($item)) { + $this->saveDevice($item); + } } } @@ -467,10 +479,17 @@ class DeviceController extends BaseController // 发送请求 $result = requestCurl($this->baseUrl . 'api/DeviceGroup/list', [], 'GET', $header,'json'); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + // 保存数据到数据库 if (!empty($response)) { foreach ($response as $item) { - $this->saveDeviceGroup($item); + if (is_array($item)) { + $this->saveDeviceGroup($item); + } } } if($isInner){ diff --git a/application/api/controller/FriendTaskController.php b/application/api/controller/FriendTaskController.php index 893a810..8844ec7 100644 --- a/application/api/controller/FriendTaskController.php +++ b/application/api/controller/FriendTaskController.php @@ -46,11 +46,22 @@ class FriendTaskController extends BaseController $result = requestCurl($this->baseUrl . 'api/AddFriendByPhoneTask/list', $params, 'GET', $header,'json'); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } // 保存数据到数据库 if (!empty($response['results'])) { foreach ($response['results'] as $item) { - $this->saveFriendTask($item); + if (is_array($item)) { + $this->saveFriendTask($item); + } } } if($isInner){ diff --git a/application/api/controller/MessageController.php b/application/api/controller/MessageController.php index 34b79b5..8e4dd7e 100644 --- a/application/api/controller/MessageController.php +++ b/application/api/controller/MessageController.php @@ -18,7 +18,7 @@ class MessageController extends BaseController public function getFriendsList($pageIndex = '',$pageSize = '',$isInner = false) { // 获取授权token - $authorization = trim($this->request->header('authorization', $this->authorization)); + $authorization = $this->authorization; if (empty($authorization)) { if($isInner){ return json_encode(['code'=>500,'msg'=>'缺少授权信息']); @@ -27,7 +27,7 @@ class MessageController extends BaseController } } - $fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00', strtotime('-1 days'))); + $fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00')); $toTime = $this->request->param('toTime', date('Y-m-d 23:59:59')); @@ -62,6 +62,17 @@ class MessageController extends BaseController // 发送请求获取好友列表 $result = requestCurl($this->baseUrl . 'api/WechatFriend/listWechatFriendForMsgPagination', $params, 'POST', $header, 'json'); $response = handleApiResponse($result); + + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } + // 获取同步消息标志 $syncMessages = $this->request->param('syncMessages', true); // 如果需要同步消息,则获取每个好友的消息 @@ -89,10 +100,18 @@ class MessageController extends BaseController // 调用获取消息的接口 $messageResult = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $messageParams, 'GET', $header, 'json'); $messageResponse = handleApiResponse($messageResult); + + // 确保 messageResponse 是数组格式 + if (!is_array($messageResponse)) { + $messageResponse = []; + } + // 保存消息到数据库 if (!empty($messageResponse)) { foreach ($messageResponse as $item) { + if (is_array($item)) { $this->saveMessage($item); + } } } @@ -159,10 +178,17 @@ class MessageController extends BaseController $result = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $params, 'GET', $header, 'json'); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + // 保存数据到数据库 if (!empty($response)) { foreach ($response as $item) { + if (is_array($item)) { $this->saveMessage($item); + } } } @@ -181,7 +207,8 @@ class MessageController extends BaseController public function getChatroomList($pageIndex = '',$pageSize = '',$isInner = false) { // 获取授权token - $authorization = trim($this->request->header('authorization', $this->authorization)); + $authorization = $this->authorization; + //$authorization = 'vIxE_SlpPqQLpG3maOL8VaPBDz_uoGqhK4HGR4VtxvtsjNkW9kP6RQicwsfX6lLXruq9UqyDV7wBU5iGT2OPv3t_GZKfVUv-PG_CL4zc6806GKhmT7QxFOXHLF0KH2VWlzVfo9i_MxsuPm9MqiuYwKDXKOpBwSemNL6vwYOrIkZBAcanG06rPEdSlrNcNyJiYrUpqZKDeQEgxE4o9WeYVczYLN8OS-p8Z57DXlVwW8CJCdLsFi7csBVT7uTreDJnAv7wraMRHB5FYs1U7vEmO9IbmsQhhdC1swMuz0kQIESr2zf11nBKEDEadMoH4HptIENXQQ'; if (empty($authorization)) { if($isInner){ return json_encode(['code'=>500,'msg'=>'缺少授权信息']); @@ -190,7 +217,7 @@ class MessageController extends BaseController } } - $fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00', strtotime('-1 days'))); + $fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00')); $toTime = $this->request->param('toTime', date('Y-m-d 23:59:59')); @@ -225,11 +252,21 @@ class MessageController extends BaseController $result = requestCurl($this->baseUrl . 'api/WechatChatroom/listWechatChatroomForMsgPagination', $params, 'POST', $header, 'json'); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } + // 获取同步消息标志 $syncMessages = $this->request->param('syncMessages', true); // 如果需要同步消息,则获取每个群的消息 - if ($syncMessages && !empty($response)) { + if ($syncMessages && !empty($response['results'])) { $from = strtotime($fromTime) * 1000; $to = strtotime($toTime) * 1000; foreach ($response['results'] as &$chatroom) { @@ -254,10 +291,17 @@ class MessageController extends BaseController $messageResult = requestCurl($this->baseUrl . 'api/ChatroomMessage/searchMessage', $messageParams, 'GET', $header, 'json'); $messageResponse = handleApiResponse($messageResult); + // 确保 messageResponse 是数组格式 + if (!is_array($messageResponse)) { + $messageResponse = []; + } + // 保存消息到数据库 if (!empty($messageResponse)) { foreach ($messageResponse as $item) { + if (is_array($item)) { $this->saveChatroomMessage($item); + } } } @@ -325,12 +369,19 @@ class MessageController extends BaseController $result = requestCurl($this->baseUrl . 'api/ChatroomMessage/searchMessage', $params, 'GET', $header, 'json'); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + // 保存数据到数据库 if (!empty($response)) { foreach ($response as $item) { + if (is_array($item)) { $res = $this->saveChatroomMessage($item); if(!$res){ return errorJson('保存群聊消息失败'); + } } } } @@ -350,7 +401,7 @@ class MessageController extends BaseController public function saveMessage($item) { // 检查消息是否已存在 - $exists = WechatMessageModel::where('id', $item['id']) ->find(); + $exists = WechatMessageModel::where(['id'=> $item['id'],'type' => 1])->find(); if (!empty($exists) && $exists['sendStatus'] == 0){ return true; @@ -421,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')->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, @@ -449,9 +506,11 @@ class MessageController extends BaseController */ public function saveChatroomMessage($item) { - // 检查消息是否已存在 - $exists = WechatMessageModel::where('id', $item['id'])->find(); + // 检查消息是否已存在(必须指定 type=2 表示群聊消息) + $exists = WechatMessageModel::where(['id' => $item['id'], 'type' => 2])->find(); + // 如果消息已存在且 sendStatus == 0(已发送),则跳过更新 + // 注意:这里只跳过已发送的消息,未发送的消息(sendStatus != 0)仍然需要更新 if (!empty($exists) && $exists['sendStatus'] == 0){ return true; } @@ -502,16 +561,29 @@ class MessageController extends BaseController 'recallId' => $item['recallId'] ?? false ]; - // 创建新记录 + // 创建或更新记录 try { if(empty($exists)){ - WechatMessageModel::create($data); + // 新记录,直接创建 + $result = WechatMessageModel::create($data); + if (!$result) { + throw new \Exception('创建群聊消息记录失败'); + } }else{ + // 已存在记录,更新(排除 id 字段) unset($data['id']); - $exists->save($data); + $result = $exists->save($data); + if ($result === false) { + throw new \Exception('更新群聊消息记录失败'); + } } return true; } catch (\Exception $e) { + // 记录错误日志,便于调试 + \think\facade\Log::error('保存群聊消息失败:' . $e->getMessage(), [ + 'message_id' => $item['id'] ?? '', + 'data' => $data ?? [] + ]); return false; } } diff --git a/application/api/controller/WebSocketController.php b/application/api/controller/WebSocketController.php index 73d6bf3..2c64245 100644 --- a/application/api/controller/WebSocketController.php +++ b/application/api/controller/WebSocketController.php @@ -239,7 +239,8 @@ class WebSocketController extends BaseController $wechatAccountId = !empty($data['wechatAccountId']) ? $data['wechatAccountId'] : ''; $wechatFriendId = !empty($data['wechatFriendId']) ? $data['wechatFriendId'] : 0; $prevSnsId = !empty($data['prevSnsId']) ? $data['prevSnsId'] : 0; - $maxPages = 1; // 最大页数限制为20 + $isTimeline = !empty($data['isTimeline']) ? $data['isTimeline'] : false; + $maxPages = !empty($data['maxPages']) ? $data['maxPages'] : 1; // 最大页数限制为20 $currentPage = 1; // 当前页码 $allMoments = []; // 存储所有朋友圈数据 @@ -254,7 +255,7 @@ class WebSocketController extends BaseController "cmdType" => "CmdFetchMoment", "count" => $count, "createTimeSec" => time(), - "isTimeline" => false, + "isTimeline" => $isTimeline, "prevSnsId" => $prevSnsId, "wechatAccountId" => $wechatAccountId, "wechatFriendId" => $wechatFriendId, diff --git a/application/api/controller/WechatChatroomController.php b/application/api/controller/WechatChatroomController.php index 3da5ec6..c47a008 100644 --- a/application/api/controller/WechatChatroomController.php +++ b/application/api/controller/WechatChatroomController.php @@ -53,13 +53,25 @@ class WechatChatroomController extends BaseController $result = requestCurl($this->baseUrl . 'api/WechatChatroom/pagelist', $params, 'GET', $header,'json'); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } + // 保存数据到数据库 if (!empty($response['results'])) { $isUpdate = false; foreach ($response['results'] as $item) { + if (is_array($item)) { $updated = $this->saveChatroom($item); if($updated && $isDel == 0){ $isUpdate = true; + } } } } @@ -174,10 +186,17 @@ class WechatChatroomController extends BaseController $result = requestCurl($this->baseUrl . 'api/WechatChatroom/listChatroomMember', $params, 'GET', $header); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + // 保存数据到数据库 if (!empty($response)) { foreach ($response as $item) { + if (is_array($item)) { $this->saveChatroomMember($item, $chatroomId); + } } } diff --git a/application/api/controller/WechatController.php b/application/api/controller/WechatController.php index b3903bc..0ad36d9 100644 --- a/application/api/controller/WechatController.php +++ b/application/api/controller/WechatController.php @@ -50,10 +50,23 @@ class WechatController extends BaseController // 发送请求获取基本信息 $result = requestCurl($this->baseUrl . 'api/WechatAccount/list', $params, 'GET', $header); $response = handleApiResponse($result); + + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + + // 确保 results 字段存在且是数组 + if (!isset($response['results']) || !is_array($response['results'])) { + $response['results'] = []; + } + // 保存基本数据到数据库 if (!empty($response['results'])) { foreach ($response['results'] as $item) { - $this->saveWechatAccount($item); + if (is_array($item)) { + $this->saveWechatAccount($item); + } } // 获取并更新微信账号状态信息 diff --git a/application/api/controller/WechatFriendController.php b/application/api/controller/WechatFriendController.php index 18fbe43..b2ef3b9 100644 --- a/application/api/controller/WechatFriendController.php +++ b/application/api/controller/WechatFriendController.php @@ -76,13 +76,20 @@ class WechatFriendController extends BaseController $result = requestCurl($this->baseUrl . 'api/WechatFriend/friendlistData', $params, 'POST', $header, 'json'); $response = handleApiResponse($result); + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } + // 保存数据到数据库 - if (is_array($response)) { + if (!empty($response)) { $isUpdate = false; foreach ($response as $item) { - $updated = $this->saveFriend($item); - if($updated && $isDel == 0){ - $isUpdate = true; + if (is_array($item)) { + $updated = $this->saveFriend($item); + if($updated && $isDel == 0){ + $isUpdate = true; + } } } } diff --git a/application/chukebao/controller/AiSettingsController.php b/application/chukebao/controller/AiSettingsController.php index bef43ac..3fe9da4 100644 --- a/application/chukebao/controller/AiSettingsController.php +++ b/application/chukebao/controller/AiSettingsController.php @@ -196,7 +196,7 @@ class AiSettingsController extends BaseController return ResponseHelper::error('参数缺失'); } //列出所有好友 - $row = Db::name('traffic_source_package_item')->alias('a') + $row = Db::name('traffic_source_package_item_v1')->alias('a') ->join('wechat_friendship f','a.identifier = f.wechatId and f.companyId = '.$companyId) ->join(['s2_wechat_account' => 'wa'],'f.ownerWechatId = wa.wechatId') ->whereIn('a.packageId' , $packageId) diff --git a/application/chukebao/controller/CustomerServiceController.php b/application/chukebao/controller/CustomerServiceController.php index d0eefb6..c6c7cd1 100644 --- a/application/chukebao/controller/CustomerServiceController.php +++ b/application/chukebao/controller/CustomerServiceController.php @@ -24,11 +24,17 @@ class CustomerServiceController extends BaseController $wechatAliveTime = time() - 86400 * 30; - $list = Db::table('s2_wechat_account') - ->whereIn('id',$accountIds) - ->where('wechatAliveTime','>',$wechatAliveTime) - ->order('id desc') - ->group('id') + $list = Db::table('s2_wechat_account')->alias('wa') + ->join(['s2_device' => 'd'],'wa.currentDeviceId = d.id','LEFT') + ->whereIn('wa.id',$accountIds) + ->where('wa.wechatAliveTime','>',$wechatAliveTime) + ->order('wa.id desc') + ->group('wa.id') + ->field([ + 'wa.*', + 'd.imei', + 'd.extra', + ]) ->select(); foreach ($list as $k=>&$v){ $v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s',$v['createTime']) : ''; @@ -37,11 +43,16 @@ class CustomerServiceController extends BaseController $momentsSetting = Db::name('kf_moments_settings')->where(['userId' => $userId,'companyId' => $companyId,'wechatId' =>$v['id']])->find(); $v['momentsMax'] = !empty($momentsSetting['max']) ? $momentsSetting['max'] : 5; $v['momentsNum'] = !empty($momentsSetting['sendNum']) ? $momentsSetting['sendNum'] : 0; - + $v['deviceExtra'] = json_decode($v['extra'],true); + $v['deviceExtra']['imei'] = $v['imei']; + $v['deviceExtra']['memo'] = $v['deviceMemo']; unset( $v['accountUserName'], $v['accountRealName'], $v['accountNickname'], + $v['extra'], + $v['imei'], + $v['deviceMemo'], ); } unset($v); diff --git a/application/chukebao/controller/MessageController.php b/application/chukebao/controller/MessageController.php index 0f6b939..9ae0573 100644 --- a/application/chukebao/controller/MessageController.php +++ b/application/chukebao/controller/MessageController.php @@ -31,74 +31,74 @@ class MessageController extends BaseController } // 直接查询好友ID列表 - $ids = Db::table('s2_wechat_friend') - ->where(['accountId' => $accountId, 'isDeleted' => 0]) - ->column('id'); - $friendIds = empty($ids) ? [0] : $ids; // 避免 IN 查询为空 + $ids = Db::table('s2_wechat_friend') + ->where(['accountId' => $accountId, 'isDeleted' => 0]) + ->column('id'); + $friendIds = empty($ids) ? [0] : $ids; // 避免 IN 查询为空 // 直接查询好友信息 - $friends = Db::table('s2_wechat_friend') - ->where(['accountId' => $accountId, 'isDeleted' => 0]) - ->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region,isTop'); + $friends = Db::table('s2_wechat_friend') + ->where(['accountId' => $accountId, 'isDeleted' => 0]) + ->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region,isTop'); // 直接查询群聊信息 - $chatrooms = Db::table('s2_wechat_chatroom') - ->where(['accountId' => $accountId, 'isDeleted' => 0]) - ->column('id,nickname,chatroomAvatar,chatroomId,isTop'); + $chatrooms = Db::table('s2_wechat_chatroom') + ->where(['accountId' => $accountId, 'isDeleted' => 0]) + ->column('id,nickname,chatroomAvatar,chatroomId,isTop'); - // 获取群聊ID列表 - $chatroomIds = array_keys($chatrooms); - if (empty($chatroomIds)) { - $chatroomIds = [0]; - } - - // 1. 查询群聊最新消息 - $chatroomMessages = []; - if (!empty($chatroomIds) && $chatroomIds[0] != 0) { - $chatroomIdsStr = implode(',', array_map('intval', $chatroomIds)); - $chatroomLatestQuery = " - SELECT wc.id as chatroomId, m.id, m.content, m.wechatChatroomId, m.createTime, m.wechatTime, m.wechatAccountId, - wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId, wc.isTop, 2 as msgType - FROM s2_wechat_chatroom wc - INNER JOIN ( - SELECT wechatChatroomId, MAX(wechatTime) as maxTime, MAX(id) as maxId - FROM s2_wechat_message - WHERE type = 2 AND wechatChatroomId IN ({$chatroomIdsStr}) - GROUP BY wechatChatroomId - ) latest ON wc.id = latest.wechatChatroomId - INNER JOIN s2_wechat_message m ON m.wechatChatroomId = latest.wechatChatroomId - AND m.wechatTime = latest.maxTime AND m.id = latest.maxId - WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0 - "; - $chatroomMessages = Db::query($chatroomLatestQuery); - } + // 获取群聊ID列表 + $chatroomIds = array_keys($chatrooms); + if (empty($chatroomIds)) { + $chatroomIds = [0]; + } + + // 1. 查询群聊最新消息 + $chatroomMessages = []; + if (!empty($chatroomIds) && $chatroomIds[0] != 0) { + $chatroomIdsStr = implode(',', array_map('intval', $chatroomIds)); + $chatroomLatestQuery = " + SELECT wc.id as chatroomId, m.id, m.content, m.wechatChatroomId, m.createTime, m.wechatTime, m.wechatAccountId, + wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId, wc.isTop, 2 as msgType + FROM s2_wechat_chatroom wc + INNER JOIN ( + SELECT wechatChatroomId, MAX(wechatTime) as maxTime, MAX(id) as maxId + FROM s2_wechat_message + WHERE type = 2 AND wechatChatroomId IN ({$chatroomIdsStr}) + GROUP BY wechatChatroomId + ) latest ON wc.id = latest.wechatChatroomId + INNER JOIN s2_wechat_message m ON m.wechatChatroomId = latest.wechatChatroomId + AND m.wechatTime = latest.maxTime AND m.id = latest.maxId + WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0 + "; + $chatroomMessages = Db::query($chatroomLatestQuery); + } - // 2. 查询好友最新消息 - $friendMessages = []; - if (!empty($friendIds) && $friendIds[0] != 0) { - $friendIdsStr = implode(',', array_map('intval', $friendIds)); - $friendLatestQuery = " - SELECT m.wechatFriendId, m.id, m.content, m.createTime, m.wechatTime, - f.wechatAccountId, 1 as msgType, 0 as isTop - FROM s2_wechat_message m - INNER JOIN ( - SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId - FROM s2_wechat_message - WHERE type = 1 AND wechatFriendId IN ({$friendIdsStr}) - GROUP BY wechatFriendId - ) latest ON m.wechatFriendId = latest.wechatFriendId - AND m.wechatTime = latest.maxTime AND m.id = latest.maxId - INNER JOIN s2_wechat_friend f ON f.id = m.wechatFriendId - WHERE m.type = 1 AND m.wechatFriendId IN ({$friendIdsStr}) - "; - $friendMessages = Db::query($friendLatestQuery); - } + // 2. 查询好友最新消息 + $friendMessages = []; + if (!empty($friendIds) && $friendIds[0] != 0) { + $friendIdsStr = implode(',', array_map('intval', $friendIds)); + $friendLatestQuery = " + SELECT m.wechatFriendId, m.id, m.content, m.createTime, m.wechatTime, + f.wechatAccountId, 1 as msgType, 0 as isTop + FROM s2_wechat_message m + INNER JOIN ( + SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId + FROM s2_wechat_message + WHERE type = 1 AND wechatFriendId IN ({$friendIdsStr}) + GROUP BY wechatFriendId + ) latest ON m.wechatFriendId = latest.wechatFriendId + AND m.wechatTime = latest.maxTime AND m.id = latest.maxId + INNER JOIN s2_wechat_friend f ON f.id = m.wechatFriendId + WHERE m.type = 1 AND m.wechatFriendId IN ({$friendIdsStr}) + "; + $friendMessages = Db::query($friendLatestQuery); + } - // 合并结果并排序 - $allMessages = array_merge($chatroomMessages, $friendMessages); - usort($allMessages, function ($a, $b) { - return $b['wechatTime'] <=> $a['wechatTime']; - }); + // 合并结果并排序 + $allMessages = array_merge($chatroomMessages, $friendMessages); + usort($allMessages, function ($a, $b) { + return $b['wechatTime'] <=> $a['wechatTime']; + }); // 计算总数 $totalCount = count($allMessages); diff --git a/application/chukebao/controller/MomentsController.php b/application/chukebao/controller/MomentsController.php index f0d6a4d..33705bb 100644 --- a/application/chukebao/controller/MomentsController.php +++ b/application/chukebao/controller/MomentsController.php @@ -379,10 +379,50 @@ class MomentsController extends BaseController $total = KfMoments::where(['companyId' => $companyId, 'userId' => $userId, 'isDel' => 0])->count(); + // 收集所有需要查询的微信账号ID + $allWechatAccountIds = []; + foreach ($list as $item) { + $sendData = json_decode($item->sendData, true); + $items = $sendData['jobPublishWechatMomentsItems'] ?? []; + foreach ($items as $accountItem) { + if (!empty($accountItem['wechatAccountId'])) { + $allWechatAccountIds[] = $accountItem['wechatAccountId']; + } + } + } + + // 批量查询微信账号信息 + $wechatAccountsMap = []; + if (!empty($allWechatAccountIds)) { + $wechatAccounts = Db::table('s2_wechat_account') + ->whereIn('id', array_unique($allWechatAccountIds)) + ->field('id, wechatId, nickName, avatar') + ->select(); + foreach ($wechatAccounts as $account) { + $wechatAccountsMap[$account['id']] = $account; + } + } + // 处理数据 $data = []; foreach ($list as $item) { - $sendData = json_decode($item->sendData,true); + $sendData = json_decode($item->sendData, true); + $momentsItems = $sendData['jobPublishWechatMomentsItems'] ?? []; + + // 构建账号详情列表 + $accounts = []; + foreach ($momentsItems as $accountItem) { + $wechatAccountId = $accountItem['wechatAccountId'] ?? 0; + $accountInfo = $wechatAccountsMap[$wechatAccountId] ?? null; + $accounts[] = [ + 'wechatAccountId' => $wechatAccountId, + 'wechatId' => $accountInfo['wechatId'] ?? '', + 'nickName' => $accountInfo['nickName'] ?? '', + 'avatar' => $accountInfo['avatar'] ?? '', + 'labels' => $accountItem['labels'] ?? [] + ]; + } + $data[] = [ 'id' => $item->id, 'content' => $sendData['text'] ?? '', @@ -392,8 +432,9 @@ class MomentsController extends BaseController 'link' => $sendData['link'] ?? [], 'publicMode' => $sendData['publicMode'] ?? 2, 'isSend' => $item->isSend, - 'sendTime' => date('Y-m-d H:i:s',$item->sendTime), - 'accountCount' => count($sendData['jobPublishWechatMomentsItems'] ?? []) + 'sendTime' => date('Y-m-d H:i:s', $item->sendTime), + 'accountCount' => count($accounts), + 'accounts' => $accounts ]; } diff --git a/application/chukebao/controller/ReplyController.php b/application/chukebao/controller/ReplyController.php index fb1dea7..c7c2fde 100644 --- a/application/chukebao/controller/ReplyController.php +++ b/application/chukebao/controller/ReplyController.php @@ -127,6 +127,27 @@ class ReplyController extends BaseController if ($title === '') { return ResponseHelper::error('标题不能为空'); } + if ($content === '') { + return ResponseHelper::error('内容不能为空'); + } + + // 根据 msgType 处理 content:3=图片,43=视频,49=链接 需要 JSON 编码 + if (in_array($msgType, [3, 43, 49])) { + // 如果 content 已经是数组,直接编码;如果是字符串,先尝试解码再编码(确保格式正确) + if (is_array($content)) { + $content = json_encode($content, JSON_UNESCAPED_UNICODE); + } elseif (is_string($content)) { + // 尝试解析,如果已经是 JSON 字符串,确保格式正确 + $decoded = json_decode($content, true); + if ($decoded !== null) { + // 是有效的 JSON,重新编码确保格式统一 + $content = json_encode($decoded, JSON_UNESCAPED_UNICODE); + } else { + // 不是 JSON,直接编码 + $content = json_encode($content, JSON_UNESCAPED_UNICODE); + } + } + } try { $now = time(); @@ -142,12 +163,20 @@ class ReplyController extends BaseController 'lastUpdateTime' => $now, 'userId' => $userId, ]; - /** @var Reply $reply */ $reply = new Reply(); $reply->save($data); - return ResponseHelper::success($reply->toArray(), '创建成功'); + // 返回时解析 content(与 buildGroupData 保持一致) + $replyData = $reply->toArray(); + if (in_array($msgType, [3, 43, 49]) && !empty($replyData['content'])) { + $decoded = json_decode($replyData['content'], true); + if ($decoded !== null) { + $replyData['content'] = $decoded; + } + } + + return ResponseHelper::success($replyData, '创建成功'); } catch (\Exception $e) { return ResponseHelper::error('创建失败:' . $e->getMessage()); } @@ -232,9 +261,46 @@ class ReplyController extends BaseController $sortIndex = $this->request->param('sortIndex', null); if ($groupId !== null) $data['groupId'] = (int)$groupId; - if ($title !== null) $data['title'] = $title; + if ($title !== null) { + if ($title === '') { + return ResponseHelper::error('标题不能为空'); + } + $data['title'] = $title; + } if ($msgType !== null) $data['msgType'] = (int)$msgType; - if ($content !== null) $data['content'] = $content; + if ($content !== null) { + // 确定 msgType:如果传了新的 msgType,用新的;否则用原有的 + $currentMsgType = $msgType !== null ? (int)$msgType : null; + if ($currentMsgType === null) { + // 需要查询原有的 msgType + $reply = Reply::where(['id' => $id, 'isDel' => 0])->find(); + if (empty($reply)) { + return ResponseHelper::error('快捷语不存在'); + } + $currentMsgType = $reply->msgType; + } + + // 根据 msgType 处理 content:3=图片,43=视频,49=链接 需要 JSON 编码 + if (in_array($currentMsgType, [3, 43, 49])) { + // 如果 content 已经是数组,直接编码;如果是字符串,先尝试解码再编码(确保格式正确) + if (is_array($content)) { + $data['content'] = json_encode($content, JSON_UNESCAPED_UNICODE); + } elseif (is_string($content)) { + // 尝试解析,如果已经是 JSON 字符串,确保格式正确 + $decoded = json_decode($content, true); + if ($decoded !== null) { + // 是有效的 JSON,重新编码确保格式统一 + $data['content'] = json_encode($decoded, JSON_UNESCAPED_UNICODE); + } else { + // 不是 JSON,直接编码 + $data['content'] = json_encode($content, JSON_UNESCAPED_UNICODE); + } + } + } else { + // 文本类型,直接使用 + $data['content'] = $content; + } + } if ($sortIndex !== null) $data['sortIndex'] = (string)$sortIndex; if (!empty($data)) { $data['lastUpdateTime'] = time(); @@ -245,12 +311,23 @@ class ReplyController extends BaseController } try { - $reply = Reply::where(['id' => $id,'isDel' => 0])->find(); + $reply = Reply::where(['id' => $id, 'isDel' => 0])->find(); if (empty($reply)) { return ResponseHelper::error('快捷语不存在'); } $reply->save($data); - return ResponseHelper::success($reply->toArray(), '更新成功'); + + // 返回时解析 content(与 buildGroupData 保持一致) + $replyData = $reply->toArray(); + $finalMsgType = isset($data['msgType']) ? $data['msgType'] : $reply->msgType; + if (in_array($finalMsgType, [3, 43, 49]) && !empty($replyData['content'])) { + $decoded = json_decode($replyData['content'], true); + if ($decoded !== null) { + $replyData['content'] = $decoded; + } + } + + return ResponseHelper::success($replyData, '更新成功'); } catch (\Exception $e) { return ResponseHelper::error('更新失败:' . $e->getMessage()); } @@ -329,10 +406,24 @@ class ReplyController extends BaseController // 获取该分组下的快捷回复 $replies = Reply::where($replyWhere) - ->order('sortIndex asc, id desc - ') + ->order('sortIndex asc, id desc') ->select(); + // 解析 replies 的 content 字段(根据 msgType 判断是否需要 JSON 解析) + $repliesArray = []; + foreach ($replies as $reply) { + $replyData = $reply->toArray(); + // 根据 msgType 解析 content:3=图片,43=视频,49=链接 + if (in_array($replyData['msgType'], [3, 43, 49]) && !empty($replyData['content'])) { + $decoded = json_decode($replyData['content'], true); + // 如果解析成功,使用解析后的内容;否则保持原样 + if ($decoded !== null) { + $replyData['content'] = $decoded; + } + } + $repliesArray[] = $replyData; + } + return [ 'id' => $group->id, 'groupName' => $group->groupName, @@ -342,7 +433,7 @@ class ReplyController extends BaseController 'replys' => $group->replys, 'companyId' => $group->companyId, 'userId' => $group->userId, - 'replies' => $replies->toArray(), + 'replies' => $repliesArray, 'children' => [] // 子分组 ]; } diff --git a/application/chukebao/controller/WechatChatroomController.php b/application/chukebao/controller/WechatChatroomController.php index 936c837..9c9e2f9 100644 --- a/application/chukebao/controller/WechatChatroomController.php +++ b/application/chukebao/controller/WechatChatroomController.php @@ -118,12 +118,13 @@ class WechatChatroomController extends BaseController } $detail = Db::table('s2_wechat_chatroom') - ->where(['accountId' => $accountId, 'id' => $id, 'isDeleted' => 0]) + //->where(['accountId' => $accountId, 'id' => $id, 'isDeleted' => 0]) + ->where([ 'id' => $id, 'isDeleted' => 0]) ->find(); - if (!$detail) { - return ResponseHelper::error('聊天室不存在或无权限访问'); - } + // if (!$detail) { + // return ResponseHelper::error('聊天室不存在或无权限访问'); + // } // 处理时间格式 $detail['createTime'] = !empty($detail['createTime']) ? date('Y-m-d H:i:s', $detail['createTime']) : ''; diff --git a/application/command.php b/application/command.php index 8fc4a2e..45b6d7d 100644 --- a/application/command.php +++ b/application/command.php @@ -50,4 +50,7 @@ return [ // 检查未读/未回复消息并自动迁移好友 'check:unread-message' => 'app\command\CheckUnreadMessageCommand', // 检查未读/未回复消息并自动迁移好友 + + // V2 流量池数据迁移 + 'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统 ]; diff --git a/application/command/CheckUnreadMessageCommand.php b/application/command/CheckUnreadMessageCommand.php index b5fa343..74fd92d 100644 --- a/application/command/CheckUnreadMessageCommand.php +++ b/application/command/CheckUnreadMessageCommand.php @@ -22,7 +22,7 @@ class CheckUnreadMessageCommand extends Command { $this->setName('check:unread-message') ->setDescription('检查未读/未回复消息并自动迁移好友') - ->addOption('minutes', 'm', \think\console\input\Option::VALUE_OPTIONAL, '未读/未回复分钟数,默认30分钟', 30) + ->addOption('minutes', 'm', \think\console\input\Option::VALUE_OPTIONAL, '未读/未回复分钟数,默认10分钟', 10) ->addOption('page-size', 'p', \think\console\input\Option::VALUE_OPTIONAL, '每页处理数量,默认100条', 100); } @@ -30,7 +30,7 @@ class CheckUnreadMessageCommand extends Command { $minutes = intval($input->getOption('minutes')); if ($minutes <= 0) { - $minutes = 30; + $minutes = 10; } $pageSize = intval($input->getOption('page-size')); diff --git a/application/command/CleanLogsCommand.php b/application/command/CleanLogsCommand.php new file mode 100644 index 0000000..b9f0ba8 --- /dev/null +++ b/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/application/command/MigrateTrafficPoolV2Command.php b/application/command/MigrateTrafficPoolV2Command.php new file mode 100644 index 0000000..40f1cf5 --- /dev/null +++ b/application/command/MigrateTrafficPoolV2Command.php @@ -0,0 +1,235 @@ +lockFile = App::getRuntimePath() . 'migrate_traffic_pool_v2.lock'; + } + + protected function configure() + { + $this->setName('migrate:trafficPoolV2') + ->setDescription('迁移数据到 V2 流量池系统') + ->addOption('step', 's', Option::VALUE_OPTIONAL, '执行指定步骤(1-7),不指定则执行全部', null); + } + + protected function execute(Input $input, Output $output) + { + // 检查锁文件 + if (file_exists($this->lockFile)) { + $lockTime = filectime($this->lockFile); + if (time() - $lockTime < 7200) { // 2小时内 + $output->writeln('迁移任务已在运行中,跳过本次执行'); + return false; + } + unlink($this->lockFile); + } + + file_put_contents($this->lockFile, time()); + + try { + $step = $input->getOption('step'); + $adapter = new ChuKeBaoAdapter(); + + $output->writeln('===================================='); + $output->writeln(' V2 流量池数据迁移开始'); + $output->writeln('===================================='); + $output->writeln(''); + + $startTime = microtime(true); + + if ($step === null) { + // 执行完整迁移 + $results = $this->runFullMigration($adapter, $output); + } else { + // 执行指定步骤 + $results = $this->runStep((int)$step, $adapter, $output); + } + + $endTime = microtime(true); + $duration = round($endTime - $startTime, 2); + + $output->writeln(''); + $output->writeln('===================================='); + $output->writeln(' 迁移完成'); + $output->writeln('===================================='); + $output->writeln("耗时: {$duration} 秒"); + $output->writeln(''); + $output->writeln('结果统计:'); + foreach ($results as $key => $value) { + $output->writeln(" - {$key}: {$value} 条"); + } + + return true; + + } catch (\Exception $e) { + $output->writeln('迁移异常: ' . $e->getMessage() . ''); + Log::error('V2流量池迁移异常:' . $e->getMessage() . "\n" . $e->getTraceAsString()); + return false; + } finally { + if (file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } + } + + /** + * 执行完整迁移 + */ + protected function runFullMigration(ChuKeBaoAdapter $adapter, Output $output) + { + $results = [ + 'friend_pool' => 0, + 'friend_pool_company' => 0, + 'friend_pool_source' => 0, + 'chatroom_pool' => 0, + 'chatroom_pool_company' => 0, + 'chatroom_pool_source' => 0, + 'pool_tags' => 0, + ]; + + // === 好友数据迁移 === + $output->writeln('【好友数据迁移】'); + + // Step 1: 好友同步到流量池总表 + $output->writeln('[1/7] 同步好友到流量池总表 ck_traffic_pool ...'); + $results['friend_pool'] = $adapter->syncToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool']}"); + + // Step 2: 好友同步到公司流量详情表 + $output->writeln('[2/7] 同步好友到公司流量详情表 ck_traffic_pool_company ...'); + $results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_company']}"); + + // Step 3: 好友同步到流量来源表 + $output->writeln('[3/7] 同步好友到流量来源表 ck_traffic_pool_source ...'); + $results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_source']}"); + + // === 群成员数据迁移 === + $output->writeln(''); + $output->writeln('【群成员数据迁移】'); + + // Step 4: 群成员同步到流量池总表 + $output->writeln('[4/7] 同步群成员到流量池总表 ck_traffic_pool ...'); + $results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool']}"); + + // Step 5: 群成员同步到公司流量详情表 + $output->writeln('[5/7] 同步群成员到公司流量详情表 ck_traffic_pool_company ...'); + $results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_company']}"); + + // Step 6: 群成员同步到流量来源表 + $output->writeln('[6/7] 同步群成员到流量来源表 ck_traffic_pool_source ...'); + $results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_source']}"); + + // === 标签数据迁移 === + $output->writeln(''); + $output->writeln('【标签数据迁移】'); + + // Step 7: 同步微信标签 + $output->writeln('[7/7] 同步微信标签 ck_traffic_pool_tag ...'); + $results['pool_tags'] = $adapter->syncWechatTagsToV2(); + $output->writeln(" 完成,影响行数: {$results['pool_tags']}"); + + return $results; + } + + /** + * 执行指定步骤 + */ + protected function runStep(int $step, ChuKeBaoAdapter $adapter, Output $output) + { + $results = []; + + switch ($step) { + case 1: + $output->writeln('[Step 1] 同步好友到流量池总表 ck_traffic_pool ...'); + $results['friend_pool'] = $adapter->syncToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool']}"); + break; + + case 2: + $output->writeln('[Step 2] 同步好友到公司流量详情表 ck_traffic_pool_company ...'); + $results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_company']}"); + break; + + case 3: + $output->writeln('[Step 3] 同步好友到流量来源表 ck_traffic_pool_source ...'); + $results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_source']}"); + break; + + case 4: + $output->writeln('[Step 4] 同步群成员到流量池总表 ck_traffic_pool ...'); + $results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool']}"); + break; + + case 5: + $output->writeln('[Step 5] 同步群成员到公司流量详情表 ck_traffic_pool_company ...'); + $results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_company']}"); + break; + + case 6: + $output->writeln('[Step 6] 同步群成员到流量来源表 ck_traffic_pool_source ...'); + $results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_source']}"); + break; + + case 7: + $output->writeln('[Step 7] 同步微信标签 ck_traffic_pool_tag ...'); + $results['pool_tags'] = $adapter->syncWechatTagsToV2(); + $output->writeln(" 完成,影响行数: {$results['pool_tags']}"); + break; + + default: + $output->writeln('无效的步骤编号,请输入 1-7'); + $output->writeln(''); + $output->writeln('步骤说明:'); + $output->writeln(' 1 - 同步好友到流量池总表'); + $output->writeln(' 2 - 同步好友到公司流量详情表'); + $output->writeln(' 3 - 同步好友到流量来源表'); + $output->writeln(' 4 - 同步群成员到流量池总表'); + $output->writeln(' 5 - 同步群成员到公司流量详情表'); + $output->writeln(' 6 - 同步群成员到流量来源表'); + $output->writeln(' 7 - 同步微信标签'); + break; + } + + return $results; + } +} diff --git a/application/command/SyncWechatDataToCkbTask.php b/application/command/SyncWechatDataToCkbTask.php index 688f4fd..8d0409c 100644 --- a/application/command/SyncWechatDataToCkbTask.php +++ b/application/command/SyncWechatDataToCkbTask.php @@ -124,5 +124,43 @@ class SyncWechatDataToCkbTask extends Command return $ChuKeBaoAdapter->syncCallRecording(); } + /** + * 同步数据到 V2 流量池总表 + */ + protected function syncToTrafficPoolV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncToTrafficPoolV2(); + } + /** + * 同步数据到 V2 公司流量详情表 + */ + protected function syncToTrafficPoolCompanyV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncToTrafficPoolCompanyV2(); + } + + /** + * 同步数据到 V2 流量来源表 + */ + protected function syncToTrafficPoolSourceV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncToTrafficPoolSourceV2(); + } + + /** + * 同步微信标签到 V2 标签系统 + */ + protected function syncWechatTagsToV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncWechatTagsToV2(); + } + + /** + * 执行完整的 V2 流量池数据迁移 + */ + protected function migrateToTrafficPoolV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->migrateToTrafficPoolV2(); + } } \ No newline at end of file diff --git a/application/command/TaskSchedulerCommand.php b/application/command/TaskSchedulerCommand.php index b23dae2..a975985 100644 --- a/application/command/TaskSchedulerCommand.php +++ b/application/command/TaskSchedulerCommand.php @@ -40,11 +40,18 @@ class TaskSchedulerCommand extends Command * 日志目录 */ protected $logDir = ''; + + /** + * 锁文件目录 + */ + protected $lockDir = ''; protected function configure() { $this->setName('scheduler:run') - ->setDescription('统一任务调度器,支持多进程并发执行所有定时任务'); + ->setDescription('统一任务调度器,支持多进程并发执行所有定时任务') + ->addOption('task', 't', \think\console\input\Option::VALUE_OPTIONAL, '指定要执行的任务ID(测试模式,忽略Cron表达式)', '') + ->addOption('force', 'f', \think\console\input\Option::VALUE_NONE, '强制执行所有启用的任务(忽略Cron表达式)'); } protected function execute(Input $input, Output $output) @@ -61,34 +68,25 @@ class TaskSchedulerCommand extends Command $this->maxConcurrent = 1; } + // 获取项目根目录(使用 __DIR__ 更可靠) + // TaskSchedulerCommand.php 位于 application/command/,向上两级到项目根目录 + $rootPath = dirname(__DIR__, 2); + // 加载任务配置 // 方法1:尝试通过框架配置加载 $this->tasks = Config::get('task_scheduler', []); // 方法2:如果框架配置没有,直接加载配置文件 if (empty($this->tasks)) { - // 获取项目根目录 - if (!defined('ROOT_PATH')) { - define('ROOT_PATH', dirname(__DIR__, 2)); - } + $configFile = $rootPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php'; - // 尝试多个可能的路径 - $possiblePaths = [ - ROOT_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php', - __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php', - dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php', - ]; - - foreach ($possiblePaths as $configFile) { - if (is_file($configFile)) { - $output->writeln("找到配置文件:{$configFile}"); - $config = include $configFile; - if (is_array($config) && !empty($config)) { - $this->tasks = $config; - break; - } else { - $output->writeln("配置文件返回的不是数组或为空:{$configFile}"); - } + if (is_file($configFile)) { + $output->writeln("找到配置文件:{$configFile}"); + $config = include $configFile; + if (is_array($config) && !empty($config)) { + $this->tasks = $config; + } else { + $output->writeln("配置文件返回的不是数组或为空:{$configFile}"); } } } @@ -99,22 +97,21 @@ class TaskSchedulerCommand extends Command $output->writeln('1. config/task_scheduler.php 文件是否存在'); $output->writeln('2. 文件是否返回有效的数组'); $output->writeln('3. 文件权限是否正确'); - if (defined('ROOT_PATH')) { - $output->writeln('项目根目录:' . ROOT_PATH . ''); - $output->writeln('期望配置文件:' . ROOT_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php'); - } + $output->writeln('项目根目录:' . $rootPath . ''); + $output->writeln('期望配置文件:' . $rootPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php'); return false; } - // 设置日志目录(ThinkPHP5 中无 runtime_path 辅助函数,直接使用 ROOT_PATH/runtime/log) - if (!defined('ROOT_PATH')) { - // CLI 下正常情况下 ROOT_PATH 已在入口脚本 define,这里兜底一次 - define('ROOT_PATH', dirname(__DIR__, 2)); - } - $this->logDir = ROOT_PATH . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR; + // 设置日志目录和锁文件目录(使用 __DIR__ 获取的根目录) + $this->logDir = $rootPath . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR; + $this->lockDir = $rootPath . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'lock' . DIRECTORY_SEPARATOR; + if (!is_dir($this->logDir)) { mkdir($this->logDir, 0755, true); } + if (!is_dir($this->lockDir)) { + mkdir($this->lockDir, 0755, true); + } // 获取当前时间 $currentTime = time(); @@ -124,36 +121,75 @@ class TaskSchedulerCommand extends Command $currentMonth = date('m', $currentTime); $currentWeekday = date('w', $currentTime); // 0=Sunday, 6=Saturday + // 获取命令行参数 + $testTaskId = $input->getOption('task'); + $force = $input->getOption('force'); + $output->writeln("当前时间: {$currentHour}:{$currentMinute}"); $output->writeln("已加载 " . count($this->tasks) . " 个任务配置"); - // 筛选需要执行的任务 - $tasksToRun = []; - $enabledCount = 0; - $disabledCount = 0; - - foreach ($this->tasks as $taskId => $task) { - if (!isset($task['enabled']) || !$task['enabled']) { - $disabledCount++; - continue; + // 测试模式:只执行指定的任务 + if (!empty($testTaskId)) { + if (!isset($this->tasks[$testTaskId])) { + $output->writeln("错误:任务 {$testTaskId} 不存在"); + $output->writeln("可用任务列表:"); + foreach ($this->tasks as $id => $task) { + $taskName = $task['name'] ?? $id; + $enabled = isset($task['enabled']) && $task['enabled'] ? '✓' : '✗'; + $output->writeln(" {$enabled} {$taskName} ({$id})"); + } + return false; } - $enabledCount++; - if ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) { - $tasksToRun[$taskId] = $task; - $output->writeln("任务 {$taskId} 符合执行条件(schedule: {$task['schedule']})"); + $task = $this->tasks[$testTaskId]; + if (!isset($task['enabled']) || !$task['enabled']) { + $output->writeln("错误:任务 {$testTaskId} 已禁用"); + return false; } + + $taskName = $task['name'] ?? $testTaskId; + $output->writeln("测试模式:执行任务 {$taskName} ({$testTaskId})"); + $output->writeln("注意:测试模式会忽略 Cron 表达式,直接执行任务"); + + $tasksToRun = [$testTaskId => $task]; + } else { + // 正常模式:筛选需要执行的任务 + $tasksToRun = []; + $enabledCount = 0; + $disabledCount = 0; + + foreach ($this->tasks as $taskId => $task) { + if (!isset($task['enabled']) || !$task['enabled']) { + $disabledCount++; + continue; + } + $enabledCount++; + + // 强制模式:忽略 Cron 表达式,执行所有启用的任务 + if ($force) { + $tasksToRun[$taskId] = $task; + $taskName = $task['name'] ?? $taskId; + $output->writeln("强制模式:任务 {$taskName} ({$taskId}) 将被执行"); + } elseif ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) { + $tasksToRun[$taskId] = $task; + $taskName = $task['name'] ?? $taskId; + $output->writeln("任务 {$taskName} ({$taskId}) 符合执行条件(schedule: {$task['schedule']})"); + } + } + + $output->writeln("已启用任务数: {$enabledCount},已禁用任务数: {$disabledCount}"); + + if (empty($tasksToRun)) { + $output->writeln('当前时间没有需要执行的任务'); + if (!$force) { + $output->writeln('提示:使用 --force 参数可以强制执行所有启用的任务'); + } + return true; + } + + $output->writeln("找到 " . count($tasksToRun) . " 个需要执行的任务"); } - $output->writeln("已启用任务数: {$enabledCount},已禁用任务数: {$disabledCount}"); - - if (empty($tasksToRun)) { - $output->writeln('当前时间没有需要执行的任务'); - return true; - } - - $output->writeln("找到 " . count($tasksToRun) . " 个需要执行的任务"); - // 执行任务 if ($this->maxConcurrent > 1 && function_exists('pcntl_fork')) { $this->executeConcurrent($tasksToRun, $output); @@ -172,7 +208,7 @@ class TaskSchedulerCommand extends Command } /** - * 判断任务是否应该执行 + * 判断任务是否应该执行(参考 schedule.php 的实现) * * @param string $schedule cron表达式,格式:分钟 小时 日 月 星期 * @param int $minute 当前分钟 @@ -185,36 +221,36 @@ class TaskSchedulerCommand extends Command protected function shouldRun($schedule, $minute, $hour, $day, $month, $weekday) { $parts = preg_split('/\s+/', trim($schedule)); - if (count($parts) < 5) { + if (count($parts) !== 5) { return false; } list($scheduleMinute, $scheduleHour, $scheduleDay, $scheduleMonth, $scheduleWeekday) = $parts; // 解析分钟 - if (!$this->matchCronField($scheduleMinute, $minute)) { + if (!$this->matchCronPart($scheduleMinute, $minute)) { return false; } // 解析小时 - if (!$this->matchCronField($scheduleHour, $hour)) { + if (!$this->matchCronPart($scheduleHour, $hour)) { return false; } // 解析日期 - if (!$this->matchCronField($scheduleDay, $day)) { + if (!$this->matchCronPart($scheduleDay, $day)) { return false; } // 解析月份 - if (!$this->matchCronField($scheduleMonth, $month)) { + if (!$this->matchCronPart($scheduleMonth, $month)) { return false; } - // 解析星期(注意:cron中0和7都表示星期日) + // 解析星期(注意:cron中0和7都表示星期日,PHP的wday中0=Sunday) if ($scheduleWeekday !== '*') { $scheduleWeekday = str_replace('7', '0', $scheduleWeekday); - if (!$this->matchCronField($scheduleWeekday, $weekday)) { + if (!$this->matchCronPart($scheduleWeekday, $weekday)) { return false; } } @@ -223,60 +259,49 @@ class TaskSchedulerCommand extends Command } /** - * 匹配cron字段 + * 匹配Cron表达式的单个部分(参考 schedule.php 的实现) * - * @param string $field cron字段表达式 + * @param string $pattern cron字段表达式 * @param int $value 当前值 * @return bool */ - protected function matchCronField($field, $value) + protected function matchCronPart($pattern, $value) { - // 通配符 - if ($field === '*') { + // * 表示匹配所有 + if ($pattern === '*') { return true; } - // 列表(逗号分隔) - if (strpos($field, ',') !== false) { - $values = explode(',', $field); + // 数字,精确匹配 + if (is_numeric($pattern)) { + return (int)$pattern === $value; + } + + // */n 表示每n个单位 + if (preg_match('/^\*\/(\d+)$/', $pattern, $matches)) { + $interval = (int)$matches[1]; + return $value % $interval === 0; + } + + // n-m 表示范围 + if (preg_match('/^(\d+)-(\d+)$/', $pattern, $matches)) { + $min = (int)$matches[1]; + $max = (int)$matches[2]; + return $value >= $min && $value <= $max; + } + + // n,m 表示多个值 + if (strpos($pattern, ',') !== false) { + $values = explode(',', $pattern); foreach ($values as $v) { - if ($this->matchCronField(trim($v), $value)) { + if ((int)trim($v) === $value) { return true; } } return false; } - // 范围(如 1-5) - if (strpos($field, '-') !== false) { - list($start, $end) = explode('-', $field); - return $value >= (int)$start && $value <= (int)$end; - } - - // 步长(如 */5 或 0-59/5) - if (strpos($field, '/') !== false) { - $parts = explode('/', $field); - $base = $parts[0]; - $step = (int)$parts[1]; - - if ($base === '*') { - return $value % $step === 0; - } else { - // 处理范围步长,如 0-59/5 - if (strpos($base, '-') !== false) { - list($start, $end) = explode('-', $base); - if ($value >= (int)$start && $value <= (int)$end) { - return ($value - (int)$start) % $step === 0; - } - return false; - } else { - return $value % $step === 0; - } - } - } - - // 精确匹配 - return (int)$field === $value; + return false; } /** @@ -296,39 +321,11 @@ class TaskSchedulerCommand extends Command usleep(100000); // 等待100ms } - // 检查任务是否已经在运行(防止重复执行) - $lockKey = "scheduler_task_lock:{$taskId}"; - $lockTime = Cache::get($lockKey); - - // 如果锁存在,检查进程是否真的在运行 - if ($lockTime) { - $lockPid = Cache::get("scheduler_task_pid:{$taskId}"); - if ($lockPid) { - // 检查进程是否真的在运行 - if (function_exists('posix_kill')) { - // 使用 posix_kill(pid, 0) 检查进程是否存在(0信号不杀死进程,只检查) - if (@posix_kill($lockPid, 0)) { - $output->writeln("任务 {$taskId} 正在运行中(PID: {$lockPid}),跳过"); - continue; - } else { - // 进程不存在,清除锁 - Cache::rm($lockKey); - Cache::rm("scheduler_task_pid:{$taskId}"); - } - } else { - // 如果没有 posix_kill,使用时间判断(2分钟内不重复执行) - if ((time() - $lockTime) < 120) { - $output->writeln("任务 {$taskId} 可能在运行中(2分钟内执行过),跳过"); - continue; - } - } - } else { - // 如果没有PID记录,使用时间判断(2分钟内不重复执行) - if ((time() - $lockTime) < 120) { - $output->writeln("任务 {$taskId} 可能在运行中(2分钟内执行过),跳过"); - continue; - } - } + // 检查任务是否已经在运行(使用文件锁,更可靠) + if ($this->isTaskRunning($taskId)) { + $taskName = $task['name'] ?? $taskId; + $output->writeln("任务 {$taskName} ({$taskId}) 正在运行中,跳过"); + continue; } // 创建子进程 @@ -336,8 +333,9 @@ class TaskSchedulerCommand extends Command if ($pid == -1) { // 创建进程失败 - $output->writeln("创建子进程失败:{$taskId}"); - Log::error("任务调度器:创建子进程失败", ['task' => $taskId]); + $taskName = $task['name'] ?? $taskId; + $output->writeln("创建子进程失败:{$taskName} ({$taskId})"); + Log::error("任务调度器:创建子进程失败", ['task' => $taskId, 'name' => $taskName]); continue; } elseif ($pid == 0) { // 子进程:执行任务 @@ -349,11 +347,11 @@ class TaskSchedulerCommand extends Command 'task_id' => $taskId, 'start_time' => time(), ]; - $output->writeln("启动任务:{$taskId} (PID: {$pid})"); + $taskName = $task['name'] ?? $taskId; + $output->writeln("启动任务:{$taskName} ({$taskId}) (PID: {$pid})"); - // 设置任务锁和PID - Cache::set($lockKey, time(), 600); // 10分钟过期 - Cache::set("scheduler_task_pid:{$taskId}", $pid, 600); // 保存PID,10分钟过期 + // 创建任务锁文件 + $this->createLock($taskId, $pid); } } @@ -375,13 +373,14 @@ class TaskSchedulerCommand extends Command $output->writeln('使用单进程顺序执行任务'); foreach ($tasks as $taskId => $task) { - $output->writeln("执行任务:{$taskId}"); + $taskName = $task['name'] ?? $taskId; + $output->writeln("执行任务:{$taskName} ({$taskId})"); $this->runTask($taskId, $task); } } /** - * 执行单个任务 + * 执行单个任务(参考 schedule.php 的实现,改进超时和错误处理) * * @param string $taskId 任务ID * @param array $task 任务配置 @@ -397,14 +396,14 @@ class TaskSchedulerCommand extends Command mkdir($logDir, 0755, true); } - // 构建命令 - // 使用指定的网站目录作为执行目录 - $executionPath = '/www/wwwroot/mckb_quwanzhi_com/Server'; + // 获取项目根目录(使用 __DIR__ 动态获取) + // TaskSchedulerCommand.php 位于 application/command/,向上两级到项目根目录 + $executionPath = dirname(__DIR__, 2); // 获取 PHP 可执行文件路径 $phpPath = PHP_BINARY ?: 'php'; - // 获取 think 脚本路径(使用执行目录) + // 获取 think 脚本路径(使用项目根目录) $thinkPath = $executionPath . DIRECTORY_SEPARATOR . 'think'; // 检查 think 文件是否存在 @@ -412,6 +411,7 @@ class TaskSchedulerCommand extends Command $errorMsg = "错误:think 文件不存在:{$thinkPath}"; Log::error($errorMsg); file_put_contents($logFile, $errorMsg . "\n", FILE_APPEND); + $this->removeLock($taskId); // 删除锁文件 return; } @@ -423,89 +423,156 @@ class TaskSchedulerCommand extends Command } } - // 添加日志重定向(在后台执行) - $command .= " >> " . escapeshellarg($logFile) . " 2>&1"; + // 获取任务名称 + $taskName = $task['name'] ?? $taskId; // 记录任务开始 $logMessage = "\n" . str_repeat('=', 60) . "\n"; - $logMessage .= "任务开始执行: {$taskId}\n"; + $logMessage .= "任务开始执行: {$taskName} ({$taskId})\n"; $logMessage .= "执行时间: " . date('Y-m-d H:i:s') . "\n"; - $logMessage .= "执行目录: {$executionPath}\n"; $logMessage .= "命令: {$command}\n"; $logMessage .= str_repeat('=', 60) . "\n"; file_put_contents($logFile, $logMessage, FILE_APPEND); - // 执行命令(使用指定的执行目录,Linux 环境) + // 设置超时时间 + $timeout = $task['timeout'] ?? 3600; + + // 执行命令(参考 schedule.php 的实现) $descriptorspec = [ - 0 => ['file', '/dev/null', 'r'], // stdin - 1 => ['file', $logFile, 'a'], // stdout - 2 => ['file', $logFile, 'a'], // stderr + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr ]; $process = @proc_open($command, $descriptorspec, $pipes, $executionPath); - if (is_resource($process)) { - // 关闭管道 - if (isset($pipes[0])) @fclose($pipes[0]); - if (isset($pipes[1])) @fclose($pipes[1]); - if (isset($pipes[2])) @fclose($pipes[2]); + if (!is_resource($process)) { + $errorMsg = "任务执行失败: 无法启动进程"; + $lastError = error_get_last(); + if ($lastError) { + $errorMsg .= "\n错误信息: " . $lastError['message']; + } + Log::error($errorMsg, ['task' => $taskId]); + file_put_contents($logFile, $errorMsg . "\n", FILE_APPEND); + $this->removeLock($taskId); // 删除锁文件 + return; + } + + // 设置非阻塞模式 + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $startWaitTime = time(); + $output = ''; + $error = ''; + $finalExitCode = null; // 保存进程结束时的退出码 + + // 等待进程完成或超时 + while (true) { + $status = proc_get_status($process); - // 设置超时 - $timeout = $task['timeout'] ?? 3600; - $startWaitTime = time(); + // 读取输出 + $output .= stream_get_contents($pipes[1]); + $error .= stream_get_contents($pipes[2]); - // 等待进程完成或超时 - while (true) { - $status = proc_get_status($process); - - if (!$status['running']) { - break; + // 检查是否完成 + if (!$status['running']) { + // 保存退出码(在进程刚结束时获取,此时最准确) + if (isset($status['exitcode'])) { + $finalExitCode = $status['exitcode']; } - - // 检查超时 - if ((time() - $startWaitTime) > $timeout) { - if (function_exists('proc_terminate')) { - proc_terminate($process, SIGTERM); - // 等待进程终止 - sleep(2); - $status = proc_get_status($process); - if ($status['running']) { - // 强制终止 - proc_terminate($process, SIGKILL); - } - } - Log::warning("任务执行超时", [ - 'task' => $taskId, - 'timeout' => $timeout, - ]); - break; - } - - usleep(500000); // 等待500ms + break; } - // 关闭进程 - proc_close($process); + // 检查超时 + if ((time() - $startWaitTime) > $timeout) { + Log::warning("任务执行超时({$timeout}秒),终止进程", ['task' => $taskId]); + file_put_contents($logFile, "任务执行超时({$timeout}秒),终止进程\n", FILE_APPEND); + + if (function_exists('proc_terminate')) { + proc_terminate($process); + } + + // 关闭管道 + @fclose($pipes[0]); + @fclose($pipes[1]); + @fclose($pipes[2]); + proc_close($process); + + $this->removeLock($taskId); // 删除锁文件 + return; + } + + // 等待100ms + usleep(100000); + } + + // 读取剩余输出 + $output .= stream_get_contents($pipes[1]); + $error .= stream_get_contents($pipes[2]); + + // 关闭管道 + @fclose($pipes[0]); + @fclose($pipes[1]); + @fclose($pipes[2]); + + // 获取退出码 + $exitCodeFromClose = proc_close($process); + + // 优先使用进程刚结束时保存的退出码(proc_get_status 在进程刚结束时的返回值) + // 因为关闭管道后,proc_get_status 可能会返回 -1,这是 PHP 的已知行为 + if ($finalExitCode !== null) { + $exitCode = $finalExitCode; } else { - // 如果 proc_open 失败,使用 exec 在后台执行(Linux 环境) - exec("cd " . escapeshellarg($executionPath) . " && " . $command . ' > /dev/null 2>&1 &'); + $exitCode = $exitCodeFromClose; + } + + // 记录输出 + if (!empty($output)) { + file_put_contents($logFile, "任务输出:\n{$output}\n", FILE_APPEND); + } + + if (!empty($error)) { + file_put_contents($logFile, "任务错误:\n{$error}\n", FILE_APPEND); + Log::error("任务执行错误", ['task' => $taskId, 'error' => $error]); } $endTime = microtime(true); $duration = round($endTime - $startTime, 2); + // 获取任务名称 + $taskName = $task['name'] ?? $taskId; + + // 解释退出码含义 + $exitCodeMeaning = $this->getExitCodeMeaning($exitCode); + // 记录任务完成 $logMessage = "\n" . str_repeat('=', 60) . "\n"; - $logMessage .= "任务执行完成: {$taskId}\n"; + $logMessage .= "任务执行完成: {$taskName} ({$taskId})\n"; $logMessage .= "完成时间: " . date('Y-m-d H:i:s') . "\n"; $logMessage .= "执行时长: {$duration} 秒\n"; + $logMessage .= "退出码: {$exitCode} ({$exitCodeMeaning})\n"; $logMessage .= str_repeat('=', 60) . "\n"; file_put_contents($logFile, $logMessage, FILE_APPEND); - Log::info("任务执行完成", [ - 'task' => $taskId, - 'duration' => $duration, - ]); + if ($exitCode === 0) { + Log::info("任务执行成功", [ + 'task' => $taskId, + 'name' => $taskName, + 'duration' => $duration, + ]); + } else { + Log::error("任务执行失败", [ + 'task' => $taskId, + 'name' => $taskName, + 'duration' => $duration, + 'exit_code' => $exitCode, + 'exit_code_meaning' => $exitCodeMeaning, + ]); + } + + // 删除锁文件(任务完成) + $this->removeLock($taskId); } /** @@ -522,9 +589,8 @@ class TaskSchedulerCommand extends Command $taskId = $info['task_id']; unset($this->runningProcesses[$pid]); - // 清除任务锁和PID - Cache::rm("scheduler_task_lock:{$taskId}"); - Cache::rm("scheduler_task_pid:{$taskId}"); + // 删除任务锁文件 + $this->removeLock($taskId); $duration = time() - $info['start_time']; Log::info("子进程执行完成", [ @@ -536,6 +602,50 @@ class TaskSchedulerCommand extends Command } } + /** + * 获取退出码的含义说明 + * @param int $exitCode 退出码 + * @return string 退出码含义 + */ + protected function getExitCodeMeaning($exitCode) + { + switch ($exitCode) { + case 0: + return '成功'; + case -1: + return '进程被信号终止或异常终止(可能是被强制终止、超时终止或发生致命错误)'; + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + return '一般性错误'; + case 126: + return '命令不可执行'; + case 127: + return '命令未找到'; + case 128: + return '无效的退出参数'; + case 130: + return '进程被 Ctrl+C 终止 (SIGINT)'; + case 137: + return '进程被 SIGKILL 信号强制终止'; + case 143: + return '进程被 SIGTERM 信号终止'; + default: + if ($exitCode > 128 && $exitCode < 256) { + $signal = $exitCode - 128; + return "进程被信号 {$signal} 终止"; + } + return '未知错误'; + } + } + /** * 清理僵尸进程 */ @@ -550,5 +660,80 @@ class TaskSchedulerCommand extends Command // 清理僵尸进程 } } + + /** + * 检查任务是否正在运行(通过锁文件,参考 schedule.php) + * + * @param string $taskId 任务ID + * @return bool + */ + protected function isTaskRunning($taskId) + { + $lockFile = $this->lockDir . 'schedule_' . md5($taskId) . '.lock'; + + if (!file_exists($lockFile)) { + return false; + } + + // 检查锁文件是否过期(超过1小时认为过期) + $lockTime = filemtime($lockFile); + if (time() - $lockTime > 3600) { + @unlink($lockFile); + return false; + } + + // 读取锁文件中的PID + $lockContent = @file_get_contents($lockFile); + if ($lockContent !== false) { + $lockData = json_decode($lockContent, true); + if (isset($lockData['pid']) && function_exists('posix_kill')) { + // 检查进程是否真的在运行 + if (@posix_kill($lockData['pid'], 0)) { + return true; + } else { + // 进程不存在,删除锁文件 + @unlink($lockFile); + return false; + } + } + } + + // 如果没有PID或无法检查,使用时间判断(2分钟内认为在运行) + if (time() - $lockTime < 120) { + return true; + } + + return false; + } + + /** + * 创建任务锁文件(参考 schedule.php) + * + * @param string $taskId 任务ID + * @param int $pid 进程ID + */ + protected function createLock($taskId, $pid = null) + { + $lockFile = $this->lockDir . 'schedule_' . md5($taskId) . '.lock'; + $lockData = [ + 'task_id' => $taskId, + 'pid' => $pid ?: getmypid(), + 'time' => time(), + ]; + file_put_contents($lockFile, json_encode($lockData)); + } + + /** + * 删除任务锁文件(参考 schedule.php) + * + * @param string $taskId 任务ID + */ + protected function removeLock($taskId) + { + $lockFile = $this->lockDir . 'schedule_' . md5($taskId) . '.lock'; + if (file_exists($lockFile)) { + @unlink($lockFile); + } + } } diff --git a/application/common/model/TrafficPool.php b/application/common/model/TrafficPool.php index c7ce245..110740d 100644 --- a/application/common/model/TrafficPool.php +++ b/application/common/model/TrafficPool.php @@ -5,11 +5,20 @@ 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'; + // ========== 新版流量池表 ========== + // 注意:为了兼容性,暂时保留此模型,但表名已改为新版 + // 新代码请使用 TrafficPoolV2 模型 protected $name = 'traffic_pool'; // 自动写入时间戳 diff --git a/application/common/model/TrafficPoolAllotRecord.php b/application/common/model/TrafficPoolAllotRecord.php new file mode 100644 index 0000000..ab0056f --- /dev/null +++ b/application/common/model/TrafficPoolAllotRecord.php @@ -0,0 +1,168 @@ +belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 创建分配记录 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param string $toWechatId + * @param int $toAccountId + * @param int $toUserId + * @param int $expireDays + * @param int $operatorId + * @param array $fromInfo [fromWechatId, fromAccountId, fromUserId] + * @return static + */ + public static function createAllotRecord( + int $poolCompanyId, + string $identifier, + int $companyId, + string $toWechatId, + int $toAccountId = null, + int $toUserId = null, + int $expireDays = 30, + int $operatorId = null, + array $fromInfo = [] + ) { + // 判断分配类型 + $existRecord = self::where('poolCompanyId', $poolCompanyId) + ->where('status', self::STATUS_ACTIVE) + ->find(); + + $allotType = self::ALLOT_TYPE_FIRST; + if ($existRecord) { + // 将原记录设为已回收 + $existRecord->save([ + 'status' => self::STATUS_RECYCLED, + 'updateTime' => time() + ]); + $allotType = self::ALLOT_TYPE_REASSIGN; + } + + // 检查之前是否有过分配记录(判断是否回收后分配) + $hasHistoryRecord = self::where('poolCompanyId', $poolCompanyId) + ->where('status', 'in', [self::STATUS_EXPIRED, self::STATUS_RECYCLED]) + ->count(); + if ($hasHistoryRecord && $allotType === self::ALLOT_TYPE_FIRST) { + $allotType = self::ALLOT_TYPE_RECYCLE; + } + + $expireTime = $expireDays > 0 ? time() + ($expireDays * 86400) : null; + + $record = self::create([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'allotType' => $allotType, + 'fromWechatId' => $fromInfo['fromWechatId'] ?? null, + 'fromAccountId' => $fromInfo['fromAccountId'] ?? null, + 'fromUserId' => $fromInfo['fromUserId'] ?? null, + 'toWechatId' => $toWechatId, + 'toAccountId' => $toAccountId, + 'toUserId' => $toUserId, + 'expireDays' => $expireDays, + 'expireTime' => $expireTime, + 'status' => self::STATUS_ACTIVE, + 'operatorId' => $operatorId, + 'createTime' => time(), + ]); + + // 更新公司流量详情表的归属信息 + TrafficPoolCompany::where('id', $poolCompanyId)->update([ + 'ownerWechatId' => $toWechatId, + 'ownerAccountId' => $toAccountId, + 'ownerUserId' => $toUserId, + 'allocateStatus' => TrafficPoolCompany::ALLOCATE_STATUS_ALLOCATED, + 'allocateTime' => time(), + 'expireTime' => $expireTime, + 'updateTime' => time() + ]); + + return $record; + } + + /** + * 回收分配 + * @param int $poolCompanyId + * @param int $operatorId + * @return bool + */ + public static function recycleAllot(int $poolCompanyId, int $operatorId = null) + { + // 更新当前生效的分配记录 + $activeRecord = self::where('poolCompanyId', $poolCompanyId) + ->where('status', self::STATUS_ACTIVE) + ->find(); + + if ($activeRecord) { + $activeRecord->save([ + 'status' => self::STATUS_RECYCLED, + 'updateTime' => time() + ]); + } + + // 更新公司流量详情表 + return TrafficPoolCompany::where('id', $poolCompanyId)->update([ + 'ownerWechatId' => null, + 'ownerAccountId' => null, + 'ownerUserId' => null, + 'allocateStatus' => TrafficPoolCompany::ALLOCATE_STATUS_RECYCLED, + 'expireTime' => null, + 'updateTime' => time() + ]); + } + + /** + * 获取流量的分配历史 + * @param int $poolCompanyId + * @return \think\Collection + */ + public static function getAllotHistory(int $poolCompanyId) + { + return self::where('poolCompanyId', $poolCompanyId) + ->order('createTime DESC') + ->select(); + } +} + + diff --git a/application/common/model/TrafficPoolBehavior.php b/application/common/model/TrafficPoolBehavior.php new file mode 100644 index 0000000..4f848fb --- /dev/null +++ b/application/common/model/TrafficPoolBehavior.php @@ -0,0 +1,240 @@ + '发送消息', + self::BEHAVIOR_TYPE_RECEIVE_MSG => '接收消息', + self::BEHAVIOR_TYPE_VIEW => '浏览', + self::BEHAVIOR_TYPE_CLICK => '点击', + self::BEHAVIOR_TYPE_CONSULT => '咨询', + self::BEHAVIOR_TYPE_ORDER => '下单', + self::BEHAVIOR_TYPE_PAY => '支付', + self::BEHAVIOR_TYPE_REFUND => '退款', + self::BEHAVIOR_TYPE_LIKE_MOMENTS => '点赞朋友圈', + self::BEHAVIOR_TYPE_COMMENT_MOMENTS => '评论朋友圈', + ]; + + /** + * 关联公司流量详情 + */ + public function poolCompany() + { + return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 获取额外信息 + * @param string $value + * @return array + */ + public function getExtraAttr($value) + { + return $value ? json_decode($value, true) : []; + } + + /** + * 设置额外信息 + * @param array $value + * @return string + */ + public function setExtraAttr($value) + { + return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null; + } + + /** + * 获取行为类型名称 + * @return string + */ + public function getBehaviorTypeNameAttr() + { + return self::BEHAVIOR_TYPE_NAMES[$this->behaviorType] ?? '未知行为'; + } + + /** + * 记录消息行为 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param int $behaviorType 发送/接收 + * @param int $messageId + * @param int $wechatAccountId + * @param array $extra + * @return static + */ + public static function recordMessageBehavior(int $poolCompanyId, string $identifier, int $companyId, int $behaviorType, int $messageId = null, int $wechatAccountId = null, array $extra = []) + { + $behavior = self::create([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'behaviorType' => $behaviorType, + 'behaviorName' => self::BEHAVIOR_TYPE_NAMES[$behaviorType] ?? '消息', + 'messageId' => $messageId, + 'wechatAccountId' => $wechatAccountId, + 'extra' => $extra, + 'behaviorTime' => time(), + 'createTime' => time(), + ]); + + // 更新流量统计 + $poolCompany = TrafficPoolCompany::find($poolCompanyId); + if ($poolCompany) { + $poolCompany->incrementMsgCount(1); + } + + return $behavior; + } + + /** + * 记录订单行为 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param int $behaviorType + * @param string $orderId + * @param float $amount + * @param array $extra + * @return static + */ + public static function recordOrderBehavior(int $poolCompanyId, string $identifier, int $companyId, int $behaviorType, string $orderId, float $amount = 0, array $extra = []) + { + $behavior = self::create([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'behaviorType' => $behaviorType, + 'behaviorName' => self::BEHAVIOR_TYPE_NAMES[$behaviorType] ?? '订单', + 'orderId' => $orderId, + 'amount' => $amount, + 'extra' => $extra, + 'behaviorTime' => time(), + 'createTime' => time(), + ]); + + // 如果是支付行为,更新订单统计 + if ($behaviorType === self::BEHAVIOR_TYPE_PAY) { + $poolCompany = TrafficPoolCompany::find($poolCompanyId); + if ($poolCompany) { + $poolCompany->incrementOrderStats($amount); + } + } + + return $behavior; + } + + /** + * 记录朋友圈互动行为 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param int $behaviorType 点赞/评论 + * @param int $momentsId + * @param array $extra + * @return static + */ + public static function recordMomentsBehavior(int $poolCompanyId, string $identifier, int $companyId, int $behaviorType, int $momentsId, array $extra = []) + { + return self::create([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'behaviorType' => $behaviorType, + 'behaviorName' => self::BEHAVIOR_TYPE_NAMES[$behaviorType] ?? '朋友圈互动', + 'momentsId' => $momentsId, + 'extra' => $extra, + 'behaviorTime' => time(), + 'createTime' => time(), + ]); + } + + /** + * 获取用户行为轨迹 + * @param int $poolCompanyId + * @param int $limit + * @return \think\Collection + */ + public static function getUserJourney(int $poolCompanyId, int $limit = 50) + { + return self::where('poolCompanyId', $poolCompanyId) + ->order('behaviorTime DESC') + ->limit($limit) + ->select(); + } + + /** + * 分页获取用户行为轨迹 + * @param int $poolCompanyId + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @param string $keyword 搜索关键词(搜索行为名称) + * @param int $behaviorType 行为类型筛选 + * @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10] + */ + public static function getUserJourneyPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = '', int $behaviorType = 0): array + { + $query = self::where('poolCompanyId', $poolCompanyId); + + // 关键词搜索 + if (!empty($keyword)) { + $query->where('behaviorName', 'like', '%' . $keyword . '%'); + } + + // 行为类型筛选 + if ($behaviorType > 0) { + $query->where('behaviorType', $behaviorType); + } + + // 统计总数 + $total = $query->count(); + + // 分页查询 + $behaviors = $query->order('behaviorTime DESC') + ->page($page, $pageSize) + ->select() + ->toArray(); + + return [ + 'list' => $behaviors, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } +} + + diff --git a/application/common/model/TrafficPoolCompany.php b/application/common/model/TrafficPoolCompany.php new file mode 100644 index 0000000..411ba4a --- /dev/null +++ b/application/common/model/TrafficPoolCompany.php @@ -0,0 +1,200 @@ +belongsTo(TrafficPoolV2::class, 'poolId', 'id'); + } + + /** + * 关联来源记录 + */ + public function sources() + { + return $this->hasMany(TrafficPoolSource::class, 'poolCompanyId', 'id'); + } + + /** + * 关联标签记录 + */ + public function tags() + { + return $this->hasMany(TrafficPoolTag::class, 'poolCompanyId', 'id'); + } + + /** + * 关联行为记录 + */ + public function behaviors() + { + return $this->hasMany(TrafficPoolBehavior::class, 'poolCompanyId', 'id'); + } + + /** + * 关联分配记录 + */ + public function allotRecords() + { + return $this->hasMany(TrafficPoolAllotRecord::class, 'poolCompanyId', 'id'); + } + + /** + * 根据identifier和companyId查找或创建 + * @param string $identifier + * @param int $companyId + * @param int $poolId + * @param array $data + * @return static + */ + public static function findOrCreateByIdentifierAndCompany(string $identifier, int $companyId, int $poolId, array $data = []) + { + $record = self::where('identifier', $identifier) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$record) { + $insertData = array_merge([ + 'poolId' => $poolId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'createTime' => time(), + ], $data); + + $record = self::create($insertData); + } + + return $record; + } + + /** + * 原子更新消息统计 + * @param int $count 增加的消息数量 + * @return bool + */ + public function incrementMsgCount(int $count = 1) + { + return Db::table($this->getTable()) + ->where('id', $this->id) + ->inc('totalMsgCount', $count) + ->inc('rfmF', $count) + ->update([ + 'lastMsgTime' => time(), + 'lastInteractTime' => time(), + 'updateTime' => time() + ]); + } + + /** + * 原子更新订单统计 + * @param float $amount 订单金额 + * @return bool + */ + public function incrementOrderStats(float $amount) + { + return Db::table($this->getTable()) + ->where('id', $this->id) + ->inc('totalOrderCount', 1) + ->inc('totalOrderAmount', $amount) + ->inc('rfmM', $amount) + ->update([ + 'lastOrderTime' => time(), + 'lastInteractTime' => time(), + 'updateTime' => time() + ]); + } + + /** + * 计算RFM R值(最后互动距今天数) + * @return int + */ + public function getRfmRAttr() + { + if (empty($this->lastInteractTime)) { + return 9999; // 未互动过 + } + return (int) floor((time() - $this->lastInteractTime) / 86400); + } + + /** + * 获取自定义字段 + * @param string $value + * @return array + */ + public function getCustomFieldsAttr($value) + { + return $value ? json_decode($value, true) : []; + } + + /** + * 设置自定义字段 + * @param array $value + * @return string + */ + public function setCustomFieldsAttr($value) + { + return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null; + } +} + + diff --git a/application/common/model/TrafficPoolGroup.php b/application/common/model/TrafficPoolGroup.php new file mode 100644 index 0000000..ed2490a --- /dev/null +++ b/application/common/model/TrafficPoolGroup.php @@ -0,0 +1,171 @@ +hasMany(TrafficPoolGroupMember::class, 'groupId', 'id'); + } + + /** + * 获取规则配置 + * @param string $value + * @return array|null + */ + public function getRuleConfigAttr($value) + { + return $value ? json_decode($value, true) : null; + } + + /** + * 设置规则配置 + * @param array $value + * @return string + */ + public function setRuleConfigAttr($value) + { + return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null; + } + + /** + * 获取公司可用的分组列表(包含系统分组和公司自定义分组) + * @param int $companyId + * @param bool $onlyEnabled + * @return \think\Collection + */ + public static function getGroupsByCompany(int $companyId, bool $onlyEnabled = true) + { + $query = self::whereIn('companyId', [0, $companyId]) + ->where('isDel', 0); + + if ($onlyEnabled) { + $query->where('status', self::STATUS_ENABLED); + } + + return $query->order('sort ASC, id ASC')->select(); + } + + /** + * 根据分组编码获取分组 + * @param string $groupCode + * @param int $companyId + * @return static|null + */ + public static function getByCode(string $groupCode, int $companyId = 0) + { + return self::where('groupCode', $groupCode) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + } + + /** + * 解析规则配置生成SQL条件 + * @param array $ruleConfig + * @return array [whereConditions, bindings] + */ + public static function parseRuleToConditions(array $ruleConfig) + { + $conditions = []; + $bindings = []; + + if (empty($ruleConfig['conditions'])) { + return [$conditions, $bindings]; + } + + $logic = strtoupper($ruleConfig['logic'] ?? 'AND'); + + foreach ($ruleConfig['conditions'] as $condition) { + if ($condition['type'] === 'group') { + // 嵌套分组,递归处理 + [$subConditions, $subBindings] = self::parseRuleToConditions($condition); + if (!empty($subConditions)) { + $conditions[] = '(' . implode(' ' . ($condition['logic'] ?? 'AND') . ' ', $subConditions) . ')'; + $bindings = array_merge($bindings, $subBindings); + } + } elseif ($condition['type'] === 'field') { + // 字段条件 + $field = $condition['field']; + $operator = $condition['operator']; + $value = $condition['value']; + + switch ($operator) { + case '=': + case '!=': + case '>': + case '<': + case '>=': + case '<=': + $conditions[] = "`{$field}` {$operator} ?"; + $bindings[] = $value; + break; + case 'in': + $placeholders = implode(',', array_fill(0, count($value), '?')); + $conditions[] = "`{$field}` IN ({$placeholders})"; + $bindings = array_merge($bindings, $value); + break; + case 'not_in': + $placeholders = implode(',', array_fill(0, count($value), '?')); + $conditions[] = "`{$field}` NOT IN ({$placeholders})"; + $bindings = array_merge($bindings, $value); + break; + case 'between': + $conditions[] = "`{$field}` BETWEEN ? AND ?"; + $bindings[] = $value[0]; + $bindings[] = $value[1]; + break; + case 'like': + $conditions[] = "`{$field}` LIKE ?"; + $bindings[] = '%' . $value . '%'; + break; + } + } elseif ($condition['type'] === 'tag') { + // 标签条件需要特殊处理,通过子查询 + // 这里返回需要在Service层特殊处理 + $conditions[] = "EXISTS (SELECT 1 FROM ck_traffic_pool_tag tpt WHERE tpt.poolCompanyId = ck_traffic_pool_company.id AND tpt.tagName IN (?))"; + $bindings[] = implode("','", $condition['value']); + } + } + + return [$conditions, $bindings, $logic]; + } +} + + diff --git a/application/common/model/TrafficPoolGroupMember.php b/application/common/model/TrafficPoolGroupMember.php new file mode 100644 index 0000000..9cb5825 --- /dev/null +++ b/application/common/model/TrafficPoolGroupMember.php @@ -0,0 +1,128 @@ +belongsTo(TrafficPoolGroup::class, 'groupId', 'id'); + } + + /** + * 关联公司流量详情 + */ + public function poolCompany() + { + return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 批量添加成员到分组 + * @param int $groupId + * @param array $poolCompanyIds + * @param int $companyId + * @param int $operatorId + * @param int $addType + * @return int 成功添加的数量 + */ + public static function batchAddMembers(int $groupId, array $poolCompanyIds, int $companyId, int $operatorId = null, int $addType = self::ADD_TYPE_MANUAL) + { + $count = 0; + $time = time(); + + // 获取已存在的成员 + $existIds = self::where('groupId', $groupId) + ->whereIn('poolCompanyId', $poolCompanyIds) + ->where('isDel', 0) + ->column('poolCompanyId'); + + // 获取要添加的流量详情 + $poolCompanies = TrafficPoolCompany::whereIn('id', $poolCompanyIds) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->column('identifier', 'id'); + + $insertData = []; + foreach ($poolCompanyIds as $poolCompanyId) { + if (in_array($poolCompanyId, $existIds)) { + continue; // 跳过已存在的 + } + if (!isset($poolCompanies[$poolCompanyId])) { + continue; // 跳过不存在的 + } + + $insertData[] = [ + 'groupId' => $groupId, + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $poolCompanies[$poolCompanyId], + 'companyId' => $companyId, + 'addType' => $addType, + 'operatorId' => $operatorId, + 'createTime' => $time, + 'isDel' => 0, + ]; + } + + if (!empty($insertData)) { + (new self())->saveAll($insertData); + $count = count($insertData); + + // 更新分组成员数量缓存 + TrafficPoolGroup::where('id', $groupId)->setInc('memberCount', $count); + } + + return $count; + } + + /** + * 批量移除成员 + * @param int $groupId + * @param array $poolCompanyIds + * @return int + */ + public static function batchRemoveMembers(int $groupId, array $poolCompanyIds) + { + $count = self::where('groupId', $groupId) + ->whereIn('poolCompanyId', $poolCompanyIds) + ->where('isDel', 0) + ->update([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + + if ($count > 0) { + // 更新分组成员数量缓存 + TrafficPoolGroup::where('id', $groupId)->setDec('memberCount', $count); + } + + return $count; + } +} + + diff --git a/application/common/model/TrafficPoolSource.php b/application/common/model/TrafficPoolSource.php new file mode 100644 index 0000000..f73f219 --- /dev/null +++ b/application/common/model/TrafficPoolSource.php @@ -0,0 +1,474 @@ + '好友添加', + self::SOURCE_TYPE_GROUP_MEMBER => '群成员', + self::SOURCE_TYPE_POSTER => '海报获客', + self::SOURCE_TYPE_PHONE => '电话获客', + self::SOURCE_TYPE_ORDER => '订单获客', + self::SOURCE_TYPE_API => 'API导入', + self::SOURCE_TYPE_MANUAL => '手动导入', + self::SOURCE_TYPE_FISSION => '裂变活动', + ]; + + /** + * 关联公司流量详情 + */ + public function poolCompany() + { + return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 获取额外信息 + * @param string $value + * @return array + */ + public function getExtraAttr($value) + { + return $value ? json_decode($value, true) : []; + } + + /** + * 设置额外信息 + * @param array $value + * @return string + */ + public function setExtraAttr($value) + { + return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null; + } + + /** + * 获取来源类型名称 + * @return string + */ + public function getSourceTypeNameAttr() + { + return self::SOURCE_TYPE_NAMES[$this->sourceType] ?? '未知来源'; + } + + /** + * 创建来源记录 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param int $sourceType + * @param array $data + * @return static + */ + public static function createSource(int $poolCompanyId, string $identifier, int $companyId, int $sourceType, array $data = []) + { + // 检查是否为首次来源 + $existSource = self::where('poolCompanyId', $poolCompanyId)->find(); + $isFirstSource = $existSource ? 0 : 1; + + $insertData = array_merge([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'sourceType' => $sourceType, + 'isFirstSource' => $isFirstSource, + 'createTime' => time(), + ], $data); + + $source = self::create($insertData); + + // 如果是首次来源,更新公司流量详情表 + if ($isFirstSource) { + TrafficPoolCompany::where('id', $poolCompanyId)->update([ + 'firstSourceType' => $sourceType, + 'firstSourceTime' => time(), + 'updateTime' => time() + ]); + } + + return $source; + } + + /** + * 获取流量的所有来源 + * @param int $poolCompanyId + * @return \think\Collection + */ + public static function getSourcesByPoolCompany(int $poolCompanyId) + { + return self::where('poolCompanyId', $poolCompanyId) + ->order('createTime DESC') + ->select(); + } + + /** + * 获取流量的所有来源(带群归属信息) + * @param int $poolCompanyId + * @param int $limit 限制数量,0表示不限制 + * @return array + */ + public static function getSourcesWithOwners(int $poolCompanyId, int $limit = 0): array + { + $query = self::where('poolCompanyId', $poolCompanyId) + ->order('createTime DESC'); + + if ($limit > 0) { + $query->limit($limit); + } + + $sources = $query->select()->toArray(); + + if (empty($sources)) { + return []; + } + + // 收集所有群ID + $chatroomIds = []; + foreach ($sources as $source) { + if (!empty($source['sourceChatroomId'])) { + $chatroomIds[] = $source['sourceChatroomId']; + } + } + + // 查询群信息和归属客服 + $chatroomOwners = []; + if (!empty($chatroomIds)) { + $chatroomOwners = self::getChatroomOwners($chatroomIds); + } + + // 组装数据(按来源类型和关键标识去重) + $result = []; + $seenChatroomIds = []; // 用于群成员来源去重 + $seenFriendIds = []; // 用于好友添加来源去重 + + foreach ($sources as $source) { + // 群成员来源去重:同一个群只保留一条记录 + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $chatroomId = $source['sourceChatroomId']; + if (isset($seenChatroomIds[$chatroomId])) { + continue; // 跳过重复的群 + } + $seenChatroomIds[$chatroomId] = true; + } + + // 好友添加来源去重:同一个好友(按sourceWechatId或sourceName)只保留一条记录 + if ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) { + $friendKey = $source['sourceWechatId'] ?: ($source['sourceName'] ?: ''); + if (!empty($friendKey) && isset($seenFriendIds[$friendKey])) { + continue; // 跳过重复的好友来源 + } + if (!empty($friendKey)) { + $seenFriendIds[$friendKey] = true; + } + } + + $sourceData = $source; + + // 格式化时间(兼容时间戳和日期字符串) + if (!empty($source['createTime'])) { + if (is_numeric($source['createTime'])) { + $sourceData['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$source['createTime']); + } else { + $sourceData['createTimeFormatted'] = $source['createTime']; + } + } else { + $sourceData['createTimeFormatted'] = null; + } + + // 添加来源类型名称 + $sourceData['sourceTypeName'] = self::SOURCE_TYPE_NAMES[$source['sourceType']] ?? '未知来源'; + + // 如果是群成员来源,添加群归属信息和群ID展示 + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $chatroomId = $source['sourceChatroomId']; + $sourceData['chatroomOwners'] = $chatroomOwners[$chatroomId] ?? []; + $sourceData['chatroomInfo'] = self::getChatroomInfo($chatroomId); + // 添加群ID用于展示 + $sourceData['displayId'] = $chatroomId; + } elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) { + // 好友添加来源,添加好友微信ID用于展示 + $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; + $sourceData['displayId'] = $source['sourceId'] ?: ''; + } + + $result[] = $sourceData; + } + + return $result; + } + + /** + * 获取群的归属客服信息(支持多个客服) + * @param array $chatroomIds 群聊ID数组 + * @return array [chatroomId => [owner1, owner2, ...]] + */ + protected static function getChatroomOwners(array $chatroomIds): array + { + if (empty($chatroomIds)) { + return []; + } + + // 查询群和归属账号信息 + $chatrooms = Db::table(['s2_wechat_chatroom' => 'wc']) + ->leftJoin(['s2_wechat_account' => 'wa'], 'wa.wechatId = wc.wechatAccountWechatId') + ->whereIn('wc.chatroomId', $chatroomIds) + ->where('wc.isDeleted', 0) + ->field([ + 'wc.chatroomId', + 'wc.nickname as chatroomName', + 'wc.chatroomAvatar', + 'wc.wechatAccountWechatId as ownerWechatId', + 'wc.wechatAccountNickname as ownerNickname', + 'wc.wechatAccountAvatar as ownerAvatar', + 'wc.wechatAccountAlias as ownerAlias', + 'wa.id as accountId', + 'wa.nickName as accountNickname', + ]) + ->select(); + + // 按 chatroomId 分组,一个群可能有多条记录(多个客服管理) + $result = []; + foreach ($chatrooms as $chatroom) { + $chatroomId = $chatroom['chatroomId']; + if (!isset($result[$chatroomId])) { + $result[$chatroomId] = []; + } + + // 避免重复添加相同的客服 + $ownerWechatId = $chatroom['ownerWechatId']; + $exists = false; + foreach ($result[$chatroomId] as $existing) { + if ($existing['ownerWechatId'] === $ownerWechatId) { + $exists = true; + break; + } + } + + if (!$exists && !empty($ownerWechatId)) { + $result[$chatroomId][] = [ + 'ownerWechatId' => $ownerWechatId, + 'ownerNickname' => $chatroom['ownerNickname'] ?: $chatroom['accountNickname'] ?: '', + 'ownerAvatar' => $chatroom['ownerAvatar'] ?: '', + 'ownerAlias' => $chatroom['ownerAlias'] ?: '', + 'accountId' => $chatroom['accountId'], + ]; + } + } + + return $result; + } + + /** + * 获取群信息 + * @param string $chatroomId 群聊ID + * @return array|null + */ + protected static function getChatroomInfo(string $chatroomId): ?array + { + $chatroom = Db::table(['s2_wechat_chatroom' => 'wc']) + ->where('wc.chatroomId', $chatroomId) + ->where('wc.isDeleted', 0) + ->field([ + 'wc.id', + 'wc.chatroomId', + 'wc.nickname as chatroomName', + 'wc.chatroomAvatar', + 'wc.createTime', + ]) + ->find(); + + if (!$chatroom) { + return null; + } + + // 格式化创建时间(兼容时间戳和日期字符串) + if (!empty($chatroom['createTime'])) { + if (is_numeric($chatroom['createTime'])) { + $chatroom['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$chatroom['createTime']); + } else { + $chatroom['createTimeFormatted'] = $chatroom['createTime']; + } + } else { + $chatroom['createTimeFormatted'] = null; + } + + return $chatroom; + } + + /** + * 分页获取流量的来源(带群归属信息) + * @param int $poolCompanyId + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @param string $keyword 搜索关键词(搜索来源名称) + * @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10] + */ + public static function getSourcesWithOwnersPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = ''): array + { + $query = self::where('poolCompanyId', $poolCompanyId); + + // 关键词搜索 + if (!empty($keyword)) { + $query->where('sourceName', 'like', '%' . $keyword . '%'); + } + + // 统计总数 + $total = $query->count(); + + // 分页查询 + $sources = $query->order('createTime DESC') + ->page($page, $pageSize) + ->select() + ->toArray(); + + if (empty($sources)) { + return [ + 'list' => [], + 'total' => 0, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } + + // 收集所有群ID + $chatroomIds = []; + foreach ($sources as $source) { + if (!empty($source['sourceChatroomId'])) { + $chatroomIds[] = $source['sourceChatroomId']; + } + } + + // 查询群信息和归属客服 + $chatroomOwners = []; + if (!empty($chatroomIds)) { + $chatroomOwners = self::getChatroomOwners($chatroomIds); + } + + // 组装数据(按来源类型和关键标识去重) + $result = []; + $seenChatroomIds = []; // 用于群成员来源去重 + $seenFriendIds = []; // 用于好友添加来源去重 + + foreach ($sources as $source) { + // 群成员来源去重:同一个群只保留一条记录 + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $chatroomId = $source['sourceChatroomId']; + if (isset($seenChatroomIds[$chatroomId])) { + continue; // 跳过重复的群 + } + $seenChatroomIds[$chatroomId] = true; + } + + // 好友添加来源去重:按来源微信ID或来源名称去重 + if ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) { + $friendAddKey = $source['sourceWechatId'] ?? ($source['sourceName'] ?? ''); + if (!empty($friendAddKey) && isset($seenFriendIds[$friendAddKey])) { + continue; // 跳过重复的好友添加 + } + $seenFriendIds[$friendAddKey] = true; + } + + $sourceData = $source; + + // 设置显示ID + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $sourceData['displayId'] = "群ID:" . $source['sourceChatroomId']; + } elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) { + $sourceData['displayId'] = "好友ID:" . ($source['sourceWechatId'] ?? $source['sourceName'] ?? '-'); + } else { + $sourceData['displayId'] = null; + } + + // 格式化时间(兼容时间戳和日期字符串) + if (!empty($source['createTime'])) { + if (is_numeric($source['createTime'])) { + $sourceData['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$source['createTime']); + } else { + $sourceData['createTimeFormatted'] = $source['createTime']; + } + } else { + $sourceData['createTimeFormatted'] = null; + } + + // 添加来源类型名称 + $sourceData['sourceTypeName'] = self::SOURCE_TYPE_NAMES[$source['sourceType']] ?? '未知来源'; + + // 如果是群成员来源,添加群归属信息 + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $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; + } + + $result[] = $sourceData; + } + + return [ + 'list' => $result, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } +} + + diff --git a/application/common/model/TrafficPoolTag.php b/application/common/model/TrafficPoolTag.php new file mode 100644 index 0000000..16d71fb --- /dev/null +++ b/application/common/model/TrafficPoolTag.php @@ -0,0 +1,229 @@ + '手动打标', + self::SOURCE_RULE => '规则自动', + self::SOURCE_AI => 'AI自动', + self::SOURCE_WECHAT_SYNC => '微信同步', + ]; + + /** + * 关联公司流量详情 + */ + public function poolCompany() + { + return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 关联标签定义 + */ + public function tagDefine() + { + return $this->belongsTo(TrafficPoolTagDefine::class, 'tagDefineId', 'id'); + } + + /** + * 为流量添加标签 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param int $tagDefineId + * @param int $source + * @param int $operatorId + * @param string $tagValue + * @param float $score AI置信度 + * @return static|null + */ + public static function addTag( + int $poolCompanyId, + string $identifier, + int $companyId, + int $tagDefineId, + int $source = self::SOURCE_MANUAL, + int $operatorId = null, + string $tagValue = null, + float $score = null + ) { + // 检查标签定义是否存在 + $tagDefine = TrafficPoolTagDefine::find($tagDefineId); + if (!$tagDefine) { + return null; + } + + // 检查是否已存在 + $existTag = self::where('poolCompanyId', $poolCompanyId) + ->where('tagDefineId', $tagDefineId) + ->where('isDel', 0) + ->find(); + + if ($existTag) { + // 更新现有标签 + $existTag->save([ + 'tagValue' => $tagValue, + 'source' => $source, + 'operatorId' => $operatorId, + 'score' => $score, + 'updateTime' => time() + ]); + return $existTag; + } + + // 如果是互斥标签,先删除同类目下的其他标签 + if ($tagDefine->isExclusive) { + self::where('poolCompanyId', $poolCompanyId) + ->where('categoryId', $tagDefine->categoryId) + ->where('isDel', 0) + ->update([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + } + + // 创建新标签关联 + $tag = self::create([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'tagDefineId' => $tagDefineId, + 'tagType' => $tagDefine->tagType, + 'categoryId' => $tagDefine->categoryId, + 'tagName' => $tagDefine->tagName, + 'tagValue' => $tagValue, + 'source' => $source, + 'operatorId' => $operatorId, + 'score' => $score, + 'createTime' => time() + ]); + + // 增加标签使用次数 + $tagDefine->incrementUseCount(); + + return $tag; + } + + /** + * 移除流量标签 + * @param int $poolCompanyId + * @param int $tagDefineId + * @return bool + */ + public static function removeTag(int $poolCompanyId, int $tagDefineId) + { + $tag = self::where('poolCompanyId', $poolCompanyId) + ->where('tagDefineId', $tagDefineId) + ->where('isDel', 0) + ->find(); + + if ($tag) { + $tag->save([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + + // 减少标签使用次数 + $tagDefine = TrafficPoolTagDefine::find($tagDefineId); + if ($tagDefine) { + $tagDefine->decrementUseCount(); + } + + return true; + } + + return false; + } + + /** + * 获取流量的所有标签 + * @param int $poolCompanyId + * @param int|null $tagType + * @return \think\Collection + */ + public static function getTagsByPoolCompany(int $poolCompanyId, int $tagType = null) + { + $query = self::where('poolCompanyId', $poolCompanyId) + ->where('isDel', 0); + + if ($tagType !== null) { + $query->where('tagType', $tagType); + } + + return $query->order('createTime DESC')->select(); + } + + /** + * 同步微信标签 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param array $wechatLabels 微信标签名称数组 + * @return int 同步的标签数量 + */ + public static function syncWechatTags(int $poolCompanyId, string $identifier, int $companyId, array $wechatLabels) + { + $count = 0; + + // 获取微信默认标签类目(假设ID为1) + $wechatCategoryId = 1; + + foreach ($wechatLabels as $labelName) { + if (empty($labelName)) { + continue; + } + + // 获取或创建标签定义 + $tagDefine = TrafficPoolTagDefine::getOrCreateByName($labelName, $companyId, $wechatCategoryId); + + // 添加标签关联 + $tag = self::addTag( + $poolCompanyId, + $identifier, + $companyId, + $tagDefine->id, + self::SOURCE_WECHAT_SYNC + ); + + if ($tag) { + $count++; + } + } + + return $count; + } +} + + diff --git a/application/common/model/TrafficPoolTagCategory.php b/application/common/model/TrafficPoolTagCategory.php new file mode 100644 index 0000000..98a9c9c --- /dev/null +++ b/application/common/model/TrafficPoolTagCategory.php @@ -0,0 +1,118 @@ + '微信标签', + self::TAG_TYPE_SITE => '站内标签', + self::TAG_TYPE_AI => 'AI标签', + ]; + + // 状态常量 + const STATUS_DISABLED = 0; + const STATUS_ENABLED = 1; + + /** + * 关联标签定义 + */ + public function tagDefines() + { + return $this->hasMany(TrafficPoolTagDefine::class, 'categoryId', 'id'); + } + + /** + * 关联子类目 + */ + public function children() + { + return $this->hasMany(self::class, 'parentId', 'id'); + } + + /** + * 关联父类目 + */ + public function parent() + { + return $this->belongsTo(self::class, 'parentId', 'id'); + } + + /** + * 获取公司可用的标签类目 + * @param int $companyId + * @param int|null $tagType + * @return \think\Collection + */ + public static function getCategoriesByCompany(int $companyId, int $tagType = null) + { + $query = self::whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->where('status', self::STATUS_ENABLED); + + if ($tagType !== null) { + $query->where('tagType', $tagType); + } + + return $query->order('sort ASC, id ASC')->select(); + } + + /** + * 获取类目树结构 + * @param int $companyId + * @param int|null $tagType + * @return array + */ + public static function getCategoryTree(int $companyId, int $tagType = null) + { + $categories = self::getCategoriesByCompany($companyId, $tagType)->toArray(); + return self::buildTree($categories); + } + + /** + * 构建树结构 + * @param array $items + * @param int $parentId + * @return array + */ + private static function buildTree(array $items, int $parentId = 0) + { + $result = []; + foreach ($items as $item) { + if ($item['parentId'] == $parentId) { + $children = self::buildTree($items, $item['id']); + if (!empty($children)) { + $item['children'] = $children; + } + $result[] = $item; + } + } + return $result; + } +} + + diff --git a/application/common/model/TrafficPoolTagDefine.php b/application/common/model/TrafficPoolTagDefine.php new file mode 100644 index 0000000..dedb317 --- /dev/null +++ b/application/common/model/TrafficPoolTagDefine.php @@ -0,0 +1,143 @@ +belongsTo(TrafficPoolTagCategory::class, 'categoryId', 'id'); + } + + /** + * 关联标签使用记录 + */ + public function tags() + { + return $this->hasMany(TrafficPoolTag::class, 'tagDefineId', 'id'); + } + + /** + * 获取公司可用的标签定义 + * @param int $companyId + * @param int|null $tagType + * @param int|null $categoryId + * @return \think\Collection + */ + public static function getTagDefinesByCompany(int $companyId, int $tagType = null, int $categoryId = null) + { + $query = self::whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->where('status', self::STATUS_ENABLED); + + if ($tagType !== null) { + $query->where('tagType', $tagType); + } + + if ($categoryId !== null) { + $query->where('categoryId', $categoryId); + } + + return $query->order('sort ASC, id ASC')->select(); + } + + /** + * 根据标签编码获取标签定义 + * @param string $tagCode + * @param int $companyId + * @return static|null + */ + public static function getByCode(string $tagCode, int $companyId = 0) + { + return self::where('tagCode', $tagCode) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + } + + /** + * 根据标签名称获取或创建标签(用于微信标签同步) + * @param string $tagName + * @param int $companyId + * @param int $categoryId + * @return static + */ + public static function getOrCreateByName(string $tagName, int $companyId, int $categoryId = 1) + { + $tagCode = 'wechat_' . md5($tagName . '_' . $companyId); + + $tag = self::where('tagCode', $tagCode) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$tag) { + $tag = self::create([ + 'companyId' => $companyId, + 'categoryId' => $categoryId, + 'tagType' => self::TAG_TYPE_WECHAT, + 'tagCode' => $tagCode, + 'tagName' => $tagName, + 'isSystem' => 0, + 'syncFromWechat' => 1, + 'status' => self::STATUS_ENABLED, + 'createTime' => time() + ]); + } + + return $tag; + } + + /** + * 增加使用次数 + * @return bool + */ + public function incrementUseCount() + { + return $this->setInc('useCount'); + } + + /** + * 减少使用次数 + * @return bool + */ + public function decrementUseCount() + { + if ($this->useCount > 0) { + return $this->setDec('useCount'); + } + return true; + } +} + + diff --git a/application/common/model/TrafficPoolV2.php b/application/common/model/TrafficPoolV2.php new file mode 100644 index 0000000..6a30cc1 --- /dev/null +++ b/application/common/model/TrafficPoolV2.php @@ -0,0 +1,85 @@ +hasMany(TrafficPoolCompany::class, 'poolId', 'id'); + } + + /** + * 根据identifier查找或创建流量记录 + * @param string $identifier 唯一标识 + * @param array $data 额外数据 + * @return static + */ + public static function findOrCreateByIdentifier(string $identifier, array $data = []) + { + $record = self::where('identifier', $identifier)->find(); + if (!$record) { + $insertData = array_merge([ + 'identifier' => $identifier, + 'identifierType' => self::IDENTIFIER_TYPE_WECHAT_ID, + 'createTime' => time(), + ], $data); + + // 如果identifier是微信ID,同时设置wechatId + if (empty($insertData['wechatId']) && $insertData['identifierType'] == self::IDENTIFIER_TYPE_WECHAT_ID) { + $insertData['wechatId'] = $identifier; + } + + $record = self::create($insertData); + } + return $record; + } + + /** + * 更新基础信息 + * @param array $data + * @return bool + */ + public function updateBasicInfo(array $data) + { + $allowFields = ['nickname', 'avatar', 'gender', 'region', 'country', 'province', 'city', 'signature', 'wechatAlias', 'mobile']; + $updateData = array_intersect_key($data, array_flip($allowFields)); + $updateData['updateTime'] = time(); + $updateData['lastSeenTime'] = time(); + + return $this->save($updateData); + } +} + + diff --git a/application/common/model/TrafficSource.php b/application/common/model/TrafficSource.php index 4b2ae67..f3e3a1b 100644 --- a/application/common/model/TrafficSource.php +++ b/application/common/model/TrafficSource.php @@ -18,7 +18,7 @@ class TrafficSource extends Model // 设置数据表名 - protected $name = 'traffic_source'; + protected $name = 'traffic_source_v1'; // 自动写入时间戳 protected $autoWriteTimestamp = true; diff --git a/application/common/model/TrafficSourcePackage.php b/application/common/model/TrafficSourcePackage.php index c9c5e61..5ff3d5c 100644 --- a/application/common/model/TrafficSourcePackage.php +++ b/application/common/model/TrafficSourcePackage.php @@ -11,7 +11,7 @@ class TrafficSourcePackage extends Model { // 设置数据表名 - protected $name = 'traffic_source_package'; + protected $name = 'traffic_source_package_v1'; } \ No newline at end of file diff --git a/application/common/model/TrafficSourcePackageItem.php b/application/common/model/TrafficSourcePackageItem.php index 923d842..d72992d 100644 --- a/application/common/model/TrafficSourcePackageItem.php +++ b/application/common/model/TrafficSourcePackageItem.php @@ -11,7 +11,7 @@ class TrafficSourcePackageItem extends Model { // 设置数据表名 - protected $name = 'traffic_source_package_item'; + protected $name = 'traffic_source_package_item_v1'; } \ No newline at end of file diff --git a/application/common/service/TagEngineService.php b/application/common/service/TagEngineService.php new file mode 100644 index 0000000..2d91de6 --- /dev/null +++ b/application/common/service/TagEngineService.php @@ -0,0 +1,275 @@ +baseUrl = rtrim($url, '/'); + return $this; + } + + /** + * 设置API Key + * @param string $key + * @return $this + */ + public function setApiKey($key) + { + $this->apiKey = $key; + return $this; + } + + /** + * 构建请求头 + * @return array + */ + private function buildHeaders() + { + return [ + 'Authorization: Bearer ' . $this->apiKey, + 'Content-Type: application/json' + ]; + } + + /** + * 通过标识查询标签 + * + * @param array $identifiers 用户标识列表,最多100个 + * 格式:[['type' => 'phone', 'value' => '13800138000'], ...] + * @param array $options 查询选项 + * - include_tags: array 包含指定标签(标签代码列表) + * - exclude_tags: array 排除指定标签(标签代码列表) + * - tag_category: string 按分类筛选标签 + * - mask_identifier: bool 是否脱敏标识信息,默认 true + * @return array|false + */ + public function queryByIdentifiers($identifiers, $options = []) + { + try { + // 参数验证 + if (empty($identifiers) || !is_array($identifiers)) { + Log::error('标签引擎:标识列表不能为空'); + return false; + } + + if (count($identifiers) > 100) { + Log::error('标签引擎:单次最多查询100个标识'); + return false; + } + + // 构建请求数据 + $data = [ + 'identifiers' => $identifiers, + ]; + + if (!empty($options)) { + $data['options'] = $options; + } + + // 发起请求 + $url = $this->baseUrl . '/api/v1/tag/query-by-identifiers'; + $response = requestCurl($url, $data, 'POST', $this->buildHeaders(), 'json'); + + // 处理响应 + $result = handleApiResponse($response); + + // 记录日志 + Log::info('标签引擎-通过标识查询标签', [ + 'identifiers_count' => count($identifiers), + 'response' => $result + ]); + + return $result; + } catch (\Exception $e) { + Log::error('标签引擎-通过标识查询标签异常:' . $e->getMessage()); + return false; + } + } + + /** + * 通过标签查询用户 + * + * @param array $tagConditions 标签条件列表,最多10个条件 + * 格式:[ + * ['tag_code' => 'user.trade.total_amount', 'operator' => '>=', 'value' => '5000'], + * ... + * ] + * 支持的操作符:=, !=, >, >=, <, <=, in, not_in + * @param string $logic 逻辑关系:AND(默认)或 OR + * @param bool $includeSensitive 是否返回敏感信息(QQ号、身份证),默认 false + * @param int $page 页码,默认 1 + * @param int $pageSize 每页数量,默认 20,最大 100 + * @return array|false + */ + public function queryUsersByTags($tagConditions, $logic = 'AND', $includeSensitive = false, $page = 1, $pageSize = 20) + { + try { + // 参数验证 + if (empty($tagConditions) || !is_array($tagConditions)) { + Log::error('标签引擎:标签条件不能为空'); + return false; + } + + if (count($tagConditions) > 10) { + Log::error('标签引擎:单次最多10个标签条件'); + return false; + } + + if ($pageSize > 100) { + Log::error('标签引擎:单页最多返回100条记录'); + return false; + } + + // 构建请求数据 + $data = [ + 'tag_conditions' => $tagConditions, + 'logic' => strtoupper($logic), + 'include_sensitive' => $includeSensitive, + 'page' => max(1, intval($page)), + 'page_size' => min(100, max(1, intval($pageSize))) + ]; + + // 发起请求 + $url = $this->baseUrl . '/api/v1/tag/query-users-by-tags'; + $response = requestCurl($url, $data, 'POST', $this->buildHeaders(), 'json'); + + // 处理响应 + $result = handleApiResponse($response); + + // 记录日志 + Log::info('标签引擎-通过标签查询用户', [ + 'conditions_count' => count($tagConditions), + 'page' => $page, + 'page_size' => $pageSize, + 'response' => $result + ]); + + return $result; + } catch (\Exception $e) { + Log::error('标签引擎-通过标签查询用户异常:' . $e->getMessage()); + return false; + } + } + + /** + * 内部调用 - 通过手机号查询标签 + * + * @param string|array $phones 手机号或手机号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByPhone($phones, $options = []) + { + if (!is_array($phones)) { + $phones = [$phones]; + } + + $identifiers = []; + foreach ($phones as $phone) { + $identifiers[] = [ + 'type' => 'phone', + 'value' => $phone + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } + + /** + * 内部调用 - 通过微信号查询标签 + * + * @param string|array $wechats 微信号或微信号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByWechat($wechats, $options = []) + { + if (!is_array($wechats)) { + $wechats = [$wechats]; + } + + $identifiers = []; + foreach ($wechats as $wechat) { + $identifiers[] = [ + 'type' => 'wechat', + 'value' => $wechat + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } + + /** + * 内部调用 - 通过身份证号查询标签 + * + * @param string|array $idCards 身份证号或身份证号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByIdCard($idCards, $options = []) + { + if (!is_array($idCards)) { + $idCards = [$idCards]; + } + + $identifiers = []; + foreach ($idCards as $idCard) { + $identifiers[] = [ + 'type' => 'id_card', + 'value' => $idCard + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } + + /** + * 内部调用 - 通过QQ号查询标签 + * + * @param string|array $qqs QQ号或QQ号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByQQ($qqs, $options = []) + { + if (!is_array($qqs)) { + $qqs = [$qqs]; + } + + $identifiers = []; + foreach ($qqs as $qq) { + $identifiers[] = [ + 'type' => 'qq', + 'value' => $qq + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } +} + diff --git a/application/cunkebao/config/route.php b/application/cunkebao/config/route.php index f1c7964..02e04f0 100644 --- a/application/cunkebao/config/route.php +++ b/application/cunkebao/config/route.php @@ -61,7 +61,7 @@ Route::group('v1/', function () { Route::get('getUserList', 'app\cunkebao\controller\plan\PlanSceneV1Controller@getUserList'); }); - // 流量池相关 + // 流量池相关(V1 旧版接口,保持兼容) Route::group('traffic/pool', function () { Route::get('getPackage', 'app\cunkebao\controller\TrafficController@getPackage'); // 获取流量池包列表 Route::get('getPackageDetail', 'app\cunkebao\controller\TrafficController@getPackageDetail'); // 获取流量池详情(元数据) @@ -71,25 +71,57 @@ Route::group('v1/', function () { Route::get('user-list', 'app\cunkebao\controller\TrafficController@getTrafficPoolList'); // 获取流量池用户列表(数据列表) - - - - //Route::get('', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@index'); Route::get('getUserJourney', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@getUserJourney'); Route::get('getUserTags', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@getUserTags'); Route::get('getUserInfo', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@getUser'); - // Route::post('addPackage', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@addPackage'); - - - Route::get('converted', 'app\cunkebao\controller\traffic\GetConvertedListWithInCompanyV1Controller@index'); Route::get('types', 'app\cunkebao\controller\traffic\GetPotentialTypeSectionV1Controller@index'); Route::get('sources', 'app\cunkebao\controller\traffic\GetTrafficSourceSectionV1Controller@index'); Route::get('statistics', 'app\cunkebao\controller\traffic\GetPoolStatisticsV1Controller@index'); + }); + // 流量池 V2 新版接口 + Route::group('traffic/pool/v2', function () { + // 分组相关 + Route::get('groups', 'app\cunkebao\controller\TrafficPoolV2Controller@getGroups'); // 获取分组列表 + Route::get('group/detail', 'app\cunkebao\controller\TrafficPoolV2Controller@getGroupDetail'); // 获取分组详情 + Route::post('group/create', 'app\cunkebao\controller\TrafficPoolV2Controller@createGroup'); // 创建分组 + 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'); // 移除分组成员 + // 流量池成员相关 + Route::get('list', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolList'); // 获取流量池列表 + Route::get('detail', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolDetail'); // 获取流量详情 + Route::put('update', 'app\cunkebao\controller\TrafficPoolV2Controller@updatePool'); // 更新流量信息 + // 标签相关 + Route::get('tag/categories', 'app\cunkebao\controller\TrafficPoolV2Controller@getTagCategories'); // 获取标签类目 + Route::get('tag/defines', 'app\cunkebao\controller\TrafficPoolV2Controller@getTagDefines'); // 获取标签定义 + Route::get('tag/pool-tags', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolTags'); // 获取流量的标签 + Route::post('tag/add', 'app\cunkebao\controller\TrafficPoolV2Controller@addTag'); // 添加标签 + 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'); // 回收流量 + + // 统计相关 + Route::get('statistics', 'app\cunkebao\controller\TrafficPoolV2Controller@getStatistics'); // 获取统计数据 + + // 来源和行为相关 + Route::get('sources', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolSources'); // 分页获取来源 + Route::get('behaviors', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolBehaviors'); // 分页获取行为轨迹 }); // 工作台相关 @@ -233,7 +265,18 @@ Route::group('v1/', function () { }); }); - + // 客户标签功能 + Route::group('tag', function () { + // 通过标识查询标签 + Route::post('query-by-identifiers', 'app\cunkebao\controller\tag\QueryTagsByIdentifiersController@index'); + Route::post('query-by-phone', 'app\cunkebao\controller\tag\QueryTagsByIdentifiersController@byPhone'); // 快捷方法:通过手机号查询 + Route::post('query-by-wechat', 'app\cunkebao\controller\tag\QueryTagsByIdentifiersController@byWechat'); // 快捷方法:通过微信号查询 + + // 通过标签查询用户 + Route::post('query-users-by-tags', 'app\cunkebao\controller\tag\QueryUsersByTagsController@index'); + Route::get('high-value-users', 'app\cunkebao\controller\tag\QueryUsersByTagsController@highValueUsers'); // 快捷方法:查询高价值用户 + Route::get('vip-users', 'app\cunkebao\controller\tag\QueryUsersByTagsController@vipUsers'); // 快捷方法:查询VIP用户 + }); })->middleware(['jwt']); diff --git a/application/cunkebao/controller/RFMController.php b/application/cunkebao/controller/RFMController.php index f9163db..0f38336 100644 --- a/application/cunkebao/controller/RFMController.php +++ b/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[] = [ @@ -170,7 +183,7 @@ class RFMController extends BaseController return $b['RFM_score'] <=> $a['RFM_score']; }); - // 6. 更新 ck_traffic_source 和 s2_wechat_friend 表的RFM值 + // 6. 更新 ck_traffic_source_v1 和 s2_wechat_friend 表的RFM值 $this->updateRfmToTables($results, $ownerWechatId); return [ @@ -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 和 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 表 + // 更新 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/application/cunkebao/controller/TrafficController.php b/application/cunkebao/controller/TrafficController.php index 548db17..faa5ab1 100644 --- a/application/cunkebao/controller/TrafficController.php +++ b/application/cunkebao/controller/TrafficController.php @@ -25,8 +25,8 @@ class TrafficController extends BaseController $keyword = $this->request->param('keyword', ''); $companyId = $this->getUserInfo('companyId'); - $package = Db::name('traffic_source_package')->alias('tsp') - ->join('traffic_source_package_item tspi', 'tspi.packageId=tsp.id', 'left') + $package = Db::name('traffic_source_package_v1')->alias('tsp') + ->join('traffic_source_package_item_v1 tspi', 'tspi.packageId=tsp.id', 'left') ->whereIn('tsp.companyId', [$companyId, 0]) ->field('tsp.id,tsp.name,tsp.description,tsp.pic,tsp.isSys as type,tsp.createTime,count(tspi.id) as num') ->group('tsp.id'); @@ -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 p', 'p.identifier=tspi.identifier', 'left') + // ========== 旧版流量池代码结束 ========== ->join('wechat_account wa', 'tspi.identifier=wa.wechatId', 'left') ->where($where); @@ -424,7 +428,7 @@ class TrafficController extends BaseController foreach ($list as $k => &$v) { //流量池筛选 $package = TrafficSourcePackageItem::alias('tspi') - ->join('traffic_source_package p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId') + ->join('traffic_source_package_v1 p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId') ->where(['tspi.identifier' => $v['identifier']]) ->whereIn('tspi.companyId', [0, $v['companyId']]) ->column('p.name'); @@ -497,7 +501,7 @@ class TrafficController extends BaseController foreach ($list as $k => &$v) { // 获取好友所属的流量池包 $package = TrafficSourcePackageItem::alias('tspi') - ->join('traffic_source_package p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId') + ->join('traffic_source_package_v1 p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId') ->where(['tspi.identifier' => $v['identifier']]) ->whereIn('tspi.companyId', [0, $companyId]) ->column('p.name'); diff --git a/application/cunkebao/controller/TrafficPoolV2Controller.php b/application/cunkebao/controller/TrafficPoolV2Controller.php new file mode 100644 index 0000000..a559830 --- /dev/null +++ b/application/cunkebao/controller/TrafficPoolV2Controller.php @@ -0,0 +1,897 @@ +poolService = new TrafficPoolService(); + $this->groupService = new TrafficPoolGroupService(); + } + + // ==================== 分组相关接口 ==================== + + /** + * 获取流量池分组列表 + * @return \think\response\Json + */ + public function getGroups() + { + $companyId = $this->getUserInfo('companyId'); + + try { + $groups = $this->groupService->getGroupList($companyId, true); + return ResponseHelper::success($groups); + } catch (\Exception $e) { + return ResponseHelper::error('获取分组列表失败:' . $e->getMessage()); + } + } + + /** + * 获取分组详情 + * @return \think\response\Json + */ + public function getGroupDetail() + { + $groupId = $this->request->param('groupId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + + if (empty($groupId)) { + return ResponseHelper::error('分组ID不能为空'); + } + + try { + $detail = $this->groupService->getGroupDetail($groupId, $companyId); + if (!$detail) { + return ResponseHelper::error('分组不存在'); + } + return ResponseHelper::success($detail); + } catch (\Exception $e) { + return ResponseHelper::error('获取分组详情失败:' . $e->getMessage()); + } + } + + /** + * 创建分组 + * @return \think\response\Json + */ + public function createGroup() + { + $companyId = $this->getUserInfo('companyId'); + $userId = $this->getUserInfo('id'); + + $data = $this->request->param(); + + if (empty($data['groupName'])) { + return ResponseHelper::error('分组名称不能为空'); + } + + try { + $group = $this->groupService->createGroup($companyId, $data, $userId); + 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 + */ + public function updateGroup() + { + $groupId = $this->request->param('groupId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + + if (empty($groupId)) { + return ResponseHelper::error('分组ID不能为空'); + } + + $data = $this->request->param(); + + try { + $this->groupService->updateGroup($groupId, $companyId, $data); + return ResponseHelper::success(null, '更新成功'); + } catch (\Exception $e) { + return ResponseHelper::error('更新分组失败:' . $e->getMessage()); + } + } + + /** + * 删除分组 + * @return \think\response\Json + */ + public function deleteGroup() + { + $groupId = $this->request->param('groupId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + + if (empty($groupId)) { + return ResponseHelper::error('分组ID不能为空'); + } + + try { + $this->groupService->deleteGroup($groupId, $companyId); + return ResponseHelper::success(null, '删除成功'); + } catch (\Exception $e) { + return ResponseHelper::error('删除分组失败:' . $e->getMessage()); + } + } + + // ==================== 流量池成员相关接口 ==================== + + /** + * 获取分组成员列表(用户列表) + * @return \think\response\Json + */ + public function getGroupMembers() + { + $groupId = $this->request->param('groupId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $page = $this->request->param('page', 1, 'intval'); + $pageSize = $this->request->param('pageSize', 10, 'intval'); + $keyword = $this->request->param('keyword', ''); + + if (empty($groupId)) { + return ResponseHelper::error('分组ID不能为空'); + } + + $filters = [ + 'keyword' => $keyword + ]; + + try { + $result = $this->groupService->getGroupMembers($groupId, $companyId, $page, $pageSize, $filters); + return ResponseHelper::success($result); + } catch (\Exception $e) { + return ResponseHelper::error('获取成员列表失败:' . $e->getMessage()); + } + } + + /** + * 获取流量池列表(全量,带筛选) + * @return \think\response\Json + */ + public function getPoolList() + { + $companyId = $this->getUserInfo('companyId'); + $page = $this->request->param('page', 1, 'intval'); + $pageSize = $this->request->param('pageSize', 10, 'intval'); + + $filters = [ + 'keyword' => $this->request->param('keyword', ''), + 'friendStatus' => $this->request->param('friendStatus'), + 'level' => $this->request->param('level'), + 'lifecycle' => $this->request->param('lifecycle'), + 'allocateStatus' => $this->request->param('allocateStatus'), + 'ownerWechatId' => $this->request->param('ownerWechatId', ''), + 'rfmMMin' => $this->request->param('rfmMMin'), + 'rfmMMax' => $this->request->param('rfmMMax'), + ]; + + // 移除空值 + $filters = array_filter($filters, function($v) { + return $v !== null && $v !== ''; + }); + + try { + $result = $this->poolService->getPoolList($companyId, $page, $pageSize, $filters); + return ResponseHelper::success($result); + } catch (\Exception $e) { + return ResponseHelper::error('获取流量池列表失败:' . $e->getMessage()); + } + } + + /** + * 获取流量详情 + * @return \think\response\Json + */ + public function getPoolDetail() + { + $poolCompanyId = $this->request->param('id', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + try { + $detail = $this->poolService->getPoolDetail($poolCompanyId, $companyId); + if (!$detail) { + return ResponseHelper::error('流量不存在'); + } + return ResponseHelper::success($detail); + } catch (\Exception $e) { + return ResponseHelper::error('获取流量详情失败:' . $e->getMessage()); + } + } + + /** + * 更新流量信息 + * @return \think\response\Json + */ + public function updatePool() + { + $poolCompanyId = $this->request->param('id', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + $data = $this->request->param(); + + try { + $this->poolService->updatePool($poolCompanyId, $companyId, $data); + return ResponseHelper::success(null, '更新成功'); + } catch (\Exception $e) { + return ResponseHelper::error('更新失败:' . $e->getMessage()); + } + } + + /** + * 添加成员到分组(手动分组) + * @return \think\response\Json + */ + public function addMembersToGroup() + { + $groupId = $this->request->param('groupId', 0, 'intval'); + $poolCompanyIds = $this->request->param('poolCompanyIds/a', []); + $companyId = $this->getUserInfo('companyId'); + $userId = $this->getUserInfo('id'); + + if (empty($groupId)) { + return ResponseHelper::error('分组ID不能为空'); + } + + if (empty($poolCompanyIds)) { + return ResponseHelper::error('请选择要添加的成员'); + } + + try { + $count = $this->groupService->addMembers($groupId, $poolCompanyIds, $companyId, $userId); + return ResponseHelper::success(['count' => $count], "成功添加 {$count} 个成员"); + } catch (\Exception $e) { + return ResponseHelper::error('添加失败:' . $e->getMessage()); + } + } + + /** + * 从分组移除成员 + * @return \think\response\Json + */ + public function removeMembersFromGroup() + { + $groupId = $this->request->param('groupId', 0, 'intval'); + $poolCompanyIds = $this->request->param('poolCompanyIds/a', []); + $companyId = $this->getUserInfo('companyId'); + + if (empty($groupId)) { + return ResponseHelper::error('分组ID不能为空'); + } + + if (empty($poolCompanyIds)) { + return ResponseHelper::error('请选择要移除的成员'); + } + + try { + $count = $this->groupService->removeMembers($groupId, $poolCompanyIds, $companyId); + return ResponseHelper::success(['count' => $count], "成功移除 {$count} 个成员"); + } catch (\Exception $e) { + return ResponseHelper::error('移除失败:' . $e->getMessage()); + } + } + + // ==================== 标签相关接口 ==================== + + /** + * 获取标签类目列表 + * @return \think\response\Json + */ + public function getTagCategories() + { + $companyId = $this->getUserInfo('companyId'); + $tagType = $this->request->param('tagType'); + + try { + $categories = TrafficPoolTagCategory::getCategoryTree($companyId, $tagType); + return ResponseHelper::success($categories); + } catch (\Exception $e) { + return ResponseHelper::error('获取标签类目失败:' . $e->getMessage()); + } + } + + /** + * 获取标签定义列表 + * @return \think\response\Json + */ + public function getTagDefines() + { + $companyId = $this->getUserInfo('companyId'); + $tagType = $this->request->param('tagType'); + $categoryId = $this->request->param('categoryId'); + + try { + $defines = TrafficPoolTagDefine::getTagDefinesByCompany($companyId, $tagType, $categoryId); + return ResponseHelper::success($defines); + } catch (\Exception $e) { + return ResponseHelper::error('获取标签定义失败:' . $e->getMessage()); + } + } + + /** + * 为流量添加标签 + * @return \think\response\Json + */ + public function addTag() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $tagDefineId = $this->request->param('tagDefineId', 0, 'intval'); + $tagValue = $this->request->param('tagValue', ''); + $companyId = $this->getUserInfo('companyId'); + $userId = $this->getUserInfo('id'); + + if (empty($poolCompanyId) || empty($tagDefineId)) { + return ResponseHelper::error('参数不完整'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $tag = TrafficPoolTag::addTag( + $poolCompanyId, + $poolCompany->identifier, + $companyId, + $tagDefineId, + TrafficPoolTag::SOURCE_MANUAL, + $userId, + $tagValue + ); + + if ($tag) { + return ResponseHelper::success(['id' => $tag->id], '添加成功'); + } else { + return ResponseHelper::error('添加失败'); + } + } catch (\Exception $e) { + return ResponseHelper::error('添加标签失败:' . $e->getMessage()); + } + } + + /** + * 移除流量标签 + * @return \think\response\Json + */ + public function removeTag() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $tagDefineId = $this->request->param('tagDefineId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + + if (empty($poolCompanyId) || empty($tagDefineId)) { + return ResponseHelper::error('参数不完整'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $result = TrafficPoolTag::removeTag($poolCompanyId, $tagDefineId); + if ($result) { + return ResponseHelper::success(null, '移除成功'); + } else { + return ResponseHelper::error('标签不存在'); + } + } catch (\Exception $e) { + return ResponseHelper::error('移除标签失败:' . $e->getMessage()); + } + } + + /** + * 获取流量的标签 + * @return \think\response\Json + */ + public function getPoolTags() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $tagType = $this->request->param('tagType'); + $companyId = $this->getUserInfo('companyId'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $tags = TrafficPoolTag::getTagsByPoolCompany($poolCompanyId, $tagType); + return ResponseHelper::success($tags); + } catch (\Exception $e) { + return ResponseHelper::error('获取标签失败:' . $e->getMessage()); + } + } + + /** + * 从标签引擎同步用户标签 + * @return \think\response\Json + */ + public function syncTagsFromEngine() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $operatorId = $this->getUserInfo('id'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + try { + $result = $this->poolService->syncTagsFromEngine($poolCompanyId, $companyId, $operatorId); + return ResponseHelper::success($result, "同步成功:已同步 {$result['syncedCount']} 个标签"); + } catch (\Exception $e) { + return ResponseHelper::error('同步标签失败:' . $e->getMessage()); + } + } + + // ==================== 分配相关接口 ==================== + + /** + * 分配流量给客服 + * @return \think\response\Json + */ + public function allocatePool() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $toWechatId = $this->request->param('toWechatId', ''); + $toAccountId = $this->request->param('toAccountId', 0, 'intval'); + $toUserId = $this->request->param('toUserId', 0, 'intval'); + $expireDays = $this->request->param('expireDays', 30, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $operatorId = $this->getUserInfo('id'); + + if (empty($poolCompanyId) || empty($toWechatId)) { + return ResponseHelper::error('参数不完整'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $fromInfo = [ + 'fromWechatId' => $poolCompany->ownerWechatId, + 'fromAccountId' => $poolCompany->ownerAccountId, + 'fromUserId' => $poolCompany->ownerUserId, + ]; + + $record = TrafficPoolAllotRecord::createAllotRecord( + $poolCompanyId, + $poolCompany->identifier, + $companyId, + $toWechatId, + $toAccountId ?: null, + $toUserId ?: null, + $expireDays, + $operatorId, + $fromInfo + ); + + return ResponseHelper::success(['id' => $record->id], '分配成功'); + } catch (\Exception $e) { + return ResponseHelper::error('分配失败:' . $e->getMessage()); + } + } + + /** + * 回收流量分配 + * @return \think\response\Json + */ + public function recyclePool() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $operatorId = $this->getUserInfo('id'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + TrafficPoolAllotRecord::recycleAllot($poolCompanyId, $operatorId); + return ResponseHelper::success(null, '回收成功'); + } catch (\Exception $e) { + return ResponseHelper::error('回收失败:' . $e->getMessage()); + } + } + + // ==================== 统计相关接口 ==================== + + /** + * 获取流量池统计数据 + * @return \think\response\Json + */ + public function getStatistics() + { + $companyId = $this->getUserInfo('companyId'); + + try { + $statistics = $this->poolService->getStatistics($companyId); + return ResponseHelper::success($statistics); + } catch (\Exception $e) { + return ResponseHelper::error('获取统计数据失败:' . $e->getMessage()); + } + } + + /** + * 分页获取流量来源 + * @return \think\response\Json + */ + public function getPoolSources() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $page = $this->request->param('page', 1, 'intval'); + $pageSize = $this->request->param('pageSize', 20, 'intval'); + $keyword = $this->request->param('keyword', ''); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $result = TrafficPoolSource::getSourcesWithOwnersPaginated($poolCompanyId, $page, $pageSize, $keyword); + return ResponseHelper::success($result); + } catch (\Exception $e) { + return ResponseHelper::error('获取来源列表失败:' . $e->getMessage()); + } + } + + /** + * 分页获取流量行为轨迹 + * @return \think\response\Json + */ + public function getPoolBehaviors() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $page = $this->request->param('page', 1, 'intval'); + $pageSize = $this->request->param('pageSize', 20, 'intval'); + $keyword = $this->request->param('keyword', ''); + $behaviorType = $this->request->param('behaviorType', 0, 'intval'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $result = TrafficPoolBehavior::getUserJourneyPaginated($poolCompanyId, $page, $pageSize, $keyword, $behaviorType); + return ResponseHelper::success($result); + } catch (\Exception $e) { + 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/application/cunkebao/controller/plan/PostExternalApiV1Controller.php b/application/cunkebao/controller/plan/PostExternalApiV1Controller.php index 4edd546..0ecdebb 100644 --- a/application/cunkebao/controller/plan/PostExternalApiV1Controller.php +++ b/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')->where('identifier', $identifier)->find(); - if (!$trafficPool) { - $trafficPoolId =Db::name('traffic_pool')->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/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php b/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php index 4ef33f1..6a733bf 100644 --- a/application/cunkebao/controller/plan/PosterWeChatMiniProgram.php +++ b/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 表 identifier mobile 都是 用 phone字段的值 - $trafficPool = Db::name('traffic_pool')->where('identifier', $result['phone_info']['phoneNumber'])->find(); - if (!$trafficPool) { - Db::name('traffic_pool')->insert([ - 'identifier' => $result['phone_info']['phoneNumber'], - 'mobile' => $result['phone_info']['phoneNumber'], - 'createTime' => time() - ]); - } + // 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() + // ]); + // } + // ========== 旧版流量池代码结束(已迁移到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')->where('identifier', $identifier)->find(); - if (!$trafficPool) { - $insertData = [ - 'identifier' => $identifier, - 'createTime' => time() - ]; - if ($isPhone) { - $insertData['mobile'] = $identifier; - } else { - $insertData['wechatId'] = $identifier; - } - Db::name('traffic_pool')->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')->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/application/cunkebao/controller/tag/QueryTagsByIdentifiersController.php b/application/cunkebao/controller/tag/QueryTagsByIdentifiersController.php new file mode 100644 index 0000000..43657e7 --- /dev/null +++ b/application/cunkebao/controller/tag/QueryTagsByIdentifiersController.php @@ -0,0 +1,169 @@ +request->param('identifiers', []); + $options = $this->request->param('options', []); + + // 参数验证 + $validate = Validate::make([ + 'identifiers' => 'require|array', + 'identifiers.*' => 'array', + ]); + + if (!$validate->check(['identifiers' => $identifiers])) { + throw new \Exception($validate->getError(), 400); + } + + // 验证标识格式 + foreach ($identifiers as $key => $identifier) { + if (!isset($identifier['type']) || !isset($identifier['value'])) { + throw new \Exception("标识[{$key}]格式错误,必须包含type和value字段", 400); + } + + // 验证标识类型 + $allowedTypes = ['phone', 'id_card', 'wechat', 'qq']; + if (!in_array($identifier['type'], $allowedTypes)) { + throw new \Exception("标识[{$key}]类型不支持,仅支持:" . implode(', ', $allowedTypes), 400); + } + + // 验证值不为空 + if (empty($identifier['value'])) { + throw new \Exception("标识[{$key}]的值不能为空", 400); + } + } + + // 限制数量 + if (count($identifiers) > 100) { + throw new \Exception('单次最多查询100个标识', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryByIdentifiers($identifiers, $options); + + if ($result === false) { + throw new \Exception('查询标签失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:通过手机号查询标签 + * + * @return \think\response\Json + */ + public function byPhone() + { + try { + // 获取请求参数 + $phones = $this->request->param('phones', []); + $options = $this->request->param('options', []); + + // 参数验证 + if (empty($phones)) { + throw new \Exception('手机号不能为空', 400); + } + + // 如果是字符串,转为数组 + if (is_string($phones)) { + $phones = explode(',', $phones); + } + + if (!is_array($phones)) { + throw new \Exception('手机号格式错误', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryByPhone($phones, $options); + + if ($result === false) { + throw new \Exception('查询标签失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:通过微信号查询标签 + * + * @return \think\response\Json + */ + public function byWechat() + { + try { + // 获取请求参数 + $wechats = $this->request->param('wechats', []); + $options = $this->request->param('options', []); + + // 参数验证 + if (empty($wechats)) { + throw new \Exception('微信号不能为空', 400); + } + + // 如果是字符串,转为数组 + if (is_string($wechats)) { + $wechats = explode(',', $wechats); + } + + if (!is_array($wechats)) { + throw new \Exception('微信号格式错误', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryByWechat($wechats, $options); + + if ($result === false) { + throw new \Exception('查询标签失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } +} + diff --git a/application/cunkebao/controller/tag/QueryUsersByTagsController.php b/application/cunkebao/controller/tag/QueryUsersByTagsController.php new file mode 100644 index 0000000..4418b34 --- /dev/null +++ b/application/cunkebao/controller/tag/QueryUsersByTagsController.php @@ -0,0 +1,177 @@ +request->param('tag_conditions', []); + $logic = $this->request->param('logic', 'AND'); + $includeSensitive = $this->request->param('include_sensitive', false); + $page = $this->request->param('page', 1); + $pageSize = $this->request->param('page_size', 20); + + // 参数验证 + $validate = Validate::make([ + 'tag_conditions' => 'require|array', + 'tag_conditions.*' => 'array', + 'logic' => 'in:AND,OR', + 'page' => 'number|>=:1', + 'page_size' => 'number|between:1,100', + ]); + + $params = [ + 'tag_conditions' => $tagConditions, + 'logic' => $logic, + 'page' => $page, + 'page_size' => $pageSize, + ]; + + if (!$validate->check($params)) { + throw new \Exception($validate->getError(), 400); + } + + // 验证标签条件格式 + foreach ($tagConditions as $key => $condition) { + if (!isset($condition['tag_code']) || !isset($condition['operator']) || !isset($condition['value'])) { + throw new \Exception("标签条件[{$key}]格式错误,必须包含tag_code、operator和value字段", 400); + } + + // 验证操作符 + $allowedOperators = ['=', '!=', '>', '>=', '<', '<=', 'in', 'not_in']; + if (!in_array($condition['operator'], $allowedOperators)) { + throw new \Exception("标签条件[{$key}]操作符不支持,仅支持:" . implode(', ', $allowedOperators), 400); + } + + // 验证 in/not_in 的值必须是数组 + if (in_array($condition['operator'], ['in', 'not_in']) && !is_array($condition['value'])) { + throw new \Exception("标签条件[{$key}]使用{$condition['operator']}操作符时,value必须是数组", 400); + } + } + + // 限制条件数量 + if (count($tagConditions) > 10) { + throw new \Exception('单次最多10个标签条件', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryUsersByTags($tagConditions, $logic, $includeSensitive, $page, $pageSize); + + if ($result === false) { + throw new \Exception('查询用户失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:查询高价值用户(示例) + * 查询累计消费金额 >= 5000 的用户 + * + * @return \think\response\Json + */ + public function highValueUsers() + { + try { + $page = $this->request->param('page', 1); + $pageSize = $this->request->param('page_size', 20); + $minAmount = $this->request->param('min_amount', 5000); + + $tagConditions = [ + [ + 'tag_code' => 'user.trade.total_amount', + 'operator' => '>=', + 'value' => strval($minAmount) + ] + ]; + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryUsersByTags($tagConditions, 'AND', false, $page, $pageSize); + + if ($result === false) { + throw new \Exception('查询用户失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:查询VIP用户(示例) + * 查询用户等级为 VIP、SVIP 或金卡会员的用户 + * + * @return \think\response\Json + */ + public function vipUsers() + { + try { + $page = $this->request->param('page', 1); + $pageSize = $this->request->param('page_size', 20); + $levels = $this->request->param('levels', ['VIP', 'SVIP', '金卡会员']); + + // 如果是字符串,转为数组 + if (is_string($levels)) { + $levels = explode(',', $levels); + } + + $tagConditions = [ + [ + 'tag_code' => 'user.trade.level', + 'operator' => 'in', + 'value' => $levels + ] + ]; + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryUsersByTags($tagConditions, 'AND', false, $page, $pageSize); + + if ($result === false) { + throw new \Exception('查询用户失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } +} + diff --git a/application/cunkebao/controller/traffic/GetConvertedListWithInCompanyV1Controller.php b/application/cunkebao/controller/traffic/GetConvertedListWithInCompanyV1Controller.php index b152c8d..b89e137 100644 --- a/application/cunkebao/controller/traffic/GetConvertedListWithInCompanyV1Controller.php +++ b/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 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/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php b/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php index 434a128..cb40466 100644 --- a/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php +++ b/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php @@ -83,10 +83,10 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController 'wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.alias' ] ) - ->join('traffic_source s', 'p.identifier=s.identifier') + ->join('traffic_source_v1 s', 'p.identifier=s.identifier') ->join('wechat_account wa', 'p.identifier=wa.wechatId', 'left') - ->join('traffic_source_package_item tspi', 'p.identifier = tspi.identifier AND s.companyId = tspi.companyId', 'left') - ->join('traffic_source_package tsp', 'tspi.packageId=tsp.id', 'left') + ->join('traffic_source_package_item_v1 tspi', 'p.identifier = tspi.identifier AND s.companyId = tspi.companyId', 'left') + ->join('traffic_source_package_v1 tsp', 'tspi.packageId=tsp.id', 'left') ->join('device_wechat_login d', 's.sourceId=d.wechatId', 'left') ->where($where); @@ -106,8 +106,8 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController if ($isPage) { foreach ($list as &$item) { //流量池筛选 - $package = Db::name('traffic_source_package_item')->alias('tspi') - ->join('traffic_source_package p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId') + $package = Db::name('traffic_source_package_item_v1')->alias('tspi') + ->join('traffic_source_package_v1 p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId') ->where(['tspi.identifier' => $item->identifier]) ->whereIn('tspi.companyId', [0, $item->companyId]) ->column('p.name'); @@ -181,7 +181,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController $data['lastMsgTime'] = ''; //来源 - $source = Db::name('traffic_source')->alias('ts') + $source = Db::name('traffic_source_v1')->alias('ts') ->field(['wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.wechatId', 'wa.alias', 'ts.createTime', 'wf.id as friendId', 'wf.wechatAccountId']) @@ -221,12 +221,12 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController //流量池 - $package = Db::name('traffic_source_package_item')->alias('tspi') + $package = Db::name('traffic_source_package_item_v1')->alias('tspi') ->join('traffic_source_package p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId') ->where(['tspi.companyId' => $companyId, 'tspi.identifier' => $data['identifier']]) ->column('p.name'); - $package2 = Db::name('traffic_source_package_item')->alias('tspi') - ->join('traffic_source_package p', 'tspi.packageId=p.id') + $package2 = Db::name('traffic_source_package_item_v1')->alias('tspi') + ->join('traffic_source_package_v1 p', 'tspi.packageId=p.id') ->where(['tspi.companyId' => $companyId, 'tspi.identifier' => $data['identifier']]) ->column('p.name'); $packages = array_merge($package, $package2); @@ -328,12 +328,24 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController if (empty($userId)) { return json_encode(['code' => 500, 'msg' => '用户id不能为空']); } - $data = Db::name('traffic_pool')->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' => []]); } @@ -383,7 +395,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController } if (!empty($addPackageId)) { - $package = Db::name('traffic_source_package') + $package = Db::name('traffic_source_package_v1') ->where(['id' => $addPackageId, 'isDel' => 0]) ->whereIn('companyId', [$companyId, 0]) ->field('id,name') @@ -393,7 +405,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController } $packageId = $package['id']; } else { - $package = Db::name('traffic_source_package') + $package = Db::name('traffic_source_package_v1') ->where(['isDel' => 0, 'name' => $packageName]) ->whereIn('companyId', [$companyId, 0]) ->field('id,name') @@ -401,7 +413,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController if (!empty($package)) { return ResponseHelper::error('该流量池名称已存在'); } - $packageId = Db::name('traffic_source_package')->insertGetId([ + $packageId = Db::name('traffic_source_package_v1')->insertGetId([ 'userId' => $userId, 'companyId' => $companyId, 'name' => $packageName, @@ -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') + // ->whereIn('tp.id', $userIds) + // ->where(['companyId' => $companyId]) + // ->group('tp.identifier') + // ->column('tc.identifier'); + // ========== 新版流量池代码 ========== $result = Db::name('traffic_pool')->alias('tp') - ->join('traffic_source tc', 'tp.identifier=tc.identifier') + ->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('请上传用户文件'); @@ -513,7 +534,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController $batchRows = array_slice($result, $i, $batchSize); if (!empty($batchRows)) { // 2. 批量查询已存在的手机 - $existing = Db::name('traffic_source_package_item') + $existing = Db::name('traffic_source_package_item_v1') ->where(['companyId' => $companyId, 'packageId' => $packageId]) ->whereIn('identifier', $batchRows) ->field('identifier') @@ -533,7 +554,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController } // 4. 批量插入新数据 if (!empty($newData)) { - Db::name('traffic_source_package_item')->insertAll($newData); + Db::name('traffic_source_package_item_v1')->insertAll($newData); } } } @@ -548,28 +569,33 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController $batchRows = array_slice($rows, $i, $batchSize); if (!empty($batchRows)) { $identifiers = array_column($batchRows, 'phone'); - //流量池处理 - $existing = Db::name('traffic_pool') - ->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')->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 = []; - $existing2 = Db::name('traffic_source') + $existing2 = Db::name('traffic_source_v1') ->where(['companyId' => $companyId]) ->whereIn('identifier', $identifiers) ->column('identifier'); @@ -587,12 +613,12 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController } } if (!empty($newData2)) { - Db::name('traffic_source')->insertAll($newData2); + Db::name('traffic_source_v1')->insertAll($newData2); } //流量池包数据处理 $newData3 = []; - $existing3 = Db::name('traffic_source_package_item') + $existing3 = Db::name('traffic_source_package_item_v1') ->where(['companyId' => $companyId, 'packageId' => $packageId]) ->whereIn('identifier', $identifiers) ->field('identifier') @@ -608,7 +634,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController } } if (!empty($newData3)) { - Db::name('traffic_source_package_item')->insertAll($newData3); + Db::name('traffic_source_package_item_v1')->insertAll($newData3); } Db::commit(); @@ -638,10 +664,20 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController $isWechat = $this->request->param('isWechat', false); $companyId = $this->getUserInfo('companyId'); - $friend = Db::name('traffic_pool')->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/application/cunkebao/controller/wechat/GetWechatMomentsV1Controller.php b/application/cunkebao/controller/wechat/GetWechatMomentsV1Controller.php index 32dae25..06f6c3b 100644 --- a/application/cunkebao/controller/wechat/GetWechatMomentsV1Controller.php +++ b/application/cunkebao/controller/wechat/GetWechatMomentsV1Controller.php @@ -3,10 +3,6 @@ namespace app\cunkebao\controller\wechat; use app\common\controller\ExportController; -use app\common\model\Device as DeviceModel; -use app\common\model\DeviceUser as DeviceUserModel; -use app\common\model\DeviceWechatLogin as DeviceWechatLoginModel; -use app\common\model\User as UserModel; use app\cunkebao\controller\BaseController; use library\ResponseHelper; use think\Db; @@ -16,58 +12,53 @@ use think\Db; */ class GetWechatMomentsV1Controller extends BaseController { - /** - * 主操盘手获取项目下所有设备ID - * - * @return array - */ - protected function getCompanyDevicesId(): array - { - return DeviceModel::where('companyId', $this->getUserInfo('companyId')) - ->column('id'); - } - - /** - * 非主操盘手仅可查看分配到的设备 - * - * @return array - */ - protected function getUserDevicesId(): array - { - return DeviceUserModel::where([ - 'userId' => $this->getUserInfo('id'), - 'companyId' => $this->getUserInfo('companyId'), - ])->column('deviceId'); - } - - /** - * 获取当前用户可访问的设备ID - * - * @return array - */ - protected function getDevicesId(): array - { - return ($this->getUserInfo('isAdmin') == UserModel::ADMIN_STP) - ? $this->getCompanyDevicesId() - : $this->getUserDevicesId(); - } - /** * 获取用户可访问的微信ID集合 + * 使用 s2_wechat_friend 验证好友归属: + * - 非管理员:当前账号在好友表中的 ownerWechatId 集合 + * - 管理员:公司下所有账号在好友表中的 ownerWechatId 集合 * * @return array * @throws \Exception */ protected function getAccessibleWechatIds(): array { - $deviceIds = $this->getDevicesId(); - if (empty($deviceIds)) { - throw new \Exception('暂无可用设备', 200); + $companyId = $this->getUserInfo('companyId'); + $isAdmin = $this->getUserInfo('isAdmin'); + $accountId = $this->getUserInfo('s2_accountId'); + + if (empty($companyId)) { + throw new \Exception('请先登录', 401); } - return DeviceWechatLoginModel::distinct(true) - ->where('companyId', $this->getUserInfo('companyId')) - ->whereIn('deviceId', $deviceIds) + // 管理员:根据公司下所有账号的好友归属(s2_wechat_friend.accountId -> ownerWechatId) + if (!empty($isAdmin)) { + // 获取公司下所有账号ID + $accountIds = Db::table('s2_company_account') + ->where('departmentId', $companyId) + ->column('id'); + + if (empty($accountIds)) { + return []; + } + + // 从好友表中取出这些账号的 ownerWechatId(去重,排除已删除好友) + return Db::table('s2_wechat_friend') + ->distinct(true) + ->whereIn('accountId', $accountIds) + ->where('isDeleted', 0) + ->column('wechatId'); + } + + // 非管理员:仅根据当前账号在好友表中的 ownerWechatId 列表 + if (empty($accountId)) { + return []; + } + + return Db::table('s2_wechat_friend') + ->distinct(true) + ->where('accountId', $accountId) + ->where('isDeleted', 0) ->column('wechatId'); } @@ -79,30 +70,18 @@ class GetWechatMomentsV1Controller extends BaseController public function index() { try { + // 可选参数:wechatId 不传则查看当前账号可访问的所有微信的朋友圈 $wechatId = $this->request->param('wechatId/s', ''); - if (empty($wechatId)) { - return ResponseHelper::error('wechatId不能为空'); + + + // 查询朋友圈:如果传了 userName 参数,则只查看指定用户的;否则查看所有 + $query = Db::table('s2_wechat_moments'); + + + if (!empty($wechatId)) { + $query->where('userName', $wechatId); } - // 权限校验:只能查看当前账号可访问的微信 - $accessibleWechatIds = $this->getAccessibleWechatIds(); - if (!in_array($wechatId, $accessibleWechatIds, true)) { - return ResponseHelper::error('无权查看该微信的朋友圈', 403); - } - - // 获取对应的微信账号ID - $accountId = Db::table('s2_wechat_account') - ->where('wechatId', $wechatId) - ->value('id'); - - if (empty($accountId)) { - return ResponseHelper::error('微信账号不存在或尚未同步', 404); - } - - $query = Db::table('s2_wechat_moments') - ->where('wechatAccountId', $accountId) - ->where('userName', $wechatId); - // 关键词搜索 if ($keyword = trim((string)$this->request->param('keyword', ''))) { $query->whereLike('content', '%' . $keyword . '%'); @@ -130,6 +109,7 @@ class GetWechatMomentsV1Controller extends BaseController $limit = (int)$this->request->param('limit', 10); $paginator = $query->order('createTime', 'desc') + ->group('snsId') ->paginate($limit, false, ['page' => $page]); $list = array_map(function ($item) { @@ -160,24 +140,15 @@ class GetWechatMomentsV1Controller extends BaseController return ResponseHelper::error('wechatId不能为空'); } - // 权限校验:只能查看当前账号可访问的微信 - $accessibleWechatIds = $this->getAccessibleWechatIds(); - if (!in_array($wechatId, $accessibleWechatIds, true)) { - return ResponseHelper::error('无权查看该微信的朋友圈', 403); + + + // 查询朋友圈(不限制 userName,导出所有朋友圈) + $query = Db::table('s2_wechat_moments'); + + if (!empty($wechatId)) { + $query->where('userName', $wechatId); } - // 获取对应的微信账号ID - $accountId = Db::table('s2_wechat_account') - ->where('wechatId', $wechatId) - ->value('id'); - - if (empty($accountId)) { - return ResponseHelper::error('微信账号不存在或尚未同步', 404); - } - - $query = Db::table('s2_wechat_moments') - ->where('wechatAccountId', $accountId); - // 关键词搜索 if ($keyword = trim((string)$this->request->param('keyword', ''))) { $query->whereLike('content', '%' . $keyword . '%'); @@ -202,7 +173,7 @@ class GetWechatMomentsV1Controller extends BaseController } // 获取所有数据(不分页) - $moments = $query->order('createTime', 'desc')->select(); + $moments = $query->order('createTime', 'desc')->group('snsId')->select(); if (empty($moments)) { return ResponseHelper::error('暂无数据可导出'); diff --git a/application/cunkebao/controller/wechat/GetWechatProfileV1Controller.php b/application/cunkebao/controller/wechat/GetWechatProfileV1Controller.php index ad7a7f5..cbd3961 100644 --- a/application/cunkebao/controller/wechat/GetWechatProfileV1Controller.php +++ b/application/cunkebao/controller/wechat/GetWechatProfileV1Controller.php @@ -55,7 +55,7 @@ class GetWechatProfileV1Controller extends BaseController { return (string)TrafficPoolModel::alias('p') ->field('t.id') - ->join('traffic_source s', 's.identifier = p.identifier') + ->join('traffic_source_v1 s', 's.identifier = p.identifier') ->where('p.wechatId', $wechatId) ->value('fromd'); } diff --git a/application/cunkebao/controller/workbench/WorkbenchController.php b/application/cunkebao/controller/workbench/WorkbenchController.php index 03c6a82..2be3a14 100644 --- a/application/cunkebao/controller/workbench/WorkbenchController.php +++ b/application/cunkebao/controller/workbench/WorkbenchController.php @@ -1002,8 +1002,8 @@ class WorkbenchController extends Controller // 处理普通流量池 if (!empty($normalPools)) { - $normalPoolList = Db::name('traffic_source_package')->alias('tsp') - ->leftJoin('traffic_source_package_item tspi', 'tspi.packageId = tsp.id and tspi.isDel = 0') + $normalPoolList = Db::name('traffic_source_package_v1')->alias('tsp') + ->leftJoin('traffic_source_package_item_v1 tspi', 'tspi.packageId = tsp.id and tspi.isDel = 0') ->whereIn('tsp.id', $normalPools) ->where('tsp.isDel', 0) ->whereIn('tsp.companyId', [$companyId, 0]) @@ -1042,8 +1042,8 @@ class WorkbenchController extends Controller } if (!empty($workbench->config->poolGroups)) { - $poolGroupsOptions = Db::name('traffic_source_package')->alias('tsp') - ->join('traffic_source_package_item tspi', 'tspi.packageId=tsp.id', 'left') + $poolGroupsOptions = Db::name('traffic_source_package_v1')->alias('tsp') + ->join('traffic_source_package_item_v1 tspi', 'tspi.packageId=tsp.id', 'left') ->whereIn('tsp.companyId', [$this->request->userInfo['companyId'], 0]) ->whereIn('tsp.id', $workbench->config->poolGroups) ->field('tsp.id,tsp.name,tsp.description,tsp.createTime,count(tspi.id) as num') @@ -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 tp', 'tp.id = wici.poolId', 'left') - ->join('traffic_source tc', 'tc.identifier = tp.identifier', '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/application/cunkebao/controller/workbench/WorkbenchHelperController.php b/application/cunkebao/controller/workbench/WorkbenchHelperController.php index 7cf8b8e..9115386 100644 --- a/application/cunkebao/controller/workbench/WorkbenchHelperController.php +++ b/application/cunkebao/controller/workbench/WorkbenchHelperController.php @@ -142,7 +142,7 @@ class WorkbenchHelperController extends Controller $keyword = $this->request->param('keyword', ''); $companyId = $this->request->userInfo['companyId']; - $baseQuery = Db::name('traffic_source_package')->alias('tsp') + $baseQuery = Db::name('traffic_source_package_v1')->alias('tsp') ->where('tsp.isDel', 0) ->whereIn('tsp.companyId', [$companyId, 0]); @@ -153,7 +153,7 @@ class WorkbenchHelperController extends Controller $total = (clone $baseQuery)->count(); $list = $baseQuery - ->leftJoin('traffic_source_package_item tspi', 'tspi.packageId = tsp.id and tspi.isDel = 0') + ->leftJoin('traffic_source_package_item_v1 tspi', 'tspi.packageId = tsp.id and tspi.isDel = 0') ->field('tsp.id,tsp.name,tsp.description,tsp.pic,tsp.companyId,COUNT(tspi.id) as itemCount,max(tspi.createTime) as latestImportTime') ->group('tsp.id') ->order('tsp.id', 'desc') diff --git a/application/cunkebao/controller/workbench/WorkbenchImportContactController.php b/application/cunkebao/controller/workbench/WorkbenchImportContactController.php index 1956d38..9a9fe37 100644 --- a/application/cunkebao/controller/workbench/WorkbenchImportContactController.php +++ b/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 tp', 'tp.id = wici.poolId', 'left') - ->join('traffic_source tc', 'tc.identifier = tp.identifier', '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/application/cunkebao/service/TrafficPoolGroupService.php b/application/cunkebao/service/TrafficPoolGroupService.php new file mode 100644 index 0000000..0ec8468 --- /dev/null +++ b/application/cunkebao/service/TrafficPoolGroupService.php @@ -0,0 +1,830 @@ +toArray(); + + if ($withCount) { + foreach ($groups as &$group) { + if ($group['ruleType'] == TrafficPoolGroup::RULE_TYPE_DYNAMIC) { + // 动态规则分组,实时计算成员数量 + $group['memberCount'] = $this->countGroupMembers($group['id'], $companyId); + } + // 手动分组使用缓存的 memberCount + + // 计算分组的 RFM 平均值 + $rfmStats = $this->getGroupRfmStats($group['id'], $companyId); + $group['avgRfmR'] = $rfmStats['avgR']; + $group['avgRfmF'] = $rfmStats['avgF']; + $group['avgRfmM'] = $rfmStats['avgM']; + $group['avgRfmScore'] = $rfmStats['avgScore']; + } + } + + return $groups; + } + + /** + * 获取分组详情 + * + * @param int $groupId 分组ID + * @param int $companyId 公司ID + * @return array|null + */ + public function getGroupDetail(int $groupId, int $companyId) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + + if (!$group) { + return null; + } + + $data = $group->toArray(); + + // 计算成员数量 + $data['memberCount'] = $this->countGroupMembers($groupId, $companyId); + + // 计算 RFM 统计 + $rfmStats = $this->getGroupRfmStats($groupId, $companyId); + $data['rfmStats'] = $rfmStats; + + return $data; + } + + /** + * 创建分组 + * + * @param int $companyId 公司ID + * @param array $data 分组数据 + * @param int $userId 创建用户ID + * @return TrafficPoolGroup + */ + public function createGroup(int $companyId, array $data, int $userId = null) + { + // 生成分组编码 + $groupCode = $data['groupCode'] ?? 'custom_' . uniqid(); + + // 检查编码是否已存在 + $existGroup = TrafficPoolGroup::where('companyId', $companyId) + ->where('groupCode', $groupCode) + ->where('isDel', 0) + ->find(); + + if ($existGroup) { + throw new \Exception('分组编码已存在'); + } + + $group = TrafficPoolGroup::create([ + 'companyId' => $companyId, + 'groupCode' => $groupCode, + 'groupName' => $data['groupName'], + 'groupIcon' => $data['groupIcon'] ?? null, + 'groupColor' => $data['groupColor'] ?? null, + 'description' => $data['description'] ?? null, + 'isSystem' => 0, + 'isDefault' => $data['isDefault'] ?? 0, + 'ruleType' => $data['ruleType'] ?? TrafficPoolGroup::RULE_TYPE_DYNAMIC, + 'ruleConfig' => $data['ruleConfig'] ?? null, + 'sort' => $data['sort'] ?? 100, + 'status' => TrafficPoolGroup::STATUS_ENABLED, + 'userId' => $userId, + 'createTime' => time() + ]); + + return $group; + } + + /** + * 更新分组 + * + * @param int $groupId 分组ID + * @param int $companyId 公司ID + * @param array $data 更新数据 + * @return bool + */ + public function updateGroup(int $groupId, int $companyId, array $data) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$group) { + throw new \Exception('分组不存在'); + } + + if ($group->isSystem) { + throw new \Exception('系统分组不允许修改'); + } + + $allowFields = [ + 'groupName', 'groupIcon', 'groupColor', 'description', + 'isDefault', 'ruleType', 'ruleConfig', 'sort', 'status' + ]; + + $updateData = array_intersect_key($data, array_flip($allowFields)); + $updateData['updateTime'] = time(); + + return $group->save($updateData); + } + + /** + * 删除分组 + * + * @param int $groupId 分组ID + * @param int $companyId 公司ID + * @return bool + */ + public function deleteGroup(int $groupId, int $companyId) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$group) { + throw new \Exception('分组不存在'); + } + + if ($group->isSystem) { + throw new \Exception('系统分组不允许删除'); + } + + Db::startTrans(); + try { + // 删除分组 + $group->save([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + + // 删除分组成员 + TrafficPoolGroupMember::where('groupId', $groupId) + ->where('isDel', 0) + ->update([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + + Db::commit(); + return true; + } catch (\Exception $e) { + Db::rollback(); + throw $e; + } + } + + /** + * 获取分组成员列表 + * + * @param int $groupId 分组ID + * @param int $companyId 公司ID + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @param array $filters 筛选条件 + * @return array + */ + public function getGroupMembers(int $groupId, int $companyId, int $page = 1, int $pageSize = 10, array $filters = []) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + + if (!$group) { + throw new \Exception('分组不存在'); + } + + if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) { + // 手动分组,从成员表查询 + return $this->getManualGroupMembers($groupId, $companyId, $page, $pageSize, $filters); + } else { + // 动态规则分组,根据规则查询 + return $this->getDynamicGroupMembers($group, $companyId, $page, $pageSize, $filters); + } + } + + /** + * 获取手动分组成员 + */ + protected function getManualGroupMembers(int $groupId, int $companyId, int $page, int $pageSize, array $filters) + { + $query = TrafficPoolGroupMember::alias('tpgm') + ->join('ck_traffic_pool_company tpc', 'tpc.id = tpgm.poolCompanyId', 'LEFT') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') + ->where('tpgm.groupId', $groupId) + ->where('tpgm.companyId', $companyId) + ->where('tpgm.isDel', 0) + ->where('tpc.isDel', 0); + + // 应用关键字筛选 + if (!empty($filters['keyword'])) { + $keyword = $filters['keyword']; + $query->where(function($q) use ($keyword) { + $q->where('tp.nickname', 'like', "%{$keyword}%") + ->whereOr('tp.wechatId', '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', + 'tpgm.createTime as addTime' + ]) + ->order('tpgm.createTime DESC') + ->page($page, $pageSize) + ->select(); + + return $this->formatMemberList($list, $total, $page, $pageSize); + } + + /** + * 获取动态规则分组成员 + */ + protected function getDynamicGroupMembers(TrafficPoolGroup $group, int $companyId, int $page, int $pageSize, array $filters) + { + $query = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') + ->where('tpc.companyId', $companyId) + ->where('tpc.isDel', 0); + + // 解析并应用规则 + $ruleConfig = $group->ruleConfig; + if (!empty($ruleConfig)) { + $this->applyRuleConditions($query, $ruleConfig, $companyId); + } + + // 应用额外筛选 + if (!empty($filters['keyword'])) { + $keyword = $filters['keyword']; + $query->where(function($q) use ($keyword) { + $q->where('tp.nickname', 'like', "%{$keyword}%") + ->whereOr('tp.wechatId', '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', + 'tpc.createTime as addTime', + '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); + } + + /** + * 应用规则条件到查询 + */ + protected function applyRuleConditions($query, array $ruleConfig, int $companyId) + { + if (empty($ruleConfig['conditions'])) { + return; + } + + $logic = strtoupper($ruleConfig['logic'] ?? 'AND'); + $conditions = $ruleConfig['conditions']; + + if ($logic === 'AND') { + foreach ($conditions as $condition) { + $this->applyCondition($query, $condition, $companyId, 'AND'); + } + } else { + $query->where(function($q) use ($conditions, $companyId) { + foreach ($conditions as $condition) { + $this->applyCondition($q, $condition, $companyId, 'OR'); + } + }); + } + } + + /** + * 应用单个条件 + */ + protected function applyCondition($query, array $condition, int $companyId, string $logic = 'AND') + { + $method = $logic === 'OR' ? 'whereOr' : 'where'; + + if ($condition['type'] === 'group') { + // 嵌套分组 + $subLogic = strtoupper($condition['logic'] ?? 'AND'); + $subConditions = $condition['conditions'] ?? []; + + $query->$method(function($q) use ($subConditions, $companyId, $subLogic) { + foreach ($subConditions as $subCond) { + $subMethod = $subLogic === 'OR' ? 'whereOr' : 'where'; + $this->applyCondition($q, $subCond, $companyId, $subLogic); + } + }); + } elseif ($condition['type'] === '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 '!=': + case '>': + case '<': + case '>=': + case '<=': + $query->$method($field, $operator, $value); + break; + case 'in': + $query->$method($field, 'in', $value); + break; + case 'not_in': + $query->$method($field, 'not in', $value); + break; + case 'between': + $query->$method($field, 'between', $value); + break; + case 'like': + $query->$method($field, 'like', "%{$value}%"); + break; + } + } elseif ($condition['type'] === 'tag') { + // 标签条件 + $tagNames = $condition['value']; + $operator = $condition['operator']; + + if ($operator === 'contains') { + $query->$method(function($q) use ($tagNames, $companyId) { + $q->whereExists(function($subQuery) use ($tagNames, $companyId) { + $subQuery->table('ck_traffic_pool_tag') + ->where('ck_traffic_pool_tag.poolCompanyId = tpc.id') + ->where('ck_traffic_pool_tag.companyId', $companyId) + ->where('ck_traffic_pool_tag.tagName', 'in', $tagNames) + ->where('ck_traffic_pool_tag.isDel', 0); + }); + }); + } elseif ($operator === 'not_contains') { + $query->$method(function($q) use ($tagNames, $companyId) { + $q->whereNotExists(function($subQuery) use ($tagNames, $companyId) { + $subQuery->table('ck_traffic_pool_tag') + ->where('ck_traffic_pool_tag.poolCompanyId = tpc.id') + ->where('ck_traffic_pool_tag.companyId', $companyId) + ->where('ck_traffic_pool_tag.tagName', 'in', $tagNames) + ->where('ck_traffic_pool_tag.isDel', 0); + }); + }); + } + } + } + + /** + * 格式化成员列表 + */ + 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; + } + + return [ + 'list' => $result, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } + + /** + * 计算分组成员数量 + */ + public function countGroupMembers(int $groupId, int $companyId) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + + if (!$group) { + return 0; + } + + if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) { + return TrafficPoolGroupMember::where('groupId', $groupId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->count(); + } + + // 动态规则分组 + $query = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') + ->where('tpc.companyId', $companyId) + ->where('tpc.isDel', 0); + + $ruleConfig = $group->ruleConfig; + if (!empty($ruleConfig)) { + $this->applyRuleConditions($query, $ruleConfig, $companyId); + } + + return $query->count(); + } + + /** + * 获取分组 RFM 统计 + */ + protected function getGroupRfmStats(int $groupId, int $companyId) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + + if (!$group) { + return ['avgR' => 0, 'avgF' => 0, 'avgM' => 0, 'avgScore' => 0]; + } + + if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) { + $query = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool_group_member tpgm', 'tpgm.poolCompanyId = tpc.id', 'INNER') + ->where('tpgm.groupId', $groupId) + ->where('tpgm.isDel', 0) + ->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); + + $ruleConfig = $group->ruleConfig; + if (!empty($ruleConfig)) { + $this->applyRuleConditions($query, $ruleConfig, $companyId); + } + } + + $stats = $query->field([ + 'AVG(DATEDIFF(NOW(), FROM_UNIXTIME(IFNULL(tpc.lastInteractTime, tpc.createTime)))) as avgR', + 'AVG(tpc.rfmF) as avgF', + 'AVG(tpc.rfmM) as avgM' + ])->find(); + + $avgR = round($stats['avgR'] ?? 0, 1); + $avgF = round($stats['avgF'] ?? 0, 1); + $avgM = round($stats['avgM'] ?? 0, 2); + + $rfmScore = $this->calculateRfmScore($avgR, $avgF, $avgM); + + return [ + 'avgR' => $avgR, + 'avgF' => $avgF, + 'avgM' => $avgM, + 'avgScore' => $rfmScore['total'] + ]; + } + + /** + * 添加成员到分组 + * + * @param int $groupId 分组ID + * @param array $poolCompanyIds 成员ID数组 + * @param int $companyId 公司ID + * @param int $operatorId 操作人ID + * @return int 成功添加数量 + */ + public function addMembers(int $groupId, array $poolCompanyIds, int $companyId, int $operatorId = null) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$group) { + throw new \Exception('分组不存在'); + } + + if ($group->ruleType != TrafficPoolGroup::RULE_TYPE_MANUAL) { + throw new \Exception('动态规则分组不支持手动添加成员'); + } + + return TrafficPoolGroupMember::batchAddMembers($groupId, $poolCompanyIds, $companyId, $operatorId); + } + + /** + * 从分组移除成员 + * + * @param int $groupId 分组ID + * @param array $poolCompanyIds 成员ID数组 + * @param int $companyId 公司ID + * @return int 成功移除数量 + */ + public function removeMembers(int $groupId, array $poolCompanyIds, int $companyId) + { + $group = TrafficPoolGroup::where('id', $groupId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$group) { + throw new \Exception('分组不存在'); + } + + if ($group->ruleType != TrafficPoolGroup::RULE_TYPE_MANUAL) { + throw new \Exception('动态规则分组不支持手动移除成员'); + } + + return TrafficPoolGroupMember::batchRemoveMembers($groupId, $poolCompanyIds); + } + + /** + * 计算 RFM 评分 + */ + protected function calculateRfmScore($r, $f, $m) + { + // R 评分 + if ($r <= 7) { + $rScore = 5; + } elseif ($r <= 30) { + $rScore = 4; + } elseif ($r <= 90) { + $rScore = 3; + } elseif ($r <= 180) { + $rScore = 2; + } else { + $rScore = 1; + } + + // F 评分 + if ($f >= 100) { + $fScore = 5; + } elseif ($f >= 50) { + $fScore = 4; + } elseif ($f >= 20) { + $fScore = 3; + } elseif ($f >= 5) { + $fScore = 2; + } else { + $fScore = 1; + } + + // M 评分 + if ($m >= 10000) { + $mScore = 5; + } elseif ($m >= 5000) { + $mScore = 4; + } elseif ($m >= 1000) { + $mScore = 3; + } elseif ($m >= 100) { + $mScore = 2; + } else { + $mScore = 1; + } + + return [ + 'R' => $rScore, + 'F' => $fScore, + 'M' => $mScore, + '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/application/cunkebao/service/TrafficPoolService.php b/application/cunkebao/service/TrafficPoolService.php new file mode 100644 index 0000000..91f4488 --- /dev/null +++ b/application/cunkebao/service/TrafficPoolService.php @@ -0,0 +1,762 @@ +id, + $companyData + ); + + // 3. 创建来源记录 + TrafficPoolSource::createSource( + $poolCompany->id, + $identifier, + $companyId, + $sourceType, + $sourceData + ); + + Db::commit(); + + return [ + 'poolId' => $pool->id, + 'poolCompanyId' => $poolCompany->id + ]; + } catch (\Exception $e) { + Db::rollback(); + throw $e; + } + } + + /** + * 好友通过时同步流量信息 + * + * @param string $identifier 微信ID + * @param int $companyId 公司ID + * @param int $wechatFriendId 微信好友表ID + * @param array $friendData 好友数据 + * @return TrafficPoolCompany|null + */ + public function syncFriendPass(string $identifier, int $companyId, int $wechatFriendId, array $friendData = []) + { + // 查找流量池记录 + $poolCompany = TrafficPoolCompany::where('identifier', $identifier) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + // 如果不存在,先入池 + $result = $this->enterPool( + $identifier, + $companyId, + TrafficPoolSource::SOURCE_TYPE_FRIEND_ADD, + $friendData, + array_merge($friendData, [ + 'wechatFriendId' => $wechatFriendId, + 'friendStatus' => TrafficPoolCompany::FRIEND_STATUS_PASSED, + 'friendPassTime' => time() + ]) + ); + $poolCompany = TrafficPoolCompany::find($result['poolCompanyId']); + } else { + // 更新现有记录 + $poolCompany->save([ + 'wechatFriendId' => $wechatFriendId, + 'friendStatus' => TrafficPoolCompany::FRIEND_STATUS_PASSED, + 'friendPassTime' => time(), + 'updateTime' => time() + ]); + } + + // 更新总表基础信息 + if (!empty($friendData)) { + $pool = TrafficPoolV2::find($poolCompany->poolId); + if ($pool) { + $pool->updateBasicInfo($friendData); + } + } + + return $poolCompany; + } + + /** + * 同步微信标签 + * + * @param int $poolCompanyId 公司流量ID + * @param array $labels 标签数组 + * @return int 同步数量 + */ + public function syncWechatTags(int $poolCompanyId, array $labels) + { + $poolCompany = TrafficPoolCompany::find($poolCompanyId); + if (!$poolCompany) { + return 0; + } + + return TrafficPoolTag::syncWechatTags( + $poolCompanyId, + $poolCompany->identifier, + $poolCompany->companyId, + $labels + ); + } + + /** + * 获取流量池列表(分页) + * + * @param int $companyId 公司ID + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @param array $filters 筛选条件 + * @return array + */ + public function getPoolList(int $companyId, int $page = 1, int $pageSize = 10, array $filters = []) + { + $query = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') + ->where('tpc.companyId', $companyId) + ->where('tpc.isDel', 0); + + // 应用筛选条件 + if (!empty($filters['keyword'])) { + $keyword = $filters['keyword']; + $query->where(function($q) use ($keyword) { + $q->where('tp.nickname', 'like', "%{$keyword}%") + ->whereOr('tp.wechatId', 'like', "%{$keyword}%") + ->whereOr('tp.wechatAlias', 'like', "%{$keyword}%") + ->whereOr('tp.mobile', 'like', "%{$keyword}%") + ->whereOr('tpc.realName', 'like', "%{$keyword}%") + ->whereOr('tpc.phone', 'like', "%{$keyword}%"); + }); + } + + if (isset($filters['friendStatus'])) { + $query->where('tpc.friendStatus', $filters['friendStatus']); + } + + if (isset($filters['level'])) { + $query->where('tpc.level', $filters['level']); + } + + if (isset($filters['lifecycle'])) { + $query->where('tpc.lifecycle', $filters['lifecycle']); + } + + if (isset($filters['allocateStatus'])) { + $query->where('tpc.allocateStatus', $filters['allocateStatus']); + } + + if (!empty($filters['ownerWechatId'])) { + $query->where('tpc.ownerWechatId', $filters['ownerWechatId']); + } + + // RFM 筛选 + if (isset($filters['rfmMMin'])) { + $query->where('tpc.rfmM', '>=', $filters['rfmMMin']); + } + if (isset($filters['rfmMMax'])) { + $query->where('tpc.rfmM', '<=', $filters['rfmMMax']); + } + + // 统计总数 + $total = $query->count(); + + // 查询列表 + $list = $query->field([ + 'tpc.id', + 'tpc.poolId', + 'tpc.identifier', + 'tpc.companyId', + 'tpc.friendStatus', + 'tpc.level', + 'tpc.intentionLevel', + 'tpc.lifecycle', + 'tpc.lastInteractTime', + 'tpc.rfmF', + 'tpc.rfmM', + 'tpc.totalMsgCount', + 'tpc.totalOrderCount', + 'tpc.totalOrderAmount', + 'tpc.ownerWechatId', + 'tpc.allocateStatus', + 'tpc.realName', + 'tpc.phone', + 'tpc.createTime', + 'tp.nickname', + 'tp.avatar', + 'tp.gender', + 'tp.wechatId', + 'tp.wechatAlias', + 'tp.mobile', + 'tp.region' + ]) + ->order('tpc.id DESC') + ->page($page, $pageSize) + ->select(); + + // 获取标签 + $poolCompanyIds = array_column($list->toArray(), 'id'); + $tags = $this->getTagsForPoolCompanies($poolCompanyIds); + + // 组装数据 + $result = []; + 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'], $data['rfmM']); + // 添加标签 + $data['tags'] = $tags[$item->id] ?? []; + $result[] = $data; + } + + return [ + 'list' => $result, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } + + /** + * 获取流量详情 + * + * @param int $poolCompanyId 公司流量ID + * @param int $companyId 公司ID + * @return array|null + */ + public function getPoolDetail(int $poolCompanyId, int $companyId) + { + $poolCompany = TrafficPoolCompany::alias('tpc') + ->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT') + ->where('tpc.id', $poolCompanyId) + ->where('tpc.companyId', $companyId) + ->where('tpc.isDel', 0) + ->field('tpc.*, tp.nickname, tp.avatar, tp.gender, tp.wechatId, tp.wechatAlias, tp.mobile, tp.region, tp.country, tp.province, tp.city, tp.signature') + ->find(); + + if (!$poolCompany) { + return null; + } + + $data = $poolCompany->toArray(); + + // 获取标签 + $data['tags'] = TrafficPoolTag::getTagsByPoolCompany($poolCompanyId)->toArray(); + + // 获取来源历史(带群归属信息,限制50条) + $data['sources'] = TrafficPoolSource::getSourcesWithOwners($poolCompanyId, 50); + + // 获取行为轨迹(最近50条) + $data['behaviors'] = TrafficPoolBehavior::getUserJourney($poolCompanyId, 50)->toArray(); + + // 获取分配历史 + $data['allotRecords'] = TrafficPoolAllotRecord::getAllotHistory($poolCompanyId)->toArray(); + + // 如果消息数为0,从微信消息表中统计实际消息数 + if (empty($data['totalMsgCount']) || $data['totalMsgCount'] == 0) { + $msgCount = 0; + + // 优先通过wechatFriendId统计(最准确) + if (!empty($poolCompany->wechatFriendId)) { + $msgCount = Db::table('s2_wechat_message') + ->where('wechatFriendId', $poolCompany->wechatFriendId) + ->where('type', 1) // 好友消息(type=1) + ->where('isDeleted', 0) + ->count(); + } + + // 如果wechatFriendId没有统计到,尝试通过identifier(微信ID)统计 + if ($msgCount == 0 && !empty($poolCompany->identifier)) { + // 统计发送者或接收者是该微信ID的消息 + // 需要关联s2_wechat_friend表,通过wechatId匹配 + $msgCount = Db::table('s2_wechat_message') + ->alias('wm') + ->join(['s2_wechat_friend' => 'wf'], 'wm.wechatFriendId = wf.id', 'LEFT') + ->where(function($query) use ($poolCompany) { + $query->where('wm.senderWechatId', $poolCompany->identifier) + ->whereOr('wf.wechatId', $poolCompany->identifier); + }) + ->where('wm.type', 1) // 好友消息 + ->where('wm.isDeleted', 0) + ->where('wf.isDeleted', 0) + ->count(); + } + + // 如果从行为表也有记录,取较大值(兼容旧数据) + $behaviorMsgCount = TrafficPoolBehavior::where('poolCompanyId', $poolCompanyId) + ->whereIn('behaviorType', [ + TrafficPoolBehavior::BEHAVIOR_TYPE_SEND_MSG, + TrafficPoolBehavior::BEHAVIOR_TYPE_RECEIVE_MSG + ]) + ->count(); + + if ($behaviorMsgCount > $msgCount) { + $msgCount = $behaviorMsgCount; + } + + if ($msgCount > 0) { + // 更新数据库中的消息数 + $poolCompany->save([ + 'totalMsgCount' => $msgCount, + 'updateTime' => time() + ]); + $data['totalMsgCount'] = $msgCount; + } + } + + // 计算 RFM + $data['rfmR'] = $poolCompany->lastInteractTime ? (int)floor((time() - $poolCompany->lastInteractTime) / 86400) : 9999; + $data['rfmScore'] = $this->calculateRfmScore($data['rfmR'], $data['rfmF'], $data['rfmM']); + + return $data; + } + + /** + * 更新流量信息 + * + * @param int $poolCompanyId 公司流量ID + * @param int $companyId 公司ID + * @param array $data 更新数据 + * @return bool + */ + public function updatePool(int $poolCompanyId, int $companyId, array $data) + { + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return false; + } + + // 允许更新的字段 + $allowFields = [ + 'realName', 'phone', 'email', 'birthday', 'address', + 'company', 'position', 'remark', 'customFields', + 'level', 'intentionLevel', 'lifecycle', 'status' + ]; + + $updateData = array_intersect_key($data, array_flip($allowFields)); + $updateData['updateTime'] = time(); + + return $poolCompany->save($updateData); + } + + /** + * 批量获取流量的标签 + * + * @param array $poolCompanyIds + * @return array + */ + protected function getTagsForPoolCompanies(array $poolCompanyIds) + { + if (empty($poolCompanyIds)) { + return []; + } + + $tags = TrafficPoolTag::whereIn('poolCompanyId', $poolCompanyIds) + ->where('isDel', 0) + ->select(); + + $result = []; + foreach ($tags as $tag) { + if (!isset($result[$tag->poolCompanyId])) { + $result[$tag->poolCompanyId] = []; + } + $result[$tag->poolCompanyId][] = [ + 'id' => $tag->id, + 'tagDefineId' => $tag->tagDefineId, + 'tagName' => $tag->tagName, + 'tagType' => $tag->tagType, + 'tagValue' => $tag->tagValue + ]; + } + + return $result; + } + + /** + * 计算 RFM 评分 + * + * @param int $r 最后互动距今天数 + * @param int $f 互动频次 + * @param float $m 消费金额 + * @return array + */ + public function calculateRfmScore($r, $f, $m) + { + // R 评分(天数越少分数越高) + if ($r <= 7) { + $rScore = 5; + } elseif ($r <= 30) { + $rScore = 4; + } elseif ($r <= 90) { + $rScore = 3; + } elseif ($r <= 180) { + $rScore = 2; + } else { + $rScore = 1; + } + + // F 评分 + if ($f >= 100) { + $fScore = 5; + } elseif ($f >= 50) { + $fScore = 4; + } elseif ($f >= 20) { + $fScore = 3; + } elseif ($f >= 5) { + $fScore = 2; + } else { + $fScore = 1; + } + + // M 评分 + if ($m >= 10000) { + $mScore = 5; + } elseif ($m >= 5000) { + $mScore = 4; + } elseif ($m >= 1000) { + $mScore = 3; + } elseif ($m >= 100) { + $mScore = 2; + } else { + $mScore = 1; + } + + return [ + 'R' => $rScore, + 'F' => $fScore, + 'M' => $mScore, + 'total' => $rScore + $fScore + $mScore + ]; + } + + /** + * 获取流量统计数据 + * + * @param int $companyId 公司ID + * @return array + */ + public function getStatistics(int $companyId) + { + $today = strtotime('today'); + $yesterday = strtotime('yesterday'); + $thisWeek = strtotime('monday this week'); + $thisMonth = strtotime('first day of this month'); + + // 总流量数 + $totalCount = TrafficPoolCompany::where('companyId', $companyId) + ->where('isDel', 0) + ->count(); + + // 好友数 + $friendCount = TrafficPoolCompany::where('companyId', $companyId) + ->where('friendStatus', TrafficPoolCompany::FRIEND_STATUS_PASSED) + ->where('isDel', 0) + ->count(); + + // 今日新增 + $todayNewCount = TrafficPoolCompany::where('companyId', $companyId) + ->where('createTime', '>=', $today) + ->where('isDel', 0) + ->count(); + + // 本周新增 + $weekNewCount = TrafficPoolCompany::where('companyId', $companyId) + ->where('createTime', '>=', $thisWeek) + ->where('isDel', 0) + ->count(); + + // 本月新增 + $monthNewCount = TrafficPoolCompany::where('companyId', $companyId) + ->where('createTime', '>=', $thisMonth) + ->where('isDel', 0) + ->count(); + + // 客户等级分布 + $levelDistribution = TrafficPoolCompany::where('companyId', $companyId) + ->where('isDel', 0) + ->group('level') + ->field('level, COUNT(*) as count') + ->select() + ->toArray(); + + // 生命周期分布 + $lifecycleDistribution = TrafficPoolCompany::where('companyId', $companyId) + ->where('isDel', 0) + ->group('lifecycle') + ->field('lifecycle, COUNT(*) as count') + ->select() + ->toArray(); + + // 来源分布 + $sourceDistribution = TrafficPoolSource::where('companyId', $companyId) + ->where('isFirstSource', 1) + ->group('sourceType') + ->field('sourceType, COUNT(*) as count') + ->select() + ->toArray(); + + return [ + 'totalCount' => $totalCount, + 'friendCount' => $friendCount, + 'todayNewCount' => $todayNewCount, + 'weekNewCount' => $weekNewCount, + 'monthNewCount' => $monthNewCount, + 'levelDistribution' => $levelDistribution, + 'lifecycleDistribution' => $lifecycleDistribution, + 'sourceDistribution' => $sourceDistribution + ]; + } + + /** + * 从标签引擎同步用户标签 + * + * @param int $poolCompanyId 流量池公司ID + * @param int $companyId 公司ID + * @param int $operatorId 操作人ID + * @return array 同步结果 + */ + public function syncTagsFromEngine(int $poolCompanyId, int $companyId, int $operatorId = null) + { + // 获取流量池记录 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + throw new \Exception('流量不存在'); + } + + // 获取标识信息用于查询标签引擎 + $identifiers = []; + + // 微信ID + if (!empty($poolCompany->identifier)) { + $identifiers[] = [ + 'type' => 'wechat', + 'value' => $poolCompany->identifier + ]; + } + + // 手机号 + if (!empty($poolCompany->phone)) { + $identifiers[] = [ + 'type' => 'phone', + 'value' => $poolCompany->phone + ]; + } + + if (empty($identifiers)) { + throw new \Exception('无有效标识可用于查询标签'); + } + + + // 调用标签引擎服务 + $tagEngineService = new \app\common\service\TagEngineService(); + $result = $tagEngineService->queryByIdentifiers($identifiers, [ + 'mask_identifier' => false + ]); + //exit_data($result); + if ($result === false) { + throw new \Exception('标签引擎查询失败'); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '标签引擎返回错误'); + } + + $data = $result['data'] ?? $result; + if (!is_array($data)) { + $data = []; + } + + $syncedCount = 0; + $skippedCount = 0; + + // 处理返回的标签数据 + foreach ($data as $item) { + if (empty($item['found']) || empty($item['tags'])) { + continue; + } + + foreach ($item['tags'] as $tagData) { + try { + // 查找或创建标签定义 + $tagDefine = $this->findOrCreateTagDefine( + $companyId, + $tagData['tag_code'] ?? '', + $tagData['tag_name'] ?? '', + $tagData['category'] ?? '标签引擎', + $tagData['tag_type'] ?? 'string' + ); + + if (!$tagDefine) { + $skippedCount++; + continue; + } + + // 添加标签到流量池 + $tag = TrafficPoolTag::addTag( + $poolCompanyId, + $poolCompany->identifier, + $companyId, + $tagDefine->id, + TrafficPoolTag::SOURCE_AI, // 来源为AI/外部同步 + $operatorId, + $tagData['tag_value'] ?? null, + null // score + ); + + if ($tag) { + $syncedCount++; + } else { + $skippedCount++; + } + } catch (\Exception $e) { + $skippedCount++; + continue; + } + } + } + + return [ + 'syncedCount' => $syncedCount, + 'skippedCount' => $skippedCount, + 'total' => $syncedCount + $skippedCount + ]; + } + + /** + * 查找或创建标签定义 + * + * @param int $companyId 公司ID + * @param string $tagCode 标签代码 + * @param string $tagName 标签名称 + * @param string $categoryName 分类名称 + * @param string $valueType 值类型 + * @return \app\common\model\TrafficPoolTagDefine|null + */ + protected function findOrCreateTagDefine( + int $companyId, + string $tagCode, + string $tagName, + string $categoryName, + string $valueType + ) { + if (empty($tagName)) { + return null; + } + + // 标签类型映射 + $typeMap = [ + 'numeric' => 'number', + 'enum' => 'enum', + 'string' => 'string', + 'boolean' => 'boolean', + 'datetime' => 'datetime', + 'json' => 'json', + ]; + $mappedType = $typeMap[$valueType] ?? 'string'; + + // 先查找是否已存在该标签定义 + $tagDefine = \app\common\model\TrafficPoolTagDefine::where('companyId', $companyId) + ->where('tagName', $tagName) + ->where('tagType', TrafficPoolTag::TAG_TYPE_AI) // AI标签类型 + ->where('isDel', 0) + ->find(); + + if ($tagDefine) { + return $tagDefine; + } + + // 查找或创建分类 + $category = \app\common\model\TrafficPoolTagCategory::where('companyId', $companyId) + ->where('categoryName', $categoryName) + ->where('tagType', TrafficPoolTag::TAG_TYPE_AI) + ->where('isDel', 0) + ->find(); + + if (!$category) { + $category = new \app\common\model\TrafficPoolTagCategory(); + $category->save([ + 'companyId' => $companyId, + 'tagType' => TrafficPoolTag::TAG_TYPE_AI, + 'categoryName' => $categoryName, + 'description' => '从标签引擎同步的标签分类', + 'sortOrder' => 0, + 'isDel' => 0, + 'createTime' => time(), + 'updateTime' => time() + ]); + } + + // 创建标签定义 + $tagDefine = new \app\common\model\TrafficPoolTagDefine(); + $tagDefine->save([ + 'companyId' => $companyId, + 'categoryId' => $category->id, + 'tagType' => TrafficPoolTag::TAG_TYPE_AI, + 'tagCode' => $tagCode ?: 'engine_' . md5($tagName), + 'tagName' => $tagName, + 'valueType' => $mappedType, + 'description' => '从标签引擎同步', + 'isSystem' => 0, + 'isDel' => 0, + 'createTime' => time(), + 'updateTime' => time() + ]); + + return $tagDefine; + } +} + + diff --git a/application/job/MessageChatroomListJob.php b/application/job/MessageChatroomListJob.php index d66112d..1f4e918 100644 --- a/application/job/MessageChatroomListJob.php +++ b/application/job/MessageChatroomListJob.php @@ -11,9 +11,9 @@ use app\api\controller\MessageController; class MessageChatroomListJob { /** - * 最大同步页数 + * 最大同步页数(0表示不限制,同步所有页面) */ - const MAX_SYNC_PAGES = 5; + const MAX_SYNC_PAGES = 0; /** * 队列任务处理 @@ -80,23 +80,62 @@ class MessageChatroomListJob // 调用添加好友任务获取方法 $result = $messageController->getChatroomList($pageIndex,$pageSize,true); $response = json_decode($result,true); + + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } // 判断是否成功 - if ($response['code'] == 200) { - $data = $response['data']; + if (isset($response['code']) && $response['code'] == 200) { + $data = isset($response['data']) ? $response['data'] : []; - // 判断是否有下一页,且未超过最大同步页数 - if (!empty($data) && count($data) > 0) { - $nextPageIndex = $pageIndex + 1; - // 检查是否超过最大同步页数 - if ($nextPageIndex < self::MAX_SYNC_PAGES) { - // 有下一页且未超过最大页数,将下一页任务添加到队列 - $this->addNextPageToQueue($nextPageIndex, $pageSize); - Log::info('添加下一页任务到队列,页码:' . $nextPageIndex); - } else { - Log::info('已达到最大同步页数(' . self::MAX_SYNC_PAGES . '),停止添加下一页任务'); + // 确保 data 是数组格式 + if (!is_array($data)) { + $data = []; + } + + // 获取 results 数组(实际的数据列表) + $results = isset($data['results']) && is_array($data['results']) ? $data['results'] : []; + $resultsCount = count($results); + + Log::info("获取到 {$resultsCount} 条群聊记录,页码:{$pageIndex},页大小:{$pageSize}"); + + // 判断是否有下一页 + // 1. 如果返回的数据量等于页大小,说明可能还有下一页 + // 2. 或者检查是否有 total 字段,通过计算判断是否有下一页 + $hasNextPage = false; + + if ($resultsCount > 0) { + // 方法1: 如果返回的数据量等于页大小,可能还有下一页 + if ($resultsCount >= $pageSize) { + $hasNextPage = true; + Log::info("返回数据量({$resultsCount})等于或大于页大小({$pageSize}),可能存在下一页"); } + + // 方法2: 如果有 total 字段,通过计算判断 + if (isset($data['total']) && is_numeric($data['total'])) { + $total = intval($data['total']); + $currentPageEnd = ($pageIndex + 1) * $pageSize; + $hasNextPage = ($currentPageEnd < $total); + Log::info("根据 total({$total})计算,当前页结束位置:{$currentPageEnd},是否有下一页:" . ($hasNextPage ? '是' : '否')); + } + } + + // 如果有下一页,且未超过最大同步页数,添加下一页任务 + if ($hasNextPage) { + $nextPageIndex = $pageIndex + 1; + + // 检查是否超过最大同步页数(0表示不限制) + if (self::MAX_SYNC_PAGES == 0 || $nextPageIndex < self::MAX_SYNC_PAGES) { + $this->addNextPageToQueue($nextPageIndex, $pageSize); + Log::info("添加下一页任务到队列,页码:{$nextPageIndex}"); + } else { + Log::info("已达到最大同步页数(" . self::MAX_SYNC_PAGES . "),停止添加下一页任务"); + } + } else { + Log::info("没有更多页面需要同步,页码:{$pageIndex},返回数据量:{$resultsCount}"); } return true; diff --git a/application/job/MessageFriendsListJob.php b/application/job/MessageFriendsListJob.php index 408bfe0..eff56dc 100644 --- a/application/job/MessageFriendsListJob.php +++ b/application/job/MessageFriendsListJob.php @@ -82,14 +82,24 @@ class MessageFriendsListJob // 调用添加好友任务获取方法 $result = $messageController->getFriendsList($pageIndex,$pageSize,true); $response = json_decode($result,true); + + // 确保 response 是数组格式 + if (!is_array($response)) { + $response = []; + } // 判断是否成功 - if ($response['code'] == 200) { - $data = $response['data']; + if (isset($response['code']) && $response['code'] == 200) { + $data = isset($response['data']) ? $response['data'] : []; + + // 确保 data 是数组格式 + if (!is_array($data)) { + $data = []; + } // 判断是否有下一页,且未超过最大同步页数 - if (!empty($data) && count($data) > 0) { + if (!empty($data) && is_array($data) && count($data) > 0) { $nextPageIndex = $pageIndex + 1; // 检查是否超过最大同步页数 if ($nextPageIndex < self::MAX_SYNC_PAGES) { diff --git a/application/job/OwnMomentsCollectJob.php b/application/job/OwnMomentsCollectJob.php index 9b8987c..a597abe 100644 --- a/application/job/OwnMomentsCollectJob.php +++ b/application/job/OwnMomentsCollectJob.php @@ -97,7 +97,9 @@ class OwnMomentsCollectJob // 采集自己的朋友圈(wechatFriendId传0或空,表示采集自己的朋友圈) $result = $webSocket->getMoments([ 'wechatAccountId' => $wechatAccountId, - 'wechatFriendId' => 0, // 0表示采集自己的朋友圈 + 'wechatFriendId' => 0, + 'isTimeline' => true, + 'maxPages' => 1, 'count' => 10 // 每次采集10条 ]); diff --git a/application/job/WorkbenchGroupCreateJob.php b/application/job/WorkbenchGroupCreateJob.php index dd846fe..ca20272 100644 --- a/application/job/WorkbenchGroupCreateJob.php +++ b/application/job/WorkbenchGroupCreateJob.php @@ -177,7 +177,7 @@ class WorkbenchGroupCreateJob // 处理普通流量池 if (!empty($normalPools)) { - $normalIdentifiers = Db::name('traffic_source_package_item') + $normalIdentifiers = Db::name('traffic_source_package_item_v1') ->whereIn('packageId', $normalPools) ->where('isDel', 0) ->group('identifier') diff --git a/application/job/WorkbenchGroupPushJob.php b/application/job/WorkbenchGroupPushJob.php index 771afc1..5594e0b 100644 --- a/application/job/WorkbenchGroupPushJob.php +++ b/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') + // ========== 旧版流量池代码(已废弃) ========== + // $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 tsp', 'tsp.id = tspi.packageId') + ->leftJoin('traffic_source_package_v1 tsp', 'tsp.id = tspi.packageId') ->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/application/job/WorkbenchImportContactJob.php b/application/job/WorkbenchImportContactJob.php index 95400d5..8cefb43 100644 --- a/application/job/WorkbenchImportContactJob.php +++ b/application/job/WorkbenchImportContactJob.php @@ -339,24 +339,40 @@ class WorkbenchImportContactJob // 处理普通流量池 if (!empty($normalPools)) { //过滤已删除的数据 - $packageIds = Db::name('traffic_source_package') + $packageIds = Db::name('traffic_source_package_v1') ->where(['isDel' => 0]) ->whereIn('id', $normalPools) ->column('id'); if (!empty($packageIds)) { - $normalData = Db::name('traffic_source_package_item')->alias('tpi') + // ========== 旧版流量池代码(已废弃) ========== + // $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 tp', 'tp.identifier = tpi.identifier') - ->join('traffic_source ts', 'ts.identifier = tpi.identifier','left') + ->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 表获取手机号 + // $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 tp', 'tp.wechatId = wf.wechatId', 'left') - ->join('traffic_source ts', 'ts.identifier = tp.identifier', '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/application/store/model/TrafficOrderModel.php b/application/store/model/TrafficOrderModel.php index 4d51d66..88221e1 100644 --- a/application/store/model/TrafficOrderModel.php +++ b/application/store/model/TrafficOrderModel.php @@ -6,6 +6,6 @@ use think\Model; class TrafficOrderModel extends Model { - protected $name = 'traffic_order'; + protected $name = 'traffic_order_v1'; } \ No newline at end of file diff --git a/application/superadmin/config/route.php b/application/superadmin/config/route.php index 337cdfc..49f22e8 100644 --- a/application/superadmin/config/route.php +++ b/application/superadmin/config/route.php @@ -3,10 +3,10 @@ use think\facade\Route; // 超级管理员认证相关路由(不需要鉴权) -Route::post('auth/login', 'app\superadmin\controller\auth\AuthLoginController@index'); +Route::post('v1/admin/auth/login', 'app\superadmin\controller\auth\AuthLoginController@index'); -// 需要登录认证的路由组 -Route::group('', function () { +// 需要登录认证的路由组(使用通用 JWT 中间件) +Route::group('v1/admin', function () { // 仪表盘概述 Route::group('dashboard', function () { Route::get('base', 'app\superadmin\controller\dashboard\GetBasestatisticsController@index'); @@ -33,7 +33,7 @@ Route::group('', function () { Route::get('detail', 'app\superadmin\controller\traffic\GetPoolDetailController@index'); }); - // 设备管理吗 + // 设备管理 Route::group('devices', function () { Route::get('add-results', 'app\superadmin\controller\devices\GetAddResultedDevicesController@index'); }); @@ -49,4 +49,4 @@ Route::group('', function () { Route::get('devices', 'app\superadmin\controller\company\GetCompanyDevicesForProfileController@index'); Route::get('subusers', 'app\superadmin\controller\company\GetCompanySubusersForProfileController@index'); }); -})->middleware(['app\superadmin\middleware\AdminAuth']); \ No newline at end of file +})->middleware(['jwt']); \ No newline at end of file diff --git a/application/superadmin/controller/auth/AuthLoginController.php b/application/superadmin/controller/auth/AuthLoginController.php index 0c52e29..bd0fddb 100644 --- a/application/superadmin/controller/auth/AuthLoginController.php +++ b/application/superadmin/controller/auth/AuthLoginController.php @@ -3,24 +3,13 @@ namespace app\superadmin\controller\auth; use app\common\model\Administrator as AdministratorModel; -use app\superadmin\controller\administrator\DeleteAdministratorController; +use app\common\util\JwtUtil; use library\ResponseHelper; use think\Controller; use think\Validate; -use think\facade\Cookie; class AuthLoginController extends Controller { - /** - * 创建登录令牌 - * @param DeleteAdministratorController $admin - * @return string - */ - protected function createToken(AdministratorModel $admin): string - { - return md5($admin->id . '|' . $admin->account . 'cunkebao_admin_secret'); - } - /** * 数据验证 * @@ -87,57 +76,6 @@ class AuthLoginController extends Controller return $this; } - /** - * 设置登录Cookie,有效期24小时 - * - * @param AdministratorModel $admin - * @return void - */ - protected function setCookie(AdministratorModel $admin): void - { - // 获取当前环境 - $env = app()->env->get('APP_ENV', 'production'); - - // 获取请求的域名 - $origin = $this->request->header('origin'); - $domain = ''; - - if ($origin) { - // 解析域名 - $parsedUrl = parse_url($origin); - if (isset($parsedUrl['host'])) { - // 如果是测试环境,使用完整的域名 - if ($env === 'testing') { - $domain = $parsedUrl['host']; - } else { - // 生产环境使用顶级域名 - $parts = explode('.', $parsedUrl['host']); - if (count($parts) > 1) { - $domain = '.' . $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1]; - } - } - } - } - - // 设置cookie选项 - $options = [ - 'expire' => 86400, - 'path' => '/', - 'httponly' => true, - 'samesite' => 'None', // 允许跨域 - 'secure' => true // 仅 HTTPS 下有效 - ]; - - // 如果有域名,添加到选项 - if ($domain) { - $options['domain'] = $domain; - } - - // 设置cookies - Cookie::set('admin_id', $admin->id, $options); - Cookie::set('admin_token', $this->createToken($admin), $options); - } - /** * 管理员登录 * @@ -149,16 +87,28 @@ class AuthLoginController extends Controller $params = $this->request->only(['account', 'password']); $admin = $this->dataValidate($params)->getAdministrator($params); - $this->saveLoginInfo($admin)->setCookie($admin); + $this->saveLoginInfo($admin); - return ResponseHelper::success( - [ - 'id' => $admin->id, - 'name' => $admin->username, - 'account' => $admin->account, - 'token' => Cookie::get('admin_token') - ] - ); + // 生成 JWT 令牌(默认有效期 24 小时) + $expire = 86400; + $payload = [ + 'id' => $admin->id, + 'account' => $admin->account, + 'username' => $admin->username, + 'authId' => $admin->authId ?? 0, + 'role' => 'superadmin', + ]; + + $token = JwtUtil::createToken($payload, $expire); + + return ResponseHelper::success([ + 'id' => $admin->id, + 'name' => $admin->username, + 'account' => $admin->account, + 'authId' => $admin->authId ?? 0, + 'token' => $token, + 'token_expired' => time() + $expire, + ]); } catch (\Exception $e) { return ResponseHelper::error($e->getMessage(), $e->getCode()); } diff --git a/application/superadmin/controller/company/GetCompanySubusersForProfileController.php b/application/superadmin/controller/company/GetCompanySubusersForProfileController.php index d9a02f8..54a8a1c 100644 --- a/application/superadmin/controller/company/GetCompanySubusersForProfileController.php +++ b/application/superadmin/controller/company/GetCompanySubusersForProfileController.php @@ -43,7 +43,22 @@ class GetCompanySubusersForProfileController extends Controller $users = $this->getSubusers(); foreach ($users as &$user) { - $user['createTime'] = date('Y-m-d H:i:s', $user['createTime']); + // 处理 createTime:如果是时间戳则转换,如果是日期字符串则直接使用,如果为空则设为空字符串 + if (empty($user['createTime'])) { + $user['createTime'] = ''; + } elseif (is_numeric($user['createTime']) && $user['createTime'] > 0) { + // 时间戳格式 + $user['createTime'] = date('Y-m-d H:i:s', $user['createTime']); + } elseif (is_string($user['createTime'])) { + // 已经是日期字符串格式,直接使用 + // 如果格式不正确,尝试转换 + $timestamp = strtotime($user['createTime']); + if ($timestamp !== false) { + $user['createTime'] = date('Y-m-d H:i:s', $timestamp); + } + } else { + $user['createTime'] = ''; + } } return ResponseHelper::success($users); diff --git a/application/superadmin/controller/traffic/GetPoolListController.php b/application/superadmin/controller/traffic/GetPoolListController.php index d19ce5d..7b56712 100644 --- a/application/superadmin/controller/traffic/GetPoolListController.php +++ b/application/superadmin/controller/traffic/GetPoolListController.php @@ -84,7 +84,7 @@ class GetPoolListController extends BaseController 'wa.avatar', 'wa.gender', 'wa.nickname', 'wa.region', 'wt.tags' ]) - ->join('traffic_source ts', 'tp.identifier = ts.identifier', 'RIGHT') + ->join('traffic_source_v1 ts', 'tp.identifier = ts.identifier', 'RIGHT') ->join('company c', 'ts.companyId = c.companyId', 'LEFT') ->join('wechat_account wa', 'tp.wechatId = wa.wechatId', 'LEFT') ->join('wechat_tag wt', 'wa.wechatId = wt.wechatId', 'LEFT'); diff --git a/config/task_scheduler.php b/config/task_scheduler.php index e0b6438..e95f93e 100644 --- a/config/task_scheduler.php +++ b/config/task_scheduler.php @@ -8,6 +8,7 @@ return [ // 任务配置格式: // '任务标识' => [ + // 'name' => '任务名称', // 必填:任务的中文名称,用于日志和显示 // 'command' => '命令名称', // 必填:执行的 ThinkPHP 命令(见 application/command.php) // 'schedule' => 'cron表达式', // 必填:cron 表达式,如 '*/5 * * * *' 表示每5分钟 // 'options' => ['--option=value'], // 可选:命令参数(原来 crontab 里的 --xxx=yyy) @@ -23,6 +24,7 @@ return [ // 同步微信好友列表(未删除好友),用于保持系统中好友数据实时更新 'wechat_friends_active' => [ + 'name' => '同步微信好友列表(未删除)', 'command' => 'wechatFriends:list', 'schedule' => '*/1 * * * *', // 每1分钟 'options' => ['--isDel=0'], @@ -31,8 +33,9 @@ return [ 'log_file' => 'crontab_wechatFriends_active.log', ], - // 拉取“添加好友任务”列表,驱动自动加好友的任务队列 + // 拉取"添加好友任务"列表,驱动自动加好友的任务队列 'friend_task' => [ + 'name' => '拉取添加好友任务列表', 'command' => 'friendTask:list', 'schedule' => '*/1 * * * *', // 每1分钟 'options' => [], @@ -43,6 +46,7 @@ return [ // 同步微信好友私聊消息列表,写入消息表,供客服工作台使用 'message_friends' => [ + 'name' => '同步微信好友私聊消息列表', 'command' => 'message:friendsList', 'schedule' => '*/1 * * * *', // 每1分钟 'options' => [], @@ -53,6 +57,7 @@ return [ // 同步微信群聊消息列表,写入消息表,供群聊记录与风控分析 'message_chatroom' => [ + 'name' => '同步微信群聊消息列表', 'command' => 'message:chatroomList', 'schedule' => '*/1 * * * *', // 每1分钟 'options' => [], @@ -63,6 +68,7 @@ return [ // 客服端消息提醒任务,负责给在线客服推送新消息通知 'kf_notice' => [ + 'name' => '客服端消息提醒', 'command' => 'kf:notice', 'schedule' => '*/1 * * * *', // 每1分钟 'options' => [], @@ -77,6 +83,7 @@ return [ // 同步微信设备列表(未删除设备),用于设备管理与监控 'device_active' => [ + 'name' => '同步微信设备列表(未删除)', 'command' => 'device:list', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => ['--isDel=0'], @@ -87,6 +94,7 @@ return [ // 同步微信群聊列表(未删除群),用于群管理与后续任务分配 'wechat_chatroom_active' => [ + 'name' => '同步微信群聊列表(未删除)', 'command' => 'wechatChatroom:list', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => ['--isDel=0'], @@ -97,6 +105,7 @@ return [ // 同步微信群成员列表(群好友),维持群成员明细数据 'group_friends' => [ + 'name' => '同步微信群成员列表', 'command' => 'groupFriends:list', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => [], @@ -105,8 +114,9 @@ return [ 'log_file' => 'crontab_groupFriends.log', ], - // 同步“微信客服列表”,获取绑定到公司的微信号,用于工作台与分配规则 + // 同步"微信客服列表",获取绑定到公司的微信号,用于工作台与分配规则 'wechat_list' => [ + 'name' => '同步微信客服列表', 'command' => 'wechatList:list', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => [], @@ -117,6 +127,7 @@ return [ // 同步公司账号列表(企业/租户账号),供后台管理与统计 'account_list' => [ + 'name' => '同步公司账号列表', 'command' => 'account:list', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => [], @@ -125,18 +136,9 @@ return [ 'log_file' => 'crontab_account.log', ], - // 内容采集任务,将外部或设备内容同步到系统内容库 - 'content_collect' => [ - 'command' => 'content:collect', - 'schedule' => '*/5 * * * *', // 每5分钟 - 'options' => [], - 'enabled' => true, - 'max_concurrent' => 1, - 'log_file' => 'crontab_contentCollect.log', - ], - // 工作台:自动点赞好友/客户朋友圈,提高账号活跃度 'workbench_auto_like' => [ + 'name' => '工作台:自动点赞朋友圈', 'command' => 'workbench:autoLike', 'schedule' => '*/6 * * * *', // 每6分钟 'options' => [], @@ -147,6 +149,7 @@ return [ // 工作台:自动建群任务,按规则批量创建微信群 'workbench_group_create' => [ + 'name' => '工作台:自动建群任务', 'command' => 'workbench:groupCreate', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => [], @@ -157,6 +160,7 @@ return [ // 工作台:自动导入通讯录到系统,生成加粉/建群等任务 'workbench_import_contact' => [ + 'name' => '工作台:自动导入通讯录', 'command' => 'workbench:import-contact', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => [], @@ -171,6 +175,7 @@ return [ // 清洗并同步微信原始数据到存客宝业务表(数据治理任务) 'sync_wechat_data' => [ + 'name' => '同步微信原始数据到存客宝', 'command' => 'sync:wechatData', 'schedule' => '*/2 * * * *', // 每2分钟 'options' => [], @@ -181,6 +186,7 @@ return [ // 工作台:流量分发任务,把流量池中的线索按规则分配给微信号或员工 'workbench_traffic_distribute' => [ + 'name' => '工作台:流量分发任务', 'command' => 'workbench:trafficDistribute', 'schedule' => '*/2 * * * *', // 每2分钟 'options' => [], @@ -191,6 +197,7 @@ return [ // 工作台:朋友圈同步任务,拉取并落库朋友圈内容 'workbench_moments' => [ + 'name' => '工作台:朋友圈同步任务', 'command' => 'workbench:moments', 'schedule' => '*/2 * * * *', // 每2分钟 'options' => [], @@ -201,6 +208,7 @@ return [ // 预防性切换好友任务,监控频繁/风控风险,自动切换加人对象,保护微信号 'switch_friends' => [ + 'name' => '预防性切换好友任务', 'command' => 'switch:friends', 'schedule' => '*/2 * * * *', // 每2分钟 'options' => [], @@ -215,6 +223,7 @@ return [ // 拉取设备通话记录(语音/电话),用于质检、统计或标签打分 'call_recording' => [ + 'name' => '拉取设备通话记录', 'command' => 'call-recording:list', 'schedule' => '*/30 * * * *', // 每30分钟 'options' => [], @@ -223,12 +232,29 @@ 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', + ], + // =========================== // 每日 / 每几天任务 // =========================== - // 每日 1:00 同步“已删除设备”列表,补齐历史状态 + // 每日 1:00 同步"已删除设备"列表,补齐历史状态 'device_deleted' => [ + 'name' => '同步已删除设备列表', 'command' => 'device:list', 'schedule' => '0 1 * * *', // 每天1点 'options' => ['--isDel=1'], @@ -237,8 +263,9 @@ return [ 'log_file' => 'crontab_device_deleted.log', ], - // 每日 1:10 同步“已停用设备”列表,更新停用状态 + // 每日 1:10 同步"已停用设备"列表,更新停用状态 'device_stopped' => [ + 'name' => '同步已停用设备列表', 'command' => 'device:list', 'schedule' => '10 1 * * *', // 每天1:10 'options' => ['--isDel=2'], @@ -247,8 +274,9 @@ return [ 'log_file' => 'crontab_device_stopped.log', ], - // 每日 1:30 同步“已删除微信好友”,用于历史恢复与报表 + // 每日 1:30 同步"已删除微信好友",用于历史恢复与报表 'wechat_friends_deleted' => [ + 'name' => '同步已删除微信好友', 'command' => 'wechatFriends:list', 'schedule' => '30 1 * * *', // 每天1:30 'options' => ['--isDel=1'], @@ -257,8 +285,9 @@ return [ 'log_file' => 'crontab_wechatFriends_deleted.log', ], - // 每日 1:30 同步“已删除微信群聊”,用于统计与留痕 + // 每日 1:30 同步"已删除微信群聊",用于统计与留痕 'wechat_chatroom_deleted' => [ + 'name' => '同步已删除微信群聊', 'command' => 'wechatChatroom:list', 'schedule' => '30 1 * * *', // 每天1:30 'options' => ['--isDel=1'], @@ -269,6 +298,7 @@ return [ // 每日 2:00 统一计算所有微信账号健康分(基础分 + 动态分) 'wechat_calculate_score' => [ + 'name' => '计算微信账号健康分', 'command' => 'wechat:calculate-score', 'schedule' => '0 2 * * *', // 每天2点 'options' => [], @@ -277,10 +307,23 @@ 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 全量同步所有在线好友,做一次大规模校准 'sync_all_friends' => [ + 'name' => '全量同步所有在线好友', 'command' => 'sync:allFriends', 'schedule' => '0 3 */3 * *', // 每3天的3点 'options' => [], @@ -291,6 +334,7 @@ return [ // 检查未读/未回复消息并自动迁移好友(每5分钟执行一次) 'check_unread_message' => [ + 'name' => '检查未读/未回复消息并自动迁移好友', 'command' => 'check:unread-message', 'schedule' => '*/5 * * * *', // 每5分钟 'options' => ['--minutes=30'], // 30分钟未读/未回复 @@ -301,6 +345,7 @@ return [ // 同步部门列表,用于部门管理与权限控制 'department_list' => [ + 'name' => '同步部门列表', 'command' => 'department:list', 'schedule' => '*/30 * * * *', // 每30分钟 'options' => [], @@ -311,6 +356,7 @@ return [ // 同步内容库,将外部内容同步到系统内容库 'content_sync' => [ + 'name' => '同步内容库', 'command' => 'content:sync', 'schedule' => '0 2 * * *', // 每天2点 'options' => [], @@ -321,6 +367,7 @@ return [ // 朋友圈采集任务,采集好友朋友圈内容 'moments_collect' => [ + 'name' => '朋友圈采集任务', 'command' => 'moments:collect', 'schedule' => '0 6 * * *', // 每天6点 'options' => [], @@ -331,6 +378,7 @@ return [ // 分配规则列表,同步分配规则数据 'allotrule_list' => [ + 'name' => '同步分配规则列表', 'command' => 'allotrule:list', 'schedule' => '0 3 * * *', // 每天3点 'options' => [], @@ -341,6 +389,7 @@ return [ // 自动创建分配规则,根据规则自动创建分配任务 'allotrule_autocreate' => [ + 'name' => '自动创建分配规则', 'command' => 'allotrule:autocreate', 'schedule' => '0 4 * * *', // 每天4点 'options' => [], @@ -351,6 +400,7 @@ return [ // 工作台:入群欢迎语任务,自动发送入群欢迎消息 'workbench_group_welcome' => [ + 'name' => '工作台:入群欢迎语任务', 'command' => 'workbench:groupWelcome', 'schedule' => '*/1 * * * *', // 每1分钟 'options' => [], @@ -361,6 +411,7 @@ return [ // 采集客服自己的朋友圈,同步客服账号的朋友圈内容 'own_moments_collect' => [ + 'name' => '采集客服自己的朋友圈', 'command' => 'own:moments:collect', 'schedule' => '*/30 * * * *', // 每30分钟 'options' => [], diff --git a/docs/traffic_pool_design.md b/docs/traffic_pool_design.md new file mode 100644 index 0000000..6cc7895 --- /dev/null +++ b/docs/traffic_pool_design.md @@ -0,0 +1,1103 @@ +# 新流量池系统设计文档 + +> **版本**:V1.0 +> **日期**:2026-01-29 +> **状态**:已确认 + +--- + +## 功能概述 + +**流量池系统是一套统一管理客户资源的系统,通过对微信好友、群成员等多渠道流量进行归集、分类、打标、分配和行为追踪,实现客户资源的精细化运营和价值最大化。** + +--- + +## 一、需求概述 + +### 1.1 项目背景 + +基于现有数据库中 `s2_` 开头的表(触客宝系统),设计一套全新的流量池管理系统,实现流量的统一管理、分类、追踪和分析。 + +### 1.2 核心需求 + +| 序号 | 需求 | 说明 | +|------|------|------| +| 1 | 流量池总表 | 全局唯一,按 identifier(微信ID优先)去重 | +| 2 | 公司子表 | 包含 companyId,支持多租户数据隔离 | +| 3 | 流量池分组 | 支持自定义流量池分组(如:高价值客户池、潜在客户池等) | +| 4 | 来源追溯 | 完整记录流量的获取渠道和路径 | +| 5 | 行为记录 | 记录所有消息互动和关键行为 | +| 6 | 标签同步 | 同步微信好友标签到流量池 | + +### 1.3 设计原则 + +- **全局唯一**:流量池总表按 identifier 全局唯一,优先使用微信ID +- **多租户隔离**:子表通过 companyId 实现数据隔离 +- **灵活分组**:支持系统默认分组和自定义分组 +- **来源可追溯**:完整记录流量的获取渠道和路径 +- **行为可分析**:记录用户行为,支持 RFM 分析 + +--- + +## 二、系统架构 + +### 2.1 整体系统架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 流量池系统整体架构 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 【数据采集层】 │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ 微信好友 │ │ 群成员 │ │ 海报获客 │ │ 电话获客 │ │ API导入 │ ... │ │ +│ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ +│ └────────┼──────────────┼──────────────┼──────────────┼──────────────┼───────────────┘ │ +│ └──────────────┴──────────────┼──────────────┴──────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 【流量入池引擎】 │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ 1.提取identifier │ ──▶ │ 2.查重/创建总表 │ ──▶ │ 3.创建公司记录 │ │ │ +│ │ │ (微信ID优先) │ │ ck_traffic_pool │ │ ck_traffic_pool │ │ │ +│ │ └─────────────────┘ └─────────────────┘ │ _company │ │ │ +│ │ └────────┬────────┘ │ │ +│ └────────────────────────────────────────────────────────────────┼─────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────────────────────────────────┼───────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐│ +│ │【来源追溯系统】 │ │ 【标签系统】 │ │【分配系统】││ +│ │ │ │ │ │ ││ +│ │ck_traffic_pool_ │ │ ┌───────────┐ │ │ck_traffic_ ││ +│ │ source │ │ │ 标签类目 │ │ │ pool_allot ││ +│ │ │ │ │ tag_ │ │ │ _record ││ +│ │ · 来源类型 │ │ │ category │ │ │ ││ +│ │ · 来源渠道 │ │ └─────┬─────┘ │ │ · 分配规则 ││ +│ │ · 关联任务 │ │ │ │ │ · 归属客服 ││ +│ │ · 首次来源标记 │ │ ▼ │ │ · 有效期 ││ +│ └─────────────────┘ │ ┌───────────┐ │ └────────────┘│ +│ │ │ 标签定义 │ │ │ +│ │ │ tag_ │ │ │ +│ │ │ define │ │ │ +│ │ └─────┬─────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌───────────┐ │ │ +│ │ │ 标签关联 │ │ │ +│ │ │ tag │ │ │ +│ │ └───────────┘ │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌───────────────────────────────────────────────────────────────┼─────────────────────┐ │ +│ │ 【行为追踪系统】 │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ │ │ +│ │ │ 消息互动 │ │ 朋友圈互动 │ │ 订单行为 │ │ 行为记录表 │ │ │ +│ │ │ s2_wechat_ │ ──▶ │ s2_wechat_ │ ──▶ │ 订单系统 │──▶│ck_traffic_ │ │ │ +│ │ │ message │ │ moments │ │ │ │ pool_behavior │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ └───────┬───────┘ │ │ +│ │ │ │ │ +│ │ 记录行为后更新 RFM 指标 │ │ │ +│ │ ┌─────────────────────────────────────┘ │ │ +│ │ ▼ │ │ +│ │ ┌───────────────────┐ │ │ +│ │ │ 更新用户画像 │ │ │ +│ │ │ · lastInteractTime│ │ │ +│ │ │ · rfmF (频次) │ │ │ +│ │ │ · rfmM (金额) │ │ │ +│ │ │ · totalMsgCount │ │ │ +│ │ └───────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 【流量池分组与查询】 │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ ck_traffic_pool_group(分组表) │ │ │ +│ │ │ │ │ │ +│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ +│ │ │ │ 全部好友池 │ │ 高价值客户 │ │ 潜在客户池 │ │ 高互动客户 │ 自定义 │ │ │ +│ │ │ │ (系统默认) │ │ (系统默认) │ │ (系统默认) │ │ (系统默认) │ 分组... │ │ │ +│ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ +│ │ └─────────┼────────────────┼────────────────┼────────────────┼────────────────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ ▼ ▼ ▼ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ ruleConfig(JSON规则配置) │ │ │ +│ │ │ │ │ │ +│ │ │ friendStatus=2 rfmM>=1000 OR intentionLevel>=2 totalMsgCount │ │ │ +│ │ │ level=VIP AND orderCount=0 >=50 │ │ │ +│ │ └─────────────────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 动态查询 ck_traffic_pool_company 匹配规则的用户 │ │ │ +│ │ │ 或查询 ck_traffic_pool_group_member 手动成员 │ │ │ +│ │ └─────────────────────────────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 【工作台集成】 │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ 流量分发 │ │ 联系人导入 │ │ 自动建群 │ │ 群消息推送 │ ... │ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ │ pools: [1,2]│ │ pools: [3] │ │poolGroups: │ │trafficPools:│ │ │ +│ │ │ │ │ │ │ [1,2,3] │ │ [4,5] │ │ │ +│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ └────────────────┴────────────────┴────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────────────────────┐ │ │ +│ │ │ 根据分组ID获取目标用户列表 │ │ │ +│ │ │ 执行工作台任务 │ │ │ +│ │ └─────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 标签与用户画像体系流程图 + +``` +┌─────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 标签与用户画像体系 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 【标签来源】 【标签存储】 【标签应用】 │ +│ │ +│ ┌─────────────────┐ │ +│ │ 微信同步 │ │ +│ │ s2_wechat_friend│──┐ │ +│ │ .labels │ │ ┌─────────────────────────────────────────┐ │ +│ └─────────────────┘ │ │ ck_traffic_pool_tag_category │ │ +│ │ │ (标签类目表) │ │ +│ ┌─────────────────┐ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ 手动打标 │ │ │ │微信标签 │ │站内标签 │ │ AI标签 │ │ │ +│ │ 用户操作 │──┤ │ │tagType=1│ │tagType=2│ │tagType=3│ │ │ +│ └─────────────────┘ │ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ +│ │ └───────┼──────────┼──────────┼─────────┘ │ +│ ┌─────────────────┐ │ │ │ │ │ +│ │ 规则自动打标 │ │ ▼ ▼ ▼ │ +│ │ 定时任务触发 │──┤ ┌─────────────────────────────────────────┐ │ +│ └─────────────────┘ │ │ ck_traffic_pool_tag_define │ │ +│ │──────────▶│ (标签定义表) │ │ +│ ┌─────────────────┐ │ │ │ │ +│ │ AI 智能打标 │ │ │ 示例标签定义: │ │ +│ │ GPT分析生成 │──┤ │ · 高价值客户 (站内/客户等级) │ │ +│ └─────────────────┘ │ │ · 活跃用户 (站内/行为标签) │ │ +│ │ │ · 潜在流失 (AI/预测标签) │ │ +│ ┌─────────────────┐ │ │ · VIP (微信/同步标签) │ │ +│ │ 行为触发打标 │ │ └─────────────────┬───────────────────────┘ │ +│ │ 订单/消息等 │──┘ │ │ +│ └─────────────────┘ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ ck_traffic_pool_tag │ │ +│ │ (标签关联表) │ │ +│ │ │ │ +│ │ poolCompanyId ──▶ 用户 │ │ +│ │ tagDefineId ──▶ 标签 │ │ +│ │ source ──▶ 打标来源 │ │ +│ │ score ──▶ AI置信度 │ │ +│ └─────────────────┬───────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────────┼──────────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ ┌───────────────┐ │ +│ │ 用户画像展示 │ │ 分组规则匹配 │ │ 精准营销 │ │ +│ │ │ │ │ │ │ │ +│ │ · 基础信息 │ │ ruleConfig 中 │ │ · 定向推送 │ │ +│ │ · 标签云 │ │ 支持标签条件: │ │ · 个性推荐 │ │ +│ │ · RFM 雷达图 │ │ │ │ · 智能客服 │ │ +│ │ · 行为轨迹 │ │ "type": "tag" │ │ │ │ +│ │ · 来源追溯 │ │ "field": "tags" │ │ │ │ +│ │ │ │ "operator": │ │ │ │ +│ │ │ │ "contains" │ │ │ │ +│ │ │ │ "value": │ │ │ │ +│ │ │ │ ["VIP","重点"] │ │ │ │ +│ └─────────────────────┘ └─────────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────────┘ + +【用户画像数据来源】 + +┌─────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ck_traffic_pool_company(用户画像核心数据表) │ +│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 【基础属性】 【客户价值】 【行为统计】 │ │ +│ │ ├─ realName ├─ level (等级) ├─ totalMsgCount │ │ +│ │ ├─ phone ├─ intentionLevel ├─ lastMsgTime │ │ +│ │ ├─ email ├─ lifecycle ├─ totalOrderCount │ │ +│ │ ├─ birthday │ ├─ totalOrderAmount │ │ +│ │ ├─ address │ ├─ lastOrderTime │ │ +│ │ └─ customFields (JSON) │ │ │ │ +│ │ │ │ │ │ +│ │ 【RFM模型】 【归属信息】 【状态管理】 │ │ +│ │ ├─ lastInteractTime (R) ├─ ownerWechatId ├─ friendStatus │ │ +│ │ ├─ rfmF (频次) ├─ ownerAccountId ├─ allocateStatus │ │ +│ │ ├─ rfmM (金额) ├─ ownerUserId ├─ status │ │ +│ │ ├─ rfmScore (综合) │ │ │ │ +│ │ └─ rfmType (客户类型) │ │ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 用户画像视图整合 │ │ +│ │ │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ +│ │ │ 基础信息 │ │ 标签信息 │ │ 行为轨迹 │ │ 来源信息 │ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ │ traffic_ │ + │ traffic_ │ + │ traffic_ │ + │ traffic_ │ │ │ +│ │ │ pool_ │ │ pool_tag │ │ pool_ │ │ pool_ │ │ │ +│ │ │ company │ │ │ │ behavior │ │ source │ │ │ +│ │ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ └────────────────┴────────────────┴────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌──────────────────────┐ │ │ +│ │ │ 完整用户画像 │ │ │ +│ │ │ (前端展示用) │ │ │ +│ │ └──────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 表结构关系图 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ck_traffic_pool(总表) │ +│ 全局唯一的流量标识 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ 1:N + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ ck_traffic_pool_company(公司子表) │ +│ 每个公司独立的流量详情(含companyId) │ +└─────────────────────────────────────────────────────────────────────┘ + │ │ │ │ │ + │ N:1 │ 1:N │ 1:N │ 1:N │ 1:N + ▼ ▼ ▼ ▼ ▼ +┌──────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 流量池 │ │ 来源记录 │ │ 标签记录 │ │ 行为记录 │ │ 分配记录 │ +│ 分组 │ │ │ │ │ │ │ │ │ +└──────────┘ └────────────┘ └────────────┘ └─────────────┘ └─────────────┘ +``` + +### 2.2 表清单 + +| 序号 | 表名 | 说明 | +|------|------|------| +| 1 | `ck_traffic_pool` | 流量池总表(全局唯一) | +| 2 | `ck_traffic_pool_company` | 公司流量详情表(多租户) | +| 3 | `ck_traffic_pool_group` | 流量池分组表(规则使用JSON存储) | +| 4 | `ck_traffic_pool_group_member` | 流量池分组成员表(手动添加) | +| 5 | `ck_traffic_pool_source` | 流量来源记录表 | +| 6 | `ck_traffic_pool_tag_category` | 标签类目表 | +| 7 | `ck_traffic_pool_tag_define` | 标签定义表 | +| 8 | `ck_traffic_pool_tag` | 流量标签关联表 | +| 9 | `ck_traffic_pool_behavior` | 流量行为记录表 | +| 10 | `ck_traffic_pool_allot_record` | 流量分配记录表 | + +> ⚠️ **优化说明**:废弃独立的 `ck_traffic_pool_group_rule` 表,规则改为存储在 `ck_traffic_pool_group.ruleConfig` 字段中(JSON格式),支持嵌套逻辑表达。 + +--- + +## 三、流量池分组设计 + +### 3.1 系统默认分组 + +| 分组名称 | 分组编码 | 说明 | 规则 | +|----------|----------|------|------| +| 全部好友流量池 | `all_friends` | 所有已添加的好友 | friendStatus = 2 | +| 高价值客户池 | `high_value` | 消费金额高的客户 | rfmM >= 阈值 或 level = 2(VIP) | +| 潜在客户池 | `potential` | 有意向但未成交 | intentionLevel >= 2 且 totalOrderCount = 0 | +| 高互动客户池 | `high_interact` | 互动频繁的客户 | rfmF >= 阈值 或 totalMsgCount >= 阈值 | + +### 3.2 分组特性说明 + +> ⚠️ **重要**:一个流量可以同时属于多个分组 + +例如:某客户既消费金额高,又互动频繁,则该客户会同时出现在: +- 全部好友流量池 +- 高价值客户池 +- 高互动客户池 + +分组之间是**非互斥**关系,前端展示时需注意去重统计。 + +### 3.3 自定义分组 + +用户可以基于以下条件创建自定义分组: + +- **标签条件**:包含/不包含特定标签 +- **属性条件**:性别、地区、等级等 +- **行为条件**:消息数、订单数、最后互动时间等 +- **RFM条件**:R/F/M 值范围 +- **来源条件**:特定来源渠道 + +### 3.4 分组规则配置(JSON结构) + +> ⚠️ 规则直接存储在 `ck_traffic_pool_group.ruleConfig` 字段中,支持嵌套逻辑 + +**简单规则示例**(全部好友): + +```json +{ + "logic": "AND", + "conditions": [ + { "type": "field", "field": "friendStatus", "operator": "=", "value": 2, "valueType": "number" } + ] +} +``` + +**复杂规则示例**(高价值客户:已通过好友 AND (消费>=1000 OR 等级=VIP)): + +```json +{ + "logic": "AND", + "conditions": [ + { "type": "field", "field": "friendStatus", "operator": "=", "value": 2, "valueType": "number" }, + { + "type": "group", + "logic": "OR", + "conditions": [ + { "type": "field", "field": "rfmM", "operator": ">=", "value": 1000, "valueType": "number" }, + { "type": "field", "field": "level", "operator": "=", "value": 2, "valueType": "number" } + ] + } + ] +} +``` + +**标签规则示例**: + +```json +{ + "logic": "AND", + "conditions": [ + { "type": "field", "field": "friendStatus", "operator": "=", "value": 2, "valueType": "number" }, + { "type": "tag", "field": "tags", "operator": "contains", "value": ["重点客户", "已成交"], "valueType": "array" } + ] +} +``` + +**规则字段说明**: + +| 字段 | 说明 | +|------|------| +| type | 条件类型:`field`=字段条件,`group`=嵌套分组,`tag`=标签条件 | +| logic | 逻辑运算符:`AND`、`OR` | +| field | 字段名 | +| operator | 操作符:`=`, `!=`, `>`, `<`, `>=`, `<=`, `in`, `not_in`, `contains`, `not_contains`, `between` | +| value | 条件值 | +| valueType | 值类型:`string`, `number`, `array`, `date` | + +--- + +## 四、详细表结构 + +### 4.1 流量池总表 `ck_traffic_pool` + +> **用途**:存储全局唯一的流量标识,不区分公司 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| identifier | varchar(64) | 是 | - | 流量唯一标识(微信ID优先) | +| identifierType | tinyint(2) | 是 | 1 | 标识类型:1=微信ID,2=微信号,3=手机号 | +| wechatId | varchar(64) | 否 | NULL | 微信ID | +| wechatAlias | varchar(64) | 否 | NULL | 微信号 | +| mobile | varchar(20) | 否 | NULL | 手机号 | +| nickname | varchar(100) | 否 | NULL | 昵称 | +| avatar | varchar(500) | 否 | NULL | 头像URL | +| gender | tinyint(1) | 否 | 0 | 性别:0=未知,1=男,2=女 | +| region | varchar(100) | 否 | NULL | 地区 | +| country | varchar(50) | 否 | NULL | 国家 | +| province | varchar(50) | 否 | NULL | 省份 | +| city | varchar(50) | 否 | NULL | 城市 | +| signature | varchar(500) | 否 | NULL | 个性签名 | +| firstSeenTime | int(11) | 否 | NULL | 首次出现时间 | +| lastSeenTime | int(11) | 否 | NULL | 最后活跃时间 | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | + +**索引设计**: +- 主键:`id` +- 唯一索引:`uk_identifier (identifier)` +- 普通索引:`idx_wechatId (wechatId)`、`idx_wechatAlias (wechatAlias)`、`idx_mobile (mobile)` + +--- + +### 4.2 公司流量详情表 `ck_traffic_pool_company` + +> **用途**:存储流量在各公司的详细信息,支持多租户 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| poolId | int(11) UNSIGNED | 是 | - | 流量池总表ID | +| identifier | varchar(64) | 是 | - | 流量标识(冗余) | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID | +| **归属信息** | +| ownerWechatId | varchar(64) | 否 | NULL | 归属客服微信ID | +| ownerAccountId | int(11) | 否 | NULL | 归属客服账号ID(s2_wechat_account.id) | +| ownerUserId | int(11) | 否 | NULL | 归属操盘手ID | +| **好友关联** | +| wechatFriendId | int(11) | 否 | NULL | 微信好友ID(s2_wechat_friend.id) | +| friendStatus | tinyint(2) | 否 | 0 | 好友状态:0=未加,1=待通过,2=已通过,3=已删除,4=被删除 | +| friendPassTime | int(11) | 否 | NULL | 好友通过时间 | +| **客户属性** | +| realName | varchar(50) | 否 | NULL | 真实姓名 | +| idCard | varchar(18) | 否 | NULL | 身份证号 | +| phone | varchar(20) | 否 | NULL | 联系电话 | +| email | varchar(100) | 否 | NULL | 邮箱 | +| birthday | date | 否 | NULL | 生日 | +| address | varchar(255) | 否 | NULL | 地址 | +| company | varchar(100) | 否 | NULL | 所在公司 | +| position | varchar(50) | 否 | NULL | 职位 | +| remark | varchar(500) | 否 | NULL | 备注 | +| customFields | json | 否 | NULL | 自定义字段 | +| **客户等级** | +| level | tinyint(2) | 否 | 0 | 客户等级:0=普通,1=重要,2=VIP | +| intentionLevel | tinyint(2) | 否 | 0 | 意向度:0=未知,1=低,2=中,3=高 | +| **RFM模型** | +| lastInteractTime | int(11) | 否 | NULL | 最后互动时间戳(R值动态计算:DATEDIFF(NOW(), FROM_UNIXTIME(lastInteractTime))) | +| rfmF | int(11) | 否 | 0 | F值-互动频次 | +| rfmM | decimal(12,2) | 否 | 0.00 | M值-消费金额 | +| rfmScore | int(11) | 否 | 0 | RFM综合评分 | +| rfmType | varchar(20) | 否 | NULL | RFM客户类型 | +| **统计信息** | +| totalOrderCount | int(11) | 否 | 0 | 累计订单数 | +| totalOrderAmount | decimal(12,2) | 否 | 0.00 | 累计订单金额 | +| lastOrderTime | int(11) | 否 | NULL | 最后下单时间 | +| totalMsgCount | int(11) | 否 | 0 | 累计消息数 | +| lastMsgTime | int(11) | 否 | NULL | 最后消息时间 | +| **来源追溯** | +| firstSourceType | tinyint(2) | 否 | NULL | 首次来源类型 | +| firstSourceTime | int(11) | 否 | NULL | 首次来源时间 | +| **生命周期** | +| lifecycle | tinyint(2) | 否 | 1 | 生命周期:1=新流量,2=跟进中,3=已成交,4=沉默,5=流失 | +| **状态管理** | +| status | tinyint(2) | 否 | 1 | 状态:0=禁用,1=正常,2=黑名单 | +| allocateStatus | tinyint(2) | 否 | 0 | 分配状态:0=未分配,1=已分配,2=已回收 | +| allocateTime | int(11) | 否 | NULL | 分配时间 | +| expireTime | int(11) | 否 | NULL | 到期时间 | +| **时间戳** | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | +| isDel | tinyint(1) | 否 | 0 | 是否删除 | +| deleteTime | int(11) | 否 | NULL | 删除时间 | + +**索引设计**: +- 主键:`id` +- 唯一索引:`uk_identifier_company (identifier, companyId)` +- 普通索引:`idx_poolId`、`idx_companyId`、`idx_ownerWechatId`、`idx_wechatFriendId`、`idx_friendStatus`、`idx_status`、`idx_level`、`idx_allocateStatus`、`idx_lifecycle` + +> 💡 **优化说明**:删除重复的 `uk_pool_company` 唯一索引,因为 `poolId` 与 `identifier` 一一对应,保留更常用的 `uk_identifier_company` + +--- + +### 4.3 流量池分组表 `ck_traffic_pool_group` + +> **用途**:管理流量池分组(如:高价值客户池、潜在客户池等) + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID(0=系统默认) | +| groupCode | varchar(50) | 是 | - | 分组编码(唯一标识) | +| groupName | varchar(50) | 是 | - | 分组名称 | +| groupIcon | varchar(255) | 否 | NULL | 分组图标 | +| groupColor | varchar(20) | 否 | NULL | 分组颜色 | +| description | varchar(255) | 否 | NULL | 分组描述 | +| isSystem | tinyint(1) | 否 | 0 | 是否系统默认:0=否,1=是 | +| isDefault | tinyint(1) | 否 | 0 | 是否默认展示:0=否,1=是 | +| ruleType | tinyint(2) | 否 | 1 | 规则类型:1=动态规则,2=手动添加 | +| ruleConfig | json | 否 | NULL | 规则配置(JSON) | +| memberCount | int(11) | 否 | 0 | 成员数量(缓存) | +| sort | int(11) | 否 | 0 | 排序(数值越小越靠前) | +| status | tinyint(1) | 否 | 1 | 状态:0=禁用,1=启用 | +| userId | int(11) | 否 | NULL | 创建用户ID | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | +| isDel | tinyint(1) | 否 | 0 | 是否删除 | +| deleteTime | int(11) | 否 | NULL | 删除时间 | + +**索引设计**: +- 主键:`id` +- 唯一索引:`uk_company_code (companyId, groupCode)` +- 普通索引:`idx_companyId`、`idx_isSystem`、`idx_status`、`idx_sort` + +--- + +### 4.4 流量池分组成员表 `ck_traffic_pool_group_member` + +> **用途**:手动添加到分组的成员(ruleType=2时使用) + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| groupId | int(11) UNSIGNED | 是 | - | 分组ID | +| poolCompanyId | int(11) UNSIGNED | 是 | - | 公司流量详情表ID | +| identifier | varchar(64) | 是 | - | 流量标识(冗余) | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID | +| addType | tinyint(2) | 否 | 1 | 添加方式:1=手动,2=批量导入 | +| operatorId | int(11) | 否 | NULL | 操作人ID | +| createTime | int(11) | 是 | - | 创建时间 | +| isDel | tinyint(1) | 否 | 0 | 是否删除 | +| deleteTime | int(11) | 否 | NULL | 删除时间 | + +**索引设计**: +- 主键:`id` +- 唯一索引:`uk_group_pool (groupId, poolCompanyId)` +- 普通索引:`idx_companyId`、`idx_identifier` + +--- + +### 4.5 流量来源表 `ck_traffic_pool_source` + +> **用途**:记录流量的获取渠道和来源路径 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| poolCompanyId | int(11) UNSIGNED | 是 | - | 公司流量详情表ID | +| identifier | varchar(64) | 是 | - | 流量标识(冗余) | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID | +| **来源类型** | +| sourceType | tinyint(2) | 是 | - | 来源类型(见下表) | +| sourceSubType | varchar(50) | 否 | NULL | 来源子类型 | +| **来源详情** | +| sourceId | varchar(100) | 否 | NULL | 来源ID | +| sourceName | varchar(255) | 否 | NULL | 来源名称 | +| sourceWechatId | varchar(64) | 否 | NULL | 来源微信ID | +| sourceChatroomId | varchar(64) | 否 | NULL | 来源群ID | +| sourceSceneId | int(11) | 否 | NULL | 来源场景ID | +| sourceChannelId | int(11) | 否 | NULL | 来源渠道ID | +| **关联任务** | +| friendTaskId | int(11) | 否 | NULL | 加好友任务ID | +| taskCustomerId | int(11) | 否 | NULL | 获客任务客户ID | +| **状态** | +| sourceStatus | tinyint(2) | 否 | 1 | 来源状态:1=待处理,2=处理中,3=已通过,4=已拒绝,5=已过期 | +| isFirstSource | tinyint(1) | 否 | 0 | 是否首次来源:0=否,1=是 | +| extra | json | 否 | NULL | 额外信息 | +| remark | varchar(255) | 否 | NULL | 备注 | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | + +**来源类型枚举**: + +| 值 | 说明 | +|----|------| +| 1 | 好友添加 | +| 2 | 群成员 | +| 3 | 海报获客 | +| 4 | 电话获客 | +| 5 | 订单获客 | +| 6 | API导入 | +| 7 | 手动导入 | +| 8 | 裂变活动 | + +--- + +## 五、标签系统设计 + +### 5.1 标签系统架构 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 标签类型(tagType) │ +│ 1=微信标签 2=站内标签 3=AI标签 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ ck_traffic_pool_tag_category(标签类目表) │ +│ 每种标签类型下可以有多个类目 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ 1:N + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ ck_traffic_pool_tag_define(标签定义表) │ +│ 每个类目下可以定义多个具体标签 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ N:M + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ ck_traffic_pool_tag(流量标签关联表) │ +│ 流量与标签的多对多关联 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 标签类型说明 + +| 标签类型 | tagType | 说明 | 数据来源 | +|----------|---------|------|----------| +| 微信标签 | 1 | 从微信同步过来的好友标签 | s2_wechat_friend.labels 同步 | +| 站内标签 | 2 | 系统内部定义的业务标签 | 用户手动打标/规则自动打标 | +| AI标签 | 3 | AI根据用户画像自动生成 | AI分析自动生成 | + +### 5.3 标签类目表 `ck_traffic_pool_tag_category` + +> **用途**:管理标签的类目/分组,支持多级分类 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID(0=系统默认) | +| parentId | int(11) UNSIGNED | 否 | 0 | 父类目ID(0=顶级类目) | +| tagType | tinyint(2) | 是 | - | 标签类型:1=微信标签,2=站内标签,3=AI标签 | +| categoryCode | varchar(50) | 是 | - | 类目编码 | +| categoryName | varchar(50) | 是 | - | 类目名称 | +| categoryIcon | varchar(255) | 否 | NULL | 类目图标 | +| categoryColor | varchar(20) | 否 | NULL | 类目颜色 | +| description | varchar(255) | 否 | NULL | 类目描述 | +| isSystem | tinyint(1) | 否 | 0 | 是否系统默认:0=否,1=是 | +| sort | int(11) | 否 | 0 | 排序 | +| status | tinyint(1) | 否 | 1 | 状态:0=禁用,1=启用 | +| userId | int(11) | 否 | NULL | 创建用户ID | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | +| isDel | tinyint(1) | 否 | 0 | 是否删除 | +| deleteTime | int(11) | 否 | NULL | 删除时间 | + +**索引设计**: +- 主键:`id` +- 唯一索引:`uk_company_code (companyId, categoryCode)` +- 普通索引:`idx_companyId`、`idx_parentId`、`idx_tagType`、`idx_status` + +--- + +### 5.4 标签定义表 `ck_traffic_pool_tag_define` + +> **用途**:定义具体的标签 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID(0=系统默认) | +| categoryId | int(11) UNSIGNED | 否 | 0 | 所属类目ID | +| tagType | tinyint(2) | 是 | - | 标签类型:1=微信标签,2=站内标签,3=AI标签 | +| tagCode | varchar(50) | 是 | - | 标签编码 | +| tagName | varchar(50) | 是 | - | 标签名称 | +| tagIcon | varchar(255) | 否 | NULL | 标签图标 | +| tagColor | varchar(20) | 否 | NULL | 标签颜色 | +| description | varchar(255) | 否 | NULL | 标签描述 | +| isSystem | tinyint(1) | 否 | 0 | 是否系统默认:0=否,1=是 | +| isExclusive | tinyint(1) | 否 | 0 | 是否互斥标签:0=否,1=是(同类目下只能选一个) | +| sort | int(11) | 否 | 0 | 排序 | +| status | tinyint(1) | 否 | 1 | 状态:0=禁用,1=启用 | +| useCount | int(11) | 否 | 0 | 使用次数(缓存) | +| syncFromWechat | tinyint(1) | 否 | 0 | 是否来自微信同步:0=否,1=是 | +| userId | int(11) | 否 | NULL | 创建用户ID | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | +| isDel | tinyint(1) | 否 | 0 | 是否删除 | +| deleteTime | int(11) | 否 | NULL | 删除时间 | + +**索引设计**: +- 主键:`id` +- 唯一索引:`uk_company_code (companyId, tagCode)` +- 普通索引:`idx_companyId`、`idx_categoryId`、`idx_tagType`、`idx_tagName`、`idx_status` + +--- + +### 5.5 流量标签关联表 `ck_traffic_pool_tag` + +> **用途**:记录流量与标签的关联关系 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| poolCompanyId | int(11) UNSIGNED | 是 | - | 公司流量详情表ID | +| identifier | varchar(64) | 是 | - | 流量标识(冗余) | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID | +| **标签信息** | +| tagDefineId | int(11) UNSIGNED | 是 | - | 标签定义ID | +| tagType | tinyint(2) | 是 | - | 标签类型:1=微信标签,2=站内标签,3=AI标签 | +| categoryId | int(11) UNSIGNED | 否 | 0 | 类目ID(冗余) | +| tagName | varchar(50) | 是 | - | 标签名称(冗余,方便查询) | +| tagValue | varchar(255) | 否 | NULL | 标签值(部分标签需要值,如:消费金额=1000) | +| **来源信息** | +| source | tinyint(2) | 否 | 1 | 打标来源:1=手动,2=规则自动,3=AI自动,4=微信同步 | +| sourceId | varchar(100) | 否 | NULL | 来源ID(规则ID/AI任务ID等) | +| sourceRemark | varchar(255) | 否 | NULL | 来源备注 | +| **操作信息** | +| operatorId | int(11) | 否 | NULL | 操作人ID | +| score | decimal(5,2) | 否 | NULL | AI标签置信度(0-100,仅AI标签使用) | +| expireTime | int(11) | 否 | NULL | 过期时间(部分标签有时效性) | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | +| isDel | tinyint(1) | 否 | 0 | 是否删除 | +| deleteTime | int(11) | 否 | NULL | 删除时间 | + +**索引设计**: +- 主键:`id` +- 唯一索引:`uk_pool_tag (poolCompanyId, tagDefineId)` +- 普通索引:`idx_identifier_company`、`idx_tagDefineId`、`idx_tagType`、`idx_categoryId`、`idx_tagName` + +--- + +### 5.6 系统默认标签类目 + +系统初始化时需要插入以下默认类目: + +**微信标签类目**(tagType=1): +| 类目编码 | 类目名称 | 说明 | +|----------|----------|------| +| wechat_default | 微信默认标签 | 从微信同步的标签 | + +**站内标签类目**(tagType=2): +| 类目编码 | 类目名称 | 说明 | +|----------|----------|------| +| customer_level | 客户等级 | 普通/重要/VIP | +| customer_intention | 客户意向 | 低意向/中意向/高意向 | +| customer_stage | 客户阶段 | 新客户/跟进中/已成交/已流失 | +| customer_source | 客户来源 | 海报/电话/群聊/好友推荐等 | +| customer_industry | 所属行业 | 行业分类标签 | +| customer_preference | 客户偏好 | 产品偏好/服务偏好等 | + +**AI标签类目**(tagType=3): +| 类目编码 | 类目名称 | 说明 | +|----------|----------|------| +| ai_portrait | AI画像标签 | AI分析的用户画像 | +| ai_behavior | AI行为标签 | AI分析的行为特征 | +| ai_prediction | AI预测标签 | AI预测的标签(如:高转化潜力) | + +--- + +### 5.7 流量行为表 `ck_traffic_pool_behavior` + +> **用途**:记录流量的各种行为(包括所有消息互动) + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | bigint(20) UNSIGNED | 是 | AUTO | 主键ID | +| poolCompanyId | int(11) UNSIGNED | 是 | - | 公司流量详情表ID | +| identifier | varchar(64) | 是 | - | 流量标识(冗余) | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID | +| **行为信息** | +| behaviorType | tinyint(2) | 是 | - | 行为类型(见下表) | +| behaviorSubType | varchar(50) | 否 | NULL | 行为子类型 | +| behaviorName | varchar(100) | 否 | NULL | 行为名称 | +| **行为详情** | +| targetType | varchar(50) | 否 | NULL | 目标类型 | +| targetId | varchar(100) | 否 | NULL | 目标ID | +| targetName | varchar(255) | 否 | NULL | 目标名称 | +| amount | decimal(12,2) | 否 | 0.00 | 金额 | +| **关联信息**(只记录ID,通过ID关联查询详情) | +| wechatAccountId | int(11) | 否 | NULL | 客服微信账号ID | +| messageId | bigint(20) | 否 | NULL | 消息ID(关联s2_wechat_message.id) | +| momentsId | int(11) | 否 | NULL | 朋友圈ID(关联s2_wechat_moments.id) | +| orderId | varchar(50) | 否 | NULL | 订单号 | +| extra | json | 否 | NULL | 额外信息 | +| remark | varchar(255) | 否 | NULL | 备注 | +| behaviorTime | int(11) | 是 | - | 行为时间 | +| createTime | int(11) | 是 | - | 创建时间 | + +**行为类型枚举**: + +| 值 | 说明 | +|----|------| +| 1 | 发送消息 | +| 2 | 接收消息 | +| 3 | 浏览 | +| 4 | 点击 | +| 5 | 咨询 | +| 6 | 下单 | +| 7 | 支付 | +| 8 | 退款 | +| 9 | 点赞朋友圈 | +| 10 | 评论朋友圈 | + +--- + +### 5.8 流量分配记录表 `ck_traffic_pool_allot_record` + +> **用途**:记录流量的分配历史 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| id | int(11) UNSIGNED | 是 | AUTO | 主键ID | +| poolCompanyId | int(11) UNSIGNED | 是 | - | 公司流量详情表ID | +| identifier | varchar(64) | 是 | - | 流量标识(冗余) | +| companyId | int(11) UNSIGNED | 是 | - | 公司ID | +| **分配信息** | +| allotType | tinyint(2) | 否 | 1 | 分配类型:1=首次分配,2=重新分配,3=回收后分配 | +| allotRuleId | int(11) | 否 | NULL | 分配规则ID | +| **分配前** | +| fromWechatId | varchar(64) | 否 | NULL | 原归属客服微信ID | +| fromAccountId | int(11) | 否 | NULL | 原归属账号ID | +| fromUserId | int(11) | 否 | NULL | 原归属操盘手ID | +| **分配后** | +| toWechatId | varchar(64) | 是 | - | 新归属客服微信ID | +| toAccountId | int(11) | 否 | NULL | 新归属账号ID | +| toUserId | int(11) | 否 | NULL | 新归属操盘手ID | +| **有效期** | +| expireDays | int(11) | 否 | 30 | 有效期(天) | +| expireTime | int(11) | 否 | NULL | 到期时间 | +| status | tinyint(2) | 否 | 1 | 状态:1=生效中,2=已过期,3=已回收 | +| operatorId | int(11) | 否 | NULL | 操作人ID | +| remark | varchar(255) | 否 | NULL | 备注 | +| createTime | int(11) | 是 | - | 创建时间 | +| updateTime | int(11) | 否 | NULL | 更新时间 | + +--- + +## 六、系统默认数据 + +### 6.1 默认流量池分组(含规则) + +> ⚠️ 规则使用 JSON 格式存储在 `ruleConfig` 字段中 + +```sql +INSERT INTO `ck_traffic_pool_group` +(`companyId`, `groupCode`, `groupName`, `description`, `isSystem`, `isDefault`, `ruleType`, `ruleConfig`, `sort`, `status`) +VALUES +-- 全部好友流量池 +(0, 'all_friends', '全部好友流量池', '所有已添加的好友', 1, 1, 1, + '{"logic":"AND","conditions":[{"type":"field","field":"friendStatus","operator":"=","value":2,"valueType":"number"}]}', + 1, 1), + +-- 高价值客户池:已通过好友 AND (消费>=1000 OR 等级=VIP) +(0, 'high_value', '高价值客户池', '消费金额高的客户', 1, 0, 1, + '{"logic":"AND","conditions":[{"type":"field","field":"friendStatus","operator":"=","value":2,"valueType":"number"},{"type":"group","logic":"OR","conditions":[{"type":"field","field":"rfmM","operator":">=","value":1000,"valueType":"number"},{"type":"field","field":"level","operator":"=","value":2,"valueType":"number"}]}]}', + 2, 1), + +-- 潜在客户池:已通过好友 AND 意向度>=中 AND 订单数=0 +(0, 'potential', '潜在客户池', '有意向但未成交的客户', 1, 0, 1, + '{"logic":"AND","conditions":[{"type":"field","field":"friendStatus","operator":"=","value":2,"valueType":"number"},{"type":"field","field":"intentionLevel","operator":">=","value":2,"valueType":"number"},{"type":"field","field":"totalOrderCount","operator":"=","value":0,"valueType":"number"}]}', + 3, 1), + +-- 高互动客户池:已通过好友 AND 消息数>=50 +(0, 'high_interact', '高互动客户池', '互动频繁的客户', 1, 0, 1, + '{"logic":"AND","conditions":[{"type":"field","field":"friendStatus","operator":"=","value":2,"valueType":"number"},{"type":"field","field":"totalMsgCount","operator":">=","value":50,"valueType":"number"}]}', + 4, 1); +``` + +--- + +## 七、与现有表的关联关系 + +### 7.1 关联图 + +``` +┌─────────────────────┐ ┌─────────────────────┐ +│ s2_wechat_account │◄────────│ ck_traffic_pool_ │ +│ (微信客服号) │ │ company │ +└─────────────────────┘ │ (ownerWechatId) │ + │ └─────────────────────┘ + │ │ + ▼ │ +┌─────────────────────┐ │ +│ s2_wechat_friend │◄─────────────────┘ +│ (微信好友) │ (wechatFriendId) +└─────────────────────┘ + │ + │ wechatId + ▼ +┌─────────────────────┐ +│ ck_traffic_pool │ +│ (流量池总表) │ +│ (identifier) │ +└─────────────────────┘ +``` + +### 7.2 关联字段映射 + +| 新表字段 | 关联表 | 关联字段 | 说明 | +|----------|--------|----------|------| +| `ck_traffic_pool.identifier` | `s2_wechat_friend` | `wechatId` | 微信ID匹配 | +| `ck_traffic_pool_company.wechatFriendId` | `s2_wechat_friend` | `id` | 好友ID关联 | +| `ck_traffic_pool_company.ownerWechatId` | `s2_wechat_account` | `wechatId` | 客服微信ID | +| `ck_traffic_pool_company.ownerAccountId` | `s2_wechat_account` | `id` | 客服账号ID | +| `ck_traffic_pool_source.friendTaskId` | `s2_friend_task` | `id` | 加好友任务ID | +| `ck_traffic_pool_behavior.messageId` | `s2_wechat_message` | `id` | 消息ID | + +--- + +## 八、核心业务流程 + +### 8.1 流量入池流程 + +``` +1. 新流量进入(好友添加/群成员/获客等) + ↓ +2. 提取 identifier(优先微信ID) + ↓ +3. 查询 ck_traffic_pool 是否存在 + ├── 不存在 → 创建总表记录 + └── 存在 → 获取 poolId + ↓ +4. 查询 ck_traffic_pool_company 是否存在该公司记录 + ├── 不存在 → 创建公司记录 + └── 存在 → 更新公司记录 + ↓ +5. 创建 ck_traffic_pool_source 来源记录 + ↓ +6. 同步标签到 ck_traffic_pool_tag + ↓ +7. 根据分配规则执行分配 → ck_traffic_pool_allot_record +``` + +### 8.2 好友通过同步流程 + +``` +1. s2_wechat_friend 新增好友通过记录 + ↓ +2. 根据 wechatId 匹配 ck_traffic_pool + ↓ +3. 更新 ck_traffic_pool_company: + - wechatFriendId = 好友ID + - friendStatus = 2 + - friendPassTime = 当前时间 + ↓ +4. 同步好友标签到 ck_traffic_pool_tag + ↓ +5. 更新总表基础信息(昵称、头像等) +``` + +### 8.3 消息互动记录流程 + +``` +1. s2_wechat_message 新增消息记录 + ↓ +2. 根据 wechatFriendId 找到对应流量 + ↓ +3. 创建 ck_traffic_pool_behavior 行为记录 + ↓ +4. 更新 ck_traffic_pool_company: + - totalMsgCount += 1 + - lastMsgTime = 当前时间 + - rfmR = 0(重置为今天) + - rfmF += 1 +``` + +### 8.4 流量池分组查询流程 + +``` +1. 获取分组配置 + ↓ +2. 解析规则配置 ruleConfig + ↓ +3. 动态构建 SQL 查询条件 + ↓ +4. 查询 ck_traffic_pool_company + ↓ +5. 返回分组成员列表 +``` + +--- + +## 九、确认事项 + +> ✅ 以下事项已全部确认 + +| 事项 | 确认结果 | +|------|----------| +| identifier 优先级 | ✅ 微信ID优先 | +| 表名前缀 | ✅ ck_ 开头 | +| 行为记录 | ✅ 需要记录所有消息互动(只记录消息ID,通过ID关联查询详情) | +| 标签同步 | ✅ 需要同步微信好友标签 | +| 流量池分组 | ✅ 支持系统默认和自定义分组,一个流量可属于多个分组 | +| 身份证字段 | ✅ 新增身份证号字段 | +| 标签分类 | ✅ 分为微信标签、站内标签、AI标签,支持类目扩展 | + +--- + +## 十、开发注意事项 + +### 10.1 统计字段并发更新规范 + +> ⚠️ `totalMsgCount`、`totalOrderCount` 等统计字段在高并发场景下需使用原子操作 + +**错误示例**: +```php +// ❌ 可能导致数据丢失 +$record = Model::find($id); +$record->totalMsgCount = $record->totalMsgCount + 1; +$record->save(); +``` + +**正确示例**: +```php +// ✅ 使用原子操作 +Db::table('ck_traffic_pool_company') + ->where('id', $id) + ->inc('totalMsgCount', 1) + ->update(); + +// ✅ 或使用 SQL +UPDATE ck_traffic_pool_company +SET totalMsgCount = totalMsgCount + 1, + lastMsgTime = UNIX_TIMESTAMP(), + lastInteractTime = UNIX_TIMESTAMP() +WHERE id = ?; +``` + +### 10.2 RFM R值计算方式 + +R值不存储天数,而是存储 `lastInteractTime` 时间戳,查询时动态计算: + +```sql +SELECT + *, + DATEDIFF(NOW(), FROM_UNIXTIME(lastInteractTime)) AS rfmR +FROM ck_traffic_pool_company +WHERE companyId = ?; +``` + +### 10.3 首次来源标记 + +流量入池时,需判断是否为首次来源: + +```php +// 检查是否已有来源记录 +$existSource = Db::table('ck_traffic_pool_source') + ->where('poolCompanyId', $poolCompanyId) + ->find(); + +$isFirstSource = $existSource ? 0 : 1; + +// 如果是首次来源,同时更新 company 表的首次来源字段 +if ($isFirstSource) { + Db::table('ck_traffic_pool_company') + ->where('id', $poolCompanyId) + ->update([ + 'firstSourceType' => $sourceType, + 'firstSourceTime' => time() + ]); +} +``` + +--- + +## 十一、下一步计划 + +确认本文档后,将进行以下开发工作: + +1. **创建 SQL 文件**:生成所有表的建表语句 +2. **数据迁移脚本**:(如需要)从旧表迁移数据 +3. **Model 层开发**:创建对应的 PHP Model 类 +4. **Service 层开发**:实现核心业务逻辑 +5. **Controller 层开发**:实现 API 接口 +6. **前端页面开发**:流量池管理界面 + +--- + +**请确认以上设计方案,确认后开始开发。** + diff --git a/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php b/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php index 3c3da14..ed08ca8 100644 --- a/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php +++ b/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php @@ -1268,7 +1268,7 @@ class Adapter implements WeChatServiceInterface } /** - * 大数据量分批处理版本 + * 大数据量分批处理版本(支持插入和更新) * 适用于数据源非常大的情况,避免一次性加载全部数据到内存 * 独立脚本执行,30min 同步一次 和 流量来源的更新一起 * @@ -1292,26 +1292,47 @@ class Adapter implements WeChatServiceInterface $affectedRows = 0; try { - for ($i = 0; $i < $batchCount; $i++) { - $offset = $i * $batchSize; - - $sql = "INSERT IGNORE INTO ck_traffic_pool(`identifier`, `wechatId`, `mobile`) - SELECT t.wechatId AS identifier, t.wechatId, - (SELECT phone FROM s2_wechat_friend - WHERE wechatId = t.wechatId LIMIT 1) AS mobile - FROM ( - SELECT wechatId FROM temp_wechat_ids LIMIT {$offset}, {$batchSize} - ) AS t"; - - $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; @@ -1565,25 +1586,33 @@ class Adapter implements WeChatServiceInterface public function syncTrafficSourceUser() { - $sql = "insert into ck_traffic_source(`identifier`,companyId,`fromd`,`sourceId`,`createTime`,`type`, `status`) - SELECT - f.wechatId identifier, - c.departmentId companyId, - f.ownerNickname fromd, - f.ownerWechatId sourceId, - f.createTime createTime, - 1 as type, - CASE WHEN f.isDeleted = 1 THEN -3 ELSE 3 END as status - FROM - s2_wechat_friend f - LEFT JOIN s2_wechat_account a ON f.wechatAccountId = a.id - LEFT JOIN s2_company_account c on c.id = a.deviceAccountId - ORDER BY f.id DESC - LIMIT ?, ? - ON DUPLICATE KEY UPDATE - identifier=VALUES(identifier), - companyId=VALUES(companyId), - sourceId=VALUES(sourceId)"; + $sql = "INSERT INTO ck_traffic_source_v1( + `identifier`, `companyId`, `fromd`, `sourceId`, `createTime`, `type`, `status`, `updateTime`, `name` + ) + SELECT + f.wechatId AS identifier, + c.departmentId AS companyId, + f.ownerNickname AS fromd, + f.ownerWechatId AS sourceId, + f.createTime AS createTime, + 1 AS type, + CASE WHEN f.isDeleted = 1 THEN -3 ELSE 3 END AS status, + UNIX_TIMESTAMP() AS updateTime, + f.nickname AS name + FROM + s2_wechat_friend f + LEFT JOIN s2_wechat_account a ON f.wechatAccountId = a.id + LEFT JOIN s2_company_account c ON c.id = a.deviceAccountId + WHERE c.departmentId IS NOT NULL + ORDER BY f.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + companyId = VALUES(companyId), + sourceId = VALUES(sourceId), + fromd = VALUES(fromd), + status = VALUES(status), + name = COALESCE(VALUES(name), name), + updateTime = UNIX_TIMESTAMP()"; $offset = 0; @@ -1600,27 +1629,35 @@ class Adapter implements WeChatServiceInterface public function syncTrafficSourceGroup() { - $sql = "insert into ck_traffic_source(`identifier`,companyId,`fromd`,`sourceId`,`createTime`,`type`, `status`) - SELECT - m.wechatId identifier, - c.departmentId companyId, - r.nickname fromd, - m.chatroomId sourceId, - m.createTime createTime, - 2 as type, - CASE WHEN m.friendType = 1 THEN 3 ELSE 1 END as status - FROM - s2_wechat_chatroom_member m - JOIN s2_wechat_chatroom r ON m.chatroomId = r.chatroomId - LEFT JOIN s2_wechat_account a ON a.id = r.wechatAccountId - LEFT JOIN s2_company_account c on c.id = a.deviceAccountId - GROUP BY m.wechatId - ORDER BY m.id DESC - LIMIT ?, ? - ON DUPLICATE KEY UPDATE - identifier=VALUES(identifier), - companyId=VALUES(companyId), - sourceId=VALUES(sourceId)"; + $sql = "INSERT INTO ck_traffic_source_v1( + `identifier`, `companyId`, `fromd`, `sourceId`, `createTime`, `type`, `status`, `updateTime`, `name` + ) + SELECT + m.wechatId AS identifier, + c.departmentId AS companyId, + r.nickname AS fromd, + m.chatroomId AS sourceId, + m.createTime AS createTime, + 2 AS type, + CASE WHEN m.friendType = 1 THEN 3 ELSE 1 END AS status, + UNIX_TIMESTAMP() AS updateTime, + m.nickName AS name + FROM + s2_wechat_chatroom_member m + JOIN s2_wechat_chatroom r ON m.chatroomId = r.chatroomId + LEFT JOIN s2_wechat_account a ON a.id = r.wechatAccountId + LEFT JOIN s2_company_account c ON c.id = a.deviceAccountId + WHERE c.departmentId IS NOT NULL + GROUP BY m.wechatId + ORDER BY m.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + companyId = COALESCE(VALUES(companyId), companyId), + sourceId = COALESCE(VALUES(sourceId), sourceId), + fromd = COALESCE(VALUES(fromd), fromd), + status = VALUES(status), + name = COALESCE(VALUES(name), name), + updateTime = UNIX_TIMESTAMP()"; $offset = 0; @@ -1635,6 +1672,708 @@ class Adapter implements WeChatServiceInterface } while ($affected > 0); } + // ============================================================================ + // V2 流量池数据同步方法 + // ============================================================================ + + /** + * 同步数据到 V2 流量池总表 ck_traffic_pool + * 从 s2_wechat_friend 提取全局唯一的流量标识 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncToTrafficPoolV2($batchSize = 2000) + { + $affectedRows = 0; + $offset = 0; + + try { + do { + // 获取一批去重的 wechatId 及其最新记录ID + $batch = Db::query(" + SELECT f.wechatId, MAX(f.id) AS max_id + FROM s2_wechat_friend f + WHERE f.wechatId IS NOT NULL AND f.wechatId != '' + GROUP BY f.wechatId + ORDER BY MAX(f.id) DESC + LIMIT ?, ? + ", [$offset, $batchSize]); + + if (empty($batch)) { + break; + } + + // 提取最新记录的ID列表 + $maxIds = array_column($batch, 'max_id'); + + if (empty($maxIds)) { + $offset += $batchSize; + continue; + } + + $idList = implode(',', $maxIds); + + // 根据ID列表获取完整记录并插入 + $sql = "INSERT INTO ck_traffic_pool( + `identifier`, `identifierType`, `wechatId`, `wechatAlias`, `mobile`, + `nickname`, `avatar`, `gender`, `region`, `country`, `province`, `city`, + `signature`, `firstSeenTime`, `lastSeenTime`, `createTime`, `updateTime` + ) + SELECT + f.wechatId AS identifier, + 1 AS identifierType, + f.wechatId AS wechatId, + f.alias AS wechatAlias, + f.phone AS mobile, + f.nickname AS nickname, + f.avatar AS avatar, + f.gender AS gender, + f.region AS region, + f.country AS country, + f.privince AS province, + f.city AS city, + f.signature AS signature, + f.createTime AS firstSeenTime, + f.updateTime AS lastSeenTime, + UNIX_TIMESTAMP() AS createTime, + UNIX_TIMESTAMP() AS updateTime + FROM s2_wechat_friend f + WHERE f.id IN ({$idList}) + ON DUPLICATE KEY UPDATE + wechatAlias = COALESCE(VALUES(wechatAlias), ck_traffic_pool.wechatAlias), + mobile = COALESCE(VALUES(mobile), ck_traffic_pool.mobile), + nickname = COALESCE(VALUES(nickname), ck_traffic_pool.nickname), + avatar = COALESCE(VALUES(avatar), ck_traffic_pool.avatar), + gender = COALESCE(VALUES(gender), ck_traffic_pool.gender), + region = COALESCE(VALUES(region), ck_traffic_pool.region), + country = COALESCE(VALUES(country), ck_traffic_pool.country), + province = COALESCE(VALUES(province), ck_traffic_pool.province), + city = COALESCE(VALUES(city), ck_traffic_pool.city), + signature = COALESCE(VALUES(signature), ck_traffic_pool.signature), + lastSeenTime = VALUES(lastSeenTime), + updateTime = UNIX_TIMESTAMP()"; + + $currentAffected = Db::execute($sql); + $affectedRows += $currentAffected; + $offset += $batchSize; + + if ($offset % 10000 == 0) { + gc_collect_cycles(); + } + usleep(30000); + + } while (count($batch) >= $batchSize); + + } catch (\Exception $e) { + Log::error("Error in syncToTrafficPoolV2: " . $e->getMessage()); + throw $e; + } + + return $affectedRows; + } + + /** + * 同步数据到 V2 公司流量详情表 ck_traffic_pool_company + * 按公司维度存储流量详情 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncToTrafficPoolCompanyV2($batchSize = 2000) + { + $affectedRows = 0; + $offset = 0; + $usleepTime = 50000; + + try { + do { + $sql = "INSERT INTO ck_traffic_pool_company( + `poolId`, `identifier`, `companyId`, + `ownerWechatId`, `ownerAccountId`, `ownerUserId`, + `wechatFriendId`, `friendStatus`, `friendPassTime`, + `realName`, `phone`, `remark`, + `level`, `intentionLevel`, + `lastInteractTime`, `rfmF`, `rfmM`, `rfmScore`, `rfmType`, + `totalMsgCount`, `lastMsgTime`, + `firstSourceType`, `firstSourceTime`, + `lifecycle`, `status`, `allocateStatus`, + `createTime`, `updateTime` + ) + SELECT + tp.id AS poolId, + f.wechatId AS identifier, + c.departmentId AS companyId, + f.ownerWechatId AS ownerWechatId, + a.id AS ownerAccountId, + c.id AS ownerUserId, + f.id AS wechatFriendId, + CASE + WHEN f.isDeleted = 1 THEN 3 + WHEN f.isPassed = 1 THEN 2 + ELSE 1 + END AS friendStatus, + f.passTime AS friendPassTime, + f.conRemark AS realName, + f.phone AS phone, + f.`desc` AS remark, + 0 AS level, + 0 AS intentionLevel, + f.updateTime AS lastInteractTime, + COALESCE(f.F, 0) AS rfmF, + COALESCE(f.M, 0) AS rfmM, + 0 AS rfmScore, + NULL AS rfmType, + COALESCE(msg_stats.msgCount, 0) AS totalMsgCount, + msg_stats.lastMsgTime AS lastMsgTime, + 1 AS firstSourceType, + f.createTime AS firstSourceTime, + 1 AS lifecycle, + CASE WHEN f.isDeleted = 1 THEN 0 ELSE 1 END AS status, + 0 AS allocateStatus, + UNIX_TIMESTAMP() AS createTime, + UNIX_TIMESTAMP() AS updateTime + FROM s2_wechat_friend f + JOIN ck_traffic_pool tp ON tp.identifier = f.wechatId + LEFT JOIN s2_wechat_account a ON f.wechatAccountId = a.id + LEFT JOIN s2_company_account c ON c.id = a.deviceAccountId + LEFT JOIN ( + SELECT + wechatFriendId, + COUNT(*) AS msgCount, + MAX(createTime) AS lastMsgTime + FROM s2_wechat_message + WHERE type = 1 AND isDeleted = 0 + GROUP BY wechatFriendId + ) msg_stats ON msg_stats.wechatFriendId = f.id + WHERE c.departmentId IS NOT NULL + ORDER BY f.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + ownerWechatId = VALUES(ownerWechatId), + ownerAccountId = VALUES(ownerAccountId), + ownerUserId = VALUES(ownerUserId), + wechatFriendId = VALUES(wechatFriendId), + friendStatus = VALUES(friendStatus), + friendPassTime = COALESCE(VALUES(friendPassTime), ck_traffic_pool_company.friendPassTime), + realName = COALESCE(VALUES(realName), ck_traffic_pool_company.realName), + phone = COALESCE(VALUES(phone), ck_traffic_pool_company.phone), + remark = COALESCE(VALUES(remark), ck_traffic_pool_company.remark), + lastInteractTime = GREATEST(COALESCE(ck_traffic_pool_company.lastInteractTime, 0), COALESCE(VALUES(lastInteractTime), 0)), + rfmF = COALESCE(VALUES(rfmF), ck_traffic_pool_company.rfmF), + rfmM = COALESCE(VALUES(rfmM), ck_traffic_pool_company.rfmM), + totalMsgCount = GREATEST(COALESCE(ck_traffic_pool_company.totalMsgCount, 0), COALESCE(VALUES(totalMsgCount), 0)), + lastMsgTime = GREATEST(COALESCE(ck_traffic_pool_company.lastMsgTime, 0), COALESCE(VALUES(lastMsgTime), 0)), + status = VALUES(status), + updateTime = UNIX_TIMESTAMP()"; + + $affected = Db::execute($sql, [$offset, $batchSize]); + $affectedRows += $affected; + $offset += $batchSize; + + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + + } catch (\Exception $e) { + Log::error("Error in syncToTrafficPoolCompanyV2: " . $e->getMessage()); + throw $e; + } + + return $affectedRows; + } + + /** + * 同步数据到 V2 流量来源表 ck_traffic_pool_source + * 记录流量的获取渠道和来源路径 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncToTrafficPoolSourceV2($batchSize = 2000) + { + $affectedRows = 0; + $lastId = PHP_INT_MAX; // 使用游标分页,从最大ID开始往前扫描 + $usleepTime = 50000; + + try { + // 同步好友来源 + // 优化点: + // 1. 子查询改为 JOIN,避免每行都执行子查询 + // 2. OFFSET 分页改为游标分页(基于 ID),避免深度分页性能问题 + do { + $sql = "INSERT INTO ck_traffic_pool_source( + `poolCompanyId`, `identifier`, `companyId`, + `sourceType`, `sourceSubType`, + `sourceId`, `sourceName`, `sourceWechatId`, + `isFirstSource`, `extra`, `remark`, + `createTime`, `updateTime` + ) + SELECT + tpc.id AS poolCompanyId, + f.wechatId AS identifier, + c.departmentId AS companyId, + 1 AS sourceType, + 'friend_add' AS sourceSubType, + f.ownerWechatId AS sourceId, + f.ownerNickname AS sourceName, + f.ownerWechatId AS sourceWechatId, + 1 AS isFirstSource, + NULL AS extra, + f.`desc` AS remark, + f.createTime AS createTime, + UNIX_TIMESTAMP() AS updateTime + FROM s2_wechat_friend f + INNER JOIN s2_wechat_account a ON f.wechatAccountId = a.id + INNER JOIN s2_company_account c ON c.id = a.deviceAccountId + INNER JOIN ck_traffic_pool_company tpc ON tpc.identifier = f.wechatId AND tpc.companyId = c.departmentId + WHERE c.departmentId IS NOT NULL + AND f.id < ? + ORDER BY f.id DESC + LIMIT ? + ON DUPLICATE KEY UPDATE + sourceName = COALESCE(VALUES(sourceName), ck_traffic_pool_source.sourceName), + remark = COALESCE(VALUES(remark), ck_traffic_pool_source.remark), + updateTime = UNIX_TIMESTAMP()"; + + // 先查询本批次最小ID,用于下一轮游标 + $minIdSql = "SELECT MIN(batch.id) as minId FROM ( + SELECT f.id AS id + FROM s2_wechat_friend f + INNER JOIN s2_wechat_account a ON f.wechatAccountId = a.id + INNER JOIN s2_company_account c ON c.id = a.deviceAccountId + INNER JOIN ck_traffic_pool_company tpc ON tpc.identifier = f.wechatId AND tpc.companyId = c.departmentId + WHERE c.departmentId IS NOT NULL + AND f.id < ? + ORDER BY f.id DESC + LIMIT ? + ) AS batch"; + + $minIdResult = Db::query($minIdSql, [$lastId, $batchSize]); + $minId = $minIdResult[0]['minId'] ?? null; + + if ($minId === null) { + break; // 没有更多数据 + } + + $affected = Db::execute($sql, [$lastId, $batchSize]); + $affectedRows += $affected; + $lastId = $minId; // 游标移动到本批次最小ID + + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + + } catch (\Exception $e) { + Log::error("Error in syncToTrafficPoolSourceV2: " . $e->getMessage()); + throw $e; + } + + return $affectedRows; + } + + /** + * 同步群成员到 V2 流量池总表 ck_traffic_pool + * 将群成员的 wechatId 作为流量标识入池 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncChatroomMembersToTrafficPoolV2($batchSize = 2000) + { + $affectedRows = 0; + $offset = 0; + + try { + do { + // 获取一批去重的群成员 wechatId 及其最新记录ID + $batch = Db::query(" + SELECT m.wechatId, MAX(m.id) AS max_id + FROM s2_wechat_chatroom_member m + WHERE m.wechatId IS NOT NULL AND m.wechatId != '' + AND m.wechatId NOT IN (SELECT identifier FROM ck_traffic_pool) + GROUP BY m.wechatId + ORDER BY MAX(m.id) DESC + LIMIT ?, ? + ", [$offset, $batchSize]); + + if (empty($batch)) { + break; + } + + $maxIds = array_column($batch, 'max_id'); + + if (empty($maxIds)) { + $offset += $batchSize; + continue; + } + + $idList = implode(',', $maxIds); + + // 根据ID列表获取完整记录并插入 + $sql = "INSERT INTO ck_traffic_pool( + `identifier`, `identifierType`, `wechatId`, `wechatAlias`, + `nickname`, `avatar`, `firstSeenTime`, `lastSeenTime`, + `createTime`, `updateTime` + ) + SELECT + m.wechatId AS identifier, + 1 AS identifierType, + m.wechatId AS wechatId, + m.alias AS wechatAlias, + m.nickname AS nickname, + m.avatar AS avatar, + m.createTime AS firstSeenTime, + m.updateTime AS lastSeenTime, + UNIX_TIMESTAMP() AS createTime, + UNIX_TIMESTAMP() AS updateTime + FROM s2_wechat_chatroom_member m + WHERE m.id IN ({$idList}) + ON DUPLICATE KEY UPDATE + wechatAlias = COALESCE(VALUES(wechatAlias), ck_traffic_pool.wechatAlias), + nickname = COALESCE(VALUES(nickname), ck_traffic_pool.nickname), + avatar = COALESCE(VALUES(avatar), ck_traffic_pool.avatar), + lastSeenTime = GREATEST(COALESCE(ck_traffic_pool.lastSeenTime, 0), COALESCE(VALUES(lastSeenTime), 0)), + updateTime = UNIX_TIMESTAMP()"; + + $currentAffected = Db::execute($sql); + $affectedRows += $currentAffected; + $offset += $batchSize; + + if ($offset % 10000 == 0) { + gc_collect_cycles(); + } + usleep(30000); + + } while (count($batch) >= $batchSize); + + } catch (\Exception $e) { + Log::error("Error in syncChatroomMembersToTrafficPoolV2: " . $e->getMessage()); + throw $e; + } + + return $affectedRows; + } + + /** + * 同步群成员到 V2 公司流量详情表 ck_traffic_pool_company + * 通过群的 accountId 关联到公司 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncChatroomMembersToTrafficPoolCompanyV2($batchSize = 2000) + { + $affectedRows = 0; + $offset = 0; + $usleepTime = 50000; + + try { + do { + $sql = "INSERT INTO ck_traffic_pool_company( + `poolId`, `identifier`, `companyId`, + `ownerWechatId`, `ownerAccountId`, `ownerUserId`, + `friendStatus`, `lifecycle`, `status`, `allocateStatus`, + `firstSourceType`, `firstSourceTime`, + `createTime`, `updateTime` + ) + SELECT + tp.id AS poolId, + m.wechatId AS identifier, + c.departmentId AS companyId, + g.wechatAccountWechatId AS ownerWechatId, + a.id AS ownerAccountId, + c.id AS ownerUserId, + 0 AS friendStatus, + 1 AS lifecycle, + 1 AS status, + 0 AS allocateStatus, + 2 AS firstSourceType, + m.createTime AS firstSourceTime, + UNIX_TIMESTAMP() AS createTime, + UNIX_TIMESTAMP() AS updateTime + FROM s2_wechat_chatroom_member m + INNER JOIN s2_wechat_chatroom g ON m.chatroomId = g.chatroomId + INNER JOIN ck_traffic_pool tp ON tp.identifier = m.wechatId + INNER JOIN s2_wechat_account a ON g.wechatAccountId = a.id + INNER JOIN s2_company_account c ON c.id = a.deviceAccountId + WHERE c.departmentId IS NOT NULL + ORDER BY m.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + ownerWechatId = COALESCE(VALUES(ownerWechatId), ck_traffic_pool_company.ownerWechatId), + ownerAccountId = COALESCE(VALUES(ownerAccountId), ck_traffic_pool_company.ownerAccountId), + ownerUserId = COALESCE(VALUES(ownerUserId), ck_traffic_pool_company.ownerUserId), + updateTime = UNIX_TIMESTAMP()"; + + $affected = Db::execute($sql, [$offset, $batchSize]); + $affectedRows += $affected; + $offset += $batchSize; + + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + + } catch (\Exception $e) { + Log::error("Error in syncChatroomMembersToTrafficPoolCompanyV2: " . $e->getMessage()); + throw $e; + } + + return $affectedRows; + } + + /** + * 同步群成员来源记录到 V2 流量来源表 ck_traffic_pool_source + * sourceType=2 表示群成员来源 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncChatroomMembersToTrafficPoolSourceV2($batchSize = 2000) + { + $affectedRows = 0; + $lastId = PHP_INT_MAX; + $usleepTime = 50000; + + try { + do { + $sql = "INSERT INTO ck_traffic_pool_source( + `poolCompanyId`, `identifier`, `companyId`, + `sourceType`, `sourceSubType`, + `sourceId`, `sourceName`, `sourceWechatId`, `sourceChatroomId`, + `isFirstSource`, `extra`, `remark`, + `createTime`, `updateTime` + ) + SELECT + tpc.id AS poolCompanyId, + m.wechatId AS identifier, + c.departmentId AS companyId, + 2 AS sourceType, + 'chatroom_member' AS sourceSubType, + g.chatroomId AS sourceId, + g.nickname AS sourceName, + g.wechatAccountWechatId AS sourceWechatId, + g.chatroomId AS sourceChatroomId, + CASE WHEN tpc.firstSourceType = 2 THEN 1 ELSE 0 END AS isFirstSource, + NULL AS extra, + m.conRemark AS remark, + m.createTime AS createTime, + UNIX_TIMESTAMP() AS updateTime + FROM s2_wechat_chatroom_member m + INNER JOIN s2_wechat_chatroom g ON m.chatroomId = g.chatroomId + INNER JOIN s2_wechat_account a ON g.wechatAccountId = a.id + INNER JOIN s2_company_account c ON c.id = a.deviceAccountId + INNER JOIN ck_traffic_pool_company tpc ON tpc.identifier = m.wechatId AND tpc.companyId = c.departmentId + WHERE c.departmentId IS NOT NULL + AND m.id < ? + ORDER BY m.id DESC + LIMIT ? + ON DUPLICATE KEY UPDATE + sourceName = COALESCE(VALUES(sourceName), ck_traffic_pool_source.sourceName), + remark = COALESCE(VALUES(remark), ck_traffic_pool_source.remark), + updateTime = UNIX_TIMESTAMP()"; + + // 先查询本批次最小ID,用于下一轮游标 + $minIdSql = "SELECT MIN(batch.id) as minId FROM ( + SELECT m.id AS id + FROM s2_wechat_chatroom_member m + INNER JOIN s2_wechat_chatroom g ON m.chatroomId = g.chatroomId + INNER JOIN s2_wechat_account a ON g.wechatAccountId = a.id + INNER JOIN s2_company_account c ON c.id = a.deviceAccountId + INNER JOIN ck_traffic_pool_company tpc ON tpc.identifier = m.wechatId AND tpc.companyId = c.departmentId + WHERE c.departmentId IS NOT NULL + AND m.id < ? + ORDER BY m.id DESC + LIMIT ? + ) AS batch"; + + $minIdResult = Db::query($minIdSql, [$lastId, $batchSize]); + $minId = $minIdResult[0]['minId'] ?? null; + + if ($minId === null) { + break; + } + + $affected = Db::execute($sql, [$lastId, $batchSize]); + $affectedRows += $affected; + $lastId = $minId; + + if ($affected > 0) { + usleep($usleepTime); + } + } while (true); + + } catch (\Exception $e) { + Log::error("Error in syncChatroomMembersToTrafficPoolSourceV2: " . $e->getMessage()); + throw $e; + } + + return $affectedRows; + } + + /** + * 同步微信标签到 V2 标签系统 + * 从 s2_wechat_friend.labels 同步标签 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncWechatTagsToV2($batchSize = 1000) + { + $affectedRows = 0; + $offset = 0; + + try { + // 获取有标签的好友记录(使用原生SQL避免表前缀问题) + do { + $sql = "SELECT + tpc.id as poolCompanyId, + f.wechatId as identifier, + c.departmentId as companyId, + f.labels + FROM s2_wechat_friend f + INNER JOIN ck_traffic_pool_company tpc ON tpc.identifier = f.wechatId + INNER JOIN s2_wechat_account a ON f.wechatAccountId = a.id + INNER JOIN s2_company_account c ON c.id = a.deviceAccountId + WHERE f.labels IS NOT NULL + AND f.labels <> '' + AND f.labels <> '[]' + AND c.departmentId IS NOT NULL + AND tpc.companyId = c.departmentId + ORDER BY f.id DESC + LIMIT ?, ?"; + + $friends = Db::query($sql, [$offset, $batchSize]); + + if (empty($friends)) { + break; + } + + foreach ($friends as $friend) { + $labels = json_decode($friend['labels'], true); + if (empty($labels) || !is_array($labels)) { + continue; + } + + foreach ($labels as $label) { + if (empty($label)) { + continue; + } + + // 先确保标签定义存在 + $tagCode = 'wechat_' . md5($label); + $tagDefine = Db::name('traffic_pool_tag_define') + ->where(['companyId' => 0, 'tagCode' => $tagCode]) + ->find(); + + if (!$tagDefine) { + // 创建标签定义 + $tagDefineId = Db::name('traffic_pool_tag_define')->insertGetId([ + 'companyId' => 0, + 'categoryId' => 1, // 微信默认标签类目 + 'tagType' => 1, + 'tagCode' => $tagCode, + 'tagName' => $label, + 'isSystem' => 0, + 'syncFromWechat' => 1, + 'status' => 1, + 'createTime' => time(), + ]); + } else { + $tagDefineId = $tagDefine['id']; + } + + // 创建标签关联 + try { + Db::name('traffic_pool_tag')->insert([ + 'poolCompanyId' => $friend['poolCompanyId'], + 'identifier' => $friend['identifier'], + 'companyId' => $friend['companyId'], + 'tagDefineId' => $tagDefineId, + 'tagType' => 1, + 'categoryId' => 1, + 'tagName' => $label, + 'source' => 4, // 微信同步 + 'createTime' => time(), + ]); + $affectedRows++; + } catch (\Exception $e) { + // 忽略重复插入错误 + if (strpos($e->getMessage(), 'Duplicate entry') === false) { + throw $e; + } + } + } + } + + $offset += $batchSize; + usleep(50000); + + } while (count($friends) >= $batchSize); + + } catch (\Exception $e) { + Log::error("Error in syncWechatTagsToV2: " . $e->getMessage()); + throw $e; + } + + return $affectedRows; + } + + /** + * 执行完整的 V2 流量池数据迁移 + * 按顺序调用各个同步方法 + * + * @return array 各步骤的影响行数 + */ + public function migrateToTrafficPoolV2() + { + $results = [ + 'traffic_pool' => 0, + 'traffic_pool_company' => 0, + 'traffic_pool_source' => 0, + 'traffic_pool_tags' => 0, + ]; + + try { + Log::info("开始迁移 V2 流量池数据..."); + + // 1. 同步流量池总表 + Log::info("Step 1: 同步流量池总表"); + $results['traffic_pool'] = $this->syncToTrafficPoolV2(); + Log::info("流量池总表同步完成,影响行数: " . $results['traffic_pool']); + + // 2. 同步公司流量详情表 + Log::info("Step 2: 同步公司流量详情表"); + $results['traffic_pool_company'] = $this->syncToTrafficPoolCompanyV2(); + Log::info("公司流量详情表同步完成,影响行数: " . $results['traffic_pool_company']); + + // 3. 同步流量来源表 + Log::info("Step 3: 同步流量来源表"); + $results['traffic_pool_source'] = $this->syncToTrafficPoolSourceV2(); + Log::info("流量来源表同步完成,影响行数: " . $results['traffic_pool_source']); + + // 4. 同步微信标签 + Log::info("Step 4: 同步微信标签"); + $results['traffic_pool_tags'] = $this->syncWechatTagsToV2(); + Log::info("微信标签同步完成,影响行数: " . $results['traffic_pool_tags']); + + Log::info("V2 流量池数据迁移完成"); + + } catch (\Exception $e) { + Log::error("V2 流量池数据迁移异常: " . $e->getMessage()); + throw $e; + } + + return $results; + } + + // ============================================================================ + // 原有方法 + // ============================================================================ + public function syncWechatGroup() { $sql = "insert into ck_wechat_group(`id`,`wechatAccountId`,`chatroomId`,`name`,`avatar`,`companyId`,`ownerWechatId`,`createTime`,`updateTime`,`deleteTime`)