dramaling-app/docs/technical/flutter-dotnet-integration.md

24 KiB

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)

// ✅ Provider 定義範例
@riverpod
class DialogueNotifier extends _$DialogueNotifier {
  @override
  DialogueState build() {
    return const DialogueState.initial();
  }

  Future<void> 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<void> 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)

// ✅ API 客戶端設定
@RestApi(baseUrl: 'https://api.dramaling.com/api/v1')
abstract class DramaLingApi {
  factory DramaLingApi(Dio dio) = _DramaLingApi;

  @POST('/auth/login')
  Future<ApiResponse<LoginResponse>> login(@Body() LoginRequest request);

  @POST('/dialogues/start')
  Future<ApiResponse<Dialogue>> startDialogue(@Body() StartDialogueRequest request);

  @GET('/users/profile')
  Future<ApiResponse<UserProfile>> getUserProfile();

  @GET('/vocabulary')
  Future<ApiResponse<List<Vocabulary>>> 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)

// ✅ 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<UserProfile> getUserProfile();
  Future<void> saveUserProfile(UserProfile profile);
  Future<void> clearUserData();
}

class UserRepositoryImpl implements UserRepository {
  final DramaLingApi _api;
  final Box<UserProfileLocal> _localBox;

  UserRepositoryImpl(this._api, this._localBox);

  @override
  Future<UserProfile> 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 控制器設計

// ✅ RESTful API 控制器範例
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class DialoguesController : ControllerBase
{
    private readonly IDialogueService _dialogueService;
    private readonly ILogger<DialoguesController> _logger;

    public DialoguesController(
        IDialogueService dialogueService,
        ILogger<DialoguesController> logger)
    {
        _dialogueService = dialogueService;
        _logger = logger;
    }

    [HttpPost("start")]
    public async Task<ActionResult<ApiResponse<DialogueDto>>> StartDialogue(
        [FromBody] StartDialogueRequest request)
    {
        try {
            var userId = GetCurrentUserId();
            var dialogue = await _dialogueService.StartDialogueAsync(userId, request);
            
            return Ok(ApiResponse<DialogueDto>.Success(dialogue));
        }
        catch (ValidationException ex)
        {
            return BadRequest(ApiResponse<DialogueDto>.Error("VALIDATION_ERROR", ex.Message));
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to start dialogue for user {UserId}", GetCurrentUserId());
            return StatusCode(500, ApiResponse<DialogueDto>.Error("INTERNAL_ERROR", "Internal server error"));
        }
    }

    [HttpPost("{dialogueId}/messages")]
    public async Task<ActionResult<ApiResponse<DialogueMessageDto>>> SendMessage(
        Guid dialogueId,
        [FromBody] SendMessageRequest request)
    {
        var userId = GetCurrentUserId();
        var message = await _dialogueService.SendMessageAsync(userId, dialogueId, request);
        
        return Ok(ApiResponse<DialogueMessageDto>.Success(message));
    }

    private Guid GetCurrentUserId()
    {
        var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        return Guid.Parse(userIdClaim ?? throw new UnauthorizedAccessException());
    }
}

業務服務層設計

// ✅ 業務服務實作範例
public interface IDialogueService
{
    Task<DialogueDto> StartDialogueAsync(Guid userId, StartDialogueRequest request);
    Task<DialogueMessageDto> SendMessageAsync(Guid userId, Guid dialogueId, SendMessageRequest request);
    Task<DialogueAnalysisDto> 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<DialogueDto> 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<DialogueDto>(dialogue);
        dto.InitialMessage = initialMessage;

        return dto;
    }

    public async Task<DialogueMessageDto> 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<DialogueMessageDto>(aiMessage);
    }
}

Entity Framework 配置

// ✅ DbContext 設定
public class DramaLingDbContext : DbContext
{
    public DbSet<User> Users { get; set; }
    public DbSet<Lesson> Lessons { get; set; }
    public DbSet<Dialogue> Dialogues { get; set; }
    public DbSet<DialogueMessage> DialogueMessages { get; set; }
    public DbSet<Vocabulary> Vocabularies { get; set; }

    public DramaLingDbContext(DbContextOptions<DramaLingDbContext> options)
        : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 應用所有配置
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(DramaLingDbContext).Assembly);
    }
}

