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,4 +1,5 @@
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.PlatformAdmin;
@@ -85,10 +86,10 @@ internal sealed class PlatformAdminService(
{
Tenant = tenant,
DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id),
SubscriptionExpiresAt = dbContext.TenantSubscriptions
SubscriptionExpiresAt = dbContext.TenantSaasSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.ExpiresAt)
.Select(subscription => subscription.ExpiresAt)
.OrderByDescending(subscription => subscription.CurrentPeriodEnd)
.Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd)
.FirstOrDefault()
})
.ToArrayAsync(cancellationToken);
@@ -113,7 +114,7 @@ internal sealed class PlatformAdminService(
.OrderByDescending(domain => domain.IsPrimary)
.ThenBy(domain => domain.Host)
.ToArrayAsync(cancellationToken);
var subscriptions = await dbContext.TenantSubscriptions.AsNoTracking()
var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(subscription => subscription.TenantId == tenantId)
.OrderByDescending(subscription => subscription.CreatedAt)
.ToArrayAsync(cancellationToken);
@@ -121,26 +122,40 @@ internal sealed class PlatformAdminService(
.SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken);
return new PlatformTenantDetail(
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.ExpiresAt),
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd),
domains.Select(ToDomainItem).ToArray(),
subscriptions.Select(ToSubscriptionItem).ToArray(),
billingProfile is null ? null : ToBillingProfileItem(billingProfile));
}, cancellationToken);
}
public async Task<PlatformTenantItem> CreateTenantAsync(
public async Task<PlatformTenantProvisioningResult> CreateTenantAsync(
PlatformAdminActor actor,
CreatePlatformTenantCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant create", async dbContext =>
return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) =>
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var slug = NormalizeCode(command.Slug);
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
{
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
}
var ownerEmail = Normalize(command.OwnerEmail);
var ownerPhone = Normalize(command.OwnerPhone);
var ownerIdentifier = ownerEmail ?? ownerPhone;
if (ownerIdentifier is null)
{
throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required");
}
if (await dbContext.Users.AnyAsync(user =>
(ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) ||
(ownerPhone != null && user.Phone == ownerPhone), cancellationToken))
{
throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists");
}
var tenant = new Tenant
{
@@ -153,14 +168,51 @@ internal sealed class PlatformAdminService(
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Tenants.Add(tenant);
var owner = new User
{
Email = ownerEmail,
NormalizedEmail = ownerEmail?.ToUpperInvariant(),
UserName = ownerIdentifier,
NormalizedUserName = ownerIdentifier.ToUpperInvariant(),
Phone = ownerPhone,
PhoneNumber = ownerPhone,
Name = command.OwnerName.Trim(),
PrimaryRole = "tenant_owner",
Status = UserStatus.Active,
ForcePasswordChange = true,
EmailConfirmed = ownerEmail is not null,
PhoneNumberConfirmed = ownerPhone is not null
};
var createOwner = await provider.GetRequiredService<UserManager<User>>()
.CreateAsync(owner, command.TemporaryPassword);
if (!createOwner.Succeeded)
{
throw new PlatformAdminException(
string.Join("; ", createOwner.Errors.Select(error => error.Description)),
"tenant_owner_password_invalid");
}
tenant.OwnerUserId = owner.Id;
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenant.Id,
UserId = owner.Id,
Role = TenantRole.TenantOwner,
Status = MembershipStatus.Active
});
dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy
{
TenantId = tenant.Id,
AllowExternalStudentSelfRegistration = false
});
await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken);
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
await dbContext.SaveChangesAsync(cancellationToken);
return ToTenantItem(tenant, 0, null);
await transaction.CommitAsync(cancellationToken);
return new PlatformTenantProvisioningResult(
ToTenantItem(tenant, 0, null),
owner.Id,
ownerIdentifier,
true);
}, cancellationToken);
}
@@ -196,10 +248,10 @@ internal sealed class PlatformAdminService(
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
var expiresAt = await dbContext.TenantSubscriptions
var expiresAt = await dbContext.TenantSaasSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.ExpiresAt)
.Select(subscription => subscription.ExpiresAt)
.OrderByDescending(subscription => subscription.CurrentPeriodEnd)
.Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd)
.FirstOrDefaultAsync(cancellationToken);
return ToTenantItem(tenant, domainCount, expiresAt);
}, cancellationToken);
@@ -240,208 +292,6 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public async Task<PlatformPlanList> GetPlansAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform plan list", async dbContext =>
{
var plans = dbContext.PlatformSaasPlans.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
plans = plans.Where(plan => plan.Status == ParsePlanStatus(query.Status));
}
return new PlatformPlanList(await plans
.OrderBy(plan => plan.SortOrder)
.ThenBy(plan => plan.Code)
.Take(Limit(query.Limit))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(
PlatformAdminActor actor,
UpsertPlatformSubscriptionCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant subscription upsert", async (provider, dbContext) =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
{
throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found");
}
var subscription = await dbContext.TenantSubscriptions.SingleOrDefaultAsync(
item => item.TenantId == command.TenantId && item.PlanCode == NormalizeCode(command.PlanCode),
cancellationToken);
if (subscription is null)
{
subscription = new TenantSubscription { TenantId = command.TenantId };
dbContext.TenantSubscriptions.Add(subscription);
}
subscription.PlanCode = NormalizeCode(command.PlanCode);
subscription.Status = command.Status;
subscription.StartsAt = command.StartsAt;
subscription.ExpiresAt = command.ExpiresAt;
subscription.BillingCycle = Normalize(command.BillingCycle);
subscription.AmountCents = command.AmountCents;
subscription.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.tenant.subscription.updated", command.TenantId, new
{
subscription.PlanCode,
subscription.Status,
subscription.ExpiresAt
});
var moduleCodes = await dbContext.PlanModuleEntitlements.AsNoTracking()
.Where(item => item.PlanCode == subscription.PlanCode)
.Select(item => item.ModuleCode)
.Distinct()
.ToArrayAsync(cancellationToken);
if (moduleCodes.Length == 0)
{
moduleCodes = ["*"];
}
var eventPublisher = provider.GetRequiredService<ISecurityEventPublisher>();
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var moduleCode in moduleCodes)
{
await eventPublisher.CapabilityChangedAsync(
command.TenantId,
moduleCode,
"subscription_changed",
version,
$"tenant-subscription-{subscription.Id:N}",
cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
return ToSubscriptionItem(subscription);
}, cancellationToken);
}
public async Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(
PlatformAdminActor actor,
ReplacePlatformPlanModulesCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform plan module entitlements replace", async (provider, dbContext) =>
{
var planCode = NormalizeCode(command.PlanCode);
var plan = await dbContext.PlatformSaasPlans.SingleOrDefaultAsync(
item => item.Code == planCode,
cancellationToken) ?? throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found");
var moduleCodes = command.ModuleCodes
.Select(NormalizeCode)
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToArray();
var invalidModules = moduleCodes.Where(module => !ProductModuleCatalog.Contains(module)).ToArray();
if (invalidModules.Length > 0)
{
throw new PlatformAdminException("One or more product modules are invalid.", "product_module_invalid");
}
var existing = await dbContext.PlanModuleEntitlements
.Where(item => item.PlanCode == planCode)
.ToArrayAsync(cancellationToken);
var changedModules = existing.Select(item => item.ModuleCode)
.Concat(moduleCodes)
.Distinct(StringComparer.Ordinal)
.ToArray();
dbContext.PlanModuleEntitlements.RemoveRange(existing);
dbContext.PlanModuleEntitlements.AddRange(moduleCodes.Select(moduleCode => new PlanModuleEntitlement
{
PlanCode = planCode,
ModuleCode = moduleCode,
Enabled = true
}));
AddAudit(dbContext, actor, "platform.plan.modules.replaced", plan.Id, new
{
planCode,
previousModuleCodes = existing.Select(item => item.ModuleCode).Order(StringComparer.Ordinal),
moduleCodes
});
var tenantIds = await dbContext.TenantSubscriptions.AsNoTracking()
.Where(item => item.PlanCode == planCode)
.Select(item => item.TenantId)
.Distinct()
.ToArrayAsync(cancellationToken);
var publisher = provider.GetRequiredService<ISecurityEventPublisher>();
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var tenantId in tenantIds)
{
foreach (var moduleCode in changedModules)
{
await publisher.CapabilityChangedAsync(
tenantId, moduleCode, "plan_entitlements_changed", version,
$"plan-modules-{plan.Id:N}", cancellationToken);
}
}
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformPlanModuleEntitlements(planCode, moduleCodes);
}, cancellationToken);
}
public async Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(
PlatformAdminActor actor,
UpsertPlatformTenantModuleOverrideCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant module override upsert", async (provider, dbContext) =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
var moduleCode = NormalizeCode(command.ModuleCode);
if (!ProductModuleCatalog.Contains(moduleCode))
{
throw new PlatformAdminException("Product module was not found.", "product_module_not_found");
}
var reason = command.Reason.Trim();
if (string.IsNullOrWhiteSpace(reason))
{
throw new PlatformAdminException("Module override reason is required.", "module_override_reason_required");
}
var item = await dbContext.TenantModuleOverrides.SingleOrDefaultAsync(
value => value.TenantId == command.TenantId && value.ModuleCode == moduleCode,
cancellationToken);
if (item is null)
{
item = new TenantModuleOverride { TenantId = command.TenantId, ModuleCode = moduleCode };
dbContext.TenantModuleOverrides.Add(item);
}
var previousMode = item.Mode;
item.Mode = command.Mode;
item.ExpiresAt = command.ExpiresAt;
item.Reason = reason;
AddAudit(dbContext, actor, "platform.tenant.module_override.updated", command.TenantId, new
{
moduleCode,
previousMode,
item.Mode,
item.ExpiresAt,
reason
});
await provider.GetRequiredService<ISecurityEventPublisher>().CapabilityChangedAsync(
command.TenantId,
moduleCode,
"tenant_module_override_changed",
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
$"tenant-module-override-{item.Id:N}",
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformTenantModuleOverrideItem(
item.TenantId, item.ModuleCode, item.Mode, item.ExpiresAt, item.Reason);
}, cancellationToken);
}
public async Task<PlatformDomainList> GetDomainsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
@@ -693,7 +543,7 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public async Task<PlatformDunningChannelList> GetDunningChannelsAsync(
public async Task<PlatformBillingDunningChannelList> GetBillingDunningChannelsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
@@ -701,7 +551,7 @@ internal sealed class PlatformAdminService(
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel list", async dbContext =>
{
var channels = dbContext.PlatformDunningNotificationChannels.AsNoTracking();
var channels = dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
@@ -716,7 +566,7 @@ internal sealed class PlatformAdminService(
channels = channels.Where(channel => channel.Enabled == enabled);
}
return new PlatformDunningChannelList(await channels
return new PlatformBillingDunningChannelList(await channels
.OrderByDescending(channel => channel.Enabled)
.ThenBy(channel => channel.MinReminderLevel)
.ThenBy(channel => channel.ChannelCode)
@@ -726,9 +576,9 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public async Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(
public async Task<PlatformBillingDunningChannelItem> UpsertBillingDunningChannelAsync(
PlatformAdminActor actor,
UpsertPlatformDunningChannelCommand command,
UpsertPlatformBillingDunningChannelCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
@@ -736,12 +586,12 @@ internal sealed class PlatformAdminService(
{
var code = NormalizeCode(command.ChannelCode);
var channel = command.ChannelId.HasValue
? await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken)
: await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken);
? await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken)
: await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken);
if (channel is null)
{
channel = new PlatformDunningNotificationChannel { ChannelCode = code };
dbContext.PlatformDunningNotificationChannels.Add(channel);
channel = new PlatformBillingDunningNotificationChannel { ChannelCode = code };
dbContext.PlatformBillingDunningNotificationChannels.Add(channel);
}
channel.ChannelCode = code;
@@ -757,7 +607,7 @@ internal sealed class PlatformAdminService(
channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray();
channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60);
channel.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.dunning_channel.upserted", channel.Id, new
AddAudit(dbContext, actor, "platform.billing_dunning_channel.upserted", channel.Id, new
{
channel.ChannelCode,
channel.Name,
@@ -771,18 +621,18 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public async Task<PlatformDunningChannelItem> DisableDunningChannelAsync(
public async Task<PlatformBillingDunningChannelItem> DisableBillingDunningChannelAsync(
PlatformAdminActor actor,
DisablePlatformDunningChannelCommand command,
DisablePlatformBillingDunningChannelCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel disable", async dbContext =>
{
var channel = await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken)
var channel = await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found");
channel.Enabled = false;
AddAudit(dbContext, actor, "platform.dunning_channel.disabled", channel.Id, new
AddAudit(dbContext, actor, "platform.billing_dunning_channel.disabled", channel.Id, new
{
channel.ChannelCode,
command.Reason
@@ -792,7 +642,7 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public async Task<PlatformDunningEventList> GetDunningEventsAsync(
public async Task<PlatformBillingDunningEventList> GetBillingDunningEventsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
@@ -800,13 +650,13 @@ internal sealed class PlatformAdminService(
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event list", async dbContext =>
{
var events = dbContext.PlatformDunningNotificationEvents.AsNoTracking();
var events = dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status));
}
return new PlatformDunningEventList(await events
return new PlatformBillingDunningEventList(await events
.OrderByDescending(item => item.CreatedAt)
.Take(Limit(query.Limit))
.Select(item => ToDunningEventItem(item))
@@ -814,7 +664,7 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public async Task<PlatformDunningEventItem> GetDunningEventDetailAsync(
public async Task<PlatformBillingDunningEventItem> GetBillingDunningEventDetailAsync(
PlatformAdminActor actor,
Guid eventId,
CancellationToken cancellationToken = default)
@@ -822,27 +672,27 @@ internal sealed class PlatformAdminService(
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event detail", async dbContext =>
{
var item = await dbContext.PlatformDunningNotificationEvents.AsNoTracking()
var item = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
return ToDunningEventItem(item);
}, cancellationToken);
}
public async Task<PlatformDunningEventItem> RetryDunningEventAsync(
public async Task<PlatformBillingDunningEventItem> RetryBillingDunningEventAsync(
PlatformAdminActor actor,
RetryPlatformDunningEventCommand command,
RetryPlatformBillingDunningEventCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event retry", async dbContext =>
{
var item = await dbContext.PlatformDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken)
var item = await dbContext.PlatformBillingDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
item.Status = PlatformDunningNotificationStatus.Pending;
item.Status = PlatformBillingDunningNotificationStatus.Pending;
item.NextAttemptAt = DateTimeOffset.UtcNow;
item.LastError = null;
AddAudit(dbContext, actor, "platform.dunning_event.retry_requested", item.Id, new
AddAudit(dbContext, actor, "platform.billing_dunning_event.retry_requested", item.Id, new
{
item.TenantId,
item.ChannelId,
@@ -941,16 +791,16 @@ internal sealed class PlatformAdminService(
domain.LastCheckedAt,
domain.LastFailureReason);
private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSubscription subscription) =>
private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) =>
new(
subscription.Id,
subscription.TenantId,
subscription.PlanCode,
subscription.BaseOfferingVersionId,
subscription.Status,
subscription.StartsAt,
subscription.ExpiresAt,
subscription.BillingCycle,
subscription.AmountCents,
subscription.CurrentPeriodStart,
subscription.CurrentPeriodEnd,
subscription.CancelAtPeriodEnd,
subscription.Metadata);
private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) =>
@@ -968,7 +818,7 @@ internal sealed class PlatformAdminService(
profile.BankAccountMasked,
profile.Metadata);
private static PlatformDunningChannelItem ToDunningChannelItem(PlatformDunningNotificationChannel channel) =>
private static PlatformBillingDunningChannelItem ToDunningChannelItem(PlatformBillingDunningNotificationChannel channel) =>
new(
channel.Id,
channel.ChannelCode,
@@ -987,7 +837,7 @@ internal sealed class PlatformAdminService(
channel.CreatedAt,
channel.UpdatedAt);
private static PlatformDunningEventItem ToDunningEventItem(PlatformDunningNotificationEvent item) =>
private static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) =>
new(
item.Id,
item.TenantId,
@@ -1082,14 +932,91 @@ internal sealed class PlatformAdminService(
private static TenantDomainStatus ParseDomainStatus(string status) =>
Enum.TryParse<TenantDomainStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformSaasPlanStatus ParsePlanStatus(string status) =>
Enum.TryParse<PlatformSaasPlanStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static async Task EnsureTenantOwnerRoleAsync(
TikuDbContext dbContext,
Guid tenantId,
Guid ownerUserId,
CancellationToken cancellationToken)
{
var existingFeatureCodes = await dbContext.SaasFeatures
.Where(value => SaasFeatureCatalog.All.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.SaasFeatures.AddRange(SaasFeatureCatalog.All
.Except(existingFeatureCodes, StringComparer.Ordinal)
.Select((code, index) => new SaasFeature
{
Code = code,
Name = code,
Category = code.Split('.')[0],
IsCore = code == SaasFeatureCatalog.CoreBackoffice,
Status = SaasFeatureStatus.Active,
SortOrder = index * 10
}));
var moduleCodes = BackendPermissions.Tenant
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
.Distinct(StringComparer.Ordinal)
.ToArray();
var existingModuleCodes = await dbContext.PermissionModules
.Where(value => moduleCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.PermissionModules.AddRange(moduleCodes
.Except(existingModuleCodes, StringComparer.Ordinal)
.Select(code => new PermissionModule
{
Code = code,
Name = code,
Area = BackendPermissionArea.Tenant,
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
}));
var existingPermissionCodes = await dbContext.BackendPermissions
.Where(value => BackendPermissions.Tenant.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.BackendPermissions.AddRange(BackendPermissions.Tenant
.Except(existingPermissionCodes, StringComparer.Ordinal)
.Select(code => new BackendPermission
{
Code = code,
Name = code,
Area = BackendPermissionArea.Tenant,
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
IsSystem = true
}));
var role = new TenantBackendRole
{
TenantId = tenantId,
Code = "tenant_owner",
Name = "租户所有者",
Status = BackendRoleStatus.Active,
IsSystem = true,
Description = "系统内置租户所有者角色",
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
};
dbContext.TenantBackendRoles.Add(role);
dbContext.TenantBackendRolePermissions.AddRange(BackendPermissions.Tenant.Select(code => new TenantBackendRolePermission
{
TenantId = tenantId,
RoleId = role.Id,
PermissionCode = code
}));
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = tenantId,
UserId = ownerUserId,
RoleId = role.Id
});
}
private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) =>
Enum.TryParse<PlatformAuditAlertStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformDunningNotificationStatus ParseDunningEventStatus(string status) =>
Enum.TryParse<PlatformDunningNotificationStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) =>
Enum.TryParse<PlatformBillingDunningNotificationStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformAdminException InvalidStatus(string status) =>
new($"Unsupported status '{status}'.", "invalid_status");