feat: 完成Controllers架構統一優化與後端重啟修復

主要改進:
- 🏗️ 新增BaseController統一響應處理架構
  - 標準化SuccessResponse和ErrorResponse格式
  - 統一GetCurrentUserIdAsync認證處理
  - 統一HandleModelStateErrors驗證錯誤處理

- 🔧 重構FlashcardsController使用BaseController
  - 所有返回類型改為IActionResult統一格式
  - 完整的異常處理與錯誤回應
  - 移除重複的用戶ID獲取邏輯

- 🛠️ 修復依賴注入配置問題
  - 使用ServiceCollectionExtensions組織服務註冊
  - 修復ICacheProvider和IImageGenerationWorkflow缺失問題
  - 清理重複的服務註冊,提升代碼可維護性

- 🐛 解決編譯錯誤
  - 修復GeminiOptionsValidator nullable警告
  - 排除測試文件避免編譯衝突
  - 確保所有依賴正確註冊

後端現已成功重啟並運行在 http://localhost:5008

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-30 05:46:20 +08:00
parent 2a6c130bb8
commit 923ce16f5f
5 changed files with 260 additions and 127 deletions

View File

@ -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;
}
/// <summary>
/// 統一的成功響應格式
/// </summary>
protected IActionResult SuccessResponse<T>(T data, string? message = null)
{
return Ok(new ApiResponse<T>
{
Success = true,
Data = data,
Message = message,
Timestamp = DateTime.UtcNow
});
}
/// <summary>
/// 統一的錯誤響應格式
/// </summary>
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);
}
/// <summary>
/// 獲取當前用戶ID統一處理認證
/// </summary>
protected async Task<Guid> 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");
}
/// <summary>
/// 檢查是否為測試環境
/// </summary>
protected bool IsTestEnvironment()
{
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
return environment == "Development" || environment == "Testing";
}
/// <summary>
/// 統一的模型驗證錯誤處理
/// </summary>
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<string>()
);
return ErrorResponse("VALIDATION_ERROR", "輸入資料驗證失敗", errors, 400);
}
/// <summary>
/// 根據錯誤代碼獲取建議
/// </summary>
private List<string> GetSuggestionsForError(string errorCode)
{
return errorCode switch
{
"VALIDATION_ERROR" => new List<string> { "請檢查輸入格式", "確保所有必填欄位已填寫" },
"INVALID_INPUT" => new List<string> { "請檢查輸入格式", "確保文本長度在限制內" },
"RATE_LIMIT_EXCEEDED" => new List<string> { "升級到Premium帳戶以獲得無限使用", "明天重新嘗試" },
"AI_SERVICE_ERROR" => new List<string> { "請稍後重試", "如果問題持續,請聯繫客服" },
"UNAUTHORIZED" => new List<string> { "請檢查登入狀態", "確認Token是否有效" },
"NOT_FOUND" => new List<string> { "請檢查資源ID是否正確", "確認資源是否存在" },
_ => new List<string> { "請稍後重試", "如果問題持續,請聯繫客服" }
};
}
}
/// <summary>
/// 統一API響應格式
/// </summary>
public class ApiResponse<T>
{
public bool Success { get; set; } = true;
public T? Data { get; set; }
public string? Message { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// 分頁響應格式
/// </summary>
public class PagedApiResponse<T> : ApiResponse<List<T>>
{
public PaginationMetadata Pagination { get; set; } = new();
}
/// <summary>
/// 分頁元數據
/// </summary>
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; }
}

View File

