Initial commit

This commit is contained in:
2026-10-28 15:30:05 +09:00
commit 696e34f403
269 changed files with 33654 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import 'error_lexicon.dart';
/// 정규화된 API 예외 — 화면에서는 [code] 로 분기하고 [message] 를 그대로 노출한다.
///
/// 모든 실패는 반드시 에러 코드를 가진다(코드 없는 에러를 만들 수 없도록 필수 인자).
class ApiException implements Exception {
const ApiException({
required this.code,
required this.message,
this.traceId,
this.statusCode,
});
/// 에러 코드 (예: AUTH_001, RES_001)
final String code;
/// 사용자에게 노출할 한국어 메시지
final String message;
/// 서버 요청 추적 ID — 문의 대응 시 로그 검색 키
final String? traceId;
/// 원본 HTTP 상태 코드 (네트워크 오류 등은 null)
final int? statusCode;
/// 코드만으로 예외를 만든다(메시지는 사전에서 조회).
factory ApiException.fromCode(
String code, {
String? traceId,
int? statusCode,
}) {
return ApiException(
code: code,
message: ErrorLexicon.getMessage(code),
traceId: traceId,
statusCode: statusCode,
);
}
@override
String toString() => 'ApiException($code): $message';
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api_exception.dart';
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
const String kJwtTokenKey = 'jwt_token';
const Duration _kConnectTimeout = Duration(seconds: 15);
const Duration _kReceiveTimeout = Duration(seconds: 15);
class DioClient {
static final DioClient _instance = DioClient._internal();
late Dio _dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
factory DioClient() {
return _instance;
}
DioClient._internal() {
// 백엔드 글로벌 prefix(/api) 와 일치
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: _kConnectTimeout,
receiveTimeout: _kReceiveTimeout,
));
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onResponse: (response, handler) {
final payload = response.data;
if (payload is Map<String, dynamic> &&
payload.containsKey('success')) {
if (payload['success'] == true) {
// Global Response Wrapper: 데이터 Unwrap
response.data = payload['data'];
return handler.next(response);
}
// HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리)
final error = _readErrorObject(payload);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: ApiException.fromCode(
error.code ?? 'BIZ_001',
traceId: error.traceId,
statusCode: response.statusCode,
),
type: DioExceptionType.badResponse,
),
);
}
return handler.next(response);
},
onError: (DioException e, handler) async {
final apiException = await _toApiException(e);
// 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다.
return handler.next(e.copyWith(error: apiException));
},
),
);
}
/// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다.
Future<ApiException> _toApiException(DioException e) async {
final response = e.response;
if (response == null) {
// 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우
return ApiException.fromCode('SYS_001');
}
String? traceId;
String? code;
if (response.data is Map<String, dynamic>) {
final error = _readErrorObject(response.data as Map<String, dynamic>);
code = error.code;
traceId = error.traceId;
}
code ??= _resolveCodeByStatus(response.statusCode);
// 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다.
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) {
await _storage.delete(key: kJwtTokenKey);
code = 'AUTH_002';
}
return ApiException.fromCode(
code,
traceId: traceId,
statusCode: response.statusCode,
);
}
/// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우)
String _resolveCodeByStatus(int? statusCode) {
switch (statusCode) {
case 401:
return 'AUTH_001';
case 403:
return 'AUTH_003';
case 400:
case 422:
return 'VAL_001';
case 404:
return 'RES_001';
case 429:
return 'REQ_001';
default:
return 'SYS_001';
}
}
/// 표준 에러 객체({ code, message, traceId })에서 필요한 값만 꺼낸다.
({String? code, String? traceId}) _readErrorObject(
Map<String, dynamic> payload,
) {
final error = payload['error'];
if (error is! Map<String, dynamic>) return (code: null, traceId: null);
final code = error['code'];
final traceId = error['traceId'];
return (
code: code is String ? code : null,
traceId: traceId is String ? traceId : null,
);
}
Dio get dio => _dio;
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dio_client.dart';
// 전역 Dio Provider — 리포지토리/데이터소스에서 ref.watch(dioProvider) 로 주입받는다.
// 인터셉터(토큰 첨부·응답 언래핑·에러 매핑)가 적용된 DioClient.dio 를 노출한다.
final dioProvider = Provider<Dio>((ref) {
return DioClient().dio;
});
+19
View File
@@ -0,0 +1,19 @@
import 'error_lexicon.g.dart';
/// 에러 코드 → 사용자 노출 메시지 사전.
///
/// 실제 문구는 backend 레지스트리에서 생성된 [kErrorCodeMessages] 가 SSOT 이며,
/// 이 파일에 문구를 직접 추가하지 않는다.
/// (문구 변경: backend/src/common/errors/error-codes.ts 수정 → npm run sync:error-codes)
class ErrorLexicon {
const ErrorLexicon._();
static const Map<String, String> codes = kErrorCodeMessages;
/// 등록된 코드인지 확인한다.
static bool contains(String code) => codes.containsKey(code);
/// 코드에 해당하는 메시지를 반환한다. 미등록 코드는 폴백 메시지를 반환한다.
static String getMessage(String code) =>
codes[code] ?? codes[kFallbackErrorCode]!;
}
+17
View File
@@ -0,0 +1,17 @@
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
const Map<String, String> kErrorCodeMessages = {
'SYS_001': '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
'REQ_001': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
'AUTH_001': '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
};
/// 매핑 실패 시 사용하는 폴백 코드
const String kFallbackErrorCode = 'SYS_001';
+1
View File
@@ -0,0 +1 @@
// Empty directory placeholder
+1
View File
@@ -0,0 +1 @@
// Empty directory placeholder
@@ -0,0 +1,27 @@
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다.
/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체)
class UserModel {
const UserModel({
required this.id,
required this.email,
required this.name,
});
final int id;
final String email;
final String name;
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as int,
email: json['email'] as String,
name: json['name'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
};
}
@@ -0,0 +1,44 @@
import 'package:dio/dio.dart';
import '../models/user_model.dart';
/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다.
///
/// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로
/// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다)
class UserRepository {
const UserRepository(this._dio);
final Dio _dio;
Future<UserModel> getUser(int id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return UserModel.fromJson(response.data!);
}
/// 커서 기반 목록 조회 — 목록 API 는 항상 페이지네이션을 적용한다.
Future<({List<UserModel> items, String? nextCursor, bool hasNext})> getUsers({
int limit = 20,
String? cursor,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/users',
queryParameters: {
'limit': limit,
'cursor': ?cursor,
},
);
final data = response.data!;
final items = (data['items'] as List<dynamic>)
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
.toList();
final pageInfo = data['pageInfo'] as Map<String, dynamic>;
return (
items: items,
nextCursor: pageInfo['nextCursor'] as String?,
hasNext: pageInfo['hasNext'] as bool? ?? false,
);
}
}
@@ -0,0 +1,15 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/api/dio_provider.dart';
import '../../data/models/user_model.dart';
import '../../data/repositories/user_repository.dart';
// 리포지토리 Provider — dioProvider 를 주입받아 생성
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(ref.watch(dioProvider));
});
// 특정 사용자 조회 Provider (family)
// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
final userProvider = FutureProvider.family<UserModel, int>((ref, id) {
return ref.watch(userRepositoryProvider).getUser(id);
});
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> main() async {
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
WidgetsFlutterBinding.ensureInitialized();
try {
await dotenv.load(fileName: '.env');
} catch (_) {
// .env 파일이 없는 경우 무시 (기본값 사용)
}
runApp(
// Riverpod Provider Scope 적용
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TMA App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const HomeView(),
);
}
}
class HomeView extends StatelessWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TMA Global Architecture'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Riverpod & Dio Interceptor Setup Complete.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')),
);
},
child: const Text('Show Global Alert Test'),
),
],
),
),
);
}
}
+1
View File
@@ -0,0 +1 @@
// Empty directory placeholder