< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
ValidateKeyAsync()0%2040%
IsStatusAllowed(...)0%620%
IsWarehouseAllowed(...)0%620%
InvalidAccessStub()0%620%
Index()0%1806420%
Generate()0%812280%
Progress()0%272160%
Result(...)0%620%
DownloadLabels()0%812280%
ImageProxy()0%620%
LabelProxy()0%620%
IsValidClientId(...)100%210%
IsValidOzonUrl(...)100%210%
IsValidOzonUrl(...)0%7280%
WriteSSE()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{
 021    private static readonly JsonSerializerOptions CamelCaseOptions = new()
 022    {
 023        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 024    };
 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
 033    public AssemblyListController(
 034        ApplicationDbContext db,
 035        OzonClientServices ozonClientServices,
 036        IMemoryCache cache,
 037        AssemblyListGenerationOrchestrator orchestrator,
 038        IHttpClientFactory httpClientFactory,
 039        ILogger<AssemblyListController> logger)
 40    {
 041        _db = db;
 042        _ozonClientServices = ozonClientServices;
 043        _cache = cache;
 044        _orchestrator = orchestrator;
 045        _httpClientFactory = httpClientFactory;
 046        _logger = logger;
 047    }
 48
 49    // ─── Key validation ───────────────────────────────────────────────────────
 50
 51    private async Task<AssemblyListAccessKey?> ValidateKeyAsync(string accessKey, int clientId)
 52    {
 053        var key = await _db.AssemblyListAccessKeys
 054            .FirstOrDefaultAsync(k => k.AccessKey == accessKey
 055                && k.IsActive
 056                && (k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow));
 057        if (key == null) return null;
 058        if (!key.AllowedClientIdList.Contains(clientId)) return null;
 059        return key;
 060    }
 61
 62    private static bool IsStatusAllowed(AssemblyListAccessKey key, string status) =>
 063        key.AllowedStatusList.Count == 0
 064        || key.AllowedStatusList.Contains(status, StringComparer.OrdinalIgnoreCase);
 65
 66    private static bool IsWarehouseAllowed(AssemblyListAccessKey key, string warehouse) =>
 067        key.AllowedWarehouseList.Count == 0
 068        || key.AllowedWarehouseList.Contains(warehouse, StringComparer.OrdinalIgnoreCase);
 69
 70    private IActionResult InvalidAccessStub()
 71    {
 072        Response.StatusCode = StatusCodes.Status404NotFound;
 073        if (HttpMethods.IsHead(Request.Method))
 074            return new EmptyResult();
 75
 076        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    {
 085        if (!int.TryParse(Request.Query["clientId"], out var clientId))
 086            return InvalidAccessStub();
 87
 088        var key = await ValidateKeyAsync(accessKey, clientId);
 089        if (key == null)
 90        {
 091            _logger.LogWarning("Public assembly denied: invalid key or client, key={AccessKey}, clientId={ClientId}", ac
 092            return InvalidAccessStub();
 93        }
 94
 095        var status = Request.Query["status"].ToString();
 096        if (string.IsNullOrEmpty(status)) status = "awaiting_deliver";
 097        if (!IsStatusAllowed(key, status))
 98        {
 099            _logger.LogWarning("Public assembly denied: status not allowed, keyId={KeyId}, status={Status}", key.Id, sta
 0100            return InvalidAccessStub();
 101        }
 102
 0103        var warehouse = Request.Query["warehouse"].ToString();
 0104        if (!string.IsNullOrEmpty(warehouse) && !IsWarehouseAllowed(key, warehouse))
 105        {
 0106            _logger.LogWarning("Public assembly denied: warehouse not allowed, keyId={KeyId}, warehouse={Warehouse}", ke
 0107            return InvalidAccessStub();
 108        }
 109
 0110        var (periodFrom, periodTo) = key.ResolvePeriod();
 0111        var from = periodFrom.ToString("yyyy-MM-dd");
 0112        var to = periodTo.ToString("yyyy-MM-dd");
 113
 0114        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
 0115        _logger.LogInformation("Public assembly view: key={KeyId} client={ClientId} warehouse={Warehouse} ip={Ip}",
 0116            key.Id, clientId, warehouse, ip);
 117
 0118        var host = $"{Request.Scheme}://{Request.Host}";
 0119        ViewBag.AccessKey = accessKey;
 0120        ViewBag.ClientId = clientId;
 0121        ViewBag.Status = status;
 0122        ViewBag.Warehouse = warehouse;
 0123        ViewBag.From = from;
 0124        ViewBag.To = to;
 0125        ViewBag.OgTitle = AssemblyListOgMetadata.BuildTitle();
 0126        ViewBag.OgDescription = AssemblyListOgMetadata.BuildDescription(from, to, warehouse, status);
 0127        ViewBag.OgSiteName = "Interparts";
 0128        ViewBag.OgImageAlt = AssemblyListOgMetadata.BuildImageAlt(warehouse);
 0129        ViewBag.OgImageUrl = $"{host}/images/og-assembly-list.jpg";
 0130        ViewBag.OgUrl = $"{host}{Request.Path}{Request.QueryString}";
 131
 0132        if (HttpMethods.IsHead(Request.Method))
 0133            return new EmptyResult();
 134
 0135        return View();
 0136    }
 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    {
 0144        if (!IsValidClientId(body.ClientId)) return NotFound();
 145
 0146        var key = await ValidateKeyAsync(accessKey, body.ClientId);
 0147        if (key == null) return NotFound();
 148
 0149        var status = string.IsNullOrEmpty(body.Status) ? "awaiting_deliver" : body.Status;
 0150        if (!IsStatusAllowed(key, status)) return NotFound();
 151
 0152        var warehouse = body.Warehouse ?? "";
 0153        if (!string.IsNullOrEmpty(warehouse) && !IsWarehouseAllowed(key, warehouse)) return NotFound();
 154
 0155        var client = await _ozonClientServices.GetOzonClientAsync(body.ClientId);
 0156        if (client?.DecryptClientId == null || client.DecryptApiKey == null)
 0157            return NotFound();
 158
 0159        var (cutoffFrom, cutoffTo) = key.ResolvePeriod();
 160
 0161        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
 0162        _logger.LogInformation("Public assembly generate: key={KeyId} client={ClientId} warehouse={Warehouse} ip={Ip}",
 0163            key.Id, body.ClientId, warehouse, ip);
 164
 0165        var req = new GenerationRequest(
 0166            OzonClientId: body.ClientId,
 0167            OzonClientName: client.Name ?? client.DecryptClientId,
 0168            DecryptedClientId: client.DecryptClientId,
 0169            DecryptedApiKey: client.DecryptApiKey,
 0170            CutoffFrom: cutoffFrom,
 0171            CutoffTo: cutoffTo,
 0172            Status: status,
 0173            WarehouseFilter: string.IsNullOrEmpty(warehouse) ? null : warehouse,
 0174            PersistItemsInBackground: true);
 175
 0176        var (jobId, dbRunId) = await _orchestrator.StartAsync(req);
 0177        return Ok(new { jobId, dbRunId });
 0178    }
 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    {
 0186        Response.Headers["Content-Type"] = "text/event-stream";
 0187        Response.Headers["Cache-Control"] = "no-cache";
 0188        Response.Headers["Connection"] = "keep-alive";
 189
 0190        var timeout = DateTime.UtcNow.AddMinutes(10);
 191
 0192        ConcurrentQueue<AssemblyListProgress>? queue = null;
 0193        for (var attempt = 0; attempt < 10 && queue == null; attempt++)
 194        {
 0195            queue = _cache.Get<ConcurrentQueue<AssemblyListProgress>>($"al_progress_{jobId}");
 0196            if (queue == null) await Task.Delay(500);
 197        }
 198
 0199        if (queue == null)
 200        {
 0201            await WriteSSE(Response, "progress",
 0202                JsonSerializer.Serialize(new AssemblyListProgress { IsError = true, ErrorMessage = "Job not found" }, Ca
 0203            await Response.Body.FlushAsync();
 0204            return;
 205        }
 206
 0207        while (DateTime.UtcNow < timeout)
 208        {
 0209            while (queue.TryDequeue(out var prog))
 210            {
 0211                await WriteSSE(Response, "progress", JsonSerializer.Serialize(prog, CamelCaseOptions));
 0212                await Response.Body.FlushAsync();
 0213                if (prog.IsComplete || prog.IsError) return;
 214            }
 0215            await Task.Delay(400);
 216        }
 0217    }
 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    {
 0225        var result = _cache.Get<AssemblyListResult>($"al_result_{jobId}");
 0226        if (result == null) return NotFound();
 0227        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    {
 0236        if (clientId <= 0) return NotFound();
 237
 0238        var key = await ValidateKeyAsync(accessKey, clientId);
 0239        if (key == null) return NotFound();
 240
 0241        var result = _cache.Get<AssemblyListResult>($"al_result_{jobId}");
 0242        if (result == null) return NotFound();
 243
 0244        var labelUrls = result.Items
 0245            .Select(i => i.LabelUrl)
 0246            .Where(u => !string.IsNullOrWhiteSpace(u))
 0247            .Distinct(StringComparer.Ordinal)
 0248            .ToList();
 249
 0250        if (labelUrls.Count == 0)
 0251            return BadRequest("Нет доступных этикеток для скачивания.");
 252
 0253        var httpClient = _httpClientFactory.CreateClient();
 0254        await using var mergedStream = new MemoryStream();
 0255        using var mergedPdf = new PdfDocument(new PdfWriter(mergedStream));
 0256        var mergedAnyPage = false;
 257
 0258        foreach (var labelUrl in labelUrls)
 259        {
 0260            if (labelUrl == null || !IsValidOzonUrl(labelUrl))
 261                continue;
 262
 263            try
 264            {
 0265                var bytes = await httpClient.GetByteArrayAsync(labelUrl);
 0266                await using var sourceStream = new MemoryStream(bytes);
 0267                using var sourcePdf = new PdfDocument(new PdfReader(sourceStream));
 0268                if (sourcePdf.GetNumberOfPages() <= 0)
 0269                    continue;
 270
 0271                sourcePdf.CopyPagesTo(1, sourcePdf.GetNumberOfPages(), mergedPdf);
 0272                mergedAnyPage = true;
 0273            }
 0274            catch
 275            {
 276                // best-effort: пропускаем битый/недоступный PDF, продолжаем склейку остальных.
 0277            }
 278        }
 279
 0280        if (!mergedAnyPage)
 0281            return BadRequest("Не удалось сформировать общий PDF этикеток.");
 282
 0283        mergedPdf.Close();
 0284        var fileName = $"assembly_labels_{DateTime.Now:yyyyMMdd_HHmm}.pdf";
 0285        return File(mergedStream.ToArray(), "application/pdf", fileName);
 0286    }
 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    {
 0294        if (!IsValidOzonUrl(url)) return BadRequest();
 295        try
 296        {
 0297            var bytes = await _httpClientFactory.CreateClient().GetByteArrayAsync(url);
 0298            return File(bytes, "image/jpeg");
 299        }
 0300        catch { return NotFound(); }
 0301    }
 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    {
 0309        if (!IsValidOzonUrl(url, out var uri)) return BadRequest();
 310        try
 311        {
 0312            var bytes = await _httpClientFactory.CreateClient().GetByteArrayAsync(url);
 0313            var fileName = Path.GetFileName(uri!.LocalPath);
 0314            Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
 0315            return File(bytes, "application/pdf");
 316        }
 0317        catch { return NotFound(); }
 0318    }
 319
 320    // ─── Helpers ──────────────────────────────────────────────────────────────
 321
 0322    private static bool IsValidClientId(int id) => id > 0;
 323
 0324    private static bool IsValidOzonUrl(string url) => IsValidOzonUrl(url, out _);
 325
 326    private static bool IsValidOzonUrl(string url, out Uri? uri)
 327    {
 0328        uri = null;
 0329        if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out uri))
 0330            return false;
 0331        var host = uri.Host;
 0332        return host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase)
 0333            || host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase)
 0334            || host.EndsWith(".ozonusercontent.com", StringComparison.OrdinalIgnoreCase);
 335    }
 336
 337    private static async Task WriteSSE(HttpResponse response, string eventName, string data)
 338    {
 0339        var sb = new StringBuilder();
 0340        sb.AppendLine($"event: {eventName}");
 0341        sb.AppendLine($"data: {data}");
 0342        sb.AppendLine();
 0343        await response.WriteAsync(sb.ToString());
 0344    }
 345}
 346
 347public class PublicGenerateRequest
 348{
 349    public int ClientId { get; set; }
 350    public string? Status { get; set; }
 351    public string? From { get; set; }
 352    public string? To { get; set; }
 353    public string? Warehouse { get; set; }
 354}