using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; using Tiku.Application.TenantAdmin; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken) { User? user = null; if (command.UserId.HasValue) { user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken); if (user is null) throw new TenantAdminDirectException("User was not found.", "user_not_found"); } else { var phone = Normalize(command.Phone); var email = Normalize(command.Email); var username = Normalize(command.Username); user = await dbContext.Users.FirstOrDefaultAsync(item => (phone != null && item.Phone == phone) || (email != null && item.Email == email) || (username != null && item.UserName == username), cancellationToken); if (user is null) { if (phone is null && email is null && username is null && Normalize(command.Name) is null) throw new TenantAdminDirectException("userId, phone, email, username or name is required.", "user_required"); user = new User { UserName = username ?? phone ?? email, Email = email, Phone = phone, Name = Normalize(command.Name) ?? username ?? phone ?? email, PrimaryRole = primaryRole, RawProfile = JsonDefaults.Object() }; dbContext.Users.Add(user); } } user.UserName = Normalize(command.Username) ?? user.UserName; user.Email = Normalize(command.Email) ?? user.Email; user.Phone = Normalize(command.Phone) ?? user.Phone; user.Name = Normalize(command.Name) ?? user.Name; user.AvatarUrl = Normalize(command.AvatarUrl) ?? user.AvatarUrl; user.PrimaryRole = string.IsNullOrWhiteSpace(user.PrimaryRole) ? primaryRole : user.PrimaryRole; return user; } private async Task EnsureMembershipAsync( Guid tenantId, Guid userId, TenantRole role, CancellationToken cancellationToken) { var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Role == role, 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, UserId = userId, Role = role, Status = MembershipStatus.Active }; dbContext.TenantMemberships.Add(membership); } 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) { return role == TenantRole.Student ? SaasQuotaMetricCatalog.StudentCount : SaasQuotaMetricCatalog.StaffCount; } private Task 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 EnsureStudentProfileAsync( Guid tenantId, Guid userId, Guid? regionId, Guid? schoolId, Guid? majorId, string? avatarPreset, JsonElement stats, JsonElement progress, JsonElement moduleSelections, CancellationToken cancellationToken) { var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId, cancellationToken); if (profile is null) { profile = new StudentProfile { TenantId = tenantId, UserId = userId, Stats = JsonDefaults.Object(), Progress = JsonDefaults.Object(), ModuleSelections = JsonDefaults.Object(), RecentActivities = JsonDefaults.Array() }; dbContext.StudentProfiles.Add(profile); } profile.RegionId = regionId ?? profile.RegionId; profile.SelectedSchoolId = schoolId ?? profile.SelectedSchoolId; profile.SelectedMajorId = majorId ?? profile.SelectedMajorId; profile.AvatarPreset = avatarPreset ?? profile.AvatarPreset; profile.Stats = JsonObjectOrDefault(stats); profile.Progress = JsonObjectOrDefault(progress); profile.ModuleSelections = JsonObjectOrDefault(moduleSelections); return profile; } private async Task> BuildStudentImportPreviewAsync( TenantAdminActor actor, CurrentDataScope scope, TenantAdminStudentImportCommand command, CancellationToken cancellationToken) { var items = new List(); var rowNo = 0; foreach (var row in command.Rows.Take(1000)) { rowNo++; string? reason = null; var phone = Normalize(row.User.Phone); var email = Normalize(row.User.Email); var name = Normalize(row.User.Name); if (row.User.UserId is null && phone is null && email is null && name is null) reason = "user_required"; else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId)) reason = "data_scope_denied"; else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken)) reason = "region_not_found"; else if (row.ClassId.HasValue) try { await AssertClassAsync(actor, scope, row.ClassId, cancellationToken); } catch (TenantAdminDirectException exception) { reason = exception.Code; } items.Add(new TenantAdminStudentImportPreviewItem( rowNo, reason is null, reason, phone, email, name, row.RegionId, row.ClassId)); } return items; } private async Task UpsertClassMemberCoreAsync( TenantAdminActor actor, Guid classId, Guid userId, TenantClassMemberType memberType, TenantClassMemberStatus status, JsonElement metadata, CancellationToken cancellationToken) { var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.ClassId == classId && member.UserId == userId && member.MemberType == memberType, cancellationToken); if (item is null) { item = new TenantClassMember { TenantId = actor.TenantId, ClassId = classId, UserId = userId, MemberType = memberType, JoinedAt = DateTimeOffset.UtcNow }; dbContext.TenantClassMembers.Add(item); } item.Status = status; item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null; item.Metadata = JsonObjectOrDefault(metadata); return item; } private async Task> GetSupervisionRulesCoreAsync( Guid tenantId, CancellationToken cancellationToken) { var settings = await dbContext.TenantSettings.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (settings is null || settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object || !settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) || rulesElement.ValueKind != JsonValueKind.Array) return []; return JsonSerializer.Deserialize(rulesElement.GetRawText()) ?? []; } private async Task SaveSupervisionRulesCoreAsync( Guid tenantId, IReadOnlyCollection rules, CancellationToken cancellationToken) { var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (settings is null) { settings = new TenantSettings { TenantId = tenantId }; dbContext.TenantSettings.Add(settings); } var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object ? JsonSerializer.Deserialize>(settings.AdminFeatureFlags.GetRawText()) ?? [] : []; existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules); settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing); } private async Task> BuildSupervisionRiskStudentsAsync( TenantAdminActor actor, CurrentDataScope scope, CancellationToken cancellationToken) { var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)) .Where(rule => rule.Enabled) .ToArray(); if (rules.Length == 0) return []; var regionIds = scope.RegionIds.ToArray(); var students = await dbContext.StudentProfiles.AsNoTracking() .Where(profile => profile.TenantId == actor.TenantId) .ApplyDataScope( scope, profile => profile.UserId == actor.UserId, profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) .Select(profile => new { Profile = profile, User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault() }) .ToArrayAsync(cancellationToken); var today = DateOnly.FromDateTime(DateTime.UtcNow); var result = new List(); foreach (var row in students) { var hitRules = new List(); var reasons = new List(); foreach (var rule in rules) { if (rule.DaysWithoutCheckIn.HasValue) { var days = row.Profile.LastCheckInDate.HasValue ? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber : int.MaxValue; if (days >= rule.DaysWithoutCheckIn.Value) { hitRules.Add(rule.Code); reasons.Add($"{rule.Title}: {days} days without check-in"); } } if (rule.MaxQuestionsAnsweredToday.HasValue && row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value) { hitRules.Add(rule.Code); reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}"); } } if (hitRules.Count > 0) result.Add(new TenantSupervisionRiskStudentItem( row.Profile.UserId, row.User?.Name, MaskPhone(row.User?.Phone), row.Profile.RegionId, hitRules.Distinct(StringComparer.Ordinal).ToArray(), reasons.Distinct(StringComparer.Ordinal).ToArray())); } return result; } private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { var exists = await dbContext.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId && item.Role == TenantRole.Student, cancellationToken); if (!exists) throw new TenantAdminDirectException("Student was not found.", "student_not_found"); } private async Task AssertStudentAsync( TenantAdminActor actor, CurrentDataScope scope, Guid userId, CancellationToken cancellationToken) { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var exists = await dbContext.TenantMemberships .Where(item => item.TenantId == actor.TenantId && item.UserId == userId && item.Role == TenantRole.Student) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))) .AnyAsync(cancellationToken); if (!exists) throw new TenantAdminDirectException("Student was not found.", "student_not_found"); } private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken) { if (!userId.HasValue) return; var exists = await dbContext.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId.Value && item.Status == MembershipStatus.Active, cancellationToken); if (!exists) throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); } private async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { await sessionStore.RevokeRealmAsync( userId, AuthRealm.Tenant, tenantId, "membership_disabled", cancellationToken); } private async Task EnsureTenantOwnerBackendRoleAsync( Guid tenantId, Guid userId, CancellationToken cancellationToken) { const string roleCode = "tenant_owner"; var role = await dbContext.TenantBackendRoles.FirstOrDefaultAsync( item => item.TenantId == tenantId && item.Code == roleCode, cancellationToken); if (role is null) { role = new TenantBackendRole { TenantId = tenantId, Code = roleCode, Name = "租户所有者", Status = BackendRoleStatus.Active, IsSystem = true, Description = "系统内置租户所有者角色", DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }) }; dbContext.TenantBackendRoles.Add(role); } else { role.Status = BackendRoleStatus.Active; role.IsSystem = true; role.DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }); } var tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); var existingPermissionCodes = await dbContext.BackendPermissions .Where(permission => tenantPermissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToArrayAsync(cancellationToken); foreach (var permissionCode in BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal)) dbContext.BackendPermissions.Add(new BackendPermission { Code = permissionCode, Name = permissionCode, Area = BackendPermissionArea.Tenant, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode), IsSystem = true }); var boundPermissionCodes = await dbContext.TenantBackendRolePermissions .Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id) .Select(binding => binding.PermissionCode) .ToArrayAsync(cancellationToken); dbContext.TenantBackendRolePermissions.AddRange( tenantPermissionCodes .Except(boundPermissionCodes, StringComparer.Ordinal) .Select(permissionCode => new TenantBackendRolePermission { TenantId = tenantId, RoleId = role.Id, PermissionCode = permissionCode })); if (!await dbContext.TenantBackendUserRoles.AnyAsync( binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id, cancellationToken)) dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = userId, RoleId = role.Id }); } private async Task EnsureBrandingThemeAsync( Guid tenantId, JsonElement theme, JsonElement publicAssets, CancellationToken cancellationToken) { var branding = await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (branding is null) { var tenantName = await dbContext.Tenants .Where(tenant => tenant.Id == tenantId) .Select(tenant => tenant.Name) .FirstOrDefaultAsync(cancellationToken); branding = new TenantBranding { TenantId = tenantId, BrandName = tenantName ?? "租户题库" }; dbContext.TenantBrandings.Add(branding); } branding.Theme = theme.Clone(); branding.PublicAssets = MergeJsonObjects(branding.PublicAssets, publicAssets); } private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken) { if (!classId.HasValue) return; var exists = await dbContext.TenantClasses.AnyAsync( item => item.TenantId == tenantId && item.Id == classId.Value, cancellationToken); if (!exists) throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } private async Task AssertClassAsync( TenantAdminActor actor, CurrentDataScope scope, Guid? classId, CancellationToken cancellationToken) { if (!classId.HasValue) return; var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var exists = await dbContext.TenantClasses .Where(item => item.TenantId == actor.TenantId && item.Id == classId.Value) .ApplyDataScope( scope, item => item.CreatedBy == actor.UserId, item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))) .AnyAsync(cancellationToken); if (!exists) throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } private async Task RequireDataScopeAsync( TenantAdminActor actor, CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); return access.DataScope; } private async Task RequireAllDataScopeAsync( TenantAdminActor actor, CancellationToken cancellationToken) { var scope = await RequireDataScopeAsync(actor, cancellationToken); if (scope.Mode != DataScopeMode.All) throw new TenantAdminDirectException("Tenant-wide resource was not found.", "tenant_resource_not_found"); } private async Task AssertReferenceAsync( Guid tenantId, Guid? id, string code, CancellationToken cancellationToken) where TEntity : TenantEntity { if (!id.HasValue) return; var exists = await dbContext.Set() .AnyAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); if (!exists) throw new TenantAdminDirectException("Referenced entity was not found in this tenant.", code); } private async Task ResolveTenantEntityAsync( DbSet set, Guid tenantId, Guid? id, string? legacyId, CancellationToken cancellationToken) where TEntity : AuditableTenantEntity { if (id.HasValue) return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); legacyId = Normalize(legacyId); if (legacyId is null) return null; return typeof(TEntity).GetProperty("LegacyId") is null ? null : await set.FirstOrDefaultAsync( entity => entity.TenantId == tenantId && EF.Property(entity, "LegacyId") == legacyId, cancellationToken); } private async Task AddAuditAsync( TenantAdminActor actor, string action, string targetType, Guid targetId, CancellationToken cancellationToken) { dbContext.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonDefaults.Object() }); await Task.CompletedTask.WaitAsync(cancellationToken); } }