< Summary

Information
Class: OzonOrdersWeb.Areas.Public.Controllers.PublicGenerateRequest
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Public/Controllers/AssemblyListController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 5
Coverable lines: 5
Total lines: 354
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_ClientId()100%210%
get_Status()100%210%
get_From()100%210%
get_To()100%210%
get_Warehouse()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Public/Controllers/AssemblyListController.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using iText.Kernel.Pdf;
 3using System.Text;
 4using System.Text.Json;
 5using iText.Kernel.Utils;
 6using Microsoft.AspNetCore.Authorization;
 7using Microsoft.AspNetCore.Mvc;
 8using Microsoft.EntityFrameworkCore;
 9using Microsoft.Extensions.Caching.Memory;
 10using OzonDomains.Models;
 11using OzonRepositories.Context;
 12using Services.ApiServices.OzonApi;
 13using Services.DataServices;
 14
 15namespace OzonOrdersWeb.Areas.Public.Controllers;
 16
 17[Area("Public")]
 18[AllowAnonymous]
 19public class AssemblyListController : Controller
 20{
 21    private static readonly JsonSerializerOptions CamelCaseOptions = new()
 22    {
 23        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 24    };
 25
 26    private readonly ApplicationDbContext _db;
 27    private readonly OzonClientServices _ozonClientServices;
 28    private readonly IMemoryCache _cache;
 29    private readonly AssemblyListGenerationOrchestrator _orchestrator;
 30    private readonly IHttpClientFactory _httpClientFactory;
 31    private readonly ILogger<AssemblyListController> _logger;
 32
 33    public AssemblyListController(
 34        ApplicationDbContext db,
 35        OzonClientServices ozonClientServices,
 36        IMemoryCache cache,
 37        AssemblyListGenerationOrchestrator orchestrator,
 38        IHttpClientFactory httpClientFactory,
 39        ILogger<AssemblyListController> logger)
 40    {
 41        _db = db;
 42        _ozonClientServices = ozonClientServices;
 43        _cache = cache;
 44        _orchestrator = orchestrator;
 45        _httpClientFactory = httpClientFactory;
 46        _logger = logger;
 47    }
 48
 49    // ─── Key validation ───────────────────────────────────────────────────────
 50
 51    private async Task<AssemblyListAccessKey?> ValidateKeyAsync(string accessKey, int clientId)
 52    {
 53        var key = await _db.AssemblyListAccessKeys
 54            .FirstOrDefaultAsync(k => k.AccessKey == accessKey
 55                && k.IsActive
 56                && (k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow));
 57        if (key == null) return null;
 58        if (!key.AllowedClientIdList.Contains(clientId)) return null;
 59        return key;
 60    }
 61
 62    private static bool IsStatusAllowed(AssemblyListAccessKey key, string status) =>
 63        key.AllowedStatusList.Count == 0
 64        || key.AllowedStatusList.Contains(status, StringComparer.OrdinalIgnoreCase);
 65
 66    private static bool IsWarehouseAllowed(AssemblyListAccessKey key, string warehouse) =>
 67        key.AllowedWarehouseList.Count == 0
 68        || key.AllowedWarehouseList.Contains(warehouse, StringComparer.OrdinalIgnoreCase);
 69
 70    private IActionResult InvalidAccessStub()
 71    {
 72        Response.StatusCode = StatusCodes.Status404NotFound;
 73        if (HttpMethods.IsHead(Request.Method))
 74            return new EmptyResult();
 75
 76        return View("AccessDenied");
 77    }
 78
 79    // ─── GET /public/assembly/{accessKey} ────────────────────────────────────
 80
 81    [AcceptVerbs("GET", "HEAD")]
 82    [Route("public/assembly/{accessKey}")]
 83    public async Task<IActionResult> Index(string accessKey)
 84    {
 85        if (!int.TryParse(Request.Query["clientId"], out var clientId))
 86            return InvalidAccessStub();
 87
 88        var key = await ValidateKeyAsync(accessKey, clientId);
 89        if (key == null)
 90        {
 91            _logger.LogWarning("Public assembly denied: invalid key or client, key={AccessKey}, clientId={ClientId}", ac
 92            return InvalidAccessStub();
 93        }
 94
 95        var status = Request.Query["status"].ToString();
 96        if (string.IsNullOrEmpty(status)) status = "awaiting_deliver";
 97        if (!IsStatusAllowed(key, status))
 98        {
 99            _logger.LogWarning("Public assembly denied: status not allowed, keyId={KeyId}, status={Status}", key.Id, sta
 100            return InvalidAccessStub();
 101        }
 102
 103        var warehouse = Request.Query["warehouse"].ToString();
 104        if (!string.IsNullOrEmpty(warehouse) && !IsWarehouseAllowed(key, warehouse))
 105        {
 106            _logger.LogWarning("Public assembly denied: warehouse not allowed, keyId={KeyId}, warehouse={Warehouse}", ke
 107            return InvalidAccessStub();
 108        }
 109
 110        var (periodFrom, periodTo) = key.ResolvePeriod();
 111        var from = periodFrom.ToString("yyyy-MM-dd");
 112        var to = periodTo.ToString("yyyy-MM-dd");
 113
 114        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
 115        _logger.LogInformation("Public assembly view: key={KeyId} client={ClientId} warehouse={Warehouse} ip={Ip}",
 116            key.Id, clientId, warehouse, ip);
 117
 118        var host = $"{Request.Scheme}://{Request.Host}";
 119        ViewBag.AccessKey = accessKey;
 120        ViewBag.ClientId = clientId;
 121        ViewBag.Status = status;
 122        ViewBag.Warehouse = warehouse;
 123        ViewBag.From = from;
 124        ViewBag.To = to;
 125        ViewBag.OgTitle = AssemblyListOgMetadata.BuildTitle();
 126        ViewBag.OgDescription = AssemblyListOgMetadata.BuildDescription(from, to, warehouse, status);
 127        ViewBag.OgSiteName = "Interparts";
 128        ViewBag.OgImageAlt = AssemblyListOgMetadata.BuildImageAlt(warehouse);
 129        ViewBag.OgImageUrl = $"{host}/images/og-assembly-list.jpg";
 130        ViewBag.OgUrl = $"{host}{Request.Path}{Request.QueryString}";
 131
 132        if (HttpMethods.IsHead(Request.Method))
 133            return new EmptyResult();
 134
 135        return View();
 136    }
 137
 138    // ─── POST /public/assembly/{accessKey}/generate ──────────────────────────
 139
 140    [HttpPost]
 141    [Route("public/assembly/{accessKey}/generate")]
 142    public async Task<IActionResult> Generate(string accessKey, [FromBody] PublicGenerateRequest body)
 143    {
 144        if (!IsValidClientId(body.ClientId)) return NotFound();
 145
 146        var key = await ValidateKeyAsync(accessKey, body.ClientId);
 147        if (key == null) return NotFound();
 148
 149        var status = string.IsNullOrEmpty(body.Status) ? "awaiting_deliver" : body.Status;
 150        if (!IsStatusAllowed(key, status)) return NotFound();
 151
 152        var warehouse = body.Warehouse ?? "";
 153        if (!string.IsNullOrEmpty(warehouse) && !IsWarehouseAllowed(key, warehouse)) return NotFound();
 154
 155        var client = await _ozonClientServices.GetOzonClientAsync(body.ClientId);
 156        if (client?.DecryptClientId == null || client.DecryptApiKey == null)
 157            return NotFound();
 158
 159        var (cutoffFrom, cutoffTo) = key.ResolvePeriod();
 160
 161        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
 162        _logger.LogInformation("Public assembly generate: key={KeyId} client={ClientId} warehouse={Warehouse} ip={Ip}",
 163            key.Id, body.ClientId, warehouse, ip);
 164
 165        var req = new GenerationRequest(
 166            OzonClientId: body.ClientId,
 167            OzonClientName: client.Name ?? client.DecryptClientId,
 168            DecryptedClientId: client.DecryptClientId,
 169            DecryptedApiKey: client.DecryptApiKey,
 170            CutoffFrom: cutoffFrom,
 171            CutoffTo: cutoffTo,
 172            Status: status,
 173            WarehouseFilter: string.IsNullOrEmpty(warehouse) ? null : warehouse,
 174            PersistItemsInBackground: true);
 175
 176        var (jobId, dbRunId) = await _orchestrator.StartAsync(req);
 177        return Ok(new { jobId, dbRunId });
 178    }
 179
 180    // ─── GET /public/assembly/{accessKey}/progress?jobId= ────────────────────
 181
 182    [HttpGet]
 183    [Route("public/assembly/{accessKey}/progress")]
 184    public async Task Progress(string accessKey, string jobId)
 185    {
 186        Response.Headers["Content-Type"] = "text/event-stream";
 187        Response.Headers["Cache-Control"] = "no-cache";
 188        Response.Headers["Connection"] = "keep-alive";
 189
 190        var timeout = DateTime.UtcNow.AddMinutes(10);
 191
 192        ConcurrentQueue<AssemblyListProgress>? queue = null;
 193        for (var attempt = 0; attempt < 10 && queue == null; attempt++)
 194        {
 195            queue = _cache.Get<ConcurrentQueue<AssemblyListProgress>>($"al_progress_{jobId}");
 196            if (queue == null) await Task.Delay(500);
 197        }
 198
 199        if (queue == null)
 200        {
 201            await WriteSSE(Response, "progress",
 202                JsonSerializer.Serialize(new AssemblyListProgress { IsError = true, ErrorMessage = "Job not found" }, Ca
 203            await Response.Body.FlushAsync();
 204            return;
 205        }
 206
 207        while (DateTime.UtcNow < timeout)
 208        {
 209            while (queue.TryDequeue(out var prog))
 210            {
 211                await WriteSSE(Response, "progress", JsonSerializer.Serialize(prog, CamelCaseOptions));
 212                await Response.Body.FlushAsync();
 213                if (prog.IsComplete || prog.IsError) return;
 214            }
 215            await Task.Delay(400);
 216        }
 217    }
 218
 219    // ─── GET /public/assembly/{accessKey}/result?jobId= ──────────────────────
 220
 221    [HttpGet]
 222    [Route("public/assembly/{accessKey}/result")]
 223    public IActionResult Result(string accessKey, string jobId)
 224    {
 225        var result = _cache.Get<AssemblyListResult>($"al_result_{jobId}");
 226        if (result == null) return NotFound();
 227        return Content(JsonSerializer.Serialize(result, CamelCaseOptions), "application/json");
 228    }
 229
 230    // ─── GET /public/assembly/{accessKey}/download-labels?jobId= ─────────────
 231
 232    [HttpGet]
 233    [Route("public/assembly/{accessKey}/download-labels")]
 234    public async Task<IActionResult> DownloadLabels(string accessKey, string jobId, int clientId)
 235    {
 236        if (clientId <= 0) return NotFound();
 237
 238        var key = await ValidateKeyAsync(accessKey, clientId);
 239        if (key == null) return NotFound();
 240
 241        var result = _cache.Get<AssemblyListResult>($"al_result_{jobId}");
 242        if (result == null) return NotFound();
 243
 244        var labelUrls = result.Items
 245            .Select(i => i.LabelUrl)
 246            .Where(u => !string.IsNullOrWhiteSpace(u))
 247            .Distinct(StringComparer.Ordinal)
 248            .ToList();
 249
 250        if (labelUrls.Count == 0)
 251            return BadRequest("Нет доступных этикеток для скачивания.");
 252
 253        var httpClient = _httpClientFactory.CreateClient();
 254        await using var mergedStream = new MemoryStream();
 255        using var mergedPdf = new PdfDocument(new PdfWriter(mergedStream));
 256        var mergedAnyPage = false;
 257
 258        foreach (var labelUrl in labelUrls)
 259        {
 260            if (labelUrl == null || !IsValidOzonUrl(labelUrl))
 261                continue;
 262
 263            try
 264            {
 265                var bytes = await httpClient.GetByteArrayAsync(labelUrl);
 266                await using var sourceStream = new MemoryStream(bytes);
 267                using var sourcePdf = new PdfDocument(new PdfReader(sourceStream));
 268                if (sourcePdf.GetNumberOfPages() <= 0)
 269                    continue;
 270
 271                sourcePdf.CopyPagesTo(1, sourcePdf.GetNumberOfPages(), mergedPdf);
 272                mergedAnyPage = true;
 273            }
 274            catch
 275            {
 276                // best-effort: пропускаем битый/недоступный PDF, продолжаем склейку остальных.
 277            }
 278        }
 279
 280        if (!mergedAnyPage)
 281            return BadRequest("Не удалось сформировать общий PDF этикеток.");
 282
 283        mergedPdf.Close();
 284        var fileName = $"assembly_labels_{DateTime.Now:yyyyMMdd_HHmm}.pdf";
 285        return File(mergedStream.ToArray(), "application/pdf", fileName);
 286    }
 287
 288    // ─── GET /public/assembly/{accessKey}/image-proxy?url= ───────────────────
 289
 290    [HttpGet]
 291    [Route("public/assembly/{accessKey}/image-proxy")]
 292    public async Task<IActionResult> ImageProxy(string accessKey, string url)
 293    {
 294        if (!IsValidOzonUrl(url)) return BadRequest();
 295        try
 296        {
 297            var bytes = await _httpClientFactory.CreateClient().GetByteArrayAsync(url);
 298            return File(bytes, "image/jpeg");
 299        }
 300        catch { return NotFound(); }
 301    }
 302
 303    // ─── GET /public/assembly/{accessKey}/label-proxy?url= ───────────────────
 304
 305    [HttpGet]
 306    [Route("public/assembly/{accessKey}/label-proxy")]
 307    public async Task<IActionResult> LabelProxy(string accessKey, string url)
 308    {
 309        if (!IsValidOzonUrl(url, out var uri)) return BadRequest();
 310        try
 311        {
 312            var bytes = await _httpClientFactory.CreateClient().GetByteArrayAsync(url);
 313            var fileName = Path.GetFileName(uri!.LocalPath);
 314            Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
 315            return File(bytes, "application/pdf");
 316        }
 317        catch { return NotFound(); }
 318    }
 319
 320    // ─── Helpers ──────────────────────────────────────────────────────────────
 321
 322    private static bool IsValidClientId(int id) => id > 0;
 323
 324    private static bool IsValidOzonUrl(string url) => IsValidOzonUrl(url, out _);
 325
 326    private static bool IsValidOzonUrl(string url, out Uri? uri)
 327    {
 328        uri = null;
 329        if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out uri))
 330            return false;
 331        var host = uri.Host;
 332        return host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase)
 333            || host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase)
 334            || host.EndsWith(".ozonusercontent.com", StringComparison.OrdinalIgnoreCase);
 335    }
 336
 337    private static async Task WriteSSE(HttpResponse response, string eventName, string data)
 338    {
 339        var sb = new StringBuilder();
 340        sb.AppendLine($"event: {eventName}");
 341        sb.AppendLine($"data: {data}");
 342        sb.AppendLine();
 343        await response.WriteAsync(sb.ToString());
 344    }
 345}
 346
 347public class PublicGenerateRequest
 348{
 0349    public int ClientId { get; set; }
 0350    public string? Status { get; set; }
 0351    public string? From { get; set; }
 0352    public string? To { get; set; }
 0353    public string? Warehouse { get; set; }
 354}