diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api index 9a5b043..3025113 100644 --- a/deploy/Dockerfile.api +++ b/deploy/Dockerfile.api @@ -21,7 +21,7 @@ RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \ && rm -rf /var/lib/apt/lists/* COPY --from=build /app . diff --git a/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs index dcee897..ec0b49c 100644 --- a/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs +++ b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs @@ -22,6 +22,12 @@ public class ClaudeCliInvoiceExtractor : IInvoiceExtractor private readonly int _timeoutSec; private readonly string? _remoteHost; private readonly string _remoteTmp; + // Hotfix 2026-10-05: контейнер stage-api → ssh → dev-vm, где + // установлен и залогинен claude. Ключ монтируется из compose в + // read-only volume, StrictHostKeyChecking=accept-new принимает ключ + // хоста на первом коннекте и пишет в known_hosts (writable тмп-файл). + private readonly string? _sshKey; + private readonly string _sshKnownHosts; public string Name => "claude-cli"; @@ -33,6 +39,32 @@ public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILoggerОбщие опции для ssh/scp: key (если задан), accept-new host + /// key, batch mode (не спрашивать пароль), connect-timeout, кастомный + /// known_hosts (писаемый путь, т.к. /root/.ssh read-only). Применяются + /// к КАЖДОМУ ssh/scp invocation'у. + private IEnumerable SshCommonOpts() + { + if (!string.IsNullOrEmpty(_sshKey)) + { + yield return "-i"; + yield return _sshKey; + yield return "-o"; yield return "IdentitiesOnly=yes"; + } + yield return "-o"; yield return "StrictHostKeyChecking=accept-new"; + yield return "-o"; yield return $"UserKnownHostsFile={_sshKnownHosts}"; + yield return "-o"; yield return "BatchMode=yes"; + yield return "-o"; yield return "ConnectTimeout=10"; + } + + private void EnsureKnownHostsDir() + { + var dir = Path.GetDirectoryName(_sshKnownHosts); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); } public bool CanHandle(string fileName, string? contentType) @@ -82,13 +114,15 @@ public async Task ExtractAsync(string filePath, CancellationToken { if (!string.IsNullOrWhiteSpace(_remoteHost)) { + EnsureKnownHostsDir(); + var sshOpts = SshCommonOpts().ToArray(); var remote = $"{_remoteTmp}/{Guid.NewGuid():N}{Path.GetExtension(filePath)}"; - await RunAsync("ssh", new[] { _remoteHost, $"mkdir -p {_remoteTmp}" }, null, 30, ct); - await RunAsync("scp", new[] { "-q", filePath, $"{_remoteHost}:{remote}" }, null, 120, ct); + await RunAsync("ssh", [.. sshOpts, _remoteHost, $"mkdir -p {_remoteTmp}"], null, 30, ct); + await RunAsync("scp", [.. sshOpts, "-q", filePath, $"{_remoteHost}:{remote}"], null, 120, ct); try { var (code, so, se) = await RunAsync("ssh", - new[] { _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read" }, + [.. sshOpts, _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read"], BuildPrompt(remote), _timeoutSec, ct); if (code != 0) throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}"); @@ -96,8 +130,10 @@ public async Task ExtractAsync(string filePath, CancellationToken } finally { - try { await RunAsync("ssh", new[] { _remoteHost, $"rm -f {remote}" }, null, 30, CancellationToken.None); } - catch { /* временный файл — не повод валить разбор */ } + // Удаляем временную копию на RemoteHost НЕЗАВИСИМО от исхода — + // owner-policy: файлы накладных на серверах не оседают. + try { await RunAsync("ssh", [.. sshOpts, _remoteHost, $"rm -f {remote}"], null, 30, CancellationToken.None); } + catch { /* тмп-файл — не повод валить разбор */ } } } diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs index 796e02b..e8a6cf3 100644 --- a/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs @@ -41,6 +41,10 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct) throw new InvoiceExtractionException("Файл не найден на диске"); var parsed = await extractor.ExtractAsync(up.StoredPath, ct); + // Owner-policy 2026-10-05: фото/сканы накладных на сервере НЕ хранить. + // Удаляем сразу после распознавания — распознанные строки живут + // в ParsedJson, этого достаточно. См. также catch-ветку. + DeleteStored(up); var sw = System.Diagnostics.Stopwatch.StartNew(); var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct); @@ -74,11 +78,25 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct) up.Status = "failed"; up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; up.ProcessedAt = DateTime.UtcNow; + // Файл удаляем и при ошибке — хранить его смысла нет, человек + // всё равно пересылает/переснимает. + DeleteStored(up); await db.SaveChangesAsync(CancellationToken.None); _log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId); } } + private static void DeleteStored(InvoiceUpload up) + { + try + { + if (!string.IsNullOrEmpty(up.StoredPath) && File.Exists(up.StoredPath)) + File.Delete(up.StoredPath); + } + catch { /* файл мог быть уже удалён или FS хрюкнула — не повод падать */ } + up.StoredPath = ""; + } + /// Строит matcher из каталога орги + её alias'ов. /// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) — /// используем IgnoreQueryFilters + явный Where по OrganizationId. @@ -121,46 +139,24 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct) /// Разбирает всё, что в очереди (Status=pending). Фото /// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается -/// как BackgroundService (один worker на процесс). Раз в час подчищает -/// старые файлы (Invoices:KeepFilesDays, default 7); разобранные -/// строки остаются навсегда. +/// как BackgroundService (один worker на процесс). +/// +/// Hotfix 2026-10-05 (owner-policy): фото/сканы накладных на сервере +/// хранить ЗАПРЕЩЕНО — файл удаляется ровно в +/// сразу после разбора (успех+неудача). Поэтому периодического sweep'а +/// больше нет (Invoices:KeepFilesDays игнорируется — конфиг оставлен +/// как no-op для обратной совместимости и будет удалён в следующей +/// очистке конфигов). Разобранные строки (ParsedJson) живут +/// бессрочно. public sealed class InvoiceWorker : BackgroundService { private readonly IServiceScopeFactory _scopes; private readonly InvoiceProcessor _proc; private readonly ILogger _log; - private DateTime _lastSweep = DateTime.MinValue; public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger l) { _scopes = s; _proc = p; _log = l; } - private async Task SweepAsync(CancellationToken ct) - { - if (DateTime.UtcNow - _lastSweep < TimeSpan.FromHours(1)) return; - _lastSweep = DateTime.UtcNow; - - using var scope = _scopes.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var cfg = scope.ServiceProvider.GetRequiredService(); - var days = int.TryParse(cfg["Invoices:KeepFilesDays"], out var d) ? d : 7; - var edge = DateTime.UtcNow.AddDays(-days); - - var old = await db.InvoiceUploads.IgnoreQueryFilters() - .Where(x => x.StoredPath != "" && x.UploadedAt < edge) - .ToListAsync(ct); - int n = 0; - foreach (var up in old) - { - try { if (File.Exists(up.StoredPath)) File.Delete(up.StoredPath); } catch { } - up.StoredPath = ""; n++; - } - if (n > 0) - { - await db.SaveChangesAsync(ct); - _log.LogInformation("[Invoices] swept {N} old files (>{D}d)", n, days); - } - } - protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -180,10 +176,6 @@ protected override async Task ExecuteAsync(CancellationToken ct) catch (OperationCanceledException) { break; } catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); } - try { await SweepAsync(ct); } - catch (OperationCanceledException) { break; } - catch (Exception ex) { _log.LogWarning(ex, "[Invoices] sweep failed"); } - try { await Task.Delay(TimeSpan.FromSeconds(3), ct); } catch (OperationCanceledException) { break; } }