feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화
- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리 - approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강 - 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트 - 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
This commit is contained in:
@@ -1,151 +1,84 @@
|
||||
# ApiClient 설계서 (Dio 기반, Superport 스타일)
|
||||
|
||||
본 문서는 Superport 레포 스타일과 동일한 인증/네트워킹 패턴을 본 프로젝트에 적용하기 위한 ApiClient 설계를 정의한다. 실제 구현은 이후 단계에서 진행한다(문서 선정리).
|
||||
## Implementation Snapshot (2025-10-31)
|
||||
- ✅ `ApiClient`/`ApiErrorMapper`/`AuthInterceptor`가 구현되어 모든 원격 저장소가 공통 경로를 사용한다 (`lib/core/network/api_client.dart`, `lib/core/network/api_error.dart`, `lib/core/network/interceptors/auth_interceptor.dart`).
|
||||
- ✅ DI/환경 설정은 `Environment.initialize()` 이후 `lib/injection_container.dart`에서 ApiClient와 TokenStorage, 인터셉터를 등록한다.
|
||||
- ✅ 단위 테스트가 경로·쿼리·에러 매핑·토큰 재발급 동작을 검증한다 (`test/core/network/api_client_test.dart`, `test/core/network/auth_interceptor_test.dart`).
|
||||
- ✅ 문서/코드가 `doc/stock_approval_system_api_v4.md` 계약과 동기화됐으며, 엔드포인트별 Remote Repository 테스트가 `include`·필터 직렬화를 검증한다.
|
||||
|
||||
## 1) 목표
|
||||
- 단일 진입점 ApiClient(Dio 래퍼)로 모든 네트워크 호출 일원화
|
||||
- 환경 변수 기반 BaseURL/타임아웃/로그 레벨 설정
|
||||
- 인증 토큰 주입, 401 자동 처리(토큰 갱신 → 재시도), 에러 매핑 일관화
|
||||
- 목록/단건 표준 응답 구조에 맞춘 헬퍼 제공
|
||||
- 목록/단건 표준 응답 구조에 맞춘 헬퍼 제공 (구현 완료)
|
||||
|
||||
## 2) 의존성(추가 예정)
|
||||
- dio: ^5.x (HTTP 클라이언트)
|
||||
- get_it: ^7.x (DI) — 이미 사용 중
|
||||
- flutter_secure_storage(or web localStorage 대체): 토큰 저장(플랫폼별 분기)
|
||||
- intl: ^0.20.x (기존)
|
||||
- 개발 전용: pretty_dio_logger(선택)
|
||||
## 2) 의존성
|
||||
- `dio: ^5.x`, `get_it: ^7.x`, `flutter_secure_storage`, `intl`, `pretty_dio_logger`(dev) — `pubspec.yaml`에 반영됨.
|
||||
- 테스트: `mocktail`, `flutter_test` (already configured).
|
||||
|
||||
## 3) 환경 변수
|
||||
- API_BASE_URL: 예) https://api.example.com/api/v1
|
||||
- API_CONNECT_TIMEOUT_MS: 예) 15000
|
||||
- API_RECEIVE_TIMEOUT_MS: 예) 30000
|
||||
- LOG_LEVEL: debug|info|warn|error
|
||||
- `API_BASE_URL`, `API_CONNECT_TIMEOUT_MS`, `API_RECEIVE_TIMEOUT_MS`, `LOG_LEVEL`
|
||||
- 로드 순서: `await Environment.initialize()` → `injection_container.dart`에서 `ApiClient` 생성
|
||||
- `.env.*` 파일에 샘플 값이 포함되어 있으며, `FeatureFlags.initialize()` 이전에 호출된다.
|
||||
|
||||
로드 순서: `await Environment.initialize()` → DI에서 ApiClient 생성 시 사용
|
||||
|
||||
## 4) 인증 방식(슈퍼포트와 동일)
|
||||
- 로그인: `POST /auth/login` → `{ data: { token: string, user?: {...} } }`
|
||||
- 요청 헤더: `Authorization: Bearer <token>`
|
||||
- 토큰 저장: 보안 저장소(모바일)/localStorage(웹) 또는 httpOnly 쿠키(백엔드 정책에 따름)
|
||||
- 토큰 갱신(선택): `POST /auth/refresh` → `{ data: { token: string } }`
|
||||
- 401 처리: `AuthInterceptor`가 401 수신 시 자동 갱신 → 원요청 재시도(1회). 갱신 실패 시 로그아웃/세션 초기화 및 로그인 화면 이동
|
||||
## 4) 인증 스택
|
||||
- 로그인/토큰 재발급 플로우는 Superport 백엔드와 동일 (`POST /auth/login`, `POST /auth/refresh`).
|
||||
- `AuthInterceptor`가 저장소에서 토큰을 읽어 Authorization 헤더를 주입하고, 401 발생 시 리프레시 콜백을 호출해 한 번만 재시도한다 (`lib/core/network/interceptors/auth_interceptor.dart:45`).
|
||||
- `TokenStorage`는 플랫폼별 저장소(web/local) 구현을 제공한다 (`lib/core/network/services/token_storage.dart`).
|
||||
|
||||
## 5) 에러 매핑 정책
|
||||
- 400 BAD_REQUEST: 검증 오류 → 필드 에러로 매핑
|
||||
- 404 NOT_FOUND: 리소스 없음
|
||||
- 409 CONFLICT: 유니크 충돌/상태 충돌
|
||||
- 422 UNPROCESSABLE_ENTITY: 비즈니스 규칙 위반(예: 출고 고객 미선택, blocking 전이)
|
||||
- 500+: 서버 오류 → 공통 메시지 + 로그 수집
|
||||
- 표준 포맷: `{ error: { code, message, details? } }` 수용. 비표준 응답은 DioException 메시지로 대체
|
||||
- `ApiErrorMapper`가 `DioException`을 `Failure`로 변환해 코드/메시지를 표준화한다 (`lib/core/network/api_error.dart`).
|
||||
- HTTP 상태별 매핑: 400(검증), 401(세션 만료), 403(권한), 404(리소스 없음), 409(충돌), 422(업무 규칙), 500+(서버 오류).
|
||||
- 테스트 `test/core/network/api_client_test.dart:62`가 매핑 결과를 검증한다.
|
||||
|
||||
## 6) 쿼리 규약/헬퍼
|
||||
- 페이지네이션: `page`, `page_size`
|
||||
- 정렬: `sort`, `order=asc|desc`
|
||||
- 검색: `q`
|
||||
- 증분: `updated_since`
|
||||
- include 확장: `include=lines,customers,approval` 등
|
||||
- 헬퍼: `buildQuery({page, pageSize, q, sort, order, include, filters})`
|
||||
|
||||
## 7) ApiClient 스켈레톤(인터페이스)
|
||||
## 6) 쿼리 헬퍼와 규약
|
||||
- `ApiClient.buildQuery`가 페이지네이션(`page`,`page_size`), 정렬(`sort`,`order`), 검색(`q`), 증분(`updated_since`), include, 맞춤 필터를 직렬화한다 (`lib/core/network/api_client.dart:25`).
|
||||
- `buildPath`가 세그먼트를 안전하게 결합한다 (`lib/core/network/api_client.dart:14`).
|
||||
- 모든 Remote Repository는 해당 헬퍼를 사용하도록 테스트로 강제된다 (예: `test/features/approvals/data/approval_repository_remote_test.dart:42`).
|
||||
|
||||
## 7) ApiClient 인터페이스
|
||||
```dart
|
||||
/// 네트워크 공통 클라이언트 (Dio 래퍼)
|
||||
class ApiClient {
|
||||
// 내부 Dio 인스턴스(외부 사용 금지, 필요한 경우 read-only 게터 제공)
|
||||
final Dio _dio;
|
||||
|
||||
ApiClient({required Dio dio}) : _dio = dio;
|
||||
|
||||
Dio get dio => _dio; // 과도한 사용은 지양하고, 가능하면 아래 헬퍼 사용
|
||||
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<Response<T>> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
Future<Response<T>> patch<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
Future<Response<T>> delete<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
static Map<String, dynamic> buildQuery({...});
|
||||
static String buildPath(Object base, [Iterable<Object?> segments = const []]);
|
||||
}
|
||||
```
|
||||
|
||||
구현 시 기본 옵션
|
||||
- BaseOptions: baseUrl, connectTimeout, receiveTimeout
|
||||
- 공통 헤더: `Accept: application/json`, `Authorization: Bearer <token?>`
|
||||
- Interceptors:
|
||||
- `AuthInterceptor`(요청 전 토큰 주입, 401에서 갱신/재시도)
|
||||
- `LoggingInterceptor`(개발 모드에서만)
|
||||
|
||||
## 8) Interceptor 설계
|
||||
- AuthInterceptor
|
||||
- 요청: 저장된 토큰이 있으면 `Authorization` 헤더 추가
|
||||
- 응답: 401이면 1) 갱신 중 동시성 잠금 2) 갱신 성공 시 대기 중 요청 재시도 3) 실패 시 토큰 삭제/로그아웃
|
||||
- Retry 정책: 재시도는 1회, idempotent GET/HEAD 위주. POST/PATCH는 401 갱신 후 재시도 1회만 허용
|
||||
## 8) 인터셉터 구성
|
||||
- `AuthInterceptor`: 토큰 주입 + 401 재시도, 동시 갱신 방지 큐 적용.
|
||||
- `LoggingInterceptor`: 개발 모드에서만 pretty 출력 (`lib/core/network/interceptors/logging_interceptor.dart`).
|
||||
- `RetryInterceptor`는 필요 시 idempotent 요청 재시도를 담당한다 (`lib/core/network/interceptors/retry_interceptor.dart`).
|
||||
|
||||
## 9) 표준 응답 파서
|
||||
- 목록: `{ items: [...], page, page_size, total }`
|
||||
- 단건: `{ data: {...} }`
|
||||
- 제네릭 파서 유틸 제공: `parseList<T>(res, fromJson)`, `parseItem<T>(res, fromJson)`
|
||||
|
||||
## 10) 샘플 사용 (Repository)
|
||||
- 목록: `{ items, page, page_size, total }` → `PaginatedResult<T>` (`lib/core/common/models/paginated_result.dart`).
|
||||
- 단건: `{ data: {...} }` → `ApiClient.unwrapAsMap/unwrap` 헬퍼가 추출한다 (`lib/core/network/api_client.dart:91`).
|
||||
|
||||
## 10) 사용 예시
|
||||
```dart
|
||||
class VendorRepositoryImpl implements VendorRepository {
|
||||
final ApiClient api;
|
||||
VendorRepositoryImpl(this.api);
|
||||
|
||||
@override
|
||||
Future<Paged<Vendor>> list({int page = 1, int pageSize = 20, String? q}) async {
|
||||
final res = await api.get('/vendors', query: { 'page': page, 'page_size': pageSize, if (q != null) 'q': q });
|
||||
return parseList<Vendor>(res.data, Vendor.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Vendor> create(VendorCreate body) async {
|
||||
final res = await api.post('/vendors', data: body.toJson());
|
||||
return parseItem<Vendor>(res.data, Vendor.fromJson);
|
||||
}
|
||||
}
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.approvals,
|
||||
query: ApiClient.buildQuery(page: page, pageSize: pageSize, include: ['requested_by']),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
```
|
||||
|
||||
## 11) 보안/스토리지
|
||||
- 토큰 저장: 플랫폼별로 적합한 저장소 사용(웹은 localStorage, 모바일은 secure storage)
|
||||
- 민감정보 로깅 금지(토큰/쿠키 마스킹)
|
||||
- CORS/쿠키 기반 인증 사용 시, Dio 요청에 `withCredentials=true` 설정 필요(백엔드 정책에 따름)
|
||||
- 웹: localStorage, 모바일: secure storage — `TokenStorage`가 추상화.
|
||||
- 민감정보 로깅 금지, 개발 모드에서만 `pretty_dio_logger` 활성화.
|
||||
- 쿠키 기반 인증 시 `dio.options.extra['withCredentials']=true`를 사용하도록 확장 가능.
|
||||
|
||||
## 12) 테스트 전략
|
||||
- 위젯/도메인 테스트: 네트워크 의존 제거(리포지토리를 테스트 더블로 대체)
|
||||
- 통합 테스트: 실제 스테이징 API를 사용하여 로그인→호출→401→갱신→재시도 플로우 검증
|
||||
- 단위 테스트: `test/core/network/api_client_test.dart`, `test/core/network/auth_interceptor_test.dart`에서 경로·쿼리·에러·토큰 재시도를 검증.
|
||||
- 기능 테스트: 각 Remote Repository 테스트가 파라미터 직렬화를 검증하고, 통합 테스트(`integration_test/approvals_flow_test.dart`)가 실제 플로우를 검증한다.
|
||||
|
||||
## 13) 구현 순서 요약(체크)
|
||||
- [ ] pubspec에 `dio`(필수), `pretty_dio_logger`(개발) 추가
|
||||
- [ ] `ApiClient`/`AuthInterceptor` 스켈레톤 작성
|
||||
- [ ] `Environment.initialize()` → `get_it` DI에서 ApiClient 생성/주입
|
||||
- [ ] 리포지토리 구현에서 ApiClient 사용으로 통일(직접 Dio 인스턴스화 금지)
|
||||
- [ ] 에러/토큰/재시도 정책 위젯 레벨 연결(토스트/로그아웃)
|
||||
|
||||
참고
|
||||
- Superport 레포: `.env`의 `API_BASE_URL`, `test_api_integration.sh`의 `/auth/login` + Bearer 사용
|
||||
- 본 프로젝트: AGENTS.md의 “Do not use mock data” 및 DI/레이어 경계 정책 준수
|
||||
## 13) 구현 체크리스트
|
||||
- [x] `dio` 및 보조 패키지 의존성을 추가했다.
|
||||
- [x] `ApiClient`/`AuthInterceptor`/`ApiErrorMapper`를 구현하고 테스트했다.
|
||||
- [x] `Environment.initialize()` 이후 DI에서 ApiClient와 인터셉터를 등록한다 (`lib/injection_container.dart:60`).
|
||||
- [x] 모든 Remote Repository가 ApiClient를 사용하도록 마이그레이션했다.
|
||||
- [x] 에러/토큰/재시도 정책을 위젯 및 도메인 테스트에 연결했다.
|
||||
- [x] 문서와 코드가 동기화되었으며, 변경 시 `tool/sync_stock_docs.sh --check`를 사용한다.
|
||||
|
||||
Reference in New Issue
Block a user