新版流量池提交

This commit is contained in:
wong
2026-02-04 11:02:33 +08:00
parent a20794366a
commit 2855ab80fb
68 changed files with 8957 additions and 714 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
// 获取并更新微信账号状态信息

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -127,6 +127,27 @@ class ReplyController extends BaseController
if ($title === '') {
return ResponseHelper::error('标题不能为空');
}
if ($content === '') {
return ResponseHelper::error('内容不能为空');
}
// 根据 msgType 处理 content3=图片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 处理 content3=图片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 解析 content3=图片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' => [] // 子分组
];
}

View File

@@ -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']) : '';

View File

@@ -50,4 +50,7 @@ return [
// 检查未读/未回复消息并自动迁移好友
'check:unread-message' => 'app\command\CheckUnreadMessageCommand', // 检查未读/未回复消息并自动迁移好友
// V2 流量池数据迁移
'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统
];

View File

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

View File

@@ -0,0 +1,188 @@
<?php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\facade\App;
use think\facade\Log;
/**
* 清除过期日志文件命令
*
* 使用方法:
* php think clean:logs # 使用默认保留10天
* php think clean:logs --days=7 # 保留7天
* php think clean:logs --days=30 # 保留30天
* php think clean:logs --dry-run # 预览模式,不实际删除
*/
class CleanLogsCommand extends Command
{
protected function configure()
{
$this->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('<error>保留天数必须大于0</error>');
return false;
}
if ($dryRun) {
$output->writeln('<info>运行在预览模式,不会实际删除文件</info>');
}
$output->writeln("<info>====================================</info>");
$output->writeln("<info> 清除过期日志文件</info>");
$output->writeln("<info>====================================</info>");
$output->writeln("保留天数: {$days}");
$output->writeln("");
// 获取日志目录
$logPath = App::getRuntimePath() . 'log' . DIRECTORY_SEPARATOR;
if (!is_dir($logPath)) {
$output->writeln("<comment>日志目录不存在: {$logPath}</comment>");
return false;
}
// 计算截止时间(保留指定天数之前的日志)
$cutoffTime = time() - ($days * 24 * 60 * 60);
$cutoffDate = date('Y-m-d H:i:s', $cutoffTime);
$output->writeln("<comment>清除 {$cutoffDate} 之前的日志文件</comment>");
$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('<error>清除日志时发生错误: ' . $e->getMessage() . '</error>');
Log::error('清除日志失败: ' . $e->getMessage());
return false;
}
// 输出统计信息
$output->writeln("");
$output->writeln("<info>====================================</info>");
$output->writeln("<info> 清除完成</info>");
$output->writeln("<info>====================================</info>");
$output->writeln("扫描文件数: {$totalFiles}");
$output->writeln("删除文件数: {$deletedFiles}");
$output->writeln("释放空间: " . $this->formatBytes($freedSize));
if ($dryRun) {
$output->writeln("");
$output->writeln("<comment>预览模式:实际未删除任何文件</comment>");
}
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("<comment>[预览] 将删除: {$path} (" . date('Y-m-d H:i:s', $fileMTime) . ", " . $this->formatBytes($fileSize) . ")</comment>");
} else {
if (@unlink($path)) {
$deleted++;
$output->writeln("<info>已删除: {$path}</info>");
} else {
$output->writeln("<error>删除失败: {$path}</error>");
}
}
}
}
}
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];
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace app\command;
use think\facade\Log;
use think\console\Input;
use think\console\Output;
use think\console\Command;
use think\console\input\Option;
use think\facade\App;
use WeChatDeviceApi\Adapters\ChuKeBao\Adapter as ChuKeBaoAdapter;
/**
* V2 流量池数据迁移命令
*
* 使用方法:
* php think migrate:trafficPoolV2 # 执行完整迁移
* php think migrate:trafficPoolV2 --step=1 # 只执行第1步好友同步到流量池总表
* php think migrate:trafficPoolV2 --step=2 # 只执行第2步好友同步到公司流量详情表
* php think migrate:trafficPoolV2 --step=3 # 只执行第3步好友同步到流量来源表
* php think migrate:trafficPoolV2 --step=4 # 只执行第4步群成员同步到流量池总表
* php think migrate:trafficPoolV2 --step=5 # 只执行第5步群成员同步到公司流量详情表
* php think migrate:trafficPoolV2 --step=6 # 只执行第6步群成员同步到流量来源表
* php think migrate:trafficPoolV2 --step=7 # 只执行第7步同步微信标签
*
* 执行前请确保已运行 SQL 迁移脚本创建了 V2 版本的表
*/
class MigrateTrafficPoolV2Command extends Command
{
protected $lockFile;
public function __construct()
{
parent::__construct();
$this->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('<error>迁移任务已在运行中,跳过本次执行</error>');
return false;
}
unlink($this->lockFile);
}
file_put_contents($this->lockFile, time());
try {
$step = $input->getOption('step');
$adapter = new ChuKeBaoAdapter();
$output->writeln('<info>====================================</info>');
$output->writeln('<info> V2 流量池数据迁移开始</info>');
$output->writeln('<info>====================================</info>');
$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('<info>====================================</info>');
$output->writeln('<info> 迁移完成</info>');
$output->writeln('<info>====================================</info>');
$output->writeln("耗时: {$duration}");
$output->writeln('');
$output->writeln('<comment>结果统计:</comment>');
foreach ($results as $key => $value) {
$output->writeln(" - {$key}: {$value}");
}
return true;
} catch (\Exception $e) {
$output->writeln('<error>迁移异常: ' . $e->getMessage() . '</error>');
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('<comment>【好友数据迁移】</comment>');
// Step 1: 好友同步到流量池总表
$output->writeln('<comment>[1/7] 同步好友到流量池总表 ck_traffic_pool ...</comment>');
$results['friend_pool'] = $adapter->syncToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool']}</info>");
// Step 2: 好友同步到公司流量详情表
$output->writeln('<comment>[2/7] 同步好友到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_company']}</info>");
// Step 3: 好友同步到流量来源表
$output->writeln('<comment>[3/7] 同步好友到流量来源表 ck_traffic_pool_source ...</comment>');
$results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_source']}</info>");
// === 群成员数据迁移 ===
$output->writeln('');
$output->writeln('<comment>【群成员数据迁移】</comment>');
// Step 4: 群成员同步到流量池总表
$output->writeln('<comment>[4/7] 同步群成员到流量池总表 ck_traffic_pool ...</comment>');
$results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool']}</info>");
// Step 5: 群成员同步到公司流量详情表
$output->writeln('<comment>[5/7] 同步群成员到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_company']}</info>");
// Step 6: 群成员同步到流量来源表
$output->writeln('<comment>[6/7] 同步群成员到流量来源表 ck_traffic_pool_source ...</comment>');
$results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_source']}</info>");
// === 标签数据迁移 ===
$output->writeln('');
$output->writeln('<comment>【标签数据迁移】</comment>');
// Step 7: 同步微信标签
$output->writeln('<comment>[7/7] 同步微信标签 ck_traffic_pool_tag ...</comment>');
$results['pool_tags'] = $adapter->syncWechatTagsToV2();
$output->writeln("<info> 完成,影响行数: {$results['pool_tags']}</info>");
return $results;
}
/**
* 执行指定步骤
*/
protected function runStep(int $step, ChuKeBaoAdapter $adapter, Output $output)
{
$results = [];
switch ($step) {
case 1:
$output->writeln('<comment>[Step 1] 同步好友到流量池总表 ck_traffic_pool ...</comment>');
$results['friend_pool'] = $adapter->syncToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool']}</info>");
break;
case 2:
$output->writeln('<comment>[Step 2] 同步好友到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_company']}</info>");
break;
case 3:
$output->writeln('<comment>[Step 3] 同步好友到流量来源表 ck_traffic_pool_source ...</comment>');
$results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_source']}</info>");
break;
case 4:
$output->writeln('<comment>[Step 4] 同步群成员到流量池总表 ck_traffic_pool ...</comment>');
$results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool']}</info>");
break;
case 5:
$output->writeln('<comment>[Step 5] 同步群成员到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_company']}</info>");
break;
case 6:
$output->writeln('<comment>[Step 6] 同步群成员到流量来源表 ck_traffic_pool_source ...</comment>');
$results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_source']}</info>");
break;
case 7:
$output->writeln('<comment>[Step 7] 同步微信标签 ck_traffic_pool_tag ...</comment>');
$results['pool_tags'] = $adapter->syncWechatTagsToV2();
$output->writeln("<info> 完成,影响行数: {$results['pool_tags']}</info>");
break;
default:
$output->writeln('<error>无效的步骤编号,请输入 1-7</error>');
$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;
}
}

