815 lines
23 KiB
C#
815 lines
23 KiB
C#
using System.Net.Http.Headers;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using Microsoft.Win32;
|
||
|
||
namespace FixedAssetPrintAgent;
|
||
|
||
internal sealed class PrintAgentRuntime : IDisposable
|
||
{
|
||
private readonly AgentConfigurationStore _store;
|
||
private readonly IPrintResultJournal _journal;
|
||
private readonly PrintAgentApiClient _apiClient = new();
|
||
private readonly LabelPrintService _labelPrintService = new();
|
||
private readonly WindowsStartupManager _startupManager;
|
||
private readonly string _appVersion;
|
||
private readonly SemaphoreSlim _heartbeatSemaphore = new(1, 1);
|
||
private readonly SemaphoreSlim _pollSemaphore = new(1, 1);
|
||
private CancellationTokenSource? _runtimeCancellationTokenSource;
|
||
private Task? _heartbeatLoopTask;
|
||
private Task? _pollLoopTask;
|
||
private bool _disposed;
|
||
|
||
public event EventHandler<PrintAgentRuntimeState>? StateChanged;
|
||
|
||
public PrintAgentRuntimeState CurrentState { get; } = new();
|
||
|
||
public PrintAgentRuntime(string storageRoot, string appVersion, string executablePath)
|
||
{
|
||
_store = new AgentConfigurationStore(storageRoot);
|
||
_journal = new PrintResultJournal(storageRoot);
|
||
_startupManager = new WindowsStartupManager(executablePath);
|
||
_appVersion = appVersion;
|
||
}
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
CurrentState.Configuration = NormalizeConfiguration(await _store.LoadAsync());
|
||
await _startupManager.ApplyAsync(CurrentState.Configuration.RunAtStartup);
|
||
await RefreshPrintersAsync();
|
||
await RefreshRemoteResourcesAsync();
|
||
|
||
if (CurrentState.IsPaired)
|
||
{
|
||
AppendLog($"已读取终端配置,当前终端编号 {CurrentState.Configuration.TerminalId}。");
|
||
}
|
||
else
|
||
{
|
||
AppendLog("当前尚未完成配对,请先输入服务器地址、终端名称和配对码。");
|
||
}
|
||
|
||
EmitState();
|
||
StartBackgroundLoops();
|
||
|
||
if (CurrentState.IsPaired)
|
||
{
|
||
await HeartbeatNowAsync(true);
|
||
await ClaimNowAsync(true);
|
||
}
|
||
}
|
||
|
||
public async Task SaveSettingsAsync(
|
||
string serverUrl,
|
||
string terminalName,
|
||
string selectedPrinterName,
|
||
bool runAtStartup)
|
||
{
|
||
CurrentState.Configuration.ServerUrl = NormalizeServerUrl(serverUrl);
|
||
CurrentState.Configuration.TerminalName = NormalizeTerminalName(terminalName);
|
||
CurrentState.Configuration.SelectedPrinterName = selectedPrinterName.Trim();
|
||
CurrentState.Configuration.RunAtStartup = runAtStartup;
|
||
CurrentState.Configuration = NormalizeConfiguration(CurrentState.Configuration);
|
||
|
||
await _store.SaveAsync(CurrentState.Configuration);
|
||
await _startupManager.ApplyAsync(CurrentState.Configuration.RunAtStartup);
|
||
StartBackgroundLoops();
|
||
AppendLog("本地配置已保存。");
|
||
EmitState();
|
||
}
|
||
|
||
public async Task PairAsync(string serverUrl, string terminalName, string pairCode)
|
||
{
|
||
var normalizedServerUrl = NormalizeServerUrl(serverUrl);
|
||
var normalizedTerminalName = NormalizeTerminalName(terminalName);
|
||
var normalizedPairCode = pairCode.Trim().ToUpperInvariant();
|
||
|
||
if (string.IsNullOrWhiteSpace(normalizedPairCode))
|
||
{
|
||
throw new InvalidOperationException("请输入一次性配对码。");
|
||
}
|
||
|
||
var response = await _apiClient.PairAsync(
|
||
normalizedServerUrl,
|
||
normalizedTerminalName,
|
||
CurrentState.Configuration.MachineName,
|
||
CurrentState.Configuration.Platform,
|
||
normalizedPairCode);
|
||
|
||
CurrentState.Configuration.ServerUrl = normalizedServerUrl;
|
||
CurrentState.Configuration.TerminalName = normalizedTerminalName;
|
||
CurrentState.Configuration.TerminalId = response.TerminalId;
|
||
CurrentState.Configuration.DeviceToken = response.DeviceToken;
|
||
CurrentState.Configuration.PairedAt = response.PairedAt ?? DateTimeOffset.Now.ToString("O");
|
||
CurrentState.Configuration = NormalizeConfiguration(CurrentState.Configuration);
|
||
|
||
await _store.SaveAsync(CurrentState.Configuration);
|
||
StartBackgroundLoops();
|
||
AppendLog(response.IsDefault
|
||
? "配对成功,这台电脑已成为默认打印终端。"
|
||
: "配对成功,这台电脑已加入终端列表。");
|
||
EmitState();
|
||
|
||
await HeartbeatNowAsync(true);
|
||
await RefreshRemoteResourcesAsync();
|
||
}
|
||
|
||
public Task RefreshPrintersAsync()
|
||
{
|
||
CurrentState.Printers = _labelPrintService.ListPrinters();
|
||
EmitState();
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public async Task RefreshRemoteResourcesAsync()
|
||
{
|
||
try
|
||
{
|
||
var releases = await _apiClient.GetReleasesAsync(CurrentState.Configuration.ServerUrl);
|
||
CurrentState.Releases = releases.Releases;
|
||
|
||
var latestVersion = releases.LatestVersion ?? CurrentState.Releases.FirstOrDefault()?.Version ?? string.Empty;
|
||
CurrentState.UpdateStatusMessage = string.IsNullOrWhiteSpace(latestVersion)
|
||
? "尚未读取到服务器版本"
|
||
: latestVersion == _appVersion
|
||
? $"当前已是最新版本 {_appVersion}"
|
||
: $"发现新版本 {latestVersion},后台可下载安装";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CurrentState.UpdateStatusMessage = "版本检查失败,请稍后重试";
|
||
CurrentState.LastError = ex.Message;
|
||
AppendLog($"版本检查失败:{ex.Message}");
|
||
}
|
||
|
||
try
|
||
{
|
||
CurrentState.LatestDriver = await _apiClient.GetLatestDriverAsync(CurrentState.Configuration.ServerUrl);
|
||
CurrentState.ArchivedDrivers = await _apiClient.GetArchivedDriversAsync(CurrentState.Configuration.ServerUrl);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CurrentState.LastError = ex.Message;
|
||
AppendLog($"驱动资源读取失败:{ex.Message}");
|
||
}
|
||
|
||
EmitState();
|
||
}
|
||
|
||
public async Task HeartbeatNowAsync(bool suppressErrors = false)
|
||
{
|
||
if (!CurrentState.IsPaired)
|
||
{
|
||
if (!suppressErrors)
|
||
{
|
||
AppendLog("尚未完成配对,暂时不能发送心跳。");
|
||
}
|
||
return;
|
||
}
|
||
|
||
await _heartbeatSemaphore.WaitAsync();
|
||
try
|
||
{
|
||
await RefreshPrintersAsync();
|
||
|
||
await _apiClient.SendHeartbeatAsync(
|
||
CurrentState.Configuration.ServerUrl,
|
||
CurrentState.Configuration.DeviceToken,
|
||
CurrentState.Configuration.TerminalId,
|
||
CurrentState.Configuration.TerminalName,
|
||
CurrentState.Configuration.MachineName,
|
||
CurrentState.Configuration.Platform,
|
||
CurrentState.Configuration.SelectedPrinterName,
|
||
CurrentState.Printers,
|
||
BuildLabelProfile());
|
||
|
||
CurrentState.LastHeartbeatAt = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||
CurrentState.LastError = string.Empty;
|
||
AppendLog("心跳已发送,服务器状态已刷新。");
|
||
EmitState();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CurrentState.LastError = ex.Message;
|
||
if (!suppressErrors)
|
||
{
|
||
AppendLog($"心跳失败:{ex.Message}");
|
||
EmitState();
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_heartbeatSemaphore.Release();
|
||
}
|
||
}
|
||
|
||
public async Task ClaimNowAsync(bool suppressErrors = false)
|
||
{
|
||
if (!CurrentState.IsPaired)
|
||
{
|
||
if (!suppressErrors)
|
||
{
|
||
AppendLog("尚未完成配对,暂时不能领取任务。");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!await _pollSemaphore.WaitAsync(0)) return;
|
||
try
|
||
{
|
||
var pending = await _journal.LoadAsync();
|
||
if (pending is not null)
|
||
{
|
||
var configuration = CurrentState.Configuration.Clone();
|
||
if (pending.ServerUrl != configuration.ServerUrl || pending.TerminalId != configuration.TerminalId)
|
||
throw new InvalidOperationException("上一任务属于其他服务器或终端,请恢复原配置并核实结果。");
|
||
await PrintResultRecovery.AcknowledgeAsync(pending, _journal,
|
||
(id, token, status, error) => _apiClient.ReportJobStatusAsync(configuration.ServerUrl,
|
||
configuration.DeviceToken, id, status, error, false, token),
|
||
id => _apiClient.GetJobStateAsync(configuration.ServerUrl, configuration.DeviceToken, id), AppendLog);
|
||
EmitState();
|
||
return;
|
||
}
|
||
var claimResult = await _apiClient.ClaimNextJobAsync(
|
||
CurrentState.Configuration.ServerUrl,
|
||
CurrentState.Configuration.DeviceToken,
|
||
CurrentState.Configuration.TerminalId);
|
||
|
||
CurrentState.LastClaimAt = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||
|
||
if (claimResult.Job is null)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(claimResult.Message) && !suppressErrors)
|
||
{
|
||
AppendLog(claimResult.Message);
|
||
EmitState();
|
||
}
|
||
return;
|
||
}
|
||
|
||
await ProcessPrintJobAsync(claimResult.Job);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CurrentState.LastError = ex.Message;
|
||
if (!suppressErrors)
|
||
{
|
||
AppendLog($"打印任务处理或结果回写失败:{ex.Message}");
|
||
EmitState();
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_pollSemaphore.Release();
|
||
}
|
||
}
|
||
|
||
public async Task TestPrintAsync()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(CurrentState.Configuration.SelectedPrinterName))
|
||
{
|
||
throw new InvalidOperationException("请先选择标签打印机,再执行测试打印。");
|
||
}
|
||
|
||
await RefreshPrintersAsync();
|
||
|
||
try
|
||
{
|
||
await _labelPrintService.PrintTestLabelAsync(CurrentState.Configuration.SelectedPrinterName);
|
||
CurrentState.LastJobResult = $"测试标签已发送到 {CurrentState.Configuration.SelectedPrinterName}";
|
||
AppendLog(CurrentState.LastJobResult);
|
||
|
||
if (CurrentState.IsPaired)
|
||
{
|
||
await _apiClient.ReportTestPrintAsync(
|
||
CurrentState.Configuration.ServerUrl,
|
||
CurrentState.Configuration.DeviceToken,
|
||
CurrentState.Configuration.SelectedPrinterName,
|
||
CurrentState.Printers,
|
||
BuildLabelProfile(),
|
||
true,
|
||
null);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CurrentState.LastError = ex.Message;
|
||
CurrentState.LastJobResult = ex.Message;
|
||
AppendLog($"测试打印失败:{ex.Message}");
|
||
|
||
if (CurrentState.IsPaired)
|
||
{
|
||
await _apiClient.ReportTestPrintAsync(
|
||
CurrentState.Configuration.ServerUrl,
|
||
CurrentState.Configuration.DeviceToken,
|
||
CurrentState.Configuration.SelectedPrinterName,
|
||
CurrentState.Printers,
|
||
BuildLabelProfile(),
|
||
false,
|
||
ex.Message);
|
||
}
|
||
}
|
||
|
||
EmitState();
|
||
}
|
||
|
||
private async Task ProcessPrintJobAsync(PrintJobDto job)
|
||
{
|
||
var configuration = CurrentState.Configuration.Clone();
|
||
await PrintResultRecovery.ExecuteAsync(job, configuration, _journal,
|
||
(id, token, status, error) => _apiClient.ReportJobStatusAsync(configuration.ServerUrl,
|
||
configuration.DeviceToken, id, status, error, false, token),
|
||
id => _apiClient.GetJobStateAsync(configuration.ServerUrl, configuration.DeviceToken, id),
|
||
async () =>
|
||
{
|
||
var payload = ResolvePayload(job);
|
||
if (payload?.Items.Count is not > 0 || payload.RenderedLabels.Count == 0)
|
||
throw new InvalidOperationException("缺少可用成品标签图,已阻止实际出纸。");
|
||
var count = await _labelPrintService.PrintRenderedLabelImagesAsync(
|
||
configuration.SelectedPrinterName, payload.RenderedLabels);
|
||
CurrentState.LastJobResult = $"任务 {job.Id} 已发送到 {configuration.SelectedPrinterName},共 {count} 张。";
|
||
CurrentState.LastError = string.Empty;
|
||
}, AppendLog);
|
||
EmitState();
|
||
}
|
||
|
||
private AssetLabelPayload? ResolvePayload(PrintJobDto job)
|
||
{
|
||
if (job.Payload?.Items?.Count > 0)
|
||
{
|
||
return job.Payload;
|
||
}
|
||
|
||
if (job.PayloadJson.ValueKind == JsonValueKind.Object)
|
||
{
|
||
return JsonSerializer.Deserialize<AssetLabelPayload>(
|
||
job.PayloadJson.GetRawText(),
|
||
PrintAgentApiClient.SerializerOptions);
|
||
}
|
||
|
||
if (job.PayloadJson.ValueKind == JsonValueKind.String)
|
||
{
|
||
var raw = job.PayloadJson.GetString();
|
||
if (!string.IsNullOrWhiteSpace(raw))
|
||
{
|
||
return JsonSerializer.Deserialize<AssetLabelPayload>(
|
||
raw,
|
||
PrintAgentApiClient.SerializerOptions);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void StartBackgroundLoops()
|
||
{
|
||
_runtimeCancellationTokenSource?.Cancel();
|
||
_runtimeCancellationTokenSource?.Dispose();
|
||
_runtimeCancellationTokenSource = new CancellationTokenSource();
|
||
|
||
var cancellationToken = _runtimeCancellationTokenSource.Token;
|
||
_heartbeatLoopTask = RunPeriodicLoopAsync(
|
||
TimeSpan.FromSeconds(CurrentState.Configuration.HeartbeatIntervalSeconds),
|
||
() => HeartbeatNowAsync(true),
|
||
cancellationToken);
|
||
_pollLoopTask = RunPeriodicLoopAsync(
|
||
TimeSpan.FromSeconds(CurrentState.Configuration.PollIntervalSeconds),
|
||
() => ClaimNowAsync(true),
|
||
cancellationToken);
|
||
}
|
||
|
||
private async Task RunPeriodicLoopAsync(
|
||
TimeSpan interval,
|
||
Func<Task> action,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (interval <= TimeSpan.Zero)
|
||
{
|
||
return;
|
||
}
|
||
|
||
using var timer = new PeriodicTimer(interval);
|
||
try
|
||
{
|
||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||
{
|
||
await action();
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
}
|
||
}
|
||
|
||
private static AgentConfiguration NormalizeConfiguration(AgentConfiguration configuration)
|
||
{
|
||
configuration.ServerUrl = NormalizeServerUrl(configuration.ServerUrl);
|
||
configuration.TerminalName = NormalizeTerminalName(configuration.TerminalName);
|
||
configuration.MachineName = string.IsNullOrWhiteSpace(configuration.MachineName)
|
||
? Environment.MachineName
|
||
: configuration.MachineName.Trim();
|
||
configuration.Platform = string.IsNullOrWhiteSpace(configuration.Platform)
|
||
? "windows"
|
||
: configuration.Platform.Trim().ToLowerInvariant();
|
||
configuration.HeartbeatIntervalSeconds = Math.Max(10, configuration.HeartbeatIntervalSeconds);
|
||
configuration.PollIntervalSeconds = Math.Max(3, configuration.PollIntervalSeconds);
|
||
return configuration;
|
||
}
|
||
|
||
private static string NormalizeServerUrl(string? serverUrl)
|
||
{
|
||
var value = string.IsNullOrWhiteSpace(serverUrl) ? "https://www.jsgdzc.cn" : serverUrl.Trim();
|
||
return value.TrimEnd('/');
|
||
}
|
||
|
||
private static string NormalizeTerminalName(string? terminalName)
|
||
{
|
||
return string.IsNullOrWhiteSpace(terminalName) ? Environment.MachineName : terminalName.Trim();
|
||
}
|
||
|
||
private static PrintLabelProfile BuildLabelProfile()
|
||
{
|
||
return new PrintLabelProfile();
|
||
}
|
||
|
||
private void AppendLog(string message)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(message))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {message}";
|
||
CurrentState.LogLines.Insert(0, line);
|
||
|
||
if (CurrentState.LogLines.Count > 80)
|
||
{
|
||
CurrentState.LogLines.RemoveRange(80, CurrentState.LogLines.Count - 80);
|
||
}
|
||
}
|
||
|
||
private void EmitState()
|
||
{
|
||
StateChanged?.Invoke(this, CurrentState.Clone());
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_disposed = true;
|
||
_runtimeCancellationTokenSource?.Cancel();
|
||
_runtimeCancellationTokenSource?.Dispose();
|
||
_heartbeatSemaphore.Dispose();
|
||
_pollSemaphore.Dispose();
|
||
_apiClient.Dispose();
|
||
}
|
||
}
|
||
|
||
internal sealed class AgentConfigurationStore
|
||
{
|
||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web)
|
||
{
|
||
WriteIndented = true
|
||
};
|
||
|
||
private readonly string _configFilePath;
|
||
|
||
public AgentConfigurationStore(string storageRoot)
|
||
{
|
||
Directory.CreateDirectory(storageRoot);
|
||
_configFilePath = Path.Combine(storageRoot, "config.json");
|
||
}
|
||
|
||
public async Task<AgentConfiguration> LoadAsync()
|
||
{
|
||
if (!File.Exists(_configFilePath))
|
||
{
|
||
return new AgentConfiguration();
|
||
}
|
||
|
||
await using var stream = File.OpenRead(_configFilePath);
|
||
return await JsonSerializer.DeserializeAsync<AgentConfiguration>(stream, _serializerOptions)
|
||
?? new AgentConfiguration();
|
||
}
|
||
|
||
public async Task SaveAsync(AgentConfiguration configuration)
|
||
{
|
||
Directory.CreateDirectory(Path.GetDirectoryName(_configFilePath)!);
|
||
await using var stream = File.Create(_configFilePath);
|
||
await JsonSerializer.SerializeAsync(stream, configuration, _serializerOptions);
|
||
}
|
||
}
|
||
|
||
internal sealed class WindowsStartupManager
|
||
{
|
||
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||
private const string StartupValueName = "JsgdzcPrintAgent";
|
||
private readonly string _executablePath;
|
||
|
||
public WindowsStartupManager(string executablePath)
|
||
{
|
||
_executablePath = executablePath;
|
||
}
|
||
|
||
public Task ApplyAsync(bool enabled)
|
||
{
|
||
using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath);
|
||
if (enabled)
|
||
{
|
||
key?.SetValue(StartupValueName, $"\"{_executablePath}\"");
|
||
}
|
||
else
|
||
{
|
||
key?.DeleteValue(StartupValueName, false);
|
||
}
|
||
|
||
return Task.CompletedTask;
|
||
}
|
||
}
|
||
|
||
internal sealed class PrintAgentApiClient : IDisposable
|
||
{
|
||
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||
internal static JsonSerializerOptions SerializerOptions { get; } = new(JsonSerializerDefaults.Web)
|
||
{
|
||
PropertyNameCaseInsensitive = true
|
||
};
|
||
|
||
public Task<PairResponse> PairAsync(
|
||
string serverUrl,
|
||
string terminalName,
|
||
string machineName,
|
||
string platform,
|
||
string pairCode)
|
||
{
|
||
return SendAsync<PairResponse>(
|
||
serverUrl,
|
||
"/api/print-agent/pair",
|
||
HttpMethod.Post,
|
||
new
|
||
{
|
||
code = pairCode,
|
||
terminal_name = terminalName,
|
||
machine_name = machineName,
|
||
platform
|
||
},
|
||
null,
|
||
false);
|
||
}
|
||
|
||
public Task SendHeartbeatAsync(
|
||
string serverUrl,
|
||
string deviceToken,
|
||
int terminalId,
|
||
string terminalName,
|
||
string machineName,
|
||
string platform,
|
||
string selectedPrinterName,
|
||
List<PrinterInfo> printerSnapshot,
|
||
PrintLabelProfile labelProfile)
|
||
{
|
||
return SendAsync<object>(
|
||
serverUrl,
|
||
"/api/print-agent/heartbeat",
|
||
HttpMethod.Post,
|
||
new
|
||
{
|
||
terminal_id = terminalId,
|
||
terminal_name = terminalName,
|
||
machine_name = machineName,
|
||
platform,
|
||
selected_printer_name = selectedPrinterName,
|
||
printer_snapshot = printerSnapshot,
|
||
label_profile = labelProfile,
|
||
protocol_version = 2
|
||
},
|
||
deviceToken,
|
||
true);
|
||
}
|
||
|
||
public Task<PrintJobClaimData> ClaimNextJobAsync(string serverUrl, string deviceToken, int terminalId)
|
||
{
|
||
return SendAsync<PrintJobClaimData>(
|
||
serverUrl,
|
||
"/api/print-agent/jobs/claim",
|
||
HttpMethod.Post,
|
||
new
|
||
{
|
||
terminal_id = terminalId,
|
||
protocol_version = 2
|
||
},
|
||
deviceToken,
|
||
true);
|
||
}
|
||
|
||
public async Task ReportJobStatusAsync(
|
||
string serverUrl,
|
||
string deviceToken,
|
||
int jobId,
|
||
string status,
|
||
string? errorMessage,
|
||
bool retryable,
|
||
string claimToken)
|
||
{
|
||
var result = await SendAsync<PrintJobState>(
|
||
serverUrl,
|
||
$"/api/print-agent/jobs/{jobId}/status",
|
||
HttpMethod.Post,
|
||
new
|
||
{
|
||
status,
|
||
claim_token = claimToken,
|
||
error_message = errorMessage,
|
||
retryable
|
||
},
|
||
deviceToken,
|
||
true);
|
||
if (!result.HasValidState() || result.Id != jobId || result.ClaimToken != claimToken ||
|
||
!(result.Status == status || (status == "failed" && result.Status == "result_unknown")))
|
||
throw new InvalidOperationException("服务器未返回明确任务结果,保留本地恢复记录。");
|
||
}
|
||
|
||
public Task<PrintJobState> GetJobStateAsync(string serverUrl, string deviceToken, int jobId)
|
||
{
|
||
return SendAsync<PrintJobState>(serverUrl, $"/api/print-agent/jobs/{jobId}/state",
|
||
HttpMethod.Get, null, deviceToken, true);
|
||
}
|
||
|
||
public Task ReportTestPrintAsync(
|
||
string serverUrl,
|
||
string deviceToken,
|
||
string selectedPrinterName,
|
||
List<PrinterInfo> printerSnapshot,
|
||
PrintLabelProfile labelProfile,
|
||
bool success,
|
||
string? errorMessage)
|
||
{
|
||
return SendAsync<object>(
|
||
serverUrl,
|
||
"/api/print-agent/test-print",
|
||
HttpMethod.Post,
|
||
new
|
||
{
|
||
selected_printer_name = selectedPrinterName,
|
||
printer_snapshot = printerSnapshot,
|
||
label_profile = labelProfile,
|
||
success,
|
||
error_message = errorMessage
|
||
},
|
||
deviceToken,
|
||
true);
|
||
}
|
||
|
||
public Task<PrintAgentReleaseCollection> GetReleasesAsync(string serverUrl)
|
||
{
|
||
return SendAsync<PrintAgentReleaseCollection>(
|
||
serverUrl,
|
||
"/downloads/print-agent/releases",
|
||
HttpMethod.Get,
|
||
null,
|
||
null,
|
||
false);
|
||
}
|
||
|
||
public Task<DriverLatestInfo> GetLatestDriverAsync(string serverUrl)
|
||
{
|
||
return SendAsync<DriverLatestInfo>(
|
||
serverUrl,
|
||
"/downloads/printer-driver/xprinter/latest",
|
||
HttpMethod.Get,
|
||
null,
|
||
null,
|
||
false);
|
||
}
|
||
|
||
public Task<List<DriverArchiveRelease>> GetArchivedDriversAsync(string serverUrl)
|
||
{
|
||
return SendAsync<List<DriverArchiveRelease>>(
|
||
serverUrl,
|
||
"/downloads/printer-driver/xprinter/archive",
|
||
HttpMethod.Get,
|
||
null,
|
||
null,
|
||
false);
|
||
}
|
||
|
||
private async Task<T> SendAsync<T>(
|
||
string serverUrl,
|
||
string endpoint,
|
||
HttpMethod method,
|
||
object? body,
|
||
string? deviceToken,
|
||
bool authorized)
|
||
{
|
||
using var request = new HttpRequestMessage(method, BuildUrl(serverUrl, endpoint));
|
||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||
|
||
if (authorized)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(deviceToken))
|
||
{
|
||
throw new InvalidOperationException("打印终端尚未完成认证。");
|
||
}
|
||
|
||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", deviceToken);
|
||
}
|
||
|
||
if (body is not null)
|
||
{
|
||
request.Content = new StringContent(
|
||
JsonSerializer.Serialize(body, SerializerOptions),
|
||
Encoding.UTF8,
|
||
"application/json");
|
||
}
|
||
|
||
using var response = await _httpClient.SendAsync(request);
|
||
var raw = await response.Content.ReadAsStringAsync();
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
throw new PrintAgentRequestException((int)response.StatusCode,
|
||
GetMessage(raw) ?? $"请求失败({(int)response.StatusCode})");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
{
|
||
return Activator.CreateInstance<T>();
|
||
}
|
||
|
||
using var document = JsonDocument.Parse(raw);
|
||
var root = document.RootElement;
|
||
var dataElement = root;
|
||
|
||
if (root.ValueKind == JsonValueKind.Object &&
|
||
root.TryGetProperty("code", out _) &&
|
||
root.TryGetProperty("data", out var wrapped))
|
||
{
|
||
dataElement = wrapped;
|
||
}
|
||
|
||
var result = JsonSerializer.Deserialize<T>(dataElement.GetRawText(), SerializerOptions);
|
||
if (result is null)
|
||
{
|
||
throw new InvalidOperationException("服务器返回了空数据。");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static string BuildUrl(string serverUrl, string endpoint)
|
||
{
|
||
var baseUrl = serverUrl.Trim().TrimEnd('/');
|
||
if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
baseUrl = baseUrl[..^4];
|
||
}
|
||
|
||
return $"{baseUrl}{endpoint}";
|
||
}
|
||
|
||
private static string? GetMessage(string raw)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
try
|
||
{
|
||
using var document = JsonDocument.Parse(raw);
|
||
var root = document.RootElement;
|
||
if (root.ValueKind != JsonValueKind.Object)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (root.TryGetProperty("message", out var messageElement) &&
|
||
messageElement.ValueKind == JsonValueKind.String)
|
||
{
|
||
return messageElement.GetString();
|
||
}
|
||
|
||
if (root.TryGetProperty("data", out var dataElement) &&
|
||
dataElement.ValueKind == JsonValueKind.Object &&
|
||
dataElement.TryGetProperty("message", out var nestedMessage) &&
|
||
nestedMessage.ValueKind == JsonValueKind.String)
|
||
{
|
||
return nestedMessage.GetString();
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_httpClient.Dispose();
|
||
}
|
||
}
|