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

@@ -30,7 +30,8 @@ public sealed class TenantAdminDirectService(
INotificationProvider notificationProvider,
ICurrentAccessContext currentAccessContext,
IAuthSessionStore sessionStore,
ISecurityEventPublisher securityEventPublisher) : ITenantAdminDirectService
ISecurityEventPublisher securityEventPublisher,
IFeatureAccessService featureAccessService) : ITenantAdminDirectService
{
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
TenantAdminActor actor,
@@ -307,6 +308,9 @@ public sealed class TenantAdminDirectService(
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
await using var transaction = dbContext.Database.CurrentTransaction is null
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
: null;
var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type");
var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status");
var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken);
@@ -342,6 +346,10 @@ public sealed class TenantAdminDirectService(
await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
}
@@ -502,6 +510,9 @@ public sealed class TenantAdminDirectService(
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken);
await AssertReferenceAsync<Major>(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken);
await using var transaction = dbContext.Database.CurrentTransaction is null
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
: null;
var user = await ResolveUserAsync(command.User, "student", cancellationToken);
if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId))
{
@@ -527,6 +538,10 @@ public sealed class TenantAdminDirectService(
await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return new ContentManagementResult<TenantAdminStudentItem>(
ToStudentItem(
@@ -572,6 +587,21 @@ public sealed class TenantAdminDirectService(
throw new TenantAdminDirectException("Student membership was not found.", "student_not_found");
}
await using var transaction = dbContext.Database.CurrentTransaction is null
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
: null;
var wasCounted = await IsUserCountedForMetricAsync(
actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
var otherMembershipCounted = await IsUserCountedForMetricAsync(
actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken, membership.Id);
var willBeCounted = otherMembershipCounted || status == MembershipStatus.Active;
if (!wasCounted && willBeCounted)
{
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
actor.TenantId,
SaasQuotaMetricCatalog.StudentCount,
cancellationToken: cancellationToken);
}
membership.Status = status;
if (status != MembershipStatus.Active)
{
@@ -581,6 +611,18 @@ public sealed class TenantAdminDirectService(
await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
if (wasCounted && !willBeCounted)
{
await featureAccessService.ReleaseQuotaAsync(
actor.TenantId,
SaasQuotaMetricCatalog.StudentCount,
1,
cancellationToken);
}
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return new ContentManagementResult<TenantAdminStudentStatusItem>(
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
}
@@ -607,6 +649,9 @@ public sealed class TenantAdminDirectService(
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
var invalidItems = previewItems.Where(item => !item.Valid).ToArray();
await using var transaction = dbContext.Database.CurrentTransaction is null
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
: null;
var createdOrUpdated = 0;
var classAssigned = 0;
var rowNo = 0;
@@ -654,6 +699,10 @@ public sealed class TenantAdminDirectService(
await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems);
}
@@ -1127,6 +1176,9 @@ public sealed class TenantAdminDirectService(
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
await using var transaction = dbContext.Database.CurrentTransaction is null
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
: null;
var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role");
var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status");
await AssertGrantableAsync(actor, role, cancellationToken);
@@ -1157,11 +1209,37 @@ public sealed class TenantAdminDirectService(
var isNew = membership is null;
var previousStatus = membership?.Status.ToString() ?? "none";
var wasStaffCounted = await IsUserCountedForMetricAsync(
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken);
var wasStudentCounted = await IsUserCountedForMetricAsync(
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
Guid? excludedMembershipId = isNew ? null : membership!.Id;
var otherStaffCounted = await IsUserCountedForMetricAsync(
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken, excludedMembershipId);
var otherStudentCounted = await IsUserCountedForMetricAsync(
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId);
var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student);
var willStudentBeCounted = otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student);
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
{
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
}
if (!wasStaffCounted && willStaffBeCounted)
{
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
actor.TenantId,
SaasQuotaMetricCatalog.StaffCount,
cancellationToken: cancellationToken);
}
if (!wasStudentCounted && willStudentBeCounted)
{
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
actor.TenantId,
SaasQuotaMetricCatalog.StudentCount,
cancellationToken: cancellationToken);
}
membership ??= new TenantMembership
{
TenantId = actor.TenantId,
@@ -1189,6 +1267,26 @@ public sealed class TenantAdminDirectService(
actor.TenantId, user.Id, previousStatus, status.ToString(),
$"tenant-member-{membership.Id:N}", cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
if (wasStaffCounted && !willStaffBeCounted)
{
await featureAccessService.ReleaseQuotaAsync(
actor.TenantId,
SaasQuotaMetricCatalog.StaffCount,
1,
cancellationToken);
}
if (wasStudentCounted && !willStudentBeCounted)
{
await featureAccessService.ReleaseQuotaAsync(
actor.TenantId,
SaasQuotaMetricCatalog.StudentCount,
1,
cancellationToken);
}
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}
@@ -1198,6 +1296,9 @@ public sealed class TenantAdminDirectService(
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
await using var transaction = dbContext.Database.CurrentTransaction is null
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
: null;
var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == membershipId,
cancellationToken);
@@ -1213,6 +1314,11 @@ public sealed class TenantAdminDirectService(
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
var previousStatus = membership.Status;
var metricCode = QuotaMetricForRole(membership.Role);
var wasCounted = await IsUserCountedForMetricAsync(
actor.TenantId, membership.UserId, metricCode, cancellationToken);
var otherMembershipCounted = await IsUserCountedForMetricAsync(
actor.TenantId, membership.UserId, metricCode, cancellationToken, membership.Id);
membership.Status = MembershipStatus.Disabled;
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
@@ -1220,6 +1326,18 @@ public sealed class TenantAdminDirectService(
actor.TenantId, membership.UserId, previousStatus.ToString(), MembershipStatus.Disabled.ToString(),
$"tenant-member-{membership.Id:N}", cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted)
{
await featureAccessService.ReleaseQuotaAsync(
actor.TenantId,
metricCode,
1,
cancellationToken);
}
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}
@@ -1995,6 +2113,14 @@ public sealed class TenantAdminDirectService(
cancellationToken);
if (membership is null)
{
var metricCode = QuotaMetricForRole(role);
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
{
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
tenantId,
metricCode,
cancellationToken: cancellationToken);
}
membership = new TenantMembership
{
TenantId = tenantId,
@@ -2006,12 +2132,48 @@ public sealed class TenantAdminDirectService(
}
else
{
if (membership.Status != MembershipStatus.Active)
{
var metricCode = QuotaMetricForRole(role);
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
{
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
tenantId,
metricCode,
cancellationToken: cancellationToken);
}
}
membership.Status = MembershipStatus.Active;
}
return membership;
}
private static string QuotaMetricForRole(TenantRole role) => role == TenantRole.Student
? SaasQuotaMetricCatalog.StudentCount
: SaasQuotaMetricCatalog.StaffCount;
private Task<bool> IsUserCountedForMetricAsync(
Guid tenantId,
Guid userId,
string metricCode,
CancellationToken cancellationToken,
Guid? excludedMembershipId = null)
{
var query = dbContext.TenantMemberships.AsNoTracking().Where(item =>
item.TenantId == tenantId &&
item.UserId == userId &&
item.Status == MembershipStatus.Active);
if (excludedMembershipId.HasValue)
{
query = query.Where(item => item.Id != excludedMembershipId.Value);
}
return metricCode == SaasQuotaMetricCatalog.StudentCount
? query.AnyAsync(item => item.Role == TenantRole.Student, cancellationToken)
: query.AnyAsync(item => item.Role != TenantRole.Student, cancellationToken);
}
private async Task<StudentProfile> EnsureStudentProfileAsync(
Guid tenantId,
Guid userId,
@@ -2347,7 +2509,7 @@ public sealed class TenantAdminDirectService(
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Tenant,
Module = permissionCode.Split(':')[1],
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
IsSystem = true
});
}