View File

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

View File

@@ -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("<info>找到配置文件:{$configFile}</info>");
$config = include $configFile;
if (is_array($config) && !empty($config)) {
$this->tasks = $config;
break;
} else {
$output->writeln("<error>配置文件返回的不是数组或为空:{$configFile}</error>");
}
if (is_file($configFile)) {
$output->writeln("<info>找到配置文件:{$configFile}</info>");
$config = include $configFile;
if (is_array($config) && !empty($config)) {
$this->tasks = $config;
} else {
$output->writeln("<error>配置文件返回的不是数组或为空:{$configFile}</error>");
}
}
}
@@ -99,22 +97,21 @@ class TaskSchedulerCommand extends Command
$output->writeln('<error>1. config/task_scheduler.php 文件是否存在</error>');
$output->writeln('<error>2. 文件是否返回有效的数组</error>');
$output->writeln('<error>3. 文件权限是否正确</error>');
if (defined('ROOT_PATH')) {
$output->writeln('<error>项目根目录' . ROOT_PATH . '</error>');
$output->writeln('<error>期望配置文件:' . ROOT_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php</error>');
}
$output->writeln('<error>项目根目录:' . $rootPath . '</error>');
$output->writeln('<error>期望配置文件' . $rootPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php</error>');
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("<error>错误:任务 {$testTaskId} 不存在</error>");
$output->writeln("<info>可用任务列表:</info>");
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("<info>任务 {$taskId} 符合执行条件schedule: {$task['schedule']}</info>");
$task = $this->tasks[$testTaskId];
if (!isset($task['enabled']) || !$task['enabled']) {
$output->writeln("<error>错误:任务 {$testTaskId} 已禁用</error>");
return false;
}
$taskName = $task['name'] ?? $testTaskId;
$output->writeln("<info>测试模式:执行任务 {$taskName} ({$testTaskId})</info>");
$output->writeln("<comment>注意:测试模式会忽略 Cron 表达式,直接执行任务</comment>");
$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("<info>强制模式:任务 {$taskName} ({$taskId}) 将被执行</info>");
} elseif ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) {
$tasksToRun[$taskId] = $task;
$taskName = $task['name'] ?? $taskId;
$output->writeln("<info>任务 {$taskName} ({$taskId}) 符合执行条件schedule: {$task['schedule']}</info>");
}
}
$output->writeln("已启用任务数: {$enabledCount},已禁用任务数: {$disabledCount}");
if (empty($tasksToRun)) {
$output->writeln('<info>当前时间没有需要执行的任务</info>');
if (!$force) {
$output->writeln('<info>提示:使用 --force 参数可以强制执行所有启用的任务</info>');
}
return true;
}
$output->writeln("找到 " . count($tasksToRun) . " 个需要执行的任务");
}
$output->writeln("已启用任务数: {$enabledCount},已禁用任务数: {$disabledCount}");
if (empty($tasksToRun)) {
$output->writeln('<info>当前时间没有需要执行的任务</info>');
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("<comment>任务 {$taskId} 正在运行中PID: {$lockPid}),跳过</comment>");
continue;
} else {
// 进程不存在,清除锁
Cache::rm($lockKey);
Cache::rm("scheduler_task_pid:{$taskId}");
}
} else {
// 如果没有 posix_kill使用时间判断2分钟内不重复执行
if ((time() - $lockTime) < 120) {
$output->writeln("<comment>任务 {$taskId} 可能在运行中2分钟内执行过跳过</comment>");
continue;
}
}
} else {
// 如果没有PID记录使用时间判断2分钟内不重复执行
if ((time() - $lockTime) < 120) {
$output->writeln("<comment>任务 {$taskId} 可能在运行中2分钟内执行过跳过</comment>");
continue;
}
}
// 检查任务是否已经在运行(使用文件锁,更可靠
if ($this->isTaskRunning($taskId)) {
$taskName = $task['name'] ?? $taskId;
$output->writeln("<comment>任务 {$taskName} ({$taskId}) 正在运行中,跳过</comment>");
continue;
}
// 创建子进程
@@ -336,8 +333,9 @@ class TaskSchedulerCommand extends Command
if ($pid == -1) {
// 创建进程失败
$output->writeln("<error>创建子进程失败:{$taskId}</error>");
Log::error("任务调度器:创建子进程失败", ['task' => $taskId]);
$taskName = $task['name'] ?? $taskId;
$output->writeln("<error>创建子进程失败:{$taskName} ({$taskId})</error>");
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("<info>启动任务:{$taskId} (PID: {$pid})</info>");
$taskName = $task['name'] ?? $taskId;
$output->writeln("<info>启动任务:{$taskName} ({$taskId}) (PID: {$pid})</info>");
// 设置任务锁和PID
Cache::set($lockKey, time(), 600); // 10分钟过期
Cache::set("scheduler_task_pid:{$taskId}", $pid, 600); // 保存PID10分钟过期
// 创建任务锁文件
$this->createLock($taskId, $pid);
}
}
@@ -375,13 +373,14 @@ class TaskSchedulerCommand extends Command
$output->writeln('<info>使用单进程顺序执行任务</info>');
foreach ($tasks as $taskId => $task) {
$output->writeln("<info>执行任务:{$taskId}</info>");
$taskName = $task['name'] ?? $taskId;
$output->writeln("<info>执行任务:{$taskName} ({$taskId})</info>");
$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);
}
}
}

