diff --git a/backend/DramaLing.Api/Controllers/BaseController.cs b/backend/DramaLing.Api/Controllers/BaseController.cs new file mode 100644 index 0000000..5b13cd3 --- /dev/null +++ b/backend/DramaLing.Api/Controllers/BaseController.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Mvc; +using DramaLing.Api.Models.DTOs; +using DramaLing.Api.Services; +using System.Security.Claims; + +namespace DramaLing.Api.Controllers; + +[ApiController] +public abstract class BaseController : ControllerBase +{ + protected readonly ILogger _logger; + protected readonly IAuthService? _authService; + + protected BaseController(ILogger logger, IAuthService? authService = null) + { + _logger = logger; + _authService = authService; + } + + /// + /// 統一的成功響應格式 + /// + protected IActionResult SuccessResponse(T data, string? message = null) + { + return Ok(new ApiResponse + { + Success = true, + Data = data, + Message = message, + Timestamp = DateTime.UtcNow + }); + } + + /// + /// 統一的錯誤響應格式 + /// + protected IActionResult ErrorResponse(string code, string message, object? details = null, int statusCode = 500) + { + var response = new ApiErrorResponse + { + Success = false, + Error = new ApiError + { + Code = code, + Message = message, + Details = details, + Suggestions = GetSuggestionsForError(code) + }, + RequestId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + + return StatusCode(statusCode, response); + } + + /// + /// 獲取當前用戶ID(統一處理認證) + /// + protected async Task GetCurrentUserIdAsync() + { + if (_authService != null) + { + // 使用AuthService進行JWT解析(適用於已實現認證的Controller) + var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); + if (userId.HasValue) + return userId.Value; + } + + // Fallback: 從Claims直接解析 + var userIdString = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? + User.FindFirst("sub")?.Value; + + if (Guid.TryParse(userIdString, out var parsedUserId)) + return parsedUserId; + + // 開發階段:使用固定測試用戶ID + if (IsTestEnvironment()) + { + return Guid.Parse("00000000-0000-0000-0000-000000000001"); + } + + throw new UnauthorizedAccessException("Invalid or missing user authentication"); + } + + /// + /// 檢查是否為測試環境 + /// + protected bool IsTestEnvironment() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + return environment == "Development" || environment == "Testing"; + } + + /// + /// 統一的模型驗證錯誤處理 + /// + protected IActionResult HandleModelStateErrors() + { + var errors = ModelState + .Where(x => x.Value?.Errors.Count > 0) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty() + ); + + return ErrorResponse("VALIDATION_ERROR", "輸入資料驗證失敗", errors, 400); + } + + /// + /// 根據錯誤代碼獲取建議 + /// + private List GetSuggestionsForError(string errorCode) + { + return errorCode switch + { + "VALIDATION_ERROR" => new List { "請檢查輸入格式", "確保所有必填欄位已填寫" }, + "INVALID_INPUT" => new List { "請檢查輸入格式", "確保文本長度在限制內" }, + "RATE_LIMIT_EXCEEDED" => new List { "升級到Premium帳戶以獲得無限使用", "明天重新嘗試" }, + "AI_SERVICE_ERROR" => new List { "請稍後重試", "如果問題持續,請聯繫客服" }, + "UNAUTHORIZED" => new List { "請檢查登入狀態", "確認Token是否有效" }, + "NOT_FOUND" => new List { "請檢查資源ID是否正確", "確認資源是否存在" }, + _ => new List { "請稍後重試", "如果問題持續,請聯繫客服" } + }; + } +} + +/// +/// 統一API響應格式 +/// +public class ApiResponse +{ + public bool Success { get; set; } = true; + public T? Data { get; set; } + public string? Message { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} + +/// +/// 分頁響應格式 +/// +public class PagedApiResponse : ApiResponse> +{ + public PaginationMetadata Pagination { get; set; } = new(); +} + +/// +/// 分頁元數據 +/// +public class PaginationMetadata +{ + public int CurrentPage { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public int TotalPages { get; set; } + public bool HasNext { get; set; } + public bool HasPrevious { get; set; } +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Controllers/FlashcardsController.cs b/backend/DramaLing.Api/Controllers/FlashcardsController.cs index 00d2aa1..d1f8841 100644 --- a/backend/DramaLing.Api/Controllers/FlashcardsController.cs +++ b/backend/DramaLing.Api/Controllers/FlashcardsController.cs @@ -5,75 +5,73 @@ using Microsoft.AspNetCore.Authorization; namespace DramaLing.Api.Controllers; -[ApiController] [Route("api/flashcards")] [AllowAnonymous] -public class FlashcardsController : ControllerBase +public class FlashcardsController : BaseController { private readonly IFlashcardRepository _flashcardRepository; - private readonly ILogger _logger; public FlashcardsController( IFlashcardRepository flashcardRepository, - ILogger logger) + ILogger logger) : base(logger) { _flashcardRepository = flashcardRepository; - _logger = logger; - } - - private Guid GetUserId() - { - // 暫時使用固定測試用戶 ID - return Guid.Parse("00000000-0000-0000-0000-000000000001"); } [HttpGet] - public async Task GetFlashcards( + public async Task GetFlashcards( [FromQuery] string? search = null, [FromQuery] bool favoritesOnly = false) { try { - var userId = GetUserId(); + var userId = await GetCurrentUserIdAsync(); var flashcards = await _flashcardRepository.GetByUserIdAsync(userId, search, favoritesOnly); - return Ok(new + var flashcardData = new { - Success = true, - Data = new + Flashcards = flashcards.Select(f => new { - Flashcards = flashcards.Select(f => new - { - f.Id, - f.Word, - f.Translation, - f.Definition, - f.PartOfSpeech, - f.Pronunciation, - f.Example, - f.ExampleTranslation, - f.IsFavorite, - f.DifficultyLevel, - f.CreatedAt, - f.UpdatedAt - }), - Count = flashcards.Count() - } - }); + f.Id, + f.Word, + f.Translation, + f.Definition, + f.PartOfSpeech, + f.Pronunciation, + f.Example, + f.ExampleTranslation, + f.IsFavorite, + f.DifficultyLevel, + f.CreatedAt, + f.UpdatedAt + }), + Count = flashcards.Count() + }; + + return SuccessResponse(flashcardData); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); } catch (Exception ex) { _logger.LogError(ex, "Error getting flashcards"); - return StatusCode(500, new { Success = false, Error = "Failed to load flashcards" }); + return ErrorResponse("INTERNAL_ERROR", "載入詞卡失敗"); } } [HttpPost] - public async Task CreateFlashcard([FromBody] CreateFlashcardRequest request) + public async Task CreateFlashcard([FromBody] CreateFlashcardRequest request) { try { - var userId = GetUserId(); + if (!ModelState.IsValid) + { + return HandleModelStateErrors(); + } + + var userId = await GetCurrentUserIdAsync(); var flashcard = new Flashcard { @@ -93,55 +91,63 @@ public class FlashcardsController : ControllerBase await _flashcardRepository.AddAsync(flashcard); - return Ok(new - { - Success = true, - Data = flashcard, - Message = "詞卡創建成功" - }); + return SuccessResponse(flashcard, "詞卡創建成功"); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); } catch (Exception ex) { _logger.LogError(ex, "Error creating flashcard"); - return StatusCode(500, new { Success = false, Error = "Failed to create flashcard" }); + return ErrorResponse("INTERNAL_ERROR", "創建詞卡失敗"); } } [HttpGet("{id}")] - public async Task GetFlashcard(Guid id) + public async Task GetFlashcard(Guid id) { try { - var userId = GetUserId(); + var userId = await GetCurrentUserIdAsync(); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); if (flashcard == null) { - return NotFound(new { Success = false, Error = "Flashcard not found" }); + return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404); } - return Ok(new { Success = true, Data = flashcard }); + return SuccessResponse(flashcard); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); } catch (Exception ex) { _logger.LogError(ex, "Error getting flashcard {FlashcardId}", id); - return StatusCode(500, new { Success = false, Error = "Failed to get flashcard" }); + return ErrorResponse("INTERNAL_ERROR", "取得詞卡失敗"); } } [HttpPut("{id}")] - public async Task UpdateFlashcard(Guid id, [FromBody] CreateFlashcardRequest request) + public async Task UpdateFlashcard(Guid id, [FromBody] CreateFlashcardRequest request) { try { - var userId = GetUserId(); + if (!ModelState.IsValid) + { + return HandleModelStateErrors(); + } + + var userId = await GetCurrentUserIdAsync(); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); if (flashcard == null) { - return NotFound(new { Success = false, Error = "Flashcard not found" }); + return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404); } // 更新詞卡資訊 @@ -156,57 +162,60 @@ public class FlashcardsController : ControllerBase await _flashcardRepository.UpdateAsync(flashcard); - return Ok(new - { - Success = true, - Data = flashcard, - Message = "詞卡更新成功" - }); + return SuccessResponse(flashcard, "詞卡更新成功"); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); } catch (Exception ex) { _logger.LogError(ex, "Error updating flashcard {FlashcardId}", id); - return StatusCode(500, new { Success = false, Error = "Failed to update flashcard" }); + return ErrorResponse("INTERNAL_ERROR", "更新詞卡失敗"); } } [HttpDelete("{id}")] - public async Task DeleteFlashcard(Guid id) + public async Task DeleteFlashcard(Guid id) { try { - var userId = GetUserId(); + var userId = await GetCurrentUserIdAsync(); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); if (flashcard == null) { - return NotFound(new { Success = false, Error = "Flashcard not found" }); + return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404); } await _flashcardRepository.DeleteAsync(flashcard); - return Ok(new { Success = true, Message = "詞卡已刪除" }); + return SuccessResponse(new { Id = id }, "詞卡已刪除"); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); } catch (Exception ex) { _logger.LogError(ex, "Error deleting flashcard {FlashcardId}", id); - return StatusCode(500, new { Success = false, Error = "Failed to delete flashcard" }); + return ErrorResponse("INTERNAL_ERROR", "刪除詞卡失敗"); } } [HttpPost("{id}/favorite")] - public async Task ToggleFavorite(Guid id) + public async Task ToggleFavorite(Guid id) { try { - var userId = GetUserId(); + var userId = await GetCurrentUserIdAsync(); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); if (flashcard == null) { - return NotFound(new { Success = false, Error = "Flashcard not found" }); + return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404); } flashcard.IsFavorite = !flashcard.IsFavorite; @@ -214,16 +223,22 @@ public class FlashcardsController : ControllerBase await _flashcardRepository.UpdateAsync(flashcard); - return Ok(new { - Success = true, - IsFavorite = flashcard.IsFavorite, - Message = flashcard.IsFavorite ? "已加入收藏" : "已取消收藏" - }); + 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 StatusCode(500, new { Success = false, Error = "Failed to toggle favorite" }); + return ErrorResponse("INTERNAL_ERROR", "切換收藏狀態失敗"); } } } diff --git a/backend/DramaLing.Api/DramaLing.Api.csproj b/backend/DramaLing.Api/DramaLing.Api.csproj index 4ed5f7e..c0d1354 100644 --- a/backend/DramaLing.Api/DramaLing.Api.csproj +++ b/backend/DramaLing.Api/DramaLing.Api.csproj @@ -24,4 +24,8 @@ + + + + \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/Configuration/GeminiOptionsValidator.cs b/backend/DramaLing.Api/Models/Configuration/GeminiOptionsValidator.cs index fb6a323..bd0536a 100644 --- a/backend/DramaLing.Api/Models/Configuration/GeminiOptionsValidator.cs +++ b/backend/DramaLing.Api/Models/Configuration/GeminiOptionsValidator.cs @@ -5,7 +5,7 @@ namespace DramaLing.Api.Models.Configuration; public class GeminiOptionsValidator : IValidateOptions { - public ValidateOptionsResult Validate(string name, GeminiOptions options) + public ValidateOptionsResult Validate(string? name, GeminiOptions options) { var failures = new List(); diff --git a/backend/DramaLing.Api/Program.cs b/backend/DramaLing.Api/Program.cs index 37ebeeb..ac7f499 100644 --- a/backend/DramaLing.Api/Program.cs +++ b/backend/DramaLing.Api/Program.cs @@ -1,13 +1,13 @@ using Microsoft.EntityFrameworkCore; using DramaLing.Api.Data; using DramaLing.Api.Services; -// Services.AI namespace removed using DramaLing.Api.Services.Caching; using DramaLing.Api.Services.Monitoring; using DramaLing.Api.Services.Storage; using DramaLing.Api.Middleware; using DramaLing.Api.Models.Configuration; using DramaLing.Api.Repositories; +using DramaLing.Api.Extensions; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Options; @@ -49,51 +49,16 @@ builder.Services.AddControllers() options.JsonSerializerOptions.WriteIndented = true; }); -// Entity Framework - 使用 SQLite 進行測試 -var useInMemoryDb = Environment.GetEnvironmentVariable("USE_INMEMORY_DB") == "true"; -if (useInMemoryDb) -{ - builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=:memory:")); -} -else -{ - var connectionString = Environment.GetEnvironmentVariable("DRAMALING_DB_CONNECTION") - ?? builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=dramaling_test.db"; // SQLite 檔案 +// 配置資料庫服務 +builder.Services.AddDatabaseServices(builder.Configuration); - builder.Services.AddDbContext(options => - options.UseSqlite(connectionString)); -} +// 配置 Repository 和 Caching 服務 +builder.Services.AddRepositoryServices(); +builder.Services.AddCachingServices(); -// 暫時註解新的服務,等修正編譯錯誤後再啟用 -// Repository Services -// builder.Services.AddScoped(typeof(IRepository<>), typeof(BaseRepository<>)); -// builder.Services.AddScoped(); -// builder.Services.AddScoped(); - -// Caching Services - now using Extension method -// builder.Services.AddMemoryCache(); -// builder.Services.AddScoped(); - -// AI Services -// builder.Services.AddHttpClient(); -// builder.Services.AddScoped(); -// builder.Services.AddScoped(); - -// Custom Services -builder.Services.AddScoped(); -builder.Services.AddHttpClient(); -// 新增帶快取的分析服務 -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -// 智能填空題系統服務已移除 -builder.Services.AddScoped(); - -// 智能複習服務已移除,準備重新實施 - -// 學習會話服務已清理移除 +// 配置 AI 和業務服務 +builder.Services.AddAIServices(builder.Configuration); +builder.Services.AddBusinessServices(); // 🆕 選項詞彙庫服務註冊 builder.Services.Configure( @@ -103,15 +68,7 @@ builder.Services.AddSingleton(); // 監控指標服務 // builder.Services.AddScoped(); // 暫時註解,使用固定選項 builder.Services.AddScoped(); -// Image Generation Services -builder.Services.AddHttpClient(); -builder.Services.AddScoped(); - -// Image Storage Services -builder.Services.AddScoped(); - -// Image Processing Services -builder.Services.AddScoped(); +// (圖片相關服務已透過 AddAIServices 和 AddBusinessServices 註冊) // Background Services (快取清理服務已移除)