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