Разбить 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:
Rustam Khalimov
2026-09-11 13:20:10 +03:00
parent cd0b3b606b
commit 410194b0cb
289 changed files with 855 additions and 101 deletions
@@ -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 L4359).
/// </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 может быть &gt; 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; может быть &gt; 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 L5759).
_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 L5155).
// 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;
}
}
}