fix(hotfix): Claude CLI via ssh на dev-vm + не хранить фото накладных
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Owner caught (повторно, после фикса 500): контейнер stage-api пытался запустить "claude" в своём cwd (/app), а binary там нет — "No such file or directory". Включаем RemoteHost SSH: 1. ClaudeCliInvoiceExtractor: передаёт в ssh/scp опции -i <SshKey>, -o StrictHostKeyChecking=accept-new, -o UserKnownHostsFile=<SshKnownHosts>, -o BatchMode=yes, -o ConnectTimeout=10. Конфиги Invoices:ClaudeCli:SshKey и Invoices:ClaudeCli:SshKnownHosts. 2. deploy/Dockerfile.api: + openssh-client в runtime. 3. (compose + ssh-ключ на prod-vm не в репо: deploy/secrets/invoice-ocr-key, authorized_keys на dev-vm с from="192.168.1.190",no-pty,no-forwarding; env RemoteHost=nns@192.168.1.192, Executable=/home/nns/.local/bin/claude.) Owner-policy: фото/сканы накладных НЕ хранить на серверах. InvoiceProcessor удаляет StoredPath сразу после распознавания — успех и неудача; remote tmp копия на dev-vm подчищается в finally. InvoiceWorker больше не содержит sweeper (периодическая очистка не нужна), конфиг Invoices:KeepFilesDays игнорируется (оставлен в комментарии для backwards compat). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f8480b06ae
commit
0ff93b6daa
|
|
@ -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
|
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
|
||||||
WORKDIR /app
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ public class ClaudeCliInvoiceExtractor : IInvoiceExtractor
|
||||||
private readonly int _timeoutSec;
|
private readonly int _timeoutSec;
|
||||||
private readonly string? _remoteHost;
|
private readonly string? _remoteHost;
|
||||||
private readonly string _remoteTmp;
|
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";
|
public string Name => "claude-cli";
|
||||||
|
|
||||||
|
|
@ -33,6 +39,32 @@ public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger<ClaudeCliInvoiceExt
|
||||||
_timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180;
|
_timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180;
|
||||||
_remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"];
|
_remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"];
|
||||||
_remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices";
|
_remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices";
|
||||||
|
_sshKey = cfg["Invoices:ClaudeCli:SshKey"];
|
||||||
|
_sshKnownHosts = cfg["Invoices:ClaudeCli:SshKnownHosts"] ?? "/tmp/.fm-ssh/known_hosts";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Общие опции для ssh/scp: key (если задан), accept-new host
|
||||||
|
/// key, batch mode (не спрашивать пароль), connect-timeout, кастомный
|
||||||
|
/// known_hosts (писаемый путь, т.к. /root/.ssh read-only). Применяются
|
||||||
|
/// к КАЖДОМУ ssh/scp invocation'у.</summary>
|
||||||
|
private IEnumerable<string> 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)
|
public bool CanHandle(string fileName, string? contentType)
|
||||||
|
|
@ -82,13 +114,15 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(_remoteHost))
|
if (!string.IsNullOrWhiteSpace(_remoteHost))
|
||||||
{
|
{
|
||||||
|
EnsureKnownHostsDir();
|
||||||
|
var sshOpts = SshCommonOpts().ToArray();
|
||||||
var remote = $"{_remoteTmp}/{Guid.NewGuid():N}{Path.GetExtension(filePath)}";
|
var remote = $"{_remoteTmp}/{Guid.NewGuid():N}{Path.GetExtension(filePath)}";
|
||||||
await RunAsync("ssh", new[] { _remoteHost, $"mkdir -p {_remoteTmp}" }, null, 30, ct);
|
await RunAsync("ssh", [.. sshOpts, _remoteHost, $"mkdir -p {_remoteTmp}"], null, 30, ct);
|
||||||
await RunAsync("scp", new[] { "-q", filePath, $"{_remoteHost}:{remote}" }, null, 120, ct);
|
await RunAsync("scp", [.. sshOpts, "-q", filePath, $"{_remoteHost}:{remote}"], null, 120, ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (code, so, se) = await RunAsync("ssh",
|
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);
|
BuildPrompt(remote), _timeoutSec, ct);
|
||||||
if (code != 0)
|
if (code != 0)
|
||||||
throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}");
|
throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}");
|
||||||
|
|
@ -96,8 +130,10 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
try { await RunAsync("ssh", new[] { _remoteHost, $"rm -f {remote}" }, null, 30, CancellationToken.None); }
|
// Удаляем временную копию на RemoteHost НЕЗАВИСИМО от исхода —
|
||||||
catch { /* временный файл — не повод валить разбор */ }
|
// owner-policy: файлы накладных на серверах не оседают.
|
||||||
|
try { await RunAsync("ssh", [.. sshOpts, _remoteHost, $"rm -f {remote}"], null, 30, CancellationToken.None); }
|
||||||
|
catch { /* тмп-файл — не повод валить разбор */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
|
||||||
throw new InvoiceExtractionException("Файл не найден на диске");
|
throw new InvoiceExtractionException("Файл не найден на диске");
|
||||||
|
|
||||||
var parsed = await extractor.ExtractAsync(up.StoredPath, ct);
|
var parsed = await extractor.ExtractAsync(up.StoredPath, ct);
|
||||||
|
// Owner-policy 2026-10-05: фото/сканы накладных на сервере НЕ хранить.
|
||||||
|
// Удаляем сразу после распознавания — распознанные строки живут
|
||||||
|
// в ParsedJson, этого достаточно. См. также catch-ветку.
|
||||||
|
DeleteStored(up);
|
||||||
|
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct);
|
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.Status = "failed";
|
||||||
up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||||
up.ProcessedAt = DateTime.UtcNow;
|
up.ProcessedAt = DateTime.UtcNow;
|
||||||
|
// Файл удаляем и при ошибке — хранить его смысла нет, человек
|
||||||
|
// всё равно пересылает/переснимает.
|
||||||
|
DeleteStored(up);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
_log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId);
|
_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 = "";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Строит matcher из каталога орги + её alias'ов.
|
/// <summary>Строит matcher из каталога орги + её alias'ов.
|
||||||
/// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) —
|
/// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) —
|
||||||
/// используем IgnoreQueryFilters + явный Where по OrganizationId.</summary>
|
/// используем IgnoreQueryFilters + явный Where по OrganizationId.</summary>
|
||||||
|
|
@ -121,46 +139,24 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
|
||||||
|
|
||||||
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
|
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
|
||||||
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается
|
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается
|
||||||
/// как BackgroundService (один worker на процесс). Раз в час подчищает
|
/// как BackgroundService (один worker на процесс).
|
||||||
/// старые файлы (<c>Invoices:KeepFilesDays</c>, default 7); разобранные
|
///
|
||||||
/// строки остаются навсегда.</summary>
|
/// Hotfix 2026-10-05 (owner-policy): фото/сканы накладных на сервере
|
||||||
|
/// хранить ЗАПРЕЩЕНО — файл удаляется ровно в <see cref="InvoiceProcessor"/>
|
||||||
|
/// сразу после разбора (успех+неудача). Поэтому периодического sweep'а
|
||||||
|
/// больше нет (<c>Invoices:KeepFilesDays</c> игнорируется — конфиг оставлен
|
||||||
|
/// как no-op для обратной совместимости и будет удалён в следующей
|
||||||
|
/// очистке конфигов). Разобранные строки (<c>ParsedJson</c>) живут
|
||||||
|
/// бессрочно.</summary>
|
||||||
public sealed class InvoiceWorker : BackgroundService
|
public sealed class InvoiceWorker : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopes;
|
private readonly IServiceScopeFactory _scopes;
|
||||||
private readonly InvoiceProcessor _proc;
|
private readonly InvoiceProcessor _proc;
|
||||||
private readonly ILogger<InvoiceWorker> _log;
|
private readonly ILogger<InvoiceWorker> _log;
|
||||||
private DateTime _lastSweep = DateTime.MinValue;
|
|
||||||
|
|
||||||
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l)
|
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l)
|
||||||
{ _scopes = s; _proc = p; _log = 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<AppDbContext>();
|
|
||||||
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
|
||||||
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)
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
|
|
@ -180,10 +176,6 @@ protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
catch (OperationCanceledException) { break; }
|
catch (OperationCanceledException) { break; }
|
||||||
catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); }
|
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); }
|
try { await Task.Delay(TimeSpan.FromSeconds(3), ct); }
|
||||||
catch (OperationCanceledException) { break; }
|
catch (OperationCanceledException) { break; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue