dramaling-vocab-learning/backend/DramaLing.Api/Controllers/StudyController.cs

755 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DramaLing.Api.Data;
using DramaLing.Api.Models.Entities;
using DramaLing.Api.Services;
using Microsoft.AspNetCore.Authorization;
namespace DramaLing.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class StudyController : ControllerBase
{
private readonly DramaLingDbContext _context;
private readonly IAuthService _authService;
private readonly ILogger<StudyController> _logger;
public StudyController(
DramaLingDbContext context,
IAuthService authService,
ILogger<StudyController> logger)
{
_context = context;
_authService = authService;
_logger = logger;
}
/// <summary>
/// 獲取待複習的詞卡 (支援 /frontend/app/learn/page.tsx)
/// </summary>
[HttpGet("due-cards")]
public async Task<ActionResult> GetDueCards(
[FromQuery] int limit = 50,
[FromQuery] string? mode = null,
[FromQuery] bool includeNew = true)
{
try
{
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
if (userId == null)
return Unauthorized(new { Success = false, Error = "Invalid token" });
var today = DateTime.Today;
var query = _context.Flashcards
.Where(f => f.UserId == userId);
// 篩選到期和新詞卡
if (includeNew)
{
// 包含到期詞卡和新詞卡
query = query.Where(f => f.NextReviewDate <= today || f.Repetitions == 0);
}
else
{
// 只包含到期詞卡
query = query.Where(f => f.NextReviewDate <= today);
}
var dueCards = await query.Take(limit * 2).ToListAsync(); // 取更多用於排序
// 計算優先級並排序
var cardsWithPriority = dueCards.Select(card => new
{
Card = card,
Priority = ReviewPriorityCalculator.CalculatePriority(
card.NextReviewDate,
card.EasinessFactor,
card.Repetitions
),
IsDue = ReviewPriorityCalculator.ShouldReview(card.NextReviewDate),
DaysOverdue = Math.Max(0, (today - card.NextReviewDate).Days)
}).OrderByDescending(x => x.Priority).Take(limit);
var result = cardsWithPriority.Select(x => new
{
x.Card.Id,
x.Card.Word,
x.Card.Translation,
x.Card.Definition,
x.Card.PartOfSpeech,
x.Card.Pronunciation,
x.Card.Example,
x.Card.ExampleTranslation,
x.Card.MasteryLevel,
x.Card.NextReviewDate,
x.Card.DifficultyLevel,
CardSet = new
{
Name = "Default",
Color = "bg-blue-500"
},
x.Priority,
x.IsDue,
x.DaysOverdue
}).ToList();
// 統計資訊
var totalDue = await _context.Flashcards
.Where(f => f.UserId == userId && f.NextReviewDate <= today)
.CountAsync();
var totalCards = await _context.Flashcards
.Where(f => f.UserId == userId)
.CountAsync();
var newCards = await _context.Flashcards
.Where(f => f.UserId == userId && f.Repetitions == 0)
.CountAsync();
return Ok(new
{
Success = true,
Data = new
{
Cards = result,
TotalDue = totalDue,
TotalCards = totalCards,
NewCards = newCards
}
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching due cards for user");
return StatusCode(500, new
{
Success = false,
Error = "Failed to fetch due cards",
Timestamp = DateTime.UtcNow
});
}
}
/// <summary>
/// 開始學習會話
/// </summary>
[HttpPost("sessions")]
public async Task<ActionResult> CreateStudySession([FromBody] CreateStudySessionRequest request)
{
try
{
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
if (userId == null)
return Unauthorized(new { Success = false, Error = "Invalid token" });
// 基本驗證
if (string.IsNullOrEmpty(request.Mode) ||
!new[] { "flip", "quiz", "fill", "listening", "speaking" }.Contains(request.Mode))
{
return BadRequest(new { Success = false, Error = "Invalid study mode" });
}
if (request.CardIds == null || request.CardIds.Count == 0)
{
return BadRequest(new { Success = false, Error = "Card IDs are required" });
}
if (request.CardIds.Count > 50)
{
return BadRequest(new { Success = false, Error = "Cannot study more than 50 cards in one session" });
}
// 驗證詞卡是否屬於用戶
var userCards = await _context.Flashcards
.Where(f => f.UserId == userId && request.CardIds.Contains(f.Id))
.CountAsync();
if (userCards != request.CardIds.Count)
{
return BadRequest(new { Success = false, Error = "Some cards not found or not accessible" });
}
// 建立學習會話
var session = new StudySession
{
Id = Guid.NewGuid(),
UserId = userId.Value,
SessionType = request.Mode,
TotalCards = request.CardIds.Count,
StartedAt = DateTime.UtcNow
};
_context.StudySessions.Add(session);
await _context.SaveChangesAsync();
// 獲取詞卡詳細資訊
var cards = await _context.Flashcards
.Where(f => f.UserId == userId && request.CardIds.Contains(f.Id))
.ToListAsync();
// 按照請求的順序排列
var orderedCards = request.CardIds
.Select(id => cards.FirstOrDefault(c => c.Id == id))
.Where(c => c != null)
.ToList();
return Ok(new
{
Success = true,
Data = new
{
SessionId = session.Id,
SessionType = request.Mode,
Cards = orderedCards.Select(c => new
{
c.Id,
c.Word,
c.Translation,
c.Definition,
c.PartOfSpeech,
c.Pronunciation,
c.Example,
c.ExampleTranslation,
c.MasteryLevel,
c.EasinessFactor,
c.Repetitions,
CardSet = new { Name = "Default", Color = "bg-blue-500" }
}),
TotalCards = orderedCards.Count,
StartedAt = session.StartedAt
},
Message = $"Study session started with {orderedCards.Count} cards"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating study session");
return StatusCode(500, new
{
Success = false,
Error = "Failed to create study session",
Timestamp = DateTime.UtcNow
});
}
}
/// <summary>
/// 記錄學習結果 (支援 SM-2 算法)
/// </summary>
[HttpPost("sessions/{sessionId}/record")]
public async Task<ActionResult> RecordStudyResult(
Guid sessionId,
[FromBody] RecordStudyResultRequest request)
{
try
{
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
if (userId == null)
return Unauthorized(new { Success = false, Error = "Invalid token" });
// 基本驗證
if (request.QualityRating < 1 || request.QualityRating > 5)
{
return BadRequest(new { Success = false, Error = "Quality rating must be between 1 and 5" });
}
// 驗證學習會話
var session = await _context.StudySessions
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId);
if (session == null)
{
return NotFound(new { Success = false, Error = "Study session not found" });
}
// 驗證詞卡
var flashcard = await _context.Flashcards
.FirstOrDefaultAsync(f => f.Id == request.FlashcardId && f.UserId == userId);
if (flashcard == null)
{
return NotFound(new { Success = false, Error = "Flashcard not found" });
}
// 計算新的 SM-2 參數
var sm2Input = new SM2Input(
request.QualityRating,
flashcard.EasinessFactor,
flashcard.Repetitions,
flashcard.IntervalDays
);
var sm2Result = SM2Algorithm.Calculate(sm2Input);
// 記錄學習結果
var studyRecord = new StudyRecord
{
Id = Guid.NewGuid(),
UserId = userId.Value,
FlashcardId = request.FlashcardId,
SessionId = sessionId,
StudyMode = session.SessionType,
QualityRating = request.QualityRating,
ResponseTimeMs = request.ResponseTimeMs,
UserAnswer = request.UserAnswer,
IsCorrect = request.IsCorrect,
PreviousEasinessFactor = sm2Input.EasinessFactor,
NewEasinessFactor = sm2Result.EasinessFactor,
PreviousIntervalDays = sm2Input.IntervalDays,
NewIntervalDays = sm2Result.IntervalDays,
PreviousRepetitions = sm2Input.Repetitions,
NewRepetitions = sm2Result.Repetitions,
NextReviewDate = sm2Result.NextReviewDate,
StudiedAt = DateTime.UtcNow
};
_context.StudyRecords.Add(studyRecord);
// 更新詞卡的 SM-2 參數
flashcard.EasinessFactor = sm2Result.EasinessFactor;
flashcard.Repetitions = sm2Result.Repetitions;
flashcard.IntervalDays = sm2Result.IntervalDays;
flashcard.NextReviewDate = sm2Result.NextReviewDate;
flashcard.MasteryLevel = SM2Algorithm.CalculateMastery(sm2Result.Repetitions, sm2Result.EasinessFactor);
flashcard.TimesReviewed++;
if (request.IsCorrect) flashcard.TimesCorrect++;
flashcard.LastReviewedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return Ok(new
{
Success = true,
Data = new
{
RecordId = studyRecord.Id,
NextReviewDate = sm2Result.NextReviewDate.ToString("yyyy-MM-dd"),
NewIntervalDays = sm2Result.IntervalDays,
NewMasteryLevel = flashcard.MasteryLevel,
EasinessFactor = sm2Result.EasinessFactor,
Repetitions = sm2Result.Repetitions,
QualityDescription = SM2Algorithm.GetQualityDescription(request.QualityRating)
},
Message = $"Study record saved. Next review in {sm2Result.IntervalDays} day(s)"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording study result");
return StatusCode(500, new
{
Success = false,
Error = "Failed to record study result",
Timestamp = DateTime.UtcNow
});
}
}
/// <summary>
/// 完成學習會話
/// </summary>
[HttpPost("sessions/{sessionId}/complete")]
public async Task<ActionResult> CompleteStudySession(
Guid sessionId,
[FromBody] CompleteStudySessionRequest request)
{
try
{
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
if (userId == null)
return Unauthorized(new { Success = false, Error = "Invalid token" });
// 驗證會話
var session = await _context.StudySessions
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId);
if (session == null)
{
return NotFound(new { Success = false, Error = "Study session not found" });
}
// 計算會話統計
var sessionRecords = await _context.StudyRecords
.Where(r => r.SessionId == sessionId && r.UserId == userId)
.ToListAsync();
var correctCount = sessionRecords.Count(r => r.IsCorrect);
var averageResponseTime = sessionRecords.Any(r => r.ResponseTimeMs.HasValue)
? (int)sessionRecords.Where(r => r.ResponseTimeMs.HasValue).Average(r => r.ResponseTimeMs!.Value)
: 0;
// 更新會話
session.EndedAt = DateTime.UtcNow;
session.CorrectCount = correctCount;
session.DurationSeconds = request.DurationSeconds;
session.AverageResponseTimeMs = averageResponseTime;
// 更新或建立每日統計
var today = DateOnly.FromDateTime(DateTime.Today);
var dailyStats = await _context.DailyStats
.FirstOrDefaultAsync(ds => ds.UserId == userId && ds.Date == today);
if (dailyStats == null)
{
dailyStats = new DailyStats
{
Id = Guid.NewGuid(),
UserId = userId.Value,
Date = today
};
_context.DailyStats.Add(dailyStats);
}
dailyStats.WordsStudied += sessionRecords.Count;
dailyStats.WordsCorrect += correctCount;
dailyStats.StudyTimeSeconds += request.DurationSeconds;
dailyStats.SessionCount++;
await _context.SaveChangesAsync();
// 計算會話統計
var accuracy = sessionRecords.Count > 0
? (int)Math.Round((double)correctCount / sessionRecords.Count * 100)
: 0;
var averageTimePerCard = request.DurationSeconds > 0 && sessionRecords.Count > 0
? request.DurationSeconds / sessionRecords.Count
: 0;
return Ok(new
{
Success = true,
Data = new
{
SessionId = sessionId,
TotalCards = session.TotalCards,
CardsStudied = sessionRecords.Count,
CorrectAnswers = correctCount,
AccuracyPercentage = accuracy,
DurationSeconds = request.DurationSeconds,
AverageTimePerCard = averageTimePerCard,
AverageResponseTimeMs = averageResponseTime,
StartedAt = session.StartedAt,
EndedAt = session.EndedAt
},
Message = $"Study session completed! {correctCount}/{sessionRecords.Count} correct ({accuracy}%)"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error completing study session");
return StatusCode(500, new
{
Success = false,
Error = "Failed to complete study session",
Timestamp = DateTime.UtcNow
});
}
}
/// <summary>
/// 獲取智能複習排程
/// </summary>
[HttpGet("schedule")]
public async Task<ActionResult> GetReviewSchedule(
[FromQuery] bool includePlan = true,
[FromQuery] bool includeStats = true)
{
try
{
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
if (userId == null)
return Unauthorized(new { Success = false, Error = "Invalid token" });
// 獲取用戶設定
var settings = await _context.UserSettings
.FirstOrDefaultAsync(s => s.UserId == userId);
var dailyGoal = settings?.DailyGoal ?? 20;
// 獲取所有詞卡
var allCards = await _context.Flashcards
.Where(f => f.UserId == userId)
.ToListAsync();
var today = DateTime.Today;
// 分類詞卡
var dueToday = allCards.Where(c => c.NextReviewDate == today).ToList();
var overdue = allCards.Where(c => c.NextReviewDate < today && c.Repetitions > 0).ToList();
var upcoming = allCards.Where(c => c.NextReviewDate > today && c.NextReviewDate <= today.AddDays(7)).ToList();
var newCards = allCards.Where(c => c.Repetitions == 0).ToList();
// 建立回應物件
var responseData = new Dictionary<string, object>
{
["Schedule"] = new
{
DueToday = dueToday.Count,
Overdue = overdue.Count,
Upcoming = upcoming.Count,
NewCards = newCards.Count
}
};
// 生成學習計劃
if (includePlan)
{
var recommendedCards = overdue.Take(dailyGoal / 2)
.Concat(dueToday.Take(dailyGoal / 3))
.Concat(newCards.Take(Math.Min(5, dailyGoal / 4)))
.Take(dailyGoal)
.Select(c => new
{
c.Id,
c.Word,
c.Translation,
c.MasteryLevel,
c.NextReviewDate,
PriorityReason = c.Repetitions == 0 ? "new_card" :
c.NextReviewDate < today ? "overdue" : "due_today"
});
responseData["StudyPlan"] = new
{
RecommendedCards = recommendedCards,
Breakdown = new
{
Overdue = Math.Min(overdue.Count, dailyGoal / 2),
DueToday = Math.Min(dueToday.Count, dailyGoal / 3),
NewCards = Math.Min(newCards.Count, 5)
},
EstimatedTimeMinutes = recommendedCards.Count() * 1,
DailyGoal = dailyGoal
};
}
// 計算統計
if (includeStats)
{
responseData["Statistics"] = new
{
TotalCards = allCards.Count,
MasteredCards = allCards.Count(c => c.MasteryLevel >= 80),
LearningCards = allCards.Count(c => c.MasteryLevel >= 40 && c.MasteryLevel < 80),
NewCardsCount = newCards.Count,
AverageMastery = allCards.Count > 0 ? (int)allCards.Average(c => c.MasteryLevel) : 0,
RetentionRate = allCards.Count(c => c.Repetitions > 0) > 0
? (int)Math.Round((double)allCards.Count(c => c.MasteryLevel >= 60) / allCards.Count(c => c.Repetitions > 0) * 100)
: 0
};
}
return Ok(new
{
Success = true,
Data = responseData
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching review schedule");
return StatusCode(500, new
{
Success = false,
Error = "Failed to fetch review schedule",
Timestamp = DateTime.UtcNow
});
}
}
/// <summary>
/// 獲取已完成的測驗記錄 (支援學習狀態恢復)
/// </summary>
[HttpGet("completed-tests")]
public async Task<ActionResult> GetCompletedTests([FromQuery] string? cardIds = null)
{
try
{
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
if (userId == null)
return Unauthorized(new { Success = false, Error = "Invalid token" });
var query = _context.StudyRecords.Where(r => r.UserId == userId);
// 如果提供了詞卡ID列表則篩選
if (!string.IsNullOrEmpty(cardIds))
{
var cardIdList = cardIds.Split(',')
.Where(id => Guid.TryParse(id, out _))
.Select(Guid.Parse)
.ToList();
if (cardIdList.Any())
{
query = query.Where(r => cardIdList.Contains(r.FlashcardId));
}
}
var completedTests = await query
.Select(r => new
{
FlashcardId = r.FlashcardId,
TestType = r.StudyMode,
IsCorrect = r.IsCorrect,
CompletedAt = r.StudiedAt,
UserAnswer = r.UserAnswer
})
.ToListAsync();
_logger.LogInformation("Retrieved {Count} completed tests for user {UserId}",
completedTests.Count, userId);
return Ok(new
{
Success = true,
Data = completedTests
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving completed tests for user");
return StatusCode(500, new
{
Success = false,
Error = "Failed to retrieve completed tests",
Timestamp = DateTime.UtcNow
});
}
}
/// <summary>
/// 直接記錄測驗完成狀態 (不觸發SM2更新)
/// </summary>
[HttpPost("record-test")]
public async Task<ActionResult> RecordTestCompletion([FromBody] RecordTestRequest request)
{
try
{
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
if (userId == null)
{
_logger.LogWarning("RecordTest failed: Invalid or missing token");
return Unauthorized(new { Success = false, Error = "Invalid token" });
}
_logger.LogInformation("Recording test for user {UserId}: FlashcardId={FlashcardId}, TestType={TestType}",
userId, request.FlashcardId, request.TestType);
// 驗證測驗類型
var validTestTypes = new[] { "flip-memory", "vocab-choice", "vocab-listening",
"sentence-listening", "sentence-fill", "sentence-reorder", "sentence-speaking" };
if (!validTestTypes.Contains(request.TestType))
{
_logger.LogWarning("Invalid test type: {TestType}", request.TestType);
return BadRequest(new { Success = false, Error = "Invalid test type" });
}
// 先檢查詞卡是否存在
var flashcard = await _context.Flashcards
.FirstOrDefaultAsync(f => f.Id == request.FlashcardId);
if (flashcard == null)
{
_logger.LogWarning("Flashcard {FlashcardId} does not exist", request.FlashcardId);
return NotFound(new { Success = false, Error = "Flashcard does not exist" });
}
// 再檢查詞卡是否屬於用戶
if (flashcard.UserId != userId)
{
_logger.LogWarning("Flashcard {FlashcardId} does not belong to user {UserId}, actual owner: {ActualOwner}",
request.FlashcardId, userId, flashcard.UserId);
return Forbid();
}
// 檢查是否已經完成過這個測驗
var existingRecord = await _context.StudyRecords
.FirstOrDefaultAsync(r => r.UserId == userId &&
r.FlashcardId == request.FlashcardId &&
r.StudyMode == request.TestType);
if (existingRecord != null)
{
return Conflict(new { Success = false, Error = "Test already completed",
CompletedAt = existingRecord.StudiedAt });
}
// 記錄測驗完成狀態
var studyRecord = new StudyRecord
{
Id = Guid.NewGuid(),
UserId = userId.Value,
FlashcardId = request.FlashcardId,
SessionId = Guid.NewGuid(), // 臨時會話ID
StudyMode = request.TestType, // 記錄具體測驗類型
QualityRating = request.ConfidenceLevel ?? (request.IsCorrect ? 4 : 2),
ResponseTimeMs = request.ResponseTimeMs,
UserAnswer = request.UserAnswer,
IsCorrect = request.IsCorrect,
StudiedAt = DateTime.UtcNow
};
_context.StudyRecords.Add(studyRecord);
await _context.SaveChangesAsync();
_logger.LogInformation("Recorded test completion: {TestType} for {Word}, correct: {IsCorrect}",
request.TestType, flashcard.Word, request.IsCorrect);
return Ok(new
{
Success = true,
Data = new
{
RecordId = studyRecord.Id,
TestType = request.TestType,
IsCorrect = request.IsCorrect,
CompletedAt = studyRecord.StudiedAt
},
Message = $"Test {request.TestType} recorded successfully"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording test completion");
return StatusCode(500, new
{
Success = false,
Error = "Failed to record test completion",
Timestamp = DateTime.UtcNow
});
}
}
}
// Request DTOs
public class CreateStudySessionRequest
{
public string Mode { get; set; } = string.Empty; // flip, quiz, fill, listening, speaking
public List<Guid> CardIds { get; set; } = new();
}
public class RecordStudyResultRequest
{
public Guid FlashcardId { get; set; }
public int QualityRating { get; set; } // 1-5
public int? ResponseTimeMs { get; set; }
public string? UserAnswer { get; set; }
public bool IsCorrect { get; set; }
}
public class CompleteStudySessionRequest
{
public int DurationSeconds { get; set; }
}
public class RecordTestRequest
{
public Guid FlashcardId { get; set; }
public string TestType { get; set; } = string.Empty; // flip-memory, vocab-choice, etc.
public bool IsCorrect { get; set; }
public string? UserAnswer { get; set; }
public int? ConfidenceLevel { get; set; } // 1-5
public int? ResponseTimeMs { get; set; }
}