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? 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( job.PayloadJson.GetRawText(), PrintAgentApiClient.SerializerOptions); } if (job.PayloadJson.ValueKind == JsonValueKind.String) { var raw = job.PayloadJson.GetString(); if (!string.IsNullOrWhiteSpace(raw)) { return JsonSerializer.Deserialize( 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 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 LoadAsync() { if (!File.Exists(_configFilePath)) { return new AgentConfiguration(); } await using var stream = File.OpenRead(_configFilePath); return await JsonSerializer.DeserializeAsync(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 PairAsync( string serverUrl, string terminalName, string machineName, string platform, string pairCode) { return SendAsync( 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 printerSnapshot, PrintLabelProfile labelProfile) { return SendAsync( 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 ClaimNextJobAsync(string serverUrl, string deviceToken, int terminalId) { return SendAsync( 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( 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 GetJobStateAsync(string serverUrl, string deviceToken, int jobId) { return SendAsync(serverUrl, $"/api/print-agent/jobs/{jobId}/state", HttpMethod.Get, null, deviceToken, true); } public Task ReportTestPrintAsync( string serverUrl, string deviceToken, string selectedPrinterName, List printerSnapshot, PrintLabelProfile labelProfile, bool success, string? errorMessage) { return SendAsync( 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 GetReleasesAsync(string serverUrl) { return SendAsync( serverUrl, "/downloads/print-agent/releases", HttpMethod.Get, null, null, false); } public Task GetLatestDriverAsync(string serverUrl) { return SendAsync( serverUrl, "/downloads/printer-driver/xprinter/latest", HttpMethod.Get, null, null, false); } public Task> GetArchivedDriversAsync(string serverUrl) { return SendAsync>( serverUrl, "/downloads/printer-driver/xprinter/archive", HttpMethod.Get, null, null, false); } private async Task SendAsync( 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(); } 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(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(); } }