using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; internal sealed class TenantExternalProviderConfigService( TikuDbContext dbContext, ITenantSecretService tenantSecretService) : ITenantExternalProviderConfigService { public async Task GetActiveProviderAsync( Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) { var normalizedProvider = NormalizeProvider(provider); var query = dbContext.TenantExternalProviders .AsNoTracking() .Where(item => item.TenantId == tenantId && item.Capability == capability && item.Status == TenantExternalProviderStatus.Active); if (!string.IsNullOrWhiteSpace(normalizedProvider)) { query = query.Where(item => item.Provider == normalizedProvider); } var item = await query .OrderBy(item => item.Priority) .ThenBy(item => item.Provider) .FirstOrDefaultAsync(cancellationToken); if (item is null) { throw new TenantExternalProviderException( "Tenant external provider is not configured.", "tenant_external_provider_not_configured"); } var secretPayload = string.IsNullOrWhiteSpace(item.SecretRef) ? JsonDocument.Parse("{}").RootElement.Clone() : await tenantSecretService.GetActiveSecretPayloadAsync( tenantId, item.SecretRef, cancellationToken); return new TenantExternalProviderAccount( item.TenantId, item.Capability, item.Provider, item.Status, item.DisplayName, item.ConfigPublic, secretPayload, item.SecretRef, item.Priority, item.Metadata); } public async Task> GetProvidersAsync( Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) { var normalizedProvider = NormalizeProvider(provider); var query = dbContext.TenantExternalProviders .AsNoTracking() .Where(item => item.TenantId == tenantId); if (capability.HasValue) { query = query.Where(item => item.Capability == capability.Value); } if (!string.IsNullOrWhiteSpace(normalizedProvider)) { query = query.Where(item => item.Provider == normalizedProvider); } return await query .OrderBy(item => item.Capability) .ThenBy(item => item.Priority) .ThenBy(item => item.Provider) .Take(Math.Clamp(limit ?? 100, 1, 200)) .Select(item => ToItem(item)) .ToArrayAsync(cancellationToken); } public async Task UpsertProviderAsync( Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) { var provider = NormalizeRequiredProvider(command.Provider); AssertPublicConfig(command.ConfigPublic, "config_public"); AssertPublicConfig(command.Metadata, "metadata"); var item = await dbContext.TenantExternalProviders .SingleOrDefaultAsync( providerConfig => providerConfig.TenantId == tenantId && providerConfig.Capability == command.Capability && providerConfig.Provider == provider, cancellationToken); if (item is null) { item = new TenantExternalProvider { TenantId = tenantId, Capability = command.Capability, Provider = provider }; dbContext.TenantExternalProviders.Add(item); } item.Status = command.Status; item.DisplayName = command.DisplayName?.Trim(); item.SecretRef = string.IsNullOrWhiteSpace(command.SecretRef) ? null : command.SecretRef.Trim(); item.Priority = Math.Clamp(command.Priority ?? item.Priority, 0, 10_000); item.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic); item.Metadata = JsonObjectOrDefault(command.Metadata); await dbContext.SaveChangesAsync(cancellationToken); return ToItem(item); } public static string NormalizeRequiredProvider(string provider) { var normalized = NormalizeProvider(provider); if (string.IsNullOrWhiteSpace(normalized)) { throw new TenantExternalProviderException("Provider is required.", "provider_required"); } return normalized; } public static string? NormalizeProvider(string? provider) { var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); return normalized switch { "wechat" or "wechatpay" or "wxpay" or "wx_pay" => "wechat_pay", "ali_pay" => "alipay", "" => null, _ => normalized }; } private static TenantExternalProviderItem ToItem(TenantExternalProvider item) => new( item.Id, item.Capability, item.Provider, item.Status, item.DisplayName, item.SecretRef, item.Priority, item.ConfigPublic, item.Metadata, item.CreatedAt, item.UpdatedAt); private static JsonElement JsonObjectOrDefault(JsonElement element) => element.ValueKind == JsonValueKind.Object ? element.Clone() : JsonSerializer.SerializeToElement(new { }); private static void AssertPublicConfig(JsonElement element, string path) { if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) { return; } if (element.ValueKind != JsonValueKind.Object) { throw new TenantExternalProviderException( $"{path} must be a JSON object.", "provider_config_must_be_object"); } foreach (var property in element.EnumerateObject()) { var key = property.Name.Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); if (key.Contains("secret", StringComparison.Ordinal) || key.Contains("token", StringComparison.Ordinal) || key.Contains("privatekey", StringComparison.Ordinal) || key is "key" or "apikey" or "accesskey" or "accesskeyid" or "accesskeysecret" or "appsecret" or "apiv3key") { throw new TenantExternalProviderException( $"{path} cannot contain secrets.", "public_config_contains_secret"); } if (property.Value.ValueKind == JsonValueKind.Object) { AssertPublicConfig(property.Value, $"{path}.{property.Name}"); } } } }