584 lines
21 KiB
C#
584 lines
21 KiB
C#
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
|
|
.Include(f => f.CardSet)
|
|
.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
|
|
{
|
|
x.Card.CardSet.Name,
|
|
x.Card.CardSet.Color
|
|
},
|
|
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
|
|
.Include(f => f.CardSet)
|
|
.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 { c.CardSet.Name, c.CardSet.Color }
|
|
}),
|
|
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
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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; }
|
|
} |