From 00660c7d62dd98bd08f638015f23294a0a498e06 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 18:14:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=C2=B7=ED=94=84=EB=A1=9C=ED=95=84=C2=B7=ED=83=88=ED=87=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B2=84=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인: 소셜 앱 키가 생기기 전까지 기기 키로 개발용 로그인(POST /auth/dev-sessions)을 한다. 기기 키는 로그인 수단이라 secure storage 에 둔다. SDK 를 붙이면 signIn 만 /auth/sessions 로 바꾼다 - 로그아웃은 리프레시 토큰을 폐기하고(실패해도 기기에서는 로그아웃), 탈퇴는 서버에서 회원을 지운 뒤 기기 기록을 정리한다 — 서버 탈퇴가 실패하면 기기 기록도 그대로 둔다 - 서버가 세션을 끊으면(AUTH_002) 진입 상태가 로그인 화면으로 보낸다 - 프로필: GET/PATCH /users/me. 마지막 값을 기기에 두어 앱을 열자마자 보여 주고, 사진은 저장할 때 올린다 - 회원이 바뀌면(로그인·로그아웃) 기록을 다시 읽도록 sessionEpochProvider 를 두었다 - 화면은 succeeded() 로 성공 여부만 보고 멈춘다 (실패 알림은 인터셉터가 이미 띄웠다) - 템플릿 예제 features/user(없는 /users/:id 를 부르던 코드)를 지웠다 Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/api/api_guard.dart | 18 ++ flutter/lib/core/api/session_store.dart | 21 +++ .../repositories/api_auth_repository.dart | 96 ++++++++++ .../data/repositories/auth_repository.dart | 28 ++- .../data/repositories/device_key_store.dart | 51 ++++++ .../providers/entry_provider.dart | 13 ++ .../mypage/data/profile_repository.dart | 156 ++++++++++++++-- .../providers/account_actions.dart | 16 +- .../widgets/profile_edit_sheet.dart | 7 +- .../features/user/data/models/user_model.dart | 27 --- .../data/repositories/user_repository.dart | 44 ----- .../presentation/providers/user_provider.dart | 15 -- flutter/test/api_account_test.dart | 171 ++++++++++++++++++ flutter/test/support/fake_api.dart | 108 +++++++++++ 14 files changed, 662 insertions(+), 109 deletions(-) create mode 100644 flutter/lib/core/api/api_guard.dart create mode 100644 flutter/lib/features/auth/data/repositories/api_auth_repository.dart create mode 100644 flutter/lib/features/auth/data/repositories/device_key_store.dart delete mode 100644 flutter/lib/features/user/data/models/user_model.dart delete mode 100644 flutter/lib/features/user/data/repositories/user_repository.dart delete mode 100644 flutter/lib/features/user/presentation/providers/user_provider.dart create mode 100644 flutter/test/api_account_test.dart create mode 100644 flutter/test/support/fake_api.dart diff --git a/flutter/lib/core/api/api_guard.dart b/flutter/lib/core/api/api_guard.dart new file mode 100644 index 0000000..0c759e3 --- /dev/null +++ b/flutter/lib/core/api/api_guard.dart @@ -0,0 +1,18 @@ +import 'package:dio/dio.dart'; + +import 'api_exception.dart'; + +/// 서버 요청을 기다리고 성공했는지만 돌려준다. +/// 실패 알림은 인터셉터(토스트)가 이미 띄웠으므로 화면은 메시지를 새로 만들지 않고 멈추기만 한다 (.claude/rules/error-codes.md). +/// if (!await succeeded(notifier.save(input))) return; +Future succeeded(Future action) async { + try { + await action; + return true; + } on DioException catch (e) { + if (e.error is ApiException) return false; + rethrow; + } on ApiException { + return false; + } +} diff --git a/flutter/lib/core/api/session_store.dart b/flutter/lib/core/api/session_store.dart index 9fa3160..f310b45 100644 --- a/flutter/lib/core/api/session_store.dart +++ b/flutter/lib/core/api/session_store.dart @@ -112,3 +112,24 @@ class SessionStore extends ChangeNotifier { /// main() 에서 보관한 토큰을 읽은 세션으로 override 한다. 기본값은 빈 메모리 세션(테스트). final sessionStoreProvider = Provider((ref) => SessionStore(MemoryTokenVault())); + +/// 로그인한 회원이 바뀐 횟수 — 로그인·로그아웃·세션 만료 때 1씩 오른다 (토큰 갱신은 같은 회원이라 그대로). +/// 서버 기록을 들고 있는 알림기는 build 에서 이 값을 watch 해, 회원이 바뀌면 다시 불러온다. +class SessionEpochNotifier extends Notifier { + @override + int build() { + final session = ref.watch(sessionStoreProvider); + var signedIn = session.hasSession; + void onChange() { + if (session.hasSession == signedIn) return; + signedIn = session.hasSession; + state = state + 1; + } + + session.addListener(onChange); + ref.onDispose(() => session.removeListener(onChange)); + return 0; + } +} + +final sessionEpochProvider = NotifierProvider(SessionEpochNotifier.new); diff --git a/flutter/lib/features/auth/data/repositories/api_auth_repository.dart b/flutter/lib/features/auth/data/repositories/api_auth_repository.dart new file mode 100644 index 0000000..946394b --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/api_auth_repository.dart @@ -0,0 +1,96 @@ +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/api/dio_client.dart'; +import 'package:tma_app/core/api/session_store.dart'; +import 'package:tma_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:tma_app/features/auth/data/repositories/device_key_store.dart'; +import 'package:tma_app/features/auth/domain/social_provider.dart'; + +/// 로그인한 소셜 계정 종류·이름 — 화면에 바로 쓰려고 기기에 함께 둔다 (토큰은 secure storage) +const String _kProviderKey = 'auth.provider'; +const String _kNicknameKey = 'auth.nickname'; + +/// 서버 인증 경로 (backend AuthController) +const String _kDevSessionsPath = '/auth/dev-sessions'; +const String _kRevocationPath = '/auth/token-revocation'; +const String _kMePath = '/users/me'; + +/// 서버 로그인 — 토큰은 [SessionStore] 가 들고, 소셜 계정 종류·이름은 기기에 둔다. +/// +/// 지금은 소셜 앱 키가 없어 **개발용 로그인**(기기 키)으로 가입·로그인한다 (운영 서버는 이 경로를 닫는다). +/// 소셜 SDK 를 붙이면 SDK 로 받은 토큰을 `POST /auth/sessions {provider, token}` 으로 보내도록 +/// [signIn] 만 바꾸면 된다 — 응답 모양(토큰 + user)은 같다. +class ApiAuthRepository implements AuthRepository { + ApiAuthRepository({ + required Dio dio, + required SessionStore session, + required SharedPreferences prefs, + required DeviceKeyStore deviceKeys, + }) : _dio = dio, + _session = session, + _prefs = prefs, + _deviceKeys = deviceKeys; + + final Dio _dio; + final SessionStore _session; + final SharedPreferences _prefs; + final DeviceKeyStore _deviceKeys; + + @override + bool get hasSession => _session.hasSession; + + @override + String? get nickname => _session.hasSession ? _prefs.getString(_kNicknameKey) : null; + + @override + SocialProvider? get provider => + _session.hasSession ? SocialProvider.values.asNameMap()[_prefs.getString(_kProviderKey)] : null; + + @override + Future signIn(SocialProvider provider) async { + final deviceKey = await _deviceKeys.readOrCreate(); + final response = await _dio.post>( + _kDevSessionsPath, + data: {'provider': provider.name, 'deviceKey': deviceKey}, + ); + final data = response.data!; + final user = data['user']! as Map; + + await _prefs.setString(_kProviderKey, (user['provider'] as String?) ?? provider.name); + final name = user['nickname'] as String?; + name == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, name); + // 세션을 마지막에 저장한다 — 저장하는 순간 진입 상태가 다음 화면으로 넘어간다 + await _session.save(AuthTokens.fromJson(data)); + } + + /// 로그아웃 — 서버의 리프레시 토큰을 폐기하고 세션을 지운다. + /// 폐기 요청이 실패해도(네트워크 등) 이 기기에서는 로그아웃한다 — 토큰은 어차피 만료된다. + @override + Future signOut() async { + final refresh = _session.tokens?.refreshToken; + if (refresh != null) { + try { + await _dio.post( + _kRevocationPath, + data: {'refreshToken': refresh}, + options: Options(extra: {kSilentExtra: true}), + ); + } on DioException { + // 기기에서는 그대로 로그아웃한다 + } + } + await _forget(); + } + + @override + Future deleteAccount() async { + await _dio.delete(_kMePath); + await _forget(); + } + + Future _forget() async { + await _prefs.remove(_kProviderKey); + await _prefs.remove(_kNicknameKey); + await _session.clear(); + } +} diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index 893a5ad..7e7df9d 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -1,11 +1,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/api/dio_provider.dart'; +import 'package:tma_app/core/api/session_store.dart'; +import 'package:tma_app/core/config/app_config.dart'; import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart'; +import 'package:tma_app/features/auth/data/repositories/device_key_store.dart'; import 'package:tma_app/features/auth/domain/social_provider.dart'; /// 로그인 세션 관리 계약. -/// 실제 구현은 소셜 SDK 로 받은 토큰을 백엔드 인증 API 에 넘기고, -/// 발급받은 JWT 를 flutter_secure_storage(kJwtTokenKey)에 저장한다. +/// 서버 구현(ApiAuthRepository)은 로그인으로 받은 토큰을 flutter_secure_storage(kJwtTokenKey)에 저장한다. abstract interface class AuthRepository { /// 저장된 세션이 있는지 (앱 시작 시 진입 화면 결정에 쓴다) bool get hasSession; @@ -19,12 +23,14 @@ abstract interface class AuthRepository { Future signIn(SocialProvider provider); Future signOut(); + + /// 회원탈퇴 — 서버의 회원과 모든 기록을 지우고 세션을 끝낸다 (기기 저장 모드는 할 일이 없다) + Future deleteAccount(); } const String _kDemoSessionKey = 'auth.demoSession'; -/// 임시 구현 — 백엔드 인증 API 와 소셜 SDK(앱 키 필요)가 준비되기 전까지 -/// 버튼을 누르면 곧바로 로그인된 것으로 처리한다. 토큰은 만들지 않는다. +/// 기기 저장 모드 — 버튼을 누르면 곧바로 로그인된 것으로 처리한다. 토큰은 만들지 않는다. class DemoAuthRepository implements AuthRepository { DemoAuthRepository(this._prefs); @@ -50,8 +56,20 @@ class DemoAuthRepository implements AuthRepository { Future signOut() async { await _prefs.remove(_kDemoSessionKey); } + + @override + Future deleteAccount() async {} } final authRepositoryProvider = Provider((ref) { - return DemoAuthRepository(ref.watch(sharedPreferencesProvider)); + final prefs = ref.watch(sharedPreferencesProvider); + return switch (ref.watch(dataModeProvider)) { + DataMode.local => DemoAuthRepository(prefs), + DataMode.api => ApiAuthRepository( + dio: ref.watch(dioProvider), + session: ref.watch(sessionStoreProvider), + prefs: prefs, + deviceKeys: ref.watch(deviceKeyStoreProvider), + ), + }; }); diff --git a/flutter/lib/features/auth/data/repositories/device_key_store.dart b/flutter/lib/features/auth/data/repositories/device_key_store.dart new file mode 100644 index 0000000..1539ff2 --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/device_key_store.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// 기기 키 저장 키 — 개발용 로그인의 계정 구분값 +const String kDeviceKeyKey = 'device_key'; + +/// 기기 키 길이 (바이트) — base64url 로 32자. 서버는 16~128자를 받는다 +const int _kDeviceKeyBytes = 24; + +/// 개발용 로그인의 기기 키 — 소셜 앱 키가 준비되기 전, 소셜 계정 대신 이 기기를 한 회원으로 본다. +/// 같은 키로 다시 로그인하면 같은 회원이므로, 로그인 수단처럼 secure storage 에 둔다. +abstract interface class DeviceKeyStore { + /// 저장된 키 — 없으면 새로 만들어 저장한다 + Future readOrCreate(); +} + +class SecureDeviceKeyStore implements DeviceKeyStore { + const SecureDeviceKeyStore([this._storage = const FlutterSecureStorage()]); + + final FlutterSecureStorage _storage; + + @override + Future readOrCreate() async { + final saved = await _storage.read(key: kDeviceKeyKey); + if (saved != null) return saved; + final key = newDeviceKey(Random.secure()); + await _storage.write(key: kDeviceKeyKey, value: key); + return key; + } +} + +/// 테스트용 — 정해진 키를 돌려준다 +class FixedDeviceKeyStore implements DeviceKeyStore { + const FixedDeviceKeyStore(this.key); + + final String key; + + @override + Future readOrCreate() async => key; +} + +/// 추측할 수 없는 기기 키 (base64url, 패딩 없음) +String newDeviceKey(Random random) { + final bytes = List.generate(_kDeviceKeyBytes, (_) => random.nextInt(256)); + return base64Url.encode(bytes).replaceAll('=', ''); +} + +final deviceKeyStoreProvider = Provider((ref) => const SecureDeviceKeyStore()); diff --git a/flutter/lib/features/entry/presentation/providers/entry_provider.dart b/flutter/lib/features/entry/presentation/providers/entry_provider.dart index 4a8b561..c085cf2 100644 --- a/flutter/lib/features/entry/presentation/providers/entry_provider.dart +++ b/flutter/lib/features/entry/presentation/providers/entry_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/session_store.dart'; import 'package:tma_app/features/auth/data/repositories/auth_repository.dart'; import 'package:tma_app/features/auth/domain/social_provider.dart'; import 'package:tma_app/features/entry/data/entry_repository.dart'; @@ -11,6 +12,18 @@ class EntryNotifier extends Notifier { @override EntryState build() { + // 서버가 세션을 끊으면(토큰 갱신 거절 — AUTH_002) 로그인 화면으로 돌아간다. + // 안내 토스트는 인터셉터가 이미 띄웠다. 로그아웃처럼 인트로를 거치지 않는다. + final session = ref.watch(sessionStoreProvider); + void onSession() { + if (session.expired && state.signedIn) { + state = state.copyWith(signedIn: false, introPassed: true); + } + } + + session.addListener(onSession); + ref.onDispose(() => session.removeListener(onSession)); + return EntryState( introPassed: false, signedIn: ref.watch(authRepositoryProvider).hasSession, diff --git a/flutter/lib/features/mypage/data/profile_repository.dart b/flutter/lib/features/mypage/data/profile_repository.dart index 9597af3..5d5739b 100644 --- a/flutter/lib/features/mypage/data/profile_repository.dart +++ b/flutter/lib/features/mypage/data/profile_repository.dart @@ -1,44 +1,176 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/api/api_guard.dart'; +import 'package:tma_app/core/api/dio_provider.dart'; +import 'package:tma_app/core/api/file_uploader.dart'; +import 'package:tma_app/core/api/server_file.dart'; +import 'package:tma_app/core/api/session_store.dart'; +import 'package:tma_app/core/config/app_config.dart'; import 'package:tma_app/core/storage/preferences_provider.dart'; const String _kNicknameKey = 'profile.nickname'; const String _kPhotoKey = 'profile.photo'; +/// 서버 모드에서 마지막으로 받은 내 프로필 — 앱을 열자마자 보여 주려고 기기에 둔다 +const String _kApiNicknameKey = 'profile.api.nickname'; +const String _kApiPhotoKey = 'profile.api.photo'; + +/// 내 정보 경로 (backend UserController) +const String _kMePath = '/users/me'; + /// 내 프로필 — 앱에서 바꾼 닉네임·사진 (없으면 소셜 계정 값 / 기본값을 쓴다) +@immutable class UserProfile { const UserProfile({this.nickname, this.photoPath}); final String? nickname; + + /// 기기 사진 경로 또는 서버 파일(`server-file:`) final String? photoPath; + + @override + bool operator ==(Object other) => + other is UserProfile && other.nickname == nickname && other.photoPath == photoPath; + + @override + int get hashCode => Object.hash(nickname, photoPath); } -/// 프로필 저장 — 서버 회원 API 가 생기면 그쪽으로 옮긴다 -class ProfileRepository { - ProfileRepository(this._prefs); +/// 프로필 저장소 계약 +abstract interface class ProfileRepository { + /// 화면에 바로 쓰는 값 — 기기에 둔 값 (서버 모드는 마지막으로 받은 값) + UserProfile load(); + + /// 최신 값 — 서버 모드는 서버에서 받는다 + Future fetch(); + + /// 저장하고 저장된 값을 돌려준다 + Future save(UserProfile profile); + + /// 로그아웃할 때 — 서버 모드는 기기에 둔 프로필을 지운다 (다음에 로그인할 회원에게 보이지 않게) + Future forget(); +} + +Future _writeOrRemove(SharedPreferences prefs, String key, String? value) => + value == null ? prefs.remove(key) : prefs.setString(key, value); + +/// 기기 저장 모드 — 프로필을 이 기기에만 둔다 +class LocalProfileRepository implements ProfileRepository { + LocalProfileRepository(this._prefs); final SharedPreferences _prefs; + @override UserProfile load() => UserProfile( nickname: _prefs.getString(_kNicknameKey), photoPath: _prefs.getString(_kPhotoKey), ); - Future save(UserProfile profile) async { - final nickname = profile.nickname; - final photo = profile.photoPath; - nickname == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, nickname); - photo == null ? await _prefs.remove(_kPhotoKey) : await _prefs.setString(_kPhotoKey, photo); + @override + Future fetch() async => load(); + + @override + Future save(UserProfile profile) async { + await _writeOrRemove(_prefs, _kNicknameKey, profile.nickname); + await _writeOrRemove(_prefs, _kPhotoKey, profile.photoPath); + return profile; + } + + /// 로그아웃해도 기록은 기기에 남는다 + @override + Future forget() async {} +} + +/// 서버 모드 — GET/PATCH /users/me. 사진은 저장할 때 올린다 +class ApiProfileRepository implements ProfileRepository { + ApiProfileRepository({ + required Dio dio, + required SessionStore session, + required FileUploader uploader, + required SharedPreferences prefs, + }) : _dio = dio, + _session = session, + _uploader = uploader, + _prefs = prefs; + + final Dio _dio; + final SessionStore _session; + final FileUploader _uploader; + final SharedPreferences _prefs; + + @override + UserProfile load() => UserProfile( + nickname: _prefs.getString(_kApiNicknameKey), + photoPath: _prefs.getString(_kApiPhotoKey), + ); + + @override + Future fetch() async { + // 로그인 전에는 부르지 않는다 (로그인 화면에서 "로그인이 필요" 알림이 뜨지 않게) + if (!_session.hasSession) return load(); + final response = await _dio.get>(_kMePath); + return _remember(response.data!); + } + + @override + Future save(UserProfile profile) async { + final photoFileId = await _uploader.ensureUploaded(profile.photoPath); + final response = await _dio.patch>( + _kMePath, + data: {'nickname': ?profile.nickname, 'photoFileId': photoFileId}, + ); + return _remember(response.data!); + } + + @override + Future forget() async { + await _prefs.remove(_kApiNicknameKey); + await _prefs.remove(_kApiPhotoKey); + } + + Future _remember(Map me) async { + final profile = UserProfile( + nickname: me['nickname'] as String?, + photoPath: serverFileRefOrNull(me['photoFileId'] as String?), + ); + await _writeOrRemove(_prefs, _kApiNicknameKey, profile.nickname); + await _writeOrRemove(_prefs, _kApiPhotoKey, profile.photoPath); + return profile; } } final profileRepositoryProvider = Provider((ref) { - return ProfileRepository(ref.watch(sharedPreferencesProvider)); + final prefs = ref.watch(sharedPreferencesProvider); + return switch (ref.watch(dataModeProvider)) { + DataMode.local => LocalProfileRepository(prefs), + DataMode.api => ApiProfileRepository( + dio: ref.watch(dioProvider), + session: ref.watch(sessionStoreProvider), + uploader: ref.watch(fileUploaderProvider), + prefs: prefs, + ), + }; }); class ProfileNotifier extends Notifier { @override - UserProfile build() => ref.watch(profileRepositoryProvider).load(); + UserProfile build() { + // 회원이 바뀌면(로그인·로그아웃) 다시 읽는다 + ref.watch(sessionEpochProvider); + final repo = ref.watch(profileRepositoryProvider); + // 기기에 둔 값으로 먼저 그리고, 최신 값이 오면 바꾼다 — 실패 알림은 인터셉터가 띄운다 + unawaited(succeeded(_refresh(repo))); + return repo.load(); + } + + Future _refresh(ProfileRepository repo) async { + final latest = await repo.fetch(); + if (ref.mounted) state = latest; + } /// 넘긴 값만 바꾼다 (null 이면 기존 값 유지) Future update({String? nickname, String? photoPath}) async { @@ -46,8 +178,8 @@ class ProfileNotifier extends Notifier { nickname: nickname ?? state.nickname, photoPath: photoPath ?? state.photoPath, ); - await ref.read(profileRepositoryProvider).save(next); - state = next; + final saved = await ref.read(profileRepositoryProvider).save(next); + if (ref.mounted) state = saved; } } diff --git a/flutter/lib/features/mypage/presentation/providers/account_actions.dart b/flutter/lib/features/mypage/presentation/providers/account_actions.dart index 1e97b15..e31b57d 100644 --- a/flutter/lib/features/mypage/presentation/providers/account_actions.dart +++ b/flutter/lib/features/mypage/presentation/providers/account_actions.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/api_guard.dart'; import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/auth/data/repositories/auth_repository.dart'; import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart'; import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; import 'package:tma_app/features/mypage/data/app_settings_repository.dart'; @@ -17,12 +19,18 @@ class AccountActions { final Ref _ref; - /// 로그아웃 — 기록은 기기에 남는다. 라우터가 로그인 화면으로 보낸다 - Future logout() => _ref.read(entryProvider.notifier).signOut(); + /// 로그아웃 — 기록은 서버(서버 모드) 또는 기기(기기 저장 모드)에 남는다. 라우터가 로그인 화면으로 보낸다. + /// 서버 모드는 다음에 로그인할 회원에게 보이지 않게 기기에 둔 프로필을 지운다. + Future logout() async { + await _ref.read(entryProvider.notifier).signOut(); + await _ref.read(profileRepositoryProvider).forget(); + } - /// 회원탈퇴 — 이 기기에 저장한 기록(아이·명함·사진·설정)을 모두 지우고 처음(인트로)으로 돌아간다. - /// 서버 회원 API 가 생기면 탈퇴 요청을 먼저 보낸다. + /// 회원탈퇴 — 서버의 회원과 기록을 지우고(서버 모드), 이 기기에 둔 기록·사진·설정도 모두 지운 뒤 처음(인트로)으로 돌아간다. + /// 서버 탈퇴가 실패하면(알림은 인터셉터가 띄운다) 기기의 기록도 그대로 둔다. Future withdraw() async { + if (!await succeeded(_ref.read(authRepositoryProvider).deleteAccount())) return; + // 기록 삭제와 화면 전환이 먼저다 — 사진 폴더 정리가 늦거나 실패해도 탈퇴는 끝나야 한다 final photos = _ref.read(petPhotoStoreProvider); await _ref.read(sharedPreferencesProvider).clear(); diff --git a/flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart b/flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart index c006f43..cd6e0b4 100644 --- a/flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart +++ b/flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:tma_app/core/api/api_guard.dart'; import 'package:tma_app/core/constants/app_assets.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; @@ -68,10 +69,12 @@ class _ProfileEditSheetState extends ConsumerState<_ProfileEditSheet> { Future _submit() async { final name = _name.text.trim(); // 비워 두면 닉네임은 바꾸지 않는다 (시안: 값이 있을 때만 반영) - await ref.read(profileProvider.notifier).update( + final saved = await succeeded(ref.read(profileProvider.notifier).update( nickname: name.isEmpty ? null : name, photoPath: _photoPath, - ); + )); + // 실패 알림은 인터셉터가 띄웠다 — 시트를 열어 둔 채 다시 시도할 수 있게 한다 + if (!saved) return; ref.read(toastProvider.notifier).show('프로필을 수정했어요', icon: RupIconName.check); if (mounted) Navigator.of(context).pop(); } diff --git a/flutter/lib/features/user/data/models/user_model.dart b/flutter/lib/features/user/data/models/user_model.dart deleted file mode 100644 index 3624e94..0000000 --- a/flutter/lib/features/user/data/models/user_model.dart +++ /dev/null @@ -1,27 +0,0 @@ -/// 사용자 모델 — 백엔드 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 json) { - return UserModel( - id: json['id'] as int, - email: json['email'] as String, - name: json['name'] as String, - ); - } - - Map toJson() => { - 'id': id, - 'email': email, - 'name': name, - }; -} diff --git a/flutter/lib/features/user/data/repositories/user_repository.dart b/flutter/lib/features/user/data/repositories/user_repository.dart deleted file mode 100644 index 0ae4164..0000000 --- a/flutter/lib/features/user/data/repositories/user_repository.dart +++ /dev/null @@ -1,44 +0,0 @@ -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 getUser(int id) async { - final response = await _dio.get>('/users/$id'); - return UserModel.fromJson(response.data!); - } - - /// 커서 기반 목록 조회 — 목록 API 는 항상 페이지네이션을 적용한다. - Future<({List items, String? nextCursor, bool hasNext})> getUsers({ - int limit = 20, - String? cursor, - }) async { - final response = await _dio.get>( - '/users', - queryParameters: { - 'limit': limit, - 'cursor': ?cursor, - }, - ); - - final data = response.data!; - final items = (data['items'] as List) - .map((json) => UserModel.fromJson(json as Map)) - .toList(); - final pageInfo = data['pageInfo'] as Map; - - return ( - items: items, - nextCursor: pageInfo['nextCursor'] as String?, - hasNext: pageInfo['hasNext'] as bool? ?? false, - ); - } -} diff --git a/flutter/lib/features/user/presentation/providers/user_provider.dart b/flutter/lib/features/user/presentation/providers/user_provider.dart deleted file mode 100644 index 7ffd7c2..0000000 --- a/flutter/lib/features/user/presentation/providers/user_provider.dart +++ /dev/null @@ -1,15 +0,0 @@ -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((ref) { - return UserRepository(ref.watch(dioProvider)); -}); - -// 특정 사용자 조회 Provider (family) -// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...) -final userProvider = FutureProvider.family((ref, id) { - return ref.watch(userRepositoryProvider).getUser(id); -}); diff --git a/flutter/test/api_account_test.dart b/flutter/test/api_account_test.dart new file mode 100644 index 0000000..7c4e77a --- /dev/null +++ b/flutter/test/api_account_test.dart @@ -0,0 +1,171 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/api/file_uploader.dart'; +import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart'; +import 'package:tma_app/features/auth/data/repositories/device_key_store.dart'; +import 'package:tma_app/features/auth/domain/social_provider.dart'; +import 'package:tma_app/features/mypage/data/profile_repository.dart'; + +import 'support/fake_api.dart'; + +/// 1×1 PNG — 올리기 테스트용 기기 사진 +final Uint8List kPngBytes = Uint8List.fromList([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, 0x49, 0x48, 0x44, 0x52, // +]); + +Map _me({String? nickname = '콩이 보호자', String? photoFileId}) => { + 'id': 'user-1', + 'nickname': nickname, + 'photoFileId': photoFileId, + 'provider': 'kakao', + 'createdAt': '2026-10-11T00:00:00.000Z', + }; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + group('ApiAuthRepository', () { + ApiAuthRepository repo(FakeApi api) => ApiAuthRepository( + dio: api.dio, + session: api.session, + prefs: prefs, + deviceKeys: const FixedDeviceKeyStore('device-key-abcdefghijklmnop'), + ); + + test('개발용 로그인으로 가입하고 토큰·계정 종류·이름을 저장한다', () async { + final api = FakeApi(tokens: null); + api.on('POST', '/auth/dev-sessions', (r) { + expect(r.json, {'provider': 'naver', 'deviceKey': 'device-key-abcdefghijklmnop'}); + return ok({ + 'accessToken': 'a1', + 'refreshToken': 'r1', + 'accessTokenExpiresIn': 3600, + 'refreshTokenExpiresAt': '2026-10-25T00:00:00.000Z', + 'user': _me(nickname: '두부맘')..['provider'] = 'naver', + 'isNewUser': true, + }, status: 201); + }); + final auth = repo(api); + + expect(auth.hasSession, isFalse); + await auth.signIn(SocialProvider.naver); + + expect(auth.hasSession, isTrue); + expect(api.session.tokens?.accessToken, 'a1'); + expect(auth.provider, SocialProvider.naver); + expect(auth.nickname, '두부맘'); + }); + + test('로그아웃은 리프레시 토큰을 폐기하고 세션을 지운다', () async { + final api = FakeApi(); + api.on('POST', '/auth/token-revocation', (r) { + expect(r.json, {'refreshToken': 'refresh'}); + return noContent; + }); + final auth = repo(api); + + await auth.signOut(); + + expect(api.session.hasSession, isFalse); + expect(api.sent('POST', '/auth/token-revocation'), hasLength(1)); + }); + + test('폐기 요청이 실패해도 이 기기에서는 로그아웃하고 알림을 띄우지 않는다', () async { + final api = FakeApi(); + api.on('POST', '/auth/token-revocation', (r) => failure('SYS_001', 500)); + + await repo(api).signOut(); + + expect(api.session.hasSession, isFalse); + expect(api.reported, isEmpty); + }); + + test('탈퇴는 서버에서 회원을 지운 뒤 세션을 지운다 — 실패하면 세션을 그대로 둔다', () async { + final api = FakeApi(); + api.on('DELETE', '/users/me', (r) => failure('SYS_001', 500)); + final auth = repo(api); + + await expectLater(auth.deleteAccount(), throwsA(isA())); + expect(api.session.hasSession, isTrue); + + api.on('DELETE', '/users/me', (r) => noContent); + await auth.deleteAccount(); + expect(api.session.hasSession, isFalse); + }); + }); + + group('ApiProfileRepository', () { + late Directory temp; + + setUp(() => temp = Directory.systemTemp.createTempSync('rup-profile')); + tearDown(() => temp.deleteSync(recursive: true)); + + ApiProfileRepository repo(FakeApi api) => ApiProfileRepository( + dio: api.dio, + session: api.session, + uploader: FileUploader(api.dio, api.reported.add), + prefs: prefs, + ); + + test('서버의 내 정보를 받아 기기에 두고, 사진은 서버 파일로 가리킨다', () async { + final api = FakeApi(); + api.on('GET', '/users/me', (r) => ok(_me(photoFileId: 'file-1'))); + final profiles = repo(api); + + final profile = await profiles.fetch(); + + expect(profile, const UserProfile(nickname: '콩이 보호자', photoPath: 'server-file:file-1')); + // 다음 실행에서는 서버를 기다리지 않고 먼저 보여 준다 + expect(profiles.load(), profile); + }); + + test('로그인 전에는 서버를 부르지 않는다', () async { + final api = FakeApi(tokens: null); + + expect(await repo(api).fetch(), const UserProfile()); + expect(api.requests, isEmpty); + }); + + test('기기 사진은 올린 뒤 그 파일 id 로 프로필을 바꾼다', () async { + final photo = File('${temp.path}/me.jpg')..writeAsBytesSync(kPngBytes); + final api = FakeApi(); + api.on('POST', '/files', (r) { + final form = r.options.data as FormData; + final file = form.files.single; + expect(file.key, 'file'); + // 원본 이름 대신 내용에 맞춘 이름·형식으로 보낸다 (확장자가 .jpg 여도 내용은 PNG) + expect(file.value.filename, 'photo.png'); + expect(file.value.contentType.toString(), 'image/png'); + return ok({'id': 'file-9', 'originalName': 'photo.png', 'mimeType': 'image/png', 'size': 16}, status: 201); + }); + api.on('PATCH', '/users/me', (r) { + expect(r.json, {'nickname': '집사', 'photoFileId': 'file-9'}); + return ok(_me(nickname: '집사', photoFileId: 'file-9')); + }); + + final saved = await repo(api).save(UserProfile(nickname: '집사', photoPath: photo.path)); + + expect(saved.photoPath, 'server-file:file-9'); + }); + + test('로그아웃하면 기기에 둔 프로필을 지운다', () async { + final api = FakeApi(); + api.on('GET', '/users/me', (r) => ok(_me())); + final profiles = repo(api); + await profiles.fetch(); + + await profiles.forget(); + + expect(profiles.load(), const UserProfile()); + }); + }); +} diff --git a/flutter/test/support/fake_api.dart b/flutter/test/support/fake_api.dart new file mode 100644 index 0000000..85ee65f --- /dev/null +++ b/flutter/test/support/fake_api.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:tma_app/core/api/api_exception.dart'; +import 'package:tma_app/core/api/dio_client.dart'; +import 'package:tma_app/core/api/session_store.dart'; + +/// 가짜 응답 — 상태 코드와 JSON 본문 +typedef FakeReply = ({int status, Object? body}); + +/// 받은 요청 — 경로 변수(:id)와 본문을 꺼내 쓰기 쉽게 정리한다 +class FakeRequest { + FakeRequest(this.options, this.params); + + final RequestOptions options; + final Map params; + + String get method => options.method; + String get path => options.path; + Map get query => options.queryParameters; + + /// JSON 본문 (FormData 면 null) + Map? get json => options.data is Map ? options.data as Map : null; +} + +typedef FakeHandler = FutureOr Function(FakeRequest request); + +/// 표준 성공 응답 `{ success: true, data }` +FakeReply ok(Object? data, {int status = 200}) => (status: status, body: {'success': true, 'data': data}); + +/// 본문 없는 성공 (204) +const FakeReply noContent = (status: 204, body: null); + +/// 표준 실패 응답 — 문구는 앱 사전이 정한다 +FakeReply failure(String code, int status) => ( + status: status, + body: { + 'success': false, + 'error': {'code': code, 'message': '서버 문구', 'traceId': 'trace-1'}, + }, + ); + +/// 목록 응답 `{ items, pageInfo }` 한 페이지 +Map page(List items, {String? nextCursor, int? total}) => { + 'items': items, + 'pageInfo': {'hasNext': nextCursor != null, 'nextCursor': nextCursor, 'total': total ?? items.length}, + }; + +/// 네트워크 없이 서버를 흉내 낸다 — `api.on('GET', '/pets/:id', (r) => ok(...))` +class FakeApi implements HttpClientAdapter { + FakeApi({AuthTokens? tokens = const AuthTokens(accessToken: 'access', refreshToken: 'refresh')}) { + session = SessionStore(MemoryTokenVault(), tokens); + client = DioClient( + baseUrl: 'http://test/api', + session: session, + onError: reported.add, + httpClientAdapter: this, + ); + } + + late final SessionStore session; + late final DioClient client; + final List reported = []; + final List requests = []; + final List<(String, RegExp, List, FakeHandler)> _routes = []; + + Dio get dio => client.dio; + + /// 경로 패턴은 `/pets/:id` 처럼 쓴다 — 나중에 등록한 것이 먼저 맞는다 + void on(String method, String pattern, FakeHandler handler) { + final names = []; + final regex = RegExp( + '^${pattern.replaceAllMapped(RegExp(r':(\w+)'), (m) { + names.add(m[1]!); + return '([^/]+)'; + })}\$', + ); + _routes.insert(0, (method, regex, names, handler)); + } + + /// [method] [path] 로 온 요청들 + List sent(String method, String path) => + requests.where((r) => r.method == method && r.path == path).toList(); + + @override + Future fetch(RequestOptions options, Stream? requestStream, Future? cancelFuture) async { + for (final (method, regex, names, handler) in _routes) { + final match = regex.firstMatch(options.path); + if (method != options.method || match == null) continue; + final request = FakeRequest(options, {for (var i = 0; i < names.length; i++) names[i]: match[i + 1]!}); + requests.add(request); + final reply = await handler(request); + return ResponseBody.fromString( + reply.body == null ? '' : jsonEncode(reply.body), + reply.status, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + throw StateError('등록하지 않은 요청: ${options.method} ${options.path}'); + } + + @override + void close({bool force = false}) {} +}