diff --git a/.claude/agents/mobile-dev.md b/.claude/agents/mobile-dev.md index 01f92ea..d0796c1 100644 --- a/.claude/agents/mobile-dev.md +++ b/.claude/agents/mobile-dev.md @@ -23,15 +23,16 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill ## 작업 순서 -1. `lib/features/user/` 의 model → repository → provider 수직 슬라이스를 복제한다. -2. 네트워크는 `core/api/dio_client.dart` 의 인터셉터 패턴을 재사용한다. 새 클라이언트를 만들지 않는다. -3. 에러 분기는 `e.error as ApiException` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다. -4. 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` 을 쓴다. +1. 새 기능은 그 모듈 폴더(`lib/features/<모듈>/`)와 그 모듈의 라우트 묶음(`<모듈>_routes.dart`)에 둔다. 새 모듈은 스킬의 "새 모듈 붙이는 법"대로. 모듈끼리 import 하지 않는다. +2. `lib/features/auth/` 의 model → repository → provider 수직 슬라이스를 복제한다(목록 페이지네이션은 `features/user` 예제). +3. 네트워크는 `dioProvider` 의 Dio 를 쓴다(인터셉터 차례 — 스킬 "API 통신"). 새 클라이언트 · 인터셉터 줄을 만들지 않는다. +4. 에러 분기는 `apiErrorCodeOf(e)` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다. +5. 화면은 `docs/design/app/README.md` 와 캔버스 확정본대로 만든다 — 그 전에는 자리 화면(`PlaceholderPage`). 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` · 테마 상수를 쓴다. ## 완료 기준 (실행하고 결과를 보고한다) ```bash -cd flutter && flutter analyze && flutter test +cd flutter && flutter analyze && flutter test && flutter build apk --debug ``` - `flutter/.env` 가 없으면 analyze 가 asset 경고를 낸다 — `.env.example` 복사 여부를 먼저 확인한다. diff --git a/.claude/rules/coding-common.md b/.claude/rules/coding-common.md index 15648e5..2e248cd 100644 --- a/.claude/rules/coding-common.md +++ b/.claude/rules/coding-common.md @@ -14,7 +14,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙 Dart `const` 누락·await 누락, backend 에서 `HttpException` 직접 생성(→ `BusinessException` 사용) - 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/` - 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리 - (반복 사용되는 키 문자열도 상수로: 예 `kJwtTokenKey`, `REQUEST_ID_HEADER`) + (반복 사용되는 키 문자열도 상수로: 예 `kRefreshTokenKey`, `REQUEST_ID_HEADER`) - 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양 — backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`) - **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]]) diff --git a/.claude/rules/error-codes.md b/.claude/rules/error-codes.md index fe93dfb..72b3e2c 100644 --- a/.claude/rules/error-codes.md +++ b/.claude/rules/error-codes.md @@ -50,9 +50,9 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다 서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다. - **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다. - 웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절). + 웹 · 앱은 그 전에 갱신을 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절). - 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다 -- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제) +- 앱: `core/api/auth_interceptor.dart` · `token_refresher.dart` — 웹처럼 갱신(`/auth/app/refresh`)을 한 번 해 보고, 갱신까지 거절됐을 때만 승격한다 (리프레시 토큰 삭제 · 로그인 화면 — `docs/mail/api-contract.md` 1절 "앱 로그인"). 그 요청을 보낸 세션이 이미 끝났으면(세션 세대가 다르다) 알리지 않는다 — 사용자가 스스로 로그아웃했으면 승격하지 않고 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝난 뒤면 `AUTH_002` ## 도메인 코드 추가 diff --git a/.claude/rules/security.md b/.claude/rules/security.md index 050e478..7332d88 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -11,8 +11,8 @@ ## 인증 · 토큰 -- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`) -- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달 +- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 리프레시 토큰만 `flutter_secure_storage`(키: `refresh_token`), 액세스 토큰은 메모리 — 앱 전용 토큰 경로(계약 `docs/mail/api-contract.md` 1절 "앱 로그인"), Android 백업 끔 +- Access Token 만료는 짧게(15분~1시간). Refresh Token 은 웹은 HttpOnly 쿠키로만, 앱은 1절 "앱 로그인"대로(본문으로 받아 보안 저장소에만 — 로그 · 일반 저장소에 두지 않는다) - 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다. 동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게) - 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다. diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 80f79d0..a1b557e 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -3,7 +3,7 @@ name: flutter description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null Safety, feature-first 디렉터리 구조, 작은 위젯 분리, 반응형 UI, Riverpod 상태관리, Dio 인터셉터, flutter_secure_storage 토큰 저장. flutter/ 에서 작업하거나 위젯·프로바이더·모델·API 클라이언트를 만들 때 사용한다. --- -> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다. +> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다. 앱 로그인의 근거는 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 (앱 전용 토큰 경로)", 화면 기준은 `docs/design/app/README.md`, 개발 실행 · 배포 준비 메모는 `flutter/README.md` 다. > 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용. > 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]] > Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조). @@ -12,25 +12,44 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S ## 템플릿 기본 제공 골격 -> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). +> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). +> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 스토어에 올린 뒤에는 못 바꾼다), 패키지 `arasystem_app`. | 제공 항목 | 위치 | 비고 | | --- | --- | --- | -| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 | -| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 | -| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 | +| 앱 뿌리 | `lib/main.dart` · `lib/app.dart` | main: dotenv → 글꼴 라이선스 → 토큰 보관 준비(첫 실행 지우기) → 기기 정보(판 · User-Agent) → `ProviderScope(overrides)`. app: `MaterialApp.router` · 한국어만(`locale: ko` · `GlobalMaterialLocalizations.delegates`) · 전역 `scaffoldMessengerKey` | +| **모듈 목록** | `lib/core/modules/app_modules.dart` | **짝: 웹 `frontend/src/shared/constants/app-modules.ts`** — 같은 id · 이름 · 차례(한쪽을 바꾸면 둘 다). 항목마다 id · 이름 · 아이콘 · 첫 화면 경로(`/`) · 켜짐. 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭이 된다(첫 판: 메일 · 설정) | +| 모듈 라우트 묶음 | `lib/features/<모듈>/<모듈>_routes.dart` → `lib/core/router/module_registry.dart` | 모듈마다 `ModuleRoutes`(`core/modules/module_routes.dart` — 탭 안 라우트 · 알림 id → 경로 · 설정 절) 하나. registry 의 exhaustive switch 가 id → 묶음. 켰는데 묶음이 없으면 라우터를 만들 때 `StateError` | +| 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) | +| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고 서버 logout · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 | +| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 | +| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | +| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | +| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | | ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) | -| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 | -| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 | -| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 | -| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 | -| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 | +| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 | +| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 | +| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) | +| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 | +| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) | +| 푸시 기기 등록 자리 | `lib/core/push/` — `push_registration.dart` · `push_target.dart` | 모듈 공통. 지금은 `NoopPushRegistrar`(서버 `/api/push-devices` 가 생기면 채운다 — 세션 노티파이어가 로그인 뒤 `register` · 로그아웃 전 `unregister` 를 부른다). 알림 데이터에는 **모듈 이름과 id 만**(`{module, id}`) — `locationForPush` 가 모듈의 `locationOfItem` 으로 경로를 고른다 | +| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 | +| 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 | +| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) | > **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다): -> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))` -> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/` -> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리 -> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가 +> - **새 모듈 붙이는 법**(웹 [[vue3]] "새 사내 모듈"에 해당): `core/modules/app_modules.dart` 의 항목을 켜고(`isEnabled`) → `features/<모듈>/<모듈>_routes.dart` 에 `ModuleRoutes`(첫 라우트 경로 = `/<모듈>`)를 만들고 → `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다. 탭 · 주소 · 알림 · 설정 절은 저절로 붙는다. 웹 목록도 함께 본다(짝) +> - **모듈끼리 서로 import 하지 않는다.** 함께 쓰는 것(사람 고르기 · 파일 첨부 · 날짜 표기 등)은 `core/` · `shared/` 에 둔다. 세션(`features/auth`)은 모듈이 아니라 모두가 쓴다. 다른 모듈의 설정 화면에 절을 더할 때는 `ModuleRoutes.settingsSections` 로 내놓기만 한다 +> - **주소는 모듈 이름부터**(`/mail/...` · `/settings/...` · 나중 `/calendar/...`). 깊은 링크 · 푸시도 모듈 이름으로 고른다 +> - **상태관리 · 복제할 패턴**: `features/auth` 의 `data/models`(손으로 쓴 모델) → `data/repositories`(`dioProvider` 의 Dio) → `presentation/providers`(타입을 적은 Riverpod 프로바이더 · 노티파이어)를 복제한다. 목록은 `features/user` 의 커서 페이지 예제. import 는 `package:arasystem_app/...` +> - **화면 글**: 디자인 README 「화면 문구 — 사용자에게 말하듯」. 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`), 로그인 단계는 `core/constants/screen_titles.dart` +> - **모델 생성기**: `freezed` · `json_serializable` · `riverpod_generator` 는 아직 넣지 않았다 — build_runner 가 같은 폴더의 `error_lexicon.g.dart`(자동 생성 사전)를 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고 한 번 돌려 사전이 남는지 확인한다 +> - **권장 패키지(선택)**: `cached_network_image`·`fpdart`·`webview_flutter`(받은 메일) 도입 시 `pubspec.yaml` 추가. 새 패키지는 배포 2주가 지난 판만 ([[security]] "의존성") + +### Flutter 판 + +**개발 PC 와 CI 를 같은 판으로 고정한다 — 지금 `3.41.9` stable (Dart 3.11.5).** 함께 고치는 곳: `.github/workflows/ci-flutter.yml` 의 `flutter-version` · `flutter/pubspec.yaml` 의 `environment.sdk` 와 `intl`(`flutter_localizations` 가 SDK 마다 정확한 판으로 고정한다 — 지금 `0.20.2`, 새 SDK 의 `packages/flutter_localizations/pubspec.yaml` 값으로) · `flutter/README.md` · [[docker]] 이미지 버전 표의 "Flutter SDK" 줄. CI 는 `flutter pub get --enforce-lockfile` 로 lock 그대로 받는다. +개발 PC 의 Flutter 는 다른 프로젝트도 함께 써서 그 판에 맞췄다 — 3.47 로 올리는 것은 사용자가 정한다(올리면 개발 PC · CI 를 함께, 2주 규칙). --- @@ -47,26 +66,33 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S ``` lib/ ├── main.dart -├── app.dart ← MaterialApp / 라우터 설정 -├── core/ -│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등) -│ ├── theme/ ← 앱 테마 정의 -│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터 -│ ├── storage/ ← flutter_secure_storage 래퍼 (선택) -│ └── utils/ ← 순수 유틸 함수 +├── app.dart ← MaterialApp.router · 한국어 · 테마 · 전역 알림 자리 +├── core/ ← 모듈 공통 (모듈은 core · shared 만 함께 쓴다) +│ ├── api/ ← Dio(dio_client) · 인터셉터 셋 · 토큰 갱신 · 에러 사전 · User-Agent +│ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판 +│ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes) +│ ├── push/ ← 푸시 기기 등록 자리 · 알림 → 경로 +│ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry) +│ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시 +│ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴 +│ └── utils/ ← 순수 유틸 함수 (시계 등) ├── features/ -│ └── user/ -│ ├── data/ -│ │ ├── models/ ← JSON 직렬화 모델 -│ │ └── repositories/ ← API 호출 구현체 -│ ├── domain/ -│ │ └── entities/ ← 순수 비즈니스 엔티티 -│ └── presentation/ -│ ├── pages/ ← 화면 단위 위젯 -│ ├── widgets/ ← 재사용 위젯 -│ └── providers/ ← 상태 관리 +│ ├── auth/ ← 세션 · 앱 로그인 (모듈이 아니다 — 모든 모듈이 쓴다) +│ ├── mail/ ← 모듈 — 주소 /mail/... +│ │ ├── mail_routes.dart ← 이 모듈의 라우트 묶음 (core/router 가 모은다) +│ │ ├── data/ +│ │ │ ├── models/ ← JSON 모델 (손으로 — 위 "모델 생성기") +│ │ │ └── repositories/ ← API 호출 구현체 +│ │ ├── domain/ +│ │ │ └── entities/ ← 순수 비즈니스 엔티티 +│ │ └── presentation/ +│ │ ├── pages/ ← 화면 단위 위젯 +│ │ ├── widgets/ ← 재사용 위젯 +│ │ └── providers/ ← 상태 관리 +│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다) +│ └── user/ ← 예제(참조용) └── shared/ - └── widgets/ ← 앱 공통 위젯 (생성 완료) + └── widgets/ ← 앱 공통 위젯 (자리 화면 등) ``` --- @@ -133,103 +159,109 @@ Expanded(flex: 1, child: RightPanel()), ## 상태 관리 -```dart -// Riverpod 예시 (권장) -@riverpod -Future fetchUser(FetchUserRef ref, int id) async { - final repo = ref.watch(userRepositoryProvider); - return repo.getUser(id); -} +생성기(`riverpod_generator` · `@riverpod`)는 쓰지 않는다 — 프로바이더는 타입을 적어 손으로 만든다(위 "모델 생성기"). + +```dart +// 프로바이더 — 타입을 적는다 (features/user 예제 · features/auth 와 같은 꼴) +final Provider userRepositoryProvider = Provider( + (Ref ref) => UserRepository(ref.watch(dioProvider)), +); + +final FutureProviderFamily userProvider = + FutureProvider.family( + (Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id), + ); + +// 위젯에서 사용 — 실패는 [다시 시도](ref.invalidate)로 다시 한다(저절로 다시 하지 않는다) +class UserTile extends ConsumerWidget { + const UserTile({required this.userId, super.key}); + + final int userId; -// 위젯에서 사용 -class UserWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final userAsync = ref.watch(fetchUserProvider(userId)); - return userAsync.when( - data: (user) => Text(user.name), + final AsyncValue user = ref.watch(userProvider(userId)); + return user.when( + data: (UserModel value) => Text(value.name), loading: () => const CircularProgressIndicator(), - error: (e, _) => const ErrorWidget(), + error: (Object error, StackTrace _) => const SizedBox.shrink(), ); } } ``` -- **Riverpod** 권장 (Provider는 레거시로 간주) +- **Riverpod 3** — 세션처럼 바꾸는 일이 있는 상태는 `AsyncNotifier`/`Notifier`(`sessionProvider` 가 본보기) +- **저절로 다시 하기는 끈다**: Riverpod 3 은 프로바이더의 `retry` 를 주지 않으면 실패한 프로바이더를 기본으로 10번까지 다시 만든다(요청을 다시 보낸다). + 앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 `noAutomaticRetry`(`core/utils/provider_retry.dart`)를 건다 — 다시 하기는 화면의 [다시 시도]가 한다 - `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용 +- 실패를 상태(`AsyncValue`)에 두어도 된다 — 비밀이 든 요청(`ApiOptions.secret` — 로그인 · 코드 · 비밀번호 단계 · logout)의 실패 예외는 본문을 비워 던지고, 인증 인터셉터가 내보내는 모든 실패 예외는 Bearer 를 지운 채 온다 --- + ## API 통신 (Dio + 인터셉터) -실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다. +앱 로그인은 앱 전용 토큰 경로다(계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — `POST /api/auth/app/login` · `/app/refresh` · `/app/logout`, 보호 API 는 `Authorization: Bearer`). +세션 규칙(1회용 회전 · 30초 유예 · 끊은 세션 바로 막기 · 제한된 세션)은 웹과 같고 토큰을 나르는 길만 다르다. + +``` +화면 · 저장소 ── ref.watch(dioProvider) + │ + ▼ 인터셉터 줄 (core/api/dio_client.dart) +ResponseInterceptor {success,data}→data · 실패는 DioException.error = ApiException +AuthInterceptor QueuedInterceptor — Bearer · 미리 갱신 · 401 → TokenRefresher(한 번에 하나) → 한 번 다시 · AUTH_010 → 다시 맞추기(renewed 면 한 번 다시) +ErrorNotifyInterceptor 맨 끝 — silent 가 아니면 ErrorNotifier(스낵바 · 같은 코드 3초) + │ 갱신 · 다시 보내기는 언래핑만 있는 안쪽 Dio 로 (같은 줄을 다시 타지 않게) + ▼ +SessionEventBus ──▶ 세션 노티파이어 (세션 끝 → 로그인 화면 · AUTH_010 → 세션 확인(안쪽 Dio) · 갱신 → RestrictionCheck 로 답 · 갱신 응답의 제한) +``` ```dart -// core/api/dio_client.dart (실제 구조 요약) -class DioClient { - DioClient._internal() { - // flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값 - final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api'; - _dio = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), - )); - _dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) async { - // jwt_token 읽어 Authorization 헤더 첨부 - final token = await _storage.read(key: kJwtTokenKey); - if (token != null) options.headers['Authorization'] = 'Bearer $token'; - handler.next(options); - }, - onResponse: (response, handler) { - // success==true → data 언래핑, false → ApiException 으로 reject - handler.next(response); - }, - onError: (err, handler) async { - // 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조) - // 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다. - handler.next(err.copyWith(error: await _toApiException(err))); - }, - )); - } +// 저장소 — 파싱만 한다. 문구를 만들지 않는다 +final Response> response = await _dio.get>( + '/mail-state', + options: ApiOptions.silent(), // 요청마다 알림 끄기 — Options(extra: {kSilentExtraKey: true}) 와 같다 +); + +// 화면 — code 로 가른다 +try { + await ref.read(sessionProvider.notifier).signIn(email: email, password: password); +} on DioException catch (e) { + if (apiErrorCodeOf(e) == 'AUTH_004') showTopError(); } ``` -> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다. +- 인터셉터를 더할 때는 위 차례를 지킨다 — 알림은 늘 맨 끝(갱신 · 다시 보내기가 끝난 결과만 알린다). +- 세션 노티파이어를 인터셉터가 직접 부르지 않는다(노티파이어가 Dio 를 쓰므로 서로 물린다) — `SessionEventBus` 로 알린다. +- 갱신을 새로 부르는 곳(제한을 푼 직후 등)도 `TokenRefresher`(`AuthRepository.refresh`)를 지난다 — 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이다. --- ## 보안 (토큰 저장) ```dart -// flutter_secure_storage 사용 -const storage = FlutterSecureStorage(); - -// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token') -await storage.write(key: kJwtTokenKey, value: token); - -// 읽기 -final token = await storage.read(key: kJwtTokenKey); - -// 삭제 (로그아웃) -await storage.deleteAll(); +// 토큰은 TokenStore 로만 다룬다 (core/storage/token_store.dart) +await tokenStore.save(tokens); // 리프레시 토큰 → 보안 저장소(kRefreshTokenKey), 액세스 토큰 → 메모리 +final String? refresh = await tokenStore.readRefreshToken(); +await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser 없이 — 늦은 401 알림을 가른다) ``` -- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용 -- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]]) -- `SharedPreferences`에 토큰 저장 **금지** +- 리프레시 토큰 · 민감 정보는 `flutter_secure_storage`(`SecureStore`)에만, 액세스 토큰은 메모리에만 둔다 +- 키 문자열을 여기저기 적지 말고 `kRefreshTokenKey` 상수를 쓴다 ([[coding-common]]) +- `SharedPreferences` 에 토큰 저장 **금지** — 첫 실행 표시(참 · 거짓 하나)만 둔다 +- 로그인 때 넣은 비밀번호는 Riverpod 상태에 두지 않는다(`LoginSecret` — 메모리 · 10분) +- 비밀번호 · 토큰은 로그 · 예외 문장에 남기지 않는다 — `LogInterceptor` 를 넣지 않고, `AuthTokens.toString()` 은 값을 가린다 - 추가 보안 규칙은 [[security]] 참조. --- ## 성능 & 최신 트렌드 추가 규칙 -- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델) +- **모델 직렬화**: 지금은 손으로 쓴 불변 모델(`final` 칸 + `fromJson`/`tryParse` — `features/auth/data/models`). `json_serializable` · `freezed` 는 위 "모델 생성기" 주의를 지킨 뒤에 넣는다 - **이미지**: `cached_network_image`로 네트워크 이미지 캐싱 - **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지) - **에러 핸들링**: `Either` 패턴 고려 (`fpdart` 패키지) -- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원) +- **라우팅**: `go_router` (도입 완료 — `core/router/`). 새 화면은 그 모듈의 라우트 묶음에 더한다 - **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리 --- @@ -241,15 +273,17 @@ await storage.deleteAll(); - [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용. - [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지. - [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`. -- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다. -- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다. +- [ ] **API**: `dioProvider` 의 Dio 를 쓴다(새 클라이언트 · 인터셉터 차례를 만들지 않는다). 리포지토리에서 try/catch 로 메시지를 만들지 않는다. 화면이 칸에 보이는 실패는 `silent` 로 끈다. +- [ ] **에러 분기**: `apiErrorCodeOf(e)`(또는 `e.error as ApiException`)의 **`code` 로 분기**한다. 문구는 사전에서만 온다. `AUTH_010` 은 화면이 다루지 않는다(세션이 그 단계로 옮긴다). - [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`. - [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]). -- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지. +- [ ] **토큰**: `TokenStore` 로만(리프레시 = `flutter_secure_storage` 의 `kRefreshTokenKey`, 액세스 = 메모리). `SharedPreferences` 저장 금지. - [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공). -- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료). -- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`. -- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. +- [ ] **모듈**: 새 기능은 그 모듈 폴더(`features/<모듈>/`)와 라우트 묶음 안에. 모듈끼리 import 하지 않는다 — 함께 쓰는 것은 `core/` · `shared/`. 모듈 목록을 바꾸면 웹 `app-modules.ts` 도(짝). +- [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상. +- [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`). +- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판은 `flutter build apk` 로 한 번 본다(release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다). + Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다. CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). 다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다. - [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).