feat: modularize external providers

This commit is contained in:
2026-07-27 17:47:21 +08:00
parent db4c7b4496
commit 70b99e6063
47 changed files with 1219 additions and 667 deletions

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Tenancy;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy;
@@ -12,55 +13,44 @@ namespace Tiku.Infrastructure.Commerce;
internal sealed class CommerceAdminService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector) : ICommerceAdminService
ITenantSecretProtector tenantSecretProtector,
ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService
{
public async Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var accounts = dbContext.TenantPaymentAccounts.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Provider))
{
var provider = NormalizeProvider(query.Provider);
accounts = accounts.Where(item => item.Provider == provider);
}
var accounts = await providerConfigService.GetProvidersAsync(
actor.TenantId,
TenantExternalProviderCapability.Payment,
string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider),
query.Limit,
cancellationToken);
return await accounts
.OrderBy(item => item.Provider)
.Take(Math.Clamp(query.Limit ?? 50, 1, 100))
.Select(item => ToPaymentAccountItem(item))
.ToArrayAsync(cancellationToken);
return accounts.Select(ToPaymentAccountItem).ToArray();
}
public async Task<TenantPaymentAccountItem> UpsertPaymentAccountAsync(
public async Task<TenantPaymentProviderItem> UpsertPaymentAccountAsync(
CommerceAdminActor actor,
UpsertPaymentAccountCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
AssertNoSecrets(command.ConfigPublic, "config_public");
var provider = NormalizeProvider(command.Provider);
var account = await dbContext.TenantPaymentAccounts
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Provider == provider, cancellationToken);
if (account is null)
{
account = new TenantPaymentAccount
{
TenantId = actor.TenantId,
Provider = provider
};
dbContext.TenantPaymentAccounts.Add(account);
}
var account = await providerConfigService.UpsertProviderAsync(
actor.TenantId,
new UpsertTenantExternalProviderCommand(
TenantExternalProviderCapability.Payment,
command.Provider,
command.Status,
command.DisplayName,
command.SecretRef,
command.Priority,
WithPaymentMode(command.ConfigPublic, command.Mode),
JsonObjectOrDefault(default)),
cancellationToken);
account.Mode = command.Mode;
account.DisplayName = command.DisplayName?.Trim();
account.Status = command.Status;
account.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic);
await dbContext.SaveChangesAsync(cancellationToken);
return ToPaymentAccountItem(account);
}
@@ -588,8 +578,18 @@ internal sealed class CommerceAdminService(
}
}
private static TenantPaymentAccountItem ToPaymentAccountItem(TenantPaymentAccount item) =>
new(item.Id, item.Provider, item.Mode.ToString(), item.DisplayName, item.Status.ToString(), item.ConfigPublic, item.CreatedAt, item.UpdatedAt);
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
new(
item.Id,
item.Provider,
GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect",
item.DisplayName,
item.Status,
item.SecretRef,
item.Priority,
item.ConfigPublic,
item.CreatedAt,
item.UpdatedAt);
private static TenantSecretItem ToSecretItem(TenantSecret item) =>
new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt);
@@ -651,6 +651,40 @@ internal sealed class CommerceAdminService(
};
}
private static JsonElement WithPaymentMode(JsonElement element, string? mode)
{
var values = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
values[property.Name] = property.Value.Clone();
}
}
values["mode"] = JsonSerializer.SerializeToElement(
string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim());
return JsonSerializer.SerializeToElement(values);
}
private static string? GetJsonString(JsonElement element, params string[] keys)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var key in keys)
{
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
{
return value.GetString();
}
}
return null;
}
private static JsonElement JsonObjectOrDefault(JsonElement element) =>
element.ValueKind == JsonValueKind.Object
? element.Clone()

View File

@@ -1,49 +1,41 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Domain.Commerce;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
internal sealed class PaymentProviderConfigService(
TikuDbContext dbContext,
ITenantSecretService tenantSecretService) : IPaymentProviderConfigService
ITenantExternalProviderConfigService providerConfigService) : 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)
TenantExternalProviderAccount account;
try
{
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(
account = await providerConfigService.GetActiveProviderAsync(
tenantId,
GetString(account.ConfigPublic, "secretRef", "secret_ref") ?? string.Empty,
TenantExternalProviderCapability.Payment,
NormalizeProvider(provider),
cancellationToken);
}
catch (TenantExternalProviderException exception)
{
throw new PaymentProviderException(exception.Message, "payment_provider_not_configured");
}
return new PaymentProviderAccount(
account.TenantId,
normalizedProvider,
account.Mode.ToString(),
account.Provider,
GetString(account.ConfigPublic, "mode") ?? "TenantCollect",
account.ConfigPublic,
secretPayload);
account.SecretPayload);
}
private static string NormalizeProvider(string provider)

View File

@@ -34,7 +34,7 @@ public sealed class TenantSecretEncryptionOptions
string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal);
}
internal interface ITenantSecretProtector
public interface ITenantSecretProtector
{
ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload);
@@ -47,13 +47,13 @@ internal interface ITenantSecretProtector
byte[] tag);
}
internal sealed record ProtectedTenantSecret(
public sealed record ProtectedTenantSecret(
string KeyId,
byte[] Ciphertext,
byte[] Nonce,
byte[] Tag);
internal sealed class TenantSecretProtector(
public sealed class TenantSecretProtector(
Microsoft.Extensions.Options.IOptions<TenantSecretEncryptionOptions> options) : ITenantSecretProtector
{
private readonly TenantSecretEncryptionOptions options = options.Value;