24 KiB
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>
待完成任務
高優先級
- 完成 Flutter 專案初始化和基礎架構設置
- 建立 .NET Core Web API 專案和 Entity Framework 配置
- 實現 JWT 認證機制的前後端整合
- 設置 API 文檔 (Swagger) 和前端 API 客戶端生成
中優先級
- 建立前後端的錯誤處理和日誌機制
- 實現離線同步和資料快取策略
- 設置自動化測試框架 (Flutter + .NET Core)
- 建立 CI/CD pipeline 支援 Flutter 和 .NET 部署
低優先級
- 研究 Flutter Web 版本的可行性
- 探索 .NET MAUI 作為 Flutter 的替代方案
- 建立效能監控和分析工具整合
- 設計微服務架構的擴展計劃
最後更新: 2024年9月5日
負責人: 前端Lead + 後端Lead
技術審查: 每月檢討技術選型和架構決策