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

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
@@ -147,14 +148,76 @@ public sealed class SmsVerificationServiceTests
"device-two")));
}
[Fact]
public async Task Successful_sms_consumes_quota_once_without_compensation()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var quota = new RecordingFeatureAccessService();
var service = CreateService(context, featureAccessService: quota);
await service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.1",
"quota-success"));
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
Assert.Empty(quota.Releases);
}
[Fact]
public async Task Failed_sms_send_releases_reserved_quota()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var quota = new RecordingFeatureAccessService();
var service = CreateService(context, new FailingSmsProvider(), featureAccessService: quota);
await Assert.ThrowsAsync<SmsProviderException>(() => service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.1",
"quota-failure")));
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Releases);
Assert.Equal(SmsVerificationStatus.Failed, Assert.Single(context.SmsVerificationCodes).Status);
}
[Fact]
public async Task Exhausted_sms_quota_rejects_before_provider_send()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var provider = new CapturingSmsProvider();
var quota = new RecordingFeatureAccessService { AllowConsumption = false };
var service = CreateService(context, provider, featureAccessService: quota);
var exception = await Assert.ThrowsAsync<FeatureAccessException>(() => service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.1",
"quota-exhausted")));
Assert.Equal("feature_quota_exhausted", exception.Code);
Assert.Empty(provider.Requests);
Assert.Empty(quota.Releases);
}
private static SmsVerificationService CreateService(
TikuDbContext context,
ISmsProvider? provider = null,
SmsSecurityOptions? options = null)
SmsSecurityOptions? options = null,
IFeatureAccessService? featureAccessService = null)
{
return new SmsVerificationService(
context,
provider ?? new CapturingSmsProvider(),
featureAccessService ?? new RecordingFeatureAccessService(),
Options.Create(options ?? ValidOptions()));
}
@@ -228,4 +291,53 @@ public sealed class SmsVerificationServiceTests
return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id"));
}
}
private sealed class FailingSmsProvider : ISmsProvider
{
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
throw new SmsProviderException("provider failed", "sms_provider_send_failed");
}
}
private sealed class RecordingFeatureAccessService : IFeatureAccessService
{
public bool AllowConsumption { get; init; } = true;
public List<(Guid TenantId, string MetricCode, long Amount)> Consumptions { get; } = [];
public List<(Guid TenantId, string MetricCode, long Amount)> Releases { get; } = [];
public Task<bool> TryConsumeQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default)
{
Consumptions.Add((tenantId, metricCode, amount));
return Task.FromResult(AllowConsumption);
}
public Task ReleaseQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default)
{
Releases.Add((tenantId, metricCode, amount));
return Task.CompletedTask;
}
public Task<FeatureAccessDecision> EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(Guid tenantId, IEnumerable<string> permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}