815 lines
23 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}