539 lines
18 KiB
C#
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);
|
|
}
|
|
}
|