feat: add payment sdk and tenant secret foundation

This commit is contained in:
xiong
2026-07-26 19:28:04 +08:00
parent 28befc57be
commit 5503d7a644
17 changed files with 16330 additions and 1 deletions

View File

@@ -0,0 +1,66 @@
using System.Text.Json;
using Aop.Api;
using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed class AlipayProvider : IPaymentProvider
{
public string Provider => PaymentProviders.Alipay;
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
PaymentProviderAccount account,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default)
{
_ = BuildClient(account);
throw new PaymentProviderException(
"Alipay SDK is configured; checkout call is enabled in the commerce checkout batch.",
"alipay_checkout_not_enabled");
}
public Task<PaymentNotificationResult> ParsePaymentNotificationAsync(
PaymentProviderAccount account,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
_ = BuildClient(account);
throw new PaymentProviderException(
"Alipay SDK is configured; notification parsing is enabled in the notification batch.",
"alipay_notification_not_enabled");
}
private static DefaultAopClient BuildClient(PaymentProviderAccount account)
{
return new DefaultAopClient(
Required(account.ConfigPublic, "gatewayUrl", "gateway"),
Required(account.ConfigPublic, "appId"),
Required(account.SecretPayload, "privateKey", "appPrivateKey"),
"json",
"1.0",
"RSA2",
Required(account.ConfigPublic, "alipayPublicKey"),
"UTF-8",
false);
}
private static string Required(JsonElement element, params string[] keys)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var key in keys)
{
if (element.TryGetProperty(key, out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(property.GetString()))
{
return property.GetString()!;
}
}
}
throw new PaymentProviderException(
"Alipay provider configuration is incomplete.",
"alipay_config_incomplete");
}
}

View File

@@ -0,0 +1,41 @@
using System.Text.Json;
using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed class ManualPaymentProvider : IPaymentProvider
{
public string Provider => PaymentProviders.Manual;
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
PaymentProviderAccount account,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default)
{
var payload = JsonSerializer.SerializeToElement(new
{
request.OrderNo,
request.AmountCents,
request.Subject,
Message = "manual_payment_requires_tenant_admin_confirmation"
});
return Task.FromResult(new CreatePaymentProviderResult(
Provider,
request.Method,
"pending",
null,
payload,
payload));
}
public Task<PaymentNotificationResult> ParsePaymentNotificationAsync(
PaymentProviderAccount account,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
throw new PaymentProviderException(
"Manual payment does not accept provider notifications.",
"manual_payment_notification_not_supported");
}
}

View File

@@ -0,0 +1,80 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Domain.Commerce;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
internal sealed class PaymentProviderConfigService(
TikuDbContext dbContext,
ITenantSecretService tenantSecretService) : IPaymentProviderConfigService
{
public async Task<PaymentProviderAccount> GetActiveAccountAsync(
Guid tenantId,
string provider,
CancellationToken cancellationToken = default)
{
var normalizedProvider = NormalizeProvider(provider);
var account = await dbContext.TenantPaymentAccounts
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.Provider == normalizedProvider &&
item.Status == TenantPaymentAccountStatus.Active)
.SingleOrDefaultAsync(cancellationToken);
if (account is null)
{
throw new PaymentProviderException(
"Payment provider account is not configured.",
"payment_provider_not_configured");
}
var secretPayload = normalizedProvider == PaymentProviders.Manual
? JsonDocument.Parse("{}").RootElement.Clone()
: await tenantSecretService.GetActiveSecretPayloadAsync(
tenantId,
GetString(account.ConfigPublic, "secretRef", "secret_ref") ?? string.Empty,
cancellationToken);
return new PaymentProviderAccount(
account.TenantId,
normalizedProvider,
account.Mode.ToString(),
account.ConfigPublic,
secretPayload);
}
private static string NormalizeProvider(string provider)
{
var normalized = provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
return normalized switch
{
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
"ali_pay" => PaymentProviders.Alipay,
"" => throw new PaymentProviderException("Payment provider is required.", "payment_provider_required"),
_ => normalized
};
}
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;
}
}

View File

@@ -0,0 +1,45 @@
using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed class PaymentProviderGateway(
IEnumerable<IPaymentProvider> providers,
IPaymentProviderConfigService configService) : IPaymentProviderGateway
{
public async Task<CreatePaymentProviderResult> CreatePaymentAsync(
string provider,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default)
{
var resolvedProvider = ResolveProvider(provider);
var account = await configService.GetActiveAccountAsync(
request.TenantId,
resolvedProvider.Provider,
cancellationToken);
return await resolvedProvider.CreatePaymentAsync(account, request, cancellationToken);
}
public async Task<PaymentNotificationResult> ParsePaymentNotificationAsync(
string provider,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
var resolvedProvider = ResolveProvider(provider);
var account = await configService.GetActiveAccountAsync(
request.TenantId,
resolvedProvider.Provider,
cancellationToken);
return await resolvedProvider.ParsePaymentNotificationAsync(account, request, cancellationToken);
}
private IPaymentProvider ResolveProvider(string provider)
{
var normalized = provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
return providers.FirstOrDefault(item => item.Provider == normalized)
?? throw new PaymentProviderException(
"Payment provider is not supported.",
"payment_provider_not_supported");
}
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecretService
{
public async Task<JsonElement> GetActiveSecretPayloadAsync(
Guid tenantId,
string secretRef,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(secretRef))
{
throw new PaymentProviderException(
"Payment provider secret is not configured.",
"payment_secret_not_configured");
}
var now = DateTimeOffset.UtcNow;
var secret = await dbContext.TenantSecrets
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.SecretRef == secretRef &&
item.Status == TenantSecretStatus.Active &&
(item.ExpiresAt == null || item.ExpiresAt > now))
.Select(item => item.SecretPayload)
.SingleOrDefaultAsync(cancellationToken);
if (secret.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
throw new PaymentProviderException(
"Payment provider secret is not configured.",
"payment_secret_not_configured");
}
return secret;
}
}

View File

@@ -0,0 +1,68 @@
using System.Text.Json;
using SKIT.FlurlHttpClient.Wechat.TenpayV3;
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
using Tiku.Application.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed class WechatPayProvider : IPaymentProvider
{
public string Provider => PaymentProviders.WechatPay;
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
PaymentProviderAccount account,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default)
{
_ = BuildClient(account);
throw new PaymentProviderException(
"WeChat Pay SDK is configured; checkout call is enabled in the commerce checkout batch.",
"wechat_pay_checkout_not_enabled");
}
public Task<PaymentNotificationResult> ParsePaymentNotificationAsync(
PaymentProviderAccount account,
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");
}
private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
{
var options = new WechatTenpayClientOptions
{
MerchantId = Required(account.ConfigPublic, "merchantId", "mchId", "mchid"),
MerchantV3Secret = Required(account.SecretPayload, "apiV3Key", "merchantV3Secret"),
MerchantCertificateSerialNumber = Required(account.ConfigPublic, "certificateSerialNumber", "serialNo"),
MerchantCertificatePrivateKey = Required(account.SecretPayload, "privateKey", "merchantCertificatePrivateKey"),
PlatformAuthScheme = PlatformAuthScheme.PublicKey,
PlatformPublicKeyManager = new InMemoryPublicKeyManager()
};
return WechatTenpayClientBuilder.Create(options).Build();
}
private static string Required(JsonElement element, params string[] keys)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var key in keys)
{
if (element.TryGetProperty(key, out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(property.GetString()))
{
return property.GetString()!;
}
}
}
throw new PaymentProviderException(
"WeChat Pay provider configuration is incomplete.",
"wechat_pay_config_incomplete");
}
}