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,8 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.Text;
using System.Text.Json;
using Tiku.Api.Contracts; using Tiku.Api.Contracts;
using Tiku.Application.Commerce; using Tiku.Application.Commerce;
using Tiku.Application.Security; using Tiku.Application.Security;
@@ -78,6 +81,28 @@ public sealed class CommerceController(
cancellationToken)); cancellationToken));
} }
[AllowAnonymous]
[HttpPost("payments/notify/wechat-pay")]
[EndpointSummary("微信支付回调")]
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<PaymentNotificationProcessResult>> WechatPayNotify(
[FromQuery] Guid tenantId,
CancellationToken cancellationToken)
{
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.WechatPay, cancellationToken));
}
[AllowAnonymous]
[HttpPost("payments/notify/alipay")]
[EndpointSummary("支付宝支付回调")]
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<PaymentNotificationProcessResult>> AlipayNotify(
[FromQuery] Guid tenantId,
CancellationToken cancellationToken)
{
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.Alipay, cancellationToken));
}
private CommerceActor ResolveActor() private CommerceActor ResolveActor()
{ {
if (currentTenant.TenantId is null || currentUser.UserId is null) 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); return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
} }
private async Task<PaymentNotificationProcessResult> 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<string> 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);
}
} }

View File

@@ -64,6 +64,13 @@ public sealed record CurrentEntitlementItem(
string Status, string Status,
int? DaysLeft); int? DaysLeft);
public sealed record PaymentNotificationProcessResult(
string Provider,
string EventId,
string OrderNo,
string Status,
bool Idempotent);
public interface ICommerceService public interface ICommerceService
{ {
Task<CommerceOrderItem> CreateOrderAsync( Task<CommerceOrderItem> CreateOrderAsync(
@@ -89,6 +96,14 @@ public interface ICommerceService
Task<CurrentEntitlementItem> GetCurrentEntitlementAsync( Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
CommerceActor actor, CommerceActor actor,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
Guid tenantId,
string provider,
IReadOnlyDictionary<string, string> headers,
string rawBody,
JsonElement body,
CancellationToken cancellationToken = default);
} }
public sealed class CommerceException(string message, string code) : Exception(message) public sealed class CommerceException(string message, string code) : Exception(message)

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Aop.Api; using Aop.Api;
using Aop.Api.Util;
using Tiku.Application.Commerce; using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce; namespace Tiku.Infrastructure.Commerce;
@@ -25,9 +26,34 @@ internal sealed class AlipayProvider : IPaymentProvider
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_ = BuildClient(account); _ = BuildClient(account);
throw new PaymentProviderException( var values = ToDictionary(request.Body);
"Alipay SDK is configured; notification parsing is enabled in the notification batch.", var signatureValid = AlipaySignature.RSACheckV1(
"alipay_notification_not_enabled"); 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) private static DefaultAopClient BuildClient(PaymentProviderAccount account)
@@ -63,4 +89,53 @@ internal sealed class AlipayProvider : IPaymentProvider
"Alipay provider configuration is incomplete.", "Alipay provider configuration is incomplete.",
"alipay_config_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)) 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 else
{ {
@@ -238,15 +248,146 @@ public sealed class CommerceService(
: null); : 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( private async Task MarkPaidAsync(
CommerceActor actor, CommerceActor actor,
Order order, Order order,
Payment payment, Payment payment,
string? providerTradeNo, string? providerTradeNo,
JsonElement rawPayload, JsonElement rawPayload,
string eventType,
string? eventId,
bool signatureValid,
DateTimeOffset? paidAtOverride,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var paidAt = DateTimeOffset.UtcNow; var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow;
payment.Status = PaymentStatus.Paid; payment.Status = PaymentStatus.Paid;
payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo; payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo;
payment.PaidAt = paidAt; payment.PaidAt = paidAt;
@@ -293,9 +434,9 @@ public sealed class CommerceService(
TenantId = actor.TenantId, TenantId = actor.TenantId,
PaymentId = payment.Id, PaymentId = payment.Id,
Provider = payment.Provider, Provider = payment.Provider,
EventType = "payment_paid", EventType = eventType,
EventId = payment.ProviderTradeNo, EventId = eventId,
SignatureValid = true, SignatureValid = signatureValid,
Payload = rawPayload, Payload = rawPayload,
ProcessedAt = paidAt ProcessedAt = paidAt
}); });

View File

@@ -1,4 +1,6 @@
using System.Text.Json; 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;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings; using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
using Tiku.Application.Commerce; using Tiku.Application.Commerce;
@@ -25,10 +27,35 @@ internal sealed class WechatPayProvider : IPaymentProvider
PaymentNotificationRequest request, PaymentNotificationRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_ = BuildClient(account); var client = BuildClient(account);
throw new PaymentProviderException( var signatureValid = TryGetHeader(request.Headers, "Wechatpay-Timestamp", out var timestamp) &&
"WeChat Pay SDK is configured; notification parsing is enabled in the notification batch.", TryGetHeader(request.Headers, "Wechatpay-Nonce", out var nonce) &&
"wechat_pay_notification_not_enabled"); 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) private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
@@ -65,4 +92,101 @@ internal sealed class WechatPayProvider : IPaymentProvider
"WeChat Pay provider configuration is incomplete.", "WeChat Pay provider configuration is incomplete.",
"wechat_pay_config_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;
}
} }

View File

@@ -164,6 +164,81 @@ public sealed class CommerceEndpointTests
Assert.Equal(firstPayment!.Id, secondPayment!.Id); 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<CurrentEntitlementItem>();
using var scope = factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
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<CommerceOrderItem>();
Assert.Equal(HttpStatusCode.BadRequest, notifyResponse.StatusCode);
Assert.Equal("Pending", currentOrder!.Status);
}
private static async Task<CommerceOrderItem> CreateOrderAsync(HttpClient client, Guid planId) private static async Task<CommerceOrderItem> CreateOrderAsync(HttpClient client, Guid planId)
{ {
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
@@ -312,7 +387,49 @@ public sealed class CommerceEndpointTests
PaymentNotificationRequest request, PaymentNotificationRequest request,
CancellationToken cancellationToken = default) 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;
} }
} }
} }