289 lines
9.9 KiB
PHP
289 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\Pool;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Modules\EcommerceFinance\Exceptions\DingTalkProcessInstanceException;
|
|
use Throwable;
|
|
|
|
class DingTalkProcessInstanceClient
|
|
{
|
|
private const PROCESS_INSTANCE_URL = 'https://api.dingtalk.com/v1.0/workflow/processInstances';
|
|
|
|
private const TOKEN_SAFETY_SECONDS = 60;
|
|
|
|
private ?string $accessToken = null;
|
|
|
|
private int $accessTokenExpiresAt = 0;
|
|
|
|
/** @var array<string,array<string,mixed>> */
|
|
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<string,mixed>
|
|
*/
|
|
public function formValues(string $processInstanceId): array
|
|
{
|
|
return $this->processSnapshot($processInstanceId)['form_values'];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* form_values:array<string,mixed>,
|
|
* tasks:list<array<string,mixed>>,
|
|
* operation_records:list<array<string,mixed>>,
|
|
* 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<string> $processInstanceIds
|
|
* @return array{
|
|
* snapshots:array<string,array<string,mixed>>,
|
|
* errors:array<string,string>,
|
|
* 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<string,mixed>|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)));
|
|
}
|
|
}
|