using System.Text.Json; namespace FixedAssetPrintAgent; internal sealed class PendingPrintResult { public int JobId { get; set; } public string ClaimToken { get; set; } = string.Empty; public string ServerUrl { get; set; } = string.Empty; public int TerminalId { get; set; } public string Phase { get; set; } = "claimed"; public string? ErrorMessage { get; set; } } internal interface IPrintResultJournal { Task LoadAsync(); Task SaveAsync(PendingPrintResult result); Task ClearAsync(); } internal sealed class PrintResultJournal(string storageRoot) : IPrintResultJournal { private readonly string _filePath = Path.Combine(storageRoot, "pending-print-result.json"); private readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); public async Task LoadAsync() { if (!File.Exists(_filePath)) return null; await using var stream = File.OpenRead(_filePath); var result = await JsonSerializer.DeserializeAsync(stream, _options); if (result is null || result.JobId <= 0 || string.IsNullOrWhiteSpace(result.ClaimToken) || result.Phase is not ("claimed" or "dispatching" or "completed_pending_ack" or "unknown_pending_ack" or "failed_pending_ack")) throw new InvalidOperationException("本地打印恢复记录损坏,请先核实上一任务出纸情况。"); return result; } public async Task SaveAsync(PendingPrintResult result) { Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); var temporaryPath = $"{_filePath}.{Guid.NewGuid():N}.tmp"; try { await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { await JsonSerializer.SerializeAsync(stream, result, _options); stream.Flush(true); } File.Move(temporaryPath, _filePath, true); } finally { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } public Task ClearAsync() { File.Delete(_filePath); return Task.CompletedTask; } } internal sealed class PrintAgentRequestException(int statusCode, string message) : Exception(message) { public int StatusCode { get; } = statusCode; } internal static class PrintResultRecovery { // Acknowledgment retries never enter the printer. Inspect a conflicting result // before clearing the journal; only a fenced attempt or final server state wins. public static async Task AcknowledgeAsync( PendingPrintResult pending, IPrintResultJournal journal, Func report, Func> readState, Action log) { var status = pending.Phase == "completed_pending_ack" ? "completed" : pending.Phase == "failed_pending_ack" ? "failed" : "result_unknown"; try { await report(pending.JobId, pending.ClaimToken, status, pending.ErrorMessage ?? (status == "result_unknown" ? "打印过程曾中断,请人工核实出纸结果。" : null)); await journal.ClearAsync(); log(status == "completed" ? $"任务 {pending.JobId} 完成结果已确认。" : $"任务 {pending.JobId} 结果已上报,请到系统查看。" ); } catch (PrintAgentRequestException ex) when (ex.StatusCode == 409) { var current = await readState(pending.JobId); if (!current.HasValidState() || current.Id != pending.JobId) throw new InvalidOperationException("服务器任务状态响应不完整,保留恢复记录并等待核实。"); if (current.ClaimToken != pending.ClaimToken || current.Status is "completed" or "cancelled" or "failed") { await journal.ClearAsync(); log($"任务 {pending.JobId} 已由服务器处理,请核对系统中的结果。"); return; } throw; } } public static async Task ExecuteAsync( PrintJobDto job, AgentConfiguration configuration, IPrintResultJournal journal, Func report, Func> readState, Func dispatch, Action log, TimeSpan? renewalInterval = null) { if (string.IsNullOrWhiteSpace(job.ClaimToken)) throw new InvalidOperationException("服务器缺少领取凭据,已阻止实际出纸,请更新服务器。"); var pending = new PendingPrintResult { JobId = job.Id, ClaimToken = job.ClaimToken, ServerUrl = configuration.ServerUrl, TerminalId = configuration.TerminalId, Phase = "claimed" }; await journal.SaveAsync(pending); if (job.JobType != "asset_label") { pending.Phase = "failed_pending_ack"; pending.ErrorMessage = "不支持的打印任务类型,未开始打印。"; await journal.SaveAsync(pending); await AcknowledgeAsync(pending, journal, report, readState, log); return; } await report(job.Id, job.ClaimToken, "printing", null); // Persist before any spool call. On restart this phase becomes result_unknown. pending.Phase = "dispatching"; await journal.SaveAsync(pending); using var cancellation = new CancellationTokenSource(); var renewal = RenewAsync(job, report, log, renewalInterval ?? TimeSpan.FromSeconds(30), cancellation.Token); try { await dispatch(); pending.Phase = "completed_pending_ack"; pending.ErrorMessage = null; } catch (Exception ex) { // Some labels may already be spooled. A partial failure cannot be retried // as a whole batch until an administrator verifies the actual output. pending.Phase = "unknown_pending_ack"; pending.ErrorMessage = ex.Message; } finally { cancellation.Cancel(); await renewal; } await journal.SaveAsync(pending); // This is deliberately outside the dispatch catch. Network failure after // printing keeps completed_pending_ack and cannot become a print failure. await AcknowledgeAsync(pending, journal, report, readState, log); } private static async Task RenewAsync( PrintJobDto job, Func report, Action log, TimeSpan interval, CancellationToken cancellation) { while (!cancellation.IsCancellationRequested) { try { await Task.Delay(interval, cancellation); if (!cancellation.IsCancellationRequested) await report(job.Id, job.ClaimToken, "printing", null); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { return; } catch (Exception ex) { log($"任务 {job.Id} 续期失败:{ex.Message}"); } } } }