using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Auth; using Tiku.Application.Content; using Tiku.Application.Notifications; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Tenancy; using Tiku.Infrastructure.Security; using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; using OrderStatus = Tiku.Domain.Commerce.OrderStatus; using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; public sealed class TenantAdminDirectService( TikuDbContext dbContext, ITenantExternalProviderConfigService providerConfigService, INotificationProvider notificationProvider, ICurrentAccessContext currentAccessContext, IAuthSessionStore sessionStore, IFeatureAccessService featureAccessService, IAuthorizationStateInvalidator authorizationStateInvalidator) : ITenantAdminDirectService { public async Task GetOverviewAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var now = DateTimeOffset.UtcNow; var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero); var scopedClasses = dbContext.TenantClasses.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.CreatedBy == actor.UserId, item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); var scopedStudents = dbContext.StudentProfiles.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); var scopedOrders = dbContext.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); var studentCount = await scopedStudents.CountAsync(cancellationToken); var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken); var staffCount = await dbContext.TenantMemberships.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.Status == MembershipStatus.Active && item.Role != TenantRole.Student, cancellationToken); var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.FinishedAt == null && (!item.ExpiresAt.HasValue || item.ExpiresAt > now), cancellationToken); var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken); var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress), cancellationToken); var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken); var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken); var revenueCents = await scopedOrders .Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded) .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && (item.Status == CommerceRefundStatus.Requested || item.Status == CommerceRefundStatus.Approved || item.Status == CommerceRefundStatus.Processing), cancellationToken); var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored, cancellationToken); return new TenantAdminOverviewItem( studentCount, classCount, staffCount, activePracticeCount, todayPracticeCount, pendingFollowupCount, unreadNotificationCount, paidOrderCount, revenueCents, pendingRefundCount, openReconciliationIssueCount, now); } public async Task GetClassesAsync( TenantAdminActor actor, TenantAdminClassFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantClasses.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.CreatedBy == actor.UserId, item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (!string.IsNullOrWhiteSpace(filter.Status)) { query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_class_status")); } else { query = query.Where(item => item.Status != TenantRecordStatus.Archived); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Name.Contains(keyword) || (item.Code != null && item.Code.Contains(keyword))); } var items = await query .OrderBy(item => item.SortOrder) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => new { Class = item, RegionName = dbContext.Regions .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId) .Select(region => region.Name) .FirstOrDefault(), StudentCount = dbContext.TenantClassMembers.Count(member => member.TenantId == actor.TenantId && member.ClassId == item.Id && member.Status == TenantClassMemberStatus.Active && member.MemberType == TenantClassMemberType.Student), StaffCount = dbContext.TenantClassMembers.Count(member => member.TenantId == actor.TenantId && member.ClassId == item.Id && member.Status == TenantClassMemberStatus.Active && member.MemberType != TenantClassMemberType.Student) }) .ToArrayAsync(cancellationToken); return new TenantAdminClassList( items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(), Scoped: scope.Mode != DataScopeMode.All); } public async Task> UpsertClassAsync( TenantAdminActor actor, UpsertTenantAdminClassCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; if (item is not null && !scope.AllowsResource(actor.UserId, item.CreatedBy, item.RegionId, item.Id)) { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } if (item is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId, command.Id)) { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.Code = Normalize(command.Code); item.Name = command.Name.Trim(); item.Description = Normalize(command.Description); item.Status = ParseEnum(command.Status, TenantRecordStatus.Active, "invalid_class_status"); item.SortOrder = command.Order ?? item.SortOrder; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantClasses.Add(item); } await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var regionName = item.RegionId.HasValue ? await dbContext.Regions .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId.Value) .Select(region => region.Name) .FirstOrDefaultAsync(cancellationToken) : null; return new ContentManagementResult(ToClassItem(item, regionName, 0, 0)); } public async Task> DisableClassAsync( TenantAdminActor actor, Guid classId, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var item = await dbContext.TenantClasses .Where(entity => entity.TenantId == actor.TenantId && entity.Id == classId) .ApplyDataScope( scope, entity => entity.CreatedBy == actor.UserId, entity => classIds.Contains(entity.Id) || (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value))) .FirstOrDefaultAsync(cancellationToken); if (item is null) { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } item.Status = TenantRecordStatus.Disabled; item.UpdatedBy = actor.UserId; await AddAuditAsync(actor, "tenant.class.disabled", "tenant_classes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassItem(item, null, 0, 0)); } public async Task> GetClassMembersAsync( TenantAdminActor actor, TenantAdminClassMemberFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertClassAsync(actor, scope, filter.ClassId, cancellationToken); var classIds = scope.ClassIds.ToArray(); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.TenantClassMembers.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId) .ApplyDataScope( scope, item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, item => classIds.Contains(item.ClassId) || dbContext.TenantClasses.Any(tenantClass => tenantClass.TenantId == actor.TenantId && tenantClass.Id == item.ClassId && tenantClass.RegionId.HasValue && regionIds.Contains(tenantClass.RegionId.Value))); if (!string.IsNullOrWhiteSpace(filter.MemberType)) { query = query.Where(item => item.MemberType == ParseEnum(filter.MemberType, "invalid_class_member_type")); } query = query.Where(item => item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status")); var items = await query .OrderBy(item => item.MemberType == TenantClassMemberType.HeadTeacher ? 0 : item.MemberType == TenantClassMemberType.Teacher ? 1 : item.MemberType == TenantClassMemberType.Assistant ? 2 : 9) .ThenBy(item => item.JoinedAt) .Take(ResolveLimit(filter.Limit)) .Join( dbContext.Users.AsNoTracking(), member => member.UserId, user => user.Id, (member, user) => ToClassMemberItem(member, ToUserSummary(user))) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertClassMemberAsync( TenantAdminActor actor, UpsertTenantAdminClassMemberCommand command, CancellationToken cancellationToken = default) { 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); await EnsureMembershipAsync(actor.TenantId, user.Id, memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken); if (memberType == TenantClassMemberType.Student) { await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); } var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.ClassId == command.ClassId && member.UserId == user.Id && member.MemberType == memberType, cancellationToken); var isNew = item is null; item ??= new TenantClassMember { TenantId = actor.TenantId, ClassId = command.ClassId, UserId = user.Id, MemberType = memberType, CreatedBy = actor.UserId }; item.Status = status; item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantClassMembers.Add(item); } 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(ToClassMemberItem(item, ToUserSummary(user))); } public async Task> RemoveClassMemberAsync( TenantAdminActor actor, Guid classMemberId, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var classIds = scope.ClassIds.ToArray(); var regionIds = scope.RegionIds.ToArray(); var item = await dbContext.TenantClassMembers .Where(member => member.TenantId == actor.TenantId && member.Id == classMemberId) .ApplyDataScope( scope, member => member.UserId == actor.UserId || member.CreatedBy == actor.UserId, member => classIds.Contains(member.ClassId) || dbContext.TenantClasses.Any(tenantClass => tenantClass.TenantId == actor.TenantId && tenantClass.Id == member.ClassId && tenantClass.RegionId.HasValue && regionIds.Contains(tenantClass.RegionId.Value))) .FirstOrDefaultAsync(cancellationToken); if (item is null) { throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found"); } var user = await dbContext.Users.SingleAsync(user => user.Id == item.UserId, cancellationToken); item.Status = TenantClassMemberStatus.Removed; item.LeftAt = DateTimeOffset.UtcNow; item.UpdatedBy = actor.UserId; await AddAuditAsync(actor, "tenant.class_member.removed", "tenant_class_members", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); } public async Task GetStudentsAsync( TenantAdminActor actor, TenantAdminStudentFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status"); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status) .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))); if (filter.ClassId.HasValue) { await AssertClassAsync(actor, scope, filter.ClassId.Value, cancellationToken); query = query.Where(item => dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.ClassId == filter.ClassId.Value && member.UserId == item.UserId && member.MemberType == TenantClassMemberType.Student && member.Status == TenantClassMemberStatus.Active)); } if (filter.RegionId.HasValue) { query = query.Where(item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId == filter.RegionId.Value)); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => dbContext.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 .OrderByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = memberships.Select(item => item.UserId).ToArray(); var users = await dbContext.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); var profiles = await dbContext.StudentProfiles.AsNoTracking() .Where(profile => profile.TenantId == actor.TenantId && userIds.Contains(profile.UserId)) .ToDictionaryAsync(profile => profile.UserId, cancellationToken); var regions = await dbContext.Regions.AsNoTracking() .Where(region => region.TenantId == actor.TenantId) .ToDictionaryAsync(region => region.Id, region => region.Name, cancellationToken); var schools = await dbContext.Schools.AsNoTracking() .Where(school => school.TenantId == actor.TenantId) .ToDictionaryAsync(school => school.Id, school => school.Name, cancellationToken); var majors = await dbContext.Majors.AsNoTracking() .Where(major => major.TenantId == actor.TenantId) .ToDictionaryAsync(major => major.Id, major => major.Name, cancellationToken); var classes = await ( from member in dbContext.TenantClassMembers.AsNoTracking() join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id where member.TenantId == actor.TenantId && tenantClass.TenantId == actor.TenantId && userIds.Contains(member.UserId) && member.Status == TenantClassMemberStatus.Active && member.MemberType == TenantClassMemberType.Student orderby tenantClass.SortOrder, tenantClass.CreatedAt descending select new { member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, tenantClass.Code }) .ToArrayAsync(cancellationToken); var classLookup = classes .GroupBy(item => item.UserId) .ToDictionary( group => group.Key, group => group.Select(item => new TenantAdminStudentClassSummary( item.ClassId, item.Name, item.Code, item.MemberType, item.JoinedAt)).ToArray() as IReadOnlyCollection); return new TenantAdminStudentList( memberships.Select(membership => { users.TryGetValue(membership.UserId, out var user); profiles.TryGetValue(membership.UserId, out var profile); classLookup.TryGetValue(membership.UserId, out var studentClasses); return ToStudentItem( membership, user ?? new User { Id = membership.UserId }, profile, regions, schools, majors, studentClasses ?? []); }).ToArray(), Scoped: scope.Mode != DataScopeMode.All); } public async Task> UpsertStudentAsync( TenantAdminActor actor, UpsertTenantAdminStudentCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(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)) { throw new TenantAdminDirectException("Student was not found.", "student_not_found"); } if (command.RawProfile.ValueKind == JsonValueKind.Object) { user.RawProfile = command.RawProfile.Clone(); } var membership = await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); var profile = await EnsureStudentProfileAsync( actor.TenantId, user.Id, command.RegionId, command.SelectedSchoolId, command.SelectedMajorId, Normalize(command.AvatarPreset), command.Stats, command.Progress, command.ModuleSelections, cancellationToken); 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( ToStudentItem( membership, user, profile, new Dictionary(), new Dictionary(), new Dictionary(), [])); } public async Task> UpdateStudentStatusAsync( TenantAdminActor actor, UpdateTenantAdminStudentStatusCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status"); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var membership = await dbContext.TenantMemberships .Where(item => item.TenantId == actor.TenantId && item.UserId == command.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))) .FirstOrDefaultAsync(cancellationToken); if (membership is null) { 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) { await sessionStore.RevokeRealmAsync( command.UserId, AuthRealm.Tenant, actor.TenantId, "membership_disabled", cancellationToken); } 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); } await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); return new ContentManagementResult( new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); } public async Task PreviewStudentImportAsync( TenantAdminActor actor, TenantAdminStudentImportCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); return new TenantAdminStudentImportPreview( command.Rows.Count, items.Count(item => item.Valid), items.Count(item => !item.Valid), items); } public async Task ImportStudentsAsync( TenantAdminActor actor, TenantAdminStudentImportCommand command, CancellationToken cancellationToken = default) { 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; foreach (var row in command.Rows) { rowNo++; if (invalidItems.Any(item => item.RowNo == rowNo)) { continue; } var user = await ResolveUserAsync(row.User, "student", cancellationToken); if (row.RawProfile.ValueKind == JsonValueKind.Object) { user.RawProfile = row.RawProfile.Clone(); } await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); await EnsureStudentProfileAsync( actor.TenantId, user.Id, row.RegionId, null, null, Normalize(row.AvatarPreset), JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); createdOrUpdated++; if (row.ClassId.HasValue) { await UpsertClassMemberCoreAsync( actor, row.ClassId.Value, user.Id, TenantClassMemberType.Student, TenantClassMemberStatus.Active, JsonSerializer.SerializeToElement(new { source = "student_import" }), cancellationToken); classAssigned++; } } 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); } public async Task BulkAssignClassAsync( TenantAdminActor actor, TenantAdminBulkAssignClassCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); var failed = new List(); var succeeded = 0; foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) { try { await AssertStudentAsync(actor, scope, userId, cancellationToken); await UpsertClassMemberCoreAsync( actor, command.ClassId, userId, TenantClassMemberType.Student, TenantClassMemberStatus.Active, JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }), cancellationToken); succeeded++; } catch (TenantAdminDirectException) { failed.Add(userId); } } await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } public async Task BulkUpdateStudentStatusAsync( TenantAdminActor actor, TenantAdminBulkStatusCommand command, CancellationToken cancellationToken = default) { var failed = new List(); var succeeded = 0; foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) { try { await UpdateStudentStatusAsync( actor, new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason), cancellationToken); succeeded++; } catch (TenantAdminDirectException) { failed.Add(userId); } } await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } public async Task> GetSupervisionRulesAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); return new CatalogList(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)); } public async Task> UpsertSupervisionRuleAsync( TenantAdminActor actor, UpsertTenantSupervisionRuleCommand command, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); var code = NormalizeCode(command.Code); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList(); var item = new TenantSupervisionRuleItem( code, command.Title.Trim(), command.Enabled, command.DaysWithoutCheckIn, command.MaxQuestionsAnsweredToday, Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(), Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(), JsonObjectOrDefault(command.Metadata)); rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal)); rules.Add(item); await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken); await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task PreviewSupervisionAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken)); } public async Task GenerateSupervisionFollowupsAsync( TenantAdminActor actor, TenantSupervisionGenerateCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken); if (command.UserIds is { Count: > 0 }) { var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet(); riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray(); } var created = 0; foreach (var student in riskStudents) { if (await dbContext.TenantStudentFollowups.AnyAsync(item => item.TenantId == actor.TenantId && item.StudentUserId == student.UserId && item.Status != StudentFollowupStatus.Done && item.FollowupType == StudentFollowupType.Risk, cancellationToken)) { continue; } dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup { TenantId = actor.TenantId, StudentUserId = student.UserId, AssignedToUserId = command.AssignedToUserId, Title = "学习风险督导", Description = string.Join(";", student.Reasons), FollowupType = StudentFollowupType.Risk, Priority = StudentFollowupPriority.High, Status = StudentFollowupStatus.Open, DueAt = command.DueAt, CreatedBy = actor.UserId, UpdatedBy = actor.UserId, Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes }) }); created++; } await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantSupervisionGenerateResult(created); } public async Task GetFollowupReportAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; return new TenantFollowupReport( await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken), await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken), await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken), await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.DueAt.HasValue && item.DueAt < now && item.Status != StudentFollowupStatus.Done && item.Status != StudentFollowupStatus.Cancelled, cancellationToken)); } public async Task GetFeedbackReportAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); return new TenantFeedbackReport( await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); } public async Task GetPointRiskReportAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); var negativeScoreUsers = await dbContext.Users .Where(user => user.Score < 0 && dbContext.TenantMemberships.Any(member => member.TenantId == actor.TenantId && member.UserId == user.Id && member.Status == MembershipStatus.Active)) .CountAsync(cancellationToken); var since = DateTimeOffset.UtcNow.AddDays(-7); var highClaimUsers = await dbContext.PointActivityClaims .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since) .GroupBy(item => item.UserId) .Where(group => group.Sum(item => item.Points) >= 1000) .CountAsync(cancellationToken); var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync( item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled, cancellationToken); return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders); } public async Task> GetStudentNotesAsync( TenantAdminActor actor, TenantAdminStudentActivityFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantStudentNotes.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.StudentUserId == actor.UserId || item.CreatedBy == actor.UserId, item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); if (filter.StudentUserId.HasValue) { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); } var items = await query .OrderByDescending(item => item.IsPinned) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => ToNoteItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertStudentNoteAsync( TenantAdminActor actor, UpsertTenantAdminStudentNoteCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Content); await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); TenantStudentNote? item = null; if (command.Id.HasValue) { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); item = await dbContext.TenantStudentNotes .Where(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value) .ApplyDataScope( scope, note => note.StudentUserId == actor.UserId || note.CreatedBy == actor.UserId, note => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == note.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == note.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (item is null) { throw new TenantAdminDirectException("Student note was not found.", "student_note_not_found"); } } var isNew = item is null; item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type"); item.Content = command.Content.Trim(); item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, "invalid_student_note_visibility"); item.IsPinned = command.IsPinned ?? item.IsPinned; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantStudentNotes.Add(item); } await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNoteItem(item)); } public async Task> GetStudentFollowupsAsync( TenantAdminActor actor, TenantAdminStudentActivityFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantStudentFollowups.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || item.CreatedBy == actor.UserId, item => (item.ClassId.HasValue && classIds.Contains(item.ClassId.Value)) || dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); if (filter.StudentUserId.HasValue) { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); } if (!string.IsNullOrWhiteSpace(filter.Status)) { query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_student_followup_status")); } var items = await query .OrderBy(item => item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled) .ThenBy(item => item.DueAt ?? DateTimeOffset.MaxValue) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => ToFollowupItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertStudentFollowupAsync( TenantAdminActor actor, UpsertTenantAdminStudentFollowupCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); TenantStudentFollowup? item = null; if (command.Id.HasValue) { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); item = await dbContext.TenantStudentFollowups .Where(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value) .ApplyDataScope( scope, followup => followup.StudentUserId == actor.UserId || followup.AssignedToUserId == actor.UserId || followup.CreatedBy == actor.UserId, followup => (followup.ClassId.HasValue && classIds.Contains(followup.ClassId.Value)) || dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == followup.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == followup.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (item is null) { throw new TenantAdminDirectException("Student followup was not found.", "student_followup_not_found"); } } var isNew = item is null; item ??= new TenantStudentFollowup { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; item.AssignedToUserId = command.AssignedToUserId; item.ClassId = command.ClassId; item.Title = command.Title.Trim(); item.Description = Normalize(command.Description); item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, "invalid_student_followup_type"); item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, "invalid_student_followup_priority"); item.Status = ParseEnum(command.Status, StudentFollowupStatus.Open, "invalid_student_followup_status"); item.DueAt = command.DueAt; item.CompletedAt = item.Status == StudentFollowupStatus.Done ? DateTimeOffset.UtcNow : null; item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantStudentFollowups.Add(item); } await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToFollowupItem(item)); } public async Task> GetMembersAsync( TenantAdminActor actor, TenantAdminMemberFilter filter, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Role)) { query = query.Where(item => item.Role == ParseEnum(filter.Role, "invalid_member_role")); } if (!string.IsNullOrWhiteSpace(filter.Status)) { query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_member_status")); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => dbContext.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 dbContext.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); return new CatalogList(memberships.Select(item => { users.TryGetValue(item.UserId, out var user); return ToMemberItem(item, user ?? new User { Id = item.UserId }); }).ToArray()); } public async Task> UpsertMemberAsync( TenantAdminActor actor, UpsertTenantAdminMemberCommand command, 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); 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 dbContext.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 dbContext.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) { dbContext.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 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); } await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); } public async Task> DisableMemberAsync( TenantAdminActor actor, Guid membershipId, 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); 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 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); } await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); } public async Task> GetAuditLogsAsync( TenantAdminActor actor, TenantAdminAuditLogFilter filter, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Action)) { var action = filter.Action.Trim(); query = query.Where(item => item.Action.StartsWith(action)); } if (!string.IsNullOrWhiteSpace(filter.TargetType)) { query = query.Where(item => item.TargetType == filter.TargetType.Trim()); } if (filter.ActorUserId.HasValue) { query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value); } var logs = await query .OrderByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var actorIds = logs.Where(item => item.ActorUserId.HasValue).Select(item => item.ActorUserId!.Value).ToArray(); var users = await dbContext.Users.AsNoTracking() .Where(user => actorIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); return new CatalogList(logs.Select(item => { var user = item.ActorUserId.HasValue && users.TryGetValue(item.ActorUserId.Value, out var actorUser) ? actorUser : null; return new TenantAdminAuditLogItem( item.Id, item.ActorUserId, item.Action, item.TargetType, item.TargetId, item.Details, item.IpAddress, item.UserAgent, user?.Name ?? user?.UserName, user?.Phone, item.CreatedAt); }).ToArray()); } public async Task> UpsertBrandingAsync( TenantAdminActor actor, UpsertTenantBrandingCommand command, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName); var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken); if (item is null) { item = new TenantBranding { TenantId = actor.TenantId }; dbContext.TenantBrandings.Add(item); } item.BrandName = command.BrandName.Trim(); item.ShortName = Normalize(command.ShortName); item.Slogan = Normalize(command.Slogan); item.OrganizationName = Normalize(command.OrganizationName); item.LogoUrl = Normalize(command.LogoUrl); item.FaviconUrl = Normalize(command.FaviconUrl); item.ServiceWechat = Normalize(command.ServiceWechat); item.ServiceAccountName = Normalize(command.ServiceAccountName); await AddAuditAsync(actor, "tenant.branding.updated", "tenant_branding", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToBrandingItem(item)); } public async Task> UpsertSettingsAsync( TenantAdminActor actor, UpsertTenantSettingsCommand command, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); AssertNoSecrets(command.PublicConfig, "public_config"); var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken); if (item is null) { item = new TenantSettings { TenantId = actor.TenantId }; dbContext.TenantSettings.Add(item); } item.FeatureFlags = JsonObjectOrDefault(command.FeatureFlags); item.AdminFeatureFlags = JsonObjectOrDefault(command.AdminFeatureFlags); item.PublicConfig = JsonObjectOrDefault(command.PublicConfig); await AddAuditAsync(actor, "tenant.settings.updated", "tenant_settings", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToSettingsItem(item)); } public async Task> GetThemeTemplatesAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); await Task.CompletedTask.WaitAsync(cancellationToken); var items = await dbContext.TenantThemeTemplates.AsNoTracking() .Where(item => item.Status == TenantThemeTemplateStatus.Active) .OrderBy(item => item.SortOrder) .ThenBy(item => item.Code) .Select(item => new TenantThemeTemplateItem( item.Code, item.Name, item.Description, item.PreviewImageUrl, item.Theme, item.PublicAssets, item.SortOrder)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetThemeAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var item = await dbContext.TenantThemeConfigs.AsNoTracking() .FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); if (item is not null) { return new ContentManagementResult(ToThemeItem(item)); } var branding = await dbContext.TenantBrandings.AsNoTracking() .FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken); return new ContentManagementResult(new TenantThemeItem( actor.TenantId, null, branding?.Theme ?? JsonDefaults.Object(), branding?.PublicAssets ?? JsonDefaults.Object(), null, JsonDefaults.Object(), JsonDefaults.Object(), TenantThemeConfigStatus.Published, null, null, null, branding?.UpdatedAt ?? DateTimeOffset.UtcNow)); } public async Task> PreviewThemeAsync( TenantAdminActor actor, PreviewTenantThemeCommand command, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active, cancellationToken); if (template is null) { throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); } var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); if (item is null) { item = new TenantThemeConfig { TenantId = actor.TenantId }; dbContext.TenantThemeConfigs.Add(item); } item.DraftTemplateCode = template.Code; item.DraftTheme = MergeJsonObjects(template.Theme, command.Theme); item.DraftPublicAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); item.Status = TenantThemeConfigStatus.Draft; item.DraftUpdatedBy = actor.UserId; await AddAuditAsync(actor, "tenant.theme.previewed", "tenant_theme_configs", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToThemeItem(item)); } public async Task> PublishThemeAsync( TenantAdminActor actor, PublishTenantThemeCommand command, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); JsonElement activeTheme; JsonElement activeAssets; string? templateCode; if (command.UseDraft) { if (item is null || string.IsNullOrWhiteSpace(item.DraftTemplateCode)) { throw new TenantAdminDirectException("No draft theme to publish.", "theme_draft_not_found"); } activeTheme = item.DraftTheme; activeAssets = item.DraftPublicAssets; templateCode = item.DraftTemplateCode; } else { ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( theme => theme.Code == command.TemplateCode && theme.Status == TenantThemeTemplateStatus.Active, cancellationToken); if (template is null) { throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); } item ??= new TenantThemeConfig { TenantId = actor.TenantId }; if (dbContext.Entry(item).State == EntityState.Detached) { dbContext.TenantThemeConfigs.Add(item); } activeTheme = MergeJsonObjects(template.Theme, command.Theme); activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); templateCode = template.Code; } item ??= new TenantThemeConfig { TenantId = actor.TenantId }; if (dbContext.Entry(item).State == EntityState.Detached) { dbContext.TenantThemeConfigs.Add(item); } item.ActiveTemplateCode = templateCode; item.ActiveTheme = activeTheme.Clone(); item.ActivePublicAssets = activeAssets.Clone(); item.DraftTemplateCode = null; item.DraftTheme = JsonDefaults.Object(); item.DraftPublicAssets = JsonDefaults.Object(); item.Status = TenantThemeConfigStatus.Published; item.PublishedAt = DateTimeOffset.UtcNow; item.PublishedBy = actor.UserId; await EnsureBrandingThemeAsync(actor.TenantId, activeTheme, activeAssets, cancellationToken); await AddAuditAsync(actor, "tenant.theme.published", "tenant_theme_configs", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToThemeItem(item)); } public async Task> GetDomainsAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var items = await dbContext.TenantDomains.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .OrderByDescending(item => item.IsPrimary) .ThenBy(item => item.CreatedAt) .Select(item => ToDomainItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> CreateDomainAsync( TenantAdminActor actor, CreateTenantDomainCommand command, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); TenantDomain generated; try { generated = TenantDomainProvisioning.CreatePrimary(actor.TenantId, command.Host); } catch (ArgumentException exception) { throw new TenantAdminDirectException(exception.Message, "invalid_domain_host"); } var host = generated.Host; if (command.IsPrimary) { var primaryDomains = await dbContext.TenantDomains .Where(item => item.TenantId == actor.TenantId && item.IsPrimary) .ToArrayAsync(cancellationToken); foreach (var domain in primaryDomains) { domain.IsPrimary = false; } } var item = new TenantDomain { TenantId = actor.TenantId, Host = host, DomainType = ParseEnum(command.DomainType, TenantDomainType.Custom, "invalid_domain_type"), Status = TenantDomainStatus.Pending, IsPrimary = command.IsPrimary, VerificationToken = generated.VerificationToken }; dbContext.TenantDomains.Add(item); await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToDomainItem(item)); } public async Task> GetAuthProvidersAsync( TenantAdminActor actor, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var items = await providerConfigService.GetProvidersAsync( actor.TenantId, TenantExternalProviderCapability.Identity, cancellationToken: cancellationToken); return new CatalogList(items.Select(ToAuthProviderItem).ToArray()); } public async Task> UpsertAuthProviderAsync( TenantAdminActor actor, UpsertTenantIdentityProviderCommand command, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider); var item = await providerConfigService.UpsertProviderAsync( actor.TenantId, new UpsertTenantExternalProviderCommand( TenantExternalProviderCapability.Identity, command.Provider, ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"), command.DisplayName, command.SecretRef, command.Priority, command.ConfigPublic, JsonObjectOrDefault(default)), cancellationToken); await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToAuthProviderItem(item)); } public async Task> GetBadgesAsync( TenantAdminActor actor, TenantAdminBadgeFilter filter, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Category)) { query = query.Where(item => item.Category == filter.Category.Trim()); } if (!filter.IncludeInactive) { query = query.Where(item => item.IsActive); } var items = await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.Level ?? int.MaxValue) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => ToBadgeItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertBadgeAsync( TenantAdminActor actor, UpsertTenantAdminBadgeCommand command, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.LegacyId = Normalize(command.LegacyId); item.Name = command.Name.Trim(); item.Description = Normalize(command.Description); item.Category = Normalize(command.Category) ?? "custom"; item.IconUrl = Normalize(command.IconUrl); item.Level = command.Level; item.UnlockType = Normalize(command.UnlockType) ?? "manual"; item.ConditionField = Normalize(command.ConditionField); item.ConditionOperator = Normalize(command.ConditionOperator); item.ConditionValue = command.ConditionValue; item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; if (isNew) { dbContext.Badges.Add(item); } await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToBadgeItem(item)); } public async Task> GetBadgeGrantsAsync( TenantAdminActor actor, TenantAdminBadgeGrantFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.UserBadges.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.UserId == actor.UserId || item.GrantedBy == actor.UserId, item => item.UserId.HasValue && (dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId.Value && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId)))); if (filter.UserId.HasValue) { query = query.Where(item => item.UserId == filter.UserId.Value); } if (filter.BadgeId.HasValue) { query = query.Where(item => item.BadgeId == filter.BadgeId.Value); } var grants = await query .OrderByDescending(item => item.GrantedAt ?? item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = grants.Select(item => item.UserId).OfType().Concat(grants.Select(item => item.GrantedBy).OfType()).Distinct().ToArray(); var badgeIds = grants.Select(item => item.BadgeId).OfType().Distinct().ToArray(); var users = await dbContext.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); var badges = await dbContext.Badges.AsNoTracking() .Where(badge => badge.TenantId == actor.TenantId && badgeIds.Contains(badge.Id)) .ToDictionaryAsync(badge => badge.Id, cancellationToken); return new CatalogList(grants.Select(grant => ToBadgeGrantItem( grant, grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null, grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) ? grantedBy : null, grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)).ToArray()); } public async Task> GrantBadgeAsync( TenantAdminActor actor, GrantTenantAdminBadgeCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var badge = await dbContext.Badges.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.BadgeId, cancellationToken); if (badge is null) { throw new TenantAdminDirectException("Badge was not found.", "badge_not_found"); } if (!badge.IsActive) { throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive"); } await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); var grant = await dbContext.UserBadges.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId, cancellationToken); var isNew = grant is null; grant ??= new UserBadge { TenantId = actor.TenantId, UserId = command.UserId, BadgeId = command.BadgeId, GrantedBy = actor.UserId, GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow }; grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId; grant.Note = Normalize(command.Note) ?? grant.Note; grant.GrantedBy ??= actor.UserId; grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow; if (isNew) { dbContext.UserBadges.Add(grant); } var dedupeKey = $"badge:{grant.Id:N}"; await notificationProvider.UpsertInAppAsync( new InAppNotificationRequest( actor.TenantId, command.UserId, "badge_granted", NotificationSeverity.Success, $"获得勋章:{badge.Name}", Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。", actor.UserId, "查看勋章", "/profile?tab=badges", "user_badges", grant.Id, dedupeKey, JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })), cancellationToken); await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var user = await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken); return new ContentManagementResult(ToBadgeGrantItem(grant, user, null, badge)); } public async Task> GetNotificationsAsync( TenantAdminActor actor, TenantAdminNotificationFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.UserNotifications.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.UserId == actor.UserId || item.CreatedBy == 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))); if (filter.UserId.HasValue) { query = query.Where(item => item.UserId == filter.UserId.Value); } if (!string.IsNullOrWhiteSpace(filter.Status)) { query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_notification_status")); } if (!string.IsNullOrWhiteSpace(filter.NotificationType)) { query = query.Where(item => item.NotificationType == filter.NotificationType.Trim()); } var items = await query .OrderByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => ToNotificationItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertNotificationAsync( TenantAdminActor actor, UpsertTenantAdminNotificationCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); ArgumentException.ThrowIfNullOrWhiteSpace(command.Message); await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); var item = await notificationProvider.UpsertInAppAsync( new InAppNotificationRequest( actor.TenantId, command.UserId, command.NotificationType, ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"), command.Title, command.Message, actor.UserId, command.ActionLabel, command.ActionPath, command.SourceType, command.SourceId, command.DedupeKey, JsonObjectOrDefault(command.Metadata)), cancellationToken); await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNotificationItem(item)); } public async Task> GetFeedbacksAsync( TenantAdminActor actor, TenantAdminFeedbackFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.Reports.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, item => item.UserId.HasValue && (dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId.Value && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId)))); if (filter.UserId.HasValue) { query = query.Where(item => item.UserId == filter.UserId.Value); } if (!string.IsNullOrWhiteSpace(filter.Status)) { query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_feedback_status")); } if (!string.IsNullOrWhiteSpace(filter.Type)) { query = query.Where(item => item.Type == ParseEnum(filter.Type, "invalid_feedback_type")); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => (item.Title != null && item.Title.Contains(keyword)) || (item.Description != null && item.Description.Contains(keyword)) || (item.Contact != null && item.Contact.Contains(keyword))); } var reports = await query .OrderByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = reports.Select(item => item.UserId).OfType().Distinct().ToArray(); var users = await dbContext.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); return new CatalogList(reports.Select(report => ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray()); } public async Task> UpdateFeedbackAsync( TenantAdminActor actor, UpdateTenantAdminFeedbackCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var report = await dbContext.Reports .Where(item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId) .ApplyDataScope( scope, item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, item => item.UserId.HasValue && (dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId.Value && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId)))) .FirstOrDefaultAsync(cancellationToken); if (report is null) { throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found"); } var fromStatus = report.Status; report.Status = ParseEnum(command.Status, report.Status, "invalid_feedback_status"); report.Priority = ParseEnum(command.Priority, report.Priority, "invalid_feedback_priority"); report.Resolution = Normalize(command.Resolution) ?? report.Resolution; if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved or ReportStatus.Closed) { report.HandledBy = actor.UserId; report.HandledAt = DateTimeOffset.UtcNow; } dbContext.ReportStatusEvents.Add(new ReportStatusEvent { TenantId = actor.TenantId, ReportId = report.Id, FromStatus = fromStatus, ToStatus = report.Status, Note = Normalize(command.Note), ActorUserId = actor.UserId, Metadata = JsonObjectOrDefault(command.Metadata) }); await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var user = report.UserId.HasValue ? await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken) : null; return new ContentManagementResult(ToFeedbackItem(report, user)); } 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) => 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); } private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, int staffCount) { return new TenantAdminClassItem( item.Id, item.RegionId, regionName, item.LegacyId, item.Code, item.Name, item.Description, item.Status, item.SortOrder, item.Metadata, studentCount, staffCount, item.CreatedAt, item.UpdatedAt); } private static TenantAdminClassMemberItem ToClassMemberItem(TenantClassMember item, TenantAdminUserSummary user) { return new TenantAdminClassMemberItem( item.Id, item.ClassId, item.UserId, item.MemberType, item.Status, item.JoinedAt, item.LeftAt, item.Metadata, user); } private static TenantAdminStudentItem ToStudentItem( TenantMembership membership, User user, StudentProfile? profile, IReadOnlyDictionary regions, IReadOnlyDictionary schools, IReadOnlyDictionary majors, IReadOnlyCollection classes) { return new TenantAdminStudentItem( membership.Id, membership.UserId, membership.Status, ToUserSummary(user), profile?.Id, profile?.AvatarPreset ?? "male", profile?.RegionId, profile?.RegionId is Guid regionId && regions.TryGetValue(regionId, out var regionName) ? regionName : null, profile?.SelectedSchoolId, profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) ? schoolName : null, profile?.SelectedMajorId, profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) ? majorName : null, profile?.Stats ?? JsonDefaults.Object(), profile?.Progress ?? JsonDefaults.Object(), profile?.ModuleSelections ?? JsonDefaults.Object(), classes, membership.CreatedAt, membership.UpdatedAt); } private static TenantAdminUserSummary ToUserSummary(User user) { return new TenantAdminUserSummary( user.Id, user.UserName, user.Email, user.Phone, user.Name, user.AvatarUrl, user.PrimaryRole); } private static TenantAdminStudentNoteItem ToNoteItem(TenantStudentNote item) { return new TenantAdminStudentNoteItem( item.Id, item.StudentUserId, item.NoteType, item.Content, item.Visibility, item.IsPinned, item.Metadata, item.CreatedBy, item.UpdatedBy, item.CreatedAt, item.UpdatedAt); } private static TenantAdminStudentFollowupItem ToFollowupItem(TenantStudentFollowup item) { return new TenantAdminStudentFollowupItem( item.Id, item.StudentUserId, item.AssignedToUserId, item.ClassId, item.Title, item.Description, item.FollowupType, item.Priority, item.Status, item.DueAt, item.CompletedAt, item.CompletedBy, item.Metadata, item.CreatedBy, item.UpdatedBy, item.CreatedAt, item.UpdatedAt); } private static TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user) { return new TenantAdminMemberItem( membership.Id, membership.UserId, membership.Role, membership.Status, membership.LegacyRole, ToUserSummary(user), membership.CreatedAt, membership.UpdatedAt); } private static TenantBrandingItem ToBrandingItem(TenantBranding item) { return new TenantBrandingItem( item.TenantId, item.BrandName, item.ShortName, item.Slogan, item.OrganizationName, item.LogoUrl, item.FaviconUrl, item.ServiceWechat, item.ServiceAccountName, item.Theme, item.PublicAssets, item.UpdatedAt); } private static TenantSettingsItem ToSettingsItem(TenantSettings item) { return new TenantSettingsItem( item.TenantId, item.FeatureFlags, item.AdminFeatureFlags, item.PublicConfig, item.UpdatedAt); } private static TenantThemeItem ToThemeItem(TenantThemeConfig item) { return new TenantThemeItem( item.TenantId, item.ActiveTemplateCode, item.ActiveTheme, item.ActivePublicAssets, item.DraftTemplateCode, item.DraftTheme, item.DraftPublicAssets, item.Status, item.PublishedAt, item.PublishedBy, item.DraftUpdatedBy, item.UpdatedAt); } private static TenantDomainItem ToDomainItem(TenantDomain item) { return new TenantDomainItem( item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.VerificationToken, item.VerifiedAt, item.LastCheckedAt, item.DnsVerifiedAt, item.TlsReadyAt, item.LastFailureReason, item.CreatedAt, item.UpdatedAt); } private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item) { return new TenantIdentityProviderItem( item.Id, item.Provider, item.Status, item.DisplayName, item.SecretRef, item.Priority, item.ConfigPublic, item.CreatedAt, item.UpdatedAt); } private static TenantAdminBadgeItem ToBadgeItem(Badge item) { return new TenantAdminBadgeItem( item.Id, item.LegacyId, item.Name, item.Description, item.Category, item.IconUrl, item.Level, item.UnlockType, item.ConditionField, item.ConditionOperator, item.ConditionValue, item.ConditionExtra, item.SortOrder, item.IsActive, item.CreatedAt, item.UpdatedAt); } private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, Badge? badge) { return new TenantAdminBadgeGrantItem( grant.Id, grant.LegacyId, grant.UserId, user?.Name ?? user?.UserName, user?.Phone, grant.BadgeId, badge?.Name, badge?.Category, badge?.IconUrl, badge?.Level, grant.GrantedBy, grantedBy?.Name ?? grantedBy?.UserName, grant.Note, grant.GrantedAt, grant.CreatedAt, grant.UpdatedAt); } private static TenantAdminNotificationItem ToNotificationItem(UserNotification item) { return new TenantAdminNotificationItem( item.Id, item.UserId, item.NotificationType, item.Status, item.Severity, item.Title, item.Message, item.ActionLabel, item.ActionPath, item.SourceType, item.SourceId, item.DedupeKey, item.Metadata, item.CreatedBy, item.ReadAt, item.CreatedAt, item.UpdatedAt); } private static TenantAdminFeedbackItem ToFeedbackItem(Report report, User? user) { return new TenantAdminFeedbackItem( report.Id, report.UserId, user?.Name ?? user?.UserName, user?.Phone, report.QuestionId, report.Type, report.Title, report.Category, report.Description, report.Status, report.Priority, report.HandledBy, report.HandledAt, report.Resolution, report.Contact, report.Attachments, report.Metadata, report.CreatedAt, report.UpdatedAt); } private static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? 100, 1, 500); } private static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static string NormalizeCode(string value) { return value.Trim().ToLowerInvariant(); } private static string? MaskPhone(string? phone) { var value = Normalize(phone); return value is { Length: >= 7 } ? $"{value[..3]}****{value[^4..]}" : value; } private static JsonElement JsonObjectOrDefault(JsonElement value) { return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); } private static JsonElement PermissionObject(JsonElement value) { var result = new Dictionary(StringComparer.Ordinal); if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) { return JsonDefaults.Object(); } if (value.ValueKind != JsonValueKind.Object) { throw new TenantAdminDirectException("Permissions must be an object.", "invalid_permission_value"); } foreach (var property in value.EnumerateObject()) { if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) { throw new TenantAdminDirectException("Permission values must be boolean.", "invalid_permission_value"); } if (property.Name != "*" && !IsPermissionKey(property.Name)) { throw new TenantAdminDirectException("Permission key was invalid.", "invalid_permission_key"); } result[property.Name] = property.Value.GetBoolean(); } return JsonSerializer.SerializeToElement(result); } private static JsonElement AccessMap(JsonElement value, string codePrefix) { var result = new Dictionary(StringComparer.Ordinal); if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) { return JsonDefaults.Object(); } if (value.ValueKind != JsonValueKind.Object) { throw new TenantAdminDirectException("Access map must be an object.", $"invalid_{codePrefix}"); } foreach (var property in value.EnumerateObject()) { if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) { throw new TenantAdminDirectException("Access map values must be boolean.", $"invalid_{codePrefix}"); } if (!IsAccessKey(property.Name)) { throw new TenantAdminDirectException("Access map key was invalid.", $"invalid_{codePrefix}_key"); } result[property.Name] = property.Value.GetBoolean(); } return JsonSerializer.SerializeToElement(result); } private static JsonElement DataScope(JsonElement value) { if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) { return JsonDefaults.Object(); } if (value.ValueKind != JsonValueKind.Object) { throw new TenantAdminDirectException("Data scope must be an object.", "invalid_data_scope"); } var allowed = new HashSet(StringComparer.Ordinal) { "mode", "regionIds", "contentNodeIds", "classIds", "ownLeadsOnly", "teamScope", "metadata" }; foreach (var property in value.EnumerateObject()) { if (!allowed.Contains(property.Name)) { throw new TenantAdminDirectException("Data scope key was invalid.", "invalid_data_scope_key"); } } return value.Clone(); } private async Task AssertGrantableAsync( TenantAdminActor actor, TenantRole role, CancellationToken cancellationToken) { if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin)) { return; } var isOwnerRoleHolder = await ( from binding in dbContext.TenantBackendUserRoles.AsNoTracking() join backendRole in dbContext.TenantBackendRoles.AsNoTracking() on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id } where binding.TenantId == actor.TenantId && binding.UserId == actor.UserId && backendRole.Code == "tenant_owner" && backendRole.IsSystem && backendRole.Status == BackendRoleStatus.Active select binding.Id) .AnyAsync(cancellationToken); if (!isOwnerRoleHolder) { throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required"); } } private static string RoleToPrimaryRole(TenantRole role) { return role switch { TenantRole.Student => "student", TenantRole.Teacher => "teacher", TenantRole.Sales => "sales", TenantRole.Agent => "agent", TenantRole.TenantOperator => "tenant_operator", TenantRole.TenantAdmin => "tenant_admin", TenantRole.TenantOwner => "tenant_owner", _ => "student" }; } private static string NormalizeRoleCode(string value) { var code = new string(value.Trim().ToLowerInvariant() .Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-') .ToArray()) .Trim('-'); while (code.Contains("--", StringComparison.Ordinal)) { code = code.Replace("--", "-", StringComparison.Ordinal); } if (code.Length is < 2 or > 64 || !char.IsAsciiLetter(code[0])) { throw new TenantAdminDirectException("Role template code was invalid.", "invalid_role_template_code"); } return code; } private static string NormalizeDomain(string value) { var host = Normalize(value)?.ToLowerInvariant() .TrimEnd('.') ?? throw new TenantAdminDirectException("Domain host is required.", "domain_host_required"); if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal) || !host.Contains('.', StringComparison.Ordinal)) { throw new TenantAdminDirectException("Domain host was invalid.", "invalid_domain_host"); } return host; } private static JsonElement MergeJsonObjects(JsonElement first, JsonElement second) { var result = new Dictionary(StringComparer.Ordinal); if (first.ValueKind == JsonValueKind.Object) { foreach (var property in first.EnumerateObject()) { result[property.Name] = property.Value.Clone(); } } if (second.ValueKind == JsonValueKind.Object) { foreach (var property in second.EnumerateObject()) { result[property.Name] = property.Value.Clone(); } } return JsonSerializer.SerializeToElement(result); } private static void AssertNoSecrets(JsonElement value, string code) { if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) { return; } if (value.ValueKind != JsonValueKind.Object) { throw new TenantAdminDirectException("Public config must be an object.", $"invalid_{code}"); } foreach (var property in value.EnumerateObject()) { var key = property.Name.ToLowerInvariant(); if (key.Contains("secret", StringComparison.Ordinal) || key.Contains("password", StringComparison.Ordinal) || key.Contains("token", StringComparison.Ordinal) || key.Contains("private", StringComparison.Ordinal) || key is "appsecret" or "app_secret" or "accesskeysecret") { throw new TenantAdminDirectException("Public config cannot contain secrets.", "public_config_contains_secret"); } } } private static bool IsPermissionKey(string key) { return key.Length <= 100 && key.Contains(':', StringComparison.Ordinal) && key.All(character => char.IsAsciiLetterOrDigit(character) || character is ':' or '*'); } private static bool IsAccessKey(string key) { return key.Length <= 100 && key.Length > 0 && char.IsAsciiLetter(key[0]) && key.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or ':' or '-'); } private static TEnum ParseEnum(string? value, TEnum fallback, string code) where TEnum : struct, Enum { return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum(value, code); } private static TEnum ParseEnum(string value, string code) where TEnum : struct, Enum { var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); foreach (var candidate in Enum.GetValues()) { if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) { return candidate; } } throw new TenantAdminDirectException("Enum value was invalid.", code); } }