| | | 1 | | using Microsoft.Extensions.Caching.Memory; |
| | | 2 | | using OzonDomains.Models; |
| | | 3 | | |
| | | 4 | | namespace Services.LabelServices; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Этикетка по артикулу заказа: отдаёт сохранённый PDF, а при его отсутствии генерирует и сохраняет. |
| | | 8 | | /// </summary> |
| | | 9 | | public class ArticleLabelService |
| | | 10 | | { |
| | | 11 | | private const string FolderIndexCacheKey = "article_label_names_index_v1"; |
| | | 12 | | private static readonly TimeSpan FolderIndexCacheExpiration = TimeSpan.FromMinutes(10); |
| | | 13 | | private static readonly TimeSpan FolderIndexFailureCacheExpiration = TimeSpan.FromSeconds(30); |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Сколько запрос готов ждать построения индекса папки. Листинг папки с десятками тысяч PDF на |
| | | 17 | | /// холодном кэше может длиться дольше таймаута прокси (504) — индекс нужен лишь для поиска |
| | | 18 | | /// файлов в старом регистре, поэтому по истечении срока этикетка генерируется без него, |
| | | 19 | | /// а индекс достраивается в фоне для следующих запросов. |
| | | 20 | | /// </summary> |
| | | 21 | | private static readonly TimeSpan DefaultFolderIndexWaitTimeout = TimeSpan.FromSeconds(5); |
| | | 22 | | |
| | | 23 | | // Один построитель индекса на процесс: параллельные запросы ждут его, а не листают папку каждый сам. |
| | | 24 | | private static readonly SemaphoreSlim FolderIndexBuildGate = new(1, 1); |
| | | 25 | | private static Task<Dictionary<string, string>>? _folderIndexBuild; |
| | | 26 | | |
| | | 27 | | private readonly ILabelFileStorage _labelFileStorage; |
| | | 28 | | private readonly BarcodeLabelService _barcodeLabelService; |
| | | 29 | | private readonly IMemoryCache _cache; |
| | | 30 | | private readonly TimeSpan _folderIndexWaitTimeout; |
| | | 31 | | |
| | | 32 | | public ArticleLabelService( |
| | | 33 | | ILabelFileStorage labelFileStorage, |
| | | 34 | | BarcodeLabelService barcodeLabelService, |
| | | 35 | | IMemoryCache cache, |
| | | 36 | | TimeSpan? folderIndexWaitTimeout = null) |
| | | 37 | | { |
| | | 38 | | _folderIndexWaitTimeout = folderIndexWaitTimeout ?? DefaultFolderIndexWaitTimeout; |
| | | 39 | | _labelFileStorage = labelFileStorage; |
| | | 40 | | _barcodeLabelService = barcodeLabelService; |
| | | 41 | | _cache = cache; |
| | | 42 | | } |
| | | 43 | | |
| | | 44 | | /// <summary> |
| | | 45 | | /// Имя файла этикетки с учётом производителя и товара. Нижний регистр — обычная ФС |
| | | 46 | | /// (в отличие от Dropbox) регистрозависима, без канонизации один и тот же товар с разным |
| | | 47 | | /// регистром названия/производителя между запросами породил бы дублирующиеся файлы. |
| | | 48 | | /// </summary> |
| | | 49 | | public static string BuildFileName(string cleanedArticle, string? producer, string? productName) |
| | | 50 | | { |
| | | 51 | | var safeProducer = Sanitize(producer); |
| | | 52 | | var safeProduct = Sanitize(productName); |
| | | 53 | | return $"{cleanedArticle.ToLowerInvariant()}_{safeProducer}_{safeProduct}.pdf"; |
| | | 54 | | } |
| | | 55 | | |
| | | 56 | | private static string Sanitize(string? value) => |
| | | 57 | | string.Join("_", (value ?? string.Empty).Split(Path.GetInvalidFileNameChars())).ToLowerInvariant(); |
| | | 58 | | |
| | | 59 | | /// <summary> |
| | | 60 | | /// Возвращает PDF этикетки заказа: из хранилища, либо генерирует новую и сохраняет её. |
| | | 61 | | /// У заказа должен быть непустой <see cref="Order.CleanedArticle"/>. |
| | | 62 | | /// </summary> |
| | | 63 | | public async Task<byte[]> GetOrCreateAsync(Order order, CancellationToken cancellationToken = default) => |
| | | 64 | | (await GetOrCreateWithMetadataAsync(order, cancellationToken).ConfigureAwait(false)).Content; |
| | | 65 | | |
| | | 66 | | /// <summary>Возвращает PDF и признак, что он был успешно создан и сохранён этим вызовом.</summary> |
| | | 67 | | public async Task<ArticleLabelResult> GetOrCreateWithMetadataAsync(Order order, CancellationToken cancellationToken |
| | | 68 | | { |
| | | 69 | | var cleanedArticle = order.CleanedArticle ?? string.Empty; |
| | | 70 | | var producer = order.EtProducer?.Name ?? string.Empty; |
| | | 71 | | var fileName = BuildFileName(cleanedArticle, producer, order.ProductName); |
| | | 72 | | |
| | | 73 | | var existingBytes = await TryDownloadIgnoringCaseAsync(fileName, cancellationToken); |
| | | 74 | | if (existingBytes != null) |
| | | 75 | | { |
| | | 76 | | return new ArticleLabelResult(existingBytes, fileName, false); |
| | | 77 | | } |
| | | 78 | | |
| | | 79 | | var request = new LabelRequest |
| | | 80 | | { |
| | | 81 | | Article = order.Article ?? string.Empty, |
| | | 82 | | Producer = producer, |
| | | 83 | | ProductName = string.IsNullOrWhiteSpace(producer) |
| | | 84 | | ? order.ProductName ?? string.Empty |
| | | 85 | | : producer + " " + (order.ProductName ?? string.Empty), |
| | | 86 | | Quantity = order.TrueQuantity ?? order.Quantity ?? 1 |
| | | 87 | | }; |
| | | 88 | | |
| | | 89 | | var pdfBytes = _barcodeLabelService.GenerateLabelsPdf(new[] { request }); |
| | | 90 | | |
| | | 91 | | try |
| | | 92 | | { |
| | | 93 | | await _labelFileStorage.UploadAsync(pdfBytes, fileName, overwrite: true, cancellationToken); |
| | | 94 | | return new ArticleLabelResult(pdfBytes, fileName, true); |
| | | 95 | | } |
| | | 96 | | catch |
| | | 97 | | { |
| | | 98 | | // Не блокируем выдачу уже сгенерированного PDF пользователю при сбое сохранения |
| | | 99 | | return new ArticleLabelResult(pdfBytes, fileName, false); |
| | | 100 | | } |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | /// <summary> |
| | | 104 | | /// Ищет файл сначала по точному имени, затем без учёта регистра: этикетки, сохранённые до |
| | | 105 | | /// перехода на канонические имена (в том числе перенесённые из Dropbox), лежат в исходном |
| | | 106 | | /// регистре и иначе не находились бы — каждая генерировалась бы заново с новым дубликатом. |
| | | 107 | | /// </summary> |
| | | 108 | | private async Task<byte[]?> TryDownloadIgnoringCaseAsync(string fileName, CancellationToken cancellationToken) |
| | | 109 | | { |
| | | 110 | | var exactBytes = await _labelFileStorage.TryDownloadAsync(fileName, cancellationToken); |
| | | 111 | | if (exactBytes != null) |
| | | 112 | | { |
| | | 113 | | return exactBytes; |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | var folderIndex = await GetFolderIndexAsync(cancellationToken); |
| | | 117 | | if (!folderIndex.TryGetValue(fileName, out var storedFileName) |
| | | 118 | | || string.Equals(storedFileName, fileName, StringComparison.Ordinal)) |
| | | 119 | | { |
| | | 120 | | return null; |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | return await _labelFileStorage.TryDownloadAsync(storedFileName, cancellationToken); |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | /// <summary> |
| | | 127 | | /// Каноническое имя → фактическое имя файла в хранилище. Кэш безопасен: только что записанные |
| | | 128 | | /// этикетки находятся по точному имени, а исторические имена уже не меняются. |
| | | 129 | | /// </summary> |
| | | 130 | | private async Task<Dictionary<string, string>> GetFolderIndexAsync(CancellationToken cancellationToken) |
| | | 131 | | { |
| | | 132 | | if (_cache.TryGetValue(FolderIndexCacheKey, out Dictionary<string, string>? cached) && cached is not null) |
| | | 133 | | { |
| | | 134 | | return cached; |
| | | 135 | | } |
| | | 136 | | |
| | | 137 | | Task<Dictionary<string, string>> build; |
| | | 138 | | await FolderIndexBuildGate.WaitAsync(cancellationToken); |
| | | 139 | | try |
| | | 140 | | { |
| | | 141 | | if (_cache.TryGetValue(FolderIndexCacheKey, out cached) && cached is not null) |
| | | 142 | | { |
| | | 143 | | return cached; |
| | | 144 | | } |
| | | 145 | | |
| | | 146 | | // Построение не привязано к токену запроса: оно общее для всех и должно дойти до кэша, |
| | | 147 | | // даже если пользователь, который его запустил, закрыл вкладку. |
| | | 148 | | build = _folderIndexBuild is { IsCompleted: false } |
| | | 149 | | ? _folderIndexBuild |
| | | 150 | | : _folderIndexBuild = Task.Run(BuildFolderIndexAsync); |
| | | 151 | | } |
| | | 152 | | finally |
| | | 153 | | { |
| | | 154 | | FolderIndexBuildGate.Release(); |
| | | 155 | | } |
| | | 156 | | |
| | | 157 | | var winner = await Task.WhenAny(build, Task.Delay(_folderIndexWaitTimeout, cancellationToken)); |
| | | 158 | | if (winner == build) |
| | | 159 | | { |
| | | 160 | | return await build; |
| | | 161 | | } |
| | | 162 | | |
| | | 163 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 164 | | Console.WriteLine("Индекс артикульных этикеток ещё строится — этикетка формируется без поиска в старом регистре. |
| | | 165 | | return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | | 166 | | } |
| | | 167 | | |
| | | 168 | | private async Task<Dictionary<string, string>> BuildFolderIndexAsync() |
| | | 169 | | { |
| | | 170 | | var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | | 171 | | var succeeded = false; |
| | | 172 | | try |
| | | 173 | | { |
| | | 174 | | var files = await _labelFileStorage.ListAsync(); |
| | | 175 | | foreach (var file in files) |
| | | 176 | | { |
| | | 177 | | if (file.IsDirectory || !file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) |
| | | 178 | | { |
| | | 179 | | continue; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | index.TryAdd(file.Name, file.Name); |
| | | 183 | | } |
| | | 184 | | |
| | | 185 | | succeeded = true; |
| | | 186 | | } |
| | | 187 | | catch (Exception ex) |
| | | 188 | | { |
| | | 189 | | Console.WriteLine($"Ошибка при построении индекса артикульных этикеток: {ex.Message}"); |
| | | 190 | | } |
| | | 191 | | |
| | | 192 | | // Пустой индекс после сбоя не должен прятать все старые этикетки на 10 минут. |
| | | 193 | | _cache.Set(FolderIndexCacheKey, index, |
| | | 194 | | succeeded ? FolderIndexCacheExpiration : FolderIndexFailureCacheExpiration); |
| | | 195 | | return index; |
| | | 196 | | } |
| | | 197 | | } |
| | | 198 | | |
| | 13 | 199 | | public sealed record ArticleLabelResult(byte[] Content, string FileName, bool WasStored); |