< Summary

Information
Class: Services.ApiServices.InterpartsEngine.InterpartsEngineService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/InterpartsEngine/InterpartsEngineService.cs
Line coverage
96%
Covered lines: 55
Uncovered lines: 2
Coverable lines: 57
Total lines: 159
Line coverage: 96.4%
Branch coverage
66%
Covered branches: 33
Total branches: 50
Branch coverage: 66%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
GetBrandsAsync(...)50%6680%
SearchOffersAsync(...)66.66%1212100%
RecheckOfferAsync(...)50%6680%
NormalizeArticle(...)100%11100%
GetCachedAsync()100%44100%
SingleFlightAsync()100%11100%
FetchAsync()75%44100%
ResolveBrandsTtl(...)50%44100%
ResolveOffersTtl(...)66.66%66100%
ResolveRecheckTtl(...)50%22100%
ResolveErrorTtl(...)100%22100%
BadRequest(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/InterpartsEngine/InterpartsEngineService.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Microsoft.Extensions.Caching.Memory;
 3
 4namespace Services.ApiServices.InterpartsEngine;
 5
 6public interface IInterpartsEngineService
 7{
 8    /// <summary>Бренды, выпускающие артикул. Пустой список — артикул неизвестен.</summary>
 9    Task<InterpartsEngineResult<EngineBrandsResponse>> GetBrandsAsync(string article, CancellationToken ct = default);
 10
 11    /// <summary>
 12    /// Предложения по артикулу и бренду: запрошенная деталь и аналоги. Первый поиск по артикулу может идти
 13    /// больше минуты — вызывать только по действию пользователя, не из фоновых и массовых прогонов.
 14    /// </summary>
 15    Task<InterpartsEngineResult<EngineOffersResponse>> SearchOffersAsync(string article, string brand, CancellationToken
 16
 17    /// <summary>Перепроверка предложения перед покупкой: идёт в движок, ответ живёт в кэше лишь несколько секунд.</summ
 18    Task<InterpartsEngineResult<EngineOffer>> RecheckOfferAsync(string offerId, CancellationToken ct = default);
 19}
 20
 21/// <summary>
 22/// Кэш и защита движка Interparts: каждый поиск без кэша движка опрашивает API поставщиков и идёт до ~2 минут.
 23/// Одинаковые запросы схлопываются в один (single-flight) и живут независимо от отмены вызывающего — ответ
 24/// всё равно кэшируется для следующего. Общий параллелизм ограничен. Предложения кэшируются не дольше
 25/// ближайшего <c>expires_at</c> с запасом, чтобы не отдать пользователю протухший <c>offer_id</c>.
 26/// </summary>
 27public sealed class InterpartsEngineService : IInterpartsEngineService
 28{
 29    private const string CacheKeyPrefix = "interparts-engine:v1:";
 30
 31    private readonly IInterpartsEngineApiClient _client;
 32    private readonly IMemoryCache _cache;
 33    private readonly InterpartsEngineConfig _config;
 34    private readonly TimeProvider _time;
 35    private readonly SemaphoreSlim _gate;
 1136    private readonly ConcurrentDictionary<string, Lazy<Task<object>>> _inflight = new();
 37
 1138    public InterpartsEngineService(IInterpartsEngineApiClient client, IMemoryCache cache, InterpartsEngineConfig config,
 1139        TimeProvider? time = null)
 40    {
 1141        _client = client;
 1142        _cache = cache;
 1143        _config = config;
 1144        _time = time ?? TimeProvider.System;
 1145        _gate = new SemaphoreSlim(Math.Max(1, config.MaxConcurrency));
 1146    }
 47
 48    public Task<InterpartsEngineResult<EngineBrandsResponse>> GetBrandsAsync(string article, CancellationToken ct = defa
 49    {
 250        article = article?.Trim() ?? string.Empty;
 251        if (NormalizeArticle(article).Length == 0)
 052            return Task.FromResult(BadRequest<EngineBrandsResponse>("Не указан артикул."));
 53
 254        var key = CacheKeyPrefix + "brands:" + NormalizeArticle(article);
 355        return GetCachedAsync(key, () => _client.GetBrandsAsync(article), ResolveBrandsTtl, ct);
 56    }
 57
 58    public Task<InterpartsEngineResult<EngineOffersResponse>> SearchOffersAsync(string article, string brand, Cancellati
 59    {
 1960        article = article?.Trim() ?? string.Empty;
 1961        brand = brand?.Trim() ?? string.Empty;
 1962        if (NormalizeArticle(article).Length == 0 || brand.Length == 0)
 363            return Task.FromResult(BadRequest<EngineOffersResponse>("Не указан артикул или бренд."));
 64
 65        // Бренд движок сравнивает без учёта регистра: Mahle и MAHLE — один запрос.
 1666        var key = CacheKeyPrefix + "offers:" + NormalizeArticle(article) + "|" + brand.ToUpperInvariant();
 2867        return GetCachedAsync(key, () => _client.SearchOffersAsync(article, brand), ResolveOffersTtl, ct);
 68    }
 69
 70    public Task<InterpartsEngineResult<EngineOffer>> RecheckOfferAsync(string offerId, CancellationToken ct = default)
 71    {
 372        offerId = offerId?.Trim() ?? string.Empty;
 373        if (offerId.Length == 0)
 074            return Task.FromResult(BadRequest<EngineOffer>("Не указано предложение."));
 75
 76        // Короткий кэш нужен, чтобы повторный опрос после «ещё идёт» (202) забрал готовый ответ, а не запустил новую пе
 377        var key = CacheKeyPrefix + "offer:" + offerId;
 578        return GetCachedAsync(key, () => _client.GetOfferAsync(offerId), ResolveRecheckTtl, ct);
 79    }
 80
 81    /// <summary>Артикул для ключа кэша так же, как нормализует движок: <c>oc-90</c>, <c>OC 90</c>, <c>oc90</c> — одно.<
 82    internal static string NormalizeArticle(string article) =>
 3983        string.Concat(article.Where(char.IsLetterOrDigit)).ToUpperInvariant();
 84
 85    private async Task<InterpartsEngineResult<T>> GetCachedAsync<T>(string key, Func<Task<InterpartsEngineResult<T>>> fe
 86        Func<InterpartsEngineResult<T>, TimeSpan> resolveTtl, CancellationToken ct)
 87        where T : class
 88    {
 2189        if (_cache.TryGetValue<InterpartsEngineResult<T>>(key, out var cached) && cached is not null)
 590            return cached;
 91
 1692        return await SingleFlightAsync(key, fetch, resolveTtl, ct);
 2093    }
 94
 95    private async Task<InterpartsEngineResult<T>> SingleFlightAsync<T>(string key, Func<Task<InterpartsEngineResult<T>>>
 96        Func<InterpartsEngineResult<T>, TimeSpan>? cacheTtl, CancellationToken ct)
 97        where T : class
 98    {
 4699        var lazy = _inflight.GetOrAdd(key, _ => new Lazy<Task<object>>(() => FetchAsync(key, fetch, cacheTtl)));
 100        // Запрос живёт независимо от отмены первого вызывающего: результат нужен остальным и кэшу.
 16101        return (InterpartsEngineResult<T>)await lazy.Value.WaitAsync(ct);
 15102    }
 103
 104    private async Task<object> FetchAsync<T>(string key, Func<Task<InterpartsEngineResult<T>>> fetch,
 105        Func<InterpartsEngineResult<T>, TimeSpan>? cacheTtl)
 106        where T : class
 107    {
 108        try
 109        {
 15110            await _gate.WaitAsync();
 111            try
 112            {
 15113                var result = await fetch();
 15114                var ttl = cacheTtl?.Invoke(result) ?? TimeSpan.Zero;
 27115                if (ttl > TimeSpan.Zero) _cache.Set(key, result, ttl);
 15116                return result;
 117            }
 118            finally
 119            {
 15120                _gate.Release();
 121            }
 122        }
 123        finally
 124        {
 15125            _inflight.TryRemove(key, out _);
 126        }
 15127    }
 128
 1129    private TimeSpan ResolveBrandsTtl(InterpartsEngineResult<EngineBrandsResponse> result) => result.IsOk
 1130        ? TimeSpan.FromMinutes(Math.Max(1, result.Value!.Brands.Count > 0 ? _config.BrandsCacheMinutes : _config.EmptyBr
 1131        : ResolveErrorTtl(result.ErrorCode);
 132
 133    private TimeSpan ResolveOffersTtl(InterpartsEngineResult<EngineOffersResponse> result)
 134    {
 14135        if (!result.IsOk) return ResolveErrorTtl(result.ErrorCode);
 136
 10137        var max = TimeSpan.FromMinutes(Math.Max(1, _config.OffersCacheMaxMinutes));
 10138        var earliest = result.Value!.EarliestExpiresAt;
 10139        if (earliest is null) return max;
 140
 10141        var untilExpiry = earliest.Value - _time.GetUtcNow() - TimeSpan.FromSeconds(Math.Max(0, _config.OfferExpirySafet
 10142        return untilExpiry < max ? untilExpiry : max;
 143    }
 144
 2145    private TimeSpan ResolveRecheckTtl(InterpartsEngineResult<EngineOffer> result) => result.Status == InterpartsEngineS
 2146        ? ResolveErrorTtl(result.ErrorCode)
 2147        : TimeSpan.FromSeconds(Math.Max(1, _config.RecheckCacheSeconds));
 148
 149    /// <summary>
 150    /// Таймаут не кэшируем: движок досчитает ответ и сохранит у себя, повтор придёт за секунду.
 151    /// Остальные сбои держим недолго, чтобы не долбить недоступный движок.
 152    /// </summary>
 2153    private TimeSpan ResolveErrorTtl(string? errorCode) => errorCode == "TIMEOUT"
 2154        ? TimeSpan.Zero
 2155        : TimeSpan.FromSeconds(Math.Max(5, _config.ErrorCacheSeconds));
 156
 157    private static InterpartsEngineResult<T> BadRequest<T>(string message) where T : class =>
 3158        InterpartsEngineResult<T>.Failure("BAD_REQUEST", message, false);
 159}