757 lines
28 KiB
C#
757 lines
28 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
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <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; }
|
||
} |