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; } } }