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