diff --git a/Tiku.Api/Controllers/CommerceController.cs b/Tiku.Api/Controllers/CommerceController.cs index bc178bf..132393d 100644 --- a/Tiku.Api/Controllers/CommerceController.cs +++ b/Tiku.Api/Controllers/CommerceController.cs @@ -1,5 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; +using System.Text.Json; using Tiku.Api.Contracts; using Tiku.Application.Commerce; using Tiku.Application.Security; @@ -78,6 +81,28 @@ public sealed class CommerceController( cancellationToken)); } + [AllowAnonymous] + [HttpPost("payments/notify/wechat-pay")] + [EndpointSummary("微信支付回调")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> WechatPayNotify( + [FromQuery] Guid tenantId, + CancellationToken cancellationToken) + { + return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.WechatPay, cancellationToken)); + } + + [AllowAnonymous] + [HttpPost("payments/notify/alipay")] + [EndpointSummary("支付宝支付回调")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AlipayNotify( + [FromQuery] Guid tenantId, + CancellationToken cancellationToken) + { + return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.Alipay, cancellationToken)); + } + private CommerceActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) @@ -87,4 +112,64 @@ public sealed class CommerceController( return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } + + private async Task ProcessNotificationAsync( + Guid tenantId, + string provider, + CancellationToken cancellationToken) + { + if (tenantId == Guid.Empty) + { + throw new CommerceException("Tenant id is required for payment notification.", "tenant_required"); + } + + var rawBody = await ReadRawBodyAsync(Request, cancellationToken); + using var body = ParseNotificationBody(Request, rawBody, cancellationToken); + var headers = Request.Headers.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToString(), + StringComparer.OrdinalIgnoreCase); + + return await commerceService.ProcessPaymentNotificationAsync( + tenantId, + provider, + headers, + rawBody, + body.RootElement.Clone(), + cancellationToken); + } + + private static async Task ReadRawBodyAsync(HttpRequest request, CancellationToken cancellationToken) + { + using var reader = new StreamReader( + request.Body, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + leaveOpen: false); + return await reader.ReadToEndAsync(cancellationToken); + } + + private static JsonDocument ParseNotificationBody( + HttpRequest request, + string rawBody, + CancellationToken cancellationToken) + { + if (request.HasFormContentType) + { + _ = cancellationToken; + var form = QueryHelpers.ParseQuery(rawBody); + var values = form.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToString(), + StringComparer.Ordinal); + return JsonDocument.Parse(JsonSerializer.Serialize(values)); + } + + if (string.IsNullOrWhiteSpace(rawBody)) + { + return JsonDocument.Parse("{}"); + } + + return JsonDocument.Parse(rawBody); + } } diff --git a/Tiku.Application/Commerce/CommerceModels.cs b/Tiku.Application/Commerce/CommerceModels.cs index 7a11563..3587587 100644 --- a/Tiku.Application/Commerce/CommerceModels.cs +++ b/Tiku.Application/Commerce/CommerceModels.cs @@ -64,6 +64,13 @@ public sealed record CurrentEntitlementItem( string Status, int? DaysLeft); +public sealed record PaymentNotificationProcessResult( + string Provider, + string EventId, + string OrderNo, + string Status, + bool Idempotent); + public interface ICommerceService { Task CreateOrderAsync( @@ -89,6 +96,14 @@ public interface ICommerceService Task GetCurrentEntitlementAsync( CommerceActor actor, CancellationToken cancellationToken = default); + + Task ProcessPaymentNotificationAsync( + Guid tenantId, + string provider, + IReadOnlyDictionary headers, + string rawBody, + JsonElement body, + CancellationToken cancellationToken = default); } public sealed class CommerceException(string message, string code) : Exception(message) diff --git a/Tiku.Infrastructure/Commerce/AlipayProvider.cs b/Tiku.Infrastructure/Commerce/AlipayProvider.cs index 92e0172..e9e0645 100644 --- a/Tiku.Infrastructure/Commerce/AlipayProvider.cs +++ b/Tiku.Infrastructure/Commerce/AlipayProvider.cs @@ -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 ToDictionary(JsonElement element) + { + var values = new Dictionary(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 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; + } } diff --git a/Tiku.Infrastructure/Commerce/CommerceService.cs b/Tiku.Infrastructure/Commerce/CommerceService.cs index e99ce93..6a1811d 100644 --- a/Tiku.Infrastructure/Commerce/CommerceService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceService.cs @@ -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 ProcessPaymentNotificationAsync( + Guid tenantId, + string provider, + IReadOnlyDictionary 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 }); diff --git a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs index 380b235..35460e8 100644 --- a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs +++ b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs @@ -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(body.GetRawText()); + if (webhookEvent?.Resource is null) + { + return false; + } + + var decrypted = client.DecryptEventResource(webhookEvent); + payload = JsonSerializer.SerializeToElement(decrypted); + _ = resource; + return true; + } + catch + { + return false; + } + } + + private static bool TryGetHeader( + IReadOnlyDictionary 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; + } } diff --git a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs index e5f3321..f971d8d 100644 --- a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs @@ -164,6 +164,81 @@ public sealed class CommerceEndpointTests Assert.Equal(firstPayment!.Id, secondPayment!.Id); } + [Fact] + public async Task Payment_notification_marks_order_paid_once() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + var order = await CreateOrderAsync(client, seed.PlanId); + var request = new CreateCommercePaymentDto + { + OrderNo = order.OrderNo, + Provider = "manual", + Method = "manual" + }; + await client.PostAsJsonAsync("/api/commerce/payments", request); + + client.DefaultRequestHeaders.Authorization = null; + var payload = new + { + eventId = "notify-001", + orderNo = order.OrderNo, + providerTradeNo = "trade-notify-001", + amountCents = order.AmountCents, + paid = true, + signatureValid = true + }; + var firstNotify = await client.PostAsJsonAsync( + $"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}", + payload); + var secondNotify = await client.PostAsJsonAsync( + $"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}", + payload); + await LoginAsync(client, seed); + var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current")) + .Content + .ReadFromJsonAsync(); + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var events = dbContext.PaymentEvents.Count(item => item.EventId == "notify-001"); + + Assert.Equal(HttpStatusCode.OK, firstNotify.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondNotify.StatusCode); + Assert.True(entitlement!.IsActive); + Assert.Equal(1, events); + } + + [Fact] + public async Task Invalid_payment_notification_signature_does_not_pay_order() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + var order = await CreateOrderAsync(client, seed.PlanId); + + client.DefaultRequestHeaders.Authorization = null; + var notifyResponse = await client.PostAsJsonAsync( + $"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}", + new + { + eventId = "notify-invalid", + orderNo = order.OrderNo, + providerTradeNo = "trade-invalid", + amountCents = order.AmountCents, + paid = true, + signatureValid = false + }); + await LoginAsync(client, seed); + var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}"); + var currentOrder = await orderResponse.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, notifyResponse.StatusCode); + Assert.Equal("Pending", currentOrder!.Status); + } + private static async Task CreateOrderAsync(HttpClient client, Guid planId) { var response = await client.PostAsJsonAsync( @@ -312,7 +387,49 @@ public sealed class CommerceEndpointTests PaymentNotificationRequest request, CancellationToken cancellationToken = default) { - throw new NotSupportedException(); + var body = request.Body; + var eventId = GetString(body, "eventId") ?? Guid.NewGuid().ToString("N"); + var orderNo = GetString(body, "orderNo") ?? throw new InvalidOperationException("orderNo missing"); + var tradeNo = GetString(body, "providerTradeNo"); + var amount = GetInt(body, "amountCents"); + var paid = GetBoolean(body, "paid"); + var signatureValid = GetBoolean(body, "signatureValid"); + return Task.FromResult(new PaymentNotificationResult( + provider, + "payment_notify", + eventId, + orderNo, + tradeNo, + amount, + paid, + signatureValid, + DateTimeOffset.UtcNow, + body)); + } + + private static string? GetString(JsonElement element, string key) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(key, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : 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 + : 0; + } + + private static bool GetBoolean(JsonElement element, string key) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(key, out var property) && + property.ValueKind == JsonValueKind.True; } } }