410 lines
15 KiB
C#
410 lines
15 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using DramaLing.Api.Models.Entities;
|
|
using DramaLing.Api.Models.DTOs;
|
|
using DramaLing.Api.Contracts.Repositories;
|
|
using DramaLing.Api.Contracts.Services.Review;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using DramaLing.Api.Utils;
|
|
using DramaLing.Api.Services;
|
|
using DramaLing.Api.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DramaLing.Api.Controllers;
|
|
|
|
[Route("api/flashcards")]
|
|
[AllowAnonymous] // 暫時開放以測試 nextReviewDate 修復
|
|
public class FlashcardsController : BaseController
|
|
{
|
|
private readonly IFlashcardRepository _flashcardRepository;
|
|
private readonly IReviewService _reviewService;
|
|
private readonly DramaLingDbContext _context;
|
|
|
|
public FlashcardsController(
|
|
IFlashcardRepository flashcardRepository,
|
|
IReviewService reviewService,
|
|
DramaLingDbContext context,
|
|
IAuthService authService,
|
|
ILogger<FlashcardsController> logger) : base(logger, authService)
|
|
{
|
|
_flashcardRepository = flashcardRepository;
|
|
_reviewService = reviewService;
|
|
_context = context;
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetFlashcards(
|
|
[FromQuery] string? search = null,
|
|
[FromQuery] bool favoritesOnly = false)
|
|
{
|
|
try
|
|
{
|
|
var userId = await GetCurrentUserIdAsync();
|
|
var flashcards = await _flashcardRepository.GetByUserIdAsync(userId, search, favoritesOnly);
|
|
|
|
// 獲取用戶的複習記錄
|
|
var flashcardIds = flashcards.Select(f => f.Id).ToList();
|
|
var reviews = await _context.FlashcardReviews
|
|
.Where(fr => fr.UserId == userId && flashcardIds.Contains(fr.FlashcardId))
|
|
.ToDictionaryAsync(fr => fr.FlashcardId);
|
|
|
|
var flashcardData = new
|
|
{
|
|
Flashcards = flashcards.Select(f => {
|
|
reviews.TryGetValue(f.Id, out var review);
|
|
return new
|
|
{
|
|
f.Id,
|
|
f.Word,
|
|
f.Translation,
|
|
f.Definition,
|
|
f.PartOfSpeech,
|
|
f.Pronunciation,
|
|
f.Example,
|
|
f.ExampleTranslation,
|
|
f.IsFavorite,
|
|
f.Synonyms,
|
|
DifficultyLevelNumeric = f.DifficultyLevelNumeric,
|
|
CEFR = CEFRHelper.ToString(f.DifficultyLevelNumeric),
|
|
f.CreatedAt,
|
|
f.UpdatedAt,
|
|
// 添加複習相關屬性
|
|
NextReviewDate = review?.NextReviewDate ?? DateTime.UtcNow.AddDays(1),
|
|
TimesReviewed = review?.TotalCorrectCount + review?.TotalWrongCount + review?.TotalSkipCount ?? 0,
|
|
MasteryLevel = review?.SuccessCount ?? 0,
|
|
// 添加圖片相關屬性
|
|
HasExampleImage = f.FlashcardExampleImages.Any(),
|
|
PrimaryImageUrl = f.FlashcardExampleImages
|
|
.Where(fei => fei.IsPrimary)
|
|
.Select(fei => $"/images/examples/{fei.ExampleImage.RelativePath}")
|
|
.FirstOrDefault()
|
|
};
|
|
}),
|
|
Count = flashcards.Count()
|
|
};
|
|
|
|
return SuccessResponse(flashcardData);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting flashcards");
|
|
return ErrorResponse("INTERNAL_ERROR", "載入詞卡失敗");
|
|
}
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreateFlashcard([FromBody] CreateFlashcardRequest request)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return HandleModelStateErrors();
|
|
}
|
|
|
|
var userId = await GetCurrentUserIdAsync();
|
|
|
|
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,
|
|
Synonyms = request.Synonyms, // 儲存 AI 生成的同義詞
|
|
DifficultyLevelNumeric = CEFRHelper.ToNumeric(request.CEFR ?? "A0"),
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
await _flashcardRepository.AddAsync(flashcard);
|
|
await _flashcardRepository.SaveChangesAsync();
|
|
|
|
return SuccessResponse(flashcard, "詞卡創建成功");
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating flashcard");
|
|
return ErrorResponse("INTERNAL_ERROR", "創建詞卡失敗");
|
|
}
|
|
}
|
|
|
|
[HttpGet("{id}")]
|
|
public async Task<IActionResult> GetFlashcard(Guid id)
|
|
{
|
|
try
|
|
{
|
|
var userId = await GetCurrentUserIdAsync();
|
|
|
|
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
|
|
|
|
if (flashcard == null)
|
|
{
|
|
return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404);
|
|
}
|
|
|
|
// 獲取複習記錄
|
|
var review = await _context.FlashcardReviews
|
|
.FirstOrDefaultAsync(fr => fr.UserId == userId && fr.FlashcardId == id);
|
|
|
|
// 格式化返回數據,保持與列表 API 一致
|
|
var flashcardData = new
|
|
{
|
|
flashcard.Id,
|
|
flashcard.Word,
|
|
flashcard.Translation,
|
|
flashcard.Definition,
|
|
flashcard.PartOfSpeech,
|
|
flashcard.Pronunciation,
|
|
flashcard.Example,
|
|
flashcard.ExampleTranslation,
|
|
flashcard.IsFavorite,
|
|
flashcard.Synonyms,
|
|
DifficultyLevelNumeric = flashcard.DifficultyLevelNumeric,
|
|
CEFR = CEFRHelper.ToString(flashcard.DifficultyLevelNumeric),
|
|
flashcard.CreatedAt,
|
|
flashcard.UpdatedAt,
|
|
// 添加複習相關屬性(與列表 API 一致)
|
|
NextReviewDate = review?.NextReviewDate ?? DateTime.UtcNow.AddDays(1),
|
|
TimesReviewed = review?.TotalCorrectCount + review?.TotalWrongCount + review?.TotalSkipCount ?? 0,
|
|
MasteryLevel = review?.SuccessCount ?? 0,
|
|
// 添加圖片相關屬性
|
|
HasExampleImage = flashcard.FlashcardExampleImages.Any(),
|
|
PrimaryImageUrl = flashcard.FlashcardExampleImages
|
|
.Where(fei => fei.IsPrimary)
|
|
.Select(fei => $"/images/examples/{fei.ExampleImage.RelativePath}")
|
|
.FirstOrDefault(),
|
|
// 保留完整的圖片關聯數據供前端使用
|
|
FlashcardExampleImages = flashcard.FlashcardExampleImages
|
|
};
|
|
|
|
return SuccessResponse(flashcardData);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting flashcard {FlashcardId}", id);
|
|
return ErrorResponse("INTERNAL_ERROR", "取得詞卡失敗");
|
|
}
|
|
}
|
|
|
|
[HttpPut("{id}")]
|
|
public async Task<IActionResult> UpdateFlashcard(Guid id, [FromBody] CreateFlashcardRequest request)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return HandleModelStateErrors();
|
|
}
|
|
|
|
var userId = await GetCurrentUserIdAsync();
|
|
|
|
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
|
|
|
|
if (flashcard == null)
|
|
{
|
|
return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404);
|
|
}
|
|
|
|
// 更新詞卡資訊
|
|
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 _flashcardRepository.UpdateAsync(flashcard);
|
|
await _flashcardRepository.SaveChangesAsync();
|
|
|
|
return SuccessResponse(flashcard, "詞卡更新成功");
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating flashcard {FlashcardId}", id);
|
|
return ErrorResponse("INTERNAL_ERROR", "更新詞卡失敗");
|
|
}
|
|
}
|
|
|
|
[HttpDelete("{id}")]
|
|
public async Task<IActionResult> DeleteFlashcard(Guid id)
|
|
{
|
|
try
|
|
{
|
|
var userId = await GetCurrentUserIdAsync();
|
|
|
|
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
|
|
|
|
if (flashcard == null)
|
|
{
|
|
return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404);
|
|
}
|
|
|
|
await _flashcardRepository.DeleteAsync(flashcard);
|
|
await _flashcardRepository.SaveChangesAsync();
|
|
|
|
return SuccessResponse(new { Id = id }, "詞卡已刪除");
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting flashcard {FlashcardId}", id);
|
|
return ErrorResponse("INTERNAL_ERROR", "刪除詞卡失敗");
|
|
}
|
|
}
|
|
|
|
[HttpPost("{id}/favorite")]
|
|
public async Task<IActionResult> ToggleFavorite(Guid id)
|
|
{
|
|
try
|
|
{
|
|
var userId = await GetCurrentUserIdAsync();
|
|
|
|
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
|
|
|
|
if (flashcard == null)
|
|
{
|
|
return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404);
|
|
}
|
|
|
|
flashcard.IsFavorite = !flashcard.IsFavorite;
|
|
flashcard.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _flashcardRepository.UpdateAsync(flashcard);
|
|
await _flashcardRepository.SaveChangesAsync();
|
|
|
|
var result = new {
|
|
Id = flashcard.Id,
|
|
IsFavorite = flashcard.IsFavorite
|
|
};
|
|
|
|
var message = flashcard.IsFavorite ? "已加入收藏" : "已取消收藏";
|
|
return SuccessResponse(result, message);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error toggling favorite for flashcard {FlashcardId}", id);
|
|
return ErrorResponse("INTERNAL_ERROR", "切換收藏狀態失敗");
|
|
}
|
|
}
|
|
|
|
[HttpGet("due")]
|
|
public async Task<IActionResult> GetDueFlashcards(
|
|
[FromQuery] int limit = 10,
|
|
[FromQuery] bool includeToday = true,
|
|
[FromQuery] bool includeOverdue = true,
|
|
[FromQuery] bool favoritesOnly = false)
|
|
{
|
|
try
|
|
{
|
|
var userId = await GetCurrentUserIdAsync();
|
|
|
|
var query = new DueFlashcardsQuery
|
|
{
|
|
Limit = limit,
|
|
IncludeToday = includeToday,
|
|
IncludeOverdue = includeOverdue,
|
|
FavoritesOnly = favoritesOnly
|
|
};
|
|
|
|
var response = await _reviewService.GetDueFlashcardsAsync(userId, query);
|
|
return Ok(response);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting due flashcards");
|
|
return ErrorResponse("INTERNAL_ERROR", "載入待複習詞卡失敗");
|
|
}
|
|
}
|
|
|
|
[HttpPost("{id}/review")]
|
|
public async Task<IActionResult> SubmitReview(Guid id, [FromBody] ReviewRequest request)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return HandleModelStateErrors();
|
|
}
|
|
|
|
var userId = await GetCurrentUserIdAsync();
|
|
var response = await _reviewService.SubmitReviewAsync(userId, id, request);
|
|
return Ok(response);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error submitting review for flashcard {FlashcardId}", id);
|
|
return ErrorResponse("INTERNAL_ERROR", "提交複習結果失敗");
|
|
}
|
|
}
|
|
|
|
[HttpPost("{id}/mastered")]
|
|
public async Task<IActionResult> MarkWordMastered(Guid id)
|
|
{
|
|
try
|
|
{
|
|
var userId = await GetCurrentUserIdAsync();
|
|
var response = await _reviewService.MarkWordMasteredAsync(userId, id);
|
|
return Ok(response);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error marking flashcard {FlashcardId} as mastered", id);
|
|
return ErrorResponse("INTERNAL_ERROR", "標記詞彙掌握失敗");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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; }
|
|
public string? Synonyms { get; set; } // AI 生成的同義詞 (JSON 字串)
|
|
public string? CEFR { get; set; } = string.Empty;
|
|
} |