feat: add student profile endpoints

This commit is contained in:
xiong
2026-07-26 18:36:28 +08:00
parent 5715869d29
commit 387b85342a
12 changed files with 16275 additions and 0 deletions

View 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);
}
}

View 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);
}
}

View File

@@ -6,6 +6,7 @@ using Tiku.Application.Content;
using Tiku.Application.Storage;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
@@ -150,6 +151,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is ProfileException profileException)
{
await WriteProblemAsync(
context,
profileException.Message,
ProfileStatusCode(profileException.Code),
profileException.Code);
return;
}
if (exception is ObjectStorageException storageException)
{
await WriteProblemAsync(
@@ -263,4 +274,15 @@ public sealed class ExceptionHandlingMiddleware(
_ => 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
};
}
}

View 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);
}

View File

@@ -7,6 +7,7 @@ public sealed class StudentProfile : AuditableTenantEntity
{
public Guid UserId { get; set; }
public string? LegacyUserId { get; set; }
public string AvatarPreset { get; set; } = "male";
public Guid? RegionId { get; set; }
public Guid? SelectedSchoolId { get; set; }
public Guid? SelectedMajorId { get; set; }

View File

@@ -7,6 +7,7 @@ using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Profile;
using Tiku.Application.Scoreline;
using Tiku.Application.Storage;
using Tiku.Application.StudyContent;
@@ -16,6 +17,7 @@ using Tiku.Infrastructure.Catalog;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Infrastructure.Storage;
@@ -49,6 +51,7 @@ public static class DependencyInjection
services.AddScoped<IContentManagementService, ContentManagementService>();
services.AddScoped<IDirectContentService, DirectContentService>();
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
services.AddScoped<IProfileService, ProfileService>();
services.AddScoped<IScorelineQueryService, ScorelineQueryService>();
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
services.AddScoped<IAssetQueryService, AssetQueryService>();

View File

@@ -60,6 +60,7 @@ internal sealed class StudentProfileConfiguration : IEntityTypeConfiguration<Stu
builder.ConfigureTimestamps();
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.Progress).IsJson("{}");
builder.Property(entity => entity.ModuleSelections).IsJson("{}");

View File

@@ -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");
}
}
}

View File

@@ -7192,6 +7192,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("id")
.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")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")

View 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;
}

View 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();
}
}