diff --git a/application/ai/config/route.php b/application/ai/config/route.php index 704b7d0..92dfc4e 100644 --- a/application/ai/config/route.php +++ b/application/ai/config/route.php @@ -13,6 +13,7 @@ Route::group('v1/ai', function () { //豆包ai Route::group('doubao', function () { - Route::post('text', 'app\ai\controller\DouBaoAI@text'); + Route::post('text', 'app\ai\controller\DouBaoAI@text'); // 文本生成 + Route::post('image', 'app\ai\controller\DouBaoAI@image'); // 图片生成 }); })->middleware(['jwt']); \ No newline at end of file diff --git a/application/ai/controller/DouBaoAI.php b/application/ai/controller/DouBaoAI.php index a3c942c..4f29499 100644 --- a/application/ai/controller/DouBaoAI.php +++ b/application/ai/controller/DouBaoAI.php @@ -49,7 +49,7 @@ class DouBaoAI extends Controller ], ]; } - $result = requestCurl($this->apiUrl, $params, 'POST', $this->headers, 'json'); + $result = requestCurl($this->apiUrl.'/api/v3/chat/completions', $params, 'POST', $this->headers, 'json'); $result = json_decode($result, true); if(isset($result['error'])){ $error = $result['error']; @@ -64,5 +64,211 @@ class DouBaoAI extends Controller } + /** + * 图片生成功能(基于火山方舟 Seedream 4.0-4.5 API) + * 参考文档:https://www.volcengine.com/docs/82379/1541523?lang=zh + * + * @param array $params 请求参数,如果为空则从请求中获取 + * @return string JSON格式的响应 + */ + public function image($params = []) + { + try { + // 如果参数为空,从请求中获取 + if (empty($params)){ + $content = $this->request->param('content', ''); + $model = $this->request->param('model', 'doubao-seedream-4-5-251128'); + $size = $this->request->param('size', '16:9'); // 支持档位(1K/2K/4K)、比例(16:9/9:16等)、像素(1280x720等) + $responseFormat = $this->request->param('response_format', 'url'); // url 或 b64_json + $sequentialImageGeneration = $this->request->param('sequential_image_generation', 'disabled'); // enabled 或 disabled + $watermark = $this->request->param('watermark', true); // true 或 false + + // 参数验证 + if(empty($content)){ + return json_encode(['code' => 500, 'msg' => '提示词(prompt)不能为空']); + } + + // 验证和规范化尺寸参数 + $size = $this->validateAndNormalizeSize($size); + if(!in_array($responseFormat, ['url', 'b64_json'])){ + $responseFormat = 'url'; + } + + if(!in_array($sequentialImageGeneration, ['enabled', 'disabled'])){ + $sequentialImageGeneration = 'disabled'; + } + + // 构建请求参数(根据火山方舟文档) + $params = [ + 'model' => $model, + 'prompt' => $content, + 'sequential_image_generation' => $sequentialImageGeneration, + 'response_format' => $responseFormat, + 'size' => $size, + 'stream' => false, + 'watermark' => true + ]; + } + + // 确保API URL正确(图片生成API的endpoint) + $imageApiUrl = $this->apiUrl. '/api/v3/images/generations'; + // 发送请求 + $result = requestCurl($imageApiUrl, $params, 'POST', $this->headers, 'json'); + $result = json_decode($result, true); + // 错误处理 + if(isset($result['error'])){ + $error = $result['error']; + $errorMsg = isset($error['message']) ? $error['message'] : '图片生成失败'; + $errorCode = isset($error['code']) ? $error['code'] : 'unknown'; + + \think\facade\Log::error('火山方舟图片生成失败', [ + 'error' => $error, + 'params' => $params + ]); + + return json_encode([ + 'code' => 500, + 'msg' => $errorMsg, + 'error_code' => $errorCode + ]); + } + + // 成功响应处理(根据火山方舟文档的响应格式) + if(isset($result['data']) && is_array($result['data']) && !empty($result['data'])){ + $imageData = $result['data'][0]; + + // 根据 response_format 获取图片数据 + $imageUrl = null; + $imageB64 = null; + + if(isset($imageData['url'])){ + $imageUrl = $imageData['url']; + } + + if(isset($imageData['b64_json'])){ + $imageB64 = $imageData['b64_json']; + } + + // 计算token(如果有usage信息) + $token = 0; + if(isset($result['usage']['total_tokens'])){ + $token = intval($result['usage']['total_tokens']) * 20; + } + + // 构建返回数据 + $responseData = [ + 'token' => $token, + 'image_url' => $imageUrl, + 'image_b64' => $imageB64, + 'model' => $params['model'] ?? '', + 'size' => $params['size'] ?? '2K', + 'created' => isset($result['created']) ? $result['created'] : time() + ]; + + // 根据请求的response_format返回对应的数据 + if($params['response_format'] == 'url' && $imageUrl){ + $responseData['content'] = $imageUrl; + } elseif($params['response_format'] == 'b64_json' && $imageB64){ + $responseData['content'] = $imageB64; + } + + return json_encode([ + 'code' => 200, + 'msg' => '图片生成成功', + 'data' => $responseData + ]); + } else { + // 响应格式不符合预期 + \think\facade\Log::warning('火山方舟图片生成响应格式异常', [ + 'result' => $result, + 'params' => $params + ]); + + return json_encode([ + 'code' => 500, + 'msg' => '图片生成响应格式异常', + 'raw_response' => $result + ]); + } + + } catch (\Exception $e) { + \think\facade\Log::error('火山方舟图片生成异常', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return json_encode([ + 'code' => 500, + 'msg' => '图片生成异常:' . $e->getMessage() + ]); + } + } + + /** + * 验证和规范化尺寸参数 + * 支持三种格式: + * 1. 档位形式:1K, 2K, 4K(不区分大小写) + * 2. 比例形式:16:9, 9:16, 1:1, 4:3, 3:4 等 + * 3. 像素形式:1280x720, 2048x2048 等(宽度1280-4096,高度720-4096,宽高比0.0625-16) + * + * @param string $size 尺寸参数 + * @return string 规范化后的尺寸值 + */ + private function validateAndNormalizeSize($size) + { + if (empty($size)) { + return '2K'; + } + + $size = trim($size); + + // 1. 检查是否为档位形式(1K, 2K, 4K) + $sizeUpper = strtoupper($size); + if (in_array($sizeUpper, ['1K', '2K', '4K'])) { + return $sizeUpper; + } + + // 2. 检查是否为比例形式(如 16:9, 9:16, 1:1) + if (preg_match('/^(\d+):(\d+)$/', $size, $matches)) { + $width = intval($matches[1]); + $height = intval($matches[2]); + + if ($width > 0 && $height > 0) { + $ratio = $width / $height; + // 验证宽高比范围:0.0625 ~ 16 + if ($ratio >= 0.0625 && $ratio <= 16) { + return $size; // 返回比例形式,如 "16:9" + } + } + } + + // 3. 检查是否为像素形式(如 1280x720, 2048x2048) + if (preg_match('/^(\d+)x(\d+)$/i', $size, $matches)) { + $width = intval($matches[1]); + $height = intval($matches[2]); + + // 验证宽度范围:1280 ~ 4096 + if ($width < 1280 || $width > 4096) { + return '2K'; // 默认返回 2K + } + + // 验证高度范围:720 ~ 4096 + if ($height < 720 || $height > 4096) { + return '2K'; // 默认返回 2K + } + + // 验证宽高比范围:0.0625 ~ 16 + $ratio = $width / $height; + if ($ratio < 0.0625 || $ratio > 16) { + return '2K'; // 默认返回 2K + } + + return $size; // 返回像素形式,如 "1280x720" + } + + // 如果都不匹配,返回默认值 + return '2K'; + } + } \ No newline at end of file diff --git a/application/api/controller/MessageController.php b/application/api/controller/MessageController.php index daedae1..34b79b5 100644 --- a/application/api/controller/MessageController.php +++ b/application/api/controller/MessageController.php @@ -3,6 +3,7 @@ namespace app\api\controller; use app\api\model\WechatMessageModel; +use app\common\service\FriendTransferService; use think\Db; use think\facade\Request; @@ -389,38 +390,17 @@ class MessageController extends BaseController if ($item['msgType'] == 10000 && strpos($item['content'],'开启了朋友验证') !== false) { Db::table('s2_wechat_friend')->where('id',$item['wechatFriendId'])->update(['isDeleted'=> 1,'deleteTime' => $wechatTime]); }else{ - //优先分配在线客服 + //优先分配在线客服 - 使用新的好友迁移服务 $friend = Db::table('s2_wechat_friend')->where('id',$item['wechatFriendId'])->find(); if (!empty($friend)){ $accountId = $item['accountId']; - $accountData = Db::table('s2_company_account')->where('id',$accountId)->find(); - if (!empty($accountData)){ - $account = new AccountController(); - $account->getlist(['pageIndex' => 0,'pageSize' => 100,'departmentId' => $accountData['departmentId']]); - $accountIds = Db::table('s2_company_account')->where(['departmentId' => $accountData['departmentId'],'alive' => 1])->column('id'); - if (!empty($accountIds)){ - if (!in_array($friend['accountId'],$accountIds)){ - // 执行切换好友命令 - $randomKey = array_rand($accountIds, 1); - $toAccountId = $accountIds[$randomKey]; - $toAccountData = Db::table('s2_company_account')->where('id',$toAccountId)->find(); - $automaticAssign = new AutomaticAssign(); - $automaticAssign->allotWechatFriend([ - 'wechatFriendId' => $friend['id'], - 'toAccountId' => $toAccountId - ], true); - Db::table('s2_wechat_friend') - ->where('id',$friend['id']) - ->update([ - 'accountId' => $toAccountId, - 'accountUserName' => $toAccountData['userName'], - 'accountRealName' => $toAccountData['realName'], - 'accountNickname' => $toAccountData['nickname'], - ]); - } - } - - } + $friendTransferService = new FriendTransferService(); + $result = $friendTransferService->transferFriend( + $item['wechatFriendId'], + $accountId, + '账号不在线,自动迁移到在线账号' + ); + // 迁移结果已记录在服务中,这里不需要额外处理 } } diff --git a/application/chukebao/controller/DataProcessing.php b/application/chukebao/controller/DataProcessing.php index af8070c..5f0121c 100644 --- a/application/chukebao/controller/DataProcessing.php +++ b/application/chukebao/controller/DataProcessing.php @@ -7,6 +7,7 @@ use library\ResponseHelper; use app\api\model\WechatFriendModel; use app\api\model\WechatMessageModel; use app\api\controller\MessageController; +use think\Db; class DataProcessing extends BaseController @@ -60,6 +61,7 @@ class DataProcessing extends BaseController $friend->conRemark = $newRemark; $friend->updateTime = time(); $friend->save(); + $msg = '修改备成功'; break; case 'CmdModifyFriendLabel': //修改好友标签 @@ -73,6 +75,7 @@ class DataProcessing extends BaseController $friend->labels = json_encode($labels,256); $friend->updateTime = time(); $friend->save(); + $msg = '修标签成功'; break; case 'CmdAllotFriend': //迁移好友 @@ -199,6 +202,7 @@ class DataProcessing extends BaseController $data->updateTime = time(); $data->isTop = $isTop; $data->save(); + break; } return ResponseHelper::success('',$msg,$codee); diff --git a/application/chukebao/controller/MessageController.php b/application/chukebao/controller/MessageController.php index 3ffeb9f..0f6b939 100644 --- a/application/chukebao/controller/MessageController.php +++ b/application/chukebao/controller/MessageController.php @@ -30,143 +30,191 @@ class MessageController extends BaseController return ResponseHelper::error('请先登录'); } + // 直接查询好友ID列表 + $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'); - - // 构建好友子查询 - $friendSubQuery = Db::table('s2_wechat_friend') + // 直接查询群聊信息 + $chatrooms = Db::table('s2_wechat_chatroom') ->where(['accountId' => $accountId, 'isDeleted' => 0]) - ->field('id') - ->buildSql(); + ->column('id,nickname,chatroomAvatar,chatroomId,isTop'); - // 优化后的查询:使用MySQL兼容的查询方式 - $unionQuery = " - (SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime,m.wechatAccountId, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId, wc.isTop - FROM s2_wechat_chatroom wc - INNER JOIN s2_wechat_message m ON wc.id = m.wechatChatroomId AND m.type = 2 - INNER JOIN ( - SELECT wechatChatroomId, MAX(wechatTime) as maxTime, MAX(id) as maxId - FROM s2_wechat_message - WHERE type = 2 - GROUP BY wechatChatroomId - ) latest ON m.wechatChatroomId = latest.wechatChatroomId AND m.wechatTime = latest.maxTime AND m.id = latest.maxId - WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0 - ) - UNION ALL - (SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId, 1 as wechatAccountId, 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 {$friendSubQuery} - GROUP BY wechatFriendId - ) latest ON m.wechatFriendId = latest.wechatFriendId AND m.wechatTime = latest.maxTime AND m.id = latest.maxId - WHERE m.type = 1 AND m.wechatFriendId IN {$friendSubQuery} - ) - ORDER BY wechatTime DESC - LIMIT " . (($page - 1) * $limit) . ", {$limit} - "; + // 获取群聊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); + } - $list = Db::query($unionQuery); + // 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); + } - // 对分页后的结果进行排序(按wechatTime降序) - usort($list, function ($a, $b) { + // 合并结果并排序 + $allMessages = array_merge($chatroomMessages, $friendMessages); + usort($allMessages, function ($a, $b) { return $b['wechatTime'] <=> $a['wechatTime']; }); - // 批量统计未读数量(isRead=0),按好友/群聊分别聚合 - $friendIds = []; - $chatroomIds = []; + // 计算总数 + $totalCount = count($allMessages); + + // 分页处理 + $list = array_slice($allMessages, ($page - 1) * $limit, $limit); + + // 收集需要查询的ID + $queryFriendIds = []; + $queryChatroomIds = []; foreach ($list as $row) { if (!empty($row['wechatFriendId'])) { - $friendIds[] = $row['wechatFriendId']; + $queryFriendIds[] = $row['wechatFriendId']; } if (!empty($row['wechatChatroomId'])) { - $chatroomIds[] = $row['wechatChatroomId']; + $queryChatroomIds[] = $row['wechatChatroomId']; } } - $friendIds = array_values(array_unique(array_filter($friendIds))); - $chatroomIds = array_values(array_unique(array_filter($chatroomIds))); + $queryFriendIds = array_unique($queryFriendIds); + $queryChatroomIds = array_unique($queryChatroomIds); - $friendUnreadMap = []; - if (!empty($friendIds)) { - // 获取未读消息数量 - $friendUnreadMap = Db::table('s2_wechat_message') - ->where(['isRead' => 0]) - ->whereIn('wechatFriendId', $friendIds) + // 批量查询未读数量(优化:合并查询) + $unreadMap = []; + if (!empty($queryFriendIds)) { + $friendUnreads = Db::table('s2_wechat_message') + ->where(['isRead' => 0, 'type' => 1]) + ->whereIn('wechatFriendId', $queryFriendIds) + ->field('wechatFriendId, COUNT(*) as cnt') ->group('wechatFriendId') - ->column('COUNT(*) AS cnt', 'wechatFriendId'); + ->select(); + foreach ($friendUnreads as $item) { + $unreadMap['friend_' . $item['wechatFriendId']] = (int)$item['cnt']; + } } - $chatroomUnreadMap = []; - if (!empty($chatroomIds)) { - // 获取未读消息数量 - $chatroomUnreadMap = Db::table('s2_wechat_message') - ->where(['isRead' => 0]) - ->whereIn('wechatChatroomId', $chatroomIds) + if (!empty($queryChatroomIds)) { + $chatroomUnreads = Db::table('s2_wechat_message') + ->where(['isRead' => 0, 'type' => 2]) + ->whereIn('wechatChatroomId', $queryChatroomIds) + ->field('wechatChatroomId, COUNT(*) as cnt') ->group('wechatChatroomId') - ->column('COUNT(*) AS cnt', 'wechatChatroomId'); + ->select(); + foreach ($chatroomUnreads as $item) { + $unreadMap['chatroom_' . $item['wechatChatroomId']] = (int)$item['cnt']; + } } + // 批量查询AI类型 $aiTypeData = []; - if (!empty($friendIds)) { - $aiTypeData = FriendSettings::where('friendId', 'in', $friendIds)->column('friendId,type'); + if (!empty($queryFriendIds)) { + $aiTypeData = FriendSettings::where('friendId', 'in', $queryFriendIds)->column('friendId,type'); } + // 格式化数据 foreach ($list as $k => &$v) { - $createTime = !empty($v['createTime']) ? date('Y-m-d H:i:s', $v['createTime']) : ''; $wechatTime = !empty($v['wechatTime']) ? date('Y-m-d H:i:s', $v['wechatTime']) : ''; - $unreadCount = 0; $v['aiType'] = 0; + if (!empty($v['wechatFriendId'])) { - $v['nickname'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['nickname'] : ''; - $v['avatar'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['avatar'] : ''; - $v['conRemark'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['conRemark'] : ''; - $v['groupId'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['groupId'] : ''; - $v['wechatAccountId'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['wechatAccountId'] : ''; - $v['wechatId'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['wechatId'] : ''; - $v['extendFields'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['extendFields'] : []; - $v['region'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['region'] : ''; - $v['phone'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['phone'] : ''; - $v['isTop'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['isTop'] : 0; - $v['labels'] = !empty($friends[$v['wechatFriendId']]) ? json_decode($friends[$v['wechatFriendId']]['labels'], true) : []; + // 好友消息 + $friendId = $v['wechatFriendId']; + $friend = $friends[$friendId] ?? null; + + $v['nickname'] = $friend['nickname'] ?? ''; + $v['avatar'] = $friend['avatar'] ?? ''; + $v['conRemark'] = $friend['conRemark'] ?? ''; + $v['groupId'] = $friend['groupId'] ?? ''; + $v['wechatAccountId'] = $friend['wechatAccountId'] ?? ''; + $v['wechatId'] = $friend['wechatId'] ?? ''; + $v['extendFields'] = $friend['extendFields'] ?? []; + $v['region'] = $friend['region'] ?? ''; + $v['phone'] = $friend['phone'] ?? ''; + $v['isTop'] = $friend['isTop'] ?? 0; + $v['labels'] = !empty($friend['labels']) ? json_decode($friend['labels'], true) : []; - $unreadCount = isset($friendUnreadMap[$v['wechatFriendId']]) ? (int)$friendUnreadMap[$v['wechatFriendId']] : 0; - $v['aiType'] = isset($aiTypeData[$v['wechatFriendId']]) ? $aiTypeData[$v['wechatFriendId']] : 0; + $unreadCount = $unreadMap['friend_' . $friendId] ?? 0; + $v['aiType'] = $aiTypeData[$friendId] ?? 0; + $v['id'] = $friendId; unset($v['chatroomId']); - } - - if (!empty($v['wechatChatroomId'])) { + } elseif (!empty($v['wechatChatroomId'])) { + // 群聊消息 + $chatroomId = $v['wechatChatroomId']; + $chatroom = $chatrooms[$chatroomId] ?? null; + + $v['nickname'] = $chatroom['nickname'] ?? ''; + $v['avatar'] = $chatroom['chatroomAvatar'] ?? ''; $v['conRemark'] = ''; - $unreadCount = isset($chatroomUnreadMap[$v['wechatChatroomId']]) ? (int)$chatroomUnreadMap[$v['wechatChatroomId']] : 0; + $v['isTop'] = $chatroom['isTop'] ?? 0; + $v['chatroomId'] = $chatroom['chatroomId'] ?? ''; + + $unreadCount = $unreadMap['chatroom_' . $chatroomId] ?? 0; + $v['id'] = $chatroomId; + unset($v['wechatFriendId']); } - $v['id'] = !empty($v['wechatFriendId']) ? $v['wechatFriendId'] : $v['wechatChatroomId']; $v['config'] = [ 'top' => !empty($v['isTop']) ? true : false, 'unreadCount' => $unreadCount, 'chat' => true, - 'msgTime' => $v['wechatTime'], + 'msgTime' => $wechatTime, ]; $v['createTime'] = $createTime; $v['lastUpdateTime'] = $wechatTime; - - // 最新消息内容已经在UNION查询中获取,直接使用 $v['latestMessage'] = [ - 'content' => $v['content'], + 'content' => $v['content'] ?? '', 'wechatTime' => $wechatTime ]; - unset($v['wechatFriendId'], $v['wechatChatroomId'],$v['isTop']); - + unset($v['wechatChatroomId'], $v['isTop'], $v['msgType']); } unset($v); - return ResponseHelper::success($list); + + return ResponseHelper::success(['list' => $list, 'total' => $totalCount]); } diff --git a/application/command.php b/application/command.php index 2bf23db..8fc4a2e 100644 --- a/application/command.php +++ b/application/command.php @@ -47,4 +47,7 @@ return [ // 统一任务调度器 'scheduler:run' => 'app\command\TaskSchedulerCommand', // 统一任务调度器,支持多进程并发执行 + + // 检查未读/未回复消息并自动迁移好友 + 'check:unread-message' => 'app\command\CheckUnreadMessageCommand', // 检查未读/未回复消息并自动迁移好友 ]; diff --git a/application/command/CheckUnreadMessageCommand.php b/application/command/CheckUnreadMessageCommand.php new file mode 100644 index 0000000..b5fa343 --- /dev/null +++ b/application/command/CheckUnreadMessageCommand.php @@ -0,0 +1,63 @@ +setName('check:unread-message') + ->setDescription('检查未读/未回复消息并自动迁移好友') + ->addOption('minutes', 'm', \think\console\input\Option::VALUE_OPTIONAL, '未读/未回复分钟数,默认30分钟', 30) + ->addOption('page-size', 'p', \think\console\input\Option::VALUE_OPTIONAL, '每页处理数量,默认100条', 100); + } + + protected function execute(Input $input, Output $output) + { + $minutes = intval($input->getOption('minutes')); + if ($minutes <= 0) { + $minutes = 30; + } + + $pageSize = intval($input->getOption('page-size')); + if ($pageSize <= 0) { + $pageSize = 100; + } + + $output->writeln("开始检查未读/未回复消息(超过{$minutes}分钟,每页处理{$pageSize}条)..."); + + try { + $friendTransferService = new FriendTransferService(); + $result = $friendTransferService->checkAndTransferUnreadOrUnrepliedFriends($minutes, $pageSize); + + $output->writeln("检查完成:"); + $output->writeln(" 总计需要迁移的好友数:{$result['total']}"); + $output->writeln(" 成功迁移的好友数:{$result['transferred']}"); + $output->writeln(" 迁移失败的好友数:{$result['failed']}"); + + if ($result['total'] > 0) { + Log::info("未读/未回复消息检查完成:总计{$result['total']},成功{$result['transferred']},失败{$result['failed']}"); + } + + } catch (\Exception $e) { + $errorMsg = "检查未读/未回复消息异常:" . $e->getMessage(); + $output->writeln("{$errorMsg}"); + Log::error($errorMsg); + } + } +} + diff --git a/application/command/TaskSchedulerCommand.php b/application/command/TaskSchedulerCommand.php index 9cad5c1..b23dae2 100644 --- a/application/command/TaskSchedulerCommand.php +++ b/application/command/TaskSchedulerCommand.php @@ -29,7 +29,7 @@ class TaskSchedulerCommand extends Command /** * 最大并发进程数 */ - protected $maxConcurrent = 10; + protected $maxConcurrent = 20; /** * 当前运行的进程数 @@ -61,23 +61,48 @@ class TaskSchedulerCommand extends Command $this->maxConcurrent = 1; } - // 加载任务配置(优先使用框架配置,其次直接引入配置文件,避免加载失败) + // 加载任务配置 + // 方法1:尝试通过框架配置加载 $this->tasks = Config::get('task_scheduler', []); - - // 如果通过 Config 没有读到,再尝试直接 include 配置文件 + + // 方法2:如果框架配置没有,直接加载配置文件 if (empty($this->tasks)) { - // 以项目根目录为基准查找 config/task_scheduler.php - $configFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php'; - if (is_file($configFile)) { - $config = include $configFile; - if (is_array($config) && !empty($config)) { - $this->tasks = $config; + // 获取项目根目录 + if (!defined('ROOT_PATH')) { + define('ROOT_PATH', dirname(__DIR__, 2)); + } + + // 尝试多个可能的路径 + $possiblePaths = [ + ROOT_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php', + __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php', + dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php', + ]; + + foreach ($possiblePaths as $configFile) { + if (is_file($configFile)) { + $output->writeln("找到配置文件:{$configFile}"); + $config = include $configFile; + if (is_array($config) && !empty($config)) { + $this->tasks = $config; + break; + } else { + $output->writeln("配置文件返回的不是数组或为空:{$configFile}"); + } } } } if (empty($this->tasks)) { - $output->writeln('错误:未找到任务配置(task_scheduler),请检查 config/task_scheduler.php 是否存在且返回数组'); + $output->writeln('错误:未找到任务配置(task_scheduler)'); + $output->writeln('请检查以下位置:'); + $output->writeln('1. config/task_scheduler.php 文件是否存在'); + $output->writeln('2. 文件是否返回有效的数组'); + $output->writeln('3. 文件权限是否正确'); + if (defined('ROOT_PATH')) { + $output->writeln('项目根目录:' . ROOT_PATH . ''); + $output->writeln('期望配置文件:' . ROOT_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php'); + } return false; } @@ -104,16 +129,24 @@ class TaskSchedulerCommand extends Command // 筛选需要执行的任务 $tasksToRun = []; + $enabledCount = 0; + $disabledCount = 0; + foreach ($this->tasks as $taskId => $task) { if (!isset($task['enabled']) || !$task['enabled']) { + $disabledCount++; continue; } + $enabledCount++; if ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) { $tasksToRun[$taskId] = $task; + $output->writeln("任务 {$taskId} 符合执行条件(schedule: {$task['schedule']})"); } } + $output->writeln("已启用任务数: {$enabledCount},已禁用任务数: {$disabledCount}"); + if (empty($tasksToRun)) { $output->writeln('当前时间没有需要执行的任务'); return true; @@ -266,9 +299,36 @@ class TaskSchedulerCommand extends Command // 检查任务是否已经在运行(防止重复执行) $lockKey = "scheduler_task_lock:{$taskId}"; $lockTime = Cache::get($lockKey); - if ($lockTime && (time() - $lockTime) < 300) { // 5分钟内不重复执行 - $output->writeln("任务 {$taskId} 正在运行中,跳过"); - continue; + + // 如果锁存在,检查进程是否真的在运行 + if ($lockTime) { + $lockPid = Cache::get("scheduler_task_pid:{$taskId}"); + if ($lockPid) { + // 检查进程是否真的在运行 + if (function_exists('posix_kill')) { + // 使用 posix_kill(pid, 0) 检查进程是否存在(0信号不杀死进程,只检查) + if (@posix_kill($lockPid, 0)) { + $output->writeln("任务 {$taskId} 正在运行中(PID: {$lockPid}),跳过"); + continue; + } else { + // 进程不存在,清除锁 + Cache::rm($lockKey); + Cache::rm("scheduler_task_pid:{$taskId}"); + } + } else { + // 如果没有 posix_kill,使用时间判断(2分钟内不重复执行) + if ((time() - $lockTime) < 120) { + $output->writeln("任务 {$taskId} 可能在运行中(2分钟内执行过),跳过"); + continue; + } + } + } else { + // 如果没有PID记录,使用时间判断(2分钟内不重复执行) + if ((time() - $lockTime) < 120) { + $output->writeln("任务 {$taskId} 可能在运行中(2分钟内执行过),跳过"); + continue; + } + } } // 创建子进程 @@ -291,8 +351,9 @@ class TaskSchedulerCommand extends Command ]; $output->writeln("启动任务:{$taskId} (PID: {$pid})"); - // 设置任务锁 + // 设置任务锁和PID Cache::set($lockKey, time(), 600); // 10分钟过期 + Cache::set("scheduler_task_pid:{$taskId}", $pid, 600); // 保存PID,10分钟过期 } } @@ -337,37 +398,51 @@ class TaskSchedulerCommand extends Command } // 构建命令 - // 使用项目根目录下的 think 脚本(同命令行 php think) - if (!defined('ROOT_PATH')) { - define('ROOT_PATH', dirname(__DIR__, 2)); + // 使用指定的网站目录作为执行目录 + $executionPath = '/www/wwwroot/mckb_quwanzhi_com/Server'; + + // 获取 PHP 可执行文件路径 + $phpPath = PHP_BINARY ?: 'php'; + + // 获取 think 脚本路径(使用执行目录) + $thinkPath = $executionPath . DIRECTORY_SEPARATOR . 'think'; + + // 检查 think 文件是否存在 + if (!is_file($thinkPath)) { + $errorMsg = "错误:think 文件不存在:{$thinkPath}"; + Log::error($errorMsg); + file_put_contents($logFile, $errorMsg . "\n", FILE_APPEND); + return; } - $thinkPath = ROOT_PATH . DIRECTORY_SEPARATOR . 'think'; - $command = "php {$thinkPath} {$task['command']}"; + + // 构建命令(使用绝对路径,确保在 Linux 上能正确执行) + $command = escapeshellarg($phpPath) . ' ' . escapeshellarg($thinkPath) . ' ' . escapeshellarg($task['command']); if (!empty($task['options'])) { foreach ($task['options'] as $option) { $command .= ' ' . escapeshellarg($option); } } - // 添加日志重定向 + // 添加日志重定向(在后台执行) $command .= " >> " . escapeshellarg($logFile) . " 2>&1"; // 记录任务开始 $logMessage = "\n" . str_repeat('=', 60) . "\n"; $logMessage .= "任务开始执行: {$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 环境) $descriptorspec = [ - 0 => ['file', (PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'), 'r'], // stdin + 0 => ['file', '/dev/null', 'r'], // stdin 1 => ['file', $logFile, 'a'], // stdout 2 => ['file', $logFile, 'a'], // stderr ]; - $process = @proc_open($command, $descriptorspec, $pipes, ROOT_PATH); + $process = @proc_open($command, $descriptorspec, $pipes, $executionPath); if (is_resource($process)) { // 关闭管道 @@ -412,12 +487,8 @@ class TaskSchedulerCommand extends Command // 关闭进程 proc_close($process); } else { - // 如果 proc_open 失败,尝试直接执行(后台执行) - if (PHP_OS_FAMILY === 'Windows') { - pclose(popen("start /B " . $command, "r")); - } else { - exec($command . ' > /dev/null 2>&1 &'); - } + // 如果 proc_open 失败,使用 exec 在后台执行(Linux 环境) + exec("cd " . escapeshellarg($executionPath) . " && " . $command . ' > /dev/null 2>&1 &'); } $endTime = microtime(true); @@ -448,12 +519,17 @@ class TaskSchedulerCommand extends Command if ($result == $pid || $result == -1) { // 进程已结束 + $taskId = $info['task_id']; unset($this->runningProcesses[$pid]); + // 清除任务锁和PID + Cache::rm("scheduler_task_lock:{$taskId}"); + Cache::rm("scheduler_task_pid:{$taskId}"); + $duration = time() - $info['start_time']; Log::info("子进程执行完成", [ 'pid' => $pid, - 'task' => $info['task_id'], + 'task' => $taskId, 'duration' => $duration, ]); } diff --git a/application/common/service/FriendTransferService.php b/application/common/service/FriendTransferService.php new file mode 100644 index 0000000..314e17c --- /dev/null +++ b/application/common/service/FriendTransferService.php @@ -0,0 +1,480 @@ + bool, 'message' => string, 'toAccountId' => int|null] + */ + public function transferFriend($wechatFriendId, $currentAccountId, $reason = '') + { + try { + // 获取好友信息 + $friend = Db::table('s2_wechat_friend')->where('id', $wechatFriendId)->find(); + if (empty($friend)) { + return [ + 'success' => false, + 'message' => '好友不存在', + 'toAccountId' => null + ]; + } + + // 获取当前账号的部门信息 + $accountData = Db::table('s2_company_account')->where('id', $currentAccountId)->find(); + if (empty($accountData)) { + return [ + 'success' => false, + 'message' => '当前账号不存在', + 'toAccountId' => null + ]; + } + + // 获取同部门的在线账号列表 + $accountIds = Db::table('s2_company_account') + ->where([ + 'departmentId' => $accountData['departmentId'], + 'alive' => 1 + ]) + ->column('id'); + if (empty($accountIds)) { + return [ + 'success' => false, + 'message' => '没有可用的在线账号', + 'toAccountId' => null + ]; + } + + // 如果好友当前账号不在可用账号列表中,或者需要迁移到其他账号 + $needTransfer = !in_array($friend['accountId'], $accountIds); + + // 如果需要迁移,选择目标账号 + if ($needTransfer || $currentAccountId != $friend['accountId']) { + // 排除当前账号,选择其他账号 + $availableAccountIds = array_filter($accountIds, function($id) use ($currentAccountId) { + return $id != $currentAccountId; + }); + + if (empty($availableAccountIds)) { + return [ + 'success' => false, + 'message' => '没有其他可用的在线账号', + 'toAccountId' => null + ]; + } + + // 随机选择一个账号 + $availableAccountIds = array_values($availableAccountIds); + $randomKey = array_rand($availableAccountIds, 1); + $toAccountId = $availableAccountIds[$randomKey]; + + // 获取目标账号信息 + $toAccountData = Db::table('s2_company_account')->where('id', $toAccountId)->find(); + if (empty($toAccountData)) { + return [ + 'success' => false, + 'message' => '目标账号不存在', + 'toAccountId' => null + ]; + } + + // 执行迁移 + $automaticAssign = new AutomaticAssign(); + $result = $automaticAssign->allotWechatFriend([ + 'wechatFriendId' => $wechatFriendId, + 'toAccountId' => $toAccountId + ], true); + + $resultData = json_decode($result, true); + + if (isset($resultData['code']) && $resultData['code'] == 200) { + // 更新好友的账号信息 + Db::table('s2_wechat_friend') + ->where('id', $wechatFriendId) + ->update([ + 'accountId' => $toAccountId, + 'accountUserName' => $toAccountData['userName'], + 'accountRealName' => $toAccountData['realName'], + 'accountNickname' => $toAccountData['nickname'], + ]); + + $logMessage = "好友迁移成功:好友ID={$wechatFriendId},从账号{$currentAccountId}迁移到账号{$toAccountId}"; + if (!empty($reason)) { + $logMessage .= ",原因:{$reason}"; + } + Log::info($logMessage); + + return [ + 'success' => true, + 'message' => '好友迁移成功', + 'toAccountId' => $toAccountId + ]; + } else { + $errorMsg = isset($resultData['msg']) ? $resultData['msg'] : '迁移失败'; + Log::error("好友迁移失败:好友ID={$wechatFriendId},错误:{$errorMsg}"); + return [ + 'success' => false, + 'message' => $errorMsg, + 'toAccountId' => null + ]; + } + } + + return [ + 'success' => true, + 'message' => '好友已在正确的账号上,无需迁移', + 'toAccountId' => $friend['accountId'] + ]; + + } catch (\Exception $e) { + Log::error("好友迁移异常:好友ID={$wechatFriendId},错误:" . $e->getMessage()); + return [ + 'success' => false, + 'message' => '迁移异常:' . $e->getMessage(), + 'toAccountId' => null + ]; + } + } + + /** + * 批量迁移好友到其他账号(按账号分组处理) + * @param array $friends 好友列表,格式:[['friendId' => int, 'accountId' => int], ...] + * @param int $currentAccountId 当前账号ID + * @param string $reason 迁移原因 + * @return array ['transferred' => int, 'failed' => int] + */ + public function transferFriendsBatch($friends, $currentAccountId, $reason = '') + { + $transferred = 0; + $failed = 0; + + if (empty($friends)) { + return ['transferred' => 0, 'failed' => 0]; + } + + try { + // 获取当前账号的部门信息 + $accountData = Db::table('s2_company_account')->where('id', $currentAccountId)->find(); + if (empty($accountData)) { + Log::error("批量迁移失败:当前账号不存在,账号ID={$currentAccountId}"); + return ['transferred' => 0, 'failed' => count($friends)]; + } + + // 获取同部门的在线账号列表 + $accountIds = Db::table('s2_company_account') + ->where([ + 'departmentId' => $accountData['departmentId'], + 'alive' => 1 + ]) + ->column('id'); + if (empty($accountIds)) { + Log::warning("批量迁移失败:没有可用的在线账号,账号ID={$currentAccountId}"); + return ['transferred' => 0, 'failed' => count($friends)]; + } + // 排除当前账号,选择其他账号 + $availableAccountIds = array_filter($accountIds, function($id) use ($currentAccountId) { + return $id != $currentAccountId; + }); + if (empty($availableAccountIds)) { + Log::warning("批量迁移失败:没有其他可用的在线账号,账号ID={$currentAccountId}"); + return ['transferred' => 0, 'failed' => count($friends)]; + } + + // 随机选择一个目标账号(同一批次使用同一个目标账号) + $availableAccountIds = array_values($availableAccountIds); + $randomKey = array_rand($availableAccountIds, 1); + $toAccountId = $availableAccountIds[$randomKey]; + + // 获取目标账号信息 + $toAccountData = Db::table('s2_company_account')->where('id', $toAccountId)->find(); + if (empty($toAccountData)) { + Log::error("批量迁移失败:目标账号不存在,账号ID={$toAccountId}"); + return ['transferred' => 0, 'failed' => count($friends)]; + } + + // 批量获取好友信息 + $friendIds = array_column($friends, 'friendId'); + $friendList = Db::table('s2_wechat_friend') + ->where('id', 'in', $friendIds) + ->select(); + + $friendMap = []; + foreach ($friendList as $friend) { + $friendMap[$friend['id']] = $friend; + } + + // 批量执行迁移 + $automaticAssign = new AutomaticAssign(); + $updateData = []; + + foreach ($friends as $friendItem) { + $wechatFriendId = $friendItem['friendId']; + + if (!isset($friendMap[$wechatFriendId])) { + $failed++; + Log::warning("批量迁移失败:好友不存在,好友ID={$wechatFriendId}"); + continue; + } + + $friend = $friendMap[$wechatFriendId]; + + // 如果好友当前账号不在可用账号列表中,或者需要迁移到其他账号 + $needTransfer = !in_array($friend['accountId'], $accountIds) || $currentAccountId != $friend['accountId']; + + if ($needTransfer) { + // 执行迁移 + $result = $automaticAssign->allotWechatFriend([ + 'wechatFriendId' => $wechatFriendId, + 'toAccountId' => $toAccountId + ], true); + + $resultData = json_decode($result, true); + + if (isset($resultData['code']) && $resultData['code'] == 200) { + // 收集需要更新的数据 + $updateData[] = [ + 'id' => $wechatFriendId, + 'accountId' => $toAccountId, + 'accountUserName' => $toAccountData['userName'], + 'accountRealName' => $toAccountData['realName'], + 'accountNickname' => $toAccountData['nickname'], + ]; + $transferred++; + } else { + $errorMsg = isset($resultData['msg']) ? $resultData['msg'] : '迁移失败'; + $failed++; + Log::warning("批量迁移失败:好友ID={$wechatFriendId},错误:{$errorMsg}"); + } + } else { + // 无需迁移 + $transferred++; + } + } + + // 批量更新好友的账号信息 + if (!empty($updateData)) { + foreach ($updateData as $data) { + Db::table('s2_wechat_friend') + ->where('id', $data['id']) + ->update([ + 'accountId' => $data['accountId'], + 'accountUserName' => $data['accountUserName'], + 'accountRealName' => $data['accountRealName'], + 'accountNickname' => $data['accountNickname'], + ]); + } + + $logMessage = "批量迁移成功:账号ID={$currentAccountId},共" . count($updateData) . "个好友迁移到账号{$toAccountId}"; + if (!empty($reason)) { + $logMessage .= ",原因:{$reason}"; + } + Log::info($logMessage); + } + + return [ + 'transferred' => $transferred, + 'failed' => $failed + ]; + + } catch (\Exception $e) { + Log::error("批量迁移异常:账号ID={$currentAccountId},错误:" . $e->getMessage()); + return [ + 'transferred' => $transferred, + 'failed' => count($friends) - $transferred + ]; + } + } + + /** + * 检查并迁移未读或未回复的好友 + * @param int $unreadMinutes 未读分钟数,默认30分钟 + * @param int $pageSize 每页处理数量,默认100 + * @return array ['total' => int, 'transferred' => int, 'failed' => int] + */ + public function checkAndTransferUnreadOrUnrepliedFriends($unreadMinutes = 30, $pageSize = 100) + { + $total = 0; + $transferred = 0; + $failed = 0; + + try { + $currentTime = time(); + $timeThreshold = $currentTime - ($unreadMinutes * 60); // 超过指定分钟数的时间点 + $last24Hours = $currentTime - (24 * 60 * 60); // 近24小时的时间点 + + // 确保每页数量合理 + $pageSize = max(1, min(1000, intval($pageSize))); + + // 查询需要迁移的好友 + // 条件:以消息表为主表,查询近24小时内的消息 + // 1. 最后一条消息是用户发送的消息(isSend=0) + // 2. 消息时间在近24小时内 + // 3. 消息时间超过指定分钟数(默认30分钟) + // 4. 在这条用户消息之后,客服没有发送任何回复 + // 即:用户发送了消息,但客服超过30分钟没有回复,需要迁移给其他客服处理 + + // SQL逻辑说明(以消息表为主表): + // 1. 从消息表开始,筛选近24小时内的用户消息(isSend=0) + // 2. 找到每个好友的最后一条用户消息(通过MAX(id)) + // 3. 这条消息的时间超过指定分钟数(wm.wechatTime <= timeThreshold) + // 4. 在这条用户消息之后,客服没有发送任何回复(NOT EXISTS isSend=1的消息) + // 5. 关联好友表,确保好友未删除且已分配账号 + + // 先统计总数 + $countSql = " + SELECT COUNT(DISTINCT wm.wechatFriendId) as total + FROM s2_wechat_message wm + INNER JOIN ( + SELECT wechatFriendId, MAX(id) as maxId + FROM s2_wechat_message + WHERE type = 1 + AND isSend = 0 -- 用户发送的消息 + AND wechatTime >= ? -- 近24小时内的消息 + GROUP BY wechatFriendId + ) last_msg ON wm.id = last_msg.maxId + INNER JOIN s2_wechat_friend wf ON wf.id = wm.wechatFriendId + WHERE wm.type = 1 + AND wm.isSend = 0 -- 最后一条消息是用户发送的(客服接收的) + AND wm.wechatTime >= ? -- 近24小时内的消息 + AND wm.wechatTime <= ? -- 超过指定时间(默认30分钟) + AND wf.isDeleted = 0 + AND wf.accountId IS NOT NULL + AND NOT EXISTS ( + -- 检查在这条用户消息之后,是否有客服的回复 + SELECT 1 + FROM s2_wechat_message + WHERE wechatFriendId = wm.wechatFriendId + AND type = 1 + AND isSend = 1 -- 客服发送的消息 + AND wechatTime > wm.wechatTime -- 在用户消息之后 + ) + "; + + $countResult = Db::query($countSql, [$last24Hours, $last24Hours, $timeThreshold]); + $total = isset($countResult[0]['total']) ? intval($countResult[0]['total']) : 0; + + if ($total == 0) { + Log::info("未找到需要迁移的未读/未回复好友(近24小时内)"); + return [ + 'total' => 0, + 'transferred' => 0, + 'failed' => 0 + ]; + } + + Log::info("开始检查未读/未回复好友(近24小时内),共找到 {$total} 个需要迁移的好友,将分页处理(每页{$pageSize}条)"); + + // 分页处理 + $page = 1; + $processed = 0; + + do { + $offset = ($page - 1) * $pageSize; + + $sql = " + SELECT DISTINCT + wf.id as friendId, + wf.accountId, + wm.wechatAccountId, + wm.wechatTime, + wm.id as lastMessageId + FROM s2_wechat_message wm + INNER JOIN ( + SELECT wechatFriendId, MAX(id) as maxId + FROM s2_wechat_message + WHERE type = 1 + AND isSend = 0 -- 用户发送的消息 + AND wechatTime >= ? -- 近24小时内的消息 + GROUP BY wechatFriendId + ) last_msg ON wm.id = last_msg.maxId + INNER JOIN s2_wechat_friend wf ON wf.id = wm.wechatFriendId + WHERE wm.type = 1 + AND wm.isSend = 0 -- 最后一条消息是用户发送的(客服接收的) + AND wm.wechatTime >= ? -- 近24小时内的消息 + AND wm.wechatTime <= ? -- 超过指定时间(默认30分钟) + AND wf.isDeleted = 0 + AND wf.accountId IS NOT NULL + AND NOT EXISTS ( + -- 检查在这条用户消息之后,是否有客服的回复 + SELECT 1 + FROM s2_wechat_message + WHERE wechatFriendId = wm.wechatFriendId + AND type = 1 + AND isSend = 1 -- 客服发送的消息 + AND wechatTime > wm.wechatTime -- 在用户消息之后 + ) + ORDER BY wf.accountId ASC, wm.id ASC + LIMIT ? OFFSET ? + "; + + $friends = Db::query($sql, [$last24Hours, $last24Hours, $timeThreshold, $pageSize, $offset]); + $currentPageCount = count($friends); + + if ($currentPageCount == 0) { + break; + } + + Log::info("处理第 {$page} 页,本页 {$currentPageCount} 条记录"); + + // 按 accountId 分组 + $friendsByAccount = []; + foreach ($friends as $friend) { + $accountId = $friend['accountId']; + if (!isset($friendsByAccount[$accountId])) { + $friendsByAccount[$accountId] = []; + } + $friendsByAccount[$accountId][] = $friend; + } + // 按账号分组批量处理 + foreach ($friendsByAccount as $accountId => $accountFriends) { + $batchResult = $this->transferFriendsBatch( + $accountFriends, + $accountId, + "消息未读或未回复超过{$unreadMinutes}分钟" + ); + + $transferred += $batchResult['transferred']; + $failed += $batchResult['failed']; + $processed += count($accountFriends); + + Log::info("账号 {$accountId} 批量迁移完成:成功{$batchResult['transferred']},失败{$batchResult['failed']},共" . count($accountFriends) . "个好友"); + } + + $page++; + + // 每处理一页后记录进度 + Log::info("已处理 {$processed}/{$total} 条记录,成功:{$transferred},失败:{$failed}"); + + } while ($currentPageCount == $pageSize && $processed < $total); + + Log::info("未读/未回复好友迁移完成:总计{$total},成功{$transferred},失败{$failed}"); + + return [ + 'total' => $total, + 'transferred' => $transferred, + 'failed' => $failed + ]; + + } catch (\Exception $e) { + Log::error("检查未读/未回复好友异常:" . $e->getMessage()); + return [ + 'total' => $total, + 'transferred' => $transferred, + 'failed' => $failed + ]; + } + } +} + diff --git a/application/cunkebao/controller/device/GetAddResultedV1Controller.php b/application/cunkebao/controller/device/GetAddResultedV1Controller.php index a2c78b0..4959870 100644 --- a/application/cunkebao/controller/device/GetAddResultedV1Controller.php +++ b/application/cunkebao/controller/device/GetAddResultedV1Controller.php @@ -49,7 +49,12 @@ class GetAddResultedV1Controller extends BaseController $deviceIds = $this->getAllDevicesIdWithInCompany($companyId) ?: [0]; // 从 s2_device 导入数据。 - $this->getNewDeviceFromS2_device($deviceIds, $companyId); + $newDeviceIds = $this->getNewDeviceFromS2_device($deviceIds, $companyId); + + // 如果有新设备,自动加入到全局配置中 + if (!empty($newDeviceIds)) { + $this->addDevicesToGlobalConfigs($newDeviceIds, $companyId); + } } /** @@ -57,12 +62,23 @@ class GetAddResultedV1Controller extends BaseController * * @param array $ids * @param int $companyId - * @return void + * @return array 返回新添加的设备ID数组 */ - protected function getNewDeviceFromS2_device(array $ids, int $companyId): void + protected function getNewDeviceFromS2_device(array $ids, int $companyId): array { $ids = implode(',', $ids); + // 先查询要插入的新设备ID + $newDeviceIds = Db::query("SELECT d.id + FROM s2_device d + JOIN s2_company_account a ON d.currentAccountId = a.id + WHERE isDeleted = 0 AND deletedAndStop = 0 AND d.id NOT IN ({$ids}) AND a.departmentId = {$companyId}"); + $newDeviceIds = array_column($newDeviceIds, 'id'); + + if (empty($newDeviceIds)) { + return []; + } + $sql = "INSERT INTO ck_device(`id`, `imei`, `model`, phone, operatingSystem, memo, alive, brand, rooted, xPosed, softwareVersion, extra, createTime, updateTime, deleteTime, companyId) SELECT d.id, d.imei, d.model, d.phone, d.operatingSystem, d.memo, d.alive, d.brand, d.rooted, d.xPosed, d.softwareVersion, d.extra, d.createTime, d.lastUpdateTime, d.deleteTime, a.departmentId AS companyId @@ -86,6 +102,8 @@ class GetAddResultedV1Controller extends BaseController companyId = VALUES(companyId)"; Db::query($sql); + + return $newDeviceIds; } /** @@ -162,4 +180,144 @@ class GetAddResultedV1Controller extends BaseController ] ); } + + /** + * 将新设备自动加入到全局配置中(planType=0的计划和工作台) + * + * @param array $newDeviceIds 新添加的设备ID数组 + * @param int $companyId 公司ID + * @return void + */ + protected function addDevicesToGlobalConfigs(array $newDeviceIds, int $companyId): void + { + try { + // 1. 更新全局计划(场景获客)的设备组 + $this->addDevicesToGlobalPlans($newDeviceIds, $companyId); + + // 2. 更新全局工作台的设备组 + $this->addDevicesToGlobalWorkbenches($newDeviceIds, $companyId); + } catch (\Exception $e) { + // 记录错误但不影响设备添加流程 + \think\facade\Log::error('自动添加设备到全局配置失败:' . $e->getMessage(), [ + 'newDeviceIds' => $newDeviceIds, + 'companyId' => $companyId + ]); + } + } + + /** + * 将新设备加入到全局计划(planType=0)的设备组 + * + * @param array $newDeviceIds 新添加的设备ID数组 + * @param int $companyId 公司ID + * @return void + */ + protected function addDevicesToGlobalPlans(array $newDeviceIds, int $companyId): void + { + // 查询所有全局计划(planType=0) + $plans = Db::name('customer_acquisition_task') + ->where('companyId', $companyId) + ->where('planType', 0) // 全局计划 + ->where('deleteTime', 0) + ->field('id,reqConf') + ->select(); + + foreach ($plans as $plan) { + $reqConf = json_decode($plan['reqConf'], true) ?: []; + $deviceGroups = isset($reqConf['device']) ? $reqConf['device'] : []; + + if (!is_array($deviceGroups)) { + $deviceGroups = []; + } + + // 合并新设备ID(去重) + $deviceGroups = array_unique(array_merge($deviceGroups, $newDeviceIds)); + $reqConf['device'] = array_values($deviceGroups); // 重新索引数组 + + // 更新数据库 + Db::name('customer_acquisition_task') + ->where('id', $plan['id']) + ->update([ + 'reqConf' => json_encode($reqConf, JSON_UNESCAPED_UNICODE), + 'updateTime' => time() + ]); + } + } + + /** + * 将新设备加入到全局工作台(planType=0)的设备组 + * + * @param array $newDeviceIds 新添加的设备ID数组 + * @param int $companyId 公司ID + * @return void + */ + protected function addDevicesToGlobalWorkbenches(array $newDeviceIds, int $companyId): void + { + // 查询所有全局工作台(planType=0) + $workbenches = Db::name('workbench') + ->where('companyId', $companyId) + ->where('planType', 0) // 全局工作台 + ->where('isDel', 0) + ->field('id,type') + ->select(); + + foreach ($workbenches as $workbench) { + // 根据工作台类型更新对应的配置表 + $this->updateWorkbenchDevices($workbench['id'], $workbench['type'], $newDeviceIds); + } + } + + /** + * 更新工作台的设备组 + * + * @param int $workbenchId 工作台ID + * @param int $type 工作台类型 + * @param array $newDeviceIds 新设备ID数组 + * @return void + */ + protected function updateWorkbenchDevices(int $workbenchId, int $type, array $newDeviceIds): void + { + $configTableMap = [ + 1 => 'workbench_auto_like', // 自动点赞 + 2 => 'workbench_moments_sync', // 朋友圈同步 + 3 => 'workbench_group_push', // 群消息推送 + 4 => 'workbench_group_create', // 自动建群 + 5 => 'workbench_traffic_config', // 流量分发 + 6 => 'workbench_import_contact', // 通讯录导入 + 7 => 'workbench_group_welcome', // 入群欢迎语 + ]; + + $tableName = $configTableMap[$type] ?? null; + if (empty($tableName)) { + return; + } + + // 查询配置 + $config = Db::name($tableName) + ->where('workbenchId', $workbenchId) + ->field('id,devices') + ->find(); + + if (empty($config)) { + return; + } + + // 解析设备组 + $deviceGroups = json_decode($config['devices'], true) ?: []; + if (!is_array($deviceGroups)) { + $deviceGroups = []; + } + + // 合并新设备ID(去重) + $deviceGroups = array_unique(array_merge($deviceGroups, $newDeviceIds)); + $deviceGroups = array_values($deviceGroups); // 重新索引数组 + + // 更新数据库 + Db::name($tableName) + ->where('id', $config['id']) + ->update([ + 'devices' => json_encode($deviceGroups, JSON_UNESCAPED_UNICODE), + 'updateTime' => time() + ]); + } } \ No newline at end of file diff --git a/application/cunkebao/controller/plan/GetAddFriendPlanDetailV1Controller.php b/application/cunkebao/controller/plan/GetAddFriendPlanDetailV1Controller.php index 7f5f609..3c31892 100644 --- a/application/cunkebao/controller/plan/GetAddFriendPlanDetailV1Controller.php +++ b/application/cunkebao/controller/plan/GetAddFriendPlanDetailV1Controller.php @@ -287,6 +287,13 @@ class GetAddFriendPlanDetailV1Controller extends Controller $newData['messagePlans'] = $msgConf; $newData = array_merge($newData, $sceneConf, $reqConf, $tagConf, $plan); + // 确保 planType 有默认值(0=全局,1=独立,默认1) + if (!isset($newData['planType'])) { + $newData['planType'] = 1; + } else { + $newData['planType'] = intval($newData['planType']); + } + // 移除不需要的字段 unset( $newData['sceneConf'], diff --git a/application/cunkebao/controller/plan/PlanSceneV1Controller.php b/application/cunkebao/controller/plan/PlanSceneV1Controller.php index 3812e56..2e70152 100644 --- a/application/cunkebao/controller/plan/PlanSceneV1Controller.php +++ b/application/cunkebao/controller/plan/PlanSceneV1Controller.php @@ -61,6 +61,13 @@ class PlanSceneV1Controller extends BaseController $val['reqConf'] = json_decode($val['reqConf'],true) ?: []; $val['msgConf'] = json_decode($val['msgConf'],true) ?: []; $val['tagConf'] = json_decode($val['tagConf'],true) ?: []; + + // 确保 planType 有默认值(0=全局,1=独立,默认1) + if (!isset($val['planType'])) { + $val['planType'] = 1; + } else { + $val['planType'] = intval($val['planType']); + } $stats = $statsMap[$val['id']] ?? [ 'acquiredCount' => 0, diff --git a/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php b/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php index 4a727d5..67594a1 100644 --- a/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php +++ b/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php @@ -150,6 +150,8 @@ class PostCreateAddFriendPlanV1Controller extends BaseController 'userId' => $this->getUserInfo('id'), 'companyId' => $this->getUserInfo('companyId'), 'status' => !empty($params['status']) ? 1 : 0, + // 计划类型:0=全局,1=独立(默认) + 'planType' => isset($params['planType']) ? intval($params['planType']) : 1, // 拉群配置 'groupInviteEnabled' => $groupInviteEnabled, 'groupName' => $params['groupName'] ?? '', diff --git a/application/cunkebao/controller/plan/PostUpdateAddFriendPlanV1Controller.php b/application/cunkebao/controller/plan/PostUpdateAddFriendPlanV1Controller.php index d55662f..5511d52 100644 --- a/application/cunkebao/controller/plan/PostUpdateAddFriendPlanV1Controller.php +++ b/application/cunkebao/controller/plan/PostUpdateAddFriendPlanV1Controller.php @@ -125,6 +125,8 @@ class PostUpdateAddFriendPlanV1Controller extends BaseController 'msgConf' => json_encode($msgConf, JSON_UNESCAPED_UNICODE), 'tagConf' => json_encode($tagConf, JSON_UNESCAPED_UNICODE), 'status' => !empty($params['status']) ? 1 : 0, + // 计划类型:0=全局,1=独立(默认) + 'planType' => isset($params['planType']) ? intval($params['planType']) : 1, // 拉群配置 'groupInviteEnabled' => $groupInviteEnabled, 'groupName' => $params['groupName'] ?? '', diff --git a/application/cunkebao/controller/workbench/WorkbenchController.php b/application/cunkebao/controller/workbench/WorkbenchController.php index 3bd4169..03c6a82 100644 --- a/application/cunkebao/controller/workbench/WorkbenchController.php +++ b/application/cunkebao/controller/workbench/WorkbenchController.php @@ -79,6 +79,8 @@ class WorkbenchController extends Controller $workbench->type = $param['type']; $workbench->status = !empty($param['status']) ? 1 : 0; $workbench->autoStart = !empty($param['autoStart']) ? 1 : 0; + // 计划类型:0=全局,1=独立(默认1) + $workbench->planType = isset($param['planType']) ? intval($param['planType']) : 1; $workbench->userId = $userInfo['id']; $workbench->companyId = $userInfo['companyId']; $workbench->createTime = time(); @@ -133,7 +135,6 @@ class WorkbenchController extends Controller case self::TYPE_GROUP_CREATE: // 自动建群 $config = new WorkbenchGroupCreate; $config->workbenchId = $workbench->id; - $config->planType = !empty($param['planType']) ? $param['planType'] : 0; $config->executorId = !empty($param['executorId']) ? $param['executorId'] : 0; $config->devices = json_encode($param['deviceGroups'] ?? [], JSON_UNESCAPED_UNICODE); @@ -295,7 +296,7 @@ class WorkbenchController extends Controller $list = Workbench::where($where) ->with($with) - ->field('id,companyId,name,type,status,autoStart,userId,createTime,updateTime') + ->field('id,companyId,name,type,status,autoStart,planType,userId,createTime,updateTime') ->order('id', 'desc') ->page($page, $limit) ->select() @@ -474,7 +475,7 @@ class WorkbenchController extends Controller break; case self::TYPE_IMPORT_CONTACT: - if (!empty($item->importContact)) { + if (!empty($item->importContact)) { $item->config = $item->importContact; $item->config->devices = json_decode($item->config->devices, true); $item->config->poolGroups = json_decode($item->config->pools, true); @@ -568,7 +569,7 @@ class WorkbenchController extends Controller $workbench = Workbench::where($where) - ->field('id,name,type,status,autoStart,createTime,updateTime,companyId') + ->field('id,name,type,status,autoStart,planType,createTime,updateTime,companyId') ->with($with) ->find(); @@ -1187,6 +1188,12 @@ class WorkbenchController extends Controller $workbench->name = $param['name']; $workbench->status = !empty($param['status']) ? 1 : 0; $workbench->autoStart = !empty($param['autoStart']) ? 1 : 0; + // 更新计划类型:0=全局,1=独立(默认保留原值或1) + if (isset($param['planType'])) { + $workbench->planType = intval($param['planType']); + } elseif (!isset($workbench->planType) || $workbench->planType === null) { + $workbench->planType = 1; + } $workbench->updateTime = time(); $workbench->save(); @@ -1457,6 +1464,12 @@ class WorkbenchController extends Controller $newWorkbench->type = $workbench->type; $newWorkbench->status = 1; // 新拷贝的默认启用 $newWorkbench->autoStart = $workbench->autoStart; + // 复制计划类型(0=全局,1=独立,默认1) + if (isset($workbench->planType)) { + $newWorkbench->planType = intval($workbench->planType); + } else { + $newWorkbench->planType = 1; + } $newWorkbench->userId = $this->request->userInfo['id']; $newWorkbench->companyId = $this->request->userInfo['companyId']; $newWorkbench->save(); diff --git a/config/task_scheduler.php b/config/task_scheduler.php index 7fa7ea3..e0b6438 100644 --- a/config/task_scheduler.php +++ b/config/task_scheduler.php @@ -289,6 +289,86 @@ return [ 'log_file' => 'all_friends.log', ], + // 检查未读/未回复消息并自动迁移好友(每5分钟执行一次) + 'check_unread_message' => [ + 'command' => 'check:unread-message', + 'schedule' => '*/5 * * * *', // 每5分钟 + 'options' => ['--minutes=30'], // 30分钟未读/未回复 + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'check_unread_message.log', + ], + + // 同步部门列表,用于部门管理与权限控制 + 'department_list' => [ + 'command' => 'department:list', + 'schedule' => '*/30 * * * *', // 每30分钟 + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'crontab_department.log', + ], + + // 同步内容库,将外部内容同步到系统内容库 + 'content_sync' => [ + 'command' => 'content:sync', + 'schedule' => '0 2 * * *', // 每天2点 + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'crontab_content_sync.log', + ], + + // 朋友圈采集任务,采集好友朋友圈内容 + 'moments_collect' => [ + 'command' => 'moments:collect', + 'schedule' => '0 6 * * *', // 每天6点 + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'crontab_moments_collect.log', + ], + + // 分配规则列表,同步分配规则数据 + 'allotrule_list' => [ + 'command' => 'allotrule:list', + 'schedule' => '0 3 * * *', // 每天3点 + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'crontab_allotrule_list.log', + ], + + // 自动创建分配规则,根据规则自动创建分配任务 + 'allotrule_autocreate' => [ + 'command' => 'allotrule:autocreate', + 'schedule' => '0 4 * * *', // 每天4点 + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'crontab_allotrule_autocreate.log', + ], + + // 工作台:入群欢迎语任务,自动发送入群欢迎消息 + 'workbench_group_welcome' => [ + 'command' => 'workbench:groupWelcome', + 'schedule' => '*/1 * * * *', // 每1分钟 + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'workbench_groupWelcome.log', + ], + + // 采集客服自己的朋友圈,同步客服账号的朋友圈内容 + 'own_moments_collect' => [ + 'command' => 'own:moments:collect', + 'schedule' => '*/30 * * * *', // 每30分钟 + 'options' => [], + 'enabled' => true, + 'max_concurrent' => 1, + 'log_file' => 'own_moments_collect.log', + ], + // 已禁用的任务(注释掉的任务) // 'workbench_group_push' => [ // 'command' => 'workbench:groupPush', diff --git a/crontab_tasks.md b/crontab_tasks.md index 3f1bdba..81def43 100644 --- a/crontab_tasks.md +++ b/crontab_tasks.md @@ -169,15 +169,14 @@ crontab -l */5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:import-contact >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/import_contact.log 2>&1 # 工作台入群欢迎语 */1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupWelcome >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupWelcome.log 2>&1 - # 消息提醒 */1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1 # 客服评分 0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechat:calculate-score >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/calculate_score.log 2>&1 - # 采集客服自己的朋友圈 -*/30 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think own:moments:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/own_moments_collect.log 2>&1 - +*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think own:moments:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/own_moments_collect.log 2>&1 +# 检查未读/未回复消息并自动迁移好友 +*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think check:unread-message --minutes=30 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/check_unread_message.log 2>&1 # 每分钟执行一次调度器(调度器内部会自动判断哪些任务需要执行)