feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -19,7 +19,7 @@ namespace Tiku.IntegrationTests.Api;
|
||||
public sealed class PlatformAdminEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Platform_admin_can_manage_tenant_subscription_domain_recheck_and_audit()
|
||||
public async Task Platform_admin_can_publish_immutable_saas_offering_and_manage_tenant_operations()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
@@ -48,13 +48,6 @@ public sealed class PlatformAdminEndpointTests
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
DnsVerifiedAt = DateTimeOffset.UtcNow,
|
||||
TlsReadyAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new PlatformSaasPlan
|
||||
{
|
||||
Code = "standard",
|
||||
Name = "Standard",
|
||||
BaseAmountCents = 99900,
|
||||
Status = PlatformSaasPlanStatus.Active
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
@@ -62,27 +55,52 @@ public sealed class PlatformAdminEndpointTests
|
||||
|
||||
var overview = await client.GetAsync("/api/platform-admin/overview");
|
||||
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
|
||||
var planModules = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/plans/standard/modules",
|
||||
new ReplacePlatformPlanModulesDto { ModuleCodes = ["content", "job", "settings"] });
|
||||
var subscription = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/subscriptions",
|
||||
new UpsertPlatformSubscriptionDto
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanCode = "standard",
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
AmountCents = 99900
|
||||
});
|
||||
var moduleOverride = await client.PutAsJsonAsync(
|
||||
$"/api/platform-admin/tenants/{tenantId}/module-overrides/content",
|
||||
new UpsertPlatformTenantModuleOverrideDto
|
||||
{
|
||||
Mode = TenantModuleOverrideMode.Disabled,
|
||||
Reason = "integration test capability revocation"
|
||||
});
|
||||
var feature = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/saas/features",
|
||||
new UpsertSaasFeatureDto(
|
||||
null,
|
||||
SaasFeatureCatalog.Exam,
|
||||
"考试",
|
||||
"learning",
|
||||
"租户考试模块",
|
||||
60_000,
|
||||
"CNY",
|
||||
SaasFeatureStatus.Active,
|
||||
30));
|
||||
var offering = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/saas/offerings",
|
||||
new UpsertSaasOfferingDto(
|
||||
null,
|
||||
$"standard-{Guid.NewGuid():N}",
|
||||
"标准套餐",
|
||||
SaasOfferingType.BasePlan,
|
||||
SaasOfferingStatus.Draft,
|
||||
"集成测试套餐",
|
||||
10));
|
||||
Assert.Equal(HttpStatusCode.OK, feature.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, offering.StatusCode);
|
||||
var offeringJson = await JsonDocument.ParseAsync(await offering.Content.ReadAsStreamAsync());
|
||||
var offeringId = offeringJson.RootElement.GetProperty("id").GetGuid();
|
||||
var versionRequest = new UpsertSaasOfferingVersionDto(
|
||||
null,
|
||||
offeringId,
|
||||
PlatformBillingCycle.Yearly,
|
||||
72_000,
|
||||
60_000,
|
||||
"CNY",
|
||||
null,
|
||||
[SaasFeatureCatalog.Exam],
|
||||
new Dictionary<string, long>(),
|
||||
JsonDefaults.Object());
|
||||
var version = await client.PutAsJsonAsync("/api/platform-admin/saas/offering-versions", versionRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, version.StatusCode);
|
||||
var versionJson = await JsonDocument.ParseAsync(await version.Content.ReadAsStreamAsync());
|
||||
var versionId = versionJson.RootElement.GetProperty("id").GetGuid();
|
||||
var published = await client.PostAsync($"/api/platform-admin/saas/offering-versions/{versionId}/publish", null);
|
||||
var immutableUpdate = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/saas/offering-versions",
|
||||
versionRequest with { Id = versionId, AmountCents = 50_000 });
|
||||
var catalog = await client.GetAsync("/api/platform-admin/saas/catalog");
|
||||
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
|
||||
var suspended = await client.PatchAsJsonAsync(
|
||||
"/api/platform-admin/tenants/status",
|
||||
@@ -100,9 +118,9 @@ public sealed class PlatformAdminEndpointTests
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, planModules.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, moduleOverride.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, published.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, immutableUpdate.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, catalog.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
|
||||
@@ -117,10 +135,16 @@ public sealed class PlatformAdminEndpointTests
|
||||
log.Action == "platform.tenant.status_changed"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.plan.modules.replaced"));
|
||||
log.Action == "platform.saas.feature.upserted"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.tenant.module_override.updated"));
|
||||
log.Action == "platform.saas.offering_version.published"));
|
||||
Assert.Equal(
|
||||
60_000,
|
||||
await dbContext.SaasOfferingVersions
|
||||
.Where(item => item.Id == versionId)
|
||||
.Select(item => item.AmountCents)
|
||||
.SingleAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -153,7 +177,7 @@ public sealed class PlatformAdminEndpointTests
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
||||
|
||||
var response = await client.GetAsync("/api/platform-admin/overview");
|
||||
var response = await client.GetAsync("/api/platform-admin/saas/catalog");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
@@ -178,23 +202,22 @@ public sealed class PlatformAdminEndpointTests
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.PastDue
|
||||
},
|
||||
new TenantInvoice
|
||||
new PlatformBillingInvoice
|
||||
{
|
||||
Id = invoiceId,
|
||||
TenantId = tenantId,
|
||||
InvoiceNo = "INV-DUNNING-1",
|
||||
Status = TenantInvoiceStatus.Overdue,
|
||||
TotalCents = 10_000,
|
||||
BalanceCents = 10_000
|
||||
Status = PlatformBillingInvoiceStatus.Overdue,
|
||||
TotalAmountCents = 10_000
|
||||
},
|
||||
new TenantInvoiceReminder
|
||||
new PlatformBillingInvoiceReminder
|
||||
{
|
||||
Id = reminderId,
|
||||
TenantId = tenantId,
|
||||
InvoiceId = invoiceId,
|
||||
ReminderType = TenantInvoiceReminderType.Overdue,
|
||||
Channel = TenantInvoiceReminderChannel.Wechat,
|
||||
Status = TenantInvoiceReminderStatus.Failed,
|
||||
ReminderType = PlatformBillingInvoiceReminderType.Overdue,
|
||||
Channel = PlatformBillingInvoiceReminderChannel.Wechat,
|
||||
Status = PlatformBillingInvoiceReminderStatus.Failed,
|
||||
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
|
||||
BalanceCentsSnapshot = 10_000
|
||||
});
|
||||
@@ -202,12 +225,12 @@ public sealed class PlatformAdminEndpointTests
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
|
||||
var upsertResponse = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/dunning-notification-channels",
|
||||
new UpsertPlatformDunningChannelDto
|
||||
"/api/platform-admin/saas/dunning/channels",
|
||||
new UpsertPlatformBillingDunningChannelDto
|
||||
{
|
||||
ChannelCode = "wecom-overdue",
|
||||
Name = "企业微信逾期提醒",
|
||||
Provider = PlatformDunningProvider.Wecom,
|
||||
Provider = PlatformBillingDunningProvider.Wecom,
|
||||
WebhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret-key",
|
||||
SecretRef = "platform_secrets:dunning:wecom:default",
|
||||
ReminderTypes = ["overdue"],
|
||||
@@ -218,14 +241,14 @@ public sealed class PlatformAdminEndpointTests
|
||||
var upsertBody = await upsertResponse.Content.ReadAsStringAsync();
|
||||
var channelJson = JsonDocument.Parse(upsertBody);
|
||||
var channelId = channelJson.RootElement.GetProperty("id").GetGuid();
|
||||
await factory.SeedAsync(new PlatformDunningNotificationEvent
|
||||
await factory.SeedAsync(new PlatformBillingDunningNotificationEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ChannelId = channelId,
|
||||
ReminderId = reminderId,
|
||||
InvoiceId = invoiceId,
|
||||
Provider = PlatformDunningProvider.Wecom,
|
||||
Status = PlatformDunningNotificationStatus.Failed,
|
||||
Provider = PlatformBillingDunningProvider.Wecom,
|
||||
Status = PlatformBillingDunningNotificationStatus.Failed,
|
||||
Attempts = 2,
|
||||
LastError = "timeout",
|
||||
LastHttpCode = 500,
|
||||
@@ -233,21 +256,21 @@ public sealed class PlatformAdminEndpointTests
|
||||
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
|
||||
});
|
||||
|
||||
var channelsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-channels?search=wecom");
|
||||
var eventsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-events?status=failed");
|
||||
var channelsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/channels?search=wecom");
|
||||
var eventsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/events?status=failed");
|
||||
var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync());
|
||||
var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||
var detailResponse = await client.GetAsync($"/api/platform-admin/dunning-notification-events/detail?eventId={eventId}");
|
||||
var detailResponse = await client.GetAsync($"/api/platform-admin/saas/dunning/events/detail?eventId={eventId}");
|
||||
var retryResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/dunning-notification-events/retry",
|
||||
new RetryPlatformDunningEventDto
|
||||
"/api/platform-admin/saas/dunning/events/retry",
|
||||
new RetryPlatformBillingDunningEventDto
|
||||
{
|
||||
EventId = eventId,
|
||||
Reason = "manual retry"
|
||||
});
|
||||
var disableResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/dunning-notification-channels/disable",
|
||||
new DisablePlatformDunningChannelDto
|
||||
"/api/platform-admin/saas/dunning/channels/disable",
|
||||
new DisablePlatformBillingDunningChannelDto
|
||||
{
|
||||
ChannelId = channelId,
|
||||
Reason = "disable test"
|
||||
@@ -264,17 +287,17 @@ public sealed class PlatformAdminEndpointTests
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify platform dunning side effects");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedEvent = await dbContext.PlatformDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
|
||||
var storedChannel = await dbContext.PlatformDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
|
||||
Assert.Equal(PlatformDunningNotificationStatus.Pending, storedEvent.Status);
|
||||
var storedEvent = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
|
||||
var storedChannel = await dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
|
||||
Assert.Equal(PlatformBillingDunningNotificationStatus.Pending, storedEvent.Status);
|
||||
Assert.Null(storedEvent.LastError);
|
||||
Assert.False(storedChannel.Enabled);
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.dunning_event.retry_requested"));
|
||||
log.Action == "platform.billing_dunning_event.retry_requested"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.dunning_channel.disabled"));
|
||||
log.Action == "platform.billing_dunning_channel.disabled"));
|
||||
}
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
|
||||
@@ -282,16 +305,29 @@ public sealed class PlatformAdminEndpointTests
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var email = $"platform-{Guid.NewGuid():N}@example.test";
|
||||
var modules = BackendPermissions.Platform
|
||||
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(code => new PermissionModule
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
Module = code.Split(':')[1],
|
||||
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
|
||||
IsSystem = true
|
||||
}).Cast<object>().ToList();
|
||||
await factory.SeedAsync(
|
||||
[
|
||||
..modules,
|
||||
..permissions,
|
||||
new User
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user