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

@@ -318,7 +318,6 @@ public sealed class AssetAccessService(
return provider switch
{
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,

View File

@@ -4,15 +4,18 @@ using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets;
public sealed class AssetManagementService(
TikuDbContext dbContext,
IObjectStorageService objectStorageService) : IAssetManagementService
IObjectStorageService objectStorageService,
ITenantExternalProviderConfigService providerConfigService) : IAssetManagementService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 500;
@@ -140,10 +143,9 @@ public sealed class AssetManagementService(
ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName);
ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType);
var provider = objectStorageService.NormalizeProvider(command.Provider, objectStorageService.ConfiguredDefaultProvider());
var bucket = string.IsNullOrWhiteSpace(command.Bucket)
? objectStorageService.ConfiguredDefaultBucket()
: command.Bucket.Trim();
var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken);
var provider = storageConfig.Provider;
var bucket = storageConfig.Bucket;
var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim());
var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes);
var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken);
@@ -782,7 +784,6 @@ public sealed class AssetManagementService(
return provider switch
{
ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl,
ObjectStorageProviders.SupabaseStorage => AssetStorageProvider.SupabaseStorage,
ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos,
ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo,
@@ -796,7 +797,6 @@ public sealed class AssetManagementService(
return provider switch
{
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
@@ -804,4 +804,50 @@ public sealed class AssetManagementService(
_ => ObjectStorageProviders.ExternalUrl
};
}
private async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync(
Guid tenantId,
CancellationToken cancellationToken)
{
try
{
var account = await providerConfigService.GetActiveProviderAsync(
tenantId,
TenantExternalProviderCapability.ObjectStorage,
cancellationToken: cancellationToken);
var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket");
if (string.IsNullOrWhiteSpace(bucket))
{
throw new ObjectStorageException(
"Object storage provider bucket is not configured.",
"STORAGE_BUCKET_NOT_CONFIGURED");
}
return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim());
}
catch (TenantExternalProviderException)
{
return (
objectStorageService.ConfiguredDefaultProvider(),
objectStorageService.ConfiguredDefaultBucket());
}
}
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;
}
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Auth;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -12,12 +13,13 @@ public sealed class AuthService(
IPasswordHasher passwordHasher,
ISmsVerificationService smsVerificationService,
ISessionService sessionService,
IWechatOAuthClient wechatOAuthClient) : IAuthService
IWechatOAuthClient wechatOAuthClient,
ITenantExternalProviderConfigService providerConfigService) : IAuthService
{
private const string PasswordProvider = "password";
private const string SmsProvider = "sms";
private const string WechatWebProvider = "wechat_web";
private const string WechatMiniAppProvider = "wechat-miniapp";
private const string WechatMiniAppProvider = "wechat_miniapp";
private static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"];
private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
@@ -356,24 +358,30 @@ public sealed class AuthService(
IReadOnlyList<string> aliases,
CancellationToken cancellationToken)
{
var rows = await dbContext.TenantAuthProviders
.Where(entity =>
entity.TenantId == tenantId &&
aliases.Contains(entity.Provider) &&
(entity.Status == TenantAuthProviderStatus.Active ||
entity.Status == TenantAuthProviderStatus.Testing))
.ToListAsync(cancellationToken);
var row = aliases
.Select(alias => rows.FirstOrDefault(entity => entity.Provider == alias))
.FirstOrDefault(entity => entity is not null);
TenantExternalProviderAccount? account = null;
foreach (var alias in aliases)
{
try
{
account = await providerConfigService.GetActiveProviderAsync(
tenantId,
TenantExternalProviderCapability.Identity,
alias,
cancellationToken);
break;
}
catch (TenantExternalProviderException)
{
}
}
if (row is null)
if (account is null)
{
throw new AuthProviderNotConfiguredException(provider);
}
var appId = GetJsonString(row.ConfigPublic, "appId", "clientId");
var appSecret = GetJsonString(row.ConfigPublic, "appSecret", "clientSecret", "secret");
var appId = GetJsonString(account.ConfigPublic, "appId", "clientId");
var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret");
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
{
throw new AuthProviderNotConfiguredException(provider);

View File

@@ -0,0 +1,14 @@
using Tiku.Application.Auth;
namespace Tiku.Infrastructure.Auth;
internal sealed class NoopSmsProvider : ISmsProvider
{
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new SmsProviderSendResult("noop", "accepted"));
}
}

View File

@@ -0,0 +1,54 @@
using Tiku.Application.Auth;
namespace Tiku.Infrastructure.Auth;
internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IIdentityProvider
{
public async Task<IdentityProviderResult> AuthenticateAsync(
IdentityProviderRequest request,
CancellationToken cancellationToken = default)
{
var provider = request.Provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
var authenticated = provider switch
{
"password" => await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
request.TenantId,
request.Identifier,
request.Secret,
request.IpAddress,
request.UserAgent),
cancellationToken),
"sms" => await authService.LoginWithSmsAsync(
new SmsLoginRequest(
request.TenantId,
request.Identifier,
request.Secret,
request.IpAddress,
request.UserAgent),
cancellationToken),
"wechat_web" => await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
request.TenantId,
request.Secret,
request.IpAddress,
request.UserAgent),
cancellationToken),
"wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
request.TenantId,
request.Secret,
request.IpAddress,
request.UserAgent),
cancellationToken),
_ => throw new AuthProviderNotConfiguredException(provider)
};
return new IdentityProviderResult(
provider,
authenticated.UserId.ToString("N"),
authenticated.Phone,
authenticated.Email,
authenticated.Name);
}
}

