177 lines
6.5 KiB
C#

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<PendingPrintResult?> 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<PendingPrintResult?> LoadAsync()
{
if (!File.Exists(_filePath)) return null;
await using var stream = File.OpenRead(_filePath);
var result = await JsonSerializer.DeserializeAsync<PendingPrintResult>(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<int, string, string, string?, Task> report,
Func<int, Task<PrintJobState>> readState,
Action<string> 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<int, string, string, string?, Task> report,
Func<int, Task<PrintJobState>> readState,
Func<Task> dispatch,
Action<string> 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<int, string, string, string?, Task> report,
Action<string> 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}"); }
}
}
}