View File

@@ -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';
// 自动写入时间戳

View File

@@ -0,0 +1,168 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量分配记录表模型类
* 表名ck_traffic_pool_allot_record
* 用途:记录流量的分配历史
*/
class TrafficPoolAllotRecord extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_allot_record';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 分配类型常量
const ALLOT_TYPE_FIRST = 1; // 首次分配
const ALLOT_TYPE_REASSIGN = 2; // 重新分配
const ALLOT_TYPE_RECYCLE = 3; // 回收后分配
// 状态常量
const STATUS_ACTIVE = 1; // 生效中
const STATUS_EXPIRED = 2; // 已过期
const STATUS_RECYCLED = 3; // 已回收
/**
* 关联公司流量详情
*/
public function poolCompany()
{
return $this->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();
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量行为表模型类
* 表名ck_traffic_pool_behavior
* 用途:记录流量的各种行为(包括所有消息互动)
*/
class TrafficPoolBehavior extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_behavior';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'createTime';
protected $createTime = 'createTime';
protected $updateTime = false;
// 行为类型常量
const BEHAVIOR_TYPE_SEND_MSG = 1; // 发送消息
const BEHAVIOR_TYPE_RECEIVE_MSG = 2; // 接收消息
const BEHAVIOR_TYPE_VIEW = 3; // 浏览
const BEHAVIOR_TYPE_CLICK = 4; // 点击
const BEHAVIOR_TYPE_CONSULT = 5; // 咨询
const BEHAVIOR_TYPE_ORDER = 6; // 下单
const BEHAVIOR_TYPE_PAY = 7; // 支付
const BEHAVIOR_TYPE_REFUND = 8; // 退款
const BEHAVIOR_TYPE_LIKE_MOMENTS = 9; // 点赞朋友圈
const BEHAVIOR_TYPE_COMMENT_MOMENTS = 10; // 评论朋友圈
// 行为类型名称映射
const BEHAVIOR_TYPE_NAMES = [
self::BEHAVIOR_TYPE_SEND_MSG => '发送消息',
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
];
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace app\common\model;
use think\Model;
use think\Db;
/**
* 公司流量详情表模型类
* 表名ck_traffic_pool_company
* 用途:存储流量在各公司的详细信息,支持多租户
*/
class TrafficPoolCompany extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_company';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 好友状态常量
const FRIEND_STATUS_NOT_ADDED = 0; // 未加
const FRIEND_STATUS_PENDING = 1; // 待通过
const FRIEND_STATUS_PASSED = 2; // 已通过
const FRIEND_STATUS_DELETED = 3; // 已删除(我删除对方)
const FRIEND_STATUS_BE_DELETED = 4; // 被删除(对方删除我)
// 客户等级常量
const LEVEL_NORMAL = 0; // 普通
const LEVEL_IMPORTANT = 1; // 重要
const LEVEL_VIP = 2; // VIP
// 意向度常量
const INTENTION_UNKNOWN = 0; // 未知
const INTENTION_LOW = 1; // 低
const INTENTION_MEDIUM = 2; // 中
const INTENTION_HIGH = 3; // 高
// 生命周期常量
const LIFECYCLE_NEW = 1; // 新流量
const LIFECYCLE_FOLLOWING = 2; // 跟进中
const LIFECYCLE_CONVERTED = 3; // 已成交
const LIFECYCLE_SILENT = 4; // 沉默
const LIFECYCLE_LOST = 5; // 流失
// 状态常量
const STATUS_DISABLED = 0; // 禁用
const STATUS_NORMAL = 1; // 正常
const STATUS_BLACKLIST = 2; // 黑名单
// 分配状态常量
const ALLOCATE_STATUS_NOT = 0; // 未分配
const ALLOCATE_STATUS_ALLOCATED = 1; // 已分配
const ALLOCATE_STATUS_RECYCLED = 2; // 已回收
/**
* 关联流量池总表
*/
public function pool()
{
return $this->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;
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量池分组表模型类
* 表名ck_traffic_pool_group
* 用途:管理流量池分组(如:高价值客户池、潜在客户池等)
*/
class TrafficPoolGroup extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_group';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 规则类型常量
const RULE_TYPE_DYNAMIC = 1; // 动态规则
const RULE_TYPE_MANUAL = 2; // 手动添加
// 状态常量
const STATUS_DISABLED = 0;
const STATUS_ENABLED = 1;
// 系统默认分组编码
const GROUP_CODE_ALL_FRIENDS = 'all_friends'; // 全部好友流量池
const GROUP_CODE_HIGH_VALUE = 'high_value'; // 高价值客户池
const GROUP_CODE_POTENTIAL = 'potential'; // 潜在客户池
const GROUP_CODE_HIGH_INTERACT = 'high_interact'; // 高互动客户池
/**
* 关联分组成员
*/
public function members()
{
return $this->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];
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量池分组成员表模型类
* 表名ck_traffic_pool_group_member
* 用途手动添加到分组的成员ruleType=2时使用
*/
class TrafficPoolGroupMember extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_group_member';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'createTime';
protected $createTime = 'createTime';
protected $updateTime = false;
// 添加方式常量
const ADD_TYPE_MANUAL = 1; // 手动
const ADD_TYPE_IMPORT = 2; // 批量导入
/**
* 关联分组
*/
public function group()
{
return $this->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;
}
}

View File

@@ -0,0 +1,474 @@
<?php
namespace app\common\model;
use think\Model;
use think\Db;
/**
* 流量来源表模型类
* 表名ck_traffic_pool_source
* 用途:记录流量的获取渠道和来源路径
*/
class TrafficPoolSource extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_source';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 来源类型常量
const SOURCE_TYPE_FRIEND_ADD = 1; // 好友添加
const SOURCE_TYPE_GROUP_MEMBER = 2; // 群成员
const SOURCE_TYPE_POSTER = 3; // 海报获客
const SOURCE_TYPE_PHONE = 4; // 电话获客
const SOURCE_TYPE_ORDER = 5; // 订单获客
const SOURCE_TYPE_API = 6; // API导入
const SOURCE_TYPE_MANUAL = 7; // 手动导入
const SOURCE_TYPE_FISSION = 8; // 裂变活动
// 来源类型名称映射
const SOURCE_TYPE_NAMES = [
self::SOURCE_TYPE_FRIEND_ADD => '好友添加',
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
];
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量标签关联表模型类
* 表名ck_traffic_pool_tag
* 用途:记录流量与标签的关联关系
*/
class TrafficPoolTag extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_tag';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标签类型常量
const TAG_TYPE_WECHAT = 1; // 微信标签
const TAG_TYPE_SITE = 2; // 站内标签
const TAG_TYPE_AI = 3; // AI标签
// 打标来源常量
const SOURCE_MANUAL = 1; // 手动
const SOURCE_RULE = 2; // 规则自动
const SOURCE_AI = 3; // AI自动
const SOURCE_WECHAT_SYNC = 4; // 微信同步
// 打标来源名称
const SOURCE_NAMES = [
self::SOURCE_MANUAL => '手动打标',
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;
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 标签类目表模型类
* 表名ck_traffic_pool_tag_category
* 用途:管理标签的类目/分组,支持多级分类
*/
class TrafficPoolTagCategory extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_tag_category';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标签类型常量
const TAG_TYPE_WECHAT = 1; // 微信标签
const TAG_TYPE_SITE = 2; // 站内标签
const TAG_TYPE_AI = 3; // AI标签
// 标签类型名称映射
const TAG_TYPE_NAMES = [
self::TAG_TYPE_WECHAT => '微信标签',
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;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 标签定义表模型类
* 表名ck_traffic_pool_tag_define
* 用途:定义具体的标签
*/
class TrafficPoolTagDefine extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_tag_define';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标签类型常量(与类目表一致)
const TAG_TYPE_WECHAT = 1; // 微信标签
const TAG_TYPE_SITE = 2; // 站内标签
const TAG_TYPE_AI = 3; // AI标签
// 状态常量
const STATUS_DISABLED = 0;
const STATUS_ENABLED = 1;
/**
* 关联类目
*/
public function category()
{
return $this->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;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量池总表模型类V2版本
* 表名ck_traffic_pool
* 用途:存储全局唯一的流量标识,不区分公司
*/
class TrafficPoolV2 extends Model
{
// 设置数据表名(不带前缀)
protected $name = 'traffic_pool';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标识类型常量
const IDENTIFIER_TYPE_WECHAT_ID = 1; // 微信ID
const IDENTIFIER_TYPE_WECHAT_ALIAS = 2; // 微信号
const IDENTIFIER_TYPE_MOBILE = 3; // 手机号
// 性别常量
const GENDER_UNKNOWN = 0;
const GENDER_MALE = 1;
const GENDER_FEMALE = 2;
/**
* 关联公司流量详情
*/
public function companies()
{
return $this->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);
}
}

View File

@@ -18,7 +18,7 @@ class TrafficSource extends Model
// 设置数据表名
protected $name = 'traffic_source';
protected $name = 'traffic_source_v1';
// 自动写入时间戳
protected $autoWriteTimestamp = true;

View File

@@ -11,7 +11,7 @@ class TrafficSourcePackage extends Model
{
// 设置数据表名
protected $name = 'traffic_source_package';
protected $name = 'traffic_source_package_v1';
}

View File

@@ -11,7 +11,7 @@ class TrafficSourcePackageItem extends Model
{
// 设置数据表名
protected $name = 'traffic_source_package_item';
protected $name = 'traffic_source_package_item_v1';
}

View File

@@ -0,0 +1,275 @@
<?php
namespace app\common\service;
use think\facade\Log;
/**
* 标签引擎服务类
* 对接外部标签系统API
*/
class TagEngineService
{
/**
* API基础URL
* @var string
*/
private $baseUrl = 'http://192.168.1.40:8080';
/**
* API Key
* @var string
*/
private $apiKey = '69aebe46b03d334f1796ef88808d3042d5851d0fd91d728bcba6ad6be436acf6';
/**
* 设置API基础URL
* @param string $url
* @return $this
*/
public function setBaseUrl($url)
{
$this->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);
}
}

View File

@@ -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']);

View File

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

View File

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

View File

@@ -0,0 +1,897 @@
<?php
namespace app\cunkebao\controller;
use app\cunkebao\service\TrafficPoolService;
use app\cunkebao\service\TrafficPoolGroupService;
use app\common\model\TrafficPoolGroup;
use app\common\model\TrafficPoolCompany;
use app\common\model\TrafficPoolTag;
use app\common\model\TrafficPoolTagCategory;
use app\common\model\TrafficPoolTagDefine;
use app\common\model\TrafficPoolAllotRecord;
use app\common\model\TrafficPoolSource;
use app\common\model\TrafficPoolBehavior;
use app\common\service\ClassTableService;
use library\ResponseHelper;
/**
* 流量池控制器 V2
* 基于新架构的流量池 API 接口
*/
class TrafficPoolV2Controller extends BaseController
{
/**
* @var TrafficPoolService
*/
protected $poolService;
/**
* @var TrafficPoolGroupService
*/
protected $groupService;
public function __construct(ClassTableService $classTable)
{
parent::__construct($classTable);
$this->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());
}
}
}

