Bicycle/Services/CurrencyRate/Client.php
2026-08-15 19:40:29 +03:00

155 lines
5.5 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CurrencyRate/Client.php
* @copyright (c) 15/08/2026
*/
namespace Services\CurrencyRate;
use RuntimeException;
use System\Classes\HTTP\Client\Curl;
use System\Classes\HTTP\Client\Request as HttpRequest;
use Throwable;
/**
* Курсы валют ЦБ РФ (XML_daily.asp, без авторизации). Кэшируется файлом на сутки — курс ЦБ
* публикуется раз в день, дёргать внешний сервис на каждый рендер страницы незачем. Любая
* ошибка (сеть/парсинг) — просто null, страница не должна падать из-за недоступности ЦБ
* (тот же принцип graceful degradation, что и у Elasticsearch\Client::ping()).
*/
class Client
{
/** @var array<string,float>|null Разобранные курсы текущего запроса (в памяти процесса) */
protected ?array $rates = null;
/**
* @param array $config url (XML_daily.asp), timeout (сек, по умолчанию 10),
* cache_path (файл кэша), ttl (сек, по умолчанию 86400)
*/
public function __construct(protected array $config)
{
}
/**
* Курс за 1 единицу валюты в рублях.
*
* @param string $code ISO 4217, например 'EUR'
* @return float|null null — RUB (курс 1 не нужен, но метод его не отдаёт отдельно, см. ниже),
* код не найден в ответе ЦБ, либо курс сейчас недоступен (сеть/парсинг)
*/
public function rate(string $code): ?float
{
$code = strtoupper($code);
if ($code === 'RUB') {
return 1.0;
}
return $this->rates()[$code] ?? null;
}
/**
* @return array<string,float> code => курс за 1 единицу в рублях; пустой массив при ошибке
*/
protected function rates(): array
{
if ($this->rates !== null) {
return $this->rates;
}
$cache_path = $this->config['cache_path'] ?? (APPPATH . '/cache/currency_rate.json');
$ttl = (int)($this->config['ttl'] ?? 86400);
if (is_file($cache_path) && (time() - filemtime($cache_path)) < $ttl) {
$cached = json_decode((string)file_get_contents($cache_path), true);
if (is_array($cached)) {
return $this->rates = $cached;
}
}
// Недавний неудачный fetch() (сеть/ЦБ недоступны) — не бьём в сеть по второму разу на
// каждый следующий запрос страницы, а сразу идём в фолбэк на протухший кэш/пустоту.
// Без этого маркера каждый заход на дашборд/операцию с не-рублёвым счётом ждал бы полный
// timeout (по умолчанию 10 сек, App/config/config.php) при недоступном ЦБ РФ.
$fail_path = $cache_path . '.failed';
$fail_cooldown = (int)($this->config['fail_cooldown'] ?? 300);
$recent_failure = is_file($fail_path) && (time() - filemtime($fail_path)) < $fail_cooldown;
$rates = $recent_failure ? null : $this->fetch();
if ($rates !== null) {
@file_put_contents($cache_path, json_encode($rates));
@unlink($fail_path);
return $this->rates = $rates;
}
if (!$recent_failure) {
@touch($fail_path);
}
// Сеть недоступна — отдаём протухший кэш, если он вообще есть, чем ничего.
if (is_file($cache_path)) {
$cached = json_decode((string)file_get_contents($cache_path), true);
if (is_array($cached)) {
return $this->rates = $cached;
}
}
return $this->rates = [];
}
/**
* @return array<string,float>|null null при любой ошибке сети/парсинга
*/
protected function fetch(): ?array
{
try {
$response = (new Curl())->execute(
HttpRequest::factory($this->config['url'] ?? 'https://www.cbr.ru/scripts/XML_daily.asp')
->method('GET')
->timeout((int)($this->config['timeout'] ?? 10))
);
} catch (RuntimeException) {
return null;
}
if (!$response->isSuccess()) {
return null;
}
// Ответ ЦБ — windows-1251, не UTF-8.
$xml_body = @iconv('windows-1251', 'UTF-8//IGNORE', $response->body());
if ($xml_body === false) {
return null;
}
try {
$xml = new \SimpleXMLElement($xml_body);
} catch (Throwable) {
return null;
}
$rates = [];
foreach ($xml->Valute as $valute) {
$code = (string)$valute->CharCode;
$nominal = (float)str_replace(',', '.', (string)$valute->Nominal);
$value = (float)str_replace(',', '.', (string)$valute->Value);
if ($code === '' || $nominal <= 0) {
continue;
}
$rates[$code] = $value / $nominal;
}
return $rates;
}
}