forked from xiongyuxing/tiku-backend.net
feat: add learning activity endpoints
This commit is contained in:
101
Tiku.Api/Contracts/LearningDtos.cs
Normal file
101
Tiku.Api/Contracts/LearningDtos.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Tiku.Application.Learning;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class LearningLimitQueryDto
|
||||
{
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
public Guid? UnitId { get; set; }
|
||||
|
||||
public LearningLimitFilter ToFilter()
|
||||
{
|
||||
return new LearningLimitFilter(Limit, Status, UnitId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SubmitAnswerDto
|
||||
{
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
public Guid? PracticeSessionId { get; set; }
|
||||
|
||||
public IReadOnlyCollection<string>? SelectedOptions { get; set; }
|
||||
|
||||
[StringLength(10000)]
|
||||
public string? AnswerText { get; set; }
|
||||
|
||||
public bool? SelfJudgedCorrect { get; set; }
|
||||
|
||||
public SubmitAnswerCommand ToCommand()
|
||||
{
|
||||
return new SubmitAnswerCommand(
|
||||
QuestionId,
|
||||
PracticeSessionId,
|
||||
SelectedOptions,
|
||||
AnswerText,
|
||||
SelfJudgedCorrect);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class QuestionActionDto
|
||||
{
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
public bool? Favorite { get; set; }
|
||||
|
||||
public QuestionActionCommand ToCommand()
|
||||
{
|
||||
return new QuestionActionCommand(QuestionId, Favorite);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WordProgressDto
|
||||
{
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[Range(0, 100)]
|
||||
public int? CorrectDelta { get; set; }
|
||||
|
||||
[Range(0, 100)]
|
||||
public int? WrongDelta { get; set; }
|
||||
|
||||
public DateTimeOffset? NextReviewAt { get; set; }
|
||||
|
||||
public WordProgressCommand ToCommand()
|
||||
{
|
||||
return new WordProgressCommand(
|
||||
WordId,
|
||||
Status,
|
||||
CorrectDelta,
|
||||
WrongDelta,
|
||||
NextReviewAt);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FavoriteWordDto
|
||||
{
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
public bool? Favorite { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
public FavoriteWordCommand ToCommand()
|
||||
{
|
||||
return new FavoriteWordCommand(WordId, Favorite, Note);
|
||||
}
|
||||
}
|
||||
154
Tiku.Api/Controllers/LearningController.cs
Normal file
154
Tiku.Api/Controllers/LearningController.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/learning")]
|
||||
public sealed class LearningController(
|
||||
ILearningActivityService learningActivityService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpPost("answers")]
|
||||
[EndpointSummary("提交题目答案")]
|
||||
[ProducesResponseType<AnswerRecordItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AnswerRecordItem>> SubmitAnswer(
|
||||
SubmitAnswerDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.SubmitAnswerAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("favorites/questions")]
|
||||
[EndpointSummary("查询收藏题目")]
|
||||
[ProducesResponseType<LearningList<FavoriteQuestionItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<FavoriteQuestionItem>>> GetFavoriteQuestions(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetFavoriteQuestionsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("favorites/questions")]
|
||||
[EndpointSummary("收藏或取消收藏题目")]
|
||||
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LearningActionResult>> ToggleFavoriteQuestion(
|
||||
QuestionActionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ToggleFavoriteQuestionAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("wrong-questions")]
|
||||
[EndpointSummary("查询错题列表")]
|
||||
[ProducesResponseType<LearningList<WrongQuestionItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<WrongQuestionItem>>> GetWrongQuestions(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWrongQuestionsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("wrong-questions/resolve")]
|
||||
[EndpointSummary("将错题标记为已解决")]
|
||||
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LearningActionResult>> ResolveWrongQuestion(
|
||||
QuestionActionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ResolveWrongQuestionAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/progress")]
|
||||
[EndpointSummary("查询单词学习进度")]
|
||||
[ProducesResponseType<LearningList<WordProgressItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<WordProgressItem>>> GetWordProgress(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWordProgressAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("vocabulary/progress")]
|
||||
[EndpointSummary("更新单词学习进度")]
|
||||
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<WordProgressItem>> UpdateWordProgress(
|
||||
WordProgressDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.UpdateWordProgressAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/favorites")]
|
||||
[EndpointSummary("查询收藏单词")]
|
||||
[ProducesResponseType<LearningList<FavoriteWordItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<FavoriteWordItem>>> GetFavoriteWords(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetFavoriteWordsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("vocabulary/favorites")]
|
||||
[EndpointSummary("收藏或取消收藏单词")]
|
||||
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LearningActionResult>> ToggleFavoriteWord(
|
||||
FavoriteWordDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ToggleFavoriteWordAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private LearningActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new LearningAccessDeniedException();
|
||||
}
|
||||
|
||||
return new LearningActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LearningAccessDeniedException()
|
||||
: Exception("Learning actor was not resolved.");
|
||||
@@ -4,6 +4,7 @@ using Tiku.Application.Assets;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Learning;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
@@ -87,6 +88,36 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is LearningValidationException learningValidationException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
learningValidationException.Message,
|
||||
StatusCodes.Status400BadRequest,
|
||||
learningValidationException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is LearningResourceNotFoundException learningResourceNotFoundException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
learningResourceNotFoundException.Message,
|
||||
StatusCodes.Status404NotFound,
|
||||
learningResourceNotFoundException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is LearningAccessDeniedException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
exception.Message,
|
||||
StatusCodes.Status403Forbidden,
|
||||
"learning_access_denied");
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is ObjectStorageException storageException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
|
||||
49
Tiku.Application/Learning/ILearningActivityService.cs
Normal file
49
Tiku.Application/Learning/ILearningActivityService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace Tiku.Application.Learning;
|
||||
|
||||
public interface ILearningActivityService
|
||||
{
|
||||
Task<AnswerRecordItem> SubmitAnswerAsync(
|
||||
LearningActor actor,
|
||||
SubmitAnswerCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningActionResult> ToggleFavoriteQuestionAsync(
|
||||
LearningActor actor,
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningActionResult> ResolveWrongQuestionAsync(
|
||||
LearningActor actor,
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningList<WordProgressItem>> GetWordProgressAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WordProgressItem> UpdateWordProgressAsync(
|
||||
LearningActor actor,
|
||||
WordProgressCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningActionResult> ToggleFavoriteWordAsync(
|
||||
LearningActor actor,
|
||||
FavoriteWordCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
69
Tiku.Application/Learning/LearningActivityModels.cs
Normal file
69
Tiku.Application/Learning/LearningActivityModels.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Application.Learning;
|
||||
|
||||
public sealed record LearningActor(Guid TenantId, Guid UserId);
|
||||
|
||||
public sealed record LearningList<TItem>(IReadOnlyCollection<TItem> Items);
|
||||
|
||||
public sealed record SubmitAnswerCommand(
|
||||
Guid QuestionId,
|
||||
Guid? PracticeSessionId,
|
||||
IReadOnlyCollection<string>? SelectedOptions,
|
||||
string? AnswerText,
|
||||
bool? SelfJudgedCorrect);
|
||||
|
||||
public sealed record QuestionActionCommand(Guid QuestionId, bool? Favorite);
|
||||
|
||||
public sealed record WordProgressCommand(
|
||||
Guid WordId,
|
||||
string? Status,
|
||||
int? CorrectDelta,
|
||||
int? WrongDelta,
|
||||
DateTimeOffset? NextReviewAt);
|
||||
|
||||
public sealed record FavoriteWordCommand(Guid WordId, bool? Favorite, string? Note);
|
||||
|
||||
public sealed record LearningLimitFilter(int? Limit = null, string? Status = null, Guid? UnitId = null);
|
||||
|
||||
public sealed record AnswerRecordItem(
|
||||
Guid Id,
|
||||
Guid QuestionId,
|
||||
Guid? QuestionVersionId,
|
||||
Guid? PracticeSessionId,
|
||||
JsonElement SelectedOptions,
|
||||
string? AnswerText,
|
||||
bool? IsCorrect,
|
||||
DateTimeOffset AnsweredAt);
|
||||
|
||||
public sealed record FavoriteQuestionItem(
|
||||
Guid QuestionId,
|
||||
string Source,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record WrongQuestionItem(
|
||||
Guid QuestionId,
|
||||
int WrongCount,
|
||||
DateTimeOffset LastWrongAt,
|
||||
DateTimeOffset? ResolvedAt);
|
||||
|
||||
public sealed record WordProgressItem(
|
||||
Guid WordId,
|
||||
WordProgressStatus Status,
|
||||
int CorrectCount,
|
||||
int WrongCount,
|
||||
DateTimeOffset? LastReviewAt,
|
||||
DateTimeOffset? NextReviewAt,
|
||||
int ReviewCount,
|
||||
int CorrectStreak,
|
||||
WordReviewResult? LastResult,
|
||||
WordDueLevel DueLevel,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record FavoriteWordItem(
|
||||
Guid WordId,
|
||||
string? Note,
|
||||
DateTimeOffset? FavoritedAt);
|
||||
|
||||
public sealed record LearningActionResult(bool Ok, bool? IsFavorite = null);
|
||||
@@ -5,6 +5,7 @@ using Tiku.Application.Assets;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.StudyContent;
|
||||
@@ -12,6 +13,7 @@ using Tiku.Infrastructure.Assets;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Learning;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
@@ -46,6 +48,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate(
|
||||
AliyunOssOptions.BeValid,
|
||||
|
||||
477
Tiku.Infrastructure/Learning/LearningActivityService.cs
Normal file
477
Tiku.Infrastructure/Learning/LearningActivityService.cs
Normal file
@@ -0,0 +1,477 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
|
||||
public sealed class LearningActivityService(TikuDbContext dbContext) : ILearningActivityService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
|
||||
public async Task<AnswerRecordItem> SubmitAnswerAsync(
|
||||
LearningActor actor,
|
||||
SubmitAnswerCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await dbContext.Questions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.QuestionId &&
|
||||
item.Status == QuestionStatus.Published)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.CurrentVersionId
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (question is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
|
||||
}
|
||||
|
||||
if (command.PracticeSessionId.HasValue)
|
||||
{
|
||||
var sessionExists = await dbContext.PracticeSessions.AnyAsync(
|
||||
session =>
|
||||
session.TenantId == actor.TenantId &&
|
||||
session.UserId == actor.UserId &&
|
||||
session.Id == command.PracticeSessionId.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (!sessionExists)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new AnswerRecord
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = question.Id,
|
||||
QuestionVersionId = question.CurrentVersionId,
|
||||
PracticeSessionId = command.PracticeSessionId,
|
||||
SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
|
||||
AnswerText = command.AnswerText,
|
||||
IsCorrect = command.SelfJudgedCorrect,
|
||||
AnsweredAt = now,
|
||||
CreatedAt = now
|
||||
};
|
||||
dbContext.AnswerRecords.Add(record);
|
||||
|
||||
if (command.SelfJudgedCorrect == false)
|
||||
{
|
||||
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, question.Id],
|
||||
cancellationToken);
|
||||
|
||||
if (wrongQuestion is null)
|
||||
{
|
||||
dbContext.WrongQuestions.Add(new WrongQuestion
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = question.Id,
|
||||
WrongCount = 1,
|
||||
LastWrongAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
wrongQuestion.WrongCount++;
|
||||
wrongQuestion.LastWrongAt = now;
|
||||
wrongQuestion.ResolvedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(record);
|
||||
}
|
||||
|
||||
public async Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = await dbContext.FavoriteQuestions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new FavoriteQuestionItem(
|
||||
item.QuestionId,
|
||||
item.Source,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new LearningList<FavoriteQuestionItem>(items);
|
||||
}
|
||||
|
||||
public async Task<LearningActionResult> ToggleFavoriteQuestionAsync(
|
||||
LearningActor actor,
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureQuestionExistsAsync(actor.TenantId, command.QuestionId, cancellationToken);
|
||||
|
||||
var favorite = command.Favorite ?? true;
|
||||
var item = await dbContext.FavoriteQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, command.QuestionId],
|
||||
cancellationToken);
|
||||
|
||||
if (favorite)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
dbContext.FavoriteQuestions.Add(new FavoriteQuestion
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = command.QuestionId,
|
||||
Source = "api",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (item is not null)
|
||||
{
|
||||
dbContext.FavoriteQuestions.Remove(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new LearningActionResult(true, favorite);
|
||||
}
|
||||
|
||||
public async Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.WrongQuestions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId);
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.ResolvedAt == null);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.LastWrongAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WrongQuestionItem(
|
||||
item.QuestionId,
|
||||
item.WrongCount,
|
||||
item.LastWrongAt,
|
||||
item.ResolvedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new LearningList<WrongQuestionItem>(items);
|
||||
}
|
||||
|
||||
public async Task<LearningActionResult> ResolveWrongQuestionAsync(
|
||||
LearningActor actor,
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, command.QuestionId],
|
||||
cancellationToken);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found.");
|
||||
}
|
||||
|
||||
item.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new LearningActionResult(true);
|
||||
}
|
||||
|
||||
public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.UserWordProgress
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId);
|
||||
|
||||
if (filter.UnitId.HasValue)
|
||||
{
|
||||
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
||||
word.TenantId == actor.TenantId &&
|
||||
word.Id == item.WordId &&
|
||||
word.UnitId == filter.UnitId.Value));
|
||||
}
|
||||
|
||||
if (TryParseWordProgressStatus(filter.Status, out var status))
|
||||
{
|
||||
query = query.Where(item => item.Status == status);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(item => item.NextReviewAt == null)
|
||||
.ThenBy(item => item.NextReviewAt)
|
||||
.ThenByDescending(item => item.UpdatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WordProgressItem(
|
||||
item.WordId,
|
||||
item.Status,
|
||||
item.CorrectCount,
|
||||
item.WrongCount,
|
||||
item.LastReviewAt,
|
||||
item.NextReviewAt,
|
||||
item.ReviewCount,
|
||||
item.CorrectStreak,
|
||||
item.LastResult,
|
||||
item.DueLevel,
|
||||
item.Metadata))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new LearningList<WordProgressItem>(items);
|
||||
}
|
||||
|
||||
public async Task<WordProgressItem> UpdateWordProgressAsync(
|
||||
LearningActor actor,
|
||||
WordProgressCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var item = await dbContext.UserWordProgress
|
||||
.SingleOrDefaultAsync(
|
||||
progress =>
|
||||
progress.TenantId == actor.TenantId &&
|
||||
progress.UserId == actor.UserId &&
|
||||
progress.WordId == command.WordId,
|
||||
cancellationToken);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
item = new UserWordProgress
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
WordId = command.WordId
|
||||
};
|
||||
dbContext.UserWordProgress.Add(item);
|
||||
}
|
||||
|
||||
if (TryParseWordProgressStatus(command.Status, out var status))
|
||||
{
|
||||
item.Status = status;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(command.Status))
|
||||
{
|
||||
item.Status = WordProgressStatus.Learning;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new LearningValidationException("invalid_word_status", "Word progress status is invalid.");
|
||||
}
|
||||
|
||||
var correctDelta = command.CorrectDelta ?? 0;
|
||||
var wrongDelta = command.WrongDelta ?? 0;
|
||||
item.CorrectCount += correctDelta;
|
||||
item.WrongCount += wrongDelta;
|
||||
item.ReviewCount += correctDelta + wrongDelta;
|
||||
item.CorrectStreak = wrongDelta > 0
|
||||
? 0
|
||||
: item.CorrectStreak + correctDelta;
|
||||
item.LastResult = wrongDelta > 0
|
||||
? WordReviewResult.Wrong
|
||||
: correctDelta > 0
|
||||
? WordReviewResult.Correct
|
||||
: item.LastResult;
|
||||
item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt;
|
||||
item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt;
|
||||
item.DueLevel = item.Status == WordProgressStatus.Mastered
|
||||
? WordDueLevel.Mastered
|
||||
: item.WrongCount > 0 && item.CorrectStreak == 0
|
||||
? WordDueLevel.Again
|
||||
: item.DueLevel;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(item);
|
||||
}
|
||||
|
||||
public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.UserWordFavorites
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId);
|
||||
|
||||
if (filter.UnitId.HasValue)
|
||||
{
|
||||
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
||||
word.TenantId == actor.TenantId &&
|
||||
word.Id == item.WordId &&
|
||||
word.UnitId == filter.UnitId.Value));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new FavoriteWordItem(
|
||||
item.WordId,
|
||||
item.Note,
|
||||
item.FavoritedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new LearningList<FavoriteWordItem>(items);
|
||||
}
|
||||
|
||||
public async Task<LearningActionResult> ToggleFavoriteWordAsync(
|
||||
LearningActor actor,
|
||||
FavoriteWordCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
|
||||
|
||||
var favorite = command.Favorite ?? true;
|
||||
var item = await dbContext.UserWordFavorites
|
||||
.SingleOrDefaultAsync(
|
||||
favoriteWord =>
|
||||
favoriteWord.TenantId == actor.TenantId &&
|
||||
favoriteWord.UserId == actor.UserId &&
|
||||
favoriteWord.WordId == command.WordId,
|
||||
cancellationToken);
|
||||
|
||||
if (favorite)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
item = new UserWordFavorite
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
WordId = command.WordId
|
||||
};
|
||||
dbContext.UserWordFavorites.Add(item);
|
||||
}
|
||||
|
||||
item.Note = command.Note ?? item.Note;
|
||||
item.FavoritedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
else if (item is not null)
|
||||
{
|
||||
dbContext.UserWordFavorites.Remove(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new LearningActionResult(true, favorite);
|
||||
}
|
||||
|
||||
private async Task EnsureQuestionExistsAsync(
|
||||
Guid tenantId,
|
||||
Guid questionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.Questions.AnyAsync(
|
||||
question =>
|
||||
question.TenantId == tenantId &&
|
||||
question.Id == questionId &&
|
||||
question.Status == QuestionStatus.Published,
|
||||
cancellationToken);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureWordExistsAsync(
|
||||
Guid tenantId,
|
||||
Guid wordId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.VocabularyWords.AnyAsync(
|
||||
word =>
|
||||
word.TenantId == tenantId &&
|
||||
word.Id == wordId &&
|
||||
word.IsActive,
|
||||
cancellationToken);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AnswerRecordItem ToItem(AnswerRecord record)
|
||||
{
|
||||
return new AnswerRecordItem(
|
||||
record.Id,
|
||||
record.QuestionId!.Value,
|
||||
record.QuestionVersionId,
|
||||
record.PracticeSessionId,
|
||||
record.SelectedOptions,
|
||||
record.AnswerText,
|
||||
record.IsCorrect,
|
||||
record.AnsweredAt);
|
||||
}
|
||||
|
||||
private static WordProgressItem ToItem(UserWordProgress item)
|
||||
{
|
||||
return new WordProgressItem(
|
||||
item.WordId,
|
||||
item.Status,
|
||||
item.CorrectCount,
|
||||
item.WrongCount,
|
||||
item.LastReviewAt,
|
||||
item.NextReviewAt,
|
||||
item.ReviewCount,
|
||||
item.CorrectStreak,
|
||||
item.LastResult,
|
||||
item.DueLevel,
|
||||
item.Metadata);
|
||||
}
|
||||
|
||||
private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
|
||||
}
|
||||
|
||||
private static string? NormalizeEnumValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class LearningException(string code, string message) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
|
||||
public sealed class LearningResourceNotFoundException(string code, string message) : LearningException(code, message);
|
||||
|
||||
public sealed class LearningValidationException(string code, string message) : LearningException(code, message);
|
||||
274
Tiku.IntegrationTests/Api/LearningEndpointTests.cs
Normal file
274
Tiku.IntegrationTests/Api/LearningEndpointTests.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class LearningEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Learning_endpoints_require_current_tenant_member()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/learning/favorites/questions");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_answer_records_wrong_question_when_self_judged_wrong()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
var questionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = seed.TenantId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"/api/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
QuestionId = questionId,
|
||||
SelectedOptions = ["A"],
|
||||
SelfJudgedCorrect = false
|
||||
});
|
||||
using var wrongResponse = await client.GetAsync("/api/learning/wrong-questions");
|
||||
var answer = await ReadJsonAsync(response);
|
||||
var wrongItems = await ReadItemsAsync(wrongResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(questionId, answer.RootElement.GetProperty("questionId").GetGuid());
|
||||
Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean());
|
||||
var wrong = Assert.Single(wrongItems);
|
||||
Assert.Equal(questionId, wrong.GetProperty("questionId").GetGuid());
|
||||
Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Favorite_question_can_be_added_listed_and_removed()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
var questionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = seed.TenantId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var addResponse = await client.PostAsJsonAsync(
|
||||
"/api/learning/favorites/questions",
|
||||
new QuestionActionDto { QuestionId = questionId });
|
||||
var listResponse = await client.GetAsync("/api/learning/favorites/questions");
|
||||
var itemsAfterAdd = await ReadItemsAsync(listResponse);
|
||||
var removeResponse = await client.PostAsJsonAsync(
|
||||
"/api/learning/favorites/questions",
|
||||
new QuestionActionDto { QuestionId = questionId, Favorite = false });
|
||||
var emptyResponse = await client.GetAsync("/api/learning/favorites/questions");
|
||||
var itemsAfterRemove = await ReadItemsAsync(emptyResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
Assert.Equal(questionId, Assert.Single(itemsAfterAdd).GetProperty("questionId").GetGuid());
|
||||
Assert.Equal(HttpStatusCode.OK, removeResponse.StatusCode);
|
||||
Assert.Empty(itemsAfterRemove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Word_progress_upserts_and_filters_by_status()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
var wordId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new VocabularyWord
|
||||
{
|
||||
Id = wordId,
|
||||
TenantId = seed.TenantId,
|
||||
Word = "composition",
|
||||
IsActive = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var updateResponse = await client.PostAsJsonAsync(
|
||||
"/api/learning/vocabulary/progress",
|
||||
new WordProgressDto
|
||||
{
|
||||
WordId = wordId,
|
||||
Status = "learning",
|
||||
CorrectDelta = 2
|
||||
});
|
||||
using var listResponse = await client.GetAsync("/api/learning/vocabulary/progress?status=learning");
|
||||
var progress = await ReadJsonAsync(updateResponse);
|
||||
var items = await ReadItemsAsync(listResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
Assert.Equal(2, progress.RootElement.GetProperty("correctCount").GetInt32());
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal(wordId, item.GetProperty("wordId").GetGuid());
|
||||
Assert.Equal("Learning", item.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Favorite_words_can_be_filtered_by_unit()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
var unitId = Guid.NewGuid();
|
||||
var otherUnitId = Guid.NewGuid();
|
||||
var wordId = Guid.NewGuid();
|
||||
var otherWordId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new VocabularyUnit
|
||||
{
|
||||
Id = unitId,
|
||||
TenantId = seed.TenantId,
|
||||
Name = "Unit 1",
|
||||
IsActive = true
|
||||
},
|
||||
new VocabularyUnit
|
||||
{
|
||||
Id = otherUnitId,
|
||||
TenantId = seed.TenantId,
|
||||
Name = "Unit 2",
|
||||
IsActive = true
|
||||
},
|
||||
new VocabularyWord
|
||||
{
|
||||
Id = wordId,
|
||||
TenantId = seed.TenantId,
|
||||
UnitId = unitId,
|
||||
Word = "design",
|
||||
IsActive = true
|
||||
},
|
||||
new VocabularyWord
|
||||
{
|
||||
Id = otherWordId,
|
||||
TenantId = seed.TenantId,
|
||||
UnitId = otherUnitId,
|
||||
Word = "sketch",
|
||||
IsActive = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
await client.PostAsJsonAsync(
|
||||
"/api/learning/vocabulary/favorites",
|
||||
new FavoriteWordDto { WordId = wordId, Note = "重点" });
|
||||
await client.PostAsJsonAsync(
|
||||
"/api/learning/vocabulary/favorites",
|
||||
new FavoriteWordDto { WordId = otherWordId });
|
||||
using var response = await client.GetAsync($"/api/learning/vocabulary/favorites?unitId={unitId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal(wordId, item.GetProperty("wordId").GetGuid());
|
||||
Assert.Equal("重点", item.GetProperty("note").GetString());
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync(
|
||||
ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13900000000";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Learning Tenant"
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Learning User"
|
||||
},
|
||||
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 async Task<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = await ReadJsonAsync(response);
|
||||
return body.RootElement
|
||||
.GetProperty("items")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
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