View File

@@ -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
// 渠道IDcid对应 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 {

View File

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

View File

@@ -0,0 +1,169 @@
<?php
namespace app\cunkebao\controller\tag;
use app\common\service\TagEngineService;
use library\ResponseHelper;
use think\Controller;
use think\Validate;
/**
* 通过标识查询标签控制器
*/
class QueryTagsByIdentifiersController extends Controller
{
/**
* 通过标识查询标签
*
* @return \think\response\Json
*/
public function index()
{
try {
// 获取请求参数
$identifiers = $this->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);
}
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace app\cunkebao\controller\tag;
use app\common\service\TagEngineService;
use library\ResponseHelper;
use think\Controller;
use think\Validate;
/**
* 通过标签查询用户控制器
*/
class QueryUsersByTagsController extends Controller
{
/**
* 通过标签查询用户
*
* @return \think\response\Json
*/
public function index()
{
try {
// 获取请求参数
$tagConditions = $this->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);
}
}
}

View File

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

View File

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

View File

@@ -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('暂无数据可导出');

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,830 @@
<?php
namespace app\cunkebao\service;
use app\common\model\TrafficPoolGroup;
use app\common\model\TrafficPoolGroupMember;
use app\common\model\TrafficPoolCompany;
use app\common\model\TrafficPoolTag;
use think\Db;
/**
* 流量池分组服务类
* 处理流量池分组的查询、创建、成员管理等业务逻辑
*/
class TrafficPoolGroupService
{
/**
* 获取分组列表
*
* @param int $companyId 公司ID
* @param bool $withCount 是否包含成员数量
* @return array
*/
public function getGroupList(int $companyId, bool $withCount = true)
{
$groups = TrafficPoolGroup::getGroupsByCompany($companyId)->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);
}
}