View File

@@ -5,7 +5,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class SmsVerificationService(TikuDbContext dbContext) : ISmsVerificationService
public sealed class SmsVerificationService(
TikuDbContext dbContext,
ISmsProvider smsProvider) : ISmsVerificationService
{
private const int MaxPhoneRequestsPerHour = 5;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
@@ -42,13 +44,22 @@ public sealed class SmsVerificationService(TikuDbContext dbContext) : ISmsVerifi
rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture);
var sendResult = await smsProvider.SendAsync(
new SmsProviderSendRequest(
request.TenantId,
phone,
request.Purpose,
code,
request.IpAddress,
request.UserAgent),
cancellationToken);
var verification = new SmsVerificationCode
{
TenantId = request.TenantId,
Phone = phone,
Purpose = request.Purpose,
CodeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code),
Provider = "mock",
Provider = sendResult.Provider,
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
IpAddress = request.IpAddress,

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;

View File

@@ -8,6 +8,7 @@ using Tiku.Application.Commerce;
using Tiku.Application.Content;
using Tiku.Application.Growth;
using Tiku.Application.Learning;
using Tiku.Application.Notifications;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Profile;
using Tiku.Application.Points;
@@ -24,6 +25,7 @@ using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Growth;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Notifications;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.Points;
@@ -56,6 +58,7 @@ public static class DependencyInjection
services.AddScoped<ITenantDirectory, TenantDirectory>();
services.AddMemoryCache();
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
services.AddSingleton<ITenantRuntimeCacheInvalidator, TenantRuntimeCacheInvalidator>();
services.AddHttpClient<IDomainOwnershipVerifier, DnsDomainOwnershipVerifier>();
services.AddHttpClient<IDomainGatewayProvisioner, HttpDomainGatewayProvisioner>();
@@ -65,9 +68,11 @@ public static class DependencyInjection
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>();
services.AddScoped<ISmsProvider, NoopSmsProvider>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IIdentityProvider, SelfHostedIdentityProvider>();
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
services.AddScoped<ITaxonomyService, TaxonomyService>();
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
@@ -91,6 +96,7 @@ public static class DependencyInjection
services.AddScoped<IReferralService, ReferralService>();
services.AddScoped<ICrmService, CrmService>();
services.AddScoped<ICommissionService, CommissionService>();
services.AddScoped<INotificationProvider, InAppNotificationProvider>();
services.AddSingleton<ITenantSecretProtector, TenantSecretProtector>();
services.AddScoped<ITenantSecretService, TenantSecretService>();
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();

View File

