forked from xiongyuxing/tiku-backend.net
feat: add learning activity endpoints
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user