新版流量池提交
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取并更新微信账号状态信息
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,27 @@ class ReplyController extends BaseController
|
||||
if ($title === '') {
|
||||
return ResponseHelper::error('标题不能为空');
|
||||
}
|
||||
if ($content === '') {
|
||||
return ResponseHelper::error('内容不能为空');
|
||||
}
|
||||
|
||||
// 根据 msgType 处理 content:3=图片,43=视频,49=链接 需要 JSON 编码
|
||||
if (in_array($msgType, [3, 43, 49])) {
|
||||
// 如果 content 已经是数组,直接编码;如果是字符串,先尝试解码再编码(确保格式正确)
|
||||
if (is_array($content)) {
|
||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE);
|
||||
} elseif (is_string($content)) {
|
||||
// 尝试解析,如果已经是 JSON 字符串,确保格式正确
|
||||
$decoded = json_decode($content, true);
|
||||
if ($decoded !== null) {
|
||||
// 是有效的 JSON,重新编码确保格式统一
|
||||
$content = json_encode($decoded, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// 不是 JSON,直接编码
|
||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$now = time();
|
||||
@@ -142,12 +163,20 @@ class ReplyController extends BaseController
|
||||
'lastUpdateTime' => $now,
|
||||
'userId' => $userId,
|
||||
];
|
||||
|
||||
/** @var Reply $reply */
|
||||
$reply = new Reply();
|
||||
$reply->save($data);
|
||||
|
||||
return ResponseHelper::success($reply->toArray(), '创建成功');
|
||||
// 返回时解析 content(与 buildGroupData 保持一致)
|
||||
$replyData = $reply->toArray();
|
||||
if (in_array($msgType, [3, 43, 49]) && !empty($replyData['content'])) {
|
||||
$decoded = json_decode($replyData['content'], true);
|
||||
if ($decoded !== null) {
|
||||
$replyData['content'] = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseHelper::success($replyData, '创建成功');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('创建失败:' . $e->getMessage());
|
||||
}
|
||||
@@ -232,9 +261,46 @@ class ReplyController extends BaseController
|
||||
$sortIndex = $this->request->param('sortIndex', null);
|
||||
|
||||
if ($groupId !== null) $data['groupId'] = (int)$groupId;
|
||||
if ($title !== null) $data['title'] = $title;
|
||||
if ($title !== null) {
|
||||
if ($title === '') {
|
||||
return ResponseHelper::error('标题不能为空');
|
||||
}
|
||||
$data['title'] = $title;
|
||||
}
|
||||
if ($msgType !== null) $data['msgType'] = (int)$msgType;
|
||||
if ($content !== null) $data['content'] = $content;
|
||||
if ($content !== null) {
|
||||
// 确定 msgType:如果传了新的 msgType,用新的;否则用原有的
|
||||
$currentMsgType = $msgType !== null ? (int)$msgType : null;
|
||||
if ($currentMsgType === null) {
|
||||
// 需要查询原有的 msgType
|
||||
$reply = Reply::where(['id' => $id, 'isDel' => 0])->find();
|
||||
if (empty($reply)) {
|
||||
return ResponseHelper::error('快捷语不存在');
|
||||
}
|
||||
$currentMsgType = $reply->msgType;
|
||||
}
|
||||
|
||||
// 根据 msgType 处理 content:3=图片,43=视频,49=链接 需要 JSON 编码
|
||||
if (in_array($currentMsgType, [3, 43, 49])) {
|
||||
// 如果 content 已经是数组,直接编码;如果是字符串,先尝试解码再编码(确保格式正确)
|
||||
if (is_array($content)) {
|
||||
$data['content'] = json_encode($content, JSON_UNESCAPED_UNICODE);
|
||||
} elseif (is_string($content)) {
|
||||
// 尝试解析,如果已经是 JSON 字符串,确保格式正确
|
||||
$decoded = json_decode($content, true);
|
||||
if ($decoded !== null) {
|
||||
// 是有效的 JSON,重新编码确保格式统一
|
||||
$data['content'] = json_encode($decoded, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// 不是 JSON,直接编码
|
||||
$data['content'] = json_encode($content, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 文本类型,直接使用
|
||||
$data['content'] = $content;
|
||||
}
|
||||
}
|
||||
if ($sortIndex !== null) $data['sortIndex'] = (string)$sortIndex;
|
||||
if (!empty($data)) {
|
||||
$data['lastUpdateTime'] = time();
|
||||
@@ -245,12 +311,23 @@ class ReplyController extends BaseController
|
||||
}
|
||||
|
||||
try {
|
||||
$reply = Reply::where(['id' => $id,'isDel' => 0])->find();
|
||||
$reply = Reply::where(['id' => $id, 'isDel' => 0])->find();
|
||||
if (empty($reply)) {
|
||||
return ResponseHelper::error('快捷语不存在');
|
||||
}
|
||||
$reply->save($data);
|
||||
return ResponseHelper::success($reply->toArray(), '更新成功');
|
||||
|
||||
// 返回时解析 content(与 buildGroupData 保持一致)
|
||||
$replyData = $reply->toArray();
|
||||
$finalMsgType = isset($data['msgType']) ? $data['msgType'] : $reply->msgType;
|
||||
if (in_array($finalMsgType, [3, 43, 49]) && !empty($replyData['content'])) {
|
||||
$decoded = json_decode($replyData['content'], true);
|
||||
if ($decoded !== null) {
|
||||
$replyData['content'] = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseHelper::success($replyData, '更新成功');
|
||||
} catch (\Exception $e) {
|
||||
return ResponseHelper::error('更新失败:' . $e->getMessage());
|
||||
}
|
||||
@@ -329,10 +406,24 @@ class ReplyController extends BaseController
|
||||
|
||||
// 获取该分组下的快捷回复
|
||||
$replies = Reply::where($replyWhere)
|
||||
->order('sortIndex asc, id desc
|
||||
')
|
||||
->order('sortIndex asc, id desc')
|
||||
->select();
|
||||
|
||||
// 解析 replies 的 content 字段(根据 msgType 判断是否需要 JSON 解析)
|
||||
$repliesArray = [];
|
||||
foreach ($replies as $reply) {
|
||||
$replyData = $reply->toArray();
|
||||
// 根据 msgType 解析 content:3=图片,43=视频,49=链接
|
||||
if (in_array($replyData['msgType'], [3, 43, 49]) && !empty($replyData['content'])) {
|
||||
$decoded = json_decode($replyData['content'], true);
|
||||
// 如果解析成功,使用解析后的内容;否则保持原样
|
||||
if ($decoded !== null) {
|
||||
$replyData['content'] = $decoded;
|
||||
}
|
||||
}
|
||||
$repliesArray[] = $replyData;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $group->id,
|
||||
'groupName' => $group->groupName,
|
||||
@@ -342,7 +433,7 @@ class ReplyController extends BaseController
|
||||
'replys' => $group->replys,
|
||||
'companyId' => $group->companyId,
|
||||
'userId' => $group->userId,
|
||||
'replies' => $replies->toArray(),
|
||||
'replies' => $repliesArray,
|
||||
'children' => [] // 子分组
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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']) : '';
|
||||
|
||||
@@ -50,4 +50,7 @@ return [
|
||||
|
||||
// 检查未读/未回复消息并自动迁移好友
|
||||
'check:unread-message' => 'app\command\CheckUnreadMessageCommand', // 检查未读/未回复消息并自动迁移好友
|
||||
|
||||
// V2 流量池数据迁移
|
||||
'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统
|
||||
];
|
||||
|
||||
@@ -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'));
|
||||
|
||||
188
application/command/CleanLogsCommand.php
Normal file
188
application/command/CleanLogsCommand.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
||||
235
application/command/MigrateTrafficPoolV2Command.php
Normal file
235
application/command/MigrateTrafficPoolV2Command.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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); // 保存PID,10分钟过期
|
||||
// 创建任务锁文件
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 自动写入时间戳
|
||||
|
||||
168
application/common/model/TrafficPoolAllotRecord.php
Normal file
168
application/common/model/TrafficPoolAllotRecord.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
240
application/common/model/TrafficPoolBehavior.php
Normal file
240
application/common/model/TrafficPoolBehavior.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
200
application/common/model/TrafficPoolCompany.php
Normal file
200
application/common/model/TrafficPoolCompany.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
171
application/common/model/TrafficPoolGroup.php
Normal file
171
application/common/model/TrafficPoolGroup.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
application/common/model/TrafficPoolGroupMember.php
Normal file
128
application/common/model/TrafficPoolGroupMember.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
474
application/common/model/TrafficPoolSource.php
Normal file
474
application/common/model/TrafficPoolSource.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
229
application/common/model/TrafficPoolTag.php
Normal file
229
application/common/model/TrafficPoolTag.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
118
application/common/model/TrafficPoolTagCategory.php
Normal file
118
application/common/model/TrafficPoolTagCategory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
143
application/common/model/TrafficPoolTagDefine.php
Normal file
143
application/common/model/TrafficPoolTagDefine.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
85
application/common/model/TrafficPoolV2.php
Normal file
85
application/common/model/TrafficPoolV2.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class TrafficSource extends Model
|
||||
|
||||
|
||||
// 设置数据表名
|
||||
protected $name = 'traffic_source';
|
||||
protected $name = 'traffic_source_v1';
|
||||
|
||||
// 自动写入时间戳
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
@@ -11,7 +11,7 @@ class TrafficSourcePackage extends Model
|
||||
{
|
||||
|
||||
// 设置数据表名
|
||||
protected $name = 'traffic_source_package';
|
||||
protected $name = 'traffic_source_package_v1';
|
||||
|
||||
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class TrafficSourcePackageItem extends Model
|
||||
{
|
||||
|
||||
// 设置数据表名
|
||||
protected $name = 'traffic_source_package_item';
|
||||
protected $name = 'traffic_source_package_item_v1';
|
||||
|
||||
|
||||
}
|
||||
275
application/common/service/TagEngineService.php
Normal file
275
application/common/service/TagEngineService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -46,14 +46,19 @@ class RFMController extends BaseController
|
||||
$weightM = isset($config['weight_M']) ? (float)$config['weight_M'] : self::DEFAULT_WEIGHT_M;
|
||||
$abnormalMoneyRatio = isset($config['abnormal_money_ratio']) ? (float)$config['abnormal_money_ratio'] : self::DEFAULT_ABNORMAL_MONEY_RATIO;
|
||||
$scoreScale = isset($config['score_scale']) ? (int)$config['score_scale'] : self::DEFAULT_SCORE_SCALE;
|
||||
$missingStrategy = isset($config['missing_strategy']) ? $config['missing_strategy'] : 'score_1';
|
||||
$missingStrategy = isset($config['missing_strategy']) ? $confi961102'] : 'score_1';
|
||||
|
||||
// 权重归一化处理
|
||||
$weightSum = $weightR + $weightF + $weightM;
|
||||
if ($weightSum != 1.0) {
|
||||
if ($weightSum != 1.0 && $weightSum > 0) {
|
||||
$weightR = $weightR / $weightSum;
|
||||
$weightF = $weightF / $weightSum;
|
||||
$weightM = $weightM / $weightSum;
|
||||
} elseif ($weightSum == 0) {
|
||||
// 如果权重全为0,使用默认权重
|
||||
$weightR = self::DEFAULT_WEIGHT_R;
|
||||
$weightF = self::DEFAULT_WEIGHT_F;
|
||||
$weightM = self::DEFAULT_WEIGHT_M;
|
||||
}
|
||||
|
||||
// 计算时间范围
|
||||
@@ -111,6 +116,8 @@ class RFMController extends BaseController
|
||||
|
||||
// 3. 异常值处理 - 剔除大额异常订单
|
||||
$mValues = array_column($customerData, 'M');
|
||||
$abnormalThreshold = null; // 初始化异常阈值
|
||||
|
||||
if (!empty($mValues)) {
|
||||
sort($mValues);
|
||||
$m99Percentile = $this->percentile($mValues, 0.99);
|
||||
@@ -120,6 +127,11 @@ class RFMController extends BaseController
|
||||
foreach ($customerData as &$customer) {
|
||||
$customer['isAbnormal'] = $customer['M'] > $abnormalThreshold;
|
||||
}
|
||||
} else {
|
||||
// 如果没有M值数据,标记所有客户为非异常
|
||||
foreach ($customerData as &$customer) {
|
||||
$customer['isAbnormal'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 使用五分位法计算各维度的区间阈值
|
||||
@@ -127,7 +139,7 @@ class RFMController extends BaseController
|
||||
$fThresholds = $this->calculatePercentiles(array_column($customerData, 'F'), false);
|
||||
// M维度排除异常值计算区间
|
||||
$mValuesForPercentile = array_filter(array_column($customerData, 'M'), function($m) use ($abnormalThreshold) {
|
||||
return isset($abnormalThreshold) ? $m <= $abnormalThreshold : true;
|
||||
return $abnormalThreshold !== null ? $m <= $abnormalThreshold : true;
|
||||
});
|
||||
$mThresholds = $this->calculatePercentiles(array_values($mValuesForPercentile), false);
|
||||
|
||||
@@ -136,7 +148,7 @@ class RFMController extends BaseController
|
||||
foreach ($customerData as $customer) {
|
||||
$rScore = $this->scoreByPercentile($customer['R'], $rThresholds, true); // R是反向的
|
||||
$fScore = $this->scoreByPercentile($customer['F'], $fThresholds, false);
|
||||
$mScore = $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分
|
||||
$mScore = isset($customer['isAbnormal']) && $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分
|
||||
|
||||
// 计算RFM总分(加权求和)
|
||||
$rfmScore = $rScore * $weightR + $fScore * $weightF + $mScore * $weightM;
|
||||
@@ -146,7 +158,8 @@ class RFMController extends BaseController
|
||||
if ($scoreScale == 100) {
|
||||
$rfmMin = $weightR * 1 + $weightF * 1 + $weightM * 1;
|
||||
$rfmMax = $weightR * 5 + $weightF * 5 + $weightM * 5;
|
||||
$standardScore = (int)round(($rfmScore - $rfmMin) / ($rfmMax - $rfmMin) * 99 + 1);
|
||||
$range = $rfmMax - $rfmMin;
|
||||
$standardScore = $range > 0 ? (int)round(($rfmScore - $rfmMin) / $range * 99 + 1) : 1;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
@@ -170,7 +183,7 @@ class RFMController extends BaseController
|
||||
return $b['RFM_score'] <=> $a['RFM_score'];
|
||||
});
|
||||
|
||||
// 6. 更新 ck_traffic_source 和 s2_wechat_friend 表的RFM值
|
||||
// 6. 更新 ck_traffic_source_v1 和 s2_wechat_friend 表的RFM值
|
||||
$this->updateRfmToTables($results, $ownerWechatId);
|
||||
|
||||
return [
|
||||
@@ -187,7 +200,7 @@ class RFMController extends BaseController
|
||||
],
|
||||
'statistics' => [
|
||||
'total_customers' => count($results),
|
||||
'avg_rfm_score' => round(array_sum(array_column($results, 'RFM_score')) / count($results), 2),
|
||||
'avg_rfm_score' => count($results) > 0 ? round(array_sum(array_column($results, 'RFM_score')) / count($results), 2) : 0,
|
||||
]
|
||||
]
|
||||
];
|
||||
@@ -352,7 +365,7 @@ class RFMController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新RFM值到 ck_traffic_source 和 s2_wechat_friend 表
|
||||
* 更新RFM值到 ck_traffic_source_v1、s2_wechat_friend 和 ck_traffic_pool_company 表
|
||||
*
|
||||
* @param array $results RFM计算结果数组
|
||||
* @param string|null $ownerWechatId 微信ID,用于过滤更新范围
|
||||
@@ -365,8 +378,11 @@ class RFMController extends BaseController
|
||||
$rScore = (string)$result['R_score'];
|
||||
$fScore = (string)$result['F_score'];
|
||||
$mScore = (string)$result['M_score'];
|
||||
$rfmRaw = $result['R_raw'];
|
||||
$rfmF = $result['F_raw'];
|
||||
$rfmM = $result['M_raw'];
|
||||
|
||||
// 更新 ck_traffic_source 表
|
||||
// 更新 ck_traffic_source_v1 表(V1旧表)
|
||||
// 根据 identifier 更新所有匹配的记录
|
||||
$trafficSourceUpdate = [
|
||||
'R' => $rScore,
|
||||
@@ -389,6 +405,18 @@ class RFMController extends BaseController
|
||||
$wechatFriendWhere['ownerWechatId'] = $ownerWechatId;
|
||||
}
|
||||
WechatFriendModel::where($wechatFriendWhere)->update($wechatFriendUpdate);
|
||||
|
||||
// 更新 ck_traffic_pool_company 表(V2新表)
|
||||
// 根据 identifier 更新,identifier可能是wechatId、phone等
|
||||
$poolCompanyUpdate = [
|
||||
'rfmF' => $rfmF,
|
||||
'rfmM' => $rfmM,
|
||||
'updateTime' => date('Y-m-d H:i:s')
|
||||
];
|
||||
Db::table('ck_traffic_pool_company')
|
||||
->where('identifier', $identifier)
|
||||
->where('isDel', 0)
|
||||
->update($poolCompanyUpdate);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
897
application/cunkebao/controller/TrafficPoolV2Controller.php
Normal file
897
application/cunkebao/controller/TrafficPoolV2Controller.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use library\ResponseHelper;
|
||||
use think\Controller;
|
||||
use think\Db;
|
||||
use app\cunkebao\service\DistributionRewardService;
|
||||
use app\cunkebao\service\TrafficPoolService;
|
||||
use app\common\model\TrafficPoolSource;
|
||||
|
||||
/**
|
||||
* 对外API接口控制器
|
||||
@@ -95,27 +97,28 @@ class PostExternalApiV1Controller extends Controller
|
||||
// 渠道ID(cid),对应 distribution_channel.id
|
||||
$channelId = !empty($params['cid']) ? intval($params['cid']) : 0;
|
||||
|
||||
|
||||
$trafficPool = Db::name('traffic_pool')->where('identifier', $identifier)->find();
|
||||
if (!$trafficPool) {
|
||||
$trafficPoolId =Db::name('traffic_pool')->insertGetId([
|
||||
'identifier' => $identifier,
|
||||
'mobile' => !empty($params['phone']) ? $params['phone'] : '',
|
||||
'createTime' => time()
|
||||
]);
|
||||
}else{
|
||||
$trafficPoolId = $trafficPool['id'];
|
||||
}
|
||||
// ========== 旧版流量池代码(已废弃,保留用于兼容) ==========
|
||||
// $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $identifier)->find();
|
||||
// if (!$trafficPool) {
|
||||
// $trafficPoolId =Db::name('traffic_pool_v1')->insertGetId([
|
||||
// 'identifier' => $identifier,
|
||||
// 'mobile' => !empty($params['phone']) ? $params['phone'] : '',
|
||||
// 'createTime' => time()
|
||||
// ]);
|
||||
// }else{
|
||||
// $trafficPoolId = $trafficPool['id'];
|
||||
// }
|
||||
// ========== 旧版流量池代码结束 ==========
|
||||
|
||||
$taskCustomer = Db::name('task_customer')
|
||||
->where('task_id', $plan['id'])
|
||||
->where('phone', $identifier)
|
||||
->find();
|
||||
|
||||
// 处理用户画像
|
||||
if(!empty($params['portrait']) && is_array($params['portrait'])){
|
||||
$this->updatePortrait($params['portrait'],$trafficPoolId,$plan['companyId']);
|
||||
}
|
||||
// 处理用户画像(已迁移到V2流量池,此处保留兼容)
|
||||
// if(!empty($params['portrait']) && is_array($params['portrait'])){
|
||||
// $this->updatePortrait($params['portrait'],$trafficPoolId,$plan['companyId']);
|
||||
// }
|
||||
if (!$taskCustomer) {
|
||||
$tags = !empty($params['tags']) ? explode(',', $params['tags']) : [];
|
||||
$siteTags = !empty($params['siteTags']) ? explode(',', $params['siteTags']) : [];
|
||||
@@ -154,6 +157,60 @@ class PostExternalApiV1Controller extends Controller
|
||||
'createTime' => time(),
|
||||
]);
|
||||
|
||||
// 实时同步到 V2 流量池系统(异步处理,不影响主流程)
|
||||
if ($customerId) {
|
||||
try {
|
||||
$poolService = new TrafficPoolService();
|
||||
|
||||
// 判断 identifier 类型:手机号还是微信号
|
||||
$identifierType = 2; // 默认手机号
|
||||
$isPhone = preg_match('/^\+?\d{6,}$/', $identifier);
|
||||
if (!$isPhone && !empty($params['wechatId'])) {
|
||||
$identifierType = 1; // 微信号
|
||||
}
|
||||
|
||||
// 准备流量池数据
|
||||
$poolData = [
|
||||
'identifierType' => $identifierType,
|
||||
'mobile' => !empty($params['phone']) ? $params['phone'] : ($isPhone ? $identifier : ''),
|
||||
'wechatId' => !empty($params['wechatId']) ? $params['wechatId'] : (!$isPhone ? $identifier : ''),
|
||||
'nickname' => !empty($params['name']) ? $params['name'] : '',
|
||||
];
|
||||
|
||||
// 准备公司流量数据
|
||||
$companyData = [
|
||||
'phone' => !empty($params['phone']) ? $params['phone'] : ($isPhone ? $identifier : ''),
|
||||
'realName' => !empty($params['name']) ? $params['name'] : '',
|
||||
'remark' => !empty($params['remark']) ? $params['remark'] : '',
|
||||
];
|
||||
|
||||
// 准备来源数据
|
||||
$sourceData = [
|
||||
'sourceName' => !empty($params['source']) ? $params['source'] : ('场景获客_' . $plan['name']),
|
||||
'remark' => !empty($params['remark']) ? $params['remark'] : '',
|
||||
'extra' => json_encode([
|
||||
'planId' => $plan['id'],
|
||||
'planName' => $plan['name'],
|
||||
'channelId' => $finalChannelId,
|
||||
'customerId' => $customerId,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
|
||||
// 同步到 V2 流量池
|
||||
$poolService->enterPool(
|
||||
$identifier,
|
||||
$plan['companyId'],
|
||||
TrafficPoolSource::SOURCE_TYPE_API, // API导入
|
||||
$poolData,
|
||||
$companyData,
|
||||
$sourceData
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但不影响主流程
|
||||
\think\facade\Log::error('同步到V2流量池失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 记录获客奖励(异步处理,不影响主流程)
|
||||
if ($customerId) {
|
||||
try {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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('暂无数据可导出');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
830
application/cunkebao/service/TrafficPoolGroupService.php
Normal file
830
application/cunkebao/service/TrafficPoolGroupService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
762
application/cunkebao/service/TrafficPoolService.php
Normal file
762
application/cunkebao/service/TrafficPoolService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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条
|
||||
]);
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -6,6 +6,6 @@ use think\Model;
|
||||
|
||||
class TrafficOrderModel extends Model
|
||||
{
|
||||
protected $name = 'traffic_order';
|
||||
protected $name = 'traffic_order_v1';
|
||||
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
1103
docs/traffic_pool_design.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user