@@ -1,18 +1,17 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Senparc.Weixin.WxOpen.AdvancedAPIs.WxApp;
using Senparc.Weixin.WxOpen.Containers;
using Tiku.Application.Growth;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Growth;
public sealed class ReferralQrcodeGenerator(
TikuDbContext dbContext,
IObjectStorageService objectStorageService) : IReferralQrcodeGenerator
IObjectStorageService objectStorageService,
ITenantExternalProviderConfigService providerConfigService) : IReferralQrcodeGenerator
{
private static readonly string[] WechatMiniappProviderAliases =
[
@@ -84,17 +83,23 @@ public sealed class ReferralQrcodeGenerator(
Guid tenantId,
CancellationToken cancellationToken)
{
var providers = await dbContext.TenantAuthProviders
.AsNoTracking()
.Where(entity =>
entity.TenantId == tenantId &&
WechatMiniappProviderAliases.Contains(entity.Provider) &&
(entity.Status == TenantAuthProviderStatus.Active ||
entity.Status == TenantAuthProviderStatus.Testing))
.ToListAsync(cancellationToken);
var provider = WechatMiniappProviderAliases
.Select(alias => providers.FirstOrDefault(entity => entity.Provider == alias))
.FirstOrDefault(entity => entity is not null);
TenantExternalProviderAccount? provider = null;
foreach (var alias in WechatMiniappProviderAliases)
{
try
{
provider = await providerConfigService.GetActiveProviderAsync(
tenantId,
TenantExternalProviderCapability.Identity,
alias,
cancellationToken);
break;
}
catch (TenantExternalProviderException)
{
}
}
if (provider is null)
{
throw new ReferralException(
@@ -103,7 +108,7 @@ public sealed class ReferralQrcodeGenerator(
}
var appId = GetJsonString(provider.ConfigPublic, "appId", "clientId");
var appSecret = GetJsonString(provider.ConfigPublic, "appSecret", "clientSecret", "secret");
var appSecret = GetJsonString(provider.SecretPayload, "appSecret", "clientSecret", "secret");
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
{
throw new ReferralException(

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Notifications;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Notifications;
internal sealed class InAppNotificationProvider(TikuDbContext dbContext) : INotificationProvider
{
public async Task<UserNotification> UpsertInAppAsync(
InAppNotificationRequest request,
CancellationToken cancellationToken = default)
{
var item = !string.IsNullOrWhiteSpace(request.DedupeKey)
? await dbContext.UserNotifications.FirstOrDefaultAsync(notification =>
notification.TenantId == request.TenantId &&
notification.UserId == request.UserId &&
notification.NotificationType == request.NotificationType &&
notification.DedupeKey == request.DedupeKey,
cancellationToken)
: null;
var isNew = item is null;
item ??= new UserNotification
{
TenantId = request.TenantId,
UserId = request.UserId,
CreatedBy = request.CreatedBy
};
item.NotificationType = request.NotificationType.Trim();
item.Severity = request.Severity;
item.Title = request.Title.Trim();
item.Message = request.Message.Trim();
item.ActionLabel = Normalize(request.ActionLabel);
item.ActionPath = Normalize(request.ActionPath);
item.SourceType = Normalize(request.SourceType);
item.SourceId = request.SourceId;
item.DedupeKey = Normalize(request.DedupeKey);
item.Metadata = request.Metadata.ValueKind == System.Text.Json.JsonValueKind.Object
? request.Metadata.Clone()
: JsonDefaults.Object();
if (isNew)
{
dbContext.UserNotifications.Add(item);
}
return item;
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -299,21 +299,6 @@ internal sealed class CouponRedemptionConfiguration : IEntityTypeConfiguration<C
}
}
internal sealed class TenantPaymentAccountConfiguration : IEntityTypeConfiguration<TenantPaymentAccount>
{
public void Configure(EntityTypeBuilder<TenantPaymentAccount> builder)
{
builder.ConfigureTenantEntity("tenant_payment_accounts");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
builder.Property(entity => entity.DisplayName).HasMaxLength(200);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.ConfigPublic).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Provider }).IsUnique();
}
}
internal sealed class TenantSubscriptionConfiguration : IEntityTypeConfiguration<TenantSubscription>
{
public void Configure(EntityTypeBuilder<TenantSubscription> builder)

View File

@@ -6,18 +6,21 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class TenantAuthProviderConfiguration : IEntityTypeConfiguration<TenantAuthProvider>
internal sealed class TenantExternalProviderConfiguration : IEntityTypeConfiguration<TenantExternalProvider>
{
public void Configure(EntityTypeBuilder<TenantAuthProvider> builder)
public void Configure(EntityTypeBuilder<TenantExternalProvider> builder)
{
builder.ConfigureTenantEntity("tenant_auth_providers");
builder.ConfigureTenantEntity("tenant_external_providers");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Capability).HasSnakeCaseEnum();
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.DisplayName).HasMaxLength(200);
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
builder.Property(entity => entity.ConfigPublic).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Provider }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.Status });
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Capability, entity.Provider }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Capability, entity.Status, entity.Priority });
}
}

View File

