diff --git a/Server/application/chukebao/controller/MomentsController.php b/Server/application/chukebao/controller/MomentsController.php index f0d6a4dfd..33705bbe0 100644 --- a/Server/application/chukebao/controller/MomentsController.php +++ b/Server/application/chukebao/controller/MomentsController.php @@ -379,10 +379,50 @@ class MomentsController extends BaseController $total = KfMoments::where(['companyId' => $companyId, 'userId' => $userId, 'isDel' => 0])->count(); + // 收集所有需要查询的微信账号ID + $allWechatAccountIds = []; + foreach ($list as $item) { + $sendData = json_decode($item->sendData, true); + $items = $sendData['jobPublishWechatMomentsItems'] ?? []; + foreach ($items as $accountItem) { + if (!empty($accountItem['wechatAccountId'])) { + $allWechatAccountIds[] = $accountItem['wechatAccountId']; + } + } + } + + // 批量查询微信账号信息 + $wechatAccountsMap = []; + if (!empty($allWechatAccountIds)) { + $wechatAccounts = Db::table('s2_wechat_account') + ->whereIn('id', array_unique($allWechatAccountIds)) + ->field('id, wechatId, nickName, avatar') + ->select(); + foreach ($wechatAccounts as $account) { + $wechatAccountsMap[$account['id']] = $account; + } + } + // 处理数据 $data = []; foreach ($list as $item) { - $sendData = json_decode($item->sendData,true); + $sendData = json_decode($item->sendData, true); + $momentsItems = $sendData['jobPublishWechatMomentsItems'] ?? []; + + // 构建账号详情列表 + $accounts = []; + foreach ($momentsItems as $accountItem) { + $wechatAccountId = $accountItem['wechatAccountId'] ?? 0; + $accountInfo = $wechatAccountsMap[$wechatAccountId] ?? null; + $accounts[] = [ + 'wechatAccountId' => $wechatAccountId, + 'wechatId' => $accountInfo['wechatId'] ?? '', + 'nickName' => $accountInfo['nickName'] ?? '', + 'avatar' => $accountInfo['avatar'] ?? '', + 'labels' => $accountItem['labels'] ?? [] + ]; + } + $data[] = [ 'id' => $item->id, 'content' => $sendData['text'] ?? '', @@ -392,8 +432,9 @@ class MomentsController extends BaseController 'link' => $sendData['link'] ?? [], 'publicMode' => $sendData['publicMode'] ?? 2, 'isSend' => $item->isSend, - 'sendTime' => date('Y-m-d H:i:s',$item->sendTime), - 'accountCount' => count($sendData['jobPublishWechatMomentsItems'] ?? []) + 'sendTime' => date('Y-m-d H:i:s', $item->sendTime), + 'accountCount' => count($accounts), + 'accounts' => $accounts ]; } diff --git a/Server/application/command.php b/Server/application/command.php index 8fc4a2e98..45b6d7da8 100644 --- a/Server/application/command.php +++ b/Server/application/command.php @@ -50,4 +50,7 @@ return [ // 检查未读/未回复消息并自动迁移好友 'check:unread-message' => 'app\command\CheckUnreadMessageCommand', // 检查未读/未回复消息并自动迁移好友 + + // V2 流量池数据迁移 + 'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统 ]; diff --git a/Server/application/command/MigrateTrafficPoolV2Command.php b/Server/application/command/MigrateTrafficPoolV2Command.php new file mode 100644 index 000000000..40f1cf5f7 --- /dev/null +++ b/Server/application/command/MigrateTrafficPoolV2Command.php @@ -0,0 +1,235 @@ +lockFile = App::getRuntimePath() . 'migrate_traffic_pool_v2.lock'; + } + + protected function configure() + { + $this->setName('migrate:trafficPoolV2') + ->setDescription('迁移数据到 V2 流量池系统') + ->addOption('step', 's', Option::VALUE_OPTIONAL, '执行指定步骤(1-7),不指定则执行全部', null); + } + + protected function execute(Input $input, Output $output) + { + // 检查锁文件 + if (file_exists($this->lockFile)) { + $lockTime = filectime($this->lockFile); + if (time() - $lockTime < 7200) { // 2小时内 + $output->writeln('迁移任务已在运行中,跳过本次执行'); + return false; + } + unlink($this->lockFile); + } + + file_put_contents($this->lockFile, time()); + + try { + $step = $input->getOption('step'); + $adapter = new ChuKeBaoAdapter(); + + $output->writeln('===================================='); + $output->writeln(' V2 流量池数据迁移开始'); + $output->writeln('===================================='); + $output->writeln(''); + + $startTime = microtime(true); + + if ($step === null) { + // 执行完整迁移 + $results = $this->runFullMigration($adapter, $output); + } else { + // 执行指定步骤 + $results = $this->runStep((int)$step, $adapter, $output); + } + + $endTime = microtime(true); + $duration = round($endTime - $startTime, 2); + + $output->writeln(''); + $output->writeln('===================================='); + $output->writeln(' 迁移完成'); + $output->writeln('===================================='); + $output->writeln("耗时: {$duration} 秒"); + $output->writeln(''); + $output->writeln('结果统计:'); + foreach ($results as $key => $value) { + $output->writeln(" - {$key}: {$value} 条"); + } + + return true; + + } catch (\Exception $e) { + $output->writeln('迁移异常: ' . $e->getMessage() . ''); + Log::error('V2流量池迁移异常:' . $e->getMessage() . "\n" . $e->getTraceAsString()); + return false; + } finally { + if (file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } + } + + /** + * 执行完整迁移 + */ + protected function runFullMigration(ChuKeBaoAdapter $adapter, Output $output) + { + $results = [ + 'friend_pool' => 0, + 'friend_pool_company' => 0, + 'friend_pool_source' => 0, + 'chatroom_pool' => 0, + 'chatroom_pool_company' => 0, + 'chatroom_pool_source' => 0, + 'pool_tags' => 0, + ]; + + // === 好友数据迁移 === + $output->writeln('【好友数据迁移】'); + + // Step 1: 好友同步到流量池总表 + $output->writeln('[1/7] 同步好友到流量池总表 ck_traffic_pool ...'); + $results['friend_pool'] = $adapter->syncToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool']}"); + + // Step 2: 好友同步到公司流量详情表 + $output->writeln('[2/7] 同步好友到公司流量详情表 ck_traffic_pool_company ...'); + $results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_company']}"); + + // Step 3: 好友同步到流量来源表 + $output->writeln('[3/7] 同步好友到流量来源表 ck_traffic_pool_source ...'); + $results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_source']}"); + + // === 群成员数据迁移 === + $output->writeln(''); + $output->writeln('【群成员数据迁移】'); + + // Step 4: 群成员同步到流量池总表 + $output->writeln('[4/7] 同步群成员到流量池总表 ck_traffic_pool ...'); + $results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool']}"); + + // Step 5: 群成员同步到公司流量详情表 + $output->writeln('[5/7] 同步群成员到公司流量详情表 ck_traffic_pool_company ...'); + $results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_company']}"); + + // Step 6: 群成员同步到流量来源表 + $output->writeln('[6/7] 同步群成员到流量来源表 ck_traffic_pool_source ...'); + $results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_source']}"); + + // === 标签数据迁移 === + $output->writeln(''); + $output->writeln('【标签数据迁移】'); + + // Step 7: 同步微信标签 + $output->writeln('[7/7] 同步微信标签 ck_traffic_pool_tag ...'); + $results['pool_tags'] = $adapter->syncWechatTagsToV2(); + $output->writeln(" 完成,影响行数: {$results['pool_tags']}"); + + return $results; + } + + /** + * 执行指定步骤 + */ + protected function runStep(int $step, ChuKeBaoAdapter $adapter, Output $output) + { + $results = []; + + switch ($step) { + case 1: + $output->writeln('[Step 1] 同步好友到流量池总表 ck_traffic_pool ...'); + $results['friend_pool'] = $adapter->syncToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool']}"); + break; + + case 2: + $output->writeln('[Step 2] 同步好友到公司流量详情表 ck_traffic_pool_company ...'); + $results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_company']}"); + break; + + case 3: + $output->writeln('[Step 3] 同步好友到流量来源表 ck_traffic_pool_source ...'); + $results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['friend_pool_source']}"); + break; + + case 4: + $output->writeln('[Step 4] 同步群成员到流量池总表 ck_traffic_pool ...'); + $results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool']}"); + break; + + case 5: + $output->writeln('[Step 5] 同步群成员到公司流量详情表 ck_traffic_pool_company ...'); + $results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_company']}"); + break; + + case 6: + $output->writeln('[Step 6] 同步群成员到流量来源表 ck_traffic_pool_source ...'); + $results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2(); + $output->writeln(" 完成,影响行数: {$results['chatroom_pool_source']}"); + break; + + case 7: + $output->writeln('[Step 7] 同步微信标签 ck_traffic_pool_tag ...'); + $results['pool_tags'] = $adapter->syncWechatTagsToV2(); + $output->writeln(" 完成,影响行数: {$results['pool_tags']}"); + break; + + default: + $output->writeln('无效的步骤编号,请输入 1-7'); + $output->writeln(''); + $output->writeln('步骤说明:'); + $output->writeln(' 1 - 同步好友到流量池总表'); + $output->writeln(' 2 - 同步好友到公司流量详情表'); + $output->writeln(' 3 - 同步好友到流量来源表'); + $output->writeln(' 4 - 同步群成员到流量池总表'); + $output->writeln(' 5 - 同步群成员到公司流量详情表'); + $output->writeln(' 6 - 同步群成员到流量来源表'); + $output->writeln(' 7 - 同步微信标签'); + break; + } + + return $results; + } +} diff --git a/Server/application/command/SyncWechatDataToCkbTask.php b/Server/application/command/SyncWechatDataToCkbTask.php index 688f4fd0a..8d0409cbe 100644 --- a/Server/application/command/SyncWechatDataToCkbTask.php +++ b/Server/application/command/SyncWechatDataToCkbTask.php @@ -124,5 +124,43 @@ class SyncWechatDataToCkbTask extends Command return $ChuKeBaoAdapter->syncCallRecording(); } + /** + * 同步数据到 V2 流量池总表 + */ + protected function syncToTrafficPoolV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncToTrafficPoolV2(); + } + /** + * 同步数据到 V2 公司流量详情表 + */ + protected function syncToTrafficPoolCompanyV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncToTrafficPoolCompanyV2(); + } + + /** + * 同步数据到 V2 流量来源表 + */ + protected function syncToTrafficPoolSourceV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncToTrafficPoolSourceV2(); + } + + /** + * 同步微信标签到 V2 标签系统 + */ + protected function syncWechatTagsToV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->syncWechatTagsToV2(); + } + + /** + * 执行完整的 V2 流量池数据迁移 + */ + protected function migrateToTrafficPoolV2(ChuKeBaoAdapter $ChuKeBaoAdapter) + { + return $ChuKeBaoAdapter->migrateToTrafficPoolV2(); + } } \ No newline at end of file diff --git a/Server/application/common/model/TrafficPoolAllotRecord.php b/Server/application/common/model/TrafficPoolAllotRecord.php new file mode 100644 index 000000000..ab0056f8f --- /dev/null +++ b/Server/application/common/model/TrafficPoolAllotRecord.php @@ -0,0 +1,168 @@ +belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 创建分配记录 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param string $toWechatId + * @param int $toAccountId + * @param int $toUserId + * @param int $expireDays + * @param int $operatorId + * @param array $fromInfo [fromWechatId, fromAccountId, fromUserId] + * @return static + */ + public static function createAllotRecord( + int $poolCompanyId, + string $identifier, + int $companyId, + string $toWechatId, + int $toAccountId = null, + int $toUserId = null, + int $expireDays = 30, + int $operatorId = null, + array $fromInfo = [] + ) { + // 判断分配类型 + $existRecord = self::where('poolCompanyId', $poolCompanyId) + ->where('status', self::STATUS_ACTIVE) + ->find(); + + $allotType = self::ALLOT_TYPE_FIRST; + if ($existRecord) { + // 将原记录设为已回收 + $existRecord->save([ + 'status' => self::STATUS_RECYCLED, + 'updateTime' => time() + ]); + $allotType = self::ALLOT_TYPE_REASSIGN; + } + + // 检查之前是否有过分配记录(判断是否回收后分配) + $hasHistoryRecord = self::where('poolCompanyId', $poolCompanyId) + ->where('status', 'in', [self::STATUS_EXPIRED, self::STATUS_RECYCLED]) + ->count(); + if ($hasHistoryRecord && $allotType === self::ALLOT_TYPE_FIRST) { + $allotType = self::ALLOT_TYPE_RECYCLE; + } + + $expireTime = $expireDays > 0 ? time() + ($expireDays * 86400) : null; + + $record = self::create([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'allotType' => $allotType, + 'fromWechatId' => $fromInfo['fromWechatId'] ?? null, + 'fromAccountId' => $fromInfo['fromAccountId'] ?? null, + 'fromUserId' => $fromInfo['fromUserId'] ?? null, + 'toWechatId' => $toWechatId, + 'toAccountId' => $toAccountId, + 'toUserId' => $toUserId, + 'expireDays' => $expireDays, + 'expireTime' => $expireTime, + 'status' => self::STATUS_ACTIVE, + 'operatorId' => $operatorId, + 'createTime' => time(), + ]); + + // 更新公司流量详情表的归属信息 + TrafficPoolCompany::where('id', $poolCompanyId)->update([ + 'ownerWechatId' => $toWechatId, + 'ownerAccountId' => $toAccountId, + 'ownerUserId' => $toUserId, + 'allocateStatus' => TrafficPoolCompany::ALLOCATE_STATUS_ALLOCATED, + 'allocateTime' => time(), + 'expireTime' => $expireTime, + 'updateTime' => time() + ]); + + return $record; + } + + /** + * 回收分配 + * @param int $poolCompanyId + * @param int $operatorId + * @return bool + */ + public static function recycleAllot(int $poolCompanyId, int $operatorId = null) + { + // 更新当前生效的分配记录 + $activeRecord = self::where('poolCompanyId', $poolCompanyId) + ->where('status', self::STATUS_ACTIVE) + ->find(); + + if ($activeRecord) { + $activeRecord->save([ + 'status' => self::STATUS_RECYCLED, + 'updateTime' => time() + ]); + } + + // 更新公司流量详情表 + return TrafficPoolCompany::where('id', $poolCompanyId)->update([ + 'ownerWechatId' => null, + 'ownerAccountId' => null, + 'ownerUserId' => null, + 'allocateStatus' => TrafficPoolCompany::ALLOCATE_STATUS_RECYCLED, + 'expireTime' => null, + 'updateTime' => time() + ]); + } + + /** + * 获取流量的分配历史 + * @param int $poolCompanyId + * @return \think\Collection + */ + public static function getAllotHistory(int $poolCompanyId) + { + return self::where('poolCompanyId', $poolCompanyId) + ->order('createTime DESC') + ->select(); + } +} + + diff --git a/Server/application/common/model/TrafficPoolBehavior.php b/Server/application/common/model/TrafficPoolBehavior.php new file mode 100644 index 000000000..43c45c6af --- /dev/null +++ b/Server/application/common/model/TrafficPoolBehavior.php @@ -0,0 +1,200 @@ + '发送消息', + 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(); + } +} + + diff --git a/Server/application/common/model/TrafficPoolCompany.php b/Server/application/common/model/TrafficPoolCompany.php new file mode 100644 index 000000000..411ba4aff --- /dev/null +++ b/Server/application/common/model/TrafficPoolCompany.php @@ -0,0 +1,200 @@ +belongsTo(TrafficPoolV2::class, 'poolId', 'id'); + } + + /** + * 关联来源记录 + */ + public function sources() + { + return $this->hasMany(TrafficPoolSource::class, 'poolCompanyId', 'id'); + } + + /** + * 关联标签记录 + */ + public function tags() + { + return $this->hasMany(TrafficPoolTag::class, 'poolCompanyId', 'id'); + } + + /** + * 关联行为记录 + */ + public function behaviors() + { + return $this->hasMany(TrafficPoolBehavior::class, 'poolCompanyId', 'id'); + } + + /** + * 关联分配记录 + */ + public function allotRecords() + { + return $this->hasMany(TrafficPoolAllotRecord::class, 'poolCompanyId', 'id'); + } + + /** + * 根据identifier和companyId查找或创建 + * @param string $identifier + * @param int $companyId + * @param int $poolId + * @param array $data + * @return static + */ + public static function findOrCreateByIdentifierAndCompany(string $identifier, int $companyId, int $poolId, array $data = []) + { + $record = self::where('identifier', $identifier) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$record) { + $insertData = array_merge([ + 'poolId' => $poolId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'createTime' => time(), + ], $data); + + $record = self::create($insertData); + } + + return $record; + } + + /** + * 原子更新消息统计 + * @param int $count 增加的消息数量 + * @return bool + */ + public function incrementMsgCount(int $count = 1) + { + return Db::table($this->getTable()) + ->where('id', $this->id) + ->inc('totalMsgCount', $count) + ->inc('rfmF', $count) + ->update([ + 'lastMsgTime' => time(), + 'lastInteractTime' => time(), + 'updateTime' => time() + ]); + } + + /** + * 原子更新订单统计 + * @param float $amount 订单金额 + * @return bool + */ + public function incrementOrderStats(float $amount) + { + return Db::table($this->getTable()) + ->where('id', $this->id) + ->inc('totalOrderCount', 1) + ->inc('totalOrderAmount', $amount) + ->inc('rfmM', $amount) + ->update([ + 'lastOrderTime' => time(), + 'lastInteractTime' => time(), + 'updateTime' => time() + ]); + } + + /** + * 计算RFM R值(最后互动距今天数) + * @return int + */ + public function getRfmRAttr() + { + if (empty($this->lastInteractTime)) { + return 9999; // 未互动过 + } + return (int) floor((time() - $this->lastInteractTime) / 86400); + } + + /** + * 获取自定义字段 + * @param string $value + * @return array + */ + public function getCustomFieldsAttr($value) + { + return $value ? json_decode($value, true) : []; + } + + /** + * 设置自定义字段 + * @param array $value + * @return string + */ + public function setCustomFieldsAttr($value) + { + return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null; + } +} + + diff --git a/Server/application/common/model/TrafficPoolGroup.php b/Server/application/common/model/TrafficPoolGroup.php new file mode 100644 index 000000000..ed2490a60 --- /dev/null +++ b/Server/application/common/model/TrafficPoolGroup.php @@ -0,0 +1,171 @@ +hasMany(TrafficPoolGroupMember::class, 'groupId', 'id'); + } + + /** + * 获取规则配置 + * @param string $value + * @return array|null + */ + public function getRuleConfigAttr($value) + { + return $value ? json_decode($value, true) : null; + } + + /** + * 设置规则配置 + * @param array $value + * @return string + */ + public function setRuleConfigAttr($value) + { + return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null; + } + + /** + * 获取公司可用的分组列表(包含系统分组和公司自定义分组) + * @param int $companyId + * @param bool $onlyEnabled + * @return \think\Collection + */ + public static function getGroupsByCompany(int $companyId, bool $onlyEnabled = true) + { + $query = self::whereIn('companyId', [0, $companyId]) + ->where('isDel', 0); + + if ($onlyEnabled) { + $query->where('status', self::STATUS_ENABLED); + } + + return $query->order('sort ASC, id ASC')->select(); + } + + /** + * 根据分组编码获取分组 + * @param string $groupCode + * @param int $companyId + * @return static|null + */ + public static function getByCode(string $groupCode, int $companyId = 0) + { + return self::where('groupCode', $groupCode) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + } + + /** + * 解析规则配置生成SQL条件 + * @param array $ruleConfig + * @return array [whereConditions, bindings] + */ + public static function parseRuleToConditions(array $ruleConfig) + { + $conditions = []; + $bindings = []; + + if (empty($ruleConfig['conditions'])) { + return [$conditions, $bindings]; + } + + $logic = strtoupper($ruleConfig['logic'] ?? 'AND'); + + foreach ($ruleConfig['conditions'] as $condition) { + if ($condition['type'] === 'group') { + // 嵌套分组,递归处理 + [$subConditions, $subBindings] = self::parseRuleToConditions($condition); + if (!empty($subConditions)) { + $conditions[] = '(' . implode(' ' . ($condition['logic'] ?? 'AND') . ' ', $subConditions) . ')'; + $bindings = array_merge($bindings, $subBindings); + } + } elseif ($condition['type'] === 'field') { + // 字段条件 + $field = $condition['field']; + $operator = $condition['operator']; + $value = $condition['value']; + + switch ($operator) { + case '=': + case '!=': + case '>': + case '<': + case '>=': + case '<=': + $conditions[] = "`{$field}` {$operator} ?"; + $bindings[] = $value; + break; + case 'in': + $placeholders = implode(',', array_fill(0, count($value), '?')); + $conditions[] = "`{$field}` IN ({$placeholders})"; + $bindings = array_merge($bindings, $value); + break; + case 'not_in': + $placeholders = implode(',', array_fill(0, count($value), '?')); + $conditions[] = "`{$field}` NOT IN ({$placeholders})"; + $bindings = array_merge($bindings, $value); + break; + case 'between': + $conditions[] = "`{$field}` BETWEEN ? AND ?"; + $bindings[] = $value[0]; + $bindings[] = $value[1]; + break; + case 'like': + $conditions[] = "`{$field}` LIKE ?"; + $bindings[] = '%' . $value . '%'; + break; + } + } elseif ($condition['type'] === 'tag') { + // 标签条件需要特殊处理,通过子查询 + // 这里返回需要在Service层特殊处理 + $conditions[] = "EXISTS (SELECT 1 FROM ck_traffic_pool_tag tpt WHERE tpt.poolCompanyId = ck_traffic_pool_company.id AND tpt.tagName IN (?))"; + $bindings[] = implode("','", $condition['value']); + } + } + + return [$conditions, $bindings, $logic]; + } +} + + diff --git a/Server/application/common/model/TrafficPoolGroupMember.php b/Server/application/common/model/TrafficPoolGroupMember.php new file mode 100644 index 000000000..9cb5825ee --- /dev/null +++ b/Server/application/common/model/TrafficPoolGroupMember.php @@ -0,0 +1,128 @@ +belongsTo(TrafficPoolGroup::class, 'groupId', 'id'); + } + + /** + * 关联公司流量详情 + */ + public function poolCompany() + { + return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 批量添加成员到分组 + * @param int $groupId + * @param array $poolCompanyIds + * @param int $companyId + * @param int $operatorId + * @param int $addType + * @return int 成功添加的数量 + */ + public static function batchAddMembers(int $groupId, array $poolCompanyIds, int $companyId, int $operatorId = null, int $addType = self::ADD_TYPE_MANUAL) + { + $count = 0; + $time = time(); + + // 获取已存在的成员 + $existIds = self::where('groupId', $groupId) + ->whereIn('poolCompanyId', $poolCompanyIds) + ->where('isDel', 0) + ->column('poolCompanyId'); + + // 获取要添加的流量详情 + $poolCompanies = TrafficPoolCompany::whereIn('id', $poolCompanyIds) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->column('identifier', 'id'); + + $insertData = []; + foreach ($poolCompanyIds as $poolCompanyId) { + if (in_array($poolCompanyId, $existIds)) { + continue; // 跳过已存在的 + } + if (!isset($poolCompanies[$poolCompanyId])) { + continue; // 跳过不存在的 + } + + $insertData[] = [ + 'groupId' => $groupId, + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $poolCompanies[$poolCompanyId], + 'companyId' => $companyId, + 'addType' => $addType, + 'operatorId' => $operatorId, + 'createTime' => $time, + 'isDel' => 0, + ]; + } + + if (!empty($insertData)) { + (new self())->saveAll($insertData); + $count = count($insertData); + + // 更新分组成员数量缓存 + TrafficPoolGroup::where('id', $groupId)->setInc('memberCount', $count); + } + + return $count; + } + + /** + * 批量移除成员 + * @param int $groupId + * @param array $poolCompanyIds + * @return int + */ + public static function batchRemoveMembers(int $groupId, array $poolCompanyIds) + { + $count = self::where('groupId', $groupId) + ->whereIn('poolCompanyId', $poolCompanyIds) + ->where('isDel', 0) + ->update([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + + if ($count > 0) { + // 更新分组成员数量缓存 + TrafficPoolGroup::where('id', $groupId)->setDec('memberCount', $count); + } + + return $count; + } +} + + diff --git a/Server/application/common/model/TrafficPoolSource.php b/Server/application/common/model/TrafficPoolSource.php new file mode 100644 index 000000000..886704d48 --- /dev/null +++ b/Server/application/common/model/TrafficPoolSource.php @@ -0,0 +1,135 @@ + '好友添加', + 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(); + } +} + + diff --git a/Server/application/common/model/TrafficPoolTag.php b/Server/application/common/model/TrafficPoolTag.php new file mode 100644 index 000000000..16d71fbac --- /dev/null +++ b/Server/application/common/model/TrafficPoolTag.php @@ -0,0 +1,229 @@ + '手动打标', + self::SOURCE_RULE => '规则自动', + self::SOURCE_AI => 'AI自动', + self::SOURCE_WECHAT_SYNC => '微信同步', + ]; + + /** + * 关联公司流量详情 + */ + public function poolCompany() + { + return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id'); + } + + /** + * 关联标签定义 + */ + public function tagDefine() + { + return $this->belongsTo(TrafficPoolTagDefine::class, 'tagDefineId', 'id'); + } + + /** + * 为流量添加标签 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param int $tagDefineId + * @param int $source + * @param int $operatorId + * @param string $tagValue + * @param float $score AI置信度 + * @return static|null + */ + public static function addTag( + int $poolCompanyId, + string $identifier, + int $companyId, + int $tagDefineId, + int $source = self::SOURCE_MANUAL, + int $operatorId = null, + string $tagValue = null, + float $score = null + ) { + // 检查标签定义是否存在 + $tagDefine = TrafficPoolTagDefine::find($tagDefineId); + if (!$tagDefine) { + return null; + } + + // 检查是否已存在 + $existTag = self::where('poolCompanyId', $poolCompanyId) + ->where('tagDefineId', $tagDefineId) + ->where('isDel', 0) + ->find(); + + if ($existTag) { + // 更新现有标签 + $existTag->save([ + 'tagValue' => $tagValue, + 'source' => $source, + 'operatorId' => $operatorId, + 'score' => $score, + 'updateTime' => time() + ]); + return $existTag; + } + + // 如果是互斥标签,先删除同类目下的其他标签 + if ($tagDefine->isExclusive) { + self::where('poolCompanyId', $poolCompanyId) + ->where('categoryId', $tagDefine->categoryId) + ->where('isDel', 0) + ->update([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + } + + // 创建新标签关联 + $tag = self::create([ + 'poolCompanyId' => $poolCompanyId, + 'identifier' => $identifier, + 'companyId' => $companyId, + 'tagDefineId' => $tagDefineId, + 'tagType' => $tagDefine->tagType, + 'categoryId' => $tagDefine->categoryId, + 'tagName' => $tagDefine->tagName, + 'tagValue' => $tagValue, + 'source' => $source, + 'operatorId' => $operatorId, + 'score' => $score, + 'createTime' => time() + ]); + + // 增加标签使用次数 + $tagDefine->incrementUseCount(); + + return $tag; + } + + /** + * 移除流量标签 + * @param int $poolCompanyId + * @param int $tagDefineId + * @return bool + */ + public static function removeTag(int $poolCompanyId, int $tagDefineId) + { + $tag = self::where('poolCompanyId', $poolCompanyId) + ->where('tagDefineId', $tagDefineId) + ->where('isDel', 0) + ->find(); + + if ($tag) { + $tag->save([ + 'isDel' => 1, + 'deleteTime' => time() + ]); + + // 减少标签使用次数 + $tagDefine = TrafficPoolTagDefine::find($tagDefineId); + if ($tagDefine) { + $tagDefine->decrementUseCount(); + } + + return true; + } + + return false; + } + + /** + * 获取流量的所有标签 + * @param int $poolCompanyId + * @param int|null $tagType + * @return \think\Collection + */ + public static function getTagsByPoolCompany(int $poolCompanyId, int $tagType = null) + { + $query = self::where('poolCompanyId', $poolCompanyId) + ->where('isDel', 0); + + if ($tagType !== null) { + $query->where('tagType', $tagType); + } + + return $query->order('createTime DESC')->select(); + } + + /** + * 同步微信标签 + * @param int $poolCompanyId + * @param string $identifier + * @param int $companyId + * @param array $wechatLabels 微信标签名称数组 + * @return int 同步的标签数量 + */ + public static function syncWechatTags(int $poolCompanyId, string $identifier, int $companyId, array $wechatLabels) + { + $count = 0; + + // 获取微信默认标签类目(假设ID为1) + $wechatCategoryId = 1; + + foreach ($wechatLabels as $labelName) { + if (empty($labelName)) { + continue; + } + + // 获取或创建标签定义 + $tagDefine = TrafficPoolTagDefine::getOrCreateByName($labelName, $companyId, $wechatCategoryId); + + // 添加标签关联 + $tag = self::addTag( + $poolCompanyId, + $identifier, + $companyId, + $tagDefine->id, + self::SOURCE_WECHAT_SYNC + ); + + if ($tag) { + $count++; + } + } + + return $count; + } +} + + diff --git a/Server/application/common/model/TrafficPoolTagCategory.php b/Server/application/common/model/TrafficPoolTagCategory.php new file mode 100644 index 000000000..98a9c9c74 --- /dev/null +++ b/Server/application/common/model/TrafficPoolTagCategory.php @@ -0,0 +1,118 @@ + '微信标签', + self::TAG_TYPE_SITE => '站内标签', + self::TAG_TYPE_AI => 'AI标签', + ]; + + // 状态常量 + const STATUS_DISABLED = 0; + const STATUS_ENABLED = 1; + + /** + * 关联标签定义 + */ + public function tagDefines() + { + return $this->hasMany(TrafficPoolTagDefine::class, 'categoryId', 'id'); + } + + /** + * 关联子类目 + */ + public function children() + { + return $this->hasMany(self::class, 'parentId', 'id'); + } + + /** + * 关联父类目 + */ + public function parent() + { + return $this->belongsTo(self::class, 'parentId', 'id'); + } + + /** + * 获取公司可用的标签类目 + * @param int $companyId + * @param int|null $tagType + * @return \think\Collection + */ + public static function getCategoriesByCompany(int $companyId, int $tagType = null) + { + $query = self::whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->where('status', self::STATUS_ENABLED); + + if ($tagType !== null) { + $query->where('tagType', $tagType); + } + + return $query->order('sort ASC, id ASC')->select(); + } + + /** + * 获取类目树结构 + * @param int $companyId + * @param int|null $tagType + * @return array + */ + public static function getCategoryTree(int $companyId, int $tagType = null) + { + $categories = self::getCategoriesByCompany($companyId, $tagType)->toArray(); + return self::buildTree($categories); + } + + /** + * 构建树结构 + * @param array $items + * @param int $parentId + * @return array + */ + private static function buildTree(array $items, int $parentId = 0) + { + $result = []; + foreach ($items as $item) { + if ($item['parentId'] == $parentId) { + $children = self::buildTree($items, $item['id']); + if (!empty($children)) { + $item['children'] = $children; + } + $result[] = $item; + } + } + return $result; + } +} + + diff --git a/Server/application/common/model/TrafficPoolTagDefine.php b/Server/application/common/model/TrafficPoolTagDefine.php new file mode 100644 index 000000000..dedb31750 --- /dev/null +++ b/Server/application/common/model/TrafficPoolTagDefine.php @@ -0,0 +1,143 @@ +belongsTo(TrafficPoolTagCategory::class, 'categoryId', 'id'); + } + + /** + * 关联标签使用记录 + */ + public function tags() + { + return $this->hasMany(TrafficPoolTag::class, 'tagDefineId', 'id'); + } + + /** + * 获取公司可用的标签定义 + * @param int $companyId + * @param int|null $tagType + * @param int|null $categoryId + * @return \think\Collection + */ + public static function getTagDefinesByCompany(int $companyId, int $tagType = null, int $categoryId = null) + { + $query = self::whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->where('status', self::STATUS_ENABLED); + + if ($tagType !== null) { + $query->where('tagType', $tagType); + } + + if ($categoryId !== null) { + $query->where('categoryId', $categoryId); + } + + return $query->order('sort ASC, id ASC')->select(); + } + + /** + * 根据标签编码获取标签定义 + * @param string $tagCode + * @param int $companyId + * @return static|null + */ + public static function getByCode(string $tagCode, int $companyId = 0) + { + return self::where('tagCode', $tagCode) + ->whereIn('companyId', [0, $companyId]) + ->where('isDel', 0) + ->find(); + } + + /** + * 根据标签名称获取或创建标签(用于微信标签同步) + * @param string $tagName + * @param int $companyId + * @param int $categoryId + * @return static + */ + public static function getOrCreateByName(string $tagName, int $companyId, int $categoryId = 1) + { + $tagCode = 'wechat_' . md5($tagName . '_' . $companyId); + + $tag = self::where('tagCode', $tagCode) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$tag) { + $tag = self::create([ + 'companyId' => $companyId, + 'categoryId' => $categoryId, + 'tagType' => self::TAG_TYPE_WECHAT, + 'tagCode' => $tagCode, + 'tagName' => $tagName, + 'isSystem' => 0, + 'syncFromWechat' => 1, + 'status' => self::STATUS_ENABLED, + 'createTime' => time() + ]); + } + + return $tag; + } + + /** + * 增加使用次数 + * @return bool + */ + public function incrementUseCount() + { + return $this->setInc('useCount'); + } + + /** + * 减少使用次数 + * @return bool + */ + public function decrementUseCount() + { + if ($this->useCount > 0) { + return $this->setDec('useCount'); + } + return true; + } +} + + diff --git a/Server/application/common/model/TrafficPoolV2.php b/Server/application/common/model/TrafficPoolV2.php new file mode 100644 index 000000000..6a30cc185 --- /dev/null +++ b/Server/application/common/model/TrafficPoolV2.php @@ -0,0 +1,85 @@ +hasMany(TrafficPoolCompany::class, 'poolId', 'id'); + } + + /** + * 根据identifier查找或创建流量记录 + * @param string $identifier 唯一标识 + * @param array $data 额外数据 + * @return static + */ + public static function findOrCreateByIdentifier(string $identifier, array $data = []) + { + $record = self::where('identifier', $identifier)->find(); + if (!$record) { + $insertData = array_merge([ + 'identifier' => $identifier, + 'identifierType' => self::IDENTIFIER_TYPE_WECHAT_ID, + 'createTime' => time(), + ], $data); + + // 如果identifier是微信ID,同时设置wechatId + if (empty($insertData['wechatId']) && $insertData['identifierType'] == self::IDENTIFIER_TYPE_WECHAT_ID) { + $insertData['wechatId'] = $identifier; + } + + $record = self::create($insertData); + } + return $record; + } + + /** + * 更新基础信息 + * @param array $data + * @return bool + */ + public function updateBasicInfo(array $data) + { + $allowFields = ['nickname', 'avatar', 'gender', 'region', 'country', 'province', 'city', 'signature', 'wechatAlias', 'mobile']; + $updateData = array_intersect_key($data, array_flip($allowFields)); + $updateData['updateTime'] = time(); + $updateData['lastSeenTime'] = time(); + + return $this->save($updateData); + } +} + + diff --git a/Server/application/common/service/TagEngineService.php b/Server/application/common/service/TagEngineService.php new file mode 100644 index 000000000..ab2581ce5 --- /dev/null +++ b/Server/application/common/service/TagEngineService.php @@ -0,0 +1,275 @@ +baseUrl = rtrim($url, '/'); + return $this; + } + + /** + * 设置API Key + * @param string $key + * @return $this + */ + public function setApiKey($key) + { + $this->apiKey = $key; + return $this; + } + + /** + * 构建请求头 + * @return array + */ + private function buildHeaders() + { + return [ + 'Authorization: Bearer ' . $this->apiKey, + 'Content-Type: application/json' + ]; + } + + /** + * 通过标识查询标签 + * + * @param array $identifiers 用户标识列表,最多100个 + * 格式:[['type' => 'phone', 'value' => '13800138000'], ...] + * @param array $options 查询选项 + * - include_tags: array 包含指定标签(标签代码列表) + * - exclude_tags: array 排除指定标签(标签代码列表) + * - tag_category: string 按分类筛选标签 + * - mask_identifier: bool 是否脱敏标识信息,默认 true + * @return array|false + */ + public function queryByIdentifiers($identifiers, $options = []) + { + try { + // 参数验证 + if (empty($identifiers) || !is_array($identifiers)) { + Log::error('标签引擎:标识列表不能为空'); + return false; + } + + if (count($identifiers) > 100) { + Log::error('标签引擎:单次最多查询100个标识'); + return false; + } + + // 构建请求数据 + $data = [ + 'identifiers' => $identifiers, + ]; + + if (!empty($options)) { + $data['options'] = $options; + } + + // 发起请求 + $url = $this->baseUrl . '/api/v1/tag/query-by-identifiers'; + $response = requestCurl($url, $data, 'POST', $this->buildHeaders(), 'json'); + + // 处理响应 + $result = handleApiResponse($response); + + // 记录日志 + Log::info('标签引擎-通过标识查询标签', [ + 'identifiers_count' => count($identifiers), + 'response' => $result + ]); + + return $result; + } catch (\Exception $e) { + Log::error('标签引擎-通过标识查询标签异常:' . $e->getMessage()); + return false; + } + } + + /** + * 通过标签查询用户 + * + * @param array $tagConditions 标签条件列表,最多10个条件 + * 格式:[ + * ['tag_code' => 'user.trade.total_amount', 'operator' => '>=', 'value' => '5000'], + * ... + * ] + * 支持的操作符:=, !=, >, >=, <, <=, in, not_in + * @param string $logic 逻辑关系:AND(默认)或 OR + * @param bool $includeSensitive 是否返回敏感信息(QQ号、身份证),默认 false + * @param int $page 页码,默认 1 + * @param int $pageSize 每页数量,默认 20,最大 100 + * @return array|false + */ + public function queryUsersByTags($tagConditions, $logic = 'AND', $includeSensitive = false, $page = 1, $pageSize = 20) + { + try { + // 参数验证 + if (empty($tagConditions) || !is_array($tagConditions)) { + Log::error('标签引擎:标签条件不能为空'); + return false; + } + + if (count($tagConditions) > 10) { + Log::error('标签引擎:单次最多10个标签条件'); + return false; + } + + if ($pageSize > 100) { + Log::error('标签引擎:单页最多返回100条记录'); + return false; + } + + // 构建请求数据 + $data = [ + 'tag_conditions' => $tagConditions, + 'logic' => strtoupper($logic), + 'include_sensitive' => $includeSensitive, + 'page' => max(1, intval($page)), + 'page_size' => min(100, max(1, intval($pageSize))) + ]; + + // 发起请求 + $url = $this->baseUrl . '/api/v1/tag/query-users-by-tags'; + $response = requestCurl($url, $data, 'POST', $this->buildHeaders(), 'json'); + + // 处理响应 + $result = handleApiResponse($response); + + // 记录日志 + Log::info('标签引擎-通过标签查询用户', [ + 'conditions_count' => count($tagConditions), + 'page' => $page, + 'page_size' => $pageSize, + 'response' => $result + ]); + + return $result; + } catch (\Exception $e) { + Log::error('标签引擎-通过标签查询用户异常:' . $e->getMessage()); + return false; + } + } + + /** + * 内部调用 - 通过手机号查询标签 + * + * @param string|array $phones 手机号或手机号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByPhone($phones, $options = []) + { + if (!is_array($phones)) { + $phones = [$phones]; + } + + $identifiers = []; + foreach ($phones as $phone) { + $identifiers[] = [ + 'type' => 'phone', + 'value' => $phone + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } + + /** + * 内部调用 - 通过微信号查询标签 + * + * @param string|array $wechats 微信号或微信号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByWechat($wechats, $options = []) + { + if (!is_array($wechats)) { + $wechats = [$wechats]; + } + + $identifiers = []; + foreach ($wechats as $wechat) { + $identifiers[] = [ + 'type' => 'wechat', + 'value' => $wechat + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } + + /** + * 内部调用 - 通过身份证号查询标签 + * + * @param string|array $idCards 身份证号或身份证号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByIdCard($idCards, $options = []) + { + if (!is_array($idCards)) { + $idCards = [$idCards]; + } + + $identifiers = []; + foreach ($idCards as $idCard) { + $identifiers[] = [ + 'type' => 'id_card', + 'value' => $idCard + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } + + /** + * 内部调用 - 通过QQ号查询标签 + * + * @param string|array $qqs QQ号或QQ号数组 + * @param array $options 查询选项 + * @return array|false + */ + public function queryByQQ($qqs, $options = []) + { + if (!is_array($qqs)) { + $qqs = [$qqs]; + } + + $identifiers = []; + foreach ($qqs as $qq) { + $identifiers[] = [ + 'type' => 'qq', + 'value' => $qq + ]; + } + + return $this->queryByIdentifiers($identifiers, $options); + } +} + diff --git a/Server/application/cunkebao/config/route.php b/Server/application/cunkebao/config/route.php index 687578cfc..ba20ffb10 100644 --- a/Server/application/cunkebao/config/route.php +++ b/Server/application/cunkebao/config/route.php @@ -61,7 +61,7 @@ Route::group('v1/', function () { Route::get('getUserList', 'app\cunkebao\controller\plan\PlanSceneV1Controller@getUserList'); }); - // 流量池相关 + // 流量池相关(V1 旧版接口,保持兼容) Route::group('traffic/pool', function () { Route::get('getPackage', 'app\cunkebao\controller\TrafficController@getPackage'); // 获取流量池包列表 Route::get('getPackageDetail', 'app\cunkebao\controller\TrafficController@getPackageDetail'); // 获取流量池详情(元数据) @@ -71,25 +71,46 @@ 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('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('allocate', 'app\cunkebao\controller\TrafficPoolV2Controller@allocatePool'); // 分配流量 + Route::post('recycle', 'app\cunkebao\controller\TrafficPoolV2Controller@recyclePool'); // 回收流量 + + // 统计相关 + Route::get('statistics', 'app\cunkebao\controller\TrafficPoolV2Controller@getStatistics'); // 获取统计数据 }); // 工作台相关 diff --git a/Server/application/cunkebao/controller/TrafficPoolV2Controller.php b/Server/application/cunkebao/controller/TrafficPoolV2Controller.php new file mode 100644 index 000000000..2c621103d --- /dev/null +++ b/Server/application/cunkebao/controller/TrafficPoolV2Controller.php @@ -0,0 +1,569 @@ +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], '创建成功'); + } 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 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()); + } + } +} + diff --git a/Server/application/cunkebao/controller/tag/QueryTagsByIdentifiersController.php b/Server/application/cunkebao/controller/tag/QueryTagsByIdentifiersController.php new file mode 100644 index 000000000..43657e7bb --- /dev/null +++ b/Server/application/cunkebao/controller/tag/QueryTagsByIdentifiersController.php @@ -0,0 +1,169 @@ +request->param('identifiers', []); + $options = $this->request->param('options', []); + + // 参数验证 + $validate = Validate::make([ + 'identifiers' => 'require|array', + 'identifiers.*' => 'array', + ]); + + if (!$validate->check(['identifiers' => $identifiers])) { + throw new \Exception($validate->getError(), 400); + } + + // 验证标识格式 + foreach ($identifiers as $key => $identifier) { + if (!isset($identifier['type']) || !isset($identifier['value'])) { + throw new \Exception("标识[{$key}]格式错误,必须包含type和value字段", 400); + } + + // 验证标识类型 + $allowedTypes = ['phone', 'id_card', 'wechat', 'qq']; + if (!in_array($identifier['type'], $allowedTypes)) { + throw new \Exception("标识[{$key}]类型不支持,仅支持:" . implode(', ', $allowedTypes), 400); + } + + // 验证值不为空 + if (empty($identifier['value'])) { + throw new \Exception("标识[{$key}]的值不能为空", 400); + } + } + + // 限制数量 + if (count($identifiers) > 100) { + throw new \Exception('单次最多查询100个标识', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryByIdentifiers($identifiers, $options); + + if ($result === false) { + throw new \Exception('查询标签失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:通过手机号查询标签 + * + * @return \think\response\Json + */ + public function byPhone() + { + try { + // 获取请求参数 + $phones = $this->request->param('phones', []); + $options = $this->request->param('options', []); + + // 参数验证 + if (empty($phones)) { + throw new \Exception('手机号不能为空', 400); + } + + // 如果是字符串,转为数组 + if (is_string($phones)) { + $phones = explode(',', $phones); + } + + if (!is_array($phones)) { + throw new \Exception('手机号格式错误', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryByPhone($phones, $options); + + if ($result === false) { + throw new \Exception('查询标签失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:通过微信号查询标签 + * + * @return \think\response\Json + */ + public function byWechat() + { + try { + // 获取请求参数 + $wechats = $this->request->param('wechats', []); + $options = $this->request->param('options', []); + + // 参数验证 + if (empty($wechats)) { + throw new \Exception('微信号不能为空', 400); + } + + // 如果是字符串,转为数组 + if (is_string($wechats)) { + $wechats = explode(',', $wechats); + } + + if (!is_array($wechats)) { + throw new \Exception('微信号格式错误', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryByWechat($wechats, $options); + + if ($result === false) { + throw new \Exception('查询标签失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } +} + diff --git a/Server/application/cunkebao/controller/tag/QueryUsersByTagsController.php b/Server/application/cunkebao/controller/tag/QueryUsersByTagsController.php new file mode 100644 index 000000000..4418b34bf --- /dev/null +++ b/Server/application/cunkebao/controller/tag/QueryUsersByTagsController.php @@ -0,0 +1,177 @@ +request->param('tag_conditions', []); + $logic = $this->request->param('logic', 'AND'); + $includeSensitive = $this->request->param('include_sensitive', false); + $page = $this->request->param('page', 1); + $pageSize = $this->request->param('page_size', 20); + + // 参数验证 + $validate = Validate::make([ + 'tag_conditions' => 'require|array', + 'tag_conditions.*' => 'array', + 'logic' => 'in:AND,OR', + 'page' => 'number|>=:1', + 'page_size' => 'number|between:1,100', + ]); + + $params = [ + 'tag_conditions' => $tagConditions, + 'logic' => $logic, + 'page' => $page, + 'page_size' => $pageSize, + ]; + + if (!$validate->check($params)) { + throw new \Exception($validate->getError(), 400); + } + + // 验证标签条件格式 + foreach ($tagConditions as $key => $condition) { + if (!isset($condition['tag_code']) || !isset($condition['operator']) || !isset($condition['value'])) { + throw new \Exception("标签条件[{$key}]格式错误,必须包含tag_code、operator和value字段", 400); + } + + // 验证操作符 + $allowedOperators = ['=', '!=', '>', '>=', '<', '<=', 'in', 'not_in']; + if (!in_array($condition['operator'], $allowedOperators)) { + throw new \Exception("标签条件[{$key}]操作符不支持,仅支持:" . implode(', ', $allowedOperators), 400); + } + + // 验证 in/not_in 的值必须是数组 + if (in_array($condition['operator'], ['in', 'not_in']) && !is_array($condition['value'])) { + throw new \Exception("标签条件[{$key}]使用{$condition['operator']}操作符时,value必须是数组", 400); + } + } + + // 限制条件数量 + if (count($tagConditions) > 10) { + throw new \Exception('单次最多10个标签条件', 400); + } + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryUsersByTags($tagConditions, $logic, $includeSensitive, $page, $pageSize); + + if ($result === false) { + throw new \Exception('查询用户失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:查询高价值用户(示例) + * 查询累计消费金额 >= 5000 的用户 + * + * @return \think\response\Json + */ + public function highValueUsers() + { + try { + $page = $this->request->param('page', 1); + $pageSize = $this->request->param('page_size', 20); + $minAmount = $this->request->param('min_amount', 5000); + + $tagConditions = [ + [ + 'tag_code' => 'user.trade.total_amount', + 'operator' => '>=', + 'value' => strval($minAmount) + ] + ]; + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryUsersByTags($tagConditions, 'AND', false, $page, $pageSize); + + if ($result === false) { + throw new \Exception('查询用户失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 快捷方法:查询VIP用户(示例) + * 查询用户等级为 VIP、SVIP 或金卡会员的用户 + * + * @return \think\response\Json + */ + public function vipUsers() + { + try { + $page = $this->request->param('page', 1); + $pageSize = $this->request->param('page_size', 20); + $levels = $this->request->param('levels', ['VIP', 'SVIP', '金卡会员']); + + // 如果是字符串,转为数组 + if (is_string($levels)) { + $levels = explode(',', $levels); + } + + $tagConditions = [ + [ + 'tag_code' => 'user.trade.level', + 'operator' => 'in', + 'value' => $levels + ] + ]; + + // 调用标签引擎服务 + $service = new TagEngineService(); + $result = $service->queryUsersByTags($tagConditions, 'AND', false, $page, $pageSize); + + if ($result === false) { + throw new \Exception('查询用户失败', 500); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500); + } + + return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功'); + } catch (\Exception $e) { + return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500); + } + } +} + diff --git a/Server/application/cunkebao/service/TrafficPoolGroupService.php b/Server/application/cunkebao/service/TrafficPoolGroupService.php new file mode 100644 index 000000000..c7205f254 --- /dev/null +++ b/Server/application/cunkebao/service/TrafficPoolGroupService.php @@ -0,0 +1,650 @@ +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.friendStatus', + 'tpc.level', + 'tpc.lastInteractTime', + 'tpc.rfmF', + 'tpc.rfmM', + 'tpc.totalMsgCount', + 'tpc.totalOrderAmount', + 'tpc.realName', + 'tpc.phone', + 'tp.nickname', + 'tp.avatar', + 'tp.wechatId', + '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.friendStatus', + 'tpc.level', + 'tpc.lastInteractTime', + 'tpc.rfmF', + 'tpc.rfmM', + 'tpc.totalMsgCount', + 'tpc.totalOrderAmount', + 'tpc.realName', + 'tpc.phone', + 'tpc.createTime as addTime', + 'tp.nickname', + 'tp.avatar', + 'tp.wechatId' + ]) + ->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') { + // 字段条件 + $field = 'tpc.' . $condition['field']; + $operator = $condition['operator']; + $value = $condition['value']; + + 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 = []; + 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); + $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') + ->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') + ->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 + ]; + } +} + + diff --git a/Server/application/cunkebao/service/TrafficPoolService.php b/Server/application/cunkebao/service/TrafficPoolService.php new file mode 100644 index 000000000..a6d52d2b7 --- /dev/null +++ b/Server/application/cunkebao/service/TrafficPoolService.php @@ -0,0 +1,507 @@ +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(); + + // 获取来源历史 + $data['sources'] = TrafficPoolSource::getSourcesByPoolCompany($poolCompanyId)->toArray(); + + // 获取行为轨迹(最近50条) + $data['behaviors'] = TrafficPoolBehavior::getUserJourney($poolCompanyId, 50)->toArray(); + + // 获取分配历史 + $data['allotRecords'] = TrafficPoolAllotRecord::getAllotHistory($poolCompanyId)->toArray(); + + // 计算 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 + ]; + } +} + + diff --git a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php index e2f67c977..6876cb5fc 100644 --- a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php +++ b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php @@ -1,2240 +1,2965 @@ -config = $config ?: Config::get('wechat_device_api.'); - $this->config = $config ?: Config::get('wechat_device_api.adapters.ChuKeBao'); - // $this->config = $config; - // $this->apiClient = new ChuKeBaoApiClient($config['api_key'], $config['api_secret'], $config['base_url']); - // 校验配置等... - if (empty($this->config['base_url']) || empty($this->config['username']) || empty($this->config['password'])) { - throw new \InvalidArgumentException("ChuKeBao username and password are required."); - } - } - - public function addFriend(string $deviceId, string $targetWxId): bool - { - // 1. 构建请求参数 (ChuKeBao 特定的格式) - $params = [ - 'device_identifier' => $deviceId, - 'wechat_user_to_add' => $targetWxId, - 'username' => $this->config['username'], - 'password' => $this->config['password'], - // ... 其他 ChuKeBao 特定参数 - ]; - - // 2. 调用 ChuKeBao 的 API (例如使用 GuzzleHttp 或 cURL) - // $response = $this->apiClient->post('/friend/add', $params); - // 伪代码: - $url = $this->config['base_url'] . '/friend/add'; - // $httpClient = new \GuzzleHttp\Client(); - // $response = $httpClient->request('POST', $url, ['form_params' => $params]); - // $responseData = json_decode($response->getBody()->getContents(), true); - - // 模拟API调用 - echo "ChuKeBao: Adding friend {$targetWxId} using device {$deviceId}\n"; - $responseData = ['code' => 0, 'message' => 'Success']; // 假设的响应 - - // 3. 处理响应,转换为标准结果 - if (!isset($responseData['code'])) { - throw new ApiException("ChuKeBao: Invalid API response for addFriend."); - } - - if ($responseData['code'] !== 0) { - throw new ApiException("ChuKeBao: Failed to add friend - " . ($responseData['message'] ?? 'Unknown error')); - } - - return true; - } - - public function likeMoment(string $deviceId, string $momentId): bool - { - echo "ChuKeBao: Liking moment {$momentId} using device {$deviceId}\n"; - // 实现 VendorA 的点赞逻辑 - return true; - } - - public function getGroupList(string $deviceId): array - { - echo "ChuKeBao: Getting group list for device {$deviceId}\n"; - // 实现 VendorA 的获取群列表逻辑,并转换数据格式 - return [ - ['id' => 'group1_va', 'name' => 'ChuKeBao Group 1', 'member_count' => 10], - ]; - } - - public function getFriendList(string $deviceId): array - { - echo "VendorA: Getting friend list for device {$deviceId}\n"; - return [ - ['id' => 'friend1_va', 'nickname' => 'ChuKeBao Friend 1', 'remark' => 'VA-F1'], - ]; - } - - public function getDeviceInfo(string $deviceId): array - { - echo "ChuKeBao: Getting device info for device {$deviceId}\n"; - return ['id' => $deviceId, 'status' => 'online_va', 'battery' => '80%']; - } - - public function bindDeviceToCompany(string $deviceId, string $companyId): bool - { - echo "ChuKeBao: Binding device {$deviceId} to company {$companyId}\n"; - return true; - } - - /** - * 获取群成员列表 - * @param string $deviceId 设备ID - * @param string $chatroomId 群ID - * @return array 群成员列表 - */ - public function getChatroomMemberList(string $deviceId, string $chatroomId): array - { - echo "ChuKeBao: Getting chatroom member list for device {$deviceId}, chatroom {$chatroomId}\n"; - return [ - ['id' => 'member1_va', 'nickname' => 'VendorA Member 1', 'avatar' => ''], - ]; - } - - /** - * 获取指定微信的朋友圈内容/列表 - * @param string $deviceId 设备ID - * @param string $wxId 微信ID - * @return array 朋友圈列表 - */ - public function getMomentList(string $deviceId, string $wxId): array - { - echo "VendorA: Getting moment list for device {$deviceId}, wxId {$wxId}\n"; - return [ - ['id' => 'moment1_va', 'content' => 'VendorA Moment 1', 'created_at' => time()], - ]; - } - - - /** - * 发送微信朋友圈 - * @param string $deviceId 设备ID - * @param string $wxId 微信ID - * @param string $moment 朋友圈内容 - * @return bool 是否成功 - */ - public function sendMoment(string $deviceId, string $wxId, string $moment): bool - { - echo "VendorA: Sending moment for device {$deviceId}, wxId {$wxId}, content: {$moment}\n"; - return true; - } - - public function handleCustomerTaskWithStatusIsNew(int $current_worker_id, int $process_count_for_status_0) - { - $task = Db::name('customer_acquisition_task') - ->where(['status' => 1, 'deleteTime' => 0]) - /* ->whereRaw("id % $process_count_for_status_0 = {$current_worker_id}")*/ - ->order('id desc') - ->select(); - - if (empty($task)) { - return false; - } - - $taskData = []; - foreach ($task as $item) { - $reqConf = json_decode($item['reqConf'], true); - $device = $reqConf['device'] ?? []; - $deviceCount = count($device); - if ($deviceCount <= 0) { - continue; - } - $tasks = Db::name('task_customer') - ->where(['status' => 0, 'task_id' => $item['id']]) - ->order('id DESC') - ->limit($deviceCount) - ->select(); - $taskData = array_merge($taskData, $tasks); - } - if ($taskData) { - - foreach ($taskData as $task) { - $task_id = $task['task_id']; - $task_info = $this->getCustomerAcquisitionTask($task_id); - if (empty($task_info['status']) || empty($task_info['reqConf']) || empty($task_info['reqConf']['device'])) { - continue; - } - //筛选出设备在线微信在线 - $wechatIdAccountIdMap = $this->getWeChatIdsAccountIdsMapByDeviceIds($task_info['reqConf']['device']); - if (empty($wechatIdAccountIdMap)) { - continue; - } - - $friendAddTaskCreated = false; - foreach ($wechatIdAccountIdMap as $accountId => $wechatId) { - // 是否已经是好友的判断,如果已经是好友,直接break; 但状态还是维持1,让另外一个进程处理发消息的逻辑 - $wechatTags = json_decode($task['tags'], true); - $isFriend = $this->checkIfIsWeChatFriendByPhone($wechatId, $task['phone'], $task['siteTags']); - if (!empty($isFriend)) { - $friendAddTaskCreated = true; - $task['processed_wechat_ids'] = $task['processed_wechat_ids'] . ',' . $wechatId; // 处理失败任务用,用于过滤已处理的微信号 - break; - } - - // 判断时间间隔\时间段和最后一次的状态 - $canCreateFriendAddTask = $this->checkIfCanCreateFriendAddTask($wechatId, $task_info['reqConf']); - if (empty($canCreateFriendAddTask)) { - continue; - } - - // 根据健康分判断24h内加的好友数量限制 - $healthScoreService = new WechatAccountHealthScoreService(); - $healthScoreInfo = $healthScoreService->getHealthScore($accountId); - - // 如果健康分记录不存在,先计算一次 - if (empty($healthScoreInfo)) { - try { - $healthScoreService->calculateAndUpdate($accountId); - $healthScoreInfo = $healthScoreService->getHealthScore($accountId); - } catch (\Exception $e) { - Log::error("计算健康分失败 (accountId: {$accountId}): " . $e->getMessage()); - // 如果计算失败,使用默认值5作为兜底 - $maxAddFriendPerDay = 5; - } - } - - // 获取每日最大加人次数(基于健康分) - $maxAddFriendPerDay = $healthScoreInfo['maxAddFriendPerDay'] ?? 5; - - // 如果健康分为0或很低,不允许添加好友 - if ($maxAddFriendPerDay <= 0) { - Log::info("账号健康分过低,不允许添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, healthScore: " . ($healthScoreInfo['healthScore'] ?? 0) . ")"); - continue; - } - - // 检查频繁暂停限制:首次频繁或再次频繁,暂停24小时 - $lastFrequentTime = $healthScoreInfo['lastFrequentTime'] ?? null; - $frequentCount = $healthScoreInfo['frequentCount'] ?? 0; - if (!empty($lastFrequentTime) && $frequentCount > 0) { - $frequentPauseHours = 24; // 频繁暂停24小时 - $frequentPauseTime = $lastFrequentTime + ($frequentPauseHours * 3600); - $currentTime = time(); - - if ($currentTime < $frequentPauseTime) { - $remainingHours = ceil(($frequentPauseTime - $currentTime) / 3600); - Log::info("账号频繁,暂停添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, frequentCount: {$frequentCount}, 剩余暂停时间: {$remainingHours}小时)"); - continue; - } - } - - // 检查封号暂停限制:封号暂停72小时 - $isBanned = $healthScoreInfo['isBanned'] ?? 0; - if ($isBanned == 1) { - // 查询封号时间(从s2_wechat_message表查询最近一次封号消息) - $banMessage = Db::table('s2_wechat_message') - ->where('wechatAccountId', $accountId) - ->where('msgType', 10000) - ->where('content', 'like', '%你的账号被限制%') - ->where('isDeleted', 0) - ->order('createTime', 'desc') - ->find(); - - if (!empty($banMessage)) { - $banTime = $banMessage['createTime'] ?? 0; - $banPauseHours = 72; // 封号暂停72小时 - $banPauseTime = $banTime + ($banPauseHours * 3600); - $currentTime = time(); - - if ($currentTime < $banPauseTime) { - $remainingHours = ceil(($banPauseTime - $currentTime) / 3600); - Log::info("账号封号,暂停添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, 剩余暂停时间: {$remainingHours}小时)"); - continue; - } - } - } - - // 判断今天添加的好友数量,使用健康分计算的每日最大加人次数 - // 优先使用今天添加的好友数量(更符合"每日"限制) - $todayAddedFriendsCount = $this->getTodayAddedFriendsCount($wechatId); - if ($todayAddedFriendsCount >= $maxAddFriendPerDay) { - Log::info("今天添加好友数量已达上限 (accountId: {$accountId}, wechatId: {$wechatId}, count: {$todayAddedFriendsCount}, max: {$maxAddFriendPerDay}, healthScore: " . ($healthScoreInfo['healthScore'] ?? 0) . ")"); - continue; - } - - // 如果今天添加数量未达上限,再检查24小时内的数量(作为额外保护) - $last24hAddedFriendsCount = $this->getLast24hAddedFriendsCount($wechatId); - // 24小时内的限制可以稍微宽松一些,设置为每日限制的1.2倍(防止跨天累积) - $max24hLimit = (int)ceil($maxAddFriendPerDay * 1.2); - if ($last24hAddedFriendsCount >= $max24hLimit) { - Log::info("24小时内添加好友数量已达上限 (accountId: {$accountId}, wechatId: {$wechatId}, count: {$last24hAddedFriendsCount}, max24h: {$max24hLimit}, maxDaily: {$maxAddFriendPerDay})"); - continue; - } - - // 采取乐观尝试的策略,假设第一个可以添加的人可以添加成功的; 回头再另外一个任务进程去判断 - - // 创建好友添加任务, 对接触客宝 - $tags = array_merge($task_info['tagConf']['customTags'], $task_info['tagConf']['scenarioTags']); - if (!empty($wechatTags)) { - $tags = array_merge($tags, $wechatTags); - } - $tags = array_unique($tags); - $tags = array_values($tags); - $conf = array_merge($task_info['reqConf'], ['task_name' => $task_info['name'], 'tags' => $tags]); - - - $this->createFriendAddTask($accountId, $task['phone'], $conf, $task['remark']); - $friendAddTaskCreated = true; - $task['processed_wechat_ids'] = $task['processed_wechat_ids'] . ',' . $wechatId; // 处理失败任务用,用于过滤已处理的微信号 - break; - } - if (!empty($friendAddTaskCreated)){ - Db::name('task_customer') - ->where('id', $task['id']) - ->update([ - 'status' => $friendAddTaskCreated ? 1 : 3, - 'fail_reason' => '', - 'processed_wechat_ids' => $task['processed_wechat_ids'], - 'addTime' => time(), - 'updateTime' => time() - ]); - } - // ~~不用管,回头再添加再判断即可~~ - // 失败一定是另一个进程/定时器在检查的 - - } - } - } - - // 处理添加中的获客任务, only run in workerman process! - public function handleCustomerTaskWithStatusIsCreated() - { - - $tasks = Db::name('task_customer') - ->whereIn('status', [1, 2]) - ->where('updateTime', '>=', (time() - 86400 * 3)) - ->limit(50) - ->order('updateTime DESC') - ->select(); - - if (empty($tasks)) { - return; - } - - foreach ($tasks as $task) { - $task_id = $task['task_id']; - $task_info = $this->getCustomerAcquisitionTask($task_id); - - - if (empty($task_info['status']) || empty($task_info['reqConf']) || empty($task_info['reqConf']['device'])) { - continue; - } - - if (empty($task['processed_wechat_ids'])) { - continue; - } - - $weChatIds = explode(',', $task['processed_wechat_ids']); - $passedWeChatId = ''; - foreach ($weChatIds as $wechatId) { - // 先是否是好友,如果不是好友,先查询执行状态,看是否还能以及需要换账号继续添加,还是直接更新状态为3 - // 如果添加成功,先更新为2,然后去发消息(先判断有无消息设置,发消息的log记录?) - if (!empty($wechatId)) { - $isFriend = $this->checkIfIsWeChatFriendByPhone($wechatId, $task['phone']); - if ($isFriend) { - // 更新状态为5(已通过未发消息) - Db::name('task_customer') - ->where('id', $task['id']) - ->update(['status' => 5,'passTime' => time(), 'updateTime' => time()]); - $passedWeChatId = $wechatId; - break; - } - } - } - - - if ($passedWeChatId) { - // 获取好友记录(用于发消息 & 拉群) - $wechatFriendRecord = $this->getWeChatAccoutIdAndFriendIdByWeChatIdAndFriendPhone($passedWeChatId, $task['phone']); - if ($wechatFriendRecord) { - // 1. 如配置了消息,则先发送消息,并将状态置为4(已通过并已发消息) - if (!empty($task_info['msgConf'])) { - Db::name('task_customer') - ->where('id', $task['id']) - ->update(['status' => 4,'passTime' => time(), 'updateTime' => time()]); - - // 记录添加好友奖励(如果之前没有记录过,status从其他状态变为4时) - if ($task['status'] != 2 && !empty($task['channelId'])) { - try { - DistributionRewardService::recordAddFriendReward( - $task['task_id'], - $task['id'], - $task['phone'], - intval($task['channelId']) - ); - } catch (\Exception $e) { - // 记录错误但不影响主流程 - Log::error('记录添加好友奖励失败:' . $e->getMessage()); - } - } - - $msgConf = is_string($task_info['msgConf']) ? json_decode($task_info['msgConf'], 1) : $task_info['msgConf']; - $this->sendMsgToFriend($wechatFriendRecord['id'], $wechatFriendRecord['wechatAccountId'], $msgConf); - } - - // 2. 好友通过后,如配置了拉群,则建群并拉人:通过的好友 + 固定成员 - $this->createGroupAfterFriendPass($task_info, $wechatFriendRecord['id'], $wechatFriendRecord['wechatAccountId']); - // 如果没有 msgConf,则保持之前更新的状态5(已通过未发消息)不变 - } - - } else { - - foreach ($weChatIds as $wechatId) { - - // 查询执行状态 - $latestFriendTask = $this->getLatestFriendTaskByPhoneAndWeChatId($task['phone'], $wechatId); - if (empty($latestFriendTask)) { - continue; - } - - // 已经执行成功的话,直接break,同时更新对应task_customer的状态为2(添加成功) - if (isset($latestFriendTask['status']) && $latestFriendTask['status'] == 1) { - // 更新状态 - Db::name('task_customer') - ->where('id', $task['id']) - ->update(['status' => 2, 'updateTime' => time()]); - - // 记录添加好友奖励(异步处理,不影响主流程) - if (!empty($task['channelId'])) { - try { - DistributionRewardService::recordAddFriendReward( - $task['task_id'], - $task['id'], - $task['phone'], - intval($task['channelId']) - ); - } catch (\Exception $e) { - // 记录错误但不影响主流程 - Log::error('记录添加好友奖励失败:' . $e->getMessage()); - } - } - - break; - } - - // todo 判断处理执行失败的情况 status=2,根据 extra 的描述去处理;-- 可以先直接更新为失败,然后 extra =》fail_reason -- 因为有专门的任务会处理失败的 - if (isset($latestFriendTask['status']) && $latestFriendTask['status'] == 2) { - Db::name('task_customer') - ->where('id', $task['id']) - ->update(['status' => 3, 'fail_reason' => $latestFriendTask['extra'] ?? '未知原因', 'updateTime' => time()]); - break; - } - } - } - } - } - - - public function handleCustomerTaskNewUser() - { - $task = Db::name('customer_acquisition_task') - ->where(['status' => 1, 'deleteTime' => 0]) - ->whereIn('sceneId', [5, 7]) - ->order('id desc') - ->select(); - - if (empty($task)) { - return false; - } - - foreach ($task as $item) { - $sceneConf = json_decode($item['sceneConf'], true); - //电话 - if ($item['sceneId'] == 5) { - $rows = Db::name('call_recording') - ->where('companyId', $item['companyId']) - ->group('phone') - ->field('id,phone') - ->order('id asc') - ->limit(0, 100) - ->select(); - } - - if ($item['sceneId'] == 7) { - if (!empty($sceneConf['groupSelected']) && is_array($sceneConf['groupSelected'])) { - $rows = Db::name('wechat_group_member')->alias('gm') - ->join('wechat_account wa', 'gm.identifier = wa.wechatId') - ->where('gm.companyId', $item['companyId']) - ->whereIn('gm.groupId', $sceneConf['groupSelected']) - ->group('gm.identifier') - ->column('wa.id,wa.wechatId,wa.alias,wa.phone'); - } - } - - - if (in_array($item['sceneId'], [5, 7]) && !empty($rows) && is_array($rows)) { - // 1000条为一组进行批量处理 - $batchSize = 1000; - $totalRows = count($rows); - - for ($i = 0; $i < $totalRows; $i += $batchSize) { - $batchRows = array_slice($rows, $i, $batchSize); - - if (!empty($batchRows)) { - // 1. 提取当前批次的phone - $phones = []; - foreach ($batchRows as $row) { - if (!empty($row['phone'])) { - $phone = !empty($row['phone']); - } elseif (!empty($row['alias'])) { - $phone = $row['alias']; - } else { - $phone = $row['wechatId']; - } - if (!empty($phone)) { - $phones[] = $phone; - } - } - - // 2. 批量查询已存在的phone - $existingPhones = []; - if (!empty($phones)) { - $existing = Db::name('task_customer') - ->where('task_id', $item['id']) - ->where('phone', 'in', $phones) - ->field('phone') - ->select(); - $existingPhones = array_column($existing, 'phone'); - } - - // 3. 过滤出新数据,批量插入 - $newData = []; - foreach ($batchRows as $row) { - if (!empty($row['phone'])) { - $phone = !empty($row['phone']); - } elseif (!empty($row['alias'])) { - $phone = $row['alias']; - } else { - $phone = $row['wechatId']; - } - if (!empty($phone) && !in_array($phone, $existingPhones)) { - $newData[] = [ - 'task_id' => $item['id'], - 'name' => '', - 'source' => '场景获客_' . $item['name'], - 'phone' => $phone, - 'tags' => json_encode([], JSON_UNESCAPED_UNICODE), - 'siteTags' => json_encode([], JSON_UNESCAPED_UNICODE), - 'createTime' => time(), - ]; - } - } - - // 4. 批量插入新数据 - if (!empty($newData)) { - Db::name('task_customer')->insertAll($newData); - } - } - } - } - - - } - } - - - // 发微信个人消息 - public function sendMsgToFriend(int $friendId, int $wechatAccountId, array $msgConf) - { - // 消息拼接 msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件) - // 当前,type 为文本、图片、动图表情包的时候,content为string, 其他情况为对象 {type: 'file/link/...', url: '', title: '', thunmbPath: '', desc: ''} - // $result = [ - // "content" => $dataArray['content'], - // "msgSubType" => 0, - // "msgType" => $dataArray['msgType'], - // "seq" => time(), - // "wechatAccountId" => $dataArray['wechatAccountId'], - // "wechatChatroomId" => 0, - // "wechatFriendId" => $dataArray['wechatFriendId'], - // ]; - $toAccountId = ''; - $username = Env::get('api.username', ''); - $password = Env::get('api.password', ''); - if (!empty($username) || !empty($password)) { - $toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId'); - } - - // 建立WebSocket - $wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); - - - $gap = 0; - foreach ($msgConf as $messages) { - foreach ($messages['messages'] as $content) { - - $msgType = 0; - $detail = ''; - switch ($content['type']) { - case 'text': - $msgType = 1; - $detail = $content['content']; - break; - case 'image': - $msgType = 3; - $detail = $content['content']; - break; - case 'video': - $msgType = 43; - $detail = $content['content']; - break; - - case 'file': - $msgType = 49; - - $detail = [ - 'type' => 'file', - 'title' => $content['content'][0]['name'], - 'url' => $content['content'][0]['url'], - ]; - $detail = json_encode($detail); - break; - - case 'miniprogram': - $msgType = 49; - $detail = ''; - break; - - case 'link': - $msgType = 49; - $detail = [ - 'type' => 'link', - 'title' => $content['title'], - 'url' => $content['linkUrl'], - 'thumbPath' => $content['cover'], - 'desc' => $content['description'], - ]; - $detail = json_encode($detail); - break; - - case 'group': - $msgType = 49; - $detail = ''; - break; - default : - $msgType = 47; - $detail = $content['content']; - break; - } - - - if (empty($detail)) { - continue; - } - - if ($gap) { - Timer::add($gap, function () use ($wsController, $friendId, $wechatAccountId, $msgType, $content, $detail) { - $wsController->sendPersonal([ - 'wechatFriendId' => $friendId, - 'wechatAccountId' => $wechatAccountId, - 'msgType' => $msgType, - 'content' => $detail, - ]); - }, [], false); - } else { - $wsController->sendPersonal([ - 'wechatFriendId' => $friendId, - 'wechatAccountId' => $wechatAccountId, - 'msgType' => $msgType, - 'content' => $detail, - ]); - } - - !empty($content['sendInterval']) && $gap += $content['sendInterval']; - } - } - - } - - // getCustomerAcquisitionTask - public function getCustomerAcquisitionTask($id) - { - // 先读取缓存 - $task_info = Db::name('customer_acquisition_task') - ->where('id', $id) - ->find(); - if ($task_info) { - $task_info['sceneConf'] = json_decode($task_info['sceneConf'], true); - $task_info['reqConf'] = json_decode($task_info['reqConf'], true); - $task_info['msgConf'] = json_decode($task_info['msgConf'], true); - $task_info['tagConf'] = json_decode($task_info['tagConf'], true); - // 处理拉群固定成员配置(JSON 字段) - if (!empty($task_info['groupFixedMembers'])) { - $fixedMembers = json_decode($task_info['groupFixedMembers'], true); - $task_info['groupFixedMembers'] = is_array($fixedMembers) ? $fixedMembers : []; - } else { - $task_info['groupFixedMembers'] = []; - } - } - return $task_info; - } - - /** - * 好友通过后,根据任务配置建群并拉人(通过好友 + 固定成员) - * @param array $taskInfo customer_acquisition_task 记录(含 groupInviteEnabled/groupName/groupFixedMembers) - * @param int $passedFriendId 通过的好友ID(s2_wechat_friend.id) - * @param int $wechatAccountId 微信账号ID(建群账号) - */ - protected function createGroupAfterFriendPass(array $taskInfo, int $passedFriendId, int $wechatAccountId): void - { - // 1. 校验拉群开关与基础配置 - if (empty($taskInfo['groupInviteEnabled'])) { - return; - } - - $groupName = $taskInfo['groupName'] ?? ''; - if ($groupName === '') { - return; - } - - $fixedMembers = $taskInfo['groupFixedMembers'] ?? []; - if (!is_array($fixedMembers)) { - $fixedMembers = []; - } - - // 2. 过滤出有效的固定成员好友ID(数字ID) - $fixedFriendIds = []; - foreach ($fixedMembers as $member) { - if (is_numeric($member)) { - $fixedFriendIds[] = intval($member); - } - } - - // 包含通过的好友 - $friendIds = array_unique(array_merge([$passedFriendId], $fixedFriendIds)); - if (empty($friendIds)) { - return; - } - - try { - // 3. 初始化 WebSocket(参考 sendMsgToFriend / Workbench 群创建逻辑) - $toAccountId = ''; - $username = Env::get('api.username', ''); - $password = Env::get('api.password', ''); - if (!empty($username) || !empty($password)) { - $toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId'); - } - if (empty($toAccountId)) { - Log::warning('createGroupAfterFriendPass: toAccountId 为空,跳过建群'); - return; - } - - $webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); - - // 4. 调用建群接口:群名 = 配置的 groupName,成员 = 通过好友 + 固定好友 - $createResult = $webSocket->CmdChatroomCreate([ - 'chatroomName' => $groupName, - 'wechatFriendIds' => $friendIds, - 'wechatAccountId' => $wechatAccountId, - ]); - - $createResultData = json_decode($createResult, true); - if (empty($createResultData) || !isset($createResultData['code']) || $createResultData['code'] != 200) { - Log::warning('createGroupAfterFriendPass: 建群失败', [ - 'taskId' => $taskInfo['id'] ?? 0, - 'wechatAccountId' => $wechatAccountId, - 'friendIds' => $friendIds, - 'result' => $createResult, - ]); - } - } catch (\Exception $e) { - Log::error('createGroupAfterFriendPass 异常: ' . $e->getMessage()); - } - } - - // 检查是否是好友关系 - - public function checkIfIsWeChatFriendByPhone($wxId = '', $phone = '', $siteTags = '') - { - if (empty($wxId) || empty($phone)) { - return false; - } - - try { - $friend = Db::table('s2_wechat_friend') - ->where('ownerWechatId', $wxId) - ->where(['isPassed' => 1, 'isDeleted' => 0]) - ->where('phone|alias|wechatId', 'like', $phone . '%') - ->order('createTime', 'desc') - ->find(); - if (!empty($friend)) { - if (!empty($siteTags)) { - $siteTags = json_decode($siteTags, true); - $siteLabels = json_decode($friend['siteLabels'], true); - $tags = array_merge($siteTags, $siteLabels); - $tags = array_unique($tags); - $tags = array_values($tags); - if (empty($tags)) { - $tags = []; - } - $tags = json_encode($tags, 256); - Db::table('s2_wechat_friend')->where(['id' => $friend['id']])->update(['siteLabels' => $tags, 'updateTime' => time()]); - } - return true; - } else { - return false; - } - } catch (\Exception $e) { - Log::error("Error in checkIfIsWeChatFriendByPhone (wxId: {$wxId}, phone: {$phone}): " . $e->getMessage()); - return false; - } - } - - // getWeChatAccoutIdAndFriendIdByWeChatId - public function getWeChatAccoutIdAndFriendIdByWeChatIdAndFriendPhone(string $wechatId, string $phone): array - { - if (empty($wechatId) || empty($phone)) { - return []; - } - - return Db::table('s2_wechat_friend') - ->where('ownerWechatId', $wechatId) - ->where('phone|alias|wechatId', 'like', $phone . '%') - ->field('id,wechatAccountId,passTime,createTime') - ->find(); - } - - // 判断是否已添加某手机号为好友并返回添加时间 - public function getWeChatFriendPassTimeByPhone(string $wxId, string $phone): int - { - if (empty($wxId) || empty($phone)) { - return 0; - } - - try { - $record = Db::table('s2_wechat_friend') - ->where('ownerWechatId', $wxId) - ->where('phone|alias|wechatId', 'like', $phone . '%') - ->field('id,createTime,passTime') - ->find(); - - return $record['passTime'] ?? $record['createTime'] ?? 0; - } catch (\Exception $e) { - Log::error("Error in getWeChatFriendPassTimeByPhone (wxId: {$wxId}, phone: {$phone}): " . $e->getMessage()); - return 0; - } - } - - /** - * 查询某个微信今天添加了多少个好友 - * @param string $wechatId 微信ID - * @return int 好友数量 - */ - public function getTodayAddedFriendsCount(string $wechatId): int - { - if (empty($wechatId)) { - return 0; - } - try { - $count = Db::table('s2_friend_task') - ->where('wechatId', $wechatId) - ->whereRaw("FROM_UNIXTIME(createTime, '%Y-%m-%d') = CURDATE()") - ->count(); - return (int)$count; - } catch (\Exception $e) { - Log::error("Error in getTodayAddedFriendsCount (wechatId: {$wechatId}): " . $e->getMessage()); - return 0; - } - } - - /** - * 查询某个微信24小时内添加了多少个好友 - * @param string $wechatId 微信ID - * @return int 好友数量 - */ - public function getLast24hAddedFriendsCount(string $wechatId): int - { - if (empty($wechatId)) { - return 0; - } - try { - $twentyFourHoursAgo = time() - (24 * 60 * 60); - $count = Db::table('s2_friend_task') - ->where('wechatId', $wechatId) - ->where('createTime', '>=', $twentyFourHoursAgo) - ->count(); - return (int)$count; - } catch (\Exception $e) { - Log::error("Error in getLast24hAddedFriendsCount (wechatId: {$wechatId}): " . $e->getMessage()); - return 0; - } - } - - /** - * 查询某个微信最新的一条添加好友任务记录 - * @param string $wechatId 微信ID - * @return array|null 任务记录或null - */ - public function getLatestFriendTask(string $wechatId): ?array - { - if (empty($wechatId)) { - return null; - } - try { - $task = Db::table('s2_friend_task') - ->where('wechatId', $wechatId) - ->order('createTime', 'desc') - ->find(); - return $task; - } catch (\Exception $e) { - Log::error("Error in getLatestFriendTask (wechatId: {$wechatId}): " . $e->getMessage()); - return null; - } - } - - // 获取某微信最后一条添加好友任务 - public function getLatestFriendTaskByPhoneAndWeChatId(string $phone, string $wechatId): array - { - if (empty($phone) || empty($wechatId)) { - return []; - } - - $record = Db::table('s2_friend_task') - ->where('phone', $phone) - ->where('wechatId', $wechatId) - ->order('createTime', 'desc') - ->find(); - return $record ?: []; - } - - // 获取最新的一条添加好友任务记录的创建时间 - public function getLastCreateFriendTaskTime(string $wechatId): int - { - if (empty($wechatId)) { - return 0; - } - $record = Db::table('s2_friend_task') - ->where('wechatId', $wechatId) - ->order('createTime', 'desc') - ->find(); - return $record['createTime'] ?? 0; - } - - // 判断是否能够加好友 - public function checkIfCanCreateFriendAddTask(string $wechatId, $conf = []): bool - { - if (empty($wechatId)) { - return false; - } - //强制请求添加好友的列表 - $friendController = new FriendTaskController(); - $friendController->getlist(0, 50); - - - $record = $this->getLatestFriendTask($wechatId); - if (empty($record)) { - return true; - } - - if (!empty($conf['addFriendInterval']) && isset($record['createTime']) && $record['createTime'] > time() - $conf['addFriendInterval'] * 60) { - return false; - } - - if (!empty($conf['startTime']) && !empty($conf['endTime'])) { - $currentTime = date('H:i'); - $startTime = $conf['startTime']; - $endTime = $conf['endTime']; - - if ($currentTime >= $startTime && $currentTime <= $endTime) { - return true; - } else { - return false; - } - } - - if (isset($record['status'])) { - - if ($record['status'] == 2) { - - // 判断$record['extra'] 是否包含文字: 操作过于频繁;如果包含判断 updateTime 是否已经超过72min,updateTime是10位时间戳;如果包含指定文字且时间未超过72min,return false - if (isset($record['extra']) && strpos($record['extra'], '操作过于频繁') !== false) { - $updateTime = isset($record['updateTime']) ? (int)$record['updateTime'] : 0; - $now = time(); - $diff = $now - $updateTime; - - if ($diff < 24 * 60 * 60) { - return false; - } - } - } - } - - return true; - } - - // 获取触客宝系统的客服微信账号id,用于后续微信相关操作 - public function getWeChatAccountIdByWechatId(string $wechatId): string - { - if (empty($wechatId)) { - return ''; - } - $record = Db::table('s2_wechat_account') - ->where('wechatId', $wechatId) - ->field('id') - ->find(); - return $record['id'] ?? ''; - } - - // 获取在线的客服微信账号id列表 - public function getOnlineWeChatAccountIdsByWechatIds(array $wechatIds): array - { - if (empty($wechatIds)) { - return []; - } - $records = Db::table('s2_wechat_account') - ->where('deviceAlive', 1) - ->where('wechatAlive', 1) - ->where('wechatId', 'in', $wechatIds) - ->field('id,wechatId') - ->column('id', 'wechatId'); - - return $records; - } - - public function getWeChatIdsAccountIdsMapByDeviceIds(array $deviceIds): array - { - if (empty($deviceIds)) { - return []; - } - - $records = Db::table('s2_wechat_account') - ->where('deviceAlive', 1) - ->where('wechatAlive', 1) - ->where('currentDeviceId', 'in', $deviceIds) - ->field('id,wechatId') - ->column('id,wechatId'); - return $records; - } - - // 触客宝添加好友API - public function addFriendTaskApi(int $wechatAccountId, string $phone, string $message, string $remark, array $labels, $authorization = '') - { - - $authorization = $authorization ?: AuthService::getSystemAuthorization(); - - if (empty($authorization)) { - return [ - 'status_code' => 0, - 'body' => null, - 'error' => true, - ]; - } - - $params = [ - 'phone' => $phone, - 'message' => $message, - 'remark' => $remark, - 'labels' => $labels, - 'wechatAccountId' => $wechatAccountId - ]; - - //准备发起添加请求 - $friendController = new FriendTaskController(); - $result = $friendController->addFriendTask($params); - $result = json_decode($result, true); - if ($result['code'] == 200) { - return $result; - } else { - $authorization = AuthService::getSystemAuthorization(false); - return $this->addFriendTaskApi($wechatAccountId, $phone, $message, $remark, $labels, $authorization); - } - - - } - - // 创建添加好友任务/执行添加 - public function createFriendAddTask(int $wechatAccountId, string $phone, array $conf, $remark = '') - { - if (empty($wechatAccountId) || empty($phone) || empty($conf)) { - return; - } - - if (empty($remark)){ - switch ($conf['remarkType']) { - case 'phone': - $remark = $phone . '-' . $conf['task_name']; - break; - case 'nickname': - $remark = ''; - break; - case 'source': - $remark = $conf['task_name']; - break; - default: - $remark = ''; - break; - } - } - - $tags = []; - if (!empty($conf['tags'])) { - if (is_array($conf['tags'])) { - $tags = $conf['tags']; - } - } - $res = $this->addFriendTaskApi($wechatAccountId, $phone, $conf['greeting'] ?? '你好', $remark, $tags); - } - - /* TODO: 以上方法待实现,基于/参考 application/api/controller/WebSocketController.php 去实现;以下同步脚本用的方法转移到其他类 */ - - - // NOTE: run in background; 5min 同步一次 - public function syncFriendship() - { - $sql = "INSERT INTO ck_wechat_friendship(id,wechatId,tags,memo,ownerWechatId,createTime,updateTime,deleteTime,companyId) - SELECT - f.id,f.wechatId,f.labels as tags,f.conRemark as memo,f.ownerWechatId,f.createTime,f.updateTime,f.deleteTime, - c.departmentId - FROM s2_wechat_friend f - LEFT JOIN s2_wechat_account a on a.id = f.wechatAccountId - LEFT JOIN s2_company_account c on c.id = a.deviceAccountId - ORDER BY f.id DESC - LIMIT ?, ? - ON DUPLICATE KEY UPDATE - id=VALUES(id), - tags=VALUES(tags), - memo=VALUES(memo), - updateTime=VALUES(updateTime), - deleteTime=VALUES(deleteTime), - companyId=VALUES(companyId)"; - - $offset = 0; - $limit = 2000; - $usleepTime = 50000; - - do { - $affected = Db::execute($sql, [$offset, $limit]); - $offset += $limit; - if ($affected > 0) { - usleep($usleepTime); - } - } while ($affected > 0); - } - - - public function syncWechatAccount() - { - $pk = 'wechatId'; - $limit = 1000; - // $lastId = ''; - $lastId = null; // Or some other sentinel indicating "first run" - - - $totalAffected = 0; - $iterations = 0; - $maxIterations = 10000; - - do { - // Fetch a batch of distinct wechatIds - // Important: Order by wechatId for consistent pagination - $sourceDb = Db::connect()->table('s2_wechat_friend'); - // if ($lastId !== '') { // For subsequent iterations - if (!is_null($lastId)) { // Check if it's not the first iteration - $sourceDb->where($pk, '>', $lastId); - } - $distinctWechatIds = $sourceDb->order($pk, 'ASC') - ->distinct(true) - ->limit($limit) - ->column($pk); // Get an array of wechatIds - - if (empty($distinctWechatIds)) { - break; // No more wechatIds to process - } - - // Prepare the main IODKU query for this batch of wechatIds - $sql = "INSERT INTO ck_wechat_account(wechatId,alias,nickname,pyInitial,quanPin,avatar,gender,region,signature,phone,country,privince,city,createTime,updateTime) - SELECT - wechatId,alias,nickname,pyInitial,quanPin,avatar,gender,region,signature,phone,country,privince,city,createTime,updateTime - FROM - s2_wechat_friend - WHERE wechatId IN (" . implode(',', array_fill(0, count($distinctWechatIds), '?')) . ") - GROUP BY wechatId -- Grouping within the selected wechatIds - ON DUPLICATE KEY UPDATE - alias=VALUES(alias), - nickname=VALUES(nickname), - pyInitial=VALUES(pyInitial), - quanPin=VALUES(quanPin), - avatar=VALUES(avatar), - gender=VALUES(gender), - region=VALUES(region), - signature=VALUES(signature), - phone=VALUES(phone), - country=VALUES(country), - privince=VALUES(privince), - city=VALUES(city), - updateTime=VALUES(updateTime)"; - - // The parameters for the IN clause are the distinctWechatIds themselves - $bindings = $distinctWechatIds; - - try { - $affected = Db::execute($sql, $bindings); - $totalAffected += $affected; - // Log::info("syncWechatAccount: Processed batch of " . count($distinctWechatIds) . " distinct wechatIds. Affected rows: " . $affected); - - // Update lastId for the next iteration - $lastId = end($distinctWechatIds); - - if ($affected > 0) { - usleep(50000); - } - } catch (\Exception $e) { - Log::error("syncWechatAccount batch error: " . $e->getMessage() . " with wechatIds starting around " . $distinctWechatIds[0] . ". SQL: " . $sql . " Bindings: " . json_encode($bindings)); - // Decide if you want to break or continue with the next batch - break; // Example: break on error - } - $iterations++; - } while (count($distinctWechatIds) === $limit && $iterations < $maxIterations); // Continue if we fetched a full batch - - // Log::info("syncWechatAccount finished. Total affected rows: " . $totalAffected); - return $totalAffected; - } - - - public function syncWechatDeviceLoginLog() - { - try { - $cursor = Db::table('s2_wechat_account') - ->alias('a') - ->join(['s2_device' => 'd'], 'd.imei = a.imei') - ->join(['s2_company_account' => 'c'], 'c.id = d.currentAccountId') - ->field('d.id as deviceId, a.wechatId, a.wechatAlive as alive, c.departmentId as companyId, a.updateTime as updateTime') - ->cursor(); - - foreach ($cursor as $item) { - - if (empty($item['deviceId']) || empty($item['wechatId']) || empty($item['companyId'])) { - continue; - } - - $exists = Db::table('ck_device_wechat_login') - ->where('deviceId', $item['deviceId']) - ->where('wechatId', $item['wechatId']) - ->where('companyId', $item['companyId']) - ->find(); - - if ($exists) { - Db::table('ck_device_wechat_login') - ->where('deviceId', $item['deviceId']) - ->where('wechatId', $item['wechatId']) - ->where('companyId', $item['companyId']) - ->update(['alive' => $item['alive'], 'updateTime' => $item['updateTime']]); - } else { - $item['createTime'] = $item['updateTime']; - Db::table('ck_device_wechat_login')->insert($item); - } - - } - - return true; - } catch (\Exception $e) { - Log::error("微信好友同步任务异常: " . $e->getMessage() . ", 堆栈: " . $e->getTraceAsString()); - return false; - } - } - - /** - * 大数据量分批处理版本 - * 适用于数据源非常大的情况,避免一次性加载全部数据到内存 - * 独立脚本执行,30min 同步一次 和 流量来源的更新一起 - * - * @param int $batchSize 每批处理的数据量 - * @return int 影响的行数 - */ - public function syncWechatFriendToTrafficPoolBatch($batchSize = 5000) - { - Db::execute("CREATE TEMPORARY TABLE IF NOT EXISTS temp_wechat_ids ( - wechatId VARCHAR(64) PRIMARY KEY - ) ENGINE=MEMORY"); - - Db::execute("TRUNCATE TABLE temp_wechat_ids"); - - // 批量插入去重的wechatId - Db::execute("INSERT INTO temp_wechat_ids SELECT DISTINCT wechatId FROM s2_wechat_friend"); - - $total = Db::table('temp_wechat_ids')->count(); - - $batchCount = ceil($total / $batchSize); - $affectedRows = 0; - - try { - for ($i = 0; $i < $batchCount; $i++) { - $offset = $i * $batchSize; - - $sql = "INSERT IGNORE INTO ck_traffic_pool_v1(`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毫秒 - } - } catch (\Exception $e) { - \think\facade\Log::error("Error in traffic pool sync: " . $e->getMessage()); - throw $e; - } finally { - Db::execute("DROP TEMPORARY TABLE IF EXISTS temp_wechat_ids"); - } - - return $affectedRows; - } - - - /** - * 同步/更新微信客服信息到ck_wechat_customer表 - * - * @param int $batchSize 每批处理的数据量 - * @return int 影响的行数 - */ - public function syncWechatCustomer($batchSize = 1000) - { - try { - // 1. 获取要处理的wechatId和companyId列表 - $customerList = Db::table('ck_device_wechat_login') - ->field('DISTINCT wechatId, companyId') - ->order('id DESC') - ->select(); - - $totalAffected = 0; - $batchCount = ceil(count($customerList) / $batchSize); - - for ($i = 0; $i < $batchCount; $i++) { - $batch = array_slice($customerList, $i * $batchSize, $batchSize); - $insertData = []; - - foreach ($batch as $customer) { - $wechatId = $customer['wechatId']; - $companyId = $customer['companyId']; - - if (empty($wechatId)) continue; - - // 2. 获取s2_wechat_account数据 - $accountInfo = Db::table('s2_wechat_account') - ->where('wechatId', $wechatId) - ->find(); - - // 3. 获取群数量 (不包含 @openim 结尾的identifier) - $groupCount = Db::table('ck_wechat_group_member') - ->where('identifier', $wechatId) - ->where('customerIs', 1) - ->where('identifier', 'not like', '%@openim') - ->count(); - - // 4. 检查记录是否已存在 - $existingRecord = Db::table('ck_wechat_customer') - ->where('wechatId', $wechatId) - ->find(); - - // 5. 构建basic JSON数据 - $basic = []; - if ($existingRecord && !empty($existingRecord['basic'])) { - $basic = json_decode($existingRecord['basic'], true) ?: []; - } - - if (empty($basic['registerDate'])) { - $basic['registerDate'] = date('Y-m-d H:i:s', strtotime('-' . mt_rand(1, 150) . ' months')); - } - - // 6. 构建activity JSON数据 - $activity = []; - if ($existingRecord && !empty($existingRecord['activity'])) { - $activity = json_decode($existingRecord['activity'], true) ?: []; - } - - if ($accountInfo) { - $activity['yesterdayMsgCount'] = $accountInfo['yesterdayMsgCount'] ?? 0; - $activity['sevenDayMsgCount'] = $accountInfo['sevenDayMsgCount'] ?? 0; - $activity['thirtyDayMsgCount'] = $accountInfo['thirtyDayMsgCount'] ?? 0; - - // 计算totalMsgCount - if (empty($activity['totalMsgCount'])) { - $activity['totalMsgCount'] = $activity['thirtyDayMsgCount']; - } else { - $activity['totalMsgCount'] += $activity['yesterdayMsgCount']; - } - } - - // 7. 构建friendShip JSON数据 - $friendShip = []; - if ($existingRecord && !empty($existingRecord['friendShip'])) { - $friendShip = json_decode($existingRecord['friendShip'], true) ?: []; - } - - if ($accountInfo) { - $friendShip['totalFriend'] = $accountInfo['totalFriend'] ?? 0; - $friendShip['maleFriend'] = $accountInfo['maleFriend'] ?? 0; - $friendShip['unknowFriend'] = $accountInfo['unknowFriend'] ?? 0; - $friendShip['femaleFriend'] = $accountInfo['femaleFriend'] ?? 0; - } - $friendShip['groupNumber'] = $groupCount; - - // 8. 构建weight JSON数据 (每天只计算一次) - // $weight = []; - // if ($existingRecord && !empty($existingRecord['weight'])) { - // $weight = json_decode($existingRecord['weight'], true) ?: []; - - // // 如果不是今天更新的,重新计算权重 - // $lastUpdateDate = date('Y-m-d', $existingRecord['updateTime'] ?? 0); - // if ($lastUpdateDate !== date('Y-m-d')) { - // $weight = $this->calculateCustomerWeight($basic, $activity, $friendShip); - // } - // } else { - // $weight = $this->calculateCustomerWeight($basic, $activity, $friendShip); - // } - - // 9. 准备更新或插入的数据 - $data = [ - 'wechatId' => $wechatId, - 'companyId' => $companyId, - 'basic' => json_encode($basic), - 'activity' => json_encode($activity), - 'friendShip' => json_encode($friendShip), - // 'weight' => json_encode($weight), - 'createTime' => $accountInfo['createTime'], - 'updateTime' => time() - ]; - - if ($existingRecord) { - // 更新记录 - Db::table('ck_wechat_customer') - ->where('wechatId', $wechatId) - ->update($data); - } else { - // 插入记录 - Db::table('ck_wechat_customer')->insert($data); - } - - $totalAffected++; - } - - // 释放内存 - if ($i % 5 == 0) { - gc_collect_cycles(); - } - - usleep(50000); // 50毫秒短暂休息 - } - - return $totalAffected; - } catch (\Exception $e) { - Log::error("同步微信客服信息异常: " . $e->getMessage() . ", 堆栈: " . $e->getTraceAsString()); - throw $e; - } - } - - /** - * 计算客服权重 - * - * @param array $basic 基础信息 - * @param array $activity 活跃信息 - * @param array $friendShip 好友关系信息 - * @return array 权重信息 - */ - private function calculateCustomerWeight($basic, $activity, $friendShip) - { - // 1. 计算账号年龄权重(最大20分) - $ageWeight = 0; - if (!empty($basic['registerDate'])) { - $registerTime = strtotime($basic['registerDate']); - $accountAgeMonths = floor((time() - $registerTime) / (30 * 24 * 3600)); - $ageWeight = min(20, floor($accountAgeMonths / 12) * 4); - } - - // 2. 计算活跃度权重(最大30分) - $activityWeight = 0; - if (!empty($activity)) { - // 基于消息数计算活跃度 - $msgScore = 0; - if ($activity['thirtyDayMsgCount'] > 10000) $msgScore = 15; - elseif ($activity['thirtyDayMsgCount'] > 5000) $msgScore = 12; - elseif ($activity['thirtyDayMsgCount'] > 1000) $msgScore = 8; - elseif ($activity['thirtyDayMsgCount'] > 500) $msgScore = 5; - elseif ($activity['thirtyDayMsgCount'] > 100) $msgScore = 3; - - // 连续活跃天数加分(这里简化处理,实际可能需要更复杂逻辑) - $activeScore = min(15, $activity['yesterdayMsgCount'] > 10 ? 15 : floor($activity['yesterdayMsgCount'] / 2)); - - $activityWeight = $msgScore + $activeScore; - } - - // 3. 计算限制影响权重(最大15分) - $restrictWeight = 15; // 默认满分,无限制 - - // 4. 计算实名认证权重(最大10分) - $realNameWeight = 0; // 简化处理,默认未实名 - - // 5. 计算可加友数量限制(基于好友数量,最大为5000) - $addLimit = 0; - if (!empty($friendShip['totalFriend'])) { - $addLimit = max(0, min(5000 - $friendShip['totalFriend'], 5000)); - $addLimit = floor($addLimit / 1000); // 每1000个空位1分,最大5分 - } - - // 6. 计算总分(满分75+5分) - $scope = $ageWeight + $activityWeight + $restrictWeight + $realNameWeight; - - return [ - 'ageWeight' => $ageWeight, - 'activityWeight' => $activityWeight, // 注意这里修正了拼写错误 - 'restrictWeight' => $restrictWeight, - 'realNameWeight' => $realNameWeight, - 'scope' => $scope, - 'addLimit' => $addLimit - ]; - } - - /** - * 同步设备信息到ck_device表 - * 数据量不大,仅同步一次所有设备 - * - * @return int 影响的行数 - */ - public function syncDevice() - { - try { - $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 companyId - FROM s2_device d - JOIN s2_company_account a ON d.currentAccountId = a.id - ON DUPLICATE KEY UPDATE - `model` = VALUES(`model`), - `phone` = VALUES(`phone`), - `operatingSystem` = VALUES(`operatingSystem`), - `memo` = VALUES(`memo`), - `alive` = VALUES(`alive`), - `brand` = VALUES(`brand`), - `rooted` = VALUES(`rooted`), - `xPosed` = VALUES(`xPosed`), - `softwareVersion` = VALUES(`softwareVersion`), - `extra` = VALUES(`extra`), - `updateTime` = VALUES(`updateTime`), - `deleteTime` = VALUES(`deleteTime`), - `companyId` = VALUES(`companyId`)"; - - $affected = Db::execute($sql); - return $affected; - } catch (\Exception $e) { - Log::error("同步设备信息异常: " . $e->getMessage() . ", 堆栈: " . $e->getTraceAsString()); - return false; - } - } - - public function syncTrafficSourceUser() - { - $sql = "insert into ck_traffic_source_v1(`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)"; - - - $offset = 0; - $limit = 2000; - $usleepTime = 50000; - do { - $affected = Db::execute($sql, [$offset, $limit]); - $offset += $limit; - if ($affected > 0) { - usleep($usleepTime); - } - } while ($affected > 0); - } - - public function syncTrafficSourceGroup() - { - $sql = "insert into ck_traffic_source_v1(`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)"; - - - $offset = 0; - $limit = 2000; - $usleepTime = 50000; - do { - $affected = Db::execute($sql, [$offset, $limit]); - $offset += $limit; - if ($affected > 0) { - usleep($usleepTime); - } - } while ($affected > 0); - } - - public function syncWechatGroup() - { - $sql = "insert into ck_wechat_group(`id`,`wechatAccountId`,`chatroomId`,`name`,`avatar`,`companyId`,`ownerWechatId`,`createTime`,`updateTime`,`deleteTime`) - SELECT - g.id id, - g.wechatAccountId wechatAccountId, - g.chatroomId chatroomId, - g.nickname name, - g.chatroomAvatar avatar, - c.departmentId companyId, - g.wechatAccountWechatId ownerWechatId, - g.createTime createTime, - g.updateTime updateTime, - g.deleteTime deleteTime - FROM - s2_wechat_chatroom g - LEFT JOIN s2_company_account c ON g.accountId = c.id - ORDER BY g.id DESC - LIMIT ?, ? - ON DUPLICATE KEY UPDATE - chatroomId=VALUES(chatroomId), - companyId=VALUES(companyId), - ownerWechatId=VALUES(ownerWechatId)"; - - - $offset = 0; - $limit = 2000; - $usleepTime = 50000; - do { - $affected = Db::execute($sql, [$offset, $limit]); - $offset += $limit; - if ($affected > 0) { - usleep($usleepTime); - } - } while ($affected > 0); - } - - public function syncWechatGroupCustomer() - { - $sql = "insert into ck_wechat_group_member(`identifier`,`chatroomId`,`companyId`,`groupId`,`createTime`) - SELECT - m.wechatId identifier, - g.chatroomId chatroomId, - c.departmentId companyId, - g.id groupId, - m.createTime createTime - FROM - s2_wechat_chatroom_member m - LEFT JOIN s2_wechat_chatroom g ON g.chatroomId = m.chatroomId - LEFT JOIN s2_company_account c ON g.accountId = c.id - ORDER BY m.id DESC - LIMIT ?, ? - ON DUPLICATE KEY UPDATE - identifier=VALUES(identifier), - chatroomId=VALUES(chatroomId), - companyId=VALUES(companyId), - groupId=VALUES(groupId)"; - - $offset = 0; - $limit = 2000; - $usleepTime = 50000; - do { - $affected = Db::execute($sql, [$offset, $limit]); - $offset += $limit; - if ($affected > 0) { - usleep($usleepTime); - } - } while ($affected > 0); - } - - - public function syncCallRecording() - { - $sql = "insert into ck_call_recording(`id`,`phone`,`isCallOut`,`companyId`,`callType`,`beginTime`,`endTime`,`createTime`) - SELECT - c.id id, - c.phone phone, - c.isCallOut isCallOut, - a.departmentId companyId, - c.callType callType, - c.beginTime beginTime, - c.endTime endTime, - c.callBeginTime createTime - FROM - s2_call_recording c - LEFT JOIN s2_company_account a ON c.deviceOwnerId = a.id - ORDER BY c.id DESC - LIMIT ?, ? - ON DUPLICATE KEY UPDATE - id=VALUES(id), - phone=VALUES(phone), - isCallOut=VALUES(isCallOut), - companyId=VALUES(companyId)"; - - $offset = 0; - $limit = 2000; - $usleepTime = 50000; - do { - $affected = Db::execute($sql, [$offset, $limit]); - $offset += $limit; - if ($affected > 0) { - usleep($usleepTime); - } - } while ($affected > 0); - } - - /** - * 处理自动问候功能 - * 根据不同的触发类型检查并发送问候消息 - */ - public function handleAutoGreetings() - { - try { - // 获取所有启用的问候规则 - $rules = Db::name('kf_auto_greetings') - ->where(['status' => 1, 'isDel' => 0]) - ->order('level asc, id asc') - ->select(); - - if (empty($rules)) { - return; - } - - foreach ($rules as $rule) { - $trigger = $rule['trigger']; - $condition = json_decode($rule['condition'], true); - - switch ($trigger) { - case 1: // 新好友 - $this->handleNewFriendGreeting($rule); - break; - case 2: // 首次发消息 - $this->handleFirstMessageGreeting($rule); - break; - case 3: // 时间触发 - $this->handleTimeTriggerGreeting($rule, $condition); - break; - case 4: // 关键词触发 - $this->handleKeywordTriggerGreeting($rule, $condition); - break; - case 5: // 生日触发 - $this->handleBirthdayTriggerGreeting($rule, $condition); - break; - case 6: // 自定义 - $this->handleCustomTriggerGreeting($rule, $condition); - break; - } - } - } catch (\Exception $e) { - Log::error('自动问候处理失败:' . $e->getMessage()); - } - } - - /** - * 处理新好友触发 - */ - private function handleNewFriendGreeting($rule) - { - // 获取最近24小时内添加的好友(避免重复处理) - $last24h = time() - 24 * 3600; - - // 查询该用户/公司最近24小时内新添加的好友 - // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId - $friends = Db::table('s2_wechat_friend') - ->alias('wf') - ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') - ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') - ->where([ - ['wf.isPassed', '=', 1], - ['wf.isDeleted', '=', 0], - ['wf.passTime', '>=', $last24h], - ['ca.departmentId', '=', $rule['companyId']], - ]) - ->field('wf.id, wf.wechatAccountId') - ->select(); - - foreach ($friends as $friend) { - // 检查是否已经发送过问候 - $exists = Db::name('kf_auto_greetings_record') - ->where([ - 'autoId' => $rule['id'], - 'friendIdOrGroupId' => $friend['id'], - 'wechatAccountId' => $friend['wechatAccountId'], - ]) - ->find(); - - if (!$exists) { - $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); - } - } - } - - /** - * 处理首次发消息触发 - */ - private function handleFirstMessageGreeting($rule) - { - // 获取最近1小时内收到的消息 - $last1h = time() - 3600; - - // 查询消息表,找出首次发消息的好友 - // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId - $messages = Db::table('s2_wechat_message') - ->alias('wm') - ->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id') - ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') - ->where([ - ['wm.isSend', '=', 0], // 接收的消息 - ['wm.wechatChatroomId', '=', 0], // 个人消息 - ['wm.createTime', '>=', $last1h], - ['ca.departmentId', '=', $rule['companyId']], - ]) - ->group('wm.wechatFriendId, wm.wechatAccountId') - ->field('wm.wechatFriendId, wm.wechatAccountId, MIN(wm.createTime) as firstMsgTime') - ->select(); - - foreach ($messages as $msg) { - // 检查该好友是否之前发送过消息 - $previousMsg = Db::table('s2_wechat_message') - ->where([ - 'wechatFriendId' => $msg['wechatFriendId'], - 'wechatAccountId' => $msg['wechatAccountId'], - 'isSend' => 0, - ]) - ->where('createTime', '<', $msg['firstMsgTime']) - ->find(); - - // 如果是首次发消息,且没有发送过问候 - if (!$previousMsg) { - $exists = Db::name('kf_auto_greetings_record') - ->where([ - 'autoId' => $rule['id'], - 'friendIdOrGroupId' => $msg['wechatFriendId'], - 'wechatAccountId' => $msg['wechatAccountId'], - ]) - ->find(); - - if (!$exists) { - $this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0); - } - } - } - } - - /** - * 处理时间触发 - */ - private function handleTimeTriggerGreeting($rule, $condition) - { - if (empty($condition) || !isset($condition['type'])) { - return; - } - - $now = time(); - $currentTime = date('H:i', $now); - $currentDate = date('m-d', $now); - $currentDateTime = date('m-d H:i', $now); - $currentWeekday = date('w', $now); // 0=周日, 1=周一, ..., 6=周六 - - $shouldTrigger = false; - - switch ($condition['type']) { - case 'daily_time': // 每天固定时间 - if ($currentTime === $condition['value']) { - $shouldTrigger = true; - } - break; - - case 'yearly_datetime': // 每年固定日期时间 - if ($currentDateTime === $condition['value']) { - $shouldTrigger = true; - } - break; - - case 'fixed_range': // 固定时间段 - if (is_array($condition['value']) && count($condition['value']) === 2) { - $startTime = strtotime('2000-01-01 ' . $condition['value'][0]); - $endTime = strtotime('2000-01-01 ' . $condition['value'][1]); - $currentTimeStamp = strtotime('2000-01-01 ' . $currentTime); - - if ($currentTimeStamp >= $startTime && $currentTimeStamp <= $endTime) { - $shouldTrigger = true; - } - } - break; - - case 'workday': // 工作日 - // 周一到周五(1-5) - if ($currentWeekday >= 1 && $currentWeekday <= 5 && $currentTime === $condition['value']) { - $shouldTrigger = true; - } - break; - } - - if ($shouldTrigger) { - // 获取该用户/公司的所有好友 - // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId - $friends = Db::table('s2_wechat_friend') - ->alias('wf') - ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') - ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') - ->where([ - ['wf.isPassed', '=', 1], - ['wf.isDeleted', '=', 0], - ['ca.departmentId', '=', $rule['companyId']], - ]) - ->field('wf.id, wf.wechatAccountId') - ->select(); - - foreach ($friends as $friend) { - // 检查今天是否已经发送过 - $todayStart = strtotime(date('Y-m-d 00:00:00')); - $exists = Db::name('kf_auto_greetings_record') - ->where([ - 'autoId' => $rule['id'], - 'friendIdOrGroupId' => $friend['id'], - 'wechatAccountId' => $friend['wechatAccountId'], - ]) - ->where('createTime', '>=', $todayStart) - ->find(); - - if (!$exists) { - $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); - } - } - } - } - - /** - * 处理关键词触发 - */ - private function handleKeywordTriggerGreeting($rule, $condition) - { - if (empty($condition) || empty($condition['keywords'])) { - return; - } - - $keywords = $condition['keywords']; - $matchType = $condition['match_type'] ?? 'fuzzy'; - - // 获取最近1小时内收到的消息 - $last1h = time() - 3600; - - // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId - $messages = Db::table('s2_wechat_message') - ->alias('wm') - ->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id') - ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') - ->where([ - ['wm.isSend', '=', 0], // 接收的消息 - ['wm.wechatChatroomId', '=', 0], // 个人消息 - ['wm.msgType', '=', 1], // 文本消息 - ['wm.createTime', '>=', $last1h], - ['ca.departmentId', '=', $rule['companyId']], - ]) - ->field('wm.*') - ->select(); - - foreach ($messages as $msg) { - $content = $msg['content'] ?? ''; - - // 检查关键词匹配 - $matched = false; - foreach ($keywords as $keyword) { - if ($matchType === 'exact') { - // 精准匹配 - if ($content === $keyword) { - $matched = true; - break; - } - } else { - // 模糊匹配 - if (strpos($content, $keyword) !== false) { - $matched = true; - break; - } - } - } - - if ($matched) { - // 检查是否已经发送过问候(同一好友同一规则,1小时内只发送一次) - $last1h = time() - 3600; - $exists = Db::name('kf_auto_greetings_record') - ->where([ - 'autoId' => $rule['id'], - 'friendIdOrGroupId' => $msg['wechatFriendId'], - 'wechatAccountId' => $msg['wechatAccountId'], - ]) - ->where('createTime', '>=', $last1h) - ->find(); - - if (!$exists) { - $this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0); - } - } - } - } - - /** - * 处理生日触发 - */ - private function handleBirthdayTriggerGreeting($rule, $condition) - { - if (empty($condition)) { - return; - } - - // 解析condition格式 - // 支持格式: - // 1. {'month': 10, 'day': 10} - 当天任何时间都可以触发 - // 2. {'month': 10, 'day': 10, 'time': '09:00'} - 当天指定时间触发 - // 3. {'month': 10, 'day': 10, 'time_range': ['09:00', '10:00']} - 当天时间范围内触发 - // 兼容旧格式:['10-10'] 或 '10-10'(仅支持 MM-DD 格式,不包含年份) - - $birthdayMonth = null; - $birthdayDay = null; - $birthdayTime = null; - $timeRange = null; - - if (is_array($condition)) { - // 新格式:对象格式 {'month': 10, 'day': 10} - if (isset($condition['month']) && isset($condition['day'])) { - $birthdayMonth = (int)$condition['month']; - $birthdayDay = (int)$condition['day']; - $birthdayTime = $condition['time'] ?? null; - $timeRange = $condition['time_range'] ?? null; - } - // 兼容旧格式:['10-10'] 或 ['10-10 09:00'](仅支持 MM-DD 格式) - elseif (isset($condition[0])) { - $dateStr = $condition[0]; - // 只接受月日格式:'10-10' 或 '10-10 09:00' - if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $dateStr, $matches)) { - $birthdayMonth = (int)$matches[1]; - $birthdayDay = (int)$matches[2]; - if (isset($matches[3])) { - $birthdayTime = $matches[3]; - } - } - } - } elseif (is_string($condition)) { - // 字符串格式:只接受 '10-10' 或 '10-10 09:00'(MM-DD 格式,不包含年份) - if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $condition, $matches)) { - $birthdayMonth = (int)$matches[1]; - $birthdayDay = (int)$matches[2]; - if (isset($matches[3])) { - $birthdayTime = $matches[3]; - } - } - } - - if ($birthdayMonth === null || $birthdayDay === null || $birthdayMonth < 1 || $birthdayMonth > 12 || $birthdayDay < 1 || $birthdayDay > 31) { - return; - } - - $todayMonth = (int)date('m'); - $todayDay = (int)date('d'); - - // 检查今天是否是生日(只匹配月日,不匹配年份) - if ($todayMonth !== $birthdayMonth || $todayDay !== $birthdayDay) { - return; - } - - // 如果配置了时间,检查当前时间是否匹配 - $now = time(); - $currentTime = date('H:i', $now); - - if ($birthdayTime !== null) { - // 指定了具体时间,检查是否在指定时间(允许1分钟误差,避免定时任务执行时间不精确) - $birthdayTimestamp = strtotime('2000-01-01 ' . $birthdayTime); - $currentTimestamp = strtotime('2000-01-01 ' . $currentTime); - $diff = abs($currentTimestamp - $birthdayTimestamp); - - // 如果时间差超过2分钟,不触发(允许1分钟误差) - if ($diff > 120) { - return; - } - } elseif ($timeRange !== null && is_array($timeRange) && count($timeRange) === 2) { - // 指定了时间范围,检查当前时间是否在范围内 - $startTime = strtotime('2000-01-01 ' . $timeRange[0]); - $endTime = strtotime('2000-01-01 ' . $timeRange[1]); - $currentTimestamp = strtotime('2000-01-01 ' . $currentTime); - - if ($currentTimestamp < $startTime || $currentTimestamp > $endTime) { - return; - } - } - // 如果没有配置时间或时间范围,则当天任何时间都可以触发 - - // 获取该用户/公司的所有好友 - // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId - $friends = Db::table('s2_wechat_friend') - ->alias('wf') - ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') - ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') - ->where([ - ['wf.isPassed', '=', 1], - ['wf.isDeleted', '=', 0], - ['ca.departmentId', '=', $rule['companyId']], - ]) - ->field('wf.id, wf.wechatAccountId') - ->select(); - - foreach ($friends as $friend) { - // 检查今天是否已经发送过 - $todayStart = strtotime(date('Y-m-d 00:00:00')); - $exists = Db::name('kf_auto_greetings_record') - ->where([ - 'autoId' => $rule['id'], - 'friendIdOrGroupId' => $friend['id'], - 'wechatAccountId' => $friend['wechatAccountId'], - ]) - ->where('createTime', '>=', $todayStart) - ->find(); - - if (!$exists) { - $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); - } - } - } - - /** - * 处理自定义触发 - */ - private function handleCustomTriggerGreeting($rule, $condition) - { - // 自定义类型需要根据具体业务需求实现 - // 这里提供一个基础框架,可根据实际需求扩展 - // 暂时不实现,留待后续扩展 - } - - /** - * 发送问候消息 - * @param array $rule 问候规则 - * @param int $wechatAccountId 微信账号ID - * @param int $friendId 好友ID - * @param int $groupId 群ID(0表示个人消息) - */ - private function sendGreetingMessage($rule, $wechatAccountId, $friendId, $groupId = 0) - { - try { - $content = $rule['content']; - - // 创建记录 - $recordId = Db::name('kf_auto_greetings_record')->insertGetId([ - 'autoId' => $rule['id'], - 'userId' => $rule['userId'], - 'companyId' => $rule['companyId'], - 'wechatAccountId' => $wechatAccountId, - 'friendIdOrGroupId' => $friendId, - 'isSend' => 0, - 'sendTime' => 0, - 'receiveTime' => 0, - 'createTime' => time(), - ]); - - // 发送消息(文本消息) - $username = Env::get('api.username', ''); - $password = Env::get('api.password', ''); - $toAccountId = ''; - if (!empty($username) || !empty($password)) { - $toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId'); - } - - $wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); - - $sendTime = time(); - $result = $wsController->sendPersonal([ - 'wechatFriendId' => $friendId, - 'wechatAccountId' => $wechatAccountId, - 'msgType' => 1, // 文本消息 - 'content' => $content, - ]); - - $isSend = 0; - $receiveTime = 0; - - // 解析返回结果 - $resultData = json_decode($result, true); - if (!empty($resultData) && $resultData['code'] == 200) { - $isSend = 1; - $receiveTime = time(); // 简化处理,实际应该从返回结果中获取 - } - - // 更新记录 - Db::name('kf_auto_greetings_record') - ->where('id', $recordId) - ->update([ - 'isSend' => $isSend, - 'sendTime' => $sendTime, - 'receiveTime' => $receiveTime, - ]); - - // 更新规则使用次数 - Db::name('kf_auto_greetings') - ->where('id', $rule['id']) - ->setInc('usageCount'); - - } catch (\Exception $e) { - Log::error('发送问候消息失败:' . $e->getMessage() . ',规则ID:' . $rule['id']); - } - } - -} +config = $config ?: Config::get('wechat_device_api.'); + $this->config = $config ?: Config::get('wechat_device_api.adapters.ChuKeBao'); + // $this->config = $config; + // $this->apiClient = new ChuKeBaoApiClient($config['api_key'], $config['api_secret'], $config['base_url']); + // 校验配置等... + if (empty($this->config['base_url']) || empty($this->config['username']) || empty($this->config['password'])) { + throw new \InvalidArgumentException("ChuKeBao username and password are required."); + } + } + + public function addFriend(string $deviceId, string $targetWxId): bool + { + // 1. 构建请求参数 (ChuKeBao 特定的格式) + $params = [ + 'device_identifier' => $deviceId, + 'wechat_user_to_add' => $targetWxId, + 'username' => $this->config['username'], + 'password' => $this->config['password'], + // ... 其他 ChuKeBao 特定参数 + ]; + + // 2. 调用 ChuKeBao 的 API (例如使用 GuzzleHttp 或 cURL) + // $response = $this->apiClient->post('/friend/add', $params); + // 伪代码: + $url = $this->config['base_url'] . '/friend/add'; + // $httpClient = new \GuzzleHttp\Client(); + // $response = $httpClient->request('POST', $url, ['form_params' => $params]); + // $responseData = json_decode($response->getBody()->getContents(), true); + + // 模拟API调用 + echo "ChuKeBao: Adding friend {$targetWxId} using device {$deviceId}\n"; + $responseData = ['code' => 0, 'message' => 'Success']; // 假设的响应 + + // 3. 处理响应,转换为标准结果 + if (!isset($responseData['code'])) { + throw new ApiException("ChuKeBao: Invalid API response for addFriend."); + } + + if ($responseData['code'] !== 0) { + throw new ApiException("ChuKeBao: Failed to add friend - " . ($responseData['message'] ?? 'Unknown error')); + } + + return true; + } + + public function likeMoment(string $deviceId, string $momentId): bool + { + echo "ChuKeBao: Liking moment {$momentId} using device {$deviceId}\n"; + // 实现 VendorA 的点赞逻辑 + return true; + } + + public function getGroupList(string $deviceId): array + { + echo "ChuKeBao: Getting group list for device {$deviceId}\n"; + // 实现 VendorA 的获取群列表逻辑,并转换数据格式 + return [ + ['id' => 'group1_va', 'name' => 'ChuKeBao Group 1', 'member_count' => 10], + ]; + } + + public function getFriendList(string $deviceId): array + { + echo "VendorA: Getting friend list for device {$deviceId}\n"; + return [ + ['id' => 'friend1_va', 'nickname' => 'ChuKeBao Friend 1', 'remark' => 'VA-F1'], + ]; + } + + public function getDeviceInfo(string $deviceId): array + { + echo "ChuKeBao: Getting device info for device {$deviceId}\n"; + return ['id' => $deviceId, 'status' => 'online_va', 'battery' => '80%']; + } + + public function bindDeviceToCompany(string $deviceId, string $companyId): bool + { + echo "ChuKeBao: Binding device {$deviceId} to company {$companyId}\n"; + return true; + } + + /** + * 获取群成员列表 + * @param string $deviceId 设备ID + * @param string $chatroomId 群ID + * @return array 群成员列表 + */ + public function getChatroomMemberList(string $deviceId, string $chatroomId): array + { + echo "ChuKeBao: Getting chatroom member list for device {$deviceId}, chatroom {$chatroomId}\n"; + return [ + ['id' => 'member1_va', 'nickname' => 'VendorA Member 1', 'avatar' => ''], + ]; + } + + /** + * 获取指定微信的朋友圈内容/列表 + * @param string $deviceId 设备ID + * @param string $wxId 微信ID + * @return array 朋友圈列表 + */ + public function getMomentList(string $deviceId, string $wxId): array + { + echo "VendorA: Getting moment list for device {$deviceId}, wxId {$wxId}\n"; + return [ + ['id' => 'moment1_va', 'content' => 'VendorA Moment 1', 'created_at' => time()], + ]; + } + + + /** + * 发送微信朋友圈 + * @param string $deviceId 设备ID + * @param string $wxId 微信ID + * @param string $moment 朋友圈内容 + * @return bool 是否成功 + */ + public function sendMoment(string $deviceId, string $wxId, string $moment): bool + { + echo "VendorA: Sending moment for device {$deviceId}, wxId {$wxId}, content: {$moment}\n"; + return true; + } + + public function handleCustomerTaskWithStatusIsNew(int $current_worker_id, int $process_count_for_status_0) + { + $task = Db::name('customer_acquisition_task') + ->where(['status' => 1, 'deleteTime' => 0]) + /* ->whereRaw("id % $process_count_for_status_0 = {$current_worker_id}")*/ + ->order('id desc') + ->select(); + + if (empty($task)) { + return false; + } + + $taskData = []; + foreach ($task as $item) { + $reqConf = json_decode($item['reqConf'], true); + $device = $reqConf['device'] ?? []; + $deviceCount = count($device); + if ($deviceCount <= 0) { + continue; + } + $tasks = Db::name('task_customer') + ->where(['status' => 0, 'task_id' => $item['id']]) + ->order('id DESC') + ->limit($deviceCount) + ->select(); + $taskData = array_merge($taskData, $tasks); + } + if ($taskData) { + + foreach ($taskData as $task) { + $task_id = $task['task_id']; + $task_info = $this->getCustomerAcquisitionTask($task_id); + if (empty($task_info['status']) || empty($task_info['reqConf']) || empty($task_info['reqConf']['device'])) { + continue; + } + //筛选出设备在线微信在线 + $wechatIdAccountIdMap = $this->getWeChatIdsAccountIdsMapByDeviceIds($task_info['reqConf']['device']); + if (empty($wechatIdAccountIdMap)) { + continue; + } + + $friendAddTaskCreated = false; + foreach ($wechatIdAccountIdMap as $accountId => $wechatId) { + // 是否已经是好友的判断,如果已经是好友,直接break; 但状态还是维持1,让另外一个进程处理发消息的逻辑 + $wechatTags = json_decode($task['tags'], true); + $isFriend = $this->checkIfIsWeChatFriendByPhone($wechatId, $task['phone'], $task['siteTags']); + if (!empty($isFriend)) { + $friendAddTaskCreated = true; + $task['processed_wechat_ids'] = $task['processed_wechat_ids'] . ',' . $wechatId; // 处理失败任务用,用于过滤已处理的微信号 + break; + } + + // 判断时间间隔\时间段和最后一次的状态 + $canCreateFriendAddTask = $this->checkIfCanCreateFriendAddTask($wechatId, $task_info['reqConf']); + if (empty($canCreateFriendAddTask)) { + continue; + } + + // 根据健康分判断24h内加的好友数量限制 + $healthScoreService = new WechatAccountHealthScoreService(); + $healthScoreInfo = $healthScoreService->getHealthScore($accountId); + + // 如果健康分记录不存在,先计算一次 + if (empty($healthScoreInfo)) { + try { + $healthScoreService->calculateAndUpdate($accountId); + $healthScoreInfo = $healthScoreService->getHealthScore($accountId); + } catch (\Exception $e) { + Log::error("计算健康分失败 (accountId: {$accountId}): " . $e->getMessage()); + // 如果计算失败,使用默认值5作为兜底 + $maxAddFriendPerDay = 5; + } + } + + // 获取每日最大加人次数(基于健康分) + $maxAddFriendPerDay = $healthScoreInfo['maxAddFriendPerDay'] ?? 5; + + // 如果健康分为0或很低,不允许添加好友 + if ($maxAddFriendPerDay <= 0) { + Log::info("账号健康分过低,不允许添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, healthScore: " . ($healthScoreInfo['healthScore'] ?? 0) . ")"); + continue; + } + + // 检查频繁暂停限制:首次频繁或再次频繁,暂停24小时 + $lastFrequentTime = $healthScoreInfo['lastFrequentTime'] ?? null; + $frequentCount = $healthScoreInfo['frequentCount'] ?? 0; + if (!empty($lastFrequentTime) && $frequentCount > 0) { + $frequentPauseHours = 24; // 频繁暂停24小时 + $frequentPauseTime = $lastFrequentTime + ($frequentPauseHours * 3600); + $currentTime = time(); + + if ($currentTime < $frequentPauseTime) { + $remainingHours = ceil(($frequentPauseTime - $currentTime) / 3600); + Log::info("账号频繁,暂停添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, frequentCount: {$frequentCount}, 剩余暂停时间: {$remainingHours}小时)"); + continue; + } + } + + // 检查封号暂停限制:封号暂停72小时 + $isBanned = $healthScoreInfo['isBanned'] ?? 0; + if ($isBanned == 1) { + // 查询封号时间(从s2_wechat_message表查询最近一次封号消息) + $banMessage = Db::table('s2_wechat_message') + ->where('wechatAccountId', $accountId) + ->where('msgType', 10000) + ->where('content', 'like', '%你的账号被限制%') + ->where('isDeleted', 0) + ->order('createTime', 'desc') + ->find(); + + if (!empty($banMessage)) { + $banTime = $banMessage['createTime'] ?? 0; + $banPauseHours = 72; // 封号暂停72小时 + $banPauseTime = $banTime + ($banPauseHours * 3600); + $currentTime = time(); + + if ($currentTime < $banPauseTime) { + $remainingHours = ceil(($banPauseTime - $currentTime) / 3600); + Log::info("账号封号,暂停添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, 剩余暂停时间: {$remainingHours}小时)"); + continue; + } + } + } + + // 判断今天添加的好友数量,使用健康分计算的每日最大加人次数 + // 优先使用今天添加的好友数量(更符合"每日"限制) + $todayAddedFriendsCount = $this->getTodayAddedFriendsCount($wechatId); + if ($todayAddedFriendsCount >= $maxAddFriendPerDay) { + Log::info("今天添加好友数量已达上限 (accountId: {$accountId}, wechatId: {$wechatId}, count: {$todayAddedFriendsCount}, max: {$maxAddFriendPerDay}, healthScore: " . ($healthScoreInfo['healthScore'] ?? 0) . ")"); + continue; + } + + // 如果今天添加数量未达上限,再检查24小时内的数量(作为额外保护) + $last24hAddedFriendsCount = $this->getLast24hAddedFriendsCount($wechatId); + // 24小时内的限制可以稍微宽松一些,设置为每日限制的1.2倍(防止跨天累积) + $max24hLimit = (int)ceil($maxAddFriendPerDay * 1.2); + if ($last24hAddedFriendsCount >= $max24hLimit) { + Log::info("24小时内添加好友数量已达上限 (accountId: {$accountId}, wechatId: {$wechatId}, count: {$last24hAddedFriendsCount}, max24h: {$max24hLimit}, maxDaily: {$maxAddFriendPerDay})"); + continue; + } + + // 采取乐观尝试的策略,假设第一个可以添加的人可以添加成功的; 回头再另外一个任务进程去判断 + + // 创建好友添加任务, 对接触客宝 + $tags = array_merge($task_info['tagConf']['customTags'], $task_info['tagConf']['scenarioTags']); + if (!empty($wechatTags)) { + $tags = array_merge($tags, $wechatTags); + } + $tags = array_unique($tags); + $tags = array_values($tags); + $conf = array_merge($task_info['reqConf'], ['task_name' => $task_info['name'], 'tags' => $tags]); + + + $this->createFriendAddTask($accountId, $task['phone'], $conf, $task['remark']); + $friendAddTaskCreated = true; + $task['processed_wechat_ids'] = $task['processed_wechat_ids'] . ',' . $wechatId; // 处理失败任务用,用于过滤已处理的微信号 + break; + } + if (!empty($friendAddTaskCreated)){ + Db::name('task_customer') + ->where('id', $task['id']) + ->update([ + 'status' => $friendAddTaskCreated ? 1 : 3, + 'fail_reason' => '', + 'processed_wechat_ids' => $task['processed_wechat_ids'], + 'addTime' => time(), + 'updateTime' => time() + ]); + } + // ~~不用管,回头再添加再判断即可~~ + // 失败一定是另一个进程/定时器在检查的 + + } + } + } + + // 处理添加中的获客任务, only run in workerman process! + public function handleCustomerTaskWithStatusIsCreated() + { + + $tasks = Db::name('task_customer') + ->whereIn('status', [1, 2]) + ->where('updateTime', '>=', (time() - 86400 * 3)) + ->limit(50) + ->order('updateTime DESC') + ->select(); + + if (empty($tasks)) { + return; + } + + foreach ($tasks as $task) { + $task_id = $task['task_id']; + $task_info = $this->getCustomerAcquisitionTask($task_id); + + + if (empty($task_info['status']) || empty($task_info['reqConf']) || empty($task_info['reqConf']['device'])) { + continue; + } + + if (empty($task['processed_wechat_ids'])) { + continue; + } + + $weChatIds = explode(',', $task['processed_wechat_ids']); + $passedWeChatId = ''; + foreach ($weChatIds as $wechatId) { + // 先是否是好友,如果不是好友,先查询执行状态,看是否还能以及需要换账号继续添加,还是直接更新状态为3 + // 如果添加成功,先更新为2,然后去发消息(先判断有无消息设置,发消息的log记录?) + if (!empty($wechatId)) { + $isFriend = $this->checkIfIsWeChatFriendByPhone($wechatId, $task['phone']); + if ($isFriend) { + // 更新状态为5(已通过未发消息) + Db::name('task_customer') + ->where('id', $task['id']) + ->update(['status' => 5,'passTime' => time(), 'updateTime' => time()]); + $passedWeChatId = $wechatId; + break; + } + } + } + + + if ($passedWeChatId) { + // 获取好友记录(用于发消息 & 拉群) + $wechatFriendRecord = $this->getWeChatAccoutIdAndFriendIdByWeChatIdAndFriendPhone($passedWeChatId, $task['phone']); + if ($wechatFriendRecord) { + // 1. 如配置了消息,则先发送消息,并将状态置为4(已通过并已发消息) + if (!empty($task_info['msgConf'])) { + Db::name('task_customer') + ->where('id', $task['id']) + ->update(['status' => 4,'passTime' => time(), 'updateTime' => time()]); + + // 记录添加好友奖励(如果之前没有记录过,status从其他状态变为4时) + if ($task['status'] != 2 && !empty($task['channelId'])) { + try { + DistributionRewardService::recordAddFriendReward( + $task['task_id'], + $task['id'], + $task['phone'], + intval($task['channelId']) + ); + } catch (\Exception $e) { + // 记录错误但不影响主流程 + Log::error('记录添加好友奖励失败:' . $e->getMessage()); + } + } + + $msgConf = is_string($task_info['msgConf']) ? json_decode($task_info['msgConf'], 1) : $task_info['msgConf']; + $this->sendMsgToFriend($wechatFriendRecord['id'], $wechatFriendRecord['wechatAccountId'], $msgConf); + } + + // 2. 好友通过后,如配置了拉群,则建群并拉人:通过的好友 + 固定成员 + $this->createGroupAfterFriendPass($task_info, $wechatFriendRecord['id'], $wechatFriendRecord['wechatAccountId']); + // 如果没有 msgConf,则保持之前更新的状态5(已通过未发消息)不变 + } + + } else { + + foreach ($weChatIds as $wechatId) { + + // 查询执行状态 + $latestFriendTask = $this->getLatestFriendTaskByPhoneAndWeChatId($task['phone'], $wechatId); + if (empty($latestFriendTask)) { + continue; + } + + // 已经执行成功的话,直接break,同时更新对应task_customer的状态为2(添加成功) + if (isset($latestFriendTask['status']) && $latestFriendTask['status'] == 1) { + // 更新状态 + Db::name('task_customer') + ->where('id', $task['id']) + ->update(['status' => 2, 'updateTime' => time()]); + + // 记录添加好友奖励(异步处理,不影响主流程) + if (!empty($task['channelId'])) { + try { + DistributionRewardService::recordAddFriendReward( + $task['task_id'], + $task['id'], + $task['phone'], + intval($task['channelId']) + ); + } catch (\Exception $e) { + // 记录错误但不影响主流程 + Log::error('记录添加好友奖励失败:' . $e->getMessage()); + } + } + + break; + } + + // todo 判断处理执行失败的情况 status=2,根据 extra 的描述去处理;-- 可以先直接更新为失败,然后 extra =》fail_reason -- 因为有专门的任务会处理失败的 + if (isset($latestFriendTask['status']) && $latestFriendTask['status'] == 2) { + Db::name('task_customer') + ->where('id', $task['id']) + ->update(['status' => 3, 'fail_reason' => $latestFriendTask['extra'] ?? '未知原因', 'updateTime' => time()]); + break; + } + } + } + } + } + + + public function handleCustomerTaskNewUser() + { + $task = Db::name('customer_acquisition_task') + ->where(['status' => 1, 'deleteTime' => 0]) + ->whereIn('sceneId', [5, 7]) + ->order('id desc') + ->select(); + + if (empty($task)) { + return false; + } + + foreach ($task as $item) { + $sceneConf = json_decode($item['sceneConf'], true); + //电话 + if ($item['sceneId'] == 5) { + $rows = Db::name('call_recording') + ->where('companyId', $item['companyId']) + ->group('phone') + ->field('id,phone') + ->order('id asc') + ->limit(0, 100) + ->select(); + } + + if ($item['sceneId'] == 7) { + if (!empty($sceneConf['groupSelected']) && is_array($sceneConf['groupSelected'])) { + $rows = Db::name('wechat_group_member')->alias('gm') + ->join('wechat_account wa', 'gm.identifier = wa.wechatId') + ->where('gm.companyId', $item['companyId']) + ->whereIn('gm.groupId', $sceneConf['groupSelected']) + ->group('gm.identifier') + ->column('wa.id,wa.wechatId,wa.alias,wa.phone'); + } + } + + + if (in_array($item['sceneId'], [5, 7]) && !empty($rows) && is_array($rows)) { + // 1000条为一组进行批量处理 + $batchSize = 1000; + $totalRows = count($rows); + + for ($i = 0; $i < $totalRows; $i += $batchSize) { + $batchRows = array_slice($rows, $i, $batchSize); + + if (!empty($batchRows)) { + // 1. 提取当前批次的phone + $phones = []; + foreach ($batchRows as $row) { + if (!empty($row['phone'])) { + $phone = !empty($row['phone']); + } elseif (!empty($row['alias'])) { + $phone = $row['alias']; + } else { + $phone = $row['wechatId']; + } + if (!empty($phone)) { + $phones[] = $phone; + } + } + + // 2. 批量查询已存在的phone + $existingPhones = []; + if (!empty($phones)) { + $existing = Db::name('task_customer') + ->where('task_id', $item['id']) + ->where('phone', 'in', $phones) + ->field('phone') + ->select(); + $existingPhones = array_column($existing, 'phone'); + } + + // 3. 过滤出新数据,批量插入 + $newData = []; + foreach ($batchRows as $row) { + if (!empty($row['phone'])) { + $phone = !empty($row['phone']); + } elseif (!empty($row['alias'])) { + $phone = $row['alias']; + } else { + $phone = $row['wechatId']; + } + if (!empty($phone) && !in_array($phone, $existingPhones)) { + $newData[] = [ + 'task_id' => $item['id'], + 'name' => '', + 'source' => '场景获客_' . $item['name'], + 'phone' => $phone, + 'tags' => json_encode([], JSON_UNESCAPED_UNICODE), + 'siteTags' => json_encode([], JSON_UNESCAPED_UNICODE), + 'createTime' => time(), + ]; + } + } + + // 4. 批量插入新数据 + if (!empty($newData)) { + Db::name('task_customer')->insertAll($newData); + } + } + } + } + + + } + } + + + // 发微信个人消息 + public function sendMsgToFriend(int $friendId, int $wechatAccountId, array $msgConf) + { + // 消息拼接 msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件) + // 当前,type 为文本、图片、动图表情包的时候,content为string, 其他情况为对象 {type: 'file/link/...', url: '', title: '', thunmbPath: '', desc: ''} + // $result = [ + // "content" => $dataArray['content'], + // "msgSubType" => 0, + // "msgType" => $dataArray['msgType'], + // "seq" => time(), + // "wechatAccountId" => $dataArray['wechatAccountId'], + // "wechatChatroomId" => 0, + // "wechatFriendId" => $dataArray['wechatFriendId'], + // ]; + $toAccountId = ''; + $username = Env::get('api.username', ''); + $password = Env::get('api.password', ''); + if (!empty($username) || !empty($password)) { + $toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId'); + } + + // 建立WebSocket + $wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); + + + $gap = 0; + foreach ($msgConf as $messages) { + foreach ($messages['messages'] as $content) { + + $msgType = 0; + $detail = ''; + switch ($content['type']) { + case 'text': + $msgType = 1; + $detail = $content['content']; + break; + case 'image': + $msgType = 3; + $detail = $content['content']; + break; + case 'video': + $msgType = 43; + $detail = $content['content']; + break; + + case 'file': + $msgType = 49; + + $detail = [ + 'type' => 'file', + 'title' => $content['content'][0]['name'], + 'url' => $content['content'][0]['url'], + ]; + $detail = json_encode($detail); + break; + + case 'miniprogram': + $msgType = 49; + $detail = ''; + break; + + case 'link': + $msgType = 49; + $detail = [ + 'type' => 'link', + 'title' => $content['title'], + 'url' => $content['linkUrl'], + 'thumbPath' => $content['cover'], + 'desc' => $content['description'], + ]; + $detail = json_encode($detail); + break; + + case 'group': + $msgType = 49; + $detail = ''; + break; + default : + $msgType = 47; + $detail = $content['content']; + break; + } + + + if (empty($detail)) { + continue; + } + + if ($gap) { + Timer::add($gap, function () use ($wsController, $friendId, $wechatAccountId, $msgType, $content, $detail) { + $wsController->sendPersonal([ + 'wechatFriendId' => $friendId, + 'wechatAccountId' => $wechatAccountId, + 'msgType' => $msgType, + 'content' => $detail, + ]); + }, [], false); + } else { + $wsController->sendPersonal([ + 'wechatFriendId' => $friendId, + 'wechatAccountId' => $wechatAccountId, + 'msgType' => $msgType, + 'content' => $detail, + ]); + } + + !empty($content['sendInterval']) && $gap += $content['sendInterval']; + } + } + + } + + // getCustomerAcquisitionTask + public function getCustomerAcquisitionTask($id) + { + // 先读取缓存 + $task_info = Db::name('customer_acquisition_task') + ->where('id', $id) + ->find(); + if ($task_info) { + $task_info['sceneConf'] = json_decode($task_info['sceneConf'], true); + $task_info['reqConf'] = json_decode($task_info['reqConf'], true); + $task_info['msgConf'] = json_decode($task_info['msgConf'], true); + $task_info['tagConf'] = json_decode($task_info['tagConf'], true); + // 处理拉群固定成员配置(JSON 字段) + if (!empty($task_info['groupFixedMembers'])) { + $fixedMembers = json_decode($task_info['groupFixedMembers'], true); + $task_info['groupFixedMembers'] = is_array($fixedMembers) ? $fixedMembers : []; + } else { + $task_info['groupFixedMembers'] = []; + } + } + return $task_info; + } + + /** + * 好友通过后,根据任务配置建群并拉人(通过好友 + 固定成员) + * @param array $taskInfo customer_acquisition_task 记录(含 groupInviteEnabled/groupName/groupFixedMembers) + * @param int $passedFriendId 通过的好友ID(s2_wechat_friend.id) + * @param int $wechatAccountId 微信账号ID(建群账号) + */ + protected function createGroupAfterFriendPass(array $taskInfo, int $passedFriendId, int $wechatAccountId): void + { + // 1. 校验拉群开关与基础配置 + if (empty($taskInfo['groupInviteEnabled'])) { + return; + } + + $groupName = $taskInfo['groupName'] ?? ''; + if ($groupName === '') { + return; + } + + $fixedMembers = $taskInfo['groupFixedMembers'] ?? []; + if (!is_array($fixedMembers)) { + $fixedMembers = []; + } + + // 2. 过滤出有效的固定成员好友ID(数字ID) + $fixedFriendIds = []; + foreach ($fixedMembers as $member) { + if (is_numeric($member)) { + $fixedFriendIds[] = intval($member); + } + } + + // 包含通过的好友 + $friendIds = array_unique(array_merge([$passedFriendId], $fixedFriendIds)); + if (empty($friendIds)) { + return; + } + + try { + // 3. 初始化 WebSocket(参考 sendMsgToFriend / Workbench 群创建逻辑) + $toAccountId = ''; + $username = Env::get('api.username', ''); + $password = Env::get('api.password', ''); + if (!empty($username) || !empty($password)) { + $toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId'); + } + if (empty($toAccountId)) { + Log::warning('createGroupAfterFriendPass: toAccountId 为空,跳过建群'); + return; + } + + $webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); + + // 4. 调用建群接口:群名 = 配置的 groupName,成员 = 通过好友 + 固定好友 + $createResult = $webSocket->CmdChatroomCreate([ + 'chatroomName' => $groupName, + 'wechatFriendIds' => $friendIds, + 'wechatAccountId' => $wechatAccountId, + ]); + + $createResultData = json_decode($createResult, true); + if (empty($createResultData) || !isset($createResultData['code']) || $createResultData['code'] != 200) { + Log::warning('createGroupAfterFriendPass: 建群失败', [ + 'taskId' => $taskInfo['id'] ?? 0, + 'wechatAccountId' => $wechatAccountId, + 'friendIds' => $friendIds, + 'result' => $createResult, + ]); + } + } catch (\Exception $e) { + Log::error('createGroupAfterFriendPass 异常: ' . $e->getMessage()); + } + } + + // 检查是否是好友关系 + + public function checkIfIsWeChatFriendByPhone($wxId = '', $phone = '', $siteTags = '') + { + if (empty($wxId) || empty($phone)) { + return false; + } + + try { + $friend = Db::table('s2_wechat_friend') + ->where('ownerWechatId', $wxId) + ->where(['isPassed' => 1, 'isDeleted' => 0]) + ->where('phone|alias|wechatId', 'like', $phone . '%') + ->order('createTime', 'desc') + ->find(); + if (!empty($friend)) { + if (!empty($siteTags)) { + $siteTags = json_decode($siteTags, true); + $siteLabels = json_decode($friend['siteLabels'], true); + $tags = array_merge($siteTags, $siteLabels); + $tags = array_unique($tags); + $tags = array_values($tags); + if (empty($tags)) { + $tags = []; + } + $tags = json_encode($tags, 256); + Db::table('s2_wechat_friend')->where(['id' => $friend['id']])->update(['siteLabels' => $tags, 'updateTime' => time()]); + } + return true; + } else { + return false; + } + } catch (\Exception $e) { + Log::error("Error in checkIfIsWeChatFriendByPhone (wxId: {$wxId}, phone: {$phone}): " . $e->getMessage()); + return false; + } + } + + // getWeChatAccoutIdAndFriendIdByWeChatId + public function getWeChatAccoutIdAndFriendIdByWeChatIdAndFriendPhone(string $wechatId, string $phone): array + { + if (empty($wechatId) || empty($phone)) { + return []; + } + + return Db::table('s2_wechat_friend') + ->where('ownerWechatId', $wechatId) + ->where('phone|alias|wechatId', 'like', $phone . '%') + ->field('id,wechatAccountId,passTime,createTime') + ->find(); + } + + // 判断是否已添加某手机号为好友并返回添加时间 + public function getWeChatFriendPassTimeByPhone(string $wxId, string $phone): int + { + if (empty($wxId) || empty($phone)) { + return 0; + } + + try { + $record = Db::table('s2_wechat_friend') + ->where('ownerWechatId', $wxId) + ->where('phone|alias|wechatId', 'like', $phone . '%') + ->field('id,createTime,passTime') + ->find(); + + return $record['passTime'] ?? $record['createTime'] ?? 0; + } catch (\Exception $e) { + Log::error("Error in getWeChatFriendPassTimeByPhone (wxId: {$wxId}, phone: {$phone}): " . $e->getMessage()); + return 0; + } + } + + /** + * 查询某个微信今天添加了多少个好友 + * @param string $wechatId 微信ID + * @return int 好友数量 + */ + public function getTodayAddedFriendsCount(string $wechatId): int + { + if (empty($wechatId)) { + return 0; + } + try { + $count = Db::table('s2_friend_task') + ->where('wechatId', $wechatId) + ->whereRaw("FROM_UNIXTIME(createTime, '%Y-%m-%d') = CURDATE()") + ->count(); + return (int)$count; + } catch (\Exception $e) { + Log::error("Error in getTodayAddedFriendsCount (wechatId: {$wechatId}): " . $e->getMessage()); + return 0; + } + } + + /** + * 查询某个微信24小时内添加了多少个好友 + * @param string $wechatId 微信ID + * @return int 好友数量 + */ + public function getLast24hAddedFriendsCount(string $wechatId): int + { + if (empty($wechatId)) { + return 0; + } + try { + $twentyFourHoursAgo = time() - (24 * 60 * 60); + $count = Db::table('s2_friend_task') + ->where('wechatId', $wechatId) + ->where('createTime', '>=', $twentyFourHoursAgo) + ->count(); + return (int)$count; + } catch (\Exception $e) { + Log::error("Error in getLast24hAddedFriendsCount (wechatId: {$wechatId}): " . $e->getMessage()); + return 0; + } + } + + /** + * 查询某个微信最新的一条添加好友任务记录 + * @param string $wechatId 微信ID + * @return array|null 任务记录或null + */ + public function getLatestFriendTask(string $wechatId): ?array + { + if (empty($wechatId)) { + return null; + } + try { + $task = Db::table('s2_friend_task') + ->where('wechatId', $wechatId) + ->order('createTime', 'desc') + ->find(); + return $task; + } catch (\Exception $e) { + Log::error("Error in getLatestFriendTask (wechatId: {$wechatId}): " . $e->getMessage()); + return null; + } + } + + // 获取某微信最后一条添加好友任务 + public function getLatestFriendTaskByPhoneAndWeChatId(string $phone, string $wechatId): array + { + if (empty($phone) || empty($wechatId)) { + return []; + } + + $record = Db::table('s2_friend_task') + ->where('phone', $phone) + ->where('wechatId', $wechatId) + ->order('createTime', 'desc') + ->find(); + return $record ?: []; + } + + // 获取最新的一条添加好友任务记录的创建时间 + public function getLastCreateFriendTaskTime(string $wechatId): int + { + if (empty($wechatId)) { + return 0; + } + $record = Db::table('s2_friend_task') + ->where('wechatId', $wechatId) + ->order('createTime', 'desc') + ->find(); + return $record['createTime'] ?? 0; + } + + // 判断是否能够加好友 + public function checkIfCanCreateFriendAddTask(string $wechatId, $conf = []): bool + { + if (empty($wechatId)) { + return false; + } + //强制请求添加好友的列表 + $friendController = new FriendTaskController(); + $friendController->getlist(0, 50); + + + $record = $this->getLatestFriendTask($wechatId); + if (empty($record)) { + return true; + } + + if (!empty($conf['addFriendInterval']) && isset($record['createTime']) && $record['createTime'] > time() - $conf['addFriendInterval'] * 60) { + return false; + } + + if (!empty($conf['startTime']) && !empty($conf['endTime'])) { + $currentTime = date('H:i'); + $startTime = $conf['startTime']; + $endTime = $conf['endTime']; + + if ($currentTime >= $startTime && $currentTime <= $endTime) { + return true; + } else { + return false; + } + } + + if (isset($record['status'])) { + + if ($record['status'] == 2) { + + // 判断$record['extra'] 是否包含文字: 操作过于频繁;如果包含判断 updateTime 是否已经超过72min,updateTime是10位时间戳;如果包含指定文字且时间未超过72min,return false + if (isset($record['extra']) && strpos($record['extra'], '操作过于频繁') !== false) { + $updateTime = isset($record['updateTime']) ? (int)$record['updateTime'] : 0; + $now = time(); + $diff = $now - $updateTime; + + if ($diff < 24 * 60 * 60) { + return false; + } + } + } + } + + return true; + } + + // 获取触客宝系统的客服微信账号id,用于后续微信相关操作 + public function getWeChatAccountIdByWechatId(string $wechatId): string + { + if (empty($wechatId)) { + return ''; + } + $record = Db::table('s2_wechat_account') + ->where('wechatId', $wechatId) + ->field('id') + ->find(); + return $record['id'] ?? ''; + } + + // 获取在线的客服微信账号id列表 + public function getOnlineWeChatAccountIdsByWechatIds(array $wechatIds): array + { + if (empty($wechatIds)) { + return []; + } + $records = Db::table('s2_wechat_account') + ->where('deviceAlive', 1) + ->where('wechatAlive', 1) + ->where('wechatId', 'in', $wechatIds) + ->field('id,wechatId') + ->column('id', 'wechatId'); + + return $records; + } + + public function getWeChatIdsAccountIdsMapByDeviceIds(array $deviceIds): array + { + if (empty($deviceIds)) { + return []; + } + + $records = Db::table('s2_wechat_account') + ->where('deviceAlive', 1) + ->where('wechatAlive', 1) + ->where('currentDeviceId', 'in', $deviceIds) + ->field('id,wechatId') + ->column('id,wechatId'); + return $records; + } + + // 触客宝添加好友API + public function addFriendTaskApi(int $wechatAccountId, string $phone, string $message, string $remark, array $labels, $authorization = '') + { + + $authorization = $authorization ?: AuthService::getSystemAuthorization(); + + if (empty($authorization)) { + return [ + 'status_code' => 0, + 'body' => null, + 'error' => true, + ]; + } + + $params = [ + 'phone' => $phone, + 'message' => $message, + 'remark' => $remark, + 'labels' => $labels, + 'wechatAccountId' => $wechatAccountId + ]; + + //准备发起添加请求 + $friendController = new FriendTaskController(); + $result = $friendController->addFriendTask($params); + $result = json_decode($result, true); + if ($result['code'] == 200) { + return $result; + } else { + $authorization = AuthService::getSystemAuthorization(false); + return $this->addFriendTaskApi($wechatAccountId, $phone, $message, $remark, $labels, $authorization); + } + + + } + + // 创建添加好友任务/执行添加 + public function createFriendAddTask(int $wechatAccountId, string $phone, array $conf, $remark = '') + { + if (empty($wechatAccountId) || empty($phone) || empty($conf)) { + return; + } + + if (empty($remark)){ + switch ($conf['remarkType']) { + case 'phone': + $remark = $phone . '-' . $conf['task_name']; + break; + case 'nickname': + $remark = ''; + break; + case 'source': + $remark = $conf['task_name']; + break; + default: + $remark = ''; + break; + } + } + + $tags = []; + if (!empty($conf['tags'])) { + if (is_array($conf['tags'])) { + $tags = $conf['tags']; + } + } + $res = $this->addFriendTaskApi($wechatAccountId, $phone, $conf['greeting'] ?? '你好', $remark, $tags); + } + + /* TODO: 以上方法待实现,基于/参考 application/api/controller/WebSocketController.php 去实现;以下同步脚本用的方法转移到其他类 */ + + + // NOTE: run in background; 5min 同步一次 + public function syncFriendship() + { + $sql = "INSERT INTO ck_wechat_friendship(id,wechatId,tags,memo,ownerWechatId,createTime,updateTime,deleteTime,companyId) + SELECT + f.id,f.wechatId,f.labels as tags,f.conRemark as memo,f.ownerWechatId,f.createTime,f.updateTime,f.deleteTime, + c.departmentId + FROM s2_wechat_friend f + LEFT JOIN s2_wechat_account a on a.id = f.wechatAccountId + LEFT JOIN s2_company_account c on c.id = a.deviceAccountId + ORDER BY f.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + id=VALUES(id), + tags=VALUES(tags), + memo=VALUES(memo), + updateTime=VALUES(updateTime), + deleteTime=VALUES(deleteTime), + companyId=VALUES(companyId)"; + + $offset = 0; + $limit = 2000; + $usleepTime = 50000; + + do { + $affected = Db::execute($sql, [$offset, $limit]); + $offset += $limit; + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + } + + + public function syncWechatAccount() + { + $pk = 'wechatId'; + $limit = 1000; + // $lastId = ''; + $lastId = null; // Or some other sentinel indicating "first run" + + + $totalAffected = 0; + $iterations = 0; + $maxIterations = 10000; + + do { + // Fetch a batch of distinct wechatIds + // Important: Order by wechatId for consistent pagination + $sourceDb = Db::connect()->table('s2_wechat_friend'); + // if ($lastId !== '') { // For subsequent iterations + if (!is_null($lastId)) { // Check if it's not the first iteration + $sourceDb->where($pk, '>', $lastId); + } + $distinctWechatIds = $sourceDb->order($pk, 'ASC') + ->distinct(true) + ->limit($limit) + ->column($pk); // Get an array of wechatIds + + if (empty($distinctWechatIds)) { + break; // No more wechatIds to process + } + + // Prepare the main IODKU query for this batch of wechatIds + $sql = "INSERT INTO ck_wechat_account(wechatId,alias,nickname,pyInitial,quanPin,avatar,gender,region,signature,phone,country,privince,city,createTime,updateTime) + SELECT + wechatId,alias,nickname,pyInitial,quanPin,avatar,gender,region,signature,phone,country,privince,city,createTime,updateTime + FROM + s2_wechat_friend + WHERE wechatId IN (" . implode(',', array_fill(0, count($distinctWechatIds), '?')) . ") + GROUP BY wechatId -- Grouping within the selected wechatIds + ON DUPLICATE KEY UPDATE + alias=VALUES(alias), + nickname=VALUES(nickname), + pyInitial=VALUES(pyInitial), + quanPin=VALUES(quanPin), + avatar=VALUES(avatar), + gender=VALUES(gender), + region=VALUES(region), + signature=VALUES(signature), + phone=VALUES(phone), + country=VALUES(country), + privince=VALUES(privince), + city=VALUES(city), + updateTime=VALUES(updateTime)"; + + // The parameters for the IN clause are the distinctWechatIds themselves + $bindings = $distinctWechatIds; + + try { + $affected = Db::execute($sql, $bindings); + $totalAffected += $affected; + // Log::info("syncWechatAccount: Processed batch of " . count($distinctWechatIds) . " distinct wechatIds. Affected rows: " . $affected); + + // Update lastId for the next iteration + $lastId = end($distinctWechatIds); + + if ($affected > 0) { + usleep(50000); + } + } catch (\Exception $e) { + Log::error("syncWechatAccount batch error: " . $e->getMessage() . " with wechatIds starting around " . $distinctWechatIds[0] . ". SQL: " . $sql . " Bindings: " . json_encode($bindings)); + // Decide if you want to break or continue with the next batch + break; // Example: break on error + } + $iterations++; + } while (count($distinctWechatIds) === $limit && $iterations < $maxIterations); // Continue if we fetched a full batch + + // Log::info("syncWechatAccount finished. Total affected rows: " . $totalAffected); + return $totalAffected; + } + + + public function syncWechatDeviceLoginLog() + { + try { + $cursor = Db::table('s2_wechat_account') + ->alias('a') + ->join(['s2_device' => 'd'], 'd.imei = a.imei') + ->join(['s2_company_account' => 'c'], 'c.id = d.currentAccountId') + ->field('d.id as deviceId, a.wechatId, a.wechatAlive as alive, c.departmentId as companyId, a.updateTime as updateTime') + ->cursor(); + + foreach ($cursor as $item) { + + if (empty($item['deviceId']) || empty($item['wechatId']) || empty($item['companyId'])) { + continue; + } + + $exists = Db::table('ck_device_wechat_login') + ->where('deviceId', $item['deviceId']) + ->where('wechatId', $item['wechatId']) + ->where('companyId', $item['companyId']) + ->find(); + + if ($exists) { + Db::table('ck_device_wechat_login') + ->where('deviceId', $item['deviceId']) + ->where('wechatId', $item['wechatId']) + ->where('companyId', $item['companyId']) + ->update(['alive' => $item['alive'], 'updateTime' => $item['updateTime']]); + } else { + $item['createTime'] = $item['updateTime']; + Db::table('ck_device_wechat_login')->insert($item); + } + + } + + return true; + } catch (\Exception $e) { + Log::error("微信好友同步任务异常: " . $e->getMessage() . ", 堆栈: " . $e->getTraceAsString()); + return false; + } + } + + /** + * 大数据量分批处理版本(支持插入和更新) + * 适用于数据源非常大的情况,避免一次性加载全部数据到内存 + * 独立脚本执行,30min 同步一次 和 流量来源的更新一起 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncWechatFriendToTrafficPoolBatch($batchSize = 5000) + { + Db::execute("CREATE TEMPORARY TABLE IF NOT EXISTS temp_wechat_ids ( + wechatId VARCHAR(64) PRIMARY KEY + ) ENGINE=MEMORY"); + + Db::execute("TRUNCATE TABLE temp_wechat_ids"); + + // 批量插入去重的wechatId + Db::execute("INSERT INTO temp_wechat_ids SELECT DISTINCT wechatId FROM s2_wechat_friend"); + + $total = Db::table('temp_wechat_ids')->count(); + + $batchCount = ceil($total / $batchSize); + $affectedRows = 0; + + try { + 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毫秒 + } + } catch (\Exception $e) { + \think\facade\Log::error("Error in traffic pool sync: " . $e->getMessage()); + throw $e; + } finally { + Db::execute("DROP TEMPORARY TABLE IF EXISTS temp_wechat_ids"); + } + + return $affectedRows; + } + + + /** + * 同步/更新微信客服信息到ck_wechat_customer表 + * + * @param int $batchSize 每批处理的数据量 + * @return int 影响的行数 + */ + public function syncWechatCustomer($batchSize = 1000) + { + try { + // 1. 获取要处理的wechatId和companyId列表 + $customerList = Db::table('ck_device_wechat_login') + ->field('DISTINCT wechatId, companyId') + ->order('id DESC') + ->select(); + + $totalAffected = 0; + $batchCount = ceil(count($customerList) / $batchSize); + + for ($i = 0; $i < $batchCount; $i++) { + $batch = array_slice($customerList, $i * $batchSize, $batchSize); + $insertData = []; + + foreach ($batch as $customer) { + $wechatId = $customer['wechatId']; + $companyId = $customer['companyId']; + + if (empty($wechatId)) continue; + + // 2. 获取s2_wechat_account数据 + $accountInfo = Db::table('s2_wechat_account') + ->where('wechatId', $wechatId) + ->find(); + + // 3. 获取群数量 (不包含 @openim 结尾的identifier) + $groupCount = Db::table('ck_wechat_group_member') + ->where('identifier', $wechatId) + ->where('customerIs', 1) + ->where('identifier', 'not like', '%@openim') + ->count(); + + // 4. 检查记录是否已存在 + $existingRecord = Db::table('ck_wechat_customer') + ->where('wechatId', $wechatId) + ->find(); + + // 5. 构建basic JSON数据 + $basic = []; + if ($existingRecord && !empty($existingRecord['basic'])) { + $basic = json_decode($existingRecord['basic'], true) ?: []; + } + + if (empty($basic['registerDate'])) { + $basic['registerDate'] = date('Y-m-d H:i:s', strtotime('-' . mt_rand(1, 150) . ' months')); + } + + // 6. 构建activity JSON数据 + $activity = []; + if ($existingRecord && !empty($existingRecord['activity'])) { + $activity = json_decode($existingRecord['activity'], true) ?: []; + } + + if ($accountInfo) { + $activity['yesterdayMsgCount'] = $accountInfo['yesterdayMsgCount'] ?? 0; + $activity['sevenDayMsgCount'] = $accountInfo['sevenDayMsgCount'] ?? 0; + $activity['thirtyDayMsgCount'] = $accountInfo['thirtyDayMsgCount'] ?? 0; + + // 计算totalMsgCount + if (empty($activity['totalMsgCount'])) { + $activity['totalMsgCount'] = $activity['thirtyDayMsgCount']; + } else { + $activity['totalMsgCount'] += $activity['yesterdayMsgCount']; + } + } + + // 7. 构建friendShip JSON数据 + $friendShip = []; + if ($existingRecord && !empty($existingRecord['friendShip'])) { + $friendShip = json_decode($existingRecord['friendShip'], true) ?: []; + } + + if ($accountInfo) { + $friendShip['totalFriend'] = $accountInfo['totalFriend'] ?? 0; + $friendShip['maleFriend'] = $accountInfo['maleFriend'] ?? 0; + $friendShip['unknowFriend'] = $accountInfo['unknowFriend'] ?? 0; + $friendShip['femaleFriend'] = $accountInfo['femaleFriend'] ?? 0; + } + $friendShip['groupNumber'] = $groupCount; + + // 8. 构建weight JSON数据 (每天只计算一次) + // $weight = []; + // if ($existingRecord && !empty($existingRecord['weight'])) { + // $weight = json_decode($existingRecord['weight'], true) ?: []; + + // // 如果不是今天更新的,重新计算权重 + // $lastUpdateDate = date('Y-m-d', $existingRecord['updateTime'] ?? 0); + // if ($lastUpdateDate !== date('Y-m-d')) { + // $weight = $this->calculateCustomerWeight($basic, $activity, $friendShip); + // } + // } else { + // $weight = $this->calculateCustomerWeight($basic, $activity, $friendShip); + // } + + // 9. 准备更新或插入的数据 + $data = [ + 'wechatId' => $wechatId, + 'companyId' => $companyId, + 'basic' => json_encode($basic), + 'activity' => json_encode($activity), + 'friendShip' => json_encode($friendShip), + // 'weight' => json_encode($weight), + 'createTime' => $accountInfo['createTime'], + 'updateTime' => time() + ]; + + if ($existingRecord) { + // 更新记录 + Db::table('ck_wechat_customer') + ->where('wechatId', $wechatId) + ->update($data); + } else { + // 插入记录 + Db::table('ck_wechat_customer')->insert($data); + } + + $totalAffected++; + } + + // 释放内存 + if ($i % 5 == 0) { + gc_collect_cycles(); + } + + usleep(50000); // 50毫秒短暂休息 + } + + return $totalAffected; + } catch (\Exception $e) { + Log::error("同步微信客服信息异常: " . $e->getMessage() . ", 堆栈: " . $e->getTraceAsString()); + throw $e; + } + } + + /** + * 计算客服权重 + * + * @param array $basic 基础信息 + * @param array $activity 活跃信息 + * @param array $friendShip 好友关系信息 + * @return array 权重信息 + */ + private function calculateCustomerWeight($basic, $activity, $friendShip) + { + // 1. 计算账号年龄权重(最大20分) + $ageWeight = 0; + if (!empty($basic['registerDate'])) { + $registerTime = strtotime($basic['registerDate']); + $accountAgeMonths = floor((time() - $registerTime) / (30 * 24 * 3600)); + $ageWeight = min(20, floor($accountAgeMonths / 12) * 4); + } + + // 2. 计算活跃度权重(最大30分) + $activityWeight = 0; + if (!empty($activity)) { + // 基于消息数计算活跃度 + $msgScore = 0; + if ($activity['thirtyDayMsgCount'] > 10000) $msgScore = 15; + elseif ($activity['thirtyDayMsgCount'] > 5000) $msgScore = 12; + elseif ($activity['thirtyDayMsgCount'] > 1000) $msgScore = 8; + elseif ($activity['thirtyDayMsgCount'] > 500) $msgScore = 5; + elseif ($activity['thirtyDayMsgCount'] > 100) $msgScore = 3; + + // 连续活跃天数加分(这里简化处理,实际可能需要更复杂逻辑) + $activeScore = min(15, $activity['yesterdayMsgCount'] > 10 ? 15 : floor($activity['yesterdayMsgCount'] / 2)); + + $activityWeight = $msgScore + $activeScore; + } + + // 3. 计算限制影响权重(最大15分) + $restrictWeight = 15; // 默认满分,无限制 + + // 4. 计算实名认证权重(最大10分) + $realNameWeight = 0; // 简化处理,默认未实名 + + // 5. 计算可加友数量限制(基于好友数量,最大为5000) + $addLimit = 0; + if (!empty($friendShip['totalFriend'])) { + $addLimit = max(0, min(5000 - $friendShip['totalFriend'], 5000)); + $addLimit = floor($addLimit / 1000); // 每1000个空位1分,最大5分 + } + + // 6. 计算总分(满分75+5分) + $scope = $ageWeight + $activityWeight + $restrictWeight + $realNameWeight; + + return [ + 'ageWeight' => $ageWeight, + 'activityWeight' => $activityWeight, // 注意这里修正了拼写错误 + 'restrictWeight' => $restrictWeight, + 'realNameWeight' => $realNameWeight, + 'scope' => $scope, + 'addLimit' => $addLimit + ]; + } + + /** + * 同步设备信息到ck_device表 + * 数据量不大,仅同步一次所有设备 + * + * @return int 影响的行数 + */ + public function syncDevice() + { + try { + $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 companyId + FROM s2_device d + JOIN s2_company_account a ON d.currentAccountId = a.id + ON DUPLICATE KEY UPDATE + `model` = VALUES(`model`), + `phone` = VALUES(`phone`), + `operatingSystem` = VALUES(`operatingSystem`), + `memo` = VALUES(`memo`), + `alive` = VALUES(`alive`), + `brand` = VALUES(`brand`), + `rooted` = VALUES(`rooted`), + `xPosed` = VALUES(`xPosed`), + `softwareVersion` = VALUES(`softwareVersion`), + `extra` = VALUES(`extra`), + `updateTime` = VALUES(`updateTime`), + `deleteTime` = VALUES(`deleteTime`), + `companyId` = VALUES(`companyId`)"; + + $affected = Db::execute($sql); + return $affected; + } catch (\Exception $e) { + Log::error("同步设备信息异常: " . $e->getMessage() . ", 堆栈: " . $e->getTraceAsString()); + return false; + } + } + + public function syncTrafficSourceUser() + { + $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; + $limit = 2000; + $usleepTime = 50000; + do { + $affected = Db::execute($sql, [$offset, $limit]); + $offset += $limit; + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + } + + public function syncTrafficSourceGroup() + { + $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; + $limit = 2000; + $usleepTime = 50000; + do { + $affected = Db::execute($sql, [$offset, $limit]); + $offset += $limit; + if ($affected > 0) { + usleep($usleepTime); + } + } 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, + 0 AS totalMsgCount, + NULL 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 + 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), + 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`) + SELECT + g.id id, + g.wechatAccountId wechatAccountId, + g.chatroomId chatroomId, + g.nickname name, + g.chatroomAvatar avatar, + c.departmentId companyId, + g.wechatAccountWechatId ownerWechatId, + g.createTime createTime, + g.updateTime updateTime, + g.deleteTime deleteTime + FROM + s2_wechat_chatroom g + LEFT JOIN s2_company_account c ON g.accountId = c.id + ORDER BY g.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + chatroomId=VALUES(chatroomId), + companyId=VALUES(companyId), + ownerWechatId=VALUES(ownerWechatId)"; + + + $offset = 0; + $limit = 2000; + $usleepTime = 50000; + do { + $affected = Db::execute($sql, [$offset, $limit]); + $offset += $limit; + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + } + + public function syncWechatGroupCustomer() + { + $sql = "insert into ck_wechat_group_member(`identifier`,`chatroomId`,`companyId`,`groupId`,`createTime`) + SELECT + m.wechatId identifier, + g.chatroomId chatroomId, + c.departmentId companyId, + g.id groupId, + m.createTime createTime + FROM + s2_wechat_chatroom_member m + LEFT JOIN s2_wechat_chatroom g ON g.chatroomId = m.chatroomId + LEFT JOIN s2_company_account c ON g.accountId = c.id + ORDER BY m.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + identifier=VALUES(identifier), + chatroomId=VALUES(chatroomId), + companyId=VALUES(companyId), + groupId=VALUES(groupId)"; + + $offset = 0; + $limit = 2000; + $usleepTime = 50000; + do { + $affected = Db::execute($sql, [$offset, $limit]); + $offset += $limit; + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + } + + + public function syncCallRecording() + { + $sql = "insert into ck_call_recording(`id`,`phone`,`isCallOut`,`companyId`,`callType`,`beginTime`,`endTime`,`createTime`) + SELECT + c.id id, + c.phone phone, + c.isCallOut isCallOut, + a.departmentId companyId, + c.callType callType, + c.beginTime beginTime, + c.endTime endTime, + c.callBeginTime createTime + FROM + s2_call_recording c + LEFT JOIN s2_company_account a ON c.deviceOwnerId = a.id + ORDER BY c.id DESC + LIMIT ?, ? + ON DUPLICATE KEY UPDATE + id=VALUES(id), + phone=VALUES(phone), + isCallOut=VALUES(isCallOut), + companyId=VALUES(companyId)"; + + $offset = 0; + $limit = 2000; + $usleepTime = 50000; + do { + $affected = Db::execute($sql, [$offset, $limit]); + $offset += $limit; + if ($affected > 0) { + usleep($usleepTime); + } + } while ($affected > 0); + } + + /** + * 处理自动问候功能 + * 根据不同的触发类型检查并发送问候消息 + */ + public function handleAutoGreetings() + { + try { + // 获取所有启用的问候规则 + $rules = Db::name('kf_auto_greetings') + ->where(['status' => 1, 'isDel' => 0]) + ->order('level asc, id asc') + ->select(); + + if (empty($rules)) { + return; + } + + foreach ($rules as $rule) { + $trigger = $rule['trigger']; + $condition = json_decode($rule['condition'], true); + + switch ($trigger) { + case 1: // 新好友 + $this->handleNewFriendGreeting($rule); + break; + case 2: // 首次发消息 + $this->handleFirstMessageGreeting($rule); + break; + case 3: // 时间触发 + $this->handleTimeTriggerGreeting($rule, $condition); + break; + case 4: // 关键词触发 + $this->handleKeywordTriggerGreeting($rule, $condition); + break; + case 5: // 生日触发 + $this->handleBirthdayTriggerGreeting($rule, $condition); + break; + case 6: // 自定义 + $this->handleCustomTriggerGreeting($rule, $condition); + break; + } + } + } catch (\Exception $e) { + Log::error('自动问候处理失败:' . $e->getMessage()); + } + } + + /** + * 处理新好友触发 + */ + private function handleNewFriendGreeting($rule) + { + // 获取最近24小时内添加的好友(避免重复处理) + $last24h = time() - 24 * 3600; + + // 查询该用户/公司最近24小时内新添加的好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $friends = Db::table('s2_wechat_friend') + ->alias('wf') + ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wf.isPassed', '=', 1], + ['wf.isDeleted', '=', 0], + ['wf.passTime', '>=', $last24h], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wf.id, wf.wechatAccountId') + ->select(); + + foreach ($friends as $friend) { + // 检查是否已经发送过问候 + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $friend['id'], + 'wechatAccountId' => $friend['wechatAccountId'], + ]) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); + } + } + } + + /** + * 处理首次发消息触发 + */ + private function handleFirstMessageGreeting($rule) + { + // 获取最近1小时内收到的消息 + $last1h = time() - 3600; + + // 查询消息表,找出首次发消息的好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $messages = Db::table('s2_wechat_message') + ->alias('wm') + ->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wm.isSend', '=', 0], // 接收的消息 + ['wm.wechatChatroomId', '=', 0], // 个人消息 + ['wm.createTime', '>=', $last1h], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->group('wm.wechatFriendId, wm.wechatAccountId') + ->field('wm.wechatFriendId, wm.wechatAccountId, MIN(wm.createTime) as firstMsgTime') + ->select(); + + foreach ($messages as $msg) { + // 检查该好友是否之前发送过消息 + $previousMsg = Db::table('s2_wechat_message') + ->where([ + 'wechatFriendId' => $msg['wechatFriendId'], + 'wechatAccountId' => $msg['wechatAccountId'], + 'isSend' => 0, + ]) + ->where('createTime', '<', $msg['firstMsgTime']) + ->find(); + + // 如果是首次发消息,且没有发送过问候 + if (!$previousMsg) { + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $msg['wechatFriendId'], + 'wechatAccountId' => $msg['wechatAccountId'], + ]) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0); + } + } + } + } + + /** + * 处理时间触发 + */ + private function handleTimeTriggerGreeting($rule, $condition) + { + if (empty($condition) || !isset($condition['type'])) { + return; + } + + $now = time(); + $currentTime = date('H:i', $now); + $currentDate = date('m-d', $now); + $currentDateTime = date('m-d H:i', $now); + $currentWeekday = date('w', $now); // 0=周日, 1=周一, ..., 6=周六 + + $shouldTrigger = false; + + switch ($condition['type']) { + case 'daily_time': // 每天固定时间 + if ($currentTime === $condition['value']) { + $shouldTrigger = true; + } + break; + + case 'yearly_datetime': // 每年固定日期时间 + if ($currentDateTime === $condition['value']) { + $shouldTrigger = true; + } + break; + + case 'fixed_range': // 固定时间段 + if (is_array($condition['value']) && count($condition['value']) === 2) { + $startTime = strtotime('2000-01-01 ' . $condition['value'][0]); + $endTime = strtotime('2000-01-01 ' . $condition['value'][1]); + $currentTimeStamp = strtotime('2000-01-01 ' . $currentTime); + + if ($currentTimeStamp >= $startTime && $currentTimeStamp <= $endTime) { + $shouldTrigger = true; + } + } + break; + + case 'workday': // 工作日 + // 周一到周五(1-5) + if ($currentWeekday >= 1 && $currentWeekday <= 5 && $currentTime === $condition['value']) { + $shouldTrigger = true; + } + break; + } + + if ($shouldTrigger) { + // 获取该用户/公司的所有好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $friends = Db::table('s2_wechat_friend') + ->alias('wf') + ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wf.isPassed', '=', 1], + ['wf.isDeleted', '=', 0], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wf.id, wf.wechatAccountId') + ->select(); + + foreach ($friends as $friend) { + // 检查今天是否已经发送过 + $todayStart = strtotime(date('Y-m-d 00:00:00')); + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $friend['id'], + 'wechatAccountId' => $friend['wechatAccountId'], + ]) + ->where('createTime', '>=', $todayStart) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); + } + } + } + } + + /** + * 处理关键词触发 + */ + private function handleKeywordTriggerGreeting($rule, $condition) + { + if (empty($condition) || empty($condition['keywords'])) { + return; + } + + $keywords = $condition['keywords']; + $matchType = $condition['match_type'] ?? 'fuzzy'; + + // 获取最近1小时内收到的消息 + $last1h = time() - 3600; + + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $messages = Db::table('s2_wechat_message') + ->alias('wm') + ->join(['s2_wechat_account' => 'wa'], 'wm.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wm.isSend', '=', 0], // 接收的消息 + ['wm.wechatChatroomId', '=', 0], // 个人消息 + ['wm.msgType', '=', 1], // 文本消息 + ['wm.createTime', '>=', $last1h], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wm.*') + ->select(); + + foreach ($messages as $msg) { + $content = $msg['content'] ?? ''; + + // 检查关键词匹配 + $matched = false; + foreach ($keywords as $keyword) { + if ($matchType === 'exact') { + // 精准匹配 + if ($content === $keyword) { + $matched = true; + break; + } + } else { + // 模糊匹配 + if (strpos($content, $keyword) !== false) { + $matched = true; + break; + } + } + } + + if ($matched) { + // 检查是否已经发送过问候(同一好友同一规则,1小时内只发送一次) + $last1h = time() - 3600; + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $msg['wechatFriendId'], + 'wechatAccountId' => $msg['wechatAccountId'], + ]) + ->where('createTime', '>=', $last1h) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $msg['wechatAccountId'], $msg['wechatFriendId'], 0); + } + } + } + } + + /** + * 处理生日触发 + */ + private function handleBirthdayTriggerGreeting($rule, $condition) + { + if (empty($condition)) { + return; + } + + // 解析condition格式 + // 支持格式: + // 1. {'month': 10, 'day': 10} - 当天任何时间都可以触发 + // 2. {'month': 10, 'day': 10, 'time': '09:00'} - 当天指定时间触发 + // 3. {'month': 10, 'day': 10, 'time_range': ['09:00', '10:00']} - 当天时间范围内触发 + // 兼容旧格式:['10-10'] 或 '10-10'(仅支持 MM-DD 格式,不包含年份) + + $birthdayMonth = null; + $birthdayDay = null; + $birthdayTime = null; + $timeRange = null; + + if (is_array($condition)) { + // 新格式:对象格式 {'month': 10, 'day': 10} + if (isset($condition['month']) && isset($condition['day'])) { + $birthdayMonth = (int)$condition['month']; + $birthdayDay = (int)$condition['day']; + $birthdayTime = $condition['time'] ?? null; + $timeRange = $condition['time_range'] ?? null; + } + // 兼容旧格式:['10-10'] 或 ['10-10 09:00'](仅支持 MM-DD 格式) + elseif (isset($condition[0])) { + $dateStr = $condition[0]; + // 只接受月日格式:'10-10' 或 '10-10 09:00' + if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $dateStr, $matches)) { + $birthdayMonth = (int)$matches[1]; + $birthdayDay = (int)$matches[2]; + if (isset($matches[3])) { + $birthdayTime = $matches[3]; + } + } + } + } elseif (is_string($condition)) { + // 字符串格式:只接受 '10-10' 或 '10-10 09:00'(MM-DD 格式,不包含年份) + if (preg_match('/^(\d{1,2})-(\d{1,2})(?:\s+(\d{2}:\d{2}))?$/', $condition, $matches)) { + $birthdayMonth = (int)$matches[1]; + $birthdayDay = (int)$matches[2]; + if (isset($matches[3])) { + $birthdayTime = $matches[3]; + } + } + } + + if ($birthdayMonth === null || $birthdayDay === null || $birthdayMonth < 1 || $birthdayMonth > 12 || $birthdayDay < 1 || $birthdayDay > 31) { + return; + } + + $todayMonth = (int)date('m'); + $todayDay = (int)date('d'); + + // 检查今天是否是生日(只匹配月日,不匹配年份) + if ($todayMonth !== $birthdayMonth || $todayDay !== $birthdayDay) { + return; + } + + // 如果配置了时间,检查当前时间是否匹配 + $now = time(); + $currentTime = date('H:i', $now); + + if ($birthdayTime !== null) { + // 指定了具体时间,检查是否在指定时间(允许1分钟误差,避免定时任务执行时间不精确) + $birthdayTimestamp = strtotime('2000-01-01 ' . $birthdayTime); + $currentTimestamp = strtotime('2000-01-01 ' . $currentTime); + $diff = abs($currentTimestamp - $birthdayTimestamp); + + // 如果时间差超过2分钟,不触发(允许1分钟误差) + if ($diff > 120) { + return; + } + } elseif ($timeRange !== null && is_array($timeRange) && count($timeRange) === 2) { + // 指定了时间范围,检查当前时间是否在范围内 + $startTime = strtotime('2000-01-01 ' . $timeRange[0]); + $endTime = strtotime('2000-01-01 ' . $timeRange[1]); + $currentTimestamp = strtotime('2000-01-01 ' . $currentTime); + + if ($currentTimestamp < $startTime || $currentTimestamp > $endTime) { + return; + } + } + // 如果没有配置时间或时间范围,则当天任何时间都可以触发 + + // 获取该用户/公司的所有好友 + // 通过 s2_wechat_account -> s2_company_account 关联获取 companyId + $friends = Db::table('s2_wechat_friend') + ->alias('wf') + ->join(['s2_wechat_account' => 'wa'], 'wf.wechatAccountId = wa.id') + ->join(['s2_company_account' => 'ca'], 'wa.deviceAccountId = ca.id') + ->where([ + ['wf.isPassed', '=', 1], + ['wf.isDeleted', '=', 0], + ['ca.departmentId', '=', $rule['companyId']], + ]) + ->field('wf.id, wf.wechatAccountId') + ->select(); + + foreach ($friends as $friend) { + // 检查今天是否已经发送过 + $todayStart = strtotime(date('Y-m-d 00:00:00')); + $exists = Db::name('kf_auto_greetings_record') + ->where([ + 'autoId' => $rule['id'], + 'friendIdOrGroupId' => $friend['id'], + 'wechatAccountId' => $friend['wechatAccountId'], + ]) + ->where('createTime', '>=', $todayStart) + ->find(); + + if (!$exists) { + $this->sendGreetingMessage($rule, $friend['wechatAccountId'], $friend['id'], 0); + } + } + } + + /** + * 处理自定义触发 + */ + private function handleCustomTriggerGreeting($rule, $condition) + { + // 自定义类型需要根据具体业务需求实现 + // 这里提供一个基础框架,可根据实际需求扩展 + // 暂时不实现,留待后续扩展 + } + + /** + * 发送问候消息 + * @param array $rule 问候规则 + * @param int $wechatAccountId 微信账号ID + * @param int $friendId 好友ID + * @param int $groupId 群ID(0表示个人消息) + */ + private function sendGreetingMessage($rule, $wechatAccountId, $friendId, $groupId = 0) + { + try { + $content = $rule['content']; + + // 创建记录 + $recordId = Db::name('kf_auto_greetings_record')->insertGetId([ + 'autoId' => $rule['id'], + 'userId' => $rule['userId'], + 'companyId' => $rule['companyId'], + 'wechatAccountId' => $wechatAccountId, + 'friendIdOrGroupId' => $friendId, + 'isSend' => 0, + 'sendTime' => 0, + 'receiveTime' => 0, + 'createTime' => time(), + ]); + + // 发送消息(文本消息) + $username = Env::get('api.username', ''); + $password = Env::get('api.password', ''); + $toAccountId = ''; + if (!empty($username) || !empty($password)) { + $toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId'); + } + + $wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]); + + $sendTime = time(); + $result = $wsController->sendPersonal([ + 'wechatFriendId' => $friendId, + 'wechatAccountId' => $wechatAccountId, + 'msgType' => 1, // 文本消息 + 'content' => $content, + ]); + + $isSend = 0; + $receiveTime = 0; + + // 解析返回结果 + $resultData = json_decode($result, true); + if (!empty($resultData) && $resultData['code'] == 200) { + $isSend = 1; + $receiveTime = time(); // 简化处理,实际应该从返回结果中获取 + } + + // 更新记录 + Db::name('kf_auto_greetings_record') + ->where('id', $recordId) + ->update([ + 'isSend' => $isSend, + 'sendTime' => $sendTime, + 'receiveTime' => $receiveTime, + ]); + + // 更新规则使用次数 + Db::name('kf_auto_greetings') + ->where('id', $rule['id']) + ->setInc('usageCount'); + + } catch (\Exception $e) { + Log::error('发送问候消息失败:' . $e->getMessage() . ',规则ID:' . $rule['id']); + } + } + +}