forked from gongxuegit/tiku-backend.net
feat: add student profile endpoints
This commit is contained in:
45
Tiku.Api/Contracts/ProfileDtos.cs
Normal file
45
Tiku.Api/Contracts/ProfileDtos.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Application.Profile;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Contracts;
|
||||||
|
|
||||||
|
public sealed class ProfileQueryDto
|
||||||
|
{
|
||||||
|
[Range(1, 50)]
|
||||||
|
public int? RecentLimit { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 20)]
|
||||||
|
public int? Limit { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateProfileDto
|
||||||
|
{
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[StringLength(32)]
|
||||||
|
public string? AvatarPreset { get; set; }
|
||||||
|
|
||||||
|
public Guid? RegionId { get; set; }
|
||||||
|
public Guid? SelectedSchoolId { get; set; }
|
||||||
|
public Guid? SelectedMajorId { get; set; }
|
||||||
|
public JsonElement? Stats { get; set; }
|
||||||
|
public JsonElement? Progress { get; set; }
|
||||||
|
public JsonElement? ModuleSelections { get; set; }
|
||||||
|
public JsonElement? RecentActivities { get; set; }
|
||||||
|
|
||||||
|
public UpdateProfileCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new UpdateProfileCommand(
|
||||||
|
Name,
|
||||||
|
AvatarPreset,
|
||||||
|
RegionId,
|
||||||
|
SelectedSchoolId,
|
||||||
|
SelectedMajorId,
|
||||||
|
Stats,
|
||||||
|
Progress,
|
||||||
|
ModuleSelections,
|
||||||
|
RecentActivities);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Tiku.Api/Controllers/ProfileController.cs
Normal file
67
Tiku.Api/Controllers/ProfileController.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Application.Profile;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Infrastructure.Profile;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("api/profile")]
|
||||||
|
public sealed class ProfileController(
|
||||||
|
IProfileService profileService,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ICurrentTenant currentTenant) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("me")]
|
||||||
|
[EndpointSummary("获取当前学生资料")]
|
||||||
|
[ProducesResponseType<StudentProfileItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<StudentProfileItem>> Me(
|
||||||
|
[FromQuery] ProfileQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await profileService.GetMeAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
new ProfileQuery(query.RecentLimit),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("me")]
|
||||||
|
[EndpointSummary("更新当前学生资料")]
|
||||||
|
[ProducesResponseType<StudentProfileItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<StudentProfileItem>> UpdateMe(
|
||||||
|
UpdateProfileDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await profileService.UpdateMeAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("exam-countdowns")]
|
||||||
|
[EndpointSummary("查询考试倒计时")]
|
||||||
|
[ProducesResponseType<ExamCountdownList>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ExamCountdownList>> ExamCountdowns(
|
||||||
|
[FromQuery] ProfileQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await profileService.GetExamCountdownsAsync(
|
||||||
|
ResolveActor(),
|
||||||
|
new ProfileQuery(Limit: query.Limit),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProfileActor ResolveActor()
|
||||||
|
{
|
||||||
|
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||||
|
{
|
||||||
|
throw new ProfileException("Current profile actor was not resolved.", "profile_access_denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProfileActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using Tiku.Application.Content;
|
|||||||
using Tiku.Application.Storage;
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
using Tiku.Infrastructure.Learning;
|
using Tiku.Infrastructure.Learning;
|
||||||
|
using Tiku.Infrastructure.Profile;
|
||||||
using Tiku.Infrastructure.QuestionBanks;
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Scoreline;
|
using Tiku.Infrastructure.Scoreline;
|
||||||
|
|
||||||
@@ -150,6 +151,16 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception is ProfileException profileException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(
|
||||||
|
context,
|
||||||
|
profileException.Message,
|
||||||
|
ProfileStatusCode(profileException.Code),
|
||||||
|
profileException.Code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (exception is ObjectStorageException storageException)
|
if (exception is ObjectStorageException storageException)
|
||||||
{
|
{
|
||||||
await WriteProblemAsync(
|
await WriteProblemAsync(
|
||||||
@@ -263,4 +274,15 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
_ => StatusCodes.Status400BadRequest
|
_ => StatusCodes.Status400BadRequest
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int ProfileStatusCode(string code)
|
||||||
|
{
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
"profile_access_denied" => StatusCodes.Status403Forbidden,
|
||||||
|
"profile_user_not_found" => StatusCodes.Status404NotFound,
|
||||||
|
"region_not_found" or "school_not_found" or "major_not_found" => StatusCodes.Status404NotFound,
|
||||||
|
_ => StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
Tiku.Application/Profile/ProfileModels.cs
Normal file
81
Tiku.Application/Profile/ProfileModels.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tiku.Application.Profile;
|
||||||
|
|
||||||
|
public sealed record ProfileActor(Guid TenantId, Guid UserId);
|
||||||
|
|
||||||
|
public sealed record ProfileQuery(int? RecentLimit = null, int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record UpdateProfileCommand(
|
||||||
|
string? Name,
|
||||||
|
string? AvatarPreset,
|
||||||
|
Guid? RegionId,
|
||||||
|
Guid? SelectedSchoolId,
|
||||||
|
Guid? SelectedMajorId,
|
||||||
|
JsonElement? Stats,
|
||||||
|
JsonElement? Progress,
|
||||||
|
JsonElement? ModuleSelections,
|
||||||
|
JsonElement? RecentActivities);
|
||||||
|
|
||||||
|
public sealed record StudentProfileItem(
|
||||||
|
Guid ProfileId,
|
||||||
|
Guid UserId,
|
||||||
|
string? Username,
|
||||||
|
string? Phone,
|
||||||
|
string? Email,
|
||||||
|
string? Name,
|
||||||
|
string AvatarPreset,
|
||||||
|
string AvatarDisplayUrl,
|
||||||
|
string PrimaryRole,
|
||||||
|
int Score,
|
||||||
|
Guid? RegionId,
|
||||||
|
string? RegionName,
|
||||||
|
Guid? SelectedSchoolId,
|
||||||
|
string? SelectedSchoolName,
|
||||||
|
Guid? SelectedMajorId,
|
||||||
|
string? SelectedMajorName,
|
||||||
|
int QuestionsAnsweredToday,
|
||||||
|
int MasteredWordsCount,
|
||||||
|
DateOnly? LastCheckInDate,
|
||||||
|
JsonElement Stats,
|
||||||
|
JsonElement Progress,
|
||||||
|
JsonElement ModuleSelections,
|
||||||
|
JsonElement RecentActivities,
|
||||||
|
IReadOnlyCollection<RecentPracticeItem> RecentPractices,
|
||||||
|
StudentMembershipItem Membership);
|
||||||
|
|
||||||
|
public sealed record RecentPracticeItem(
|
||||||
|
Guid Id,
|
||||||
|
string? PracticeType,
|
||||||
|
string? TargetName,
|
||||||
|
int Progress,
|
||||||
|
DateTimeOffset? LastAccessAt,
|
||||||
|
DateTimeOffset? LastPracticeAt);
|
||||||
|
|
||||||
|
public sealed record StudentMembershipItem(bool IsSvip, DateTimeOffset? ExpiresAt);
|
||||||
|
|
||||||
|
public sealed record ExamCountdownItem(
|
||||||
|
Guid Id,
|
||||||
|
string? LegacyId,
|
||||||
|
Guid? RegionId,
|
||||||
|
Guid? SchoolId,
|
||||||
|
string ExamName,
|
||||||
|
DateTimeOffset? ExamAt,
|
||||||
|
string? ExamType,
|
||||||
|
string? Description,
|
||||||
|
JsonElement Metadata,
|
||||||
|
int Order,
|
||||||
|
bool IsActive,
|
||||||
|
int? DaysLeft);
|
||||||
|
|
||||||
|
public sealed record ExamCountdownList(
|
||||||
|
IReadOnlyCollection<ExamCountdownItem> Items,
|
||||||
|
Guid? RegionId,
|
||||||
|
Guid? SchoolId);
|
||||||
|
|
||||||
|
public interface IProfileService
|
||||||
|
{
|
||||||
|
Task<StudentProfileItem> GetMeAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default);
|
||||||
|
Task<StudentProfileItem> UpdateMeAsync(ProfileActor actor, UpdateProfileCommand command, CancellationToken cancellationToken = default);
|
||||||
|
Task<ExamCountdownList> GetExamCountdownsAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ public sealed class StudentProfile : AuditableTenantEntity
|
|||||||
{
|
{
|
||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public string? LegacyUserId { get; set; }
|
public string? LegacyUserId { get; set; }
|
||||||
|
public string AvatarPreset { get; set; } = "male";
|
||||||
public Guid? RegionId { get; set; }
|
public Guid? RegionId { get; set; }
|
||||||
public Guid? SelectedSchoolId { get; set; }
|
public Guid? SelectedSchoolId { get; set; }
|
||||||
public Guid? SelectedMajorId { get; set; }
|
public Guid? SelectedMajorId { get; set; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Tiku.Application.Catalog;
|
|||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
using Tiku.Application.Learning;
|
using Tiku.Application.Learning;
|
||||||
using Tiku.Application.QuestionBanks;
|
using Tiku.Application.QuestionBanks;
|
||||||
|
using Tiku.Application.Profile;
|
||||||
using Tiku.Application.Scoreline;
|
using Tiku.Application.Scoreline;
|
||||||
using Tiku.Application.Storage;
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Application.StudyContent;
|
using Tiku.Application.StudyContent;
|
||||||
@@ -16,6 +17,7 @@ using Tiku.Infrastructure.Catalog;
|
|||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
using Tiku.Infrastructure.Learning;
|
using Tiku.Infrastructure.Learning;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Profile;
|
||||||
using Tiku.Infrastructure.QuestionBanks;
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Scoreline;
|
using Tiku.Infrastructure.Scoreline;
|
||||||
using Tiku.Infrastructure.Storage;
|
using Tiku.Infrastructure.Storage;
|
||||||
@@ -49,6 +51,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IContentManagementService, ContentManagementService>();
|
services.AddScoped<IContentManagementService, ContentManagementService>();
|
||||||
services.AddScoped<IDirectContentService, DirectContentService>();
|
services.AddScoped<IDirectContentService, DirectContentService>();
|
||||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||||
|
services.AddScoped<IProfileService, ProfileService>();
|
||||||
services.AddScoped<IScorelineQueryService, ScorelineQueryService>();
|
services.AddScoped<IScorelineQueryService, ScorelineQueryService>();
|
||||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ internal sealed class StudentProfileConfiguration : IEntityTypeConfiguration<Stu
|
|||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
|
|
||||||
builder.Property(entity => entity.LegacyUserId).HasMaxLength(64);
|
builder.Property(entity => entity.LegacyUserId).HasMaxLength(64);
|
||||||
|
builder.Property(entity => entity.AvatarPreset).HasMaxLength(32).HasDefaultValue("male");
|
||||||
builder.Property(entity => entity.Stats).IsJson("{}");
|
builder.Property(entity => entity.Stats).IsJson("{}");
|
||||||
builder.Property(entity => entity.Progress).IsJson("{}");
|
builder.Property(entity => entity.Progress).IsJson("{}");
|
||||||
builder.Property(entity => entity.ModuleSelections).IsJson("{}");
|
builder.Property(entity => entity.ModuleSelections).IsJson("{}");
|
||||||
|
|||||||
15562
Tiku.Infrastructure/Persistence/Migrations/20260726103534_AddStudentProfileAvatarPreset.Designer.cs
generated
Normal file
15562
Tiku.Infrastructure/Persistence/Migrations/20260726103534_AddStudentProfileAvatarPreset.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStudentProfileAvatarPreset : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "avatar_preset",
|
||||||
|
table: "student_profiles",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "male");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "avatar_preset",
|
||||||
|
table: "student_profiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7192,6 +7192,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("id")
|
.HasColumnName("id")
|
||||||
.HasDefaultValueSql("gen_random_uuid()");
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarPreset")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasDefaultValue("male")
|
||||||
|
.HasColumnName("avatar_preset");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
|
|||||||
273
Tiku.Infrastructure/Profile/ProfileService.cs
Normal file
273
Tiku.Infrastructure/Profile/ProfileService.cs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Application.Profile;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Learning;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Profile;
|
||||||
|
|
||||||
|
public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedAvatarPresets = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"male",
|
||||||
|
"female"
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<StudentProfileItem> GetMeAsync(
|
||||||
|
ProfileActor actor,
|
||||||
|
ProfileQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var profile = await EnsureProfileAsync(actor, cancellationToken);
|
||||||
|
return await BuildProfileItemAsync(actor, profile, query.RecentLimit, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StudentProfileItem> UpdateMeAsync(
|
||||||
|
ProfileActor actor,
|
||||||
|
UpdateProfileCommand command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var profile = await EnsureProfileAsync(actor, cancellationToken);
|
||||||
|
var user = await dbContext.Users.FindAsync([actor.UserId], cancellationToken)
|
||||||
|
?? throw new ProfileException("Current user was not found.", "profile_user_not_found");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(command.Name))
|
||||||
|
{
|
||||||
|
user.Name = command.Name.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.AvatarPreset is not null)
|
||||||
|
{
|
||||||
|
var avatarPreset = command.AvatarPreset.Trim();
|
||||||
|
if (!AllowedAvatarPresets.Contains(avatarPreset))
|
||||||
|
{
|
||||||
|
throw new ProfileException("Avatar preset must be male or female.", "invalid_avatar_preset");
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.AvatarPreset = avatarPreset.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
profile.RegionId = command.RegionId ?? profile.RegionId;
|
||||||
|
profile.SelectedSchoolId = command.SelectedSchoolId ?? profile.SelectedSchoolId;
|
||||||
|
profile.SelectedMajorId = command.SelectedMajorId ?? profile.SelectedMajorId;
|
||||||
|
if (command.Stats.HasValue)
|
||||||
|
{
|
||||||
|
profile.Stats = JsonObjectOrDefault(command.Stats.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.Progress.HasValue)
|
||||||
|
{
|
||||||
|
profile.Progress = JsonObjectOrDefault(command.Progress.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.ModuleSelections.HasValue)
|
||||||
|
{
|
||||||
|
profile.ModuleSelections = JsonObjectOrDefault(command.ModuleSelections.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.RecentActivities.HasValue)
|
||||||
|
{
|
||||||
|
profile.RecentActivities = JsonArrayOrDefault(command.RecentActivities.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return await BuildProfileItemAsync(actor, profile, null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExamCountdownList> GetExamCountdownsAsync(
|
||||||
|
ProfileActor actor,
|
||||||
|
ProfileQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var profile = await EnsureProfileAsync(actor, cancellationToken);
|
||||||
|
var limit = Math.Clamp(query.Limit ?? 5, 1, 20);
|
||||||
|
var items = await dbContext.ExamDates.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.TenantId == actor.TenantId &&
|
||||||
|
item.IsActive &&
|
||||||
|
(profile.RegionId == null || item.RegionId == null || item.RegionId == profile.RegionId) &&
|
||||||
|
(profile.SelectedSchoolId == null || item.SchoolId == null || item.SchoolId == profile.SelectedSchoolId))
|
||||||
|
.OrderBy(item => item.ExamAt == null)
|
||||||
|
.ThenBy(item => item.ExamAt)
|
||||||
|
.ThenBy(item => item.SortOrder)
|
||||||
|
.Take(limit)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var today = DateTimeOffset.UtcNow.Date;
|
||||||
|
|
||||||
|
return new ExamCountdownList(
|
||||||
|
items.Select(item => new ExamCountdownItem(
|
||||||
|
item.Id,
|
||||||
|
item.LegacyId,
|
||||||
|
item.RegionId,
|
||||||
|
item.SchoolId,
|
||||||
|
item.ExamName,
|
||||||
|
item.ExamAt,
|
||||||
|
item.ExamType,
|
||||||
|
item.Description,
|
||||||
|
item.Metadata,
|
||||||
|
item.SortOrder,
|
||||||
|
item.IsActive,
|
||||||
|
item.ExamAt.HasValue ? (int)Math.Ceiling((item.ExamAt.Value.UtcDateTime.Date - today).TotalDays) : null))
|
||||||
|
.ToArray(),
|
||||||
|
profile.RegionId,
|
||||||
|
profile.SelectedSchoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<StudentProfile> EnsureProfileAsync(
|
||||||
|
ProfileActor actor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var profile = await dbContext.StudentProfiles
|
||||||
|
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken);
|
||||||
|
if (profile is not null)
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
var membershipExists = await dbContext.TenantMemberships.AnyAsync(
|
||||||
|
membership =>
|
||||||
|
membership.TenantId == actor.TenantId &&
|
||||||
|
membership.UserId == actor.UserId &&
|
||||||
|
membership.Status == Domain.Tenancy.MembershipStatus.Active,
|
||||||
|
cancellationToken);
|
||||||
|
if (!membershipExists)
|
||||||
|
{
|
||||||
|
throw new ProfileException("Current user is not a tenant member.", "profile_access_denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = new StudentProfile
|
||||||
|
{
|
||||||
|
TenantId = actor.TenantId,
|
||||||
|
UserId = actor.UserId,
|
||||||
|
AvatarPreset = "male"
|
||||||
|
};
|
||||||
|
dbContext.StudentProfiles.Add(profile);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<StudentProfileItem> BuildProfileItemAsync(
|
||||||
|
ProfileActor actor,
|
||||||
|
StudentProfile profile,
|
||||||
|
int? recentLimit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await dbContext.Users.AsNoTracking()
|
||||||
|
.SingleAsync(item => item.Id == actor.UserId, cancellationToken);
|
||||||
|
var regionName = profile.RegionId.HasValue
|
||||||
|
? await dbContext.Regions.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.RegionId.Value)
|
||||||
|
.Select(item => item.Name)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
var schoolName = profile.SelectedSchoolId.HasValue
|
||||||
|
? await dbContext.Schools.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedSchoolId.Value)
|
||||||
|
.Select(item => item.Name)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
var majorName = profile.SelectedMajorId.HasValue
|
||||||
|
? await dbContext.Majors.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedMajorId.Value)
|
||||||
|
.Select(item => item.Name)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
var limit = Math.Clamp(recentLimit ?? 8, 1, 50);
|
||||||
|
var recentPractices = await dbContext.RecentPractices.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId)
|
||||||
|
.OrderByDescending(item => item.LastPracticeAt)
|
||||||
|
.ThenByDescending(item => item.LastAccessAt)
|
||||||
|
.ThenByDescending(item => item.UpdatedAt)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(item => new RecentPracticeItem(
|
||||||
|
item.Id,
|
||||||
|
item.PracticeType,
|
||||||
|
item.TargetName,
|
||||||
|
item.Progress,
|
||||||
|
item.LastAccessAt,
|
||||||
|
item.LastPracticeAt))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var entitlement = await dbContext.Entitlements.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.TenantId == actor.TenantId &&
|
||||||
|
item.UserId == actor.UserId &&
|
||||||
|
item.EntitlementType == "svip" &&
|
||||||
|
item.Status == EntitlementStatus.Active &&
|
||||||
|
item.StartsAt <= DateTimeOffset.UtcNow &&
|
||||||
|
(item.ExpiresAt == null || item.ExpiresAt > DateTimeOffset.UtcNow))
|
||||||
|
.OrderByDescending(item => item.ExpiresAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new StudentProfileItem(
|
||||||
|
profile.Id,
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.Phone,
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
profile.AvatarPreset,
|
||||||
|
$"/assets/avatars/default-{profile.AvatarPreset}.svg",
|
||||||
|
user.PrimaryRole,
|
||||||
|
user.Score,
|
||||||
|
profile.RegionId,
|
||||||
|
regionName,
|
||||||
|
profile.SelectedSchoolId,
|
||||||
|
schoolName,
|
||||||
|
profile.SelectedMajorId,
|
||||||
|
majorName,
|
||||||
|
profile.QuestionsAnsweredToday,
|
||||||
|
profile.MasteredWordsCount,
|
||||||
|
profile.LastCheckInDate,
|
||||||
|
profile.Stats,
|
||||||
|
profile.Progress,
|
||||||
|
profile.ModuleSelections,
|
||||||
|
profile.RecentActivities,
|
||||||
|
recentPractices,
|
||||||
|
new StudentMembershipItem(entitlement is not null, entitlement?.ExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AssertReferenceAsync<TEntity>(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid? id,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where TEntity : Tiku.Domain.Common.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 ProfileException("Profile reference was not found.", code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||||||
|
{
|
||||||
|
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement JsonArrayOrDefault(JsonElement value)
|
||||||
|
{
|
||||||
|
return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProfileException(string message, string code) : Exception(message)
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
182
Tiku.IntegrationTests/Api/ProfileEndpointTests.cs
Normal file
182
Tiku.IntegrationTests/Api/ProfileEndpointTests.cs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Learning;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Auth;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class ProfileEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Logged_in_student_can_get_and_update_profile()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var seed = await SeedStudentAsync(factory);
|
||||||
|
var regionId = Guid.NewGuid();
|
||||||
|
var schoolId = Guid.NewGuid();
|
||||||
|
var majorId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" },
|
||||||
|
new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" },
|
||||||
|
new Major { Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" });
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
|
||||||
|
using var getResponse = await client.GetAsync("/api/profile/me");
|
||||||
|
var getJson = await ReadJsonAsync(getResponse);
|
||||||
|
using var patchResponse = await client.PatchAsJsonAsync(
|
||||||
|
"/api/profile/me",
|
||||||
|
new UpdateProfileDto
|
||||||
|
{
|
||||||
|
Name = "张三",
|
||||||
|
AvatarPreset = "female",
|
||||||
|
RegionId = regionId,
|
||||||
|
SelectedSchoolId = schoolId,
|
||||||
|
SelectedMajorId = majorId,
|
||||||
|
Stats = JsonSerializer.SerializeToElement(new { level = 3 }),
|
||||||
|
RecentActivities = JsonSerializer.SerializeToElement(new[] { new { type = "login" } })
|
||||||
|
});
|
||||||
|
var patchJson = await ReadJsonAsync(patchResponse);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||||
|
Assert.NotEqual(Guid.Empty, getJson.RootElement.GetProperty("profileId").GetGuid());
|
||||||
|
Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode);
|
||||||
|
Assert.Equal("张三", patchJson.RootElement.GetProperty("name").GetString());
|
||||||
|
Assert.Equal("female", patchJson.RootElement.GetProperty("avatarPreset").GetString());
|
||||||
|
Assert.Equal(regionId, patchJson.RootElement.GetProperty("regionId").GetGuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Exam_countdowns_use_current_profile_target()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var seed = await SeedStudentAsync(factory);
|
||||||
|
var regionId = Guid.NewGuid();
|
||||||
|
var schoolId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new StudentProfile
|
||||||
|
{
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
UserId = seed.UserId,
|
||||||
|
RegionId = regionId,
|
||||||
|
SelectedSchoolId = schoolId
|
||||||
|
},
|
||||||
|
new ExamDate
|
||||||
|
{
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
RegionId = regionId,
|
||||||
|
SchoolId = schoolId,
|
||||||
|
ExamName = "校考",
|
||||||
|
ExamAt = DateTimeOffset.UtcNow.AddDays(10)
|
||||||
|
},
|
||||||
|
new ExamDate
|
||||||
|
{
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
RegionId = Guid.NewGuid(),
|
||||||
|
ExamName = "其他地区考试",
|
||||||
|
ExamAt = DateTimeOffset.UtcNow.AddDays(1)
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
|
||||||
|
using var response = await client.GetAsync("/api/profile/exam-countdowns");
|
||||||
|
var json = await ReadJsonAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var item = json.RootElement.GetProperty("items").EnumerateArray().Single();
|
||||||
|
Assert.Equal("校考", item.GetProperty("examName").GetString());
|
||||||
|
Assert.True(item.GetProperty("daysLeft").GetInt32() >= 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Profile_requires_authentication()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync("/api/profile/me");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(ApiTestFactory factory)
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var phone = $"136{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 = "Student"
|
||||||
|
},
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = userId,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user