< Summary

Information
Class: Services.ApiServices.PartsnavigatorApi.PartsnavigatorService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/PartsnavigatorApi/PartsnavigatorService.cs
Line coverage
100%
Covered lines: 44
Uncovered lines: 0
Coverable lines: 44
Total lines: 102
Line coverage: 100%
Branch coverage
93%
Covered branches: 30
Total branches: 32
Branch coverage: 93.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetAsync()90%2020100%
LookupCachedAsync()100%66100%
FetchAsync()100%11100%
ResolveTtl(...)100%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/PartsnavigatorApi/PartsnavigatorService.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Microsoft.Extensions.Caching.Memory;
 3
 4namespace Services.ApiServices.PartsnavigatorApi;
 5
 6public interface IPartsnavigatorService
 7{
 8    /// <summary>
 9    /// Карточка запчасти: пробует бренды-кандидаты по порядку и возвращает первую найденную. Если не найдена нигде,
 10    /// но был сбой сервиса — возвращает сбой (чтобы пользователь мог повторить), иначе «не найдено».
 11    /// </summary>
 12    /// <param name="withAnalogs">false — быстрая карточка, true — со страницей аналогов (у сайта она медленная, поэтому
 13    Task<PartsnavigatorResult> GetAsync(IEnumerable<string?> brandCandidates, string number, bool withAnalogs, Cancellat
 14}
 15
 16/// <summary>
 17/// Кеш и защита парсера: бэкенд обрабатывает не более 5 запросов одновременно, поэтому одинаковые запросы
 18/// схлопываются в один (single-flight), общий параллелизм ограничен, а результат кешируется на разные сроки
 19/// (успех — часы, «не найдено» — минуты, сбой — секунды, чтобы не долбить упавший сервис).
 20/// </summary>
 21public sealed class PartsnavigatorService : IPartsnavigatorService
 22{
 23    private const string CacheKeyPrefix = "partsnavigator:v2:";
 24
 25    private readonly IPartsnavigatorApiClient _client;
 26    private readonly IMemoryCache _cache;
 27    private readonly PartsnavigatorConfig _config;
 28    private readonly SemaphoreSlim _gate;
 929    private readonly ConcurrentDictionary<string, Lazy<Task<PartsnavigatorResult>>> _inflight = new();
 30
 931    public PartsnavigatorService(IPartsnavigatorApiClient client, IMemoryCache cache, PartsnavigatorConfig config)
 32    {
 933        _client = client;
 934        _cache = cache;
 935        _config = config;
 936        _gate = new SemaphoreSlim(Math.Max(1, config.MaxConcurrency));
 937    }
 38
 39    public async Task<PartsnavigatorResult> GetAsync(IEnumerable<string?> brandCandidates, string number, bool withAnalo
 40    {
 3241        number = number?.Trim() ?? string.Empty;
 3242        var brands = brandCandidates
 3843            .Where(b => !string.IsNullOrWhiteSpace(b))
 3444            .Select(b => b!.Trim())
 3245            .Distinct(StringComparer.OrdinalIgnoreCase)
 3246            .ToList();
 3247        if (brands.Count == 0 || number.Length == 0)
 248            return PartsnavigatorResult.NotFound("Не указан бренд или артикул.");
 49
 3050        PartsnavigatorResult? firstFailure = null;
 3051        PartsnavigatorResult? notFound = null;
 10152        foreach (var brand in brands)
 53        {
 3354            var result = await LookupCachedAsync(brand, number, withAnalogs, ct);
 5855            if (result.Status == PartsnavigatorStatus.Found) return result with { MatchedBrand = brand };
 1156            if (result.Status == PartsnavigatorStatus.Error) firstFailure ??= result;
 557            else notFound ??= result;
 858        }
 59
 560        return firstFailure ?? notFound!;
 3261    }
 62
 63    private async Task<PartsnavigatorResult> LookupCachedAsync(string brand, string number, bool withAnalogs, Cancellati
 64    {
 3365        var key = CacheKeyPrefix + (withAnalogs ? "analogs:" : "card:") + brand.ToUpperInvariant() + "|" + number.ToUppe
 3366        if (_cache.TryGetValue<PartsnavigatorResult>(key, out var cached) && cached is not null)
 567            return cached;
 68
 6669        var lazy = _inflight.GetOrAdd(key, _ => new Lazy<Task<PartsnavigatorResult>>(() => FetchAsync(key, brand, number
 70        // Запрос живёт независимо от отмены первого вызывающего: результат нужен и остальным ожидающим.
 2871        return await lazy.Value.WaitAsync(ct);
 3372    }
 73
 74    private async Task<PartsnavigatorResult> FetchAsync(string key, string brand, string number, bool withAnalogs)
 75    {
 76        try
 77        {
 1978            await _gate.WaitAsync();
 79            try
 80            {
 1981                var result = await _client.LookupAsync(brand, number, withAnalogs);
 1982                _cache.Set(key, result, ResolveTtl(result));
 1983                return result;
 84            }
 85            finally
 86            {
 1987                _gate.Release();
 88            }
 89        }
 90        finally
 91        {
 1992            _inflight.TryRemove(key, out _);
 93        }
 1994    }
 95
 1996    private TimeSpan ResolveTtl(PartsnavigatorResult result) => result.Status switch
 1997    {
 1398        PartsnavigatorStatus.Found => TimeSpan.FromHours(Math.Max(1, _config.FoundCacheHours)),
 499        PartsnavigatorStatus.NotFound => TimeSpan.FromMinutes(Math.Max(1, _config.NotFoundCacheMinutes)),
 2100        _ => TimeSpan.FromSeconds(Math.Max(5, _config.ErrorCacheSeconds))
 19101    };
 102}