feat: add payment notifications and entitlement fulfillment

This commit is contained in:
xiong
2026-07-26 19:38:53 +08:00
parent 494039973d
commit 36b64dcf70
6 changed files with 570 additions and 13 deletions

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Aop.Api;
using Aop.Api.Util;
using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce;
@@ -25,9 +26,34 @@ internal sealed class AlipayProvider : IPaymentProvider
CancellationToken cancellationToken = default)
{
_ = BuildClient(account);
throw new PaymentProviderException(
"Alipay SDK is configured; notification parsing is enabled in the notification batch.",
"alipay_notification_not_enabled");
var values = ToDictionary(request.Body);
var signatureValid = AlipaySignature.RSACheckV1(
values,
Required(account.ConfigPublic, "alipayPublicKey"),
GetString(account.ConfigPublic, "charset") ?? "UTF-8",
GetString(account.ConfigPublic, "signType") ?? "RSA2",
false);
var eventId = GetValue(values, "notify_id") ?? GetValue(values, "trade_no") ?? Guid.NewGuid().ToString("N");
var orderNo = GetValue(values, "out_trade_no")
?? throw new PaymentProviderException("Alipay notification order number is missing.", "alipay_notify_order_missing");
var tradeStatus = GetValue(values, "trade_status");
var amountCents = YuanToCents(GetValue(values, "total_amount") ?? GetValue(values, "receipt_amount"));
DateTimeOffset? paidAt = DateTimeOffset.TryParse(GetValue(values, "gmt_payment"), out var parsedPaidAt)
? parsedPaidAt
: null;
return Task.FromResult(new PaymentNotificationResult(
Provider,
"payment_notify",
eventId,
orderNo,
GetValue(values, "trade_no"),
amountCents,
string.Equals(tradeStatus, "TRADE_SUCCESS", StringComparison.OrdinalIgnoreCase) ||
string.Equals(tradeStatus, "TRADE_FINISHED", StringComparison.OrdinalIgnoreCase),
signatureValid,
paidAt,
request.Body));
}
private static DefaultAopClient BuildClient(PaymentProviderAccount account)
@@ -63,4 +89,53 @@ internal sealed class AlipayProvider : IPaymentProvider
"Alipay provider configuration is incomplete.",
"alipay_config_incomplete");
}
private static Dictionary<string, string> ToDictionary(JsonElement element)
{
var values = new Dictionary<string, string>(StringComparer.Ordinal);
if (element.ValueKind != JsonValueKind.Object)
{
return values;
}
foreach (var property in element.EnumerateObject())
{
values[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.GetRawText();
}
return values;
}
private static string? GetString(JsonElement element, params string[] keys)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var key in keys)
{
if (element.TryGetProperty(key, out var property) &&
property.ValueKind == JsonValueKind.String)
{
return property.GetString();
}
}
return null;
}
private static string? GetValue(IReadOnlyDictionary<string, string> values, string key) =>
values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: null;
private static int YuanToCents(string? value)
{
return decimal.TryParse(value, out var amount)
? (int)Math.Round(amount * 100, MidpointRounding.AwayFromZero)
: 0;
}
}

View File

