Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
161 lines
5.8 KiB
C#
161 lines
5.8 KiB
C#
using System.Globalization;
|
||
using System.Text.Json;
|
||
using Deal.Modules.Settings.Application.Abstractions;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace Deal.Infrastructure.Integrations.Services;
|
||
|
||
/// <summary>
|
||
/// HTTP-источник курсов ЦБ РФ
|
||
/// </summary>
|
||
public sealed class CbrRateSource : IRatesSource
|
||
{
|
||
/// <summary>
|
||
/// Таймаут HTTP-запроса в секундах.
|
||
/// </summary>
|
||
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<CbrRateSource> _logger;
|
||
|
||
/// <summary>
|
||
/// Создаёт источник поверх HttpClient.
|
||
/// </summary>
|
||
/// <param name="httpClient">Клиент с таймаутом 15 с (DI: AddHttpClient в Deal.Api).</param>
|
||
/// <param name="logger">Логгер предупреждений о сбоях.</param>
|
||
public CbrRateSource(HttpClient httpClient, ILogger<CbrRateSource> logger)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(httpClient);
|
||
ArgumentNullException.ThrowIfNull(logger);
|
||
_httpClient = httpClient;
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<Dictionary<string, double>?> 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<Dictionary<string, double>?> 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<string, double> { [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;
|
||
}
|
||
}
|
||
}
|