639 lines
22 KiB
C#
639 lines
22 KiB
C#
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()
|
|
{
|
|
return ResolvePemContent(
|
|
_options.PrivateKeyPem,
|
|
_options.PrivateKeyPath,
|
|
"WeChat private key");
|
|
}
|
|
|
|
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");
|
|
}
|
|
else
|
|
{
|
|
ValidateNotifyUrl(_options.NotifyUrl);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(_options.PrivateKeyPem) && string.IsNullOrWhiteSpace(_options.PrivateKeyPath))
|
|
{
|
|
missingFields.Add("WeChatPay:PrivateKeyPem (PEM content or file path)");
|
|
}
|
|
|
|
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");
|
|
}
|
|
else
|
|
{
|
|
ValidateApiV3Key(_options.ApiV3Key);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPem) &&
|
|
string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPath))
|
|
{
|
|
missingFields.Add("WeChatPay:PlatformPublicKeyPem (PEM content or file path)");
|
|
}
|
|
|
|
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()
|
|
{
|
|
return ResolvePemContent(
|
|
_options.PlatformPublicKeyPem,
|
|
_options.PlatformPublicKeyPath,
|
|
"WeChat platform public key");
|
|
}
|
|
|
|
private static string ResolvePemContent(string pemOrPath, string fallbackPath, string keyName)
|
|
{
|
|
var configuredValue = !string.IsNullOrWhiteSpace(pemOrPath)
|
|
? pemOrPath.Trim()
|
|
: fallbackPath.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(configuredValue))
|
|
{
|
|
throw new InvalidOperationException($"{keyName} is not configured.");
|
|
}
|
|
|
|
if (LooksLikePemContent(configuredValue))
|
|
{
|
|
return configuredValue;
|
|
}
|
|
|
|
var resolvedPath = configuredValue;
|
|
if (!Path.IsPathRooted(resolvedPath))
|
|
{
|
|
resolvedPath = Path.Combine(AppContext.BaseDirectory, resolvedPath);
|
|
}
|
|
|
|
if (!File.Exists(resolvedPath))
|
|
{
|
|
throw new InvalidOperationException($"{keyName} file was not found: {resolvedPath}");
|
|
}
|
|
|
|
return File.ReadAllText(resolvedPath);
|
|
}
|
|
|
|
private static bool LooksLikePemContent(string value)
|
|
{
|
|
return value.Contains("-----BEGIN", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static void ValidateNotifyUrl(string notifyUrl)
|
|
{
|
|
if (!Uri.TryCreate(notifyUrl, UriKind.Absolute, out var uri))
|
|
{
|
|
throw new InvalidOperationException("WeChatPay:NotifyUrl must be a valid absolute URL.");
|
|
}
|
|
|
|
var isLocalhost = uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
|
uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!isLocalhost && !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidOperationException("WeChatPay:NotifyUrl should use https for public callback addresses.");
|
|
}
|
|
}
|
|
|
|
private static void ValidateApiV3Key(string apiV3Key)
|
|
{
|
|
if (LooksLikePemContent(apiV3Key) || apiV3Key.StartsWith("MII", StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException("WeChatPay:ApiV3Key looks like a certificate or private key. It must be the 32-byte APIv3 key from WeChat Pay.");
|
|
}
|
|
|
|
if (Encoding.UTF8.GetByteCount(apiV3Key) != 32)
|
|
{
|
|
throw new InvalidOperationException("WeChatPay:ApiV3Key must be exactly 32 bytes.");
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|