using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { 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); } }