@ -5,75 +5,73 @@ using Microsoft.AspNetCore.Authorization;
namespace DramaLing.Api.Controllers; namespace DramaLing.Api.Controllers;
[ApiController]
[Route("api/flashcards")] [Route("api/flashcards")]
[AllowAnonymous] [AllowAnonymous]
public class FlashcardsController : ControllerBase public class FlashcardsController : BaseController
{ {
private readonly IFlashcardRepository _flashcardRepository; private readonly IFlashcardRepository _flashcardRepository;
private readonly ILogger<FlashcardsController> _logger;
public FlashcardsController( public FlashcardsController(
IFlashcardRepository flashcardRepository, IFlashcardRepository flashcardRepository,
ILogger<FlashcardsController> logger) ILogger<FlashcardsController> logger) : base(logger)
{ {
_flashcardRepository = flashcardRepository; _flashcardRepository = flashcardRepository;
_logger = logger;
}
private Guid GetUserId()
{
// 暫時使用固定測試用戶 ID
return Guid.Parse("00000000-0000-0000-0000-000000000001");
} }
[HttpGet] [HttpGet]
public async Task<ActionResult> GetFlashcards( public async Task<IActionResult> GetFlashcards(
[FromQuery] string? search = null, [FromQuery] string? search = null,
[FromQuery] bool favoritesOnly = false) [FromQuery] bool favoritesOnly = false)
{ {
try try
{ {
var userId = GetUserId(); var userId = await GetCurrentUserIdAsync();
var flashcards = await _flashcardRepository.GetByUserIdAsync(userId, search, favoritesOnly); var flashcards = await _flashcardRepository.GetByUserIdAsync(userId, search, favoritesOnly);
return Ok(new var flashcardData = new
{ {
Success = true, Flashcards = flashcards.Select(f => new
Data = new
{ {
Flashcards = flashcards.Select(f => new f.Id,
{ f.Word,
f.Id, f.Translation,
f.Word, f.Definition,
f.Translation, f.PartOfSpeech,
f.Definition, f.Pronunciation,
f.PartOfSpeech, f.Example,
f.Pronunciation, f.ExampleTranslation,
f.Example, f.IsFavorite,
f.ExampleTranslation, f.DifficultyLevel,
f.IsFavorite, f.CreatedAt,
f.DifficultyLevel, f.UpdatedAt
f.CreatedAt, }),
f.UpdatedAt Count = flashcards.Count()
}), };
Count = flashcards.Count()
} return SuccessResponse(flashcardData);
}); }
catch (UnauthorizedAccessException)
{
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting flashcards"); _logger.LogError(ex, "Error getting flashcards");
return StatusCode(500, new { Success = false, Error = "Failed to load flashcards" }); return ErrorResponse("INTERNAL_ERROR", "載入詞卡失敗");
} }
} }
[HttpPost] [HttpPost]
public async Task<ActionResult> CreateFlashcard([FromBody] CreateFlashcardRequest request) public async Task<IActionResult> CreateFlashcard([FromBody] CreateFlashcardRequest request)
{ {
try try
{ {
var userId = GetUserId(); if (!ModelState.IsValid)
{
return HandleModelStateErrors();
}
var userId = await GetCurrentUserIdAsync();
var flashcard = new Flashcard var flashcard = new Flashcard
{ {
@ -93,55 +91,63 @@ public class FlashcardsController : ControllerBase
await _flashcardRepository.AddAsync(flashcard); await _flashcardRepository.AddAsync(flashcard);
return Ok(new return SuccessResponse(flashcard, "詞卡創建成功");
{ }
Success = true, catch (UnauthorizedAccessException)
Data = flashcard, {
Message = "詞卡創建成功" return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
});
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error creating flashcard"); _logger.LogError(ex, "Error creating flashcard");
return StatusCode(500, new { Success = false, Error = "Failed to create flashcard" }); return ErrorResponse("INTERNAL_ERROR", "創建詞卡失敗");
} }
} }
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<ActionResult> GetFlashcard(Guid id) public async Task<IActionResult> GetFlashcard(Guid id)
{ {
try try
{ {
var userId = GetUserId(); var userId = await GetCurrentUserIdAsync();
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
if (flashcard == null) 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting flashcard {FlashcardId}", id); _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}")] [HttpPut("{id}")]
public async Task<ActionResult> UpdateFlashcard(Guid id, [FromBody] CreateFlashcardRequest request) public async Task<IActionResult> UpdateFlashcard(Guid id, [FromBody] CreateFlashcardRequest request)
{ {
try try
{ {
var userId = GetUserId(); if (!ModelState.IsValid)
{
return HandleModelStateErrors();
}
var userId = await GetCurrentUserIdAsync();
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
if (flashcard == null) 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); await _flashcardRepository.UpdateAsync(flashcard);
return Ok(new return SuccessResponse(flashcard, "詞卡更新成功");
{ }
Success = true, catch (UnauthorizedAccessException)
Data = flashcard, {
Message = "詞卡更新成功" return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
});
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error updating flashcard {FlashcardId}", id); _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}")] [HttpDelete("{id}")]
public async Task<ActionResult> DeleteFlashcard(Guid id) public async Task<IActionResult> DeleteFlashcard(Guid id)
{ {
try try
{ {
var userId = GetUserId(); var userId = await GetCurrentUserIdAsync();
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
if (flashcard == null) if (flashcard == null)
{ {
return NotFound(new { Success = false, Error = "Flashcard not found" }); return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404);
} }
await _flashcardRepository.DeleteAsync(flashcard); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Error deleting flashcard {FlashcardId}", id); _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")] [HttpPost("{id}/favorite")]
public async Task<ActionResult> ToggleFavorite(Guid id) public async Task<IActionResult> ToggleFavorite(Guid id)
{ {
try try
{ {
var userId = GetUserId(); var userId = await GetCurrentUserIdAsync();
var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id); var flashcard = await _flashcardRepository.GetByUserIdAndFlashcardIdAsync(userId, id);
if (flashcard == null) if (flashcard == null)
{ {
return NotFound(new { Success = false, Error = "Flashcard not found" }); return ErrorResponse("NOT_FOUND", "詞卡不存在", null, 404);
} }
flashcard.IsFavorite = !flashcard.IsFavorite; flashcard.IsFavorite = !flashcard.IsFavorite;
@ -214,16 +223,22 @@ public class FlashcardsController : ControllerBase
await _flashcardRepository.UpdateAsync(flashcard); await _flashcardRepository.UpdateAsync(flashcard);
return Ok(new { var result = new {
Success = true, Id = flashcard.Id,
IsFavorite = flashcard.IsFavorite, IsFavorite = flashcard.IsFavorite
Message = flashcard.IsFavorite ? "已加入收藏" : "已取消收藏" };
});
var message = flashcard.IsFavorite ? "已加入收藏" : "已取消收藏";
return SuccessResponse(result, message);
}
catch (UnauthorizedAccessException)
{
return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error toggling favorite for flashcard {FlashcardId}", id); _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", "切換收藏狀態失敗");
} }
} }
} }

View File

@ -24,4 +24,8 @@
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.10" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.10" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Remove="DramaLing.Api.Tests/**/*.cs" />
</ItemGroup>
</Project> </Project>

View File

@ -5,7 +5,7 @@ namespace DramaLing.Api.Models.Configuration;
public class GeminiOptionsValidator : IValidateOptions<GeminiOptions> public class GeminiOptionsValidator : IValidateOptions<GeminiOptions>
{ {
public ValidateOptionsResult Validate(string name, GeminiOptions options) public ValidateOptionsResult Validate(string? name, GeminiOptions options)
{ {
var failures = new List<string>(); var failures = new List<string>();

View File

@ -1,13 +1,13 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DramaLing.Api.Data; using DramaLing.Api.Data;
using DramaLing.Api.Services; using DramaLing.Api.Services;
// Services.AI namespace removed
using DramaLing.Api.Services.Caching; using DramaLing.Api.Services.Caching;
using DramaLing.Api.Services.Monitoring; using DramaLing.Api.Services.Monitoring;
using DramaLing.Api.Services.Storage; using DramaLing.Api.Services.Storage;
using DramaLing.Api.Middleware; using DramaLing.Api.Middleware;
using DramaLing.Api.Models.Configuration; using DramaLing.Api.Models.Configuration;
using DramaLing.Api.Repositories; using DramaLing.Api.Repositories;
using DramaLing.Api.Extensions;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -49,51 +49,16 @@ builder.Services.AddControllers()
options.JsonSerializerOptions.WriteIndented = true; options.JsonSerializerOptions.WriteIndented = true;
}); });
// Entity Framework - 使用 SQLite 進行測試 // 配置資料庫服務
var useInMemoryDb = Environment.GetEnvironmentVariable("USE_INMEMORY_DB") == "true"; builder.Services.AddDatabaseServices(builder.Configuration);
if (useInMemoryDb)
{
builder.Services.AddDbContext<DramaLingDbContext>(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.AddDbContext<DramaLingDbContext>(options => // 配置 Repository 和 Caching 服務
options.UseSqlite(connectionString)); builder.Services.AddRepositoryServices();
} builder.Services.AddCachingServices();
// 暫時註解新的服務,等修正編譯錯誤後再啟用 // 配置 AI 和業務服務
// Repository Services builder.Services.AddAIServices(builder.Configuration);
// builder.Services.AddScoped(typeof(IRepository<>), typeof(BaseRepository<>)); builder.Services.AddBusinessServices();
// builder.Services.AddScoped<IFlashcardRepository, SimpleFlashcardRepository>();
// builder.Services.AddScoped<IUserRepository, UserRepository>();
// Caching Services - now using Extension method
// builder.Services.AddMemoryCache();
// builder.Services.AddScoped<ICacheService, HybridCacheService>();
// AI Services
// builder.Services.AddHttpClient<GeminiAIProvider>();
// builder.Services.AddScoped<IAIProvider, GeminiAIProvider>();
// builder.Services.AddScoped<IAIProviderManager, AIProviderManager>();
// Custom Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddHttpClient<IGeminiService, GeminiService>();
// 新增帶快取的分析服務
builder.Services.AddScoped<IAnalysisService, AnalysisService>();
builder.Services.AddScoped<IUsageTrackingService, UsageTrackingService>();
builder.Services.AddScoped<IAzureSpeechService, AzureSpeechService>();
// 智能填空題系統服務已移除
builder.Services.AddScoped<IAudioCacheService, AudioCacheService>();
// 智能複習服務已移除,準備重新實施
// 學習會話服務已清理移除
// 🆕 選項詞彙庫服務註冊 // 🆕 選項詞彙庫服務註冊
builder.Services.Configure<OptionsVocabularyOptions>( builder.Services.Configure<OptionsVocabularyOptions>(
@ -103,15 +68,7 @@ builder.Services.AddSingleton<OptionsVocabularyMetrics>(); // 監控指標服務
// builder.Services.AddScoped<OptionsVocabularySeeder>(); // 暫時註解,使用固定選項 // builder.Services.AddScoped<OptionsVocabularySeeder>(); // 暫時註解,使用固定選項
builder.Services.AddScoped<IOptionsVocabularyService, OptionsVocabularyService>(); builder.Services.AddScoped<IOptionsVocabularyService, OptionsVocabularyService>();
// Image Generation Services // (圖片相關服務已透過 AddAIServices 和 AddBusinessServices 註冊)
builder.Services.AddHttpClient<IReplicateService, ReplicateService>();
builder.Services.AddScoped<IImageGenerationOrchestrator, ImageGenerationOrchestrator>();
// Image Storage Services
builder.Services.AddScoped<IImageStorageService, LocalImageStorageService>();
// Image Processing Services
builder.Services.AddScoped<IImageProcessingService, ImageProcessingService>();
// Background Services (快取清理服務已移除) // Background Services (快取清理服務已移除)