@@ -187,7 +187,17 @@ public sealed class CommerceService(
if (IsPaid(result.Status))
{
await MarkPaidAsync(actor, order, payment, result.ProviderTradeNo, result.RawPayload, cancellationToken);
await MarkPaidAsync(
actor,
order,
payment,
result.ProviderTradeNo,
result.RawPayload,
"payment_paid",
result.ProviderTradeNo,
true,
null,
cancellationToken);
}
else
{
@@ -238,15 +248,146 @@ public sealed class CommerceService(
: null);
}
public async Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
Guid tenantId,
string provider,
IReadOnlyDictionary<string, string> headers,
string rawBody,
JsonElement body,
CancellationToken cancellationToken = default)
{
var normalizedProvider = NormalizeProvider(provider);
var notification = await paymentGateway.ParsePaymentNotificationAsync(
normalizedProvider,
new PaymentNotificationRequest(
tenantId,
normalizedProvider,
headers,
rawBody,
body),
cancellationToken);
if (!notification.SignatureValid)
{
throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid");
}
var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync(
item =>
item.Provider == normalizedProvider &&
item.EventId == notification.EventId &&
item.ProcessedAt != null,
cancellationToken);
if (alreadyProcessed)
{
return new PaymentNotificationProcessResult(
normalizedProvider,
notification.EventId,
notification.OrderNo,
"processed",
true);
}
var order = await dbContext.Orders
.SingleOrDefaultAsync(item =>
item.TenantId == tenantId &&
item.OrderNo == notification.OrderNo,
cancellationToken)
?? throw new CommerceException("Order was not found.", "order_not_found");
if (order.UserId is null)
{
throw new CommerceException("Order does not belong to a user.", "order_user_missing");
}
if (order.AmountCents != notification.AmountCents)
{
dbContext.PaymentEvents.Add(new PaymentEvent
{
TenantId = tenantId,
Provider = normalizedProvider,
EventType = notification.EventType,
EventId = notification.EventId,
SignatureValid = true,
Payload = notification.RawPayload,
Error = "payment_amount_mismatch"
});
await dbContext.SaveChangesAsync(cancellationToken);
throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch");
}
var payment = await dbContext.Payments
.Where(item =>
item.TenantId == tenantId &&
item.OrderId == order.Id &&
item.Provider == normalizedProvider)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (payment is null)
{
payment = new Payment
{
TenantId = tenantId,
OrderId = order.Id,
Provider = normalizedProvider,
Method = order.PayMethod,
Status = PaymentStatus.Pending,
AmountCents = order.AmountCents
};
dbContext.Payments.Add(payment);
}
if (notification.Paid && order.Status == OrderStatus.Pending)
{
await MarkPaidAsync(
new CommerceActor(tenantId, order.UserId.Value),
order,
payment,
notification.ProviderTradeNo,
notification.RawPayload,
notification.EventType,
notification.EventId,
notification.SignatureValid,
notification.PaidAt,
cancellationToken);
}
else
{
dbContext.PaymentEvents.Add(new PaymentEvent
{
TenantId = tenantId,
PaymentId = payment.Id,
Provider = normalizedProvider,
EventType = notification.EventType,
EventId = notification.EventId,
SignatureValid = notification.SignatureValid,
Payload = notification.RawPayload,
ProcessedAt = DateTimeOffset.UtcNow
});
}
await dbContext.SaveChangesAsync(cancellationToken);
return new PaymentNotificationProcessResult(
normalizedProvider,
notification.EventId,
notification.OrderNo,
"processed",
false);
}
private async Task MarkPaidAsync(
CommerceActor actor,
Order order,
Payment payment,
string? providerTradeNo,
JsonElement rawPayload,
string eventType,
string? eventId,
bool signatureValid,
DateTimeOffset? paidAtOverride,
CancellationToken cancellationToken)
{
var paidAt = DateTimeOffset.UtcNow;
var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow;
payment.Status = PaymentStatus.Paid;
payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo;
payment.PaidAt = paidAt;
@@ -293,9 +434,9 @@ public sealed class CommerceService(
TenantId = actor.TenantId,
PaymentId = payment.Id,
Provider = payment.Provider,
EventType = "payment_paid",
EventId = payment.ProviderTradeNo,
SignatureValid = true,
EventType = eventType,
EventId = eventId,
SignatureValid = signatureValid,
Payload = rawPayload,
ProcessedAt = paidAt
});

View File

