$userIds * @return array{records:list>,pages:int} */ public function listRecords(array $userIds, CarbonInterface $from, CarbonInterface $to): array { if (! $this->isConfigured()) { throw new DingTalkAttendanceException( '钉钉应用凭据未配置', 'dingtalk_credentials_missing', ); } $userIds = array_values(array_unique(array_filter(array_map( static fn (mixed $userId): string => is_scalar($userId) ? trim((string) $userId) : '', $userIds, )))); if ($userIds === [] || count($userIds) > 50) { throw new InvalidArgumentException('钉钉考勤查询每次必须包含 1 至 50 个用户。'); } $fromDate = $from->copy()->startOfDay(); $toDate = $to->copy()->startOfDay(); if ($toDate->lessThan($fromDate) || $fromDate->diffInDays($toDate) > 6) { throw new InvalidArgumentException('钉钉考勤查询每次最多覆盖连续 7 天。'); } $limit = min(max((int) config('dingtalk.attendance.page_size', 50), 1), 50); $maxPages = max((int) config('dingtalk.attendance.max_pages', 200), 1); $offset = 0; $pages = 0; $records = []; do { $payload = [ 'workDateFrom' => $from->format('Y-m-d H:i:s'), 'workDateTo' => $to->format('Y-m-d H:i:s'), 'userIdList' => $userIds, 'offset' => $offset, 'limit' => $limit, ]; try { $response = Http::asJson() ->acceptJson() ->timeout($this->timeout()) ->post( self::LIST_URL . '?access_token=' . rawurlencode($this->accessToken()), $payload, ); } catch (ConnectionException|RequestException $exception) { throw new DingTalkAttendanceException( '钉钉考勤列表请求失败', 'dingtalk_attendance_connection_failed', true, $exception, ); } $json = (array) $response->json(); $errorCode = (int) ($json['errcode'] ?? -1); $subCode = (int) ($json['subcode'] ?? 0); $errorMessage = trim((string) ($json['errmsg'] ?? '')); if (! $response->successful() || $errorCode !== 0) { if (in_array($errorCode, [88, 60011], true) || in_array($subCode, [88, 60011], true) || str_contains($errorMessage, 'qyapi_attendance') || str_contains($errorMessage, 'qyapi_get_attendance_data')) { throw new DingTalkAttendanceException( '钉钉考勤接口缺少权限', 'dingtalk_attendance_permission_required', ); } throw new DingTalkAttendanceException( '钉钉考勤列表查询失败:' . ($errorMessage !== '' ? $errorMessage : 'HTTP ' . $response->status()), in_array($response->status(), [401, 403, 429], true) || $response->status() >= 500 ? 'dingtalk_attendance_request_retryable' : 'dingtalk_attendance_request_failed', in_array($response->status(), [401, 403, 429], true) || $response->status() >= 500, ); } $pageRows = (array) ($json['recordresult'] ?? []); foreach ($pageRows as $row) { if (! is_array($row)) { continue; } $recordId = trim((string) ($row['id'] ?? '')); if ($recordId !== '') { $records[$recordId] = $row; } } $pages++; if (count($pageRows) < $limit || $pages >= $maxPages) { break; } $offset += $limit; } while (true); return ['records' => array_values($records), 'pages' => $pages]; } 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 DingTalkAttendanceException( '钉钉应用令牌获取失败', '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 DingTalkAttendanceException( '钉钉应用令牌不可用', 'dingtalk_token_unavailable', true, ); } return $token; } private function timeout(): int { return max(3, min(30, (int) config('dingtalk.timeout', 10))); } }