# Flutter + .NET Core 整合開發指南 ## 概述 針對 Drama Ling 專案使用 Flutter 前端 + .NET Core 後端的技術架構,提供完整的整合開發指引。 ## Flutter 前端架構 ### 專案結構設計 ``` lib/ ├── main.dart # 應用程式進入點 ├── app/ │ ├── app.dart # App主要配置 │ ├── routes/ # 路由配置 │ └── themes/ # 主題設定 ├── core/ │ ├── constants/ # 常數定義 │ ├── utils/ # 工具類 │ ├── errors/ # 錯誤處理 │ └── network/ # 網路層配置 ├── features/ # 功能模組 │ ├── authentication/ # 用戶認證 │ ├── dialogue/ # 對話練習 │ ├── vocabulary/ # 詞彙管理 │ ├── gamification/ # 遊戲化功能 │ └── subscription/ # 訂閱功能 └── shared/ ├── widgets/ # 共用元件 ├── models/ # 資料模型 └── services/ # 共用服務 ``` ### 狀態管理架構 (Riverpod) ```dart // ✅ Provider 定義範例 @riverpod class DialogueNotifier extends _$DialogueNotifier { @override DialogueState build() { return const DialogueState.initial(); } Future startDialogue(String scenarioId) async { state = const DialogueState.loading(); try { final dialogue = await ref.read(dialogueRepositoryProvider) .startDialogue(scenarioId); state = DialogueState.success(dialogue); } catch (error) { state = DialogueState.error(error.toString()); } } Future sendMessage(String message) async { // 發送訊息邏輯 } } // State 類定義 @freezed class DialogueState with _$DialogueState { const factory DialogueState.initial() = _Initial; const factory DialogueState.loading() = _Loading; const factory DialogueState.success(Dialogue dialogue) = _Success; const factory DialogueState.error(String message) = _Error; } ``` ### 網路層設計 (Dio + Retrofit) ```dart // ✅ API 客戶端設定 @RestApi(baseUrl: 'https://api.dramaling.com/api/v1') abstract class DramaLingApi { factory DramaLingApi(Dio dio) = _DramaLingApi; @POST('/auth/login') Future> login(@Body() LoginRequest request); @POST('/dialogues/start') Future> startDialogue(@Body() StartDialogueRequest request); @GET('/users/profile') Future> getUserProfile(); @GET('/vocabulary') Future>> getVocabulary( @Query('category') String? category, @Query('difficulty') String? difficulty, ); } // 攔截器設置 class AuthInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { final token = getStoredToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } @override void onError(DioException err, ErrorInterceptorHandler handler) { if (err.response?.statusCode == 401) { // 處理 Token 過期 logout(); } handler.next(err); } } ``` ### 本地存儲設計 (Hive) ```dart // ✅ Hive 資料模型 @HiveType(typeId: 0) class UserProfileLocal extends HiveObject { @HiveField(0) late String userId; @HiveField(1) late String username; @HiveField(2) late String email; @HiveField(3) late int totalScore; @HiveField(4) late DateTime lastSyncAt; } // Repository 模式 abstract class UserRepository { Future getUserProfile(); Future saveUserProfile(UserProfile profile); Future clearUserData(); } class UserRepositoryImpl implements UserRepository { final DramaLingApi _api; final Box _localBox; UserRepositoryImpl(this._api, this._localBox); @override Future getUserProfile() async { try { // 嘗試從 API 獲取最新資料 final response = await _api.getUserProfile(); // 存儲到本地 final localProfile = UserProfileLocal() ..userId = response.data.userId ..username = response.data.username ..email = response.data.email ..totalScore = response.data.totalScore ..lastSyncAt = DateTime.now(); await _localBox.put('user_profile', localProfile); return response.data; } catch (e) { // API 失敗時使用本地快取 final localProfile = _localBox.get('user_profile'); if (localProfile != null) { return UserProfile.fromLocal(localProfile); } rethrow; } } } ``` ## .NET Core 後端架構 ### 專案結構設計 ``` DramaLing.Api/ ├── DramaLing.Api/ # Web API 專案 │ ├── Controllers/ # API 控制器 │ ├── Middleware/ # 自定義中介軟體 │ ├── Filters/ # 過濾器 │ └── Program.cs # 應用程式啟動 ├── DramaLing.Core/ # 核心業務邏輯 │ ├── Entities/ # 實體類別 │ ├── Interfaces/ # 介面定義 │ ├── Services/ # 業務服務 │ └── DTOs/ # 資料傳輸物件 ├── DramaLing.Infrastructure/ # 基礎設施層 │ ├── Data/ # 資料存取 │ ├── Repositories/ # Repository 實作 │ ├── External/ # 外部服務整合 │ └── Configurations/ # 設定檔 └── DramaLing.Tests/ # 測試專案 ├── Unit/ # 單元測試 ├── Integration/ # 整合測試 └── Common/ # 測試共用程式碼 ``` ### API 控制器設計 ```csharp // ✅ RESTful API 控制器範例 [ApiController] [Route("api/v1/[controller]")] [Authorize] public class DialoguesController : ControllerBase { private readonly IDialogueService _dialogueService; private readonly ILogger _logger; public DialoguesController( IDialogueService dialogueService, ILogger logger) { _dialogueService = dialogueService; _logger = logger; } [HttpPost("start")] public async Task>> StartDialogue( [FromBody] StartDialogueRequest request) { try { var userId = GetCurrentUserId(); var dialogue = await _dialogueService.StartDialogueAsync(userId, request); return Ok(ApiResponse.Success(dialogue)); } catch (ValidationException ex) { return BadRequest(ApiResponse.Error("VALIDATION_ERROR", ex.Message)); } catch (Exception ex) { _logger.LogError(ex, "Failed to start dialogue for user {UserId}", GetCurrentUserId()); return StatusCode(500, ApiResponse.Error("INTERNAL_ERROR", "Internal server error")); } } [HttpPost("{dialogueId}/messages")] public async Task>> SendMessage( Guid dialogueId, [FromBody] SendMessageRequest request) { var userId = GetCurrentUserId(); var message = await _dialogueService.SendMessageAsync(userId, dialogueId, request); return Ok(ApiResponse.Success(message)); } private Guid GetCurrentUserId() { var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; return Guid.Parse(userIdClaim ?? throw new UnauthorizedAccessException()); } } ``` ### 業務服務層設計 ```csharp // ✅ 業務服務實作範例 public interface IDialogueService { Task StartDialogueAsync(Guid userId, StartDialogueRequest request); Task SendMessageAsync(Guid userId, Guid dialogueId, SendMessageRequest request); Task GetDialogueAnalysisAsync(Guid userId, Guid dialogueId); } public class DialogueService : IDialogueService { private readonly IDialogueRepository _dialogueRepository; private readonly ILessonRepository _lessonRepository; private readonly IAiAnalysisService _aiAnalysisService; private readonly IMapper _mapper; public DialogueService( IDialogueRepository dialogueRepository, ILessonRepository lessonRepository, IAiAnalysisService aiAnalysisService, IMapper mapper) { _dialogueRepository = dialogueRepository; _lessonRepository = lessonRepository; _aiAnalysisService = aiAnalysisService; _mapper = mapper; } public async Task StartDialogueAsync(Guid userId, StartDialogueRequest request) { // 驗證場景是否存在 var lesson = await _lessonRepository.GetByCodeAsync(request.ScenarioId) ?? throw new ValidationException($"Scenario {request.ScenarioId} not found"); // 檢查用戶權限 await ValidateUserAccessAsync(userId, lesson); // 創建對話實例 var dialogue = new Dialogue { UserId = userId, LessonId = lesson.Id, SessionToken = Guid.NewGuid().ToString(), Status = DialogueStatus.InProgress, StartedAt = DateTime.UtcNow }; await _dialogueRepository.AddAsync(dialogue); await _dialogueRepository.SaveChangesAsync(); // 生成初始 AI 訊息 var initialMessage = await GenerateInitialMessageAsync(dialogue, lesson); var dto = _mapper.Map(dialogue); dto.InitialMessage = initialMessage; return dto; } public async Task SendMessageAsync( Guid userId, Guid dialogueId, SendMessageRequest request) { var dialogue = await _dialogueRepository.GetByIdAsync(dialogueId) ?? throw new NotFoundException($"Dialogue {dialogueId} not found"); if (dialogue.UserId != userId) throw new ForbiddenException("Access denied"); // 保存用戶訊息 var userMessage = new DialogueMessage { DialogueId = dialogueId, TurnNumber = await GetNextTurnNumberAsync(dialogueId), SenderType = MessageSenderType.User, MessageText = request.Message, CreatedAt = DateTime.UtcNow }; await _dialogueRepository.AddMessageAsync(userMessage); // AI 分析和回應 var analysisResult = await _aiAnalysisService.AnalyzeMessageAsync( dialogue, userMessage); // 生成 AI 回應 var aiResponse = await _aiAnalysisService.GenerateResponseAsync( dialogue, userMessage, analysisResult); var aiMessage = new DialogueMessage { DialogueId = dialogueId, TurnNumber = userMessage.TurnNumber, SenderType = MessageSenderType.AI, MessageText = aiResponse.Message, CreatedAt = DateTime.UtcNow }; await _dialogueRepository.AddMessageAsync(aiMessage); await _dialogueRepository.SaveChangesAsync(); return _mapper.Map(aiMessage); } } ``` ### Entity Framework 配置 ```csharp // ✅ DbContext 設定 public class DramaLingDbContext : DbContext { public DbSet Users { get; set; } public DbSet Lessons { get; set; } public DbSet Dialogues { get; set; } public DbSet DialogueMessages { get; set; } public DbSet Vocabularies { get; set; } public DramaLingDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // 應用所有配置 modelBuilder.ApplyConfigurationsFromAssembly(typeof(DramaLingDbContext).Assembly); } } // ✅ 實體配置範例 public class DialogueConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("dialogues"); builder.HasKey(d => d.Id); builder.Property(d => d.Id) .HasColumnName("dialogue_id") .HasDefaultValueSql("gen_random_uuid()"); builder.Property(d => d.SessionToken) .HasColumnName("session_token") .HasMaxLength(255) .IsRequired(); builder.Property(d => d.Status) .HasColumnName("status") .HasConversion(); // 關聯設定 builder.HasOne(d => d.User) .WithMany(u => u.Dialogues) .HasForeignKey(d => d.UserId) .OnDelete(DeleteBehavior.Cascade); builder.HasOne(d => d.Lesson) .WithMany() .HasForeignKey(d => d.LessonId) .OnDelete(DeleteBehavior.SetNull); // 索引設定 builder.HasIndex(d => d.UserId); builder.HasIndex(d => d.SessionToken).IsUnique(); builder.HasIndex(d => d.CreatedAt); } } ``` ## 前後端整合最佳實踐 ### API 回應格式統一 ```csharp // ✅ C# API 回應模型 public class ApiResponse { public bool Success { get; set; } public T? Data { get; set; } public string? Message { get; set; } public ApiError? Error { get; set; } public ApiMeta? Meta { get; set; } public static ApiResponse Success(T data, string? message = null) { return new ApiResponse { Success = true, Data = data, Message = message, Meta = new ApiMeta { Timestamp = DateTime.UtcNow, RequestId = Activity.Current?.Id ?? Guid.NewGuid().ToString() } }; } public static ApiResponse Error(string code, string message) { return new ApiResponse { Success = false, Error = new ApiError { Code = code, Message = message } }; } } ``` ```dart // ✅ Flutter API 回應模型 @freezed class ApiResponse with _$ApiResponse { const factory ApiResponse({ required bool success, T? data, String? message, ApiError? error, ApiMeta? meta, }) = _ApiResponse; factory ApiResponse.fromJson( Map json, T Function(Object? json) fromJsonT, ) => _$ApiResponseFromJson(json, fromJsonT); } @freezed class ApiError with _$ApiError { const factory ApiError({ required String code, required String message, Map? details, }) = _ApiError; factory ApiError.fromJson(Map json) => _$ApiErrorFromJson(json); } ``` ### 錯誤處理機制 ```csharp // ✅ C# 全域錯誤處理中介軟體 public class GlobalExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (Exception ex) { await HandleExceptionAsync(context, ex); } } private async Task HandleExceptionAsync(HttpContext context, Exception exception) { _logger.LogError(exception, "An unhandled exception occurred"); var response = exception switch { ValidationException validationEx => ApiResponse.Error("VALIDATION_ERROR", validationEx.Message), NotFoundException notFoundEx => ApiResponse.Error("NOT_FOUND", notFoundEx.Message), ForbiddenException forbiddenEx => ApiResponse.Error("FORBIDDEN", forbiddenEx.Message), UnauthorizedAccessException => ApiResponse.Error("UNAUTHORIZED", "Unauthorized access"), _ => ApiResponse.Error("INTERNAL_ERROR", "An internal server error occurred") }; context.Response.StatusCode = exception switch { ValidationException => 400, NotFoundException => 404, ForbiddenException => 403, UnauthorizedAccessException => 401, _ => 500 }; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(response)); } } ``` ```dart // ✅ Flutter 錯誤處理 class ApiException implements Exception { final String code; final String message; final int? statusCode; const ApiException({ required this.code, required this.message, this.statusCode, }); factory ApiException.fromDioError(DioException error) { if (error.response?.data != null) { final responseData = error.response!.data as Map; if (responseData.containsKey('error')) { final errorData = responseData['error'] as Map; return ApiException( code: errorData['code'] ?? 'UNKNOWN_ERROR', message: errorData['message'] ?? 'An unknown error occurred', statusCode: error.response?.statusCode, ); } } return ApiException( code: 'NETWORK_ERROR', message: error.message ?? 'Network error occurred', statusCode: error.response?.statusCode, ); } } // Repository 錯誤處理範例 abstract class BaseRepository { Future handleApiCall(Future Function() apiCall) async { try { return await apiCall(); } on DioException catch (e) { throw ApiException.fromDioError(e); } catch (e) { throw ApiException( code: 'UNKNOWN_ERROR', message: e.toString(), ); } } } ``` ### JWT 認證整合 ```csharp // ✅ C# JWT 配置 public static class AuthenticationExtensions { public static IServiceCollection AddJwtAuthentication( this IServiceCollection services, IConfiguration configuration) { var jwtSettings = configuration.GetSection("JwtSettings").Get()!; services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings.Issuer, ValidAudience = jwtSettings.Audience, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtSettings.SecretKey)), ClockSkew = TimeSpan.Zero }; }); return services; } } ``` ```dart // ✅ Flutter Token 管理 class TokenManager { static const String _accessTokenKey = 'access_token'; static const String _refreshTokenKey = 'refresh_token'; final FlutterSecureStorage _secureStorage; TokenManager(this._secureStorage); Future getAccessToken() async { return await _secureStorage.read(key: _accessTokenKey); } Future getRefreshToken() async { return await _secureStorage.read(key: _refreshTokenKey); } Future saveTokens(String accessToken, String refreshToken) async { await Future.wait([ _secureStorage.write(key: _accessTokenKey, value: accessToken), _secureStorage.write(key: _refreshTokenKey, value: refreshToken), ]); } Future clearTokens() async { await Future.wait([ _secureStorage.delete(key: _accessTokenKey), _secureStorage.delete(key: _refreshTokenKey), ]); } bool isTokenExpired(String token) { try { final jwt = JWT.verify(token, SecretKey('your-secret')); final exp = jwt.payload['exp'] as int; final expireDate = DateTime.fromMillisecondsSinceEpoch(exp * 1000); return DateTime.now().isAfter(expireDate); } catch (e) { return true; } } } ``` ## 開發工具配置 ### Flutter 開發環境 ```yaml # pubspec.yaml 關鍵依賴 dependencies: flutter: sdk: flutter # 狀態管理 flutter_riverpod: ^2.4.9 riverpod_annotation: ^2.3.3 # 網路請求 dio: ^5.3.3 retrofit: ^4.0.3 # 本地存儲 hive: ^2.2.3 hive_flutter: ^1.1.0 flutter_secure_storage: ^9.0.0 # JSON 序列化 freezed_annotation: ^2.4.1 json_annotation: ^4.8.1 # 路由 go_router: ^12.1.1 # UI flutter_svg: ^2.0.9 cached_network_image: ^3.3.0 dev_dependencies: # 程式碼生成 build_runner: ^2.4.7 freezed: ^2.4.6 json_serializable: ^6.7.1 retrofit_generator: ^7.0.8 riverpod_generator: ^2.3.9 hive_generator: ^2.0.1 # 測試 flutter_test: sdk: flutter integration_test: sdk: flutter mockito: ^5.4.3 ``` ### .NET Core 開發環境 ```xml net8.0 enable enable ``` --- ## 待完成任務 ### 高優先級 1. [ ] 完成 Flutter 專案初始化和基礎架構設置 2. [ ] 建立 .NET Core Web API 專案和 Entity Framework 配置 3. [ ] 實現 JWT 認證機制的前後端整合 4. [ ] 設置 API 文檔 (Swagger) 和前端 API 客戶端生成 ### 中優先級 1. [ ] 建立前後端的錯誤處理和日誌機制 2. [ ] 實現離線同步和資料快取策略 3. [ ] 設置自動化測試框架 (Flutter + .NET Core) 4. [ ] 建立 CI/CD pipeline 支援 Flutter 和 .NET 部署 ### 低優先級 1. [ ] 研究 Flutter Web 版本的可行性 2. [ ] 探索 .NET MAUI 作為 Flutter 的替代方案 3. [ ] 建立效能監控和分析工具整合 4. [ ] 設計微服務架構的擴展計劃 --- **最後更新**: 2024年9月5日 **負責人**: 前端Lead + 後端Lead **技術審查**: 每月檢討技術選型和架構決策