Files
tiku-backend.net/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs

3098 lines
133 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.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) : ITenantAdminDirectService
{
public async Task<TenantAdminOverviewItem> 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<TenantAdminClassList> 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<TenantRecordStatus>(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<ContentManagementResult<TenantAdminClassItem>> UpsertClassAsync(
TenantAdminActor actor,
UpsertTenantAdminClassCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(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<TenantAdminClassItem>(ToClassItem(item, regionName, 0, 0));
}
public async Task<ContentManagementResult<TenantAdminClassItem>> 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<TenantAdminClassItem>(ToClassItem(item, null, 0, 0));
}
public async Task<CatalogList<TenantAdminClassMemberItem>> 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<TenantClassMemberType>(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<TenantAdminClassMemberItem>(items);
}
public async Task<ContentManagementResult<TenantAdminClassMemberItem>> UpsertClassMemberAsync(
TenantAdminActor actor,
UpsertTenantAdminClassMemberCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
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);
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
}
public async Task<ContentManagementResult<TenantAdminClassMemberItem>> 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<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
}
public async Task<TenantAdminStudentList> 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<TenantAdminStudentClassSummary>);
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<ContentManagementResult<TenantAdminStudentItem>> UpsertStudentAsync(
TenantAdminActor actor,
UpsertTenantAdminStudentCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken);
await AssertReferenceAsync<Major>(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken);
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);
return new ContentManagementResult<TenantAdminStudentItem>(
ToStudentItem(
membership,
user,
profile,
new Dictionary<Guid, string>(),
new Dictionary<Guid, string>(),
new Dictionary<Guid, string>(),
[]));
}
public async Task<ContentManagementResult<TenantAdminStudentStatusItem>> 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");
}
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);
return new ContentManagementResult<TenantAdminStudentStatusItem>(
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
}
public async Task<TenantAdminStudentImportPreview> 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<TenantAdminStudentImportResult> 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();
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);
return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems);
}
public async Task<TenantAdminBulkOperationResult> 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<Guid>();
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<TenantAdminBulkOperationResult> BulkUpdateStudentStatusAsync(
TenantAdminActor actor,
TenantAdminBulkStatusCommand command,
CancellationToken cancellationToken = default)
{
var failed = new List<Guid>();
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<CatalogList<TenantSupervisionRuleItem>> GetSupervisionRulesAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
await RequireDataScopeAsync(actor, cancellationToken);
return new CatalogList<TenantSupervisionRuleItem>(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken));
}
public async Task<ContentManagementResult<TenantSupervisionRuleItem>> 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<TenantSupervisionRuleItem>(item);
}
public async Task<TenantSupervisionPreview> PreviewSupervisionAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken));
}
public async Task<TenantSupervisionGenerateResult> 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<TenantFollowupReport> 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<TenantFeedbackReport> 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<TenantPointRiskReport> 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<CatalogList<TenantAdminStudentNoteItem>> 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<TenantAdminStudentNoteItem>(items);
}
public async Task<ContentManagementResult<TenantAdminStudentNoteItem>> 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<TenantAdminStudentNoteItem>(ToNoteItem(item));
}
public async Task<CatalogList<TenantAdminStudentFollowupItem>> 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<StudentFollowupStatus>(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<TenantAdminStudentFollowupItem>(items);
}
public async Task<ContentManagementResult<TenantAdminStudentFollowupItem>> 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<TenantAdminStudentFollowupItem>(ToFollowupItem(item));
}
public async Task<CatalogList<TenantAdminMemberItem>> 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<TenantRole>(filter.Role, "invalid_member_role"));
}
if (!string.IsNullOrWhiteSpace(filter.Status))
{
query = query.Where(item => item.Status == ParseEnum<MembershipStatus>(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<TenantAdminMemberItem>(memberships.Select(item =>
{
users.TryGetValue(item.UserId, out var user);
return ToMemberItem(item, user ?? new User { Id = item.UserId });
}).ToArray());
}
public async Task<ContentManagementResult<TenantAdminMemberItem>> UpsertMemberAsync(
TenantAdminActor actor,
UpsertTenantAdminMemberCommand command,
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
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;
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
{
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
}
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);
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}
public async Task<ContentManagementResult<TenantAdminMemberItem>> DisableMemberAsync(
TenantAdminActor actor,
Guid membershipId,
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
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);
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);
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}
public async Task<CatalogList<TenantAdminAuditLogItem>> 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<TenantAdminAuditLogItem>(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<ContentManagementResult<TenantBrandingItem>> 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<TenantBrandingItem>(ToBrandingItem(item));
}
public async Task<ContentManagementResult<TenantSettingsItem>> 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<TenantSettingsItem>(ToSettingsItem(item));
}
public async Task<CatalogList<TenantThemeTemplateItem>> 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<TenantThemeTemplateItem>(items);
}
public async Task<ContentManagementResult<TenantThemeItem>> 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<TenantThemeItem>(ToThemeItem(item));
}
var branding = await dbContext.TenantBrandings.AsNoTracking()
.FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken);
return new ContentManagementResult<TenantThemeItem>(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<ContentManagementResult<TenantThemeItem>> 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<TenantThemeItem>(ToThemeItem(item));
}
public async Task<ContentManagementResult<TenantThemeItem>> 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<TenantThemeItem>(ToThemeItem(item));
}
public async Task<CatalogList<TenantDomainItem>> 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<TenantDomainItem>(items);
}
public async Task<ContentManagementResult<TenantDomainItem>> CreateDomainAsync(
TenantAdminActor actor,
CreateTenantDomainCommand command,
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
var host = NormalizeDomain(command.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 = $"tenant-{actor.TenantId:N}"[..23]
};
dbContext.TenantDomains.Add(item);
await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<TenantDomainItem>(ToDomainItem(item));
}
public async Task<CatalogList<TenantIdentityProviderItem>> GetAuthProvidersAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
await RequireAllDataScopeAsync(actor, cancellationToken);
var items = await providerConfigService.GetProvidersAsync(
actor.TenantId,
TenantExternalProviderCapability.Identity,
cancellationToken: cancellationToken);
return new CatalogList<TenantIdentityProviderItem>(items.Select(ToAuthProviderItem).ToArray());
}
public async Task<ContentManagementResult<TenantIdentityProviderItem>> 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<TenantIdentityProviderItem>(ToAuthProviderItem(item));
}
public async Task<CatalogList<TenantAdminBadgeItem>> 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<TenantAdminBadgeItem>(items);
}
public async Task<ContentManagementResult<TenantAdminBadgeItem>> 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<TenantAdminBadgeItem>(ToBadgeItem(item));
}
public async Task<CatalogList<TenantAdminBadgeGrantItem>> 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<Guid>().Concat(grants.Select(item => item.GrantedBy).OfType<Guid>()).Distinct().ToArray();
var badgeIds = grants.Select(item => item.BadgeId).OfType<Guid>().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<TenantAdminBadgeGrantItem>(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<ContentManagementResult<TenantAdminBadgeGrantItem>> 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<TenantAdminBadgeGrantItem>(ToBadgeGrantItem(grant, user, null, badge));
}
public async Task<CatalogList<TenantAdminNotificationItem>> 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<NotificationStatus>(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<TenantAdminNotificationItem>(items);
}
public async Task<ContentManagementResult<TenantAdminNotificationItem>> 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<TenantAdminNotificationItem>(ToNotificationItem(item));
}
public async Task<CatalogList<TenantAdminFeedbackItem>> 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<ReportStatus>(filter.Status, "invalid_feedback_status"));
}
if (!string.IsNullOrWhiteSpace(filter.Type))
{
query = query.Where(item => item.Type == ParseEnum<ReportType>(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<Guid>().Distinct().ToArray();
var users = await dbContext.Users.AsNoTracking()
.Where(user => userIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, cancellationToken);
return new CatalogList<TenantAdminFeedbackItem>(reports.Select(report =>
ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray());
}
public async Task<ContentManagementResult<TenantAdminFeedbackItem>> 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<TenantAdminFeedbackItem>(ToFeedbackItem(report, user));
}
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)
{
membership = new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = role,
Status = MembershipStatus.Active
};
dbContext.TenantMemberships.Add(membership);
}
else
{
membership.Status = MembershipStatus.Active;
}
return membership;
}
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,
Module = permissionCode.Split(':')[1],
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);
}
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<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);
}
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<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);
}
private 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);
}
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<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();
}
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<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);
}
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<TEnum>(string? value, TEnum fallback, string code)
where TEnum : struct, Enum
{
return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum<TEnum>(value, code);
}
private 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);
}
}