feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
@@ -19,7 +20,8 @@ public sealed class AuthService(
ISmsVerificationService smsVerificationService,
IAuthSessionStore sessionStore,
IWechatOAuthClient wechatOAuthClient,
ITenantExternalProviderConfigService providerConfigService) : IAuthService
ITenantExternalProviderConfigService providerConfigService,
IFeatureAccessService featureAccessService) : IAuthService
{
private const string PasswordProvider = "password";
private const string SmsProvider = "sms";
@@ -393,6 +395,9 @@ public sealed class AuthService(
throw;
}
await using var transaction = dbContext.Database.CurrentTransaction is null
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
: null;
var providerSubject = $"{config.AppId}:{identity.OpenId}";
var user = await UpsertWechatUserAsync(
provider,
@@ -408,6 +413,10 @@ public sealed class AuthService(
// tenant policy and existing membership state have accepted the login.
// A denied first login must not leave a user or provider identity behind.
await dbContext.SaveChangesAsync(cancellationToken);
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return await CompleteSuccessfulLoginAsync(
request.Realm,
@@ -568,6 +577,11 @@ public sealed class AuthService(
throw new TenantAccessDeniedException();
}
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
tenantId,
SaasQuotaMetricCatalog.StudentCount,
cancellationToken: cancellationToken);
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenantId,

View File

@@ -17,13 +17,15 @@ public sealed class SmsVerificationService(
TikuDbContext dbContext,
ISmsProvider smsProvider,
IRedisSecurityStore redisSecurityStore,
IFeatureAccessService featureAccessService,
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
{
public SmsVerificationService(
TikuDbContext dbContext,
ISmsProvider smsProvider,
IFeatureAccessService featureAccessService,
IOptions<SmsSecurityOptions> securityOptions)
: this(dbContext, smsProvider, new NullRedisSecurityStore(), securityOptions)
: this(dbContext, smsProvider, new NullRedisSecurityStore(), featureAccessService, securityOptions)
{
}
@@ -40,6 +42,17 @@ public sealed class SmsVerificationService(
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var now = DateTimeOffset.UtcNow;
await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
var quotaReserved = await featureAccessService.TryConsumeQuotaAsync(
request.TenantId,
SaasQuotaMetricCatalog.SmsCount,
1,
cancellationToken);
if (!quotaReserved)
{
throw new FeatureAccessException(
"Tenant SMS quota is exhausted.",
"feature_quota_exhausted");
}
var code = RandomNumberGenerator
.GetInt32(100000, 1000000)
@@ -52,6 +65,7 @@ public sealed class SmsVerificationService(
options.CodePepper);
SmsProviderSendResult sendResult;
var providerAccepted = false;
try
{
sendResult = await smsProvider.SendAsync(
@@ -63,6 +77,7 @@ public sealed class SmsVerificationService(
request.IpAddress,
request.UserAgent),
cancellationToken);
providerAccepted = true;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -98,6 +113,17 @@ public sealed class SmsVerificationService(
"sms_provider_send_failed",
exception);
}
finally
{
if (!providerAccepted)
{
await featureAccessService.ReleaseQuotaAsync(
request.TenantId,
SaasQuotaMetricCatalog.SmsCount,
1,
CancellationToken.None);
}
}
await ExpirePreviousCodesAsync(
request.TenantId,