fail('缺少用户ID参数'); } $service = new \app\common\service\wechat\WechatWorkService(); $result = $service->batchGetCustomerDetails($userId, $cursor, $limit); if ($result === false) { return $this->fail('获取客户列表失败,可能是权限不足'); } // 构建响应数据 $response = [ 'customers' => $result['customers'] ?? [], 'next_cursor' => $result['next_cursor'] ?? '', 'count' => $result['count'] ?? 0, 'limit' => $limit ]; return $this->success('获取成功', $response); } catch (\Throwable $e) { return $this->fail('获取失败: ' . $e->getMessage()); } } /** * @notes 客户列表 */ public function lists() { return $this->dataLists(new CustomerLists()); } /** * @notes 获取企业微信token */ public function getAccessToken() { $token = CustomerLogic::getAccessToken(); if ($token === false) { return $this->fail(CustomerLogic::getError()); } return $this->success('获取成功', ['access_token' => $token]); } /** * @notes 获取外部联系人列表 */ public function getExternalContacts() { $userId = input('user_id', ''); $contacts = CustomerLogic::getExternalContacts($userId); if ($contacts === false) { return $this->fail(CustomerLogic::getError()); } return $this->success('获取成功', $contacts); } /** * @notes 获取客户详情 */ public function getContactDetail() { $externalUserId = input('external_user_id', ''); if (empty($externalUserId)) { return $this->fail('缺少外部联系人ID'); } $detail = CustomerLogic::getContactDetail($externalUserId); if ($detail === false) { return $this->fail(CustomerLogic::getError()); } return $this->success('获取成功', $detail); } /** * @notes 获取统计信息 */ public function stats() { $stats = CustomerLogic::getStats(); return $this->data($stats); } /** * @notes 按时间点统计用户数量 */ public function countByTime() { try { $time = input('time', time()); $timestamp = is_numeric($time) ? (int)$time : strtotime($time); if (!$timestamp) { return $this->fail('时间格式错误'); } $count = \app\common\model\QywxExternalContact::where('created_at', '<=', $timestamp)->count(); return $this->success('获取成功', [ 'count' => $count, 'time' => date('Y-m-d H:i:s', $timestamp) ]); } catch (\Throwable $e) { return $this->fail('获取失败: ' . $e->getMessage()); } } /** * @notes 获取企业微信员工列表 */ public function getStaffList() { try { $service = new \app\common\service\wechat\WechatWorkService(); $userList = $service->getDepartmentUserList(); // 确保返回的数据是正确的UTF-8编码 $userList = array_map(function($user) { return $this->recursiveConvert($user); }, $userList); return $this->success('获取成功', $userList); } catch (\Throwable $e) { error_log('getStaffList error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); error_log('Stack trace: ' . $e->getTraceAsString()); return $this->fail('获取失败: ' . $e->getMessage()); } } /** * @notes 获取同步设置 */ public function getSyncSettings() { // 从缓存获取同步设置 $cacheKey = 'qywx_sync_settings'; $settings = Cache::get($cacheKey, [ 'auto_sync' => false, 'interval' => 3600, 'limit' => 30, 'days' => 7 ]); return $this->success('获取成功', $settings); } /** * @notes 保存同步设置 */ public function saveSyncSettings() { $settings = input('post.'); // 验证并设置默认值 $validatedSettings = [ 'auto_sync' => (bool)($settings['auto_sync'] ?? false), 'interval' => max(60, (int)($settings['interval'] ?? 3600)), // 最小1分钟 'limit' => min(100, max(10, (int)($settings['limit'] ?? 30))), // 10-100之间 'days' => min(30, max(1, (int)($settings['days'] ?? 7))) // 1-30天之间 ]; // 保存到缓存 $cacheKey = 'qywx_sync_settings'; Cache::set($cacheKey, $validatedSettings, 86400 * 30); // 缓存30天 return $this->success('保存成功', $validatedSettings); } /** * @notes 同步企业微信客户 */ public function sync() { try { $limit = (int)input('limit', 30); $fullSync = (bool)input('full_sync', false); $syncMode = input('sync_mode', 'all'); $selectedUsers = input('selected_users', []); $days = (int)input('days', 7); // 最近几天的数据 // 设置执行超时时间 set_time_limit(120); Log::info('开始同步企业微信客户', [ 'limit' => $limit, 'full_sync' => $fullSync, 'sync_mode' => $syncMode, 'selected_users' => $selectedUsers, 'days' => $days ]); $service = new \app\common\service\wechat\WechatWorkService(); // 1. 获取员工列表 $userList = $service->getDepartmentUserList(); if (empty($userList)) { Log::warning('未获取到员工列表'); return $this->fail('未获取到员工列表'); } // 按同步模式过滤员工 if ($syncMode === 'specific' && !empty($selectedUsers)) { $userList = array_filter($userList, function($user) use ($selectedUsers) { return in_array($user['userid'] ?? '', $selectedUsers); }); } $syncCount = 0; $newCount = 0; $updateCount = 0; $processedCustomers = []; // 获取上次同步时间(使用缓存存储,避免session丢失) $cacheKey = 'qywx_last_sync_time'; $lastSyncTime = Cache::get($cacheKey, 0); $currentTime = time(); // 计算时间范围(最近N天) $timeLimit = $currentTime - ($days * 86400); // 2. 遍历每个员工,获取其客户列表 foreach ($userList as $user) { $userId = $user['userid'] ?? ''; if (empty($userId)) { continue; } Log::info('开始同步员工客户', ['user_id' => $userId, 'user_name' => $user['name'] ?? '']); // 获取该成员的客户列表(支持分页) $cursor = ''; $hasMore = true; while ($hasMore && $syncCount < $limit) { $customerResult = $service->getExternalContactList($userId, $cursor, 100); if (empty($customerResult) || empty($customerResult['external_userid'])) { Log::info('员工无客户数据', ['user_id' => $userId]); break; } $customerList = $customerResult['external_userid'] ?? []; $cursor = $customerResult['next_cursor'] ?? ''; $hasMore = !empty($cursor); Log::info('获取到员工客户列表', [ 'user_id' => $userId, 'customer_count' => count($customerList), 'has_more' => $hasMore ]); // 3. 处理客户,限制总数量 foreach ($customerList as $externalUserId) { // 达到限制数量,停止同步 if ($syncCount >= $limit) { $hasMore = false; break; } // 避免重复处理同一个客户 if (isset($processedCustomers[$externalUserId])) { continue; } $processedCustomers[$externalUserId] = true; // 增量同步:检查客户是否已存在且更新时间较新 if (!$fullSync) { $existing = \app\common\model\QywxExternalContact::where('external_user_id', $externalUserId)->find(); if ($existing && $existing->update_time && strtotime($existing->update_time) > time() - 86400) { continue; } } // 获取客户详情 $detail = $service->getExternalContactDetail($externalUserId); if (empty($detail)) { Log::warning('获取客户详情失败', ['external_user_id' => $externalUserId]); continue; } $externalContact = $detail['external_contact'] ?? []; $followUsers = $detail['follow_user'] ?? []; // 获取客户实际添加时间 $createTime = time(); if (!empty($followUsers)) { // 取第一个跟进人的添加时间 $firstFollow = reset($followUsers); if (isset($firstFollow['create_time'])) { $createTime = $firstFollow['create_time']; } } // 增量同步:只同步上次同步时间之后的客户 if (!$fullSync && $createTime <= $lastSyncTime) { continue; } // 时间范围过滤:只同步最近N天的客户 if ($createTime < $timeLimit) { continue; } // 保存或更新客户信息 $data = [ 'external_user_id' => $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), ]; // 处理添加人信息 if (!empty($followUsers)) { // 取第一个跟进人作为添加人 $firstFollow = reset($followUsers); if (isset($firstFollow['userid']) && isset($firstFollow['name'])) { $data['first_staff_id'] = $firstFollow['userid']; $data['first_staff_name'] = $firstFollow['name']; $data['first_add_time'] = date('Y-m-d H:i:s', $createTime); $data['last_staff_id'] = $firstFollow['userid']; $data['last_staff_name'] = $firstFollow['name']; $data['last_add_time'] = date('Y-m-d H:i:s', $createTime); } } $existing = \app\common\model\QywxExternalContact::where('external_user_id', $data['external_user_id'])->find(); if ($existing) { $existing->save($data); $updateCount++; Log::info('更新客户信息', ['external_user_id' => $data['external_user_id'], 'name' => $data['name']]); } else { \app\common\model\QywxExternalContact::create($data); $newCount++; Log::info('新增客户信息', ['external_user_id' => $data['external_user_id'], 'name' => $data['name']]); } $syncCount++; } } // 达到限制数量,停止同步 if ($syncCount >= $limit) { break; } } // 保存本次同步时间(使用缓存存储) Cache::set($cacheKey, $currentTime, 86400 * 30); // 缓存30天 // 更新同步状态 $syncStatus = [ 'last_sync_time' => date('Y-m-d H:i:s', $currentTime), 'sync_count' => $syncCount, 'new_count' => $newCount, 'update_count' => $updateCount, 'status' => 'completed' ]; Cache::set('qywx_sync_status', $syncStatus, 86400 * 30); Log::info('同步企业微信客户完成', [ 'sync_count' => $syncCount, 'new_count' => $newCount, 'update_count' => $updateCount, 'last_sync_time' => date('Y-m-d H:i:s', $currentTime) ]); return $this->success('同步成功', [ 'sync_count' => $syncCount, 'new_count' => $newCount, 'update_count' => $updateCount, 'last_sync_time' => date('Y-m-d H:i:s', $currentTime), 'time_limit' => date('Y-m-d H:i:s', $timeLimit) ]); } catch (\Throwable $e) { Log::error('同步企业微信客户失败', [ 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine() ]); return $this->fail('同步失败: ' . $e->getMessage()); } } /** * @notes 获取同步状态 */ public function getSyncStatus() { try { // 从缓存获取同步状态 $syncStatus = Cache::get('qywx_sync_status', [ 'last_sync_time' => '', 'sync_count' => 0, 'new_count' => 0, 'update_count' => 0, 'status' => 'idle' ]); return $this->success('获取成功', $syncStatus); } catch (\Throwable $e) { return $this->fail('获取失败: ' . $e->getMessage()); } } /** * @notes 导出客户数据 */ public function export() { try { $customers = \app\common\model\QywxExternalContact::select(); // 准备导出数据 $exportData = []; foreach ($customers as $customer) { $followUsers = json_decode($customer->follow_users, true) ?? []; $followUserNames = array_column($followUsers, 'name'); $exportData[] = [ '客户名称' => $customer->name, 'External ID' => $customer->external_user_id, '性别' => $customer->gender == 1 ? '男' : ($customer->gender == 2 ? '女' : '未知'), '类型' => $customer->type == 1 ? '微信' : '企微', '企业名称' => $customer->corp_name, '职位' => $customer->position, '跟进人' => implode(', ', $followUserNames), '添加时间' => $customer->created_at, '更新时间' => $customer->updated_at ]; } // 生成CSV文件 $filename = 'qywx_customers_' . date('YmdHis') . '.csv'; $filePath = sys_get_temp_dir() . '/' . $filename; $fp = fopen($filePath, 'w'); if ($fp) { // 写入表头 fputcsv($fp, array_keys($exportData[0])); // 写入数据 foreach ($exportData as $row) { fputcsv($fp, $row); } fclose($fp); // 下载文件 header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename=' . $filename); header('Content-Length: ' . filesize($filePath)); readfile($filePath); unlink($filePath); exit; } return $this->fail('导出失败'); } catch (\Throwable $e) { return $this->fail('导出失败: ' . $e->getMessage()); } } /** * @notes 导入客户数据 */ public function import() { try { // 记录开始时间 $startTime = microtime(true); // 检查文件上传 if (empty($_FILES['file'])) { $file = request()->file('file'); if (!$file) { return $this->fail('请选择文件'); } } else { $file = request()->file('file'); } if (!$file) { return $this->fail('请选择文件'); } // 记录文件信息 $originalName = $file->getOriginalName(); $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); $size = $file->getSize(); $tmpName = $file->getRealPath(); // 检查扩展名 $allowedExts = ['csv', 'xlsx']; if (!in_array($ext, $allowedExts)) { return $this->fail('请上传CSV或XLSX文件'); } // 检查文件大小(限制10MB) if ($size > 10 * 1024 * 1024) { return $this->fail('文件大小不能超过10MB'); } $importCount = 0; $skipCount = 0; $batchSize = 50; // 每批插入50条,减少内存消耗 $batchData = []; // 设置内存限制 ini_set('memory_limit', '256M'); // 设置执行时间 set_time_limit(120); if ($ext === 'csv') { // 读取CSV文件 $fp = fopen($tmpName, 'r'); if (!$fp) { return $this->fail('文件读取失败'); } try { // 读取表头 $header = fgetcsv($fp); if (!$header) { fclose($fp); return $this->fail('文件格式错误'); } // 转换表头编码 $header = array_map(function($item) { return $this->safeConvert($item); }, $header); // 映射表头 $headerMap = []; foreach ($header as $index => $field) { $field = trim($field); $headerMap[$field] = $index; } // 读取数据 $rowNumber = 1; while (($row = fgetcsv($fp)) !== false) { $rowNumber++; // 跳过空行 if (empty(array_filter($row))) { continue; } // 转换行数据编码 $row = array_map(function($item) { return $this->safeConvert($item); }, $row); // 解析数据 $data = $this->parseImportData($row, $headerMap); // 检查必填字段 if (empty($data['name'])) { $skipCount++; continue; } // 添加到批次 $batchData[] = $data; $importCount++; // 达到批次大小,执行插入 if (count($batchData) >= $batchSize) { try { \app\common\model\QywxExternalContact::insertAll($batchData); } catch (\Exception $e) { fclose($fp); return $this->fail('数据插入失败: ' . $e->getMessage()); } $batchData = []; } } // 插入剩余数据 if (!empty($batchData)) { try { \app\common\model\QywxExternalContact::insertAll($batchData); } catch (\Exception $e) { fclose($fp); return $this->fail('数据插入失败: ' . $e->getMessage()); } } } catch (\Exception $e) { fclose($fp); return $this->fail('CSV文件解析失败: ' . $e->getMessage()); } finally { if (isset($fp) && is_resource($fp)) { fclose($fp); } } } else { // 读取XLSX文件 try { $spreadsheet = IOFactory::load($tmpName); $worksheet = $spreadsheet->getActiveSheet(); // 读取表头(第一行) $header = []; $highestColumn = $worksheet->getHighestColumn(); $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn); for ($col = 1; $col <= $highestColumnIndex; $col++) { $value = $worksheet->getCellByColumnAndRow($col, 1)->getValue(); $header[] = trim($this->safeConvert($value)); } // 映射表头 $headerMap = []; foreach ($header as $index => $field) { $headerMap[$field] = $index; } // 读取数据(从第二行开始) $highestRow = $worksheet->getHighestRow(); for ($row = 2; $row <= $highestRow; $row++) { $rowData = []; for ($col = 1; $col <= $highestColumnIndex; $col++) { $value = $worksheet->getCellByColumnAndRow($col, $row)->getValue(); $rowData[] = $this->safeConvert($value); } // 跳过空行 if (empty(array_filter($rowData))) { continue; } // 解析数据 $data = $this->parseImportData($rowData, $headerMap); // 检查必填字段 if (empty($data['name'])) { $skipCount++; continue; } // 添加到批次 $batchData[] = $data; $importCount++; // 达到批次大小,执行插入 if (count($batchData) >= $batchSize) { try { \app\common\model\QywxExternalContact::insertAll($batchData); } catch (\Exception $e) { return $this->fail('数据插入失败: ' . $e->getMessage()); } $batchData = []; } } // 插入剩余数据 if (!empty($batchData)) { try { \app\common\model\QywxExternalContact::insertAll($batchData); } catch (\Exception $e) { return $this->fail('数据插入失败: ' . $e->getMessage()); } } } catch (\Exception $e) { return $this->fail('XLSX文件解析失败: ' . $e->getMessage()); } } return $this->success('导入成功', [ 'import_count' => $importCount, 'skip_count' => $skipCount ]); } catch (\Throwable $e) { return $this->fail('导入失败: ' . $e->getMessage()); } } /** * @notes 解析导入数据 */ private function parseImportData($row, $headerMap) { // 解析数据 $data = [ 'name' => isset($headerMap['客户名称']) ? ($this->safeConvert($row[$headerMap['客户名称']] ?? '')) : '', 'external_user_id' => 'import_' . uniqid(), 'avatar' => '', 'type' => 1, 'gender' => 0, 'unionid' => '', 'position' => isset($headerMap['职务']) ? ($this->safeConvert($row[$headerMap['职务']] ?? '')) : '', 'corp_name' => isset($headerMap['企业']) ? ($this->safeConvert($row[$headerMap['企业']] ?? '')) : '', 'corp_full_name' => isset($headerMap['企业']) ? ($this->safeConvert($row[$headerMap['企业']] ?? '')) : '', 'external_profile' => json_encode([], JSON_UNESCAPED_UNICODE), 'follow_users' => json_encode([[ 'name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '', 'userid' => isset($headerMap['添加人账号']) ? ($this->safeConvert($row[$headerMap['添加人账号']] ?? '')) : '' ]], JSON_UNESCAPED_UNICODE), 'first_staff_name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '', 'last_staff_name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; // 处理添加时间 if (isset($headerMap['添加时间'])) { $addTime = $row[$headerMap['添加时间']] ?? ''; if (!empty($addTime)) { // 尝试解析时间格式 $timestamp = strtotime($addTime); if ($timestamp) { $data['first_add_time'] = date('Y-m-d H:i:s', $timestamp); $data['last_add_time'] = date('Y-m-d H:i:s', $timestamp); } } } // 处理其他字段 if (isset($headerMap['手机'])) { $data['phone'] = $this->safeConvert($row[$headerMap['手机']] ?? ''); } if (isset($headerMap['邮箱'])) { $data['email'] = $this->safeConvert($row[$headerMap['邮箱']] ?? ''); } if (isset($headerMap['地址'])) { $data['address'] = $this->safeConvert($row[$headerMap['地址']] ?? ''); } if (isset($headerMap['电话'])) { $data['phone'] = $this->safeConvert($row[$headerMap['电话']] ?? ''); } return $data; } /** * @notes 安全的编码转换 */ private function safeConvert($value) { $type = gettype($value); if ($type === 'NULL') { return ''; } if ($type === 'array') { return json_encode($value, JSON_UNESCAPED_UNICODE); } if ($type === 'object') { $strValue = method_exists($value, '__toString') ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE); if ($strValue === false) { return ''; } $result = @mb_convert_encoding($strValue, 'UTF-8', 'auto'); return $result ?: $strValue; } if ($type !== 'string') { if ($type === 'boolean') { return $value ? '1' : '0'; } if ($type === 'integer' || $type === 'double') { return (string)$value; } return ''; } if ($value === '') { return ''; } $result = @mb_convert_encoding($value, 'UTF-8', 'auto'); return $result ?: $value; } /** * @notes 递归转换数组中的所有字符串为UTF-8编码 */ private function recursiveConvert($data) { if (is_null($data)) { return ''; } if (is_array($data)) { foreach ($data as $key => $value) { $data[$key] = $this->recursiveConvert($value); } } elseif (is_string($data)) { $data = mb_convert_encoding($data, 'UTF-8', 'auto'); } return $data; } /** * @notes 异步同步企业微信客户 */ public function asyncSync() { try { $limit = (int)input('limit', 30); $fullSync = (bool)input('full_sync', false); $syncMode = input('sync_mode', 'all'); $selectedUsers = input('selected_users', []); $days = (int)input('days', 7); // 最近几天的数据 // 生成同步任务ID $taskId = uniqid('qywx_sync_'); // 构建同步参数 $params = [ 'limit' => $limit, 'full_sync' => $fullSync, 'sync_mode' => $syncMode, 'selected_users' => $selectedUsers, 'days' => $days, 'task_id' => $taskId ]; // 保存任务状态 $cacheKey = 'qywx_sync_task_' . $taskId; Cache::set($cacheKey, [ 'status' => 'pending', 'progress' => 0, 'start_time' => time(), 'params' => $params ], 86400); // 执行异步同步 $this->executeAsyncSync($params, $taskId); return $this->success('同步任务已启动', [ 'task_id' => $taskId, 'status' => 'pending' ]); } catch (\Throwable $e) { return $this->fail('启动同步任务失败: ' . $e->getMessage()); } } /** * @notes 获取同步任务状态 */ public function getSyncTaskStatus() { try { $taskId = input('task_id', ''); if (empty($taskId)) { return $this->fail('缺少任务ID'); } $cacheKey = 'qywx_sync_task_' . $taskId; $taskInfo = Cache::get($cacheKey); if (!$taskInfo) { return $this->fail('任务不存在'); } return $this->success('获取成功', $taskInfo); } catch (\Throwable $e) { return $this->fail('获取任务状态失败: ' . $e->getMessage()); } } /** * @notes 执行异步同步 */ private function executeAsyncSync($params, $taskId) { // 构建命令 $phpPath = PHP_BINARY; $scriptPath = realpath(__DIR__ . '/../../../../') . '/sync_qywx.php'; // 生成临时参数文件 $paramFile = sys_get_temp_dir() . '/' . $taskId . '.json'; file_put_contents($paramFile, json_encode($params)); // 构建执行命令 $command = "{$phpPath} {$scriptPath} {$paramFile} {$taskId} > NUL 2>&1 &"; // 执行后台命令 exec($command); } }