Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/ Services (включая Storage); Persistence-конфигурации -> Configurations; корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos. namespace приведён к путям, using добавлены/дедуплицированы, FQN обновлены.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Options;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L43–59).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Запрос — GET <c>https://www.cbr-xml-daily.ru/daily_json.js</c> (JSON-зеркало ЦБ). SSRF-контур: URL —
|
||||
/// фиксированная константа (allowlist), тенант не управляет адресом источника (в отличие от baseUrl
|
||||
/// AI-провайдеров, Task 6). Таймаут клиента — 15 с (python: <c>httpx timeout=15</c>), задаётся
|
||||
/// DI-регистрацией AddHttpClient в Deal.Api. Парсинг: <c>Valute[code].Value / Nominal</c> (1 единица
|
||||
/// валюты в рублях; Nominal может быть > 1, напр. 100 KZT), к курсам добавляется <c>RUB:1</c>.
|
||||
/// Любой сбой (HTTP-код ≠ 2xx, нераспознанное тело/запись, сетевая ошибка) → null + warning — кэш
|
||||
/// RatesService при этом не трогает (Ruling 6). Отмена вызывающего пробрасывается (не «сбой»).
|
||||
/// </remarks>
|
||||
public sealed class CbrRateSource : IRatesSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса в секундах (python rates.py L46: <c>timeout=15</c>).
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 15;
|
||||
|
||||
// URL JSON-зеркала курсов ЦБ (constants.py L52). Фиксированный — SSRF-allowlist.
|
||||
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";
|
||||
|
||||
// Курс рубля к рублю (всегда 1.0, rates.py L50).
|
||||
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)
|
||||
{
|
||||
// Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L57–59).
|
||||
_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))
|
||||
{
|
||||
// Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch.
|
||||
return null;
|
||||
}
|
||||
|
||||
rates[currency.Name] = rate;
|
||||
}
|
||||
|
||||
return rates;
|
||||
}
|
||||
|
||||
// Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L51–55).
|
||||
// currency: Пара «код валюты → объект {Value, Nominal}».
|
||||
// rate: Курс единицы валюты к рублю (валиден при возврате true).
|
||||
// Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой).
|
||||
private static bool TryParseCurrency(JsonProperty currency, out double rate)
|
||||
{
|
||||
rate = 0;
|
||||
if (currency.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonElement item = currency.Value;
|
||||
|
||||
// Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой).
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user