feat: add tenant admin direct operations endpoints

This commit is contained in:
xiong
2026-07-26 18:53:01 +08:00
parent 89e1f2a4df
commit f671a7f07a
8 changed files with 1822 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.TenantAdmin;
using Tiku.Domain.Common;
namespace Tiku.Api.Contracts;
public sealed class TenantAdminClassQueryDto
{
public Guid? RegionId { get; set; }
public string? Status { get; set; }
public string? Keyword { get; set; }
[Range(1, 500)]
public int? Limit { get; set; }
public TenantAdminClassFilter ToFilter()
{
return new TenantAdminClassFilter(RegionId, Status, Keyword, Limit);
}
}
public sealed class TenantAdminClassMemberQueryDto
{
[Required]
public Guid ClassId { get; set; }
public string? MemberType { get; set; }
public string? Status { get; set; }
[Range(1, 500)]
public int? Limit { get; set; }
public TenantAdminClassMemberFilter ToFilter()
{
return new TenantAdminClassMemberFilter(ClassId, MemberType, Status, Limit);
}
}
public sealed class TenantAdminStudentQueryDto
{
public Guid? RegionId { get; set; }
public Guid? ClassId { get; set; }
public string? Status { get; set; }
public string? Keyword { get; set; }
[Range(1, 500)]
public int? Limit { get; set; }
public TenantAdminStudentFilter ToFilter()
{
return new TenantAdminStudentFilter(RegionId, ClassId, Status, Keyword, Limit);
}
}
public sealed class TenantAdminStudentActivityQueryDto
{
public Guid? StudentUserId { get; set; }
public string? Status { get; set; }
[Range(1, 500)]
public int? Limit { get; set; }
public TenantAdminStudentActivityFilter ToFilter()
{
return new TenantAdminStudentActivityFilter(StudentUserId, Status, Limit);
}
}
public sealed class UpsertTenantAdminClassDto
{
public Guid? Id { get; set; }
public Guid? RegionId { get; set; }
public string? LegacyId { get; set; }
public string? Code { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public string? Status { get; set; }
public int? Order { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public UpsertTenantAdminClassCommand ToCommand()
{
return new UpsertTenantAdminClassCommand(Id, RegionId, LegacyId, Code, Name, Description, Status, Order, Metadata);
}
}
public sealed class TenantAdminUserLookupDto
{
public Guid? UserId { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Name { get; set; }
public string? AvatarUrl { get; set; }
public UserLookupCommand ToCommand()
{
return new UserLookupCommand(UserId, Username, Email, Phone, Name, AvatarUrl);
}
}
public sealed class UpsertTenantAdminClassMemberDto
{
[Required]
public Guid ClassId { get; set; }
public TenantAdminUserLookupDto User { get; set; } = new();
public string? MemberType { get; set; }
public string? Status { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public UpsertTenantAdminClassMemberCommand ToCommand()
{
return new UpsertTenantAdminClassMemberCommand(ClassId, User.ToCommand(), MemberType, Status, Metadata);
}
}
public sealed class RemoveTenantAdminClassMemberDto
{
[Required]
public Guid ClassMemberId { get; set; }
}
public sealed class UpsertTenantAdminStudentDto
{
public TenantAdminUserLookupDto User { get; set; } = new();
public Guid? RegionId { get; set; }
public Guid? SelectedSchoolId { get; set; }
public Guid? SelectedMajorId { get; set; }
public string? AvatarPreset { get; set; }
public JsonElement Stats { get; set; } = JsonDefaults.Object();
public JsonElement Progress { get; set; } = JsonDefaults.Object();
public JsonElement ModuleSelections { get; set; } = JsonDefaults.Object();
public JsonElement RawProfile { get; set; } = JsonDefaults.Object();
public UpsertTenantAdminStudentCommand ToCommand()
{
return new UpsertTenantAdminStudentCommand(
User.ToCommand(),
RegionId,
SelectedSchoolId,
SelectedMajorId,
AvatarPreset,
Stats,
Progress,
ModuleSelections,
RawProfile);
}
}
public sealed class UpdateTenantAdminStudentStatusDto
{
[Required]
public Guid UserId { get; set; }
public string? Status { get; set; }
public string? Reason { get; set; }
public UpdateTenantAdminStudentStatusCommand ToCommand()
{
return new UpdateTenantAdminStudentStatusCommand(UserId, Status, Reason);
}
}
public sealed class UpsertTenantAdminStudentNoteDto
{
public Guid? Id { get; set; }
[Required]
public Guid StudentUserId { get; set; }
public string? NoteType { get; set; }
public required string Content { get; set; }
public string? Visibility { get; set; }
public bool? IsPinned { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public UpsertTenantAdminStudentNoteCommand ToCommand()
{
return new UpsertTenantAdminStudentNoteCommand(Id, StudentUserId, NoteType, Content, Visibility, IsPinned, Metadata);
}
}
public sealed class UpsertTenantAdminStudentFollowupDto
{
public Guid? Id { get; set; }
[Required]
public Guid StudentUserId { get; set; }
public Guid? AssignedToUserId { get; set; }
public Guid? ClassId { get; set; }
public required string Title { get; set; }
public string? Description { get; set; }
public string? FollowupType { get; set; }
public string? Priority { get; set; }
public string? Status { get; set; }
public DateTimeOffset? DueAt { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public UpsertTenantAdminStudentFollowupCommand ToCommand()
{
return new UpsertTenantAdminStudentFollowupCommand(
Id,
StudentUserId,
AssignedToUserId,
ClassId,
Title,
Description,
FollowupType,
Priority,
Status,
DueAt,
Metadata);
}
}

View File

@@ -0,0 +1,159 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Application.TenantAdmin;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Produces("application/json")]
[Route("api/tenant-admin")]
public sealed class TenantAdminDirectController(
ITenantAdminDirectService tenantAdminService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
{
[HttpGet("classes")]
[EndpointSummary("查询租户班级")]
[ProducesResponseType<TenantAdminClassList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminClassList>> GetClasses(
[FromQuery] TenantAdminClassQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetClassesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("classes")]
[EndpointSummary("新增或更新租户班级")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> UpsertClass(
UpsertTenantAdminClassDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertClassAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("classes/disable")]
[EndpointSummary("停用租户班级")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> DisableClass(
[FromBody] Guid classId,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.DisableClassAsync(ResolveActor(), classId, cancellationToken));
}
[HttpGet("classes/members")]
[EndpointSummary("查询班级成员")]
[ProducesResponseType<CatalogList<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminClassMemberItem>>> GetClassMembers(
[FromQuery] TenantAdminClassMemberQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetClassMembersAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("classes/members")]
[EndpointSummary("新增或更新班级成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> UpsertClassMember(
UpsertTenantAdminClassMemberDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertClassMemberAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("classes/members/remove")]
[EndpointSummary("移除班级成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> RemoveClassMember(
RemoveTenantAdminClassMemberDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.RemoveClassMemberAsync(ResolveActor(), request.ClassMemberId, cancellationToken));
}
[HttpGet("students")]
[EndpointSummary("查询租户学生")]
[ProducesResponseType<TenantAdminStudentList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminStudentList>> GetStudents(
[FromQuery] TenantAdminStudentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetStudentsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("students")]
[EndpointSummary("新增或更新租户学生档案")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentItem>>> UpsertStudent(
UpsertTenantAdminStudentDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertStudentAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("students/status")]
[EndpointSummary("更新租户学生状态")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentStatusItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentStatusItem>>> UpdateStudentStatus(
UpdateTenantAdminStudentStatusDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("student-notes")]
[EndpointSummary("查询学生备注")]
[ProducesResponseType<CatalogList<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminStudentNoteItem>>> GetStudentNotes(
[FromQuery] TenantAdminStudentActivityQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetStudentNotesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("student-notes")]
[EndpointSummary("新增或更新学生备注")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentNoteItem>>> UpsertStudentNote(
UpsertTenantAdminStudentNoteDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertStudentNoteAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("student-followups")]
[EndpointSummary("查询学生跟进")]
[ProducesResponseType<CatalogList<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminStudentFollowupItem>>> GetStudentFollowups(
[FromQuery] TenantAdminStudentActivityQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetStudentFollowupsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("student-followups")]
[EndpointSummary("新增或更新学生跟进")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentFollowupItem>>> UpsertStudentFollowup(
UpsertTenantAdminStudentFollowupDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertStudentFollowupAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
private TenantAdminActor ResolveActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
{
throw new TenantAdminDirectException("Tenant admin actor was not resolved.", "tenant_admin_access_denied");
}
return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
}

View File

@@ -9,6 +9,7 @@ using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Application.TenantAdmin;
namespace Tiku.Api.Middleware;
@@ -161,6 +162,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is TenantAdminDirectException tenantAdminDirectException)
{
await WriteProblemAsync(
context,
tenantAdminDirectException.Message,
TenantAdminDirectStatusCode(tenantAdminDirectException.Code),
tenantAdminDirectException.Code);
return;
}
if (exception is ObjectStorageException storageException)
{
await WriteProblemAsync(
@@ -285,4 +296,16 @@ public sealed class ExceptionHandlingMiddleware(
_ => StatusCodes.Status400BadRequest
};
}
private static int TenantAdminDirectStatusCode(string code)
{
return code switch
{
"tenant_admin_access_denied" => StatusCodes.Status403Forbidden,
"class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound,
"tenant_member_not_found" => StatusCodes.Status400BadRequest,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status400BadRequest
};
}
}

View File

@@ -0,0 +1,72 @@
using Tiku.Application.Catalog;
using Tiku.Application.Content;
namespace Tiku.Application.TenantAdmin;
public interface ITenantAdminDirectService
{
Task<TenantAdminClassList> GetClassesAsync(
TenantAdminActor actor,
TenantAdminClassFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminClassItem>> UpsertClassAsync(
TenantAdminActor actor,
UpsertTenantAdminClassCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminClassItem>> DisableClassAsync(
TenantAdminActor actor,
Guid classId,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminClassMemberItem>> GetClassMembersAsync(
TenantAdminActor actor,
TenantAdminClassMemberFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminClassMemberItem>> UpsertClassMemberAsync(
TenantAdminActor actor,
UpsertTenantAdminClassMemberCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminClassMemberItem>> RemoveClassMemberAsync(
TenantAdminActor actor,
Guid classMemberId,
CancellationToken cancellationToken = default);
Task<TenantAdminStudentList> GetStudentsAsync(
TenantAdminActor actor,
TenantAdminStudentFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminStudentItem>> UpsertStudentAsync(
TenantAdminActor actor,
UpsertTenantAdminStudentCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminStudentStatusItem>> UpdateStudentStatusAsync(
TenantAdminActor actor,
UpdateTenantAdminStudentStatusCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
TenantAdminActor actor,
TenantAdminStudentActivityFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminStudentNoteItem>> UpsertStudentNoteAsync(
TenantAdminActor actor,
UpsertTenantAdminStudentNoteCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminStudentFollowupItem>> GetStudentFollowupsAsync(
TenantAdminActor actor,
TenantAdminStudentActivityFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminStudentFollowupItem>> UpsertStudentFollowupAsync(
TenantAdminActor actor,
UpsertTenantAdminStudentFollowupCommand command,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,210 @@
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
namespace Tiku.Application.TenantAdmin;
public sealed record TenantAdminActor(Guid TenantId, Guid UserId);
public sealed record TenantAdminClassFilter(
Guid? RegionId = null,
string? Status = null,
string? Keyword = null,
int? Limit = null);
public sealed record TenantAdminClassMemberFilter(
Guid ClassId,
string? MemberType = null,
string? Status = null,
int? Limit = null);
public sealed record TenantAdminStudentFilter(
Guid? RegionId = null,
Guid? ClassId = null,
string? Status = null,
string? Keyword = null,
int? Limit = null);
public sealed record TenantAdminStudentActivityFilter(
Guid? StudentUserId = null,
string? Status = null,
int? Limit = null);
public sealed record UpsertTenantAdminClassCommand(
Guid? Id,
Guid? RegionId,
string? LegacyId,
string? Code,
string Name,
string? Description,
string? Status,
int? Order,
JsonElement Metadata);
public sealed record UserLookupCommand(
Guid? UserId,
string? Username,
string? Email,
string? Phone,
string? Name,
string? AvatarUrl);
public sealed record UpsertTenantAdminClassMemberCommand(
Guid ClassId,
UserLookupCommand User,
string? MemberType,
string? Status,
JsonElement Metadata);
public sealed record UpsertTenantAdminStudentCommand(
UserLookupCommand User,
Guid? RegionId,
Guid? SelectedSchoolId,
Guid? SelectedMajorId,
string? AvatarPreset,
JsonElement Stats,
JsonElement Progress,
JsonElement ModuleSelections,
JsonElement RawProfile);
public sealed record UpdateTenantAdminStudentStatusCommand(
Guid UserId,
string? Status,
string? Reason);
public sealed record UpsertTenantAdminStudentNoteCommand(
Guid? Id,
Guid StudentUserId,
string? NoteType,
string Content,
string? Visibility,
bool? IsPinned,
JsonElement Metadata);
public sealed record UpsertTenantAdminStudentFollowupCommand(
Guid? Id,
Guid StudentUserId,
Guid? AssignedToUserId,
Guid? ClassId,
string Title,
string? Description,
string? FollowupType,
string? Priority,
string? Status,
DateTimeOffset? DueAt,
JsonElement Metadata);
public sealed record TenantAdminClassList(
IReadOnlyCollection<TenantAdminClassItem> Items,
bool Scoped);
public sealed record TenantAdminStudentList(
IReadOnlyCollection<TenantAdminStudentItem> Items,
bool Scoped);
public sealed record TenantAdminClassItem(
Guid Id,
Guid? RegionId,
string? RegionName,
string? LegacyId,
string? Code,
string Name,
string? Description,
TenantRecordStatus Status,
int Order,
JsonElement Metadata,
int StudentCount,
int StaffCount,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record TenantAdminClassMemberItem(
Guid Id,
Guid ClassId,
Guid UserId,
TenantClassMemberType MemberType,
TenantClassMemberStatus Status,
DateTimeOffset JoinedAt,
DateTimeOffset? LeftAt,
JsonElement Metadata,
TenantAdminUserSummary User);
public sealed record TenantAdminUserSummary(
Guid Id,
string? Username,
string? Email,
string? Phone,
string? Name,
string? AvatarUrl,
string PrimaryRole);
public sealed record TenantAdminStudentItem(
Guid MembershipId,
Guid UserId,
MembershipStatus Status,
TenantAdminUserSummary User,
Guid? ProfileId,
string AvatarPreset,
Guid? RegionId,
string? RegionName,
Guid? SelectedSchoolId,
string? SelectedSchoolName,
Guid? SelectedMajorId,
string? SelectedMajorName,
JsonElement Stats,
JsonElement Progress,
JsonElement ModuleSelections,
IReadOnlyCollection<TenantAdminStudentClassSummary> Classes,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record TenantAdminStudentClassSummary(
Guid ClassId,
string ClassName,
string? ClassCode,
TenantClassMemberType MemberType,
DateTimeOffset JoinedAt);
public sealed record TenantAdminStudentStatusItem(
Guid MembershipId,
Guid UserId,
TenantRole Role,
MembershipStatus Status,
DateTimeOffset UpdatedAt);
public sealed record TenantAdminStudentNoteItem(
Guid Id,
Guid StudentUserId,
StudentNoteType NoteType,
string Content,
StudentNoteVisibility Visibility,
bool IsPinned,
JsonElement Metadata,
Guid? CreatedBy,
Guid? UpdatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record TenantAdminStudentFollowupItem(
Guid Id,
Guid StudentUserId,
Guid? AssignedToUserId,
Guid? ClassId,
string Title,
string? Description,
StudentFollowupType FollowupType,
StudentFollowupPriority Priority,
StudentFollowupStatus Status,
DateTimeOffset? DueAt,
DateTimeOffset? CompletedAt,
Guid? CompletedBy,
JsonElement Metadata,
Guid? CreatedBy,
Guid? UpdatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed class TenantAdminDirectException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -11,6 +11,7 @@ using Tiku.Application.Profile;
using Tiku.Application.Scoreline;
using Tiku.Application.Storage;
using Tiku.Application.StudyContent;
using Tiku.Application.TenantAdmin;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Catalog;
@@ -22,6 +23,7 @@ using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Infrastructure.Storage;
using Tiku.Infrastructure.StudyContent;
using Tiku.Infrastructure.TenantAdmin;
namespace Tiku.Infrastructure;
@@ -58,6 +60,7 @@ public static class DependencyInjection
services.AddScoped<IAssetAccessService, AssetAccessService>();
services.AddScoped<IAssetManagementService, AssetManagementService>();
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddOptions<AliyunOssOptions>()
.Validate(
AliyunOssOptions.BeValid,

View File

@@ -0,0 +1,896 @@
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.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.TenantAdmin;
public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantAdminDirectService
{
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));
}
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 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 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 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);
}
}

View File

@@ -0,0 +1,242 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class TenantAdminDirectEndpointTests
{
[Fact]
public async Task Tenant_admin_can_manage_classes_members_and_students()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var regionId = Guid.NewGuid();
await factory.SeedAsync(new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" });
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var classResponse = await client.PutAsJsonAsync(
"/api/tenant-admin/classes",
new UpsertTenantAdminClassDto
{
RegionId = regionId,
Code = "A001",
Name = "冲刺一班",
Order = 1
});
var classJson = await ReadJsonAsync(classResponse);
var classId = classJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
var memberResponse = await client.PutAsJsonAsync(
"/api/tenant-admin/classes/members",
new UpsertTenantAdminClassMemberDto
{
ClassId = classId,
MemberType = "student",
User = new TenantAdminUserLookupDto
{
Phone = "13900000001",
Name = "张同学"
},
Metadata = JsonSerializer.SerializeToElement(new { source = "test" })
});
var memberJson = await ReadJsonAsync(memberResponse);
var studentUserId = memberJson.RootElement.GetProperty("item").GetProperty("userId").GetGuid();
var listResponse = await client.GetAsync($"/api/tenant-admin/students?classId={classId}");
var listJson = await ReadJsonAsync(listResponse);
var classesResponse = await client.GetAsync("/api/tenant-admin/classes");
var classesJson = await ReadJsonAsync(classesResponse);
Assert.Equal(HttpStatusCode.OK, classResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
Assert.Equal(studentUserId, listJson.RootElement.GetProperty("items")[0].GetProperty("userId").GetGuid());
Assert.Equal(1, classesJson.RootElement.GetProperty("items")[0].GetProperty("studentCount").GetInt32());
using var scope = factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.True(await dbContext.StudentProfiles.AnyAsync(profile => profile.TenantId == seed.TenantId && profile.UserId == studentUserId));
Assert.True(await dbContext.TenantMemberships.AnyAsync(member => member.TenantId == seed.TenantId && member.UserId == studentUserId && member.Role == TenantRole.Student));
Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.TenantId == seed.TenantId && log.Action == "tenant.class_member.upserted"));
}
[Fact]
public async Task Tenant_admin_can_write_student_notes_and_followups()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var studentId = Guid.NewGuid();
await factory.SeedAsync(
new User { Id = studentId, Phone = "13900000002", Name = "李同学" },
new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
new StudentProfile { TenantId = seed.TenantId, UserId = studentId });
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var noteResponse = await client.PutAsJsonAsync(
"/api/tenant-admin/student-notes",
new UpsertTenantAdminStudentNoteDto
{
StudentUserId = studentId,
NoteType = "learning",
Content = "需要关注英语词汇",
IsPinned = true
});
var followupResponse = await client.PutAsJsonAsync(
"/api/tenant-admin/student-followups",
new UpsertTenantAdminStudentFollowupDto
{
StudentUserId = studentId,
AssignedToUserId = seed.UserId,
Title = "电话回访",
FollowupType = "service",
Priority = "high",
Status = "in_progress"
});
var notesResponse = await client.GetAsync($"/api/tenant-admin/student-notes?studentUserId={studentId}");
var followupsResponse = await client.GetAsync($"/api/tenant-admin/student-followups?studentUserId={studentId}");
var notesJson = await ReadJsonAsync(notesResponse);
var followupsJson = await ReadJsonAsync(followupsResponse);
Assert.Equal(HttpStatusCode.OK, noteResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, followupResponse.StatusCode);
Assert.Single(notesJson.RootElement.GetProperty("items").EnumerateArray());
Assert.Single(followupsJson.RootElement.GetProperty("items").EnumerateArray());
Assert.Equal("电话回访", followupsJson.RootElement.GetProperty("items")[0].GetProperty("title").GetString());
}
[Fact]
public async Task Student_status_update_revokes_active_sessions()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var studentId = Guid.NewGuid();
await factory.SeedAsync(
new User { Id = studentId, Phone = "13900000003", Name = "王同学" },
new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
new AuthSession
{
TenantId = seed.TenantId,
UserId = studentId,
TokenHash = "student-session",
Provider = "password",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var statusResponse = await client.PostAsJsonAsync(
"/api/tenant-admin/students/status",
new UpdateTenantAdminStudentStatusDto
{
UserId = studentId,
Status = "disabled",
Reason = "测试禁用"
});
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
using var scope = factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.True(await dbContext.AuthSessions.AnyAsync(session => session.UserId == studentId && session.RevokedAt != null));
}
[Fact]
public async Task Tenant_operator_cannot_access_tenant_admin_direct_endpoints()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory, TenantRole.TenantOperator);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var response = await client.GetAsync("/api/tenant-admin/students");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(
ApiTestFactory factory,
TenantRole role = TenantRole.TenantAdmin)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = $"137{Random.Shared.Next(10000000, 99999999)}";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Test Tenant",
Status = TenantStatus.Active,
Metadata = JsonDefaults.Object()
},
new User
{
Id = userId,
Phone = phone,
Name = "Tenant Admin"
},
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = role,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
{
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
}