This commit is contained in:
Your Name
2026-04-20 09:04:54 +08:00
parent 4097fe0cc6
commit 34ba9222fa
245 changed files with 537 additions and 242 deletions
@@ -192,10 +192,45 @@ class CustomerLogic extends BaseLogic
try {
$useBatch = (bool) config('project.qywx_sync_use_batch_detail', true);
$mergedMap = null;
/** 已走「时间筛选 + 流式 batch」分支且未抛错时,不再 list+get 全量回退 */
$createtimeFilterBatchPathDone = false;
if ($useBatch) {
try {
$mergedMap = self::buildMergedExternalContactsMap($service, $userList);
Log::info('qywx sync: 批量接口合并后客户数 ' . count($mergedMap));
// 按跟进时间筛选:不构建全量合并表(10 万+ 会爆内存/慢);流式扫 batch,仅对候选 id get+写库
if ($filterByFollowCreatetime) {
$candidates = self::collectCandidateExternalUserIdsForCreatetimeWindow(
$service,
$userList,
$ctimeFrom,
$ctimeTo,
$followCtimeMode
);
Log::info('qywx sync: 时间筛选模式 候选客户数(待 get 校验) ' . count($candidates));
foreach (array_keys($candidates) as $extId) {
$detail = $service->getExternalContactDetail($extId);
if (empty($detail)) {
continue;
}
$externalContact = $detail['external_contact'] ?? [];
$followUsers = $detail['follow_user'] ?? [];
self::upsertOneExternalContactBundle(
$externalContact,
is_array($followUsers) ? $followUsers : [],
true,
$followCtimeMode,
$ctimeFrom,
$ctimeTo,
$syncCount,
$newCount,
$updateCount,
$skippedCount
);
}
$createtimeFilterBatchPathDone = true;
} else {
$mergedMap = self::buildMergedExternalContactsMap($service, $userList);
Log::info('qywx sync: 批量接口合并后客户数 ' . count($mergedMap));
}
} catch (\Throwable $e) {
Log::warning('qywx sync: 批量拉取失败,回退 list+get — ' . $e->getMessage());
$mergedMap = null;
@@ -217,7 +252,7 @@ class CustomerLogic extends BaseLogic
$skippedCount
);
}
} else {
} elseif (!$createtimeFilterBatchPathDone) {
$processedCustomers = [];
foreach ($userList as $user) {
$userId = $user['userid'] ?? '';
@@ -370,6 +405,82 @@ class CustomerLogic extends BaseLogic
return false;
}
/**
* 流式扫 batch/get_by_user,按单条 follow_info 做时间窗口启发式,收集可能要落库的 external_userid(去重)。
* 企微无「只拉今日」接口,仍需翻完所有分页;但不构建全量合并表,后续仅对候选调用 get 做精确过滤。
*
* @return array<string, true>
*/
private static function collectCandidateExternalUserIdsForCreatetimeWindow(
WechatWorkService $service,
array $userList,
int $ctimeFrom,
int $ctimeTo,
string $followCtimeMode
): array {
$candidates = [];
foreach ($userList as $user) {
$userId = trim((string) ($user['userid'] ?? ''));
if ($userId === '') {
continue;
}
Log::info('qywx sync(batch 筛选扫描): 成员 ' . $userId . ' ' . ($user['name'] ?? ''));
$cursor = '';
do {
$resp = $service->batchGetExternalContactByUser($userId, $cursor, 100);
$err = (int) ($resp['errcode'] ?? -1);
if ($err !== 0) {
throw new \RuntimeException('errcode=' . $err . ' errmsg=' . (string) ($resp['errmsg'] ?? ''));
}
foreach ($resp['external_contact_list'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$fi = $item['follow_info'] ?? [];
if (!is_array($fi)) {
$fi = [];
}
if (!self::batchFollowInfoHintMayMatchCreatetimeWindow($fi, $ctimeFrom, $ctimeTo, $followCtimeMode)) {
continue;
}
$ec = $item['external_contact'] ?? [];
$extId = trim((string) ($ec['external_userid'] ?? ''));
if ($extId !== '') {
$candidates[$extId] = true;
}
}
$cursor = (string) ($resp['next_cursor'] ?? '');
} while ($cursor !== '');
}
return $candidates;
}
/**
* 批量接口单条 follow_info 是否「可能」命中时间策略(用于缩小候选;最终以 get 全量 follow_user 为准)
*
* @param array<string, mixed> $followInfo
*/
private static function batchFollowInfoHintMayMatchCreatetimeWindow(
array $followInfo,
int $ctimeFrom,
int $ctimeTo,
string $followCtimeMode
): bool {
if ($followCtimeMode === 'any') {
return self::followCreatetimeInRange([$followInfo], $ctimeFrom, $ctimeTo);
}
$ct = self::normalizeFollowUserCreatetime($followInfo);
if ($ct > 0 && $ct < $ctimeFrom) {
return false;
}
if ($ct > 0 && $ct > $ctimeTo) {
return false;
}
return true;
}
/**
* 使用 batch/get_by_user 按成员分页拉取并合并跟进人(列表接口无按时间筛选)
*
@@ -379,8 +490,10 @@ class CustomerLogic extends BaseLogic
private static function buildMergedExternalContactsMap(WechatWorkService $service, array $userList): array
{
$merged = [];
foreach ($userList as $user) {
$userId = trim((string) ($user['userid'] ?? ''));
if ($userId === '') {
continue;
}
@@ -388,6 +501,7 @@ class CustomerLogic extends BaseLogic
$cursor = '';
do {
$resp = $service->batchGetExternalContactByUser($userId, $cursor, 100);
$err = (int) ($resp['errcode'] ?? -1);
if ($err !== 0) {
throw new \RuntimeException('errcode=' . $err . ' errmsg=' . (string) ($resp['errmsg'] ?? ''));
@@ -468,6 +582,66 @@ class CustomerLogic extends BaseLogic
* @param array<string, mixed> $externalContact
* @param array<int, array<string, mixed>> $followUsers
*/
/**
* 客户联系事件回调:按 external_userid 拉取详情并 UPSERT(不走全量同步文件锁)。
*
* @see https://developer.work.weixin.qq.com/document/path/92130
*/
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
return;
}
$service = new WechatWorkService();
$detail = $service->getExternalContactDetail($externalUserId);
if (empty($detail['external_contact']) || !is_array($detail['external_contact'])) {
Log::warning('qywx external contact callback: 拉取详情为空', ['external_userid' => $externalUserId]);
return;
}
$followUsers = $detail['follow_user'] ?? [];
if (!is_array($followUsers)) {
$followUsers = [];
}
$syncCount = 0;
$newCount = 0;
$updateCount = 0;
$skippedCount = 0;
self::upsertOneExternalContactBundle(
$detail['external_contact'],
$followUsers,
false,
'first',
0,
0,
$syncCount,
$newCount,
$updateCount,
$skippedCount
);
}
/**
* 客户联系「删除企业客户」等事件:本地软删除一行。
*/
public static function softDeleteExternalContactRow(string $externalUserId): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
return;
}
$now = time();
Db::name('qywx_external_contact')->where('external_userid', $externalUserId)->update([
'delete_time' => $now,
'update_time' => $now,
]);
}
private static function upsertOneExternalContactBundle(
array $externalContact,
array $followUsers,