From ac05493177b959de412aa5dfc9db07529bcbddf1 Mon Sep 17 00:00:00 2001 From: "GukSang.Jin" Date: Fri, 20 Mar 2026 16:23:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PetWash.Api/Controllers/OrdersController.cs | 79 +++++-- PetWash.Api/Models/Order.cs | 8 +- PetWash.Api/Program.cs | 50 ++++ PetWash.Api/Services/OrderService.cs | 124 +++++++++- PetWashControl/Models/Order.cs | 8 +- PetWashControl/Services/ApiService.cs | 72 ++++-- .../Services/ConfigurationService.cs | 8 + PetWashControl/Services/ModbusService.cs | 38 +++ PetWashControl/ViewModels/MainViewModel.cs | 118 ++++++++-- PetWashControl/Views/MainWindow.xaml | 216 +++++++++++------- PetWashControl/appsettings.json | 2 + 11 files changed, 580 insertions(+), 143 deletions(-) diff --git a/PetWash.Api/Controllers/OrdersController.cs b/PetWash.Api/Controllers/OrdersController.cs index fbb1afc..4572078 100644 --- a/PetWash.Api/Controllers/OrdersController.cs +++ b/PetWash.Api/Controllers/OrdersController.cs @@ -20,18 +20,25 @@ public class OrdersController : ControllerBase [HttpPost] public async Task CreateOrder([FromBody] CreateOrderRequest request) { + Order? order = null; + try { - var order = await _orderService.CreateOrderAsync(request.PackageId); - var payment = await _weChatPayService.CreateNativePayAsync(order, HttpContext.RequestAborted); - - return Ok(new CreateOrderResponse + order = await _orderService.GetLatestRetryableOrderAsync(request.PackageId); + if (order is not null && HasActivePayment(order)) { - Order = order, - CodeUrl = payment.CodeUrl, - OutTradeNo = payment.OutTradeNo, - ExpiresAt = payment.ExpiresAt - }); + return Ok(ToCreateOrderResponse(order)); + } + + order ??= await _orderService.CreateOrderAsync(request.PackageId); + var payment = await _weChatPayService.CreateNativePayAsync(order, HttpContext.RequestAborted); + order = await _orderService.MarkPaymentReadyAsync( + order.Id, + payment.OutTradeNo, + payment.CodeUrl, + payment.ExpiresAt) ?? order; + + return Ok(ToCreateOrderResponse(order)); } catch (ArgumentException ex) { @@ -39,6 +46,11 @@ public class OrdersController : ControllerBase } catch (InvalidOperationException ex) { + if (order != null) + { + await _orderService.MarkPaymentInitializationFailedAsync(order.Id, ex.Message); + } + return StatusCode(StatusCodes.Status502BadGateway, ex.Message); } } @@ -52,20 +64,25 @@ public class OrdersController : ControllerBase return NotFound(); } + if (HasActivePayment(order)) + { + return Ok(ToCreateOrderResponse(order)); + } + try { var payment = await _weChatPayService.CreateNativePayAsync(order, HttpContext.RequestAborted); + order = await _orderService.MarkPaymentReadyAsync( + order.Id, + payment.OutTradeNo, + payment.CodeUrl, + payment.ExpiresAt) ?? order; - return Ok(new CreateOrderResponse - { - Order = order, - CodeUrl = payment.CodeUrl, - OutTradeNo = payment.OutTradeNo, - ExpiresAt = payment.ExpiresAt - }); + return Ok(ToCreateOrderResponse(order)); } catch (InvalidOperationException ex) { + await _orderService.MarkPaymentInitializationFailedAsync(order.Id, ex.Message); return StatusCode(StatusCodes.Status502BadGateway, ex.Message); } } @@ -96,6 +113,12 @@ public class OrdersController : ControllerBase if (order.IsPaid) { + if (order.Status == OrderStatus.Paid) + { + await _orderService.EnsureOpenDoorCommandDispatchedAsync(id); + order = await _orderService.GetOrderAsync(id) ?? order; + } + return Ok(new PaymentStatusResponse { Order = order, @@ -149,6 +172,30 @@ public class OrdersController : ControllerBase return Ok(order); } + + private static bool HasActivePayment(Order? order) + { + return order is + { + IsPaid: false, + Status: OrderStatus.WaitingPayment + } && + !string.IsNullOrWhiteSpace(order.PaymentCodeUrl) && + !string.IsNullOrWhiteSpace(order.OutTradeNo) && + order.PaymentExpiresAt is not null && + order.PaymentExpiresAt > DateTimeOffset.UtcNow; + } + + private static CreateOrderResponse ToCreateOrderResponse(Order order) + { + return new CreateOrderResponse + { + Order = order, + CodeUrl = order.PaymentCodeUrl, + OutTradeNo = order.OutTradeNo, + ExpiresAt = order.PaymentExpiresAt + }; + } } public record CreateOrderRequest(int PackageId); diff --git a/PetWash.Api/Models/Order.cs b/PetWash.Api/Models/Order.cs index 5f35df1..c4ae462 100644 --- a/PetWash.Api/Models/Order.cs +++ b/PetWash.Api/Models/Order.cs @@ -8,6 +8,10 @@ public class Order public DateTime CreatedAt { get; set; } public OrderStatus Status { get; set; } public bool IsPaid { get; set; } + public string OutTradeNo { get; set; } = string.Empty; + public string PaymentCodeUrl { get; set; } = string.Empty; + public DateTimeOffset? PaymentExpiresAt { get; set; } + public string PaymentInitError { get; set; } = string.Empty; public DateTime? PaidAt { get; set; } public DateTime? StartedAt { get; set; } public DateTime? CompletedAt { get; set; } @@ -22,5 +26,7 @@ public enum OrderStatus DoorClosed, Washing, Completed, - Cancelled + Cancelled, + Expired, + PaymentInitFailed } diff --git a/PetWash.Api/Program.cs b/PetWash.Api/Program.cs index d81929d..77ac4ec 100644 --- a/PetWash.Api/Program.cs +++ b/PetWash.Api/Program.cs @@ -71,6 +71,7 @@ using (var scope = app.Services.CreateScope()) } EnsurePackageTimingColumns(db, logger); + EnsureOrderPaymentColumns(db, logger); foreach (var package in PackageCatalog.DefaultPackages) { @@ -186,3 +187,52 @@ static List<(string Name, int DefaultValue)> GetMissingPackageTimingColumns(DbCo .Where(column => !existingColumns.Contains(column.Name)) .ToList(); } + +static void EnsureOrderPaymentColumns(PetWashDbContext db, ILogger logger) +{ + var providerName = db.Database.ProviderName ?? string.Empty; + var isSqlite = providerName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase); + var missingColumns = GetMissingOrderPaymentColumns(db.Database.GetDbConnection(), isSqlite); + + foreach (var column in missingColumns) + { + var sql = isSqlite + ? $"ALTER TABLE Orders ADD COLUMN {column.Name} {column.SqliteTypeClause};" + : $"ALTER TABLE Orders ADD COLUMN {column.Name} {column.MySqlTypeClause};"; + + db.Database.ExecuteSqlRaw(sql); + logger.LogInformation("Added missing order payment column {ColumnName}", column.Name); + } +} + +static List<(string Name, string SqliteTypeClause, string MySqlTypeClause)> GetMissingOrderPaymentColumns( + DbConnection connection, + bool isSqlite) +{ + var expectedColumns = new List<(string Name, string SqliteTypeClause, string MySqlTypeClause)> + { + ("OutTradeNo", "TEXT NOT NULL DEFAULT ''", "VARCHAR(128) NOT NULL DEFAULT ''"), + ("PaymentCodeUrl", "TEXT NOT NULL DEFAULT ''", "VARCHAR(2048) NOT NULL DEFAULT ''"), + ("PaymentExpiresAt", "TEXT NULL", "DATETIME NULL"), + ("PaymentInitError", "TEXT NOT NULL DEFAULT ''", "VARCHAR(2048) NOT NULL DEFAULT ''") + }; + + if (connection.State != ConnectionState.Open) + { + connection.Open(); + } + + using var command = connection.CreateCommand(); + command.CommandText = isSqlite ? "PRAGMA table_info(Orders);" : "SHOW COLUMNS FROM Orders;"; + + using var reader = command.ExecuteReader(); + var existingColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (reader.Read()) + { + existingColumns.Add(reader.GetString(isSqlite ? 1 : 0)); + } + + return expectedColumns + .Where(column => !existingColumns.Contains(column.Name)) + .ToList(); +} diff --git a/PetWash.Api/Services/OrderService.cs b/PetWash.Api/Services/OrderService.cs index 45377f4..710d022 100644 --- a/PetWash.Api/Services/OrderService.cs +++ b/PetWash.Api/Services/OrderService.cs @@ -6,6 +6,14 @@ namespace PetWash.Api.Services; public class OrderService { + private static readonly OrderStatus[] RetryablePaymentStatuses = + [ + OrderStatus.Created, + OrderStatus.WaitingPayment, + OrderStatus.Expired, + OrderStatus.PaymentInitFailed + ]; + private readonly PetWashDbContext _context; private readonly MqttService _mqttService; private readonly ILogger _logger; @@ -28,8 +36,12 @@ public class OrderService PackageId = packageId, Package = package, CreatedAt = DateTime.Now, - Status = OrderStatus.WaitingPayment, - IsPaid = false + Status = OrderStatus.Created, + IsPaid = false, + OutTradeNo = string.Empty, + PaymentCodeUrl = string.Empty, + PaymentExpiresAt = null, + PaymentInitError = string.Empty }; _context.Orders.Add(order); @@ -39,6 +51,71 @@ public class OrderService return order; } + public async Task GetLatestRetryableOrderAsync(int packageId) + { + var retryCutoff = DateTime.Now.AddMinutes(-30); + + return await _context.Orders + .Include(o => o.Package) + .Where(o => + o.PackageId == packageId && + !o.IsPaid && + o.CreatedAt >= retryCutoff && + RetryablePaymentStatuses.Contains(o.Status)) + .OrderByDescending(o => o.Id) + .FirstOrDefaultAsync(); + } + + public async Task MarkPaymentReadyAsync( + int orderId, + string outTradeNo, + string codeUrl, + DateTimeOffset? expiresAt) + { + var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId); + if (order == null) + { + return null; + } + + order.Status = OrderStatus.WaitingPayment; + order.OutTradeNo = outTradeNo; + order.PaymentCodeUrl = codeUrl; + order.PaymentExpiresAt = expiresAt; + order.PaymentInitError = string.Empty; + await _context.SaveChangesAsync(); + + _logger.LogInformation( + "订单支付初始化成功: OrderId={OrderId}, OutTradeNo={OutTradeNo}, ExpiresAt={ExpiresAt}", + order.Id, + order.OutTradeNo, + order.PaymentExpiresAt); + + return order; + } + + public async Task MarkPaymentInitializationFailedAsync(int orderId, string errorMessage) + { + var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId); + if (order == null) + { + return null; + } + + order.Status = OrderStatus.PaymentInitFailed; + order.PaymentCodeUrl = string.Empty; + order.PaymentExpiresAt = null; + order.PaymentInitError = errorMessage; + await _context.SaveChangesAsync(); + + _logger.LogWarning( + "订单支付初始化失败: OrderId={OrderId}, Error={Error}", + order.Id, + errorMessage); + + return order; + } + public async Task ConfirmPaymentAsync(int orderId) { var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId); @@ -46,6 +123,12 @@ public class OrderService if (order.IsPaid) { + if (order.Status == OrderStatus.Paid) + { + await PublishOpenDoorCommandAsync(order); + _logger.LogInformation("订单已支付但尚未开门,补发开门指令: OrderId={OrderId}", orderId); + } + _logger.LogInformation("Order payment already confirmed: OrderId={OrderId}", orderId); return order; } @@ -53,16 +136,10 @@ public class OrderService order.IsPaid = true; order.PaidAt = DateTime.Now; order.Status = OrderStatus.Paid; + order.PaymentInitError = string.Empty; await _context.SaveChangesAsync(); - // 支付成功后,通过MQTT发送开门指令 - await _mqttService.PublishAsync("device/command", new - { - command = "open_door", - orderId = order.Id, - timestamp = DateTime.Now - }); - + await PublishOpenDoorCommandAsync(order); _logger.LogInformation($"订单支付成功,已发送开门指令: OrderId={orderId}"); return order; } @@ -90,6 +167,11 @@ public class OrderService { order.CompletedAt = DateTime.Now; } + else if (status == OrderStatus.Cancelled || status == OrderStatus.Expired) + { + order.PaymentCodeUrl = string.Empty; + order.PaymentExpiresAt = null; + } await _context.SaveChangesAsync(); _logger.LogInformation($"订单状态更新: OrderId={orderId}, Status={status}"); @@ -100,4 +182,26 @@ public class OrderService { return await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId); } + + public async Task EnsureOpenDoorCommandDispatchedAsync(int orderId) + { + var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId); + if (order == null || !order.IsPaid || order.Status != OrderStatus.Paid) + { + return; + } + + await PublishOpenDoorCommandAsync(order); + _logger.LogInformation("轮询支付状态时补发开门指令: OrderId={OrderId}", orderId); + } + + private async Task PublishOpenDoorCommandAsync(Order order) + { + await _mqttService.PublishAsync("device/command", new + { + command = "open_door", + orderId = order.Id, + timestamp = DateTime.Now + }); + } } diff --git a/PetWashControl/Models/Order.cs b/PetWashControl/Models/Order.cs index 827ad82..dc4379b 100644 --- a/PetWashControl/Models/Order.cs +++ b/PetWashControl/Models/Order.cs @@ -8,6 +8,10 @@ public class Order public DateTime CreatedAt { get; set; } public OrderStatus Status { get; set; } public bool IsPaid { get; set; } + public string OutTradeNo { get; set; } = string.Empty; + public string PaymentCodeUrl { get; set; } = string.Empty; + public DateTimeOffset? PaymentExpiresAt { get; set; } + public string PaymentInitError { get; set; } = string.Empty; } public enum OrderStatus @@ -19,5 +23,7 @@ public enum OrderStatus DoorClosed, Washing, Completed, - Cancelled + Cancelled, + Expired, + PaymentInitFailed } diff --git a/PetWashControl/Services/ApiService.cs b/PetWashControl/Services/ApiService.cs index ca9bc94..7c17881 100644 --- a/PetWashControl/Services/ApiService.cs +++ b/PetWashControl/Services/ApiService.cs @@ -39,24 +39,10 @@ public class ApiService try { var response = await _httpClient.PostAsJsonAsync("api/orders", new { packageId }); - response.EnsureSuccessStatusCode(); + await EnsureSuccessStatusCodeAsync(response); var responseBody = await response.Content.ReadAsStringAsync(); - var parsed = ParseCreateOrderResponse(responseBody); - - if (parsed?.Order?.Id > 0 && - (string.IsNullOrWhiteSpace(parsed.CodeUrl) || string.IsNullOrWhiteSpace(parsed.OutTradeNo))) - { - var fallback = await TryCreatePaymentQrAsync(parsed.Order.Id); - if (fallback?.Order != null && - !string.IsNullOrWhiteSpace(fallback.CodeUrl) && - !string.IsNullOrWhiteSpace(fallback.OutTradeNo)) - { - return fallback; - } - } - - return parsed; + return ParseCreateOrderResponse(responseBody); } catch (HttpRequestException ex) { @@ -64,6 +50,22 @@ public class ApiService } } + public async Task CreatePaymentQrAsync(int orderId) + { + try + { + var response = await _httpClient.PostAsync($"api/orders/{orderId}/payment-qrcode", null); + await EnsureSuccessStatusCodeAsync(response); + + var responseBody = await response.Content.ReadAsStringAsync(); + return ParseCreateOrderResponse(responseBody); + } + catch (HttpRequestException ex) + { + throw new Exception($"刷新支付二维码失败: {ex.Message}", ex); + } + } + public async Task ConfirmPaymentAsync(int orderId) { try @@ -108,7 +110,7 @@ public class ApiService try { var response = await _httpClient.PutAsJsonAsync($"api/orders/{orderId}/status", new { status }); - response.EnsureSuccessStatusCode(); + await EnsureSuccessStatusCodeAsync(response); return await response.Content.ReadFromJsonAsync(); } catch (HttpRequestException ex) @@ -135,7 +137,7 @@ public class ApiService package.ColdAirTime, package.UvSterilizationTime }); - response.EnsureSuccessStatusCode(); + await EnsureSuccessStatusCodeAsync(response); return await response.Content.ReadFromJsonAsync(); } catch (HttpRequestException ex) @@ -163,6 +165,40 @@ public class ApiService } } + private static async Task EnsureSuccessStatusCodeAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var responseBody = await response.Content.ReadAsStringAsync(); + var message = string.IsNullOrWhiteSpace(responseBody) + ? $"{(int)response.StatusCode} ({response.ReasonPhrase})" + : UnwrapErrorMessage(responseBody); + + throw new HttpRequestException(message, null, response.StatusCode); + } + + private static string UnwrapErrorMessage(string responseBody) + { + var trimmed = responseBody.Trim(); + + if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') + { + try + { + return JsonSerializer.Deserialize(trimmed) ?? trimmed; + } + catch (JsonException) + { + return trimmed; + } + } + + return trimmed; + } + private static CreateOrderResponse? ParseCreateOrderResponse(string responseBody) { if (string.IsNullOrWhiteSpace(responseBody)) diff --git a/PetWashControl/Services/ConfigurationService.cs b/PetWashControl/Services/ConfigurationService.cs index ef60332..2bbab17 100644 --- a/PetWashControl/Services/ConfigurationService.cs +++ b/PetWashControl/Services/ConfigurationService.cs @@ -24,6 +24,8 @@ public class ConfigurationService public byte ModbusSlaveId { get; set; } = 1; public int ModbusConnectTimeoutMs { get; set; } = 5000; public int ModbusReadTimeoutMs { get; set; } = 3000; + public ushort ModbusOpenDoorCoilAddress { get; set; } = 81; + public int ModbusPulseDurationMs { get; set; } = 100; public int PaymentCheckIntervalSeconds { get; set; } = 2; public int WashSimulationSeconds { get; set; } = 10; public int FirstSprayWaterTime { get; set; } = 2; @@ -91,6 +93,8 @@ public class ConfigurationService ModbusSlaveId = model.ModbusSlaveId; ModbusConnectTimeoutMs = model.ModbusConnectTimeoutMs; ModbusReadTimeoutMs = model.ModbusReadTimeoutMs; + ModbusOpenDoorCoilAddress = model.ModbusOpenDoorCoilAddress; + ModbusPulseDurationMs = Math.Max(50, model.ModbusPulseDurationMs); PaymentCheckIntervalSeconds = model.PaymentCheckIntervalSeconds; WashSimulationSeconds = model.WashSimulationSeconds; FirstSprayWaterTime = model.FirstSprayWaterTime; @@ -124,6 +128,8 @@ public class ConfigurationService ModbusSlaveId = ModbusSlaveId, ModbusConnectTimeoutMs = ModbusConnectTimeoutMs, ModbusReadTimeoutMs = ModbusReadTimeoutMs, + ModbusOpenDoorCoilAddress = ModbusOpenDoorCoilAddress, + ModbusPulseDurationMs = Math.Max(50, ModbusPulseDurationMs), PaymentCheckIntervalSeconds = PaymentCheckIntervalSeconds, WashSimulationSeconds = WashSimulationSeconds, FirstSprayWaterTime = FirstSprayWaterTime, @@ -156,6 +162,8 @@ public class ConfigurationService public byte ModbusSlaveId { get; set; } = 1; public int ModbusConnectTimeoutMs { get; set; } = 5000; public int ModbusReadTimeoutMs { get; set; } = 3000; + public ushort ModbusOpenDoorCoilAddress { get; set; } = 81; + public int ModbusPulseDurationMs { get; set; } = 100; public int PaymentCheckIntervalSeconds { get; set; } = 2; public int WashSimulationSeconds { get; set; } = 10; public int FirstSprayWaterTime { get; set; } = 2; diff --git a/PetWashControl/Services/ModbusService.cs b/PetWashControl/Services/ModbusService.cs index ab32329..2f696ff 100644 --- a/PetWashControl/Services/ModbusService.cs +++ b/PetWashControl/Services/ModbusService.cs @@ -595,6 +595,44 @@ public class ModbusService : IDisposable } } + /// + /// 触发开门按钮(复归型) + /// 默认使用配置中的 ModbusOpenDoorCoilAddress。 + /// + public async Task TriggerOpenDoorAsync() + { + try + { + if (_modbusMaster != null && _isConnected) + { + var openDoorAddress = _config.ModbusOpenDoorCoilAddress; + var pulseDurationMs = Math.Max(50, _config.ModbusPulseDurationMs); + _logger.LogInfo($"[Modbus] 触发开门按钮 M{openDoorAddress}"); + + await WriteSingleCoilAsync(openDoorAddress, true); + _logger.LogInfo($"[Modbus] M{openDoorAddress} = true"); + + await Task.Delay(pulseDurationMs); + + await WriteSingleCoilAsync(openDoorAddress, false); + _logger.LogInfo($"[Modbus] M{openDoorAddress} = false"); + + await Task.Delay(100); + + _logger.LogInfo($"[Modbus] M{openDoorAddress} 脉冲信号发送完成"); + } + else + { + throw new InvalidOperationException("Modbus 未连接"); + } + } + catch (Exception ex) + { + _logger.LogError($"触发开门失败: {ex.Message}", ex); + throw; + } + } + /// /// 触发紧急停止按钮 M83(复归型) /// M83 是复归型按钮,需要写入脉冲信号:true → 延迟 → false diff --git a/PetWashControl/ViewModels/MainViewModel.cs b/PetWashControl/ViewModels/MainViewModel.cs index eb1a930..0992897 100644 --- a/PetWashControl/ViewModels/MainViewModel.cs +++ b/PetWashControl/ViewModels/MainViewModel.cs @@ -227,6 +227,7 @@ public partial class MainViewModel : ObservableObject private int _currentImageIndex = 0; private bool _isPaymentPolling; private bool _isCheckingPaymentStatus; + private bool _isDoorOpeningInProgress; public int EditingPackageTotalDuration => EditingPackageFirstSprayWaterTime + @@ -960,10 +961,15 @@ public partial class MainViewModel : ObservableObject if (CurrentOrder != null && !CurrentOrder.IsPaid) { - await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Cancelled); + await LoadPaymentSessionAsync( + SelectedPackage, + await _apiService.CreatePaymentQrAsync(CurrentOrder.Id), + true); + } + else + { + await CreatePaymentOrderAsync(SelectedPackage, true); } - - await CreatePaymentOrderAsync(SelectedPackage, true); } catch (Exception ex) { @@ -980,7 +986,17 @@ public partial class MainViewModel : ObservableObject _logger.LogInfo($"创建订单,套餐ID: {package.Id}"); await ApplyPackageTimingProfileAsync(package); - var createOrderResponse = await _apiService.CreateOrderAsync(package.Id); + await LoadPaymentSessionAsync( + package, + await _apiService.CreateOrderAsync(package.Id), + navigateToQrCode); + } + + private Task LoadPaymentSessionAsync( + Package package, + CreateOrderResponse? createOrderResponse, + bool navigateToQrCode) + { CurrentOrder = createOrderResponse?.Order; PaymentCodeUrl = createOrderResponse?.CodeUrl ?? ""; PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? ""; @@ -1009,6 +1025,7 @@ public partial class MainViewModel : ObservableObject } StartPaymentStatusPolling(); + return Task.CompletedTask; } private static BitmapImage BuildPaymentQrCodeImage(string codeUrl) @@ -1140,15 +1157,13 @@ public partial class MainViewModel : ObservableObject } IsPaymentExpired = false; - PaymentStatusText = "支付成功"; + PaymentStatusText = IsDoorOpen ? "支付成功,设备门已打开" : "支付成功,正在打开设备门..."; PaymentCountdownText = ""; PaymentExpiresAt = null; - StatusMessage = "支付成功!设备门已打开,请将宠物放入"; - IsDoorOpen = true; - _logger.LogInfo("支付成功,门已打开"); - - MessageBox.Show("支付成功!\n\n设备门已自动打开\n请将宠物放入设备后关闭门开始洗护", - "支付成功", MessageBoxButton.OK, MessageBoxImage.Information); + StatusMessage = IsDoorOpen + ? "支付成功!设备门已打开,请将宠物放入" + : "支付成功,等待设备自动开门..."; + _logger.LogInfo(IsDoorOpen ? "支付成功,门已打开" : "支付成功,等待开门指令"); CurrentView = "Idle"; ViewChanged?.Invoke("Idle"); @@ -1173,7 +1188,7 @@ public partial class MainViewModel : ObservableObject { try { - CurrentOrder = await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Cancelled); + CurrentOrder = await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Expired); } catch (Exception ex) { @@ -1530,9 +1545,7 @@ public partial class MainViewModel : ObservableObject if (command == "open_door") { - IsDoorOpen = true; - StatusMessage = "设备门已打开,请将宠物放入后点击关门"; - _logger.LogInfo("收到开门指令"); + _ = HandleOpenDoorCommandAsync(message); } else if (command == "start_wash") { @@ -1545,7 +1558,17 @@ public partial class MainViewModel : ObservableObject else if (topic == "device/status") { var status = message.GetProperty("status").GetString(); - StatusMessage = $"设备状态: {status}"; + if (status == "door_opened") + { + IsDoorOpen = true; + PaymentStatusText = "支付成功,设备门已打开"; + StatusMessage = "设备门已打开,请将宠物放入后点击关门"; + } + else + { + StatusMessage = $"设备状态: {status}"; + } + _logger.LogInfo($"设备状态更新: {status}"); } } @@ -1557,6 +1580,69 @@ public partial class MainViewModel : ObservableObject }); } + private async Task HandleOpenDoorCommandAsync(JsonElement message) + { + if (_isDoorOpeningInProgress) + { + return; + } + + try + { + var orderId = message.TryGetProperty("orderId", out var orderIdElement) + ? orderIdElement.GetInt32() + : 0; + + if (CurrentOrder != null && orderId > 0 && CurrentOrder.Id != orderId) + { + _logger.LogWarning($"忽略非当前订单的开门指令: CurrentOrderId={CurrentOrder.Id}, MessageOrderId={orderId}"); + return; + } + + _isDoorOpeningInProgress = true; + PaymentStatusText = "正在打开设备门..."; + StatusMessage = "已收到开门指令,正在打开设备门..."; + _logger.LogInfo($"收到开门指令,开始执行开门: OrderId={orderId}"); + + await _modbusService.TriggerOpenDoorAsync(); + + if (CurrentOrder != null) + { + CurrentOrder.IsPaid = true; + CurrentOrder.Status = OrderStatus.DoorOpened; + CurrentOrder = await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.DoorOpened) ?? CurrentOrder; + } + + await TryPublishDeviceStatusAsync(new + { + status = "door_opened", + orderId, + timestamp = DateTime.Now + }, "开门"); + + IsDoorOpen = true; + PaymentStatusText = "支付成功,设备门已打开"; + StatusMessage = "设备门已打开,请将宠物放入后点击关门"; + _logger.LogInfo($"开门完成: OrderId={orderId}"); + + MessageBox.Show("支付成功!\n\n设备门已自动打开\n请将宠物放入设备后关闭门开始洗护", + "支付成功", MessageBoxButton.OK, MessageBoxImage.Information); + + CurrentView = "Idle"; + ViewChanged?.Invoke("Idle"); + } + catch (Exception ex) + { + _logger.LogError("处理开门指令失败", ex); + PaymentStatusText = "设备开门失败"; + StatusMessage = $"支付成功,但设备开门失败: {ex.Message}"; + } + finally + { + _isDoorOpeningInProgress = false; + } + } + private void OnModbusConnectionStatusChanged(bool isConnected) { Application.Current.Dispatcher.Invoke(() => diff --git a/PetWashControl/Views/MainWindow.xaml b/PetWashControl/Views/MainWindow.xaml index 0797aff..115679c 100644 --- a/PetWashControl/Views/MainWindow.xaml +++ b/PetWashControl/Views/MainWindow.xaml @@ -7,7 +7,10 @@ xmlns:converters="clr-namespace:PetWashControl.Converters" mc:Ignorable="d" Title="全自动洗宠机" - Height="800" Width="1280" + Height="1024" Width="1028" + FontFamily="Microsoft YaHei UI" + UseLayoutRounding="True" + SnapsToDevicePixels="True" WindowStyle="None" ResizeMode="NoResize" WindowStartupLocation="CenterScreen"> @@ -20,16 +23,20 @@ - - - + + + + + + + @@ -79,21 +90,38 @@ - - + + - + - + - + + + + + + - - + + + + + + + + @@ -172,7 +200,7 @@ - + @@ -316,7 +344,7 @@ TextAlignment="Center"/> -