From 561569895b976b7001f5804f27e6c990b9506f21 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Sat, 25 Oct 2025 00:47:52 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[FEAT]=20=EC=9C=A0=EC=A0=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8,=20=ED=9A=8C=EC=9B=90=20=EA=B0=80=EC=9E=85,?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0,=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83,=20Authorization=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement/App/ContentView.swift | 23 ++++- SampoomManagement/App/RootView.swift | 96 ++++++++++--------- .../App/SampoomManagementApp.swift | 16 ++++ .../Core/DI/AppDependencies.swift | 33 ++++++- .../Core/Network/AuthRequestInterceptor.swift | 81 ++++++++++++++++ .../Core/Network/NetworkError.swift | 9 ++ .../Core/Network/NetworkManager.swift | 11 ++- .../Core/Network/TokenRefreshService.swift | 63 ++++++++++++ .../Core/Resources/StringResources.swift | 4 + .../Local/Preferences/AuthPreferences.swift | 55 +++++++++++ .../Auth/Data/Mappers/AuthMappers.swift | 2 + .../Auth/Data/Remote/API/AuthAPI.swift | 26 +++++ .../Data/Remote/DTO/RefreshRequestDTO.swift | 12 +++ .../Data/Remote/DTO/RefreshResponseDTO.swift | 14 +++ .../Data/Repository/AuthRepositoryImpl.swift | 55 ++++++++++- .../Features/Auth/Domain/Models/User.swift | 2 + .../Domain/Repository/AuthRepository.swift | 6 ++ .../UseCase/CheckLoginStateUseCase.swift | 20 ++++ .../Domain/UseCase/ClearTokensUseCase.swift | 20 ++++ .../Auth/Domain/UseCase/SignOutUseCase.swift | 20 ++++ .../Features/Auth/UI/AuthViewModel.swift | 68 +++++++++++++ .../Features/Auth/UI/LoginView.swift | 14 ++- .../Features/Auth/UI/LoginViewModel.swift | 4 +- .../Features/Cart/UI/CartListView.swift | 6 +- .../Part/UI/PartDetailBottomSheetView.swift | 2 +- .../Features/Part/UI/PartListView.swift | 2 +- .../Features/Part/UI/PartView.swift | 10 +- 27 files changed, 600 insertions(+), 74 deletions(-) create mode 100644 SampoomManagement/Core/Network/AuthRequestInterceptor.swift create mode 100644 SampoomManagement/Core/Network/TokenRefreshService.swift create mode 100644 SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshRequestDTO.swift create mode 100644 SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshResponseDTO.swift create mode 100644 SampoomManagement/Features/Auth/Domain/UseCase/CheckLoginStateUseCase.swift create mode 100644 SampoomManagement/Features/Auth/Domain/UseCase/ClearTokensUseCase.swift create mode 100644 SampoomManagement/Features/Auth/Domain/UseCase/SignOutUseCase.swift create mode 100644 SampoomManagement/Features/Auth/UI/AuthViewModel.swift diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index 81b6e33..f095567 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -24,7 +24,12 @@ struct ContentView: View { } var body: some View { - TabView(selection: $selectedTab) { + ZStack { + // 전체 백그라운드 + Color.background + .ignoresSafeArea(.all) + + TabView(selection: $selectedTab) { // Dashboard 탭 (임시) Tab(value: .dashboard) { NavigationStack { @@ -33,11 +38,23 @@ struct ContentView: View { Text(StringResources.Tabs.dashboard) .font(.largeTitle) .fontWeight(.bold) + Text(StringResources.Placeholders.inventoryDescription) .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) + + // 로그아웃 버튼 + Spacer() + CommonButton(StringResources.Auth.logoutButton, backgroundColor: .red, textColor: .white) { + Task { + await dependencies.authViewModel.signOut() + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + Spacer() } .navigationTitle(StringResources.Tabs.dashboard) @@ -145,8 +162,8 @@ struct ContentView: View { } } } - .accentColor(.blue) + .accentColor(.accentColor) .tabViewStyle(.automatic) - .background(Color.background) + } } } diff --git a/SampoomManagement/App/RootView.swift b/SampoomManagement/App/RootView.swift index 1b7a168..1bef0e2 100644 --- a/SampoomManagement/App/RootView.swift +++ b/SampoomManagement/App/RootView.swift @@ -12,66 +12,76 @@ struct RootView: View { @StateObject private var loginViewModel: LoginViewModel @StateObject private var signUpViewModel: SignUpViewModel - @State private var isAuthenticated: Bool = false + @ObservedObject private var authViewModel: AuthViewModel @State private var showSignUp: Bool = false init(dependencies: AppDependencies) { self.dependencies = dependencies _loginViewModel = StateObject(wrappedValue: dependencies.makeLoginViewModel()) _signUpViewModel = StateObject(wrappedValue: dependencies.makeSignUpViewModel()) + self.authViewModel = dependencies.authViewModel } var body: some View { - Group { - if !isAuthenticated { - // 로그인 되어있으면 메인 화면 - ContentView(dependencies: dependencies) - } else { - // 로그인 안되어있으면 로그인/회원가입 화면 - if showSignUp { - NavigationStack { - SignUpView( - viewModel: signUpViewModel, - onSuccess: { - // 회원가입 성공 시 자동 로그인 완료 → 메인 화면으로 - isAuthenticated = true - } - ) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: { - showSignUp = false - }) { - Image(systemName: "chevron.left") - .foregroundColor(Color(red: 0.5, green: 0.2, blue: 0.8)) + ZStack { + // 전체 백그라운드 + Color.background + .ignoresSafeArea(.all) + + Group { + if authViewModel.isLoggedIn { + // 로그인 되어있으면 메인 화면 + ContentView(dependencies: dependencies) + } else { + // 로그인 안되어있으면 로그인/회원가입 화면 + if showSignUp { + NavigationStack { + SignUpView( + viewModel: signUpViewModel, + onSuccess: { + // 회원가입 성공 시 자동 로그인 완료 → 메인 화면으로 + authViewModel.updateLoginState() + } + ) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + showSignUp = false + }) { + Image(systemName: "chevron.left") + .foregroundColor(Color(red: 0.5, green: 0.2, blue: 0.8)) + } } } } + } else { + LoginView( + viewModel: loginViewModel, + onSuccess: { + // 로그인 성공 시 메인 화면으로 + authViewModel.updateLoginState() + }, + onNavigateSignUp: { + // 회원가입 화면으로 + showSignUp = true + } + ) } - } else { - LoginView( - viewModel: loginViewModel, - onSuccess: { - // 로그인 성공 시 메인 화면으로 - isAuthenticated = true - }, - onNavigateSignUp: { - // 회원가입 화면으로 - showSignUp = true - } - ) } } } - .background(Color.background) .onAppear { - // 앱 시작 시 로그인 상태 확인 - checkAuthenticationStatus() + // 로그아웃 성공 콜백 설정 + authViewModel.onSignOutSuccess = { + showSignUp = false + } + } + .onChange(of: authViewModel.shouldNavigateToLogin) { _, shouldNavigate in + if shouldNavigate { + showSignUp = false + authViewModel.resetNavigationState() + } } - } - - private func checkAuthenticationStatus() { - isAuthenticated = dependencies.authPreferences.hasToken() } } diff --git a/SampoomManagement/App/SampoomManagementApp.swift b/SampoomManagement/App/SampoomManagementApp.swift index 7a38eb4..8d52609 100644 --- a/SampoomManagement/App/SampoomManagementApp.swift +++ b/SampoomManagement/App/SampoomManagementApp.swift @@ -15,6 +15,8 @@ struct SampoomManagementApp: App { init() { // 앱 전체 폰트 설정 setupGlobalFont() + // 앱 전체 백그라운드 설정 + setupGlobalBackground() } var body: some Scene { @@ -33,4 +35,18 @@ struct SampoomManagementApp: App { UITextView.appearance().font = font } } + + private func setupGlobalBackground() { + // UIKit 컴포넌트에 대한 기본 백그라운드 설정 + UITableView.appearance().backgroundColor = UIColor.clear + UICollectionView.appearance().backgroundColor = UIColor.clear + UINavigationBar.appearance().backgroundColor = UIColor.clear + UITabBar.appearance().backgroundColor = UIColor.clear + + // 시스템 배경색 설정 + if let backgroundColor = UIColor(named: "Background") { + UINavigationBar.appearance().barTintColor = backgroundColor + UITabBar.appearance().barTintColor = backgroundColor + } + } } diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift index 7efe4f5..68fc866 100644 --- a/SampoomManagement/Core/DI/AppDependencies.swift +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -19,6 +19,14 @@ class AppDependencies { let authRepository: AuthRepository let loginUseCase: LoginUseCase let signUpUseCase: SignUpUseCase + let checkLoginStateUseCase: CheckLoginStateUseCase + let signOutUseCase: SignOutUseCase + let clearTokensUseCase: ClearTokensUseCase + let authViewModel: AuthViewModel + + // MARK: - Network Auth + let tokenRefreshService: TokenRefreshService + let authRequestInterceptor: AuthRequestInterceptor // MARK: - Part let partAPI: PartAPI @@ -57,11 +65,20 @@ class AppDependencies { let cancelOrderUseCase: CancelOrderUseCase init() { - // Core - networkManager = NetworkManager() + // Auth Preferences + authPreferences = AuthPreferences() + + // Network Auth Services + tokenRefreshService = TokenRefreshService(authPreferences: authPreferences) + authRequestInterceptor = AuthRequestInterceptor( + authPreferences: authPreferences, + tokenRefreshService: tokenRefreshService + ) + + // Core Network + networkManager = NetworkManager(authRequestInterceptor: authRequestInterceptor) // Auth - authPreferences = AuthPreferences() authAPI = AuthAPI(networkManager: networkManager) authRepository = AuthRepositoryImpl( api: authAPI, @@ -69,6 +86,16 @@ class AppDependencies { ) loginUseCase = LoginUseCase(repository: authRepository) signUpUseCase = SignUpUseCase(repository: authRepository) + checkLoginStateUseCase = CheckLoginStateUseCase(repository: authRepository) + signOutUseCase = SignOutUseCase(repository: authRepository) + clearTokensUseCase = ClearTokensUseCase(repository: authRepository) + + // Auth ViewModel + authViewModel = AuthViewModel( + checkLoginStateUseCase: checkLoginStateUseCase, + signOutUseCase: signOutUseCase, + clearTokensUseCase: clearTokensUseCase + ) // Part partAPI = PartAPI(networkManager: networkManager) diff --git a/SampoomManagement/Core/Network/AuthRequestInterceptor.swift b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift new file mode 100644 index 0000000..ecf77d5 --- /dev/null +++ b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift @@ -0,0 +1,81 @@ +// +// AuthRequestInterceptor.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation +import Alamofire + +class AuthRequestInterceptor: RequestInterceptor { + private let authPreferences: AuthPreferences + private let tokenRefreshService: TokenRefreshService + private let refreshMutex = NSLock() + + init(authPreferences: AuthPreferences, tokenRefreshService: TokenRefreshService) { + self.authPreferences = authPreferences + self.tokenRefreshService = tokenRefreshService + } + + // 요청에 Authorization 헤더 추가 + func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { + var adaptedRequest = urlRequest + + // 이미 Authorization 헤더가 있으면 그대로 사용 + if adaptedRequest.value(forHTTPHeaderField: "Authorization") == nil { + do { + if let accessToken = try authPreferences.getAccessToken() { + adaptedRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + } catch { + print("AuthRequestInterceptor - 토큰 조회 실패: \(error)") + } + } + + completion(.success(adaptedRequest)) + } + + // 401 응답 시 토큰 재발급 및 재시도 + func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { + // 이미 재시도된 요청인지 확인 + if request.request?.value(forHTTPHeaderField: "X-Retry-Count") != nil { + completion(.doNotRetry) + return + } + + guard let response = request.task?.response as? HTTPURLResponse, + response.statusCode == 401 else { + completion(.doNotRetry) + return + } + + refreshMutex.lock() + defer { refreshMutex.unlock() } + + Task { + do { + _ = try await tokenRefreshService.refreshToken() + + // 새로운 토큰으로 요청 재시도 + var retryRequest = request.request + retryRequest?.setValue("1", forHTTPHeaderField: "X-Retry-Count") + + do { + if let accessToken = try await authPreferences.getAccessToken() { + retryRequest?.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + } catch { + print("AuthRequestInterceptor - 재시도 시 토큰 조회 실패: \(error)") + } + + completion(.retryWithDelay(0.1)) + } catch { + print("AuthRequestInterceptor - 토큰 재발급 실패: \(error)") + // 토큰 재발급 실패 시 로그아웃 처리 + await authPreferences.clear() + completion(.doNotRetry) + } + } + } +} diff --git a/SampoomManagement/Core/Network/NetworkError.swift b/SampoomManagement/Core/Network/NetworkError.swift index cc93fd8..fb4f22b 100644 --- a/SampoomManagement/Core/Network/NetworkError.swift +++ b/SampoomManagement/Core/Network/NetworkError.swift @@ -14,6 +14,7 @@ enum NetworkError: Error, LocalizedError { case noData case serverError(Int) case invalidParameters + case unauthorized var errorDescription: String? { switch self { @@ -29,6 +30,8 @@ enum NetworkError: Error, LocalizedError { return "서버 오류: \(code)" case .invalidParameters: return "잘못된 매개변수입니다" + case .unauthorized: + return "인증이 필요합니다" } } } @@ -38,6 +41,8 @@ enum AuthError: Error, LocalizedError { case invalidCredentials case networkError(Error) case invalidResponse + case tokenRefreshFailed + case unauthorized var errorDescription: String? { switch self { @@ -49,6 +54,10 @@ enum AuthError: Error, LocalizedError { return "네트워크 오류: \(error.localizedDescription)" case .invalidResponse: return "잘못된 응답입니다" + case .tokenRefreshFailed: + return "토큰 재발급에 실패했습니다" + case .unauthorized: + return "인증이 필요합니다" } } } diff --git a/SampoomManagement/Core/Network/NetworkManager.swift b/SampoomManagement/Core/Network/NetworkManager.swift index fd39d33..be0125c 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -9,11 +9,14 @@ import Foundation import Alamofire class NetworkManager { - static let shared = NetworkManager() - private let baseURL = "https://sampoom.store/api/" + private let session: Session - init() {} + init(authRequestInterceptor: AuthRequestInterceptor) { + // Alamofire Session 설정 with interceptor + let configuration = URLSessionConfiguration.default + self.session = Session(configuration: configuration, interceptor: authRequestInterceptor) + } func request( endpoint: String, @@ -25,7 +28,7 @@ class NetworkManager { return try await withTaskCancellationHandler(operation: { try await withCheckedThrowingContinuation { continuation in - let dataRequest = AF.request( + let dataRequest = session.request( url, method: method, parameters: parameters, diff --git a/SampoomManagement/Core/Network/TokenRefreshService.swift b/SampoomManagement/Core/Network/TokenRefreshService.swift new file mode 100644 index 0000000..31c7462 --- /dev/null +++ b/SampoomManagement/Core/Network/TokenRefreshService.swift @@ -0,0 +1,63 @@ +// +// TokenRefreshService.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class TokenRefreshService { + private let authPreferences: AuthPreferences + + init(authPreferences: AuthPreferences) { + self.authPreferences = authPreferences + } + + func refreshToken() async throws -> User { + guard let refreshToken = try authPreferences.getRefreshToken() else { + throw AuthError.tokenRefreshFailed + } + + // 새로운 URLSession 인스턴스 생성 (인터셉터 없이) + let session = URLSession.shared + let url = URL(string: "https://sampoom.store/api/auth/refresh")! + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = RefreshRequestDTO(refreshToken: refreshToken) + request.httpBody = try JSONEncoder().encode(requestBody) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw AuthError.tokenRefreshFailed + } + + let apiResponse = try JSONDecoder().decode(APIResponse.self, from: data) + guard let dto = apiResponse.data else { + throw AuthError.invalidResponse + } + + // 기존 사용자 정보 조회 + guard let existingUser = try authPreferences.getStoredUser() else { + throw AuthError.tokenRefreshFailed + } + + // 새로운 토큰 정보로 사용자 정보 업데이트 + let updatedUser = User( + id: existingUser.id, + name: existingUser.name, + role: existingUser.role, + accessToken: dto.accessToken, + refreshToken: dto.refreshToken, + expiresIn: dto.expiresIn + ) + + try authPreferences.saveUser(updatedUser) + return updatedUser + } +} diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 0980801..7848069 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -131,6 +131,9 @@ struct StringResources { // MARK: - Part struct Part { static let quantity = "수량" + static let selectCategory = "카테고리 선택" + static let selectCategoryPrompt = "카테고리를 선택해주세요" + static let selectGroup = "그룹 선택" } // MARK: - Order @@ -168,6 +171,7 @@ struct StringResources { static let needAccount = "계정이 없으신가요?" static let signUpLink = "회원가입" static let signUpDo = "하기" + static let logoutButton = "로그아웃" // SignUp static let nameLabel = "이름" diff --git a/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift index 56be25c..dd73461 100644 --- a/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift +++ b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift @@ -13,6 +13,30 @@ class AuthPreferences { private enum Keys { static let accessToken = "auth.accessToken" static let refreshToken = "auth.refreshToken" + static let userId = "auth.userId" + static let userName = "auth.userName" + static let userRole = "auth.userRole" + static let expiresIn = "auth.expiresIn" + } + + func saveUser(_ user: User) throws { + do { + try keychain.save(user.accessToken, for: Keys.accessToken) + try keychain.save(user.refreshToken, for: Keys.refreshToken) + try keychain.save(String(user.id), for: Keys.userId) + try keychain.save(user.name, for: Keys.userName) + try keychain.save(user.role, for: Keys.userRole) + try keychain.save(String(user.expiresIn), for: Keys.expiresIn) + } catch { + // 부분 저장 실패 시 롤백 + try? keychain.delete(Keys.accessToken) + try? keychain.delete(Keys.refreshToken) + try? keychain.delete(Keys.userId) + try? keychain.delete(Keys.userName) + try? keychain.delete(Keys.userRole) + try? keychain.delete(Keys.expiresIn) + throw error + } } func saveToken(accessToken: String, refreshToken: String) throws { @@ -27,6 +51,33 @@ class AuthPreferences { } } + func getStoredUser() throws -> User? { + do { + guard let userIdString = try keychain.get(Keys.userId), + let userId = Int(userIdString), + let userName = try keychain.get(Keys.userName), + let userRole = try keychain.get(Keys.userRole), + let accessToken = try keychain.get(Keys.accessToken), + let refreshToken = try keychain.get(Keys.refreshToken), + let expiresInString = try keychain.get(Keys.expiresIn), + let expiresIn = Int(expiresInString) else { + return nil + } + + return User( + id: userId, + name: userName, + role: userRole, + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: expiresIn + ) + } catch { + print("AuthPreferences - 사용자 정보 조회 실패: \(error)") + return nil + } + } + func getAccessToken() throws -> String? { return try keychain.get(Keys.accessToken) } @@ -58,6 +109,10 @@ class AuthPreferences { do { try keychain.delete(Keys.accessToken) try keychain.delete(Keys.refreshToken) + try keychain.delete(Keys.userId) + try keychain.delete(Keys.userName) + try keychain.delete(Keys.userRole) + try keychain.delete(Keys.expiresIn) } catch { // 로그아웃 시에는 실패해도 에러를 던지지 않음 (이미 로그아웃 상태로 간주) print("AuthPreferences - 키체인 삭제 실패: \(error)") diff --git a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift index 931395c..b7887ed 100644 --- a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift +++ b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift @@ -13,6 +13,8 @@ extension LoginResponseDTO { id: self.userId, name: self.userName, role: self.role, + accessToken: self.accessToken, + refreshToken: self.refreshToken, expiresIn: self.expiresIn ) } diff --git a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift index 7f2c914..bf37626 100644 --- a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift +++ b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift @@ -66,5 +66,31 @@ class AuthAPI { responseType: SignupResponseDTO.self ) } + + // 로그아웃 + func logout() async throws -> APIResponse { + return try await networkManager.request( + endpoint: "auth/logout", + method: .post, + parameters: nil, + responseType: EmptyResponse.self + ) + } + + // 토큰 재발급 + func refresh(refreshToken: String) async throws -> APIResponse { + let requestDTO = RefreshRequestDTO(refreshToken: refreshToken) + + let parameters: [String: Any] = [ + "refreshToken": requestDTO.refreshToken + ] + + return try await networkManager.request( + endpoint: "auth/refresh", + method: .post, + parameters: parameters, + responseType: RefreshResponseDTO.self + ) + } } diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshRequestDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshRequestDTO.swift new file mode 100644 index 0000000..b74cc03 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshRequestDTO.swift @@ -0,0 +1,12 @@ +// +// RefreshRequestDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +struct RefreshRequestDTO: Codable { + let refreshToken: String +} diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshResponseDTO.swift new file mode 100644 index 0000000..fa969c6 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshResponseDTO.swift @@ -0,0 +1,14 @@ +// +// RefreshResponseDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +struct RefreshResponseDTO: Codable { + let accessToken: String + let expiresIn: Int + let refreshToken: String +} diff --git a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift index 80bc02e..8757a37 100644 --- a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift +++ b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift @@ -44,21 +44,66 @@ class AuthRepositoryImpl: AuthRepository { throw AuthError.invalidResponse } + let user = dto.toModel() do { - try preferences.saveToken( - accessToken: dto.accessToken, - refreshToken: dto.refreshToken - ) + try preferences.saveUser(user) } catch { // 키체인 저장 실패 시 로깅 및 에러 전파 print("AuthRepositoryImpl - 키체인 저장 실패: \(error)") throw AuthError.tokenSaveFailed(error) } - return dto.toModel() + return user } func signOut() async throws { + // API 호출 실패해도 토큰은 삭제 (이미 로그아웃 상태로 간주) + do { + _ = try await api.logout() + } catch { + print("AuthRepositoryImpl - 로그아웃 API 호출 실패: \(error)") + // API 실패해도 토큰은 삭제 + } + + preferences.clear() + } + + func refreshToken() async throws -> User { + guard let refreshToken = try preferences.getRefreshToken() else { + throw AuthError.tokenRefreshFailed + } + + let response = try await api.refresh(refreshToken: refreshToken) + guard let dto = response.data else { + throw AuthError.invalidResponse + } + + // 기존 사용자 정보 조회 + guard let existingUser = try preferences.getStoredUser() else { + throw AuthError.tokenRefreshFailed + } + + // 새로운 토큰 정보로 사용자 정보 업데이트 + let updatedUser = User( + id: existingUser.id, + name: existingUser.name, + role: existingUser.role, + accessToken: dto.accessToken, + refreshToken: dto.refreshToken, + expiresIn: dto.expiresIn + ) + + do { + try preferences.saveUser(updatedUser) + } catch { + print("AuthRepositoryImpl - 토큰 갱신 후 키체인 저장 실패: \(error)") + throw AuthError.tokenSaveFailed(error) + } + + return updatedUser + } + + func clearTokens() async throws { preferences.clear() } diff --git a/SampoomManagement/Features/Auth/Domain/Models/User.swift b/SampoomManagement/Features/Auth/Domain/Models/User.swift index caefcd9..a3e9f82 100644 --- a/SampoomManagement/Features/Auth/Domain/Models/User.swift +++ b/SampoomManagement/Features/Auth/Domain/Models/User.swift @@ -11,5 +11,7 @@ struct User: Equatable { let id: Int let name: String let role: String + let accessToken: String + let refreshToken: String let expiresIn: Int } diff --git a/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift b/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift index 8a44ef0..bbac761 100644 --- a/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift +++ b/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift @@ -19,5 +19,11 @@ protocol AuthRepository { func signIn(email: String, password: String) async throws -> User func signOut() async throws + func refreshToken() async throws -> User + func clearTokens() async throws func isSignedIn() -> Bool + + // 토큰 조회 (API 요청 시 사용) + func getAccessToken() throws -> String? + func getRefreshToken() throws -> String? } diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/CheckLoginStateUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/CheckLoginStateUseCase.swift new file mode 100644 index 0000000..18ce2e1 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/CheckLoginStateUseCase.swift @@ -0,0 +1,20 @@ +// +// CheckLoginStateUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class CheckLoginStateUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute() -> Bool { + return repository.isSignedIn() + } +} diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/ClearTokensUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/ClearTokensUseCase.swift new file mode 100644 index 0000000..61f8968 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/ClearTokensUseCase.swift @@ -0,0 +1,20 @@ +// +// ClearTokensUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class ClearTokensUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute() async throws { + try await repository.clearTokens() + } +} diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/SignOutUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/SignOutUseCase.swift new file mode 100644 index 0000000..130742c --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/SignOutUseCase.swift @@ -0,0 +1,20 @@ +// +// SignOutUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class SignOutUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute() async throws { + try await repository.signOut() + } +} diff --git a/SampoomManagement/Features/Auth/UI/AuthViewModel.swift b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift new file mode 100644 index 0000000..7d52421 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift @@ -0,0 +1,68 @@ +// +// AuthViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class AuthViewModel: ObservableObject { + @Published var isLoggedIn: Bool = false + @Published var shouldNavigateToLogin: Bool = false + + private let checkLoginStateUseCase: CheckLoginStateUseCase + private let signOutUseCase: SignOutUseCase + private let clearTokensUseCase: ClearTokensUseCase + + var onSignOutSuccess: (() -> Void)? + + init( + checkLoginStateUseCase: CheckLoginStateUseCase, + signOutUseCase: SignOutUseCase, + clearTokensUseCase: ClearTokensUseCase + ) { + self.checkLoginStateUseCase = checkLoginStateUseCase + self.signOutUseCase = signOutUseCase + self.clearTokensUseCase = clearTokensUseCase + + // 초기 로그인 상태 확인 + updateLoginState() + } + + func updateLoginState() { + isLoggedIn = checkLoginStateUseCase.execute() + } + + func signOut() async { + do { + try await signOutUseCase.execute() + } catch { + print("AuthViewModel - 로그아웃 실패: \(error)") + } + + // 로그아웃 성공/실패 관계없이 로컬 상태 업데이트 + isLoggedIn = false + onSignOutSuccess?() + } + + func handleTokenExpired() async { + do { + try await clearTokensUseCase.execute() + isLoggedIn = false + shouldNavigateToLogin = true + } catch { + print("AuthViewModel - 토큰 삭제 실패: \(error)") + // 토큰 삭제 실패해도 로그아웃 처리 + isLoggedIn = false + shouldNavigateToLogin = true + } + } + + func resetNavigationState() { + shouldNavigateToLogin = false + } +} diff --git a/SampoomManagement/Features/Auth/UI/LoginView.swift b/SampoomManagement/Features/Auth/UI/LoginView.swift index 32a0d96..6673a4a 100644 --- a/SampoomManagement/Features/Auth/UI/LoginView.swift +++ b/SampoomManagement/Features/Auth/UI/LoginView.swift @@ -17,6 +17,15 @@ struct LoginView: View { let onSuccess: () -> Void let onNavigateSignUp: () -> Void + init(viewModel: LoginViewModel, onSuccess: @escaping () -> Void, onNavigateSignUp: @escaping () -> Void) { + self.viewModel = viewModel + self.onSuccess = onSuccess + self.onNavigateSignUp = onNavigateSignUp + + // 로그인 성공 콜백 설정 + self.viewModel.onLoginSuccess = onSuccess + } + var body: some View { VStack { Spacer() @@ -95,11 +104,6 @@ struct LoginView: View { .onTapGesture { hideKeyboard() } - .onChange(of: viewModel.uiState.success) { _, success in - if success { - onSuccess() - } - } .onChange(of: viewModel.uiState.error) { _, error in if let message = error, !message.isEmpty { // 타임스탬프 제거하여 순수한 에러 메시지만 표시 diff --git a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift index 0671667..8fe7b13 100644 --- a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift +++ b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift @@ -14,6 +14,7 @@ class LoginViewModel: ObservableObject { @Published var uiState = LoginUiState() private let loginUseCase: LoginUseCase + var onLoginSuccess: (() -> Void)? init(loginUseCase: LoginUseCase) { self.loginUseCase = loginUseCase @@ -46,7 +47,8 @@ class LoginViewModel: ObservableObject { do { _ = try await loginUseCase.execute(email: email, password: password) - uiState = uiState.copy(loading: false, success: true) + uiState = uiState.copy(loading: false) + onLoginSuccess?() } catch { uiState = uiState.copy(loading: false) showError(error.localizedDescription) diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift index 96dd556..e381aad 100644 --- a/SampoomManagement/Features/Cart/UI/CartListView.swift +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -51,9 +51,9 @@ struct CartListView: View { viewModel.onEvent(.dismissOrderResult) }, viewModel: OrderDetailViewModel( - getOrderDetailUseCase: GetOrderDetailUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager()))), - cancelOrderUseCase: CancelOrderUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager()))), - receiveOrderUseCase: ReceiveOrderUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager()))) + getOrderDetailUseCase: GetOrderDetailUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager(authRequestInterceptor: AuthRequestInterceptor(authPreferences: AuthPreferences(), tokenRefreshService: TokenRefreshService(authPreferences: AuthPreferences())))))), + cancelOrderUseCase: CancelOrderUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager(authRequestInterceptor: AuthRequestInterceptor(authPreferences: AuthPreferences(), tokenRefreshService: TokenRefreshService(authPreferences: AuthPreferences())))))), + receiveOrderUseCase: ReceiveOrderUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager(authRequestInterceptor: AuthRequestInterceptor(authPreferences: AuthPreferences(), tokenRefreshService: TokenRefreshService(authPreferences: AuthPreferences())))))) ) ) } else { diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift index 685cda7..d947cb1 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -170,7 +170,7 @@ private struct QuantityControlView: View { .frame(width: 50, height: 44) .disabled(isDecreaseDisabled) - TextField("수량", text: $quantityText) + TextField(StringResources.Part.quantity, text: $quantityText) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 100) .multilineTextAlignment(.center) diff --git a/SampoomManagement/Features/Part/UI/PartListView.swift b/SampoomManagement/Features/Part/UI/PartListView.swift index 6bb4c5d..30d3aaa 100644 --- a/SampoomManagement/Features/Part/UI/PartListView.swift +++ b/SampoomManagement/Features/Part/UI/PartListView.swift @@ -65,7 +65,7 @@ struct PartListView: View { } } } - .navigationTitle("부품조회") + .navigationTitle(StringResources.Tabs.parts) .navigationBarTitleDisplayMode(.automatic) .background(Color.background) .sheet(isPresented: $showBottomSheet) { diff --git a/SampoomManagement/Features/Part/UI/PartView.swift b/SampoomManagement/Features/Part/UI/PartView.swift index 12e5059..4a574f2 100644 --- a/SampoomManagement/Features/Part/UI/PartView.swift +++ b/SampoomManagement/Features/Part/UI/PartView.swift @@ -29,7 +29,7 @@ struct PartView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { // Category 선택 제목 - Text("카테고리 선택") + Text(StringResources.Part.selectCategory) .font(.gmarketTitle2) .fontWeight(.bold) .padding(.horizontal, 16) @@ -55,9 +55,9 @@ struct PartView: View { .background(Color.background) } } - .navigationTitle("부품조회") + .navigationTitle(StringResources.Tabs.parts) .navigationBarTitleDisplayMode(.automatic) - .searchable(text: $searchQuery, prompt: "부품 검색") + .searchable(text: $searchQuery, prompt: StringResources.SearchParts.placeholder) .onChange(of: searchQuery) { _, newValue in if newValue.isEmpty { searchViewModel.onEvent(.clearSearch) @@ -126,7 +126,7 @@ struct PartView: View { .font(.system(size: 32)) .foregroundColor(.gray) - Text("카테고리를 선택해주세요") + Text(StringResources.Part.selectCategoryPrompt) .font(.gmarketBody) .foregroundColor(.gray) } @@ -134,7 +134,7 @@ struct PartView: View { .padding(.vertical, 32) } else { // 그룹 선택 제목 - Text("그룹 선택") + Text(StringResources.Part.selectGroup) .font(.gmarketTitle2) .fontWeight(.bold) .padding(.horizontal, 16) From ab52662b7fd4a1621b8ee24e8e6b743bded0f0b5 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Sat, 25 Oct 2025 01:07:51 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[REFAC]=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement/App/ContentView.swift | 220 ++++++++---------- SampoomManagement/App/RootView.swift | 6 - .../App/Screens/DashboardScreen.swift | 43 ++++ .../Features/Auth/UI/AuthViewModel.swift | 8 +- .../Features/Auth/UI/LoginView.swift | 8 +- .../Features/Auth/UI/LoginViewModel.swift | 4 +- .../Features/Cart/UI/CartListView.swift | 7 +- .../Order/UI/OrderListViewModel.swift | 30 ++- 8 files changed, 167 insertions(+), 159 deletions(-) create mode 100644 SampoomManagement/App/Screens/DashboardScreen.swift diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index f095567..a848274 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -12,17 +12,20 @@ enum Tabs { } struct ContentView: View { + // MARK: - Properties let dependencies: AppDependencies @StateObject private var partViewModel: PartViewModel @State private var selectedTab: Tabs = .dashboard @State private var ordersNavigationPath = NavigationPath() @State private var partsNavigationPath = NavigationPath() + // MARK: - Initialization init(dependencies: AppDependencies) { self.dependencies = dependencies _partViewModel = StateObject(wrappedValue: dependencies.makePartViewModel()) } + // MARK: - Body var body: some View { ZStack { // 전체 백그라운드 @@ -30,140 +33,113 @@ struct ContentView: View { .ignoresSafeArea(.all) TabView(selection: $selectedTab) { - // Dashboard 탭 (임시) - Tab(value: .dashboard) { - NavigationStack { - VStack(spacing: 20) { - Spacer() + // Dashboard 탭 (임시) + Tab(value: .dashboard) { + DashboardScreen(dependencies: dependencies) + } label: { + Label { Text(StringResources.Tabs.dashboard) - .font(.largeTitle) - .fontWeight(.bold) - - Text(StringResources.Placeholders.inventoryDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - - // 로그아웃 버튼 - Spacer() - CommonButton(StringResources.Auth.logoutButton, backgroundColor: .red, textColor: .white) { - Task { - await dependencies.authViewModel.signOut() - } - } - .padding(.horizontal, 16) - .padding(.bottom, 16) - - Spacer() + .font(.gmarketSubheadline) + } icon: { + Image("dashboard") + .renderingMode(.template) + .foregroundStyle(.text) } - .navigationTitle(StringResources.Tabs.dashboard) - .background(Color.background) - } - } label: { - Label { - Text(StringResources.Tabs.dashboard) - .font(.gmarketSubheadline) - } icon: { - Image("dashboard") - .renderingMode(.template) - .foregroundStyle(.text) - } - } - - // Outbound 탭 - Tab(value: .outbound) { - NavigationStack { - OutboundListView(viewModel: dependencies.makeOutboundListViewModel()) } - } label: { - Label { - Text(StringResources.Tabs.outbound) - .font(.gmarketSubheadline) - } icon: { - Image("outbound") - .renderingMode(.template) - .foregroundStyle(Color.text) - } - } - - // Cart 탭 - Tab(value: .cart) { - NavigationStack { - CartListView(viewModel: dependencies.makeCartListViewModel()) - } - } label: { - Label { - Text(StringResources.Tabs.cart) - .font(.gmarketSubheadline) - } icon: { - Image("cart") - .renderingMode(.template) - .foregroundStyle(Color.text) + + // Outbound 탭 + Tab(value: .outbound) { + NavigationStack { + OutboundListView(viewModel: dependencies.makeOutboundListViewModel()) + } + } label: { + Label { + Text(StringResources.Tabs.outbound) + .font(.gmarketSubheadline) + } icon: { + Image("outbound") + .renderingMode(.template) + .foregroundStyle(Color.text) + } } - } - - // Orders 탭 - Tab(value: .orders) { - NavigationStack(path: $ordersNavigationPath) { - OrderListView( - viewModel: dependencies.makeOrderListViewModel(), - onNavigateOrderDetail: { orderId in - ordersNavigationPath.append(orderId) - } - ) - .navigationDestination(for: Int.self) { orderId in - OrderDetailView( - orderId: orderId, - viewModel: dependencies.makeOrderDetailViewModel(orderId: orderId) + + // Cart 탭 + Tab(value: .cart) { + NavigationStack { + CartListView( + viewModel: dependencies.makeCartListViewModel(), + dependencies: dependencies ) } + } label: { + Label { + Text(StringResources.Tabs.cart) + .font(.gmarketSubheadline) + } icon: { + Image("cart") + .renderingMode(.template) + .foregroundStyle(Color.text) + } } - } label: { - Label { - Text(StringResources.Tabs.orders) - .font(.gmarketSubheadline) - } icon: { - Image("orders") - .renderingMode(.template) - .foregroundStyle(Color.text) - } - } - - // PartView 탭 - Tab(value: .parts, role: .search) { - NavigationStack(path: $partsNavigationPath) { - PartView( - onNavigatePartList: { group in - partsNavigationPath.append(group.id) - }, - viewModel: partViewModel, - searchViewModel: dependencies.makeSearchViewModel() - ) - .navigationDestination(for: Int.self) { groupId in - PartListView( - viewModel: PartListViewModel( - getPartUseCase: dependencies.getPartUseCase, - groupId: groupId - ), - dependencies: dependencies + + // Orders 탭 + Tab(value: .orders) { + NavigationStack(path: $ordersNavigationPath) { + OrderListView( + viewModel: dependencies.makeOrderListViewModel(), + onNavigateOrderDetail: { orderId in + ordersNavigationPath.append(orderId) + } ) + .navigationDestination(for: Int.self) { orderId in + OrderDetailView( + orderId: orderId, + viewModel: dependencies.makeOrderDetailViewModel(orderId: orderId) + ) + } + } + } label: { + Label { + Text(StringResources.Tabs.orders) + .font(.gmarketSubheadline) + } icon: { + Image("orders") + .renderingMode(.template) + .foregroundStyle(Color.text) } } - .environmentObject(partViewModel) - } label: { - Label { - Text(StringResources.Tabs.parts) - .font(.gmarketSubheadline) - } icon: { - Image("parts") - .renderingMode(.template) - .foregroundStyle(Color.text) + + // PartView 탭 + Tab(value: .parts, role: .search) { + NavigationStack(path: $partsNavigationPath) { + PartView( + onNavigatePartList: { group in + partsNavigationPath.append(group.id) + }, + viewModel: partViewModel, + searchViewModel: dependencies.makeSearchViewModel() + ) + .navigationDestination(for: Int.self) { groupId in + PartListView( + viewModel: dependencies.makePartListViewModel(groupId: groupId), + dependencies: dependencies + ) + } + } + .environmentObject(partViewModel) + } label: { + Label { + Text(StringResources.Tabs.parts) + .font(.gmarketSubheadline) + } icon: { + Image("parts") + .renderingMode(.template) + .foregroundStyle(Color.text) + } } } - } - .accentColor(.accentColor) - .tabViewStyle(.automatic) + .accentColor(.accentColor) + .tabViewStyle(.automatic) } } } diff --git a/SampoomManagement/App/RootView.swift b/SampoomManagement/App/RootView.swift index 1bef0e2..48ed5c0 100644 --- a/SampoomManagement/App/RootView.swift +++ b/SampoomManagement/App/RootView.swift @@ -71,12 +71,6 @@ struct RootView: View { } } } - .onAppear { - // 로그아웃 성공 콜백 설정 - authViewModel.onSignOutSuccess = { - showSignUp = false - } - } .onChange(of: authViewModel.shouldNavigateToLogin) { _, shouldNavigate in if shouldNavigate { showSignUp = false diff --git a/SampoomManagement/App/Screens/DashboardScreen.swift b/SampoomManagement/App/Screens/DashboardScreen.swift new file mode 100644 index 0000000..f768124 --- /dev/null +++ b/SampoomManagement/App/Screens/DashboardScreen.swift @@ -0,0 +1,43 @@ +// +// DashboardScreen.swift +// SampoomManagement +// +// Created by 채상윤 on 10/25/25. +// + +import SwiftUI + +struct DashboardScreen: View { + let dependencies: AppDependencies + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Spacer() + Text(StringResources.Tabs.dashboard) + .font(.largeTitle) + .fontWeight(.bold) + + Text(StringResources.Placeholders.inventoryDescription) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + // 로그아웃 버튼 + Spacer() + CommonButton(StringResources.Auth.logoutButton, backgroundColor: .red, textColor: .white) { + Task { + await dependencies.authViewModel.signOut() + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + + Spacer() + } + .navigationTitle(StringResources.Tabs.dashboard) + .background(Color.background) + } + } +} diff --git a/SampoomManagement/Features/Auth/UI/AuthViewModel.swift b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift index 7d52421..99609f1 100644 --- a/SampoomManagement/Features/Auth/UI/AuthViewModel.swift +++ b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift @@ -11,6 +11,7 @@ import Combine @MainActor class AuthViewModel: ObservableObject { + // MARK: - Properties @Published var isLoggedIn: Bool = false @Published var shouldNavigateToLogin: Bool = false @@ -18,8 +19,7 @@ class AuthViewModel: ObservableObject { private let signOutUseCase: SignOutUseCase private let clearTokensUseCase: ClearTokensUseCase - var onSignOutSuccess: (() -> Void)? - + // MARK: - Initialization init( checkLoginStateUseCase: CheckLoginStateUseCase, signOutUseCase: SignOutUseCase, @@ -29,10 +29,10 @@ class AuthViewModel: ObservableObject { self.signOutUseCase = signOutUseCase self.clearTokensUseCase = clearTokensUseCase - // 초기 로그인 상태 확인 updateLoginState() } + // MARK: - Actions func updateLoginState() { isLoggedIn = checkLoginStateUseCase.execute() } @@ -46,7 +46,7 @@ class AuthViewModel: ObservableObject { // 로그아웃 성공/실패 관계없이 로컬 상태 업데이트 isLoggedIn = false - onSignOutSuccess?() + shouldNavigateToLogin = true } func handleTokenExpired() async { diff --git a/SampoomManagement/Features/Auth/UI/LoginView.swift b/SampoomManagement/Features/Auth/UI/LoginView.swift index 6673a4a..7faa0b4 100644 --- a/SampoomManagement/Features/Auth/UI/LoginView.swift +++ b/SampoomManagement/Features/Auth/UI/LoginView.swift @@ -21,9 +21,6 @@ struct LoginView: View { self.viewModel = viewModel self.onSuccess = onSuccess self.onNavigateSignUp = onNavigateSignUp - - // 로그인 성공 콜백 설정 - self.viewModel.onLoginSuccess = onSuccess } var body: some View { @@ -112,6 +109,11 @@ struct LoginView: View { viewModel.consumeError() } } + .onChange(of: viewModel.uiState.success) { _, success in + if success { + onSuccess() + } + } } // MARK: - Helper Methods diff --git a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift index 8fe7b13..0671667 100644 --- a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift +++ b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift @@ -14,7 +14,6 @@ class LoginViewModel: ObservableObject { @Published var uiState = LoginUiState() private let loginUseCase: LoginUseCase - var onLoginSuccess: (() -> Void)? init(loginUseCase: LoginUseCase) { self.loginUseCase = loginUseCase @@ -47,8 +46,7 @@ class LoginViewModel: ObservableObject { do { _ = try await loginUseCase.execute(email: email, password: password) - uiState = uiState.copy(loading: false) - onLoginSuccess?() + uiState = uiState.copy(loading: false, success: true) } catch { uiState = uiState.copy(loading: false) showError(error.localizedDescription) diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift index e381aad..743c194 100644 --- a/SampoomManagement/Features/Cart/UI/CartListView.swift +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -10,6 +10,7 @@ import Combine struct CartListView: View { @ObservedObject var viewModel: CartListViewModel + let dependencies: AppDependencies @State private var showEmptyCartDialog = false @State private var showConfirmDialog = false @@ -50,11 +51,7 @@ struct CartListView: View { onDismiss: { viewModel.onEvent(.dismissOrderResult) }, - viewModel: OrderDetailViewModel( - getOrderDetailUseCase: GetOrderDetailUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager(authRequestInterceptor: AuthRequestInterceptor(authPreferences: AuthPreferences(), tokenRefreshService: TokenRefreshService(authPreferences: AuthPreferences())))))), - cancelOrderUseCase: CancelOrderUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager(authRequestInterceptor: AuthRequestInterceptor(authPreferences: AuthPreferences(), tokenRefreshService: TokenRefreshService(authPreferences: AuthPreferences())))))), - receiveOrderUseCase: ReceiveOrderUseCase(repository: OrderRepositoryImpl(api: OrderAPI(networkManager: NetworkManager(authRequestInterceptor: AuthRequestInterceptor(authPreferences: AuthPreferences(), tokenRefreshService: TokenRefreshService(authPreferences: AuthPreferences())))))) - ) + viewModel: dependencies.makeOrderDetailViewModel(orderId: processedOrder.first?.orderId ?? 0) ) } else { EmptyView() diff --git a/SampoomManagement/Features/Order/UI/OrderListViewModel.swift b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift index d0d74f2..a6bd6c9 100644 --- a/SampoomManagement/Features/Order/UI/OrderListViewModel.swift +++ b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift @@ -11,14 +11,17 @@ import Combine @MainActor class OrderListViewModel: ObservableObject { + // MARK: - Properties @Published var uiState = OrderListUiState() private let getOrderUseCase: GetOrderUseCase + // MARK: - Initialization init(getOrderUseCase: GetOrderUseCase) { self.getOrderUseCase = getOrderUseCase } + // MARK: - Actions func onEvent(_ event: OrderListUiEvent) { switch event { case .loadOrderList, .retryOrderList: @@ -26,28 +29,23 @@ class OrderListViewModel: ObservableObject { } } + // MARK: - Private Methods private func loadOrderList() { Task { - await MainActor.run { - uiState = uiState.copy(orderLoading: true, orderError: nil) - } + uiState = uiState.copy(orderLoading: true, orderError: nil) do { let orderList = try await getOrderUseCase.execute() - await MainActor.run { - uiState = uiState.copy( - orderList: orderList.items, - orderLoading: false, - orderError: nil - ) - } + uiState = uiState.copy( + orderList: orderList.items, + orderLoading: false, + orderError: nil + ) } catch { - await MainActor.run { - uiState = uiState.copy( - orderLoading: false, - orderError: error.localizedDescription - ) - } + uiState = uiState.copy( + orderLoading: false, + orderError: error.localizedDescription + ) } print("OrderListViewModel - loadOrderList: \(uiState)") } From 29c95cff36628f3020fd28c32be029f9776d5f8e Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Sat, 25 Oct 2025 01:27:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EC=82=AC=ED=95=AD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/DI/AppDependencies.swift | 4 +- .../Core/Network/AuthRequestInterceptor.swift | 58 +++++++++---------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift index 68fc866..08d778f 100644 --- a/SampoomManagement/Core/DI/AppDependencies.swift +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -25,8 +25,8 @@ class AppDependencies { let authViewModel: AuthViewModel // MARK: - Network Auth - let tokenRefreshService: TokenRefreshService - let authRequestInterceptor: AuthRequestInterceptor + private let tokenRefreshService: TokenRefreshService + private let authRequestInterceptor: AuthRequestInterceptor // MARK: - Part let partAPI: PartAPI diff --git a/SampoomManagement/Core/Network/AuthRequestInterceptor.swift b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift index ecf77d5..1476706 100644 --- a/SampoomManagement/Core/Network/AuthRequestInterceptor.swift +++ b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift @@ -8,10 +8,27 @@ import Foundation import Alamofire -class AuthRequestInterceptor: RequestInterceptor { +// 비동기-세이프 토큰 갱신 조정자 +actor RefreshCoordinator { + private var inFlight: Task? + + func refresh(using service: TokenRefreshService) async throws -> User { + if let t = inFlight { + return try await t.value + } + let t = Task { + try await service.refreshToken() + } + inFlight = t + defer { inFlight = nil } + return try await t.value + } +} + +final class AuthRequestInterceptor: RequestInterceptor, @unchecked Sendable { private let authPreferences: AuthPreferences private let tokenRefreshService: TokenRefreshService - private let refreshMutex = NSLock() + private let refreshCoordinator = RefreshCoordinator() init(authPreferences: AuthPreferences, tokenRefreshService: TokenRefreshService) { self.authPreferences = authPreferences @@ -22,15 +39,14 @@ class AuthRequestInterceptor: RequestInterceptor { func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { var adaptedRequest = urlRequest - // 이미 Authorization 헤더가 있으면 그대로 사용 - if adaptedRequest.value(forHTTPHeaderField: "Authorization") == nil { - do { - if let accessToken = try authPreferences.getAccessToken() { - adaptedRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - } - } catch { - print("AuthRequestInterceptor - 토큰 조회 실패: \(error)") + do { + if let accessToken = try authPreferences.getAccessToken() { + adaptedRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } else { + adaptedRequest.setValue(nil, forHTTPHeaderField: "Authorization") } + } catch { + print("AuthRequestInterceptor - 토큰 조회 실패: \(error)") } completion(.success(adaptedRequest)) @@ -38,8 +54,8 @@ class AuthRequestInterceptor: RequestInterceptor { // 401 응답 시 토큰 재발급 및 재시도 func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { - // 이미 재시도된 요청인지 확인 - if request.request?.value(forHTTPHeaderField: "X-Retry-Count") != nil { + // 재시도 한도 (예: 1회) + if request.retryCount >= 1 { completion(.doNotRetry) return } @@ -50,25 +66,9 @@ class AuthRequestInterceptor: RequestInterceptor { return } - refreshMutex.lock() - defer { refreshMutex.unlock() } - Task { do { - _ = try await tokenRefreshService.refreshToken() - - // 새로운 토큰으로 요청 재시도 - var retryRequest = request.request - retryRequest?.setValue("1", forHTTPHeaderField: "X-Retry-Count") - - do { - if let accessToken = try await authPreferences.getAccessToken() { - retryRequest?.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - } - } catch { - print("AuthRequestInterceptor - 재시도 시 토큰 조회 실패: \(error)") - } - + _ = try await refreshCoordinator.refresh(using: tokenRefreshService) completion(.retryWithDelay(0.1)) } catch { print("AuthRequestInterceptor - 토큰 재발급 실패: \(error)")