Files
tiku-backend.net/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.MappingAndValidation.cs
xiong 8493f8f0ed
Some checks failed
ci / release-gate (push) Has been cancelled
style: format module migration files
2026-08-04 09:57:54 +08:00

539 lines
18 KiB
C#

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;
internal abstract partial class TenantAdminServiceBase
{
protected 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);
}
protected 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);
}
protected static TenantAdminStudentItem ToStudentItem(
TenantMembership membership,
User user,
StudentProfile? profile,
IReadOnlyDictionary<Guid, string> regions,
IReadOnlyDictionary<Guid, string> schools,
IReadOnlyDictionary<Guid, string> majors,
IReadOnlyCollection<TenantAdminStudentClassSummary> 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);
}
protected static TenantAdminUserSummary ToUserSummary(User user)
{
return new TenantAdminUserSummary(
user.Id,
user.UserName,
user.Email,
user.Phone,
user.Name,
user.AvatarUrl,
user.PrimaryRole);
}
protected 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);
}
protected 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);
}
protected 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);
}
protected 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);
}
protected static TenantSettingsItem ToSettingsItem(TenantSettings item)
{
return new TenantSettingsItem(
item.TenantId,
item.FeatureFlags,
item.AdminFeatureFlags,
item.PublicConfig,
item.UpdatedAt);
}
protected 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);
}
protected 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);
}
protected 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);
}
protected 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);
}
protected 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);
}
protected 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);
}
protected 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);
}
protected static int ResolveLimit(int? limit)
{
return Math.Clamp(limit ?? 100, 1, 500);
}
protected static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
protected static string NormalizeCode(string value)
{
return value.Trim().ToLowerInvariant();
}
protected static string? MaskPhone(string? phone)
{
var value = Normalize(phone);
return value is { Length: >= 7 }
? $"{value[..3]}****{value[^4..]}"
: value;
}
protected static JsonElement JsonObjectOrDefault(JsonElement value)
{
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
}
protected static JsonElement PermissionObject(JsonElement value)
{
var result = new Dictionary<string, bool>(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);
}
protected static JsonElement AccessMap(JsonElement value, string codePrefix)
{
var result = new Dictionary<string, bool>(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);
}
protected 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<string>(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();
}
protected 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");
}
protected 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"
};
}
protected 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;
}
protected 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;
}
protected static JsonElement MergeJsonObjects(JsonElement first, JsonElement second)
{
var result = new Dictionary<string, JsonElement>(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);
}
protected 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");
}
}
protected static bool IsPermissionKey(string key)
{
return key.Length <= 100 &&
key.Contains(':', StringComparison.Ordinal) &&
key.All(character => char.IsAsciiLetterOrDigit(character) || character is ':' or '*');
}
protected 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 '-');
}
protected static TEnum ParseEnum<TEnum>(string? value, TEnum fallback, string code)
where TEnum : struct, Enum
{
return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum<TEnum>(value, code);
}
protected static TEnum ParseEnum<TEnum>(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<TEnum>())
if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
return candidate;
throw new TenantAdminDirectException("Enum value was invalid.", code);
}
}