using System.Globalization;
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
///
/// HTTP-источник курсов ЦБ РФ
///
public sealed class CbrRateSource : IRatesSource
{
///
/// Таймаут HTTP-запроса в секундах.
///
public const int RequestTimeoutSeconds = 15;
private const string CbrUrl = "https://www.cbr-xml-daily.ru/daily_json.js";
// Корневой объект ответа: валюта → {Value, Nominal, …}.
private const string ValutePropertyName = "Valute";
// Курс единицы валюты в рублях (число).
private const string ValuePropertyName = "Value";
// Номинал (сколько единиц за курс Value; может быть > 1).
private const string NominalPropertyName = "Nominal";
// Базовая валюта ответа: курсы даются к рублю.
private const string BaseCurrency = "RUB";
private const double RubToRubRate = 1.0;
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
///
/// Создаёт источник поверх HttpClient.
///
/// Клиент с таймаутом 15 с (DI: AddHttpClient в Deal.Api).
/// Логгер предупреждений о сбоях.
public CbrRateSource(HttpClient httpClient, ILogger logger)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(logger);
_httpClient = httpClient;
_logger = logger;
}
///
public async Task?> FetchAsync(CancellationToken ct)
{
try
{
using HttpResponseMessage response = await _httpClient.GetAsync(CbrUrl, ct);
response.EnsureSuccessStatusCode();
return await ParseRatesAsync(response, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Отменил вызывающий (обрыв запроса/фоновой задачи) — пробрасываем, это не «сбой источника».
throw;
}
catch (Exception exception)
{
_logger.LogWarning("CBR fetch failed: {Reason}", exception.Message);
return null;
}
}
// Разбирает тело daily_json.js в курсы к рублю; нераспознанное тело/запись — null.
// response: Успешный HTTP-ответ (статус 2xx).
// ct: Токен отмены.
// Возвращает: Словарь «код валюты → курс к RUB» (RUB:1 в начале) или null.
private static async Task?> ParseRatesAsync(HttpResponseMessage response, CancellationToken ct)
{
using Stream content = await response.Content.ReadAsStreamAsync(ct);
using JsonDocument document = await JsonDocument.ParseAsync(content, cancellationToken: ct);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty(ValutePropertyName, out JsonElement valute)
|| valute.ValueKind != JsonValueKind.Object)
{
// Тело не похоже на daily_json.js (нет объекта Valute) — это не курсы ЦБ.
return null;
}
var rates = new Dictionary { [BaseCurrency] = RubToRubRate };
foreach (JsonProperty currency in valute.EnumerateObject())
{
if (!TryParseCurrency(currency, out double rate))
{
return null;
}
rates[currency.Name] = rate;
}
return rates;
}
private static bool TryParseCurrency(JsonProperty currency, out double rate)
{
rate = 0;
if (currency.Value.ValueKind != JsonValueKind.Object)
{
return false;
}
JsonElement item = currency.Value;
double value = 0;
if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement))
{
if (!TryReadDouble(valueElement, out value))
{
return false;
}
}
double nominal = 1;
if (item.TryGetProperty(NominalPropertyName, out JsonElement nominalElement))
{
if (!TryReadDouble(nominalElement, out nominal))
{
return false;
}
}
if (nominal == 0)
{
nominal = 1; // python: int(...) or 1 — нулевой номинал трактуем как 1
}
rate = Math.Round(value / nominal, 6);
return true;
}
// Читает число из JSON-элемента (число или строка, как их отдаёт зеркало).
// element: JSON-элемент записи валюты.
// value: Прочитанное число (инвариантная культура).
// Возвращает: True — элемент распознан как число.
private static bool TryReadDouble(JsonElement element, out double value)
{
switch (element.ValueKind)
{
case JsonValueKind.Number:
value = element.GetDouble();
return true;
case JsonValueKind.String:
return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
default:
value = 0;
return false;
}
}
}