forked from xiongyuxing/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user