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