|false */ public static function triggerBackgroundSync() { if (Cache::get(self::SYNC_LOCK_KEY)) { self::$error = '同步任务正在执行中,请稍后再试或刷新页面查看状态'; return false; } Cache::set(self::SYNC_LOCK_KEY, time(), 7200); self::updateSyncStatus('syncing', 0, '', false); $think = root_path() . 'think'; if (!is_file($think)) { Cache::delete(self::SYNC_LOCK_KEY); self::$error = '未找到项目 think 入口,无法启动后台任务'; return false; } $phpBin = self::resolvePhpCliBinary(); $logFile = runtime_path() . 'log/qywx_sync.log'; $logDir = dirname($logFile); if (!is_dir($logDir)) { @mkdir($logDir, 0755, true); } if (PHP_OS_FAMILY === 'Windows') { $cmd = sprintf( 'start /B cmd /c %s %s qywx:sync-customer --force ^>^> %s 2^>^&1', escapeshellarg($phpBin), escapeshellarg($think), escapeshellarg($logFile) ); @pclose(@popen($cmd, 'r')); } else { $cmd = sprintf( '%s %s qywx:sync-customer --force >> %s 2>&1 &', escapeshellarg($phpBin), escapeshellarg($think), escapeshellarg($logFile) ); exec($cmd); } return [ 'background' => true, 'message' => '已在后台开始同步,请稍候刷新列表或等待状态变为「同步成功」', ]; } /** * CLI 路径:FPM 下 PHP_BINARY 可能是 php-fpm,不可用。 */ private static function resolvePhpCliBinary(): string { $fromEnv = (string) env('PHP_CLI_BINARY', ''); if ($fromEnv !== '' && is_executable($fromEnv)) { return $fromEnv; } if (PHP_BINARY !== '' && is_executable(PHP_BINARY) && stripos(PHP_BINARY, 'fpm') === false) { return PHP_BINARY; } return 'php'; } private static function acquireSyncProcessLock(): bool { $dir = runtime_path() . 'lock'; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $path = $dir . '/qywx_customer_sync.lock'; $fp = @fopen($path, 'c+'); if ($fp === false) { Log::error('qywx sync: 无法打开锁文件 ' . $path); return false; } if (!flock($fp, LOCK_EX | LOCK_NB)) { fclose($fp); return false; } self::$syncFileLockHandle = $fp; return true; } private static function releaseSyncProcessLock(): void { if (is_resource(self::$syncFileLockHandle)) { flock(self::$syncFileLockHandle, LOCK_UN); fclose(self::$syncFileLockHandle); self::$syncFileLockHandle = null; } } /** * @notes 同步企业微信客户(由定时任务或后台 CLI 调用,耗时可数分钟) * * @param array{follow_createtime_from?:int,follow_createtime_to?:int,follow_createtime_mode?:string} $options * follow_createtime_*:与 follow_createtime_mode 联用;mode 默认 first=仅「首次添加」时间在窗口内(今日新客),any=任一条跟进 createtime 在窗口内(旧逻辑,命中多) */ public static function syncCustomers(array $options = []) { try { @set_time_limit(0); } catch (\Throwable) { } if (!self::acquireSyncProcessLock()) { self::$error = '已有同步任务在执行中(请勿同时运行多条 php think qywx:sync-customer 或网页「立即同步」),请等待结束后再试'; return false; } try { try { Db::execute('SET SESSION innodb_lock_wait_timeout = 120'); } catch (\Throwable $e) { Log::warning('qywx sync: SET innodb_lock_wait_timeout ' . $e->getMessage()); } $service = new WechatWorkService(); // 1. 获取部门成员列表(从根部门开始,递归获取所有成员) $departmentId = config('project.qywx_sync_department_id', 1); Log::info('开始同步企业微信客户 - 部门ID: ' . $departmentId); $userList = $service->getDepartmentUserList($departmentId, true); Log::info('获取到企业成员数量: ' . count($userList)); if (empty($userList)) { Log::error('未获取到企业成员列表'); self::$error = '未获取到企业成员列表'; return false; } $syncCount = 0; $updateCount = 0; $newCount = 0; $skippedCount = 0; $ctimeFrom = (int) ($options['follow_createtime_from'] ?? 0); $ctimeTo = (int) ($options['follow_createtime_to'] ?? 0); $filterByFollowCreatetime = $ctimeFrom > 0 && $ctimeTo >= $ctimeFrom; $followCtimeMode = (string) ($options['follow_createtime_mode'] ?? 'first'); if ($filterByFollowCreatetime) { $tz = (string) config('app.default_timezone', 'Asia/Shanghai'); Log::info(sprintf( 'qywx sync: 按跟进时间筛选 mode=%s 窗口=[%s .. %s] tz=%s', $followCtimeMode, date('Y-m-d H:i:s', $ctimeFrom), date('Y-m-d H:i:s', $ctimeTo), $tz )); } // 不使用包层 Db 事务:同步耗时长且含大量 API 调用;模型在已有事务下会嵌套 SAVEPOINT, // 易导致 MySQL 1305「SAVEPOINT trans2 does not exist」。逐条写入由单次 SQL 保证原子即可。 try { $useBatch = (bool) config('project.qywx_sync_use_batch_detail', true); $mergedMap = null; /** 已走「时间筛选 + 流式 batch」分支且未抛错时,不再 list+get 全量回退 */ $createtimeFilterBatchPathDone = false; if ($useBatch) { try { // 按跟进时间筛选:不构建全量合并表(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; } } if ($mergedMap !== null) { foreach ($mergedMap as $bundle) { self::upsertOneExternalContactBundle( $bundle['external_contact'], $bundle['follow_users'], $filterByFollowCreatetime, $followCtimeMode, $ctimeFrom, $ctimeTo, $syncCount, $newCount, $updateCount, $skippedCount ); } } elseif (!$createtimeFilterBatchPathDone) { $processedCustomers = []; foreach ($userList as $user) { $userId = $user['userid'] ?? ''; if (empty($userId)) { continue; } Log::info('获取成员客户列表 - userId: ' . $userId . ', name: ' . ($user['name'] ?? '')); $customerList = $service->getExternalContactList($userId); if ($customerList === false) { Log::error('获取客户列表失败 - 可能是API权限未开启(错误码48002)'); throw new \Exception('企业微信API权限不足,请在企业微信管理后台开启"客户联系"权限'); } Log::info('成员 ' . $userId . ' 的客户数量: ' . count($customerList)); if (empty($customerList)) { continue; } foreach ($customerList as $externalUserId) { if (isset($processedCustomers[$externalUserId])) { continue; } $processedCustomers[$externalUserId] = true; $detail = $service->getExternalContactDetail($externalUserId); if (empty($detail)) { continue; } $externalContact = $detail['external_contact'] ?? []; $followUsers = $detail['follow_user'] ?? []; self::upsertOneExternalContactBundle( $externalContact, is_array($followUsers) ? $followUsers : [], $filterByFollowCreatetime, $followCtimeMode, $ctimeFrom, $ctimeTo, $syncCount, $newCount, $updateCount, $skippedCount ); } } } // 4. 更新同步设置 self::updateSyncStatus('success', $syncCount); Log::info('同步完成 - 总数: ' . $syncCount . ', 新增: ' . $newCount . ', 更新: ' . $updateCount); return [ 'sync_count' => $syncCount, 'new_count' => $newCount, 'update_count' => $updateCount, 'skipped_count' => $skippedCount, ]; } catch (\Throwable $e) { Log::error('同步企业微信客户失败: ' . $e->getMessage()); self::$error = '同步失败: ' . $e->getMessage(); self::updateSyncStatus('failed', 0, $e->getMessage()); return false; } } catch (\Throwable $e) { Log::error('同步企业微信客户异常: ' . $e->getMessage()); self::$error = '同步异常: ' . $e->getMessage(); self::updateSyncStatus('failed', 0, $e->getMessage()); return false; } finally { self::releaseSyncProcessLock(); Cache::delete(self::SYNC_LOCK_KEY); } } /** * 企微 follow_user 单条里的添加时间,统一为 Unix 秒(兼容毫秒、create_time 字段名) * * @param array $fu */ public static function normalizeFollowUserCreatetime(array $fu): int { $raw = $fu['createtime'] ?? $fu['create_time'] ?? 0; $ct = (int) $raw; if ($ct > 1_000_000_000_000) { $ct = intdiv($ct, 1000); } return $ct; } /** * follow_user[].createtime 最小值:企微侧「首次添加为外部联系人」时间(Unix 秒),无则 0 * * @param array $followUsers */ public static function minFollowCreatetime(array $followUsers): int { $min = 0; foreach ($followUsers as $fu) { if (!is_array($fu)) { continue; } $ct = self::normalizeFollowUserCreatetime($fu); if ($ct > 0 && ($min === 0 || $ct < $min)) { $min = $ct; } } return $min; } /** * 「今日」起止 Unix 秒(闭区间结束为次日 0 秒前一秒),使用 app.default_timezone * * @return array{0:int,1:int} */ public static function todayCreatetimeWindowBounds(): array { $tzName = (string) config('app.default_timezone', 'Asia/Shanghai'); $tz = new \DateTimeZone($tzName); $from = (new \DateTime('today', $tz))->getTimestamp(); $to = (new \DateTime('tomorrow', $tz))->getTimestamp() - 1; return [$from, $to]; } /** * 客户详情 follow_user 中 createtime 是否有任一条落在 [from, to](闭区间,Unix 秒) * * @param array $followUsers */ private static function followCreatetimeInRange(array $followUsers, int $from, int $to): bool { foreach ($followUsers as $fu) { if (!is_array($fu)) { continue; } $ct = self::normalizeFollowUserCreatetime($fu); if ($ct >= $from && $ct <= $to) { return true; } } return false; } /** * 流式扫 batch/get_by_user,按单条 follow_info 做时间窗口启发式,收集可能要落库的 external_userid(去重)。 * 企微无「只拉今日」接口,仍需翻完所有分页;但不构建全量合并表,后续仅对候选调用 get 做精确过滤。 * * @return array */ 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 $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 按成员分页拉取并合并跟进人(列表接口无按时间筛选) * * @param array> $userList * @return array, follow_users: array>}> */ private static function buildMergedExternalContactsMap(WechatWorkService $service, array $userList): array { $merged = []; 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)) { self::mergeBatchExternalItemIntoMap($merged, $item); } } $cursor = (string) ($resp['next_cursor'] ?? ''); } while ($cursor !== ''); } return $merged; } /** * @param array, follow_users: array>}> $merged * @param array $item */ private static function mergeBatchExternalItemIntoMap(array &$merged, array $item): void { $ec = $item['external_contact'] ?? []; if (!is_array($ec)) { $ec = []; } $fi = $item['follow_info'] ?? []; if (!is_array($fi)) { $fi = []; } $extId = trim((string) ($ec['external_userid'] ?? '')); if ($extId === '') { return; } if (!isset($merged[$extId])) { $merged[$extId] = [ 'external_contact' => $ec, 'follow_users' => [], ]; } else { $merged[$extId]['external_contact'] = self::mergeExternalContactShallow( $merged[$extId]['external_contact'], $ec ); } $uid = trim((string) ($fi['userid'] ?? '')); if ($uid === '') { return; } foreach ($merged[$extId]['follow_users'] as $ex) { if (is_array($ex) && (($ex['userid'] ?? '') === $uid)) { return; } } $merged[$extId]['follow_users'][] = $fi; } /** * @param array $base * @param array $in * @return array */ private static function mergeExternalContactShallow(array $base, array $in): array { foreach ($in as $k => $v) { if ($v === null || $v === '') { continue; } if (!array_key_exists($k, $base) || $base[$k] === '' || $base[$k] === null) { $base[$k] = $v; } } return $base; } /** * @param array $externalContact * @param array> $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, bool $filterByFollowCreatetime, string $followCtimeMode, int $ctimeFrom, int $ctimeTo, int &$syncCount, int &$newCount, int &$updateCount, int &$skippedCount ): void { if ($filterByFollowCreatetime) { if ($followCtimeMode === 'any') { $pass = self::followCreatetimeInRange($followUsers, $ctimeFrom, $ctimeTo); } else { $minCt = self::minFollowCreatetime($followUsers); $pass = $minCt >= $ctimeFrom && $minCt <= $ctimeTo; } if (!$pass) { $skippedCount++; return; } } $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), 'follow_admin_ids' => self::resolveFollowAdminIds($followUsers), 'external_first_add_time' => self::minFollowCreatetime($followUsers), 'create_time' => $now, 'update_time' => $now, 'delete_time' => null, ]; self::upsertExternalContactRow($row, $syncCount, $newCount, $updateCount); if (($syncCount % 25) === 0) { usleep(8000); } } /** * 将企微 follow_user[].userid 与后台 admin.work_wechat_userid 对齐,得到 zyt_admin.id 列表 JSON。 * * @param array $followUsers */ private static function resolveFollowAdminIds(array $followUsers): string { $wxUserids = []; foreach ($followUsers as $fu) { if (!is_array($fu)) { continue; } $uid = trim((string) ($fu['userid'] ?? '')); if ($uid !== '') { $wxUserids[] = $uid; } } $wxUserids = array_values(array_unique($wxUserids)); if ($wxUserids === []) { return '[]'; } $idByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('id', 'work_wechat_userid'); $adminIds = []; foreach ($wxUserids as $wx) { if (!empty($idByWx[$wx])) { $adminIds[] = (int) $idByWx[$wx]; } } return json_encode(array_values(array_unique($adminIds)), JSON_UNESCAPED_UNICODE); } /** * 是否 MySQL 锁等待超时 / 死锁(可短重试) */ private static function isMysqlLockTimeoutOrDeadlock(\Throwable $e): bool { $m = $e->getMessage(); return str_contains($m, '1205') || str_contains($m, '1213') || str_contains($m, 'Deadlock'); } /** * INSERT ... ON DUPLICATE KEY UPDATE + 锁冲突重试 * * @param array $row */ private static function upsertExternalContactRow(array $row, int &$syncCount, int &$newCount, int &$updateCount): void { $maxAttempts = 12; $attempt = 0; 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', 'follow_admin_ids', 'external_first_add_time', 'update_time', 'delete_time', ])->insert($row); $syncCount++; // PDO MySQL:新插入 1;更新 2;值完全相同 0 if ($affected === 1) { $newCount++; } elseif ($affected === 2) { $updateCount++; } elseif ($affected === 0) { $updateCount++; } return; } catch (\Throwable $e) { if ($attempt >= $maxAttempts || !self::isMysqlLockTimeoutOrDeadlock($e)) { throw $e; } usleep(min(800_000, 40_000 * (2 ** ($attempt - 1)))); } } } /** * @notes 获取统计信息 */ public static function getStats() { $total = QywxExternalContact::whereNull('delete_time')->count(); $todayStart = strtotime(date('Y-m-d 00:00:00')); // 今日新增:以企微首次添加时间为准;历史行未回填字段(0)时退回 create_time $today = QywxExternalContact::whereNull('delete_time') ->whereRaw( '(external_first_add_time >= ? OR (external_first_add_time = 0 AND create_time >= ?))', [$todayStart, $todayStart] ) ->count(); $settings = self::getSyncSettings(); $lastSyncTime = $settings['last_sync_time'] ?? 0; $lastSync = $lastSyncTime > 0 ? date('Y-m-d H:i:s', $lastSyncTime) : ''; return [ 'total' => $total, 'today' => $today, 'lastSync' => $lastSync, 'syncStatus' => $settings['sync_status'] ?? 'idle', 'syncStatusText' => self::getSyncStatusText($settings['sync_status'] ?? 'idle'), ]; } /** * @notes 获取同步设置 */ public static function getSyncSettings() { $setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find(); if (!$setting) { return [ 'auto_sync' => false, 'interval' => 3600, 'last_sync_time' => 0, 'sync_status' => 'idle', ]; } $value = json_decode($setting->setting_value, true); return $value ?: [ 'auto_sync' => false, 'interval' => 3600, 'last_sync_time' => 0, 'sync_status' => 'idle', ]; } /** * @notes 保存同步设置 */ public static function saveSyncSettings(array $params) { try { $currentSettings = self::getSyncSettings(); $newSettings = array_merge($currentSettings, [ 'auto_sync' => (bool)($params['auto_sync'] ?? false), 'interval' => (int)($params['interval'] ?? 3600), ]); $setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find(); if ($setting) { $setting->setting_value = json_encode($newSettings, JSON_UNESCAPED_UNICODE); $setting->update_time = time(); $setting->save(); } else { QywxSyncSettings::create([ 'setting_key' => 'customer_sync', 'setting_value' => json_encode($newSettings, JSON_UNESCAPED_UNICODE), 'create_time' => time(), 'update_time' => time(), ]); } return true; } catch (\Throwable $e) { self::$error = '保存失败: ' . $e->getMessage(); return false; } } /** * @param bool $bumpLastSyncTime 为 false 时仅改状态(用于 syncing),避免「最后同步」在跑任务期间被刷新 */ private static function updateSyncStatus(string $status, int $syncCount = 0, string $error = '', bool $bumpLastSyncTime = true) { try { $settings = self::getSyncSettings(); $settings['sync_status'] = $status; if ($bumpLastSyncTime) { $settings['last_sync_time'] = time(); $settings['last_sync_count'] = $syncCount; } if ($error) { $settings['last_error'] = $error; } $payload = json_encode($settings, JSON_UNESCAPED_UNICODE); $now = time(); // 直连 UPDATE,避免模型层隐式事务/事件拉长锁时间 $n = Db::name('qywx_sync_settings') ->where('setting_key', 'customer_sync') ->update([ 'setting_value' => $payload, 'update_time' => $now, ]); if ($n === 0) { Db::name('qywx_sync_settings')->insert([ 'setting_key' => 'customer_sync', 'setting_value' => $payload, 'create_time' => $now, 'update_time' => $now, ]); } } catch (\Throwable $e) { Log::error('更新同步状态失败: ' . $e->getMessage()); } } /** * @notes 获取同步状态文本 */ private static function getSyncStatusText(string $status): string { $map = [ 'idle' => '未同步', 'syncing' => '同步中', 'success' => '同步成功', 'failed' => '同步失败', ]; return $map[$status] ?? '未知'; } }