> */ private array $snapshots = []; public function isConfigured(): bool { return trim((string) config('dingtalk.client_id', '')) !== '' && trim((string) config('dingtalk.client_secret', '')) !== ''; } /** * Return DingTalk's latest form component values by component name. * * @return array */ public function formValues(string $processInstanceId): array { return $this->processSnapshot($processInstanceId)['form_values']; } /** * @return array{ * form_values:array, * tasks:list>, * operation_records:list>, * status:?string, * result:?string * } */ public function processSnapshot(string $processInstanceId): array { $processInstanceId = trim($processInstanceId); if ($processInstanceId === '') { throw new DingTalkProcessInstanceException( 'DingTalk process instance ID is required.', ); } if (isset($this->snapshots[$processInstanceId])) { return $this->snapshots[$processInstanceId]; } try { $response = Http::acceptJson() ->timeout($this->timeout()) ->withHeaders([ 'x-acs-dingtalk-access-token' => $this->appAccessToken(), ]) ->get(self::PROCESS_INSTANCE_URL, [ 'processInstanceId' => $processInstanceId, ]); } catch (ConnectionException|RequestException $exception) { throw new DingTalkProcessInstanceException( 'DingTalk process instance lookup failed.', true, $exception, ); } if (! $response->successful()) { $status = $response->status(); throw new DingTalkProcessInstanceException( 'DingTalk process instance lookup returned HTTP ' . $status . '.', in_array($status, [401, 403, 429], true) || $status >= 500, ); } $snapshot = $this->normalizeSnapshot($response->json('result')); if ($snapshot === null) { throw new DingTalkProcessInstanceException( 'DingTalk process instance response was incomplete.', ); } return $this->snapshots[$processInstanceId] = $snapshot; } /** * Fetch a bounded batch concurrently while preserving one request per * process instance. Individual HTTP errors do not hide successful rows. * * @param list $processInstanceIds * @return array{ * snapshots:array>, * errors:array, * fatal:bool, * attempted:int * } */ public function processSnapshots(array $processInstanceIds): array { $ids = array_values(array_unique(array_filter(array_map( static fn (mixed $value): string => trim((string) $value), $processInstanceIds, )))); $snapshots = []; $pending = []; foreach ($ids as $processInstanceId) { if (isset($this->snapshots[$processInstanceId])) { $snapshots[$processInstanceId] = $this->snapshots[$processInstanceId]; } else { $pending[] = $processInstanceId; } } if ($pending === []) { return [ 'snapshots' => $snapshots, 'errors' => [], 'fatal' => false, 'attempted' => 0, ]; } $accessToken = $this->appAccessToken(); $errors = []; $fatal = false; $attempted = 0; $concurrency = max(1, min(10, (int) config('dingtalk.process_concurrency', 5))); foreach (array_chunk($pending, $concurrency) as $chunk) { $attempted += count($chunk); try { $responses = Http::acceptJson() ->timeout($this->timeout()) ->withHeaders([ 'x-acs-dingtalk-access-token' => $accessToken, ]) ->pool(function (Pool $pool) use ($chunk, $accessToken): void { foreach ($chunk as $index => $processInstanceId) { $pool->as((string) $index) ->acceptJson() ->timeout($this->timeout()) ->withHeaders([ 'x-acs-dingtalk-access-token' => $accessToken, ]) ->get(self::PROCESS_INSTANCE_URL, [ 'processInstanceId' => $processInstanceId, ]); } }); } catch (Throwable $exception) { foreach ($chunk as $processInstanceId) { $errors[$processInstanceId] = 'DingTalk process instance lookup failed.'; } $fatal = true; break; } foreach ($responses as $index => $response) { $processInstanceId = $chunk[(int) $index] ?? ''; if ($processInstanceId === '' || ! $response instanceof Response) { continue; } if (! $response->successful()) { $status = $response->status(); $errors[$processInstanceId] = 'DingTalk process instance lookup returned HTTP ' . $status . '.'; $fatal = $fatal || in_array($status, [401, 403, 429], true) || $status >= 500; continue; } $snapshot = $this->normalizeSnapshot($response->json('result')); if ($snapshot === null) { $errors[$processInstanceId] = 'DingTalk process instance response was incomplete.'; continue; } $snapshots[$processInstanceId] = $this->snapshots[$processInstanceId] = $snapshot; } if ($fatal) { break; } } return [ 'snapshots' => $snapshots, 'errors' => $errors, 'fatal' => $fatal, 'attempted' => $attempted, ]; } /** @return array|null */ private function normalizeSnapshot(mixed $result): ?array { if (! is_array($result)) { return null; } $values = []; foreach ((array) ($result['formComponentValues'] ?? []) as $component) { if (! is_array($component)) { continue; } $name = trim((string) ($component['name'] ?? '')); if ($name !== '') { $values[$name] = $component['value'] ?? null; } } return [ 'form_values' => $values, 'tasks' => array_values(array_filter( (array) ($result['tasks'] ?? []), 'is_array', )), 'operation_records' => array_values(array_filter( (array) ($result['operationRecords'] ?? []), 'is_array', )), 'status' => isset($result['status']) ? (string) $result['status'] : null, 'result' => isset($result['result']) ? (string) $result['result'] : null, ]; } private function appAccessToken(): string { if ($this->accessToken !== null && time() < $this->accessTokenExpiresAt) { return $this->accessToken; } if (! $this->isConfigured()) { throw new DingTalkProcessInstanceException( 'DingTalk process API credentials are not configured.', true, ); } 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 DingTalkProcessInstanceException( 'DingTalk application token lookup failed.', true, $exception, ); } $token = trim((string) $response->json('access_token')); if ((int) $response->json('errcode', -1) !== 0 || $token === '') { throw new DingTalkProcessInstanceException( 'DingTalk application token was unavailable.', true, ); } $expiresIn = max(300, (int) $response->json('expires_in', 7200)); $this->accessToken = $token; $this->accessTokenExpiresAt = time() + max( 120, $expiresIn - self::TOKEN_SAFETY_SECONDS, ); return $token; } private function timeout(): int { return max(3, min(30, (int) config('dingtalk.timeout', 10))); } }