225 lines
7.6 KiB
PHP
225 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Modules\EcommerceFinance\Exceptions\DingTalkWorklogException;
|
|
|
|
class DingTalkWorklogClient
|
|
{
|
|
private const LIST_URL = 'https://oapi.dingtalk.com/topapi/report/list';
|
|
|
|
private const TOKEN_CACHE_KEY = 'dingtalk:app_access_token';
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
return trim((string) config('dingtalk.client_id', '')) !== ''
|
|
&& trim((string) config('dingtalk.client_secret', '')) !== '';
|
|
}
|
|
|
|
/**
|
|
* @return array{reports:list<array<string,mixed>>,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<string,mixed> $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)));
|
|
}
|
|
}
|