diff --git a/admin/src/views/fans/qywx.vue b/admin/src/views/fans/qywx.vue index 39724491..1032a2c6 100644 --- a/admin/src/views/fans/qywx.vue +++ b/admin/src/views/fans/qywx.vue @@ -39,8 +39,16 @@
-
总客户数
+
总客户数(去重)
{{ stats.total }}
+ +
+ 跟进关系数(企微口径):{{ stats.relation_total }} +
+
@@ -52,6 +60,9 @@
{{ stats.today }}
+
+ 含老客户 {{ arrival.returning }} +
@@ -114,6 +125,24 @@ @change="onAddTimeRangeChange" /> + + + + + + +
-
{{ row.name }}
+
+ {{ row.name }} + + 以前加过 + + + 已删除 + +
{{ row.external_userid }}
@@ -278,6 +323,9 @@
今日进入明细 共 {{ arrivalList.total }} 人 + + 老客户 {{ arrival.returning }} +
@@ -331,8 +379,17 @@ {{ (row.customer_name || '?').slice(0, 1) }}
-
- {{ row.customer_name || row.external_userid || '(未知客户)' }} +
+ + {{ row.customer_name || row.external_userid || '(未知客户)' }} + + + 老客户 +
接待:{{ row.admin_name || row.user_id || '—' }} @@ -469,6 +526,9 @@ {{ currentCustomer.type === 1 ? '微信用户' : '企业微信用户' }} + + 以前加过 + {{ currentCustomer.corp_name || '—' }} @@ -544,6 +604,7 @@ const currentCustomer = ref(null) const stats = reactive({ total: 0, + relation_total: 0, today: 0, today_follow_staff: 0, lastSync: '', @@ -575,12 +636,14 @@ const queryParams = reactive<{ tag_ids: string[] add_time_start: string add_time_end: string + add_time_mode: 'first' | 'any' }>({ name: '', follow_user: '', tag_ids: [], add_time_start: '', - add_time_end: '' + add_time_end: '', + add_time_mode: 'first' }) /** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time) */ @@ -597,6 +660,13 @@ function onAddTimeRangeChange(val: [string, string] | null) { resetPage() } +/** 口径切换只在已选时间范围时才需要重查 */ +function onAddTimeModeChange() { + if (queryParams.add_time_start || queryParams.add_time_end) { + resetPage() + } +} + // ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ────────────────────────── interface TagItem { tag_id: string @@ -689,6 +759,7 @@ function filterByTag(tagId: string) { interface ArrivalStats { total: number recent_time: number + returning: number hourly: number[] by_state: { state: string; count: number }[] } @@ -702,11 +773,13 @@ interface ArrivalItem { customer_avatar: string state: string welcome_code: number + is_old_customer: number } const arrival = reactive({ total: 0, recent_time: 0, + returning: 0, hourly: new Array(24).fill(0), by_state: [] }) @@ -740,6 +813,7 @@ async function loadArrival(refreshList = false) { if (res) { arrival.total = Number(res.total ?? 0) arrival.recent_time = Number(res.recent_time ?? 0) + arrival.returning = Number(res.returning ?? 0) arrival.hourly = Array.isArray(res.hourly) && res.hourly.length === 24 ? res.hourly.map((n: any) => Number(n) || 0) : new Array(24).fill(0) @@ -829,6 +903,7 @@ function handleReset() { queryParams.tag_ids = [] queryParams.add_time_start = '' queryParams.add_time_end = '' + queryParams.add_time_mode = 'first' addTimeRange.value = null resetParams() } diff --git a/server/app/adminapi/lists/qywx/CustomerLists.php b/server/app/adminapi/lists/qywx/CustomerLists.php index 21e22296..aedaaf4a 100755 --- a/server/app/adminapi/lists/qywx/CustomerLists.php +++ b/server/app/adminapi/lists/qywx/CustomerLists.php @@ -77,20 +77,37 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface } } - // 添加时间:与 lists 排序口径一致(external_first_add_time 优先,0 则 create_time) + // 添加时间筛选,两种口径: + // first(默认)= 首次添加时间在窗口内(external_first_add_time 优先,0 则 create_time) + // any = 企微「全部客户+时间筛选」口径:存在"添加时间在窗口内的现存跟进关系"即命中 + // (含老客户被重加/被其他员工添加;关系已解除的不算),数据源为现存关系表 $addStart = trim((string) ($this->params['add_time_start'] ?? '')); $addEnd = trim((string) ($this->params['add_time_end'] ?? '')); - $effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)'; - if ($addStart !== '') { - $t = strtotime($addStart . ' 00:00:00'); - if ($t !== false) { - $query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$t]); + $addMode = trim((string) ($this->params['add_time_mode'] ?? 'first')); + $startTs = $addStart !== '' ? strtotime($addStart . ' 00:00:00') : false; + $endTs = $addEnd !== '' ? strtotime($addEnd . ' 23:59:59') : false; + + if ($addMode === 'any' && ($startTs !== false || $endTs !== false)) { + $followQuery = Db::name('qywx_external_contact_follow')->where('createtime', '>', 0); + if ($startTs !== false) { + $followQuery->where('createtime', '>=', $startTs); } - } - if ($addEnd !== '') { - $t = strtotime($addEnd . ' 23:59:59'); - if ($t !== false) { - $query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$t]); + if ($endTs !== false) { + $followQuery->where('createtime', '<=', $endTs); + } + $matchedExtIds = $followQuery->group('external_userid')->column('external_userid'); + if ($matchedExtIds === []) { + $query->whereRaw('1=0'); + } else { + $query->whereIn('external_userid', $matchedExtIds); + } + } else { + $effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)'; + if ($startTs !== false) { + $query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$startTs]); + } + if ($endTs !== false) { + $query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]); } } @@ -158,6 +175,9 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface $fromDb = (int) ($item['external_first_add_time'] ?? 0); $fromJson = CustomerLogic::minFollowCreatetime($followUsers); $item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson; + + // 「任意添加」口径会带出软删行,前端据此显示"已删除"标记 + $item['is_deleted'] = !empty($item['delete_time']) ? 1 : 0; } unset($item); diff --git a/server/app/adminapi/logic/qywx/CustomerLogic.php b/server/app/adminapi/logic/qywx/CustomerLogic.php index 7ccbf399..d38ffdd4 100755 --- a/server/app/adminapi/logic/qywx/CustomerLogic.php +++ b/server/app/adminapi/logic/qywx/CustomerLogic.php @@ -585,15 +585,32 @@ class CustomerLogic extends BaseLogic /** * 客户联系事件回调:按 external_userid 拉取详情并 UPSERT(不走全量同步文件锁)。 * + * @param bool $isAddEvent 是否 add_external_contact 事件:是且本地已有该客户(含软删行)时, + * 新增一行 readd_flag=1(以前加过)的记录,原有行不动 + * @param string $eventUserId 本次添加人的企微 userid(add 事件回调里的 UserID,用于取本次添加时间) + * @param int $eventTime 回调事件时间(企微 CreateTime,秒) * @see https://developer.work.weixin.qq.com/document/path/92130 */ - public static function upsertSingleExternalContactFromApi(string $externalUserId): void - { + public static function upsertSingleExternalContactFromApi( + string $externalUserId, + bool $isAddEvent = false, + string $eventUserId = '', + int $eventTime = 0 + ): void { $externalUserId = trim($externalUserId); + $eventUserId = trim($eventUserId); if ($externalUserId === '') { return; } + // 必须在写库之前判断"是否已存在",否则无法区分新客户和重加客户 + $existedBefore = false; + if ($isAddEvent) { + $existedBefore = Db::name('qywx_external_contact') + ->where('external_userid', $externalUserId) + ->count() > 0; + } + $service = new WechatWorkService(); $detail = []; $lastError = null; @@ -619,6 +636,14 @@ class CustomerLogic extends BaseLogic if (empty($detail['external_contact']) || !is_array($detail['external_contact'])) { $errcode = is_array($lastError) ? ($lastError['errcode'] ?? null) : null; $errmsg = is_array($lastError) ? (string) ($lastError['errmsg'] ?? '') : ''; + // 84061 = 已不是企业客户(双向解除/员工删除后无人跟进):与企微对齐,本地软删。 + // 注意:客户单方删除员工(del_follow_user)时企微仍保留客户,get 会成功返回,不会走到这里。 + if ($errcode === 84061) { + self::softDeleteExternalContactRow($externalUserId); + Log::info('qywx external contact callback: 企微侧已不存在(84061),本地软删 ext=' . $externalUserId); + + return; + } Log::warning(sprintf( 'qywx external contact callback: 拉取详情为空,跳过 UPSERT errcode=%s errmsg=%s ext=%s', $errcode === null ? '?' : (string) $errcode, @@ -634,6 +659,18 @@ class CustomerLogic extends BaseLogic $followUsers = []; } + // 重复添加:不动原有行,新增一行并打「以前加过」标记(与企微"每次添加都算一条"对齐) + if ($isAddEvent && $existedBefore) { + self::insertReaddContactRow( + $detail['external_contact'], + $followUsers, + $eventUserId, + $eventTime + ); + + return; + } + $syncCount = 0; $newCount = 0; $updateCount = 0; @@ -652,6 +689,48 @@ class CustomerLogic extends BaseLogic ); } + /** + * 重复添加:为已存在的客户新增一行记录(原有行保持不变)。 + * + * 新行的「添加时间」取本次添加动作的时间:优先本次添加人($eventUserId)在 follow_user + * 里的 createtime,其次回调事件时间,最后兜底当前时间——保证这行出现在"本次添加"的日期下。 + * + * @param array $externalContact + * @param array> $followUsers + */ + private static function insertReaddContactRow( + array $externalContact, + array $followUsers, + string $eventUserId, + int $eventTime + ): void { + $row = self::buildContactRow($externalContact, $followUsers); + + $addTime = 0; + if ($eventUserId !== '') { + foreach ($followUsers as $fu) { + if (is_array($fu) && trim((string) ($fu['userid'] ?? '')) === $eventUserId) { + $addTime = self::normalizeFollowUserCreatetime($fu); + break; + } + } + } + if ($addTime <= 0) { + $addTime = $eventTime > 0 ? $eventTime : time(); + } + + $row['external_first_add_time'] = $addTime; + $row['readd_flag'] = 1; + + Db::name('qywx_external_contact')->insert($row); + + $extId = (string) $row['external_userid']; + self::syncContactTagsRelation($extId, $followUsers); + self::syncContactFollowRelation($extId, $followUsers); + + Log::info('qywx external contact callback: 重复添加,新增一行(readd_flag=1) ext=' . $extId); + } + /** * 事件流水(零误差进入计数) * @@ -720,7 +799,7 @@ class CustomerLogic extends BaseLogic } /** - * 客户联系「删除企业客户」等事件:本地软删除一行。 + * 客户彻底流失(企微 get 返回 84061):该客户的所有行(含历史重加行)一并软删。 */ public static function softDeleteExternalContactRow(string $externalUserId): void { @@ -739,84 +818,9 @@ class CustomerLogic extends BaseLogic Db::name('qywx_external_contact_tag') ->where('external_userid', $externalUserId) ->delete(); - } - - /** - * `del_follow_user` 事件:某员工不再跟进该客户。 - * 只更新本地 `follow_users` JSON:移除匹配的 userid;若已无跟进人则软删该行。 - * 不再回调 /externalcontact/get,避免 84061「not external contact」刷 warning。 - */ - public static function removeFollowUserFromLocal(string $externalUserId, string $userId): void - { - $externalUserId = trim($externalUserId); - $userId = trim($userId); - if ($externalUserId === '' || $userId === '') { - return; - } - - $row = Db::name('qywx_external_contact') + Db::name('qywx_external_contact_follow') ->where('external_userid', $externalUserId) - ->find(); - if (!$row) { - return; - } - - $followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true); - $followUsers = is_array($followUsers) ? $followUsers : []; - $kept = []; - $removed = false; - foreach ($followUsers as $fu) { - if (!is_array($fu)) { - continue; - } - $uid = trim((string) ($fu['userid'] ?? '')); - if ($uid === $userId) { - $removed = true; - continue; - } - $kept[] = $fu; - } - - if (!$removed) { - return; - } - - $now = time(); - if ($kept === []) { - Db::name('qywx_external_contact') - ->where('id', (int) $row['id']) - ->update([ - 'follow_users' => json_encode([], JSON_UNESCAPED_UNICODE), - 'tags' => '[]', - 'delete_time' => $now, - 'update_time' => $now, - ]); - - // 整客户已无人跟进 → 关系表清空 - Db::name('qywx_external_contact_tag') - ->where('external_userid', $externalUserId) - ->delete(); - - return; - } - - $minCreate = self::minFollowCreatetime($kept); - $update = [ - 'follow_users' => json_encode($kept, JSON_UNESCAPED_UNICODE), - 'tags' => self::extractFollowUserTags($kept), - 'update_time' => $now, - ]; - if ($minCreate > 0) { - // 移除最早那条跟进人后,首次添加时间可能后移;重新刷新字段以与 JSON 保持一致 - $update['external_first_add_time'] = $minCreate; - } - - Db::name('qywx_external_contact') - ->where('id', (int) $row['id']) - ->update($update); - - // 关系表按剩余 kept follow_users 同步(自动清掉离开员工那行 + 保留其他员工的标签) - self::syncContactTagsRelation($externalUserId, $kept); + ->delete(); } private static function upsertOneExternalContactBundle( @@ -845,32 +849,16 @@ class CustomerLogic extends BaseLogic } } - $now = time(); - $row = [ - 'external_userid' => $externalContact['external_userid'] ?? '', - 'name' => $externalContact['name'] ?? '', - 'avatar' => $externalContact['avatar'] ?? '', - 'type' => $externalContact['type'] ?? 1, - 'gender' => $externalContact['gender'] ?? 0, - 'unionid' => $externalContact['unionid'] ?? '', - 'position' => $externalContact['position'] ?? '', - 'corp_name' => $externalContact['corp_name'] ?? '', - 'corp_full_name' => $externalContact['corp_full_name'] ?? '', - 'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE), - 'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE), - 'tags' => self::extractFollowUserTags($followUsers), - 'follow_admin_ids' => self::resolveFollowAdminIds($followUsers), - 'external_first_add_time' => self::minFollowCreatetime($followUsers), - 'create_time' => $now, - 'update_time' => $now, - 'delete_time' => null, - ]; + $row = self::buildContactRow($externalContact, $followUsers); self::upsertExternalContactRow($row, $syncCount, $newCount, $updateCount); // 同步「客户↔员工↔标签」关系表(用于检索/统计;列表筛选/聚合不必再解析 follow_users JSON) self::syncContactTagsRelation((string) $row['external_userid'], $followUsers); + // 同步「客户↔员工」现存跟进关系表(企微口径的添加时间筛选用) + self::syncContactFollowRelation((string) $row['external_userid'], $followUsers); + if (($syncCount % 25) === 0) { usleep(8000); } @@ -982,6 +970,67 @@ class CustomerLogic extends BaseLogic } } + /** + * 同步「客户↔员工」现存跟进关系表(zyt_qywx_external_contact_follow)。 + * + * 与企微后台「全部客户 + 时间筛选」同口径:只保留当前仍存在的跟进关系及其 createtime; + * 关系被解除(员工删客户且未重加)后行随之删除,客户在对应日期的筛选下即不再出现。 + * + * @param array $followUsers /externalcontact/get 返回的 follow_user[] + * @internal 仅供 UPSERT / 回填复用 + */ + public static function syncContactFollowRelation(string $externalUserId, array $followUsers): void + { + $externalUserId = trim($externalUserId); + if ($externalUserId === '') { + return; + } + + $rows = []; + foreach ($followUsers as $fu) { + if (!is_array($fu)) { + continue; + } + $uid = trim((string) ($fu['userid'] ?? '')); + if ($uid === '') { + continue; + } + $rows[$uid] = self::normalizeFollowUserCreatetime($fu); + } + + try { + if ($rows === []) { + Db::name('qywx_external_contact_follow') + ->where('external_userid', $externalUserId) + ->delete(); + + return; + } + + Db::name('qywx_external_contact_follow') + ->where('external_userid', $externalUserId) + ->whereNotIn('follow_user_id', array_keys($rows)) + ->delete(); + + $now = time(); + foreach ($rows as $uid => $createtime) { + Db::name('qywx_external_contact_follow') + ->duplicate(['createtime' => $createtime, 'update_time' => $now]) + ->insert([ + 'external_userid' => $externalUserId, + 'follow_user_id' => $uid, + 'createtime' => $createtime, + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } catch (\Throwable $e) { + Log::warning('qywx contact follow relation sync failed: ' . $e->getMessage(), [ + 'ext' => $externalUserId, + ]); + } + } + /** * 把所有 follow_user[].tags 合并去重(按 tag_id),返回 JSON 字符串。 * @@ -1062,6 +1111,38 @@ class CustomerLogic extends BaseLogic return json_encode(array_values(array_unique($adminIds)), JSON_UNESCAPED_UNICODE); } + /** + * 由企微 API 返回的 external_contact + follow_user 组装一行客户表数据 + * + * @param array $externalContact + * @param array> $followUsers + * @return array + */ + private static function buildContactRow(array $externalContact, array $followUsers): array + { + $now = time(); + + return [ + 'external_userid' => $externalContact['external_userid'] ?? '', + 'name' => $externalContact['name'] ?? '', + 'avatar' => $externalContact['avatar'] ?? '', + 'type' => $externalContact['type'] ?? 1, + 'gender' => $externalContact['gender'] ?? 0, + 'unionid' => $externalContact['unionid'] ?? '', + 'position' => $externalContact['position'] ?? '', + 'corp_name' => $externalContact['corp_name'] ?? '', + 'corp_full_name' => $externalContact['corp_full_name'] ?? '', + 'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE), + 'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE), + 'tags' => self::extractFollowUserTags($followUsers), + 'follow_admin_ids' => self::resolveFollowAdminIds($followUsers), + 'external_first_add_time' => self::minFollowCreatetime($followUsers), + 'create_time' => $now, + 'update_time' => $now, + 'delete_time' => null, + ]; + } + /** * 是否 MySQL 锁等待超时 / 死锁(可短重试) */ @@ -1073,7 +1154,13 @@ class CustomerLogic extends BaseLogic } /** - * INSERT ... ON DUPLICATE KEY UPDATE + 锁冲突重试 + * 客户行写入 + 锁冲突重试。 + * + * 表允许同一 external_userid 多行(重复添加会新增行),不再依赖唯一键 UPSERT: + * - 已有行 → 只更新"最新一行"(MAX(id)),历史重加行保持原样; + * - 无行 → 插入新行。 + * 更新时不覆盖 create_time / readd_flag / external_first_add_time + * (最新行的添加时间代表"该行那次添加"的时间,由插入时决定,后续详情刷新不应改动)。 * * @param array $row */ @@ -1084,33 +1171,21 @@ class CustomerLogic extends BaseLogic while (true) { $attempt++; try { - $affected = Db::name('qywx_external_contact')->duplicate([ - 'name', - 'avatar', - 'type', - 'gender', - 'unionid', - 'position', - 'corp_name', - 'corp_full_name', - 'external_profile', - 'follow_users', - 'tags', - 'follow_admin_ids', - 'external_first_add_time', - 'update_time', - 'delete_time', - ])->insert($row); + $latestId = (int) Db::name('qywx_external_contact') + ->where('external_userid', (string) $row['external_userid']) + ->order('id', 'desc') + ->value('id'); - $syncCount++; - // PDO MySQL:新插入 1;更新 2;值完全相同 0 - if ($affected === 1) { + if ($latestId > 0) { + $update = $row; + unset($update['external_userid'], $update['create_time'], $update['external_first_add_time']); + Db::name('qywx_external_contact')->where('id', $latestId)->update($update); + $updateCount++; + } else { + Db::name('qywx_external_contact')->insert($row); $newCount++; - } elseif ($affected === 2) { - $updateCount++; - } elseif ($affected === 0) { - $updateCount++; } + $syncCount++; return; } catch (\Throwable $e) { @@ -1211,8 +1286,16 @@ class CustomerLogic extends BaseLogic */ public static function getStats() { - $total = QywxExternalContact::whereNull('delete_time')->count(); - + // 表内同一客户可能多行(重复添加各占一行),去重后才是真实客户数 + $total = (int) Db::name('qywx_external_contact') + ->whereNull('delete_time') + ->fieldRaw('COUNT(DISTINCT external_userid) AS c') + ->find()['c']; + + // 企微管理后台「全部客户」按"客户×添加人"关系计数(同一客户被 N 名员工添加计 N 条), + // 现存跟进关系表与其同口径,便于对账。 + $relationTotal = (int) Db::name('qywx_external_contact_follow')->count(); + $todayStart = strtotime(date('Y-m-d 00:00:00')); // 今日进入数 = 今日收到的 add_external_contact 事件条数。 @@ -1254,6 +1337,7 @@ class CustomerLogic extends BaseLogic return [ 'total' => $total, + 'relation_total' => $relationTotal, 'today' => $today, 'today_follow_staff' => $todayFollowStaff, 'lastSync' => $lastSync, @@ -1332,14 +1416,74 @@ class CustomerLogic extends BaseLogic } } + // 老客户回流数(去重客户维度):今天有 add 事件,但今天之前就加过—— + // 依据①事件流水里有更早的 add 事件,或②客户表首次添加时间早于今天(覆盖流水表上线前的老客户) + $returning = 0; + if ($total > 0) { + $todayExtIds = Db::name('qywx_external_contact_event') + ->where('change_type', 'add_external_contact') + ->where('event_time', '>=', $todayStart) + ->where('event_time', '<', $todayEnd) + ->group('external_userid') + ->column('external_userid'); + $todayExtIds = array_values(array_filter($todayExtIds)); + if ($todayExtIds !== []) { + $oldSet = self::resolveOldCustomerSet($todayExtIds, $todayStart); + $returning = count($oldSet); + } + } + return [ 'total' => $total, 'recent_time' => $recent, + 'returning' => $returning, 'hourly' => array_values($hourly), 'by_state' => $byState, ]; } + /** + * 判定"老客户":在 $beforeTs 之前就已加过企业的客户集合。 + * + * 依据(满足其一即算老客户): + * ① 事件流水表在 $beforeTs 之前存在该客户的 add_external_contact 记录; + * ② 客户表 external_first_add_time(企微 follow_user 最早 createtime)早于 $beforeTs—— + * 覆盖事件流水表上线之前就已存在的客户。 + * + * @param string[] $extIds + * @return array external_userid 为键的集合 + */ + private static function resolveOldCustomerSet(array $extIds, int $beforeTs): array + { + $extIds = array_values(array_unique(array_filter($extIds))); + if ($extIds === []) { + return []; + } + + $old = []; + + $earlierEventIds = Db::name('qywx_external_contact_event') + ->whereIn('external_userid', $extIds) + ->where('change_type', 'add_external_contact') + ->where('event_time', '<', $beforeTs) + ->group('external_userid') + ->column('external_userid'); + foreach ($earlierEventIds as $id) { + $old[(string) $id] = true; + } + + $earlierFirstAddIds = Db::name('qywx_external_contact') + ->whereIn('external_userid', $extIds) + ->where('external_first_add_time', '>', 0) + ->where('external_first_add_time', '<', $beforeTs) + ->column('external_userid'); + foreach ($earlierFirstAddIds as $id) { + $old[(string) $id] = true; + } + + return $old; + } + /** * 今日"进入明细"流水(分页,按时间倒序) * @@ -1391,6 +1535,9 @@ class CustomerLogic extends BaseLogic $adminMap = Admin::whereIn('work_wechat_userid', $userIds)->column('name', 'work_wechat_userid'); } + // 标记老客户:今天之前就加过企业(重加 / 被另一名员工添加的回流客户) + $oldSet = self::resolveOldCustomerSet($extIds, $todayStart); + $lists = []; foreach ($rows as $r) { $ext = (string) ($r['external_userid'] ?? ''); @@ -1405,6 +1552,7 @@ class CustomerLogic extends BaseLogic 'customer_avatar' => (string) ($customerMap[$ext]['avatar'] ?? ''), 'state' => (string) ($r['state'] ?? ''), 'welcome_code' => (int) ($r['welcome_code'] ?? 0), + 'is_old_customer' => isset($oldSet[$ext]) ? 1 : 0, ]; } diff --git a/server/app/api/controller/QywxExternalContactCallbackController.php b/server/app/api/controller/QywxExternalContactCallbackController.php index 94c7cae8..3bbb77ef 100755 --- a/server/app/api/controller/QywxExternalContactCallbackController.php +++ b/server/app/api/controller/QywxExternalContactCallbackController.php @@ -133,12 +133,6 @@ class QywxExternalContactCallbackController extends BaseApiController $failReason !== '' ? $failReason : '-' )); - if ($changeType === 'del_external_contact') { - CustomerLogic::softDeleteExternalContactRow($extId); - - return; - } - if ($changeType === 'add_half_external_contact') { // 半客户:客户尚未通过验证,/externalcontact/get 通常返回 84061「客户尚未通过」之类, // 这里只 log 不写库,避免产生 noise;客户通过后会再触发 add_external_contact 事件再走 UPSERT。 @@ -153,17 +147,17 @@ class QywxExternalContactCallbackController extends BaseApiController return; } - if ($changeType === 'del_follow_user') { - // 某员工不再跟进该客户:只需把本地 follow_users 里对应 userid 移除; - // 若已无跟进人则软删;不再回调 /externalcontact/get(最后一个跟进人被删时会稳定返回 84061)。 - if ($userId !== '') { - CustomerLogic::removeFollowUserFromLocal($extId, $userId); - } - - return; - } - - // 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType - CustomerLogic::upsertSingleExternalContactFromApi($extId); + // 其余全部变更(添加/编辑/删除/被删/转接/标签变化等):一律以 /externalcontact/get 的真实状态为准。 + // - 仍是企业客户(含"客户单方删除员工",企微会保留客户与跟进关系)→ UPSERT 最新数据; + // - 已彻底不是企业客户(84061)→ 本地软删。 + // 实测验证:del_follow_user(客户删员工)后 get 依然成功返回且 follow_user 保留, + // 本地自行推断移除跟进人/软删会导致客户数持续少于企微,故删除类事件不再走本地推断。 + // 添加事件若命中已有客户(含软删行):不动原有行,新增一行 readd_flag=1(以前加过)的记录。 + CustomerLogic::upsertSingleExternalContactFromApi( + $extId, + $changeType === 'add_external_contact', + $userId, + $eventTime + ); } }