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,12 +1,15 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
@@ -125,6 +128,54 @@ public sealed class AssetManagementEndpointTests
Assert.Equal(seed.UserId, asset.VerifiedBy);
Assert.Equal(2048, asset.VerifiedSizeBytes);
Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256);
Assert.False(dbContext.TenantFeatureUsages.Any(value =>
value.TenantId == seed.TenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes));
}
[Fact]
public async Task Verified_upload_consumes_storage_quota_and_archive_releases_it()
{
var storage = new FakeObjectStorageService { MetadataSizeBytes = 6 };
await using var factory = new ApiTestFactory(objectStorageService: storage);
var seed = await SeedAdminWithStorageQuotaAsync(factory, 10);
var firstAssetId = Guid.NewGuid();
var secondAssetId = Guid.NewGuid();
await factory.SeedAsync(
PendingAsset(firstAssetId, seed.TenantId, "first.pdf"),
PendingAsset(secondAssetId, seed.TenantId, "second.pdf"));
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var firstConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = firstAssetId });
Assert.Equal(HttpStatusCode.OK, firstConfirm.StatusCode);
Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId));
storage.MetadataSizeBytes = 5;
using var exhausted = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.Conflict, exhausted.StatusCode);
var exhaustedBody = await ReadJsonAsync(exhausted);
Assert.Equal("feature_quota_exhausted", exhaustedBody.RootElement.GetProperty("code").GetString());
Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId));
using var archive = await client.DeleteAsync($"/api/tenant-content/assets/{firstAssetId}");
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
Assert.Equal(0, await StorageUsageAsync(factory, seed.TenantId));
using var secondConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.OK, secondConfirm.StatusCode);
Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId));
using var repeatedConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.OK, repeatedConfirm.StatusCode);
Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId));
}
[Fact]
@@ -281,6 +332,140 @@ public sealed class AssetManagementEndpointTests
return (tenantId, userId, phone);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminWithStorageQuotaAsync(
ApiTestFactory factory,
long storageLimit)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13900000001";
var offeringId = Guid.NewGuid();
var versionId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Storage Quota Tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active
},
new User
{
Id = userId,
Phone = phone,
Name = "Storage Quota Admin"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new SaasFeature
{
Code = SaasFeatureCatalog.PrivateQuestionBank,
Name = "Private question bank",
Category = "content",
Status = SaasFeatureStatus.Active
},
new SaasOffering
{
Id = offeringId,
Code = $"storage-quota-{tenantId:N}",
Name = "Storage Quota Plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
},
new SaasOfferingVersion
{
Id = versionId,
OfferingId = offeringId,
Version = 1,
Status = SaasOfferingVersionStatus.Draft,
BillingCycle = PlatformBillingCycle.Monthly,
OriginalAmountCents = 100,
AmountCents = 100,
EffectiveAt = now.AddDays(-1)
},
new SaasOfferingVersionFeature
{
OfferingVersionId = versionId,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank
},
new SaasFeatureLimitDefinition
{
MetricCode = SaasQuotaMetricCatalog.StorageBytes,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank,
Name = "Storage bytes",
Unit = "byte",
Kind = SaasFeatureLimitKind.Current,
WarningPercent = 80,
IsHardLimit = true
},
new SaasOfferingVersionLimit
{
OfferingVersionId = versionId,
MetricCode = SaasQuotaMetricCatalog.StorageBytes,
LimitValue = storageLimit
},
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = versionId,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddMonths(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = versionId,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddMonths(1)
});
using var scope = factory.CreateSystemScope("Publish storage quota fixture");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await dbContext.SaasOfferingVersions.SingleAsync(value => value.Id == versionId);
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = now;
await dbContext.SaveChangesAsync();
return (tenantId, userId, phone);
}
private static ContentAsset PendingAsset(Guid id, Guid tenantId, string fileName) => new()
{
Id = id,
TenantId = tenantId,
Title = fileName,
FileName = fileName,
StorageProvider = AssetStorageProvider.AliyunOss,
Bucket = "tenant-assets",
ObjectKey = $"{tenantId:N}/assets/{fileName}",
MimeType = "application/pdf",
AssetType = ContentAssetType.Pdf,
UploadStatus = AssetUploadStatus.Pending,
SecurityScanStatus = AssetSecurityScanStatus.Pending
};
private static async Task<long> StorageUsageAsync(ApiTestFactory factory, Guid tenantId)
{
using var scope = factory.CreateSystemScope("Read storage quota usage");
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>().TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes)
.Select(value => value.UsedValue)
.SingleOrDefaultAsync();
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
@@ -296,7 +481,7 @@ public sealed class AssetManagementEndpointTests
private sealed class FakeObjectStorageService : IObjectStorageService
{
public long? MetadataSizeBytes { get; init; }
public long? MetadataSizeBytes { get; set; }
public string? MetadataChecksumSha256 { get; init; }