// ✅ 實體配置範例
public class DialogueConfiguration : IEntityTypeConfiguration<Dialogue>
{
    public void Configure(EntityTypeBuilder<Dialogue> 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<string>();

        // 關聯設定
        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 回應格式統一

// ✅ C# API 回應模型
public class ApiResponse<T>
{
    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<T> Success(T data, string? message = null)
    {
        return new ApiResponse<T>
        {
            Success = true,
            Data = data,
            Message = message,
            Meta = new ApiMeta
            {
                Timestamp = DateTime.UtcNow,
                RequestId = Activity.Current?.Id ?? Guid.NewGuid().ToString()
            }
        };
    }

    public static ApiResponse<T> Error(string code, string message)
    {
        return new ApiResponse<T>
        {
            Success = false,
            Error = new ApiError { Code = code, Message = message }
        };
    }
}
// ✅ Flutter API 回應模型
@freezed
class ApiResponse<T> with _$ApiResponse<T> {
  const factory ApiResponse({
    required bool success,
    T? data,
    String? message,
    ApiError? error,
    ApiMeta? meta,
  }) = _ApiResponse<T>;

  factory ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,
  ) => _$ApiResponseFromJson(json, fromJsonT);
}

@freezed
class ApiError with _$ApiError {
  const factory ApiError({
    required String code,
    required String message,
    Map<String, dynamic>? details,
  }) = _ApiError;

  factory ApiError.fromJson(Map<String, dynamic> json) =>
      _$ApiErrorFromJson(json);
}

錯誤處理機制

// ✅ C# 全域錯誤處理中介軟體
public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;

    public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> 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<object>.Error("VALIDATION_ERROR", validationEx.Message),
            NotFoundException notFoundEx => ApiResponse<object>.Error("NOT_FOUND", notFoundEx.Message),
            ForbiddenException forbiddenEx => ApiResponse<object>.Error("FORBIDDEN", forbiddenEx.Message),
            UnauthorizedAccessException => ApiResponse<object>.Error("UNAUTHORIZED", "Unauthorized access"),
            _ => ApiResponse<object>.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));
    }
}
// ✅ 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<String, dynamic>;
      if (responseData.containsKey('error')) {
        final errorData = responseData['error'] as Map<String, dynamic>;
        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<T> handleApiCall<T>(Future<T> 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 認證整合

// ✅ C# JWT 配置
public static class AuthenticationExtensions
{
    public static IServiceCollection AddJwtAuthentication(
        this IServiceCollection services, 
        IConfiguration configuration)
    {
        var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>()!;

        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;
    }
}
// ✅ Flutter Token 管理
class TokenManager {
  static const String _accessTokenKey = 'access_token';
  static const String _refreshTokenKey = 'refresh_token';

  final FlutterSecureStorage _secureStorage;

  TokenManager(this._secureStorage);

  Future<String?> getAccessToken() async {
    return await _secureStorage.read(key: _accessTokenKey);
  }

  Future<String?> getRefreshToken() async {
    return await _secureStorage.read(key: _refreshTokenKey);
  }

  Future<void> saveTokens(String accessToken, String refreshToken) async {
    await Future.wait([
      _secureStorage.write(key: _accessTokenKey, value: accessToken),
      _secureStorage.write(key: _refreshTokenKey, value: refreshToken),
    ]);
  }

  Future<void> 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 開發環境

# 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 開發環境

<!-- DramaLing.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <!-- Web API -->
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
    
    <!-- Entity Framework -->
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
    
    <!-- 序列化和映射 -->
    <PackageReference Include="AutoMapper" Version="12.0.1" />
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
    
    <!-- 驗證 -->
    <PackageReference Include="FluentValidation" Version="11.8.0" />
    <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
    
    <!-- 日誌 -->
    <PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
    <PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
    
    <!-- 快取 -->
    <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.0" />
    
    <!-- 背景服務 -->
    <PackageReference Include="Hangfire" Version="1.8.6" />
    <PackageReference Include="Hangfire.PostgreSql" Version="1.20.6" />
    
    <!-- 測試 -->
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
  </ItemGroup>
</Project>

待完成任務

高優先級

  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
技術審查: 每月檢討技術選型和架構決策