From f671a7f07a9f45d5e7b529b269300b0cfd36aeeb Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 18:53:01 +0800 Subject: [PATCH] feat: add tenant admin direct operations endpoints --- Tiku.Api/Contracts/TenantAdminDirectDtos.cs | 217 +++++ .../TenantAdminDirectController.cs | 159 ++++ .../Middleware/ExceptionHandlingMiddleware.cs | 23 + .../TenantAdmin/ITenantAdminDirectService.cs | 72 ++ .../TenantAdmin/TenantAdminDirectModels.cs | 210 ++++ Tiku.Infrastructure/DependencyInjection.cs | 3 + .../TenantAdmin/TenantAdminDirectService.cs | 896 ++++++++++++++++++ .../Api/TenantAdminDirectEndpointTests.cs | 242 +++++ 8 files changed, 1822 insertions(+) create mode 100644 Tiku.Api/Contracts/TenantAdminDirectDtos.cs create mode 100644 Tiku.Api/Controllers/TenantAdminDirectController.cs create mode 100644 Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs create mode 100644 Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs create mode 100644 Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs create mode 100644 Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs new file mode 100644 index 0000000..27fcfef --- /dev/null +++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs @@ -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); + } +} diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs new file mode 100644 index 0000000..93ac12a --- /dev/null +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -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(StatusCodes.Status200OK)] + public async Task> GetClasses( + [FromQuery] TenantAdminClassQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetClassesAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("classes")] + [EndpointSummary("新增或更新租户班级")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertClass( + UpsertTenantAdminClassDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertClassAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("classes/disable")] + [EndpointSummary("停用租户班级")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> DisableClass( + [FromBody] Guid classId, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.DisableClassAsync(ResolveActor(), classId, cancellationToken)); + } + + [HttpGet("classes/members")] + [EndpointSummary("查询班级成员")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetClassMembers( + [FromQuery] TenantAdminClassMemberQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetClassMembersAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("classes/members")] + [EndpointSummary("新增或更新班级成员")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertClassMember( + UpsertTenantAdminClassMemberDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertClassMemberAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("classes/members/remove")] + [EndpointSummary("移除班级成员")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> RemoveClassMember( + RemoveTenantAdminClassMemberDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.RemoveClassMemberAsync(ResolveActor(), request.ClassMemberId, cancellationToken)); + } + + [HttpGet("students")] + [EndpointSummary("查询租户学生")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetStudents( + [FromQuery] TenantAdminStudentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetStudentsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("students")] + [EndpointSummary("新增或更新租户学生档案")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertStudent( + UpsertTenantAdminStudentDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertStudentAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("students/status")] + [EndpointSummary("更新租户学生状态")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpdateStudentStatus( + UpdateTenantAdminStudentStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("student-notes")] + [EndpointSummary("查询学生备注")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetStudentNotes( + [FromQuery] TenantAdminStudentActivityQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetStudentNotesAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("student-notes")] + [EndpointSummary("新增或更新学生备注")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertStudentNote( + UpsertTenantAdminStudentNoteDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertStudentNoteAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("student-followups")] + [EndpointSummary("查询学生跟进")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetStudentFollowups( + [FromQuery] TenantAdminStudentActivityQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetStudentFollowupsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("student-followups")] + [EndpointSummary("新增或更新学生跟进")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> 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); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 603eb0f..865197d 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -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 + }; + } } diff --git a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs new file mode 100644 index 0000000..ea3ff2a --- /dev/null +++ b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs @@ -0,0 +1,72 @@ +using Tiku.Application.Catalog; +using Tiku.Application.Content; + +namespace Tiku.Application.TenantAdmin; + +public interface ITenantAdminDirectService +{ + Task GetClassesAsync( + TenantAdminActor actor, + TenantAdminClassFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertClassAsync( + TenantAdminActor actor, + UpsertTenantAdminClassCommand command, + CancellationToken cancellationToken = default); + + Task> DisableClassAsync( + TenantAdminActor actor, + Guid classId, + CancellationToken cancellationToken = default); + + Task> GetClassMembersAsync( + TenantAdminActor actor, + TenantAdminClassMemberFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertClassMemberAsync( + TenantAdminActor actor, + UpsertTenantAdminClassMemberCommand command, + CancellationToken cancellationToken = default); + + Task> RemoveClassMemberAsync( + TenantAdminActor actor, + Guid classMemberId, + CancellationToken cancellationToken = default); + + Task GetStudentsAsync( + TenantAdminActor actor, + TenantAdminStudentFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertStudentAsync( + TenantAdminActor actor, + UpsertTenantAdminStudentCommand command, + CancellationToken cancellationToken = default); + + Task> UpdateStudentStatusAsync( + TenantAdminActor actor, + UpdateTenantAdminStudentStatusCommand command, + CancellationToken cancellationToken = default); + + Task> GetStudentNotesAsync( + TenantAdminActor actor, + TenantAdminStudentActivityFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertStudentNoteAsync( + TenantAdminActor actor, + UpsertTenantAdminStudentNoteCommand command, + CancellationToken cancellationToken = default); + + Task> GetStudentFollowupsAsync( + TenantAdminActor actor, + TenantAdminStudentActivityFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertStudentFollowupAsync( + TenantAdminActor actor, + UpsertTenantAdminStudentFollowupCommand command, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs new file mode 100644 index 0000000..e35ae60 --- /dev/null +++ b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs @@ -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 Items, + bool Scoped); + +public sealed record TenantAdminStudentList( + IReadOnlyCollection 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 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; +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 4eb78a5..2cb8d3b 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddOptions() .Validate( AliyunOssOptions.BeValid, diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs new file mode 100644 index 0000000..a036b57 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -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 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(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> UpsertClassAsync( + TenantAdminActor actor, + UpsertTenantAdminClassCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(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(ToClassItem(item, regionName, 0, 0)); + } + + public async Task> 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(ToClassItem(item, null, 0, 0)); + } + + public async Task> 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(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(items); + } + + public async Task> 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(ToClassMemberItem(item, ToUserSummary(user))); + } + + public async Task> 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(ToClassMemberItem(item, ToUserSummary(user))); + } + + public async Task 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); + + 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> UpsertStudentAsync( + TenantAdminActor actor, + UpsertTenantAdminStudentCommand command, + CancellationToken cancellationToken = default) + { + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); + await AssertReferenceAsync(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( + ToStudentItem( + membership, + user, + profile, + new Dictionary(), + new Dictionary(), + new Dictionary(), + [])); + } + + public async Task> 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( + new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); + } + + public async Task> 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(items); + } + + public async Task> 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(ToNoteItem(item)); + } + + public async Task> 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(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(items); + } + + public async Task> 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(ToFollowupItem(item)); + } + + private async Task 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 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 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( + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : TenantEntity + { + if (!id.HasValue) + { + return; + } + + var exists = await dbContext.Set().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 ResolveTenantEntityAsync( + DbSet 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(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 regions, + IReadOnlyDictionary schools, + IReadOnlyDictionary majors, + IReadOnlyCollection 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(string? value, TEnum fallback, string code) + where TEnum : struct, Enum + { + return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum(value, code); + } + + private static TEnum ParseEnum(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()) + { + if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + + throw new TenantAdminDirectException("Enum value was invalid.", code); + } +} diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs new file mode 100644 index 0000000..e54b694 --- /dev/null +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -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(); + 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(); + 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 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(); + } +}