|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(不走全量同步文件锁)。 * * @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, 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; // 接口偶发抖动:最多重试 1 次(共 2 次请求),间隔 1.2s。仅对「非预期错误 / 空响应」重试, // 业务预期错误(如 84061 客户未通过、60011/60111 无权限、41068 非法 ID)不重试,避免无谓压力。 $maxAttempts = 2; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $err = null; $detail = $service->getExternalContactDetail($externalUserId, $err); if (!empty($detail['external_contact']) && is_array($detail['external_contact'])) { break; } $lastError = $err; $errcode = is_array($err) ? ($err['errcode'] ?? null) : null; $shouldRetry = $attempt < $maxAttempts && !in_array($errcode, [84061, 41068, 60011, 60111], true); if (!$shouldRetry) { break; } usleep(1_200_000); } 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, $errmsg !== '' ? $errmsg : '-', $externalUserId )); return; } $followUsers = $detail['follow_user'] ?? []; if (!is_array($followUsers)) { $followUsers = []; } // 重复添加:不动原有行,新增一行并打「以前加过」标记(与企微"每次添加都算一条"对齐) if ($isAddEvent && $existedBefore) { self::insertReaddContactRow( $detail['external_contact'], $followUsers, $eventUserId, $eventTime ); return; } $syncCount = 0; $newCount = 0; $updateCount = 0; $skippedCount = 0; self::upsertOneExternalContactBundle( $detail['external_contact'], $followUsers, false, 'first', 0, 0, $syncCount, $newCount, $updateCount, $skippedCount ); } /** * 重复添加:为已存在的客户新增一行记录(原有行保持不变)。 * * 新行的「添加时间」取本次添加动作的时间:优先本次添加人($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); } /** * 事件流水(零误差进入计数) * * 每次 change_external_contact 回调都调用一次,`INSERT IGNORE` 幂等: * - 企微对非 2xx 响应最多重试 3 次,同一 (change_type,user_id,external_userid,event_time) 只会计一行; * - 与业务 UPSERT 解耦,即便后续 DB 逻辑抛错也不影响"今日进入数"统计。 * * 入参 $data: * - change_type string * - user_id string * - external_userid string * - state string * - fail_reason string * - welcome_code int 0/1(只存是否带欢迎码,不落原 code,避免泄漏) * - event_time int 企微 CreateTime(秒) * - raw mixed 原始消息(用于留痕;非标量会 json_encode) * * @param array $data */ public static function recordExternalContactEvent(array $data): void { $changeType = (string) ($data['change_type'] ?? ''); if ($changeType === '') { // 没有 ChangeType 的事件流水没有价值,直接丢弃 return; } $eventTime = (int) ($data['event_time'] ?? 0); if ($eventTime <= 0) { $eventTime = time(); } $raw = $data['raw'] ?? null; if ($raw !== null && !is_string($raw)) { try { $raw = json_encode($raw, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR); } catch (\Throwable) { $raw = null; } } $row = [ 'change_type' => $changeType, 'user_id' => (string) ($data['user_id'] ?? ''), 'external_userid' => (string) ($data['external_userid'] ?? ''), 'state' => mb_substr((string) ($data['state'] ?? ''), 0, 128), 'fail_reason' => mb_substr((string) ($data['fail_reason'] ?? ''), 0, 64), 'welcome_code' => !empty($data['welcome_code']) ? 1 : 0, 'event_time' => $eventTime, 'create_time' => time(), 'raw' => $raw, ]; try { // INSERT IGNORE:命中唯一键 (change_type,user_id,external_userid,event_time) 时静默跳过。 // ThinkPHP 未提供统一的 ignore helper,这里直接用原生 SQL(与 SyncImChatArchive 的做法一致)。 $cols = array_keys($row); $table = config('database.connections.mysql.prefix') . 'qywx_external_contact_event'; $sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ')'; Db::execute($sql, array_values($row)); } catch (\Throwable $e) { // 事件流水只用于统计,失败只记日志不阻塞主回调 Log::warning('qywx external contact event insert failed: ' . $e->getMessage()); } } /** * 客户彻底流失(企微 get 返回 84061):该客户的所有行(含历史重加行)一并软删。 */ 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, ]); // 客户已删 → 关系表硬删(无 delete_time 列;统计场景不需要保留) Db::name('qywx_external_contact_tag') ->where('external_userid', $externalUserId) ->delete(); Db::name('qywx_external_contact_follow') ->where('external_userid', $externalUserId) ->delete(); } 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; } } $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); } } /** * 同步「客户↔员工↔标签」关系表(zyt_qywx_external_contact_tag)。 * * 行粒度:(external_userid, follow_user_id, tag_id) 三元组——同一标签由不同员工打则各占一行。 * 用于检索/统计/聚合:按标签筛客户、统计某标签客户数、多标签 AND/OR、按分组漏斗等。 * * 写入策略(保留 create_time,仅在改名/分组变化时刷 tag_name/group_name/type): * 1. 计算目标三元组集合 * 2. 删除"该 external_userid 下"不在新集合里的旧关系(含跟进人离开 / 标签移除) * 3. 对每个新三元组 INSERT ... ON DUPLICATE KEY UPDATE 刷新冗余字段 * * @param array $followUsers /externalcontact/get 返回的 follow_user[] * @internal 仅供 UPSERT / 回填命令复用 */ public static function syncContactTagsRelation(string $externalUserId, array $followUsers): void { $externalUserId = trim($externalUserId); if ($externalUserId === '') { return; } // 1. 拍平为 (follow_user_id, tag_id) → 行 的字典;保持 follow_user_id 维度区分 $rows = []; foreach ($followUsers as $fu) { if (!is_array($fu)) { continue; } $followUserId = trim((string) ($fu['userid'] ?? '')); $tags = $fu['tags'] ?? []; if (!is_array($tags)) { continue; } foreach ($tags as $t) { if (!is_array($t)) { continue; } $tagId = trim((string) ($t['tag_id'] ?? '')); if ($tagId === '') { continue; } $key = $followUserId . '|' . $tagId; $rows[$key] = [ 'follow_user_id' => mb_substr($followUserId, 0, 64), 'tag_id' => mb_substr($tagId, 0, 64), 'tag_name' => mb_substr((string) ($t['tag_name'] ?? ''), 0, 128), 'group_name' => mb_substr((string) ($t['group_name'] ?? ''), 0, 128), 'type' => isset($t['type']) ? (int) $t['type'] : 1, ]; } } $now = time(); // 2. 删除该客户下不在目标集合里的旧关系 if ($rows === []) { Db::name('qywx_external_contact_tag') ->where('external_userid', $externalUserId) ->delete(); return; } // 用 (follow_user_id || tag_id) 拼字符串过滤;记录少时 PHP 拉出来比 SQL CONCAT 更高效 $existing = Db::name('qywx_external_contact_tag') ->where('external_userid', $externalUserId) ->field(['id', 'follow_user_id', 'tag_id']) ->select() ->toArray(); $deleteIds = []; foreach ($existing as $ex) { $key = (string) ($ex['follow_user_id'] ?? '') . '|' . (string) ($ex['tag_id'] ?? ''); if (!isset($rows[$key])) { $deleteIds[] = (int) $ex['id']; } } if ($deleteIds !== []) { Db::name('qywx_external_contact_tag') ->whereIn('id', $deleteIds) ->delete(); } // 3. UPSERT 新关系(命中唯一键时只刷冗余字段 + update_time,create_time 保持原值) foreach ($rows as $r) { try { Db::name('qywx_external_contact_tag') ->duplicate(['tag_name', 'group_name', 'type', 'update_time']) ->insert([ 'external_userid' => $externalUserId, 'follow_user_id' => $r['follow_user_id'], 'tag_id' => $r['tag_id'], 'tag_name' => $r['tag_name'], 'group_name' => $r['group_name'], 'type' => $r['type'], 'create_time' => $now, 'update_time' => $now, ]); } catch (\Throwable $e) { Log::warning('qywx external contact tag upsert failed: ' . $e->getMessage(), [ 'ext' => $externalUserId, 'follow_user_id' => $r['follow_user_id'], 'tag_id' => $r['tag_id'], ]); } } } /** * 同步「客户↔员工」现存跟进关系表(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 字符串。 * * 一个客户可能同时被多个员工跟进,每个员工各自打的 tag 在各自的 follow_user.tags 里; * 这里按 tag_id 唯一去重,保留 tag_name / group_name / type 完整结构,便于后台直接渲染/筛选, * 不必再解析 follow_users 大 JSON。 * * 来源结构示例: * "tags":[{"group_name":"自媒体1","tag_name":"自媒体1","type":1,"tag_id":"etRr2..."}] * * @internal 仅供 UPSERT / 回填命令复用,不属于业务对外 API * @param array $followUsers */ public static function extractFollowUserTags(array $followUsers): string { $bag = []; foreach ($followUsers as $fu) { if (!is_array($fu)) { continue; } $tags = $fu['tags'] ?? []; if (!is_array($tags)) { continue; } foreach ($tags as $t) { if (!is_array($t)) { continue; } $tagId = trim((string) ($t['tag_id'] ?? '')); if ($tagId === '') { continue; } if (isset($bag[$tagId])) { continue; } $bag[$tagId] = [ 'group_name' => (string) ($t['group_name'] ?? ''), 'tag_name' => (string) ($t['tag_name'] ?? ''), 'type' => isset($t['type']) ? (int) $t['type'] : 1, 'tag_id' => $tagId, ]; } } return json_encode(array_values($bag), JSON_UNESCAPED_UNICODE); } /** * 将企微 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); } /** * 由企微 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 锁等待超时 / 死锁(可短重试) */ private static function isMysqlLockTimeoutOrDeadlock(\Throwable $e): bool { $m = $e->getMessage(); return str_contains($m, '1205') || str_contains($m, '1213') || str_contains($m, 'Deadlock'); } /** * 客户行写入 + 锁冲突重试。 * * 表允许同一 external_userid 多行(重复添加会新增行),不再依赖唯一键 UPSERT: * - 已有行 → 只更新"最新一行"(MAX(id)),历史重加行保持原样; * - 无行 → 插入新行。 * 更新时不覆盖 create_time / readd_flag / external_first_add_time * (最新行的添加时间代表"该行那次添加"的时间,由插入时决定,后续详情刷新不应改动)。 * * @param array $row */ private static function upsertExternalContactRow(array $row, int &$syncCount, int &$newCount, int &$updateCount): void { $maxAttempts = 12; $attempt = 0; while (true) { $attempt++; try { $latestId = (int) Db::name('qywx_external_contact') ->where('external_userid', (string) $row['external_userid']) ->order('id', 'desc') ->value('id'); 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++; } $syncCount++; return; } catch (\Throwable $e) { if ($attempt >= $maxAttempts || !self::isMysqlLockTimeoutOrDeadlock($e)) { throw $e; } usleep(min(800_000, 40_000 * (2 ** ($attempt - 1)))); } } } /** * 标签维度统计:用于「按标签筛客户」下拉选项 + 「按标签看分布」面板 * * 数据源:zyt_qywx_external_contact_tag(关系表)+ zyt_qywx_external_contact(剔除已软删客户) * 输出按 group_name 分组、组内按客户数倒序,每条带 tag_id / tag_name / customer_count * * @return array{ * total_tags: int, * total_relations: int, * total_tagged_customers: int, * groups: array}> * } */ public static function getTagStats(): array { // 关系表里 external_userid 可能指向已被软删的客户;这里 INNER JOIN 主表过滤未删除的 $rows = Db::name('qywx_external_contact_tag') ->alias('ect') ->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER') ->whereNull('ec.delete_time') ->field([ 'ect.tag_id', 'ect.tag_name', 'ect.group_name', 'COUNT(DISTINCT ect.external_userid) AS customer_count', ]) ->group('ect.tag_id, ect.tag_name, ect.group_name') ->order('customer_count', 'desc') ->select() ->toArray(); $groupMap = []; $customersUnion = []; foreach ($rows as $r) { $g = (string) ($r['group_name'] ?? ''); if (!isset($groupMap[$g])) { $groupMap[$g] = [ 'group_name' => $g, 'customer_count' => 0, 'tags' => [], ]; } $cnt = (int) ($r['customer_count'] ?? 0); $groupMap[$g]['tags'][] = [ 'tag_id' => (string) ($r['tag_id'] ?? ''), 'tag_name' => (string) ($r['tag_name'] ?? ''), 'customer_count' => $cnt, ]; } // 组内已经按 customer_count desc 排好序了(外面 SQL 排序结果); // 组本身按"组内最大 customer_count"倒序排(最热门标签所在组优先) foreach ($groupMap as &$g) { $g['customer_count'] = $g['tags'][0]['customer_count'] ?? 0; } unset($g); $groups = array_values($groupMap); usort($groups, static function ($a, $b) { return $b['customer_count'] <=> $a['customer_count']; }); // 全表 distinct 客户数(带 tag 的客户) $taggedCustomers = (int) Db::name('qywx_external_contact_tag') ->alias('ect') ->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER') ->whereNull('ec.delete_time') ->group('ect.external_userid') ->count(); $totalRelations = (int) Db::name('qywx_external_contact_tag') ->alias('ect') ->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER') ->whereNull('ec.delete_time') ->count(); return [ 'total_tags' => count($rows), 'total_relations' => $totalRelations, 'total_tagged_customers' => $taggedCustomers, 'groups' => $groups, ]; } /** * @notes 获取统计信息 */ public static function getStats() { // 表内同一客户可能多行(重复添加各占一行),去重后才是真实客户数 $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 事件条数。 // - 事件表 INSERT IGNORE 幂等,企微自动重试不会重复计数; // - 与业务表 UPSERT/软删解耦,客户即使很快被删、或 /externalcontact/get 同步失败,都不影响计数; // - 取 event_time(企微 CreateTime)而不是 create_time,跨日分钟内推送过来也归到事件真实日期。 $today = (int) Db::name('qywx_external_contact_event') ->where('change_type', 'add_external_contact') ->where('event_time', '>=', $todayStart) ->count(); /** 今日新增客户中,跟进人条数合计(同一 userid 多客户或多条均累计,不去重) */ $todayFollowStaff = 0; $followJsonList = QywxExternalContact::whereNull('delete_time') ->whereRaw( '(external_first_add_time >= ? OR (external_first_add_time = 0 AND create_time >= ?))', [$todayStart, $todayStart] ) ->column('follow_users'); foreach ($followJsonList as $json) { $raw = json_decode((string) $json, true); if (!is_array($raw)) { continue; } foreach ($raw as $fu) { if (!is_array($fu)) { continue; } $wx = trim((string) ($fu['userid'] ?? '')); if ($wx !== '') { $todayFollowStaff++; } } } $settings = self::getSyncSettings(); $lastSyncTime = $settings['last_sync_time'] ?? 0; $lastSync = $lastSyncTime > 0 ? date('Y-m-d H:i:s', $lastSyncTime) : ''; return [ 'total' => $total, 'relation_total' => $relationTotal, 'today' => $today, 'today_follow_staff' => $todayFollowStaff, 'lastSync' => $lastSync, 'syncStatus' => $settings['sync_status'] ?? 'idle', 'syncStatusText' => self::getSyncStatusText($settings['sync_status'] ?? 'idle'), ]; } /** * 今日"进入"分布概览(直观卡片用) * * 数据源:qywx_external_contact_event(add_external_contact)——回调一到就幂等写入, * 与 qywx_external_contact 的软删/UPSERT 解耦,保证"进入数"零误差。 * * 返回: * - total int 今日进入总数(= stats.today) * - recent_time int 今日最近一条 add 事件时间戳(0 表示今天还没进人) * - hourly int[24] 按 0-23 点分布(本地时区) * - by_state array 按 state 分组 Top 5:[{state, count}] * * @return array */ public static function getTodayArrivalStats(): array { $todayStart = strtotime(date('Y-m-d 00:00:00')); $todayEnd = $todayStart + 86400; $total = (int) Db::name('qywx_external_contact_event') ->where('change_type', 'add_external_contact') ->where('event_time', '>=', $todayStart) ->where('event_time', '<', $todayEnd) ->count(); $recent = (int) Db::name('qywx_external_contact_event') ->where('change_type', 'add_external_contact') ->where('event_time', '>=', $todayStart) ->where('event_time', '<', $todayEnd) ->max('event_time'); $hourly = array_fill(0, 24, 0); if ($total > 0) { // 用 FROM_UNIXTIME 按本地时区分桶;php.ini / mysql time_zone 有差异时,可改 DATE_FORMAT $rows = Db::name('qywx_external_contact_event') ->fieldRaw('HOUR(FROM_UNIXTIME(event_time)) AS h, COUNT(*) AS c') ->where('change_type', 'add_external_contact') ->where('event_time', '>=', $todayStart) ->where('event_time', '<', $todayEnd) ->group('h') ->select() ->toArray(); foreach ($rows as $r) { $h = (int) ($r['h'] ?? 0); if ($h >= 0 && $h <= 23) { $hourly[$h] = (int) ($r['c'] ?? 0); } } } $byState = []; if ($total > 0) { $stateRows = Db::name('qywx_external_contact_event') ->field(['state', 'COUNT(*) AS c']) ->where('change_type', 'add_external_contact') ->where('event_time', '>=', $todayStart) ->where('event_time', '<', $todayEnd) ->group('state') ->orderRaw('c DESC') ->limit(5) ->select() ->toArray(); foreach ($stateRows as $r) { $byState[] = [ 'state' => (string) ($r['state'] ?? ''), 'count' => (int) ($r['c'] ?? 0), ]; } } // 老客户回流数(去重客户维度):今天有 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; } /** * 今日"进入明细"流水(分页,按时间倒序) * * 左联客户表补客户名/头像,左联 admin 补员工名;客户已删 / 同步未回不影响流水本身。 * * @return array{total:int, page_no:int, page_size:int, lists:array>} */ public static function getTodayArrivalList(int $pageNo, int $pageSize): array { $pageNo = max(1, $pageNo); $pageSize = min(100, max(1, $pageSize)); $todayStart = strtotime(date('Y-m-d 00:00:00')); $todayEnd = $todayStart + 86400; $base = Db::name('qywx_external_contact_event') ->where('change_type', 'add_external_contact') ->where('event_time', '>=', $todayStart) ->where('event_time', '<', $todayEnd); $total = (int) (clone $base)->count(); $rows = (clone $base) ->orderRaw('event_time DESC, id DESC') ->page($pageNo, $pageSize) ->select() ->toArray(); if ($rows === []) { return ['total' => $total, 'page_no' => $pageNo, 'page_size' => $pageSize, 'lists' => []]; } $extIds = array_values(array_unique(array_filter(array_map(static fn($r) => (string) ($r['external_userid'] ?? ''), $rows)))); $userIds = array_values(array_unique(array_filter(array_map(static fn($r) => (string) ($r['user_id'] ?? ''), $rows)))); $customerMap = []; if ($extIds !== []) { $customerRows = Db::name('qywx_external_contact') ->field(['external_userid', 'name', 'avatar']) ->whereIn('external_userid', $extIds) ->select() ->toArray(); foreach ($customerRows as $c) { $customerMap[(string) $c['external_userid']] = $c; } } $adminMap = []; if ($userIds !== []) { $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'] ?? ''); $uid = (string) ($r['user_id'] ?? ''); $lists[] = [ 'id' => (int) $r['id'], 'event_time' => (int) $r['event_time'], 'user_id' => $uid, 'admin_name' => (string) ($adminMap[$uid] ?? ''), 'external_userid' => $ext, 'customer_name' => (string) ($customerMap[$ext]['name'] ?? ''), '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, ]; } return [ 'total' => $total, 'page_no' => $pageNo, 'page_size' => $pageSize, 'lists' => $lists, ]; } /** * @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] ?? '未知'; } }