feat: migrate direct tenant content and learning endpoints

This commit is contained in:
xiong
2026-07-26 17:57:03 +08:00
parent 1d7e09b1f0
commit 91a4162908
18 changed files with 4079 additions and 0 deletions

View File

@@ -14,6 +14,100 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
private const int DefaultLimit = 100;
private const int MaxLimit = 500;
public async Task<LearningStatsItem> GetStatsAsync(
LearningActor actor,
CancellationToken cancellationToken = default)
{
var answers = dbContext.AnswerRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
return new LearningStatsItem(
await answers.CountAsync(cancellationToken),
await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
await dbContext.WrongQuestions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null,
cancellationToken),
await dbContext.FavoriteQuestions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.UserWordProgress.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.PracticeSessions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken));
}
public async Task<LearningList<LearningTrendItem>> GetTrendAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit));
var rows = await dbContext.AnswerRecords.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.AnsweredAt >= since)
.Select(item => new { item.AnsweredAt, item.IsCorrect })
.ToArrayAsync(cancellationToken);
var items = rows
.GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
.OrderBy(group => group.Key)
.Select(group => new LearningTrendItem(
group.Key,
group.Count(),
group.Count(item => item.IsCorrect == true),
group.Count(item => item.IsCorrect == false)))
.ToArray();
return new LearningList<LearningTrendItem>(items);
}
public async Task<LearningLeaderboardResult> GetLeaderboardAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var rows = await dbContext.AnswerRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.GroupBy(item => item.UserId)
.Select(group => new
{
UserId = group.Key,
AnswerCount = group.Count(),
CorrectCount = group.Count(item => item.IsCorrect == true),
WrongCount = group.Count(item => item.IsCorrect == false)
})
.OrderByDescending(item => item.CorrectCount)
.ThenByDescending(item => item.AnswerCount)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
var userIds = rows.Select(item => item.UserId).ToArray();
var names = await dbContext.Users.AsNoTracking()
.Where(item => userIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken);
var items = rows
.Select(item => new LearningLeaderboardItem(
item.UserId,
names.GetValueOrDefault(item.UserId),
item.AnswerCount,
item.CorrectCount,
item.WrongCount,
item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4)))
.ToArray();
return new LearningLeaderboardResult(
"correct_count",
"all",
items,
items.FirstOrDefault(item => item.UserId == actor.UserId),
DateTimeOffset.UtcNow);
}
public async Task<AnswerRecordItem> SubmitAnswerAsync(
LearningActor actor,
SubmitAnswerCommand command,
@@ -201,6 +295,31 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
return new LearningActionResult(true);
}
public async Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var items = await dbContext.WrongQuestions.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.ResolvedAt == null)
.OrderByDescending(item => item.WrongCount)
.ThenBy(item => item.LastWrongAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt))
.ToArrayAsync(cancellationToken);
return new WrongQuestionReviewPlan(
items,
JsonSerializer.SerializeToElement(new
{
mode = "wrong_review",
questionCount = items.Length,
recommendedEndpoint = "/api/learning/practice-sessions"
}));
}
public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
LearningActor actor,
LearningLimitFilter filter,
@@ -312,6 +431,91 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
return ToItem(item);
}
public async Task<WordReviewPlan> GetWordReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
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));
}
var items = await query
.Where(item => item.NextReviewAt == null || item.NextReviewAt <= now)
.OrderBy(item => item.NextReviewAt == null)
.ThenBy(item => item.NextReviewAt)
.ThenByDescending(item => item.WrongCount)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WordReviewPlanItem(
item.WordId,
item.Status,
item.NextReviewAt,
item.CorrectCount,
item.WrongCount,
item.DueLevel))
.ToArrayAsync(cancellationToken);
return new WordReviewPlan(
items,
JsonSerializer.SerializeToElement(new
{
mode = "word_review",
wordCount = items.Length
}));
}
public Task<WordProgressItem> ReviewWordAsync(
LearningActor actor,
WordReviewCommand command,
CancellationToken cancellationToken = default)
{
var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) ||
string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase);
return UpdateWordProgressAsync(
actor,
new WordProgressCommand(
command.WordId,
correct ? "Reviewing" : "Learning",
correct ? 1 : 0,
correct ? 0 : 1,
command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)),
cancellationToken);
}
public async Task<WordStatsItem> GetWordStatsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
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));
}
return new WordStatsItem(
await query.CountAsync(cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken),
await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken),
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken));
}
public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
LearningActor actor,
LearningLimitFilter filter,