176 lines
6.4 KiB
PHP
176 lines
6.4 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 InvalidArgumentException;
|
|
use Modules\EcommerceFinance\Exceptions\DingTalkAttendanceException;
|
|
|
|
class DingTalkAttendanceClient
|
|
{
|
|
private const LIST_URL = 'https://oapi.dingtalk.com/attendance/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', '')) !== '';
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $userIds
|
|
* @return array{records:list<array<string,mixed>>,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)));
|
|
}
|
|
}
|