@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(TikuDbContext))]
[Migration("20260727084726_InitialSchema")]
[Migration("20260727093301_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
@@ -3400,72 +3400,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("mode");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_payment_accounts");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_payment_accounts_tenant_id_id");
b.HasIndex("TenantId", "Provider")
.IsUnique()
.HasDatabaseName("ix_tenant_payment_accounts_tenant_id_provider");
b.ToTable("tenant_payment_accounts", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{
b.Property<Guid>("Id")
@@ -12476,69 +12410,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_auth_providers");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_auth_providers_tenant_id_id");
b.HasIndex("TenantId", "Provider")
.IsUnique()
.HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider");
b.HasIndex("TenantId", "Provider", "Status")
.HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider_status");
b.ToTable("tenant_auth_providers", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{
b.Property<Guid>("TenantId")
@@ -12900,6 +12771,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_domains", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("capability");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("SecretRef")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("secret_ref");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_external_providers");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_external_providers_tenant_id_id");
b.HasIndex("TenantId", "Capability", "Provider")
.IsUnique()
.HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_provider");
b.HasIndex("TenantId", "Capability", "Status", "Priority")
.HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_status_prior~");
b.ToTable("tenant_external_providers", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{
b.Property<Guid>("Id")
@@ -14363,16 +14318,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_svip_plans_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_payment_accounts_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16541,16 +16486,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenants_users_owner_user_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_auth_providers_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16625,6 +16560,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenant_domains_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_external_providers_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -1087,31 +1087,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_auth_providers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_auth_providers", x => x.id);
table.UniqueConstraint("ak_tenant_auth_providers_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_auth_providers_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_billing_profiles",
columns: table => new
@@ -1236,6 +1211,35 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_external_providers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
capability = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
priority = table.Column<int>(type: "integer", nullable: false),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_external_providers", x => x.id);
table.UniqueConstraint("ak_tenant_external_providers_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_external_providers_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_frontend_configs",
columns: table => new
@@ -1319,32 +1323,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "tenant_payment_accounts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
config_public = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_payment_accounts", x => x.id);
table.UniqueConstraint("ak_tenant_payment_accounts_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_payment_accounts_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_role_templates",
columns: table => new
@@ -7995,17 +7973,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
columns: new[] { "tenant_id", "code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_auth_providers_tenant_id_provider",
table: "tenant_auth_providers",
columns: new[] { "tenant_id", "provider" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_auth_providers_tenant_id_provider_status",
table: "tenant_auth_providers",
columns: new[] { "tenant_id", "provider", "status" });
migrationBuilder.CreateIndex(
name: "ix_tenant_class_members_tenant_id_class_id_status_member_type",
table: "tenant_class_members",
@@ -8117,6 +8084,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
unique: true,
filter: "is_primary");
migrationBuilder.CreateIndex(
name: "ix_tenant_external_providers_tenant_id_capability_provider",
table: "tenant_external_providers",
columns: new[] { "tenant_id", "capability", "provider" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_external_providers_tenant_id_capability_status_prior~",
table: "tenant_external_providers",
columns: new[] { "tenant_id", "capability", "status", "priority" });
migrationBuilder.CreateIndex(
name: "ix_tenant_frontend_configs_tenant_id",
table: "tenant_frontend_configs",
@@ -8214,12 +8192,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "tenant_memberships",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_tenant_payment_accounts_tenant_id_provider",
table: "tenant_payment_accounts",
columns: new[] { "tenant_id", "provider" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_question_bank_preferences_created_by",
table: "tenant_question_bank_preferences",
@@ -9142,9 +9114,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "subject_shares");
migrationBuilder.DropTable(
name: "tenant_auth_providers");
migrationBuilder.DropTable(
name: "tenant_billing_profiles");
@@ -9163,6 +9132,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "tenant_domains");
migrationBuilder.DropTable(
name: "tenant_external_providers");
migrationBuilder.DropTable(
name: "tenant_frontend_configs");
@@ -9175,9 +9147,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "tenant_memberships");
migrationBuilder.DropTable(
name: "tenant_payment_accounts");
migrationBuilder.DropTable(
name: "tenant_question_bank_preferences");

View File

@@ -3397,72 +3397,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("mode");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_payment_accounts");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_payment_accounts_tenant_id_id");
b.HasIndex("TenantId", "Provider")
.IsUnique()
.HasDatabaseName("ix_tenant_payment_accounts_tenant_id_provider");
b.ToTable("tenant_payment_accounts", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{
b.Property<Guid>("Id")
@@ -12473,69 +12407,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_auth_providers");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_auth_providers_tenant_id_id");
b.HasIndex("TenantId", "Provider")
.IsUnique()
.HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider");
b.HasIndex("TenantId", "Provider", "Status")
.HasDatabaseName("ix_tenant_auth_providers_tenant_id_provider_status");
b.ToTable("tenant_auth_providers", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{
b.Property<Guid>("TenantId")
@@ -12897,6 +12768,90 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_domains", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Capability")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("capability");
b.Property<JsonElement>("ConfigPublic")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config_public")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("SecretRef")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("secret_ref");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_tenant_external_providers");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_external_providers_tenant_id_id");
b.HasIndex("TenantId", "Capability", "Provider")
.IsUnique()
.HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_provider");
b.HasIndex("TenantId", "Capability", "Status", "Priority")
.HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_status_prior~");
b.ToTable("tenant_external_providers", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{
b.Property<Guid>("Id")
@@ -14360,16 +14315,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_svip_plans_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantPaymentAccount", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_payment_accounts_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16538,16 +16483,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenants_users_owner_user_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthProvider", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_auth_providers_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -16622,6 +16557,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_tenant_domains_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_external_providers_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -44,7 +44,7 @@ public sealed class TikuDbContext(
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
public DbSet<TenantAuthProvider> TenantAuthProviders => Set<TenantAuthProvider>();
public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
@@ -124,7 +124,6 @@ public sealed class TikuDbContext(
public DbSet<ActivationCode> ActivationCodes => Set<ActivationCode>();
public DbSet<Coupon> Coupons => Set<Coupon>();
public DbSet<CouponRedemption> CouponRedemptions => Set<CouponRedemption>();
public DbSet<TenantPaymentAccount> TenantPaymentAccounts => Set<TenantPaymentAccount>();
public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>();
public DbSet<TenantUsageRecord> TenantUsageRecords => Set<TenantUsageRecord>();
public DbSet<CommerceRefundRequest> CommerceRefundRequests => Set<CommerceRefundRequest>();

View File

@@ -17,7 +17,6 @@ public sealed partial class AliyunOssObjectStorageService(
private static readonly HashSet<string> SupportedProviders =
[
ObjectStorageProviders.ExternalUrl,
ObjectStorageProviders.SupabaseStorage,
ObjectStorageProviders.AliyunOss,
ObjectStorageProviders.TencentCos,
ObjectStorageProviders.QiniuKodo,

View File

@@ -0,0 +1,210 @@
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<TenantExternalProviderAccount> 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<IReadOnlyCollection<TenantExternalProviderItem>> 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<TenantExternalProviderItem> 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}");
}
}
}
}

View File

@@ -2,6 +2,8 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Notifications;
using Tiku.Application.Tenancy;
using Tiku.Application.TenantAdmin;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
@@ -13,7 +15,10 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.TenantAdmin;
public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantAdminDirectService
public sealed class TenantAdminDirectService(
TikuDbContext dbContext,
ITenantExternalProviderConfigService providerConfigService,
INotificationProvider notificationProvider) : ITenantAdminDirectService
{
private static readonly TenantAdminPermissionCatalogItem[] PermissionCatalog =
[
@@ -1099,42 +1104,39 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
return new ContentManagementResult<TenantDomainItem>(ToDomainItem(item));
}
public async Task<CatalogList<TenantAuthProviderItem>> GetAuthProvidersAsync(
public async Task<CatalogList<TenantIdentityProviderItem>> GetAuthProvidersAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
var items = await dbContext.TenantAuthProviders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.OrderBy(item => item.CreatedAt)
.Select(item => ToAuthProviderItem(item))
.ToArrayAsync(cancellationToken);
return new CatalogList<TenantAuthProviderItem>(items);
var items = await providerConfigService.GetProvidersAsync(
actor.TenantId,
TenantExternalProviderCapability.Identity,
cancellationToken: cancellationToken);
return new CatalogList<TenantIdentityProviderItem>(items.Select(ToAuthProviderItem).ToArray());
}
public async Task<ContentManagementResult<TenantAuthProviderItem>> UpsertAuthProviderAsync(
public async Task<ContentManagementResult<TenantIdentityProviderItem>> UpsertAuthProviderAsync(
TenantAdminActor actor,
UpsertTenantAuthProviderCommand command,
UpsertTenantIdentityProviderCommand command,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider);
AssertNoSecrets(command.ConfigPublic, "config_public");
var provider = command.Provider.Trim().ToLowerInvariant();
var item = await dbContext.TenantAuthProviders.FirstOrDefaultAsync(
authProvider => authProvider.TenantId == actor.TenantId && authProvider.Provider == provider,
var item = await providerConfigService.UpsertProviderAsync(
actor.TenantId,
new UpsertTenantExternalProviderCommand(
TenantExternalProviderCapability.Identity,
command.Provider,
ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"),
command.DisplayName,
command.SecretRef,
command.Priority,
command.ConfigPublic,
JsonObjectOrDefault(default)),
cancellationToken);
var isNew = item is null;
item ??= new TenantAuthProvider { TenantId = actor.TenantId, Provider = provider };
item.Status = ParseEnum(command.Status, TenantAuthProviderStatus.Disabled, "invalid_auth_provider_status");
item.DisplayName = Normalize(command.DisplayName);
item.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic);
if (isNew)
{
dbContext.TenantAuthProviders.Add(item);
}
await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_auth_providers", item.Id, cancellationToken);
await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<TenantAuthProviderItem>(ToAuthProviderItem(item));
return new ContentManagementResult<TenantIdentityProviderItem>(ToAuthProviderItem(item));
}
public async Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(
@@ -1273,33 +1275,22 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
}
var dedupeKey = $"badge:{grant.Id:N}";
var notification = await dbContext.UserNotifications.FirstOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == command.UserId &&
item.NotificationType == "badge_granted" &&
item.DedupeKey == dedupeKey,
await notificationProvider.UpsertInAppAsync(
new InAppNotificationRequest(
actor.TenantId,
command.UserId,
"badge_granted",
NotificationSeverity.Success,
$"获得勋章:{badge.Name}",
Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。",
actor.UserId,
"查看勋章",
"/profile?tab=badges",
"user_badges",
grant.Id,
dedupeKey,
JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })),
cancellationToken);
notification ??= new UserNotification
{
TenantId = actor.TenantId,
UserId = command.UserId,
NotificationType = "badge_granted",
DedupeKey = dedupeKey,
CreatedBy = actor.UserId
};
notification.Severity = NotificationSeverity.Success;
notification.Title = $"获得勋章:{badge.Name}";
notification.Message = Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。";
notification.ActionLabel = "查看勋章";
notification.ActionPath = "/profile?tab=badges";
notification.SourceType = "user_badges";
notification.SourceId = grant.Id;
notification.Metadata = JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name });
if (dbContext.Entry(notification).State == EntityState.Detached)
{
dbContext.UserNotifications.Add(notification);
}
await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
@@ -1346,35 +1337,22 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
ArgumentException.ThrowIfNullOrWhiteSpace(command.Message);
await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken);
var item = !string.IsNullOrWhiteSpace(command.DedupeKey)
? await dbContext.UserNotifications.FirstOrDefaultAsync(notification =>
notification.TenantId == actor.TenantId &&
notification.UserId == command.UserId &&
notification.NotificationType == command.NotificationType &&
notification.DedupeKey == command.DedupeKey,
cancellationToken)
: null;
var isNew = item is null;
item ??= new UserNotification
{
TenantId = actor.TenantId,
UserId = command.UserId,
CreatedBy = actor.UserId
};
item.NotificationType = command.NotificationType.Trim();
item.Severity = ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity");
item.Title = command.Title.Trim();
item.Message = command.Message.Trim();
item.ActionLabel = Normalize(command.ActionLabel);
item.ActionPath = Normalize(command.ActionPath);
item.SourceType = Normalize(command.SourceType);
item.SourceId = command.SourceId;
item.DedupeKey = Normalize(command.DedupeKey);
item.Metadata = JsonObjectOrDefault(command.Metadata);
if (isNew)
{
dbContext.UserNotifications.Add(item);
}
var item = await notificationProvider.UpsertInAppAsync(
new InAppNotificationRequest(
actor.TenantId,
command.UserId,
command.NotificationType,
ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"),
command.Title,
command.Message,
actor.UserId,
command.ActionLabel,
command.ActionPath,
command.SourceType,
command.SourceId,
command.DedupeKey,
JsonObjectOrDefault(command.Metadata)),
cancellationToken);
await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
@@ -1941,13 +1919,15 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
item.UpdatedAt);
}
private static TenantAuthProviderItem ToAuthProviderItem(TenantAuthProvider item)
private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item)
{
return new TenantAuthProviderItem(
return new TenantIdentityProviderItem(
item.Id,
item.Provider,
item.Status,
item.DisplayName,
item.SecretRef,
item.Priority,
item.ConfigPublic,
item.CreatedAt,
item.UpdatedAt);