155 lines
5.5 KiB
PHP
155 lines
5.5 KiB
PHP
<?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;
|
||
}
|
||
}
|