View File

@@ -0,0 +1,762 @@
<?php
namespace app\cunkebao\service;
use app\common\model\TrafficPoolV2;
use app\common\model\TrafficPoolCompany;
use app\common\model\TrafficPoolSource;
use app\common\model\TrafficPoolBehavior;
use app\common\model\TrafficPoolTag;
use app\common\model\TrafficPoolAllotRecord;
use think\Db;
/**
* 流量池核心服务类
* 处理流量的入池、查询、更新等核心业务逻辑
*/
class TrafficPoolService
{
/**
* 流量入池(核心方法)
*
* @param string $identifier 唯一标识微信ID优先
* @param int $companyId 公司ID
* @param int $sourceType 来源类型
* @param array $poolData 流量总表数据
* @param array $companyData 公司流量数据
* @param array $sourceData 来源数据
* @return array [poolId, poolCompanyId]
*/
public function enterPool(
string $identifier,
int $companyId,
int $sourceType,
array $poolData = [],
array $companyData = [],
array $sourceData = []
) {
Db::startTrans();
try {
// 1. 查找或创建总表记录
$pool = TrafficPoolV2::findOrCreateByIdentifier($identifier, $poolData);
// 2. 查找或创建公司记录
$poolCompany = TrafficPoolCompany::findOrCreateByIdentifierAndCompany(
$identifier,
$companyId,
$pool->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;
}
}

View File

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

View File

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

View File

@@ -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条
]);

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,6 @@ use think\Model;
class TrafficOrderModel extends Model
{
protected $name = 'traffic_order';
protected $name = 'traffic_order_v1';
}

View File

@@ -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']);
})->middleware(['jwt']);

View File

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

View File

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

View File

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

View File

@@ -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' => [],

1103
docs/traffic_pool_design.md Normal file

File diff suppressed because it is too large Load Diff

View File

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