Files
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

200 lines
10 KiB
C#

using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Application.TenantAdmin;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.TenantAdmin;
internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies dependencies)
: TenantAdminServiceBase(dependencies), ITenantMemberAccessService
{
public async Task<CatalogList<TenantAdminMemberItem>> GetMembersAsync(
TenantAdminActor actor,
TenantAdminMemberFilter filter,
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
var query = identityPersistence.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(filter.Role))
query = query.Where(item => item.Role == ParseEnum<TenantRole>(filter.Role, "invalid_member_role"));
if (!string.IsNullOrWhiteSpace(filter.Status))
query = query.Where(item =>
item.Status == ParseEnum<MembershipStatus>(filter.Status, "invalid_member_status"));
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(item => identityPersistence.Users.Any(user =>
user.Id == item.UserId &&
((user.UserName != null && user.UserName.Contains(keyword)) ||
(user.Phone != null && user.Phone.Contains(keyword)) ||
(user.Email != null && user.Email.Contains(keyword)) ||
(user.Name != null && user.Name.Contains(keyword)))));
}
var memberships = await query
.OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 :
item.Role == TenantRole.TenantAdmin ? 1 :
item.Role == TenantRole.TenantOperator ? 2 :
item.Role == TenantRole.Teacher ? 3 : 9)
.ThenBy(item => item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
var userIds = memberships.Select(item => item.UserId).ToArray();
var users = await identityPersistence.Users.AsNoTracking()
.Where(user => userIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, cancellationToken);
return new CatalogList<TenantAdminMemberItem>(memberships.Select(item =>
{
users.TryGetValue(item.UserId, out var user);
return ToMemberItem(item, user ?? new User { Id = item.UserId });
}).ToArray());
}
public async Task<ContentManagementResult<TenantAdminMemberItem>> UpsertMemberAsync(
TenantAdminActor actor,
UpsertTenantAdminMemberCommand command,
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
await using var transaction = unitOfWork.Database.CurrentTransaction is null
? await unitOfWork.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);
var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role);
var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken);
if (user.Id == actor.UserId && status == MembershipStatus.Disabled)
throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
TenantMembership? membership = null;
if (command.MembershipId.HasValue)
{
membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value,
cancellationToken);
if (membership is null)
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
}
else
{
membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role,
cancellationToken);
}
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,
UserId = user.Id
};
membership.UserId = user.Id;
membership.Role = role;
membership.Status = status;
if (isNew) identityPersistence.TenantMemberships.Add(membership);
if (status != MembershipStatus.Active)
await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken);
else if (role == TenantRole.TenantOwner)
await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken);
await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
await unitOfWork.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);
await authorizationStateInvalidator.InvalidateMembershipAsync(
actor.TenantId, membership.UserId, cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken);
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}
public async Task<ContentManagementResult<TenantAdminMemberItem>> DisableMemberAsync(
TenantAdminActor actor,
Guid membershipId,
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
await using var transaction = unitOfWork.Database.CurrentTransaction is null
? await unitOfWork.Database.BeginTransactionAsync(cancellationToken)
: null;
var membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == membershipId,
cancellationToken);
if (membership is null)
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
if (membership.UserId == actor.UserId)
throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
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);
await unitOfWork.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);
await authorizationStateInvalidator.InvalidateMembershipAsync(
actor.TenantId, membership.UserId, cancellationToken);
var user = await identityPersistence.Users.AsNoTracking()
.SingleAsync(item => item.Id == membership.UserId, cancellationToken);
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}
}