Files
tiku-backend.net/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

604 lines
24 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Application.TenantAdmin;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.TenantAdmin;
public sealed partial class TenantAdminDirectService
{
private async Task<User> 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<TenantMembership> EnsureMembershipAsync(
Guid tenantId,
Guid userId,
TenantRole role,
CancellationToken cancellationToken)
{
var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item =>
item.TenantId == tenantId && item.UserId == userId && item.Role == role,
cancellationToken);
if (membership is null)
{
var metricCode = QuotaMetricForRole(role);
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
tenantId,
metricCode,
cancellationToken: cancellationToken);
membership = new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = role,
Status = MembershipStatus.Active
};
dbContext.TenantMemberships.Add(membership);
}
else
{
if (membership.Status != MembershipStatus.Active)
{
var metricCode = QuotaMetricForRole(role);
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
tenantId,
metricCode,
cancellationToken: cancellationToken);
}
membership.Status = MembershipStatus.Active;
}
return membership;
}
private static string QuotaMetricForRole(TenantRole role)
{
return role == TenantRole.Student
? SaasQuotaMetricCatalog.StudentCount
: SaasQuotaMetricCatalog.StaffCount;
}
private Task<bool> 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<StudentProfile> 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<IReadOnlyCollection<TenantAdminStudentImportPreviewItem>> BuildStudentImportPreviewAsync(
TenantAdminActor actor,
CurrentDataScope scope,
TenantAdminStudentImportCommand command,
CancellationToken cancellationToken)
{
var items = new List<TenantAdminStudentImportPreviewItem>();
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<TenantClassMember> 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<IReadOnlyCollection<TenantSupervisionRuleItem>> 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<TenantSupervisionRuleItem[]>(rulesElement.GetRawText()) ?? [];
}
private async Task SaveSupervisionRulesCoreAsync(
Guid tenantId,
IReadOnlyCollection<TenantSupervisionRuleItem> 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<Dictionary<string, JsonElement>>(settings.AdminFeatureFlags.GetRawText()) ?? []
: [];
existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules);
settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing);
}
private async Task<IReadOnlyCollection<TenantSupervisionRiskStudentItem>> 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<TenantSupervisionRiskStudentItem>();
foreach (var row in students)
{
var hitRules = new List<string>();
var reasons = new List<string>();
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<CurrentDataScope> 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<TEntity>(
Guid tenantId,
Guid? id,
string code,
CancellationToken cancellationToken)
where TEntity : TenantEntity
{
if (!id.HasValue) return;
var exists = await dbContext.Set<TEntity>()
.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<TEntity?> ResolveTenantEntityAsync<TEntity>(
DbSet<TEntity> 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<string?>(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);
}
}