>,pages:int} */ public function listReports( CarbonInterface $from, CarbonInterface $to, ?string $templateName = null, ): array { if (! $this->isConfigured()) { throw new DingTalkWorklogException( '钉钉应用凭据未配置', 'dingtalk_credentials_missing', ); } $templateName = trim((string) $templateName); $userId = trim((string) config('dingtalk.worklog.userid', '')); $size = (int) config('dingtalk.worklog.page_size', 20); $maxPages = (int) config('dingtalk.worklog.max_pages', 100); $cursor = 0; $pages = 0; $reports = []; do { $payload = [ 'start_time' => (int) $from->getTimestampMs(), 'end_time' => (int) $to->getTimestampMs(), 'cursor' => $cursor, 'size' => $size, ]; if ($templateName !== '') { $payload['template_name'] = $templateName; } if ($userId !== '') { $payload['userid'] = $userId; } $json = $this->requestReportPage($payload); $result = (array) ($json['result'] ?? []); $pageRows = (array) ($result['data_list'] ?? $result['list'] ?? $result['data'] ?? []); foreach ($pageRows as $row) { if (is_array($row)) { $reports[(string) ($row['report_id'] ?? $row['id'] ?? count($reports))] = $row; } } $pages++; $hasMore = filter_var($result['has_more'] ?? false, FILTER_VALIDATE_BOOL); $nextCursor = $result['next_cursor'] ?? null; if (! $hasMore || ! is_numeric($nextCursor) || $pages >= $maxPages) { break; } $cursor = (int) $nextCursor; } while (true); return ['reports' => array_values($reports), 'pages' => $pages]; } /** @param array $payload */ private function requestReportPage(array $payload): array { $attempts = min(max((int) config('dingtalk.worklog.request_attempts', 4), 1), 6); $baseDelayMs = min(max((int) config('dingtalk.worklog.retry_delay_ms', 500), 0), 5000); for ($attempt = 1; $attempt <= $attempts; $attempt++) { try { $response = Http::asJson() ->acceptJson() ->timeout($this->timeout()) ->post( self::LIST_URL . '?access_token=' . rawurlencode($this->accessToken()), $payload, ); } catch (ConnectionException|RequestException $exception) { if ($attempt < $attempts) { $this->waitBeforeRetry($baseDelayMs, $attempt); continue; } throw new DingTalkWorklogException( '钉钉日志列表请求失败', 'dingtalk_worklog_connection_failed', true, $exception, ); } $json = (array) $response->json(); $errorCode = (int) ($json['errcode'] ?? -1); if ($response->successful() && $errorCode === 0) { return $json; } $errorMessage = trim((string) ($json['errmsg'] ?? '')); if ($errorCode === 88 || str_contains($errorMessage, 'qyapi_report_query') || str_contains($errorMessage, '60011')) { throw new DingTalkWorklogException( '钉钉日志接口缺少 qyapi_report_query 权限', 'dingtalk_worklog_permission_required', ); } $retryable = $this->isTransientResponse($response->status(), $errorMessage); if ($retryable && $attempt < $attempts) { $this->waitBeforeRetry($baseDelayMs, $attempt); continue; } throw new DingTalkWorklogException( '钉钉日志列表查询失败:' . ($errorMessage !== '' ? $errorMessage : 'HTTP ' . $response->status()), $retryable ? 'dingtalk_worklog_request_retryable' : 'dingtalk_worklog_request_failed', $retryable, ); } throw new DingTalkWorklogException( '钉钉日志列表查询持续失败', 'dingtalk_worklog_request_retryable', true, ); } private function isTransientResponse(int $status, string $message): bool { if ($status === 429 || $status >= 500) { return true; } $message = strtolower($message); foreach ([ 'isp.-1', 'isp.top-remote-connection-timeout', '系统繁忙', '远程服务调用超时', 'timeout', 'too many requests', ] as $needle) { if (str_contains($message, $needle)) { return true; } } return false; } private function waitBeforeRetry(int $baseDelayMs, int $attempt): void { if ($baseDelayMs > 0) { usleep($baseDelayMs * (2 ** max($attempt - 1, 0)) * 1000); } } private function accessToken(): string { $token = Cache::remember( self::TOKEN_CACHE_KEY, now()->addMinutes(100), function (): ?string { try { $response = Http::acceptJson() ->timeout($this->timeout()) ->get((string) config('dingtalk.app_token_url'), [ 'appkey' => (string) config('dingtalk.client_id'), 'appsecret' => (string) config('dingtalk.client_secret'), ]) ->throw(); } catch (ConnectionException|RequestException $exception) { throw new DingTalkWorklogException( '钉钉应用令牌获取失败', 'dingtalk_token_request_failed', true, $exception, ); } $token = trim((string) $response->json('access_token')); return (int) $response->json('errcode', -1) === 0 && $token !== '' ? $token : null; }, ); if (! is_string($token) || $token === '') { throw new DingTalkWorklogException( '钉钉应用令牌不可用', 'dingtalk_token_unavailable', true, ); } return $token; } private function timeout(): int { return max(3, min(30, (int) config('dingtalk.timeout', 10))); } }