번호 자동 부여 대응 및 API 공통 처리 보강

This commit is contained in:
JiWoong Sul
2025-10-23 14:02:31 +09:00
parent 09c31b2503
commit 7e933a2dda
55 changed files with 948 additions and 586 deletions

View File

@@ -12,6 +12,7 @@ class ApiClient {
final Dio _dio;
final ApiErrorMapper _errorMapper;
static const _dataKey = 'data';
/// 내부에서 사용하는 Dio 인스턴스
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
@@ -99,4 +100,37 @@ class ApiClient {
throw _errorMapper.map(error);
}
}
/// `{ "data": ... }` 형태의 응답에서 내부 데이터를 추출한다.
///
/// - `data` 키가 존재하면 해당 값을 반환한다.
/// - `data`가 맵 타입이 아니거나 null이면 원본 본문을 그대로 돌려준다.
/// - 최종적으로 맵이 아니면 빈 맵을 반환한다.
Map<String, dynamic> unwrapAsMap(Response<dynamic> response) {
final payload = _maybeUnwrap(response.data);
if (payload is Map<String, dynamic>) {
return payload;
}
final original = response.data;
if (original is Map<String, dynamic>) {
return original;
}
return const <String, dynamic>{};
}
/// 응답 본문에서 envelope을 제거해 반환한다.
///
/// - `{ "data": [...] }`는 내부 리스트를 돌려준다.
/// - `data` 구조가 아니면 원본을 그대로 반환한다.
dynamic unwrap(Response<dynamic> response) => _maybeUnwrap(response.data);
dynamic _maybeUnwrap(dynamic body) {
if (body is Map<String, dynamic> && body.containsKey(_dataKey)) {
final nested = body[_dataKey];
if (nested != null) {
return nested;
}
}
return body;
}
}

View File

@@ -44,6 +44,7 @@ class ApiErrorMapper {
final status = error.response?.statusCode;
final data = error.response?.data;
final message = _resolveMessage(error, data);
final details = _extractDetails(data);
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
@@ -76,7 +77,6 @@ class ApiErrorMapper {
}
if (status != null) {
final details = _extractDetails(data);
switch (status) {
case 400:
return ApiException(
@@ -89,8 +89,9 @@ class ApiErrorMapper {
case 401:
return ApiException(
code: ApiErrorCode.unauthorized,
message: '세션이 만료되었습니다. 다시 로그인해 주세요.',
message: _localizeUnauthorizedMessage(message),
statusCode: status,
details: details,
cause: error,
);
case 403:
@@ -98,7 +99,7 @@ class ApiErrorMapper {
code: ApiErrorCode.forbidden,
message: message,
statusCode: status,
details: _extractDetails(data),
details: details,
cause: error,
);
case 404:
@@ -106,6 +107,7 @@ class ApiErrorMapper {
code: ApiErrorCode.notFound,
message: '요청한 리소스를 찾을 수 없습니다.',
statusCode: status,
details: details,
cause: error,
);
case 409:
@@ -133,6 +135,7 @@ class ApiErrorMapper {
code: ApiErrorCode.unknown,
message: message,
statusCode: status,
details: details,
cause: error,
);
}
@@ -332,4 +335,25 @@ class ApiErrorMapper {
}
return <dynamic>[value];
}
/// 인증 실패 응답 메시지를 한글 안내로 정규화한다.
String _localizeUnauthorizedMessage(String message) {
final trimmed = message.trim();
if (trimmed.isEmpty) {
return '세션이 만료되었습니다. 다시 로그인해 주세요.';
}
final normalized = trimmed.toLowerCase();
switch (normalized) {
case 'invalid credentials':
return '아이디 또는 비밀번호가 올바르지 않습니다.';
case 'account is inactive':
return '비활성 계정입니다. 관리자에게 문의하세요.';
case 'token expired':
return '세션이 만료되었습니다. 다시 로그인해 주세요.';
case 'invalid token':
return '유효하지 않은 토큰입니다. 다시 로그인해 주세요.';
default:
return trimmed;
}
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import '../../features/approvals/history/presentation/pages/approval_history_page.dart';
import '../../features/auth/application/auth_service.dart';
import '../../features/approvals/request/presentation/pages/approval_request_page.dart';
import '../../features/approvals/step/presentation/pages/approval_step_page.dart';
import '../../features/approvals/template/presentation/pages/approval_template_page.dart';
@@ -34,6 +36,18 @@ final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
final appRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: loginRoutePath,
redirect: (context, state) {
final authService = GetIt.I<AuthService>();
final loggedIn = authService.session != null;
final loggingIn = state.uri.path == loginRoutePath;
if (!loggedIn && !loggingIn) {
return loginRoutePath;
}
if (loggedIn && loggingIn) {
return dashboardRoutePath;
}
return null;
},
routes: [
GoRoute(path: '/', redirect: (_, __) => loginRoutePath),
GoRoute(