This commit is contained in:
Your Name
2026-07-15 09:07:05 +08:00
parent f6688a740d
commit 796441b407
4 changed files with 398 additions and 161 deletions
+275 -127
View File
@@ -585,15 +585,32 @@ class CustomerLogic extends BaseLogic
/**
* 客户联系事件回调:按 external_userid 拉取详情并 UPSERT(不走全量同步文件锁)。
*
* @param bool $isAddEvent 是否 add_external_contact 事件:是且本地已有该客户(含软删行)时,
* 新增一行 readd_flag=1(以前加过)的记录,原有行不动
* @param string $eventUserId 本次添加人的企微 useridadd 事件回调里的 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<string, mixed> $externalContact
* @param array<int, array<string, mixed>> $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<int, mixed> $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<string, mixed> $externalContact
* @param array<int, array<string, mixed>> $followUsers
* @return array<string, mixed>
*/
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<string, mixed> $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<string, true> 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,
];
}