@@ -1,4 +1,6 @@
using System.Text.Json;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Models;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Events;
using SKIT.FlurlHttpClient.Wechat.TenpayV3;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
using Tiku.Application.Commerce;
@@ -25,10 +27,35 @@ internal sealed class WechatPayProvider : IPaymentProvider
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
_ = BuildClient(account);
throw new PaymentProviderException(
"WeChat Pay SDK is configured; notification parsing is enabled in the notification batch.",
"wechat_pay_notification_not_enabled");
var client = BuildClient(account);
var signatureValid = TryGetHeader(request.Headers, "Wechatpay-Timestamp", out var timestamp) &&
TryGetHeader(request.Headers, "Wechatpay-Nonce", out var nonce) &&
TryGetHeader(request.Headers, "Wechatpay-Signature", out var signature) &&
TryGetHeader(request.Headers, "Wechatpay-Serial", out var serial) &&
client.VerifyEventSignature(timestamp, nonce, request.RawBody, signature, serial);
var payload = TryDecryptTransactionResource(client, request.Body, out var decrypted)
? decrypted
: request.Body;
var eventId = GetString(request.Body, "id") ?? GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N");
var orderNo = GetString(payload, "out_trade_no", "outTradeNo")
?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", "wechat_pay_notify_order_missing");
var tradeNo = GetString(payload, "transaction_id", "transactionId");
var tradeState = GetString(payload, "trade_state", "tradeState") ?? GetString(request.Body, "event_type");
var amount = GetInt(payload, "amount", "total") ?? GetInt(payload, "amountCents") ?? 0;
var paidAt = GetDateTimeOffset(payload, "success_time", "successTime");
return Task.FromResult(new PaymentNotificationResult(
Provider,
GetString(request.Body, "event_type") ?? "TRANSACTION.SUCCESS",
eventId,
orderNo,
tradeNo,
amount,
string.Equals(tradeState, "SUCCESS", StringComparison.OrdinalIgnoreCase) ||
string.Equals(tradeState, "TRANSACTION.SUCCESS", StringComparison.OrdinalIgnoreCase),
signatureValid,
paidAt,
payload));
}
private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
@@ -65,4 +92,101 @@ internal sealed class WechatPayProvider : IPaymentProvider
"WeChat Pay provider configuration is incomplete.",
"wechat_pay_config_incomplete");
}
private static bool TryDecryptTransactionResource(
WechatTenpayClient client,
JsonElement body,
out JsonElement payload)
{
payload = default;
try
{
if (body.ValueKind != JsonValueKind.Object ||
!body.TryGetProperty("resource", out var resource))
{
return false;
}
var webhookEvent = JsonSerializer.Deserialize<WechatTenpayEvent>(body.GetRawText());
if (webhookEvent?.Resource is null)
{
return false;
}
var decrypted = client.DecryptEventResource<TransactionResource>(webhookEvent);
payload = JsonSerializer.SerializeToElement(decrypted);
_ = resource;
return true;
}
catch
{
return false;
}
}
private static bool TryGetHeader(
IReadOnlyDictionary<string, string> headers,
string name,
out string value)
{
foreach (var pair in headers)
{
if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase))
{
value = pair.Value;
return true;
}
}
value = string.Empty;
return false;
}
private static string? GetString(JsonElement element, params string[] keys)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var key in keys)
{
if (element.TryGetProperty(key, out var property) &&
property.ValueKind == JsonValueKind.String)
{
return property.GetString();
}
}
return null;
}
private static int? GetInt(JsonElement element, string parentKey, string childKey)
{
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(parentKey, out var parent) &&
parent.ValueKind == JsonValueKind.Object &&
parent.TryGetProperty(childKey, out var child) &&
child.TryGetInt32(out var value))
{
return value;
}
return null;
}
private static int? GetInt(JsonElement element, string key)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(key, out var property) &&
property.TryGetInt32(out var value)
? value
: null;
}
private static DateTimeOffset? GetDateTimeOffset(JsonElement element, params string[] keys)
{
var value = GetString(element, keys);
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
}