更新20260316
This commit is contained in:
12
.env.example
12
.env.example
@@ -29,3 +29,15 @@ LOG_LEVEL=Information
|
||||
# MQTT_PORT=1883
|
||||
# MQTT_USERNAME=
|
||||
# MQTT_PASSWORD=
|
||||
|
||||
# WeChat Pay Native
|
||||
# WECHATPAY__APPID=wx1234567890abcdef
|
||||
# WECHATPAY__MERCHANTID=1900000001
|
||||
# WECHATPAY__CERTIFICATESERIALNUMBER=7777777777777777777777777777777777777777
|
||||
# WECHATPAY__PRIVATEKEYPATH=certs/apiclient_key.pem
|
||||
# WECHATPAY__NOTIFYURL=https://your-domain.example.com/api/payments/wechat/notify
|
||||
# WECHATPAY__APIV3KEY=32bytesapiv3keyxxxxxxxxxxxxxxxx
|
||||
# WECHATPAY__PLATFORMPUBLICKEYPATH=certs/wechatpay_public_key.pem
|
||||
# WECHATPAY__PLATFORMPUBLICKEYSERIAL=PUB_KEY_ID_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# WECHATPAY__CURRENCY=CNY
|
||||
# WECHATPAY__ORDEREXPIREMINUTES=5
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ logs/
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
.dotnet/
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace PetWash.Api.Controllers;
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private readonly OrderService _orderService;
|
||||
private readonly WeChatPayService _weChatPayService;
|
||||
|
||||
public OrdersController(OrderService orderService)
|
||||
public OrdersController(OrderService orderService, WeChatPayService weChatPayService)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_weChatPayService = weChatPayService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -21,12 +23,24 @@ public class OrdersController : ControllerBase
|
||||
try
|
||||
{
|
||||
var order = await _orderService.CreateOrderAsync(request.PackageId);
|
||||
return Ok(order);
|
||||
var payment = await _weChatPayService.CreateNativePayAsync(order, HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new CreateOrderResponse
|
||||
{
|
||||
Order = order,
|
||||
CodeUrl = payment.CodeUrl,
|
||||
OutTradeNo = payment.OutTradeNo,
|
||||
ExpiresAt = payment.ExpiresAt
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status502BadGateway, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/payment")]
|
||||
@@ -39,6 +53,56 @@ public class OrdersController : ControllerBase
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/payment-status")]
|
||||
public async Task<IActionResult> GetPaymentStatus(int id, [FromQuery] string outTradeNo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outTradeNo))
|
||||
{
|
||||
return BadRequest("outTradeNo is required.");
|
||||
}
|
||||
|
||||
var order = await _orderService.GetOrderAsync(id);
|
||||
if (order == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (order.IsPaid)
|
||||
{
|
||||
return Ok(new PaymentStatusResponse
|
||||
{
|
||||
Order = order,
|
||||
IsPaid = true,
|
||||
TradeState = "SUCCESS",
|
||||
OutTradeNo = outTradeNo,
|
||||
Message = "Order already confirmed."
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payment = await _weChatPayService.QueryOrderByOutTradeNoAsync(outTradeNo, HttpContext.RequestAborted);
|
||||
if (string.Equals(payment.TradeState, "SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
order = await _orderService.ConfirmPaymentAsync(id) ?? order;
|
||||
}
|
||||
|
||||
return Ok(new PaymentStatusResponse
|
||||
{
|
||||
Order = order,
|
||||
IsPaid = order.IsPaid,
|
||||
TradeState = payment.TradeState,
|
||||
OutTradeNo = payment.OutTradeNo,
|
||||
TransactionId = payment.TransactionId,
|
||||
Message = order.IsPaid ? "Payment confirmed." : "Waiting for payment."
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status502BadGateway, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetOrder(int id)
|
||||
{
|
||||
|
||||
57
PetWash.Api/Controllers/PaymentsController.cs
Normal file
57
PetWash.Api/Controllers/PaymentsController.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PetWash.Api.Services;
|
||||
|
||||
namespace PetWash.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/payments/wechat")]
|
||||
public class PaymentsController : ControllerBase
|
||||
{
|
||||
private readonly OrderService _orderService;
|
||||
private readonly WeChatPayService _weChatPayService;
|
||||
private readonly ILogger<PaymentsController> _logger;
|
||||
|
||||
public PaymentsController(
|
||||
OrderService orderService,
|
||||
WeChatPayService weChatPayService,
|
||||
ILogger<PaymentsController> logger)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_weChatPayService = weChatPayService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("notify")]
|
||||
public async Task<IActionResult> Notify(CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var body = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var notification = await _weChatPayService.ParsePaymentNotificationAsync(
|
||||
body,
|
||||
Request.Headers,
|
||||
cancellationToken);
|
||||
|
||||
if (string.Equals(notification.TradeState, "SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var order = await _orderService.ConfirmPaymentAsync(notification.OrderId);
|
||||
if (order == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"WeChat payment notification references a missing order. OrderId={OrderId}, OutTradeNo={OutTradeNo}",
|
||||
notification.OrderId,
|
||||
notification.OutTradeNo);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { code = "SUCCESS", message = "成功" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process WeChat payment notification.");
|
||||
return BadRequest(new { code = "FAIL", message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
9
PetWash.Api/Models/CreateOrderResponse.cs
Normal file
9
PetWash.Api/Models/CreateOrderResponse.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PetWash.Api.Models;
|
||||
|
||||
public sealed class CreateOrderResponse
|
||||
{
|
||||
public required Order Order { get; init; }
|
||||
public string CodeUrl { get; init; } = string.Empty;
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
11
PetWash.Api/Models/PaymentStatusResponse.cs
Normal file
11
PetWash.Api/Models/PaymentStatusResponse.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace PetWash.Api.Models;
|
||||
|
||||
public sealed class PaymentStatusResponse
|
||||
{
|
||||
public required Order Order { get; init; }
|
||||
public bool IsPaid { get; init; }
|
||||
public string TradeState { get; init; } = string.Empty;
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
public string TransactionId { get; init; } = string.Empty;
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -27,6 +27,12 @@ builder.Services.AddDbContext<PetWashDbContext>(options =>
|
||||
builder.Services.AddSingleton<MqttService>();
|
||||
builder.Services.AddHostedService(provider => provider.GetRequiredService<MqttService>());
|
||||
builder.Services.AddScoped<OrderService>();
|
||||
builder.Services.Configure<WeChatPayOptions>(builder.Configuration.GetSection("WeChatPay"));
|
||||
builder.Services.AddHttpClient<WeChatPayService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.mch.weixin.qq.com");
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
// 添加CORS
|
||||
builder.Services.AddCors(options =>
|
||||
|
||||
@@ -26,6 +26,7 @@ public class OrderService
|
||||
var order = new Order
|
||||
{
|
||||
PackageId = packageId,
|
||||
Package = package,
|
||||
CreatedAt = DateTime.Now,
|
||||
Status = OrderStatus.WaitingPayment,
|
||||
IsPaid = false
|
||||
@@ -43,6 +44,12 @@ public class OrderService
|
||||
var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId);
|
||||
if (order == null) return null;
|
||||
|
||||
if (order.IsPaid)
|
||||
{
|
||||
_logger.LogInformation("Order payment already confirmed: OrderId={OrderId}", orderId);
|
||||
return order;
|
||||
}
|
||||
|
||||
order.IsPaid = true;
|
||||
order.PaidAt = DateTime.Now;
|
||||
order.Status = OrderStatus.Paid;
|
||||
@@ -62,7 +69,7 @@ public class OrderService
|
||||
|
||||
public async Task<Order?> UpdateOrderStatusAsync(int orderId, OrderStatus status)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(orderId);
|
||||
var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId);
|
||||
if (order == null) return null;
|
||||
|
||||
order.Status = status;
|
||||
|
||||
602
PetWash.Api/Services/WeChatPayService.cs
Normal file
602
PetWash.Api/Services/WeChatPayService.cs
Normal file
@@ -0,0 +1,602 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PetWash.Api.Models;
|
||||
|
||||
namespace PetWash.Api.Services;
|
||||
|
||||
public sealed class WeChatPayOptions
|
||||
{
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
public string MerchantId { get; set; } = string.Empty;
|
||||
public string CertificateSerialNumber { get; set; } = string.Empty;
|
||||
public string PrivateKeyPath { get; set; } = string.Empty;
|
||||
public string PrivateKeyPem { get; set; } = string.Empty;
|
||||
public string NotifyUrl { get; set; } = string.Empty;
|
||||
public string ApiV3Key { get; set; } = string.Empty;
|
||||
public string PlatformPublicKeyPath { get; set; } = string.Empty;
|
||||
public string PlatformPublicKeyPem { get; set; } = string.Empty;
|
||||
public string PlatformPublicKeySerial { get; set; } = string.Empty;
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public int OrderExpireMinutes { get; set; } = 5;
|
||||
}
|
||||
|
||||
public sealed class WeChatNativePayResult
|
||||
{
|
||||
public string CodeUrl { get; init; } = string.Empty;
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WeChatTransactionStatus
|
||||
{
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
public string TradeState { get; init; } = string.Empty;
|
||||
public string TransactionId { get; init; } = string.Empty;
|
||||
public string Attach { get; init; } = string.Empty;
|
||||
public string SuccessTime { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class WeChatPaymentNotification
|
||||
{
|
||||
public required int OrderId { get; init; }
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
public string TradeState { get; init; } = string.Empty;
|
||||
public string TransactionId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class WeChatPayService
|
||||
{
|
||||
private const string NativePayPath = "/v3/pay/transactions/native";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly WeChatPayOptions _options;
|
||||
private readonly ILogger<WeChatPayService> _logger;
|
||||
|
||||
public WeChatPayService(
|
||||
HttpClient httpClient,
|
||||
IOptions<WeChatPayOptions> options,
|
||||
ILogger<WeChatPayService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WeChatNativePayResult> CreateNativePayAsync(Order order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateOptions();
|
||||
|
||||
if (order.Package is null)
|
||||
{
|
||||
throw new InvalidOperationException("Package data is required to create a WeChat payment.");
|
||||
}
|
||||
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.OrderExpireMinutes);
|
||||
var outTradeNo = BuildOutTradeNo(order.Id);
|
||||
var payload = new NativePayRequest
|
||||
{
|
||||
AppId = _options.AppId,
|
||||
MerchantId = _options.MerchantId,
|
||||
Description = $"{order.Package.Name} payment",
|
||||
OutTradeNo = outTradeNo,
|
||||
NotifyUrl = _options.NotifyUrl,
|
||||
TimeExpire = expiresAt.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture),
|
||||
Attach = $"orderId={order.Id}",
|
||||
Amount = new NativePayAmount
|
||||
{
|
||||
Total = ConvertToFen(order.Package.Price),
|
||||
Currency = string.IsNullOrWhiteSpace(_options.Currency) ? "CNY" : _options.Currency
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, NativePayPath)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var nonce = Guid.NewGuid().ToString("N");
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"WECHATPAY2-SHA256-RSA2048",
|
||||
BuildAuthorizationParameter(NativePayPath, HttpMethod.Post.Method, body, nonce, timestamp));
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = TryDeserialize<WeChatErrorResponse>(responseBody);
|
||||
var message = error is null
|
||||
? responseBody
|
||||
: $"{error.Code}: {error.Message}";
|
||||
|
||||
_logger.LogError(
|
||||
"WeChat Native pay request failed. StatusCode={StatusCode}, Message={Message}",
|
||||
(int)response.StatusCode,
|
||||
message);
|
||||
|
||||
throw new InvalidOperationException($"WeChat Native pay request failed: {message}");
|
||||
}
|
||||
|
||||
var result = TryDeserialize<NativePayResponse>(responseBody);
|
||||
if (string.IsNullOrWhiteSpace(result?.CodeUrl))
|
||||
{
|
||||
throw new InvalidOperationException("WeChat returned an empty code_url.");
|
||||
}
|
||||
|
||||
return new WeChatNativePayResult
|
||||
{
|
||||
CodeUrl = result.CodeUrl,
|
||||
OutTradeNo = outTradeNo,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<WeChatTransactionStatus> QueryOrderByOutTradeNoAsync(
|
||||
string outTradeNo,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateOptions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outTradeNo))
|
||||
{
|
||||
throw new ArgumentException("outTradeNo is required.", nameof(outTradeNo));
|
||||
}
|
||||
|
||||
var canonicalUrl =
|
||||
$"/v3/pay/transactions/out-trade-no/{Uri.EscapeDataString(outTradeNo)}?mchid={Uri.EscapeDataString(_options.MerchantId)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, canonicalUrl);
|
||||
|
||||
var nonce = Guid.NewGuid().ToString("N");
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"WECHATPAY2-SHA256-RSA2048",
|
||||
BuildAuthorizationParameter(canonicalUrl, HttpMethod.Get.Method, string.Empty, nonce, timestamp));
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = TryDeserialize<WeChatErrorResponse>(responseBody);
|
||||
var message = error is null
|
||||
? responseBody
|
||||
: $"{error.Code}: {error.Message}";
|
||||
|
||||
throw new InvalidOperationException($"WeChat order query failed: {message}");
|
||||
}
|
||||
|
||||
var result = TryDeserialize<QueryOrderResponse>(responseBody);
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("WeChat order query returned an unreadable response.");
|
||||
}
|
||||
|
||||
return new WeChatTransactionStatus
|
||||
{
|
||||
OutTradeNo = result.OutTradeNo,
|
||||
TradeState = result.TradeState,
|
||||
TransactionId = result.TransactionId,
|
||||
Attach = result.Attach,
|
||||
SuccessTime = result.SuccessTime
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<WeChatPaymentNotification> ParsePaymentNotificationAsync(
|
||||
string body,
|
||||
IHeaderDictionary headers,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(headers);
|
||||
ValidateNotificationOptions();
|
||||
|
||||
var timestamp = headers["Wechatpay-Timestamp"].ToString();
|
||||
var nonce = headers["Wechatpay-Nonce"].ToString();
|
||||
var signature = headers["Wechatpay-Signature"].ToString();
|
||||
var serial = headers["Wechatpay-Serial"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(timestamp) ||
|
||||
string.IsNullOrWhiteSpace(nonce) ||
|
||||
string.IsNullOrWhiteSpace(signature) ||
|
||||
string.IsNullOrWhiteSpace(serial))
|
||||
{
|
||||
throw new InvalidOperationException("Missing WeChat notification headers.");
|
||||
}
|
||||
|
||||
VerifyNotificationSignature(timestamp, nonce, body, signature, serial);
|
||||
|
||||
var notification = TryDeserialize<PaymentNotificationEnvelope>(body)
|
||||
?? throw new InvalidOperationException("Invalid WeChat notification payload.");
|
||||
|
||||
if (notification.Resource is null)
|
||||
{
|
||||
throw new InvalidOperationException("WeChat notification resource is missing.");
|
||||
}
|
||||
|
||||
var decryptedJson = DecryptNotificationResource(notification.Resource);
|
||||
var transaction = TryDeserialize<PaymentTransaction>(decryptedJson)
|
||||
?? throw new InvalidOperationException("Unable to decrypt WeChat notification resource.");
|
||||
|
||||
if (!TryExtractOrderId(transaction.Attach, out var orderId))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to resolve orderId from WeChat notification.");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
|
||||
return new WeChatPaymentNotification
|
||||
{
|
||||
OrderId = orderId,
|
||||
OutTradeNo = transaction.OutTradeNo,
|
||||
TradeState = transaction.TradeState,
|
||||
TransactionId = transaction.TransactionId
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildAuthorizationParameter(string canonicalUrl, string method, string body, string nonce, string timestamp)
|
||||
{
|
||||
var message = $"{method}\n{canonicalUrl}\n{timestamp}\n{nonce}\n{body}\n";
|
||||
var signature = Sign(message);
|
||||
|
||||
return
|
||||
$"mchid=\"{_options.MerchantId}\"," +
|
||||
$"nonce_str=\"{nonce}\"," +
|
||||
$"timestamp=\"{timestamp}\"," +
|
||||
$"serial_no=\"{_options.CertificateSerialNumber}\"," +
|
||||
$"signature=\"{signature}\"";
|
||||
}
|
||||
|
||||
private string Sign(string message)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(GetPrivateKeyPem());
|
||||
var signatureBytes = rsa.SignData(
|
||||
Encoding.UTF8.GetBytes(message),
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
return Convert.ToBase64String(signatureBytes);
|
||||
}
|
||||
|
||||
private string GetPrivateKeyPem()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPem))
|
||||
{
|
||||
return _options.PrivateKeyPem;
|
||||
}
|
||||
|
||||
var keyPath = _options.PrivateKeyPath;
|
||||
if (string.IsNullOrWhiteSpace(keyPath))
|
||||
{
|
||||
throw new InvalidOperationException("WeChat private key is not configured.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(keyPath))
|
||||
{
|
||||
keyPath = Path.Combine(AppContext.BaseDirectory, keyPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(keyPath))
|
||||
{
|
||||
throw new InvalidOperationException($"WeChat private key file was not found: {keyPath}");
|
||||
}
|
||||
|
||||
return File.ReadAllText(keyPath);
|
||||
}
|
||||
|
||||
private void ValidateOptions()
|
||||
{
|
||||
var missingFields = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.AppId))
|
||||
{
|
||||
missingFields.Add("WeChatPay:AppId");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.MerchantId))
|
||||
{
|
||||
missingFields.Add("WeChatPay:MerchantId");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.CertificateSerialNumber))
|
||||
{
|
||||
missingFields.Add("WeChatPay:CertificateSerialNumber");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.NotifyUrl))
|
||||
{
|
||||
missingFields.Add("WeChatPay:NotifyUrl");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.PrivateKeyPem) && string.IsNullOrWhiteSpace(_options.PrivateKeyPath))
|
||||
{
|
||||
missingFields.Add("WeChatPay:PrivateKeyPath or WeChatPay:PrivateKeyPem");
|
||||
}
|
||||
|
||||
if (missingFields.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Missing WeChat Pay configuration: {string.Join(", ", missingFields)}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateNotificationOptions()
|
||||
{
|
||||
var missingFields = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiV3Key))
|
||||
{
|
||||
missingFields.Add("WeChatPay:ApiV3Key");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPem) &&
|
||||
string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPath))
|
||||
{
|
||||
missingFields.Add("WeChatPay:PlatformPublicKeyPath or WeChatPay:PlatformPublicKeyPem");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.PlatformPublicKeySerial))
|
||||
{
|
||||
missingFields.Add("WeChatPay:PlatformPublicKeySerial");
|
||||
}
|
||||
|
||||
if (missingFields.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Missing WeChat notification configuration: {string.Join(", ", missingFields)}");
|
||||
}
|
||||
}
|
||||
|
||||
private void VerifyNotificationSignature(
|
||||
string timestamp,
|
||||
string nonce,
|
||||
string body,
|
||||
string signature,
|
||||
string serial)
|
||||
{
|
||||
if (!string.Equals(serial, _options.PlatformPublicKeySerial, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected WeChat notification serial.");
|
||||
}
|
||||
|
||||
var signatureMessage = $"{timestamp}\n{nonce}\n{body}\n";
|
||||
var signatureBytes = Convert.FromBase64String(signature);
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(GetPlatformPublicKeyPem());
|
||||
|
||||
var verified = rsa.VerifyData(
|
||||
Encoding.UTF8.GetBytes(signatureMessage),
|
||||
signatureBytes,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
if (!verified)
|
||||
{
|
||||
throw new InvalidOperationException("WeChat notification signature verification failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetPlatformPublicKeyPem()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPem))
|
||||
{
|
||||
return _options.PlatformPublicKeyPem;
|
||||
}
|
||||
|
||||
var keyPath = _options.PlatformPublicKeyPath;
|
||||
if (string.IsNullOrWhiteSpace(keyPath))
|
||||
{
|
||||
throw new InvalidOperationException("WeChat platform public key is not configured.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(keyPath))
|
||||
{
|
||||
keyPath = Path.Combine(AppContext.BaseDirectory, keyPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(keyPath))
|
||||
{
|
||||
throw new InvalidOperationException($"WeChat platform public key file was not found: {keyPath}");
|
||||
}
|
||||
|
||||
return File.ReadAllText(keyPath);
|
||||
}
|
||||
|
||||
private string DecryptNotificationResource(PaymentNotificationResource resource)
|
||||
{
|
||||
if (!string.Equals(resource.Algorithm, "AEAD_AES_256_GCM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported WeChat notification algorithm: {resource.Algorithm}");
|
||||
}
|
||||
|
||||
var keyBytes = Encoding.UTF8.GetBytes(_options.ApiV3Key);
|
||||
if (keyBytes.Length != 32)
|
||||
{
|
||||
throw new InvalidOperationException("WeChat ApiV3Key must be 32 bytes.");
|
||||
}
|
||||
|
||||
var cipherBytes = Convert.FromBase64String(resource.Ciphertext);
|
||||
var nonceBytes = Encoding.UTF8.GetBytes(resource.Nonce);
|
||||
var associatedDataBytes = Encoding.UTF8.GetBytes(resource.AssociatedData ?? string.Empty);
|
||||
|
||||
if (cipherBytes.Length < 16)
|
||||
{
|
||||
throw new InvalidOperationException("Invalid WeChat notification ciphertext.");
|
||||
}
|
||||
|
||||
var tagLength = 16;
|
||||
var ciphertextLength = cipherBytes.Length - tagLength;
|
||||
var plaintextBytes = new byte[ciphertextLength];
|
||||
var tagBytes = new byte[tagLength];
|
||||
Buffer.BlockCopy(cipherBytes, ciphertextLength, tagBytes, 0, tagLength);
|
||||
|
||||
using var aesGcm = new AesGcm(keyBytes, tagLength);
|
||||
aesGcm.Decrypt(
|
||||
nonceBytes,
|
||||
cipherBytes.AsSpan(0, ciphertextLength),
|
||||
tagBytes,
|
||||
plaintextBytes,
|
||||
associatedDataBytes);
|
||||
|
||||
return Encoding.UTF8.GetString(plaintextBytes);
|
||||
}
|
||||
|
||||
private static bool TryExtractOrderId(string? attach, out int orderId)
|
||||
{
|
||||
orderId = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attach))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const string prefix = "orderId=";
|
||||
if (!attach.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(attach[prefix.Length..], out orderId);
|
||||
}
|
||||
|
||||
private static int ConvertToFen(decimal amount)
|
||||
{
|
||||
return decimal.ToInt32(decimal.Round(amount * 100m, 0, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
private static string BuildOutTradeNo(int orderId)
|
||||
{
|
||||
return $"PW{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{orderId:D6}";
|
||||
}
|
||||
|
||||
private static T? TryDeserialize<T>(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NativePayRequest
|
||||
{
|
||||
[JsonPropertyName("appid")]
|
||||
public string AppId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("mchid")]
|
||||
public string MerchantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("out_trade_no")]
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("notify_url")]
|
||||
public string NotifyUrl { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("time_expire")]
|
||||
public string? TimeExpire { get; init; }
|
||||
|
||||
[JsonPropertyName("attach")]
|
||||
public string? Attach { get; init; }
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public NativePayAmount Amount { get; init; } = new();
|
||||
}
|
||||
|
||||
private sealed class NativePayAmount
|
||||
{
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("currency")]
|
||||
public string Currency { get; init; } = "CNY";
|
||||
}
|
||||
|
||||
private sealed class NativePayResponse
|
||||
{
|
||||
[JsonPropertyName("code_url")]
|
||||
public string CodeUrl { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class QueryOrderResponse
|
||||
{
|
||||
[JsonPropertyName("out_trade_no")]
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("trade_state")]
|
||||
public string TradeState { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("transaction_id")]
|
||||
public string TransactionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attach")]
|
||||
public string Attach { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("success_time")]
|
||||
public string SuccessTime { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class WeChatErrorResponse
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class PaymentNotificationEnvelope
|
||||
{
|
||||
[JsonPropertyName("resource")]
|
||||
public PaymentNotificationResource? Resource { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PaymentNotificationResource
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ciphertext")]
|
||||
public string Ciphertext { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("nonce")]
|
||||
public string Nonce { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("associated_data")]
|
||||
public string? AssociatedData { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PaymentTransaction
|
||||
{
|
||||
[JsonPropertyName("out_trade_no")]
|
||||
public string OutTradeNo { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("trade_state")]
|
||||
public string TradeState { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("transaction_id")]
|
||||
public string TransactionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attach")]
|
||||
public string Attach { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,19 @@
|
||||
"DefaultConnection": "Data Source=petwash.db",
|
||||
"MySqlConnection": "Server=101.132.182.216;Database=petwash;User=sc_root;Password=Shsc#$@2024#@!;Port=3306;CharSet=utf8mb4;"
|
||||
},
|
||||
"DatabaseProvider": "Sqlite"
|
||||
"DatabaseProvider": "Sqlite",
|
||||
"WeChatPay": {
|
||||
"AppId": "",
|
||||
"MerchantId": "1107066208",
|
||||
"CertificateSerialNumber": "",
|
||||
"PrivateKeyPath": "",
|
||||
"PrivateKeyPem": "",
|
||||
"NotifyUrl": "",
|
||||
"ApiV3Key": "",
|
||||
"PlatformPublicKeyPath": "",
|
||||
"PlatformPublicKeyPem": "",
|
||||
"PlatformPublicKeySerial": "",
|
||||
"Currency": "CNY",
|
||||
"OrderExpireMinutes": 5
|
||||
}
|
||||
}
|
||||
|
||||
9
PetWashControl/Models/CreateOrderResponse.cs
Normal file
9
PetWashControl/Models/CreateOrderResponse.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PetWashControl.Models;
|
||||
|
||||
public sealed class CreateOrderResponse
|
||||
{
|
||||
public Order? Order { get; set; }
|
||||
public string CodeUrl { get; set; } = string.Empty;
|
||||
public string OutTradeNo { get; set; } = string.Empty;
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
11
PetWashControl/Models/PaymentStatusResponse.cs
Normal file
11
PetWashControl/Models/PaymentStatusResponse.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace PetWashControl.Models;
|
||||
|
||||
public sealed class PaymentStatusResponse
|
||||
{
|
||||
public Order? Order { get; set; }
|
||||
public bool IsPaid { get; set; }
|
||||
public string TradeState { get; set; } = string.Empty;
|
||||
public string OutTradeNo { get; set; } = string.Empty;
|
||||
public string TransactionId { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="MQTTnet" Version="4.3.7.1207" />
|
||||
<PackageReference Include="NModbus" Version="3.0.81" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="System.Net.Http.Json" Version="8.0.1" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -32,13 +32,13 @@ public class ApiService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Order?> CreateOrderAsync(int packageId)
|
||||
public async Task<CreateOrderResponse?> CreateOrderAsync(int packageId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("api/orders", new { packageId });
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<Order>();
|
||||
return await response.Content.ReadFromJsonAsync<CreateOrderResponse>();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -60,6 +60,19 @@ public class ApiService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PaymentStatusResponse?> GetPaymentStatusAsync(int orderId, string outTradeNo)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _httpClient.GetFromJsonAsync<PaymentStatusResponse>(
|
||||
$"api/orders/{orderId}/payment-status?outTradeNo={Uri.EscapeDataString(outTradeNo)}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new Exception($"获取支付状态失败: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Order?> GetOrderAsync(int orderId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,9 +2,14 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PetWashControl.Models;
|
||||
using PetWashControl.Services;
|
||||
using QRCoder;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace PetWashControl.ViewModels;
|
||||
|
||||
@@ -39,6 +44,27 @@ public partial class MainViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private Order? _currentOrder;
|
||||
|
||||
[ObservableProperty]
|
||||
private ImageSource? _paymentQrCodeImage;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _paymentCodeUrl = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _paymentOutTradeNo = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTimeOffset? _paymentExpiresAt;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _paymentCountdownText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _paymentStatusText = "等待扫码支付";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isPaymentExpired;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = "系统就绪";
|
||||
|
||||
@@ -146,8 +172,12 @@ public partial class MainViewModel : ObservableObject
|
||||
private readonly System.Timers.Timer _carouselTimer;
|
||||
private readonly System.Timers.Timer _clockTimer;
|
||||
private readonly System.Timers.Timer _liquidLevelTimer;
|
||||
private readonly DispatcherTimer _paymentStatusTimer;
|
||||
private readonly DispatcherTimer _paymentCountdownTimer;
|
||||
private readonly string[] _carouselImages = { "/Images/dog.png", "/Images/dog1.png", "/Images/dog2.png" };
|
||||
private int _currentImageIndex = 0;
|
||||
private bool _isPaymentPolling;
|
||||
private bool _isCheckingPaymentStatus;
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
@@ -217,6 +247,18 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
};
|
||||
_liquidLevelTimer.Start();
|
||||
|
||||
_paymentStatusTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(Math.Max(1, _config.PaymentCheckIntervalSeconds))
|
||||
};
|
||||
_paymentStatusTimer.Tick += async (s, e) => await PollPaymentStatusAsync();
|
||||
|
||||
_paymentCountdownTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_paymentCountdownTimer.Tick += async (s, e) => await RefreshPaymentCountdownAsync();
|
||||
}
|
||||
|
||||
private void InitializeWashSteps()
|
||||
@@ -654,16 +696,33 @@ public partial class MainViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
// 创建订单
|
||||
CurrentOrder = await _apiService.CreateOrderAsync(package.Id);
|
||||
var createOrderResponse = await _apiService.CreateOrderAsync(package.Id);
|
||||
CurrentOrder = createOrderResponse?.Order;
|
||||
PaymentCodeUrl = createOrderResponse?.CodeUrl ?? "";
|
||||
PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? "";
|
||||
PaymentExpiresAt = createOrderResponse?.ExpiresAt;
|
||||
IsPaymentExpired = false;
|
||||
PaymentStatusText = "等待扫码支付";
|
||||
RefreshPaymentCountdown();
|
||||
PaymentQrCodeImage = string.IsNullOrWhiteSpace(PaymentCodeUrl)
|
||||
? null
|
||||
: BuildPaymentQrCodeImage(PaymentCodeUrl);
|
||||
|
||||
if (CurrentOrder == null || PaymentQrCodeImage == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo))
|
||||
{
|
||||
throw new InvalidOperationException("Payment QR code was not returned by the server.");
|
||||
}
|
||||
StatusMessage = $"订单创建成功,请扫码支付 ¥{package.Price}";
|
||||
_logger.LogInfo($"订单创建成功,订单ID: {CurrentOrder?.Id}");
|
||||
|
||||
// 切换到二维码支付界面
|
||||
CurrentView = "QRCode";
|
||||
ViewChanged?.Invoke("QRCode");
|
||||
StartPaymentStatusPolling();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StopPaymentStatusPolling();
|
||||
_logger.LogError("创建订单失败", ex);
|
||||
StatusMessage = $"创建订单失败: {ex.Message}";
|
||||
MessageBox.Show($"创建订单失败\n\n{ex.Message}", "错误",
|
||||
@@ -672,14 +731,10 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelPayment()
|
||||
private async Task CancelPayment()
|
||||
{
|
||||
_logger.LogInfo("取消支付");
|
||||
CurrentOrder = null;
|
||||
SelectedPackage = null;
|
||||
CurrentView = "Payment";
|
||||
ViewChanged?.Invoke("Payment");
|
||||
StatusMessage = "已取消支付,请重新选择套餐";
|
||||
await CancelCurrentPaymentAsync("已取消支付,请重新选择套餐");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -694,7 +749,22 @@ public partial class MainViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
_logger.LogInfo($"创建订单,套餐ID: {SelectedPackage.Id}");
|
||||
CurrentOrder = await _apiService.CreateOrderAsync(SelectedPackage.Id);
|
||||
var createOrderResponse = await _apiService.CreateOrderAsync(SelectedPackage.Id);
|
||||
CurrentOrder = createOrderResponse?.Order;
|
||||
PaymentCodeUrl = createOrderResponse?.CodeUrl ?? "";
|
||||
PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? "";
|
||||
PaymentExpiresAt = createOrderResponse?.ExpiresAt;
|
||||
IsPaymentExpired = false;
|
||||
PaymentStatusText = "等待扫码支付";
|
||||
RefreshPaymentCountdown();
|
||||
PaymentQrCodeImage = string.IsNullOrWhiteSpace(PaymentCodeUrl)
|
||||
? null
|
||||
: BuildPaymentQrCodeImage(PaymentCodeUrl);
|
||||
|
||||
if (CurrentOrder == null || PaymentQrCodeImage == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo))
|
||||
{
|
||||
throw new InvalidOperationException("Payment QR code was not returned by the server.");
|
||||
}
|
||||
StatusMessage = $"订单创建成功,订单号: {CurrentOrder?.Id},请支付 ¥{SelectedPackage.Price}";
|
||||
_logger.LogInfo($"订单创建成功,订单ID: {CurrentOrder?.Id}");
|
||||
}
|
||||
@@ -719,6 +789,7 @@ public partial class MainViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
_logger.LogInfo($"模拟支付,订单ID: {CurrentOrder.Id}");
|
||||
StopPaymentStatusPolling();
|
||||
|
||||
// 模拟支付处理延迟
|
||||
StatusMessage = "正在处理支付...";
|
||||
@@ -726,17 +797,7 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
// 确认支付
|
||||
CurrentOrder = await _apiService.ConfirmPaymentAsync(CurrentOrder.Id);
|
||||
StatusMessage = "支付成功!设备门已打开,请将宠物放入";
|
||||
IsDoorOpen = true;
|
||||
_logger.LogInfo("支付成功,门已打开");
|
||||
|
||||
// 显示支付成功提示
|
||||
MessageBox.Show("支付成功!\n\n设备门已自动打开\n请将宠物放入设备后关闭门开始洗护",
|
||||
"支付成功", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
|
||||
// 返回待机界面,显示关门按钮
|
||||
CurrentView = "Idle";
|
||||
ViewChanged?.Invoke("Idle");
|
||||
await HandlePaymentSuccessAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -747,6 +808,212 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapImage BuildPaymentQrCodeImage(string codeUrl)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var qrCodeData = generator.CreateQrCode(codeUrl, QRCodeGenerator.ECCLevel.Q);
|
||||
var qrCode = new PngByteQRCode(qrCodeData);
|
||||
var pngBytes = qrCode.GetGraphic(20);
|
||||
|
||||
using var stream = new MemoryStream(pngBytes);
|
||||
var image = new BitmapImage();
|
||||
image.BeginInit();
|
||||
image.CacheOption = BitmapCacheOption.OnLoad;
|
||||
image.StreamSource = stream;
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
private void StartPaymentStatusPolling()
|
||||
{
|
||||
StopPaymentStatusPolling();
|
||||
|
||||
if (CurrentOrder == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPaymentPolling = true;
|
||||
_paymentStatusTimer.Start();
|
||||
_paymentCountdownTimer.Start();
|
||||
}
|
||||
|
||||
private void StopPaymentStatusPolling()
|
||||
{
|
||||
_isPaymentPolling = false;
|
||||
_paymentStatusTimer.Stop();
|
||||
_paymentCountdownTimer.Stop();
|
||||
_isCheckingPaymentStatus = false;
|
||||
}
|
||||
|
||||
private void RefreshPaymentCountdown()
|
||||
{
|
||||
if (PaymentExpiresAt == null)
|
||||
{
|
||||
PaymentCountdownText = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = PaymentExpiresAt.Value - DateTimeOffset.Now;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
PaymentCountdownText = "00:00";
|
||||
return;
|
||||
}
|
||||
|
||||
PaymentCountdownText = remaining.ToString(@"mm\:ss");
|
||||
}
|
||||
|
||||
private async Task RefreshPaymentCountdownAsync()
|
||||
{
|
||||
RefreshPaymentCountdown();
|
||||
|
||||
if (IsPaymentExpired || PaymentExpiresAt == null || CurrentOrder == null || CurrentOrder.IsPaid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (PaymentExpiresAt.Value <= DateTimeOffset.Now)
|
||||
{
|
||||
await HandlePaymentExpiredAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollPaymentStatusAsync()
|
||||
{
|
||||
if (_isCheckingPaymentStatus)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isPaymentPolling || CurrentOrder == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo))
|
||||
{
|
||||
StopPaymentStatusPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_isCheckingPaymentStatus = true;
|
||||
PaymentStatusText = "正在确认支付结果...";
|
||||
var paymentStatus = await _apiService.GetPaymentStatusAsync(CurrentOrder.Id, PaymentOutTradeNo);
|
||||
if (paymentStatus?.Order != null)
|
||||
{
|
||||
CurrentOrder = paymentStatus.Order;
|
||||
}
|
||||
|
||||
if (paymentStatus?.IsPaid == true)
|
||||
{
|
||||
await HandlePaymentSuccessAsync();
|
||||
}
|
||||
else if (!IsPaymentExpired)
|
||||
{
|
||||
PaymentStatusText = "等待扫码支付";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("轮询支付状态失败", ex);
|
||||
if (!IsPaymentExpired)
|
||||
{
|
||||
PaymentStatusText = "支付状态查询异常";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isCheckingPaymentStatus = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandlePaymentSuccessAsync()
|
||||
{
|
||||
StopPaymentStatusPolling();
|
||||
|
||||
if (CurrentOrder == null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
IsPaymentExpired = false;
|
||||
PaymentStatusText = "支付成功";
|
||||
PaymentCountdownText = "";
|
||||
PaymentExpiresAt = null;
|
||||
StatusMessage = "支付成功!设备门已打开,请将宠物放入";
|
||||
IsDoorOpen = true;
|
||||
_logger.LogInfo("支付成功,门已打开");
|
||||
|
||||
MessageBox.Show("支付成功!\n\n设备门已自动打开\n请将宠物放入设备后关闭门开始洗护",
|
||||
"支付成功", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
|
||||
CurrentView = "Idle";
|
||||
ViewChanged?.Invoke("Idle");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandlePaymentExpiredAsync()
|
||||
{
|
||||
if (IsPaymentExpired)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsPaymentExpired = true;
|
||||
StopPaymentStatusPolling();
|
||||
PaymentStatusText = "二维码已超时,请重新下单";
|
||||
PaymentCountdownText = "00:00";
|
||||
PaymentQrCodeImage = null;
|
||||
StatusMessage = "支付超时,请重新选择套餐";
|
||||
|
||||
if (CurrentOrder != null && !CurrentOrder.IsPaid)
|
||||
{
|
||||
try
|
||||
{
|
||||
CurrentOrder = await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Cancelled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("支付超时后取消订单失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelCurrentPaymentAsync(string statusMessage)
|
||||
{
|
||||
StopPaymentStatusPolling();
|
||||
|
||||
if (CurrentOrder != null && !CurrentOrder.IsPaid)
|
||||
{
|
||||
try
|
||||
{
|
||||
CurrentOrder = await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Cancelled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("取消支付时更新订单状态失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
ResetPaymentSession();
|
||||
CurrentView = "Payment";
|
||||
ViewChanged?.Invoke("Payment");
|
||||
StatusMessage = statusMessage;
|
||||
}
|
||||
|
||||
private void ResetPaymentSession()
|
||||
{
|
||||
CurrentOrder = null;
|
||||
SelectedPackage = null;
|
||||
PaymentCodeUrl = "";
|
||||
PaymentOutTradeNo = "";
|
||||
PaymentExpiresAt = null;
|
||||
PaymentCountdownText = "";
|
||||
PaymentStatusText = "等待扫码支付";
|
||||
IsPaymentExpired = false;
|
||||
PaymentQrCodeImage = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CloseDoorAsync()
|
||||
{
|
||||
|
||||
@@ -813,20 +813,38 @@
|
||||
|
||||
<!-- 二维码 -->
|
||||
<Border Background="#F5F5F5"
|
||||
Width="220"
|
||||
Height="220"
|
||||
Width="260"
|
||||
Height="260"
|
||||
CornerRadius="15"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,18"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="2">
|
||||
<Image Source="/Images/qrcode.png"
|
||||
<Image Source="{Binding PaymentQrCodeImage}"
|
||||
Stretch="Uniform"
|
||||
Margin="10"/>
|
||||
</Border>
|
||||
|
||||
<Border Background="#F5F5F5"
|
||||
CornerRadius="12"
|
||||
Padding="18,12"
|
||||
Margin="0,0,0,18">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding PaymentStatusText}"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#2E7D32"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,6"/>
|
||||
<TextBlock Text="{Binding PaymentCountdownText, StringFormat=二维码有效期剩余 {0}}"
|
||||
FontSize="14"
|
||||
Foreground="#757575"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 支付提示 -->
|
||||
<TextBlock Text="请使用微信或支付宝扫描二维码"
|
||||
<TextBlock Text="请使用微信扫码完成支付"
|
||||
FontSize="15"
|
||||
Foreground="#757575"
|
||||
HorizontalAlignment="Center"
|
||||
@@ -892,7 +910,7 @@
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<Border Grid.Row="2" Padding="30,20">
|
||||
<TextBlock Text="等待支付中..."
|
||||
<TextBlock Text="{Binding PaymentStatusText}"
|
||||
FontSize="20"
|
||||
Foreground="#558B2F"
|
||||
HorizontalAlignment="Center"
|
||||
|
||||
Reference in New Issue
Block a user