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

674 lines
26 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.Models.DTOs;
using DramaLing.Api.Models.DTOs.SpacedRepetition;
using DramaLing.Api.Services;
using DramaLing.Api.Services.Storage;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
namespace DramaLing.Api.Controllers;
[ApiController]
[Route("api/flashcards")]
[AllowAnonymous] // 暫時移除認證要求,修復網路錯誤
public class FlashcardsController : ControllerBase
{
private readonly DramaLingDbContext _context;
private readonly ILogger<FlashcardsController> _logger;
private readonly IImageStorageService _imageStorageService;
private readonly IAuthService _authService;
// 🆕 智能複習服務依賴
private readonly ISpacedRepetitionService _spacedRepetitionService;
private readonly IReviewTypeSelectorService _reviewTypeSelectorService;
private readonly IQuestionGeneratorService _questionGeneratorService;
// 🆕 智能填空題服務依賴
private readonly IBlankGenerationService _blankGenerationService;
public FlashcardsController(
DramaLingDbContext context,
ILogger<FlashcardsController> logger,
IImageStorageService imageStorageService,
IAuthService authService,
ISpacedRepetitionService spacedRepetitionService,
IReviewTypeSelectorService reviewTypeSelectorService,
IQuestionGeneratorService questionGeneratorService,
IBlankGenerationService blankGenerationService)
{
_context = context;
_logger = logger;
_imageStorageService = imageStorageService;
_authService = authService;
_spacedRepetitionService = spacedRepetitionService;
_reviewTypeSelectorService = reviewTypeSelectorService;
_questionGeneratorService = questionGeneratorService;
_blankGenerationService = blankGenerationService;
}
private Guid GetUserId()
{
// 暫時使用固定測試用戶 ID避免認證問題
// TODO: 恢復真實認證後改回 JWT Token 解析
return Guid.Parse("00000000-0000-0000-0000-000000000001");
// var userIdString = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ??
// User.FindFirst("sub")?.Value;
//
// if (Guid.TryParse(userIdString, out var userId))
// return userId;
//
// throw new UnauthorizedAccessException("Invalid user ID in token");
}
[HttpGet]
public async Task<ActionResult> GetFlashcards(
[FromQuery] string? search = null,
[FromQuery] bool favoritesOnly = false,
[FromQuery] string? cefrLevel = null,
[FromQuery] string? partOfSpeech = null,
[FromQuery] string? masteryLevel = null)
{
try
{
var userId = GetUserId();
_logger.LogInformation("GetFlashcards called for user: {UserId}", userId);
var query = _context.Flashcards
.Include(f => f.FlashcardExampleImages)
.ThenInclude(fei => fei.ExampleImage)
.Where(f => f.UserId == userId && !f.IsArchived)
.AsQueryable();
_logger.LogInformation("Base query created successfully");
// 搜尋篩選 (擴展支援例句內容)
if (!string.IsNullOrEmpty(search))
{
query = query.Where(f =>
f.Word.Contains(search) ||
f.Translation.Contains(search) ||
(f.Definition != null && f.Definition.Contains(search)) ||
(f.Example != null && f.Example.Contains(search)) ||
(f.ExampleTranslation != null && f.ExampleTranslation.Contains(search)));
}
// 收藏篩選
if (favoritesOnly)
{
query = query.Where(f => f.IsFavorite);
}
// CEFR 等級篩選
if (!string.IsNullOrEmpty(cefrLevel))
{
query = query.Where(f => f.DifficultyLevel == cefrLevel);
}
// 詞性篩選
if (!string.IsNullOrEmpty(partOfSpeech))
{
query = query.Where(f => f.PartOfSpeech == partOfSpeech);
}
// 掌握度篩選
if (!string.IsNullOrEmpty(masteryLevel))
{
switch (masteryLevel.ToLower())
{
case "high":
query = query.Where(f => f.MasteryLevel >= 80);
break;
case "medium":
query = query.Where(f => f.MasteryLevel >= 60 && f.MasteryLevel < 80);
break;
case "low":
query = query.Where(f => f.MasteryLevel < 60);
break;
}
}
_logger.LogInformation("Executing database query...");
var flashcards = await query
.AsNoTracking() // 效能優化:只讀查詢
.OrderByDescending(f => f.CreatedAt)
.ToListAsync();
_logger.LogInformation("Found {Count} flashcards", flashcards.Count);
// 生成圖片資訊
var flashcardDtos = new List<object>();
foreach (var flashcard in flashcards)
{
// 獲取例句圖片資料 (與 GetFlashcard 方法保持一致)
var exampleImages = flashcard.FlashcardExampleImages?
.Select(fei => new
{
Id = fei.ExampleImage.Id,
ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}",
IsPrimary = fei.IsPrimary,
QualityScore = fei.ExampleImage.QualityScore,
FileSize = fei.ExampleImage.FileSize,
CreatedAt = fei.ExampleImage.CreatedAt
})
.ToList();
flashcardDtos.Add(new
{
flashcard.Id,
flashcard.Word,
flashcard.Translation,
flashcard.Definition,
flashcard.PartOfSpeech,
flashcard.Pronunciation,
flashcard.Example,
flashcard.ExampleTranslation,
flashcard.MasteryLevel,
flashcard.TimesReviewed,
flashcard.IsFavorite,
flashcard.NextReviewDate,
flashcard.DifficultyLevel,
flashcard.CreatedAt,
flashcard.UpdatedAt,
// 新增圖片相關欄位
ExampleImages = exampleImages ?? (object)new List<object>(),
HasExampleImage = exampleImages?.Any() ?? false,
PrimaryImageUrl = exampleImages?.FirstOrDefault(img => img.IsPrimary)?.ImageUrl
});
}
return Ok(new
{
Success = true,
Data = new
{
Flashcards = flashcardDtos,
Count = flashcardDtos.Count
}
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting flashcards for user: {Message}", ex.Message);
_logger.LogError("Stack trace: {StackTrace}", ex.StackTrace);
return StatusCode(500, new { Success = false, Error = "Failed to load flashcards", Details = ex.Message });
}
}
[HttpPost]
public async Task<ActionResult> CreateFlashcard([FromBody] CreateFlashcardRequest request)
{
try
{
var userId = GetUserId();
// 確保測試用戶存在
var testUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (testUser == null)
{
testUser = new User
{
Id = userId,
Username = "testuser",
Email = "test@example.com",
PasswordHash = "test_hash",
DisplayName = "測試用戶",
SubscriptionType = "free",
Preferences = new Dictionary<string, object>(),
EnglishLevel = "A2",
LevelUpdatedAt = DateTime.UtcNow,
IsLevelVerified = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.Users.Add(testUser);
await _context.SaveChangesAsync();
}
// 檢測重複詞卡
var existing = await _context.Flashcards
.FirstOrDefaultAsync(f => f.UserId == userId &&
f.Word.ToLower() == request.Word.ToLower() &&
!f.IsArchived);
if (existing != null)
{
return Ok(new
{
Success = false,
Error = "詞卡已存在",
IsDuplicate = true,
ExistingCard = new
{
existing.Id,
existing.Word,
existing.Translation,
existing.CreatedAt
}
});
}
var flashcard = new Flashcard
{
Id = Guid.NewGuid(),
UserId = userId,
Word = request.Word,
Translation = request.Translation,
Definition = request.Definition ?? "",
PartOfSpeech = request.PartOfSpeech,
Pronunciation = request.Pronunciation,
Example = request.Example,
ExampleTranslation = request.ExampleTranslation,
MasteryLevel = 0,
TimesReviewed = 0,
IsFavorite = false,
NextReviewDate = DateTime.Today,
DifficultyLevel = "A2", // 預設等級
EasinessFactor = 2.5f,
IntervalDays = 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.Flashcards.Add(flashcard);
await _context.SaveChangesAsync();
return Ok(new
{
Success = true,
Data = new
{
flashcard.Id,
flashcard.Word,
flashcard.Translation,
flashcard.Definition,
flashcard.CreatedAt
},
Message = "詞卡創建成功"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating flashcard");
return StatusCode(500, new { Success = false, Error = "Failed to create flashcard" });
}
}
[HttpGet("{id}")]
public async Task<ActionResult> GetFlashcard(Guid id)
{
try
{
var userId = GetUserId();
var flashcard = await _context.Flashcards
.Include(f => f.FlashcardExampleImages)
.ThenInclude(fei => fei.ExampleImage)
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
if (flashcard == null)
{
return NotFound(new { Success = false, Error = "Flashcard not found" });
}
// 獲取例句圖片資料
var exampleImages = flashcard.FlashcardExampleImages
?.Select(fei => new
{
Id = fei.ExampleImage.Id,
ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}",
IsPrimary = fei.IsPrimary,
QualityScore = fei.ExampleImage.QualityScore,
FileSize = fei.ExampleImage.FileSize,
CreatedAt = fei.ExampleImage.CreatedAt
})
.ToList();
return Ok(new
{
Success = true,
Data = new
{
flashcard.Id,
flashcard.Word,
flashcard.Translation,
flashcard.Definition,
flashcard.PartOfSpeech,
flashcard.Pronunciation,
flashcard.Example,
flashcard.ExampleTranslation,
flashcard.MasteryLevel,
flashcard.TimesReviewed,
flashcard.IsFavorite,
flashcard.NextReviewDate,
flashcard.DifficultyLevel,
flashcard.CreatedAt,
flashcard.UpdatedAt,
// 新增圖片相關欄位
ExampleImages = exampleImages ?? (object)new List<object>(),
HasExampleImage = exampleImages?.Any() ?? false,
PrimaryImageUrl = flashcard.FlashcardExampleImages?
.Where(fei => fei.IsPrimary)
.Select(fei => $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}")
.FirstOrDefault()
}
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting flashcard {FlashcardId}", id);
return StatusCode(500, new { Success = false, Error = "Failed to get flashcard" });
}
}
[HttpPut("{id}")]
public async Task<ActionResult> UpdateFlashcard(Guid id, [FromBody] CreateFlashcardRequest request)
{
try
{
var userId = GetUserId();
var flashcard = await _context.Flashcards
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
if (flashcard == null)
{
return NotFound(new { Success = false, Error = "Flashcard not found" });
}
// 更新詞卡資訊
flashcard.Word = request.Word;
flashcard.Translation = request.Translation;
flashcard.Definition = request.Definition ?? "";
flashcard.PartOfSpeech = request.PartOfSpeech;
flashcard.Pronunciation = request.Pronunciation;
flashcard.Example = request.Example;
flashcard.ExampleTranslation = request.ExampleTranslation;
flashcard.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return Ok(new
{
Success = true,
Data = new
{
flashcard.Id,
flashcard.Word,
flashcard.Translation,
flashcard.Definition,
flashcard.CreatedAt,
flashcard.UpdatedAt
},
Message = "詞卡更新成功"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating flashcard {FlashcardId}", id);
return StatusCode(500, new { Success = false, Error = "Failed to update flashcard" });
}
}
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteFlashcard(Guid id)
{
try
{
var userId = GetUserId();
var flashcard = await _context.Flashcards
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
if (flashcard == null)
{
return NotFound(new { Success = false, Error = "Flashcard not found" });
}
_context.Flashcards.Remove(flashcard);
await _context.SaveChangesAsync();
return Ok(new { Success = true, Message = "詞卡已刪除" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting flashcard {FlashcardId}", id);
return StatusCode(500, new { Success = false, Error = "Failed to delete flashcard" });
}
}
[HttpPost("{id}/favorite")]
public async Task<ActionResult> ToggleFavorite(Guid id)
{
try
{
var userId = GetUserId();
var flashcard = await _context.Flashcards
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
if (flashcard == null)
{
return NotFound(new { Success = false, Error = "Flashcard not found" });
}
flashcard.IsFavorite = !flashcard.IsFavorite;
flashcard.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return Ok(new {
Success = true,
IsFavorite = flashcard.IsFavorite,
Message = flashcard.IsFavorite ? "已加入收藏" : "已取消收藏"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error toggling favorite for flashcard {FlashcardId}", id);
return StatusCode(500, new { Success = false, Error = "Failed to toggle favorite" });
}
}
// ================== 🆕 智能複習API端點 ==================
/// <summary>
/// 取得到期詞卡列表
/// </summary>
[HttpGet("due")]
public async Task<ActionResult> GetDueFlashcards(
[FromQuery] string? date = null,
[FromQuery] int limit = 50)
{
try
{
var userId = GetUserId();
var queryDate = DateTime.TryParse(date, out var parsed) ? parsed : DateTime.Now.Date;
var dueCards = await _spacedRepetitionService.GetDueFlashcardsAsync(userId, queryDate, limit);
// 🆕 智能挖空處理:檢查並生成缺失的填空題目
var cardsToUpdate = new List<Flashcard>();
foreach(var flashcard in dueCards)
{
if(string.IsNullOrEmpty(flashcard.FilledQuestionText) && !string.IsNullOrEmpty(flashcard.Example))
{
_logger.LogDebug("Generating blank question for flashcard {Id}, word: {Word}",
flashcard.Id, flashcard.Word);
var blankQuestion = await _blankGenerationService.GenerateBlankQuestionAsync(
flashcard.Word, flashcard.Example);
if(!string.IsNullOrEmpty(blankQuestion))
{
flashcard.FilledQuestionText = blankQuestion;
flashcard.UpdatedAt = DateTime.UtcNow;
cardsToUpdate.Add(flashcard);
_logger.LogInformation("Generated blank question for flashcard {Id}, word: {Word}",
flashcard.Id, flashcard.Word);
}
else
{
_logger.LogWarning("Failed to generate blank question for flashcard {Id}, word: {Word}",
flashcard.Id, flashcard.Word);
}
}
}
// 批次更新資料庫
if (cardsToUpdate.Count > 0)
{
_context.UpdateRange(cardsToUpdate);
await _context.SaveChangesAsync();
_logger.LogInformation("Updated {Count} flashcards with blank questions", cardsToUpdate.Count);
}
_logger.LogInformation("Retrieved {Count} due flashcards for user {UserId}", dueCards.Count, userId);
return Ok(new { success = true, data = dueCards, count = dueCards.Count });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting due flashcards");
return StatusCode(500, new { success = false, error = "Failed to get due flashcards" });
}
}
/// <summary>
/// 取得下一張需要復習的詞卡 (最高優先級)
/// </summary>
[HttpGet("next-review")]
public async Task<ActionResult> GetNextReviewCard()
{
try
{
var userId = GetUserId();
var nextCard = await _spacedRepetitionService.GetNextReviewCardAsync(userId);
if (nextCard == null)
{
return Ok(new { success = true, data = (object?)null, message = "沒有到期的詞卡" });
}
// 計算當前熟悉度
var currentMasteryLevel = _spacedRepetitionService.CalculateCurrentMasteryLevel(nextCard);
// UserLevel和WordLevel欄位已移除 - 改用即時CEFR轉換
var response = new
{
nextCard.Id,
nextCard.Word,
nextCard.Translation,
nextCard.Definition,
nextCard.Pronunciation,
nextCard.PartOfSpeech,
nextCard.Example,
nextCard.ExampleTranslation,
nextCard.MasteryLevel,
nextCard.TimesReviewed,
nextCard.IsFavorite,
nextCard.NextReviewDate,
nextCard.DifficultyLevel,
// 智能複習擴展欄位 (改用即時CEFR轉換)
BaseMasteryLevel = nextCard.MasteryLevel,
LastReviewDate = nextCard.LastReviewedAt,
CurrentInterval = nextCard.IntervalDays,
IsOverdue = DateTime.Now.Date > nextCard.NextReviewDate.Date,
OverdueDays = Math.Max(0, (DateTime.Now.Date - nextCard.NextReviewDate.Date).Days),
CurrentMasteryLevel = currentMasteryLevel
};
_logger.LogInformation("Retrieved next review card: {Word} for user {UserId}", nextCard.Word, userId);
return Ok(new { success = true, data = response });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting next review card");
return StatusCode(500, new { success = false, error = "Failed to get next review card" });
}
}
/// <summary>
/// 系統自動選擇最適合的複習題型 (基於CEFR等級)
/// </summary>
[HttpPost("{id}/optimal-review-mode")]
public async Task<ActionResult> GetOptimalReviewMode(Guid id, [FromBody] OptimalModeRequest request)
{
try
{
var result = await _reviewTypeSelectorService.SelectOptimalReviewModeAsync(
id, request.UserCEFRLevel, request.WordCEFRLevel);
_logger.LogInformation("Selected optimal review mode {SelectedMode} for flashcard {FlashcardId}, userCEFR: {UserCEFR}, wordCEFR: {WordCEFR}",
result.SelectedMode, id, request.UserCEFRLevel, request.WordCEFRLevel);
return Ok(new { success = true, data = result });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error selecting optimal review mode for flashcard {FlashcardId}", id);
return StatusCode(500, new { success = false, error = "Failed to select optimal review mode" });
}
}
/// <summary>
/// 生成指定題型的題目選項
/// </summary>
[HttpPost("{id}/question")]
public async Task<ActionResult> GenerateQuestion(Guid id, [FromBody] QuestionRequest request)
{
try
{
var questionData = await _questionGeneratorService.GenerateQuestionAsync(id, request.QuestionType);
_logger.LogInformation("Generated {QuestionType} question for flashcard {FlashcardId}",
request.QuestionType, id);
return Ok(new { success = true, data = questionData });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating {QuestionType} question for flashcard {FlashcardId}",
request.QuestionType, id);
return StatusCode(500, new { success = false, error = "Failed to generate question" });
}
}
/// <summary>
/// 提交復習結果並更新間隔重複算法
/// </summary>
[HttpPost("{id}/review")]
public async Task<ActionResult> SubmitReview(Guid id, [FromBody] ReviewRequest request)
{
try
{
var result = await _spacedRepetitionService.ProcessReviewAsync(id, request);
_logger.LogInformation("Processed review for flashcard {FlashcardId}, questionType: {QuestionType}, isCorrect: {IsCorrect}",
id, request.QuestionType, request.IsCorrect);
return Ok(new { success = true, data = result });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing review for flashcard {FlashcardId}", id);
return StatusCode(500, new { success = false, error = "Failed to process review" });
}
}
}
// 請求 DTO
public class CreateFlashcardRequest
{
public string Word { get; set; } = string.Empty;
public string Translation { get; set; } = string.Empty;
public string Definition { get; set; } = string.Empty;
public string PartOfSpeech { get; set; } = string.Empty;
public string Pronunciation { get; set; } = string.Empty;
public string Example { get; set; } = string.Empty;
public string? ExampleTranslation { get; set; }
}