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

2298 lines
95 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
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;
namespace Tiku.Infrastructure.TenantAdmin;
public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantAdminDirectService
{
private static readonly TenantAdminPermissionCatalogItem[] PermissionCatalog =
[
new("tenant:overview:read", "租户概览"),
new("tenant:branding:write", "品牌配置"),
new("tenant:theme:read", "主题查看"),
new("tenant:theme:write", "主题预览/发布"),
new("tenant:settings:write", "公开设置"),
new("tenant:domains:read", "域名查看"),
new("tenant:domains:write", "域名管理"),
new("tenant:auth:read", "登录配置查看"),
new("tenant:auth:write", "登录配置管理"),
new("members:read", "成员查看"),
new("members:write", "成员管理"),
new("roles:read", "角色模板查看"),
new("roles:write", "角色模板管理"),
new("audit:read", "审计日志查看"),
new("classes:read", "班级查看"),
new("classes:write", "班级管理"),
new("students:read", "学生查看"),
new("students:write", "学生管理"),
new("students:status:write", "学生状态管理"),
new("students:notes:read", "学生备注查看"),
new("students:notes:write", "学生备注管理"),
new("students:followups:read", "学生跟进查看"),
new("students:followups:write", "学生跟进管理"),
new("content:*", "内容维护")
];
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> RoleDefaults =
new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.Ordinal)
{
["tenant_owner"] = ["*"],
["tenant_admin"] = ["*"],
["tenant_operator"] = ["tenant:overview:read", "classes:read", "students:read", "content:*"],
["teacher"] = ["classes:read", "students:read", "students:notes:read", "students:followups:read", "content:*"],
["sales"] = ["students:read", "students:followups:read", "students:followups:write"],
["agent"] = ["students:read"],
["student"] = []
};
public async Task<TenantAdminClassList> GetClassesAsync(
TenantAdminActor actor,
TenantAdminClassFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.TenantClasses.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
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: false);
}
public async Task<ContentManagementResult<TenantAdminClassItem>> UpsertClassAsync(
TenantAdminActor actor,
UpsertTenantAdminClassCommand command,
CancellationToken cancellationToken = default)
{
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;
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 item = await dbContext.TenantClasses
.FirstOrDefaultAsync(entity => entity.TenantId == actor.TenantId && entity.Id == classId, 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)
{
await AssertClassAsync(actor.TenantId, filter.ClassId, cancellationToken);
var query = dbContext.TenantClassMembers.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId);
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)
{
await AssertClassAsync(actor.TenantId, 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 item = await dbContext.TenantClassMembers
.FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.Id == classMemberId, 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 status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status");
var query = dbContext.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status);
if (filter.ClassId.HasValue)
{
await AssertClassAsync(actor.TenantId, 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: false);
}
public async Task<ContentManagementResult<TenantAdminStudentItem>> UpsertStudentAsync(
TenantAdminActor actor,
UpsertTenantAdminStudentCommand command,
CancellationToken cancellationToken = default)
{
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 (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 status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status");
var membership = await dbContext.TenantMemberships
.FirstOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == command.UserId &&
item.Role == TenantRole.Student,
cancellationToken);
if (membership is null)
{
throw new TenantAdminDirectException("Student membership was not found.", "student_not_found");
}
membership.Status = status;
if (status != MembershipStatus.Active)
{
var now = DateTimeOffset.UtcNow;
var sessions = await dbContext.AuthSessions
.Where(session => session.TenantId == actor.TenantId && session.UserId == command.UserId && session.RevokedAt == null)
.ToArrayAsync(cancellationToken);
foreach (var session in sessions)
{
session.RevokedAt = now;
}
}
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<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
TenantAdminActor actor,
TenantAdminStudentActivityFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.TenantStudentNotes.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.Content);
await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken);
var item = command.Id.HasValue
? await dbContext.TenantStudentNotes.FirstOrDefaultAsync(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value, cancellationToken)
: null;
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 query = dbContext.TenantStudentFollowups.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken);
await AssertClassAsync(actor.TenantId, command.ClassId, cancellationToken);
await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken);
var item = command.Id.HasValue
? await dbContext.TenantStudentFollowups.FirstOrDefaultAsync(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value, cancellationToken)
: null;
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)
{
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 templateIds = memberships.Where(item => item.RoleTemplateId.HasValue).Select(item => item.RoleTemplateId!.Value).ToArray();
var users = await dbContext.Users.AsNoTracking()
.Where(user => userIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, cancellationToken);
var templates = await dbContext.TenantRoleTemplates.AsNoTracking()
.Where(template => template.TenantId == actor.TenantId && templateIds.Contains(template.Id))
.ToDictionaryAsync(template => template.Id, cancellationToken);
return new CatalogList<TenantAdminMemberItem>(memberships.Select(item =>
{
users.TryGetValue(item.UserId, out var user);
var template = item.RoleTemplateId.HasValue && templates.TryGetValue(item.RoleTemplateId.Value, out var resolvedTemplate)
? resolvedTemplate
: null;
return ToMemberItem(item, user ?? new User { Id = item.UserId }, template);
}).ToArray());
}
public async Task<ContentManagementResult<TenantAdminMemberItem>> UpsertMemberAsync(
TenantAdminActor actor,
UpsertTenantAdminMemberCommand command,
CancellationToken cancellationToken = default)
{
var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role");
var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status");
var permissions = PermissionObject(command.Permissions);
TenantRoleTemplate? roleTemplate = null;
if (command.RoleTemplateId.HasValue)
{
roleTemplate = await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.RoleTemplateId.Value && item.Status == TenantRoleTemplateStatus.Active,
cancellationToken);
if (roleTemplate is null)
{
throw new TenantAdminDirectException("Role template was not found.", "role_template_not_found");
}
role = roleTemplate.BaseRole;
}
AssertGrantable(actor, role, permissions);
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;
membership ??= new TenantMembership
{
TenantId = actor.TenantId,
UserId = user.Id
};
membership.UserId = user.Id;
membership.Role = role;
membership.Status = status;
membership.RoleTemplateId = command.RoleTemplateId;
membership.Permissions = permissions;
if (isNew)
{
dbContext.TenantMemberships.Add(membership);
}
if (status != MembershipStatus.Active)
{
await RevokeSessionsAsync(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, roleTemplate));
}
public async Task<ContentManagementResult<TenantAdminMemberItem>> DisableMemberAsync(
TenantAdminActor actor,
Guid membershipId,
CancellationToken cancellationToken = default)
{
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");
}
AssertGrantable(actor, membership.Role, JsonDefaults.Object());
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, null));
}
public async Task<CatalogList<TenantAdminAuditLogItem>> GetAuditLogsAsync(
TenantAdminActor actor,
TenantAdminAuditLogFilter filter,
CancellationToken cancellationToken = default)
{
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<TenantAdminPermissionMatrix> GetPermissionMatrixAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
await Task.CompletedTask.WaitAsync(cancellationToken);
return new TenantAdminPermissionMatrix(
new TenantAdminCurrentPermission(actor.UserId, actor.TenantId, TenantRole.TenantAdmin),
PermissionCatalog,
RoleDefaults);
}
public async Task<CatalogList<TenantAdminRoleTemplateItem>> GetRoleTemplatesAsync(
TenantAdminActor actor,
TenantAdminRoleTemplateFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.TenantRoleTemplates.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(filter.Status))
{
query = query.Where(item => item.Status == ParseEnum<TenantRoleTemplateStatus>(filter.Status, "invalid_role_template_status"));
}
var items = await query
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => ToRoleTemplateItem(item))
.ToArrayAsync(cancellationToken);
return new CatalogList<TenantAdminRoleTemplateItem>(items);
}
public async Task<ContentManagementResult<TenantAdminRoleTemplateItem>> UpsertRoleTemplateAsync(
TenantAdminActor actor,
UpsertTenantAdminRoleTemplateCommand command,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
var baseRole = ParseEnum(command.BaseRole, TenantRole.Student, "invalid_member_role");
var permissions = PermissionObject(command.Permissions);
AssertGrantable(actor, baseRole, permissions);
var code = NormalizeRoleCode(command.Code ?? command.Name);
var item = command.Id.HasValue
? await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(template => template.TenantId == actor.TenantId && template.Id == command.Id.Value, cancellationToken)
: await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(template => template.TenantId == actor.TenantId && template.Code == code, cancellationToken);
var isNew = item is null;
item ??= new TenantRoleTemplate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId };
if (item.IsSystem)
{
throw new TenantAdminDirectException("System role template cannot be modified.", "system_role_template_locked");
}
item.Code = code;
item.Name = command.Name.Trim();
item.Description = Normalize(command.Description);
item.BaseRole = baseRole;
item.Status = ParseEnum(command.Status, TenantRoleTemplateStatus.Active, "invalid_role_template_status");
item.Permissions = permissions;
item.MenuPermissions = AccessMap(command.MenuPermissions, "menu_permissions");
item.ModulePermissions = AccessMap(command.ModulePermissions, "module_permissions");
item.FieldPermissions = AccessMap(command.FieldPermissions, "field_permissions");
item.DataScope = DataScope(command.DataScope);
item.SortOrder = command.Order ?? item.SortOrder;
item.UpdatedBy = actor.UserId;
if (isNew)
{
dbContext.TenantRoleTemplates.Add(item);
}
await AddAuditAsync(actor, "tenant.role_template.upserted", "tenant_role_templates", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<TenantAdminRoleTemplateItem>(ToRoleTemplateItem(item));
}
public async Task<ContentManagementResult<TenantAdminRoleTemplateItem>> DisableRoleTemplateAsync(
TenantAdminActor actor,
Guid roleTemplateId,
CancellationToken cancellationToken = default)
{
var item = await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(
template => template.TenantId == actor.TenantId && template.Id == roleTemplateId,
cancellationToken);
if (item is null)
{
throw new TenantAdminDirectException("Role template was not found.", "role_template_not_found");
}
if (item.IsSystem)
{
throw new TenantAdminDirectException("System role template cannot be disabled.", "system_role_template_locked");
}
AssertGrantable(actor, item.BaseRole, item.Permissions);
item.Status = TenantRoleTemplateStatus.Disabled;
item.UpdatedBy = actor.UserId;
await AddAuditAsync(actor, "tenant.role_template.disabled", "tenant_role_templates", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<TenantAdminRoleTemplateItem>(ToRoleTemplateItem(item));
}
public async Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
TenantAdminActor actor,
UpsertTenantBrandingCommand command,
CancellationToken cancellationToken = default)
{
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)
{
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 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)
{
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)
{
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)
{
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)
{
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)
{
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<TenantAuthProviderItem>> GetAuthProvidersAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
var items = await dbContext.TenantAuthProviders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.OrderBy(item => item.CreatedAt)
.Select(item => ToAuthProviderItem(item))
.ToArrayAsync(cancellationToken);
return new CatalogList<TenantAuthProviderItem>(items);
}
public async Task<ContentManagementResult<TenantAuthProviderItem>> UpsertAuthProviderAsync(
TenantAdminActor actor,
UpsertTenantAuthProviderCommand command,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider);
AssertNoSecrets(command.ConfigPublic, "config_public");
var provider = command.Provider.Trim().ToLowerInvariant();
var item = await dbContext.TenantAuthProviders.FirstOrDefaultAsync(
authProvider => authProvider.TenantId == actor.TenantId && authProvider.Provider == provider,
cancellationToken);
var isNew = item is null;
item ??= new TenantAuthProvider { TenantId = actor.TenantId, Provider = provider };
item.Status = ParseEnum(command.Status, TenantAuthProviderStatus.Disabled, "invalid_auth_provider_status");
item.DisplayName = Normalize(command.DisplayName);
item.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic);
if (isNew)
{
dbContext.TenantAuthProviders.Add(item);
}
await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_auth_providers", item.Id, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<TenantAuthProviderItem>(ToAuthProviderItem(item));
}
public async Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(
TenantAdminActor actor,
TenantAdminBadgeFilter filter,
CancellationToken cancellationToken = default)
{
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)
{
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 query = dbContext.UserBadges.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
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 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 AssertTenantMemberAsync(actor.TenantId, 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}";
var notification = await dbContext.UserNotifications.FirstOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == command.UserId &&
item.NotificationType == "badge_granted" &&
item.DedupeKey == dedupeKey,
cancellationToken);
notification ??= new UserNotification
{
TenantId = actor.TenantId,
UserId = command.UserId,
NotificationType = "badge_granted",
DedupeKey = dedupeKey,
CreatedBy = actor.UserId
};
notification.Severity = NotificationSeverity.Success;
notification.Title = $"获得勋章:{badge.Name}";
notification.Message = Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。";
notification.ActionLabel = "查看勋章";
notification.ActionPath = "/profile?tab=badges";
notification.SourceType = "user_badges";
notification.SourceId = grant.Id;
notification.Metadata = JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name });
if (dbContext.Entry(notification).State == EntityState.Detached)
{
dbContext.UserNotifications.Add(notification);
}
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 query = dbContext.UserNotifications.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Message);
await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken);
var item = !string.IsNullOrWhiteSpace(command.DedupeKey)
? await dbContext.UserNotifications.FirstOrDefaultAsync(notification =>
notification.TenantId == actor.TenantId &&
notification.UserId == command.UserId &&
notification.NotificationType == command.NotificationType &&
notification.DedupeKey == command.DedupeKey,
cancellationToken)
: null;
var isNew = item is null;
item ??= new UserNotification
{
TenantId = actor.TenantId,
UserId = command.UserId,
CreatedBy = actor.UserId
};
item.NotificationType = command.NotificationType.Trim();
item.Severity = ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity");
item.Title = command.Title.Trim();
item.Message = command.Message.Trim();
item.ActionLabel = Normalize(command.ActionLabel);
item.ActionPath = Normalize(command.ActionPath);
item.SourceType = Normalize(command.SourceType);
item.SourceId = command.SourceId;
item.DedupeKey = Normalize(command.DedupeKey);
item.Metadata = JsonObjectOrDefault(command.Metadata);
if (isNew)
{
dbContext.UserNotifications.Add(item);
}
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 query = dbContext.Reports.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
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 report = await dbContext.Reports.FirstOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId,
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,
Permissions = JsonDefaults.Object()
};
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 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 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)
{
var now = DateTimeOffset.UtcNow;
var sessions = await dbContext.AuthSessions
.Where(session => session.TenantId == tenantId && session.UserId == userId && session.RevokedAt == null)
.ToArrayAsync(cancellationToken);
foreach (var session in sessions)
{
session.RevokedAt = now;
}
}
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 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, TenantRoleTemplate? roleTemplate)
{
return new TenantAdminMemberItem(
membership.Id,
membership.UserId,
membership.Role,
membership.Status,
membership.Permissions,
membership.RoleTemplateId,
roleTemplate?.Code,
roleTemplate?.Name,
membership.LegacyRole,
ToUserSummary(user),
membership.CreatedAt,
membership.UpdatedAt);
}
private static TenantAdminRoleTemplateItem ToRoleTemplateItem(TenantRoleTemplate item)
{
return new TenantAdminRoleTemplateItem(
item.Id,
item.Code,
item.Name,
item.Description,
item.BaseRole,
item.Status,
item.Permissions,
item.MenuPermissions,
item.ModulePermissions,
item.FieldPermissions,
item.DataScope,
item.IsSystem,
item.SortOrder,
item.CreatedBy,
item.UpdatedBy,
item.CreatedAt,
item.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.CreatedAt,
item.UpdatedAt);
}
private static TenantAuthProviderItem ToAuthProviderItem(TenantAuthProvider item)
{
return new TenantAuthProviderItem(
item.Id,
item.Provider,
item.Status,
item.DisplayName,
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 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 static void AssertGrantable(TenantAdminActor actor, TenantRole role, JsonElement permissions)
{
if (actor.Role == TenantRole.TenantOwner)
{
return;
}
var grantsAll = permissions.ValueKind == JsonValueKind.Object &&
permissions.TryGetProperty("*", out var wildcard) &&
wildcard.ValueKind is JsonValueKind.True;
if (role is TenantRole.TenantOwner or TenantRole.TenantAdmin || grantsAll)
{
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",
TenantRole.PlatformAdmin => "platform_admin",
_ => "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);
}
}